From 973f831f92b6d20337e77ddc4ff7068c4f07a069 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Thu, 27 Aug 2026 07:43:26 -0700
Subject: [PATCH] feat: enable structured Claude launches in renderer
---
...tructuredAgentSessionStatusBridge.test.tsx | 33 ++++
.../settings/ExperimentalPane.test.tsx | 3 +
.../NativeChatExperimentalSetting.tsx | 2 +-
...urface.client-hosted-active-state.test.tsx | 39 ++++
...Panel.structured-session-provider.test.tsx | 106 +++++++++++
.../components/terminal-pane/TerminalPane.tsx | 43 +++--
.../native-chat-render-agent.test.ts | 34 ++++
.../terminal-pane/native-chat-render-agent.ts | 17 ++
src/renderer/src/i18n/locales/en.json | 9 +-
.../src/lib/launch-agent-in-new-tab.ts | 17 +-
...launch-agent-structured-chat-guard.test.ts | 180 +++++++++++++++---
.../launch-structured-agent-session.test.ts | 57 ++++++
....ts => launch-structured-agent-session.ts} | 9 +-
.../launch-structured-codex-session.test.ts | 50 -----
.../lib/structured-agent-session-launch.ts | 69 ++++---
.../src/runtime/web-session-tabs-sync.test.ts | 30 +++
.../src/runtime/web-session-tabs-sync.ts | 3 +-
.../agent-session-provider-label.test.ts | 12 ++
src/shared/agent-session-provider-label.ts | 9 +
19 files changed, 591 insertions(+), 131 deletions(-)
create mode 100644 src/renderer/src/components/tab-group/TabGroupPanel.structured-session-provider.test.tsx
create mode 100644 src/renderer/src/components/terminal-pane/native-chat-render-agent.test.ts
create mode 100644 src/renderer/src/components/terminal-pane/native-chat-render-agent.ts
create mode 100644 src/renderer/src/lib/launch-structured-agent-session.test.ts
rename src/renderer/src/lib/{launch-structured-codex-session.ts => launch-structured-agent-session.ts} (90%)
delete mode 100644 src/renderer/src/lib/launch-structured-codex-session.test.ts
create mode 100644 src/shared/agent-session-provider-label.test.ts
create mode 100644 src/shared/agent-session-provider-label.ts
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`
+}