diff --git a/docs/specs/dor-browser.md b/docs/specs/dor-browser.md index e32cadee6..8f04a96ff 100644 --- a/docs/specs/dor-browser.md +++ b/docs/specs/dor-browser.md @@ -116,8 +116,7 @@ Header contract: - **Must keep back/forward/reload enabled.** Agent-browser uses native commands; iframe uses parent history and re-resolves its proxy. - **Must show non-default managed `--key` as a badge, never a title prefix.** -- **Must hide split/zoom below `420px` and nav below `360px`;** minimize and kill - remain. +- Width tiers and the narrow-pane popover: `docs/specs/layout.md` → "Pane header responsive sizing". Source of truth: `lib/src/components/wall/SurfacePaneHeader.tsx`, `lib/src/components/wall/agent-browser-screen.ts`, diff --git a/docs/specs/dor-tool.md b/docs/specs/dor-tool.md index a08dede08..bd09a84e2 100644 --- a/docs/specs/dor-tool.md +++ b/docs/specs/dor-tool.md @@ -161,7 +161,9 @@ Source of truth: `toolCommand` in `dor/src/commands/tool.ts`; `dor/test/snapshot **Must prefer known extensions over filename-based text fallbacks; source extensions remain escaped previews.** -**Must run the built-in viewer as a Tool-owned `dor` process**, serving HTML, images, PDF/media, and escaped text/source previews. Markdown is source text; custom viewers may render it. Text previews and HTML/CSS dependency inspection are limited to 8 MiB per file. Text/source previews grant only their opened file and skip dependency inspection. (rationale) Oversized HTML and referenced CSS still stream without dependency inspection. The grant contains at most 256 files: the opened document and statically referenced relative HTML/CSS assets within its directory tree; exceeding that bound fails the open without serving a partial grant. Never expand the grant through root-relative, external, or dynamic references; requests can read only granted paths. +**Must run the built-in viewer as a Tool-owned `dor` process**, serving HTML, images, media, and escaped text/source previews. Markdown is source text; custom viewers may render it. Text previews and HTML/CSS dependency inspection are limited to 8 MiB per file. Text/source previews grant only their opened file and skip dependency inspection. (rationale) Oversized HTML and referenced CSS still stream without dependency inspection. The grant contains at most 256 files: the opened document and statically referenced relative HTML/CSS assets within its directory tree; exceeding that bound fails the open without serving a partial grant. Never expand the grant through root-relative, external, or dynamic references; requests can read only granted paths. + +**Must require a user Tool for PDFs**, including files named `README.pdf`. (rationale) **Must retain the viewer's opened file descriptors until the Tool exits.** Refresh reads those files again, but atomic replacements and changes to the dependency graph require restarting the viewer. Cold restore runs the saved file command with a fresh URL capability; Workspace movement keeps the live binding. The listener's authority is `docs/specs/security-local.md` → Local-file viewer. diff --git a/docs/specs/dor-tool.rationale.md b/docs/specs/dor-tool.rationale.md index ab75c7b41..6b6f93b22 100644 --- a/docs/specs/dor-tool.rationale.md +++ b/docs/specs/dor-tool.rationale.md @@ -40,10 +40,51 @@ A hardcoded Storybook port can disagree with the port it obtains under contentio ## Lifecycle +### September 2026 innerdogfood QC record + +The `dor-tool-qc` run began at `4c7f9012` and used the real standalone sidecar, +staged CLI, PTYs, and iframe proxy. At that historical baseline the Tools flag +could reject creation and standalone `dor open` split; both behaviors were +subsequently superseded by always-enabled Tools and eligible inline opening. + +Observed passes covered project approval (pending dedupe, decline/re-prompt, +folder-only permission, failed spawn without a PTY, repair and Retry); literal +argv and canonical symlink targets; three concurrent keyed invocations sharing +one Tool, fresh instances, idle/fast-command restart with stable refs; automatic +single-port serving, three-port refusal, and announced port/path selection. +User-rule ordering and explicit overrides worked; malformed user configuration +failed, and project associations did not intercept file opens. Text/Markdown +source, HTML/CSS/image, SVG, audio, and awkward filenames rendered; URL, +directory, missing/unsupported-file and oversized-text cases failed usefully. + +Approval controls remained usable at 249×203 pixels. Terminal Context, +minimize/reveal, exit/refocus, and iframe/screencast round trips passed. A clean +harness reload preserved every ID, kind, URL, and Workspace; cross-Workspace +identity stayed scoped. The four final viewer processes and earlier fixture +listeners exited when their Tools closed; both owned harnesses stopped and +private credential captures were deleted. + +Full `pnpm test` and `pnpm build` passed during the run, plus 139 focused UI +checks. The PDF-policy follow-up passed 168 CLI tests, 20 host dispatch/proxy +tests, and spec/public-doc lints; staged hosts contained no PDF renderer assets. +These counts describe that run, not the current test inventory. + +One development-state reset made Tools appear as terminals while root tests +and builds ran beside the harness. Investigation confirmed that +`e2e-lint-selftest` temporarily mutates Vite inputs, including invalid root +package JSON; the exact metadata-loss trigger was not captured. A clean +restart and stable-build reload passed. This run did not exercise Tool transfer, +native-window movement, native Tauri/VS Code rendering, Windows shells, or cold +restore. Screenshots and raw JSON were local ignored artifacts, not portable +verification evidence. The reusable recipe is `docs/testing/dor-tool-qc.md`. + + The September 2026 integration reuses Terminal Context for the Tool's primary terminal. The auxiliary helper's automatic refresh, Reset, and Promote semantics do not describe a serving command, whose Session also owns the browser and remote terminal identity. Sharing the presentation avoids introducing a second navigation mechanism or a second shell. ## Opening local files +Innerdogfood QC in Chromium (2026-09) showed the native PDF plugin failing inside the normal iframe sandbox. PDFs use configured user Tools; the built-in viewer carries no PDF renderer dependency. + A CSS source preview escapes its contents, so its URLs cannot load assets. Scanning those references adds unused authority and can reject a small source file at the asset limit. CSS loaded by HTML is active, so its dependencies still enter the bounded grant. Keeping the built-in viewer in the Tool's process tree reuses port discovery, kill, restart, and Workspace transfer. An OSC path carries the per-run URL capability without saving that secret in the restart command. Holding the selected file descriptors bounds what the server can read after launch; it trades automatic replacement-file refresh for a grant whose contents cannot widen through path replacement. diff --git a/docs/specs/layout.md b/docs/specs/layout.md index da1e27b49..ef78e0952 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -107,11 +107,24 @@ Both layers wear the leaf's own rounding (header radius on top, terminal radius ### Pane header responsive sizing -A ResizeObserver picks one of three tiers by header width: +**Must measure each header's own border-box width, never the viewport, retaining its tier at zero width** (rationale). Terminal tiers: -- **Full** (>280px): everything. -- **Compact** (>160px): split, zoom, and unzoom hidden. -- **Minimal** (≤160px): also hides the TODO pill and the mouse-override icon, leaving alert, minimize, and kill. **The notepad icon survives this tier only while the Surface has notes** (`docs/specs/notepad.md` → "Notepad UI"). The label truncates with ellipsis. +- **Full** (>293px): everything. +- **Compact** (>173px): split, zoom, and unzoom hidden. +- **Minimal** (≤173px): also hides the TODO pill and the mouse-override icon, leaving alert, minimize, and kill. **The notepad icon survives this tier only while the Surface has notes** (`docs/specs/notepad.md` → "Notepad UI"). The label truncates with ellipsis. + +A browser header, including a Tool's (Terminal Context sits outside the measured width), collapses by border-box width: + +| Below | Change | +|---|---| +| 420px | Split and zoom hidden. | +| 360px | Navigation hidden. | +| 180px | Chrome moves into a viewport-clamped popover behind one trigger; minimize and kill stay inline. | +| 72px | Minimize and kill join the popover. | + +**Must reclamp the popover when its content resizes and keep it keyboard reachable** (focus enters on open, Tab stays inside, Escape returns it to the trigger) **and dismiss it on a pane resize or, without restoring focus, when its Surface is hidden**; otherwise `lib/src/components/wall/use-dismiss-overlay.ts` applies, and a control inside dismisses only after its action ran. The trigger shows a filled notepad glyph and the note count while the Surface has notes; long keys and connection labels truncate before controls. + +Source of truth: `SurfacePaneHeader` in `lib/src/components/wall/SurfacePaneHeader.tsx`; `TerminalPaneHeader` in `lib/src/components/wall/TerminalPaneHeader.tsx`; `useHeaderTier` in `lib/src/components/wall/use-header-tier.ts`; `lib/src/components/wall/SurfacePaneHeader.test.tsx`; `lib/src/components/wall/TerminalPaneHeader.test.tsx`; `lib/src/stories/BrowserChromeHeader.stories.tsx`. ## Baseboard diff --git a/docs/specs/layout.rationale.md b/docs/specs/layout.rationale.md index 33e9b15da..fd655b3dd 100644 --- a/docs/specs/layout.rationale.md +++ b/docs/specs/layout.rationale.md @@ -2,6 +2,14 @@ > Informative companion to [layout.md](layout.md): the evidence, measurements, and dead-approach history behind its rules, keyed by that spec's headings (AGENTS.md → "What, not why"). Nothing here is normative. +## Pane header responsive sizing + +A viewport breakpoint says nothing about a narrow split inside a wide window: at a 1200px viewport every control stayed rendered in a 103px pane and overflowed into its neighbor (innerdogfood QC, 2026-09). Tool headers have even less browser width because Terminal Context occupies its own button. Measuring the header and moving fixed controls together keeps long keys, note buttons, and renderer chips from pushing minimize/kill into a neighboring pane; quantizing the measurement to a tier keeps the header from re-rendering on every frame of a sash drag or tween. + +In the same run, real clicks exposed premature popup dismissal before the action ran. After repair, Zoom reached 716×403 pixels, Unzoom returned to the compact header, Reload worked, and Display retained modal focus. Header buttons stayed within their panes at the final 1200×800 viewport. + +Terminal border-box thresholds of 293/173 pixels preserve the former 280/160 content-box thresholds plus 13 pixels of horizontal padding. A content box can clamp to zero in a visible tiny leaf; treating that as hidden retained the full tier. Positive border-box width distinguishes that case from a hidden leaf. + ## Pane body xterm.js paints only its own rendered surface, and integer row fitting leaves a sub-row remainder at the bottom of the pane: a host background differing from the terminal screen shows as a stripe under the last row, and an unclipped host squares off the rounded bottom corners. diff --git a/docs/testing/dor-tool-qc.md b/docs/testing/dor-tool-qc.md new file mode 100644 index 000000000..452afd95b --- /dev/null +++ b/docs/testing/dor-tool-qc.md @@ -0,0 +1,44 @@ +# Dor Tool innerdogfood QC recipe + +Use this fixture with the current contracts in `docs/specs/dor-tool.md` and +`docs/specs/layout.md`. Historical September 2026 results and coverage limits +live in `docs/specs/dor-tool.rationale.md` → Lifecycle; header findings live in +`docs/specs/layout.rationale.md` → Pane header responsive sizing. + +## Harness and isolation + +1. Run source-mutating root self-tests before starting the live harness. Start + `dor ensure -- pnpm innerdogfood` from the checkout under test. Use the + printed browser command through `dor ab`; the harness provides real sidecar + PTYs, a staged CLI, and the iframe proxy. +2. Keep generated files and a separate XDG user configuration under the ignored + `standalone/src-tauri/target/dor-tool-qc/` directory. Use that configuration + for inner CLI invocations; leave the installed application's configuration + and trust records untouched. Capture inner CLI credentials only to a + mode-0600 local file, never to a report. +3. From an inner terminal, start the fixture as a Tool: + `dor tool -- node scripts/dor-tool-qc/server.mjs --label QC --ports 1`. + Adjust the fixture path if that terminal starts outside the checkout. + +## Serving and interaction checks + +The fixture accepts `--ports N`, `--label TEXT`, `--path PATH`, and `--announce`. +It prints its PID, listening ports, and argv. `--ports 3` creates a port conflict; +`--announce` selects the first port through OSC 367. On POSIX, send `SIGUSR1` to +the printed PID to announce after startup. The page echoes the requested path +and argv, and its text input makes document-state retention visible. + +Use the fixture to exercise narrow header controls, popup actions and focus, +Terminal Context, minimize/reveal, renderer changes, and live reload. Add local +files and temporary user/project `dormouse.yml` declarations for approval, +argument quoting, keyed reuse/restart, and file dispatch. Record the tested +commit, observed outcomes, and coverage limits when collecting new evidence. +The browser harness does not establish native Tauri/VS Code rendering, +native-window transfer, or Windows shell behavior. + +## Cleanup + +Close every Tool created by the run, verify its printed PID and listeners have +exited, then stop the owned harness. Remove private credential captures. Keep +any screenshots and raw observations ignored locally; summarize durable +findings in the owning spec's rationale rather than linking private artifacts. diff --git a/dor/src/commands/open.ts b/dor/src/commands/open.ts index d4f1803b5..01824e9f3 100644 --- a/dor/src/commands/open.ts +++ b/dor/src/commands/open.ts @@ -19,7 +19,7 @@ export const openCommand: Command = { brief: 'Open a local file with a Dor Tool.', fullDescription: `Opens one existing local file. Relative paths resolve from the caller's directory (or --cwd); symlink aliases resolve to the same file. URLs, directories, and Surface handles are not accepted. -The first matching rule in the user dormouse.yml selects a user Tool or builtin:file. --tool chooses a handler explicitly. Without a matching rule, the built-in viewer opens supported HTML, text/source, image, PDF, and media files. Use --tool builtin:file to select it explicitly. Markdown is shown as source text; a user Tool can provide rendered Markdown. Project associations and project Tools never participate in this lookup. The user file is $XDG_CONFIG_HOME/dormouse/dormouse.yml, or ~/.config/dormouse/dormouse.yml. +The first matching rule in the user dormouse.yml selects a user Tool or builtin:file. --tool chooses a handler explicitly. Without a matching rule, the built-in viewer opens supported HTML, text/source, image, and media files. PDFs require a user Tool association or --tool . Use --tool builtin:file to select it explicitly. Markdown is shown as source text; a user Tool can provide rendered Markdown. Project associations and project Tools never participate in this lookup. The user file is $XDG_CONFIG_HOME/dormouse/dormouse.yml, or ~/.config/dormouse/dormouse.yml. The ordered open list contains {match, tool} entries. Patterns without a slash match the filename; patterns with a slash match both the canonical absolute path and the path relative to the invocation directory. Matching uses picomatch glob syntax with forward slashes and case sensitivity. Dotfiles require explicit patterns. The built-in HTML viewer serves statically referenced relative assets within the document directory tree; root-relative and external resources are unavailable. Text previews are capped at 8 MiB. diff --git a/dor/src/file-viewer-format.ts b/dor/src/file-viewer-format.ts index c6d700a2a..47aba00db 100644 --- a/dor/src/file-viewer-format.ts +++ b/dor/src/file-viewer-format.ts @@ -2,7 +2,7 @@ * Tool association rather than being guessed to be text. */ const MIME: Record = { html: 'text/html; charset=utf-8', htm: 'text/html; charset=utf-8', - pdf: 'application/pdf', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', + svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif', ico: 'image/x-icon', css: 'text/css; charset=utf-8', js: 'text/javascript; charset=utf-8', mjs: 'text/javascript; charset=utf-8', json: 'application/json', woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf', @@ -20,6 +20,9 @@ export const VIEW_FILE_ARGV = '__view-file'; export function fileViewerFormat(path: string): { mime: string; text: boolean } | null { const name = path.replace(/\\/g, '/').split('/').pop()!.toLowerCase(); const ext = name.includes('.') ? name.split('.').pop()! : ''; + // PDF plugins cannot run inside the viewer's iframe sandbox. Exclude PDFs + // before source-name heuristics so README.pdf never becomes a text preview. + if (ext === 'pdf') return null; const knownMime = Object.prototype.hasOwnProperty.call(MIME, ext) ? MIME[ext] : undefined; const text = TEXT.has(ext) || (!knownMime && /^(readme|license|licence|makefile|dockerfile|\.gitignore|\.env)(\..*)?$/.test(name)); const mime = knownMime ?? (text ? 'text/plain; charset=utf-8' : null); diff --git a/dor/test/file-viewer.test.mjs b/dor/test/file-viewer.test.mjs index 478d45b96..82a23e2b2 100644 --- a/dor/test/file-viewer.test.mjs +++ b/dor/test/file-viewer.test.mjs @@ -37,10 +37,11 @@ async function get(viewer, path = viewer.path, headers = {}, method = 'GET') { } const asset = (viewer, path) => viewer.path.replace(/\/file\/.*$/, `/file/${path}`); -test('known formats override source-name heuristics without treating prototype keys as formats', () => { - for (const [name, mime] of [['README.pdf', 'application/pdf'], ['readme.png', 'image/png'], ['LICENSE.html', 'text/html; charset=utf-8']]) { +test('known formats override source-name heuristics, PDFs never preview, and prototype keys are not formats', () => { + for (const [name, mime] of [['readme.png', 'image/png'], ['LICENSE.html', 'text/html; charset=utf-8']]) { assert.deepEqual(fileViewerFormat(name), { mime, text: false }); } + for (const name of ['report.pdf', 'README.pdf', 'LICENSE.PDF']) assert.equal(fileViewerFormat(name), null, name); for (const name of ['README', 'Dockerfile.dev', 'README.md', '.gitignore']) { assert.deepEqual(fileViewerFormat(name), { mime: 'text/plain; charset=utf-8', text: true }); } @@ -100,12 +101,12 @@ test('rejects parent-directory references and symlinks escaping the document dir assert.notEqual((await get(viewer, asset(viewer, '../secret.txt'))).status, 200); }); -test('supports byte ranges and HEAD for native PDF/image presentation', async () => { - const viewer = await start('README.pdf', '%PDF-1.7 example bytes'); - assert.equal((await get(viewer)).headers['content-type'], 'application/pdf'); +test('supports byte ranges and HEAD for media presentation', async () => { + const viewer = await start('sample.wav', 'RIFF example bytes'); + assert.equal((await get(viewer)).headers['content-type'], 'audio/wav'); const range = await get(viewer, viewer.path, { Range: 'bytes=0-3' }); assert.equal(range.status, 206); - assert.equal(range.body, '%PDF'); + assert.equal(range.body, 'RIFF'); assert.equal((await get(viewer, viewer.path, { Range: 'bytes=-5' })).body, 'bytes'); assert.equal((await get(viewer, viewer.path, { Range: 'bytes=999-1000' })).status, 416); assert.equal((await get(viewer, viewer.path, { Range: 'bytes=0-1,4-6' })).status, 416); diff --git a/dor/test/snapshots/help/open.md b/dor/test/snapshots/help/open.md index a5a1881a0..b701d9777 100644 --- a/dor/test/snapshots/help/open.md +++ b/dor/test/snapshots/help/open.md @@ -9,7 +9,7 @@ USAGE Opens one existing local file. Relative paths resolve from the caller's directory (or --cwd); symlink aliases resolve to the same file. URLs, directories, and Surface handles are not accepted. -The first matching rule in the user dormouse.yml selects a user Tool or builtin:file. --tool chooses a handler explicitly. Without a matching rule, the built-in viewer opens supported HTML, text/source, image, PDF, and media files. Use --tool builtin:file to select it explicitly. Markdown is shown as source text; a user Tool can provide rendered Markdown. Project associations and project Tools never participate in this lookup. The user file is $XDG_CONFIG_HOME/dormouse/dormouse.yml, or ~/.config/dormouse/dormouse.yml. +The first matching rule in the user dormouse.yml selects a user Tool or builtin:file. --tool chooses a handler explicitly. Without a matching rule, the built-in viewer opens supported HTML, text/source, image, and media files. PDFs require a user Tool association or --tool . Use --tool builtin:file to select it explicitly. Markdown is shown as source text; a user Tool can provide rendered Markdown. Project associations and project Tools never participate in this lookup. The user file is $XDG_CONFIG_HOME/dormouse/dormouse.yml, or ~/.config/dormouse/dormouse.yml. The ordered open list contains {match, tool} entries. Patterns without a slash match the filename; patterns with a slash match both the canonical absolute path and the path relative to the invocation directory. Matching uses picomatch glob syntax with forward slashes and case sensitivity. Dotfiles require explicit patterns. The built-in HTML viewer serves statically referenced relative assets within the document directory tree; root-relative and external resources are unavailable. Text previews are capped at 8 MiB. diff --git a/lib/src/components/use-notepad.ts b/lib/src/components/use-notepad.ts index 9d6028074..395a622fc 100644 --- a/lib/src/components/use-notepad.ts +++ b/lib/src/components/use-notepad.ts @@ -51,7 +51,12 @@ export function useOpenNotepadId(): string | null { * filled icon says how filled it is. */ export function notepadLabel(count: number): string { if (count === 0) return 'Notepad'; - return `Notepad · ${count} ${count === 1 ? 'note' : 'notes'}`; + return `Notepad · ${noteCountPhrase(count)}`; +} + +/** "1 note" / "3 notes" — the one plural rule every notepad trigger shares. */ +export function noteCountPhrase(count: number): string { + return `${count} ${count === 1 ? 'note' : 'notes'}`; } /** Fire-and-forget: the clipboard write is best effort by contract, and the diff --git a/lib/src/components/wall/SurfacePaneHeader.test.tsx b/lib/src/components/wall/SurfacePaneHeader.test.tsx index 5485d8696..034f21f02 100644 --- a/lib/src/components/wall/SurfacePaneHeader.test.tsx +++ b/lib/src/components/wall/SurfacePaneHeader.test.tsx @@ -6,6 +6,10 @@ import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { PaneProps } from './pane-props'; import { SurfacePaneHeader } from './SurfacePaneHeader'; +import { ToolPaneHeader } from './ToolPaneHeader'; +import { FakePtyAdapter } from '../../lib/platform/fake-adapter'; +import { setPlatform } from '../../lib/platform'; +import { addPlainNote, clearAllNotepads, getOpenNotepadId } from '../../lib/notepad/notepad-store'; import { registerAgentBrowserScreen, type ChromeSnapshot, @@ -14,13 +18,14 @@ import { import { setDevServerResolution } from './agent-browser-ports'; import { ModeContext, + WorkspaceActiveContext, SelectedIdContext, WallActionsContext, WindowFocusedContext, ZoomedIdContext, type WallActions, } from './wall-context'; -import { registerStubScreen, STUB_CHROME, STUB_SCREEN, stubWallActions as stubActions } from './wall-test-utils'; +import { registerStubScreen, STUB_CHROME, STUB_SCREEN, stubResizeObserver, stubWallActions as stubActions } from './wall-test-utils'; import { setNativeFieldValue } from '../../lib/dom'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -38,43 +43,265 @@ function headerProps(id: string, title: string): PaneProps { let container: HTMLDivElement; let root: Root; +let resizeHeader: (width: number) => void; beforeEach(() => { + setPlatform(new FakePtyAdapter()); + clearAllNotepads(); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); + resizeHeader = stubResizeObserver(620); }); afterEach(() => { act(() => root.unmount()); container.remove(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); }); function renderHeader( props: PaneProps, actions: WallActions, - state: { active?: boolean; zoomedId?: string | null } = {}, + state: { active?: boolean; zoomedId?: string | null; tool?: boolean; workspaceActive?: boolean } = {}, ) { act(() => { root.render( - - - - - - - - - - - + + + + + + + {state.tool ? : } + + + + + + , ); }); } +/** The compact header's popover, portaled to `document.body`. */ +const popup = () => document.querySelector('[role="dialog"][aria-label="Browser controls"]'); +/** The compact header's trigger; its label grows a note count. */ +const overflowTrigger = () => container.querySelector('[aria-label^="Browser controls"]')!; +const inPopup = (selector: string) => popup()?.querySelector(selector) ?? null; +/** Click, then let the popover's deferred dismissal (a 0ms task) run. */ +async function clickAndSettle(element: HTMLElement) { + await act(async () => { element.click(); await new Promise(resolve => setTimeout(resolve, 0)); }); +} +function openPopup() { + act(() => overflowTrigger().click()); + expect(popup()).not.toBeNull(); +} + describe('SurfacePaneHeader — browser chrome', () => { + it.each(['workspace', 'parked'] as const)('dismisses compact controls without stealing focus when hidden by %s', hiddenBy => { + const id = 'pane-hidden-controls'; + const registration = register(id); + const props = headerProps(id, 'Browser'); + const actions = stubActions(); + const otherWorkspaceControl = document.createElement('button'); + document.body.appendChild(otherWorkspaceControl); + try { + renderHeader(props, actions); + act(() => resizeHeader(79)); + openPopup(); + otherWorkspaceControl.focus(); + renderHeader({ ...props, parked: hiddenBy === 'parked' }, actions, { workspaceActive: hiddenBy !== 'workspace' }); + expect(popup()).toBeNull(); + expect(document.activeElement).toBe(otherWorkspaceControl); + renderHeader(props, actions); + expect(popup()).toBeNull(); + expect(overflowTrigger().getAttribute('aria-expanded')).toBe('false'); + } finally { + otherWorkspaceControl.remove(); + registration.dispose(); + } + }); + + it('collapses chrome by its own width, excluding the Tool context button', () => { + const registration = register('pane-resize', { ...CHROME, key: 'a'.repeat(300) }); + renderHeader({ ...headerProps('pane-resize', 'Browser'), params: { surfaceType: 'tool', url: CHROME.url } }, stubActions(), { tool: true }); + expect(container.querySelector('[aria-label="Terminal context"]')).not.toBeNull(); + expect(container.querySelector('[aria-label="Back"]')).not.toBeNull(); + expect(container.querySelector('[aria-label="Zoom"]')).not.toBeNull(); + act(() => resizeHeader(400)); + expect(container.querySelector('[aria-label="Back"]')).not.toBeNull(); + expect(container.querySelector('[aria-label="Zoom"]')).toBeNull(); + act(() => resizeHeader(340)); + expect(container.querySelector('[aria-label="Back"]')).toBeNull(); + // A 103px Tool leaves 79px beside its Terminal Context button. + act(() => resizeHeader(79)); + expect(overflowTrigger()).not.toBeNull(); + expect(container.querySelector('[aria-label="Kill"]')).not.toBeNull(); + act(() => resizeHeader(56)); + expect(container.querySelector('[aria-label="Kill"]')).toBeNull(); + act(() => resizeHeader(620)); + expect(container.querySelector('[aria-label^="Browser controls"]')).toBeNull(); + expect(container.querySelector('[aria-label="Back"]')).not.toBeNull(); + expect(container.querySelector('[aria-label="Zoom"]')).not.toBeNull(); + registration.dispose(); + }); + + it('keeps the popover keyboard reachable and hands focus back to its trigger', async () => { + const registration = register('pane-popup'); + const actions = stubActions(); + renderHeader(headerProps('pane-popup', 'Browser'), actions); + act(() => resizeHeader(79)); + openPopup(); + expect(popup()!.contains(document.activeElement)).toBe(true); + const firstControl = document.activeElement!; + act(() => firstControl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }))); + expect(document.activeElement).not.toBe(firstControl); + expect(popup()!.contains(document.activeElement)).toBe(true); + expect(inPopup('[aria-label="Back"]')).not.toBeNull(); + await clickAndSettle(inPopup('[aria-label="Split left/right"]')!); + expect(actions.onSplitH).toHaveBeenCalledWith('pane-popup'); + expect(popup()).toBeNull(); + + openPopup(); + const url = inPopup('[role="button"]')!; + act(() => { url.focus(); url.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); }); + expect(inPopup('input')).not.toBeNull(); + act(() => inPopup('input')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))); + expect(popup()).toBeNull(); + expect(document.activeElement).toBe(overflowTrigger()); + + openPopup(); + act(() => document.body.dispatchEvent(new Event('pointerdown', { bubbles: true }))); + expect(popup()).toBeNull(); + + openPopup(); + // Separate acts: a browser flushes the pointerdown's state before the click. + act(() => overflowTrigger().dispatchEvent(new Event('pointerdown', { bubbles: true }))); + act(() => overflowTrigger().click()); + expect(popup()).toBeNull(); + expect(overflowTrigger().getAttribute('aria-expanded')).toBe('false'); + registration.dispose(); + }); + + it('reclamps changing popup content near the viewport edge without moving editor focus', () => { + const registration = register('pane-popup-geometry'); + vi.stubGlobal('innerWidth', 300); + vi.stubGlobal('innerHeight', 300); + const originalRect = HTMLElement.prototype.getBoundingClientRect; + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this.getAttribute('role') === 'dialog') { + return new DOMRect(0, 0, 276, this.querySelector('input') ? 40 : 80); + } + if (this.getAttribute('aria-label')?.startsWith('Browser controls')) return new DOMRect(280, 240, 20, 20); + return originalRect.call(this); + }); + renderHeader(headerProps('pane-popup-geometry', 'Browser'), stubActions()); + act(() => resizeHeader(79)); + let resizePopup: () => void; + const disconnect = vi.fn(); + vi.stubGlobal('ResizeObserver', class { + constructor(private readonly callback: ResizeObserverCallback) {} + observe(target: Element) { + resizePopup = () => this.callback([{ target } as ResizeObserverEntry], this as unknown as ResizeObserver); + } + disconnect = disconnect; + }); + try { + openPopup(); + expect(popup()!.style.top).toBe('208px'); + expect(popup()!.style.left).toBe('12px'); + expect(popup()!.style.maxWidth).toBe('calc(100vw - 24px)'); + act(() => inPopup('[role="button"]')!.click()); + const input = inPopup('input')!; + expect(document.activeElement).toBe(input); + act(() => resizePopup()); + expect(popup()!.style.top).toBe('248px'); + expect(document.activeElement).toBe(input); + act(() => inPopup('[aria-label="Back"]')!.focus()); + expect(inPopup('input')).toBeNull(); + act(() => resizePopup()); + expect(popup()!.style.top).toBe('208px'); + expect(Number.parseFloat(popup()!.style.top) + 80).toBe(288); + const disconnectsBeforeClose = disconnect.mock.calls.length; + act(() => overflowTrigger().click()); + expect(popup()).toBeNull(); + expect(disconnect.mock.calls.length).toBeGreaterThan(disconnectsBeforeClose); + } finally { + registration.dispose(); + } + }); + + it('names notes on the trigger and opens the notepad from the popover', async () => { + const registration = register('pane-notes'); + renderHeader(headerProps('pane-notes', 'Browser'), stubActions()); + act(() => resizeHeader(79)); + act(() => addPlainNote('pane-notes', 'A saved note')); + expect(overflowTrigger().getAttribute('aria-label')).toBe('Browser controls, 1 note'); + openPopup(); + expect(inPopup('input')).toBeNull(); + await clickAndSettle(inPopup('button[aria-label^="Notepad"]')!); + expect(getOpenNotepadId()).toBe('pane-notes'); + registration.dispose(); + }); + + it('closes the popover from Minimize and Kill wherever they render', async () => { + const registration = register('pane-actions'); + const actions = stubActions(); + renderHeader(headerProps('pane-actions', 'Browser'), actions); + act(() => resizeHeader(79)); + for (const label of ['Minimize', 'Kill']) { + openPopup(); + act(() => container.querySelector(`[aria-label="${label}"]`)!.click()); + expect(popup()).toBeNull(); + } + act(() => resizeHeader(56)); + for (const label of ['Minimize', 'Kill']) { + openPopup(); + await clickAndSettle(inPopup(`[aria-label="${label}"]`)!); + expect(popup()).toBeNull(); + } + expect(actions.onMinimize).toHaveBeenCalledTimes(2); + expect(actions.onKill).toHaveBeenCalledTimes(2); + expect(actions.onKill).toHaveBeenCalledWith('pane-actions'); + registration.dispose(); + }); + + it('runs popup Zoom, Reload and Display before dismissal and preserves modal focus', async () => { + const modalControl = document.createElement('button'); + document.body.appendChild(modalControl); + const stillOwnsFocus = () => expect(popup()?.contains(document.activeElement)).toBe(true); + const onZoom = vi.fn(stillOwnsFocus); + const reload = vi.fn(stillOwnsFocus); + const openModal = vi.fn(() => { stillOwnsFocus(); modalControl.focus(); }); + const registration = registerAgentBrowserScreen('pane-popup-actions', { + snapshot: SCREEN, chrome: CHROME, hostCapable: true, + actions: { engageSync: vi.fn(), applyDevice: vi.fn(), applyViewport: vi.fn(), openModal }, + chromeActions: { navigate: vi.fn(), back: vi.fn(), forward: vi.fn(), reload }, + }); + try { + renderHeader(headerProps('pane-popup-actions', 'Browser'), stubActions({ onZoom })); + act(() => resizeHeader(79)); + for (const selector of ['[aria-label="Zoom"]', '[aria-label="Reload"]', '[data-browser-display-trigger]']) { + openPopup(); + const action = inPopup(selector)!; + action.focus(); + await clickAndSettle(action); + expect(popup()).toBeNull(); + } + expect(onZoom).toHaveBeenCalledWith('pane-popup-actions'); + expect(reload).toHaveBeenCalledOnce(); + expect(openModal).toHaveBeenCalledOnce(); + expect(document.activeElement).toBe(modalControl); + } finally { + registration.dispose(); + modalControl.remove(); + } + }); + it('uses the shared capability-first icon pair for every browser display mode', () => { const cases = [ [{ ...SCREEN, renderMode: 'ab-screencast', syncEngaged: true }, 'ab-resize', 2], diff --git a/lib/src/components/wall/SurfacePaneHeader.tsx b/lib/src/components/wall/SurfacePaneHeader.tsx index 9ec95bfee..f2d5311cf 100644 --- a/lib/src/components/wall/SurfacePaneHeader.tsx +++ b/lib/src/components/wall/SurfacePaneHeader.tsx @@ -1,5 +1,14 @@ -import { useContext, useEffect, useState } from 'react'; +import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from 'react'; +import { createPortal } from 'react-dom'; +import { POPOVER_FOCUSABLE_SELECTOR, usePopoverFocusTrap } from '../use-popover-focus-trap'; +import { useDismissOverlay } from './use-dismiss-overlay'; +import { useHeaderTier } from './use-header-tier'; +import { useSurfaceVisibility } from './use-surface-visibility'; +import { noteCountPhrase, useNoteCount } from '../use-notepad'; +import { clampOverlayPosition, OVERLAY_VIEWPORT_MARGIN_PX } from '../../lib/ui-geometry'; import { + DotsThreeIcon, + NotepadIcon, ArrowClockwiseIcon, ArrowLeftIcon, ArrowLineDownIcon, @@ -11,7 +20,7 @@ import { XIcon, } from '@phosphor-icons/react'; import { HeaderActionButton } from '../HeaderActionButton'; -import { HEADER_PALETTE_TRANSITION_CLASS, paneZoomButtonClass, TERMINAL_TOP_RADIUS_CLASS } from '../design'; +import { chromeButton, HEADER_PALETTE_TRANSITION_CLASS, OVERLAY_MAX_HEIGHT, POPUP_SURFACE_CLASS, paneZoomButtonClass, TERMINAL_TOP_RADIUS_CLASS } from '../design'; import { NotepadHeaderButton } from './NotepadHeaderButton'; import { useAgentBrowserChromeSnapshot, @@ -33,7 +42,18 @@ import { useDialogKeyboardOwner, } from './wall-context'; -export function SurfacePaneHeader({ id, title }: PaneProps) { +/** What the browser chrome shows inline at a header width (`docs/specs/layout.md` + * → "Pane header responsive sizing"). Below `minimal` the chrome moves into the + * popover, where it renders at `full`. */ +type BrowserInlineTier = 'full' | 'compact' | 'minimal'; +type BrowserHeaderTier = BrowserInlineTier | 'overflow' | 'tiny'; +const browserHeaderTier = (width: number): BrowserHeaderTier => + width >= 420 ? 'full' : width >= 360 ? 'compact' : width >= 180 ? 'minimal' : width >= 72 ? 'overflow' : 'tiny'; + +export function SurfacePaneHeader({ id, title, parked }: PaneProps) { + const visible = useSurfaceVisibility(parked); + const visibleRef = useRef(visible); + visibleRef.current = visible; const mode = useContext(ModeContext); const selectedId = useContext(SelectedIdContext); const windowFocused = useContext(WindowFocusedContext); @@ -80,11 +100,29 @@ export function SurfacePaneHeader({ id, title }: PaneProps) { }; const closeUrlEditor = () => setEditingUrl(false); - return ( -
actions.onClickPanel(id)} - > + // Below the `minimal` tier the chrome lives in a popover behind one trigger; + // a pane resize or a hidden Surface closes it, the latter without pulling + // focus back to a trigger nobody can see. `closeMenu` stays identity-stable + // (reading `visibleRef`) so the popover's listeners subscribe once. + const headerRef = useRef(null); + const overflowRef = useRef(null); + const [menuOpen, setMenuOpen] = useState(false); + const closeMenu = useCallback((restoreFocus = true) => { + setMenuOpen(false); + setEditingUrl(false); + if (restoreFocus && visibleRef.current) overflowRef.current?.focus(); + }, []); + const tier = useHeaderTier(headerRef, browserHeaderTier, { onResize: () => closeMenu(false) }); + const inline: BrowserInlineTier | null = tier === 'overflow' || tier === 'tiny' ? null : tier; + const popoverOpen = visible && inline === null && menuOpen; + const noteCount = useNoteCount(id); + const overflowLabel = `Browser controls${noteCount ? `, ${noteCountPhrase(noteCount)}` : ''}`; + useEffect(() => { + if (!visible) closeMenu(false); + }, [visible, closeMenu]); + + const renderBrowserControls = (placement: BrowserInlineTier | 'popover') => ( + <> {screen && screenSnapshot && chrome ? ( <> {/* Render/screen chip → far left, out of the way of the nav controls. @@ -104,7 +142,7 @@ export function SurfacePaneHeader({ id, title }: PaneProps) { {/* Back / forward / refresh — native agent-browser commands; always enabled (no canGoBack/Forward in the stream). Collapse before the URL but after split/zoom. */} -
+ {placement !== 'minimal' &&
{ e.stopPropagation(); screen.chromeActions.back(); }} @@ -123,14 +161,14 @@ export function SurfacePaneHeader({ id, title }: PaneProps) { ariaLabel="Reload" tooltip="Reload" > -
+
} {/* --key indicator for non-default keys only — the key name inline, small + quiet (hover reveals `--key `), never a prefix on the persisted title. Raw --session surfaces show none. */} {chrome.key && chrome.key !== 'default' && ( {chrome.key} )} @@ -163,7 +201,7 @@ export function SurfacePaneHeader({ id, title }: PaneProps) { className="flex h-5 min-w-0 items-center gap-1 rounded px-1.5 text-xs transition-colors hover:bg-current/10" > {devServer.label} - {port != null && :{port}} + {port != null && :{port}} )} @@ -172,14 +210,17 @@ export function SurfacePaneHeader({ id, title }: PaneProps) { / full URL → tooltip. Gives up width (shrink-[10]) long before the command does. */} <span - className="min-w-0 shrink-[10] cursor-text truncate font-medium underline-offset-2 hover:underline" + className={`${placement === 'popover' ? 'basis-full' : ''} min-w-0 shrink-[10] cursor-text truncate font-medium underline-offset-2 hover:underline`} title={chrome.title ?? chrome.url ?? undefined} onMouseDown={(e) => e.stopPropagation()} + role="button" + tabIndex={0} + onKeyDown={event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); setEditingUrl(true); } }} onClick={(e) => { e.stopPropagation(); setEditingUrl(true); }} >{urlText || title || id}</span> {/* Flexible spacer keeps the layout buttons right-aligned. */} - <div className="min-w-0 flex-1" /> + {placement !== 'popover' && <div className="min-w-0 flex-1" />} </> )} </> @@ -188,7 +229,7 @@ export function SurfacePaneHeader({ id, title }: PaneProps) { )} <NotepadHeaderButton surfaceId={id} /> - <div className="ml-1 hidden shrink-0 items-center gap-0.5 min-[420px]:flex"> + {(placement === 'popover' || placement === 'full') && <div className="ml-1 flex shrink-0 items-center gap-0.5"> <HeaderActionButton className="flex h-5 min-w-5 items-center justify-center rounded transition-colors hover:bg-current/10" onClick={(e) => { e.stopPropagation(); actions.onSplitH(id); }} @@ -207,21 +248,103 @@ export function SurfacePaneHeader({ id, title }: PaneProps) { ariaLabel={zoomed ? 'Unzoom' : 'Zoom'} tooltip={zoomed ? 'Unzoom' : 'Zoom [z]'} >{zoomed ? <ArrowsInIcon size={14} /> : <ArrowsOutIcon size={14} />}</HeaderActionButton> - </div> - <div className="ml-1 flex shrink-0 items-center gap-0.5"> - <HeaderActionButton - className="flex h-5 min-w-5 items-center justify-center rounded transition-colors hover:bg-current/10" - onClick={(e) => { e.stopPropagation(); actions.onMinimize(id); }} - ariaLabel="Minimize" - tooltip="Minimize [m] or [d]" - ><ArrowLineDownIcon size={14} /></HeaderActionButton> - <HeaderActionButton - className="flex h-5 min-w-5 items-center justify-center rounded transition-colors hover:bg-error/10 hover:text-error" - onClick={(e) => { e.stopPropagation(); actions.onKill(id); }} - ariaLabel="Kill" - tooltip="Kill [k] or [x]" - ><XIcon size={14} /></HeaderActionButton> - </div> + </div>} + </> + ); + + // Preserve the 4px separation with inline chrome; collapsed controls align right. + const paneActions = ( + <div className={`${inline ? 'ml-1' : 'ml-auto'} flex shrink-0 items-center gap-0.5`}> + <HeaderActionButton + className="flex h-5 min-w-5 items-center justify-center rounded transition-colors hover:bg-current/10" + onClick={(e) => { e.stopPropagation(); closeMenu(); actions.onMinimize(id); }} + ariaLabel="Minimize" + tooltip="Minimize [m] or [d]" + ><ArrowLineDownIcon size={14} /></HeaderActionButton> + <HeaderActionButton + className="flex h-5 min-w-5 items-center justify-center rounded transition-colors hover:bg-error/10 hover:text-error" + onClick={(e) => { e.stopPropagation(); closeMenu(); actions.onKill(id); }} + ariaLabel="Kill" + tooltip="Kill [k] or [x]" + ><XIcon size={14} /></HeaderActionButton> </div> ); + + return ( + <div + ref={headerRef} + className={`flex h-full min-w-0 flex-1 cursor-grab items-center ${inline ? 'gap-1.5 pl-2 pr-[5px]' : 'gap-0.5 px-1'} ${TERMINAL_TOP_RADIUS_CLASS} text-sm leading-none font-mono select-none active:cursor-grabbing ${HEADER_PALETTE_TRANSITION_CLASS} ${isActiveHeader ? 'bg-header-active-bg text-header-active-fg' : 'bg-header-inactive-bg text-header-inactive-fg'}`} + onMouseDown={() => actions.onClickPanel(id)} + > + {inline ? renderBrowserControls(inline) : ( + <button ref={overflowRef} type="button" aria-label={overflowLabel} + aria-haspopup="dialog" aria-expanded={popoverOpen} + title={overflowLabel} + className={`${chromeButton()} shrink-0`} + /* A press on the trigger toggles; it must not dismiss first and then reopen. */ + onPointerDown={event => event.stopPropagation()} + onMouseDown={event => event.stopPropagation()} + onClick={event => { event.stopPropagation(); if (menuOpen) closeMenu(); else setMenuOpen(true); }}> + {noteCount ? <NotepadIcon size={14} weight="fill" /> : <DotsThreeIcon size={14} />} + </button> + )} + {tier !== 'tiny' && paneActions} + {popoverOpen && <BrowserHeaderPopover anchorRef={overflowRef} onClose={closeMenu}> + {renderBrowserControls('popover')} + {tier === 'tiny' && paneActions} + </BrowserHeaderPopover>} + </div> + ); +} + +/** Gap between the trigger's bottom edge and the popover. */ +const POPOVER_GAP_PX = 4; + +function BrowserHeaderPopover({ anchorRef, onClose, children }: { + anchorRef: RefObject<HTMLElement | null>; + onClose: (restoreFocus?: boolean) => void; + children: ReactNode; +}) { + const ref = useRef<HTMLDivElement>(null); + const [position, setPosition] = useState<CSSProperties>({ position: 'fixed' }); + useDialogKeyboardOwner(true); + usePopoverFocusTrap(ref, onClose); + useDismissOverlay(onClose, ref); + useLayoutEffect(() => { + const element = ref.current!; + const positionPopover = () => { + const anchor = anchorRef.current!.getBoundingClientRect(); + const rect = element.getBoundingClientRect(); + setPosition(clampOverlayPosition({ left: anchor.left, top: anchor.bottom + POPOVER_GAP_PX, width: rect.width, height: rect.height })); + }; + positionPopover(); + const observer = new ResizeObserver(positionPopover); + observer.observe(element, { box: 'border-box' }); + // Content resizing (URL editing, notes, or connection labels) changes only + // geometry. Moving focus again would cancel the URL editor on its blur. + element.querySelector<HTMLElement>(POPOVER_FOCUSABLE_SELECTOR)?.focus(); + return () => observer.disconnect(); + }, [anchorRef]); + return createPortal( + <div ref={ref} role="dialog" aria-label="Browser controls" + style={{ ...position, maxWidth: `calc(100vw - ${OVERLAY_VIEWPORT_MARGIN_PX * 2}px)` }} + className={`${POPUP_SURFACE_CLASS} ${OVERLAY_MAX_HEIGHT.popover} flex w-80 flex-wrap items-center gap-2 overflow-auto p-2 text-sm`} + /* Presses inside survive the dismissal contract and never start a pane drag. */ + onPointerDown={event => event.stopPropagation()} + onMouseDown={event => event.stopPropagation()} + onClickCapture={event => { + // Only real buttons dismiss: the URL is a `role="button"` span whose + // click opens the editor here, inside the popover. + if (!(event.target as Element).closest('button')) return; + // Native clicks can drain microtasks between capture and bubble. Wait + // a task so the action runs before its target unmounts; a new modal + // keeps any focus it acquired in the action handler. + setTimeout(() => { + const focusStillOurs = document.activeElement === document.body || !!ref.current?.contains(document.activeElement); + onClose(focusStillOurs); + }, 0); + }}> + {children} + </div>, document.body, + ); } diff --git a/lib/src/components/wall/TerminalPaneHeader.test.tsx b/lib/src/components/wall/TerminalPaneHeader.test.tsx index 0703a6ae1..792c161b3 100644 --- a/lib/src/components/wall/TerminalPaneHeader.test.tsx +++ b/lib/src/components/wall/TerminalPaneHeader.test.tsx @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { PaneProps } from './pane-props'; import { TerminalPaneHeader } from './TerminalPaneHeader'; import { RenamingIdContext, WallActionsContext, type WallActions } from './wall-context'; -import { ensureResizeObserver, stubWallActions as stubActions } from './wall-test-utils'; +import { ensureResizeObserver, stubResizeObserver, stubWallActions as stubActions } from './wall-test-utils'; import { FakePtyAdapter } from '../../lib/platform/fake-adapter'; import { setPlatform } from '../../lib/platform'; import { setNativeFieldValue } from '../../lib/dom'; @@ -154,27 +154,15 @@ describe('TerminalPaneHeader — inline rename', () => { describe('TerminalPaneHeader — notepad icon', () => { // The tier is ResizeObserver-driven, so the suite's inert stub can only ever // show `full`. This one reports a width the test picks. - let headerWidth = 400; - let previousObserver: typeof ResizeObserver; + let resizeHeader: (width: number) => void; beforeEach(() => { - headerWidth = 400; - previousObserver = globalThis.ResizeObserver; - globalThis.ResizeObserver = class { - constructor(private readonly callback: ResizeObserverCallback) {} - observe(target: Element): void { - this.callback( - [{ target, contentRect: { width: headerWidth } } as unknown as ResizeObserverEntry], - this as unknown as ResizeObserver, - ); - } - unobserve(): void {} - disconnect(): void {} - } as unknown as typeof ResizeObserver; + resizeHeader = stubResizeObserver(400); }); afterEach(() => { - globalThis.ResizeObserver = previousObserver; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); // Still mounted at this point (the outer hook unmounts), so both stores // notify a live header. act(() => { @@ -220,14 +208,11 @@ describe('TerminalPaneHeader — notepad icon', () => { }); it('keeps its place at the compact tier and yields it at minimal only when empty', () => { - headerWidth = 200; renderHeader(stubActions(), null); + act(() => resizeHeader(200)); expect(notepadButton()).not.toBeNull(); - headerWidth = 100; - act(() => root.unmount()); - root = createRoot(container); - renderHeader(stubActions(), null); + act(() => resizeHeader(100)); expect(notepadButton()).toBeNull(); // Notes are never invisible: the icon comes back to carry them. @@ -235,6 +220,53 @@ describe('TerminalPaneHeader — notepad icon', () => { expect(notepadButton()).not.toBeNull(); }); + it('preserves visual breakpoints and the previous tier while hidden', () => { + renderHeader(stubActions(), null); + const split = () => container.querySelector('[aria-label="Split left/right"]'); + act(() => resizeHeader(293)); + expect(split()).toBeNull(); + expect(notepadButton()).not.toBeNull(); + act(() => resizeHeader(0)); + expect(split()).toBeNull(); + expect(notepadButton()).not.toBeNull(); + act(() => resizeHeader(294)); + expect(split()).not.toBeNull(); + act(() => resizeHeader(173)); + expect(notepadButton()).toBeNull(); + act(() => resizeHeader(174)); + expect(notepadButton()).not.toBeNull(); + }); + + it('measures the initial border width before ResizeObserver delivers', () => { + stubResizeObserver(0); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 293, 30)); + renderHeader(stubActions(), null); + expect(container.querySelector('[aria-label="Split left/right"]')).toBeNull(); + expect(notepadButton()).not.toBeNull(); + }); + + it.each([true, false])('handles zero content width with borderBoxSize available=%s', (hasBorderBox) => { + let resize: (width: number) => void; + const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 400, 30)); + vi.stubGlobal('ResizeObserver', class { + constructor(private readonly callback: ResizeObserverCallback) {} + observe(target: Element) { + resize = (width) => this.callback([{ + target, + borderBoxSize: hasBorderBox ? [{ inlineSize: width, blockSize: 30 }] : undefined, + contentRect: { width: Math.max(0, width - 13) }, + } as unknown as ResizeObserverEntry], this as unknown as ResizeObserver); + } + disconnect() {} + }); + renderHeader(stubActions(), null); + expect(container.querySelector('[aria-label="Split left/right"]')).not.toBeNull(); + rect.mockReturnValue(new DOMRect(0, 0, 8, 30)); + act(() => resize(8)); + expect(container.querySelector('[aria-label="Split left/right"]')).toBeNull(); + expect(notepadButton()).toBeNull(); + }); + it('toggles the one open notepad', () => { renderHeader(stubActions(), null); diff --git a/lib/src/components/wall/TerminalPaneHeader.tsx b/lib/src/components/wall/TerminalPaneHeader.tsx index 3c20239ca..39018d16a 100644 --- a/lib/src/components/wall/TerminalPaneHeader.tsx +++ b/lib/src/components/wall/TerminalPaneHeader.tsx @@ -16,6 +16,7 @@ import { HeaderActionButton } from '../HeaderActionButton'; import { HEADER_PALETTE_TRANSITION_CLASS, paneZoomButtonClass, POPUP_SURFACE_CLASS, TERMINAL_TOP_RADIUS_CLASS, TODO_PILL_TRACKING_CLASS } from '../design'; import { AlertBell } from '../AlertBell'; import { useTodoPillContent } from '../TodoPillBody'; +import { useHeaderTier } from './use-header-tier'; import { NotepadHeaderButton } from './NotepadHeaderButton'; import type { PaneProps } from './pane-props'; import { IllegalRenameWarning, type RenameRejection } from './IllegalRenameWarning'; @@ -64,7 +65,10 @@ const tabVariant = tv({ }, }); -type HeaderTier = 'full' | 'compact' | 'minimal'; +type TerminalHeaderTier = 'full' | 'compact' | 'minimal'; +// Includes the header's 8px left + 5px right padding; the former content-box +// boundaries were 280/160px. Border-box width distinguishes tiny from hidden. +const terminalHeaderTier = (width: number): TerminalHeaderTier => width > 293 ? 'full' : width > 173 ? 'compact' : 'minimal'; // WATCHING is a rule on the running command, so the bell says which command it // would act on rather than naming an abstract toggle (`docs/specs/alert.md`). @@ -126,7 +130,7 @@ export function TerminalPaneHeader({ id, title }: PaneProps) { const isRenaming = renamingId === id; const tabRef = useRef<HTMLDivElement>(null); const suppressAlertClickRef = useRef(false); - const [tier, setTier] = useState<HeaderTier>('full'); + const tier = useHeaderTier(tabRef, terminalHeaderTier); const [todoPreviewRect, setTodoPreviewRect] = useState<DOMRect | null>(null); const [renameWarning, setRenameWarning] = useState<{ rect: DOMRect; reason: RenameRejection; value: string } | null>(null); const todoPill = useTodoPillContent(activity.todo); @@ -169,19 +173,6 @@ export function TerminalPaneHeader({ id, title }: PaneProps) { } }, [actions, id, context]); - useEffect(() => { - const el = tabRef.current; - if (!el) return; - const ro = new ResizeObserver(([entry]) => { - const w = entry.contentRect.width; - if (w > 280) setTier('full'); - else if (w > 160) setTier('compact'); - else setTier('minimal'); - }); - ro.observe(el); - return () => ro.disconnect(); - }, []); - useEffect(() => { if (!activity.notification) setTodoPreviewRect(null); }, [activity.notification]); diff --git a/lib/src/components/wall/use-header-tier.ts b/lib/src/components/wall/use-header-tier.ts new file mode 100644 index 000000000..3f89a2dfb --- /dev/null +++ b/lib/src/components/wall/use-header-tier.ts @@ -0,0 +1,33 @@ +import { useLayoutEffect, useRef, useState, type RefObject } from 'react'; + +/** + * A pane header's responsive tier, quantized from its own border-box width + * (`docs/specs/layout.md` → "Pane header responsive sizing"). The Lath animator + * resizes leaves every frame of a tween or sash drag, so the observer keeps the + * raw width out of React state: the header re-renders only when `tierFor` + * changes its answer. The first measurement is synchronous so a narrow pane + * never paints one frame of full-width chrome; a zero width (a hidden leaf) + * keeps the previous tier. `onResize` fires on every observed resize regardless. + */ +export function useHeaderTier<T>( + ref: RefObject<HTMLElement | null>, + tierFor: (width: number) => T, + { onResize }: { onResize?: () => void } = {}, +): T { + const [tier, setTier] = useState<T>(() => tierFor(Number.POSITIVE_INFINITY)); + const latest = useRef({ tierFor, onResize }); + latest.current = { tierFor, onResize }; + useLayoutEffect(() => { + const header = ref.current; + if (!header) return; + const measure = (width: number) => { if (width > 0) setTier(latest.current.tierFor(width)); }; + measure(header.getBoundingClientRect().width); + const observer = new ResizeObserver(([entry]) => { + measure(entry.borderBoxSize?.[0]?.inlineSize ?? header.getBoundingClientRect().width); + latest.current.onResize?.(); + }); + observer.observe(header, { box: 'border-box' }); + return () => observer.disconnect(); + }, [ref]); + return tier; +} diff --git a/lib/src/components/wall/wall-test-utils.ts b/lib/src/components/wall/wall-test-utils.ts index 82c9a84f5..42460ddfd 100644 --- a/lib/src/components/wall/wall-test-utils.ts +++ b/lib/src/components/wall/wall-test-utils.ts @@ -40,6 +40,39 @@ export function ensureResizeObserver(): void { } as unknown as typeof ResizeObserver; } +/** + * A ResizeObserver whose width the test drives: every observed element is told + * `initialWidth` on observe, and the returned setter re-delivers a new width to + * all of them. Stubbed through `vi.stubGlobal`, so `vi.unstubAllGlobals()` in + * `afterEach` restores jsdom. + */ +export function stubResizeObserver(initialWidth: number): (width: number) => void { + let width = initialWidth; + const deliveries = new Set<() => void>(); + vi.stubGlobal('ResizeObserver', class { + private readonly delivery = new Set<() => void>(); + constructor(private readonly callback: ResizeObserverCallback) {} + observe(target: Element): void { + const deliver = () => this.callback([{ + target, + borderBoxSize: [{ inlineSize: width, blockSize: 0 }], + contentRect: { width }, + } as unknown as ResizeObserverEntry], this as unknown as ResizeObserver); + this.delivery.add(deliver); + deliveries.add(deliver); + deliver(); + } + unobserve(): void {} + disconnect(): void { + for (const deliver of this.delivery) deliveries.delete(deliver); + } + }); + return (next) => { + width = next; + for (const deliver of deliveries) deliver(); + }; +} + export interface WallHarness { container: HTMLDivElement; root: Root; diff --git a/lib/src/host/file-viewer-proxy.test.ts b/lib/src/host/file-viewer-proxy.test.ts index 9cade49c1..c8c7368cf 100644 --- a/lib/src/host/file-viewer-proxy.test.ts +++ b/lib/src/host/file-viewer-proxy.test.ts @@ -76,7 +76,7 @@ it('retains the policy for an escaped text preview', async () => { it.each([ ['image.svg', '<svg xmlns="http://www.w3.org/2000/svg"/>', 'image/svg+xml'], - ['document.pdf', '%PDF-1.7 example bytes', 'application/pdf'], + ['sound.wav', 'RIFF example bytes', 'audio/wav'], ['readme.png', 'image bytes', 'image/png'], ['video.mp4', 'video bytes', 'video/mp4'], ])('retains CSP, bytes, HEAD, and ranges for %s through the proxy', async (name, bytes, mime) => { diff --git a/lib/src/host/tool-open.test.ts b/lib/src/host/tool-open.test.ts index 0e937bec2..9f6eb40b5 100644 --- a/lib/src/host/tool-open.test.ts +++ b/lib/src/host/tool-open.test.ts @@ -96,6 +96,16 @@ it('matches catch-all rules above the invocation directory and canonical absolut expect(await host().handle(request)).toMatchObject({ status: 'ok', name: 'viewer' }); }); +it('requires a user Tool for PDFs', async () => { + const target = join(root, 'README.pdf'); + await writeFile(target, '%PDF-1.7'); + await rm(config); + expect(await host().handle({ op: 'open', target, cwd: root })).toMatchObject({ status: 'error', message: expect.stringContaining('add an open rule') }); + expect(await host().handle({ op: 'open', target, cwd: root, tool: 'builtin:file' })).toMatchObject({ status: 'error', message: expect.stringContaining('does not support') }); + await writeConfig(viewerConfig('*.pdf')); + expect(await host().handle({ op: 'open', target, cwd: root })).toMatchObject({ status: 'ok', scope: 'user', run: ['view', target] }); +}); + it('matches the first specific rule through a symlinked working directory without changing the run directory', async () => { await writeConfig(`tools: special: diff --git a/lib/src/stories/BrowserChromeHeader.stories.tsx b/lib/src/stories/BrowserChromeHeader.stories.tsx index ff9c51ca7..0a38c9ffb 100644 --- a/lib/src/stories/BrowserChromeHeader.stories.tsx +++ b/lib/src/stories/BrowserChromeHeader.stories.tsx @@ -8,6 +8,7 @@ import { type WallActions, } from '../components/wall/wall-context'; import { SurfacePaneHeader } from '../components/wall/SurfacePaneHeader'; +import { ToolPaneHeader } from '../components/wall/ToolPaneHeader'; import { registerAgentBrowserScreen, type ChromeSnapshot, @@ -69,6 +70,8 @@ interface StoryArgs { hostCapable: boolean; /** Header width — shrink past 420/360 to watch split-zoom then nav collapse. */ width: number; + /** Include the Tool Terminal Context button beside the browser header. */ + tool: boolean; /** Whether the surface is the selected/active pane (header highlight). */ selected: boolean; } @@ -142,6 +145,7 @@ function BrowserChromeStory(args: StoryArgs) { setDevServerResolution(port, label ? { paneId: 'term-dev', label } : null); }, [port, args.devServerLabel]); + const Header = args.tool ? ToolPaneHeader : SurfacePaneHeader; return ( <ModeContext.Provider value="passthrough"> <SelectedIdContext.Provider value={args.selected ? surfaceId : null}> @@ -150,11 +154,12 @@ function BrowserChromeStory(args: StoryArgs) { story's un-zoomed header. */} <WallActionsContext.Provider value={loggingActions}> <div style={{ width: args.width }}> + {/* Preserve the compact 26px visual baseline for these isolated headers. */} <div className="bg-app-bg" style={{ height: 26 }}> - <SurfacePaneHeader + <Header id={surfaceId} title={args.htmlTitle || hostPathDisplay(args.url)} - params={undefined} + params={args.tool ? { surfaceType: 'tool', url: args.url } : undefined} /> </div> </div> @@ -177,8 +182,9 @@ const meta: Meta<typeof BrowserChromeStory> = { paneKey: { control: 'select', options: ['', 'default', 'storybook'] }, devServerLabel: { control: 'text' }, hostCapable: { control: 'boolean' }, - width: { control: { type: 'range', min: 200, max: 900, step: 10 } }, + width: { control: { type: 'range', min: 80, max: 900, step: 10 } }, selected: { control: 'boolean' }, + tool: { control: 'boolean' }, }, args: { renderMode: 'ab-screencast', @@ -190,6 +196,7 @@ const meta: Meta<typeof BrowserChromeStory> = { devServerLabel: 'pnpm dev', hostCapable: true, width: 620, + tool: false, selected: true, }, }; @@ -235,3 +242,14 @@ export const RawSession: Story = { export const Narrow: Story = { args: { width: 340 }, }; + +/** Real narrow split: the Tool context button leaves 79px for browser chrome, + * so the chrome sits behind one trigger while minimize/kill stay inline. */ +export const TinyTool: Story = { + args: { width: 103, tool: true, paneKey: 'a-very-long-tool-identity', devServerLabel: 'pnpm --filter a-very-long-project-name dev' }, +}; + +/** Below 72px minimize/kill join the popover too. */ +export const SmallestTool: Story = { + args: { width: 80, tool: true }, +}; diff --git a/scripts/dor-tool-qc/server.mjs b/scripts/dor-tool-qc/server.mjs new file mode 100644 index 000000000..fc67ad6df --- /dev/null +++ b/scripts/dor-tool-qc/server.mjs @@ -0,0 +1,28 @@ +// Small real-process fixture for docs/testing/dor-tool-qc.md. +import http from 'node:http'; +const argv = process.argv.slice(2); +const option = (name, fallback) => { + const i = argv.indexOf(name); + return i === -1 ? fallback : argv[i + 1]; +}; +const label = option('--label', 'Tool QC'); +const count = Number(option('--ports', '1')); +const escape = text => text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('"', '"'); +const servers = []; +for (let i = 0; i < count; i++) { + const server = http.createServer((req, res) => { + res.setHeader('content-type', 'text/html; charset=utf-8'); + res.end(`<html><head><title>${escape(label)}

