fix(native-chat): make structured launches observable and recoverable

This commit is contained in:
Merge Sim
2026-09-02 14:12:16 -07:00
parent 801cef1060
commit c52297dd93
6 changed files with 116 additions and 11 deletions
@@ -1,5 +1,5 @@
import React, { useCallback } from 'react'
import { Settings as SettingsIcon } from 'lucide-react'
import { Loader2, Settings as SettingsIcon } from 'lucide-react'
import { toast } from 'sonner'
import { DropdownMenuItem, DropdownMenuShortcut } from '@/components/ui/dropdown-menu'
import { getAgentCatalog, AgentIcon } from '@/lib/agent-catalog'
@@ -15,6 +15,7 @@ import {
filterEnabledTuiAgents
} from '../../../../shared/tui-agent-selection'
import { translate } from '@/i18n/i18n'
import { useStructuredCodexLaunchStatus } from '@/lib/structured-agent-session-launch'
export type QuickLaunchAgentMenuItemsProps = {
worktreeId: string
@@ -116,6 +117,7 @@ function QuickLaunchAgentMenuItemsInner({
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const newAgentShortcut = useOptionalShortcutLabel('tab.newAgent')
const structuredCodexLaunchStatus = useStructuredCodexLaunchStatus(worktreeId)
const openAgentSettings = useCallback(() => {
openSettingsTarget({ pane: 'agents', repoId: null })
@@ -197,21 +199,31 @@ function QuickLaunchAgentMenuItemsInner({
{agents.map((agent) => {
const entry = getCatalogEntry(agent)
const label = entry?.label ?? agent
const isStructuredCodexPending =
agent === 'codex' && structuredCodexLaunchStatus === 'pending'
const menuLabel = isStructuredCodexPending ? 'Starting Codex chat…' : label
const showsDefaultAgentShortcut =
newAgentShortcut !== null && defaultAgent !== 'blank' && agent === defaultAgent
return (
<DropdownMenuItem
key={agent}
disabled={isStructuredCodexPending}
onSelect={() => runLaunch(agent)}
className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium"
title={translate(
'auto.components.tab.bar.QuickLaunchButton.ec2adf093e',
'Launch {{value0}} in a new terminal',
{ value0: label }
isStructuredCodexPending
? 'Starting Codex chat…'
: 'Launch {{value0}} in a new terminal',
isStructuredCodexPending ? undefined : { value0: label }
)}
>
<AgentIcon agent={agent} size={14} />
<span className="flex-1">{label}</span>
{isStructuredCodexPending ? (
<Loader2 className="size-3.5 shrink-0 animate-spin" aria-hidden="true" />
) : (
<AgentIcon agent={agent} size={14} />
)}
<span className="flex-1">{menuLabel}</span>
{showsDefaultAgentShortcut ? (
<DropdownMenuShortcut>{newAgentShortcut}</DropdownMenuShortcut>
) : null}
@@ -33,6 +33,7 @@ import {
getTabEntryOmniboxPlaceholder
} from './tab-create-entry-copy'
import { EMPTY_AGENT_OPTIONS, EMPTY_MENU_OPTIONS } from './tab-create-entry-empty-options'
import { useStructuredCodexLaunchStatus } from '@/lib/structured-agent-session-launch'
import type { TabEntryActionClassification } from './tab-create-entry-classifier'
import type { TabBarCreateEntryProps } from './tab-create-entry-props'
@@ -59,6 +60,7 @@ function TabBarCreateEntrySession({
const [error, setError] = useState<string | null>(null)
const [switchError, setSwitchError] = useState<string | null>(null)
const [selectionGuidance, setSelectionGuidance] = useState<string | null>(null)
const structuredCodexLaunchStatus = useStructuredCodexLaunchStatus(worktreeId)
// null = follow ranking (deferred tabs can prepend); set on arrow keys only.
const [pinnedOptionId, setPinnedOptionId] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
@@ -374,8 +376,26 @@ function TabBarCreateEntrySession({
id={resultOptionDomId(index)}
option={option}
selected={index === activeSelectedIndex}
disabled={disabled || pending}
loading={pending && index === activeSelectedIndex}
labelOverride={
option.kind === 'agent' &&
option.option.agent === 'codex' &&
structuredCodexLaunchStatus === 'pending'
? 'Starting Codex chat…'
: undefined
}
disabled={
disabled ||
pending ||
(option.kind === 'agent' &&
option.option.agent === 'codex' &&
structuredCodexLaunchStatus === 'pending')
}
loading={
(pending && index === activeSelectedIndex) ||
(option.kind === 'agent' &&
option.option.agent === 'codex' &&
structuredCodexLaunchStatus === 'pending')
}
onClick={() => {
setSelectionGuidance(null)
submitOption(option)
@@ -44,6 +44,7 @@ export function EntryStatusRow({
export function EntryActionRow({
disabled = false,
id,
labelOverride,
loading = false,
onClick,
option,
@@ -51,12 +52,13 @@ export function EntryActionRow({
}: {
disabled?: boolean
id: string
labelOverride?: string
loading?: boolean
onClick: () => void
option: ActiveOption
selected: boolean
}): React.JSX.Element {
const presentation = getActionPresentation(option)
const presentation = getActionPresentation(option, labelOverride)
const row = (
<button
@@ -150,7 +152,10 @@ function getOpenTabIcon(option: Extract<ActiveOption, { kind: 'tab' }>['option']
return <GitCompare className="size-3.5 shrink-0" aria-hidden="true" />
}
function getActionPresentation(option: ActiveOption): {
function getActionPresentation(
option: ActiveOption,
labelOverride?: string
): {
detail: string
icon: React.ReactNode
label: string
@@ -201,7 +206,9 @@ function getActionPresentation(option: ActiveOption): {
return {
detail: option.option.label,
icon: <AgentIcon agent={option.option.agent} size={14} />,
label: translate('auto.components.tab.bar.TabBarCreateEntry.b27864279e', 'Launch agent'),
label:
labelOverride ??
translate('auto.components.tab.bar.TabBarCreateEntry.b27864279e', 'Launch agent'),
showDetail: true
}
}
@@ -49,6 +49,9 @@ const store = {
detectedWorktreesByRepo: {},
allWorktrees: vi.fn(() => store.worktreesByRepo['repo-1']),
tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] },
unifiedTabsByWorktree: {
'wt-1': [{ contentType: 'agent-session', entityId: 'codex-session-1', worktreeId: 'wt-1' }]
},
openFiles: [] as { id: string; worktreeId: string }[],
browserTabsByWorktree: {} as Record<string, { id: string }[]>,
tabBarOrderByWorktree: {} as Record<string, string[]>,
@@ -249,6 +252,9 @@ describe('structured chat adoption guard on the launch path', () => {
await new Promise((resolve) => setTimeout(resolve, 0))
// A successful retry must release the reservation so a later launch can start normally.
store.unifiedTabsByWorktree['wt-1'] = [
{ contentType: 'agent-session', entityId: secondIntent.sessionId, worktreeId: 'wt-1' }
]
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
await vi.waitFor(() => expect(mockRefreshLocalStructuredSessionTabs).toHaveBeenCalledTimes(4))
expect(mockCreateStructuredCodexSessionLaunchIntent).toHaveBeenCalledTimes(2)
@@ -5,7 +5,8 @@ import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-ses
const mocks = vi.hoisted(() => ({
createIntent: vi.fn(),
launch: vi.fn(),
abandonIntent: vi.fn()
abandonIntent: vi.fn(),
rendererTabs: {} as Record<string, unknown[]>
}))
vi.mock('sonner', () => ({
@@ -33,6 +34,12 @@ vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
}))
vi.mock('@/store', () => ({
useAppStore: {
getState: () => ({ unifiedTabsByWorktree: mocks.rendererTabs })
}
}))
import {
StructuredAgentSessionCreateRefusalError,
type StructuredAgentSessionLaunchIntent
@@ -64,6 +71,9 @@ function launchIntent(
}
function publishedSnapshot(worktreeId: string, sessionId: string): RuntimeMobileSessionTabsResult {
mocks.rendererTabs[worktreeId] = [
{ contentType: 'agent-session', entityId: sessionId, worktreeId }
]
return {
worktree: worktreeId,
publicationEpoch: 'epoch-1',
@@ -93,6 +103,7 @@ async function flushLaunchSettlement(): Promise<void> {
describe('startStructuredCodexLaunch', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.rendererTabs = {}
mocks.createIntent.mockImplementation((worktreeId: string) => launchIntent(worktreeId))
})
@@ -1,3 +1,4 @@
import { useSyncExternalStore } from 'react'
import { toast } from 'sonner'
import {
createStructuredCodexSessionLaunchIntent,
@@ -8,6 +9,7 @@ import {
} from '@/lib/launch-structured-codex-session'
import { refreshLocalStructuredSessionTabs } from '@/runtime/local-structured-session-tabs-sync'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
type StructuredLaunchState = {
intent: StructuredAgentSessionLaunchIntent
@@ -16,7 +18,37 @@ type StructuredLaunchState = {
cancelled: boolean
}
export type StructuredCodexLaunchStatus = 'idle' | 'pending' | 'unknown'
const pendingStructuredLaunchesByWorktree = new Map<string, StructuredLaunchState>()
const structuredLaunchListeners = new Set<() => void>()
function notifyStructuredLaunchListeners(): void {
for (const listener of structuredLaunchListeners) {
listener()
}
}
export function subscribeStructuredCodexLaunchStatus(listener: () => void): () => void {
structuredLaunchListeners.add(listener)
return () => structuredLaunchListeners.delete(listener)
}
export function getStructuredCodexLaunchStatus(worktreeId: string): StructuredCodexLaunchStatus {
const state = pendingStructuredLaunchesByWorktree.get(worktreeId)
if (!state) {
return 'idle'
}
return state.visibilityUnknown ? 'unknown' : 'pending'
}
export function useStructuredCodexLaunchStatus(worktreeId: string): StructuredCodexLaunchStatus {
return useSyncExternalStore(
subscribeStructuredCodexLaunchStatus,
() => getStructuredCodexLaunchStatus(worktreeId),
() => 'idle'
)
}
class StructuredAgentSessionLaunchCancelledError extends Error {
constructor() {
@@ -43,6 +75,7 @@ function trackLaunchSettlement(
pendingStructuredLaunchesByWorktree.get(worktreeId) === state
) {
pendingStructuredLaunchesByWorktree.delete(worktreeId)
notifyStructuredLaunchListeners()
}
},
() => {
@@ -52,6 +85,7 @@ function trackLaunchSettlement(
pendingStructuredLaunchesByWorktree.get(worktreeId) === state
) {
pendingStructuredLaunchesByWorktree.delete(worktreeId)
notifyStructuredLaunchListeners()
}
}
)
@@ -69,6 +103,17 @@ async function verifyPublishedSession(intent: StructuredAgentSessionLaunchIntent
if (!published) {
throw new Error('structured session tab publication unavailable')
}
const adopted = useAppStore
.getState()
.unifiedTabsByWorktree[intent.worktreeId]?.some(
(tab) =>
tab.contentType === 'agent-session' &&
tab.entityId === intent.sessionId &&
tab.worktreeId === intent.worktreeId
)
if (!adopted) {
throw new Error('structured session tab adoption unavailable')
}
return intent.sessionId
}
@@ -89,6 +134,7 @@ async function retrySameIntent(state: StructuredLaunchState, priorError: unknown
return await verifyPublishedSession(state.intent)
} catch {
state.visibilityUnknown = true
notifyStructuredLaunchListeners()
throw error ?? priorError
}
}
@@ -125,6 +171,7 @@ async function launchAndReconcile(state: StructuredLaunchState): Promise<string>
async function reconcileUnknownLaunch(state: StructuredLaunchState): Promise<string> {
throwIfLaunchCancelled(state)
state.visibilityUnknown = false
notifyStructuredLaunchListeners()
try {
return await verifyPublishedSession(state.intent)
} catch (error) {
@@ -149,6 +196,7 @@ function launchStructuredCodexSessionOnce(worktreeId: string): Promise<string> {
}
state.promise = launchAndReconcile(state)
pendingStructuredLaunchesByWorktree.set(worktreeId, state)
notifyStructuredLaunchListeners()
trackLaunchSettlement(worktreeId, state, state.promise)
return state.promise
}
@@ -161,6 +209,7 @@ export function cancelStructuredCodexLaunch(worktreeId: string, sessionId: strin
}
state.cancelled = true
pendingStructuredLaunchesByWorktree.delete(worktreeId)
notifyStructuredLaunchListeners()
abandonStructuredAgentSessionLaunchIntent(state.intent)
return true
}