From b7e263bb23cdc98a710d1206abd00b3e5571aea5 Mon Sep 17 00:00:00 2001 From: serafin-garcia Date: Thu, 30 Jul 2026 20:32:45 -0700 Subject: [PATCH] Split terminal and agents into separate panels (#3049) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ok): split terminal and agents panels * fix(ok): let terminal tabs launch a CLI again The split gave terminal hosts `showClis={false}` and pinned their New choice to a bare shell, which removed the whole dropdown (`hasMenu` also goes false with no agent rows) from both the bottom dock and the standalone terminal window. Picking a TUI was the point of that menu, so restore it: the CLI + Terminal rows are back, the pick sticks, and the primary, ⌘J reveal-seed and ⇧⌘J all repeat it. One deliberate departure from the pre-split behavior. `resolveLauncherSelection` falls back to the first enabled CLI when nothing is picked — right for a composer, wrong for the terminal dock, where it would drop a user who never chose a TUI straight into one. A CLI now counts only as an explicit remembered pick; every other outcome is a plain shell. ⇧⌘J needed its own resolution rather than reusing the Ask-AI one, which turns the bare-terminal knobs off on purpose: a passage needs an AI, but a promptless new session may legitimately be a shell. Reusing it would have made "pick Terminal, press ⇧⌘J" open an agent. `claimsAskAi` serves both callers now, so it is `claimsSessionKind`. * feat(ok): move the edge reveal tab to the agents panel Exactly one panel should advertise itself with a persistent edge tab, and it should be the agents panel: an agent conversation is the thing worth a one-click return, and a chord is not discoverable. It is no longer gated on having conversations — a never-opened panel now shows the tab too, since its cold entry (the New button) is a fine place to land. The terminal loses its tab. It is a ⌘J surface, and a second permanent tab over the editor footer's bottom-right competed with the Ask AI composer for that corner. That cascades out a fair amount of now-dead wiring: TerminalDock's onReveal/showRevealTab, EditorArea's onRevealTerminal and terminalAvailable, EditorPane's revealTerminal, the whole agentsHasSessions chain, and EditorFooter's reserveRightGutter (which existed only to pad clear of that tab). The bottom Ask AI composer now also hides while the agents panel is open. Unlike the terminal it does not overlap the composer, so this is about redundancy rather than real estate: an open panel is already a place to type the request. The gate's own test was not catching the new required input — packages/app excludes *.test.ts* from tsc, so the missing field read as undefined (falsy) and passed. Added explicitly, with hide-cases for both predicates. * fix(ok): drop the orphaned onHasSessionsChange prop, bump changeset to minor Removing the agents reveal tab's has-sessions gate took out the prop's only consumer, leaving the declaration, destructure and effect firing into an always-undefined callback — with JSDoc still claiming column visibility is session-presence-driven, which is no longer true of either panel. The changeset also declared `patch`. Per .changeset/README.md a new feature or additive API surface is `minor` pre-1.0, and this adds panels, shortcuts, a menu item and palette entries; the fixed group bumps in lock-step on the highest declared level, so `patch` under-versions the whole group. Both found by /review-local. * fix(ok): close the review's coverage, a11y and hardening gaps Both Major findings were coverage gaps on the split's own headline behaviors, and both are mutation-checked — the production code was broken to confirm each new test actually catches the regression described, rather than passing vacuously: - The reload-survival test hardcoded `agentPanelVisible: false`, so the `setAgentsVisible` restore branch never ran. Deleting it (or letting the field name drift across the IPC seam) would have left the agents panel collapsed after every reload, silently. Adds the agents case and a both-retained case, which also pins that restoring one panel does not drag the other open. - `buildViewMenuStateDeps` mapped `agentPanelVisible` and `onToggleAgentPanel` with neither under test; `menu.test.ts` passes props straight to `buildMenuTemplate` and bypasses the mapping. A dropped field would freeze the native item on "Show Agents"; a copy-pasted action ID would close the terminal instead. Both panels also rendered a tablist named "Sessions". They are co-visible now, so a screen-reader user met two indistinguishable tablists (WCAG 1.3.1) — the name is derived from `edge` (the standalone window has none and is a terminal), and the test asserts the two names DIFFER rather than merely exist. Hardening: `set-dock-state` validated `req.surface` nowhere, though it indexes the per-window order map and the sibling `cli-preflight` handler guards its own discriminant for exactly this reason; `getDockState` collapsed every IPC failure into a silent null, making "my panel comes back empty" ungreppable. The reviewer also proposed dropping the caller's now-"redundant" catch — declined: the web path can still throw synchronously outside the promise, and the second call site is a bare `void ...then()` with no catch of its own. Found by /review-local. * fix(ok): share the sticky pick across both session hosts pullfrog caught a real double-dispatch bug. The two hosts partition Ask-AI and ⇧⌘J work by comparing the kind each one resolves, which only holds while they read the same inputs — but `stickyAgentId` and `preferBareTerminal` were mount-time `useState` snapshots, and the stores wrote to localStorage with no listener. One New-dropdown pick mutated only the terminal dock's copy, and from that click on both hosts claimed: ⇧⌘J opened two sessions and revealed both panels, and ⌘J-with-selection delivered the passage twice. Both stores now carry the module-scope-state + listener-Set + useSyncExternalStore shape `lib/acp/enabled-agents.ts` already uses, so every surface observes one value and the invariant holds by construction rather than by comment. The composers get the same fix for free. Deliberately cache-free: the values are primitives, so an unchanged read is a no-op re-render, whereas a module cache survives `localStorage.clear()` — that leaked one test's CLI pick into the next while I was building this. Also from review: - ⌘J on the web host preventDefaulted and then hit `if (!terminalAvailable)`, which can never be false there (the effect already returned when okDesktop was non-null) — swallowing the browser's own ⌘J for a no-op. It now claims the chord only when it will act. The existing test pinned the swallow; it now pins the opposite. - ⌥⌘L is KDE Plasma's built-in alternate for Lock Session, grabbed by kglobalaccel at the shell level, so the rehomed Ask AI chord would have locked the screen on KDE. Moved to ⇧⌘L — unclaimed by KDE and GNOME defaults, still the L family, and the chord Cursor uses to send a selection to chat. - AgentsPanelWidthStorage -> WidthStorage, matching doc-panel-width-store and terminal-height-store. Found by /review-local and the PR's cloud reviewers. * fix(ok): drop a duplicated JSDoc, pin the width-key migration contract Two follow-ups from cloud review of f779c69d2. `EmptyEditorState` carried two consecutive JSDoc blocks on one param — the first still describing the removed `terminalDock: string | null`, so a reader scanning the type inline met contradictory documentation. The width store's tests all referenced `AGENTS_PANEL_WIDTH_KEY` rather than its value, but the migration contract IS the value: the key stays `ok-terminal-width-v1` so a user who sized the right column when it held the terminal keeps that width now the agents panel owns it. Renaming the constant and its value together would have kept every test green while silently resetting everyone's panel width — and the terminal/agents naming mismatch makes exactly that rename tempting (I renamed a different symbol in this file one commit ago). * test(ok): close the three pending recommendations from re-review None were blocking — the re-review landed APPROVE WITH SUGGESTIONS — but all three are correct and cheap, so they land here rather than as tracked debt. `claimsSessionKind`'s complement arm kept the partition total, but total is not correct: a new `LauncherSelection` member would have fallen silently to the terminal dock, which may be the wrong owner. The terminal side's kinds are now a total `Record`, so adding a member is a compile error here instead of a quiet mis-route — and the Record IS the predicate, not a decorative assertion beside it. `threadLaunch` forwarding had no coverage: the host mock captured `launch` and dropped `threadLaunch`, so nothing proved EditorPane forwards the intent or that it reveals the panel first (a thread launched into a hidden panel is invisible work). Mutation-checked — removing the prop fails the test. `getInitialAgentsPanelWidth`'s two guards were untested, a gap inherited from the store this replaced. The interesting one is a `localStorage` GETTER that throws (file://, Safari private mode, sandboxed iframes), where `typeof localStorage` is not a safe probe. --------- GitOrigin-RevId: d9c3d8f4ec9d58f905b100a67f01e1d0bdfbc3b7 --- .../split-terminal-and-agents-panels.md | 11 + .../components/BottomComposer.dom.test.tsx | 71 ++- .../app/src/components/BottomComposer.tsx | 22 +- .../components/CommandPalette.dom.test.tsx | 5 + .../src/components/EditorArea.dom.test.tsx | 154 ++++-- packages/app/src/components/EditorArea.tsx | 399 +++++++------- packages/app/src/components/EditorFooter.tsx | 12 +- .../src/components/EditorPane.dom.test.tsx | 287 +++++++--- packages/app/src/components/EditorPane.tsx | 234 +++++---- .../components/EmptyEditorState.dom.test.tsx | 28 +- .../app/src/components/EmptyEditorState.tsx | 29 +- .../app/src/components/OnboardingCard.tsx | 4 +- ...t.tsx => SessionsHost.agents.dom.test.tsx} | 86 +-- ...minalSessionsHost.tsx => SessionsHost.tsx} | 492 +++++++++++------- .../src/components/TerminalDock.dom.test.tsx | 238 +++------ .../TerminalDock.reload-survival.dom.test.tsx | 15 +- packages/app/src/components/TerminalDock.tsx | 100 +--- .../src/components/TerminalNewChatButton.tsx | 36 +- .../components/TerminalRevealTab.dom.test.tsx | 34 +- .../app/src/components/TerminalRevealTab.tsx | 51 +- .../components/TerminalTabStrip.dom.test.tsx | 86 ++- .../app/src/components/TerminalTabStrip.tsx | 87 +--- .../components/TerminalWindowApp.dom.test.tsx | 2 +- .../app/src/components/TerminalWindowApp.tsx | 24 +- .../src/components/ask-ai-composer-events.ts | 2 +- .../components/bottom-composer-gate.test.ts | 25 +- .../src/components/bottom-composer-gate.ts | 26 +- .../components/command-palette-commands.ts | 3 + .../app/src/components/desktop-root-app.tsx | 2 +- .../editor-area-sticky-repin.test.ts | 30 +- .../components/editor-area-sticky-repin.ts | 2 +- .../empty-state/CreatePromptComposer.tsx | 2 +- .../focus-composer-on-card-pointer.ts | 2 +- .../handoff/preferred-session-events.ts | 12 +- .../handoff/terminal-input-events.ts | 20 +- .../src/editor/extensions/CodeBlockView.tsx | 2 +- .../src/lib/agents-panel-width-store.test.ts | 132 +++++ .../app/src/lib/agents-panel-width-store.ts | 63 +++ .../app/src/lib/command-menu-parity.test.ts | 7 +- packages/app/src/lib/desktop-bridge-types.ts | 22 +- .../src/lib/dock-session-persistence.test.ts | 181 +++++++ .../app/src/lib/dock-session-persistence.ts | 101 ++-- .../app/src/lib/keyboard-shortcuts.test.ts | 67 ++- packages/app/src/lib/keyboard-shortcuts.ts | 33 +- packages/app/src/lib/ok-menu-actions.ts | 1 + .../app/src/lib/terminal-dock-store.test.ts | 59 --- packages/app/src/lib/terminal-dock-store.ts | 55 -- .../src/lib/terminal-new-tab-store.test.ts | 35 ++ .../app/src/lib/terminal-new-tab-store.ts | 51 +- .../app/src/lib/terminal-width-store.test.ts | 95 ---- packages/app/src/lib/terminal-width-store.ts | 57 -- packages/app/src/lib/unified-agent-store.ts | 50 +- packages/app/src/locales/en/messages.json | 14 +- packages/app/src/locales/en/messages.po | 64 ++- packages/app/src/locales/pseudo/messages.json | 14 +- packages/app/src/locales/pseudo/messages.po | 62 ++- .../tests/stress/fixtures/handoff-mocks.ts | 2 +- .../core/src/commands/command-identity.ts | 25 +- packages/core/src/constants/menu-labels.ts | 2 + packages/core/src/desktop-bridge.ts | 22 +- packages/desktop/src/main/index.ts | 69 ++- packages/desktop/src/main/menu.ts | 13 + .../desktop/src/main/view-menu-state.test.ts | 31 ++ packages/desktop/src/main/view-menu-state.ts | 7 +- packages/desktop/src/preload/index.ts | 1 + .../desktop/src/shared/bridge-contract.ts | 40 +- packages/desktop/src/shared/ipc-channels.ts | 32 +- .../tests/integration/m1-smoke.test.ts | 4 +- packages/desktop/tests/main/menu.test.ts | 69 +++ .../desktop/tests/smoke/terminal-dock.e2e.ts | 81 +-- .../desktop/tests/smoke/terminal-links.e2e.ts | 10 +- 71 files changed, 2511 insertions(+), 1695 deletions(-) create mode 100644 .changeset/split-terminal-and-agents-panels.md rename packages/app/src/components/{TerminalSessionsHost.threads.dom.test.tsx => SessionsHost.agents.dom.test.tsx} (87%) rename packages/app/src/components/{TerminalSessionsHost.tsx => SessionsHost.tsx} (74%) create mode 100644 packages/app/src/lib/agents-panel-width-store.test.ts create mode 100644 packages/app/src/lib/agents-panel-width-store.ts create mode 100644 packages/app/src/lib/dock-session-persistence.test.ts delete mode 100644 packages/app/src/lib/terminal-dock-store.test.ts delete mode 100644 packages/app/src/lib/terminal-dock-store.ts delete mode 100644 packages/app/src/lib/terminal-width-store.test.ts delete mode 100644 packages/app/src/lib/terminal-width-store.ts diff --git a/.changeset/split-terminal-and-agents-panels.md b/.changeset/split-terminal-and-agents-panels.md new file mode 100644 index 000000000..206d3d219 --- /dev/null +++ b/.changeset/split-terminal-and-agents-panels.md @@ -0,0 +1,11 @@ +--- +"@inkeep/open-knowledge": minor +--- + +The terminal and agent conversations are now two separate panels instead of one shared dock. The terminal owns the bottom of the editor and still opens with ⌘J; agent conversations own the right panel and open with ⌘L. Both can be open at once, each keeps its own size, tabs, and reload state, and closing one leaves the other alone — so you no longer have to give up your terminal to see an agent. The dock-position toggle is gone with the split; a terminal that used to sit in the right panel moves to the bottom on first launch, and the right panel keeps the width you had set. + +A new tab in the terminal panel opens a plain shell by default — you no longer get dropped into a CLI you never chose. The New button's dropdown still lists every terminal CLI, though, so you can start a tab directly in one; that choice sticks, and the + button, ⌘J, and ⇧⌘J repeat it until you pick Terminal again. The agents panel's New button remains dedicated to in-app agents. Sending a passage to AI — from the selection bubble, a code block's Ask action, or ⌘J with selected text — still uses your preferred AI and reveals the panel where it runs. + +The agents panel now keeps a small tab on the right edge whenever it is closed, so a conversation is always one click away even if you have never opened the panel. The terminal no longer has an edge tab of its own — ⌘J and the View menu open it — which clears the bottom-right corner it used to share with the Ask AI composer. + +The bottom Ask AI composer moves from ⌘L to ⇧⌘L (Ctrl+Shift+L on Windows and Linux), and now hides whenever either panel is open — both are already places to type a request, so it stepped aside rather than offering a third. diff --git a/packages/app/src/components/BottomComposer.dom.test.tsx b/packages/app/src/components/BottomComposer.dom.test.tsx index d225bb4b6..ed9f6dc96 100644 --- a/packages/app/src/components/BottomComposer.dom.test.tsx +++ b/packages/app/src/components/BottomComposer.dom.test.tsx @@ -3,7 +3,7 @@ * * The rich `@`-mention input (`ComposerMentionInput`) is mocked with a plain * textarea double that mirrors its imperative handle + `onEmptyChange`/`onSubmit` - * contract, so these tests stay focused on the shell's responsibilities: the ⌘L + * contract, so these tests stay focused on the shell's responsibilities: the ⇧⌘L * focus shortcut, the rotating/reduced-motion placeholder, agent-picker + * sticky-default wiring, Claude CLI terminal routing, the pending + clear flow, * and the defensive null-input toast. The real input's mention behavior + @@ -357,16 +357,32 @@ function getInput() { return screen.getByRole('textbox', { name: 'Ask AI' }) as HTMLTextAreaElement; } -function dispatchOpenAskAiShortcut() { +function makeOpenAskAiEvent() { + // ⇧⌘L on mac / Ctrl+Shift+L elsewhere. `code` (not `key`) is what the registry + // matches: shift makes the layout emit 'L', so keying off `key` would be + // case-sensitive for no reason. const meta = new KeyboardEvent('keydown', { - key: 'l', + key: 'L', + code: 'KeyL', metaKey: true, + shiftKey: true, bubbles: true, cancelable: true, }); - const event = matchesKeyboardShortcut(meta, 'open-ask-ai') + return matchesKeyboardShortcut(meta, 'open-ask-ai') ? meta - : new KeyboardEvent('keydown', { key: 'l', ctrlKey: true, bubbles: true, cancelable: true }); + : new KeyboardEvent('keydown', { + key: 'L', + code: 'KeyL', + ctrlKey: true, + shiftKey: true, + bubbles: true, + cancelable: true, + }); +} + +function dispatchOpenAskAiShortcut() { + const event = makeOpenAskAiEvent(); act(() => { window.dispatchEvent(event); }); @@ -451,7 +467,7 @@ describe('BottomComposer (shell behavior)', () => { } }); - test('the ⌘L shortcut focuses the persistent field', async () => { + test('the ⇧⌘L shortcut focuses the persistent field', async () => { await renderComposer(); const input = getInput(); expect(document.activeElement).not.toBe(input); @@ -478,7 +494,7 @@ describe('BottomComposer (shell behavior)', () => { expect(document.activeElement).not.toBe(getInput()); }); - test('⌘L is ignored while a native form field is focused (no caret theft)', async () => { + test('⇧⌘L is ignored while a native form field is focused (no caret theft)', async () => { await renderComposer(); const composerInput = getInput(); // A real native form field elsewhere in the page (e.g. a rename / search box). @@ -488,22 +504,9 @@ describe('BottomComposer (shell behavior)', () => { act(() => nativeField.focus()); expect(document.activeElement).toBe(nativeField); - // Dispatch ⌘L FROM the native field — the window capture handler sees it + // Dispatch ⇧⌘L FROM the native field — the window capture handler sees it // with `event.target` = the native input and must bail before preventDefault. - const meta = new KeyboardEvent('keydown', { - key: 'l', - metaKey: true, - bubbles: true, - cancelable: true, - }); - const event = matchesKeyboardShortcut(meta, 'open-ask-ai') - ? meta - : new KeyboardEvent('keydown', { - key: 'l', - ctrlKey: true, - bubbles: true, - cancelable: true, - }); + const event = makeOpenAskAiEvent(); act(() => { nativeField.dispatchEvent(event); }); @@ -1288,7 +1291,7 @@ describe('BottomComposer (dismiss / reopen)', () => { expect(screen.queryByRole('textbox', { name: 'Ask AI' })).toBeNull(); }); - test('⌘L while dismissed reopens (calls onReopen) instead of focusing', async () => { + test('⇧⌘L while dismissed reopens (calls onReopen) instead of focusing', async () => { const onReopen = vi.fn(() => {}); await renderComposer('notes', { dismissed: true, onReopen }); @@ -1378,25 +1381,15 @@ describe('BottomComposer (failure + defensive guards)', () => { }); /** - * ⌘L is registered capture-phase on `window`, so it also outruns anything an + * ⇧⌘L is registered capture-phase on `window`, so it also outruns anything an * overlay installs. `defaultPrevented` is the load-bearing signal here: the - * handler cancels the keystroke before emitting, so an uncancelled ⌘L proves + * handler cancels the keystroke before emitting, so an uncancelled ⇧⌘L proves * the handler declined rather than proving the focus trap bounced it back. */ -describe('BottomComposer ⌘L — overlay gate', () => { - function askAiEvent() { - const meta = new KeyboardEvent('keydown', { - key: 'l', - metaKey: true, - bubbles: true, - cancelable: true, - }); - return matchesKeyboardShortcut(meta, 'open-ask-ai') - ? meta - : new KeyboardEvent('keydown', { key: 'l', ctrlKey: true, bubbles: true, cancelable: true }); - } +describe('BottomComposer ⇧⌘L — overlay gate', () => { + const askAiEvent = makeOpenAskAiEvent; - test('claims ⌘L with no overlay open', async () => { + test('claims ⇧⌘L with no overlay open', async () => { await renderComposer(); const event = askAiEvent(); @@ -1407,7 +1400,7 @@ describe('BottomComposer ⌘L — overlay gate', () => { expect(event.defaultPrevented).toBe(true); }); - test('declines ⌘L while an overlay owns the keyboard', async () => { + test('declines ⇧⌘L while an overlay owns the keyboard', async () => { enableInstalledDesktopTargets(); const { BottomComposer } = await import('./BottomComposer'); const { Dialog, DialogContent, DialogDescription, DialogTitle } = await import( diff --git a/packages/app/src/components/BottomComposer.tsx b/packages/app/src/components/BottomComposer.tsx index af8ca3a7b..5b21a8a22 100644 --- a/packages/app/src/components/BottomComposer.tsx +++ b/packages/app/src/components/BottomComposer.tsx @@ -14,7 +14,7 @@ * carries a segmented "Ask " send control (the shared * `AgentSplitButton`: primary submit + joined agent-picker chevron), a * rotating-suggestion placeholder that cross-fades between prompts while - * empty, and the ⌘L focus shortcut. + * empty, and the ⇧⌘L focus shortcut. * * Submitting dispatches the typed instruction to the resolved default agent * (first installed, or the user's sticky pick) scoped to the current doc or @@ -122,10 +122,10 @@ function markdownRelativePathStem(path: string): string | null { } /** - * Whether a keydown originated inside a native form field. ⌘L should still fire + * Whether a keydown originated inside a native form field. ⇧⌘L should still fire * from the ProseMirror body (a contentEditable root), so this is deliberately * NARROWER than `isEditableShortcutTarget` — only native INPUT/TEXTAREA/SELECT - * are excluded, so ⌘L never steals a caret out of a real form field (e.g. the + * are excluded, so ⇧⌘L never steals a caret out of a real form field (e.g. the * rename input, a search box). */ function isNativeTextControl(target: EventTarget | null): boolean { @@ -221,7 +221,7 @@ export function BottomComposer({ * touched-file lifecycle, scroll-inset/caret machinery) are skipped. */ folderPath?: string; /** When dismissed, the field collapses to nothing (the host shows a reopen - * badge in the footer); the component stays mounted so ⌘L can reopen it. Doc + * badge in the footer); the component stays mounted so ⇧⌘L can reopen it. Doc * mode only — folder mode is always visible (no footer to dock a badge in). */ dismissed?: boolean; onDismiss?: () => void; @@ -385,7 +385,7 @@ export function BottomComposer({ }; }, [dismissed, surface, docName, folderMode]); - // Mirror the latest dismissed/onReopen into refs so the once-bound ⌘L handler + // Mirror the latest dismissed/onReopen into refs so the once-bound ⇧⌘L handler // reads current values without re-subscribing (refs written in an effect, not // during render, keeps React Compiler happy). const dismissedRef = useRef(dismissed); @@ -395,7 +395,7 @@ export function BottomComposer({ onReopenRef.current = onReopen; }); - // Single open+focus path, shared by ⌘L and the editor's "Ask AI" selection + // Single open+focus path, shared by ⇧⌘L and the editor's "Ask AI" selection // affordance (the bubble-menu button dispatches the same event): if the field // is dismissed it reopens first (the reopen effect below then focuses on the // dismissed -> visible flip); otherwise it focuses the input directly. @@ -407,13 +407,13 @@ export function BottomComposer({ return subscribeToOpenAskAiComposer(openAndFocus); }, []); - // ⌘L routes through the shared event so the button and the shortcut never + // ⇧⌘L routes through the shared event so the button and the shortcut never // duplicate the reopen/focus logic. useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (!matchesKeyboardShortcut(event, 'open-ask-ai')) return; if (isOverlayLayerOpen()) return; - // Don't hijack ⌘L when the caret is in a native form field (rename input, + // Don't hijack ⇧⌘L when the caret is in a native form field (rename input, // search box, …) — only swallow it for the editor body / global context. if (isNativeTextControl(event.target)) return; event.preventDefault(); @@ -424,7 +424,7 @@ export function BottomComposer({ }, []); // Focus the input only on a genuine reopen (dismissed true -> false), so a - // badge click or ⌘L lands the caret in the field. Comparing against the + // badge click or ⇧⌘L lands the caret in the field. Comparing against the // PREVIOUS dismissed value (rather than a "skip the first render" ref) keeps // the mount itself from focusing — and, crucially, survives React StrictMode's // dev-only effect double-invoke, which defeats a skip-first-render ref (the @@ -933,7 +933,7 @@ export function BottomComposer({ }; // Dismissed: render nothing (the host shows the footer reopen badge). The - // component stays mounted above this point so the ⌘L handler can reopen it. + // component stays mounted above this point so the ⇧⌘L handler can reopen it. if (dismissed) return null; // Compact, Cursor-style selection chip: `name (range)` — the doc basename @@ -962,7 +962,7 @@ export function BottomComposer({ // the card stacks its children vertically. The card markup is mode-agnostic; // only the outer host wrapper (overlay vs in-flow) differs below. const card = ( - // biome-ignore lint/a11y/noStaticElementInteractions: pointer clicks only delegate focus to the composer's editable; keyboard users focus it directly (Tab / ⌘L). + // biome-ignore lint/a11y/noStaticElementInteractions: pointer clicks only delegate focus to the composer's editable; keyboard users focus it directly (Tab / ⇧⌘L).
{ { testid: 'command-palette-toggle-sidebar', query: 'sidebar', id: 'toggle-sidebar' }, { testid: 'command-palette-toggle-doc-panel', query: 'document panel', id: 'toggle-doc-panel' }, { testid: 'command-palette-toggle-terminal', query: 'hide terminal', id: 'toggle-terminal' }, + { + testid: 'command-palette-toggle-agent-panel', + query: 'agents', + id: 'toggle-agent-panel', + }, { testid: 'command-palette-toggle-show-hidden-files', query: 'hidden files', diff --git a/packages/app/src/components/EditorArea.dom.test.tsx b/packages/app/src/components/EditorArea.dom.test.tsx index 3887cb320..58da42298 100644 --- a/packages/app/src/components/EditorArea.dom.test.tsx +++ b/packages/app/src/components/EditorArea.dom.test.tsx @@ -98,11 +98,21 @@ vi.doMock('@/editor/DocumentContext', () => ({ })); vi.doMock('@/components/EmptyEditorState', () => ({ - // Forward terminalDock so the EditorArea -> EmptyEditorState prop wiring is - // observable (the empty state collapses to the header-only view whenever a - // terminal is up, in either dock). - EmptyEditorState: ({ terminalDock }: { terminalDock?: string | null }) => ( -
+ // Forward both panel flags so the EditorArea -> EmptyEditorState prop wiring is + // observable (the empty state collapses to the header-only view whenever + // either panel is up). + EmptyEditorState: ({ + terminalOpen, + agentsOpen, + }: { + terminalOpen?: boolean; + agentsOpen?: boolean; + }) => ( +
), })); @@ -230,6 +240,7 @@ vi.doMock('@/lib/use-settings-route', () => ({ })); const { EditorArea } = await import('./EditorArea'); +const { TooltipProvider } = await import('@/components/ui/tooltip'); function renderEditorArea() { return render( @@ -289,7 +300,6 @@ describe('EditorArea empty-state terminal host', () => { terminalBridge={{} as never} terminalVisible onTerminalVisibleChange={() => {}} - terminalDock="bottom" />, ); @@ -297,33 +307,29 @@ describe('EditorArea empty-state terminal host', () => { expect(dock.getAttribute('data-visible')).toBe('true'); const emptyState = dock.querySelector('[data-testid="empty-editor-state"]'); expect(emptyState).not.toBeNull(); - // EditorArea forwards the dock position so the empty state collapses to the + // EditorArea forwards the open panel so the empty state collapses to the // header-only view (composer bubble dropped) while the terminal is up. - expect(emptyState?.getAttribute('data-terminal-dock')).toBe('bottom'); + expect(emptyState?.getAttribute('data-terminal-open')).toBe('true'); + expect(emptyState?.getAttribute('data-agents-open')).toBe('false'); }); - test('collapses the empty state to the header-only view when the terminal is right-docked', () => { + test('collapses the empty state to the header-only view when the agents panel is open', () => { render( {}} activeTab="timeline" onActiveTabChange={() => {}} - terminalBridge={{} as never} - terminalVisible - onTerminalVisibleChange={() => {}} - // Right is the default dock. Either dock position collapses the empty - // state — the open terminal is its own AI entry point, so the composer - // bubble must not compete with it. - terminalDock="right" + // Either panel collapses the empty state — an open panel is its own AI + // entry point, so the composer bubble must not compete with it. + agentsVisible + onAgentsVisibleChange={() => {}} />, ); - const dock = screen.getByTestId('terminal-dock'); - expect(dock.getAttribute('data-visible')).toBe('true'); - const emptyState = dock.querySelector('[data-testid="empty-editor-state"]'); - expect(emptyState).not.toBeNull(); - expect(emptyState?.getAttribute('data-terminal-dock')).toBe('right'); + const emptyState = screen.getByTestId('empty-editor-state'); + expect(emptyState.getAttribute('data-agents-open')).toBe('true'); + expect(emptyState.getAttribute('data-terminal-open')).toBe('false'); }); test('renders the empty state; the dock shell is present but inactive on the web host', () => { @@ -338,23 +344,22 @@ describe('EditorArea empty-state terminal host', () => { // The dock shell is host-agnostic now, but with nothing docked it stays inactive. expect(screen.getByTestId('terminal-dock').getAttribute('data-visible')).toBe('false'); - // Pins the `terminalVisible ? position : null` forwarding: without an open - // dock the empty state must receive null, or it would collapse to header-only - // on every new tab. - expect(screen.getByTestId('empty-editor-state').getAttribute('data-terminal-dock')).toBe( - 'null', - ); + // Pins the forwarding: with neither panel open the empty state must receive + // both flags false, or it would collapse to header-only on every new tab. + const emptyState = screen.getByTestId('empty-editor-state'); + expect(emptyState.getAttribute('data-terminal-open')).toBe('false'); + expect(emptyState.getAttribute('data-agents-open')).toBe('false'); }); }); -describe('EditorArea right-rail layout assert on terminal-column mount/unmount', () => { +describe('EditorArea right-rail layout assert on agents-column mount/unmount', () => { // react-resizable-panels caches layouts per panel-ID set and restores the // cached layout whenever the set changes — so before the corrective assert, - // hiding the right-docked terminal resurrected a doc panel the user had - // closed while it was up (and revealing it restored equally stale state). - // These pin the assert: on every terminal-column presence flip, EditorArea - // writes one full corrected layout through the group handle, preserving the - // doc panel's pre-flip state and routing the difference to the editor. + // hiding the right column resurrected a doc panel the user had closed while it + // was up (and revealing it restored equally stale state). These pin the + // assert: on every agents-column presence flip, EditorArea writes one full + // corrected layout through the group handle, preserving the doc panel's + // pre-flip state and routing the difference to the editor. const setViewportWidth = (px: number) => { Object.defineProperty(window, 'innerWidth', { value: px, @@ -369,8 +374,7 @@ describe('EditorArea right-rail layout assert on terminal-column mount/unmount', activeTab: 'timeline', onActiveTabChange: () => {}, terminalBridge: {} as never, - terminalDock: 'right', - onTerminalVisibleChange: () => {}, + onAgentsVisibleChange: () => {}, } as const; // px→% conversion basis fixed by the panel mock: 340px at 25% → 1360px. @@ -385,17 +389,17 @@ describe('EditorArea right-rail layout assert on terminal-column mount/unmount', panelIsCollapsed = false; }); - test('hiding the terminal re-asserts the collapsed doc panel over the stale panel-set restore', async () => { + test('hiding the agents panel re-asserts the collapsed doc panel over the stale panel-set restore', async () => { // Below 1280px the doc panel starts collapsed (no pin), so the intended // post-hide state is "collapsed" even though the cached two-panel layout // (mimicked) says expanded. setViewportWidth(1024); - const view = render(); + const view = render(); expect(groupSetLayoutCalls).toHaveLength(0); // The stale two-panel layout the library restores on unmount: doc panel // expanded to 30% — the resurrection this assert corrects. groupLayout = { 'editor-main': 70, 'doc-panel': 30 }; - view.rerender(); + view.rerender(); // Flush the microtask-deferred assert. await act(async () => {}); const corrected = groupSetLayoutCalls.at(-1); @@ -404,40 +408,40 @@ describe('EditorArea right-rail layout assert on terminal-column mount/unmount', expect(corrected?.['editor-main']).toBe(100); }); - test('revealing the terminal keeps the open doc panel open despite a stale cached layout', async () => { + test('revealing the agents panel keeps the open doc panel open despite a stale cached layout', async () => { // At 1400px the doc panel starts open. A stale three-panel cached layout // could say anything; the assert must restore the pre-reveal state (open) - // at the persisted width, with the terminal at its own persisted width. + // at the persisted width, with the agents column at its own persisted width. setViewportWidth(1400); - const view = render(); - groupLayout = { 'editor-main': 45, 'doc-panel': 25, 'terminal-column': 30 }; - view.rerender(); + const view = render(); + groupLayout = { 'editor-main': 45, 'doc-panel': 25, 'agents-column': 30 }; + view.rerender(); await act(async () => {}); const corrected = groupSetLayoutCalls.at(-1); expect(corrected).toBeDefined(); // Exact pins against the mock's deterministic basis: doc panel at its - // persisted default (320px), terminal at its persisted default (480px), + // persisted default (320px), agents column at its persisted default (480px), // editor absorbing the remainder. expect(corrected?.['doc-panel']).toBeCloseTo(pctOf(320), 3); - expect(corrected?.['terminal-column']).toBeCloseTo(pctOf(480), 3); + expect(corrected?.['agents-column']).toBeCloseTo(pctOf(480), 3); expect(corrected?.['editor-main']).toBeCloseTo(100 - pctOf(320) - pctOf(480), 3); }); - test('releasing a terminal-handle drag with the column snapped shut hides the terminal', async () => { - // Drag-to-close: the pointerup handler checks the terminal panel's + test('releasing an agents-handle drag with the column snapped shut hides the panel', async () => { + // Drag-to-close: the pointerup handler checks the agents panel's // isCollapsed() and turns a snapped-shut column into a real hide. setViewportWidth(1400); const visibleChanges: boolean[] = []; render( { + agentsVisible + onAgentsVisibleChange={(visible: boolean) => { visibleChanges.push(visible); }} />, ); - // The empty view renders no doc panel, so the only handle is the terminal's. + // The empty view renders no doc panel, so the only handle is the column's. const handle = screen.getByTestId('resizable-handle'); act(() => { fireEvent.pointerDown(handle); @@ -449,14 +453,14 @@ describe('EditorArea right-rail layout assert on terminal-column mount/unmount', expect(visibleChanges.at(-1)).toBe(false); }); - test('releasing a terminal-handle drag with the column still open does NOT hide the terminal', async () => { + test('releasing an agents-handle drag with the column still open does NOT hide the panel', async () => { setViewportWidth(1400); const visibleChanges: boolean[] = []; render( { + agentsVisible + onAgentsVisibleChange={(visible: boolean) => { visibleChanges.push(visible); }} />, @@ -472,6 +476,50 @@ describe('EditorArea right-rail layout assert on terminal-column mount/unmount', }); }); +// Exactly ONE panel advertises itself with a persistent edge tab, and EditorArea +// owns that one: the agents panel (an agent conversation is worth a one-click +// return, and a chord is not discoverable). The terminal deliberately has none — +// ⌘J and the View menu are its entry points. Its absence is asserted in +// TerminalDock.dom.test.tsx, which renders the real dock; TerminalDock is mocked +// here, so a terminal-tab assertion in this file could never fail. +describe('EditorArea session-panel edge reveal tabs', () => { + const revealProps = { + editorMode: 'wysiwyg', + onModeChange: () => {}, + activeTab: 'timeline', + onActiveTabChange: () => {}, + terminalBridge: { terminal: {} } as never, + onAgentsVisibleChange: () => {}, + onTerminalVisibleChange: () => {}, + onRevealAgents: () => {}, + } as const; + + beforeEach(() => { + cleanup(); + docCtx = EMPTY_DOC_CTX; + }); + + // Ungated on having conversations: a never-opened panel still advertises + // itself, since its cold entry (the New button) is a fine place to land. + // The reveal tab carries a Radix tooltip, so it needs the provider in scope. + const renderArea = (props: Record) => + render( + + + , + ); + + test('the agents tab is up while the panel is hidden, even with no conversations', () => { + renderArea({ agentsVisible: false }); + expect(screen.getByRole('button', { name: 'Open agents panel' })).toBeTruthy(); + }); + + test('the agents tab goes away once the panel is open', () => { + renderArea({ agentsVisible: true }); + expect(screen.queryByRole('button', { name: 'Open agents panel' })).toBeNull(); + }); +}); + describe('EditorArea folder-view terminal host', () => { beforeEach(() => { cleanup(); diff --git a/packages/app/src/components/EditorArea.tsx b/packages/app/src/components/EditorArea.tsx index 9f3f12fb4..ec2d320b8 100644 --- a/packages/app/src/components/EditorArea.tsx +++ b/packages/app/src/components/EditorArea.tsx @@ -5,7 +5,6 @@ import { parseExternalSkillDocName, } from '@inkeep/open-knowledge-core'; import { Trans, useLingui } from '@lingui/react/macro'; -import { useTheme } from 'next-themes'; import { lazy, type ReactNode, @@ -48,6 +47,11 @@ import { useDocumentStats } from '@/hooks/use-document-stats'; import { useLifecycleStatus } from '@/hooks/use-lifecycle-status'; import { useSelectionStats } from '@/hooks/use-selection-stats'; import { closeAgentDiff, useAgentDiffView } from '@/lib/agent-diff-store'; +import { + getInitialAgentsPanelWidth, + MIN_AGENTS_PANEL_WIDTH, + writeAgentsPanelWidth, +} from '@/lib/agents-panel-width-store'; import type { OkDesktopBridge } from '@/lib/desktop-bridge-types'; import { docNameFromHash, hashFromDocName } from '@/lib/doc-hash'; import { getInitialDocPanelWidth, writeDocPanelWidth } from '@/lib/doc-panel-width-store'; @@ -61,12 +65,6 @@ import { } from '@/lib/share/pending-receive-nav-store'; import { RIGHT_COLLAPSE_THRESHOLD, resolvePartition } from '@/lib/sidebar-partition'; import { applyToggle, readPins, resolveEffectiveState } from '@/lib/sidebar-pin-store'; -import type { TerminalDockPosition } from '@/lib/terminal-dock-store'; -import { - getInitialTerminalWidth, - MIN_TERMINAL_WIDTH, - writeTerminalWidth, -} from '@/lib/terminal-width-store'; import { closeTimelineDiff, useTimelineDiffView } from '@/lib/timeline-diff-store'; import { useSettingsRoute } from '@/lib/use-settings-route'; import { cn } from '@/lib/utils'; @@ -82,7 +80,6 @@ import { shouldPaintOverlay } from './editor-area-overlay'; import { computeStickyRepinLayout } from './editor-area-sticky-repin'; import { TerminalDock } from './TerminalDock'; import { TerminalRevealTab } from './TerminalRevealTab'; -import { useLiveXtermTheme } from './use-live-xterm-theme'; const LazyActivityModeContent = lazy(async () => { const mod = await import('@/components/ActivityModeContent'); @@ -144,23 +141,29 @@ const DOC_PANEL_MAX_SIZE = '600px'; // residual absorber and intentionally has no id (an explicit id on it changes how // the library redistributes an imperative resize), so the right-rail layout assert // finds it as the one live panel id that is not one of these. -const RIGHT_PANEL_IDS = new Set(['doc-panel', 'terminal-column', 'agent-panel']); +const RIGHT_PANEL_IDS = new Set(['doc-panel', 'agents-column', 'agent-panel']); /** - * Where + whether the terminal should attach right now. EditorArea computes this - * (it knows the view kind and the bottom/right mount containers) and - * reports it UP to EditorPane, which owns the long-lived session host. The host is - * mounted above EditorArea so a dock toggle (which remounts EditorArea's subtree) - * can't re-spawn the terminal — the VS Code / Zed pattern of owning the terminal - * above the movable layout and re-attaching the view. + * Where + whether ONE session panel should attach right now. EditorArea computes + * this (it knows the view kind and the mount containers) and reports it UP to + * EditorPane, which owns the long-lived session hosts. The hosts are mounted + * above EditorArea so a view-kind change (which remounts EditorArea's subtree) + * can't re-spawn a PTY — the VS Code / Zed pattern of owning the terminal above + * the layout and re-attaching the view. */ -export interface TerminalPlacement { - /** The DOM container to portal the live terminal into (bottom dock or right region). */ +interface SessionPanelPlacement { + /** The DOM container to portal that panel's live sessions into. */ readonly container: HTMLElement | null; - /** Whether the terminal is on screen (drives focus). */ + /** Whether the panel is on screen (drives focus). */ readonly isShowing: boolean; - readonly dockPosition: TerminalDockPosition; - /** Focus target for returning focus to the editor when the terminal hides. */ +} + +/** Both panels' placements plus the shared focus-return target, reported in one + * callback so EditorPane takes a single state update per layout change. */ +export interface SessionPlacements { + readonly terminal: SessionPanelPlacement; + readonly agents: SessionPanelPlacement; + /** Focus target for returning focus to the editor when a panel hides. */ readonly editorRegion: HTMLElement | null; } @@ -171,31 +174,26 @@ interface EditorAreaProps { onActiveTabChange: (tab: PanelTab) => void; /** * Desktop bridge for the docked terminal — `null` on the web host (no shell). - * When present, the terminal docks either under the editor (bottom, via - * `TerminalDock`'s vertical split) or as its own resizable column to the right - * (`#terminal-column`, the far-right column past the doc/agent panel). The live - * session host is owned by EditorPane and portals into whichever container is - * active, so the PTY survives tab switches, view-kind changes, and dock moves. - * State is owned by EditorPane and threaded down via these props. + * When present, the terminal docks under the editor via `TerminalDock`'s + * vertical split. The live session host is owned by EditorPane and portals into + * the container this component renders, so the PTY survives tab switches and + * view-kind changes. State is owned by EditorPane and threaded down via these + * props. */ terminalBridge?: OkDesktopBridge | null; terminalVisible?: boolean; onTerminalVisibleChange?: (visible: boolean) => void; - /** Terminal dock position (right default | bottom). When `'right'` the terminal - * is its own column to the right of the doc/agent panel (MD | PANE | TERMINAL) - * instead of docking under the editor. */ - terminalDock?: TerminalDockPosition; - /** Whether the sessions dock currently holds any tab (terminal or thread), - * reported up from the host. Placement predicates key off dock-visible-&-has-tabs - * rather than the bridge, so the dock column renders on web (thread tabs only). */ - terminalHasSessions?: boolean; - /** Report the terminal's attach point up to EditorPane (which owns the session - * host). See {@link TerminalPlacement}. */ - onTerminalPlacement?: (placement: TerminalPlacement) => void; - /** Reveal the sessions dock and seed the preferred New-session choice if none is - * open — drives the edge "Open session dock" tab shown while the - * dock is hidden. Provided on every host now (web reveals thread tabs). */ - onRevealTerminal?: () => void; + /** Agents-panel visibility. Its own resizable column to the right of the + * doc/agent panel (`#agents-column`, MD | PANE | AGENTS). Independent of the + * terminal — both panels can be open at once. */ + agentsVisible?: boolean; + onAgentsVisibleChange?: (visible: boolean) => void; + /** Report both panels' attach points up to EditorPane (which owns the session + * hosts). See {@link SessionPlacements}. */ + onSessionPlacements?: (placements: SessionPlacements) => void; + /** Reveal the agents panel — drives the right edge tab shown while it is + * hidden. Provided on every host (agent threads are server-hosted). */ + onRevealAgents?: () => void; } export function EditorArea(props: EditorAreaProps) { @@ -255,18 +253,12 @@ function EditorAreaInner({ terminalBridge, terminalVisible = false, onTerminalVisibleChange, - terminalDock = 'right', - terminalHasSessions = false, - onTerminalPlacement, - onRevealTerminal, + agentsVisible = false, + onAgentsVisibleChange, + onSessionPlacements, + onRevealAgents, }: EditorAreaProps) { const { t } = useLingui(); - const { resolvedTheme } = useTheme(); - // Paint the right-docked terminal column with the xterm canvas color so the tab - // strip + chrome read as one continuous surface with the terminal — matching the - // bottom dock (TerminalDock applies the same fill). Without it the strip shows - // the app background and reads as a black seam above the terminal. - const xtermBackground = useLiveXtermTheme(resolvedTheme).background; const { activeDocName, activeProvider, @@ -392,10 +384,10 @@ function EditorAreaInner({ rightPartitionRef.current = rightPartition; }, [rightPartition]); const panelRef = usePanelRef(); - // Independent ref for the terminal column (MD | PANE | TERMINAL). Bound only - // while that column is mounted (right-docked + visible); the sticky-width RO - // pins it the same way it pins the doc panel. - const terminalColumnPanelRef = usePanelRef(); + // Independent ref for the agents column (MD | PANE | AGENTS). Bound only while + // that column is mounted (agents panel visible); the sticky-width RO pins it + // the same way it pins the doc panel. + const agentsColumnPanelRef = usePanelRef(); const [initialRightCollapsed] = useState(() => { const pins = readPins(); return resolveEffectiveState('right', rightPartition, pins) === 'collapsed'; @@ -405,69 +397,53 @@ function EditorAreaInner({ // the observer on every isCollapsed flip. const isCollapsedRef = useRef(isCollapsed); - // The terminal's right-dock mount point — a dedicated column to the right of the - // doc panels (MD | PANE | TERMINAL). The session host portals into this element - // when the terminal is right-docked and visible. - const [rightTerminalContainer, setRightTerminalContainer] = useState(null); + // The agents panel's mount point — a dedicated column to the right of the doc + // panels (MD | PANE | AGENTS), present across EVERY view kind (the column just + // has no doc panel beside it on asset / large-file / empty views). + const [agentsContainer, setAgentsContainer] = useState(null); // The bottom-dock mount + the editor-region focus target, reported up by - // TerminalDock (the bottom shell). The session host portals into the active - // container and returns focus to the editor region when the terminal hides. + // TerminalDock (the bottom shell). The terminal host portals into the container + // and both hosts return focus to the editor region when they hide. const [bottomTerminalContainer, setBottomTerminalContainer] = useState( null, ); const [terminalEditorRegion, setTerminalEditorRegion] = useState(null); - // Terminal placement, computed early (before the view branches) so it can be - // reported up to EditorPane regardless of which branch renders. When right- - // docked the terminal is its own far-right column past the doc panels, present - // across EVERY view kind (the column just has no doc panel beside it on - // asset / large-file / empty views). This is why the dock stays on the right - // even when there's nothing else to put there. - // Whether the desktop bridge can host TERMINAL-kind tabs (a session-only bridge - // has no `terminal` surface). The dock, its placement, and the reveal tab now - // key off dock visibility rather than `terminalBridge != null`, so the column - // renders on the web host (thread tabs only). `terminalAvailable` gates only the - // terminal-*kind* reveal reason (a cold reveal that can spawn a shell). - const terminalAvailable = terminalBridge?.terminal != null; - const rightDocked = terminalDock === 'right'; - const terminalDockPosition: TerminalDockPosition = rightDocked ? 'right' : 'bottom'; - // Whether the far-right dock column participates in the panel group this render. - // Drives the panel-set-change layout assert below and the collapsed doc-panel - // neutralization — compute it up here so effects can depend on it. Un-gated from - // the bridge: on web the column hosts thread tabs. The host renders a starting / - // empty state while a session spins up, so keying off visibility (not has-tabs) - // keeps the dock's feedback immediate during the seconds-long agent spawn. - const terminalColumnPresent = rightDocked && terminalVisible; - // The edge "Show sessions" reveal tab is up while the dock is hidden. A terminal - // surface always shows it (revealing can seed a shell); a thread-only surface - // shows it only with latched tabs to return to (its cold entry is the handoff - // menu, not this tab). It floats over a corner other UI also wants: bottom-dock - // over the editor footer's bottom-right, right-dock over the far-right top. - const revealTabHidden = - !terminalVisible && onRevealTerminal != null && (terminalAvailable || terminalHasSessions); - const bottomRevealTabPresent = revealTabHidden && !rightDocked; - const rightRevealTabPresent = revealTabHidden && rightDocked; - const rightTerminalShowing = rightDocked && terminalVisible && rightTerminalContainer != null; - const activeTerminalContainer = rightTerminalShowing - ? rightTerminalContainer - : bottomTerminalContainer; - const terminalShowing = - (rightDocked ? rightTerminalShowing : terminalVisible) && activeTerminalContainer != null; - // Report the attach point up to EditorPane (which owns the long-lived session - // host). EditorArea only says where to attach — the VS Code / Zed pattern of - // owning the terminal above the layout that moves. + // Placements are computed early (before the view branches) so they can be + // reported up to EditorPane regardless of which branch renders. + // + // Whether the far-right agents column participates in the panel group this + // render. Drives the panel-set-change layout assert below and the collapsed + // doc-panel neutralization — compute it up here so effects can depend on it. + // Keyed off visibility rather than has-tabs so the panel's feedback is + // immediate during the seconds-long agent spawn. + const agentsColumnPresent = agentsVisible; + // The agents panel is the one surface with a persistent edge affordance: it is + // discoverable in a way a chord is not, and an agent conversation is the thing a + // user is most likely to want back. Ungated on having conversations — an empty + // panel opens on its New button, which is a fine cold entry. + // + // The terminal has NO reveal tab. It is a developer surface reached by ⌘J (and + // the View menu), and a second permanent tab hovering over the editor footer's + // bottom-right was clutter competing with the Ask AI composer for that corner. + const rightRevealTabPresent = !agentsVisible && onRevealAgents != null; + const terminalShowing = terminalVisible && bottomTerminalContainer != null; + const agentsShowing = agentsVisible && agentsContainer != null; + // Report the attach points up to EditorPane (which owns the long-lived session + // hosts). EditorArea only says where to attach — the VS Code / Zed pattern of + // owning the terminal above the layout that changes. useEffect(() => { - onTerminalPlacement?.({ - container: activeTerminalContainer, - isShowing: terminalShowing, - dockPosition: terminalDockPosition, + onSessionPlacements?.({ + terminal: { container: bottomTerminalContainer, isShowing: terminalShowing }, + agents: { container: agentsContainer, isShowing: agentsShowing }, editorRegion: terminalEditorRegion, }); }, [ - onTerminalPlacement, - activeTerminalContainer, + onSessionPlacements, + bottomTerminalContainer, terminalShowing, - terminalDockPosition, + agentsContainer, + agentsShowing, terminalEditorRegion, ]); @@ -502,26 +478,26 @@ function EditorAreaInner({ }, 100); } - // The terminal column carries the same sticky-pixel-width treatment as the doc + // The agents column carries the same sticky-pixel-width treatment as the doc // panel — its own persisted width, drag-tracking ref, and RO-pin — so it does // not grow proportionally when the container widens. - const [initialTerminalWidthPx] = useState(() => getInitialTerminalWidth()); - const terminalWidthPxRef = useRef(initialTerminalWidthPx); - const [isDraggingTerminalHandle, setIsDraggingTerminalHandle] = useState(false); - const isDraggingTerminalHandleRef = useRef(false); - const terminalWriteTimerRef = useRef | null>(null); - function debouncedWriteTerminalWidth(px: number) { - if (terminalWriteTimerRef.current != null) clearTimeout(terminalWriteTimerRef.current); - terminalWriteTimerRef.current = setTimeout(() => { - writeTerminalWidth(px); - terminalWriteTimerRef.current = null; + const [initialAgentsWidthPx] = useState(() => getInitialAgentsPanelWidth()); + const agentsWidthPxRef = useRef(initialAgentsWidthPx); + const [isDraggingAgentsHandle, setIsDraggingAgentsHandle] = useState(false); + const isDraggingAgentsHandleRef = useRef(false); + const agentsWriteTimerRef = useRef | null>(null); + function debouncedWriteAgentsWidth(px: number) { + if (agentsWriteTimerRef.current != null) clearTimeout(agentsWriteTimerRef.current); + agentsWriteTimerRef.current = setTimeout(() => { + writeAgentsPanelWidth(px); + agentsWriteTimerRef.current = null; }, 100); } useEffect( () => () => { if (writeTimerRef.current != null) clearTimeout(writeTimerRef.current); - if (terminalWriteTimerRef.current != null) clearTimeout(terminalWriteTimerRef.current); + if (agentsWriteTimerRef.current != null) clearTimeout(agentsWriteTimerRef.current); }, [], ); @@ -540,24 +516,24 @@ function EditorAreaInner({ // Group-level imperative handle. Layout corrections MUST go through // `setLayout` (the whole layout in one shot): the per-panel imperative APIs // (`resize`/`collapse`/`expand`) always exchange space with the panel's flex - // NEIGHBOR, and in the EDITOR | doc-panel | terminal-column order that + // NEIGHBOR, and in the EDITOR | doc-panel | agents-column order that // neighbor is never the editor — a doc-panel collapse dumps its width into - // the terminal, and re-pinning the terminal hands it right back to the doc + // the agents column, and re-pinning it hands the width right back to the doc // panel. Only a full-layout write can route deltas to the editor. The layout // math lives in `computeStickyRepinLayout` (unit-tested). const groupRef = useGroupRef(); - // Live mirror of `terminalColumnPresent` for subscribers with narrow deps. - const terminalColumnPresentRef = useRef(terminalColumnPresent); + // Live mirror of `agentsColumnPresent` for subscribers with narrow deps. + const agentsColumnPresentRef = useRef(agentsColumnPresent); useEffect(() => { - terminalColumnPresentRef.current = terminalColumnPresent; - }, [terminalColumnPresent]); + agentsColumnPresentRef.current = agentsColumnPresent; + }, [agentsColumnPresent]); // Pixel basis for px→% conversion in `assertRightRailLayout`. Layout // percentages are relative to the group's panel space; derive the basis from // a panel whose percentage and pixel width are both known (immune to the // separator widths the container includes), falling back to the container. function resolveGroupPxWidth(): number | null { - for (const ref of [panelRef, terminalColumnPanelRef]) { + for (const ref of [panelRef, agentsColumnPanelRef]) { const size = ref.current?.getSize(); // The `> 1` floor excludes collapsed/near-zero panels: px / ~0% diverges // (Infinity at exactly 0), which would corrupt every layout assertion @@ -572,12 +548,12 @@ function EditorAreaInner({ // Write the intended right-rail layout in one `setLayout` call: the doc // panel at its persisted width (or pinned shut at 0 when collapsed), the - // terminal column at its persisted width, other rail panels (agent-panel) + // agents column at its persisted width, other rail panels (agent-panel) // untouched, and the EDITOR absorbing the remainder. This is the single // correction primitive for every path where the library would otherwise // misroute space. function assertRightRailLayout(docCollapsed: boolean) { - if (isDraggingDocHandleRef.current || isDraggingTerminalHandleRef.current) return; + if (isDraggingDocHandleRef.current || isDraggingAgentsHandleRef.current) return; const group = groupRef.current; if (group == null) return; // The imperative handles throw once their group/panel has unregistered, @@ -595,8 +571,8 @@ function EditorAreaInner({ if ('doc-panel' in layout) { pinnedPx['doc-panel'] = docCollapsed ? 0 : docPanelWidthPxRef.current; } - if ('terminal-column' in layout) { - pinnedPx['terminal-column'] = terminalWidthPxRef.current; + if ('agents-column' in layout) { + pinnedPx['agents-column'] = agentsWidthPxRef.current; } if (Object.keys(pinnedPx).length === 0) return; const next = computeStickyRepinLayout({ @@ -623,10 +599,10 @@ function EditorAreaInner({ // Expand the doc panel from a non-toggle path (tab request, avatar click, // width-threshold crossing). Same routing rule as togglePanel: with the - // terminal column mounted, go through the full-layout assert so the width - // comes from the editor rather than the terminal. + // agents column mounted, go through the full-layout assert so the width + // comes from the editor rather than the agents column. function expandDocPanel() { - if (terminalColumnPresentRef.current) { + if (agentsColumnPresentRef.current) { assertRightRailLayout(false); } else { panelRef.current?.expand(); @@ -643,19 +619,19 @@ function EditorAreaInner({ // threshold and immediately invokes the toggle before React commits the // new partition. const partition = rightPartitionRef.current; - // With the terminal column mounted, expand/collapse must route through the + // With the agents column mounted, expand/collapse must route through the // full-layout assert so the space comes from / returns to the editor — the - // per-panel APIs would exchange it with the terminal column instead. + // per-panel APIs would exchange it with the agents column instead. if (isCollapsed) { applyToggle('right', partition, 'open'); - if (terminalColumnPresentRef.current) { + if (agentsColumnPresentRef.current) { assertRightRailLayout(false); } else { panelRef.current?.expand(); } } else { applyToggle('right', partition, 'collapsed'); - if (terminalColumnPresentRef.current) { + if (agentsColumnPresentRef.current) { assertRightRailLayout(true); } else { panelRef.current?.collapse(); @@ -676,7 +652,7 @@ function EditorAreaInner({ // but until it does any effect reading `isCollapsed` (focus-safety, // notifyViewMenuStateChanged) would see the pre-collapse value. setIsCollapsed(nextCollapsed); - if (terminalColumnPresentRef.current) { + if (agentsColumnPresentRef.current) { assertRightRailLayout(nextCollapsed); } else if (nextCollapsed) { panelRef.current?.collapse(); @@ -697,8 +673,8 @@ function EditorAreaInner({ // a window resize or a LEFT-sidebar collapse — not on a right-panel collapse, // which is internal flex redistribution. react-resizable-panels sizes every // panel as a percentage of the group, so without correction the pixel-sized - // doc panel and terminal column grow proportionally with the container (the - // terminal measured 480px → 673px on a left-sidebar collapse). One full-layout + // doc panel and agents column grow proportionally with the container (the + // column measured 480px → 673px on a left-sidebar collapse). One full-layout // write restores both pins with the container delta flowing to the editor; // sequential per-panel `resize` calls would fight each other — each one // re-balances against its flex neighbor, knocking the other off its pin. @@ -756,8 +732,8 @@ function EditorAreaInner({ // react-resizable-panels caches layouts keyed by the panel-ID set and // restores the cached layout whenever the set changes — so mounting or - // unmounting the terminal column would resurrect whatever doc-panel state the - // OTHER panel set last saw (e.g. hiding the terminal re-opened a doc panel + // unmounting the agents column would resurrect whatever doc-panel state the + // OTHER panel set last saw (e.g. hiding the panel re-opened a doc panel // the user had closed while it was up). Re-assert the intended layout on // every panel-set change: the doc panel keeps its pre-change collapsed state, // both rail widths stay pinned, and the editor absorbs the difference. The @@ -765,15 +741,15 @@ function EditorAreaInner({ // panel-set change triggers, so the correction is deferred one microtask to // land after it (still ahead of paint — `setLayout` notifies the panels' // external stores synchronously). - const prevTerminalColumnPresentRef = useRef(terminalColumnPresent); + const prevAgentsColumnPresentRef = useRef(agentsColumnPresent); useLayoutEffect(() => { - if (prevTerminalColumnPresentRef.current === terminalColumnPresent) return; - prevTerminalColumnPresentRef.current = terminalColumnPresent; + if (prevAgentsColumnPresentRef.current === agentsColumnPresent) return; + prevAgentsColumnPresentRef.current = agentsColumnPresent; const docCollapsed = isCollapsed; queueMicrotask(() => { assertRightRailLayoutRef.current(docCollapsed); }); - }, [terminalColumnPresent, isCollapsed]); + }, [agentsColumnPresent, isCollapsed]); useLayoutEffect(() => { if (!isCollapsed) return; @@ -866,7 +842,7 @@ function EditorAreaInner({ let viewContent: ReactNode; let rightPanel: ReactNode = null; - // The terminal column (when right-docked + visible) is rendered once at the + // The agents column (when visible) is rendered once at the // panel-group level below, to the right of `rightPanel`, so the branches here // only resolve the view content and its own doc/agent panel. if (activeTarget?.kind === 'large-file') { @@ -887,6 +863,7 @@ function EditorAreaInner({ // list is a discrete table, not a continuous document. const showFolderComposer = shouldShowFolderComposer({ terminalVisible, + agentsVisible, isEmbedded, }); viewContent = ( @@ -1080,14 +1057,11 @@ function EditorAreaInner({ // carries an AI entry point, so the Skills composer would compete with it. viewContent = ; } else { - // The empty state collapses to the header-only view while a terminal is - // open in EITHER dock — the open terminal is its own AI entry point, so - // the composer bubble + starter packs would compete with it. The dock - // position picks the header pose (bottom-anchored above the bottom dock; - // centered beside the right column). - viewContent = ( - - ); + // The empty state collapses to the header-only view while EITHER session + // panel is open — an open panel is its own AI entry point, so the composer + // bubble + starter packs would compete with it. Which panel picks the + // header pose (bottom-anchored above the dock; centered beside the column). + viewContent = ; } } else { const isSourceMode = editorMode === 'source'; @@ -1101,7 +1075,7 @@ function EditorAreaInner({ } // Visibility for the open doc's "Ask AI" composer — the pure gate in - // bottom-composer-gate.ts (hidden while the docked terminal is open, in + // bottom-composer-gate.ts (hidden while either session panel is open, in // embedded webviews, and with no doc open). The folder overview mounts its // own instance under shouldShowFolderComposer. Positioning and the // --ask-composer-height scroll inset are documented at the render site @@ -1109,6 +1083,7 @@ function EditorAreaInner({ const showBottomComposer = shouldShowBottomComposer({ terminalVisible, + agentsVisible, isEmbedded, activeDocName, }) && @@ -1235,7 +1210,7 @@ function EditorAreaInner({ isPanelCollapsed={isPanelCollapsed} onTogglePanel={togglePanel} // When the doc panel is collapsed, the action cluster reaches the - // far-right corner where the terminal reveal tab sits — shift it left + // far-right corner where the agents reveal tab sits — shift it left // so the three stay in one row instead of overlapping. reserveRightGutter={rightRevealTabPresent && isPanelCollapsed} /> @@ -1264,15 +1239,14 @@ function EditorAreaInner({ ? { onReopen: () => setComposerDismissed(false) } : null } - reserveRightGutter={bottomRevealTabPresent} />
); viewContent = editorContent; - // While the terminal column is open and the doc panel is closed, the + // While the agents column is open and the doc panel is closed, the // collapsed doc panel sits as a zero-width flex neighbor between the editor - // and the terminal. Its own handle is disabled whenever it is collapsed + // and the agents column. Its own handle is disabled whenever it is collapsed // (see the ResizableHandle below), but drags on the TERMINAL's handle still // route through the collapsed panel: the library snap-expands it once the // drag crosses half its min size, instead of returning the space to the @@ -1280,9 +1254,9 @@ function EditorAreaInner({ // skip it (deltas flow through to the editor) and `minSize 0` disarms the // snap-expand threshold. Imperative paths (`setLayout`, `expand`) still // move it, so the toolbar toggle keeps working. Scoped to - // terminal-column-present: without the terminal no drag can reach the + // agents-column-present: without that column no drag can reach the // collapsed panel at all. - const docPanelNeutralized = terminalColumnPresent && isCollapsed; + const docPanelNeutralized = agentsColumnPresent && isCollapsed; rightPanel = ( <> {})} - dockPosition={terminalDockPosition} onBottomContainer={setBottomTerminalContainer} onEditorRegion={setTerminalEditorRegion} - onReveal={onRevealTerminal} - showRevealTab={bottomRevealTabPresent} > {viewContent} ); // The terminal column sits to the RIGHT of the doc/agent panel - // (MD | PANE | TERMINAL) when right-docked and visible — the far-right column, - // its own independent resizable column rather than a tenant of the panel region. - // The mount div is a callback ref so the session host (owned in EditorPane) - // portals into it; it unmounts to null when the terminal hides or bottom-docks. - const terminalColumn = terminalColumnPresent ? ( + // (MD | PANE | AGENTS) — the far-right column, its own independent resizable + // column rather than a tenant of the panel region. The mount div is a callback + // ref so the session host (owned in EditorPane) portals into it; it unmounts to + // null when the panel hides. + const agentsColumn = agentsColumnPresent ? ( <> { - setIsDraggingTerminalHandle(true); - isDraggingTerminalHandleRef.current = true; + setIsDraggingAgentsHandle(true); + isDraggingAgentsHandleRef.current = true; const handleUp = () => { - setIsDraggingTerminalHandle(false); - isDraggingTerminalHandleRef.current = false; + setIsDraggingAgentsHandle(false); + isDraggingAgentsHandleRef.current = false; window.removeEventListener('pointerup', handleUp); // Drag-to-close: releasing with the column snapped shut hides the - // terminal (unmounting the column), mirroring the doc panel's + // agents panel (unmounting the column), mirroring the doc panel's // drag-to-close affordance. Deferred to pointerup — hiding // mid-drag would unmount the separator under the active drag. - if (terminalColumnPanelRef.current?.isCollapsed()) { - onTerminalVisibleChange?.(false); + if (agentsColumnPanelRef.current?.isCollapsed()) { + onAgentsVisibleChange?.(false); } }; window.addEventListener('pointerup', handleUp); }} /> { // Persist only on a user drag — the sticky-width RO replays the // persisted value through onResize too and must not overwrite it. - if (size.inPixels > 0 && isDraggingTerminalHandleRef.current) { - terminalWidthPxRef.current = size.inPixels; - debouncedWriteTerminalWidth(size.inPixels); + if (size.inPixels > 0 && isDraggingAgentsHandleRef.current) { + agentsWidthPxRef.current = size.inPixels; + debouncedWriteAgentsWidth(size.inPixels); } }} className={cn( 'flex flex-col', - !isDraggingTerminalHandle && + !isDraggingAgentsHandle && 'transition-[flex-grow] duration-200 ease-out motion-reduce:transition-none motion-reduce:duration-0', )} > - {/* Mount point for the session host's stable host div when right-docked. */} -
+ {/* Mount point for the agents host's stable host div. */} +
) : null; // The editor absorbs the residual width whenever something on the right claims - // space — the doc panel (when present and not collapsed) or the terminal column. + // space — the doc panel (when present and not collapsed) or the agents column. const editorAbsorbsResidual = - (rightPanel != null && !initialRightCollapsed) || terminalColumnPresent; + (rightPanel != null && !initialRightCollapsed) || agentsColumnPresent; - // The right-dock reveal tab pins to the far-right column edge here; the - // bottom-dock tab lives inside TerminalDock, pinned to the bottom of the editor - // column where that terminal docks. (Both gated by `revealTabHidden` above.) + // The agents reveal tab pins to the far-right column edge here; the terminal's + // tab lives inside TerminalDock, pinned to the bottom of the editor column. + // (Each gated by its own predicate above; both can be up at once.) - // Order: EDITOR | doc/agent panel | terminal column. The terminal is the - // far-right column when right-docked, so it renders AFTER `rightPanel`. + // Order: EDITOR | doc/agent panel | agents column. The agents panel is the + // far-right column, so it renders AFTER `rightPanel`. return (
{leftColumn} {rightPanel} - {terminalColumn} + {agentsColumn} {rightRevealTabPresent ? ( // Pinned to the far-right top, vertically in line with the toolbar's // action buttons. When the doc panel is collapsed those buttons reach this // same corner; the toolbar shifts its cluster left (reserveRightGutter) so // all three sit in one row rather than overlapping. - + ) : null}
); diff --git a/packages/app/src/components/EditorFooter.tsx b/packages/app/src/components/EditorFooter.tsx index 3da349a77..14b7376a9 100644 --- a/packages/app/src/components/EditorFooter.tsx +++ b/packages/app/src/components/EditorFooter.tsx @@ -7,7 +7,6 @@ import { useEditorFooterIdentity, } from '@/hooks/use-editor-footer-identity'; import type { DocumentStats } from '@/lib/document-stats'; -import { cn } from '@/lib/utils'; interface EditorFooterProps { stats: DocumentStats; @@ -21,10 +20,6 @@ interface EditorFooterProps { /** When set, a "Ask AI" reopen badge renders next to the stats — shown only * while the bottom composer is dismissed. Clicking it reopens the composer. */ composerBadge?: { onReopen: () => void } | null; - /** Reserve extra right padding so the right-aligned stats clear the - * bottom-dock "Open session dock" reveal tab, which floats over the footer's - * bottom-right corner when the terminal is hidden. */ - reserveRightGutter?: boolean; } export function EditorFooter({ @@ -32,7 +27,6 @@ export function EditorFooter({ selectionStats, showStats = true, composerBadge, - reserveRightGutter = false, }: EditorFooterProps) { const { t } = useLingui(); const identity = useEditorFooterIdentity(); @@ -50,11 +44,7 @@ export function EditorFooter({ ? t`Selection statistics` : t`Document statistics` } - className={cn( - 'relative flex h-6 shrink-0 items-center justify-between gap-3 bg-background px-3 text-2xs text-muted-foreground', - // Clear the bottom-dock reveal tab that floats over the bottom-right. - reserveRightGutter && 'pr-12', - )} + className="relative flex h-6 shrink-0 items-center justify-between gap-3 bg-background px-3 text-2xs text-muted-foreground" >
({ EditorHeader: () =>
, })); -// EditorArea renders the bottom layout shell + reports the terminal placement up; -// the live session host now lives in EditorPane as a sibling of EditorArea (so a -// dock toggle can't remount it). EditorPane still owns the open/⌘J/menu/telemetry -// state. The EditorArea mock is a bare stand-in; the TerminalSessionsHost mock -// (below) surfaces the threaded `visible` + `launch` props so these tests keep +// EditorArea renders the layout shells + reports both panels' placements up; the +// live session hosts live in EditorPane as siblings of EditorArea (so a view-kind +// change can't remount them). EditorPane still owns the open/⌘J/⌘L/menu/telemetry +// state. The EditorArea mock is a bare stand-in; the SessionsHost mock (below) +// surfaces the threaded `surface` + `visible` + `launch` props so these tests keep // asserting EditorPane's wiring across the prop boundary. vi.doMock('./EditorArea', () => ({ EditorArea: () =>
, @@ -173,27 +174,37 @@ vi.doMock('./EditorArea', () => ({ vi.doMock('./acp/AgentThreadClientBinder', () => ({ AgentThreadClientBinder: () => null, })); -// The sessions host now mounts UNCONDITIONALLY (web too) — a shell and an agent -// are just tabs of a different kind. It receives `bridge` (null on web) so these -// tests assert EditorPane's wiring across the prop boundary, including which host -// gets a live bridge. -vi.doMock('./TerminalSessionsHost', () => ({ - TerminalSessionsHost: ({ +// EditorPane mounts the host twice — the agents panel unconditionally (agent +// threads are server-hosted, so it works on web) and the terminal dock only where +// a shell can spawn. One mock serves both; the testid is keyed by `surface` so a +// test can assert on the panel it means. `data-terminal-capable` pins the GLOBAL +// capability fact both hosts need for the Ask-AI arbitration. +vi.doMock('./SessionsHost', () => ({ + SessionsHost: ({ + surface, bridge, + terminalCapable, visible, launch, + threadLaunch, }: { + surface: string; bridge?: unknown; + terminalCapable?: boolean; visible?: boolean; launch?: { nonce: number; stagePaste?: string } | null; + threadLaunch?: { nonce: number; agentId?: string; prompt?: string | null } | null; }) => { return (
); }, @@ -405,17 +416,25 @@ describe('EditorPane auto-sync onboarding gate', () => { // Minimal faithful stand-in for the desktop bridge surfaces EditorPane's // terminal wiring touches: `onMenuAction` (subscribe), the View-menu-state push, -// `terminal.getDockState` (read once on mount to restore dock visibility after a -// reload), and `terminal.cliInstalledMap` (read once on mount for the New-chat -// default CLI). The real `window.okDesktop` always exposes these, so an empty -// `{}` stub would no longer model the boundary now that EditorPane calls them on -// mount. getDockState resolves `visible: false` so the restore is a no-op — -// these tests exercise the start-hidden toggle/launch behavior. +// `terminal.getDockState` (read once on mount to restore BOTH panels' visibility +// after a reload), and `terminal.cliInstalledMap` (read once on mount for the +// New-chat default CLI). The real `window.okDesktop` always exposes these, so an +// empty `{}` stub would no longer model the boundary now that EditorPane calls +// them on mount. getDockState resolves both panels hidden so the restore is a +// no-op — these tests exercise the start-hidden toggle/launch behavior. +type DockStateResult = { terminalVisible: boolean; agentPanelVisible: boolean }; function makeOkDesktopStub( - getDockState: () => Promise<{ visible: boolean }> = async () => ({ visible: false }), + getDockState: () => Promise = async () => ({ + terminalVisible: false, + agentPanelVisible: false, + }), ) { const menuHandlers: Array<(action: string) => void> = []; - const viewMenuPushes: Array<{ terminalVisible?: boolean; canViewInSource?: boolean }> = []; + const viewMenuPushes: Array<{ + terminalVisible?: boolean; + agentPanelVisible?: boolean; + canViewInSource?: boolean; + }> = []; return { viewMenuPushes, dispatchMenuAction(action: string) { @@ -436,6 +455,7 @@ function makeOkDesktopStub( editor: { notifyViewMenuStateChanged(state: { terminalVisible?: boolean; + agentPanelVisible?: boolean; canViewInSource?: boolean; }) { viewMenuPushes.push(state); @@ -454,7 +474,7 @@ function makeOkDesktopStub( }; } -describe('EditorPane terminal dock wiring', () => { +describe('EditorPane session-panel wiring', () => { afterEach(() => { cleanup(); delete (window as { okDesktop?: unknown }).okDesktop; @@ -464,41 +484,85 @@ describe('EditorPane terminal dock wiring', () => { clearPendingSourceNavigationsForTest(); }); - test('web host mounts the sessions host (host-agnostic) with no desktop bridge', async () => { + test('web host mounts the agents panel only — no shell can spawn, so no terminal dock', async () => { await renderEditorPane(); - // The unified sessions dock is host-agnostic now — it mounts on web too (thread - // tabs only), just without a desktop bridge (terminal-kind affordances gate on - // the bridge inside the host). - const dock = screen.getByTestId('terminal-dock'); - expect(dock).toBeTruthy(); - expect(dock.getAttribute('data-has-bridge')).toBe('false'); + // Agent threads are server-hosted, so the agents panel is universal. The + // terminal dock is NOT mounted where no PTY can spawn: an empty dock there + // would be a control that can never do anything. + const agents = screen.getByTestId('agents-panel'); + expect(agents.getAttribute('data-has-bridge')).toBe('false'); + expect(agents.getAttribute('data-terminal-capable')).toBe('false'); + expect(screen.queryByTestId('terminal-dock')).toBeNull(); expect(screen.getByTestId('editor-header')).toBeTruthy(); expect(screen.getByTestId('editor-area')).toBeTruthy(); }); - test('desktop host mounts the sessions host with a live bridge under the editor area', async () => { + test('desktop host mounts BOTH panels as siblings of the editor area', async () => { (window as { okDesktop?: unknown }).okDesktop = makeOkDesktopStub().stub; await renderEditorPane(); - // The header and the live session host are both siblings of the editor area - // (the host lives in EditorPane so a dock toggle can't remount it). On desktop - // the host gets a live bridge. + // Both hosts live in EditorPane (above EditorArea) so a view-kind change + // can't remount them, and both get the live bridge + the same global + // terminal-capability fact the Ask-AI arbitration resolves against. expect(screen.getByTestId('editor-header')).toBeTruthy(); expect(screen.getByTestId('editor-area')).toBeTruthy(); - const dock = screen.getByTestId('terminal-dock'); - expect(dock).not.toBeNull(); - expect(dock.getAttribute('data-has-bridge')).toBe('true'); + for (const testid of ['terminal-dock', 'agents-panel']) { + const panel = screen.getByTestId(testid); + expect(panel.getAttribute('data-has-bridge')).toBe('true'); + expect(panel.getAttribute('data-terminal-capable')).toBe('true'); + } }); - test('desktop: toggle-terminal menu action flips dock visibility and pushes the view-menu state', async () => { + test('desktop: the two panels open and close independently', async () => { + const desk = makeOkDesktopStub(); + (window as { okDesktop?: unknown }).okDesktop = desk.stub; + await renderEditorPane(); + + const terminal = () => screen.getByTestId('terminal-dock').getAttribute('data-visible'); + const agents = () => screen.getByTestId('agents-panel').getAttribute('data-visible'); + expect(terminal()).toBe('false'); + expect(agents()).toBe('false'); + + // ⌘L opens the agents panel and leaves the terminal alone. + act(() => desk.dispatchMenuAction('toggle-agent-panel')); + expect(agents()).toBe('true'); + expect(terminal()).toBe('false'); + + // ⌘J then opens the terminal — both are up at once, the whole point of the + // split. Toggling one must never close the other. + act(() => desk.dispatchMenuAction('toggle-terminal')); + expect(agents()).toBe('true'); + expect(terminal()).toBe('true'); + + act(() => desk.dispatchMenuAction('toggle-agent-panel')); + expect(agents()).toBe('false'); + expect(terminal()).toBe('true'); + }); + + test('desktop: each panel pushes its own view-menu field, never the other', async () => { + const desk = makeOkDesktopStub(); + (window as { okDesktop?: unknown }).okDesktop = desk.stub; + await renderEditorPane(); + + act(() => desk.dispatchMenuAction('toggle-agent-panel')); + + // The agents push carries agentPanelVisible ONLY — main merges partials, so a + // push that also carried terminalVisible would clobber the terminal's + // retained per-window state on every ⌘L. + const agentsPush = desk.viewMenuPushes.at(-1); + expect(agentsPush).toEqual({ agentPanelVisible: true }); + }); + + test('desktop: toggle-terminal flips terminal visibility and pushes the view-menu state', async () => { const desk = makeOkDesktopStub(); (window as { okDesktop?: unknown }).okDesktop = desk.stub; await renderEditorPane(); expect(screen.getByTestId('terminal-dock').getAttribute('data-visible')).toBe('false'); // Mount pushes terminalVisible:false so the View menu reads "Show Terminal". - expect(desk.viewMenuPushes.at(-1)).toEqual({ terminalVisible: false }); + // Both panels push on mount, so name the field rather than taking the last. + expect(desk.viewMenuPushes).toContainEqual({ terminalVisible: false }); act(() => desk.dispatchMenuAction('toggle-terminal')); expect(screen.getByTestId('terminal-dock').getAttribute('data-visible')).toBe('true'); @@ -676,12 +740,18 @@ describe('EditorPane terminal dock wiring', () => { config: { ptyAvailable: true }, onMenuAction: () => () => {}, editor: { - notifyViewMenuStateChanged(state: { terminalVisible?: boolean }) { + notifyViewMenuStateChanged(state: { + terminalVisible?: boolean; + agentPanelVisible?: boolean; + }) { if (state.terminalVisible !== undefined) retainedDockVisible = state.terminalVisible; }, }, terminal: { - getDockState: async () => ({ visible: retainedDockVisible }), + getDockState: async () => ({ + terminalVisible: retainedDockVisible, + agentPanelVisible: false, + }), }, }; @@ -693,6 +763,70 @@ describe('EditorPane terminal dock wiring', () => { expect(terminalOpenedCalls).toHaveLength(0); }); + // The agents half of "each panel keeps its own reload state". The terminal + // half is covered above; without this the `if (state.agentPanelVisible)` + // restore branch is never exercised, so deleting it — or letting the field + // name drift across the IPC seam — would leave the panel collapsed after every + // reload with nothing catching it. + test('desktop: a reload re-expands an agents panel that was open before it', async () => { + (window as { okDesktop?: unknown }).okDesktop = { + config: { ptyAvailable: true }, + onMenuAction: () => () => {}, + editor: { notifyViewMenuStateChanged() {} }, + terminal: { + getDockState: async () => ({ terminalVisible: false, agentPanelVisible: true }), + }, + }; + + await renderEditorPane(); + + expect(screen.getByTestId('agents-panel').getAttribute('data-visible')).toBe('true'); + // Independent: restoring one panel must not drag the other open with it. + expect(screen.getByTestId('terminal-dock').getAttribute('data-visible')).toBe('false'); + }); + + test('desktop: a reload with BOTH panels retained restores both', async () => { + (window as { okDesktop?: unknown }).okDesktop = { + config: { ptyAvailable: true }, + onMenuAction: () => () => {}, + editor: { notifyViewMenuStateChanged() {} }, + terminal: { + getDockState: async () => ({ terminalVisible: true, agentPanelVisible: true }), + }, + }; + + await renderEditorPane(); + + expect(screen.getByTestId('agents-panel').getAttribute('data-visible')).toBe('true'); + expect(screen.getByTestId('terminal-dock').getAttribute('data-visible')).toBe('true'); + }); + + // The agents panel's launch intent had no coverage: the host mock captured + // `launch` but dropped `threadLaunch`, so nothing proved EditorPane forwards it + // (or that it reveals the panel first — a thread launched into a hidden panel + // is invisible work). + test('an agent-thread launch request reveals the agents panel and forwards the intent', async () => { + await renderEditorPane(); + expect(screen.getByTestId('agents-panel').getAttribute('data-visible')).toBe('false'); + + await act(async () => { + requestAgentThreadLaunch({ + agentSource: 'registry', + agentId: 'acme-agent', + prompt: 'summarize this doc', + docName: TEST_DOC, + titleHint: null, + }); + }); + + const agents = screen.getByTestId('agents-panel'); + expect(agents.getAttribute('data-visible')).toBe('true'); + expect(agents.getAttribute('data-thread-launch-agent')).toBe('acme-agent'); + // A fresh one-shot: the nonce is what makes a repeat request a NEW launch + // rather than a no-op re-render of the same intent. + expect(agents.getAttribute('data-thread-launch-nonce')).not.toBe('none'); + }); + test('desktop: a rejecting getDockState still settles the gate so the view-menu push converges', async () => { // getDockState rejects (IPC torn down mid-reload). The restore's `.finally` // must still settle dockRestoreSettled so the deferred mount push lands — @@ -705,12 +839,16 @@ describe('EditorPane terminal dock wiring', () => { (window as { okDesktop?: unknown }).okDesktop = desk.stub; await renderEditorPane(); - expect(desk.viewMenuPushes.at(-1)).toEqual({ terminalVisible: false }); + expect(desk.viewMenuPushes).toContainEqual({ terminalVisible: false }); // With no restored state the dock stays hidden (the breadcrumb is logged). expect(screen.getByTestId('terminal-dock').getAttribute('data-visible')).toBe('false'); }); - test('web host: a Cmd/Ctrl+J keydown is intercepted (the toggle handler is wired)', async () => { + // With no selection AND no shell to spawn, ⌘J has nothing to do on the web + // host, so it must leave the browser's own ⌘J alone. It used to preventDefault + // and then hit a guard that can never be false — swallowing the chord for a + // no-op. The selection-send path below is the half that DOES act here. + test('web host: a Cmd/Ctrl+J keydown with no selection is NOT swallowed', async () => { await renderEditorPane(); const init: KeyboardEventInit = { key: 'j', cancelable: true, bubbles: true }; @@ -719,6 +857,22 @@ describe('EditorPane terminal dock wiring', () => { const event = new KeyboardEvent('keydown', init); window.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + }); + + // ⌘L's twin. On desktop the native menu accelerator covers this, but the web + // host has only the renderer listener and no fallback — if it were ever moved + // inside the `window.okDesktop != null` early-return, or bound to the wrong + // phase, ⌘L would silently do nothing there. + test('web host: a Cmd/Ctrl+L keydown is intercepted (the agents toggle is wired)', async () => { + await renderEditorPane(); + + const init: KeyboardEventInit = { key: 'l', cancelable: true, bubbles: true }; + if (isMacOS()) init.metaKey = true; + else init.ctrlKey = true; + const event = new KeyboardEvent('keydown', init); + window.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); }); @@ -749,13 +903,15 @@ describe('EditorPane terminal dock wiring', () => { input.stop(); preferred.stop(); - // The dock reveals and the host is asked to open the user's preferred AI. - // EditorPane deliberately names no CLI: resolving here could only ever yield - // a CLI, which is what made ⇧⌘J ignore a preferred ACP agent. - const dock = screen.getByTestId('terminal-dock'); - expect(dock.getAttribute('data-visible')).toBe('true'); + // The hosts are asked to open the user's preferred AI. EditorPane names no + // CLI and reveals NO panel: resolving here could only ever yield a CLI (what + // made ⇧⌘J ignore a preferred ACP agent), and with two panels this pane + // cannot know which one should end up on screen — the host that owns the + // resolved kind reveals itself. expect(preferred.count).toBe(1); - expect(dock.getAttribute('data-launch-nonce')).toBe('none'); + expect(screen.getByTestId('terminal-dock').getAttribute('data-visible')).toBe('false'); + expect(screen.getByTestId('agents-panel').getAttribute('data-visible')).toBe('false'); + expect(screen.getByTestId('terminal-dock').getAttribute('data-launch-nonce')).toBe('none'); expect(input.texts).toEqual([]); }); @@ -772,11 +928,10 @@ describe('EditorPane terminal dock wiring', () => { input.stop(); preferred.stop(); - // The passage rides the Ask-AI channel with `newTab` set, so the host opens a - // fresh session in whichever family the user prefers instead of reusing one. - // Nothing is auto-run and no CLI is named here. + // The passage rides the Ask-AI channel with `newTab` set, so the host that + // owns the preferred family opens a fresh session instead of reusing one, and + // reveals its own panel. Nothing is auto-run and no CLI is named here. const dock = screen.getByTestId('terminal-dock'); - expect(dock.getAttribute('data-visible')).toBe('true'); expect(input.details).toHaveLength(1); expect(input.details[0]?.newTab).toBe(true); expect(input.details[0]?.text).toContain('some highlighted text'); @@ -801,14 +956,20 @@ describe('EditorPane terminal dock wiring', () => { expect(event.defaultPrevented).toBe(true); }); - test('web host: ⇧⌘J is a no-op (no terminal to open)', async () => { + test('web host: ⇧⌘J still asks for a preferred-AI session (the agents panel can answer)', async () => { await renderEditorPane(); + const preferred = capturePreferredSessionRequests(); const event = new KeyboardEvent('keydown', shiftJKeydownInit()); - window.dispatchEvent(event); - expect(event.defaultPrevented).toBe(false); - // The sessions dock still mounts host-agnostic (thread tabs), but ⇧⌘J is a - // terminal-launch accelerator: with no desktop bridge it stages nothing. - expect(screen.getByTestId('terminal-dock').getAttribute('data-launch-nonce')).toBe('none'); + act(() => { + window.dispatchEvent(event); + }); + preferred.stop(); + + // ⇧⌘J used to be gated on a terminal being available, which made it dead on + // web. Agent threads are server-hosted, so the agents panel can always answer + // — the chord is universal now and claims the keystroke. + expect(event.defaultPrevented).toBe(true); + expect(preferred.count).toBe(1); }); test('desktop: ⌘J with a selection sends the passage to the host for reuse (no toggle, no launch)', async () => { @@ -823,10 +984,11 @@ describe('EditorPane terminal dock wiring', () => { input.stop(); // ⌘J means "continue where I am when that makes sense", so `newTab` is unset - // and the host decides: a running CLI or an open agent thread takes the - // passage, otherwise it launches the preferred AI. EditorPane reveals the dock - // and stages nothing itself — whether the active tab is a CLI, a bare shell, or - // an agent thread is knowledge only the host has. + // and the hosts decide: a running CLI or an open agent thread takes the + // passage, otherwise the owner of the preferred family launches one and + // reveals itself. EditorPane stages nothing and reveals nothing — whether the + // active tab is a CLI, a bare shell, or an agent thread is knowledge only a + // host has. const dock = screen.getByTestId('terminal-dock'); expect(input.details).toHaveLength(1); expect(input.details[0]?.newTab).toBe(false); @@ -835,7 +997,8 @@ describe('EditorPane terminal dock wiring', () => { // Trailing soft newlines land the caret on a blank line below the passage. expect(input.details[0]?.text.endsWith('\n\n')).toBe(true); expect(dock.getAttribute('data-launch-nonce')).toBe('none'); - expect(dock.getAttribute('data-visible')).toBe('true'); + // The selection send replaces the toggle, so ⌘J did NOT open the terminal. + expect(dock.getAttribute('data-visible')).toBe('false'); }); test('desktop: ⌘J with no selection still toggles and stages nothing', async () => { diff --git a/packages/app/src/components/EditorPane.tsx b/packages/app/src/components/EditorPane.tsx index 1103278fd..a2c2cf8a5 100644 --- a/packages/app/src/components/EditorPane.tsx +++ b/packages/app/src/components/EditorPane.tsx @@ -27,18 +27,13 @@ import { useConfigContext } from '@/lib/config-provider'; import { matchesKeyboardShortcut } from '@/lib/keyboard-shortcuts'; import { subscribeLocalMenuAction } from '@/lib/local-menu-action-bus'; import { isOverlayLayerOpen } from '@/lib/overlay-layers'; -import { - getInitialTerminalDock, - type TerminalDockPosition, - writeTerminalDock, -} from '@/lib/terminal-dock-store'; import { recordTerminalOpened } from '@/lib/terminal-telemetry'; import { setViewMenuState } from '@/lib/view-menu-state-store'; import { AuthModal } from './AuthModal'; import { AutoSyncOnboardingDialog } from './AutoSyncOnboardingDialog'; import { resolveAutoSyncOnboarding } from './auto-sync-onboarding-gate'; import { type PanelTab, TABS } from './DocPanel'; -import { EditorArea, type TerminalPlacement } from './EditorArea'; +import { EditorArea, type SessionPlacements } from './EditorArea'; import { EditorHeader } from './EditorHeader'; import { composeTerminalSelectionPaste } from './handoff/compose-terminal-selection'; import { requestPreferredSession } from './handoff/preferred-session-events'; @@ -60,8 +55,8 @@ const AgentThreadClientBinder = lazy(() => default: mod.AgentThreadClientBinder, })), ); -const TerminalSessionsHost = lazy(() => - import('./TerminalSessionsHost').then((mod) => ({ default: mod.TerminalSessionsHost })), +const SessionsHost = lazy(() => + import('./SessionsHost').then((mod) => ({ default: mod.SessionsHost })), ); /** @@ -152,14 +147,14 @@ export function EditorPane({ onOpenSearch }: EditorPaneProps = {}) { desktopBridge.terminal != null && desktopBridge.config.ptyAvailable === true; const [terminalVisible, setTerminalVisible] = useState(false); + // The agents panel is independent of the terminal — different edge, different + // kind, its own toggle (⌘L). Universal: agent threads are server-hosted, so it + // works on the web host and where pty does not. + const [agentsVisible, setAgentsVisible] = useState(false); // Which launchable CLIs are on PATH (desktop probe, cached ~60s in main). // Handed to the sessions host, which folds it into its launcher resolution. // Shared with the Ask-X bubble. const installedClis = useInstalledClis(); - // Whether the dock currently holds any session (reported up by the host). - // Keeps the edge reveal tab available on a host with no terminal once threads - // are open, so a hidden dock is still reachable there. - const [hasSessions, setHasSessions] = useState(false); // Gates the View-menu visibility push (below) until the mount-time dock-state // restore has read main's retained per-window visibility. Without the gate the // reconnecting renderer's initial `false` push overwrites that retained value @@ -180,31 +175,14 @@ export function EditorPane({ onOpenSearch }: EditorPaneProps = {}) { // twin of `terminalLaunch`. Both buses target the one host now. const [threadLaunch, setThreadLaunch] = useState(null); const threadLaunchNonceRef = useRef(0); - // Where the terminal docks (right default | bottom), persisted per machine. - // Moving the terminal is also a request to see it, so the setter reveals the - // dock — re-docking a hidden terminal that stays hidden would feel inert. - const [terminalDock, setTerminalDockState] = - useState(getInitialTerminalDock); - function setTerminalDock(next: TerminalDockPosition) { - setTerminalDockState(next); - writeTerminalDock(next); - setTerminalVisible(true); - } - // The tab strip's dock-toggle button flips between the bottom dock and the right - // column. Reading `terminalDock` from the render closure is correct for the click - // path: React commits each click in its own render, so the closure is always the - // freshly-committed position — a double-click flips back and forth as expected. - function toggleTerminalDock() { - setTerminalDock(terminalDock === 'right' ? 'bottom' : 'right'); - } - // The live terminal session host is mounted HERE (below), above EditorArea, so a - // dock toggle — which remounts EditorArea's subtree — can't re-spawn the terminal - // (the VS Code / Zed pattern: own the terminal above the movable layout, re-attach - // the view). EditorArea reports where to attach via onTerminalPlacement. - const [terminalPlacement, setTerminalPlacement] = useState({ - container: null, - isShowing: false, - dockPosition: 'bottom', + // The live session hosts are mounted HERE (below), above EditorArea, so a + // view-kind change — which remounts EditorArea's subtree — can't re-spawn a + // terminal (the VS Code / Zed pattern: own the terminal above the layout that + // changes, re-attach the view). EditorArea reports where each panel attaches + // via onSessionPlacements. + const [placements, setPlacements] = useState({ + terminal: { container: null, isShowing: false }, + agents: { container: null, isShowing: false }, editorRegion: null, }); // Monotonic source for the launch nonce. It must survive the hide-clear of @@ -213,27 +191,25 @@ export function EditorPane({ onOpenSearch }: EditorPaneProps = {}) { // the dock would then dedup away as a repeat. const launchNonceRef = useRef(0); - // "New chat" with the preferred AI: reveal the dock and let the host resolve - // and open it. The host's `launchSelectedNewTab` spans all three families - // (in-app agent / CLI / bare shell) and honors the Configure-agents toggles; - // resolving a CLI here instead could only ever produce a CLI, never the in-app - // agent a user may prefer. + // "New chat" with the preferred AI: let the hosts resolve, open, and reveal. + // Their resolution spans all three families (in-app agent / CLI / bare shell) + // and honors the Configure-agents toggles; resolving a CLI here instead could + // only ever produce a CLI, never the in-app agent a user may prefer — and this + // pane cannot know which panel should end up on screen. function launchNewChat() { - setTerminalVisible(true); requestPreferredSession(); } - // Reveal the sessions dock; the host seeds it when it opens empty. Drives the - // edge "Show sessions" reveal tab and the View → Show Terminal item; leaves the - // right doc-panel untouched (the two coexist). + // Reveal the agents panel; the host seeds it when it opens empty. Drives the + // right edge reveal tab and ⌘L; leaves the other panels untouched (all coexist). // // Seeding is deliberately NOT done here. The host's `seedOnReveal` resolves the // whole preferred-AI space (in-app agent / CLI / bare shell, enablement-aware); // this pane can only see the CLI slice. Launching from here would set a launch // intent that PREEMPTS that seed, forcing a CLI on a user whose preferred AI is // an in-app agent. - function revealTerminal() { - setTerminalVisible(true); + function revealAgents() { + setAgentsVisible(true); } const syncStatus = useGitSyncStatus(); @@ -278,23 +254,26 @@ export function EditorPane({ onOpenSearch }: EditorPaneProps = {}) { // instance needed, so it works even from the OS-captured ⌘J menu accelerator) // and composes the same grounded prompt the Ask-AI selection button sends. // - // Where it lands is the HOST's call, not this pane's: it owns both the live - // session state and the preferred-AI resolution, so ⌘J reuses a running CLI or - // an open agent thread, and otherwise launches whichever AI the user prefers. - // Resolving here instead would limit ⌘J to the CLI slice this pane can see, - // ignoring a preferred in-app agent. + // Which panel it lands in is the HOSTS' call, not this pane's: they own the + // live session state and both resolve the same preferred AI, so the passage + // reuses a running CLI in the bottom dock or an open thread in the agents + // panel, and the winner reveals itself. Resolving here instead would limit ⌘J + // to the CLI slice this pane can see, ignoring a preferred in-app agent — and + // this pane cannot know which panel is the right one anyway. + // + // Deliberately NOT gated on a terminal being available: the agents panel is + // universal, so a selection send works on the web host too. // // Returns true when a selection was staged (caller skips the toggle / new-tab - // fallback). No-ops on the web host (no terminal). + // fallback). function sendSelectionToTerminal(newTab: boolean): boolean { - if (!terminalAvailable || activeDocName == null) return false; + if (activeDocName == null) return false; const snapshot = getSelectionContext(activeDocName, editorMode); const selectionMarkdown = snapshot?.markdown ?? ''; if (selectionMarkdown.trim() === '') return false; // Trailing soft newlines (\n, not \r — no submit) drop the CLI input caret // onto a blank line below the staged passage. const staged = `${composeTerminalSelectionPaste(activeDocName, selectionMarkdown)}\n\n`; - setTerminalVisible(true); // Raw selected material, not an instruction — written and left for the user // to extend and send, on a CLI and on an agent thread alike. requestActiveTerminalInput(staged, { newTab, submit: false }); @@ -320,6 +299,8 @@ export function EditorPane({ onOpenSearch }: EditorPaneProps = {}) { // the toggle). The dock adds the new tab itself off the same action; this // only owns visibility and covers the case where no dock is mounted yet. setTerminalVisible(true); + } else if (action === 'toggle-agent-panel') { + setAgentsVisible((visible) => !visible); } }); }, []); @@ -329,24 +310,48 @@ export function EditorPane({ onOpenSearch }: EditorPaneProps = {}) { function handleKeyDown(event: KeyboardEvent) { if (!matchesKeyboardShortcut(event, 'toggle-terminal-panel')) return; if (isOverlayLayerOpen()) return; + // Claim the chord only when we will actually act on it. A selection send is + // the whole of ⌘J on a shell-less host; with no selection AND no shell there + // is nothing to toggle, so let the browser keep its own ⌘J rather than + // swallowing it for a no-op. + if (sendSelectionToTerminalEvent(false)) { + event.preventDefault(); + return; + } + if (!terminalAvailable) return; event.preventDefault(); - if (sendSelectionToTerminalEvent(false)) return; setTerminalVisible((visible) => !visible); } // Capture phase so a focused xterm textarea can't swallow ⌘J first. window.addEventListener('keydown', handleKeyDown, { capture: true }); return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }); + }, [terminalAvailable]); + + // ⌘L / Ctrl+L toggles the agents panel. Same dual wiring as ⌘J — on desktop the + // View menu item's accelerator is OS-captured and dispatches `toggle-agent-panel` + // (handled above), so this window keydown is the web host's stand-in. Unlike ⌘J + // it has no selection-send behavior: a selection goes to whichever panel the + // user's preferred AI lives in, which the hosts arbitrate off ⌘J. + useEffect(() => { + if (window.okDesktop != null) return; + function handleKeyDown(event: KeyboardEvent) { + if (!matchesKeyboardShortcut(event, 'toggle-agent-panel')) return; + if (isOverlayLayerOpen()) return; + event.preventDefault(); + setAgentsVisible((visible) => !visible); + } + window.addEventListener('keydown', handleKeyDown, { capture: true }); + return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }); }, []); // ⇧⌘J / Ctrl+Shift+J: open a fresh session with the preferred AI. With a // selection, that selection is staged into the new session (always fresh, never - // reusing the active tab); otherwise the host opens a promptless one. Both paths + // reusing the active tab); otherwise the hosts open a promptless one. Both paths // resolve to whichever AI the user prefers (in-app agent / CLI / bare shell), - // not a hardcoded CLI. Renderer-owned on both hosts (no menu item claims ⇧⌘J), - // capture-phase so a focused xterm can't swallow it. No-ops on the web host (no - // terminal surface). + // not a hardcoded CLI, and the panel that owns the resolved kind reveals itself. + // Renderer-owned on both hosts (no menu item claims ⇧⌘J), capture-phase so a + // focused xterm can't swallow it. Universal — the agents panel can always answer. useEffect(() => { - if (!terminalAvailable) return; function handleKeyDown(event: KeyboardEvent) { if (!matchesKeyboardShortcut(event, 'new-terminal-tab')) return; if (isOverlayLayerOpen()) return; @@ -355,7 +360,7 @@ export function EditorPane({ onOpenSearch }: EditorPaneProps = {}) { } window.addEventListener('keydown', handleKeyDown, { capture: true }); return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }); - }, [terminalAvailable]); + }, []); // CLI launch — "Open in terminal" from a handoff menu, or the sessions host // resolving an Ask AI / selection send to a CLI. Open the dock (the terminal is @@ -383,13 +388,13 @@ export function EditorPane({ onOpenSearch }: EditorPaneProps = {}) { }, []); // "Start an agent" launch — a handoff-menu click fires a window event naming a - // catalog agent + the composed prompt. Reveal the dock AND carry the intent to - // the host as a fresh one-shot (the host resolves the agent / opens the catalog - // and creates the thread). Universal — agent threads are server-hosted, so - // unlike the terminal bus this one has no desktop gate. + // catalog agent + the composed prompt. Reveal the AGENTS panel AND carry the + // intent to its host as a fresh one-shot (the host resolves the agent / opens + // the catalog and creates the thread). Universal — agent threads are + // server-hosted, so unlike the terminal bus this one has no desktop gate. useEffect(() => { return subscribeToAgentThreadLaunchRequests((detail: AgentThreadLaunchDetail) => { - setTerminalVisible(true); + setAgentsVisible(true); threadLaunchNonceRef.current += 1; setThreadLaunch({ agentSource: detail.agentSource, @@ -433,14 +438,24 @@ export function EditorPane({ onOpenSearch }: EditorPaneProps = {}) { window.okDesktop.editor.notifyViewMenuStateChanged({ terminalVisible }); }, [terminalVisible, dockRestoreSettled]); - // Restore the dock's expanded state after a renderer reload: main retains the - // per-window visibility (written by the gated push above once this settles), - // so a reloaded window re-expands the dock when it was open before the reload. - // Reads false after a fresh launch (main has no retained state), so the dock - // stays hidden. Run-once; only ever expands (never force-hides), so a user + // The agents panel's twin of the push above, behind the same restore gate for + // the same reason: its retained per-window visibility is what a reloaded window + // re-expands from, and a mount-initial `false` would overwrite it first. + useEffect(() => { + if (window.okDesktop == null) return; + if (!dockRestoreSettled) return; + setViewMenuState({ agentPanelVisible: agentsVisible }); + window.okDesktop.editor.notifyViewMenuStateChanged({ agentPanelVisible: agentsVisible }); + }, [agentsVisible, dockRestoreSettled]); + + // Restore both panels' expanded state after a renderer reload: main retains the + // per-window visibility of each (written by the gated pushes above once this + // settles), so a reloaded window re-expands whichever were open before the + // reload. Reads false after a fresh launch (main has no retained state), so + // both stay hidden. Run-once; only ever expands (never force-hides), so a user // toggle that races the restore is never overridden closed. Settling the gate - // (always, even on a read failure) releases the deferred push so the View menu - // converges. Desktop-only. + // (always, even on a read failure) releases the deferred pushes so the View + // menu converges. Desktop-only. useEffect(() => { const bridge = window.okDesktop; if (bridge == null) return; @@ -458,7 +473,9 @@ export function EditorPane({ onOpenSearch }: EditorPaneProps = {}) { void bridge.terminal .getDockState() .then((state) => { - if (cancelled || !state.visible) return; + if (cancelled) return; + if (state.agentPanelVisible) setAgentsVisible(true); + if (!state.terminalVisible) return; // The restore — not the user — is driving this reveal; mark it so the // adoption telemetry below skips it. restoreRevealRef.current = true; @@ -467,7 +484,7 @@ export function EditorPane({ onOpenSearch }: EditorPaneProps = {}) { .catch((err) => { // Leave a breadcrumb instead of swallowing: a restore failure is // otherwise indistinguishable from "main had no retained state", and the - // dock silently stays hidden. Mirrors the list() catch in TerminalDock. + // panels silently stay hidden. Mirrors the list() catch in TerminalDock. console.error('[terminal] dock-state restore failed; staying hidden:', err); }) .finally(() => { @@ -619,13 +636,11 @@ export function EditorPane({ onOpenSearch }: EditorPaneProps = {}) { }} onOpenSearch={onOpenSearch} /> - {/* The editor takes the row's full width. The unified sessions dock - (terminals + agent threads) docks WITHIN EditorArea — bottom, or its own + {/* The editor takes the row's full width. Both session panels live WITHIN + EditorArea — the terminal beneath the editor, the agents panel as its own right column past the doc panels; EditorArea owns that layout. The live - session host is mounted below (above EditorArea) so a dock move never - remounts it. `terminalAvailable` folds in `ptyAvailable`, so on - Windows/Linux (no bundled node-pty) the bridge passes as null and the - dock behaves web-like — thread tabs only, no terminal-kind affordances. */} + hosts are mounted below (above EditorArea) so a view-kind change never + remounts them. */}
- {/* The sessions dock host mounts UNCONDITIONALLY — a shell and an agent are - just tabs of a different kind, and agents are server-hosted, so the dock - is host-agnostic (web = thread tabs only, as is win/linux where pty is - unavailable). Terminal-kind affordances gate on the bridge inside the host. */} + {/* The agents host mounts UNCONDITIONALLY — agent threads are server-hosted, + so the panel works on the web host and on Windows/Linux where pty is + unavailable. The terminal host mounts only where a shell can actually + spawn (`terminalAvailable` folds in `ptyAvailable`); an empty terminal + dock on those hosts would be a control that can never do anything. */} - terminalPlacement.editorRegion?.focus()} - dockPosition={terminalDock} - onToggleDock={toggleTerminalDock} - onHasSessionsChange={setHasSessions} + container={placements.agents.container} + isShowing={placements.agents.isShowing} + onRequestEditorFocus={() => placements.editorRegion?.focus()} /> + {terminalAvailable ? ( + + placements.editorRegion?.focus()} + /> + + ) : null} { - test('no terminal: renders the full view (composer surface present)', async () => { - render(); +describe('EmptyEditorState session-panel-aware collapse', () => { + test('neither panel open: renders the full view (composer surface present)', async () => { + render(); await waitFor(() => expect(screen.getByTestId('create-view')).toBeTruthy()); expect(screen.queryByTestId('empty-state-header')).toBeNull(); }); - test('bottom-docked terminal: header-only, bottom-anchored above the dock', async () => { - render(); + test('terminal open: header-only, bottom-anchored above the dock', async () => { + render(); const header = await screen.findByTestId('empty-state-header'); expect(screen.queryByTestId('create-view')).toBeNull(); - const pose = header.closest('.justify-end'); - expect(pose).not.toBeNull(); + expect(header.closest('.justify-end')).not.toBeNull(); }); - test('right-docked terminal: header-only too (the composer bubble must not compete), centered', async () => { - render(); + test('agents panel open: header-only too (the composer bubble must not compete), centered', async () => { + render(); const header = await screen.findByTestId('empty-state-header'); expect(screen.queryByTestId('create-view')).toBeNull(); - const pose = header.closest('.justify-center'); - expect(pose).not.toBeNull(); + // The right column leaves the vertical space intact, so the header centers. + expect(header.closest('.justify-center')).not.toBeNull(); + }); + + test('both panels open: the bottom dock wins the pose (it is what takes the space)', async () => { + render(); + const header = await screen.findByTestId('empty-state-header'); + expect(screen.queryByTestId('create-view')).toBeNull(); + expect(header.closest('.justify-end')).not.toBeNull(); }); }); diff --git a/packages/app/src/components/EmptyEditorState.tsx b/packages/app/src/components/EmptyEditorState.tsx index d01f79abe..3edd1ed91 100644 --- a/packages/app/src/components/EmptyEditorState.tsx +++ b/packages/app/src/components/EmptyEditorState.tsx @@ -16,14 +16,16 @@ import { emitCreateTopLevelFile } from '@/lib/create-file-events'; import type { OkPackId } from '@/lib/desktop-bridge-types'; import { subscribeToDocumentsChanged } from '@/lib/documents-events'; import { fetchDocumentListShared } from '@/lib/documents-fetch'; -import type { TerminalDockPosition } from '@/lib/terminal-dock-store'; import { cn } from '@/lib/utils'; export function EmptyEditorState({ - terminalDock = null, + terminalOpen = false, + agentsOpen = false, }: { - /** Where the visible terminal is docked, or null when no terminal is open. */ - terminalDock?: TerminalDockPosition | null; + /** Whether the bottom terminal dock is open. */ + terminalOpen?: boolean; + /** Whether the right agents panel is open. */ + agentsOpen?: boolean; }) { const [seedDialogOpen, setSeedDialogOpen] = useState(false); const [seedDialogInitialPackId, setSeedDialogInitialPackId] = useState( @@ -109,15 +111,14 @@ export function EmptyEditorState({ if (!next) setSeedDialogInitialPackId(undefined); } - // When the terminal is open (an empty-state CLI launch), keep the header — - // blob + headline + subtitle, left-aligned in the same column as the full - // view — but drop the composer bubble + starter-pack list: the terminal is - // its own AI entry point, so the composer would be a competing dispatch - // affordance in either dock position. Bottom dock: `justify-end` - // bottom-anchors the header just above the dock so it rides up/down as the - // terminal is resized (matching the prior blob-only pose). Right dock keeps - // its vertical space, so the header centers instead. - if (terminalDock !== null) { + // With a session panel open (an empty-state launch), keep the header — blob + + // headline + subtitle, left-aligned in the same column as the full view — but + // drop the composer bubble + starter-pack list: the panel is its own AI entry + // point, so the composer would be a competing dispatch affordance. An open + // BOTTOM dock takes vertical space, so `justify-end` bottom-anchors the header + // just above it and the header rides up/down as the dock is resized. The right + // agents panel leaves the vertical space intact, so the header centers. + if (terminalOpen || agentsOpen) { return ( // `@container/emptystate` scopes the child media queries to the editor // pane's own width, not the window's — a narrow split-view pane gets the @@ -126,7 +127,7 @@ export function EmptyEditorState({
diff --git a/packages/app/src/components/OnboardingCard.tsx b/packages/app/src/components/OnboardingCard.tsx index d98f7f5fc..7099aae7d 100644 --- a/packages/app/src/components/OnboardingCard.tsx +++ b/packages/app/src/components/OnboardingCard.tsx @@ -4,10 +4,10 @@ * 1. Create your first project — always pre-checked (endowed progress; the * user is already inside their first project when the card shows). * 2. Create your first file — shows the ⌘N shortcut. - * 3. Ask AI — shows the ⌘L shortcut. + * 3. Ask AI — shows the ⇧⌘L shortcut. * * The card is informational: steps check off as the user performs the actions - * (⌘N, ⌘L) in the editor; the rows themselves are not interactive. The + * (⌘N, ⇧⌘L) in the editor; the rows themselves are not interactive. The * completion checkbox is a decorative, full-opacity status indicator * (aria-hidden); completion is conveyed to assistive tech via the strikethrough * label plus an sr-only marker. diff --git a/packages/app/src/components/TerminalSessionsHost.threads.dom.test.tsx b/packages/app/src/components/SessionsHost.agents.dom.test.tsx similarity index 87% rename from packages/app/src/components/TerminalSessionsHost.threads.dom.test.tsx rename to packages/app/src/components/SessionsHost.agents.dom.test.tsx index 9236eda60..e09ee6881 100644 --- a/packages/app/src/components/TerminalSessionsHost.threads.dom.test.tsx +++ b/packages/app/src/components/SessionsHost.agents.dom.test.tsx @@ -1,10 +1,16 @@ /** - * Behavioral tests for the sessions dock hosting AGENT THREADS (the unified-dock - * half of the host). TerminalGate + ThreadView + the thread store are stubbed so - * the assertions pin what the host owns for thread tabs: mirroring the server - * thread list into tabs, kind-aware close (archive) + focus (composer), rename → - * server, and auto-reveal on a new live thread. The terminal half is covered by - * TerminalDock.dom.test.tsx (same host). + * Behavioral tests for the AGENTS PANEL surface of the shared session host. + * TerminalGate + ThreadView + the thread store are stubbed so the assertions pin + * what the host owns for thread tabs: mirroring the server thread list into tabs, + * close (archive) + focus (composer), rename → server, and auto-reveal on a new + * live thread. The terminal surface is covered by TerminalDock.dom.test.tsx (same + * component, different `surface`). + * + * The "Ask AI honors the preferred AI" block below is the regression guard for + * the cross-panel arbitration: both docked hosts resolve the SAME preferred AI + * from global capabilities, and each answers only for the kinds it owns. These + * tests assert the agents panel takes `thread` and `none`, and declines `cli` + * (which the terminal dock picks up off the same event). */ import type { ThreadInfo } from '@inkeep/open-knowledge-core/acp/thread-protocol'; @@ -100,7 +106,7 @@ vi.doMock('@/lib/acp/registered-agents', () => ({ useDefaultRegisteredAgent: () => mockRegisteredAgent, getDefaultRegisteredAgent: () => mockRegisteredAgent, registerAgent: () => {}, - // Real code loaded here imports these too (TerminalSessionsHost → + // Real code loaded here imports these too (SessionsHost → // pickEffectiveDefaultAgent; catalog → hydrateRegisteredAgentMeta). A // mock.module replaces the whole module, so any omitted export becomes an // unresolved import that fails the file (and can cascade to siblings). @@ -117,7 +123,7 @@ vi.doMock('@tanstack/react-query', () => ({ useQuery: () => ({ data: catalogData, isLoading: false, isError: false }), })); -const { TerminalSessionsHost } = await import('./TerminalSessionsHost'); +const { SessionsHost } = await import('./SessionsHost'); /** Two distinguishable agents, so a stale publish names a visibly wrong one. */ const FIRST_AGENT = { id: 'a1', name: 'First Agent', source: 'registry' } as const; @@ -164,8 +170,13 @@ function Harness({ return (
- { onVisibleChange?.(v); @@ -175,13 +186,12 @@ function Harness({ container={container} isShowing={visible && container != null} onRequestEditorFocus={() => {}} - dockPosition="bottom" /> ); } -describe('TerminalSessionsHost — agent-thread hosting (web / no bridge)', () => { +describe('SessionsHost — agents panel (web / no bridge)', () => { beforeEach(() => { openThreads = []; archivedThreads = []; @@ -515,7 +525,7 @@ describe('TerminalSessionsHost — agent-thread hosting (web / no bridge)', () = // Closes the triangle: the channel and `launchSelectedNewTab` are each tested // alone, but nothing fired a request into a mounted host. A stale closure or an // event-name drift would slip past both endpoint tests. - test('a promptless preferred-session request opens the preferred agent', async () => { + test('a promptless preferred-session request opens a thread when an agent is preferred', async () => { mockRegisteredAgent = { source: 'registry', id: 'acme-agent', name: 'Acme' }; render(); await screen.findByTestId('terminal-new-chat'); @@ -525,27 +535,39 @@ describe('TerminalSessionsHost — agent-thread hosting (web / no bridge)', () = }); expect(launchAgentThread).toHaveBeenCalledTimes(1); - const [agent, prompt, , , stagedDraft] = launchAgentThread.mock.calls[0]; - expect(agent).toEqual({ source: 'registry', id: 'acme-agent' }); - // Promptless: ⇧⌘J with no selection carries nothing to run or stage. - // Normalized because this path omits the optional 5th arg rather than - // passing an explicit null — the invariant is "no staged draft", not which - // of the two nullish encodings the call site happens to use. - expect(prompt).toBeNull(); - expect(stagedDraft ?? null).toBeNull(); + expect(launchAgentThread.mock.calls[0][0]).toEqual({ source: 'registry', id: 'acme-agent' }); }); - // The CLI family must carry the SAME distinction, or ⇧⌘J would auto-run a - // raw passage the moment no agent is configured. + // The other half of the ⇧⌘J partition: no in-app agent means the preferred AI + // is a CLI or a shell, both of which belong to the terminal dock. Declining + // here is what stops the two panels each opening a session for one press. + test('a promptless preferred-session request is left to the terminal dock with no agent', async () => { + render(); + await screen.findByTestId('terminal-new-chat'); + + await act(async () => { + requestPreferredSession(); + }); + + expect(launchAgentThread).not.toHaveBeenCalled(); + }); + + // Arbitration, the half this panel owns: with no in-app agent the preferred + // AI resolves to a CLI, which belongs to the TERMINAL dock. This panel must + // decline outright rather than launch one — a CLI started from here would + // have no PTY surface to live in, and the terminal dock (answering the same + // event, with the same resolution) would open a second one. The answering + // half is asserted in TerminalDock.dom.test.tsx. test.each([ - { submit: true, stage: false, label: 'an Ask AI instruction runs' }, - { submit: false, stage: true, label: 'a selection send stages' }, - ])('with NO agent set up, $label on the CLI', async ({ submit, stage }) => { - const launches: { prompt: string; cli: string; stage: boolean }[] = []; + { submit: true, label: 'an Ask AI instruction' }, + { submit: false, label: 'a selection send' }, + ])('with NO agent set up, $label is left to the terminal dock', async ({ submit }) => { + const launches: unknown[] = []; const stopLaunch = subscribeToTerminalLaunchRequests((prompt, cli, opts) => - launches.push({ prompt, cli, stage: opts.stage }), + launches.push({ prompt, cli, ...opts }), ); - render(); + const onVisibleChange = vi.fn((_v: boolean) => {}); + render(); await screen.findByTestId('terminal-new-chat'); await act(async () => { @@ -553,11 +575,11 @@ describe('TerminalSessionsHost — agent-thread hosting (web / no bridge)', () = }); stopLaunch(); - // No in-app agent → the CLI family is next in the uniform precedence, so the - // passage still reaches an AI. Claude here is the resolver's install-nudge - // default, not a hardcoded sender-side choice. + // Nothing at all: no thread, no CLI launch of its own, and — the part a + // silent mis-claim would break — no reveal of a panel that has no answer. expect(launchAgentThread).not.toHaveBeenCalled(); - expect(launches).toEqual([{ prompt: 'fix this lint error', cli: 'claude', stage }]); + expect(launches).toEqual([]); + expect(onVisibleChange).not.toHaveBeenCalled(); }); test('with nothing set up, an Ask AI send opens Configure agents (no silent shell)', async () => { diff --git a/packages/app/src/components/TerminalSessionsHost.tsx b/packages/app/src/components/SessionsHost.tsx similarity index 74% rename from packages/app/src/components/TerminalSessionsHost.tsx rename to packages/app/src/components/SessionsHost.tsx index 5584ef4ac..0f21899fd 100644 --- a/packages/app/src/components/TerminalSessionsHost.tsx +++ b/packages/app/src/components/SessionsHost.tsx @@ -19,7 +19,11 @@ import { TabsContent } from '@/components/ui/tabs'; import { isInAppAgentEnabled } from '@/lib/acp/agent-visibility'; import { useEnabledOverrides } from '@/lib/acp/enabled-agents'; import { launchAgentThread } from '@/lib/acp/launch-agent-thread'; -import { enabledTerminalClis, resolveLauncherSelection } from '@/lib/acp/launcher-selection'; +import { + enabledTerminalClis, + type LauncherSelection, + resolveLauncherSelection, +} from '@/lib/acp/launcher-selection'; import { getDefaultRegisteredAgent, pickEffectiveDefaultAgent, @@ -38,6 +42,7 @@ import { stageThreadDraft } from '@/lib/acp/thread-draft-staging'; import type { OkDesktopBridge } from '@/lib/desktop-bridge-types'; import { type DockSessionOrder, + type DockSurface, readDockSessionOrder, readWebDockSessionOrder, writeDockSessionOrder, @@ -45,16 +50,13 @@ import { import { subscribeLocalMenuAction } from '@/lib/local-menu-action-bus'; import type { NewSessionChoice } from '@/lib/new-session-choice'; import { isOverlayLayerOpen } from '@/lib/overlay-layers'; -import type { TerminalDockPosition } from '@/lib/terminal-dock-store'; -import { - getInitialPreferBareTerminal, - writePreferBareTerminal, -} from '@/lib/terminal-new-tab-store'; +import { usePreferBareTerminal, writePreferBareTerminal } from '@/lib/terminal-new-tab-store'; import { - loadStickyAgent, + parseStickyCliId, saveStickyAgent, terminalCliId, threadAgentId, + useStickyAgent, } from '@/lib/unified-agent-store'; import { openAgentSettings } from '@/lib/use-settings-route'; import { cn } from '@/lib/utils'; @@ -77,16 +79,20 @@ import { import { requestTerminalLaunch } from './handoff/terminal-launch-events'; import { TerminalGate } from './TerminalGate'; import { TerminalNewChatButton } from './TerminalNewChatButton'; -import { type TerminalTabDescriptor, TerminalTabStrip } from './TerminalTabStrip'; - -/** A session the host keeps as a tab. Two kinds share one ordered list + one - * active id (the unified sessions dock). Terminal fields carry PTY state; the - * thread variant carries only its server-owned `threadId` (its live title + - * status come from the thread store). `ordinal` is a shared monotonic number the - * panel render sorts by (so a tab reorder never moves a panel's DOM node); the - * terminal positional label ("Terminal N") also reads it. `id` is a stable - * client identity: `terminal-session-` for a terminal, the threadId itself - * for a thread. */ +import { + type SessionPanelEdge, + type TerminalTabDescriptor, + TerminalTabStrip, +} from './TerminalTabStrip'; + +/** A session the host keeps as a tab. A given host instance holds ONE kind (see + * {@link SessionSurface}); the union is shared because the tab model is. Terminal + * fields carry PTY state; the thread member carries only its server-owned + * `threadId` (its live title + status come from the thread store). `ordinal` is a + * monotonic number the panel render sorts by (so a tab reorder never moves a + * panel's DOM node); the terminal positional label ("Terminal N") also reads it. + * `id` is a stable client identity: `terminal-session-` for a terminal, the + * threadId itself for a thread. */ interface BaseSessionDescriptor { readonly id: string; readonly ordinal: number; @@ -149,11 +155,21 @@ function focusInsideHost(hostEl: HTMLElement | null): boolean { return hostEl?.contains(document.activeElement) ?? false; } +/** + * Which surface a host instance is. Each owns one edge and one session kind: + * the terminal dock owns the bottom and PTYs, the agents panel owns the right + * and ACP threads, and the standalone terminal window is its own window. The + * host is one component because the tab mechanics (order, active tab, rename, + * reorder, reload placement, focus) are identical across all three; only the + * kind gates and chrome differ. + */ +export type SessionSurface = 'terminal-dock' | 'agents-panel' | 'terminal-window'; + /** Focus-gate for the host's capture-phase chords (⌘1–9, ⌘⇧←/→): always in the - * window variant (the whole window IS the terminal), in the dock only while - * focus sits inside the host. */ -function chordTargetsHost(hostEl: HTMLElement | null, variant: 'dock' | 'window'): boolean { - return variant === 'window' || focusInsideHost(hostEl); + * standalone window (the whole window IS the terminal), in a docked panel only + * while focus sits inside the host. */ +function chordTargetsHost(hostEl: HTMLElement | null, isWindow: boolean): boolean { + return isWindow || focusInsideHost(hostEl); } /** Colour of a thread tab's status dot, by lifecycle. Transitional states pulse; @@ -206,24 +222,34 @@ function threadTabIcon(info: ThreadInfo | undefined): ReactNode { ); } -interface TerminalSessionsHostProps { +interface SessionsHostProps { /** - * Desktop bridge, or `null` on the web host. The host mounts unconditionally - * now — a shell and an agent are just tabs of a different kind in one dock, and - * agents are server-hosted, so the dock is host-agnostic. Terminal-*kind* - * affordances (creation, PTY bodies, reload adopt) gate on the bridge exposing a - * `terminal` surface; thread tabs work with no bridge at all. + * Desktop bridge, or `null` on the web host. Passed to EVERY surface, including + * the agents panel — the panel has no terminal affordances but still persists + * its tab order through main's per-window store. Terminal-*kind* affordances + * (creation, PTY bodies, reload adopt) additionally gate on {@link terminalCapable} + * and the bridge exposing a `terminal` surface; thread tabs need neither. */ readonly bridge: OkDesktopBridge | null; /** - * Which surface hosts the sessions. `'dock'` (default) is the editor's sessions - * dock: terminals AND agent threads, visibility-driven seeding, dock-toggle + - * collapse controls, ⌘1–9 scoped to focus inside the host. `'window'` is the - * standalone terminal window: terminals ONLY (no agent threads), always visible, - * seeds its first tab on mount, the tab row doubles as the macOS title bar, no - * dock/collapse controls, and ⌘1–9 is scope-free. + * Whether this host CAN spawn a PTY at all — the caller's `terminal` surface + + * `ptyAvailable` check. Read by every surface, not just the terminal ones: the + * Ask-AI arbitration resolves the preferred AI against the whole space (see + * {@link SessionsHost}), so the agents panel needs the same global answer the + * terminal dock would compute or the two would disagree about where a passage + * belongs. + */ + readonly terminalCapable?: boolean; + /** + * Which surface this host is. `'terminal-dock'` is the bottom panel: terminals + * only, visibility-driven seeding, a collapse control, ⌘1–9 scoped to focus + * inside the host. `'agents-panel'` is the right panel: agent threads only, + * otherwise identical chrome. `'terminal-window'` is the standalone terminal + * window: terminals only, always visible, seeds its first tab on mount, the tab + * row doubles as the macOS title bar, no collapse control, and ⌘1–9 is + * scope-free. */ - readonly variant?: 'dock' | 'window'; + readonly surface: SessionSurface; /** Controlled visibility. The host reflects it and reports close-last back * through {@link onVisibleChange}; it never owns it. */ readonly visible: boolean; @@ -231,44 +257,67 @@ interface TerminalSessionsHostProps { /** "Open in terminal" launch intent — each new intent opens its own terminal tab. */ readonly launch?: TerminalLaunchIntent | null; /** "Start an agent" launch intent — each new intent opens its own thread tab (or - * the agent catalog when no concrete agent is resolvable). Dock variant only. */ + * the agent catalog when no concrete agent is resolvable). Agents panel only. */ readonly threadLaunch?: ThreadLaunchIntent | null; /** Which CLIs are on PATH (desktop probe). The New split-button resolves its * default CLI from this + the sticky pick. */ readonly installedClis?: Partial>; - /** The DOM container the live session subtree portals into right now (bottom - * dock mount or right region tenant). Null only transiently before a container - * attaches. */ + /** The DOM container the live session subtree portals into. Null only + * transiently before the container attaches. */ readonly container: HTMLElement | null; - /** Whether the dock is actually on screen — drives focus in/out. */ + /** Whether the panel is actually on screen — drives focus in/out. */ readonly isShowing: boolean; - /** Return focus to the editor when the dock hides or the last tab closes. */ + /** Return focus to the editor when the panel hides or the last tab closes. */ readonly onRequestEditorFocus: () => void; - /** Current dock position — passed to the strip's dock-toggle + collapse controls. */ - readonly dockPosition?: TerminalDockPosition; - /** Flip the dock between bottom and right. Dock variant only. */ - readonly onToggleDock?: () => void; - /** Reports whether ANY session (terminal or thread) is open, so the placement - * owner (EditorArea via EditorPane) can render the dock column/shell. */ - readonly onHasSessionsChange?: (hasSessions: boolean) => void; } /** - * Owns the unified session collection (terminals + agent threads) and the single - * stable host div. Mounted ONCE at a stable position ABOVE the editor's resizable - * panel group so a dock change cannot remount it — live shells, scrollback, - * transcripts, and tabs survive the move. The sessions render into the host div - * via a portal whose target never changes; the host div is appended into whichever - * {@link container} is active (bottom dock ↔ right region). + * The kinds the TERMINAL side owns — the exact complement of the agents panel's + * `thread` / `none`. Typed as a total `Record` on purpose: a bare `!agentsPanelKind` + * complement keeps the partition total, but "total" is not "correct" — a new + * `LauncherSelection` member would silently fall to the terminal dock, which may + * be the wrong owner. This makes adding one a compile error here instead. + */ +const TERMINAL_DOCK_KINDS: Record, true> = { + cli: true, + terminal: true, + desktop: true, +}; + +/** + * Owns one panel's session collection and its single stable host div. Mounted + * ONCE at a stable position ABOVE the editor's resizable panel group so a layout + * change cannot remount it — live shells, scrollback, transcripts, and tabs + * survive. The sessions render into the host div via a portal whose target never + * changes; the host div is appended into whichever {@link container} is active. * - * The tab strip + panels dispatch by `kind`: reorder / activate / rename / close - * are strip-uniform, their behavior kind-specific. Terminals are host-owned (PTY - * lifecycle); threads mirror the server-authoritative thread store (the host owns - * only the dock/tab model — order, active, visibility — never thread lifecycle). + * One component serves all three surfaces (see {@link SessionSurface}) because + * the tab model is identical: reorder / activate / rename / close are uniform, + * only their per-kind behavior differs. Terminals are host-owned (PTY lifecycle); + * threads mirror the server-authoritative thread store (the host owns only the + * tab model — order, active, visibility — never thread lifecycle). + * + * ── Launcher resolution ───────────────────────────────────────────────────── + * Every host resolves its New button through `resolveLauncherSelection`, and the + * pick sticks: the primary repeats whatever you last chose from the dropdown. The + * two panels differ only in which families they can offer — the agents panel gets + * in-app agents, the terminal ones get the CLIs plus a bare shell — because the + * `terminalAvailable` / `threadsAvailable` inputs are SURFACE-scoped, so a host + * can never resolve to something it cannot launch. + * + * The two GLOBAL-scoped resolutions are the exception. `askAiSelection` (a + * passage) and `preferredSessionSelection` (⇧⌘J) ask "what is the user's + * preferred AI" across both families, so both docked hosts compute the SAME + * answer and each claims only the kinds it owns ({@link claimsSessionKind}). + * Scoping either by surface instead would make the terminal dock resolve a CLI + * for a user whose preferred AI is an in-app agent — a session silently landing + * in the wrong panel. That is why {@link SessionsHostProps.terminalCapable} is + * read here even on the agents panel. */ -export function TerminalSessionsHost({ +export function SessionsHost({ bridge, - variant = 'dock', + terminalCapable = false, + surface, visible, onVisibleChange, launch = null, @@ -277,17 +326,22 @@ export function TerminalSessionsHost({ container, isShowing, onRequestEditorFocus, - dockPosition, - onToggleDock, - onHasSessionsChange, -}: TerminalSessionsHostProps) { +}: SessionsHostProps) { const { t } = useLingui(); - // Terminal affordances need a bridge that actually exposes the `terminal` - // surface (a session-only bridge, some E2E hosts, has none). Thread hosting is - // the dock variant only (the standalone terminal window is shells-only). - const terminalAvailable = bridge?.terminal != null; - const hostThreads = variant === 'dock'; + const isWindow = surface === 'terminal-window'; + const hostThreads = surface === 'agents-panel'; + const hostTerminals = !hostThreads; + // Terminal affordances additionally need a bridge that actually exposes the + // `terminal` surface (a session-only bridge, some E2E hosts, has none). + const terminalAvailable = hostTerminals && terminalCapable && bridge?.terminal != null; + // Which edge this panel occupies — the strip points its collapse chevron by it. + const edge: SessionPanelEdge = hostThreads ? 'right' : 'bottom'; + // Which per-window record this panel's tab order persists under. The two + // panels persist independently; the standalone window has nothing to restore + // into, so it does not persist at all. + const persistSurface: DockSurface = hostThreads ? 'agents' : 'terminal'; + const persistsOrder = !isWindow; // The single stable host div for the session subtree. Created once via a // useState lazy initializer (never a render-time ref write — React Compiler @@ -300,7 +354,8 @@ export function TerminalSessionsHost({ }); // Append the stable host div into the active container. A constant portal target - // plus DOM relocation means no remount on a dock move. useLayoutEffect runs + // plus DOM relocation means no remount when the container re-attaches (a + // view-kind change re-runs EditorArea's tree). useLayoutEffect runs // before the focus passive effects below, so the host is attached before a // focus-on-reveal. useLayoutEffect(() => { @@ -312,7 +367,7 @@ export function TerminalSessionsHost({ // reload, so the host rehydrates them on mount instead of starting fresh. When // it can, the synchronous terminal seed below stands down. Web + session-only // bridges keep the synchronous cold-start (no terminals to rehydrate). - const canRehydrate = typeof bridge?.terminal?.list === 'function'; + const canRehydrate = hostTerminals && typeof bridge?.terminal?.list === 'function'; // Seed the first terminal synchronously only on a terminal surface with no // rehydrate capability — web + session-only bridges never seed a terminal. @@ -326,9 +381,12 @@ export function TerminalSessionsHost({ // has no reload to restore, and its terminal persist goes through the bridge, not // localStorage — so a stale localStorage arrangement never yanks a fresh seed. const [webReloadOrder] = useState(() => { - if (canRehydrate || !hostThreads || coldSeedTerminal) return null; - // Only the web dock persists to localStorage; skip for the window variant. - return typeof bridge?.terminal?.getDockState === 'function' ? null : readWebDockSessionOrder(); + if (canRehydrate || !persistsOrder || coldSeedTerminal) return null; + // Only the web backend is readable synchronously; a desktop host reads main's + // per-window record from the async restore effect below. + return typeof bridge?.terminal?.getDockState === 'function' + ? null + : readWebDockSessionOrder(persistSurface); }); const reloadOrderRef = useRef(webReloadOrder?.order ?? []); // Active key still awaiting a matching session (reload restore). Cleared once @@ -364,13 +422,13 @@ export function TerminalSessionsHost({ coldSeedTerminal && launch ? launch.nonce : null, ); const lastHandledThreadNonceRef = useRef(null); - const prevVisibleRef = useRef(variant === 'window' ? false : visible); + const prevVisibleRef = useRef(isWindow ? false : visible); const ptyIdBySessionRef = useRef(new Map()); const stripLaunchNonceRef = useRef(0); // Live agent-thread tabs (server-authoritative). Always subscribed (the hook is // cheap + the store is empty until a URL is bound), reconciled into thread - // descriptors only in the dock variant. + // descriptors only on the agents panel. const openThreadTabs = useOpenAgentThreadTabs(); const archivedThreads = useArchivedAgentThreads(); // WS status for the agent-thread channel — drives a "reconnecting" banner shown @@ -406,13 +464,13 @@ export function TerminalSessionsHost({ /** Persist the current unified dock order + active key (reload-durable). Reads * the post-commit refs so it is correct when called from a ptyId callback. */ function persistDockOrderNow() { - if (!hostThreads) return; // the window variant IS the surface — nothing to restore into + if (!persistsOrder) return; // the standalone window IS the surface — nothing to restore into const ptyMap = ptyIdBySessionRef.current; const order = sessionsRef.current .map((session) => computePersistKey(session, ptyMap)) .filter((key): key is string => key != null); const active = sessionsRef.current.find((s) => s.id === activeSessionIdRef.current); - writeDockSessionOrder(bridge, { + writeDockSessionOrder(bridge, persistSurface, { order, activeKey: active != null ? computePersistKey(active, ptyMap) : null, }); @@ -461,19 +519,20 @@ export function TerminalSessionsHost({ setActiveSessionId(id); } - // Sticky pick mirror (raw id from the shared Ask-AI store) so the New split - // button's primary reflects the last pick across agents/CLIs/bare and updates - // reactively when the user switches it from the dropdown. - const [stickyAgentId, setStickyAgentId] = useState(() => loadStickyAgent()); - const [preferBareTerminal, setPreferBareTerminal] = useState(() => - getInitialPreferBareTerminal(), - ); - - // The shared, enablement-aware selection — the SAME `resolveLauncherSelection` - // the Ask + Create composers use, so a disabled agent / CLI is never what the - // dock primary launches (the old `resolveNewSessionChoice` forced a Claude CLI - // fallback that ignored the toggles). The dock offers no Desktop rows and falls - // back to a bare terminal, so it only ever yields agent / cli / terminal. + // Sticky pick, read from the SHARED stores rather than a mount-time snapshot. + // Both session hosts partition Ask-AI and ⇧⌘J work by comparing the kind each + // one resolves, which is only sound while they read the same inputs. Local + // `useState` copies diverged the moment a New-dropdown pick mutated one host, + // and from then on both claimed — ⇧⌘J opened two sessions and a passage landed + // in both panels. Subscribing keeps the invariant true by construction. + const stickyAgentId = useStickyAgent(); + const preferBareTerminal = usePreferBareTerminal(); + + // This panel's New button — the same enablement-aware resolver the Ask + Create + // composers use, so a disabled agent / CLI is never what the primary launches. + // SURFACE-scoped, unlike `askAiSelection` below: `terminalAvailable` is false on + // the agents panel and `threadsAvailable` false on the terminal ones, so a host + // can only ever resolve to a family it can actually launch. const selection = resolveLauncherSelection({ sticky: stickyAgentId, effectiveThreadAgent: effectiveDefaultAgent, @@ -486,14 +545,22 @@ export function TerminalSessionsHost({ preferBareTerminal, bareTerminalFallback: true, }); + // A CLI counts only as an explicit remembered pick. `resolveLauncherSelection` + // also falls back to the first enabled CLI when nothing is picked — right for a + // composer, wrong here: the terminal dock IS the terminal, so a TUI is something + // you choose, never what a fresh + hands you. Both CLI-less outcomes therefore + // collapse to a plain shell. + const pickedCli = parseStickyCliId(stickyAgentId); const newSessionChoice: NewSessionChoice = selection.kind === 'thread' ? { kind: 'agent', agent: selection.agent } : selection.kind === 'cli' - ? { kind: 'cli', cli: selection.cli } + ? selection.cli === pickedCli + ? { kind: 'cli', cli: selection.cli } + : { kind: 'terminal' } : selection.kind === 'terminal' ? { kind: 'terminal' } - : // 'none' (thread-only surface, nothing enabled) → primary opens Settings. + : // 'none' (nothing enabled to launch here) → primary opens Settings. { kind: 'agent', agent: null }; // Tab-strip New-chat primary: open a promptless terminal session running `cli`. @@ -502,7 +569,8 @@ export function TerminalSessionsHost({ openSession({ prompt: null, cli, nonce: stripLaunchNonceRef.current }); } - // Primary click: launch the current sticky pick across all three families. + // Primary click: launch this panel's current sticky pick — a bare shell, a CLI, + // or an in-app agent (Configure agents when nothing is enabled to launch). function launchSelectedNewTab() { if (newSessionChoice.kind === 'terminal') openSession(null); else if (newSessionChoice.kind === 'cli') openNewChatSession(newSessionChoice.cli); @@ -516,22 +584,14 @@ export function TerminalSessionsHost({ else openAgentSettings(); } - // Seed the dock when it opens empty (⌘J / edge reveal with nothing latched) by - // repeating the choice shown on the New-session primary button — EXCEPT the - // neutral `choose` (no default agent). A passive reveal must NOT auto-open the - // agent catalog: revealing an empty dock just shows it empty; starting an - // in-app agent stays an explicit New-button click. + // Seed an empty terminal dock on reveal by repeating the New primary's pick — + // a bare shell or the picked CLI. The agents panel stays empty until the user + // explicitly starts an agent: a passive reveal must not auto-open a thread or + // the catalog. A terminal host with nothing enabled likewise just shows empty. function seedOnReveal() { + if (!hostTerminals) return; if (newSessionChoice.kind === 'terminal') openSession(null); else if (newSessionChoice.kind === 'cli') openNewChatSession(newSessionChoice.cli); - else if (newSessionChoice.kind === 'agent' && newSessionChoice.agent != null) - launchAgentThread( - { source: newSessionChoice.agent.source, id: newSessionChoice.agent.id }, - null, - null, - null, - ); - // `choose` → reveal the empty dock without auto-opening the catalog. } /** @@ -541,6 +601,13 @@ export function TerminalSessionsHost({ * pick was "Terminal" still gets their preferred agent for Ask AI, and when * nothing is enabled this yields `none` (→ Configure agents) rather than * silently opening a shell. + * + * GLOBAL capabilities, not this surface's: threads are server-hosted so they + * are always launchable, and terminal availability is the caller's whole-app + * fact. Both docked hosts therefore resolve the same preferred AI and + * {@link claimsSessionKind} decides which one answers. The standalone terminal + * window is the exception — it has no sibling host in its window, so it + * resolves threads away and answers everything itself. */ const askAiSelection = resolveLauncherSelection({ sticky: stickyAgentId, @@ -548,18 +615,59 @@ export function TerminalSessionsHost({ enabledClis: enabledTerminalClis(enabledOverrides, installedClis ?? {}), enabledDesktopTargets: [], installedClis: installedClis ?? {}, - terminalAvailable, - threadsAvailable: hostThreads, + terminalAvailable: terminalCapable, + threadsAvailable: !isWindow, desktopSelectable: false, preferBareTerminal: false, bareTerminalFallback: false, }); + /** + * Where a promptless ⇧⌘J new session belongs. Globally scoped like + * {@link askAiSelection}, but with the bare-terminal knobs ON: ⇧⌘J carries no + * passage, so "Terminal" is a legitimate answer and a user whose last pick was + * a bare shell must get one rather than having it resolved away to an agent. + */ + const preferredSessionSelection = resolveLauncherSelection({ + sticky: stickyAgentId, + effectiveThreadAgent: effectiveDefaultAgent, + enabledClis: enabledTerminalClis(enabledOverrides, installedClis ?? {}), + enabledDesktopTargets: [], + installedClis: installedClis ?? {}, + terminalAvailable: terminalCapable, + threadsAvailable: !isWindow, + desktopSelectable: false, + preferBareTerminal, + bareTerminalFallback: true, + }); + + /** + * Whether THIS surface owns a session of `kind`. The two docked panels + * PARTITION the space, so an Ask-AI passage or a ⇧⌘J new session lands in + * exactly one. + * + * The agents panel takes `thread`, and `none` too: `none`'s destination is + * Configure agents, and the agents panel is the one that mounts on every host + * (the terminal dock is absent wherever no shell can spawn), so routing the + * fallback there is what keeps it reachable. The terminal dock takes the + * COMPLEMENT rather than an enumerated list — written as a partition so a new + * `LauncherSelection` member lands somewhere instead of being dropped by both. + * That is also what lets the bare-terminal-aware ⇧⌘J resolution share this: + * its extra `terminal` kind falls to the terminal dock without a special case. + */ + function claimsSessionKind(kind: LauncherSelection['kind']): boolean { + if (isWindow) return true; // no sibling host in that window — it answers everything + const agentsPanelKind = kind === 'thread' || kind === 'none'; + return hostThreads ? agentsPanelKind : kind in TERMINAL_DOCK_KINDS; + } + /** * Route an "Ask AI" passage (selection bubble, code block, Problems panel, the - * ⌘J/⇧⌘J selection sends) to the user's preferred AI. This is the single place - * the which-AI decision is made, so every surface honors the preferred agent - * rather than a per-surface default. + * ⌘J/⇧⌘J selection sends) to the user's preferred AI. Both docked hosts + * subscribe and both resolve the same `askAiSelection`; `claimsSessionKind` makes + * exactly one of them act, and the winner reveals its own panel. That is what + * keeps "preferred AI decides" true across two independent panels rather than + * "whichever panel the user last touched". * * Reuse first, unless the caller asked for a fresh session (⇧⌘J): a live thread * takes the passage as a staged composer draft; a live CLI takes it as a @@ -652,6 +760,11 @@ export function TerminalSessionsHost({ } function dispatchAskAi({ text, newTab, submit }: ActiveTerminalInputDetail) { + // Not this panel's kind — the sibling host answers. Returning here (rather + // than falling through) is what stops a passage double-landing. + if (!claimsSessionKind(askAiSelection.kind)) return; + // The passage is landing here, so this panel must be on screen for it. + if (!visible) onVisibleChange(true); const activeId = activeSessionIdRef.current; const active = sessionsRef.current.find((s) => s.id === activeId); if (!newTab && active != null) { @@ -700,17 +813,13 @@ export function TerminalSessionsHost({ // Dropdown CLI pick: clear the bare-terminal preference, persist `cli`, open it. function pickNewChatCli(cli: TerminalCli) { - setPreferBareTerminal(false); writePreferBareTerminal(false); - const id = terminalCliId(cli); - setStickyAgentId(id); - saveStickyAgent(id); + saveStickyAgent(terminalCliId(cli)); openNewChatSession(cli); } // Dropdown "Terminal" pick: persist the bare-shell preference, open a bare shell. function pickNewChatTerminal() { - setPreferBareTerminal(true); writePreferBareTerminal(true); openSession(null); } @@ -720,11 +829,8 @@ export function TerminalSessionsHost({ // a thread. Mirrors the catalog pick's "the agent you chose last is your agent". function pickNewChatAgent(agent: RegisteredAgent) { registerAgent(agent); - setPreferBareTerminal(false); writePreferBareTerminal(false); - const id = threadAgentId(agent); - setStickyAgentId(id); - saveStickyAgent(id); + saveStickyAgent(threadAgentId(agent)); launchAgentThread({ source: agent.source, id: agent.id }, null, null, null); } @@ -796,9 +902,11 @@ export function TerminalSessionsHost({ if (next.every((session, index) => session === prev[index])) return prev; return next; }); - // Persist the terminal-only display order to main (ptyIds in visual order) so a - // reorder survives a renderer reload — keeps `list()` self-consistent — and the - // unified cross-kind order for the mixed strip. + // Persist the terminal display order to main (ptyIds in visual order), which + // keeps `list()` self-consistent with what the strip shows. This covers + // terminals ONLY — each panel is single-kind now, and the order a panel + // restores after a reload comes from the separate `writeDockSessionOrder` + // effect below, not from here. const orderedPtyIds = newOrderIds .map((id) => ptyIdBySessionRef.current.get(id)) .filter((ptyId): ptyId is string => ptyId != null); @@ -819,11 +927,22 @@ export function TerminalSessionsHost({ reorderSessions(ids); return { label: sessionLabel(current[from]), position: to + 1, total: current.length }; } + // ⇧⌘J: a new session with the preferred AI. Both docked hosts subscribe, so the + // GLOBAL resolution picks the OWNER and the winner then launches its own primary + // and reveals itself. The claim is family-level, not exact: a global `cli` with + // no CLI actually picked hands the terminal dock a plain shell. That is the + // intended split of labor — global decides WHICH panel, the panel decides what. + function launchPreferredSession() { + if (!claimsSessionKind(preferredSessionSelection.kind)) return; + if (!visible) onVisibleChange(true); + launchSelectedNewTab(); + } + const moveActiveSessionRef = useRef(moveActiveSession); const openSessionRef = useRef(openSession); const seedOnRevealRef = useRef(seedOnReveal); const dispatchAskAiRef = useRef(dispatchAskAi); - const launchSelectedNewTabRef = useRef(launchSelectedNewTab); + const launchPreferredSessionRef = useRef(launchPreferredSession); // Close a tab — kind-dispatched. A terminal is removed from the list (its panel // unmounts, killing the PTY); a thread is archived server-side (discarded if it @@ -859,7 +978,7 @@ export function TerminalSessionsHost({ openSessionRef.current = openSession; seedOnRevealRef.current = seedOnReveal; dispatchAskAiRef.current = dispatchAskAi; - launchSelectedNewTabRef.current = launchSelectedNewTab; + launchPreferredSessionRef.current = launchPreferredSession; moveActiveSessionRef.current = moveActiveSession; activeSessionIdRef.current = activeSessionId; sessionsRef.current = sessions; @@ -881,19 +1000,19 @@ export function TerminalSessionsHost({ // changes (reorder, add, remove, activate). Reads the ptyId map by ref; the // computation uses only listed deps + the module-pure `computePersistKey`. useEffect(() => { - if (!hostThreads) return; + if (!persistsOrder) return; const ptyMap = ptyIdBySessionRef.current; const order = sessions .map((session) => computePersistKey(session, ptyMap)) .filter((key): key is string => key != null); const active = sessions.find((s) => s.id === activeSessionId); - writeDockSessionOrder(bridge, { + writeDockSessionOrder(bridge, persistSurface, { order, activeKey: active != null ? computePersistKey(active, ptyMap) : null, }); - }, [sessions, activeSessionId, hostThreads, bridge]); + }, [sessions, activeSessionId, persistsOrder, persistSurface, bridge]); - // ── Thread reconciliation (dock variant) ──────────────────────────────── + // ── Thread reconciliation (agents panel) ──────────────────────────────── // Mirror the server-authoritative open-thread list into thread descriptors: add // one per newly-open thread, drop one when its thread leaves the open set. The // host owns only order/active; the thread store owns thread lifecycle. Restored @@ -1022,7 +1141,7 @@ export function TerminalSessionsHost({ } }, [visible, launch, threadLaunch, sessions.length, rehydrationSettled, hostThreads]); - // "Start an agent" launch intent (dock variant): resolve the agent (concrete / + // "Start an agent" launch intent (agents panel): resolve the agent (concrete / // default-registered) and start a thread. Each new nonce opens its own thread // tab; the store reconcile + auto-reveal bring it to front + reveal. When no // agent resolves (nothing enabled yet), open Configure agents so the user can @@ -1050,6 +1169,7 @@ export function TerminalSessionsHost({ // survivor and read the persisted unified order + active key, so restored tabs // land in place and the active tab is restored across kinds. useEffect(() => { + if (!hostTerminals) return; if (typeof bridge?.terminal?.list !== 'function') return; if (rehydratedRef.current) return; rehydratedRef.current = true; @@ -1057,7 +1177,7 @@ export function TerminalSessionsHost({ void (async () => { // Read the persisted unified order first so terminal survivors are placed by // it (and threads, arriving async, land at their persisted slots too). - const persisted = await readDockSessionOrder(bridge).catch(() => null); + const persisted = await readDockSessionOrder(bridge, persistSurface).catch(() => null); if (!cancelled && persisted != null) { reloadOrderRef.current = persisted.order; pendingActiveKeyRef.current = persisted.activeKey; @@ -1116,13 +1236,38 @@ export function TerminalSessionsHost({ // rehydration on the second mount (see the original terminal host rationale). rehydratedRef.current = false; }; - }, [bridge]); + }, [bridge, hostTerminals, persistSurface]); + + // Reload restore for a surface with no PTYs to rehydrate (the agents panel, and + // any host on web). There is no `list()` pass to hang the read off, so read the + // persisted order directly; sessions arriving async then land at their slots. + // The web backend was already read synchronously into the mount seed above. + useEffect(() => { + if (canRehydrate || !persistsOrder) return; + if (typeof bridge?.terminal?.getDockState !== 'function') return; + let cancelled = false; + void readDockSessionOrder(bridge, persistSurface).then((persisted) => { + if (cancelled || persisted == null) return; + reloadOrderRef.current = persisted.order; + // Never override a key the user's own activation already cleared. Tested + // through a local rather than in place: `??=` (and the equivalent + // self-assignment) is un-lowerable by the React Compiler, while an + // `if (x === null) x = …` on the ref itself trips oxlint's + // logical-assignment rule. Reading first satisfies both. + const activationTookOver = pendingActiveKeyRef.current !== null; + if (!activationTookOver) pendingActiveKeyRef.current = persisted.activeKey; + }); + return () => { + cancelled = true; + }; + }, [bridge, canRehydrate, persistsOrder, persistSurface]); - // Web reload: no bridge to rehydrate, but a persisted active key may still be - // pending — bound its wait the same way so a stale key never blocks activation. + // Bound the reload-active wait on those same surfaces, so a restored active key + // whose session never materializes stops blocking live activation. Armed + // unconditionally (not gated on a key being pending yet) because the restore + // above resolves async — the key may not exist when this effect first runs. useEffect(() => { if (canRehydrate) return; - if (pendingActiveKeyRef.current == null) return; const timer = window.setTimeout(() => { pendingActiveKeyRef.current = null; }, 9_000); @@ -1138,29 +1283,33 @@ export function TerminalSessionsHost({ return subscribeToActiveTerminalInput((detail) => dispatchAskAiRef.current(detail)); }, [terminalAvailable, hostThreads]); - // ⇧⌘J with no selection: open a new session with the preferred AI — the same - // resolution the New split-button primary uses, so it is never hardcoded to a CLI. + // ⇧⌘J with no selection: open a new session with the preferred AI. Arbitrated + // like an Ask-AI passage — the GLOBAL resolution decides which panel owns the + // new session, then that panel opens it with its own surface-scoped primary + // (which resolves to the same thing, since the kinds now agree) and reveals + // itself. Without the claim check both panels would each open a tab. useEffect(() => { - return subscribeToPreferredSessionRequests(() => launchSelectedNewTabRef.current()); + return subscribeToPreferredSessionRequests(() => launchPreferredSessionRef.current()); }, []); - // Terminal application-menu actions act on the tab collection. + // Terminal application-menu actions act on the tab collection — terminal + // surfaces only, so "Kill Terminal" can never close an agent conversation. useEffect(() => { + if (!hostTerminals) return; return subscribeLocalMenuAction((action) => { if (action === 'new-terminal') { if (terminalAvailable) openSessionRef.current(null); } else if (action === 'kill-terminal') closeActiveRef.current(); - else if (action === 'close-active-tab-or-window' && variant === 'window') - closeActiveRef.current(); + else if (action === 'close-active-tab-or-window' && isWindow) closeActiveRef.current(); }); - }, [variant, terminalAvailable]); + }, [isWindow, hostTerminals, terminalAvailable]); // ⌘1–⌘9 jump straight to the Nth tab (capture phase, focus-scoped in the dock). useEffect(() => { function onKeyDown(event: KeyboardEvent) { if (!event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return; if (!/^[1-9]$/.test(event.key)) return; - if (!chordTargetsHost(hostEl, variant)) return; + if (!chordTargetsHost(hostEl, isWindow)) return; // `chordTargetsHost` short-circuits to true in the dedicated terminal // window, so focus alone does not gate this there. if (isOverlayLayerOpen()) return; @@ -1173,7 +1322,7 @@ export function TerminalSessionsHost({ } window.addEventListener('keydown', onKeyDown, { capture: true }); return () => window.removeEventListener('keydown', onKeyDown, { capture: true }); - }, [hostEl, variant]); + }, [hostEl, isWindow]); // ⌘⇧← / ⌘⇧→ move the ACTIVE tab one slot (capture phase + focus-gate). useEffect(() => { @@ -1181,7 +1330,7 @@ export function TerminalSessionsHost({ if (!event.metaKey || !event.shiftKey || event.ctrlKey || event.altKey) return; const direction = event.key === 'ArrowLeft' ? -1 : event.key === 'ArrowRight' ? 1 : 0; if (direction === 0) return; - if (!chordTargetsHost(hostEl, variant)) return; + if (!chordTargetsHost(hostEl, isWindow)) return; if (isOverlayLayerOpen()) return; if (dragActiveRef.current) return; const target = event.target as HTMLElement | null; @@ -1208,32 +1357,27 @@ export function TerminalSessionsHost({ announceTimerRef.current = null; } }; - }, [hostEl, variant, t]); + }, [hostEl, isWindow, t]); // Reflect terminal liveness to main so the Terminal menu's "Kill Terminal" // enables only while at least one TERMINAL session is live. useEffect(() => { + if (!hostTerminals) return; const terminalLive = sessions.some((s) => s.kind === 'terminal'); // Mirror into the renderer store so the Cmd+K palette can gate "Kill // terminal" on a live session (the bridge push below is main-only). setViewMenuState({ terminalLive }); bridge?.editor.notifyViewMenuStateChanged({ terminalLive }); - }, [bridge, sessions]); - - useEffect(() => { - onHasSessionsChange?.(sessions.length > 0); - }, [onHasSessionsChange, sessions.length]); + }, [bridge, sessions, hostTerminals]); - // Return focus out of the hidden dock so a keyboard user is never stranded. - // Only acts when focus is actually inside the dock. + // Return focus out of the hidden panel so a keyboard user is never stranded. + // Only acts when focus is actually inside the host. // - // Gate on `visible`, not just `isShowing`: a dock move (bottom ↔ right) keeps the - // dock `visible` but transiently drops `isShowing` to false for one commit - // while the destination container's callback ref attaches (`activeTerminalContainer` - // is null until then). Without the `visible` guard, clicking the dock-toggle — - // which lives inside this portaled host, so focus is inside it — would satisfy the - // focus-inside check and yank focus to the editor mid-move. A genuine hide (⌘J, - // collapse, close-last) always sets `visible` false, so focus-return still fires. + // Gate on `visible`, not just `isShowing`: `isShowing` also dips for a commit + // whenever the container's callback ref re-attaches, and a focus yank on that + // edge would steal the caret out of a panel that never actually closed. A + // genuine hide (the toggle, collapse, close-last) always sets `visible` false, + // so focus-return still fires. useLayoutEffect(() => { if (isShowing || visible) return; if (!focusInsideHost(hostEl)) return; @@ -1280,19 +1424,18 @@ export function TerminalSessionsHost({ ); // The conversation-history menu rides in the strip's trailing controls, just - // left of the dock-toggle/collapse buttons — shown only with archived history to - // return to. + // left of the collapse button — shown only with archived history to return to. const trailingControls = hostThreads && archivedThreads.length > 0 ? ( ) : null; // Render the strip (with the + split button + a starting/empty body) whenever - // there are sessions, or the dock is visible in the dock variant — so a visible - // dock always shows immediate feedback while a session spins up, and an entry - // point when idle-empty. The standalone terminal window seeds on mount, so it - // shows the strip only once it has a tab (no empty flash). - const showStrip = sessions.length > 0 || (visible && hostThreads); + // there are sessions, or a docked panel is visible — so an open panel always + // shows immediate feedback while a session spins up, and an entry point when + // idle-empty. The standalone terminal window seeds on mount, so it shows the + // strip only once it has a tab (no empty flash). + const showStrip = sessions.length > 0 || (visible && !isWindow); const sessionViews = showStrip ? ( { dragActiveRef.current = active; }} - dockPosition={dockPosition} - onToggleDock={onToggleDock} - onCollapse={variant === 'window' ? undefined : () => onVisibleChange(false)} - draggable={variant === 'window'} + edge={edge} + onCollapse={isWindow ? undefined : () => onVisibleChange(false)} + draggable={isWindow} className="h-full" > {sessions.length === 0 ? ( - // A terminal surface auto-seeds on reveal, so an empty dock is only ever a - // sub-frame transient there — render nothing (the + button suffices) so no - // "no sessions" text flashes. On a thread-only surface an empty dock is a + // A terminal surface auto-seeds on reveal, so an empty panel is only ever + // a sub-frame transient there — render nothing (the + button suffices) so + // no "no sessions" text flashes. On the agents panel an empty state is a // real resting state: offer the reopen-a-past-conversation chooser when // there is history, else the entry-point guidance. terminalAvailable ? null : hostThreads && archivedThreads.length > 0 ? ( @@ -1342,7 +1484,7 @@ export function TerminalSessionsHost({ {...(session.kind === 'terminal' ? { 'data-terminal-session': session.id } : {})} className={cn( 'm-0 flex min-h-0 flex-1 flex-col overflow-hidden data-[state=inactive]:hidden', - variant === 'window' && 'px-[22px] pb-[22px]', + isWindow && 'px-[22px] pb-[22px]', )} > {session.kind === 'terminal' ? ( diff --git a/packages/app/src/components/TerminalDock.dom.test.tsx b/packages/app/src/components/TerminalDock.dom.test.tsx index eea595a3f..bcf1749d9 100644 --- a/packages/app/src/components/TerminalDock.dom.test.tsx +++ b/packages/app/src/components/TerminalDock.dom.test.tsx @@ -26,9 +26,9 @@ import { __resetLocalMenuActionBusForTests, emitLocalMenuAction, } from '@/lib/local-menu-action-bus'; -import type { TerminalDockPosition } from '@/lib/terminal-dock-store'; import { writePreferBareTerminal } from '@/lib/terminal-new-tab-store'; import { saveStickyAgent, terminalCliId } from '@/lib/unified-agent-store'; +import { requestPreferredSession } from './handoff/preferred-session-events'; import { requestActiveTerminalInput } from './handoff/terminal-input-events'; import { subscribeToTerminalLaunchRequests } from './handoff/terminal-launch-events'; @@ -170,7 +170,7 @@ vi.doMock('@/lib/terminal-height-store', () => ({ })); const { TerminalDock, MAX_STRANDED_REPORTS } = await import('./TerminalDock'); -const { TerminalSessionsHost } = await import('./TerminalSessionsHost'); +const { SessionsHost } = await import('./SessionsHost'); // After the vi.doMock block (a static import would load the real xterm). const { STAGE_PASTE_SETTLE_MS } = await import('./TerminalPanel'); @@ -202,7 +202,7 @@ function makeBridge() { kill, input, viewMenuPushes, - // TerminalSessionsHost now listens on the renderer-local menu-action bus + // SessionsHost now listens on the renderer-local menu-action bus // (a real menu click reaches it via main → the bus forwarder), so the test // drives it with emitLocalMenuAction. dispatchMenuAction(action: OkMenuAction) { @@ -213,11 +213,10 @@ function makeBridge() { // Mini-harness mirroring how EditorArea wires the two pieces: the TerminalDock // shell exposes the bottom mount + editor-region elements, and the once-mounted -// TerminalSessionsHost portals the live sessions into that container. `isShowing` -// is gated on the container so focus never targets a detached host (the same -// invariant EditorArea enforces). Session behavior is bottom-dock only — -// right-dock placement is covered by EditorArea + the live-Electron smoke; the -// `dock` knob here exercises only the shell's handle gating across positions. +// SessionsHost portals the live sessions into that container. `isShowing` is +// gated on the container so focus never targets a detached host (the same +// invariant EditorArea enforces). This is the TERMINAL surface's suite; the +// agents panel has its own (SessionsHost.agents.dom.test.tsx). // Structural mirror of TerminalLaunchIntent (EditorPane) so tests can express // promptless / staged launches without casts. type TestLaunch = { @@ -232,8 +231,6 @@ function DockHarness({ l, onVisibleChange, bridge, - onReveal, - dock = 'bottom', // biome-ignore lint/suspicious/noExplicitAny: test harness props }: any) { const [bottomContainer, setBottomContainer] = useState(null); @@ -243,43 +240,31 @@ function DockHarness({
- editorRegionEl?.focus()} - dockPosition={dock} - onToggleDock={() => {}} /> ); } -function renderDock(visible: boolean, launch?: TestLaunch | null, onReveal?: () => void) { +function renderDock(visible: boolean, launch?: TestLaunch | null) { const onVisibleChange = vi.fn((_v: boolean) => {}); const { bridge, create, kill, input, viewMenuPushes, dispatchMenuAction } = makeBridge(); - const ui = (v: boolean, l?: TestLaunch | null, dock?: TerminalDockPosition) => ( - + const ui = (v: boolean, l?: TestLaunch | null) => ( + ); const utils = render(ui(visible, launch)); return { @@ -290,8 +275,7 @@ function renderDock(visible: boolean, launch?: TestLaunch | null, onReveal?: () input, viewMenuPushes, dispatchMenuAction, - rerender: (v: boolean, l?: TestLaunch | null, dock?: TerminalDockPosition) => - utils.rerender(ui(v, l, dock)), + rerender: (v: boolean, l?: TestLaunch | null) => utils.rerender(ui(v, l)), }; } @@ -324,12 +308,9 @@ function editorRegion(): HTMLElement { return region; } -// Adds a plain-shell tab via the New-chat split button's "Terminal" option — the -// path that replaced the standalone "New terminal tab" button. Opens a bare shell -// (no CLI launch), the same session the old button created. +// Adds a plain-shell tab via the terminal panel's New button. async function addTerminalTab(user: ReturnType) { - await user.click(screen.getByRole('button', { name: 'Choose what a new session starts' })); - await user.click(await screen.findByRole('menuitem', { name: 'Terminal' })); + await user.click(screen.getByRole('button', { name: 'New terminal' })); } describe('TerminalDock multi-session', () => { @@ -348,13 +329,11 @@ describe('TerminalDock multi-session', () => { __resetLocalMenuActionBusForTests(); }); - test('tab strip exposes the dock-toggle + collapse buttons and no drag grip', () => { + test('tab strip exposes the collapse button, and no way to move the dock', () => { renderDock(true); - // The dock-toggle button is the dock-move affordance now (dragging removed). - // The harness is bottom-docked, so the toggle offers "move to the right". - expect(screen.getByRole('button', { name: 'Dock sessions on the right' })).not.toBeNull(); - expect(screen.getByRole('button', { name: 'Collapse session dock' })).not.toBeNull(); - // The old drag grip is gone. + expect(screen.getByRole('button', { name: 'Collapse panel' })).not.toBeNull(); + // The terminal owns the bottom edge outright: no dock-toggle, no drag grip. + expect(screen.queryByRole('button', { name: /Dock sessions/ })).toBeNull(); expect(screen.queryByRole('button', { name: 'Drag to dock the terminal' })).toBeNull(); }); @@ -390,6 +369,53 @@ describe('TerminalDock multi-session', () => { expect(screen.getByTestId('terminal-session').getAttribute('data-cli')).toBe('cursor'); }); + test('opening an empty dock launches a bare shell when Terminal is the pick', () => { + writePreferBareTerminal(true); + const view = renderDock(false); + + act(() => view.rerender(true)); + + expect(screen.getByTestId('terminal-session').getAttribute('data-cli')).toBe('none'); + }); + + // A CLI is opt-in here. `resolveLauncherSelection` would hand back the first + // enabled CLI with nothing picked (the right default for a composer), so the + // dock gates on an explicit pick — otherwise a user who never chose a TUI gets + // dropped into one. No sticky is set: `localStorage` is cleared per test. + test('opening an empty dock with NO pick launches a bare shell, not the first enabled CLI', () => { + const view = renderDock(false); + + act(() => view.rerender(true)); + + expect(screen.getByTestId('terminal-session').getAttribute('data-cli')).toBe('none'); + }); + + test('a preferred-session shortcut launches the preferred CLI', () => { + writePreferBareTerminal(false); + saveStickyAgent(terminalCliId('cursor')); + renderDock(true); + + act(() => requestPreferredSession()); + + const sessions = screen.getAllByTestId('terminal-session'); + expect(sessions).toHaveLength(2); + expect(sessions[1].getAttribute('data-cli')).toBe('cursor'); + }); + + // The bare-shell pick is the case that forces ⇧⌘J to resolve on its own inputs + // rather than reusing the Ask-AI resolution, which discards it: a passage needs + // an AI, but a promptless new session may legitimately be a plain shell. + test('a preferred-session shortcut honors a bare-shell pick', () => { + writePreferBareTerminal(true); + renderDock(true); + + act(() => requestPreferredSession()); + + const sessions = screen.getAllByTestId('terminal-session'); + expect(sessions).toHaveLength(2); + expect(sessions[1].getAttribute('data-cli')).toBe('none'); + }); + test('the new-terminal control adds a session, activates it, and spawns its PTY', async () => { const user = userEvent.setup(); const view = renderDock(true); @@ -1147,7 +1173,7 @@ describe('TerminalDock multi-session', () => { renderDock(true); await addTerminalTab(user); - const tablist = screen.getByRole('tablist', { name: 'Sessions' }); + const tablist = screen.getByRole('tablist', { name: 'Terminal sessions' }); const tabs = within(tablist).getAllByRole('tab'); expect(tabs).toHaveLength(2); // Each tab's aria-controls resolves to a rendered panel (no dangling ref). @@ -1181,39 +1207,22 @@ describe('TerminalDock multi-session', () => { expect(document.activeElement).toBe(session); }); - test('shows the bottom-edge "Open session dock" tab only while hidden, inside the editor column', () => { - const onReveal = vi.fn(() => {}); - const view = renderDock(false, null, onReveal); - - // Hidden → the reveal tab is present, and lives inside the editor region (not - // the doc panel), since a bottom-docked terminal slides up from there. - const reveal = screen.getByRole('button', { name: 'Open session dock' }); - expect(editorRegion().contains(reveal)).toBe(true); + // The terminal deliberately has NO edge affordance: it is a ⌘J surface, and a + // permanent tab over the editor footer's bottom-right competed with the Ask AI + // composer for that corner. The agents panel is the one panel that keeps a tab + // (asserted in EditorArea's suite, which owns that placement). + test('renders no edge reveal tab, hidden or visible', () => { + const view = renderDock(false); + expect(screen.queryByRole('button', { name: 'Open terminal' })).toBeNull(); + expect(editorRegion().querySelector('[data-terminal-reveal]')).toBeNull(); - // Visible → the reveal tab is gone (the tab strip's collapse control is the - // hide affordance while open). act(() => view.rerender(true)); - expect(screen.queryByRole('button', { name: 'Open session dock' })).toBeNull(); - }); - - test('clicking the reveal tab requests a reveal', async () => { - const user = userEvent.setup(); - const onReveal = vi.fn(() => {}); - renderDock(false, null, onReveal); - - await user.click(screen.getByRole('button', { name: 'Open session dock' })); - - expect(onReveal).toHaveBeenCalledTimes(1); - }); - - test('renders no reveal tab when no reveal handler is wired (web host)', () => { - renderDock(false); - expect(screen.queryByRole('button', { name: 'Open session dock' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Open terminal' })).toBeNull(); }); test('disables the resize handle while hidden so there is no drag-to-open', () => { const view = renderDock(false); - // Hidden: dragging up to open is gone (the reveal tab is the single way in). + // Hidden: dragging up to open is gone (⌘J is the way back in). expect(screen.getByTestId('terminal-resize-handle').getAttribute('data-disabled')).toBe('true'); // Open: the handle is live again — resize + drag-all-the-way-down-to-collapse. @@ -1222,98 +1231,10 @@ describe('TerminalDock multi-session', () => { 'false', ); }); - - test('hides the grabber while right-docked and restores it on return to bottom', () => { - // Right-docked, terminal visible: `visible` stays true but the bottom panel is - // collapsed and empty — the handle must not render its grabber nor accept a - // drag (which would pull up an empty panel). - const view = renderDock(true); - act(() => view.rerender(true, null, 'right')); - const handle = () => screen.getByTestId('terminal-resize-handle'); - expect(handle().getAttribute('data-disabled')).toBe('true'); - expect(handle().getAttribute('data-with-handle')).toBe('false'); - - // Dock back to bottom: the grabber returns and the handle drags again. - act(() => view.rerender(true, null, 'bottom')); - expect(handle().getAttribute('data-disabled')).toBe('false'); - expect(handle().getAttribute('data-with-handle')).toBe('true'); - }); -}); - -// Regression: the focus-return effect must distinguish a genuine hide (⌘J / -// collapse / close-last → `visible` false) from a dock move, where `visible` stays -// true but `isShowing` transiently dips to false for one commit while the -// destination container's callback ref attaches. The dock-toggle button lives -// inside the portaled host, so a move starts with focus inside the host — without -// the `visible` guard, the transient dip would yank focus to the editor mid-move. -describe('TerminalSessionsHost focus-return gating across a dock move', () => { - afterEach(() => cleanup()); - - function FocusHarness({ - bridge, - isShowing, - visible, - onEditorFocus, - }: { - // biome-ignore lint/suspicious/noExplicitAny: test harness bridge stub - bridge: any; - isShowing: boolean; - visible: boolean; - onEditorFocus: () => void; - }) { - const [container, setContainer] = useState(null); - return ( - -
- {}} - launch={null} - container={container} - isShowing={isShowing} - onRequestEditorFocus={onEditorFocus} - dockPosition="right" - onToggleDock={() => {}} - /> - - ); - } - - test('a dock move keeps focus (visible stays true); a genuine hide returns focus to the editor', () => { - const onEditorFocus = vi.fn(() => {}); - const { bridge } = makeBridge(); - const ui = (isShowing: boolean, visible: boolean) => ( - - ); - const { rerender } = render(ui(true, true)); - - // One session is seeded (visible at mount); put focus inside the portaled host. - const sink = document.querySelector( - '[data-terminal-session] .xterm-helper-textarea', - ); - act(() => sink?.focus()); - expect(onEditorFocus).not.toHaveBeenCalled(); - - // Dock-move transient: isShowing dips to false while visible stays true. Focus - // must NOT be yanked to the editor. - act(() => rerender(ui(false, true))); - expect(onEditorFocus).not.toHaveBeenCalled(); - - // Genuine hide: visible flips false → focus returns to the editor. - act(() => rerender(ui(false, false))); - expect(onEditorFocus).toHaveBeenCalled(); - }); }); // The behavior-preservation contract for the terminal session model -// (TerminalSessionsHost, shared by the dock and the standalone terminal -// window): these five behaviors (close-last collapse, seed-on-reveal, +// (SessionsHost, shared by the dock and the standalone terminal window): these five behaviors (close-last collapse, seed-on-reveal, // single-tab-per-launch-nonce, Cmd+number tab switch, close-active-neighbor // focus) are the ones most easily broken when the dock's container wiring and // the shared session core drift out of lockstep. Kept as a discrete, minimal @@ -1484,7 +1405,6 @@ describe('TerminalDock hidden-dock invariant', () => { event: 'ok-terminal-dock-stranded-while-hidden', panelPx: 588, panelPct: 42.5, - dockPosition: 'bottom', visible: false, }); // Viewport geometry is the half that distinguishes a height clamped for the diff --git a/packages/app/src/components/TerminalDock.reload-survival.dom.test.tsx b/packages/app/src/components/TerminalDock.reload-survival.dom.test.tsx index 644110833..ea5575990 100644 --- a/packages/app/src/components/TerminalDock.reload-survival.dom.test.tsx +++ b/packages/app/src/components/TerminalDock.reload-survival.dom.test.tsx @@ -99,11 +99,11 @@ vi.doMock('@/lib/terminal-height-store', () => ({ })); const { TerminalDock } = await import('./TerminalDock'); -const { TerminalSessionsHost } = await import('./TerminalSessionsHost'); +const { SessionsHost } = await import('./SessionsHost'); // Mirror EditorArea's wiring: the TerminalDock shell exposes the bottom mount, and -// the once-mounted TerminalSessionsHost (which now owns the session collection + -// reload rehydration) portals the live sessions into it. The rehydration the +// the once-mounted SessionsHost (which owns the session collection + reload +// rehydration) portals the live sessions into it. The rehydration the // renderer half is responsible for lives in the host, so reload-survival is // asserted through this pair, not TerminalDock alone. function ReloadHarness({ @@ -121,14 +121,15 @@ function ReloadHarness({ {}} - dockPosition="bottom" onBottomContainer={setBottomContainer} onEditorRegion={() => {}} >
- {}} // biome-ignore lint/suspicious/noExplicitAny: test launch shape @@ -136,8 +137,6 @@ function ReloadHarness({ container={bottomContainer} isShowing={visible && bottomContainer != null} onRequestEditorFocus={() => {}} - dockPosition="bottom" - onToggleDock={() => {}} /> ); @@ -238,7 +237,7 @@ describe('issue #351 — the terminal dock rehydrates surviving sessions after a // Tabs come back in main's returned (reordered) order with the custom names // restored; the un-named survivor falls back to its RESTORED sticky ordinal // (Terminal 1), not a positional renumber to Terminal 2. - const tablist = screen.getByRole('tablist', { name: 'Sessions' }); + const tablist = screen.getByRole('tablist', { name: 'Terminal sessions' }); const tabs = within(tablist).getAllByRole('tab'); expect(tabs.map((tab) => tab.textContent)).toEqual(['deploy', 'Terminal 1', 'logs']); }); diff --git a/packages/app/src/components/TerminalDock.tsx b/packages/app/src/components/TerminalDock.tsx index 29352744a..afd6824df 100644 --- a/packages/app/src/components/TerminalDock.tsx +++ b/packages/app/src/components/TerminalDock.tsx @@ -2,14 +2,12 @@ import { useTheme } from 'next-themes'; import { type ReactNode, useEffect, useRef, useState } from 'react'; import { usePanelRef } from 'react-resizable-panels'; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'; -import type { TerminalDockPosition } from '@/lib/terminal-dock-store'; import { clampTerminalHeight, getInitialTerminalHeight, writeTerminalHeight, } from '@/lib/terminal-height-store'; import { cn } from '@/lib/utils'; -import { TerminalRevealTab } from './TerminalRevealTab'; import { useLiveXtermTheme } from './use-live-xterm-theme'; const TERMINAL_PANEL_ID = 'terminal-dock-panel'; @@ -26,23 +24,14 @@ interface TerminalDockProps { /** The editor chrome (header + area) the terminal docks beneath. */ readonly children: ReactNode; /** - * Controlled visibility. The bottom panel opens when the terminal is bottom- - * docked AND visible; drag-collapsing it reports back through {@link onVisibleChange}. + * Controlled visibility. Drag-collapsing the bottom panel reports back through + * {@link onVisibleChange}. */ readonly visible: boolean; readonly onVisibleChange: (visible: boolean) => void; - /** - * Where the terminal is docked. `'bottom'` opens this component's bottom panel; - * `'right'` keeps it collapsed (the terminal lives in the right region — the - * live session host portals into that container instead, mounted above this - * component so a dock change never remounts it). - */ - readonly dockPosition?: TerminalDockPosition; /** * Callback ref reporting the bottom-dock mount element up to EditorArea, which - * passes it to the session host as a portal target. The host div lands here when - * the terminal is bottom-docked; when right-docked the host attaches to the - * right column's container instead and this bottom panel stays collapsed + empty. + * passes it to the terminal session host as a portal target. */ readonly onBottomContainer: (el: HTMLDivElement | null) => void; /** @@ -50,19 +39,6 @@ interface TerminalDockProps { * the session host to return focus to the editor when the terminal hides. */ readonly onEditorRegion: (el: HTMLDivElement | null) => void; - /** - * Reveal the sessions dock, launching the user's preferred New-session choice - * if none is open. Wired to the edge "Open session dock" tab this shell renders at - * the bottom of the editor region when {@link showRevealTab} is set. The - * right-dock reveal tab is owned by EditorArea instead (different container). - */ - readonly onReveal?: () => void; - /** - * Whether to render the bottom edge reveal tab. EditorArea owns the predicate - * (dock-hidden with a reason to reveal: a terminal surface, or latched tabs to - * return to on the web host), so this shell just renders it when told. - */ - readonly showRevealTab?: boolean; } /** @@ -70,33 +46,21 @@ interface TerminalDockProps { * editor on top and a collapsible bottom panel beneath. It owns the bottom panel's * height (persist + drag) and collapse, and exposes the bottom mount + editor * region elements. It deliberately owns NO session state — the live terminal lives - * in {@link TerminalSessionsHost}, mounted above the panel group, and portals into - * the bottom mount this component renders. That separation is what lets the - * terminal move docks (and the editor re-render) without re-spawning the PTY. + * in {@link SessionsHost}, mounted above the panel group, and portals into + * the bottom mount this component renders. That separation is what lets the editor + * re-render (and the view kind change) without re-spawning the PTY. */ export function TerminalDock({ children, visible, onVisibleChange, - dockPosition = 'bottom', onBottomContainer, onEditorRegion, - onReveal, - showRevealTab = false, }: TerminalDockProps) { const { resolvedTheme } = useTheme(); const panelRef = usePanelRef(); const [isCollapsed, setIsCollapsed] = useState(!visible); const xtermBackground = useLiveXtermTheme(resolvedTheme).background; - // A right-docked terminal keeps `visible` true while this panel sits collapsed - // and empty — the handle must gate on this, not `visible`, or the grabber - // lingers and drags up an empty panel. - const bottomOpen = visible && dockPosition === 'bottom'; - // The edge "Show sessions" tab belongs to the bottom dock only — it hugs the - // bottom of the editor column, where a bottom-docked dock slides up from. - // EditorArea owns the predicate (dock hidden + a reason to reveal). - const showBottomRevealTab = showRevealTab && dockPosition === 'bottom' && onReveal != null; - // Snapshot the persisted height once at mount; the ref carries the running value // during user drag. const [initialHeightPx] = useState(() => getInitialTerminalHeight()); @@ -129,7 +93,6 @@ export function TerminalDock({ event: 'ok-terminal-dock-stranded-while-hidden', panelPx: Math.round(panelPx), panelPct: Number.isFinite(panelPct) ? Math.round(panelPct * 10) / 10 : null, - dockPosition, visible, // The height this shell would reopen at. Reading it against `innerHeight` // is what distinguishes a value clamped for the current viewport from one @@ -158,13 +121,12 @@ export function TerminalDock({ ); // Drive the panel from the controlled prop: restore the persisted height when - // bottom-docked and visible, collapse otherwise (hidden, or right-docked where - // the terminal lives in the right region). + // visible, collapse when hidden. useEffect(() => { const panel = panelRef.current; if (panel == null) return; try { - if (bottomOpen) { + if (visible) { panel.resize(`${heightPxRef.current}px`); } else { panel.collapse(); @@ -173,10 +135,10 @@ export function TerminalDock({ // The imperative panel handles throw once their group has unregistered // (same reason EditorArea's assertRightRailLayout wraps its calls). A throw // means the panel never resized, so no onResize fires and the invariant - // guard cannot see it: recovery is the next `bottomOpen` transition + // guard cannot see it: recovery is the next `visible` transition // re-running this effect, or any later change to the panel's own box. } - }, [bottomOpen, panelRef]); + }, [visible, panelRef]); // The persisted height is viewport-relative: `readTerminalHeight` caps it at // 50vh, but only at read time, and this shell snapshots it once at mount. A @@ -190,7 +152,7 @@ export function TerminalDock({ const next = clampTerminalHeight(heightPxRef.current); if (next === heightPxRef.current) return; heightPxRef.current = next; - if (!bottomOpen) return; + if (!visible) return; try { panelRef.current?.resize(`${next}px`); } catch { @@ -199,7 +161,7 @@ export function TerminalDock({ }; window.addEventListener('resize', reclampToViewport); return () => window.removeEventListener('resize', reclampToViewport); - }, [bottomOpen, panelRef]); + }, [visible, panelRef]); return ( {/* tabIndex -1 makes this a programmatic focus target for focus-return on - collapse without adding it to the tab order. `relative` anchors the - bottom-dock reveal tab to the bottom of the editor column. */} + collapse without adding it to the tab order. */}
{children} - {showBottomRevealTab && onReveal ? ( - - ) : null}
- {/* The handle drags only while the bottom panel is open: you can resize it, - and drag all the way down to collapse (hide). Otherwise it is disabled — - when bottom-docked-and-hidden the reveal tab rendered above is the single - way back in, and when right-docked the ways in live outside this file - (EditorArea's reveal tab; the tab strip's dock-toggle) — so there is no - drag-up-to-open (which would be a second, redundant way in). Gating on + {/* The handle drags only while the panel is open: you can resize it, and + drag all the way down to collapse (hide). While hidden it is disabled — + the terminal has no edge tab, so ⌘J (or the View menu) is the way back + in and drag-up-to-open would be a hidden second entry point. Gating on controlled props (not `isCollapsed`) means an in-progress drag-to-collapse completes before the handle disables on the next commit. */} { - if (!bottomOpen) return; + if (!visible) return; setIsDragging(true); isDraggingRef.current = true; const handleUp = () => { @@ -258,7 +210,7 @@ export function TerminalDock({ // the terminal — no app-background seam between the strip and canvas. style={{ backgroundColor: xtermBackground }} panelRef={panelRef} - defaultSize={bottomOpen ? `${initialHeightPx}px` : 0} + defaultSize={visible ? `${initialHeightPx}px` : 0} minSize="120px" // The terminal can be dragged tall — up to 95% of the dock — leaving the // editor a 5% sliver (its panel `minSize`). Pair the two: the terminal's max @@ -270,9 +222,9 @@ export function TerminalDock({ const collapsed = size.asPercentage === 0; setIsCollapsed(collapsed); // Invariant: the bottom panel occupies ZERO height whenever the dock is - // not open (hidden, or right-docked). The `bottomOpen` effect asserts - // that only on a TRANSITION, so any path leaving the panel expanded - // without flipping `bottomOpen` — a library re-layout, or a collapse + // hidden. The `visible` effect asserts that only on a TRANSITION, so + // any path leaving the panel expanded without flipping `visible` — a + // library re-layout, or a collapse // issued while the group was unmeasurable and therefore discarded — // strands the editor behind an empty band with no dock chrome and // nothing left to re-assert it. This is the panel's own resize signal, @@ -284,7 +236,7 @@ export function TerminalDock({ // `asPercentage` NaN, which compares false against 0 and would read as // "expanded", firing this guard spuriously on a panel that has no size // at all. - if (!bottomOpen && size.inPixels > 0 && !isDraggingRef.current) { + if (!visible && size.inPixels > 0 && !isDraggingRef.current) { reportStrandedDock(size.inPixels, size.asPercentage); try { panelRef.current?.collapse(); @@ -315,7 +267,7 @@ export function TerminalDock({ 'transition-[flex-grow] duration-150 ease-out motion-reduce:transition-none motion-reduce:duration-0', )} > - {/* Mount point for the session host's stable host div when bottom-docked. */} + {/* Mount point for the terminal host's stable host div. */}
diff --git a/packages/app/src/components/TerminalNewChatButton.tsx b/packages/app/src/components/TerminalNewChatButton.tsx index c7871a4a0..71d8848a9 100644 --- a/packages/app/src/components/TerminalNewChatButton.tsx +++ b/packages/app/src/components/TerminalNewChatButton.tsx @@ -106,6 +106,7 @@ export function TerminalNewChatButton({ }); const maxThreads = catalog.data?.maxThreads ?? 8; const atCap = liveThreadCount >= maxThreads; + const hasMenu = showAgents || showClis; // The `t` template macro is scope-bound, so the label is computed inline here // rather than in a helper that receives `t` as an argument. @@ -128,10 +129,15 @@ export function TerminalNewChatButton({ size="xs" aria-label={primaryLabel} data-testid="terminal-new-chat" - className="cursor-pointer gap-0.5 rounded-r-none px-1.5 text-muted-foreground hover:text-foreground" + className={cn( + 'cursor-pointer gap-0.5 px-1.5 text-muted-foreground hover:text-foreground', + hasMenu && 'rounded-r-none', + )} onClick={onLaunchSelected} > - + {selected.kind === 'terminal' ? null : ( + + )}