${escape(label)}

${escape(req.url)}

${escape(JSON.stringify(argv))}
`); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + servers.push(server); +} +const ports = servers.map(server => server.address().port); +const announce = () => process.stdout.write(`\x1b]367;serve;${JSON.stringify({ v: 1, port: ports[0], path: option('--path', '/qc'), name: label })}\x1b\\`); +console.log(JSON.stringify({ event: 'qc-start', pid: process.pid, ports, argv })); +if (argv.includes('--announce')) announce(); +process.on('SIGUSR1', announce); +for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => { + for (const server of servers) server.close(); + process.exit(0); +}); diff --git a/scripts/loopback-lint.mjs b/scripts/loopback-lint.mjs index 140140982..308b7f79a 100644 --- a/scripts/loopback-lint.mjs +++ b/scripts/loopback-lint.mjs @@ -91,6 +91,11 @@ const ALLOWED = { + 'it vouches for no one. It skips the Host check on purpose: rebinding ' + 'exists to make same-origin-looking requests, which buys nothing against ' + 'an unguessable one-shot secret. See lib/src/host/loopback-guard.ts.', + 'scripts/dor-tool-qc/server.mjs': + 'A hand-run QC fixture (docs/testing/dor-tool-qc.md), never shipped or ' + + 'started by a host. It serves one static page echoing its own argv and ' + + 'the request path, holds no state and reaches nothing, so a page that ' + + 'finds the port learns only what the QC operator typed.', }; const GUARD_REFERENCES = ['loopback-guard', 'dev-host-guard']; diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 41625686a..75f16735e 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -9,7 +9,7 @@ "docs/specs/dor-cli.md": 6000, "docs/specs/dor-tool.md": 3800, "docs/specs/glossary.md": 3000, - "docs/specs/layout.md": 8850, + "docs/specs/layout.md": 9000, "docs/specs/mobile-terminal-ui.md": 1950, "docs/specs/mouse-and-clipboard.md": 3800, "docs/specs/notepad.md": 4000,