diff --git a/docs/specs/dor-browser.md b/docs/specs/dor-browser.md index 43772dd3..c4acadb7 100644 --- a/docs/specs/dor-browser.md +++ b/docs/specs/dor-browser.md @@ -161,9 +161,68 @@ polls only while a wanted port is still unmatched. Reload revalidates optimistically. Source of truth: `lib/src/components/wall/use-dev-server-ports.ts`, +`lib/src/components/wall/port-url.ts` (the `servesLoopback` predicate), `lib/src/components/wall/agent-browser-ports.ts`, `lib/src/components/wall/browser-url.ts`. +## Pane Context Menu Connect + +The terminal pane header's context menu (`docs/specs/layout.md` → Pane +header — right-click, or `>` in command mode; that spec owns the menu's +layout and keyboard contract) lists the ports a pane's process tree binds +using the **same** per-port URL selection as `surface.resolveOpen` +(`dor ab open ` / `dor iframe `): `listenerUrlsByPort` in +`port-url.ts` groups TCP listeners into one openable `http://:/` +per distinct port (loopback-reachable bind wins `localhost`; otherwise the +bound LAN/Tailnet address, IPv6 bracketed). + +Activating a port row — click, its `1`–`9` digit accelerator, or `Enter` on +the focused row — reproduces `dor ab open ` against the **default** +key/session: it runs `agent-browser open ` on that session and +reuses-or-creates the session's browser surface — the wall-side mirror of the CLI +flow, not the control plane. It is host-gated on `agentBrowserCommand`: absent +(e.g. the web demo host), the rows render as inert labels. + +**Activation reveals its surface.** Unlike `dor ab` (focus-neutral: an agent must +not steal focus from the human), activating a menu row — by mouse or keyboard — +*is* the human asking to see that browser, so every arm of the eager lookup below +ends by selecting the surface — reattaching it first when it is minimized, on the +same terms as clicking its Door chip. Selection only: passthrough stays on the +terminal the menu was opened from, so connecting a port never redirects the +keyboard. This is why a repeat activation on an already-connected port is visible +at all — the session merely re-navigates to the URL it is already on, so the +selection move is the only feedback. +Source of truth: `revealSurface` in `Wall.tsx`, threaded into `useDorControl`. + +**Instant create.** The click is fire-and-forget: the menu closes at once and the +pane appears **before** `agent-browser open` runs (a cold daemon boot is 1–3s). +The eager surface is created **without a `session`** — deliberately: a +session-less `ab-screencast` pane is inert (the controller's `maybeRecoverStalePort` +returns early with no session, so it spawns no CLI and cannot race the daemon +boot), while the panel still shows `Connecting to browser session…` — the +session-less placeholder branch exists for exactly this pane, and deliberately +does *not* fall through to the idle `run dor ab open ` line, which would ask +the user to redo the click they just made. It carries +`key: 'default'` and the target `url` so the browser chrome shows the destination +immediately. Once `open` succeeds, a best-effort `stream status` runs, then the +pane receives `{session, wsPort, binaryPath}` as **one** params refresh — setting +`session` reconciles the controller and connects it (safe now: the daemon is up). +If `open` fails, the pane is still handed the `session` (so its placeholder names +it) and the failure is logged, not shown in the (already closed) menu. + +The eager lookup reuses before it creates: (a) a surface already bound to the +default session, else (b) a still-booting session-less `key: 'default'` pane from a +rapid earlier click (so a double-click doesn't spawn two panes), else (c) a fresh +session-less pane. Accepted edge: a pane persisted mid-boot restores session-less +and stays a `Connecting…` placeholder — kill it, or connect again (the eager +lookup reuses it via arm (b)). + +Source of truth: `lib/src/components/wall/connect-port.ts` +(`connectPortToDefaultBrowser`), the `connectPort` binding in +`use-dor-control.ts` (its `ensureEagerSurface` + `updateSurfaceParams` seams, +shared with `ensureAgentBrowserSurface`), +`lib/src/components/wall/PaneHeaderContextMenu.tsx`. + ## Display Modal And Render Swaps The Display modal is the sole GUI for changing render mode and screencast @@ -257,6 +316,11 @@ the same zero-resource end state with less thrash. The agent-browser daemon/session stays alive throughout and reattaches from persisted params. The controller's client resources are released only at pane kill or a render swap away from the renderer (`disposeAgentBrowserSurfaceController` in `Wall.tsx`). +A controller whose params carry no `session` is deliberately inert — no +connection, no `stream status` query: the instant connect flow +([Pane Context Menu Connect](#pane-context-menu-connect)) depends on that +inertness to keep the eager pane from racing the daemon boot, so the session +must never be derived from `key` here. Hidden-but-mounted panes park too. A Lath leaf is always mounted (no active-tab gating), so a backgrounded window would keep its ~20Hz stream plus per-pulse @@ -540,6 +604,8 @@ Source of truth: `lib/src/lib/platform/types.ts`, - Shell/render swap/lifecycle: `lib/src/components/Wall.tsx`, `lib/src/components/wall/BrowserPanel.tsx`, `lib/src/components/wall/browser-surface.ts`. +- Pane context-menu connect: `lib/src/components/wall/PaneHeaderContextMenu.tsx`, + `lib/src/components/wall/connect-port.ts`, `lib/src/components/wall/port-url.ts`. - Chrome/modal: `SurfacePaneHeader.tsx`, `AgentBrowserScreenModal.tsx`, `agent-browser-screen.ts`, `browser-url.ts`. - Agent-browser renderer: `AgentBrowserPanel.tsx`, diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index e0a82936..054e9aaa 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -433,7 +433,8 @@ Source of truth: `dor/src/commands/open-target.ts` (classification + `:port` sugar + `surface.resolveOpen` call), `dor/src/commands/iframe.ts` / `dor/src/commands/agent-browser.ts` (the two entry points), `dor/src/protocol.ts` (`resolveOpen`), the `surface.resolveOpen` handler in -`lib/src/components/wall/use-dor-control.ts`. +`lib/src/components/wall/use-dor-control.ts`, and the port→URL grouping/selection +in `lib/src/components/wall/port-url.ts` (`listenerUrlsByPort`). ## Agent Workflows diff --git a/docs/specs/layout.md b/docs/specs/layout.md index bc331735..d4158e3a 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -77,13 +77,23 @@ The content area is a tiling layout of panes rendered by Lath (`docs/specs/tilin Each pane has a 30px header that doubles as a drag handle (a `pointerdown` on the header, past a 5px threshold, begins a Lath pane drag; below the threshold the header's own click behavior stands). The header uses `cursor-grab` / `active:cursor-grabbing`, `select-none`, and the shared terminal top radius from `lib/src/components/design.tsx`. Background and foreground use the `--color-header-active-*` / `--color-header-inactive-*` token pairs, which map to VSCode file-tree list colors. -The header label is the `DerivedHeader` returned by `deriveHeader(paneState, visiblePanes)` in `docs/specs/terminal-state.md` — that spec is the single source of truth for the priority chain (user-pinned title, app-sent overrides, current command title, ` ${LAST_TITLE}` for finished panes, plain `` for fresh panes), the disambiguator rule, and which OSC sources contribute. Layout's job is to render the result: the primary label truncates with ellipsis, the secondary label (when present) is shown muted next to it, click renames/pins, right-click opens the diagnostic popup. +The header label is the `DerivedHeader` returned by `deriveHeader(paneState, visiblePanes)` in `docs/specs/terminal-state.md` — that spec is the single source of truth for the priority chain (user-pinned title, app-sent overrides, current command title, ` ${LAST_TITLE}` for finished panes, plain `` for fresh panes), the disambiguator rule, and which OSC sources contribute. Layout's job is to render the result: the primary label truncates with ellipsis, the secondary label (when present) is shown muted next to it, click renames/pins, right-click — or `>` in command mode — opens the header context menu, which leads with the title-candidates table. -The diagnostic popup lists the latest entry per `titleCandidates` channel as defined in `docs/specs/terminal-state.md`. Each row shows the channel, latest candidate text, and timestamp. The popup is diagnostic only; it does not change the title priority rules. +Right-clicking anywhere on the header opens the pane's single **context menu** at the pointer; pressing `>` in command mode opens the same menu for the selected pane, anchored under the header's left edge (the handler finds the header via `data-pane-header-for` and dispatches a synthetic `contextmenu` at that corner, so both paths share one code path; browser surfaces and Doors have no such header, so `>` no-ops there). Only the alert bell owns its own right-click (`stopPropagation`, opening the alert dialog); every other region — including the title span — bubbles to this one menu. It is portaled to `document.body`, viewport-clamped, and dismissed by outside `pointerdown`, `Escape`, `resize`, or capture-phase `scroll` — except scrolls originating inside the menu itself, which must not dismiss it (arrow-key focus moves auto-scroll the overflowing list). + +Content, top to bottom: + +- **Header row** — the current display title, the pane's `surface:N` handle (`resolveSurfaceRef`, muted), and a close button. +- **Title-candidates table** — the latest entry per `titleCandidates` channel as defined in `docs/specs/terminal-state.md`: channel, latest candidate text, and timestamp per row, or a muted `No title candidates` line. Diagnostic only; it does not change the title priority rules. +- **Port rows** — the TCP ports the pane's process tree binds: a spinner while `getOpenPorts` runs (once per open — reopen to rescan), then one `host:port` row per distinct port (digit accelerator chip first, process name muted beside it), or a muted `no listening ports` / `port scan failed` line. + +The menu owns the keyboard while open: it takes DOM focus on mount and restores the previously focused element on close (so a passthrough terminal gets its keys back), and it registers as dialog-keyboard-active so command-mode keys don't fire underneath (`use-wall-keyboard.ts`). `1`–`9` activate the corresponding port row; presses while the scan is still running are dropped, not buffered — the spinner explains why nothing happened. `↑`/`↓` rove focus across the port rows (wrapping), `Enter`/`Space` activate the focused row, `Tab`/`Shift+Tab` cycle every focusable element, and `Escape` closes. + +Activating a port row (click, digit, or `Enter` on the focused row) reproduces `dor ab open ` for that port and closes the menu at once (`docs/specs/dor-browser.md` → Pane Context Menu Connect): the browser surface appears immediately and becomes the selection (reattaching first if it was minimized) — the one command-mode gesture whose side effect moves selection off the pane it targeted — and loading/errors surface in the pane, not the menu. On hosts that cannot open a browser surface the rows are inert labels with no digit chips, and digits do nothing. Only terminal panes have this menu. Source of truth: `PaneHeaderContextMenu.tsx`, `TerminalPaneHeader.tsx`, `handle-pane-shortcuts.ts`. Elements from left to right: -- Derived session label (click to rename/pin, right-click to inspect title candidates, truncates with ellipsis) +- Derived session label (click to rename/pin, right-click opens the header context menu with the title-candidates table, truncates with ellipsis) - Alert bell button (reflects session activity status) - TODO pill (if todo state is set; hidden in minimal tier) - Flexible gap @@ -382,7 +392,8 @@ The refill adopts the replacement (`selectPane`) only when the current selection | `lib/src/components/wall/wall-types.ts` / `wall-context.tsx` | Shared Wall types and React contexts used by Wall, pane headers, panels, overlays, and the baseboard | | `lib/src/components/wall/LathHost.tsx` | The tiling engine's HTML adapter: leaf divs, sashes, the pane/door drag gesture, and imperative animator frame application. Engine internals are mapped in `docs/specs/tiling-engine.md`. | | `lib/src/components/wall/TerminalPanel.tsx` | Pane body wrapper; registers the pane's DOM element (`usePaneChrome`) | -| `lib/src/components/wall/TerminalPaneHeader.tsx` | Pane header with rename, alert/TODO, mouse override, split/zoom/minimize/kill controls | +| `lib/src/components/wall/TerminalPaneHeader.tsx` | Pane header with rename, alert/TODO, mouse override, split/zoom/minimize/kill controls, and the right-click context menu | +| `lib/src/components/wall/PaneHeaderContextMenu.tsx` | Pane-header right-click menu: the `surface:N` handle plus the pane's bound TCP ports; a port click connects it to the default browser (`docs/specs/dor-browser.md`) | | `lib/src/components/wall/WorkspaceSelectionOverlay.tsx` | Pane/door focus ring and marching-ants overlay; re-measures on Lath store commits + animator frames | | `lib/src/components/wall/MarchingAntsRect.tsx` | SVG marching-ants border path and dash sizing | | `lib/src/components/wall/MouseOverrideBanner.tsx` | Temporary mouse override banner shown from the header icon | diff --git a/docs/specs/shortcuts.md b/docs/specs/shortcuts.md index 967a917f..1a4f9f67 100644 --- a/docs/specs/shortcuts.md +++ b/docs/specs/shortcuts.md @@ -29,6 +29,7 @@ In the VS Code extension host, selected workbench chords are mirrored: the termi | `,` | Rename | Enter rename mode for the selected pane's title. | | `a` | Toggle alert | Dismiss or toggle the bell alert for the selected pane. Meaningful only for a terminal Surface — a browser surface has no bell to ring (`docs/specs/glossary.md`). | | `t` | Toggle todo | Toggle the TODO marker on or off for the selected pane's Surface. Works on any Surface — a terminal Session or a browser surface. | +| `>` | Header context menu | Open the selected pane's header context menu — current title + `surface:N`, title candidates, and bound ports with digit-to-connect (mirrors tmux's pane `display-menu` binding). Terminal panes only; no-op on browser surfaces and doors. | ## Navigation (command mode) @@ -65,6 +66,8 @@ On macOS, `Ctrl+C` passes through to the running program (only `⌘C` copies); ` | Prompted character | Confirm kill | Type the character shown in the kill prompt to confirm termination. | | `a` (alert dialog open) | Toggle alert | Same as command-mode `a`. | | `t` (alert dialog open) | Toggle todo | Same as command-mode `t`. | +| `1`–`9` (header context menu open) | Connect port | Open a browser surface on the nth port row. Dropped while the port scan is running; inert on hosts that can't open a browser surface. | +| `↑` / `↓` (header context menu open) | Move row focus | Rove focus across port rows, wrapping; `Enter` or `Space` activates the focused row. | ## Implementation references diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index 5835086f..95e6f6e6 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -74,6 +74,16 @@ and reads the bytes in Rust so images never ride the JSON-lines pipe shared with PTY traffic (`docs/specs/dor-browser.md`). Request/response commands block on the sidecar's reply with a timeout; `OPEN_PORT_TIMEOUT_MS` in `lib.rs` mirrors the constant in `lib/src/lib/platform/types.ts` and the two must stay in sync. + +**Blocking commands must be `#[tauri::command(async)]`.** `request_from_sidecar` +and `request_from_sidecar_timeout` block the calling thread on a `recv_timeout`, +and Tauri runs a *plain* sync command on the main thread — where that block stops +the webview from painting for the whole round trip (up to `AGENT_BROWSER_TIMEOUT` += 30s for a hung agent-browser; a cold `agent-browser open` froze the UI ~3s, +long enough that a pane created instantly before it looked like it never +appeared). `(async)` runs the same blocking body on a runtime worker instead. The +three clipboard readers are knowingly still sync: their Windows branches call the +Win32 clipboard directly rather than the sidecar. `pty_graceful_kill_all` (`TauriAdapter.gracefulKillAllPtys`) SIGTERMs every live PTY and awaits the sidecar's `gracefulKillDone` (echoing the request's `requestId`; bounded at `timeout + 1.5s`). `gracefulKillDone` fires early once diff --git a/docs/specs/terminal-escapes.md b/docs/specs/terminal-escapes.md index 7a62a30f..0eea3400 100644 --- a/docs/specs/terminal-escapes.md +++ b/docs/specs/terminal-escapes.md @@ -63,7 +63,7 @@ Unknown non-iTerm2 OSC families pass through to xterm.js unchanged so xterm.js c (`BEL` is not itself an OSC; it has a row here because a standalone `BEL` is parsed and stripped at the same PTY data boundary as the OSCs.) -Some sequences are dual-purpose. The notification rows for `OSC 9 ; ST`, `OSC 99` (`p=title`/`p=body`), and `OSC 777 ; notify` also feed the title-candidate channel in `terminal-state.md` — see its [Title candidate diagnostics](terminal-state.md#supported-osc-inputs) table. Only the OSC 9 *message* form can become a header/door label; OSC 99 and OSC 777 candidates are stored for the diagnostic popup only. The OSC 9 *progress* form (`OSC 9 ; 4`) carries no text and never contributes a title candidate. +Some sequences are dual-purpose. The notification rows for `OSC 9 ; ST`, `OSC 99` (`p=title`/`p=body`), and `OSC 777 ; notify` also feed the title-candidate channel in `terminal-state.md` — see its [Title candidate diagnostics](terminal-state.md#supported-osc-inputs) table. Only the OSC 9 *message* form can become a header/door label; OSC 99 and OSC 777 candidates are stored only for the diagnostic title-candidates table in the header context menu. The OSC 9 *progress* form (`OSC 9 ; 4`) carries no text and never contributes a title candidate. #### OSC color queries on Windows require the bundled ConPTY diff --git a/docs/specs/terminal-state.md b/docs/specs/terminal-state.md index e692435e..81a8642d 100644 --- a/docs/specs/terminal-state.md +++ b/docs/specs/terminal-state.md @@ -238,7 +238,7 @@ Header priority — first match wins: When the finished command exited non-zero, a trailing fail glyph (`✗`) is appended — ` ${LAST_TITLE} ✗` — and `lastCommandFailed` is set on the result. "Failed" requires a real non-zero `exitCode`: the keystroke fallback never records one, so it shows no glyph either way. The glyph rides in `primary` so plain-text title consumers (OS/tab titles) carry it, while the pane header reads `lastCommandFailed` to color it red without re-parsing the string. 4. Otherwise (no running command and no last command): ``. -Rich notification titles from `OSC 99` and `OSC 777` are stored in `titleCandidates` for the diagnostic popup but never become header/door labels. Older shell titles (terminal titles emitted before the current command started, or after the last command finished) remain fallback-only and do not replace `` or pollute `LAST_TITLE`. +Rich notification titles from `OSC 99` and `OSC 777` are stored in `titleCandidates` for the diagnostic title-candidates table (in the header context menu) but never become header/door labels. Older shell titles (terminal titles emitted before the current command started, or after the last command finished) remain fallback-only and do not replace `` or pollute `LAST_TITLE`. ` ${LAST_TITLE}` keeps the just-finished context visible so the user can see at a glance which program just exited. The header surfaces failure minimally — the trailing `✗` glyph for a non-zero exit, nothing more; output and TODO notification are still surfaced via the alert/TODO machinery (`docs/specs/alert.md`). ` ${LAST_TITLE}` persists across subsequent prompt/editing transitions until a new `commandStart` replaces it; only a fresh pane (no `lastCommand` at all) shows plain ``. diff --git a/dor-lib-common/package.json b/dor-lib-common/package.json index 4848b9d6..2515c1c2 100644 --- a/dor-lib-common/package.json +++ b/dor-lib-common/package.json @@ -8,6 +8,10 @@ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" + }, + "./agent-browser": { + "types": "./dist/agent-browser.d.ts", + "default": "./dist/agent-browser.js" } }, "scripts": { diff --git a/lib/.storybook/main.ts b/lib/.storybook/main.ts index c460fe9e..5f42e176 100644 --- a/lib/.storybook/main.ts +++ b/lib/.storybook/main.ts @@ -30,6 +30,11 @@ const config: StorybookConfig = { // at a `dist` the Storybook/Chromatic job never builds, so alias the bare // specifier to source too. 'server-lib-common': path.resolve(here, '..', '..', 'server-lib-common', 'src'), + // And `Wall` → `useDorControl` → `connect-port` imports + // `dor-lib-common/agent-browser`, whose `exports` point at the same kind of + // unbuilt `dist`. The directory alias covers the subpath and the bare + // specifier both. + 'dor-lib-common': path.resolve(here, '..', '..', 'dor-lib-common', 'src'), }; return config; }, diff --git a/lib/src/components/TodoAlertDialog.tsx b/lib/src/components/TodoAlertDialog.tsx index 0e3b516a..178b44f6 100644 --- a/lib/src/components/TodoAlertDialog.tsx +++ b/lib/src/components/TodoAlertDialog.tsx @@ -2,6 +2,7 @@ import { useLayoutEffect, useEffect, useRef, useState, useSyncExternalStore, typ import { createPortal } from 'react-dom'; import { XIcon } from '@phosphor-icons/react'; import { Shortcut } from './design'; +import { usePopoverFocusTrap } from './use-popover-focus-trap'; import { clampOverlayPosition, pointInConvexPolygon } from '../lib/ui-geometry'; import { clearSessionTodo, @@ -16,58 +17,6 @@ import { toggleSessionTodo, } from '../lib/terminal-registry'; -/** - * Manages focus trapping, Escape-to-close, and click-outside-to-close for - * portal-based popovers. Scopes keyboard handling to the popover's DOM subtree - * so Tab/Escape don't leak to the rest of the app. - */ -function usePopoverFocusTrap( - ref: React.RefObject, - onClose: () => void, -) { - useEffect(() => { - const el = ref.current; - if (!el) return; - - const handleMouseDown = (e: MouseEvent) => { - if (!el.contains(e.target as Node)) onClose(); - }; - - const handleKeyDown = (e: KeyboardEvent) => { - // Only handle keys when focus is inside the popover - if (!el.contains(document.activeElement)) return; - - if (e.key === 'Escape') { - e.preventDefault(); - e.stopPropagation(); - onClose(); - return; - } - if (e.key !== 'Tab') return; - - const focusables = Array.from( - el.querySelectorAll('button:not([disabled]), [tabindex]:not([tabindex="-1"])'), - ); - if (focusables.length === 0) return; - - const currentIndex = focusables.findIndex((f) => f === document.activeElement); - const nextIndex = currentIndex === -1 - ? 0 - : (currentIndex + (e.shiftKey ? -1 : 1) + focusables.length) % focusables.length; - - e.preventDefault(); - focusables[nextIndex]?.focus(); - }; - - window.addEventListener('mousedown', handleMouseDown); - window.addEventListener('keydown', handleKeyDown, true); - return () => { - window.removeEventListener('mousedown', handleMouseDown); - window.removeEventListener('keydown', handleKeyDown, true); - }; - }, [ref, onClose]); -} - export function TodoAlertDialog({ triggerRect, sessionId, diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index c7b57121..a8ef05e7 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -783,6 +783,22 @@ export function Wall({ const handleReattachRef = useRef(handleReattach); handleReattachRef.current = handleReattach; + /** Put the selection on a surface so the user can see it — the human half of + * `connectPort` (clicking a port row is a request to look at that browser). + * A visible pane is selected in place; a minimized one reattaches first, on + * the same terms as clicking its Door chip. Distinct from `onFocusPane`, + * which also enters passthrough on an already-visible pane: connecting a port + * should highlight the browser, not steal the keyboard from the terminal the + * user right-clicked. */ + const revealSurface = useCallback((id: string) => { + if (nav.hasPane(id)) { + selectPane(id); + return; + } + const door = doorsRef.current.find((item) => item.id === id); + if (door) handleReattachRef.current(door); + }, [nav, selectPane]); + // The Surfaces of the current Workspace. `buildDorSurfaces` is the visible-pane // projection used for geometry/placement; `buildDorSurfaceList` additionally // includes minimized (doored) Surfaces for `dor list` and direct operations. @@ -1119,7 +1135,7 @@ export function Wall({ }, [generatePaneId, surfaceRefForId, forgetSurfaceRef, selectPane, enterTerminalMode, showShellSpawnNotice, lath, nav]); // --- dor control plane (the `dor` CLI's webview handler) --- - useDorControl({ + const { connectPort } = useDorControl({ lath, nav, doorsRef, @@ -1130,6 +1146,7 @@ export function Wall({ createSplitSurface, createContentSurface, killPaneImmediately, + revealSurface, lastAgentBrowserBinaryPathRef, }); @@ -1313,7 +1330,10 @@ export function Wall({ title: hostPathDisplay(url, true), }); }, - }), [addSplitPanel, minimizePane, enterTerminalMode, exitTerminalMode, killPaneImmediately, replaceSurface, buildDorSurfaces, createContentSurface, lath, nav]); + resolveSurfaceRef: surfaceRefForId, + // The pane context menu's "connect a port" action: act like `dor ab open`. + onConnectPort: connectPort, + }), [addSplitPanel, minimizePane, enterTerminalMode, exitTerminalMode, killPaneImmediately, replaceSurface, buildDorSurfaces, createContentSurface, surfaceRefForId, connectPort, lath, nav]); const wallActionsRef = useRef(wallActions); wallActionsRef.current = wallActions; diff --git a/lib/src/components/design.tsx b/lib/src/components/design.tsx index 6e611872..ee559d46 100644 --- a/lib/src/components/design.tsx +++ b/lib/src/components/design.tsx @@ -3,6 +3,7 @@ import { tv, type VariantProps } from 'tailwind-variants'; import { XIcon } from '@phosphor-icons/react'; import { forwardRef, useEffect, useLayoutEffect, useRef, useState } from 'react'; import type { ButtonHTMLAttributes, CSSProperties, HTMLAttributes, ReactNode, RefObject } from 'react'; +import { stepFocus } from './focus-step'; // App-wide type scale, color strategy, and chrome conventions: see // docs/specs/theme.md and AGENTS.md. @@ -28,6 +29,11 @@ export const DOOR_SELECTION_BORDER_RADIUS = `${TERMINAL_BORDER_RADIUS_REM}rem ${ // tiny label legible. Shared so both pill sites stay in sync. export const TODO_PILL_TRACKING_CLASS = 'tracking-[0.08em]'; +// Chrome for small anchored popovers (title candidates, TODO preview, pane +// context menu, rename warning). Text size and padding vary per popover and +// stay at the call site; the surface recipe is shared so they can't drift. +export const POPUP_SURFACE_CLASS = 'z-[1000] rounded border border-border bg-surface-raised font-mono text-foreground shadow-md'; + export function PopupButtonRow({ className, ...props @@ -380,16 +386,11 @@ function useModalFocusTrap(MODAL_FOCUSABLE_SELECTOR)); - if (focusables.length === 0) return; - - const currentIndex = focusables.findIndex((item) => item === document.activeElement); - const nextIndex = currentIndex === -1 - ? 0 - : (currentIndex + (event.shiftKey ? -1 : 1) + focusables.length) % focusables.length; - event.preventDefault(); - focusables[nextIndex]?.focus(); + stepFocus( + Array.from(modal.querySelectorAll(MODAL_FOCUSABLE_SELECTOR)), + event.shiftKey ? -1 : 1, + ); }; window.addEventListener('keydown', handleKeyDown, true); diff --git a/lib/src/components/focus-step.ts b/lib/src/components/focus-step.ts new file mode 100644 index 00000000..2604ff29 --- /dev/null +++ b/lib/src/components/focus-step.ts @@ -0,0 +1,15 @@ +/** + * Move DOM focus within an ordered candidate list: step from the currently + * focused entry by `delta`, wrapping at both ends. When focus is outside the + * list, seed at the first entry for a forward step and the last for a backward + * one. Shared by the popover/modal focus traps (Tab cycling) and the pane + * header context menu (arrow roving) so the wrap arithmetic exists once. + */ +export function stepFocus(items: HTMLElement[], delta: number): void { + if (items.length === 0) return; + const currentIndex = items.findIndex((item) => item === document.activeElement); + const nextIndex = currentIndex === -1 + ? (delta > 0 ? 0 : items.length - 1) + : (currentIndex + delta + items.length) % items.length; + items[nextIndex]?.focus(); +} diff --git a/lib/src/components/use-popover-focus-trap.ts b/lib/src/components/use-popover-focus-trap.ts new file mode 100644 index 00000000..4903bf32 --- /dev/null +++ b/lib/src/components/use-popover-focus-trap.ts @@ -0,0 +1,50 @@ +import { useEffect } from 'react'; +import { stepFocus } from './focus-step'; + +/** What Tab reaches inside a popover: real buttons plus explicit tab stops. */ +export const POPOVER_FOCUSABLE_SELECTOR = 'button:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +/** + * Manages focus trapping, Escape-to-close, and click-outside-to-close for + * portal-based popovers. Scopes keyboard handling to the popover's DOM subtree + * so Tab/Escape don't leak to the rest of the app. + */ +export function usePopoverFocusTrap( + ref: React.RefObject, + onClose: () => void, +) { + useEffect(() => { + const el = ref.current; + if (!el) return; + + const handleMouseDown = (e: MouseEvent) => { + if (!el.contains(e.target as Node)) onClose(); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + // Only handle keys when focus is inside the popover + if (!el.contains(document.activeElement)) return; + + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + onClose(); + return; + } + if (e.key !== 'Tab') return; + + e.preventDefault(); + stepFocus( + Array.from(el.querySelectorAll(POPOVER_FOCUSABLE_SELECTOR)), + e.shiftKey ? -1 : 1, + ); + }; + + window.addEventListener('mousedown', handleMouseDown); + window.addEventListener('keydown', handleKeyDown, true); + return () => { + window.removeEventListener('mousedown', handleMouseDown); + window.removeEventListener('keydown', handleKeyDown, true); + }; + }, [ref, onClose]); +} diff --git a/lib/src/components/wall/AgentBrowserPanel.test.tsx b/lib/src/components/wall/AgentBrowserPanel.test.tsx index 86c10301..030c29a4 100644 --- a/lib/src/components/wall/AgentBrowserPanel.test.tsx +++ b/lib/src/components/wall/AgentBrowserPanel.test.tsx @@ -11,6 +11,7 @@ import { AgentBrowserPanel, HIDDEN_PARK_DELAY_MS } from './AgentBrowserPanel'; import { getAgentBrowserScreenController } from './agent-browser-screen'; import { disposeAllAgentBrowserSurfaceControllers } from './agent-browser-surface-controller'; import { ModeContext, PaneWriteContext, SelectedIdContext, WallActionsContext, type PaneWriteActions, type WallActions } from './wall-context'; +import { stubWallActions as stubActions } from './wall-test-utils'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -31,24 +32,6 @@ class ResizeObserverMock { disconnect() {} } -function stubActions(overrides: Partial = {}): WallActions { - return { - onKill: vi.fn(), - onMinimize: vi.fn(), - onAlertButton: vi.fn(() => 'noop'), - onToggleTodo: vi.fn(), - onSplitH: vi.fn(), - onSplitV: vi.fn(), - onZoom: vi.fn(), - onClickPanel: vi.fn(), - onFocusPane: vi.fn(), - onStartRename: vi.fn(), - onFinishRename: vi.fn(() => ({ accepted: true })), - onCancelRename: vi.fn(), - onSwapRenderMode: vi.fn(), - ...overrides, - }; -} function paneProps(id: string, params: TestPanelParams = DEFAULT_PARAMS): PaneProps { return { id, title: 'Browser', params }; diff --git a/lib/src/components/wall/AgentBrowserPanel.tsx b/lib/src/components/wall/AgentBrowserPanel.tsx index a70c89e9..dc9e5e0c 100644 --- a/lib/src/components/wall/AgentBrowserPanel.tsx +++ b/lib/src/components/wall/AgentBrowserPanel.tsx @@ -317,7 +317,12 @@ export function AgentBrowserPanel({ id, params: rawParams, renderMode: renderMod // --- placeholder state (derived from the snapshot) --- const placeholder = (() => { - if (!streamPort) return `Waiting for browser session ${session ?? ''} — run dor ab open `; + // Session-less: the pane context menu's eager connect pane, on screen before + // the daemon boots (docs/specs/dor-browser.md → Pane Context Menu Connect). + // It is mid-boot, not idle — telling the user to run `dor ab open` here would + // ask them to redo the click they just made. + if (!session) return 'Connecting to browser session…'; + if (!streamPort) return `Waiting for browser session ${session} — run dor ab open `; if (connectionLost || status?.connected === false) { return `Browser session ${session ?? ''} ended — run dor ab open to restart it, or close this surface.`; } diff --git a/lib/src/components/wall/IframePanel.test.tsx b/lib/src/components/wall/IframePanel.test.tsx index 0cde700b..cd13be04 100644 --- a/lib/src/components/wall/IframePanel.test.tsx +++ b/lib/src/components/wall/IframePanel.test.tsx @@ -10,28 +10,10 @@ import type { PaneProps } from './pane-props'; import { IframePanel } from './IframePanel'; import { getAgentBrowserScreenController } from './agent-browser-screen'; import { PaneWriteContext, WallActionsContext, type PaneWriteActions, type WallActions } from './wall-context'; +import { stubWallActions as stubActions } from './wall-test-utils'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; -function stubActions(overrides: Partial = {}): WallActions { - return { - onKill: vi.fn(), - onMinimize: vi.fn(), - onAlertButton: vi.fn(() => 'noop'), - onToggleTodo: vi.fn(), - onSplitH: vi.fn(), - onSplitV: vi.fn(), - onZoom: vi.fn(), - onClickPanel: vi.fn(), - onFocusPane: vi.fn(), - onStartRename: vi.fn(), - onFinishRename: vi.fn(() => ({ accepted: true })), - onCancelRename: vi.fn(), - onSwapRenderMode: vi.fn(), - ...overrides, - }; -} - function paneProps(id: string): PaneProps { return { id, title: 'Raw iframe', params: { url: 'http://example.test/app' } }; } diff --git a/lib/src/components/wall/IllegalRenameWarning.tsx b/lib/src/components/wall/IllegalRenameWarning.tsx index b42f6d0d..ab07b6f1 100644 --- a/lib/src/components/wall/IllegalRenameWarning.tsx +++ b/lib/src/components/wall/IllegalRenameWarning.tsx @@ -1,6 +1,8 @@ import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react'; import { createPortal } from 'react-dom'; +import { POPUP_SURFACE_CLASS } from '../design'; import type { SetTerminalUserTitleResult } from '../../lib/terminal-registry'; +import { useDismissOverlay } from './use-dismiss-overlay'; export type RenameRejection = Extract['reason']; @@ -36,23 +38,11 @@ export function IllegalRenameWarning({ anchorRect, reason, attemptedValue, onClo }); }, [anchorRect]); + useDismissOverlay(onClose, ref); + useEffect(() => { - const dismiss = () => onClose(); - const onKey = (event: KeyboardEvent) => { - if (event.key === 'Escape') dismiss(); - }; - const timeout = window.setTimeout(dismiss, AUTO_DISMISS_MS); - window.addEventListener('pointerdown', dismiss); - window.addEventListener('resize', dismiss); - window.addEventListener('scroll', dismiss, true); - window.addEventListener('keydown', onKey); - return () => { - window.clearTimeout(timeout); - window.removeEventListener('pointerdown', dismiss); - window.removeEventListener('resize', dismiss); - window.removeEventListener('scroll', dismiss, true); - window.removeEventListener('keydown', onKey); - }; + const timeout = window.setTimeout(onClose, AUTO_DISMISS_MS); + return () => window.clearTimeout(timeout); }, [onClose]); return createPortal( @@ -61,7 +51,7 @@ export function IllegalRenameWarning({ anchorRect, reason, attemptedValue, onClo role="alert" data-testid="illegal-rename-warning" aria-label={`Illegal name: ${describeReason(reason, attemptedValue)}`} - className="z-[1000] max-w-72 rounded border border-border bg-surface-raised px-2.5 py-1.5 font-mono text-xs leading-snug text-foreground shadow-md" + className={`${POPUP_SURFACE_CLASS} max-w-72 px-2.5 py-1.5 text-xs leading-snug`} style={style} onPointerDown={(e) => e.stopPropagation()} > diff --git a/lib/src/components/wall/PaneHeaderContextMenu.test.tsx b/lib/src/components/wall/PaneHeaderContextMenu.test.tsx new file mode 100644 index 00000000..b23b8428 --- /dev/null +++ b/lib/src/components/wall/PaneHeaderContextMenu.test.tsx @@ -0,0 +1,360 @@ +/** + * @vitest-environment jsdom + */ +import { act, StrictMode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { PaneProps } from './pane-props'; +import { TerminalPaneHeader } from './TerminalPaneHeader'; +import { DialogKeyboardContext, WallActionsContext, type WallActions } from './wall-context'; +import { ensureResizeObserver, stubWallActions as stubActions } from './wall-test-utils'; +import { FakePtyAdapter } from '../../lib/platform/fake-adapter'; +import { setPlatform } from '../../lib/platform'; +import type { OpenPort, PlatformAdapter } from '../../lib/platform/types'; +import { removeTerminalPaneState, resetTerminalPaneState } from '../../lib/terminal-registry'; +import type { TerminalTitle, TerminalTitleSource } from '../../lib/terminal-state'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +function headerProps(id: string, title: string): PaneProps { + return { id, title, params: undefined }; +} + +function candidate(title: string, source: TerminalTitleSource, updatedAt: number): TerminalTitle { + return { title, source, updatedAt }; +} + +function loopbackPort(port: number, processName?: string): OpenPort { + return { protocol: 'tcp', family: 'IPv4', address: '127.0.0.1', port, pid: 100, processName }; +} + +/** Make the running host able to open a browser surface, so port rows are buttons. */ +function enableConnect(platform: FakePtyAdapter): void { + (platform as PlatformAdapter).agentBrowserCommand = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); +} + +let container: HTMLDivElement; +let root: Root; +let platform: FakePtyAdapter; +let keyboardActiveSpy: ReturnType; + +beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + platform = new FakePtyAdapter(); + setPlatform(platform); + ensureResizeObserver(); + keyboardActiveSpy = vi.fn(); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + platform.reset(); + removeTerminalPaneState('term-1'); +}); + +function renderHeader(props: PaneProps, actions: WallActions) { + act(() => { + root.render( + + + + + + + , + ); + }); +} + +function fireContextMenu() { + const header = container.firstElementChild as HTMLElement; + header.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 40, clientY: 12 })); +} + +function menuFor(id: string): HTMLElement | null { + return document.body.querySelector(`[data-pane-context-menu-for="${id}"]`); +} + +describe('PaneHeaderContextMenu — pane header right-click', () => { + it('opens on header contextmenu showing the surface ref from resolveSurfaceRef', async () => { + const resolveSurfaceRef = vi.fn(() => 'surface:3'); + renderHeader(headerProps('term-1', 'title'), stubActions({ resolveSurfaceRef })); + + await act(async () => { fireContextMenu(); }); + + const menu = menuFor('term-1'); + expect(menu).not.toBeNull(); + expect(menu?.textContent).toContain('surface:3'); + expect(resolveSurfaceRef).toHaveBeenCalledWith('term-1'); + }); + + it('shows a spinner while the scan is pending, then port entries after it resolves', async () => { + platform.spawnPty('term-1'); + let resolvePorts!: (ports: OpenPort[]) => void; + platform.getOpenPorts = () => new Promise((res) => { resolvePorts = res; }); + renderHeader(headerProps('term-1', 't'), stubActions()); + + act(() => { fireContextMenu(); }); + const menu = menuFor('term-1'); + expect(menu?.querySelector('.animate-spin')).not.toBeNull(); + expect(menu?.textContent).toContain('scanning ports'); + + await act(async () => { resolvePorts([loopbackPort(5173, 'node')]); }); + expect(menu?.querySelector('.animate-spin')).toBeNull(); + expect(menu?.textContent).toContain('localhost:5173'); + expect(menu?.textContent).toContain('node'); + }); + + it('fires the connect and closes the menu immediately (loading feedback lives in the pane)', async () => { + enableConnect(platform); + platform.spawnPty('term-1'); + platform.setOpenPorts('term-1', [loopbackPort(5173, 'node')]); + const onConnectPort = vi.fn(); + renderHeader(headerProps('term-1', 't'), stubActions({ onConnectPort })); + + await act(async () => { fireContextMenu(); }); + const button = menuFor('term-1')?.querySelector('button[data-port-entry="5173"]') as HTMLButtonElement; + expect(button).not.toBeNull(); + + await act(async () => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); + + expect(onConnectPort).toHaveBeenCalledWith('term-1', 'http://localhost:5173/'); + expect(menuFor('term-1')).toBeNull(); + }); + + it('shows an empty state when the scan finds no listening ports', async () => { + platform.spawnPty('term-1'); + renderHeader(headerProps('term-1', 't'), stubActions()); + + await act(async () => { fireContextMenu(); }); + expect(menuFor('term-1')?.textContent).toContain('no listening ports'); + }); + + it('renders port rows as inert labels (no buttons) when the host cannot connect', async () => { + platform.spawnPty('term-1'); + platform.setOpenPorts('term-1', [loopbackPort(5173, 'node')]); + renderHeader(headerProps('term-1', 't'), stubActions()); + + await act(async () => { fireContextMenu(); }); + const menu = menuFor('term-1'); + expect(menu?.querySelector('button[data-port-entry="5173"]')).toBeNull(); + expect(menu?.querySelector('[data-port-entry="5173"]')).not.toBeNull(); + expect(menu?.textContent).toContain('localhost:5173'); + }); + + it('dismisses on Escape and on an outside pointerdown', async () => { + renderHeader(headerProps('term-1', 't'), stubActions()); + + await act(async () => { fireContextMenu(); }); + expect(menuFor('term-1')).not.toBeNull(); + act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); }); + expect(menuFor('term-1')).toBeNull(); + + await act(async () => { fireContextMenu(); }); + expect(menuFor('term-1')).not.toBeNull(); + act(() => { window.dispatchEvent(new Event('pointerdown')); }); + expect(menuFor('term-1')).toBeNull(); + }); + + it('opens the one context menu when a title-span right-click bubbles up', async () => { + renderHeader(headerProps('term-1', 'title'), stubActions()); + + const titleSpan = container.querySelector('[data-pane-title-for="term-1"]') as HTMLElement; + expect(titleSpan).not.toBeNull(); + await act(async () => { + titleSpan.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 40, clientY: 12 })); + }); + + expect(menuFor('term-1')).not.toBeNull(); + }); + + it('renders the header row (display title + surface ref) and the title-candidates table inline', async () => { + const resolveSurfaceRef = vi.fn(() => 'surface:7'); + resetTerminalPaneState('term-1', { + title: candidate('Pinned API', 'user', 6_000), + titleCandidates: { + user: candidate('Pinned API', 'user', 6_000), + osc0: candidate('dev server', 'osc0', 1_000), + osc99: candidate('Codex waiting', 'osc99', 4_000), + }, + }); + renderHeader(headerProps('term-1', 't'), stubActions({ resolveSurfaceRef })); + + await act(async () => { fireContextMenu(); }); + + const menu = menuFor('term-1'); + expect(menu).not.toBeNull(); + // Header row: current display title + surface ref. + expect(menu?.textContent).toContain('Pinned API'); + expect(menu?.textContent).toContain('surface:7'); + // Candidates table: one row per channel (source label + candidate title). + expect(menu?.textContent).toContain('OSC 0'); + expect(menu?.textContent).toContain('dev server'); + expect(menu?.textContent).toContain('OSC 99'); + expect(menu?.textContent).toContain('Codex waiting'); + expect(menu?.textContent).not.toContain('No title candidates'); + }); + + it('shows the empty title-candidates line when the pane has no candidates', async () => { + renderHeader(headerProps('term-1', 't'), stubActions()); + + await act(async () => { fireContextMenu(); }); + expect(menuFor('term-1')?.textContent).toContain('No title candidates'); + }); + + it('closes the menu when the header close button is clicked', async () => { + renderHeader(headerProps('term-1', 't'), stubActions()); + + await act(async () => { fireContextMenu(); }); + const closeButton = menuFor('term-1')?.querySelector('button[aria-label="Close menu"]') as HTMLButtonElement; + expect(closeButton).not.toBeNull(); + + await act(async () => { closeButton.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); + expect(menuFor('term-1')).toBeNull(); + }); +}); + +const DEFAULT_KB_PORTS: OpenPort[] = [loopbackPort(3000, 'alpha'), loopbackPort(5173, 'beta')]; + +function keydown(key: string, init: KeyboardEventInit = {}): KeyboardEvent { + return new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }); +} + +/** Enable connect, spawn a pty with `ports`, render, and open the menu. */ +async function openConnectableMenu( + onConnectPort: ReturnType = vi.fn(), + ports: OpenPort[] = DEFAULT_KB_PORTS, +): Promise> { + enableConnect(platform); + platform.spawnPty('term-1'); + platform.setOpenPorts('term-1', ports); + renderHeader(headerProps('term-1', 't'), stubActions({ onConnectPort })); + await act(async () => { fireContextMenu(); }); + return onConnectPort; +} + +describe('PaneHeaderContextMenu — keyboard access', () => { + it('takes DOM focus on the menu container when it opens', async () => { + await openConnectableMenu(); + expect(document.activeElement).toBe(menuFor('term-1')); + }); + + it('reports dialog-keyboard-active while open and inactive once closed', async () => { + await openConnectableMenu(); + expect(keyboardActiveSpy).toHaveBeenLastCalledWith(true); + + act(() => { window.dispatchEvent(keydown('Escape')); }); + expect(menuFor('term-1')).toBeNull(); + expect(keyboardActiveSpy).toHaveBeenLastCalledWith(false); + }); + + it('renders digit chips and connects the nth port row when its digit is pressed, then closes', async () => { + const onConnectPort = vi.fn(); + await openConnectableMenu(onConnectPort); + // First 9 connectable rows carry a digit accelerator chip. + expect(menuFor('term-1')?.textContent).toContain('[1]'); + expect(menuFor('term-1')?.textContent).toContain('[2]'); + + act(() => { window.dispatchEvent(keydown('2')); }); + // Ports sort ascending: 3000 → [1], 5173 → [2]. + expect(onConnectPort).toHaveBeenCalledWith('term-1', 'http://localhost:5173/'); + expect(menuFor('term-1')).toBeNull(); + }); + + it('drops a digit press while the port scan is still running', async () => { + enableConnect(platform); + platform.spawnPty('term-1'); + platform.getOpenPorts = () => new Promise(() => {}); // never resolves + const onConnectPort = vi.fn(); + renderHeader(headerProps('term-1', 't'), stubActions({ onConnectPort })); + + act(() => { fireContextMenu(); }); + expect(menuFor('term-1')?.textContent).toContain('scanning ports'); + + act(() => { window.dispatchEvent(keydown('1')); }); + expect(onConnectPort).not.toHaveBeenCalled(); + expect(menuFor('term-1')).not.toBeNull(); + }); + + it('ignores an out-of-range digit', async () => { + const onConnectPort = vi.fn(); + await openConnectableMenu(onConnectPort); + + act(() => { window.dispatchEvent(keydown('5')); }); + expect(onConnectPort).not.toHaveBeenCalled(); + expect(menuFor('term-1')).not.toBeNull(); + }); + + it('renders no digit chips and ignores digits on an inert host', async () => { + platform.spawnPty('term-1'); + platform.setOpenPorts('term-1', DEFAULT_KB_PORTS); + const onConnectPort = vi.fn(); + renderHeader(headerProps('term-1', 't'), stubActions({ onConnectPort })); + + await act(async () => { fireContextMenu(); }); + expect(menuFor('term-1')?.textContent).not.toContain('[1]'); + + act(() => { window.dispatchEvent(keydown('1')); }); + expect(onConnectPort).not.toHaveBeenCalled(); + expect(menuFor('term-1')).not.toBeNull(); + }); + + it('roves focus across port rows with the arrow keys, wrapping at the ends', async () => { + await openConnectableMenu(); + const menu = menuFor('term-1')!; + const rows = menu.querySelectorAll('button[role="menuitem"]'); + expect(rows.length).toBe(2); + + act(() => { window.dispatchEvent(keydown('ArrowDown')); }); + expect(document.activeElement).toBe(rows[0]); + act(() => { window.dispatchEvent(keydown('ArrowDown')); }); + expect(document.activeElement).toBe(rows[1]); + act(() => { window.dispatchEvent(keydown('ArrowDown')); }); + expect(document.activeElement).toBe(rows[0]); // wraps past the last + act(() => { window.dispatchEvent(keydown('ArrowUp')); }); + expect(document.activeElement).toBe(rows[1]); // wraps before the first + }); + + it('cycles focus through every focusable with Tab, including the close button', async () => { + await openConnectableMenu(); + const menu = menuFor('term-1')!; + const closeBtn = menu.querySelector('button[aria-label="Close menu"]')!; + const rows = Array.from(menu.querySelectorAll('button[role="menuitem"]')); + const order = [closeBtn, rows[0], rows[1]]; + + // From the container, Tab lands on the first focusable and wraps through all. + for (const expected of [...order, order[0]]) { + act(() => { window.dispatchEvent(keydown('Tab')); }); + expect(document.activeElement).toBe(expected); + } + }); + + it('restores focus to the previously focused element when it closes', async () => { + const outside = document.createElement('button'); + document.body.appendChild(outside); + outside.focus(); + expect(document.activeElement).toBe(outside); + + await openConnectableMenu(); + expect(document.activeElement).toBe(menuFor('term-1')); + + act(() => { window.dispatchEvent(keydown('Escape')); }); + expect(menuFor('term-1')).toBeNull(); + expect(document.activeElement).toBe(outside); + outside.remove(); + }); + + it('survives a scroll originating inside the menu but dismisses on an outside scroll', async () => { + await openConnectableMenu(); + const menu = menuFor('term-1')!; + + act(() => { menu.dispatchEvent(new Event('scroll', { bubbles: false })); }); + expect(menuFor('term-1')).not.toBeNull(); + + act(() => { window.dispatchEvent(new Event('scroll')); }); + expect(menuFor('term-1')).toBeNull(); + }); +}); diff --git a/lib/src/components/wall/PaneHeaderContextMenu.tsx b/lib/src/components/wall/PaneHeaderContextMenu.tsx new file mode 100644 index 00000000..597891c3 --- /dev/null +++ b/lib/src/components/wall/PaneHeaderContextMenu.tsx @@ -0,0 +1,245 @@ +import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react'; +import { createPortal } from 'react-dom'; +import { CircleNotchIcon, XIcon } from '@phosphor-icons/react'; +import { POPUP_SURFACE_CLASS, Shortcut } from '../design'; +import { clampOverlayPosition } from '../../lib/ui-geometry'; +import { getPlatform } from '../../lib/platform'; +import type { OpenPort } from '../../lib/platform/types'; +import { titleSourceLabel, type TerminalTitle } from '../../lib/terminal-state'; +import { stepFocus } from '../focus-step'; +import { POPOVER_FOCUSABLE_SELECTOR } from '../use-popover-focus-trap'; +import { listenerUrlsByPort, type PortUrlEntry } from './port-url'; +import { useDismissOverlay } from './use-dismiss-overlay'; +import { WallActionsContext } from './wall-context'; + +type ScanState = + | { status: 'scanning' } + | { status: 'loaded'; entries: PortUrlEntry[] } + | { status: 'failed' }; + +// One recipe for the interactive port-entry rows. +const MENU_ROW_CLASS = 'flex w-full items-baseline gap-2 px-2.5 py-1 text-left hover:bg-foreground/10'; + +/** + * The right-click menu on a terminal pane header (`docs/specs/layout.md` → Pane + * header): a header row with the current display title, the pane's `surface:N` + * handle, and a close button; then the diagnostic title-candidates table (latest + * entry per channel); then the TCP ports the pane's process tree binds. Clicking a + * port fires `dor ab open ` via `WallActions.onConnectPort` + * (`docs/specs/dor-browser.md` → Pane Context Menu Connect) and closes the menu + * immediately — the new pane's own "Connecting…" placeholder is the loading + * feedback, and a failure is logged, not shown here. Port rows are only clickable + * when the host can run agent-browser; otherwise the list is an inert label (the + * host-gated-affordance convention). + * + * The menu owns the keyboard while open (`docs/specs/layout.md` → Pane header): + * it takes DOM focus on mount (restoring the prior focus on close so a + * passthrough terminal gets its keys back), reports dialog-keyboard-active so + * command-mode keys don't fire underneath, and handles Tab cycling, `↑`/`↓` + * roving, and the `1`–`9` port accelerators in one keydown handler. Dismissal + * (Escape, outside press, resize, scroll) is owned solely by `useDismissOverlay`. + */ +export function PaneHeaderContextMenu({ + id, + anchor, + onClose, + onKeyboardActiveChange, + candidates, + currentTitle, +}: { + id: string; + anchor: { x: number; y: number }; + onClose: () => void; + onKeyboardActiveChange: (active: boolean) => void; + candidates: TerminalTitle[]; + currentTitle: string; +}) { + const actions = useContext(WallActionsContext); + const ref = useRef(null); + const [style, setStyle] = useState({ position: 'fixed', left: anchor.x, top: anchor.y }); + const [scan, setScan] = useState({ status: 'scanning' }); + + // Absent ⇒ opening a browser surface isn't supported here; port rows render as + // plain labels rather than buttons (the list stays informative). + const canConnect = !!getPlatform().agentBrowserCommand; + + // Scan once when the menu opens — no polling, no rescan while open (right-click + // again to rescan). The scan may reject on timeout (`OPEN_PORT_TIMEOUT_MS`). + useEffect(() => { + let cancelled = false; + getPlatform().getOpenPorts(id).then( + (ports: OpenPort[]) => { if (!cancelled) setScan({ status: 'loaded', entries: listenerUrlsByPort(ports) }); }, + () => { if (!cancelled) setScan({ status: 'failed' }); }, + ); + return () => { cancelled = true; }; + }, [id]); + + // Clamp inside the viewport once measured; re-run when the content height + // changes (scanning → loaded). + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + setStyle(clampOverlayPosition({ left: anchor.x, top: anchor.y, width: rect.width, height: rect.height })); + }, [anchor.x, anchor.y, scan]); + + useDismissOverlay(onClose, ref); + + // Report dialog-keyboard-active so command-mode shortcuts stay dormant while + // the menu is open (matches TodoAlertDialog). + useEffect(() => { + onKeyboardActiveChange(true); + return () => onKeyboardActiveChange(false); + }, [onKeyboardActiveChange]); + + // Take DOM focus on the menu container (tabIndex=-1) so our keyboard handlers + // fire via `el.contains(document.activeElement)`; restore the prior focus on + // close so a passthrough terminal gets its keys back. + useEffect(() => { + const previouslyFocused = document.activeElement as HTMLElement | null; + ref.current?.focus({ preventScroll: true }); + return () => { + if (previouslyFocused?.isConnected) previouslyFocused.focus({ preventScroll: true }); + }; + }, []); + + // Fire-and-forget: the pane appears immediately and reports its own progress, so + // the menu closes at once rather than waiting on the daemon boot. + const connect = useCallback((entry: PortUrlEntry) => { + actions.onConnectPort(id, entry.url); + onClose(); + }, [actions, id, onClose]); + + // Tab cycling + arrow rove + digit accelerators, one handler. Capture phase, + // scoped to the menu, and active only while mounted. Enter/Space are left to + // the focused native button. + useEffect(() => { + const el = ref.current; + if (!el) return; + const handler = (e: KeyboardEvent) => { + if (!el.contains(document.activeElement)) return; + if (e.key === 'Tab') { + e.preventDefault(); + stepFocus(Array.from(el.querySelectorAll(POPOVER_FOCUSABLE_SELECTOR)), e.shiftKey ? -1 : 1); + return; + } + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + e.stopPropagation(); + stepFocus(Array.from(el.querySelectorAll('[role="menuitem"]')), e.key === 'ArrowDown' ? 1 : -1); + return; + } + // Digits connect the nth port row — but only when there is a loaded list to + // index into. Scanning/failed/out-of-range/inert-host presses are dropped, + // not buffered. + if (canConnect && scan.status === 'loaded' && /^[1-9]$/.test(e.key)) { + const entry = scan.entries[Number(e.key) - 1]; + if (entry) { + e.preventDefault(); + e.stopPropagation(); + connect(entry); + } + } + }; + window.addEventListener('keydown', handler, true); + return () => window.removeEventListener('keydown', handler, true); + }, [canConnect, scan, connect]); + + return createPortal( +
e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onContextMenu={(e) => e.preventDefault()} + > +
+ {currentTitle} + {actions.resolveSurfaceRef(id)} + +
+
+ {candidates.length === 0 ? ( +
No title candidates
+ ) : ( +
+ {candidates.map((candidate) => ( +
+ {titleSourceLabel(candidate.source)} + {candidate.title} + +
+ ))} +
+ )} +
+
+ {scan.status === 'scanning' && ( +
+ + scanning ports… +
+ )} + {scan.status === 'failed' && ( +
port scan failed
+ )} + {scan.status === 'loaded' && scan.entries.length === 0 && ( +
no listening ports
+ )} + {scan.status === 'loaded' && scan.entries.map((entry, index) => { + const label = ( + <> + {entry.host}:{entry.port} + {entry.processName && {entry.processName}} + + ); + return canConnect ? ( + + ) : ( +
+ {label} +
+ ); + })} +
, + document.body, + ); +} + +function formatTitleCandidateTime(timestamp: number): string { + if (!Number.isFinite(timestamp)) return 'unknown'; + return new Date(timestamp).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} + +function formatTitleCandidateDateTime(timestamp: number): string | undefined { + if (!Number.isFinite(timestamp)) return undefined; + return new Date(timestamp).toISOString(); +} diff --git a/lib/src/components/wall/SurfacePaneHeader.test.tsx b/lib/src/components/wall/SurfacePaneHeader.test.tsx index 8d42d718..e05f06e9 100644 --- a/lib/src/components/wall/SurfacePaneHeader.test.tsx +++ b/lib/src/components/wall/SurfacePaneHeader.test.tsx @@ -20,6 +20,7 @@ import { ZoomedIdContext, type WallActions, } from './wall-context'; +import { stubWallActions as stubActions } from './wall-test-utils'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -38,25 +39,6 @@ const CHROME: ChromeSnapshot = { key: 'storybook', }; -function stubActions(overrides: Partial = {}): WallActions { - return { - onKill: vi.fn(), - onMinimize: vi.fn(), - onAlertButton: vi.fn(() => 'noop'), - onToggleTodo: vi.fn(), - onSplitH: vi.fn(), - onSplitV: vi.fn(), - onZoom: vi.fn(), - onClickPanel: vi.fn(), - onFocusPane: vi.fn(), - onStartRename: vi.fn(), - onFinishRename: vi.fn(() => ({ accepted: true })), - onCancelRename: vi.fn(), - onSwapRenderMode: vi.fn(), - ...overrides, - }; -} - function register(id: string, chrome: ChromeSnapshot = CHROME) { return registerAgentBrowserScreen(id, { snapshot: SCREEN, diff --git a/lib/src/components/wall/TerminalPaneHeader.tsx b/lib/src/components/wall/TerminalPaneHeader.tsx index 89a5b367..293e28e3 100644 --- a/lib/src/components/wall/TerminalPaneHeader.tsx +++ b/lib/src/components/wall/TerminalPaneHeader.tsx @@ -14,11 +14,12 @@ import { } from '@phosphor-icons/react'; import { HeaderActionButton } from '../HeaderActionButton'; import { TodoAlertDialog } from '../TodoAlertDialog'; -import { paneZoomButtonClass, TERMINAL_TOP_RADIUS_CLASS, TODO_PILL_TRACKING_CLASS } from '../design'; +import { paneZoomButtonClass, POPUP_SURFACE_CLASS, TERMINAL_TOP_RADIUS_CLASS, TODO_PILL_TRACKING_CLASS } from '../design'; import { bellIconClass } from '../bell-icon-class'; import { useTodoPillContent } from '../TodoPillBody'; import type { PaneProps } from './pane-props'; import { IllegalRenameWarning, type RenameRejection } from './IllegalRenameWarning'; +import { PaneHeaderContextMenu } from './PaneHeaderContextMenu'; import { DEFAULT_MOUSE_SELECTION_STATE, getMouseSelectionSnapshot, @@ -41,8 +42,6 @@ import { deriveHeader, resolveDisplayPrimary, titleCandidatesForDisplay, - titleSourceLabel, - type TerminalTitle, } from '../../lib/terminal-state'; import { DialogKeyboardContext, @@ -79,8 +78,6 @@ const ALERT_BUTTON_LABELS: Record('full'); const [dialogTriggerRect, setDialogTriggerRect] = useState(null); const [todoPreviewRect, setTodoPreviewRect] = useState(null); - const [titleCandidatesRect, setTitleCandidatesRect] = useState(null); + const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const [renameWarning, setRenameWarning] = useState<{ rect: DOMRect; reason: RenameRejection; value: string } | null>(null); const todoPill = useTodoPillContent(activity.todo); const titleCandidates = useMemo(() => titleCandidatesForDisplay(paneState), [paneState]); @@ -144,7 +141,7 @@ export function TerminalPaneHeader({ id, title }: PaneProps) { const closeDialog = useCallback(() => setDialogTriggerRect(null), []); const closeTodoPreview = useCallback(() => setTodoPreviewRect(null), []); - const closeTitleCandidates = useCallback(() => setTitleCandidatesRect(null), []); + const closeContextMenu = useCallback(() => setContextMenu(null), []); const closeRenameWarning = useCallback(() => setRenameWarning(null), []); const submitRename = useCallback((value: string, anchor: HTMLElement) => { const rect = anchor.getBoundingClientRect(); @@ -184,30 +181,20 @@ export function TerminalPaneHeader({ id, title }: PaneProps) { if (!activity.notification) setTodoPreviewRect(null); }, [activity.notification]); - const titleCandidatesOpen = !!titleCandidatesRect; - useEffect(() => { - if (!titleCandidatesOpen) return; - const close = () => setTitleCandidatesRect(null); - const closeOnKey = (event: KeyboardEvent) => { - if (event.key === 'Escape') close(); - }; - window.addEventListener('pointerdown', close); - window.addEventListener('resize', close); - window.addEventListener('scroll', close, true); - window.addEventListener('keydown', closeOnKey); - return () => { - window.removeEventListener('pointerdown', close); - window.removeEventListener('resize', close); - window.removeEventListener('scroll', close, true); - window.removeEventListener('keydown', closeOnKey); - }; - }, [titleCandidatesOpen]); - return (
actions.onClickPanel(id)} + onContextMenu={(e) => { + // The whole header opens this one menu; only the bell button + // stopPropagations its own right-click (the alert dialog). Right-clicks + // on the title now bubble here — the menu offers "title candidates". + e.preventDefault(); + e.stopPropagation(); + setContextMenu({ x: e.clientX, y: e.clientY }); + }} >
{isRenaming ? ( @@ -230,15 +217,10 @@ export function TerminalPaneHeader({ id, title }: PaneProps) { /> ) : ( e.stopPropagation()} onClick={(e) => { e.stopPropagation(); actions.onStartRename(id); }} - onContextMenu={(e) => { - e.preventDefault(); - e.stopPropagation(); - setTitleCandidatesRect(e.currentTarget.getBoundingClientRect()); - }} > {displayTitleBase} {showsFailGlyph && ( @@ -398,12 +380,14 @@ export function TerminalPaneHeader({ id, title }: PaneProps) { anchorRect={todoPreviewRect} /> )} - {titleCandidatesRect && !dialogTriggerRect && ( - )} {renameWarning && ( @@ -418,80 +402,6 @@ export function TerminalPaneHeader({ id, title }: PaneProps) { ); } -function TitleCandidatesPopover({ - anchorRect, - candidates, - currentTitle, - onClose, -}: { - anchorRect: DOMRect; - candidates: TerminalTitle[]; - currentTitle: string; - onClose: () => void; -}) { - const ref = useRef(null); - const [style, setStyle] = useState({ - position: 'fixed', - left: anchorRect.left, - top: anchorRect.bottom + TITLE_CANDIDATES_GAP, - }); - - useLayoutEffect(() => { - const el = ref.current; - if (!el) return; - const rect = el.getBoundingClientRect(); - const top = anchorRect.bottom + TITLE_CANDIDATES_GAP; - const maxLeft = Math.max(TITLE_CANDIDATES_MARGIN, window.innerWidth - rect.width - TITLE_CANDIDATES_MARGIN); - setStyle({ - position: 'fixed', - left: Math.min(Math.max(anchorRect.left, TITLE_CANDIDATES_MARGIN), maxLeft), - top, - maxHeight: Math.max(80, window.innerHeight - top - TITLE_CANDIDATES_MARGIN), - }); - }, [anchorRect]); - - return createPortal( -
e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - onContextMenu={(e) => e.preventDefault()} - > -
-
{currentTitle}
- -
- {candidates.length === 0 ? ( -
No title candidates
- ) : ( -
- {candidates.map((candidate) => ( -
- {titleSourceLabel(candidate.source)} - {candidate.title} - -
- ))} -
- )} -
, - document.body, - ); -} - function TodoNotificationPreview({ id, notification, @@ -527,7 +437,7 @@ function TodoNotificationPreview({ ref={ref} id={id} role="tooltip" - className="z-[1000] max-w-80 rounded border border-border bg-surface-raised px-2.5 py-2 font-mono text-sm leading-snug text-foreground shadow-md" + className={`${POPUP_SURFACE_CLASS} max-w-80 px-2.5 py-2 text-sm leading-snug`} style={style} > {notification.title && ( @@ -558,17 +468,3 @@ function formatNotificationPreview(notification: { title: string | null; body: s const preview = parts.join('\n'); return preview.length > 512 ? `${preview.slice(0, 509)}...` : preview; } - -function formatTitleCandidateTime(timestamp: number): string { - if (!Number.isFinite(timestamp)) return 'unknown'; - return new Date(timestamp).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }); -} - -function formatTitleCandidateDateTime(timestamp: number): string | undefined { - if (!Number.isFinite(timestamp)) return undefined; - return new Date(timestamp).toISOString(); -} diff --git a/lib/src/components/wall/agent-browser-surface-controller.test.ts b/lib/src/components/wall/agent-browser-surface-controller.test.ts index c232feb5..4310a4be 100644 --- a/lib/src/components/wall/agent-browser-surface-controller.test.ts +++ b/lib/src/components/wall/agent-browser-surface-controller.test.ts @@ -378,6 +378,30 @@ describe('updateParams', () => { }); describe('stale-port recovery gating', () => { + it('stays fully inert for a session-less pane until params deliver the session', async () => { + const streamStatus = vi.fn(async () => ({ ok: true, wsPort: 2222 })); + const platform = new FakePtyAdapter() as FakePtyAdapter & Pick; + platform.agentBrowserStreamStatus = streamStatus; + setPlatform(platform); + + // The pane context menu's instant connect mounts its surface WITHOUT a + // session precisely so nothing here can race the daemon boot — no recovery + // query, no socket (docs/specs/dor-browser.md → Pane Context Menu Connect). + // Deriving the session from `key` would silently reintroduce the race. + const controller = acquireAgentBrowserSurfaceController('id', { key: 'default', url: 'http://localhost:5173/' }); + const sink = makeSink(); + controller.attachView(sink); + await flushMicrotasks(); + expect(streamStatus).not.toHaveBeenCalled(); + expect(WebSocketMock.instances).toHaveLength(0); + + // The background boot hands over {session, wsPort} in one params write, + // which is what brings the stream up. + controller.updateParams({ key: 'default', url: 'http://localhost:5173/', session: 'sess', wsPort: 1111 }); + await flushMicrotasks(); + expect(streamSocket(1111)?.readyState).toBe(1); + }); + it('never queries stream status while parked', async () => { vi.useFakeTimers(); try { diff --git a/lib/src/components/wall/agent-browser-surface-controller.ts b/lib/src/components/wall/agent-browser-surface-controller.ts index 71f974f4..303c1256 100644 --- a/lib/src/components/wall/agent-browser-surface-controller.ts +++ b/lib/src/components/wall/agent-browser-surface-controller.ts @@ -884,6 +884,11 @@ export class AgentBrowserSurfaceController { // query (mirrors the old effect's cleanup running on every dep change). const gen = ++this.recoveryGen; const session = this.session; + // Session-less is deliberate inertness, not just a null-guard: the pane + // context menu's eager connect creates its surface WITHOUT a session + // precisely so no recovery/CLI spawn can race the daemon boot + // (docs/specs/dor-browser.md → Pane Context Menu Connect). Never derive the + // session from `key` here — that would silently reintroduce the race. if (!session) return; // A parked pane must never query the daemon: a `stream status` at the wrong // moment can spawn a competing daemon, and it's a pointless CLI spawn per diff --git a/lib/src/components/wall/connect-port.test.ts b/lib/src/components/wall/connect-port.test.ts new file mode 100644 index 00000000..f1d72c53 --- /dev/null +++ b/lib/src/components/wall/connect-port.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from 'vitest'; +import { sessionForKey } from 'dor-lib-common/agent-browser'; +import { connectPortToDefaultBrowser } from './connect-port'; + +const URL = 'http://localhost:5173/'; +const SESSION = sessionForKey('default'); + +// The eager surface is created before the daemon boots; a plain ok result stands +// in for the real reuse-or-create seam. +const okEager = () => ({ ok: true as const, value: { surfaceId: 'pane-2' } }); + +describe('connectPortToDefaultBrowser', () => { + it('creates the eager surface before opening, reads the port, then refreshes with session/wsPort/binaryPath', async () => { + const calls: string[] = []; + const ensureEagerSurface = vi.fn(() => { calls.push('ensure'); return okEager(); }); + const refreshSurface = vi.fn(); + const platform = { + agentBrowserCommand: vi.fn(async () => { calls.push('open'); return { exitCode: 0, stdout: '', stderr: '' }; }), + agentBrowserStreamStatus: vi.fn(async () => ({ ok: true, wsPort: 4321 })), + }; + const result = await connectPortToDefaultBrowser({ url: URL, platform, binaryPath: '/bin/ab', ensureEagerSurface, refreshSurface }); + + expect(result).toEqual({ ok: true }); + // The pane is created (session-less) before the slow daemon boot. + expect(calls).toEqual(['ensure', 'open']); + expect(ensureEagerSurface).toHaveBeenCalledWith(SESSION); + expect(platform.agentBrowserCommand).toHaveBeenCalledWith(SESSION, ['open', URL], '/bin/ab'); + expect(platform.agentBrowserStreamStatus).toHaveBeenCalledWith(SESSION, '/bin/ab'); + expect(refreshSurface).toHaveBeenCalledTimes(1); + expect(refreshSurface).toHaveBeenCalledWith('pane-2', { session: SESSION, wsPort: 4321, binaryPath: '/bin/ab' }); + }); + + it('short-circuits before creating a surface when the host cannot run agent-browser', async () => { + const ensureEagerSurface = vi.fn(okEager); + const refreshSurface = vi.fn(); + const result = await connectPortToDefaultBrowser({ url: URL, platform: {}, ensureEagerSurface, refreshSurface }); + expect(result).toEqual({ ok: false, message: 'opening a browser surface is not supported on this host' }); + expect(ensureEagerSurface).not.toHaveBeenCalled(); + expect(refreshSurface).not.toHaveBeenCalled(); + }); + + it('propagates an ensureEagerSurface failure without opening', async () => { + const ensureEagerSurface = vi.fn(() => ({ ok: false as const, message: 'no room' })); + const refreshSurface = vi.fn(); + const platform = { agentBrowserCommand: vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })) }; + const result = await connectPortToDefaultBrowser({ url: URL, platform, ensureEagerSurface, refreshSurface }); + expect(result).toEqual({ ok: false, message: 'no room' }); + expect(platform.agentBrowserCommand).not.toHaveBeenCalled(); + expect(refreshSurface).not.toHaveBeenCalled(); + }); + + it('keeps the pane (refreshing just the session) and surfaces trimmed stderr on a non-zero exit', async () => { + const ensureEagerSurface = vi.fn(okEager); + const refreshSurface = vi.fn(); + const platform = { agentBrowserCommand: vi.fn(async () => ({ exitCode: 3, stdout: '', stderr: ' boom \n' })) }; + const result = await connectPortToDefaultBrowser({ url: URL, platform, ensureEagerSurface, refreshSurface }); + expect(result).toEqual({ ok: false, message: 'boom' }); + // The pane stays; the session lets its placeholder name the session. + expect(refreshSurface).toHaveBeenCalledWith('pane-2', { session: SESSION }); + }); + + it('reports the exit code when a non-zero exit has no stderr', async () => { + const platform = { agentBrowserCommand: vi.fn(async () => ({ exitCode: 3, stdout: '', stderr: '' })) }; + const result = await connectPortToDefaultBrowser({ url: URL, platform, ensureEagerSurface: vi.fn(okEager), refreshSurface: vi.fn() }); + expect(result).toEqual({ ok: false, message: 'agent-browser open exited 3' }); + }); + + it('omits wsPort in the refresh when the host has no stream-status channel', async () => { + const refreshSurface = vi.fn(); + const platform = { agentBrowserCommand: vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })) }; + const result = await connectPortToDefaultBrowser({ url: URL, platform, ensureEagerSurface: vi.fn(okEager), refreshSurface }); + expect(result).toEqual({ ok: true }); + expect(refreshSurface).toHaveBeenCalledWith('pane-2', { session: SESSION }); + }); + + it('omits wsPort in the refresh when stream status fails', async () => { + const refreshSurface = vi.fn(); + const platform = { + agentBrowserCommand: vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })), + agentBrowserStreamStatus: vi.fn(async () => ({ ok: false, error: 'nope' })), + }; + await connectPortToDefaultBrowser({ url: URL, platform, binaryPath: '/bin/ab', ensureEagerSurface: vi.fn(okEager), refreshSurface }); + expect(refreshSurface).toHaveBeenCalledWith('pane-2', { session: SESSION, binaryPath: '/bin/ab' }); + }); +}); diff --git a/lib/src/components/wall/connect-port.ts b/lib/src/components/wall/connect-port.ts new file mode 100644 index 00000000..c4501a4b --- /dev/null +++ b/lib/src/components/wall/connect-port.ts @@ -0,0 +1,81 @@ +/** + * "Connect a port" — the wall-side reproduction of `dor ab open ` for the + * pane context menu (see `dor/src/commands/agent-browser.ts` for the CLI flow). + * Opens the URL in the workspace's default agent-browser session and reuses or + * creates that session's browser surface. Dependency-injected (platform + + * eager-surface/refresh seams) so it stays unit-testable off the React tree. + * + * Sequencing (`docs/specs/dor-browser.md` → Pane Context Menu Connect): the pane + * is created SYNCHRONOUSLY and WITHOUT a `session`, so it appears the instant the + * port is clicked and the controller's stale-port recovery stays inert until the + * daemon is up (a session-less agent-browser pane spawns no CLI). The background + * `open` + `stream status` then deliver `{session, wsPort, binaryPath}` as one + * params refresh. + */ +import type { PlatformAdapter } from '../../lib/platform/types'; +import type { ParseResult } from 'dor/commands/types'; +// The browser-safe subpath: dist/agent-browser.js is pure ES, so importing it +// keeps cross-spawn (the package's Node-only default export) out of the webview. +import { sessionForKey } from 'dor-lib-common/agent-browser'; + +/** The host capabilities `connectPortToDefaultBrowser` needs — the same two the + * CLI path leans on, narrowed so tests can stub them without a full adapter. */ +type ConnectPlatform = Pick; + +export type ConnectPortResult = { ok: true } | { ok: false; message: string }; + +export async function connectPortToDefaultBrowser({ + url, + platform, + binaryPath, + ensureEagerSurface, + refreshSurface, +}: { + url: string; + platform: ConnectPlatform; + /** Last binary path a `dor ab` surface resolved; undefined ⇒ host falls back + * to PATH / DORMOUSE_AGENT_BROWSER_BIN. */ + binaryPath?: string; + /** Reuse-or-create the default browser pane synchronously, before the daemon + * boots, so the pane is on screen the instant the port is clicked. Created + * session-less on purpose (see file header). */ + ensureEagerSurface: (session: string) => ParseResult<{ surfaceId: string }>; + /** Fold a params patch onto the eager surface (visible pane or minimized door) + * — used to hand the pane its `session` and refreshed stream port. */ + refreshSurface: (surfaceId: string, patch: Record) => void; +}): Promise { + // Host-gated: no agent-browser runner ⇒ no browser surface (same spirit as the + // render-swap guard in Wall.tsx). Short-circuit before touching the layout. + if (!platform.agentBrowserCommand) { + return { ok: false, message: 'opening a browser surface is not supported on this host' }; + } + const session = sessionForKey('default'); + // Pane appears NOW, session-less — the controller can't race the daemon boot. + const eager = ensureEagerSurface(session); + if (!eager.ok) return { ok: false, message: eager.message }; + + // 'open' is on the host's subcommand allowlist; the CLI boots the daemon/browser + // if it isn't already running. + const opened = await platform.agentBrowserCommand(session, ['open', url], binaryPath); + if (opened.exitCode !== 0) { + // The pane stays; hand it the session so its placeholder names the session + // instead of sitting sessionless. + refreshSurface(eager.value.surfaceId, { session }); + return { ok: false, message: opened.stderr.trim() || `agent-browser open exited ${opened.exitCode}` }; + } + // Best-effort stream port so the panel connects straight to the live screencast; + // if it's absent or stale the panel recovers it later, so a miss is non-fatal. + let wsPort: number | undefined; + if (platform.agentBrowserStreamStatus) { + const status = await platform.agentBrowserStreamStatus(session, binaryPath); + if (status.ok) wsPort = status.wsPort; + } + // One params write reconciles the session-less pane: setting `session` connects + // the controller (the daemon is up now, so its recovery is safe to run). + refreshSurface(eager.value.surfaceId, { + session, + ...(wsPort !== undefined ? { wsPort } : {}), + ...(binaryPath !== undefined ? { binaryPath } : {}), + }); + return { ok: true }; +} diff --git a/lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts b/lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts index 128731fb..a1a359b6 100644 --- a/lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts +++ b/lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment jsdom */ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { handlePaneShortcuts } from './handle-pane-shortcuts'; import type { WallKeyboardCtx } from './types'; @@ -23,6 +23,11 @@ vi.mock('../../KillConfirm', () => ({ randomKillChar: () => 'Q', })); +// jsdom here ships no `CSS` global; the header lookup escapes ids via CSS.escape. +globalThis.CSS ??= { + escape: (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, (c) => `\\${c}`), +} as unknown as typeof CSS; + function makeNav(overrides: Partial = {}): WallKeyboardCtx['nav'] { return { findInDirection: () => null, @@ -173,3 +178,41 @@ describe('handlePaneShortcuts Cmd-Arrow swap (nav seam)', () => { expect(ctx.selectPane).not.toHaveBeenCalled(); }); }); + +describe('handlePaneShortcuts `>` header context menu', () => { + beforeEach(() => vi.clearAllMocks()); + afterEach(() => { document.body.innerHTML = ''; }); + + function makeHeaderEl(id: string): ReturnType { + const el = document.createElement('div'); + el.setAttribute('data-pane-header-for', id); + document.body.appendChild(el); + const onContextMenu = vi.fn(); + el.addEventListener('contextmenu', onContextMenu); + return onContextMenu; + } + + it('dispatches contextmenu on the selected pane header element', () => { + const onContextMenu = makeHeaderEl('pane-a'); + + expect(handlePaneShortcuts(keydown('>'), makeCtx(), { current: null })).toBe(true); + + expect(onContextMenu).toHaveBeenCalledTimes(1); + expect(onContextMenu.mock.calls[0][0].type).toBe('contextmenu'); + }); + + it('consumes `>` without throwing when no header element matches', () => { + const ctx = makeCtx(); + expect(document.querySelector('[data-pane-header-for="pane-a"]')).toBeNull(); + expect(() => handlePaneShortcuts(keydown('>'), ctx, { current: null })).not.toThrow(); + expect(handlePaneShortcuts(keydown('>'), ctx, { current: null })).toBe(true); + }); + + it('does nothing when a door is selected', () => { + const onContextMenu = makeHeaderEl('pane-a'); + const ctx = makeCtx({ selectedTypeRef: { current: 'door' } }); + + expect(handlePaneShortcuts(keydown('>'), ctx, { current: null })).toBe(false); + expect(onContextMenu).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts b/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts index 5b390fb6..63a4c3bf 100644 --- a/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts +++ b/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts @@ -11,10 +11,15 @@ function findAlertButtonForSession(id: string): HTMLButtonElement | null { return document.querySelector(`[data-alert-button-for="${CSS.escape(id)}"]`); } +function findPaneHeaderForSession(id: string): HTMLElement | null { + return document.querySelector(`[data-pane-header-for="${CSS.escape(id)}"]`); +} + /** * Single-pane shortcuts: Enter (focus/reattach), `|`/`%`/`-`/`"` (split), * Cmd-Arrow (swap with neighbor), k/x (kill confirm), `,` (rename), - * m/d (minimize), t/a (todo/alert toggle), z (zoom + passthrough focus). + * m/d (minimize), t/a (todo/alert toggle), z (zoom + passthrough focus), + * `>` (open the selected pane's header context menu). */ export function handlePaneShortcuts( e: KeyboardEvent, @@ -143,5 +148,26 @@ export function handlePaneShortcuts( return true; } + if (e.key === '>' && sid && ctx.selectedTypeRef.current === 'pane') { + e.preventDefault(); + e.stopPropagation(); + // Reuse the header's own onContextMenu path: dispatch a synthetic + // contextmenu at the header's bottom-left corner so the menu opens anchored + // under the header's left edge. Browser-surface panes carry no + // `data-pane-header-for`, so the lookup misses and the key is a consumed + // no-op — the spec'd behavior for surfaces with no header context menu. + const header = findPaneHeaderForSession(sid); + if (header) { + const rect = header.getBoundingClientRect(); + header.dispatchEvent(new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: rect.left, + clientY: rect.bottom, + })); + } + return true; + } + return false; } diff --git a/lib/src/components/wall/port-url.test.ts b/lib/src/components/wall/port-url.test.ts new file mode 100644 index 00000000..58e016d7 --- /dev/null +++ b/lib/src/components/wall/port-url.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; +import { listenerUrlsByPort, servesLoopback } from './port-url'; +import type { OpenPort } from '../../lib/platform/types'; + +function tcp(o: { + address: string; + port: number; + family?: 'IPv4' | 'IPv6'; + pid?: number; + processName?: string; +}): OpenPort { + return { + protocol: 'tcp', + family: o.family ?? (o.address.includes(':') ? 'IPv6' : 'IPv4'), + address: o.address, + port: o.port, + pid: o.pid ?? 1234, + ...(o.processName !== undefined ? { processName: o.processName } : {}), + }; +} + +// protocol is typed 'tcp'; the filter is still exercised via a cast. +const udp = (port: number): OpenPort => + ({ protocol: 'udp', family: 'IPv4', address: '127.0.0.1', port, pid: 1 } as unknown as OpenPort); + +describe('servesLoopback', () => { + it('is true for loopback and any-interface binds', () => { + for (const addr of ['127.0.0.1', '::1', '0.0.0.0', '::']) expect(servesLoopback(addr)).toBe(true); + }); + it('is false for a specific interface', () => { + for (const addr of ['192.168.1.5', 'fe80::1']) expect(servesLoopback(addr)).toBe(false); + }); +}); + +describe('listenerUrlsByPort', () => { + it.each(['127.0.0.1', '::1', '0.0.0.0', '::'])( + 'prefers a loopback-servable bind (%s) → localhost, even beside a specific one', + (loopbackAddr) => { + const ports = [tcp({ address: '192.168.1.5', port: 5173 }), tcp({ address: loopbackAddr, port: 5173 })]; + expect(listenerUrlsByPort(ports)).toEqual([{ port: 5173, host: 'localhost', url: 'http://localhost:5173/' }]); + }, + ); + + it('uses the specific bound address when no loopback bind exists', () => { + expect(listenerUrlsByPort([tcp({ address: '192.168.1.5', port: 8080 })])).toEqual([ + { port: 8080, host: '192.168.1.5', url: 'http://192.168.1.5:8080/' }, + ]); + }); + + it('brackets a specific IPv6 bind', () => { + expect(listenerUrlsByPort([tcp({ address: 'fe80::1', port: 3000 })])).toEqual([ + { port: 3000, host: '[fe80::1]', url: 'http://[fe80::1]:3000/' }, + ]); + }); + + it('breaks ties IPv4-before-IPv6 among specific binds', () => { + const ports = [tcp({ address: 'fe80::1', port: 4000 }), tcp({ address: '10.0.0.2', port: 4000 })]; + expect(listenerUrlsByPort(ports)[0]).toEqual({ port: 4000, host: '10.0.0.2', url: 'http://10.0.0.2:4000/' }); + }); + + it('breaks same-family ties by address lexicographically', () => { + const ports = [tcp({ address: '10.0.0.9', port: 4100 }), tcp({ address: '10.0.0.2', port: 4100 })]; + expect(listenerUrlsByPort(ports)[0].host).toBe('10.0.0.2'); + }); + + it('emits one entry per port even across families (dedupe)', () => { + const ports = [tcp({ address: '127.0.0.1', port: 5173 }), tcp({ address: '::1', port: 5173 })]; + expect(listenerUrlsByPort(ports)).toEqual([{ port: 5173, host: 'localhost', url: 'http://localhost:5173/' }]); + }); + + it('sorts entries ascending by port', () => { + const ports = [8080, 3000, 5173].map((port) => tcp({ address: '127.0.0.1', port })); + expect(listenerUrlsByPort(ports).map((e) => e.port)).toEqual([3000, 5173, 8080]); + }); + + it('ignores non-tcp listeners', () => { + expect(listenerUrlsByPort([udp(9000), tcp({ address: '127.0.0.1', port: 5173 })]).map((e) => e.port)).toEqual([5173]); + expect(listenerUrlsByPort([udp(9000)])).toEqual([]); + }); + + it('carries the selected listener processName', () => { + expect(listenerUrlsByPort([tcp({ address: '127.0.0.1', port: 5173, processName: 'node' })])[0].processName).toBe('node'); + }); + + it('prefers the selected listener processName over siblings', () => { + const ports = [ + tcp({ address: '192.168.1.5', port: 5173, processName: 'sibling' }), + tcp({ address: '127.0.0.1', port: 5173, processName: 'chosen' }), + ]; + expect(listenerUrlsByPort(ports)[0].processName).toBe('chosen'); + }); + + it('falls back to the first defined processName when the selected listener has none', () => { + const ports = [ + tcp({ address: '127.0.0.1', port: 5173 }), + tcp({ address: '192.168.1.5', port: 5173, processName: 'vite' }), + ]; + expect(listenerUrlsByPort(ports)[0].processName).toBe('vite'); + }); +}); diff --git a/lib/src/components/wall/port-url.ts b/lib/src/components/wall/port-url.ts new file mode 100644 index 00000000..39488985 --- /dev/null +++ b/lib/src/components/wall/port-url.ts @@ -0,0 +1,50 @@ +/** + * Port → dev-server URL selection, shared by the `surface.resolveOpen` control + * method (`dor ab open ` / `dor iframe `) and the pane + * context menu's port list. One entry per distinct TCP port a process tree + * binds; the host/URL choice matches what `dor ab open` opens. + */ +import type { OpenPort } from '../../lib/platform/types'; + +/** One openable dev-server URL for a distinct listening port. */ +export type PortUrlEntry = { port: number; host: string; url: string; processName?: string }; + +// A process bound here answers `localhost:`: loopback (127.0.0.1 / ::1) +// or any-interface (0.0.0.0 / ::). A process bound to one specific non-loopback +// interface is excluded — it isn't reachable as localhost. +export function servesLoopback(address: string): boolean { + return address === '127.0.0.1' || address === '::1' || address === '0.0.0.0' || address === '::'; +} + +/** + * Group TCP listeners into one openable URL per distinct port, sorted ascending + * by port. Per port: a loopback-servable bind wins the host `localhost`; + * otherwise pick a listener IPv4-first then address-lexicographic, bracketing + * IPv6 (`[::1]`). The URL is `http://:/`. `processName` is the + * selected listener's, falling back to the first defined one for that port. + */ +export function listenerUrlsByPort(ports: OpenPort[]): PortUrlEntry[] { + const tcpPorts = ports.filter((entry) => entry.protocol === 'tcp'); + const distinctPorts = [...new Set(tcpPorts.map((entry) => entry.port))].sort((a, b) => a - b); + return distinctPorts.map((port) => { + const portListeners = tcpPorts.filter((entry) => entry.port === port); + const loopbackListener = portListeners.find((entry) => servesLoopback(entry.address)); + const selectedListener = loopbackListener ?? [...portListeners].sort((a, b) => { + if (a.family !== b.family) return a.family === 'IPv4' ? -1 : 1; + return a.address < b.address ? -1 : a.address > b.address ? 1 : 0; + })[0]; + const host = loopbackListener + ? 'localhost' + : selectedListener.family === 'IPv6' + ? `[${selectedListener.address}]` + : selectedListener.address; + const processName = selectedListener.processName + ?? portListeners.find((entry) => entry.processName !== undefined)?.processName; + return { + port, + host, + url: `http://${host}:${port}/`, + ...(processName !== undefined ? { processName } : {}), + }; + }); +} diff --git a/lib/src/components/wall/use-dev-server-ports.ts b/lib/src/components/wall/use-dev-server-ports.ts index 5fa933e2..79019ba9 100644 --- a/lib/src/components/wall/use-dev-server-ports.ts +++ b/lib/src/components/wall/use-dev-server-ports.ts @@ -37,6 +37,7 @@ import { import type { DooredItem } from './wall-types'; import type { LathWallEngine } from './lath-wall-engine'; import { isBrowserParams } from './browser-surface'; +import { servesLoopback } from './port-url'; // Wait this long after interest changes before scanning, so a tab's open + // initial screencast settle first and quick navigation coalesces into one scan. @@ -57,13 +58,6 @@ function isTerminalDoor(door: DooredItem): boolean { return (door.component ?? 'terminal') === 'terminal'; } -// A process bound here answers `localhost:`: loopback (127.0.0.1 / ::1) -// or any-interface (0.0.0.0 / ::). A process bound to one specific non-loopback -// interface is excluded — it isn't reachable as localhost. -export function servesLoopback(address: string): boolean { - return address === '127.0.0.1' || address === '::1' || address === '0.0.0.0' || address === '::'; -} - // requestIdleCallback isn't universal (absent in WKWebView / Tauri on macOS), // so fall back to a short timer. Handles are plain numbers in both paths. function scheduleIdle(cb: () => void): number { diff --git a/lib/src/components/wall/use-dismiss-overlay.ts b/lib/src/components/wall/use-dismiss-overlay.ts new file mode 100644 index 00000000..2124d126 --- /dev/null +++ b/lib/src/components/wall/use-dismiss-overlay.ts @@ -0,0 +1,37 @@ +import { useEffect, type RefObject } from 'react'; + +/** + * The pane-header popovers' shared dismissal contract (docs/specs/layout.md): + * any window `pointerdown` (an overlay that should survive its own clicks stops + * propagation on its root), Escape, `resize`, or capture-phase `scroll` — + * except scrolls originating inside `overlayRef` itself, which never dismiss: + * internal overflow scrolling (e.g. arrow-key focus moves auto-scrolling an + * overflowing list) doesn't move the overlay's anchor, so it must not close it. + * Register only while the overlay is mounted — consumers render only when open. + */ +export function useDismissOverlay( + onClose: () => void, + overlayRef: RefObject, +): void { + useEffect(() => { + const close = () => onClose(); + const closeOnKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') close(); + }; + const closeOnScroll = (event: Event) => { + const overlay = overlayRef.current; + if (overlay && event.target instanceof Node && overlay.contains(event.target)) return; + close(); + }; + window.addEventListener('pointerdown', close); + window.addEventListener('resize', close); + window.addEventListener('scroll', closeOnScroll, true); + window.addEventListener('keydown', closeOnKey); + return () => { + window.removeEventListener('pointerdown', close); + window.removeEventListener('resize', close); + window.removeEventListener('scroll', closeOnScroll, true); + window.removeEventListener('keydown', closeOnKey); + }; + }, [onClose, overlayRef]); +} diff --git a/lib/src/components/wall/use-dor-control.ts b/lib/src/components/wall/use-dor-control.ts index 6e60c4ff..da46c451 100644 --- a/lib/src/components/wall/use-dor-control.ts +++ b/lib/src/components/wall/use-dor-control.ts @@ -20,7 +20,10 @@ import { import { surfaceRunsCommand, type TerminalPaneState } from '../../lib/terminal-state'; import { hostPathDisplay } from './browser-url'; import { isAgentBrowserParams } from './browser-surface'; -import { servesLoopback } from './use-dev-server-ports'; +// One-way import: connect-port no longer depends on this module (its eager-surface +// and refresh seams are injected as plain functions). +import { connectPortToDefaultBrowser } from './connect-port'; +import { listenerUrlsByPort } from './port-url'; import { dorDirectionForEdge, type LathWallEngine } from './lath-wall-engine'; import type { WallNav } from './keyboard/types'; import type { DooredItem } from './wall-types'; @@ -57,6 +60,34 @@ type DorControlRequest = Omit & { respond: (response: DorControlResult) => void; }; +/** Outcome of {@link EnsureAgentBrowserSurface}: the fields the caller maps onto + * its response, or a failure message. `minimized` is the surface's current + * minimized state (the reused surface's, or the requested value for a fresh one). */ +type EnsureAgentBrowserSurfaceResult = + | { ok: true; status: 'created' | 'existing' | 'replaced'; surfaceId: string; surfaceRef: string; minimized: boolean } + | { ok: false; message: string }; + +/** Reuse-or-create an agent-browser browser surface — the surface half of + * `dor ab` (the control plane), and, with `session` omitted, the pane context + * menu's eager session-less create (docs/specs/dor-browser.md → Pane Context + * Menu Connect). At least one of `key` / `session` is required (it names the + * surface). */ +type EnsureAgentBrowserSurface = (args: { + key?: string; + /** Omitted for the eager connect pane, which is created session-less on + * purpose so the controller stays inert until the daemon is up; the reuse + * arm is skipped (there is no session to match). */ + session?: string; + url?: string; + wsPort?: number; + binaryPath?: string; + /** Resolved lazily, only when a fresh surface must be created: the reuse path + * must succeed without a visible reference (e.g. `dor ab` from a minimized + * terminal refreshing an existing surface). */ + reference: () => ParseResult; + minimized?: boolean; +}) => EnsureAgentBrowserSurfaceResult; + function isSingletonWorkspaceTarget(target: string | undefined): boolean { return !target || target === 'workspace:1' || target === '1'; } @@ -310,6 +341,7 @@ export function useDorControl({ createSplitSurface, createContentSurface, killPaneImmediately, + revealSurface, lastAgentBrowserBinaryPathRef, }: { /** The Lath engine — visible-pane projection (`lath.listPanes()`), aspect-ratio @@ -343,9 +375,13 @@ export function useDorControl({ focusNeutral?: boolean; }) => ParseResult<{ id: string; ref: string; status: 'created' | 'replaced' }>; killPaneImmediately: (id: string) => void; + /** Put the selection on a surface, reattaching it first when it is minimized. + * Used by the human-initiated `connectPort` (a menu click is a request to see + * that surface); the `dor ab` control path stays focus-neutral. */ + revealSurface: (id: string) => void; /** The last binary path a `dor ab` surface resolved on a terminal's PATH. */ lastAgentBrowserBinaryPathRef: MutableRefObject; -}): void { +}): { connectPort: (id: string, url: string) => Promise } { const resolveVisibleSurface = useCallback(( target: string | undefined, callerSurfaceId: string | undefined, @@ -401,14 +437,11 @@ export function useDorControl({ }, [lath]); /** - * The agent-browser session ↔ surface registry, derived from panel/door - * params rather than kept as separate state so it survives webview reloads. - * Returns the surface bound to `session`, or null if none exists. + * The surface (visible pane or minimized door — panes win) whose params match, + * derived from panel/door params rather than kept as separate state so it + * survives webview reloads. Null when nothing matches. */ - const findAgentBrowserSurface = useCallback((session: string): { id: string; minimized: boolean } | null => { - const isMatch = (params: unknown) => - isAgentBrowserParams(params) && (params as { session?: unknown }).session === session; - + const findSurfaceByParams = useCallback((isMatch: (params: unknown) => boolean): { id: string; minimized: boolean } | null => { const panel = lath.listPanes().find((candidate) => isMatch(candidate.params)); if (panel) return { id: panel.id, minimized: false }; const door = doorsRef.current.find((candidate) => isMatch(candidate.params)); @@ -416,6 +449,144 @@ export function useDorControl({ return null; }, [lath]); + /** The agent-browser session ↔ surface registry: the surface bound to + * `session`, or null if none exists. */ + const findAgentBrowserSurface = useCallback((session: string) => findSurfaceByParams((params) => + isAgentBrowserParams(params) && (params as { session?: unknown }).session === session, + ), [findSurfaceByParams]); + + // Fold a params patch onto a surface whether it's a visible pane (engine + // metadata) or a minimized door (the doorsRef map) — the reuse/refresh + // mechanics shared by `ensureAgentBrowserSurface`'s reuse arm and the + // connect-port refresh seam. A no-op on an empty patch. + const updateSurfaceParams = useCallback((id: string, patch: Record) => { + if (Object.keys(patch).length === 0) return; + const door = doorsRef.current.find((candidate) => candidate.id === id); + if (door) { + const nextDoors = doorsRef.current.map((d) => d.id === id + ? { ...d, params: { ...d.params, ...patch } } + : d); + doorsRef.current = nextDoors; + setDoors(nextDoors); + } else { + lath.store.updateParams(id, patch); + } + }, [doorsRef, lath, setDoors]); + + const ensureAgentBrowserSurface = useCallback(({ + key, + session, + url, + wsPort, + binaryPath, + reference, + minimized = false, + }) => { + // Remember the resolved binary so an embed→screencast swap can spawn one. + if (binaryPath) lastAgentBrowserBinaryPathRef.current = binaryPath; + const refreshedParams = { + ...(wsPort !== undefined ? { wsPort } : {}), + ...(binaryPath !== undefined ? { binaryPath } : {}), + }; + + const existing = session === undefined ? null : findAgentBrowserSurface(session); + if (existing) { + // Reuse: refresh the stream port (OS-assigned, churns across session + // restarts) so the panel reconnects to the live stream, and the + // resolved binary path alongside it. + updateSurfaceParams(existing.id, refreshedParams); + return { + ok: true, + status: 'existing', + surfaceId: existing.id, + surfaceRef: surfaceRefForId(existing.id), + minimized: existing.minimized, + }; + } + + const title = key ?? session; + if (title === undefined) return { ok: false, message: 'an agent-browser surface needs a key or a session' }; + const target = reference(); + if (!target.ok) return { ok: false, message: target.message }; + const result = createContentSurface({ + minimized, + params: { + surfaceType: 'browser', + renderMode: 'ab-screencast', + ...(session !== undefined ? { session } : {}), + ...(key !== undefined ? { key } : {}), + ...(url !== undefined ? { url } : {}), + ...refreshedParams, + }, + reference: target.value, + title, + // `dor ab` opens the screencast in the background; caller keeps focus. + focusNeutral: true, + }); + if (!result.ok) return { ok: false, message: result.message }; + return { + ok: true, + status: result.value.status, + surfaceId: result.value.id, + surfaceRef: result.value.ref, + minimized, + }; + }, [createContentSurface, findAgentBrowserSurface, updateSurfaceParams, surfaceRefForId]); + + // The pane context menu's "connect a port" action, bound to this hook's + // closure so Wall.tsx delegates in one line instead of re-threading the + // hook's internals. The pane is created eagerly and session-less so it appears + // instantly; `connectPortToDefaultBrowser` then hands it its session + + // stream port (docs/specs/dor-browser.md → Pane Context Menu Connect). + // Failures are logged, not returned — the menu closes before one can exist. + const connectPort = useCallback((id: string, url: string): Promise => { + const ensureEagerSurface = (session: string): ParseResult<{ surfaceId: string }> => { + // Every arm below ends on the same surface id, and a menu click is a human + // asking to *see* that surface — so reveal it (reattach if minimized, then + // select). `dor ab`'s control path stays focus-neutral; this one does not. + const reveal = (surfaceId: string): ParseResult<{ surfaceId: string }> => { + revealSurface(surfaceId); + return { ok: true, value: { surfaceId } }; + }; + // (a) A surface already bound to this session — reuse; params untouched + // (the navigation + final refresh handle the rest). + const existing = findAgentBrowserSurface(session); + if (existing) return reveal(existing.id); + // (b) A still-booting default pane from a rapid earlier connect (created + // but not yet handed its session) — reuse it so a second click during the + // daemon boot doesn't spawn a duplicate. + const booting = findSurfaceByParams((params) => + isAgentBrowserParams(params) + && (params as { key?: unknown }).key === 'default' + && (params as { session?: unknown }).session === undefined); + if (booting) return reveal(booting.id); + // (c) Create it now: NO `session` (keeps the controller's stale-port + // recovery inert until the daemon is up), but carry the target `url` so + // the browser chrome shows it immediately. + const created = ensureAgentBrowserSurface({ + key: 'default', + url, + reference: () => { + const surface = buildDorSurfaces().find((candidate) => candidate.id === id); + return surface ? { ok: true, value: surface } : { ok: false, message: `surface for pane '${id}' was not found` }; + }, + }); + if (!created.ok) return created; + return reveal(created.surfaceId); + }; + return connectPortToDefaultBrowser({ + url, + platform: getPlatform(), + binaryPath: lastAgentBrowserBinaryPathRef.current, + ensureEagerSurface, + refreshSurface: updateSurfaceParams, + }).then((outcome) => { + // The menu no longer surfaces errors (it closes instantly); log a failure + // the way the render-swap path in Wall.tsx does. + if (!outcome.ok) console.warn('[dormouse] connect port failed:', outcome.message); + }); + }, [buildDorSurfaces, ensureAgentBrowserSurface, findAgentBrowserSurface, findSurfaceByParams, updateSurfaceParams, revealSurface, lastAgentBrowserBinaryPathRef]); + useEffect(() => { const handler = async (event: Event) => { const detail = (event as CustomEvent).detail; @@ -723,61 +894,13 @@ export function useDorControl({ detail.respond({ ok: false, error: 'session is required' }); return; } - const key = stringParam(params.key); - const wsPort = numberParam(params.wsPort); - const binaryPath = stringParam(params.binaryPath); - // Remember the resolved binary so an embed→screencast swap can spawn one. - if (binaryPath) lastAgentBrowserBinaryPathRef.current = binaryPath; - const refreshedParams = { - ...(wsPort !== undefined ? { wsPort } : {}), - ...(binaryPath !== undefined ? { binaryPath } : {}), - }; - - const existing = findAgentBrowserSurface(session); - if (existing) { - // Reuse: refresh the stream port (OS-assigned, churns across session - // restarts) so the panel reconnects to the live stream, and the - // resolved binary path alongside it. - if (!existing.minimized && Object.keys(refreshedParams).length > 0) { - lath.store.updateParams(existing.id, refreshedParams); - } else if (existing.minimized && Object.keys(refreshedParams).length > 0) { - const nextDoors = doorsRef.current.map((door) => door.id === existing.id - ? { ...door, params: { ...door.params, ...refreshedParams } } - : door); - doorsRef.current = nextDoors; - setDoors(nextDoors); - } - detail.respond({ - ok: true, - result: { - status: 'existing', - surfaceId: existing.id, - surfaceRef: surfaceRefForId(existing.id), - session, - minimized: existing.minimized, - }, - }); - return; - } - - const target = resolveVisibleSurface(stringParam(params.surface), detail.surfaceId); - if (!target.ok) { - detail.respond({ ok: false, error: target.message }); - return; - } - const result = createContentSurface({ + const result = ensureAgentBrowserSurface({ + key: stringParam(params.key), + session, + wsPort: numberParam(params.wsPort), + binaryPath: stringParam(params.binaryPath), + reference: () => resolveVisibleSurface(stringParam(params.surface), detail.surfaceId), minimized: booleanParam(params.minimized), - params: { - surfaceType: 'browser', - renderMode: 'ab-screencast', - session, - ...(key !== undefined ? { key } : {}), - ...refreshedParams, - }, - reference: target.value, - title: key ?? session, - // `dor ab` opens the screencast in the background; caller keeps focus. - focusNeutral: true, }); if (!result.ok) { detail.respond({ ok: false, error: result.message }); @@ -786,11 +909,11 @@ export function useDorControl({ detail.respond({ ok: true, result: { - status: result.value.status, - surfaceId: result.value.id, - surfaceRef: result.value.ref, + status: result.status, + surfaceId: result.surfaceId, + surfaceRef: result.surfaceRef, session, - minimized: booleanParam(params.minimized), + minimized: result.minimized, }, }); return; @@ -809,40 +932,28 @@ export function useDorControl({ } catch { ports = []; } - const tcpPorts = ports.filter((entry) => entry.protocol === 'tcp'); - // Group every TCP listener by port. A loopback-reachable binding wins - // for the localhost URL; otherwise use the bound LAN/Tailnet address. - const candidatePorts = [...new Set(tcpPorts.map((entry) => entry.port))].sort((a, b) => a - b); - if (candidatePorts.length === 0) { + // Group every TCP listener into one openable URL per distinct port + // (loopback-reachable bind wins localhost; otherwise the bound + // LAN/Tailnet address). Shared with the pane context menu's port list. + const entries = listenerUrlsByPort(ports); + if (entries.length === 0) { detail.respond({ ok: false, error: `surface '${target.ref}' is not serving any port` }); return; } - if (candidatePorts.length > 1) { + if (entries.length > 1) { detail.respond({ ok: false, - error: `surface '${target.ref}' is serving multiple ports (${candidatePorts.join(', ')}); open one explicitly, e.g. http://localhost:${candidatePorts[0]}`, + error: `surface '${target.ref}' is serving multiple ports (${entries.map((entry) => entry.port).join(', ')}); open one explicitly, e.g. http://localhost:${entries[0].port}`, }); return; } - const port = candidatePorts[0]; - const portListeners = tcpPorts.filter((entry) => entry.port === port); - const loopbackListener = portListeners.find((entry) => servesLoopback(entry.address)); - const selectedListener = loopbackListener ?? [...portListeners].sort((a, b) => { - if (a.family !== b.family) return a.family === 'IPv4' ? -1 : 1; - return a.address < b.address ? -1 : a.address > b.address ? 1 : 0; - })[0]; - const host = loopbackListener - ? 'localhost' - : selectedListener.family === 'IPv6' - ? `[${selectedListener.address}]` - : selectedListener.address; detail.respond({ ok: true, result: { surfaceId: target.id, surfaceRef: target.ref, - port, - url: `http://${host}:${port}/`, + port: entries[0].port, + url: entries[0].url, }, }); return; @@ -853,5 +964,7 @@ export function useDorControl({ window.addEventListener('dormouse:control-request', handler); return () => window.removeEventListener('dormouse:control-request', handler); - }, [buildDorSurfaces, buildDorSurfaceList, createContentSurface, createSplitSurface, findAgentBrowserSurface, findSurfaceIdRunningCommand, killPaneImmediately, requireListedSurface, requireTerminalSurface, resolveListedSurface, resolveVisibleSurface, surfaceRefForId, lath, nav]); + }, [buildDorSurfaces, buildDorSurfaceList, createContentSurface, createSplitSurface, ensureAgentBrowserSurface, findSurfaceIdRunningCommand, killPaneImmediately, requireListedSurface, requireTerminalSurface, resolveListedSurface, resolveVisibleSurface, surfaceRefForId, lath, nav]); + + return { connectPort }; } diff --git a/lib/src/components/wall/wall-context.tsx b/lib/src/components/wall/wall-context.tsx index b84d7aef..686f324f 100644 --- a/lib/src/components/wall/wall-context.tsx +++ b/lib/src/components/wall/wall-context.tsx @@ -49,6 +49,14 @@ export interface WallActions { * window.open, surfaced by the proxy shim) becomes a new pane * (docs/specs/dor-browser.md → "Iframe Shim"). */ onOpenBrowserPane?: (id: string, url: string) => void; + /** The stable `surface:N` ref for a pane/door id (minted lazily, exactly as + * `dor list` assigns refs). Used by the pane context menu to show the handle. */ + resolveSurfaceRef: (id: string) => string; + /** Act like `dor ab open ` for a port the pane's process tree binds: create + * the default agent-browser surface immediately, then open the URL on its + * session and connect the pane (`connect-port.ts`). Fire-and-forget — failures + * are logged, and the pane itself shows loading state. */ + onConnectPort: (id: string, url: string) => void; } export const WallActionsContext = createContext({ @@ -66,6 +74,8 @@ export const WallActionsContext = createContext({ onCancelRename: () => {}, onSwapRenderMode: () => {}, onOpenBrowserPane: () => {}, + resolveSurfaceRef: (id: string) => id, + onConnectPort: () => {}, }); /** Engine-directed writes from a pane/header (title + params). The read side is diff --git a/lib/src/components/wall/wall-test-utils.ts b/lib/src/components/wall/wall-test-utils.ts new file mode 100644 index 00000000..1b3e9cb1 --- /dev/null +++ b/lib/src/components/wall/wall-test-utils.ts @@ -0,0 +1,34 @@ +import { vi } from 'vitest'; +import type { WallActions } from './wall-context'; + +/** The full `WallActions` surface as inert vi.fn stubs, so a new member is one + * edit here instead of one per component test. */ +export function stubWallActions(overrides: Partial = {}): WallActions { + return { + onKill: vi.fn(), + onMinimize: vi.fn(), + onAlertButton: vi.fn(() => 'noop'), + onToggleTodo: vi.fn(), + onSplitH: vi.fn(), + onSplitV: vi.fn(), + onZoom: vi.fn(), + onClickPanel: vi.fn(), + onFocusPane: vi.fn(), + onStartRename: vi.fn(), + onFinishRename: vi.fn(() => ({ accepted: true })), + onCancelRename: vi.fn(), + onSwapRenderMode: vi.fn(), + resolveSurfaceRef: vi.fn((id: string) => id), + onConnectPort: vi.fn(), + ...overrides, + }; +} + +/** jsdom lacks ResizeObserver; the pane headers' responsive-tier observer needs it. */ +export function ensureResizeObserver(): void { + globalThis.ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; +} diff --git a/lib/src/stories/BrowserChromeHeader.stories.tsx b/lib/src/stories/BrowserChromeHeader.stories.tsx index e80f7f10..8d94a643 100644 --- a/lib/src/stories/BrowserChromeHeader.stories.tsx +++ b/lib/src/stories/BrowserChromeHeader.stories.tsx @@ -46,6 +46,8 @@ const loggingActions: WallActions = { onFinishRename: () => ({ accepted: true }), onCancelRename: () => {}, onSwapRenderMode: (id, mode) => console.log('[story] swap render', id, mode), + resolveSurfaceRef: (id) => id, + onConnectPort: (id, url) => console.log('[story] connect port', id, url), }; interface StoryArgs { diff --git a/lib/src/stories/MouseHeaderIcon.stories.tsx b/lib/src/stories/MouseHeaderIcon.stories.tsx index 1d15a7d6..aa92e8c0 100644 --- a/lib/src/stories/MouseHeaderIcon.stories.tsx +++ b/lib/src/stories/MouseHeaderIcon.stories.tsx @@ -33,6 +33,8 @@ const noopActions: WallActions = { onFinishRename: () => ({ accepted: true }), onCancelRename: () => {}, onSwapRenderMode: () => {}, + resolveSurfaceRef: (id) => id, + onConnectPort: () => {}, }; function MouseIconStoryFrame({ diff --git a/lib/src/stories/ShellCwd.stories.tsx b/lib/src/stories/ShellCwd.stories.tsx index 8bfc8a90..6fe816af 100644 --- a/lib/src/stories/ShellCwd.stories.tsx +++ b/lib/src/stories/ShellCwd.stories.tsx @@ -53,6 +53,8 @@ const noopActions: WallActions = { onFinishRename: () => ({ accepted: true }), onCancelRename: () => {}, onSwapRenderMode: () => {}, + resolveSurfaceRef: (id) => id, + onConnectPort: () => {}, }; const meta: Meta = { @@ -121,16 +123,16 @@ export const TitleFallbacksAndPinnedTitles: Story = storyFor([ caseState('title-long-user', 'Long user title', idle({ cwd: manual('/repo/app'), title: terminalTitle('my-extremely-long-running-background-process-with-a-very-descriptive-name', 'user') }), 'Truncates before controls'), ]); -export const TitleCandidatePopup: Story = { +export const TitleCandidatesInHeaderMenu: Story = { ...storyFor([ caseState( 'title-candidates-popup', - 'Title candidates popup', + 'Title candidates in header menu', titleCandidateState(), - 'Right-click popup lists each latest title channel', + 'Right-click menu embeds the latest title per channel above the port rows', ), ]), - play: openTitleCandidatesPopup, + play: openHeaderContextMenu, }; export const GroupingKeys: Story = storyFor([ @@ -387,9 +389,9 @@ function wait(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } -async function openTitleCandidatesPopup() { +async function openHeaderContextMenu() { await wait(100); - const title = document.querySelector('[data-title-candidates-for="title-candidates-popup"]'); + const title = document.querySelector('[data-pane-title-for="title-candidates-popup"]'); if (!title) return; const rect = title.getBoundingClientRect(); diff --git a/lib/src/stories/TerminalPaneHeader.stories.tsx b/lib/src/stories/TerminalPaneHeader.stories.tsx index c3b57e5d..c509cd7a 100644 --- a/lib/src/stories/TerminalPaneHeader.stories.tsx +++ b/lib/src/stories/TerminalPaneHeader.stories.tsx @@ -29,6 +29,8 @@ const noopActions: WallActions = { onFinishRename: () => ({ accepted: true }), onCancelRename: () => {}, onSwapRenderMode: () => {}, + resolveSurfaceRef: (id) => id, + onConnectPort: () => {}, }; function actionsRejecting(reason: 'empty' | 'reserved'): WallActions { diff --git a/lib/tsconfig.app.json b/lib/tsconfig.app.json index cf3a4c0b..2261411a 100644 --- a/lib/tsconfig.app.json +++ b/lib/tsconfig.app.json @@ -23,5 +23,6 @@ "include": ["src"], // The pure rewrite helpers typecheck here (DOM provides URL); the Node proxy // server is esbuild-only (bundled per host), like the rest of our host code. - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/host/iframe-proxy.ts", "src/host/agent-browser-host.ts"] + // wall-test-utils imports vitest, so it lives with the tests, not the app. + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/components/wall/wall-test-utils.ts", "src/host/iframe-proxy.ts", "src/host/agent-browser-host.ts"] } diff --git a/lib/vite.config.ts b/lib/vite.config.ts index 4b2173d8..33ccc141 100644 --- a/lib/vite.config.ts +++ b/lib/vite.config.ts @@ -12,6 +12,10 @@ export default defineConfig({ // path; Vite (and vitest) do not read tsconfig paths, and `dor` has no // package exports, so resolve it to source — the same alias standalone uses. dor: path.resolve(__dirname, "../dor/src"), + // `connect-port.ts` imports `dor-lib-common/agent-browser`; that package's + // `exports` resolve to a `dist` a vitest run has no reason to have built. + // Alias to source so the tests never depend on build order. + "dor-lib-common": path.resolve(__dirname, "../dor-lib-common/src"), }, }, }); diff --git a/lib/vite.pocket.config.ts b/lib/vite.pocket.config.ts index 2760637e..2e3fb2ba 100644 --- a/lib/vite.pocket.config.ts +++ b/lib/vite.pocket.config.ts @@ -22,6 +22,9 @@ export default defineConfig({ // step to generate it). Alias to source, same as the website and // Storybook configs. "server-lib-common": fileURLToPath(new URL("../server-lib-common/src", import.meta.url)), + // `dor-lib-common` has the same unbuilt-`dist` exports problem, reached via + // `Wall` → `useDorControl` → `connect-port`. + "dor-lib-common": fileURLToPath(new URL("../dor-lib-common/src", import.meta.url)), }, }, build: { diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index acf8746b..cfd735a8 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -315,6 +315,15 @@ fn request_from_sidecar( request_from_sidecar_timeout(state, event, data, Duration::from_secs(1)) } +/// INVARIANT: every `#[tauri::command]` that reaches these two blocking helpers +/// must be declared `#[tauri::command(async)]` (or be an `async fn`). Tauri runs +/// a plain sync command on the **main thread**, where the `recv_timeout` below +/// stops the webview from painting for the whole round trip — up to +/// `AGENT_BROWSER_TIMEOUT` (30s) for a hung agent-browser, and a visible ~3s +/// freeze on a cold `agent-browser open`, which is long enough to look like a +/// pane that never appeared. `(async)` moves the same blocking body onto a +/// runtime worker, so the UI keeps rendering while the sidecar works. + fn request_from_sidecar_timeout( state: &SidecarState, event: &str, @@ -417,7 +426,7 @@ fn dor_control_response(state: tauri::State<'_, SidecarState>, response: DorCont send_to_sidecar(&state, msg.to_string()); } -#[tauri::command] +#[tauri::command(async)] fn pty_get_cwd( state: tauri::State<'_, SidecarState>, id: String, @@ -431,7 +440,7 @@ fn pty_get_cwd( // Mirrors `OPEN_PORT_TIMEOUT_MS` in `lib/src/lib/platform/types.ts` — keep in sync. const OPEN_PORT_TIMEOUT_MS: u64 = 3000; -#[tauri::command] +#[tauri::command(async)] fn pty_get_open_ports( state: tauri::State<'_, SidecarState>, id: String, @@ -448,7 +457,7 @@ fn pty_get_open_ports( .unwrap_or_else(|| JsonValue::Array(Vec::new()))) } -#[tauri::command] +#[tauri::command(async)] fn pty_get_scrollback( state: tauri::State<'_, SidecarState>, id: String, @@ -481,7 +490,7 @@ async fn pty_graceful_kill_all( // Stands up the loopback iframe proxy in the sidecar and returns the // IframeProxyResult JSON the webview's IframePanel expects. The proxy server is // the shared lib/src/host/iframe-proxy.ts; this only bridges the request. -#[tauri::command] +#[tauri::command(async)] fn iframe_create_proxy_url( state: tauri::State<'_, SidecarState>, target: String, @@ -514,7 +523,7 @@ fn agent_browser_forward( Ok(response.get("result").cloned().unwrap_or(JsonValue::Null)) } -#[tauri::command] +#[tauri::command(async)] fn agent_browser_command( state: tauri::State<'_, SidecarState>, session: String, @@ -528,7 +537,7 @@ fn agent_browser_command( ) } -#[tauri::command] +#[tauri::command(async)] fn agent_browser_edit( state: tauri::State<'_, SidecarState>, session: String, @@ -542,7 +551,7 @@ fn agent_browser_edit( ) } -#[tauri::command] +#[tauri::command(async)] fn agent_browser_stream_status( state: tauri::State<'_, SidecarState>, session: String, @@ -555,7 +564,7 @@ fn agent_browser_stream_status( ) } -#[tauri::command] +#[tauri::command(async)] fn agent_browser_open( state: tauri::State<'_, SidecarState>, url: String, @@ -570,7 +579,7 @@ fn agent_browser_open( } // `rect` is accepted by the adapter but unused — no window positioning today. -#[tauri::command] +#[tauri::command(async)] fn agent_browser_pop_out( state: tauri::State<'_, SidecarState>, session: String, @@ -584,7 +593,7 @@ fn agent_browser_pop_out( ) } -#[tauri::command] +#[tauri::command(async)] fn agent_browser_pop_in( state: tauri::State<'_, SidecarState>, session: String, @@ -604,7 +613,7 @@ fn agent_browser_pop_in( // decodes with createImageBitmap). A base64 `bytesBase64` field is kept as a // fallback for a stale sidecar bundle (dev-time version skew), but the path // branch is preferred. -#[tauri::command] +#[tauri::command(async)] fn agent_browser_screenshot( state: tauri::State<'_, SidecarState>, session: String, @@ -923,7 +932,7 @@ struct ShellInfo { args: Vec, } -#[tauri::command] +#[tauri::command(async)] fn get_available_shells(state: tauri::State<'_, SidecarState>) -> Result, String> { let response = request_from_sidecar_timeout(&state, "pty:getShells", serde_json::json!({}), Duration::from_secs(10))?; let shells: Vec = response diff --git a/standalone/tsconfig.json b/standalone/tsconfig.json index 29485e94..b84ec2b4 100644 --- a/standalone/tsconfig.json +++ b/standalone/tsconfig.json @@ -18,7 +18,9 @@ "paths": { "dormouse-lib/*": ["../lib/src/*"], "dor/*": ["../dor/src/*"], - "server-lib-common": ["../server-lib-common/src/index.ts"] + "server-lib-common": ["../server-lib-common/src/index.ts"], + "dor-lib-common": ["../dor-lib-common/src/index.ts"], + "dor-lib-common/agent-browser": ["../dor-lib-common/src/agent-browser.ts"] } }, "include": ["src"] diff --git a/website/vite.config.ts b/website/vite.config.ts index a0e7e70d..fc05662f 100644 --- a/website/vite.config.ts +++ b/website/vite.config.ts @@ -16,6 +16,10 @@ export default defineConfig(({ mode }) => ({ // `server-lib-common`, whose package `exports` resolve to a `dist` this // build never compiles. Alias it to source, exactly like `dormouse-lib`. "server-lib-common": path.resolve(__dirname, "../server-lib-common/src"), + // Same story for `dor-lib-common`: `Wall` → `useDorControl` → `connect-port` + // imports its `./agent-browser` subpath. The directory alias covers both + // that subpath and the bare specifier. + "dor-lib-common": path.resolve(__dirname, "../dor-lib-common/src"), // Wall also imports `dor/*` (protocol + command types); `dor` has no // package `exports`, and vite does not read tsconfig paths, so resolve it // to source — the same alias lib and standalone use.