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 : ( + + )}