Skip to content

Commit 818c920

Browse files
authored
dor agent-browser: browser-chrome header (nav + URL + dev-server link) (#141)
2 parents e9d50e8 + 3d0eb63 commit 818c920

30 files changed

Lines changed: 1951 additions & 253 deletions

docs/specs/dor-agent-browser.md

Lines changed: 121 additions & 113 deletions
Large diffs are not rendered by default.

docs/specs/dor-cli.md

Lines changed: 3 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -133,57 +133,6 @@ Invariants:
133133
- Workspace/window refs and target flags will be added only when Dormouse
134134
actually supports them.
135135

136-
## Iframe Surface: Limitations And Status
137-
138-
> Status: **provisional.** The `dor iframe` surface works for displaying a page,
139-
> but has structural limitations (below) that may keep it from shipping. The
140-
> likely path forward is to ship an **agent-browser** surface instead — a
141-
> Dormouse-controlled browser view (see `lib/src/components/wall/AgentBrowserPanel.tsx`)
142-
> that does not embed a foreign page as a peer browsing context — and to drop or
143-
> hide `dor iframe`. That surface is specified in
144-
> [dor-agent-browser.md](dor-agent-browser.md). Do not build features on top of
145-
> the iframe surface until this is resolved.
146-
147-
An `<iframe>` pointed at an arbitrary URL is a **separate browsing context** from
148-
the Wall. Dormouse's input, focus, and attention model assumes a single
149-
same-document context, so the iframe surface conflicts with it in ways that are
150-
inherent to the browser, not bugs we can patch away:
151-
152-
- **Focus leaves Dormouse entirely.** When the iframe gains focus, a focused
153-
cross-origin frame owns the keyboard. Its keystrokes fire in *its* document and
154-
never reach the parent. Dormouse's global shortcuts are a capturing `window`
155-
keydown listener (`lib/src/components/wall/use-wall-keyboard.ts`), so dual-tap-⌘,
156-
pane navigation, split, and kill all go dead until focus returns to the Wall.
157-
The same-origin policy means the parent cannot observe or intercept those keys —
158-
this cannot be fixed, only designed around (e.g. a click-to-interact overlay, or
159-
an accept-focus model with a mouse-driven escape affordance).
160-
161-
- **The app reads iframe-focus as being backgrounded.** Focusing the iframe fires
162-
a `blur` on the parent `window`. Current handlers treat that as the whole app
163-
losing focus: `Wall.tsx` clears cross-session attention, and
164-
`use-window-focused.ts` flips `windowFocused` to `false`, which drives the
165-
active styling (e.g. `SurfacePaneHeader.tsx`'s `isActiveHeader`). The result is
166-
every header/focus-ring goes inactive and attention clears the instant the
167-
iframe is focused. This part *is* fixable (distinguish `document.activeElement`
168-
being one of our own iframes from a real window blur) but is not yet done.
169-
170-
- **No programmatic focus handle.** `focusSession` (`lib/src/lib/terminal-lifecycle.ts`)
171-
only knows xterm terminals in a registry. The iframe pane is not registered, so
172-
`onClickPanel → enterTerminalMode → focusSession(iframeId)` is a no-op: Dormouse
173-
cannot focus the iframe programmatically and cannot tell when it is focused.
174-
175-
- **Some sites refuse to be framed, with no error signal.** Servers that send
176-
`X-Frame-Options` or a CSP `frame-ancestors` directive cannot be embedded at
177-
all, yielding a blank pane. Cross-origin frames do not report load errors to the
178-
embedder (`onError` never fires; `onLoad` fires even for a blocked frame), so the
179-
surface cannot reliably distinguish "loading", "blocked", and "broken". The
180-
current `IframePanel` shows a best-effort stall hint after a timeout only.
181-
182-
- **The VS Code webview must opt into framing.** The webview CSP
183-
(`vscode-ext/src/webview-html.ts`) is `default-src 'none'`; without a `frame-src`
184-
directive every `<iframe>` is blocked outright (blank white pane). A
185-
`frame-src http: https:` allowance is required for the surface to render at all.
186-
187136
## Current Implemented Commands
188137

189138
Implemented commands call private `surface.*` control methods. `surface.list`
@@ -215,7 +164,9 @@ from `command-detail`.
215164
- `dor send` [impl](../../dor/src/commands/send.ts) [docs](../../dor/test/snapshots/help/send.md)
216165
- `dor read` [impl](../../dor/src/commands/read.ts) [docs](../../dor/test/snapshots/help/read.md)
217166
- `dor kill` [impl](../../dor/src/commands/kill.ts) [docs](../../dor/test/snapshots/help/kill.md)
218-
- `dor iframe` [impl](../../dor/src/commands/iframe.ts) [docs](../../dor/test/snapshots/help/iframe.md)
167+
- `dor iframe`**provisional**; high-fidelity URL embed with structural
168+
limitations, see [dor-iframe.md](dor-iframe.md).
169+
[impl](../../dor/src/commands/iframe.ts) [docs](../../dor/test/snapshots/help/iframe.md)
219170
- `dor agent-browser` / `dor ab` — delegates to the user's `agent-browser`,
220171
rendered in a Dormouse-native surface; see [dor-agent-browser.md](dor-agent-browser.md)
221172
(the chosen alternative to the iframe surface)

docs/specs/dor-iframe.md

Lines changed: 324 additions & 0 deletions
Large diffs are not rendered by default.

lib/.storybook/main.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ const config: StorybookConfig = {
1919
'@tauri-apps/plugin-shell': stub,
2020
'@tauri-apps/plugin-updater': stub,
2121
'dormouse-lib': path.resolve(here, '..', 'src'),
22+
// Mirror tsconfig.app.json's `dor/* → ../dor/src/*` mapping so stories
23+
// that import `Wall` (which pulls `dor/commands/*`, `dor/protocol`)
24+
// resolve. Storybook's Vite doesn't read tsconfig paths, so without this
25+
// any Wall-importing story fails with "Failed to resolve import 'dor/…'".
26+
// Safe next to `dormouse-lib`: a string alias only matches `dor` or `dor/…`.
27+
dor: path.resolve(here, '..', '..', 'dor', 'src'),
2228
};
2329
return config;
2430
},

lib/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"preview": "vite preview",
1111
"test": "vitest run",
1212
"test:watch": "vitest",
13-
"storybook": "storybook dev -p 6006",
13+
"storybook": "storybook dev -p 6006 --no-open",
1414
"build-storybook": "storybook build"
1515
},
1616
"dependencies": {

lib/src/components/Baseboard.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,12 @@ import { IS_MAC } from '../lib/platform';
77
import {
88
buildAppTitleResolver,
99
DEFAULT_ACTIVITY_STATE,
10-
deriveHeader,
1110
getActivitySnapshot,
1211
getTerminalPaneStateSnapshot,
13-
resolveDisplayPrimary,
1412
subscribeToActivity,
1513
subscribeToTerminalPaneState,
1614
} from '../lib/terminal-registry';
17-
import { createTerminalPaneState, type TerminalPaneState } from '../lib/terminal-state';
15+
import { createTerminalPaneState, deriveSurfaceLabel, type TerminalPaneState } from '../lib/terminal-state';
1816

1917
export interface BaseboardProps {
2018
items: DooredItem[];
@@ -227,5 +225,5 @@ function deriveDoorTitle(
227225
): string {
228226
const paneState = terminalStates.get(id) ?? createTerminalPaneState();
229227
const visible = allPaneStates.length > 0 ? allPaneStates : [paneState];
230-
return resolveDisplayPrimary(deriveHeader(paneState, visible, { appTitleForPane }).primary, savedTitle);
228+
return deriveSurfaceLabel(paneState, visible, appTitleForPane, savedTitle);
231229
}

lib/src/components/MobileTerminalUi.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
type MobileGestureTrackingState,
4040
} from '../lib/mobile-gesture-menu';
4141
import { useDynamicPalette } from '../lib/themes/use-dynamic-palette';
42+
import { isEditableTarget } from '../lib/dom';
4243
import { TouchUiContext } from './touch-ui-context';
4344
import type { SessionStatus } from '../lib/terminal-registry';
4445

@@ -508,13 +509,7 @@ export function MobileTerminalUi({
508509
const active = document.activeElement;
509510
if (!(active instanceof HTMLElement)) return;
510511
if (!terminalHostRef.current?.contains(active)) return;
511-
if (
512-
active instanceof HTMLInputElement
513-
|| active instanceof HTMLTextAreaElement
514-
|| active.isContentEditable
515-
) {
516-
active.blur();
517-
}
512+
if (isEditableTarget(active)) active.blur();
518513
};
519514
// Wall defers xterm focus via rAF, so a single blur can be reverted after we
520515
// return; repeat across rAF and a few staggered ticks. See mobile-ui.md §10.

lib/src/components/Wall.tsx

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,7 @@ import {
3333
import {
3434
buildAppTitleResolver,
3535
createTerminalPaneState,
36-
deriveHeader,
37-
resolveDisplayPrimary,
36+
deriveSurfaceLabel,
3837
surfaceRunsCommand,
3938
} from '../lib/terminal-state';
4039
import { orchestrateKill } from '../lib/kill-animation';
@@ -56,12 +55,14 @@ import { TerminalPanel } from './wall/TerminalPanel';
5655
import { TerminalPaneHeader } from './wall/TerminalPaneHeader';
5756
import { AgentBrowserPanel } from './wall/AgentBrowserPanel';
5857
import { IframePanel } from './wall/IframePanel';
58+
import { hostPathDisplay } from './wall/browser-url';
5959
import { SurfacePaneHeader } from './wall/SurfacePaneHeader';
6060
import { WorkspaceSelectionOverlay } from './wall/WorkspaceSelectionOverlay';
6161
import { useDockviewReady } from './wall/use-dockview-ready';
6262
import { pickSplitDirection } from './wall/dockview-helpers';
6363
import { useWallKeyboard } from './wall/use-wall-keyboard';
6464
import { useSessionPersistence } from './wall/use-session-persistence';
65+
import { useDevServerPortCorrelation } from './wall/use-dev-server-ports';
6566
import { useWindowFocused } from './wall/use-window-focused';
6667
import {
6768
DialogKeyboardContext,
@@ -171,16 +172,6 @@ function surfaceTypeFromParams(params: unknown): DorSurfaceType {
171172
return 'terminal';
172173
}
173174

174-
function iframeTitle(url: string): string {
175-
try {
176-
const parsed = new URL(url);
177-
const path = parsed.pathname === '/' ? '' : parsed.pathname;
178-
return `${parsed.host}${path}${parsed.search}`;
179-
} catch {
180-
return url;
181-
}
182-
}
183-
184175
function componentForSurfaceType(type: DorSurfaceType): string {
185176
return type;
186177
}
@@ -641,6 +632,9 @@ export function Wall({
641632
selectedTypeRef,
642633
});
643634

635+
// --- Dev-server port → pane correlation (browser header connection chip) ---
636+
useDevServerPortCorrelation({ apiRef, doorsRef });
637+
644638
// --- Reattach ---
645639

646640
const handleReattach = useCallback((
@@ -762,9 +756,8 @@ export function Wall({
762756
return panels.map((panel, index) => {
763757
const type = surfaceTypeFromParams(panel.params);
764758
const state = panelStates[index] ?? createTerminalPaneState();
765-
const derived = deriveHeader(state, panelStates, { appTitleForPane });
766759
const title = type === 'terminal'
767-
? resolveDisplayPrimary(derived.primary, panel.title ?? panel.id)
760+
? deriveSurfaceLabel(state, panelStates, appTitleForPane, panel.title ?? panel.id)
768761
: (panel.title ?? panel.id);
769762

770763
return {
@@ -1296,7 +1289,7 @@ export function Wall({
12961289
minimized: booleanParam(params.minimized),
12971290
params: { surfaceType: 'iframe', url },
12981291
reference: target.value,
1299-
title: iframeTitle(url),
1292+
title: hostPathDisplay(url, true),
13001293
});
13011294
if (!result.ok) {
13021295
detail.respond({ ok: false, error: result.message });
@@ -1471,6 +1464,16 @@ export function Wall({
14711464
setConfirmKill(null);
14721465
enterTerminalMode(id);
14731466
},
1467+
onFocusPane: (id: string) => {
1468+
setConfirmKill(null);
1469+
// Visible pane → jump straight in; minimized (a door) → reattach first.
1470+
if (apiRef.current?.getPanel(id)) {
1471+
enterTerminalMode(id);
1472+
return;
1473+
}
1474+
const door = doorsRef.current.find((item) => item.id === id);
1475+
if (door) handleReattachRef.current(door, { enterPassthrough: true });
1476+
},
14741477
onStartRename: (id: string) => {
14751478
setRenamingPaneId(id);
14761479
},

lib/src/components/wall/AgentBrowserPanel.tsx

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,18 @@ import { clsx } from 'clsx';
1212
import { TERMINAL_BOTTOM_RADIUS_CLASS } from '../design';
1313
import { getPlatform } from '../../lib/platform';
1414
import { readTextFromClipboard } from '../../lib/clipboard';
15+
import { isEditableTarget } from '../../lib/dom';
1516
import {
1617
openAgentBrowserScreenModal,
1718
registerAgentBrowserScreen,
19+
type ChromeActions,
20+
type ChromeSnapshot,
1821
type ScreenActions,
1922
type ScreenRegistration,
2023
type ScreenSnapshot,
2124
type ScreenState,
2225
} from './agent-browser-screen';
26+
import { hostPathDisplay } from './browser-url';
2327
import {
2428
EDIT_OPS,
2529
MOUSE_BUTTONS,
@@ -84,13 +88,7 @@ type StreamStatus = {
8488
function tabDisplayTitle(tab: StreamTab): string {
8589
const title = tab.title?.trim();
8690
if (title) return title;
87-
try {
88-
const parsed = new URL(tab.url);
89-
const path = parsed.pathname === '/' ? '' : parsed.pathname;
90-
return `${parsed.host}${path}` || tab.url;
91-
} catch {
92-
return tab.url || 'untitled';
93-
}
91+
return hostPathDisplay(tab.url) || 'untitled';
9492
}
9593

9694
// Decode a base64 screencast frame to an ImageBitmap (the fallback display path
@@ -397,12 +395,38 @@ export function AgentBrowserPanel({ api, params }: IDockviewPanelProps<AgentBrow
397395
};
398396
}, [wsPort, runAgentBrowser, maybeDisengageSync, publishScreen, screenshotLoop, drawBitmap]);
399397

400-
// --- header title follows the active tab ---
398+
// --- header: persisted title + browser-chrome (URL / key) ---
401399

400+
const activeTab = useMemo(() => tabs.find((tab) => tab.active) ?? tabs[0] ?? null, [tabs]);
401+
402+
// The persisted panel title (door labels, session save) stays the active
403+
// tab's display title; the live browser-chrome header shows the URL instead
404+
// (from the snapshot below) and demotes this title to a tooltip.
402405
useEffect(() => {
403-
const active = tabs.find((tab) => tab.active) ?? tabs[0];
404-
if (active) api.setTitle(tabDisplayTitle(active));
405-
}, [tabs, api]);
406+
if (activeTab) api.setTitle(tabDisplayTitle(activeTab));
407+
}, [activeTab, api]);
408+
409+
// The browser-chrome snapshot the header reads. Recomputed each render and
410+
// mirrored into a ref so a (re-)registration always seeds the latest; the
411+
// publish effect below pushes it to subscribers only on real changes.
412+
const chromeSnapshot: ChromeSnapshot = {
413+
url: activeTab?.url ?? '',
414+
displayUrl: activeTab ? hostPathDisplay(activeTab.url) : '',
415+
title: activeTab?.title ?? null,
416+
key: params?.key ?? null,
417+
};
418+
const chromeSnapshotRef = useRef(chromeSnapshot);
419+
chromeSnapshotRef.current = chromeSnapshot;
420+
421+
// Native history nav — `back`/`forward`/`reload` issued like tab actions
422+
// (allowlisted in agentBrowserCommand). Stable; reads the live closure via
423+
// the ref so the registered controller never goes stale.
424+
const chromeActions = useMemo<ChromeActions>(() => ({
425+
navigate(url) { if (url) runAgentBrowserRef.current(['open', url]); },
426+
back() { runAgentBrowserRef.current(['back']); },
427+
forward() { runAgentBrowserRef.current(['forward']); },
428+
reload() { runAgentBrowserRef.current(['reload']); },
429+
}), []);
406430

407431
// --- screen controller: register for the header/modal bridge ---
408432

@@ -437,6 +461,8 @@ export function AgentBrowserPanel({ api, params }: IDockviewPanelProps<AgentBrow
437461
const registration = registerAgentBrowserScreen(api.id, {
438462
snapshot: computeSnapshot(),
439463
actions: screenActions,
464+
chrome: chromeSnapshotRef.current,
465+
chromeActions,
440466
hostCapable: !!getPlatform().agentBrowserCommand,
441467
});
442468
registrationRef.current = registration;
@@ -446,7 +472,15 @@ export function AgentBrowserPanel({ api, params }: IDockviewPanelProps<AgentBrow
446472
registration.dispose();
447473
registrationRef.current = null;
448474
};
449-
}, [api.id, screenActions, computeSnapshot, publishScreen]);
475+
}, [api.id, screenActions, chromeActions, computeSnapshot, publishScreen]);
476+
477+
// Push browser-chrome changes to the header on a channel separate from the
478+
// screen snapshot. Gated on the primitive fields so it fires on real
479+
// tab/status changes, not every frame pulse.
480+
useEffect(() => {
481+
registrationRef.current?.updateChrome(chromeSnapshotRef.current);
482+
// displayUrl is a pure function of url, so url covers it.
483+
}, [chromeSnapshot.url, chromeSnapshot.title, chromeSnapshot.key]);
450484

451485
// Persist sync state into the panel params so it round-trips through the
452486
// dockview layout blob (and survives reattach). Skip no-op writes.
@@ -729,6 +763,9 @@ export function AgentBrowserPanel({ api, params }: IDockviewPanelProps<AgentBrow
729763
// contains() check above misses it; without this, typing into the modal's
730764
// Custom W/H/DPI fields would be swallowed and forwarded to the browser.
731765
if (e.target instanceof Element && e.target.closest('[role="dialog"]')) return;
766+
// Likewise never hijack keystrokes destined for an editable field that
767+
// lives outside the pane — notably the header's URL editor.
768+
if (isEditableTarget(e.target)) return;
732769
e.preventDefault();
733770
if (e.type === 'keydown') handleKeyDownLike(e);
734771
else sendKey(e, 'keyUp');

0 commit comments

Comments
 (0)