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