From d49ad60ead704ed069f53d8285bfaf7f8d1601af Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:17:28 -0700 Subject: [PATCH] feat(chat): add terminal-style pane header actions (#28968) * Transfer keyboard focus when a sibling chat pane opens * fix(native-chat): hand focus to newly opened chat composer * fix(native-chat): keep tab bar controller within line limit * fix(chat): split into a fresh conversation beside the current chat * test(chat): declare the split fixture store without an assertion * fix(chat): route the split to the source conversation host * test(chat): keep the split regression focused on the launch contract * test(chat): give the command palette split fixture its conversation agent * test(chat): seed the legacy command fixture without expanding its asserted shape * test(chat): isolate pane identity commands from the split launch boundary * Prevent chat splits from starting a terminal on host refusal * Cancel structured launches when their destination pane closes * Model live destination groups in agent launch test fixture * Capture split chat focus intent at the user action * feat(chat): add the terminal pane hover action cluster * fix(chat): keep pane controls above the conversation surface * refactor(chat): share the tab resume launch hook and format pane controls * style(chat): document reuse of the terminal header button styles * refactor(ui): share the pane title button variants between terminals and chats * fix(chat): keep header actions scoped to the displayed chat * fix(chat): abandon Resume when its destination pane closes * test(chat): declare destination fixtures without type assertions * fix(chat): keep Resume visible when its source pane closes * fix(chat): reuse tab menu Resume placement unchanged * test: fix Resume regression harness setup * fix(chat): hand input to the split pane during admission * fix(chat): Split opens a fresh chat beside the current one Split Right/Down on a native chat used to move the chat into a new pane. It now opens an empty pane beside the chat and launches a new chat of the same agent on the same host into it through the New Chat launch, so the new composer takes focus at once. A launch that never opens a tab closes the empty pane. Drops the earlier admission and focus-transfer changes that main now covers. * fix(chat): Split palette search words describe a new chat, not a move * fix: remove misleading chat move shortcut hints * fix(chat): a new chat stays in its pane until its host lists it A host tab list built before a chat's create landed can arrive after the create reply. Once the reply settled the launch, nothing protected the new tab, so that older list dropped it: Split's new pane collapsed and focus moved to another chat, and the next list re-added the chat elsewhere. Keep a small in-memory record for a chat this client created that no host list has shown yet. While it exists, a list that omits the chat keeps its tab. The record ends when an accepted list from that host includes the chat, when the tab closes, on workspace teardown, or 30 seconds after the reply, when the worktree is re-read from its host (including an unchanged version) so a chat the host really closed is dropped then. The launch lifecycle, status, persistence and settlement are unchanged. * test(chat): type the faked create replies without an assertion * Keep new chat protection tied to accepted host lists * Update paired chat close regression to current suppression API * fix(chat): Portuguese Split palette words describe a new chat * test(chat): use the destination group option in split fixture * Avoid History projection in native chat header and currentness checks * fix(native-chat): keep Find below pane controls * fix(ui): keep noninteractive tooltip layers pointer-transparent * Mount native chat actions only for shown panes * Use checked property access in header instrumentation * Read Continue eligibility records through workspace selectors * Cover native header Find clearance and tooltip click passage --------- Co-authored-by: Orca Worker --- docs/automated-setup-tab-design.md | 25 +- src/renderer/src/assets/main.css | 6 + .../native-chat/NativeChatFindBar.tsx | 2 +- .../native-chat/NativeChatPaneHeader.test.tsx | 117 ++++++ .../native-chat/NativeChatPaneHeader.tsx | 124 ++++++ .../NativeChatResumeInCliButton.tsx | 112 +++++ .../NativeChatStructuredSession.tsx | 9 +- .../native-chat/native-chat-find.css | 8 + .../native-chat-header-ownership.test.tsx | 382 ++++++++++++++++++ .../use-structured-chat-pane-actions.ts | 29 ++ .../tab-bar/NativeChatCliForkMenuItems.tsx | 4 +- .../tab-bar/native-chat-cli-fork-run.test.ts | 10 +- .../tab-bar/native-chat-cli-fork-run.ts | 20 +- .../tab-session-history-switch.test.ts | 20 + .../tab-bar/tab-session-history-switch.ts | 44 +- .../TerminalPaneHeaderOverlay.tsx | 16 +- .../TerminalPaneResumeInChatButton.tsx | 4 +- src/renderer/src/components/ui/button.tsx | 2 + .../native-chat-controls-rendered/fixture.css | 6 + .../native-chat-controls-rendered/fixture.tsx | 44 ++ .../native-chat-controls-rendered/index.html | 10 + .../native-chat-controls-rendered/run.mjs | 120 ++++++ 22 files changed, 1062 insertions(+), 52 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatPaneHeader.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-header-ownership.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-chat-pane-actions.ts create mode 100644 tests/tools/native-chat-controls-rendered/fixture.css create mode 100644 tests/tools/native-chat-controls-rendered/fixture.tsx create mode 100644 tests/tools/native-chat-controls-rendered/index.html create mode 100644 tests/tools/native-chat-controls-rendered/run.mjs diff --git a/docs/automated-setup-tab-design.md b/docs/automated-setup-tab-design.md index 710d08c7255..ce65de89368 100644 --- a/docs/automated-setup-tab-design.md +++ b/docs/automated-setup-tab-design.md @@ -10,22 +10,22 @@ This is PR 2. [PR 1 (#29810)](https://github.com/stablyai/orca/pull/29810) separ ## Evidence and severity -* Confirmed pre-change mechanism: `shouldSeedWorktreePrimaryTerminal` treated split setup as a reason to create a shell even when the caller supplies native chat. Renderer `queueSetupAndIssueCommands`, runtime `provisionWorktreeTerminals`, and `spawnLocalStartupAndSetupTerminals` in `worktree-remote.ts` each branched on setup location. This PR reuses their existing dedicated-tab arms. -* The supplied incident investigation observed two leaves bound to one PTY and an independent equal-count chat-publication failure. This worktree does not establish the original missing-binding cause or two setup executions. Removing placement complexity is the user-requested product policy; it is not proof of fixing every frozen terminal. -* No demonstrated P0. P1 acceptance risks are replaying setup after host provisioning, losing requested startup/setup sequencing, or changing primary selection/background workspace selection. The validation below exercises actual creation routes and the preserved one-shot startup mechanisms. -* P2 tradeoff: automatic side-by-side setup placement disappears; manual splitting remains. Older execution hosts can still return existing split layouts, which updated clients must accept without moving/replaying setup. -* The first-terminal delayed delivery coupling exists in `ensureWorktreeHasInitialTerminal` for runtime-owned fallback commands. Ordinary desktop structured creation passes no backend startup and returns setup through local IPC, then uses host authority `none`, so its null-primary Setup path is already independent. The host native-chat route normally consumes setup before returning. Keep delayed delivery unchanged: no actual request proves the alleged native-chat P1. The post-PR-1 semantic review found no reason to expand this scope. The conditional STA-9417 expected-failure fixture does not establish the original incident's cause. +- Confirmed pre-change mechanism: `shouldSeedWorktreePrimaryTerminal` treated split setup as a reason to create a shell even when the caller supplies native chat. Renderer `queueSetupAndIssueCommands`, runtime `provisionWorktreeTerminals`, and `spawnLocalStartupAndSetupTerminals` in `worktree-remote.ts` each branched on setup location. This PR reuses their existing dedicated-tab arms. +- The supplied incident investigation observed two leaves bound to one PTY and an independent equal-count chat-publication failure. This worktree does not establish the original missing-binding cause or two setup executions. Removing placement complexity is the user-requested product policy; it is not proof of fixing every frozen terminal. +- No demonstrated P0. P1 acceptance risks are replaying setup after host provisioning, losing requested startup/setup sequencing, or changing primary selection/background workspace selection. The validation below exercises actual creation routes and the preserved one-shot startup mechanisms. +- P2 tradeoff: automatic side-by-side setup placement disappears; manual splitting remains. Older execution hosts can still return existing split layouts, which updated clients must accept without moving/replaying setup. +- The first-terminal delayed delivery coupling exists in `ensureWorktreeHasInitialTerminal` for runtime-owned fallback commands. Ordinary desktop structured creation passes no backend startup and returns setup through local IPC, then uses host authority `none`, so its null-primary Setup path is already independent. The host native-chat route normally consumes setup before returning. Keep delayed delivery unchanged: no actual request proves the alleged native-chat P1. The post-PR-1 semantic review found no reason to expand this scope. The conditional STA-9417 expected-failure fixture does not establish the original incident's cause. ## Surfaces and selection -| Intent | Surfaces owed by this create with setup | -| --- | --- | -| Native chat | Requested chat plus Setup, without a split-anchor shell. | -| Terminal agent | Requested agent terminal plus Setup. | -| Plain terminal | Normal primary terminal plus Setup. | -| Setup only | Setup alone; preserve current suppression of default tabs. | +| Intent | Surfaces owed by this create with setup | +| --------------------- | -------------------------------------------------------------------- | +| Native chat | Requested chat plus Setup, without a split-anchor shell. | +| Terminal agent | Requested agent terminal plus Setup. | +| Plain terminal | Normal primary terminal plus Setup. | +| Setup only | Setup alone; preserve current suppression of default tabs. | | Explicit default tabs | Existing template allocation/commands/title/color policy plus Setup. | -| No setup | Existing behavior. | +| No setup | Existing behavior. | Issue automation retains its independent split from the normal primary pane, command/env, one-shot behavior, and focus policy. Setup stays unselected when another primary surface exists. Setup-only can select Setup when creation is still in view. Background creates/navigation away preserve workspace/tab selection and never reveal/focus a native window. @@ -59,7 +59,6 @@ Rendered checks require rebuilt hidden Electron plus Playwright CDP; inspect tab After implementation: Astra reviews actual final diff/tests and semantic overlap after PR 1 rebase. No merge until PR 1 confirmation, exact-head full `.github/workflows/pr.yml` successful aggregate `verify` / PR checks passed and all required checks, and parent approval through the mandatory blocking merge-gate ask. Use `--match-head-commit` and confirm the merge. - ## Implemented validation record Reviewed source head: `d96ae2ca6afdca919b4d7f8d85308363abe8b28b`, based on the exact PR 1 merge above. The follow-up documentation commit changes no source behavior. The independent review records no unresolved P0/P1 and explicit P2 tradeoffs. diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index ec1a8070afd..cd7361aa5a3 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -960,6 +960,12 @@ html.native-shell .app-layout { -webkit-app-region: no-drag; } +/* Modal inline pointer styles must not turn noninteractive labels into hit targets. */ +[data-radix-popper-content-wrapper]:has(> [data-slot='tooltip-content'].pointer-events-none), +[data-slot='tooltip-content'].pointer-events-none { + pointer-events: none !important; +} + .titlebar { height: 36px; min-height: 36px; diff --git a/src/renderer/src/components/native-chat/NativeChatFindBar.tsx b/src/renderer/src/components/native-chat/NativeChatFindBar.tsx index 9bce722ca2f..68edd3f9524 100644 --- a/src/renderer/src/components/native-chat/NativeChatFindBar.tsx +++ b/src/renderer/src/components/native-chat/NativeChatFindBar.tsx @@ -44,7 +44,7 @@ export function NativeChatFindBar({ // The chat sends clicks on its plain surface to the composer; the bar's are its own. data-native-chat-typing-redirect-ignore="true" data-native-chat-find-bar="true" - className="absolute top-2 right-4 z-20 flex w-85 max-w-[calc(100%-2rem)] items-center gap-1 rounded-lg border border-border bg-popover/95 px-2 py-1 text-popover-foreground shadow-floating backdrop-blur-sm" + className="absolute top-[calc(var(--native-chat-find-top-inset,0px)+var(--spacing)*2)] right-4 z-20 flex w-85 max-w-[calc(100%-2rem)] items-center gap-1 rounded-lg border border-border bg-popover/95 px-2 py-1 text-popover-foreground shadow-floating backdrop-blur-sm" onMouseDown={(event) => { // A press on the count or padding keeps typing in the find input. if (!(event.target instanceof Element) || !event.target.closest('button, input')) { diff --git a/src/renderer/src/components/native-chat/NativeChatPaneHeader.test.tsx b/src/renderer/src/components/native-chat/NativeChatPaneHeader.test.tsx new file mode 100644 index 00000000000..4834ea2b983 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatPaneHeader.test.tsx @@ -0,0 +1,117 @@ +// @vitest-environment happy-dom +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import { useAppStore } from '@/store' +import { createTabsSliceMockApi } from '@/store/slices/tabs-slice-test-harness' +import { makeFolderWorkspace } from '@/store/slices/worktrees-slice-test-fixtures' +import * as historyRequest from '../right-sidebar/ai-vault-panel-session-list-request' + +const mocks = vi.hoisted(() => ({ launch: vi.fn(), resume: vi.fn(), close: vi.fn() })) +vi.mock('@/store', async () => { + const { createTestStore } = await import('@/store/slices/store-test-helpers') + return { useAppStore: createTestStore() } +}) +vi.mock('@/lib/launch-agent-in-new-tab', () => ({ launchAgentInNewTab: mocks.launch })) +vi.mock('@/lib/workspace-tab-commands', () => ({ dispatchWorkspaceTabCommand: mocks.close })) +vi.mock('../tab-bar/native-chat-cli-fork-run', () => ({ + runNativeChatCliFork: mocks.resume, + useNativeChatCliForkPending: () => false +})) + +import { NativeChatPaneHeader } from './NativeChatPaneHeader' + +const fullRequest = vi.spyOn(historyRequest, 'resolveAiVaultPanelSessionListRequest') +const testWindow = window +const api = createTabsSliceMockApi() +vi.stubGlobal('window', testWindow) +Object.assign(window, { api }) +afterEach(cleanup) +beforeEach(() => { + vi.clearAllMocks() + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ + folderWorkspaces: [makeFolderWorkspace({ id: 'workspace', folderPath: '/repo' })] + }) +}) + +function renderHeader() { + const source = useAppStore.getState().createUnifiedTab('folder:workspace', 'agent-session', { + agentSessionAgent: 'codex', + executionHostId: 'runtime:remote-host' + }) + render( + + + + ) + return source +} + +it('offers the terminal header action order and targets Resume and Close at the displayed chat', () => { + const source = renderHeader() + expect(screen.getAllByRole('button').map((button) => button.getAttribute('aria-label'))).toEqual([ + 'Continue with Terminal Agent', + 'Split Chat Right', + 'Close tab' + ]) + const folder = useAppStore.getState().folderWorkspaces[0] + act(() => useAppStore.setState({ folderWorkspaces: [{ ...folder, executionHostId: 'ssh:dev' }] })) + expect(screen.queryByRole('button', { name: 'Continue with Terminal Agent' })).toBeNull() + act(() => useAppStore.setState({ folderWorkspaces: [folder] })) + expect(fullRequest).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Continue with Terminal Agent' })) + expect(fullRequest).toHaveBeenCalledTimes(1) + expect(mocks.resume).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + subject: expect.objectContaining({ + sessionId: source.entityId, + request: { scopePaths: ['/repo'], executionHostScope: 'local', sessionLimit: 250 } + }), + tab: { id: source.id, worktreeId: source.worktreeId }, + agent: 'codex' + }) + ) + fireEvent.click(screen.getByRole('button', { name: 'Close tab' })) + expect(mocks.close).toHaveBeenCalledExactlyOnceWith({ + type: 'close', + target: { kind: 'tab', worktreeId: source.worktreeId, tabId: source.id } + }) +}) + +it('Split from the header keeps the source and opens a fresh chat on the same host', () => { + const source = renderHeader() + mocks.launch.mockImplementation((args: { worktreeId: string; groupId: string }) => { + useAppStore.getState().createUnifiedTab(args.worktreeId, 'agent-session', { + targetGroupId: args.groupId, + agentSessionAgent: 'codex', + executionHostId: 'runtime:remote-host' + }) + return { surface: { kind: 'local-chat', tabId: 'fresh-chat' } } + }) + fireEvent.click(screen.getByRole('button', { name: 'Split Chat Right' })) + const state = useAppStore.getState() + const destination = state.activeGroupIdByWorktree[source.worktreeId] + expect(destination).not.toBe(source.groupId) + expect(mocks.launch).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + agent: 'codex', + requestedSurface: 'chat', + selectedExecutionHostId: source.executionHostId, + worktreeId: source.worktreeId, + groupId: destination, + focusIntent: expect.objectContaining({ cancel: expect.any(Function) }) + }) + ) + const tabs = state.unifiedTabsByWorktree[source.worktreeId] + expect(tabs.find((tab) => tab.id === source.id)).toEqual(source) + expect(tabs).toHaveLength(2) + expect(tabs.find((tab) => tab.groupId === destination)?.entityId).not.toBe(source.entityId) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx b/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx new file mode 100644 index 00000000000..ab301fb84a5 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx @@ -0,0 +1,124 @@ +import { SquareSplitVertical, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { agentForksConversations } from '../../../../shared/agent-resume-argv' +import type { NativeChatTabSession } from '../tab-bar/tab-session-history-switch' +import { useSystemPrefersDark } from '../terminal-pane/use-system-prefers-dark' +import { useStructuredChatPaneActions } from './use-structured-chat-pane-actions' +import { NativeChatResumeInCliButton } from './NativeChatResumeInCliButton' +import type { NativeChatStructuredViewProps } from './native-chat-view-types' + +const HEADER_STYLE: React.CSSProperties & Record<'--orca-pane-title-bg', string> = { + '--orca-pane-title-bg': 'var(--chat-canvas)' +} + +export function NativeChatPaneHeader({ + tabId, + worktreeId, + groupId, + nativeChat, + focused, + colorScheme +}: { + tabId: string + worktreeId: string + groupId: string + nativeChat: NativeChatTabSession + focused: boolean + colorScheme: React.CSSProperties['colorScheme'] +}): React.JSX.Element { + const actions = useStructuredChatPaneActions(tabId, worktreeId, groupId) + const theme = useAppStore((state) => state.settings?.theme) + const systemDark = useSystemPrefersDark() + const light = colorScheme + ? colorScheme === 'light' + : theme === 'light' || (theme === 'system' && !systemDark) + const split = translate('auto.components.cmd.j.quick.actions.splitChatRight', 'Split Chat Right') + return ( +
+
+ {nativeChat.agent && agentForksConversations(nativeChat.agent) ? ( + + ) : null} + + + + + + {split} + + + {actions.canClosePane ? ( + + + + + + {actions.closeLabel} + + + ) : null} +
+
+ ) +} + +export function nativeChatPaneHeader( + view: Pick< + NativeChatStructuredViewProps, + | 'tabId' + | 'groupId' + | 'sessionId' + | 'agent' + | 'isVisible' + | 'isFocusedGroup' + | 'contextMenuActions' + >, + worktreeId: string | null, + colorScheme: React.CSSProperties['colorScheme'] +): React.JSX.Element | null { + return view.isVisible && worktreeId && view.groupId && !view.contextMenuActions ? ( + + ) : null +} diff --git a/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx b/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx new file mode 100644 index 00000000000..cd9b9f70d43 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx @@ -0,0 +1,112 @@ +import { useMemo } from 'react' +import { SquareTerminal } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' +import { translate } from '@/i18n/i18n' +import { AI_VAULT_AGENTS } from '../../../../shared/ai-vault-types' +import { + runNativeChatCliFork, + useNativeChatCliForkPending +} from '../tab-bar/native-chat-cli-fork-run' +import { + resolveTabSessionHistorySubject, + resolveNativeChatCliForkIdentity, + type NativeChatCliForkIdentity, + type NativeChatTabSession +} from '../tab-bar/tab-session-history-switch' + +function createExecutionHostSelector( + tabId: string, + worktreeId: string, + sessionId: string, + agent: NativeChatTabSession['agent'] +) { + let worktrees: AppState['worktreesByRepo'] | undefined + let folders: AppState['folderWorkspaces'] | undefined + let groups: AppState['projectGroups'] | undefined + let repos: AppState['repos'] | undefined + let scope: NativeChatCliForkIdentity['executionHostScope'] | null = null + return (state: AppState) => { + const nextWorktrees = selectWorktreesByRepo(state) + const nextFolders = selectFolderWorkspaces(state) + if ( + worktrees !== nextWorktrees || + folders !== nextFolders || + groups !== state.projectGroups || + repos !== state.repos + ) { + scope = + resolveNativeChatCliForkIdentity(state, { + tab: { id: tabId, worktreeId }, + nativeChat: { sessionId, agent } + })?.executionHostScope ?? null + worktrees = nextWorktrees + folders = nextFolders + groups = state.projectGroups + repos = state.repos + } + return scope + } +} + +export function NativeChatResumeInCliButton({ + tabId, + worktreeId, + nativeChat +}: { + tabId: string + worktreeId: string + nativeChat: NativeChatTabSession +}): React.JSX.Element | null { + const tab = { id: tabId, worktreeId } + const { sessionId, agent: nativeAgent } = nativeChat + const selectExecutionHost = useMemo( + () => createExecutionHostSelector(tabId, worktreeId, sessionId, nativeAgent), + [tabId, worktreeId, sessionId, nativeAgent] + ) + const executionHostScope = useAppStore(selectExecutionHost) + const pending = useNativeChatCliForkPending( + executionHostScope ? { executionHostScope, sessionId: nativeChat.sessionId } : null + ) + const commandOverrides = useAppStore((state) => state.settings?.agentCmdOverrides) + const agent = AI_VAULT_AGENTS.find((candidate) => candidate === nativeChat.agent) + const label = translate( + 'components.tab.bar.SortableTabContextMenu.switchToTerminalView', + 'Continue with Terminal Agent' + ) + if (!executionHostScope || !agent) { + return null + } + return ( + + + + + + {label} + + + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index eab8caef3ec..81e8d9a22ed 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -51,6 +51,7 @@ import { useAppStore } from '../../store' import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' import { useNativeChatStructuredComposerTransport } from './use-native-chat-structured-composer-transport' import { NativeChatStructuredGoalStrip } from './NativeChatStructuredGoalStrip' +import { nativeChatPaneHeader } from './NativeChatPaneHeader' import { structuredAgentSessionReadFailureNotice } from './structured-agent-session-read-failure-notice' import { useStructuredAgentSessionDeliveryNotices } from './use-structured-agent-session-delivery-notices' import { dispatchWasWithdrawn } from '../../../../shared/structured-agent-session-dispatch-rejection' @@ -166,6 +167,7 @@ export function NativeChatStructuredSession( rootRef, { sessionId: props.sessionId, isVisible: props.isVisible } ) + const fileLinkProps = { onLinkClick, allowFileUriLinks: onLinkClick !== undefined } const paneCommands = useStructuredNativeChatPaneCommands({ tabId: props.tabId, groupId: props.groupId, @@ -288,6 +290,7 @@ export function NativeChatStructuredSession( style={appearanceStyle} data-native-chat-scheme={appearanceStyle.colorScheme} > + {nativeChatPaneHeader(props, ownerWorktreeId, appearanceStyle.colorScheme)} {find.isOpen ? : null} {viewState.kind === 'loading' || (viewState.kind === 'error' && !readFailure) ? ( @@ -323,8 +326,7 @@ export function NativeChatStructuredSession( awaitingInput={prompt === null ? null : 'shown'} turnActivity={controller.turnActivity} stopping={stopControls.stopping} - onLinkClick={onLinkClick} - allowFileUriLinks={onLinkClick !== undefined} + {...fileLinkProps} runtimeContext={imageRuntimeContext} deliveryNotices={deliveryNotices} /> @@ -375,8 +377,7 @@ export function NativeChatStructuredSession( isSubmitting={promptResponse.holds(prompt)} onCancel={cancelPrompt} shouldFocus={!promptsUnanswerable && props.isVisible && props.isFocusedGroup} - onLinkClick={onLinkClick} - allowFileUriLinks={onLinkClick !== undefined} + {...fileLinkProps} /> ) : null} {prompt && questionBody ? ( diff --git a/src/renderer/src/components/native-chat/native-chat-find.css b/src/renderer/src/components/native-chat/native-chat-find.css index 18180831718..3e828708e4e 100644 --- a/src/renderer/src/components/native-chat/native-chat-find.css +++ b/src/renderer/src/components/native-chat/native-chat-find.css @@ -1,3 +1,11 @@ +.native-chat-appearance { + --native-chat-find-top-inset: 0px; +} + +.native-chat-appearance:has(> .pane-title-bar) { + --native-chat-find-top-inset: var(--orca-pane-title-height); +} + /* Why: find matches are painted as CSS highlights (Ranges, no DOM mutation), in the markdown preview's colors; see use-native-chat-find-matches.ts. ::highlight() supports color/background only. */ diff --git a/src/renderer/src/components/native-chat/native-chat-header-ownership.test.tsx b/src/renderer/src/components/native-chat/native-chat-header-ownership.test.tsx new file mode 100644 index 00000000000..a22c5f8d57f --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-header-ownership.test.tsx @@ -0,0 +1,382 @@ +// @vitest-environment happy-dom +import { Profiler } from 'react' +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import type { Tab } from '../../../../shared/tab-types' +import { createTabsSliceMockApi } from '@/store/slices/tabs-slice-test-harness' +import { makeTabGroup } from '@/store/slices/store-test-helpers' +import { + makeFolderWorkspace, + makeRepo, + makeUnifiedTab, + makeWorktree +} from '../worktree-jump-palette-test-fixtures' +import * as subjects from '../tab-bar/tab-session-history-switch' +import * as history from '../right-sidebar/ai-vault-panel-session-list-request' +import type { NativeChatCliForkPreparation } from '@/lib/native-chat-cli-fork-preparation' +import type { NativeChatStructuredViewProps } from './native-chat-view-types' +import type * as paneActions from './use-structured-chat-pane-actions' + +const work = vi.hoisted(() => ({ + header: false, + headerVisits: 0, + otherVisits: 0, + lookups: 0, + commits: 0 +})) +const mocks = vi.hoisted(() => ({ prepare: vi.fn(), launch: vi.fn(), close: vi.fn() })) +vi.mock('@/store', async () => { + const { createTestStore } = await import('@/store/slices/store-test-helpers') + return { useAppStore: createTestStore() } +}) +vi.mock('./use-structured-chat-pane-actions', async (original) => { + const { useStructuredChatPaneActions } = await original() + return { + useStructuredChatPaneActions(...args: Parameters) { + work.header = true + const actions = useStructuredChatPaneActions(...args) + work.header = false + return actions + } + } +}) +vi.mock('./NativeChatView', async () => { + const { nativeChatPaneHeader } = await import('./NativeChatPaneHeader') + return { + default: (props: NativeChatStructuredViewProps) => ( +
+ {nativeChatPaneHeader(props, 'folder:workspace', 'dark')} + {props.sessionId} +
+ ) + } +}) +vi.mock('@/lib/workspace-tab-commands', () => ({ dispatchWorkspaceTabCommand: mocks.close })) +vi.mock('@/lib/native-chat-cli-fork-preparation', () => ({ + prepareNativeChatCliFork: mocks.prepare +})) +vi.mock('@/lib/launch-ai-vault-session-and-reveal', () => ({ + launchAiVaultSessionAndReveal: mocks.launch +})) +vi.mock('@/lib/ai-vault-session-fork-startup', () => ({ + assertAiVaultCopiedSessionLaunchAllowed: () => {}, + buildAiVaultForkStartupForWorktree: () => ({ command: 'fixture-fork' }) +})) + +import StructuredAgentSessionPaneOverlayLayer from './StructuredAgentSessionPaneOverlayLayer' +import { NativeChatResumeInCliButton } from './NativeChatResumeInCliButton' + +const workspace = 'folder:workspace' +const initial = useAppStore.getInitialState() +const fullRequest = vi.spyOn(history, 'resolveAiVaultPanelSessionListRequest') +const identity = vi.spyOn(subjects, 'resolveNativeChatCliForkIdentity') +const testWindow = window +const originalApi = window.api +const api = createTabsSliceMockApi() +vi.stubGlobal('window', testWindow) +beforeEach(() => { + vi.clearAllMocks() + Object.assign(window, { api }) + useAppStore.setState(initial, true) + useAppStore.setState({ + folderWorkspaces: [makeFolderWorkspace({ id: 'workspace', folderPath: '/repo' })] + }) +}) +afterEach(() => { + cleanup() + useAppStore.setState(initial, true) + Object.assign(window, { api: originalApi }) +}) +function hasPropertyKey(target: T, key: PropertyKey): key is keyof T { + return key in target +} +function countedTab(tab: Tab): Tab { + return new Proxy(tab, { + get(target, key) { + if (key === 'groupId' || key === 'id') { + work[work.header ? 'headerVisits' : 'otherVisits']++ + } + return hasPropertyKey(target, key) ? target[key] : undefined + } + }) +} +function seedChats(size: number) { + const tabs = Array.from({ length: size }, (_, index) => + countedTab({ + ...makeUnifiedTab(`chat-${index}`, workspace, `session-${index}`, `Chat ${index}`), + contentType: 'agent-session', + agentSessionAgent: 'codex', + executionHostId: 'local', + groupId: index === size - 1 ? 'second' : 'first' + }) + ) + const groups = ['first', 'second'].map((id) => + makeTabGroup({ + id, + worktreeId: workspace, + tabOrder: tabs.filter((tab) => tab.groupId === id).map((tab) => tab.id), + activeTabId: id === 'first' ? 'chat-0' : `chat-${size - 1}` + }) + ) + useAppStore.setState({ + unifiedTabsByWorktree: { [workspace]: tabs }, + groupsByWorktree: { [workspace]: groups }, + activeGroupIdByWorktree: { [workspace]: 'first' } + }) +} +function overlay(active: boolean) { + return ( + + + + ) +} +function chat(container: HTMLElement, id: string) { + return within(container).getByText(`session-${id.slice(5)}`).parentElement! +} + +it.each([10, 100])( + 'bounds shown header tab work at %i retained chats and keeps hidden transcripts', + async (size) => { + seedChats(size) + const view = render(overlay(true)) + const retained = chat(view.container, 'chat-0').querySelector('output') + const unfocused = chat(view.container, `chat-${size - 1}`) + expect(within(unfocused).getByRole('button', { name: 'Close Pane' })).toBeTruthy() + for (const operation of [ + () => useAppStore.getState().setTabLabel('chat-0', 'Changed title'), + () => useAppStore.getState().activateTab('chat-1', { worktreeId: workspace }) + ]) { + work.headerVisits = work.otherVisits = 0 + await act(async () => operation()) + expect(work.headerVisits).toBeGreaterThan(0) + // Filter plus pin lookup: at most four real tab visits per shown header per tab. + expect(work.headerVisits).toBeLessThanOrEqual(4 * 2 * size) + expect(work.otherVisits).toBeGreaterThan(0) + } + expect(useAppStore.getState().getTab('chat-0')?.label).toBe('Changed title') + expect(chat(view.container, 'chat-0').querySelector('output')).toBe(retained) + view.rerender(overlay(false)) + work.headerVisits = 0 + await act(async () => { + useAppStore.getState().setTabLabel('chat-1', 'Hidden title') + useAppStore.getState().pinTab('chat-1', { recordInteraction: false }) + useAppStore.setState({ + groupsByWorktree: { [workspace]: [useAppStore.getState().groupsByWorktree[workspace][0]] } + }) + }) + expect(work.headerVisits).toBe(0) + expect(view.container.querySelector('.pane-title-bar')).toBeNull() + view.rerender(overlay(true)) + const shown = chat(view.container, 'chat-1') + expect(within(shown).queryByRole('button', { name: 'Close tab' })).toBeNull() + act(() => useAppStore.getState().unpinTab('chat-1', { recordInteraction: false })) + fireEvent.click(within(shown).getByRole('button', { name: 'Close tab' })) + expect(mocks.close).toHaveBeenCalledExactlyOnceWith({ + type: 'close', + target: { kind: 'tab', worktreeId: workspace, tabId: 'chat-1' } + }) + expect(chat(view.container, 'chat-0').querySelector('output')).toBe(retained) + expect(fullRequest).not.toHaveBeenCalled() + } +) + +function countedRecords(records: T[]): T[] { + return new Proxy(records, { + get(target, key) { + if (typeof key === 'string' && /^\d+$/.test(key)) { + work.lookups++ + } + return hasPropertyKey(target, key) ? target[key] : undefined + } + }) +} +function eligibility( + folder: string, + git: string, + sessionId = 'session', + agent: subjects.NativeChatTabSession['agent'] = 'codex', + tabId = 'tab' +) { + return ( + + work.commits++}> + {[ + { key: 'folder', id: folder }, + { key: 'git', id: git } + ].map(({ key, id }) => ( + + ))} + + + ) +} + +it.each([10, 100])( + 'does no idle eligibility lookup across %i workspace records and tracks all dependencies', + async (size) => { + const folders = countedRecords( + Array.from({ length: size }, (_, i) => + makeFolderWorkspace({ + id: `folder-${i}`, + folderPath: `/folder/${i}`, + projectGroupId: `project-${size - 1}` + }) + ) + ) + const groups = countedRecords( + Array.from({ length: size }, (_, i) => ({ + id: `project-${i}`, + name: `Project ${i}`, + parentPath: null, + parentGroupId: null, + createdFrom: 'manual' as const, + tabOrder: i, + isCollapsed: false, + color: null, + createdAt: 0, + updatedAt: 0 + })) + ) + const repos = countedRecords( + Array.from({ length: size }, (_, i) => ({ + ...makeRepo(), + id: `repo-${i}`, + projectGroupId: `project-${size - 1}` + })) + ) + const worktrees = Object.fromEntries( + repos.map((repo, i) => [ + repo.id, + countedRecords([ + makeWorktree(`${repo.id}::/git/${i}`, `Tree ${i}`, { repoId: repo.id, path: `/git/${i}` }) + ]) + ]) + ) + const folder = `folder:folder-${size - 1}` + const git = `repo-${size - 1}::/git/${size - 1}` + useAppStore.setState({ + folderWorkspaces: folders, + projectGroups: groups, + repos, + worktreesByRepo: worktrees + }) + const view = render(eligibility(folder, git)) + work.lookups = work.commits = 0 + identity.mockClear() + act(() => { + for (let tick = 0; tick < 50; tick++) { + useAppStore.setState({ lastTerminalInputAtByPaneKey: { 'other:pane': tick } }) + } + }) + expect(work.lookups).toBe(0) + expect(identity).not.toHaveBeenCalled() + expect(work.commits).toBe(0) + const restore = { + folderWorkspaces: folders, + projectGroups: groups, + repos, + worktreesByRepo: worktrees + } + const replacements: Partial[] = [ + { + worktreesByRepo: { + ...worktrees, + [`repo-${size - 1}`]: countedRecords([ + makeWorktree(git, 'missing path', { repoId: `repo-${size - 1}`, path: '' }) + ]) + } + }, + { + folderWorkspaces: countedRecords( + folders.map((row, i) => (i === size - 1 ? { ...row, folderPath: '' } : row)) + ) + }, + { + projectGroups: countedRecords( + groups.map((row, i) => (i === size - 1 ? { ...row, executionHostId: 'ssh:dev' } : row)) + ) + }, + { repos: countedRecords(repos.map((row) => ({ ...row, executionHostId: 'ssh:dev' }))) } + ] + for (const replacement of replacements) { + work.lookups = 0 + identity.mockClear() + await act(async () => useAppStore.setState(replacement)) + expect(identity).toHaveBeenCalledTimes(2) + expect(work.lookups).toBeGreaterThan(0) + // Two shown buttons; existing folder candidates/groups and git lookups are linear in records. + expect(work.lookups).toBeLessThanOrEqual(20 * 2 * size) + const expected = [folder, git].filter((worktreeId) => + subjects.resolveNativeChatCliForkIdentity(useAppStore.getState(), { + tab: { id: 'tab', worktreeId }, + nativeChat: { sessionId: 'session', agent: 'codex' } + }) + ) + expect(screen.queryAllByRole('button')).toHaveLength(expected.length) + await act(async () => useAppStore.setState(restore)) + expect(screen.getAllByRole('button')).toHaveLength(2) + } + identity.mockClear() + view.rerender(eligibility(folder, git)) + expect(identity).not.toHaveBeenCalled() + view.rerender(eligibility(folder, git, 'new-session')) + expect(identity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ nativeChat: { sessionId: 'new-session', agent: 'codex' } }) + ) + identity.mockClear() + view.rerender(eligibility(folder, git, 'new-session', 'codex', 'new-tab')) + expect(identity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ tab: { id: 'new-tab', worktreeId: folder } }) + ) + view.rerender(eligibility(folder, git, 'new-session', 'gemini', 'new-tab')) + expect(screen.queryAllByRole('button')).toHaveLength(0) + view.rerender(eligibility(folder, git, 'new-session', 'codex', 'new-tab')) + expect(screen.getAllByRole('button')).toHaveLength(2) + view.rerender(eligibility('folder:missing', git, 'new-session', 'codex', 'new-tab')) + expect(screen.getAllByRole('button')).toHaveLength(1) + expect(fullRequest).not.toHaveBeenCalled() + } +) + +it('keeps the real Continue pending operation disabled across header hide/reveal and launches once', async () => { + seedChats(10) + const preparation = Promise.withResolvers() + mocks.prepare.mockReturnValue(preparation.promise) + mocks.launch.mockResolvedValue(undefined) + const view = render(overlay(true)) + const source = chat(view.container, 'chat-0') + const button = () => within(source).getByRole('button', { name: 'Continue with Terminal Agent' }) + expect(fullRequest).not.toHaveBeenCalled() + fireEvent.click(button()) + expect(button().hasAttribute('disabled')).toBe(true) + expect(button().getAttribute('aria-busy')).toBe('true') + view.rerender(overlay(false)) + expect(within(source).queryByRole('button')).toBeNull() + expect(useAppStore.getState().getTab('chat-0')).not.toBeNull() + view.rerender(overlay(true)) + expect(button().hasAttribute('disabled')).toBe(true) + expect(button().getAttribute('aria-busy')).toBe('true') + fireEvent.click(button()) + expect(mocks.prepare).toHaveBeenCalledTimes(1) + await act(async () => + preparation.resolve({ + copied: false, + session: { agent: 'codex', sessionId: 'fork', cwd: '/repo', codexHome: null } + }) + ) + expect(mocks.launch).toHaveBeenCalledTimes(1) + expect(button().hasAttribute('disabled')).toBe(false) + expect(button().hasAttribute('aria-busy')).toBe(false) + expect(fullRequest).toHaveBeenCalledTimes(1) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-chat-pane-actions.ts b/src/renderer/src/components/native-chat/use-structured-chat-pane-actions.ts new file mode 100644 index 00000000000..afd7960d15f --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-chat-pane-actions.ts @@ -0,0 +1,29 @@ +import { useMemo } from 'react' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import { useTabGroupTabCloseCommands } from '../tab-group/useTabGroupTabCloseCommands' +import { runNativeChatSplitTarget } from './native-chat-layout-actions' +import type { Tab, TabGroup } from '../../../../shared/tab-types' + +const EMPTY_TABS: Tab[] = [] +const EMPTY_GROUPS: TabGroup[] = [] + +export function useStructuredChatPaneActions(tabId: string, worktreeId: string, groupId: string) { + const tabs = useAppStore((state) => state.unifiedTabsByWorktree[worktreeId] ?? EMPTY_TABS) + const groups = useAppStore((state) => state.groupsByWorktree[worktreeId] ?? EMPTY_GROUPS) + const groupTabs = useMemo(() => tabs.filter((tab) => tab.groupId === groupId), [tabs, groupId]) + const { closeItem } = useTabGroupTabCloseCommands({ worktreeId }) + const closeLabel = + groups.length < 2 || groupTabs.length > 1 + ? translate('auto.components.tab.bar.SortableTab.95db5f2f7d', 'Close tab') + : translate('auto.components.terminal.pane.TerminalContextMenu.8c17d6786d', 'Close Pane') + return { + onSplitRight: () => + runNativeChatSplitTarget({ kind: 'workspace-tab', unifiedTabId: tabId, groupId }, 'right'), + onSplitDown: () => + runNativeChatSplitTarget({ kind: 'workspace-tab', unifiedTabId: tabId, groupId }, 'down'), + canClosePane: groups.length > 1 || !groupTabs.find((tab) => tab.id === tabId)?.isPinned, + closeLabel, + onClosePane: () => closeItem(tabId) + } +} diff --git a/src/renderer/src/components/tab-bar/NativeChatCliForkMenuItems.tsx b/src/renderer/src/components/tab-bar/NativeChatCliForkMenuItems.tsx index 99a1c1e73e9..bf17bbc7258 100644 --- a/src/renderer/src/components/tab-bar/NativeChatCliForkMenuItems.tsx +++ b/src/renderer/src/components/tab-bar/NativeChatCliForkMenuItems.tsx @@ -24,7 +24,9 @@ export function NativeChatCliForkMenuItems({ resolveTabSessionHistorySubject(useAppStore.getState(), { tab, nativeChat }) ) const chat = subject?.kind === 'chat' ? subject : null - const pending = useNativeChatCliForkPending(chat) + const pending = useNativeChatCliForkPending( + chat ? { executionHostScope: chat.request.executionHostScope, sessionId: chat.sessionId } : null + ) const commandOverrides = useAppStore((state) => state.settings?.agentCmdOverrides) const agent = AI_VAULT_AGENTS.find((candidate) => candidate === nativeChat.agent) if (!chat || !agent) { diff --git a/src/renderer/src/components/tab-bar/native-chat-cli-fork-run.test.ts b/src/renderer/src/components/tab-bar/native-chat-cli-fork-run.test.ts index 48affbb294b..631c079ff88 100644 --- a/src/renderer/src/components/tab-bar/native-chat-cli-fork-run.test.ts +++ b/src/renderer/src/components/tab-bar/native-chat-cli-fork-run.test.ts @@ -34,10 +34,10 @@ vi.mock('@/store', () => ({ } })) vi.mock('./tab-session-history-switch', () => ({ - resolveTabSessionHistorySubject: () => ({ - kind: 'chat', - request: { executionHostScope: mocks.host } - }) + resolveNativeChatCliForkIdentity: () => ({ executionHostScope: mocks.host }), + resolveTabSessionHistorySubject: () => { + throw new Error('History projection in currentness') + } })) vi.mock('@/lib/native-chat-cli-fork-preparation', () => ({ prepareNativeChatCliFork: mocks.prepare @@ -93,6 +93,8 @@ it('shares pending across menu openings and suppresses dispatch after source ret const first = runNativeChatCliFork(args) await runNativeChatCliFork(args) expect(mocks.prepare).toHaveBeenCalledTimes(1) + mocks.listeners.forEach((listener) => listener()) + expect(mocks.error).not.toHaveBeenCalled() closeSource() settle(prepared) await first diff --git a/src/renderer/src/components/tab-bar/native-chat-cli-fork-run.ts b/src/renderer/src/components/tab-bar/native-chat-cli-fork-run.ts index 694f5488715..45ef6fc2de2 100644 --- a/src/renderer/src/components/tab-bar/native-chat-cli-fork-run.ts +++ b/src/renderer/src/components/tab-bar/native-chat-cli-fork-run.ts @@ -23,19 +23,22 @@ import { onRuntimeEnvironmentRevisionsChanged } from '@/runtime/runtime-environment-revision' import type { AiVaultAgent } from '../../../../shared/ai-vault-types' -import { resolveTabSessionHistorySubject } from './tab-session-history-switch' +import { + resolveNativeChatCliForkIdentity, + type NativeChatCliForkIdentity +} from './tab-session-history-switch' const pending = new Map() const listeners = new Set<() => void>() const changed = () => listeners.forEach((listener) => listener()) -const keyFor = (subject: NativeChatCliForkSubject) => - `${subject.request.executionHostScope}\0${subject.sessionId}` +type PendingIdentity = Pick +const keyFor = (subject: PendingIdentity) => `${subject.executionHostScope}\0${subject.sessionId}` const subscribe = (listener: () => void) => { listeners.add(listener) return () => listeners.delete(listener) } -export function useNativeChatCliForkPending(subject: NativeChatCliForkSubject | null): boolean { +export function useNativeChatCliForkPending(subject: PendingIdentity | null): boolean { return useSyncExternalStore( subscribe, () => !!subject && pending.has(keyFor(subject)), @@ -51,7 +54,7 @@ export async function runNativeChatCliFork(args: { commandOverrides?: Partial> }): Promise { const { subject, tab } = args - const key = keyFor(subject) + const key = keyFor({ ...subject, executionHostScope: subject.request.executionHostScope }) if (pending.has(key)) { return } @@ -66,15 +69,12 @@ export async function runNativeChatCliFork(args: { const source = state.unifiedTabsByWorktree[tab.worktreeId]?.find((item) => item.id === tab.id) const current = source?.contentType === 'agent-session' && source.entityId === subject.sessionId - ? resolveTabSessionHistorySubject(state, { + ? resolveNativeChatCliForkIdentity(state, { tab, nativeChat: { sessionId: subject.sessionId, agent: args.agent } }) : null - return ( - current?.kind === 'chat' && - current.request.executionHostScope === subject.request.executionHostScope - ) + return current?.executionHostScope === subject.request.executionHostScope } if (!isCurrent()) { return diff --git a/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts b/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts index 1b39afd447a..2ca6065c8eb 100644 --- a/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts +++ b/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts @@ -24,6 +24,7 @@ import { findTabSessionHistoryRow, lookupTabSessionHistoryRow, resolveTabSessionHistorySubject, + resolveNativeChatCliForkIdentity, resolveTabSessionSwitch, type TabSessionHistorySubject } from './tab-session-history-switch' @@ -201,6 +202,25 @@ describe('resolveTabSessionHistorySubject', () => { executionHostId: 'local' }) }) + it.each(['local', 'runtime:server-1', 'ssh:dev-box', null] as const)( + 'keeps lightweight native eligibility and the public request on workspace host %s', + (executionHostId) => { + const state = makeState({}, executionHostId ? { executionHostId } : {}) + if (!executionHostId) { + state.repos = [] + } + const args = { + tab: { id: 'chat-tab', worktreeId: WORKTREE_ID }, + nativeChat: { sessionId: 'chat-1', agent: 'claude' as const } + } + const expected = executionHostId === 'ssh:dev-box' ? null : executionHostId + const identity = resolveNativeChatCliForkIdentity(state, args) + const full = resolveTabSessionHistorySubject(state, args) + expect(identity?.executionHostScope ?? null).toBe(expected) + expect(full?.request.executionHostScope ?? null).toBe(expected) + } + ) + it('finds a native chat tab by the chat it shows', () => { expect( resolveTabSessionHistorySubject(makeState(), { diff --git a/src/renderer/src/components/tab-bar/tab-session-history-switch.ts b/src/renderer/src/components/tab-bar/tab-session-history-switch.ts index 90656c58633..d620c9924d0 100644 --- a/src/renderer/src/components/tab-bar/tab-session-history-switch.ts +++ b/src/renderer/src/components/tab-bar/tab-session-history-switch.ts @@ -113,6 +113,33 @@ export type NativeChatTabSession = { agent: AgentType | undefined } +export type NativeChatCliForkIdentity = { + workspaceId: string + sessionId: string + executionHostScope: ExecutionHostScope +} + +export function resolveNativeChatCliForkIdentity( + state: AppState, + args: { tab: Pick; nativeChat: NativeChatTabSession } +): NativeChatCliForkIdentity | null { + const { nativeChat, tab } = args + if ( + !nativeChat.agent || + !agentForksConversations(nativeChat.agent) || + !resolveAiVaultTargetWorkspacePath(state, tab.worktreeId) + ) { + return null + } + const executionHostScope = resolveAiVaultHostScopeDefaults( + getAiVaultResumeWorkspaceExecutionHostId(state, tab.worktreeId), + tab.worktreeId + ).defaultExecutionHostScope + return canHostOfferTabSessionMove('chat', executionHostScope) + ? { workspaceId: tab.worktreeId, sessionId: nativeChat.sessionId, executionHostScope } + : null +} + export function resolveTabSessionHistorySubject( state: AppState, args: { @@ -134,11 +161,18 @@ export function resolveTabSessionHistorySubject( } if (args.nativeChat) { // A chat tab's only move is opening a copy in its CLI. - const target = - args.nativeChat.agent && agentForksConversations(args.nativeChat.agent) - ? workspace(args.tab.worktreeId, 'chat') - : null - return target ? { ...target, kind: 'chat', sessionId: args.nativeChat.sessionId } : null + const identity = resolveNativeChatCliForkIdentity(state, { + ...args, + nativeChat: args.nativeChat + }) + return identity + ? { + kind: 'chat', + workspaceId: identity.workspaceId, + sessionId: identity.sessionId, + request: resolveAiVaultPanelSessionListRequest(state, identity.workspaceId) + } + : null } // The pane-to-conversation mapping tab titles use, for the agents a chat can take over, limited to // agents still running: once the agent exits, the tab title keeps its conversation but the move is diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx index 826503267b3..d488d1e108a 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx @@ -223,9 +223,8 @@ export default function TerminalPaneHeaderOverlay({ +
+ + + {find.isOpen && } +

+ alpha alpha +

+