diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx index ebe7edde7ab..ceb75c4f09c 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx @@ -145,6 +145,39 @@ describe('StructuredAgentSessionStatusBridge', () => { expect(mocks.setAgentStatus.mock.calls.at(-1)?.[5]?.providerSession?.id).not.toBe('session-1') }) + it('publishes Claude as the structured status agent type', async () => { + mocks.store?.setState({ + unifiedTabsByWorktree: { + 'wt-1': [{ ...structuredTab, label: 'Claude Chat', agentSessionAgent: 'claude' }] + } + }) + mocks.call.mockResolvedValueOnce({ + ...historyResult, + page: { + ...historyResult.page, + items: [ + { + itemId: 'item-1', + revision: 1, + sequence: 1, + observedAt: 1, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } + } + ], + window: { + ...historyResult.page.window, + newest: { epoch: 'epoch-1', sequence: 1 } + }, + liveCursor: { epoch: 'epoch-1', sequence: 1 } + } + }) + render() + + await waitFor(() => expect(mocks.setAgentStatus).toHaveBeenCalled()) + expect(mocks.setAgentStatus.mock.calls.at(-1)?.[1]?.agentType).toBe('claude') + expect(mocks.setAgentStatus.mock.calls.at(-1)?.[2]).toBe('Claude Chat') + }) + it('does not publish a completed status for an untouched session', async () => { render() diff --git a/src/renderer/src/components/settings/ExperimentalPane.test.tsx b/src/renderer/src/components/settings/ExperimentalPane.test.tsx index 3a972f13af9..c235c5c330c 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.test.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.test.tsx @@ -240,6 +240,9 @@ describe('ExperimentalPane', () => { const { root, container } = await renderExperimentalPane({ updateSettings, settings }) expect(container.textContent).toContain('Use updated structured native chat') + expect(container.textContent).toContain( + 'Opt in to the host-owned structured Claude and Codex runtimes. Off keeps the existing terminal-backed chat path.' + ) expect(container.textContent).toContain( 'Local macOS and Linux sessions only for now. Windows, WSL, remote, and SSH sessions continue to use terminal chat.' ) diff --git a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx index 8ebf18cf08e..078c7553d9c 100644 --- a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx +++ b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx @@ -66,7 +66,7 @@ export function NativeChatExperimentalSetting({

{translate( 'auto.components.settings.ExperimentalPane.nativeChat.structuredCopy', - 'Opt in to the host-owned structured Codex runtime. Off keeps the existing terminal-backed chat path.' + 'Opt in to the host-owned structured Claude and Codex runtimes. Off keeps the existing terminal-backed chat path.' )}

diff --git a/src/renderer/src/components/tab-bar/tab-bar-item-surface.client-hosted-active-state.test.tsx b/src/renderer/src/components/tab-bar/tab-bar-item-surface.client-hosted-active-state.test.tsx index 2cdff8b1931..b96bc112393 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-item-surface.client-hosted-active-state.test.tsx +++ b/src/renderer/src/components/tab-bar/tab-bar-item-surface.client-hosted-active-state.test.tsx @@ -167,6 +167,45 @@ describe('client-hosted row while a real tab is activated', () => { ) }) +describe('structured session tab provider', () => { + it('passes the Claude label and launch agent to the tab surface', () => { + const item: TabBarItem = { + type: 'agent-session', + id: 'structured-agent-session-claude-1', + unifiedTabId: 'structured-agent-session-claude-1', + isPinned: false, + data: { + id: 'structured-agent-session-claude-1', + entityId: 'claude-1', + groupId: 'group-1', + worktreeId: 'wt-1', + contentType: 'agent-session', + agentSessionAgent: 'claude', + label: 'Claude Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + } + + const [rendered] = renderTabBarItems({ + items: [item], + props: makeProps('agent-session'), + runtime: RUNTIME, + dropIndicatorByVisibleId: new Map(), + includeTopTabBorder: true, + activeClientHostedBrowserRowId: null, + togglePinned: () => {} + }) + const tab = (rendered as React.ReactElement<{ tab: { title: string; launchAgent?: string } }>) + .props.tab + + expect(tab.title).toBe('Claude Chat') + expect(tab.launchAgent).toBe('claude') + }) +}) + /** * The fixtures above cannot cover a row kind that does not exist yet, and a new one wired straight * to its own active-tab selector is exactly how this regressed the first time. diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.structured-session-provider.test.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.structured-session-provider.test.tsx new file mode 100644 index 00000000000..cd04e9b4d73 --- /dev/null +++ b/src/renderer/src/components/tab-group/TabGroupPanel.structured-session-provider.test.tsx @@ -0,0 +1,106 @@ +// @vitest-environment happy-dom + +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + activeTab: { + id: 'structured-agent-session-claude-1', + entityId: 'claude-1', + groupId: 'group-1', + worktreeId: 'wt-1', + contentType: 'agent-session' as const, + agentSessionAgent: 'claude' as unknown, + label: 'Claude Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +})) + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ + rightSidebarOpen: false, + sidebarOpen: false, + groupsByWorktree: { 'wt-1': [{ id: 'group-1' }] } + }) +})) + +vi.mock('./useTabGroupWorkspaceModel', () => ({ + useTabGroupWorkspaceModel: () => ({ + activeTab: mocks.activeTab, + agentSessionItems: [], + browserItems: [], + commands: new Proxy({}, { get: () => vi.fn() }), + editorItems: [], + expandedPaneByTabId: {}, + groupTabs: [mocks.activeTab], + tabBarOrder: [], + terminalTabs: [] + }) +})) + +vi.mock('@dnd-kit/core', () => ({ + useDroppable: () => ({ setNodeRef: vi.fn() }) +})) + +vi.mock('../tab-bar/TabBar', () => ({ default: () => null })) +vi.mock('../tab-bar/TabBarQuickCommandsButton', () => ({ + TabBarQuickCommandsButton: () => null +})) +vi.mock('@/lib/lazy-with-retry', () => ({ lazyWithRetry: () => () => null })) +vi.mock('@/lib/pane-manager/client-hosted-browser-row-state', () => ({ + useClientHostedBrowserRows: () => [] +})) +vi.mock('../tab-bar/client-hosted-browser-row-strip-placement', () => ({ + resolveClientHostedBrowserRowStripGroupId: () => 'group-1' +})) +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getExecutionHostIdForWorktree: () => 'local', + getRuntimeEnvironmentIdForWorktree: () => null +})) +vi.mock('@/runtime/runtime-rpc-client', () => ({ + getActiveRuntimeTarget: () => ({ kind: 'local' }) +})) +vi.mock('../native-chat/NativeChatView', () => ({ + default: ({ agent, sessionId }: { agent: string; sessionId: string }) => ( +

+ ) +})) + +import TabGroupPanel from './TabGroupPanel' + +afterEach(cleanup) + +function renderPanel(): ReturnType { + return render( + + ) +} + +describe('TabGroupPanel structured provider rendering', () => { + it('passes Claude to the structured native chat surface', () => { + mocks.activeTab.agentSessionAgent = 'claude' + const view = renderPanel().getByTestId('structured-chat') + + expect(view.getAttribute('data-agent')).toBe('claude') + expect(view.getAttribute('data-session-id')).toBe('claude-1') + }) + + it('renders no structured surface for an unknown provider', () => { + mocks.activeTab.agentSessionAgent = 'gemini' + expect(renderPanel().queryByTestId('structured-chat')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 6157af7d36d..e3cd1a30e1e 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -12,7 +12,8 @@ import { import { useShallow } from 'zustand/react/shallow' import { createPortal } from 'react-dom' import type { CSSProperties } from 'react' -import type { TuiAgent } from '../../../../shared/tui-agent' +import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import { resolveNativeChatRenderAgent } from './native-chat-render-agent' import type { IDisposable } from '@xterm/xterm' import { useAppStore } from '../../store' import { useLinkRoutingPreferenceDialog } from '@/components/link-routing-preference-dialog' @@ -528,6 +529,9 @@ function TerminalPane( getCachedUnifiedTerminalTabForWorktree(store.unifiedTabsByWorktree, worktreeId, tabId) ?.agentSessionAgent ) + const validatedStructuredSessionAgent = isAgentSessionHandleProvider(structuredSessionAgent) + ? structuredSessionAgent + : null const isChatViewMode = useAppStore( (store) => getCachedUnifiedTerminalTabForWorktree(store.unifiedTabsByWorktree, worktreeId, tabId) @@ -639,15 +643,18 @@ function TerminalPane( // foreground hook has not republished agent status after returning to TUI. resolvedAgent: detectedAgent ? null - : ((structuredSessionAgent as TuiAgent | null) ?? resolveTitleAgentForLeaf(leafId)), + : structuredSessionId + ? validatedStructuredSessionAgent + : resolveTitleAgentForLeaf(leafId), nativeChatTranscriptIsLocalReadable }) }, [ tabAgentTypeByLeaf, nativeChatEnabled, - structuredSessionAgent, + validatedStructuredSessionAgent, nativeChatTranscriptIsLocalReadable, + structuredSessionId, terminalTab?.launchAgent, getNativeChatLeafIds, getTabWideAgentHintLeafId, @@ -2981,7 +2988,11 @@ function TerminalPane( leafId: chatPane?.leafId ?? null, leafIds: getNativeChatLeafIds() }) - const structuredChatAgent = structuredSessionAgent ?? chatPaneResolvedAgent ?? chatPaneLaunchAgent + const structuredChatAgent = resolveNativeChatRenderAgent({ + structuredSessionId, + structuredSessionAgent, + terminalAgent: chatPaneResolvedAgent ?? chatPaneLaunchAgent + }) const structuredChatTarget = useMemo(() => ({ kind: 'local' as const }), []) // A split can host different agents, so continuation resolves the specific leaf before using tab-wide hints. const resolveAgentForLeaf = (leafId: string | null): string | null => { @@ -3136,17 +3147,19 @@ function TerminalPane( {effectiveChatViewMode && chatPane?.container ? createPortal(
- {structuredSessionId && structuredChatAgent ? ( - + {structuredSessionId ? ( + structuredChatAgent ? ( + + ) : null ) : ( { + it('passes Claude through to a structured native chat surface', () => { + expect( + resolveNativeChatRenderAgent({ + structuredSessionId: 'claude-session-1', + structuredSessionAgent: 'claude', + terminalAgent: 'codex' + }) + ).toBe('claude') + }) + + it.each([undefined, 'gemini'])('rejects structured provider metadata %s', (provider) => { + expect( + resolveNativeChatRenderAgent({ + structuredSessionId: 'structured-session-1', + structuredSessionAgent: provider, + terminalAgent: 'codex' + }) + ).toBeNull() + }) + + it('keeps terminal-native-chat agent resolution when no structured session exists', () => { + expect( + resolveNativeChatRenderAgent({ + structuredSessionId: null, + structuredSessionAgent: undefined, + terminalAgent: 'codex' + }) + ).toBe('codex') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/native-chat-render-agent.ts b/src/renderer/src/components/terminal-pane/native-chat-render-agent.ts new file mode 100644 index 00000000000..813c5ee9c46 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/native-chat-render-agent.ts @@ -0,0 +1,17 @@ +import type { TuiAgent } from '../../../../shared/tui-agent' +import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' + +export function resolveNativeChatRenderAgent({ + structuredSessionId, + structuredSessionAgent, + terminalAgent +}: { + structuredSessionId: string | null + structuredSessionAgent: unknown + terminalAgent: TuiAgent | null +}): TuiAgent | null { + if (!structuredSessionId) { + return terminalAgent + } + return isAgentSessionHandleProvider(structuredSessionAgent) ? structuredSessionAgent : null +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2f7a3293a3b..ae75710a29b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -6875,7 +6875,7 @@ "defaultViewTerminal": "Terminal chat", "defaultViewNative": "Chat UI", "structuredTitle": "Use updated structured native chat", - "structuredCopy": "Opt in to the host-owned structured Codex runtime. Off keeps the existing terminal-backed chat path.", + "structuredCopy": "Opt in to the host-owned structured Claude and Codex runtimes. Off keeps the existing terminal-backed chat path.", "structuredScope": "Local macOS and Linux sessions only for now. Windows, WSL, remote, and SSH sessions continue to use terminal chat.", "structuredToggleLabel": "Toggle updated structured native chat" }, @@ -16064,8 +16064,8 @@ "contextMenu": { "copy": "Copy" }, - "structuredSessionLaunchStarting": "Opening Codex chat…", - "structuredSessionLaunchInProgress": "Codex chat is still opening" + "structuredSessionLaunchStarting": "Opening {{providerLabel}} chat…", + "structuredSessionLaunchInProgress": "{{providerLabel}} chat is still opening" }, "orca": { "profiles": { @@ -16507,7 +16507,8 @@ "command": "orca orchestration check" }, "structuredSessionCloseFailed": "Could not close this Codex chat", - "structuredSessionLaunchFailed": "Could not open Codex chat", + "structuredSessionLaunchFailed": "Could not open {{providerLabel}} chat", + "structuredSessionLaunchTerminalAction": "Open terminal agent", "structuredSessionCloseFailedDescription": "The terminal stayed open so the provider remains recoverable." }, "tab": { diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index b6cbbb736d8..103a2583871 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -30,7 +30,8 @@ import { getConnectionIdFromState } from '@/lib/connection-context' import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat/native-chat-launch-session-options' import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' import { canUseStructuredNativeChat } from '@/lib/structured-native-chat-availability' -import { startStructuredCodexLaunch } from '@/lib/structured-agent-session-launch' +import { startStructuredAgentLaunch } from '@/lib/structured-agent-session-launch' +import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' export type LaunchAgentInNewTabArgs = { agent: TuiAgent @@ -80,6 +81,13 @@ export function shouldQueueTerminalFocusAfterMenuClose( * Returns `null` when no startup plan can be built (e.g. a whitespace-only prompt). */ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentInNewTabResult { + return launchAgentInNewTabWithStructuredEligibility(args, true) +} + +function launchAgentInNewTabWithStructuredEligibility( + args: LaunchAgentInNewTabArgs, + allowStructuredLaunch: boolean +): LaunchAgentInNewTabResult { const { agent, worktreeId, @@ -184,12 +192,15 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI } const launchDirectStructuredChat = - agent === 'codex' && + allowStructuredLaunch && + isAgentSessionHandleProvider(agent) && !hasPrompt && store.settings?.experimentalNativeChat === true && canUseStructuredNativeChat(store, worktreeId) if (launchDirectStructuredChat) { - startStructuredCodexLaunch(worktreeId) + startStructuredAgentLaunch(worktreeId, agent, () => { + launchAgentInNewTabWithStructuredEligibility(args, false) + }) return { tabId: null, startupPlan, diff --git a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts index 5838fada19b..fac71c3a467 100644 --- a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts +++ b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts @@ -5,7 +5,7 @@ const mockSetTabViewMode = vi.fn() const mockWaitForAgentReady = vi.fn() const mockPasteDraftWhenAgentReady = vi.fn() const mockMarkNativeChatLaunchPromptFailed = vi.fn() -const mockLaunchStructuredCodexSession = vi.fn() +const mockLaunchStructuredAgentSession = vi.fn() const mockRefreshLocalStructuredSessionTabs = vi.fn() const mockToastError = vi.fn() @@ -65,8 +65,8 @@ vi.mock('@/runtime/web-runtime-session', () => ({ isWebRuntimeSessionActive: vi.fn(() => false), isWebTerminalSurfaceTabId: vi.fn(() => false) })) -vi.mock('@/lib/launch-structured-codex-session', () => ({ - launchStructuredCodexSession: mockLaunchStructuredCodexSession +vi.mock('@/lib/launch-structured-agent-session', () => ({ + launchStructuredAgentSession: mockLaunchStructuredAgentSession })) vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({ refreshLocalStructuredSessionTabs: mockRefreshLocalStructuredSessionTabs, @@ -74,7 +74,7 @@ vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({ })) /** Structured adoption creates the tab in terminal mode and flips it to chat once - * Codex is ready; the bridge stamps `viewMode: 'chat'` on the tab up front. That + * the provider is ready; the bridge stamps `viewMode: 'chat'` on the tab up front. That * difference is the only observable signal that the availability guard ran. */ describe('structured chat adoption guard on the launch path', () => { beforeEach(() => { @@ -84,7 +84,7 @@ describe('structured chat adoption guard on the launch path', () => { mockCreateTab.mockReturnValue({ id: 'tab-1' }) mockWaitForAgentReady.mockResolvedValue({ ready: true, reason: 'foreground-match' }) mockPasteDraftWhenAgentReady.mockResolvedValue(true) - mockLaunchStructuredCodexSession.mockResolvedValue('codex-session-1') + mockLaunchStructuredAgentSession.mockResolvedValue('codex-session-1') mockRefreshLocalStructuredSessionTabs.mockResolvedValue([ { worktree: 'wt-1', @@ -92,6 +92,8 @@ describe('structured chat adoption guard on the launch path', () => { } ]) mockToastError.mockReset() + store.settings.experimentalNativeChat = true + store.settings.experimentalStructuredNativeChat = true store.settings.openAgentTabsInChatByDefault = true }) @@ -108,13 +110,82 @@ describe('structured chat adoption guard on the launch path', () => { focusAfterMenuClose: 'structured-session' }) expect(shouldQueueTerminalFocusAfterMenuClose(result!)).toBe(false) - expect(mockLaunchStructuredCodexSession).toHaveBeenCalledWith('wt-1') + expect(mockLaunchStructuredAgentSession).toHaveBeenCalledWith('wt-1', 'codex') expect(mockCreateTab).not.toHaveBeenCalled() expect(mockWaitForAgentReady).not.toHaveBeenCalled() }) + it('opens an unprompted local Claude launch as a provider-explicit structured session', async () => { + mockLaunchStructuredAgentSession.mockResolvedValueOnce('claude-session-1') + mockRefreshLocalStructuredSessionTabs.mockResolvedValueOnce([ + { + worktree: 'wt-1', + tabs: [{ type: 'agent-session', sessionId: 'claude-session-1' }] + } + ]) + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + const result = launchAgentInNewTab({ agent: 'claude', worktreeId: 'wt-1' }) + + expect(result).toMatchObject({ + tabId: null, + pasteDraftAfterLaunch: false, + focusAfterMenuClose: 'structured-session' + }) + expect(mockLaunchStructuredAgentSession).toHaveBeenCalledWith('wt-1', 'claude') + expect(mockCreateTab).not.toHaveBeenCalled() + }) + + it('does not coalesce concurrent Codex and Claude launches in one worktree', async () => { + const pendingLaunches = new Map void>() + mockRefreshLocalStructuredSessionTabs.mockResolvedValue([ + { + worktree: 'wt-1', + tabs: [ + { type: 'agent-session', sessionId: 'codex-session-1' }, + { type: 'agent-session', sessionId: 'claude-session-1' } + ] + } + ]) + mockLaunchStructuredAgentSession.mockImplementation( + (_worktreeId: string, agent: string) => + new Promise((resolve) => pendingLaunches.set(agent, resolve)) + ) + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + launchAgentInNewTab({ agent: 'claude', worktreeId: 'wt-1' }) + + expect(mockLaunchStructuredAgentSession).toHaveBeenCalledTimes(2) + expect(mockLaunchStructuredAgentSession).toHaveBeenNthCalledWith(1, 'wt-1', 'codex') + expect(mockLaunchStructuredAgentSession).toHaveBeenNthCalledWith(2, 'wt-1', 'claude') + pendingLaunches.get('codex')?.('codex-session-1') + pendingLaunches.get('claude')?.('claude-session-1') + await vi.waitFor(() => expect(mockRefreshLocalStructuredSessionTabs).toHaveBeenCalledTimes(2)) + }) + + it.each(['claude', 'codex'] as const)( + 'keeps %s terminal-backed when the parent setting is off and child setting is on', + async (agent) => { + store.settings.experimentalNativeChat = false + store.settings.experimentalStructuredNativeChat = true + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + const result = launchAgentInNewTab({ agent, worktreeId: 'wt-1' }) + + expect(result?.tabId).toBe('tab-1') + expect(mockCreateTab).toHaveBeenCalledWith( + 'wt-1', + undefined, + undefined, + expect.objectContaining({ launchAgent: agent }) + ) + expect(mockLaunchStructuredAgentSession).not.toHaveBeenCalled() + } + ) + it('surfaces a direct structured launch failure instead of silently doing nothing', async () => { - mockLaunchStructuredCodexSession.mockRejectedValueOnce(new Error('provider unavailable')) + mockLaunchStructuredAgentSession.mockRejectedValueOnce(new Error('provider unavailable')) const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') const result = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) @@ -123,15 +194,44 @@ describe('structured chat adoption guard on the launch path', () => { await vi.waitFor(() => expect(mockToastError).toHaveBeenCalledWith( 'Could not open Codex chat', - expect.objectContaining({ description: 'provider unavailable' }) + expect.objectContaining({ + description: 'provider unavailable', + action: expect.objectContaining({ label: 'Open terminal agent' }) + }) ) ) expect(mockCreateTab).not.toHaveBeenCalled() + + const action = mockToastError.mock.calls[0]?.[1]?.action as { onClick: () => void } | undefined + action?.onClick() + expect(mockCreateTab).toHaveBeenCalledWith( + 'wt-1', + undefined, + undefined, + expect.objectContaining({ launchAgent: 'codex' }) + ) + }) + + it('names Claude in its structured launch failure', async () => { + mockLaunchStructuredAgentSession.mockRejectedValueOnce(new Error('provider unavailable')) + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ agent: 'claude', worktreeId: 'wt-1' }) + + await vi.waitFor(() => + expect(mockToastError).toHaveBeenCalledWith( + 'Could not open Claude chat', + expect.objectContaining({ + description: 'provider unavailable', + action: expect.objectContaining({ label: 'Open terminal agent' }) + }) + ) + ) }) it('coalesces repeated structured launches for one worktree while the host is starting', async () => { let resolveLaunch!: (sessionId: string) => void - mockLaunchStructuredCodexSession.mockImplementationOnce( + mockLaunchStructuredAgentSession.mockImplementationOnce( () => new Promise((resolve) => (resolveLaunch = resolve)) ) const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') @@ -141,7 +241,7 @@ describe('structured chat adoption guard on the launch path', () => { expect(first).toMatchObject({ focusAfterMenuClose: 'structured-session' }) expect(second).toMatchObject({ focusAfterMenuClose: 'structured-session' }) - expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(1) + expect(mockLaunchStructuredAgentSession).toHaveBeenCalledTimes(1) resolveLaunch('codex-session-1') }) @@ -150,7 +250,7 @@ describe('structured chat adoption guard on the launch path', () => { mockRefreshLocalStructuredSessionTabs.mockImplementationOnce( () => new Promise((resolve) => (resolveRefresh = resolve)) ) - mockLaunchStructuredCodexSession.mockResolvedValue('codex-session-1') + mockLaunchStructuredAgentSession.mockResolvedValue('codex-session-1') const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) @@ -158,7 +258,7 @@ describe('structured chat adoption guard on the launch path', () => { launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) - expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(1) + expect(mockLaunchStructuredAgentSession).toHaveBeenCalledTimes(1) resolveRefresh([ { worktree: 'wt-1', tabs: [{ type: 'agent-session', sessionId: 'codex-session-1' }] } ]) @@ -166,7 +266,7 @@ describe('structured chat adoption guard on the launch path', () => { }) it('does not create a sibling when post-create visibility proof is unknown', async () => { - mockLaunchStructuredCodexSession.mockResolvedValue('codex-session-1') + mockLaunchStructuredAgentSession.mockResolvedValue('codex-session-1') mockRefreshLocalStructuredSessionTabs .mockRejectedValueOnce(new Error('inventory unavailable')) .mockResolvedValueOnce([ @@ -175,7 +275,7 @@ describe('structured chat adoption guard on the launch path', () => { .mockResolvedValueOnce([ { worktree: 'wt-1', tabs: [{ type: 'agent-session', sessionId: 'codex-session-2' }] } ]) - mockLaunchStructuredCodexSession + mockLaunchStructuredAgentSession .mockResolvedValueOnce('codex-session-1') .mockResolvedValueOnce('codex-session-2') const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') @@ -186,13 +286,13 @@ describe('structured chat adoption guard on the launch path', () => { launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) await vi.waitFor(() => expect(mockRefreshLocalStructuredSessionTabs).toHaveBeenCalledTimes(2)) - expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(1) + expect(mockLaunchStructuredAgentSession).toHaveBeenCalledTimes(1) await new Promise((resolve) => setTimeout(resolve, 0)) // A successful retry must release the reservation so a later launch can start normally. launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) await vi.waitFor(() => expect(mockRefreshLocalStructuredSessionTabs).toHaveBeenCalledTimes(3)) - expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(2) + expect(mockLaunchStructuredAgentSession).toHaveBeenCalledTimes(2) }) it('keeps prompted Codex on the ordinary terminal launch path', async () => { @@ -215,6 +315,25 @@ describe('structured chat adoption guard on the launch path', () => { expect(mockSetTabViewMode).not.toHaveBeenCalled() }) + it('keeps prompted Claude on the ordinary terminal launch path', async () => { + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + const result = launchAgentInNewTab({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'start this task' + }) + + expect(result?.tabId).toBe('tab-1') + expect(mockCreateTab).toHaveBeenCalledWith( + 'wt-1', + undefined, + undefined, + expect.objectContaining({ launchAgent: 'claude' }) + ) + expect(mockLaunchStructuredAgentSession).not.toHaveBeenCalled() + }) + it('shows rejected prompt delivery in chat after Codex becomes ready', async () => { const error = new Error('prompt transport rejected') mockPasteDraftWhenAgentReady.mockRejectedValue(error) @@ -232,29 +351,32 @@ describe('structured chat adoption guard on the launch path', () => { expect(mockSetTabViewMode).not.toHaveBeenCalled() }) - it('keeps an SSH Codex tab on the bridge', async () => { + it.each(['claude', 'codex'] as const)('keeps an SSH %s tab on the bridge', async (agent) => { store.repos = [{ id: 'repo-1', connectionId: 'ssh-a', path: '/repo' }] const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + launchAgentInNewTab({ agent, worktreeId: 'wt-1' }) expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { - launchAgent: 'codex', + launchAgent: agent, viewMode: 'chat' }) expect(mockWaitForAgentReady).not.toHaveBeenCalled() }) - it('keeps a runtime-paired Codex tab on the bridge', async () => { - store.repos = [{ id: 'repo-1', connectionId: 'runtime-ssh-a', path: '/repo' }] - const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + it.each(['claude', 'codex'] as const)( + 'keeps a runtime-paired %s tab on the bridge', + async (agent) => { + store.repos = [{ id: 'repo-1', connectionId: 'runtime-ssh-a', path: '/repo' }] + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + launchAgentInNewTab({ agent, worktreeId: 'wt-1' }) - expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { - launchAgent: 'codex', - viewMode: 'chat' - }) - expect(mockWaitForAgentReady).not.toHaveBeenCalled() - }) + expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { + launchAgent: agent, + viewMode: 'chat' + }) + expect(mockWaitForAgentReady).not.toHaveBeenCalled() + } + ) }) diff --git a/src/renderer/src/lib/launch-structured-agent-session.test.ts b/src/renderer/src/lib/launch-structured-agent-session.test.ts new file mode 100644 index 00000000000..d54772f03fa --- /dev/null +++ b/src/renderer/src/lib/launch-structured-agent-session.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import { launchStructuredAgentSession } from './launch-structured-agent-session' + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: vi.fn() +})) + +describe('structured agent launch', () => { + beforeEach(() => { + vi.mocked(callStructuredAgentSession).mockReset() + }) + + it.each(['claude', 'codex'])( + 'creates a %s native session with a provider-explicit host-verifiable launch intent', + async (agent) => { + vi.mocked(callStructuredAgentSession).mockImplementation( + async (_target, _method, params) => ({ + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 0 }, + value: { + sessionId: (params as { envelope: { sessionId: string } }).envelope.sessionId, + fence: 1, + snapshot: { cursor: { epoch: 'epoch-1', sequence: 0 }, items: [] }, + unconfirmedClientMessageIds: [] + } + }) + ) + + const sessionId = await launchStructuredAgentSession('workspace-1', agent) + const params = vi.mocked(callStructuredAgentSession).mock.calls[0]?.[2] as { + envelope: { sessionId: string; payloadFingerprint: string } + worktree: string + agent: AgentSessionHandleProvider + } + + expect(sessionId).toMatch(new RegExp(`^${agent}_[A-Za-z0-9_]{36}$`)) + expect(callStructuredAgentSession).toHaveBeenCalledWith( + { kind: 'local' }, + 'agentSession.create', + expect.objectContaining({ worktree: 'id:workspace-1', agent }) + ) + expect(params.agent).toBe(agent) + expect(params.envelope.payloadFingerprint).toBe( + structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.create', + sessionId: params.envelope.sessionId, + fields: { worktree: 'id:workspace-1', agent } + }) + ) + } + ) +}) diff --git a/src/renderer/src/lib/launch-structured-codex-session.ts b/src/renderer/src/lib/launch-structured-agent-session.ts similarity index 90% rename from src/renderer/src/lib/launch-structured-codex-session.ts rename to src/renderer/src/lib/launch-structured-agent-session.ts index b60528e4fa9..8d695311319 100644 --- a/src/renderer/src/lib/launch-structured-codex-session.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.ts @@ -2,6 +2,7 @@ import type { AgentSessionAttachResult, AgentSessionMutationResult } from '../../../shared/agent-session-wire' +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' import { createStructuredAgentSessionOperationId, structuredAgentSessionPayloadFingerprint @@ -16,13 +17,13 @@ import { } from '@/runtime/web-session-focus-intent' import { LOCAL_STRUCTURED_SESSION_OWNER } from '@/runtime/local-structured-session-tabs-sync' -function newSessionId(agent: 'claude' | 'codex'): string { +function newSessionId(agent: AgentSessionHandleProvider): string { return `${agent}_${crypto.randomUUID().replaceAll('-', '_')}` } export async function launchStructuredAgentSession( worktreeId: string, - agent: 'claude' | 'codex' + agent: AgentSessionHandleProvider ): Promise { const sessionId = newSessionId(agent) const fields = { worktree: toRuntimeWorktreeSelector(worktreeId), agent } @@ -65,7 +66,3 @@ export async function launchStructuredAgentSession( throw error } } - -export function launchStructuredCodexSession(worktreeId: string): Promise { - return launchStructuredAgentSession(worktreeId, 'codex') -} diff --git a/src/renderer/src/lib/launch-structured-codex-session.test.ts b/src/renderer/src/lib/launch-structured-codex-session.test.ts deleted file mode 100644 index f8d82809363..00000000000 --- a/src/renderer/src/lib/launch-structured-codex-session.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' -import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' -import { launchStructuredCodexSession } from './launch-structured-codex-session' - -vi.mock('@/runtime/structured-agent-session-client', () => ({ - callStructuredAgentSession: vi.fn() -})) - -describe('structured Codex launch', () => { - beforeEach(() => { - vi.mocked(callStructuredAgentSession).mockReset() - }) - - it('creates a native session with a host-verifiable launch intent', async () => { - vi.mocked(callStructuredAgentSession).mockImplementation(async (_target, _method, params) => ({ - ok: true, - replayed: false, - fence: 1, - cursor: { epoch: 'epoch-1', sequence: 0 }, - value: { - sessionId: (params as { envelope: { sessionId: string } }).envelope.sessionId, - fence: 1, - snapshot: { cursor: { epoch: 'epoch-1', sequence: 0 }, items: [] }, - unconfirmedClientMessageIds: [] - } - })) - - const sessionId = await launchStructuredCodexSession('workspace-1') - const params = vi.mocked(callStructuredAgentSession).mock.calls[0]?.[2] as { - envelope: { sessionId: string; payloadFingerprint: string } - worktree: string - agent: 'codex' - } - - expect(sessionId).toMatch(/^codex_[A-Za-z0-9_]{36}$/) - expect(callStructuredAgentSession).toHaveBeenCalledWith( - { kind: 'local' }, - 'agentSession.create', - expect.objectContaining({ worktree: 'id:workspace-1', agent: 'codex' }) - ) - expect(params.envelope.payloadFingerprint).toBe( - structuredAgentSessionPayloadFingerprint({ - method: 'agentSession.create', - sessionId: params.envelope.sessionId, - fields: { worktree: 'id:workspace-1', agent: 'codex' } - }) - ) - }) -}) diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index ef08ee781b5..d33b281118e 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -1,7 +1,9 @@ import { toast } from 'sonner' -import { launchStructuredCodexSession } from '@/lib/launch-structured-codex-session' +import { launchStructuredAgentSession } from '@/lib/launch-structured-agent-session' import { refreshLocalStructuredSessionTabs } from '@/runtime/local-structured-session-tabs-sync' import { translate } from '@/i18n/i18n' +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import { agentSessionProviderLabel } from '../../../shared/agent-session-provider-label' type StructuredLaunchState = { promise: Promise @@ -9,29 +11,30 @@ type StructuredLaunchState = { visibilityUnknown: boolean } -const pendingStructuredLaunchesByWorktree = new Map() +const pendingStructuredLaunchesByKey = new Map() + +function structuredLaunchKey(worktreeId: string, agent: AgentSessionHandleProvider): string { + return JSON.stringify([worktreeId, agent]) +} function trackLaunchSettlement( - worktreeId: string, + key: string, state: StructuredLaunchState, promise: Promise ): void { void promise.then( () => { - if ( - state.promise === promise && - pendingStructuredLaunchesByWorktree.get(worktreeId) === state - ) { - pendingStructuredLaunchesByWorktree.delete(worktreeId) + if (state.promise === promise && pendingStructuredLaunchesByKey.get(key) === state) { + pendingStructuredLaunchesByKey.delete(key) } }, () => { if ( state.promise === promise && !state.visibilityUnknown && - pendingStructuredLaunchesByWorktree.get(worktreeId) === state + pendingStructuredLaunchesByKey.get(key) === state ) { - pendingStructuredLaunchesByWorktree.delete(worktreeId) + pendingStructuredLaunchesByKey.delete(key) } } ) @@ -50,8 +53,12 @@ async function verifyPublishedSession(worktreeId: string, sessionId: string): Pr return sessionId } -function launchStructuredCodexSessionOnce(worktreeId: string): Promise { - const existing = pendingStructuredLaunchesByWorktree.get(worktreeId) +function launchStructuredAgentSessionOnce( + worktreeId: string, + agent: AgentSessionHandleProvider +): Promise { + const key = structuredLaunchKey(worktreeId, agent) + const existing = pendingStructuredLaunchesByKey.get(key) if (existing) { if (existing.visibilityUnknown && existing.sessionId) { existing.visibilityUnknown = false @@ -59,7 +66,7 @@ function launchStructuredCodexSessionOnce(worktreeId: string): Promise { existing.visibilityUnknown = true throw error }) - trackLaunchSettlement(worktreeId, existing, existing.promise) + trackLaunchSettlement(key, existing, existing.promise) } return existing.promise } @@ -70,7 +77,7 @@ function launchStructuredCodexSessionOnce(worktreeId: string): Promise { promise: Promise.resolve(''), visibilityUnknown: false } - state.promise = launchStructuredCodexSession(worktreeId) + state.promise = launchStructuredAgentSession(worktreeId, agent) .then((sessionId) => { state.sessionId = sessionId return verifyPublishedSession(worktreeId, sessionId) @@ -81,28 +88,46 @@ function launchStructuredCodexSessionOnce(worktreeId: string): Promise { } throw error }) - pendingStructuredLaunchesByWorktree.set(worktreeId, state) - trackLaunchSettlement(worktreeId, state, state.promise) + pendingStructuredLaunchesByKey.set(key, state) + trackLaunchSettlement(key, state, state.promise) return state.promise } -export function startStructuredCodexLaunch(worktreeId: string): void { - const alreadyOpening = pendingStructuredLaunchesByWorktree.has(worktreeId) +export function startStructuredAgentLaunch( + worktreeId: string, + agent: AgentSessionHandleProvider, + openTerminalAgent: () => void +): void { + const alreadyOpening = pendingStructuredLaunchesByKey.has(structuredLaunchKey(worktreeId, agent)) + const providerLabel = agentSessionProviderLabel(agent) toast.message( translate( alreadyOpening ? 'auto.components.nativeChat.structuredSessionLaunchInProgress' : 'auto.components.nativeChat.structuredSessionLaunchStarting', - alreadyOpening ? 'Codex chat is still opening' : 'Opening Codex chat…' + alreadyOpening + ? '{{providerLabel}} chat is still opening' + : 'Opening {{providerLabel}} chat…', + { providerLabel } ) ) - void launchStructuredCodexSessionOnce(worktreeId).catch((error) => { + void launchStructuredAgentSessionOnce(worktreeId, agent).catch((error) => { toast.error( translate( 'components.native-chat.structuredSessionLaunchFailed', - 'Could not open Codex chat' + 'Could not open {{providerLabel}} chat', + { providerLabel } ), - { description: error instanceof Error ? error.message : String(error) } + { + description: error instanceof Error ? error.message : String(error), + action: { + label: translate( + 'components.native-chat.structuredSessionLaunchTerminalAction', + 'Open terminal agent' + ), + onClick: openTerminalAgent + } + } ) }) } diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index 9e3d53751f6..4651a933967 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -85,6 +85,36 @@ describe('applyWebSessionTabsSnapshot', () => { ).toBe(agentTab.id) }) + it('labels an untitled mirrored Claude session as Claude chat', () => { + const agentTab = { + type: 'agent-session' as const, + id: 'agent-session:claude-session-1', + title: ' ', + sessionId: 'claude-session-1', + agent: 'claude' as const, + isActive: true + } + const patch = applyWebSessionTabsSnapshot( + makeState(), + makeSnapshot([agentTab], { + activeTabId: agentTab.id, + activeTabType: 'agent-session', + tabGroups: [ + { + id: 'host-group-1', + activeTabId: agentTab.id, + tabOrder: [agentTab.id] + } + ] + }), + ENV, + NOW + ) + + expect(patch.unifiedTabsByWorktree?.[WT]?.[0]?.label).toBe('Claude Chat') + expect(patch.unifiedTabsByWorktree?.[WT]?.[0]?.agentSessionAgent).toBe('claude') + }) + it('removes a restored structured tab when the host publishes no structured sessions', () => { const structuredTab: Tab = { id: 'structured-agent-session-session-1', diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index e6f8443a0e7..a772fe238e2 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -13,6 +13,7 @@ import { import { agentEntryCompletionAt } from '../../../shared/agent-completion-time' import { normalizeTurnCompletedAtField } from '../../../shared/agent-status-field-normalization' import { agentProviderSessionsEqual } from '../../../shared/agent-session-resume' +import { agentSessionChatLabel } from '../../../shared/agent-session-provider-label' import type { RuntimeMobileSessionTabsResult, RuntimeMobileSessionAgentTab, @@ -997,7 +998,7 @@ function buildMirroredAgentTabs( worktreeId: snapshot.worktree, contentType: 'agent-session', agentSessionAgent: tab.agent, - label: tab.title.trim() || 'Codex Chat', + label: tab.title.trim() || agentSessionChatLabel(tab.agent), customLabel: null, color: tab.color !== undefined ? tab.color : (existing?.color ?? null), sortOrder: sortOffset + index, diff --git a/src/shared/agent-session-provider-label.test.ts b/src/shared/agent-session-provider-label.test.ts new file mode 100644 index 00000000000..5bd0001688e --- /dev/null +++ b/src/shared/agent-session-provider-label.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest' +import { agentSessionChatLabel, agentSessionProviderLabel } from './agent-session-provider-label' + +describe('agent session provider labels', () => { + it.each([ + ['claude', 'Claude', 'Claude Chat'], + ['codex', 'Codex', 'Codex Chat'] + ] as const)('labels %s sessions', (provider, providerLabel, chatLabel) => { + expect(agentSessionProviderLabel(provider)).toBe(providerLabel) + expect(agentSessionChatLabel(provider)).toBe(chatLabel) + }) +}) diff --git a/src/shared/agent-session-provider-label.ts b/src/shared/agent-session-provider-label.ts new file mode 100644 index 00000000000..911a8db5a40 --- /dev/null +++ b/src/shared/agent-session-provider-label.ts @@ -0,0 +1,9 @@ +import type { AgentSessionHandleProvider } from './agent-session-provider-handle' + +export function agentSessionProviderLabel(provider: AgentSessionHandleProvider): string { + return provider === 'claude' ? 'Claude' : 'Codex' +} + +export function agentSessionChatLabel(provider: AgentSessionHandleProvider): string { + return `${agentSessionProviderLabel(provider)} Chat` +}