diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.agent-surface.test.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.agent-surface.test.tsx new file mode 100644 index 00000000000..a81ce9e5369 --- /dev/null +++ b/src/renderer/src/components/NewWorkspaceComposerModal.agent-surface.test.tsx @@ -0,0 +1,159 @@ +// @vitest-environment happy-dom +import { useRef } from 'react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { NewWorkspaceComposerCardProps } from './new-workspace/new-workspace-composer-card-props' +import { useAppStore } from '@/store' +import { getDefaultSettings } from '../../../shared/constants' +import { RUNTIME_CAPABILITIES } from '../../../shared/protocol-version' +import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' +import type { HostStructuredAgentAvailability } from '@/runtime/host-structured-agent-availability' +import { TooltipProvider } from '@/components/ui/tooltip' +import AgentCombobox from '@/components/agent/AgentCombobox' +import { AGENT_CATALOG } from '@/lib/agent-catalog' +import NewWorkspaceComposerModal from './NewWorkspaceComposerModal' + +const mocks = vi.hoisted(() => ({ + submit: vi.fn(), + host: 'local', + availability: vi.fn<() => HostStructuredAgentAvailability>() +})) +vi.mock('@/runtime/use-host-structured-agent-availability', () => ({ + useHostStructuredAgentAvailability: mocks.availability +})) +vi.mock('@/components/agent/AgentSettingsDialog', () => ({ default: () => null })) +vi.mock('@/hooks/useDetectedAgents', () => ({ + useDetectedAgents: () => ({ + detectedIds: ['claude', 'opencode'], + isLoading: false, + detectionFailed: false, + needsServerUpdate: false + }) +})) +vi.mock('@/hooks/useComposerState', () => ({ + useComposerState: () => ({ + cardProps: { + projectOptions: [], + selectedRepoIsGit: true, + detectedAgentIds: new Set(['claude', 'opencode']), + agentRows: { + target: { workspace: { kind: 'git-worktree', executionHostId: mocks.host } }, + detectionTarget: { kind: 'local' } + } + }, + composerRef: useRef(null), + submitQuick: mocks.submit, + createDisabled: false + }) +})) +// Keep the actual owner/handler and picker; unrelated project/name fields are outside this seam. +vi.mock('@/components/NewWorkspaceComposerCard', () => ({ + default: (p: NewWorkspaceComposerCardProps) => ( +
+ + + + + + +
+ ) +})) +const initial = useAppStore.getState() +beforeEach(() => { + mocks.submit.mockReset() + mocks.host = 'local' + mocks.availability.mockReturnValue({ state: 'pending' }) + setLocalRuntimeCapabilitiesForTests(RUNTIME_CAPABILITIES) + useAppStore.setState({ + activeModal: 'new-workspace-composer', + modalData: {}, + settings: { + ...getDefaultSettings('/test'), + experimentalNativeChat: true, + defaultTuiAgent: 'claude' + } + }) +}) +afterEach(() => { + cleanup() + useAppStore.setState(initial, true) +}) +const create = () => screen.getByRole('button', { name: 'Create' }) +const submit = async () => { + await act(async () => fireEvent.click(create())) +} + +it('gates the selected chat at click/keyboard, preserves terminal and blank exceptions, and repairs host changes', async () => { + setLocalRuntimeCapabilitiesForTests(null) + const view = render(, { wrapper: TooltipProvider }) + expect(create().hasAttribute('disabled')).toBe(true) + await submit() + fireEvent.keyDown(create(), { key: 'Enter', ctrlKey: true }) + fireEvent.keyDown(create(), { key: 'Enter', metaKey: true }) + expect(mocks.submit).not.toHaveBeenCalled() + act(() => setLocalRuntimeCapabilitiesForTests(RUNTIME_CAPABILITIES)) + fireEvent.click(screen.getByText('Blank')) + await submit() + expect(mocks.submit).toHaveBeenLastCalledWith(null, expect.objectContaining({ setupOnly: false })) + fireEvent.click(screen.getByText('Setup only')) + await submit() + expect(mocks.submit).toHaveBeenLastCalledWith(null, expect.objectContaining({ setupOnly: true })) + fireEvent.click(screen.getByText('Choose CLI')) + expect(screen.getByRole('combobox').textContent).toContain('Claude (CLI)') + await submit() + expect(mocks.submit.mock.calls.at(-1)?.[1].launchChoice).toMatchObject({ + route: 'terminal-tui', + requestedSurface: 'terminal' + }) + mocks.availability.mockReturnValue({ + state: 'received', + byAgent: new Map([['claude', { agent: 'claude', state: 'available' }]]) + }) + fireEvent.click(screen.getByText('Choose chat')) + await submit() + expect(mocks.submit.mock.calls.at(-1)?.[1].launchChoice).toMatchObject({ + route: 'structured-native-chat', + requestedSurface: 'chat' + }) + mocks.host = 'ssh:other' + view.rerender() + expect(screen.getByRole('combobox').textContent).toContain('Claude (CLI)') + await submit() + expect(mocks.submit.mock.calls.at(-1)?.[1].launchChoice).toMatchObject({ + route: 'terminal-tui', + requestedSurface: 'terminal' + }) +}) + +it('submits the untouched refusal-aware OpenCode CLI instead of the registered catalog chat', async () => { + mocks.availability.mockReturnValue({ + state: 'received', + byAgent: new Map([['opencode', { agent: 'opencode', state: 'refused' }]]) + }) + render(, { wrapper: TooltipProvider }) + act(() => + useAppStore.setState({ + settings: { + ...getDefaultSettings('/test'), + experimentalNativeChat: true, + defaultTuiAgent: 'opencode' + } + }) + ) + expect(screen.getByRole('combobox').textContent).toContain('OpenCode (CLI)') + await submit() + expect(mocks.submit.mock.calls[0][1].launchChoice).toMatchObject({ + agent: 'opencode', + route: 'terminal-tui' + }) + expect(mocks.submit.mock.calls[0][1].launchChoice.requestedSurface).toBeUndefined() +}) diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index 604a9deff70..2f7cb432d54 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -1,3 +1,4 @@ +import type { AgentLaunchSurface } from '@/lib/agent-session-launch-plan' import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useAppStore } from '@/store' import { lazyWithRetry } from '@/lib/lazy-with-retry' @@ -28,6 +29,7 @@ import { translate } from '@/i18n/i18n' import { getWorkspaceComposerInitialFocusTarget } from '@/lib/workspace-composer-initial-focus' import { getFolderWorkspacePrimaryActionLabel } from '@/components/sidebar/folder-workspace-composer-helpers' import type { WorkspaceCreationTargetHint } from '@/lib/project-host-workspace-target' +import { useAgentMenuObservation } from '@/components/tab-bar/use-new-tab-agent-menu-rows' import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' // Why: match App-level AddRepoDialog loading — the add flow is off the hot @@ -133,7 +135,7 @@ function QuickTabBody({ onComposerNodeChange, nameInputRef, submitQuick, - createDisabled, + createDisabled: formDisabled, selectAddedProjectRepo } = useComposerState({ initialName: modalData.prefilledName ?? '', @@ -172,8 +174,6 @@ function QuickTabBody({ ) const preferredQuickAgent = useMemo(() => { const pref = settings?.defaultTuiAgent - // Why: detection can still be pending when quick-create submits; keep the - // prior catalog fallback while filtering disabled agents out of that choice. return pickQuickWorkspaceAgent(pref, cardProps.detectedAgentIds, settings?.disabledTuiAgents) }, [cardProps.detectedAgentIds, settings?.defaultTuiAgent, settings?.disabledTuiAgents]) const resolvedQuickAgentSelection = resolveQuickWorkspaceAgentSelection({ @@ -191,10 +191,28 @@ function QuickTabBody({ const [quickSelectionCleared, setQuickSelectionCleared] = useState(false) const quickSetupOnly = quickAgent === null && quickSelectionCleared - const handleQuickAgentChange = useCallback((agent: TuiAgent | null) => { - setQuickAgentOverride(agent) - setQuickSelectionCleared(false) - }, []) + // Why keyed by agent: a pick repaired to another agent must not carry the old one's surface. + const [quickAgentPick, setQuickAgentPick] = useState<{ + agent: TuiAgent | null + surface?: AgentLaunchSurface + }>({ agent: null }) + const quickAgentSurface = quickAgentPick.agent === quickAgent ? quickAgentPick.surface : undefined + const observation = useAgentMenuObservation( + cardProps.agentRows?.target ?? null, + active && Boolean(cardProps.agentRows), + cardProps.agentRows?.detectionTarget + ) + const launchSelection = observation.selectionFor(quickAgent, quickAgentSurface) + const createDisabled = formDisabled || !launchSelection.ready + + const handleQuickAgentChange = useCallback( + (agent: TuiAgent | null, surface?: AgentLaunchSurface) => { + setQuickAgentOverride(agent) + setQuickAgentPick({ agent, surface }) + setQuickSelectionCleared(false) + }, + [] + ) const handleQuickSetupOnlySelect = useCallback(() => { setQuickAgentOverride(null) @@ -202,8 +220,14 @@ function QuickTabBody({ }, []) const handleCreate = useCallback(async (): Promise => { - await submitQuick(quickAgent, { setupOnly: quickSetupOnly }) - }, [quickAgent, quickSetupOnly, submitQuick]) + if (createDisabled) { + return + } + await submitQuick(quickAgent, { + setupOnly: quickSetupOnly, + launchChoice: launchSelection.choice + }) + }, [createDisabled, quickAgent, launchSelection.choice, quickSetupOnly, submitQuick]) // Why: Add Project layers over the composer as a nested dialog instead of // replacing it in the activeModal slot — closing the composer mid-flow (and // losing the typed name/prompt) was the old, abrupt behavior. Once opened it @@ -320,6 +344,18 @@ function QuickTabBody({ quickSetupOnly={quickSetupOnly} onQuickSetupOnlySelect={handleQuickSetupOnlySelect} {...cardProps} + createDisabled={createDisabled} + agentSurfaceRows={ + cardProps.agentRows + ? { + model: observation.model, + selection: launchSelection, + surface: quickAgentSurface, + contextKey: observation.contextKey, + onPick: handleQuickAgentChange + } + : undefined + } primaryActionLabel={primaryActionLabel} onOpenAgentSettings={() => setAgentSettingsOpen(true)} onCreate={() => void handleCreate()} diff --git a/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.test.tsx b/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.test.tsx index 7a4265d3813..6e64f4953ab 100644 --- a/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.test.tsx +++ b/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.test.tsx @@ -1,120 +1,103 @@ // @vitest-environment happy-dom - -import React, { type ReactNode, act } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionContinuationRequest } from '@/lib/agent-session-continuation' - -const mocks = vi.hoisted(() => ({ - detectAgents: vi.fn(), - launchContinuation: vi.fn(), - settings: { defaultTuiAgent: 'codex', disabledTuiAgents: [] } -})) - -vi.mock('@/store', () => ({ - useAppStore: (selector: (state: unknown) => unknown) => selector({ settings: mocks.settings }) -})) -vi.mock('@/lib/launch-agent-session-continuation', () => ({ - detectAgentSessionContinuationAgents: mocks.detectAgents, - launchAgentSessionContinuation: mocks.launchContinuation -})) -vi.mock('@/lib/agent-catalog', () => ({ - getAgentCatalog: () => [{ id: 'codex', label: 'Codex' }], - getAgentLabel: () => 'Codex' -})) -vi.mock('@/components/agent/AgentCombobox', () => ({ - default: ({ - value, - allowBlankTerminal - }: { - value: string | null - allowBlankTerminal?: boolean - }) => - React.createElement('div', { - 'data-agent': value ?? '', - 'data-allow-blank': String(allowBlankTerminal === true) - }) -})) -vi.mock('@/components/ui/dialog', () => ({ - Dialog: ({ open, children }: { open: boolean; children?: ReactNode }) => - open ? React.createElement('div', null, children) : null, - DialogContent: ({ children }: { children?: ReactNode }) => - React.createElement('div', null, children), - DialogDescription: ({ children }: { children?: ReactNode }) => - React.createElement('p', null, children), - DialogFooter: ({ children }: { children?: ReactNode }) => - React.createElement('footer', null, children), - DialogHeader: ({ children }: { children?: ReactNode }) => - React.createElement('header', null, children), - DialogTitle: ({ children }: { children?: ReactNode }) => React.createElement('h2', null, children) -})) -vi.mock('@/components/ui/select', () => ({ - Select: ({ children }: { children?: ReactNode }) => React.createElement('div', null, children), - SelectContent: ({ children }: { children?: ReactNode }) => - React.createElement('div', null, children), - SelectItem: ({ children }: { children?: ReactNode }) => - React.createElement('div', null, children), - SelectTrigger: ({ children }: { children?: ReactNode }) => - React.createElement('button', null, children), - SelectValue: () => React.createElement('span') -})) - +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { getDefaultSettings } from '../../../../shared/constants' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY as CHAT } from '../../../../shared/protocol-version' +import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' +import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' +import { TooltipProvider } from '@/components/ui/tooltip' +import type { UseDetectedAgentsResult } from '@/hooks/useDetectedAgents' import { AgentSessionContinuationDialog } from './AgentSessionContinuationDialog' -function request(worktreeId: string): AgentSessionContinuationRequest { - return { - source: { capturedText: 'previous session', sourceAgent: 'codex' }, - worktreeId, - workspacePath: '/repo', - launchSource: 'sidebar' - } +const mocks = vi.hoisted(() => ({ + detection: vi.fn<() => UseDetectedAgentsResult>(), + launch: vi.fn() +})) +vi.mock('@/hooks/useDetectedAgents', () => ({ useDetectedAgents: mocks.detection })) +vi.mock('@/hooks/useAgentDetectionTarget', () => ({ + useAgentDetectionTargetForWorktree: () => ({ kind: 'local' }) +})) +vi.mock('@/lib/launch-agent-session-continuation', () => ({ + launchAgentSessionContinuation: mocks.launch +})) +const initial = useAppStore.getState() +const request = { + source: { capturedText: 'previous session', sourceAgent: 'codex' as const }, + worktreeId: 'wt-1', + workspacePath: '/repo', + groupId: 'group-1', + initialCwd: '/repo/.', + launchSource: 'sidebar' as const } - -describe('AgentSessionContinuationDialog', () => { - let container: HTMLDivElement - let root: Root - - beforeEach(() => { - ;( - globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } - ).IS_REACT_ACT_ENVIRONMENT = true - vi.clearAllMocks() - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) +const detection: UseDetectedAgentsResult = { + detectedIds: ['codex'], + isLoading: false, + detectionFailed: false, + isRefreshing: false, + needsServerUpdate: false, + refresh: async () => ['codex'] +} +beforeEach(() => { + vi.clearAllMocks() + mocks.detection.mockReturnValue(detection) + mocks.launch.mockResolvedValue(true) + setLocalRuntimeCapabilitiesForTests([CHAT]) + useAppStore.setState({ + settings: { ...getDefaultSettings('/test'), experimentalNativeChat: true }, + repos: [{ id: 'repo-1', path: '/repo', displayName: 'repo', badgeColor: '', addedAt: 0 }], + worktreesByRepo: { + 'repo-1': [makeWorktree({ id: 'wt-1', repoId: 'repo-1', path: '/repo', hostId: 'local' })] + } }) +}) +afterEach(() => { + cleanup() + useAppStore.setState(initial, true) +}) - afterEach(() => { - act(() => root.unmount()) - container.remove() +it('clears failed detection, gates unresolved Start, and submits the displayed root/subfolder choice', async () => { + const props = { open: true, request, onOpenChange: vi.fn() } + mocks.detection.mockReturnValue({ ...detection, detectedIds: null, detectionFailed: true }) + const view = render(, { wrapper: TooltipProvider }) + expect(screen.getByText('Could not detect Agents on this workspace host.')).toBeTruthy() + mocks.detection.mockReturnValue({ ...detection, detectedIds: null, isLoading: true }) + view.rerender() + expect(screen.queryByText('Could not detect Agents on this workspace host.')).toBeNull() + expect(screen.getByText('Detecting Agents on this workspace host…')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Start New Session' })) + expect(mocks.launch).not.toHaveBeenCalled() + mocks.detection.mockReturnValue(detection) + view.rerender() + fireEvent.click(screen.getAllByRole('combobox')[0]) + fireEvent.click(screen.getByRole('option', { name: /Codex/ })) + await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Start New Session' }))) + expect(mocks.launch.mock.calls[0][0]).toMatchObject({ + initialCwd: '/repo/.', + groupId: 'group-1', + launchChoice: { route: 'structured-native-chat', requestedSurface: 'chat' } }) - - it('clears a prior detection failure while detecting a new request', async () => { - let resolveSecond: (agents: ['codex']) => void = () => {} - mocks.detectAgents.mockRejectedValueOnce(new Error('offline')).mockReturnValueOnce( - new Promise<['codex']>((resolve) => { - resolveSecond = resolve - }) - ) - - await act(async () => { - root.render( - - ) - }) - await vi.waitFor(() => expect(container.textContent).toContain('Could not detect Agents')) - - act(() => { - root.render( - - ) - }) - expect(container.textContent).toContain('Detecting Agents') - expect(container.textContent).not.toContain('Could not detect Agents') - - await act(async () => resolveSecond(['codex'])) - await vi.waitFor(() => expect(container.querySelector('[data-agent="codex"]')).not.toBeNull()) - // Why: continuing a chat needs an agent to resume it into. - expect(container.querySelector('[data-allow-blank="false"]')).not.toBeNull() + fireEvent.click(screen.getAllByRole('combobox')[0]) + fireEvent.click(screen.getByRole('button', { name: 'Start Codex in a terminal' })) + expect(screen.getAllByRole('combobox')[0].textContent).toContain('Codex (CLI)') + view.rerender() + view.rerender() + expect(screen.getAllByRole('combobox')[0].textContent).not.toContain('(CLI)') + await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Start New Session' }))) + expect(mocks.launch.mock.calls.at(-1)?.[0].launchChoice.requestedSurface).toBeUndefined() + view.rerender( + + ) + expect(screen.getAllByRole('combobox')[0].textContent).toContain('Codex (CLI)') + fireEvent.click(screen.getAllByRole('combobox')[0]) + expect(screen.getByText('No chat in a subfolder')).toBeTruthy() + fireEvent.click(screen.getByRole('option', { name: 'Codex (CLI)' })) + await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Start New Session' }))) + expect(mocks.launch.mock.calls.at(-1)?.[0]).toMatchObject({ + initialCwd: '/repo/packages/app', + launchChoice: { route: 'terminal-tui', requestedSurface: 'terminal' } }) }) diff --git a/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx b/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx index 8b31cb59e83..73569b6ca68 100644 --- a/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx +++ b/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx @@ -1,6 +1,10 @@ import { useEffect, useMemo, useState } from 'react' import { Loader2, MessageSquarePlus } from 'lucide-react' import AgentCombobox from '@/components/agent/AgentCombobox' +import { useAgentMenuObservation } from '@/components/tab-bar/use-new-tab-agent-menu-rows' +import { useAgentDetectionTargetForWorktree } from '@/hooks/useAgentDetectionTarget' +import type { AgentLaunchSurface } from '@/lib/agent-session-launch-plan' +import { workspaceKindForWorktreeId } from '@/lib/agent-launch-route-input' import { Button } from '@/components/ui/button' import { Dialog, @@ -25,10 +29,7 @@ import { type AgentSessionContinuationContextMode, type AgentSessionContinuationRequest } from '@/lib/agent-session-continuation' -import { - detectAgentSessionContinuationAgents, - launchAgentSessionContinuation -} from '@/lib/launch-agent-session-continuation' +import { launchAgentSessionContinuation } from '@/lib/launch-agent-session-continuation' import { useAppStore } from '@/store' import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' import type { TuiAgent } from '../../../../shared/tui-agent' @@ -51,67 +52,64 @@ export function AgentSessionContinuationDialog({ onStarted }: AgentSessionContinuationDialogProps): React.JSX.Element { const settings = useAppStore((state) => state.settings) - const [detectedAgents, setDetectedAgents] = useState([]) - const [selectedAgent, setSelectedAgent] = useState(null) - const [contextMode, setContextMode] = useState('focused') - const [detecting, setDetecting] = useState(true) - const [detectionFailed, setDetectionFailed] = useState(false) + const [pick, setPick] = useState<{ + request: AgentSessionContinuationRequest | null + agent: TuiAgent + surface?: AgentLaunchSurface + } | null>(null) + const detectionTarget = useAgentDetectionTargetForWorktree(request?.worktreeId ?? null) + const [contextPick, setContextPick] = useState<{ + request: AgentSessionContinuationRequest | null + mode: AgentSessionContinuationContextMode + } | null>(null) + if (!open) { + if (pick) { + setPick(null) + } + if (contextPick) { + setContextPick(null) + } + } + const contextMode = contextPick?.request === request ? contextPick.mode : 'focused' const [starting, setStarting] = useState(false) const [showStarting, setShowStarting] = useState(false) const disabledAgents = settings?.disabledTuiAgents ?? EMPTY_DISABLED_AGENTS + const observation = useAgentMenuObservation( + request + ? { + workspace: { + kind: workspaceKindForWorktreeId(request.worktreeId), + worktreeId: request.worktreeId + }, + launchCwd: request.initialCwd + } + : null, + open && Boolean(request), + detectionTarget + ) + const { detectedIds, detectionFailed } = observation.detection + const detecting = + detectedIds === null && !detectionFailed && !observation.detection.needsServerUpdate const agents = useMemo( () => getAgentCatalog().filter( - (agent) => detectedAgents.includes(agent.id) && isTuiAgentEnabled(agent.id, disabledAgents) + (agent) => detectedIds?.includes(agent.id) && isTuiAgentEnabled(agent.id, disabledAgents) ), - [detectedAgents, disabledAgents] + [detectedIds, disabledAgents] ) const hasFullContext = request ? hasFullAgentSessionContext(request.source) : false - useEffect(() => { - if (!open || !request) { - return - } - let cancelled = false - setDetecting(true) - setDetectionFailed(false) - setDetectedAgents([]) - setSelectedAgent(null) - setContextMode('focused') - void detectAgentSessionContinuationAgents(request.worktreeId) - .then((detected) => { - if (cancelled) { - return - } - const enabled = detected.filter((agent) => isTuiAgentEnabled(agent, disabledAgents)) - setDetectedAgents(enabled) - setSelectedAgent( - chooseInitialContinuationAgent({ - availableAgents: enabled, - sourceAgent: request.source.sourceAgent, - defaultAgent: settings?.defaultTuiAgent - }) - ) - }) - .catch((error) => { - console.error('Agent detection failed for continuation dialog', error) - if (!cancelled) { - setDetectedAgents([]) - setSelectedAgent(null) - setDetectionFailed(true) - } - }) - .finally(() => { - if (!cancelled) { - setDetecting(false) - } - }) - - return () => { - cancelled = true - } - }, [disabledAgents, open, request, settings?.defaultTuiAgent]) + const currentPick = + pick?.request === request && agents.some((agent) => agent.id === pick?.agent) ? pick : null + const selectedAgent = + currentPick?.agent ?? + chooseInitialContinuationAgent({ + availableAgents: agents.map((agent) => agent.id), + sourceAgent: request?.source.sourceAgent ?? null, + defaultAgent: settings?.defaultTuiAgent + }) + const selection = observation.selectionFor(selectedAgent, currentPick?.surface) useEffect(() => { if (!starting) { @@ -124,7 +122,7 @@ export function AgentSessionContinuationDialog({ }, [starting]) const handleStart = async (): Promise => { - if (!request || !selectedAgent || starting) { + if (!request || !selectedAgent || starting || !selection.ready) { return } const prompt = buildAgentSessionContinuationPrompt(request.source, contextMode) @@ -138,6 +136,7 @@ export function AgentSessionContinuationDialog({ worktreeId: request.worktreeId, groupId: request.groupId, initialCwd: request.initialCwd, + launchChoice: selection.choice, launchSource: request.launchSource }) setStarting(false) @@ -151,7 +150,8 @@ export function AgentSessionContinuationDialog({ const sourceAgentLabel = request?.source.sourceAgent ? getAgentLabel(request.source.sourceAgent) : null - const startDisabled = detecting || starting || agents.length === 0 || !selectedAgent + const startDisabled = + detecting || starting || agents.length === 0 || !selectedAgent || !selection.ready return ( {translate('components.agentSessionContinuation.agent', 'Agent')} - + {request ? ( + setPick({ request, agent })} + surfaceRows={{ + model: observation.model, + selection, + contextKey: observation.contextKey, + surface: currentPick?.surface, + onPick: (agent, surface) => setPick({ request, agent, surface }) + }} + allowNarrowTrigger + triggerClassName="min-w-0 w-full" + /> + ) : null} {detecting ? (

{translate( @@ -237,9 +246,11 @@ export function AgentSessionContinuationDialog({