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 +

+