mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
Merge remote-tracking branch 'origin/brennanb2025/claude-nc-s1-renderer' into brennanb2025/claude-nc-s1-integrated
This commit is contained in:
@@ -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(<StructuredAgentSessionStatusBridge />)
|
||||
|
||||
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(<StructuredAgentSessionStatusBridge />)
|
||||
|
||||
|
||||
@@ -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.'
|
||||
)
|
||||
|
||||
@@ -66,7 +66,7 @@ export function NativeChatExperimentalSetting({
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{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.'
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
+39
@@ -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.
|
||||
|
||||
+106
@@ -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<string, unknown>) => 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 }) => (
|
||||
<div data-testid="structured-chat" data-agent={agent} data-session-id={sessionId} />
|
||||
)
|
||||
}))
|
||||
|
||||
import TabGroupPanel from './TabGroupPanel'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function renderPanel(): ReturnType<typeof render> {
|
||||
return render(
|
||||
<TabGroupPanel
|
||||
groupId="group-1"
|
||||
worktreeId="wt-1"
|
||||
isVisible
|
||||
isFocused
|
||||
hasSplitGroups={false}
|
||||
touchesRightEdge
|
||||
touchesLeftEdge
|
||||
reserveClosedExplorerToggleSpace={false}
|
||||
reserveCollapsedSidebarHeaderSpace={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
<div className="native-chat-pane-shell absolute inset-0 z-10 flex min-h-0 min-w-0 bg-background">
|
||||
{structuredSessionId && structuredChatAgent ? (
|
||||
<NativeChatView
|
||||
mode="structured"
|
||||
tabId={unifiedTabId ?? tabId}
|
||||
sessionId={structuredSessionId}
|
||||
agent={structuredChatAgent}
|
||||
isVisible={isRendererVisible}
|
||||
target={structuredChatTarget}
|
||||
allowFileUriLinks
|
||||
orchestrationDispatchStatus={chatPaneDispatchStatus}
|
||||
/>
|
||||
{structuredSessionId ? (
|
||||
structuredChatAgent ? (
|
||||
<NativeChatView
|
||||
mode="structured"
|
||||
tabId={unifiedTabId ?? tabId}
|
||||
sessionId={structuredSessionId}
|
||||
agent={structuredChatAgent}
|
||||
isVisible={isRendererVisible}
|
||||
target={structuredChatTarget}
|
||||
allowFileUriLinks
|
||||
orchestrationDispatchStatus={chatPaneDispatchStatus}
|
||||
/>
|
||||
) : null
|
||||
) : (
|
||||
<NativeChatView
|
||||
terminalTabId={tabId}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveNativeChatRenderAgent } from './native-chat-render-agent'
|
||||
|
||||
describe('native chat render agent', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, (sessionId: string) => 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<string>((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<string>((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<unknown[]>((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()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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<AgentSessionHandleProvider>(['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 }
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
+3
-6
@@ -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<string> {
|
||||
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<string> {
|
||||
return launchStructuredAgentSession(worktreeId, 'codex')
|
||||
}
|
||||
@@ -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' }
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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<string>
|
||||
@@ -9,29 +11,30 @@ type StructuredLaunchState = {
|
||||
visibilityUnknown: boolean
|
||||
}
|
||||
|
||||
const pendingStructuredLaunchesByWorktree = new Map<string, StructuredLaunchState>()
|
||||
const pendingStructuredLaunchesByKey = new Map<string, StructuredLaunchState>()
|
||||
|
||||
function structuredLaunchKey(worktreeId: string, agent: AgentSessionHandleProvider): string {
|
||||
return JSON.stringify([worktreeId, agent])
|
||||
}
|
||||
|
||||
function trackLaunchSettlement(
|
||||
worktreeId: string,
|
||||
key: string,
|
||||
state: StructuredLaunchState,
|
||||
promise: Promise<string>
|
||||
): 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<string> {
|
||||
const existing = pendingStructuredLaunchesByWorktree.get(worktreeId)
|
||||
function launchStructuredAgentSessionOnce(
|
||||
worktreeId: string,
|
||||
agent: AgentSessionHandleProvider
|
||||
): Promise<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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`
|
||||
}
|
||||
Reference in New Issue
Block a user