Create worktree, Create folder and continuation pickers offer each agent as a chat or its CLI, like the "+" menu (#31189)

* feat(agents): Create worktree and continuation pickers offer chat or CLI per agent, like the "+" menu

The Create worktree, Create folder and session-continuation agent pickers now
list the same rows as the tab bar "+" menu: chat agents first, each with a
terminal button that picks the agent's CLI instead, then the agents that only
run in a terminal, labelled "(CLI)". A terminal pick shows as "Claude (CLI)"
and starts the agent in a terminal even when chats are the default.
A continuation into a subfolder shows chat as unavailable there.

* fix(agents): picker rows keep the combobox item spacing instead of restyling CommandItem

* chore: drop accidental lockfile churn

* fix(agents): type the surface picker's props and the composer's agent rows

* fix(agents): submit the picker selection captured by each form

* test(agents): include repository identity in continuation fixture

* fix(agents): settle inventory when workspace ownership is unresolved

* test: hydrate workspace owners in quick launch fixtures
This commit is contained in:
Brennan Benson
2026-10-21 10:44:50 -07:00
committed by GitHub
parent 8174208436
commit 899ed4124f
34 changed files with 1635 additions and 317 deletions
@@ -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) => (
<div ref={p.composerRef}>
<AgentCombobox
agents={AGENT_CATALOG}
value={p.quickAgent}
onValueChange={p.onQuickAgentChange}
allowBlankTerminal
surfaceRows={p.agentSurfaceRows}
/>
<button onClick={p.onCreate} disabled={p.createDisabled}>
Create
</button>
<button onClick={() => p.onQuickAgentChange(null)}>Blank</button>
<button onClick={p.onQuickSetupOnlySelect}>Setup only</button>
<button onClick={() => p.onQuickAgentChange('claude', 'terminal')}>Choose CLI</button>
<button onClick={() => p.onQuickAgentChange('claude', 'chat')}>Choose chat</button>
</div>
)
}))
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(<NewWorkspaceComposerModal />, { 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(<NewWorkspaceComposerModal />)
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(<NewWorkspaceComposerModal />, { 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()
})
@@ -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<TuiAgent | null>(() => {
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<void> => {
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()}
@@ -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(<AgentSessionContinuationDialog {...props} />, { wrapper: TooltipProvider })
expect(screen.getByText('Could not detect Agents on this workspace host.')).toBeTruthy()
mocks.detection.mockReturnValue({ ...detection, detectedIds: null, isLoading: true })
view.rerender(<AgentSessionContinuationDialog {...props} request={{ ...request }} />)
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(<AgentSessionContinuationDialog {...props} />)
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(
<AgentSessionContinuationDialog open request={request('wt-1')} onOpenChange={vi.fn()} />
)
})
await vi.waitFor(() => expect(container.textContent).toContain('Could not detect Agents'))
act(() => {
root.render(
<AgentSessionContinuationDialog open request={request('wt-2')} onOpenChange={vi.fn()} />
)
})
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(<AgentSessionContinuationDialog {...props} open={false} />)
view.rerender(<AgentSessionContinuationDialog {...props} />)
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(
<AgentSessionContinuationDialog
{...props}
request={{ ...request, initialCwd: '/repo/packages/app' }}
/>
)
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' }
})
})
@@ -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<TuiAgent[]>([])
const [selectedAgent, setSelectedAgent] = useState<TuiAgent | null>(null)
const [contextMode, setContextMode] = useState<AgentSessionContinuationContextMode>('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<void> => {
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 (
<Dialog
@@ -200,13 +200,22 @@ export function AgentSessionContinuationDialog({
<label className="text-xs font-medium">
{translate('components.agentSessionContinuation.agent', 'Agent')}
</label>
<AgentCombobox
agents={agents}
value={selectedAgent}
onValueChange={setSelectedAgent}
allowNarrowTrigger
triggerClassName="min-w-0 w-full"
/>
{request ? (
<AgentCombobox
agents={agents}
value={selectedAgent}
onValueChange={(agent) => 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 ? (
<p className="text-[11px] text-muted-foreground">
{translate(
@@ -237,9 +246,11 @@ export function AgentSessionContinuationDialog({
</label>
<Select
value={contextMode}
onValueChange={(value) =>
setContextMode(value as AgentSessionContinuationContextMode)
}
onValueChange={(value) => {
if (value === 'focused' || value === 'full') {
setContextPick({ request, mode: value })
}
}}
>
<SelectTrigger className="min-w-0 w-full" size="sm">
<SelectValue />
@@ -0,0 +1,174 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ReactElement } from 'react'
import { AGENT_CATALOG } from '@/lib/agent-catalog'
import { TooltipProvider } from '@/components/ui/tooltip'
import type { NewTabAgentMenuRow } from '@/lib/new-tab-agent-menu-rows'
import type { NewTabAgentMenuModel } from '@/components/tab-bar/use-new-tab-agent-menu-rows'
import type { AgentLaunchSurface } from '@/lib/agent-session-launch-plan'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type { UseDetectedAgentsResult } from '@/hooks/useDetectedAgents'
import AgentCombobox from './AgentCombobox'
import { resolveAgentMenuSelection } from '@/lib/agent-menu-selection'
afterEach(cleanup)
const detection: UseDetectedAgentsResult = {
detectedIds: ['claude', 'codex', 'gemini', 'grok'],
isLoading: false,
detectionFailed: false,
isRefreshing: false,
needsServerUpdate: false,
refresh: async () => []
}
function settled(rows: NewTabAgentMenuRow[]): NewTabAgentMenuModel {
return { state: 'settled', rows, detection }
}
const CHAT_ROWS: NewTabAgentMenuRow[] = [
{ kind: 'chat', agent: 'claude' },
{ kind: 'chat', agent: 'codex' },
{ kind: 'chat-unavailable', agent: 'grok', reason: 'subfolder' },
{ kind: 'cli', agent: 'gemini' },
{ kind: 'cli', agent: 'grok' }
]
function renderPicker(
model: NewTabAgentMenuModel,
value: TuiAgent | null,
surface?: AgentLaunchSurface
): { onPick: ReturnType<typeof vi.fn> } {
const onPick = vi.fn()
const element: ReactElement = (
<AgentCombobox
agents={AGENT_CATALOG}
value={value}
onValueChange={vi.fn()}
allowBlankTerminal
surfaceRows={{
model,
surface,
onPick,
selection: resolveAgentMenuSelection({
agent: value,
surface,
settings: { experimentalNativeChat: true },
inventoryReady: model.state === 'settled',
answer:
model.state === 'settled' &&
model.rows.some((row) => row.agent === value && row.kind === 'chat')
? 'yes'
: 'no-chats',
chatPending: model.state === 'loading',
plain: model.state === 'settled' && model.rows.every((row) => row.kind === 'terminal')
})
}}
/>
)
render(element, { wrapper: TooltipProvider })
return { onPick }
}
function option(text: string): HTMLElement {
const match = screen
.getAllByRole('option')
.find((candidate) => candidate.textContent?.startsWith(text))
if (!match) {
throw new Error(`no option ${text}`)
}
return match
}
describe('AgentCombobox surface rows', () => {
it('picks a chat from the row and the CLI from its terminal button or the keyboard', () => {
const { onPick } = renderPicker(settled(CHAT_ROWS), 'claude')
expect(screen.getByRole('combobox').textContent).toBe('Claude')
fireEvent.click(screen.getByRole('combobox'))
expect(option('Gemini').textContent).toBe('Gemini (CLI)')
expect(option('Grok').getAttribute('aria-disabled')).toBe('true')
expect(option('Grok').textContent).toContain('No chat in a subfolder')
fireEvent.click(option('Codex'))
expect(onPick).toHaveBeenLastCalledWith('codex', 'chat')
fireEvent.click(screen.getByRole('combobox'))
fireEvent.click(screen.getByRole('button', { name: 'Start Codex in a terminal' }))
expect(onPick).toHaveBeenLastCalledWith('codex', 'terminal')
fireEvent.click(screen.getByRole('combobox'))
const search = screen.getByPlaceholderText('Search agents...')
fireEvent.keyDown(search, { key: 'ArrowRight' })
fireEvent.keyDown(search, { key: 'Enter' })
expect(onPick).toHaveBeenLastCalledWith('claude', 'terminal')
expect(onPick).toHaveBeenCalledTimes(3)
// A mouse commit and a dismissed interaction both cancel a transient terminal arm.
for (const dismiss of [false, true]) {
fireEvent.click(screen.getByRole('combobox'))
const input = screen.getByPlaceholderText('Search agents...')
fireEvent.keyDown(input, { key: 'ArrowRight' })
if (dismiss) {
fireEvent.keyDown(input, { key: 'Escape' })
} else {
fireEvent.click(option('Claude'))
}
fireEvent.click(screen.getByRole('combobox'))
fireEvent.keyDown(screen.getByPlaceholderText('Search agents...'), { key: 'Enter' })
expect(onPick).toHaveBeenLastCalledWith('claude', 'chat')
}
fireEvent.click(screen.getByRole('combobox'))
const input = screen.getByPlaceholderText('Search agents...')
fireEvent.keyDown(input, { key: 'ArrowRight' })
fireEvent.pointerMove(option('Codex'))
fireEvent.pointerMove(option('Claude'))
fireEvent.keyDown(input, { key: 'Enter' })
expect(onPick).toHaveBeenLastCalledWith('claude', 'chat')
})
it('shows a terminal pick as the CLI and keeps its button lit on reopen', () => {
renderPicker(settled(CHAT_ROWS), 'claude', 'terminal')
expect(screen.getByRole('combobox').textContent).toBe('Claude (CLI)')
fireEvent.click(screen.getByRole('combobox'))
expect(
screen
.getByRole('button', { name: 'Start Claude in a terminal' })
.getAttribute('aria-pressed')
).toBe('true')
})
it('keeps the plain list when chats are off, and waits for the host before labelling', () => {
renderPicker(
settled([
{ kind: 'terminal', agent: 'claude' },
{ kind: 'terminal', agent: 'codex' }
]),
'claude',
'terminal'
)
expect(screen.getByRole('combobox').textContent).toBe('Claude')
fireEvent.click(screen.getByRole('combobox'))
expect(screen.queryByRole('button', { name: /in a terminal/ })).toBeNull()
cleanup()
renderPicker({ state: 'loading', placeholderCount: 2 }, 'claude')
expect(screen.getByRole('combobox').hasAttribute('disabled')).toBe(true)
})
})
it('keeps exact-agent search ahead of the default agent', () => {
const { onPick } = renderPicker(
settled([
{ kind: 'terminal', agent: 'openclaude' },
{ kind: 'terminal', agent: 'claude' }
]),
'openclaude'
)
fireEvent.click(screen.getByRole('combobox'))
const input = screen.getByPlaceholderText('Search agents...')
fireEvent.change(input, { target: { value: 'claude' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onPick).toHaveBeenCalledExactlyOnceWith('claude', 'terminal')
})
@@ -23,6 +23,13 @@ import {
renderAgentComboboxItem
} from './agent-combobox-items'
import { translate } from '@/i18n/i18n'
import { rowLabel } from '@/components/tab-bar/tab-agent-row-presentation'
import {
SurfaceAgentItems,
surfaceCommandValues,
useSurfaceRowArming,
type AgentComboboxSurfaceRows
} from './agent-combobox-surface-items'
type DefaultAgentPreference = TuiAgent | 'blank' | null
@@ -54,6 +61,8 @@ type AgentComboboxProps = AgentComboboxSelectionProps & {
cleared: boolean
onClear: () => void
}
/** Lists the "+" menu's chat and CLI rows instead of `agents`; picks go to `onPick`. */
surfaceRows?: AgentComboboxSurfaceRows
}
const BLANK_VALUE = '__none__'
@@ -69,6 +78,7 @@ export default function AgentCombobox({
onTriggerEnter,
allowNarrowTrigger = false,
clearSelection,
surfaceRows,
...selection
}: AgentComboboxProps): React.JSX.Element {
const allowBlankTerminal = selection.allowBlankTerminal === true
@@ -90,20 +100,26 @@ export default function AgentCombobox({
const selectedDefaultPreference = selectionCleared
? null
: (value ?? (allowBlankTerminal ? 'blank' : null))
const openCommandValue = selectionCleared ? '' : (value ?? BLANK_VALUE)
const canClear = clearSelection !== undefined && !selectionCleared
const filteredAgents = useMemo(() => searchAgentPickerEntries(agents, query), [agents, query])
const blankMatchesQuery = useMemo(
() => allowBlankTerminal && agentPickerBlankTerminalMatches(query),
[allowBlankTerminal, query]
)
const pickedCommandValue = getAgentPickerCommandValue({
blankValue: BLANK_VALUE,
blankMatchesQuery,
currentValue: value,
filteredAgents,
rawQuery: query
})
const pickerValues = { agents, query, value, blankValue: BLANK_VALUE, blankMatchesQuery }
const surface = surfaceRows ? surfaceCommandValues(surfaceRows.model, pickerValues) : null
const surfaceList = surface?.rows ?? []
const openCommandValue = selectionCleared
? ''
: ((surface ? surface.selected : value) ?? BLANK_VALUE)
const pickedCommandValue = surface
? surface.highlighted
: getAgentPickerCommandValue({
...pickerValues,
currentValue: value,
filteredAgents,
rawQuery: query
})
const activeCommandValue = selectionCleared && !query ? '' : pickedCommandValue
const resolvedCommandState = resolveAgentComboboxCommandState(
commandState,
@@ -173,6 +189,18 @@ export default function AgentCombobox({
setQuery('')
}, [])
const handleSurfacePick: AgentComboboxSurfaceRows['onPick'] = (agent, surface) => {
surfaceRows?.onPick(agent, surface)
closePicker()
}
const arming = useSurfaceRowArming(
surfaceList,
commandValue,
handleSurfacePick,
JSON.stringify([open, query, surfaceRows?.contextKey])
)
const picksCli = surfaceRows?.selection.cli === true
const handleSelectAgent = (nextValue: TuiAgent): void => {
selection.onValueChange(nextValue)
closePicker()
@@ -249,6 +277,8 @@ export default function AgentCombobox({
role="combobox"
aria-expanded={open}
onKeyDown={handleTriggerKeyDown}
// Why: the "(CLI)" suffix depends on the host's answer; it never pops in on a settled field.
disabled={surfaceRows?.selection.ready === false && value !== null}
className={cn(
// Why: callers sometimes pass `min-w-0` for grid layouts, but
// the compact trigger still needs room for "GitHub Copilot".
@@ -270,7 +300,11 @@ export default function AgentCombobox({
) : selectedAgent ? (
<AgentIconLabel
icon={<AgentIcon agent={selectedAgent.id} size={14} />}
label={selectedAgent.label}
label={
picksCli
? rowLabel({ kind: 'cli', agent: selectedAgent.id })
: selectedAgent.label
}
/>
) : (
<AgentIconLabel
@@ -305,7 +339,12 @@ export default function AgentCombobox({
focusSearchInput()
}}
>
<Command shouldFilter={false} value={commandValue} onValueChange={setCommandValue}>
<Command
shouldFilter={false}
value={commandValue}
onValueChange={setCommandValue}
onKeyDown={surfaceRows ? arming.onKeyDown : undefined}
>
<CommandInput
ref={setInputNode}
placeholder={translate(
@@ -337,7 +376,17 @@ export default function AgentCombobox({
)
})
: null}
{filteredAgents.map((agent) =>
{surfaceRows ? (
<SurfaceAgentItems
surfaceRows={{ ...surfaceRows, onPick: handleSurfacePick }}
rows={surfaceList}
value={value}
armedValue={arming.armedValue}
defaultAgent={defaultAgent}
onSetDefault={onSetDefault}
/>
) : null}
{(surfaceRows ? [] : filteredAgents).map((agent) =>
renderAgentComboboxItem({
key: agent.id,
itemValue: agent.id,
@@ -0,0 +1,31 @@
import React from 'react'
import { SquareTerminal } from 'lucide-react'
import { cn } from '@/lib/utils'
type AgentTerminalLaunchButtonProps = Omit<React.ComponentProps<'button'>, 'children' | 'title'> & {
title: string
}
/** The trailing control on an agent's chat row that starts its CLI in a terminal instead.
* Hidden until its row is highlighted; each list reveals it with its own selector. */
export function AgentTerminalLaunchButton({
title,
className,
...props
}: AgentTerminalLaunchButtonProps): React.JSX.Element {
return (
<button
type="button"
tabIndex={-1}
title={title}
aria-label={title}
className={cn(
'-my-0.5 grid size-6 shrink-0 place-items-center rounded-md opacity-0 outline-none focus-visible:opacity-100',
className
)}
{...props}
>
<SquareTerminal />
</button>
)
}
@@ -19,6 +19,10 @@ type ItemRenderArgs = {
onSetDefault?: () => void
icon: React.ReactNode
label: string
disabled?: boolean
title?: string
/** After the label: an unavailable row's reason, or a chat row's terminal button. */
trailing?: React.ReactNode
}
type AgentDefaultContextMenuProps = {
@@ -75,19 +79,25 @@ export function renderAgentComboboxItem({
onSelect,
onSetDefault,
icon,
label
label,
disabled,
title,
trailing
}: ItemRenderArgs): React.ReactNode {
const row = (
<CommandItem
key={key}
value={itemValue}
onSelect={onSelect}
disabled={disabled}
title={title}
className="items-center gap-2 px-3 py-1.5"
>
<Check
className={cn('size-4 shrink-0 text-foreground', isChecked ? 'opacity-100' : 'opacity-0')}
/>
<AgentIconLabel icon={icon} label={label} />
{trailing}
</CommandItem>
)
return (
@@ -0,0 +1,210 @@
import React, { useState } from 'react'
import { CommandItem, CommandSeparator } from '@/components/ui/command'
import { AgentIcon, type AgentCatalogEntry } from '@/lib/agent-catalog'
import { searchAgentPickerEntries } from '@/lib/agent-picker-search'
import type { AgentLaunchSurface } from '@/lib/agent-session-launch-plan'
import type { NewTabAgentMenuRow } from '@/lib/new-tab-agent-menu-rows'
import type { NewTabAgentMenuModel } from '@/components/tab-bar/use-new-tab-agent-menu-rows'
import {
chatUnavailableLabel,
rowId,
rowLabel,
rowTitle
} from '@/components/tab-bar/tab-agent-row-presentation'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type { resolveAgentMenuSelection } from '@/lib/agent-menu-selection'
import { renderAgentComboboxItem } from './agent-combobox-items'
import { AgentTerminalLaunchButton } from './AgentTerminalLaunchButton'
/** The "+" menu's rows in the picker: a chat row picks the chat, its terminal button the CLI. */
export type AgentComboboxSurfaceRows = {
model: NewTabAgentMenuModel
/** The user's explicit pick for the selected agent; absent follows the agent's own row. */
surface?: AgentLaunchSurface
selection: ReturnType<typeof resolveAgentMenuSelection>
contextKey?: string
onPick: (agent: TuiAgent, surface: AgentLaunchSurface) => void
}
// Nothing launches until the form is submitted, so the picker says "start", not "launch".
function terminalPickTitle(label: string): string {
return translate('components.agentCombobox.startInTerminal', 'Start {{value0}} in a terminal', {
value0: label
})
}
const PLACEHOLDER_WIDTHS = ['w-3/5', 'w-1/2', 'w-2/3', 'w-2/5', 'w-3/4', 'w-1/3', 'w-4/5', 'w-1/4']
export function visibleSurfaceRows(
model: NewTabAgentMenuModel,
agents: readonly AgentCatalogEntry[],
query: string
): NewTabAgentMenuRow[] {
if (model.state !== 'settled') {
return []
}
const matching = searchAgentPickerEntries(agents, query)
return query.trim()
? matching.flatMap((agent) => model.rows.filter((row) => row.agent === agent.id))
: model.rows.filter((row) => matching.some((agent) => row.agent === agent.id))
}
/** cmdk values for the surface list: the selected agent's row, and the row a query highlights. */
export function surfaceCommandValues(
model: NewTabAgentMenuModel,
picker: {
agents: readonly AgentCatalogEntry[]
query: string
value: TuiAgent | null
blankValue: string
blankMatchesQuery: boolean
}
): { rows: NewTabAgentMenuRow[]; selected: string | null; highlighted: string } {
const rows = visibleSurfaceRows(model, picker.agents, picker.query)
const selected =
surfaceRowValue(visibleSurfaceRows(model, picker.agents, ''), picker.value) ?? null
const first = rows.find((row) => row.kind !== 'chat-unavailable')
const highlighted = !picker.query.trim()
? (selected ?? picker.blankValue)
: picker.blankMatchesQuery
? picker.blankValue
: first
? rowId(first)
: ''
return { rows, selected, highlighted }
}
/** The row that carries the selected agent: its chat row when it has one, so the lit button shows. */
export function surfaceRowValue(
rows: readonly NewTabAgentMenuRow[],
agent: TuiAgent | null
): string | undefined {
const own = rows.filter((row) => row.agent === agent && row.kind !== 'chat-unavailable')
const row = own.find((candidate) => candidate.kind === 'chat') ?? own[0]
return row ? rowId(row) : undefined
}
/** cmdk keeps focus in the search box, so ArrowRight arms the highlighted row's terminal button. */
export function useSurfaceRowArming(
rows: readonly NewTabAgentMenuRow[],
commandValue: string,
pick: (agent: TuiAgent, surface: AgentLaunchSurface) => void,
interactionKey: string
): { armedValue: string | null; onKeyDown: (event: React.KeyboardEvent) => void } {
const [armed, setArmed] = useState<string | null>(null)
const key = JSON.stringify([interactionKey, commandValue])
const [previousKey, setPreviousKey] = useState(key)
if (previousKey !== key) {
setPreviousKey(key)
setArmed(null)
}
const armedValue = previousKey === key ? armed : null
const onKeyDown = (event: React.KeyboardEvent): void => {
const row = rows.find((candidate) => rowId(candidate) === commandValue)
const input = event.target instanceof HTMLInputElement ? event.target : null
const caretAtEnd = !input || input.selectionStart === input.value.length
if (event.key === 'ArrowRight' && row?.kind === 'chat' && caretAtEnd) {
event.preventDefault()
setArmed(commandValue)
} else if (event.key === 'Enter' && armedValue !== null && row) {
// Stopped so cmdk does not also select the row and start a chat.
event.preventDefault()
pick(row.agent, 'terminal')
} else if (armedValue !== null && !['Shift', 'Meta', 'Control', 'Alt'].includes(event.key)) {
if (event.key === 'ArrowLeft') {
event.preventDefault()
}
setArmed(null)
}
}
return { armedValue, onKeyDown }
}
export function SurfaceAgentItems({
surfaceRows,
rows,
value,
armedValue,
defaultAgent,
onSetDefault
}: {
surfaceRows: AgentComboboxSurfaceRows
rows: readonly NewTabAgentMenuRow[]
value: TuiAgent | null
armedValue: string | null
defaultAgent?: TuiAgent | 'blank' | null
onSetDefault?: (agent: TuiAgent) => void
}): React.ReactNode {
const { model, onPick } = surfaceRows
if (model.state === 'loading') {
// Why: rows fill in once every answer is known, instead of reshuffling under the pointer.
return PLACEHOLDER_WIDTHS.slice(0, model.placeholderCount).map((width) => (
<CommandItem
key={width}
value={`loading:${width}`}
disabled
aria-label={translate(
'auto.components.tab.bar.QuickLaunchButton.agentsLoading',
'Loading agents'
)}
>
<span aria-hidden="true" className="flex h-[1lh] flex-1 items-center">
<span className={cn('h-2 rounded-full bg-muted', width)} />
</span>
</CommandItem>
))
}
const pickedValue = surfaceRowValue(rows, value)
const terminalPicked = surfaceRows.selection.cli
return rows.map((row, index) => {
const id = rowId(row)
const label = rowLabel(row)
const startsCliGroup = row.kind === 'cli' && index > 0 && rows[index - 1]?.kind !== 'cli'
const unavailable = row.kind === 'chat-unavailable'
const pressed = row.kind === 'chat' && id === pickedValue && terminalPicked
const agentLabel = rowLabel({ ...row, kind: 'chat' })
return (
<React.Fragment key={id}>
{startsCliGroup ? <CommandSeparator /> : null}
{renderAgentComboboxItem({
key: id,
itemValue: unavailable ? `unavailable:${row.agent}` : id,
isChecked: id === pickedValue,
isDefault: defaultAgent === row.agent,
onSelect: () => onPick(row.agent, row.kind === 'chat' ? 'chat' : 'terminal'),
onSetDefault: onSetDefault && !unavailable ? () => onSetDefault(row.agent) : undefined,
icon: <AgentIcon agent={row.agent} />,
label,
disabled: unavailable,
title: unavailable
? undefined
: row.kind === 'chat'
? rowTitle(row, agentLabel)
: terminalPickTitle(agentLabel),
trailing: unavailable ? (
<span className="shrink-0 text-muted-foreground">
{chatUnavailableLabel(row.reason)}
</span>
) : row.kind === 'chat' ? (
<AgentTerminalLaunchButton
title={terminalPickTitle(agentLabel)}
aria-pressed={pressed}
className={cn(
'hover:bg-background [[data-selected=true]_&]:opacity-100',
(pressed || armedValue === id) && 'bg-background opacity-100'
)}
// Keeps focus in the search box; the click picks the CLI, not the row.
onMouseDown={(event) => event.preventDefault()}
onClick={(event) => {
event.stopPropagation()
onPick(row.agent, 'terminal')
}}
/>
) : null
})}
</React.Fragment>
)
})
}
@@ -6,11 +6,14 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type { NewWorkspaceComposerCardProps } from './new-workspace-composer-card-props'
import type { TuiAgent } from '../../../../shared/tui-agent'
type NewWorkspaceComposerAgentSectionProps = Pick<
NewWorkspaceComposerCardProps,
| 'quickAgent'
| 'agentSurfaceRows'
| 'onQuickAgentChange'
| 'agentRows'
| 'quickSetupOnly'
| 'onQuickSetupOnlySelect'
| 'onOpenAgentSettings'
@@ -29,6 +32,7 @@ type NewWorkspaceComposerAgentSectionProps = Pick<
export function NewWorkspaceComposerAgentSection({
quickAgent,
agentSurfaceRows,
onQuickAgentChange,
quickSetupOnly = false,
onQuickSetupOnlySelect,
@@ -42,6 +46,22 @@ export function NewWorkspaceComposerAgentSection({
defaultTuiAgent,
handleSetDefaultAgent
}: NewWorkspaceComposerAgentSectionProps): React.JSX.Element {
const comboboxProps = {
agents: visibleQuickAgents,
value: quickAgent,
onValueChange: (agent: TuiAgent | null) => onQuickAgentChange(agent),
allowBlankTerminal: true as const,
onOpenManageAgents: onOpenAgentSettings,
defaultAgent: defaultTuiAgent,
onSetDefault: handleSetDefaultAgent,
clearSelection: onQuickSetupOnlySelect
? { cleared: quickSetupOnly, onClear: onQuickSetupOnlySelect }
: undefined,
allowNarrowTrigger: true,
triggerClassName:
'h-9 w-full min-w-0 border-input text-sm focus:border-ring focus:ring-[3px] focus:ring-ring/50',
onTriggerEnter: createDisabled ? undefined : onCreate
}
return (
<>
<div className="min-w-0 space-y-1" data-contextual-tour-target="workspace-creation-agent">
@@ -71,26 +91,7 @@ export function NewWorkspaceComposerAgentSection({
</TooltipContent>
</Tooltip>
</div>
<AgentCombobox
agents={visibleQuickAgents}
value={quickAgent}
onValueChange={onQuickAgentChange}
allowBlankTerminal
onOpenManageAgents={onOpenAgentSettings}
defaultAgent={defaultTuiAgent}
onSetDefault={handleSetDefaultAgent}
clearSelection={
onQuickSetupOnlySelect
? {
cleared: quickSetupOnly,
onClear: onQuickSetupOnlySelect
}
: undefined
}
allowNarrowTrigger
triggerClassName="h-9 w-full min-w-0 border-input text-sm focus:border-ring focus:ring-[3px] focus:ring-ring/50"
onTriggerEnter={createDisabled ? undefined : onCreate}
/>
<AgentCombobox {...comboboxProps} surfaceRows={agentSurfaceRows} />
</div>
<div className="!mb-2">
@@ -1,3 +1,5 @@
import type { AgentComboboxSurfaceRows } from '@/components/agent/agent-combobox-surface-items'
import type { ComposerAgentRows } from '@/hooks/composer-state/composer-agent-rows-target'
import type RepoCombobox from '@/components/repo/RepoCombobox'
import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options'
import type {
@@ -37,7 +39,13 @@ export type NewWorkspaceComposerCardProps = {
onComposerNodeChange?: (node: HTMLDivElement | null) => void
nameInputRef?: React.RefObject<HTMLInputElement | null>
quickAgent: TuiAgent | null
onQuickAgentChange: (agent: TuiAgent | null) => void
agentSurfaceRows?: AgentComboboxSurfaceRows
onQuickAgentChange: (
agent: TuiAgent | null,
surface?: AgentComboboxSurfaceRows['surface']
) => void
/** Where the quick agent would start; null keeps the picker's plain agent list. */
agentRows?: ComposerAgentRows | null
/** Cleared picker: skip the agent and the extra shell when setup runs. */
quickSetupOnly?: boolean
onQuickSetupOnlySelect?: () => void
@@ -0,0 +1,99 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
} from '../../../../shared/protocol-version'
import type * as LocalRuntimeCapabilitiesModule from '@/runtime/local-runtime-capabilities'
import { makeProjectGroup, makeFolderWorkspace } from './folder-workspace-composer-submit-fixtures'
const mocks = vi.hoisted(() => ({
activateAndRevealFolderWorkspace: vi.fn(),
terminal: vi.fn()
}))
vi.mock('@/lib/worktree-activation', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
activateAndRevealFolderWorkspace: mocks.activateAndRevealFolderWorkspace
}))
vi.mock('@/lib/launch-agent-in-new-tab', () => ({ launchAgentInNewTab: mocks.terminal }))
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: vi.fn(async () => ({ supported: false, reason: 'wsl' }))
}))
vi.mock('@/runtime/local-runtime-capabilities', async (importOriginal) => ({
...(await importOriginal<typeof LocalRuntimeCapabilitiesModule>()),
readLocalRuntimeCapabilitiesOrUnknown: () => [
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY
]
}))
import { useAppStore } from '@/store'
import { getDefaultSettings } from '../../../../shared/constants'
import { submitFolderWorkspaceCreate } from './folder-workspace-composer-submit'
afterEach(() => {
mocks.activateAndRevealFolderWorkspace.mockReset()
mocks.terminal.mockReset()
})
function create(
surface?: 'terminal',
createFolderWorkspace = vi.fn(async () => makeFolderWorkspace())
): Promise<boolean> {
return submitFolderWorkspaceCreate({
projectGroup: makeProjectGroup(),
name: '',
lastAutoName: '',
linkedWorkItem: null,
note: 'Fix the flaky checkout flow',
quickAgent: 'claude',
launchChoice: {
agent: 'claude',
route: surface ? 'terminal-tui' : 'structured-native-chat',
executionHostId: 'local',
requestedSurface: surface ?? 'chat'
},
autoRenameBranchFromWork: false,
agentCmdOverrides: {},
createFolderWorkspace,
onOpenChange: vi.fn()
})
}
describe('submitFolderWorkspaceCreate surface pick', () => {
it('starts the CLI in a terminal for a terminal pick even when chats are the default', async () => {
useAppStore.setState({
settings: { ...getDefaultSettings('/home/test'), experimentalNativeChat: true }
})
mocks.activateAndRevealFolderWorkspace.mockReturnValue({ primaryTabId: 'tab-1' })
mocks.terminal.mockReturnValue({ surface: { kind: 'host-published' } })
const materialization = Promise.withResolvers<ReturnType<typeof makeFolderWorkspace>>()
const creating = create(
undefined,
vi.fn(() => materialization.promise)
)
useAppStore.setState({
settings: { ...getDefaultSettings('/home/test'), experimentalNativeChat: false }
})
materialization.resolve(makeFolderWorkspace())
await creating
await vi.waitFor(() => expect(mocks.terminal).toHaveBeenCalledOnce())
expect(mocks.terminal.mock.calls[0]?.[0]).toMatchObject({
agent: 'claude',
prompt: 'Fix the flaky checkout flow',
agentSessionLaunchPlan: { route: 'terminal-tui' }
})
useAppStore.setState({
settings: { ...getDefaultSettings('/home/test'), experimentalNativeChat: true }
})
await create('terminal')
expect(mocks.terminal).toHaveBeenCalledOnce()
const startup = mocks.activateAndRevealFolderWorkspace.mock.calls.at(-1)?.[1]?.startup
expect(startup?.command).toContain('claude')
expect(startup?.command).toContain('Fix the flaky checkout flow')
})
})
@@ -15,7 +15,11 @@ import {
getLinkedItemDisplayName,
toFolderWorkspaceLinkedTask
} from './folder-workspace-composer-helpers'
import { planAgentSessionLaunch } from '@/lib/agent-session-launch-plan'
import {
planAgentSessionLaunch,
adoptAgentSessionLaunchVerdict
} from '@/lib/agent-session-launch-plan'
import type { AgentLaunchChoice } from '@/lib/agent-menu-selection'
import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id'
import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab'
import { getNewWorkspaceProjectGroupHostId } from '@/lib/new-workspace-project-options'
@@ -51,6 +55,7 @@ type SubmitFolderWorkspaceCreateParams = {
linkedTaskSourceContext?: TaskSourceContext | null
note: string
quickAgent: TuiAgent | null
launchChoice?: AgentLaunchChoice
autoRenameBranchFromWork: boolean | undefined
agentCmdOverrides: Record<string, string> | undefined
agentArgs?: string | null
@@ -71,6 +76,7 @@ export async function submitFolderWorkspaceCreate({
linkedTaskSourceContext,
note,
quickAgent,
launchChoice,
autoRenameBranchFromWork,
agentCmdOverrides,
agentArgs,
@@ -130,18 +136,28 @@ export async function submitFolderWorkspaceCreate({
// `startupPlan.draftPrompt` alone can't tell whether this launch has one.
const launchDraftPrompt =
quickAgent && linkedWorkItem ? resolveFolderWorkspaceLaunchDraft(linkedWorkItem, note) : null
const plan = quickAgent
? planAgentSessionLaunch(useAppStore.getState(), {
const launchRequest = quickAgent
? {
requestId: newAgentLaunchRequestId(),
agent: quickAgent,
workspace: {
kind: 'folder',
kind: 'folder' as const,
runtimeEnvironmentId,
executionHostId: getNewWorkspaceProjectGroupHostId(projectGroup)
},
prompt: launchDraftPrompt ?? note,
promptDelivery: launchDraftPrompt ? 'draft' : 'auto-submit'
})
promptDelivery: launchDraftPrompt ? ('draft' as const) : ('auto-submit' as const)
}
: null
// Creation retains its chosen route, but admission after materialization may fall back.
const plan = launchRequest
? launchChoice
? adoptAgentSessionLaunchVerdict({
...launchRequest,
route: launchChoice.route,
executionHostId: launchChoice.executionHostId
})
: planAgentSessionLaunch(useAppStore.getState(), launchRequest)
: null
const structuredLaunch = plan?.route === 'structured-native-chat'
// Why: the pending badge should only appear when the submitted prompt can
@@ -5,16 +5,22 @@ import { cleanup, fireEvent, render, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { StructuredLaunchState } from '@/lib/structured-agent-session-launch-registry'
import type { StructuredLaunchAttempt } from '@/lib/structured-agent-session-launch-request'
import type * as WorktreeFixtures from '@/store/slices/worktrees-slice-test-fixtures'
vi.mock('@/hooks/useDetectedAgents', () => ({
useDetectedAgents: () => ({ detectedIds: ['claude', 'codex'] })
}))
vi.mock('@/hooks/useShortcutLabel', () => ({ useOptionalShortcutLabel: () => null }))
vi.mock('@/store', () => {
vi.mock('@/store', async () => {
const { makeWorktree, TEST_REPO } = await vi.importActual<typeof WorktreeFixtures>(
'@/store/slices/worktrees-slice-test-fixtures'
)
const state = {
settings: { defaultTuiAgent: 'codex', disabledTuiAgents: [] },
worktreesByRepo: {},
repos: [],
worktreesByRepo: {
[TEST_REPO.id]: [makeWorktree({ id: 'worktree-1', repoId: TEST_REPO.id, hostId: 'local' })]
},
repos: [TEST_REPO],
activeWorktreeId: 'worktree-1',
tabsByWorktree: { 'worktree-1': [{ id: 'starting-tab', ptyId: null }] },
ptyIdsByTabId: {},
@@ -1,6 +1,7 @@
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { makeWorktree, TEST_REPO } from '@/store/slices/worktrees-slice-test-fixtures'
import { QuickLaunchAgentMenuItems } from './QuickLaunchButton'
const {
@@ -128,8 +129,10 @@ beforeEach(() => {
openSettingsTargetMock.mockReset()
storeState.settings.defaultTuiAgent = 'codex'
storeState.settings.disabledTuiAgents = []
storeState.worktreesByRepo = {}
storeState.repos = []
storeState.worktreesByRepo = {
[TEST_REPO.id]: [makeWorktree({ id: 'worktree-1', repoId: TEST_REPO.id, hostId: 'local' })]
}
storeState.repos = [TEST_REPO]
storeState.openSettingsPage = openSettingsPageMock
storeState.openSettingsTarget = openSettingsTargetMock
})
@@ -5,7 +5,8 @@ import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id'
import { captureNativeChatComposerFocusIntent } from '@/components/native-chat/use-native-chat-composer-reveal-focus'
import { newAgentPromptOutcome } from '@/lib/new-agent-prompt-outcome'
import { activeAgentNotesSendFailureMessage } from '@/lib/active-agent-note-send-result'
import { Settings as SettingsIcon, SquareTerminal } from 'lucide-react'
import { Settings as SettingsIcon } from 'lucide-react'
import { AgentTerminalLaunchButton } from '@/components/agent/AgentTerminalLaunchButton'
import {
DropdownMenuItem,
DropdownMenuSeparator,
@@ -329,13 +330,10 @@ function ChatRow({
}}
>
{children}
<button
<AgentTerminalLaunchButton
ref={cliButton}
type="button"
tabIndex={-1}
title={cliTitle}
aria-label={cliTitle}
className="-my-0.5 grid size-6 place-items-center rounded-md opacity-0 outline-none hover:bg-accent focus-visible:bg-accent focus-visible:opacity-100 [[data-highlighted]_&]:opacity-100"
className="hover:bg-accent focus-visible:bg-accent [[data-highlighted]_&]:opacity-100"
onKeyDown={(event) => {
// Stopped so the row's own Enter handling does not start a chat instead.
if (event.key === 'Enter' || event.key === ' ') {
@@ -353,9 +351,7 @@ function ChatRow({
}
}
}}
>
<SquareTerminal />
</button>
/>
</DropdownMenuItem>
)
}
@@ -21,6 +21,11 @@ export function chatUnavailableLabel(reason: ChatUnavailableReason): string {
)
case 'wsl':
return translate('auto.components.tab.bar.QuickLaunchButton.chatNotInWsl', 'No chat in WSL')
case 'subfolder':
return translate(
'auto.components.tab.bar.QuickLaunchButton.chatNotInSubfolder',
'No chat in a subfolder'
)
case 'not-on-host':
return translate(
'auto.components.tab.bar.QuickLaunchButton.chatNotOnHost',
@@ -0,0 +1,122 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { useAgentDetectionTargetForWorktree } from '@/hooks/useAgentDetectionTarget'
import { workspaceKindForWorktreeId } from '@/lib/agent-launch-route-input'
import {
makeFolderWorkspace,
makeWorktree,
TEST_REPO
} from '@/store/slices/worktrees-slice-test-fixtures'
import { makeProjectGroup } from '@/components/sidebar/folder-workspace-composer-submit-fixtures'
import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities'
import { getDefaultSettings } from '../../../../shared/constants'
import type { AppState } from '@/store/types'
import {
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY as CHAT,
STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY as CLIENT
} from '../../../../shared/protocol-version'
import { useAgentMenuObservation, useNewTabAgentMenuRows } from './use-new-tab-agent-menu-rows'
const initial = useAppStore.getState()
afterEach(() => {
cleanup()
useAppStore.setState(initial, true)
setLocalRuntimeCapabilitiesForTests(null)
})
it('settles ownerless continuation and plus inventory without borrowing agents, then recovers on hydration', async () => {
const group = makeProjectGroup()
const folder = makeFolderWorkspace({ id: 'f1', projectGroupId: group.id, folderPath: '/work' })
const repo = { ...TEST_REPO, id: 'r1', path: '/work/local' }
const cases: { worktreeId: string; rows: Partial<AppState> }[] = [
{
worktreeId: 'folder:f1',
rows: {
folderWorkspaces: [
{ ...folder, executionHostId: 'local' },
{ ...folder, executionHostId: 'ssh:other' }
]
}
},
{
worktreeId: 'folder:f1',
rows: {
folderWorkspaces: [folder],
projectGroups: [group],
repos: [repo, { ...repo, id: 'r2', path: '/work/remote', connectionId: 'other' }]
}
},
{ worktreeId: 'r1::/remote/restored', rows: { repos: [repo] } }
]
for (const { worktreeId, rows } of cases) {
const local = vi.fn<AppState['ensureDetectedAgents']>(async () => ['claude'])
const otherHost = vi.fn(async () => [])
const getStatus = vi.fn()
Object.assign(window, { api: { runtime: { getStatus } } })
setLocalRuntimeCapabilitiesForTests(worktreeId.startsWith('folder:') ? null : [CHAT, CLIENT])
useAppStore.setState({
...initial,
settings: { ...getDefaultSettings('/home/me'), experimentalNativeChat: true },
activeWorktreeId: worktreeId,
activeWorkspaceExecutionHostId: null,
folderWorkspaces: [],
projectGroups: [],
repos: [],
worktreesByRepo: {},
detectedWorktreesByRepo: {},
restoredRuntimeHostIdByWorkspaceSessionKey: {},
detectedAgentIds: ['gemini'],
remoteDetectedAgentIds: { other: ['codex'] },
ensureDetectedAgents: local,
ensureRemoteDetectedAgents: otherHost,
ensureRuntimeDetectedAgents: otherHost,
...rows
})
const hook = renderHook(() => {
const target = useAgentDetectionTargetForWorktree(worktreeId)
const workspace = { kind: workspaceKindForWorktreeId(worktreeId), worktreeId }
return {
form: useAgentMenuObservation({ workspace }, true, target),
plus: useNewTabAgentMenuRows(worktreeId, true, target)
}
})
expect(hook.result.current.form.detection).toMatchObject({ detectedIds: [], isLoading: false })
expect(hook.result.current.form.selectionFor('claude', 'terminal').ready).toBe(false)
for (const model of [hook.result.current.form.model, hook.result.current.plus]) {
expect(model.state).toBe('settled')
expect(
model.state === 'settled' &&
model.rows.every(
(row) =>
row.kind === 'chat-unavailable' &&
row.reason === 'check-failed' &&
row.executionHostId === undefined
)
).toBe(true)
}
for (const probe of [local, otherHost, getStatus]) {
expect(probe).not.toHaveBeenCalled()
}
local.mockImplementation(async () => {
useAppStore.setState({ detectedAgentIds: ['claude'] })
return ['claude']
})
await act(async () => {
setLocalRuntimeCapabilitiesForTests([CHAT, CLIENT])
useAppStore.setState({
folderWorkspaces: [{ ...folder, executionHostId: 'local' }],
repos: [repo],
worktreesByRepo: {
r1: [makeWorktree({ id: worktreeId, repoId: 'r1', hostId: 'local', path: '/work/local' })]
},
detectedAgentIds: null
})
})
expect(hook.result.current.form.detection.detectedIds).toEqual(['claude'])
expect(hook.result.current.form.selectionFor('claude').ready).toBe(true)
expect(
hook.result.current.plus.state === 'settled' && hook.result.current.plus.rows
).toContainEqual({ kind: 'chat', agent: 'claude', executionHostId: 'local' })
hook.unmount()
}
})
@@ -10,13 +10,10 @@ import { runtimeHostContactForEntry } from '../../../../shared/runtime-host-cont
import { isTuiAgent } from '../../../../shared/tui-agent-config'
import { isNativeChatEnabled } from '../../../../shared/structured-native-chat-launch-route'
import { useAppStore } from '@/store'
import {
structuredAgentLaunchSupport,
type AgentLaunchRoutingInput
} from '@/lib/agent-launch-routing'
import {
buildAgentLaunchRouteInput,
workspaceKindForWorktreeId
workspaceKindForWorktreeId,
type ProspectiveWorkspace
} from '@/lib/agent-launch-route-input'
import { useHostStructuredAgentsVersion } from '@/lib/chat-provider-agents'
import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner'
@@ -30,6 +27,7 @@ import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-rev
import { useHostStructuredAgentAvailability } from '@/runtime/use-host-structured-agent-availability'
import {
buildNewTabAgentMenuRows,
hostChatAnswer,
type HostChatAnswer,
type NewTabAgentMenuRow
} from '@/lib/new-tab-agent-menu-rows'
@@ -44,31 +42,9 @@ import {
type UseDetectedAgentsResult
} from '@/hooks/useDetectedAgents'
import { readDetectedAgentsForTarget } from '@/lib/agent-detection-target-inventory'
function hostChatAnswer(input: AgentLaunchRoutingInput): HostChatAnswer {
const support = structuredAgentLaunchSupport(input)
if (support.supported) {
return 'yes'
}
switch (support.blocker) {
case 'runtime-capability-unknown':
return 'not-yet-known'
case 'agent-without-structured-session':
return !input.hostStructuredAgents && input.hostCapabilities?.includes(CATALOG) !== false
? 'not-yet-known'
: 'not-on-host'
case 'remote-execution-host':
return 'ssh'
case 'project-runtime':
return 'wsl'
case 'runtime-capability':
case 'client-capability':
return 'no-chats'
case 'custom-start-directory':
case 'reused-terminal':
return 'yes'
}
}
import { resolveAgentMenuSelection } from '@/lib/agent-menu-selection'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type { AgentLaunchSurface } from '@/lib/agent-session-launch-plan'
export type NewTabAgentMenuModel =
| { state: 'loading'; placeholderCount: number }
@@ -87,21 +63,46 @@ function catalogUnlearned(
)
}
/** A launch whose workspace may not exist yet, or that starts in a directory of its own. */
export type AgentMenuRowsTarget = {
workspace: ProspectiveWorkspace
/** Only a terminal can start here, so chat rows say so instead of dropping the directory. */
launchCwd?: string | null
}
/** `detectionTarget` is the workspace's agent-inventory host, resolved by the caller. */
export function useNewTabAgentMenuRows(
worktreeId: string,
target: string | AgentMenuRowsTarget,
open: boolean,
detectionTarget: AgentDetectionTarget | undefined
): NewTabAgentMenuModel {
return useAgentMenuObservation(target, open, detectionTarget).model
}
export function useAgentMenuObservation(
target: string | AgentMenuRowsTarget | null,
open: boolean,
detectionTarget: AgentDetectionTarget | undefined
) {
const worktreeId = typeof target === 'string' ? target : target?.workspace.worktreeId
const launchCwd = typeof target === 'string' ? undefined : target?.launchCwd
const unresolvedTarget = Boolean(worktreeId) && detectionTarget === undefined
// Why: hidden worktrees stay mounted, so only the shown one holds its host's streams.
const worktreeShown = useAppStore((state) => state.activeWorktreeId === worktreeId)
const worktreeShown = useAppStore(
(state) => typeof target === 'string' && state.activeWorktreeId === worktreeId
)
// Why: acquiring while the workspace is shown means the first open already knows its agents.
const acquired = useDetectedAgents(open || worktreeShown ? detectionTarget : undefined)
// Closing the menu or leaving the workspace must not erase the known inventory.
const storedIds = useAppStore((state) =>
detectionTarget ? readDetectedAgentsForTarget(state, detectionTarget) : null
)
const detection = { ...acquired, detectedIds: acquired.detectedIds ?? storedIds }
// No owner means no agents to offer; a later owner publication resumes normal detection.
const detection = {
...acquired,
detectedIds: unresolvedTarget ? [] : (acquired.detectedIds ?? storedIds),
isLoading: !unresolvedTarget && acquired.isLoading
}
useHostStructuredAgentsVersion()
const localCapabilities = useSyncExternalStore(
subscribeLocalRuntimeCapabilityObservation,
@@ -109,7 +110,10 @@ export function useNewTabAgentMenuRows(
readLocalRuntimeCapabilityObservation
)
const settings = useAppStore((state) => state.settings)
const workspace = { kind: workspaceKindForWorktreeId(worktreeId), worktreeId }
const workspace =
typeof target === 'string'
? { kind: workspaceKindForWorktreeId(target), worktreeId: target }
: (target?.workspace ?? { kind: 'floating' as const })
const facts = useAppStore(
useShallow((state) => {
const input = buildAgentLaunchRouteInput(state, { agent: 'claude', workspace })
@@ -143,7 +147,7 @@ export function useNewTabAgentMenuRows(
input.hostCapabilities?.includes(AVAILABILITY) === true &&
Boolean(structuredAgentSessionTargetForHost(facts.executionHostId))
const availability = useHostStructuredAgentAvailability(
published && !plain
published && !plain && !unresolvedTarget
? {
executionHostId: facts.executionHostId,
runtimeId: facts.runtimeId,
@@ -194,7 +198,7 @@ export function useNewTabAgentMenuRows(
localCapabilities.state === 'known' ? localCapabilities.capabilities : null
)
useEffect(() => {
if ((!open && !worktreeShown) || plain || inventoryFailed) {
if ((!open && !worktreeShown) || plain || inventoryFailed || unresolvedTarget) {
return
}
if (localCapabilities.state === 'unlearned') {
@@ -211,6 +215,7 @@ export function useNewTabAgentMenuRows(
worktreeShown,
plain,
inventoryFailed,
unresolvedTarget,
localCapabilities,
hostCatalogPending,
localCatalogPending,
@@ -218,7 +223,8 @@ export function useNewTabAgentMenuRows(
input.hostCapabilities
])
// Only dependencies that can enable an action hold the cold fill; a failed inventory is terminal.
if (
const loading =
!unresolvedTarget &&
!inventoryFailed &&
((detection.detectedIds === null && detectionTarget !== undefined) ||
(!plain &&
@@ -227,29 +233,68 @@ export function useNewTabAgentMenuRows(
availability?.state === 'pending' ||
hostCatalogPending ||
localCatalogPending)))
) {
return {
state: 'loading',
placeholderCount: Math.max(chatAgents.length, detection.detectedIds?.length ?? 0, 2)
const answerFor = (agent: TuiAgent): HostChatAnswer => {
if (unresolvedTarget) {
return 'not-yet-known'
}
const route = buildAgentLaunchRouteInput(state, {
agent,
workspace,
...(launchCwd ? { tuiCustomization: { cwd: launchCwd } } : {})
})
const preliminary = hostChatAnswer(roster ? { ...route, hostStructuredAgents: roster } : route)
if (!roster || !['yes', 'not-yet-known', 'not-on-host'].includes(preliminary)) {
return preliminary
}
const verdict =
availability?.state === 'received' ? availability.byAgent.get(agent)?.state : undefined
// An agent the host could not check keeps this client's facts; the click's admission decides.
return !verdict || verdict === 'refused' ? 'not-on-host' : preliminary
}
const executionHostId = unresolvedTarget
? undefined
: parseExecutionHostId(facts.executionHostId)?.id
const rows = buildNewTabAgentMenuRows({
settings,
detectedOnHost: detection.detectedIds,
chatAgents,
hostChatAnswer: (agent) => {
const route = buildAgentLaunchRouteInput(state, { agent, workspace })
const preliminary = hostChatAnswer(
roster ? { ...route, hostStructuredAgents: roster } : route
)
if (!roster || !['yes', 'not-yet-known', 'not-on-host'].includes(preliminary)) {
return preliminary
hostChatAnswer: answerFor
}).map((row) => ({ ...row, executionHostId }))
const model: NewTabAgentMenuModel = loading
? {
state: 'loading',
placeholderCount: Math.max(chatAgents.length, detection.detectedIds?.length ?? 0, 2)
}
const verdict =
availability?.state === 'received' ? availability.byAgent.get(agent)?.state : undefined
// An agent the host could not check keeps this client's facts; the click's admission decides.
return !verdict || verdict === 'refused' ? 'not-on-host' : preliminary
}
}).map((row) => ({ ...row, executionHostId: parseExecutionHostId(facts.executionHostId)?.id }))
return { state: 'settled', rows, detection }
: { state: 'settled', rows, detection }
return {
model,
detection,
contextKey: JSON.stringify([workspace, launchCwd, facts]),
selectionFor: (agent: TuiAgent | null, surface?: AgentLaunchSurface) =>
resolveAgentMenuSelection({
agent,
surface,
settings,
executionHostId,
inventoryReady:
target === null ||
(detectionTarget !== undefined &&
(inventoryFailed ||
(detection.detectedIds !== null &&
(agent === null || detection.detectedIds.includes(agent))))),
answer:
target === null
? 'no-chats'
: inventoryFailed
? 'not-on-host'
: agent === null
? 'yes'
: answerFor(agent),
chatPending:
!unresolvedTarget &&
!inventoryFailed &&
(capabilityPending || availability?.state === 'pending' || hostCatalogPending),
plain
})
}
}
@@ -0,0 +1,53 @@
import { getNewWorkspaceProjectGroupHostId } from '@/lib/new-workspace-project-options'
import type { AgentDetectionTarget } from '@/hooks/useDetectedAgents'
import type { AgentMenuRowsTarget } from '@/components/tab-bar/use-new-tab-agent-menu-rows'
import type { ComposerModel } from './composer-model'
export type ComposerAgentRows = {
target: AgentMenuRowsTarget
detectionTarget: AgentDetectionTarget | undefined
}
/**
* The workspace the quick-create agent would start in, named the way the create submit names it.
* Null where that host is not known until creation (an ephemeral VM): the picker keeps its plain list.
*/
export function resolveComposerAgentRows(state: ComposerModel): ComposerAgentRows | null {
if (state.isProjectGroupTarget) {
const group = state.selectedProjectGroup
return group
? {
target: {
workspace: {
kind: 'folder',
runtimeEnvironmentId: state.folderTargetRuntimeEnvironmentId,
executionHostId: getNewWorkspaceProjectGroupHostId(group)
}
},
detectionTarget: state.folderTargetAgentDetectionTarget
}
: null
}
if (!state.resolvedRepoId || (state.ephemeralVmsEnabled && state.selectedEphemeralVmRecipeId)) {
return null
}
const runtimeEnvironmentId = state.selectedRepoRuntimeEnvironmentId?.trim() || null
const connectionId = state.selectedRepoConnectionId
const runTarget =
state.selectedWorkspaceTarget.status === 'ready' ? state.selectedWorkspaceTarget.target : null
return {
target: {
workspace: {
kind: state.selectedRepoIsGit ? 'git-worktree' : 'folder',
repoId: state.resolvedRepoId,
executionHostId: runTarget?.hostId ?? state.selectedRepoExecutionHostId ?? undefined
}
},
// Same inventory host the dialog's own agent list reads.
detectionTarget: runtimeEnvironmentId
? { kind: 'runtime', environmentId: runtimeEnvironmentId }
: typeof connectionId === 'string'
? { kind: 'ssh', connectionId }
: { kind: 'local' }
}
}
@@ -1,4 +1,5 @@
import type { ComposerModel } from './composer-model'
import type { ComposerAgentRows } from './composer-agent-rows-target'
export type ComposerCardSourceProps = Pick<
ComposerModel,
@@ -57,6 +58,7 @@ export type ComposerCardSourceProps = Pick<
>
export type ComposerCardActionProps = {
agentRows?: ComposerAgentRows | null
selectedRepo?: ComposerModel['selectedRepo'] | null
onRepoChange: ComposerModel['handleRepoChange']
onProjectChange: ComposerModel['handleProjectChange']
@@ -2,6 +2,7 @@ import { resolveComposerAttachmentTarget } from './composer-attachment-target'
import { getAttachmentLabel } from '@/lib/new-workspace'
import { getQuickComposerCreateDisabled } from '@/lib/new-workspace-create-gates'
import type { ComposerModel } from './composer-model'
import { resolveComposerAgentRows } from './composer-agent-rows-target'
import type { ComposerCardActionProps, ComposerCardSourceProps } from './composer-card-contract'
export function buildComposerCardProps(state: ComposerModel) {
@@ -204,6 +205,7 @@ export function buildComposerCardProps(state: ComposerModel) {
tuiAgent,
onTuiAgentChange: setTuiAgent,
detectedAgentIds: isProjectGroupTarget ? folderDetectedAgentIds : detectedAgentIds,
agentRows: resolveComposerAgentRows(state),
onOpenAgentSettings: handleOpenAgentSettings,
advancedOpen,
onToggleAdvanced: () => setAdvancedOpen((current) => !current),
@@ -62,5 +62,8 @@ export type ComposerSubmitModel = {
) => QuickSubmitSource | null
resetForNextCreate: () => void
submitQuick: (agent: TuiAgent | null, options?: QuickSubmitOptions) => Promise<void>
submitFolderTarget: (requestedAgent: TuiAgent | null) => Promise<void>
submitFolderTarget: (
requestedAgent: TuiAgent | null,
options?: QuickSubmitOptions
) => Promise<void>
}
@@ -1,3 +1,4 @@
import type { QuickSubmitOptions } from './quick-creation-request'
import type { ComposerModel } from './composer-model'
type FolderSubmitOrchestrationInput = Pick<
@@ -70,7 +71,7 @@ export function useFolderSubmitOrchestration(input: FolderSubmitOrchestrationInp
const { canResolveFolderSmartGitHubSubmit } = decisions
const submitFolderTarget = useCallback(
async (requestedAgent: TuiAgent | null): Promise<void> => {
async (requestedAgent: TuiAgent | null, options?: QuickSubmitOptions): Promise<void> => {
if (!selectedProjectGroup?.parentPath || folderCreateDisabled) {
return
}
@@ -108,6 +109,7 @@ export function useFolderSubmitOrchestration(input: FolderSubmitOrchestrationInp
linkedTaskSourceContext: taskSourceContext,
note,
quickAgent: agent,
launchChoice: options?.launchChoice,
autoRenameBranchFromWork: settings?.autoRenameBranchFromWork,
agentCmdOverrides: settings?.agentCmdOverrides,
agentArgs: agent
@@ -0,0 +1,123 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import { useAppStore } from '@/store'
import type { PreparedQuickSubmit } from './composer-submit-model'
import { useQuickCreationExecution } from './quick-creation-execution'
const background = vi.hoisted(() => vi.fn())
vi.mock('@/lib/worktree-creation-flow', () => ({ runBackgroundWorktreeCreation: background }))
afterEach(() => {
cleanup()
background.mockClear()
})
it('carries the accepted route across preparation and retains CLI startup/prompt under chat defaults', async () => {
const prepared: PreparedQuickSubmit = {
submitLinkedWorkItem: {
type: 'issue',
provider: 'linear',
number: 0,
url: '',
title: 'Typed note'
},
agent: 'opencode',
submitLinkedIssueNumber: null,
submitLinkedPR: null,
submitTitleName: null,
nameIsAutoManaged: false,
smartGitHubCreateNames: { workspaceName: 'fix', displayName: undefined },
workspaceName: 'fix',
nameWasGenerated: false,
smartSubmitBaseBranch: undefined,
submitCompareBaseRef: undefined,
submitPushTarget: undefined,
submitBranchNameOverride: undefined,
effectiveSetupDecision: 'skip',
hookPreparation: undefined,
linkedLinearIssue: undefined,
linkedLinearIssueWorkspaceId: undefined,
linkedLinearIssueOrganizationUrlKey: undefined,
effectiveBranchNameOverride: undefined,
submitBaseBranch: undefined,
createDisplayName: undefined,
pendingFirstAgentMessageRename: false,
trimmedNote: 'Fix the checkout'
}
const settings = { ...getDefaultSettings('/test'), experimentalNativeChat: true }
const original = useAppStore.getState().settings
const prepare = vi.fn<() => Promise<PreparedQuickSubmit | null>>()
const { result } = renderHook(() =>
useQuickCreationExecution({
clearNewWorkspaceDraft: vi.fn(),
createMultiple: false,
effectivePresetId: null,
ephemeralVmRecipes: [],
ephemeralVmsEnabled: false,
isSubmissionCancelled: () => false,
linkedGitLabIssue: null,
linkedGitLabMR: null,
normalizedSparseDirectories: [],
onCreated: vi.fn(),
parentWorktreeId: null,
persistDraft: false,
persistSetupAgentStartupPolicy: async () => true,
prepareQuickSubmit: prepare,
resetForNextCreate: vi.fn(),
resolvedInitialWorkspaceStatus: undefined,
selectedEphemeralVmRecipeId: null,
selectedRepoAgentLaunchFact: { kind: 'known', platform: 'darwin', hostKind: 'local' },
selectedRepoExecutionHostId: 'local',
selectedRepoIsGit: true,
selectedRepoIsRemote: false,
selectedRepoTarget: { kind: 'local' },
selectedRepoStartupShell: 'posix',
selectedWorkspaceTarget: { status: 'unavailable', reason: 'no-eligible-repo' },
settings,
sparseEnabled: false,
taskSourceContext: null,
telemetrySource: undefined
})
)
try {
for (const route of ['terminal-tui', 'structured-native-chat'] as const) {
const deferred = Promise.withResolvers<PreparedQuickSubmit>()
prepare.mockReturnValueOnce(deferred.promise)
let pending: Promise<void> = Promise.resolve()
act(() => {
pending = result.current.executeQuickCreation(
{ kind: 'none' },
'opencode',
'fix',
null,
'repo-1',
{ launchChoice: { agent: 'opencode', route } }
)
})
useAppStore.setState({
settings: { ...settings, experimentalNativeChat: route === 'terminal-tui' }
})
await act(async () => {
deferred.resolve(prepared)
await pending
})
const request = background.mock.calls.at(-1)?.[0]
expect(request).toMatchObject({
agent: 'opencode',
agentLaunchRoute: route,
quickPrompt: 'Fix the checkout'
})
if (route === 'terminal-tui') {
expect(request.startup).toMatchObject({
launchAgent: 'opencode',
command: expect.stringContaining('opencode')
})
expect(request.startup.command).toContain('Fix the checkout')
} else {
expect(request.startup).toBeUndefined()
}
}
} finally {
useAppStore.setState({ settings: original })
}
})
@@ -174,8 +174,10 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) {
const promptDelivery = quickDraftPrompt ? 'draft' : 'auto-submit'
// Why: the verdict is persisted on the request as data and re-entered once the worktree exists.
const agentLaunchRoute = agent
? resolveAgentSessionLaunchRoute(useAppStore.getState(), {
const agentLaunchRoute = !agent
? 'terminal-tui'
: (options?.launchChoice?.route ??
resolveAgentSessionLaunchRoute(useAppStore.getState(), {
agent,
workspace: {
kind: selectedRepoIsGit ? 'git-worktree' : 'folder',
@@ -186,8 +188,7 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) {
},
prompt: quickDraftPrompt ?? quickPrompt,
promptDelivery
})
: 'terminal-tui'
}))
const structuredLaunch = agentLaunchRoute === 'structured-native-chat'
const request = buildQuickCreationRequest({
@@ -4,6 +4,7 @@ import type { AgentStartupPlan } from '@/lib/tui-agent-startup'
import type { LinkedWorkItemSummary } from '@/lib/new-workspace'
import type { TaskSourceContext } from '../../../../shared/task-source-context'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type { AgentLaunchChoice } from '@/lib/agent-menu-selection'
import type { GitPushTarget } from '../../../../shared/worktree/types'
import type { SetupDecision } from '../../../../shared/worktree/create-types'
import { toFolderWorkspaceLinkedTask } from '@/components/sidebar/folder-workspace-composer-helpers'
@@ -56,6 +57,7 @@ export type QuickCreationRequestInput = {
export type QuickSubmitOptions = {
setupOnly?: boolean
launchChoice?: AgentLaunchChoice
}
export function buildQuickCreationRequest(
@@ -69,7 +69,7 @@ export function useQuickSubmitAction(input: QuickSubmitActionInput) {
const submitQuick = useCallback(
async (requestedAgent: TuiAgent | null, options?: QuickSubmitOptions): Promise<void> => {
if (isProjectGroupTarget) {
await submitFolderTarget(requestedAgent)
await submitFolderTarget(requestedAgent, options)
return
}
+3 -1
View File
@@ -3817,6 +3817,7 @@
"chatNotInstalled": "Not installed",
"chatNotOverSsh": "No chat over SSH",
"chatNotInWsl": "No chat in WSL",
"chatNotInSubfolder": "No chat in a subfolder",
"chatNotOnHost": "No chat on this host",
"chatCheckFailed": "Couldn’t check chat availability",
"chatLoading": "Checking chat support…",
@@ -19846,7 +19847,8 @@
"runTerminalUnavailable": "Run terminal is unavailable."
},
"agentCombobox": {
"clearSelection": "Clear agent selection"
"clearSelection": "Clear agent selection",
"startInTerminal": "Start {{value0}} in a terminal"
},
"newWorkspace": {
"agentOptional": "Agent [Optional]",
@@ -0,0 +1,47 @@
import type { TuiAgent } from '../../../shared/tui-agent'
import type { ExecutionHostId } from '../../../shared/execution-host'
import { isNativeChatDefaultFor } from '../../../shared/structured-native-chat-launch-route'
import type { AgentLaunchSurface } from './agent-session-launch-plan'
import type { AgentLaunchRoute } from './agent-launch-routing'
import type { HostChatAnswer, NewTabAgentMenuSettings } from './new-tab-agent-menu-rows'
/** Accepted renderer intent; route and explicitness remain distinct through admission. */
export type AgentLaunchChoice = {
agent: TuiAgent
route: AgentLaunchRoute
executionHostId?: ExecutionHostId
requestedSurface?: AgentLaunchSurface
}
export function resolveAgentMenuSelection(input: {
agent: TuiAgent | null
surface?: AgentLaunchSurface
settings: NewTabAgentMenuSettings | null | undefined
executionHostId?: ExecutionHostId
inventoryReady: boolean
answer: HostChatAnswer
chatPending: boolean
plain: boolean
}): { ready: boolean; choice?: AgentLaunchChoice; cli: boolean } {
const { agent, surface, answer } = input
if (agent === null) {
return { ready: true, cli: false }
}
const wantsChat = surface !== 'terminal' && isNativeChatDefaultFor(input.settings, agent)
const canBeChat = ['yes', 'not-yet-known', 'not-on-host'].includes(answer)
const pending =
(surface !== 'terminal' && input.settings == null) ||
(wantsChat && canBeChat && input.chatPending)
const route =
wantsChat && !pending && answer === 'yes' ? 'structured-native-chat' : 'terminal-tui'
return {
ready: input.inventoryReady && !pending,
cli: !pending && !input.plain && route === 'terminal-tui',
choice: {
agent,
route,
executionHostId: input.executionHostId,
...(surface ? { requestedSurface: route === 'terminal-tui' ? 'terminal' : 'chat' } : {})
}
}
}
@@ -34,10 +34,13 @@ import type { AgentLaunchRequestId } from '@/lib/agent-launch-request-id'
import { relearnHostStructuredAgents } from '@/runtime/host-structured-agents'
import { awaitLocalRuntimeCapabilities } from '@/runtime/local-runtime-capabilities'
/** Which surface the user picked for one launch: the agent's chat, or its CLI in a terminal. */
export type AgentLaunchSurface = 'chat' | 'terminal'
export type AgentSessionLaunchRequest = AgentLaunchRouteArgs & {
/** The user action this launch serves, minted where that action is handled. */
requestId: AgentLaunchRequestId
requestedSurface?: 'chat' | 'terminal'
requestedSurface?: AgentLaunchSurface
selectedExecutionHostId?: ExecutionHostId
resumeFrom?: StructuredAgentSessionResumeSource
onPromptDelivered?: () => void
@@ -52,7 +55,7 @@ export type AgentSessionLaunchRequest = AgentLaunchRouteArgs & {
*/
export type AgentSessionLaunchVerdict = {
route: AgentLaunchRoute
requestedSurface?: 'chat' | 'terminal'
requestedSurface?: AgentLaunchSurface
/** The user action this launch serves; a re-entry with this verdict is that same action. */
requestId: AgentLaunchRequestId
agent: TuiAgent
@@ -4,17 +4,22 @@ const launchAgentInNewTab = vi.hoisted(() => vi.fn())
const writeClipboardText = vi.hoisted(() => vi.fn(async () => undefined))
const ensureDetectedAgentsForWorktree = vi.hoisted(() => vi.fn())
const toast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn(), warning: vi.fn() }))
const store: { settings: { disabledTuiAgents: string[] } } = vi.hoisted(() => ({
settings: { disabledTuiAgents: [] }
}))
const store: { settings: { disabledTuiAgents: string[]; experimentalNativeChat?: boolean } } =
vi.hoisted(() => ({
settings: { disabledTuiAgents: [] }
}))
vi.mock('@/store', () => ({ useAppStore: { getState: () => store } }))
vi.mock('@/lib/launch-agent-in-new-tab', () => ({ launchAgentInNewTab }))
vi.mock('@/lib/agent-catalog', () => ({
getAgentCatalog: () => [{ id: 'claude', label: 'Claude' }],
getAgentLabel: (agent: string) => (agent === 'codex' ? 'Codex' : 'Claude')
}))
vi.mock('@/lib/agent-detection-target-inventory', () => ({ ensureDetectedAgentsForWorktree }))
vi.mock('sonner', () => ({ toast }))
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: vi.fn(async () => ({ supported: false, reason: 'wsl' }))
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, values?: Record<string, string>) =>
Object.entries(values ?? {}).reduce(
@@ -172,3 +177,51 @@ describe('launchAgentSessionContinuation', () => {
expect(toast.warning).not.toHaveBeenCalled()
})
})
it('retains the accepted route across detection, and explicit chat declines without the preference fallback', async () => {
const { launchAgentSessionContinuation } = await import('./launch-agent-session-continuation')
const { beginHostAdmittedStructuredLaunch } =
await import('./structured-agent-session-launch-admission')
for (const requestedSurface of [undefined, 'chat'] as const) {
const detected = Promise.withResolvers<string[]>()
ensureDetectedAgentsForWorktree.mockReturnValueOnce(detected.promise)
const pending = launchAgentSessionContinuation({
agent: 'claude',
prompt: 'continue',
worktreeId: 'wt-1',
groupId: 'group-1',
initialCwd: '/repo/.',
launchSource: 'sidebar',
launchChoice: {
agent: 'claude',
route: 'structured-native-chat',
executionHostId: 'local',
requestedSurface
}
})
store.settings.experimentalNativeChat = false
detected.resolve(['claude'])
await pending
const args = launchAgentInNewTab.mock.calls.at(-1)?.[0]
expect(args).toMatchObject({
initialCwd: '/repo/.',
groupId: 'group-1',
prompt: 'continue',
agentSessionLaunchPlan: { route: 'structured-native-chat', requestedSurface }
})
const fallback = vi.fn(() => ({ opened: true }))
const launch = beginHostAdmittedStructuredLaunch({
plan: args.agentSessionLaunchPlan,
hooks: {},
worktreeId: 'wt-1',
executionHostId: 'local',
target: { kind: 'local' },
openAdmitted: vi.fn(),
onHostDeclined: fallback
})
await expect(launch.settlement).resolves.toMatchObject({
kind: requestedSurface ? 'failed' : 'terminal'
})
expect(fallback).toHaveBeenCalledTimes(requestedSurface ? 0 : 1)
}
})
@@ -7,6 +7,12 @@ import { useAppStore } from '@/store'
import { isTuiAgentEnabled } from '../../../shared/tui-agent-selection'
import type { LaunchSource } from '../../../shared/telemetry-events'
import type { TuiAgent } from '../../../shared/tui-agent'
import {
adoptAgentSessionLaunchVerdict,
planAgentSessionLaunch
} from '@/lib/agent-session-launch-plan'
import type { AgentLaunchChoice } from '@/lib/agent-menu-selection'
import { workspaceKindForWorktreeId } from '@/lib/agent-launch-route-input'
import { translate } from '@/i18n/i18n'
type LaunchAgentSessionContinuationArgs = {
@@ -15,6 +21,7 @@ type LaunchAgentSessionContinuationArgs = {
worktreeId: string
groupId?: string | null
initialCwd?: string | null
launchChoice?: AgentLaunchChoice
launchSource: LaunchSource
}
@@ -65,24 +72,20 @@ export async function launchAgentSessionContinuation({
worktreeId,
groupId,
initialCwd,
launchChoice,
launchSource
}: LaunchAgentSessionContinuationArgs): Promise<boolean> {
if (!(await ensureAgentAvailable(agent, worktreeId))) {
return false
}
const label = getAgentLabel(agent)
// Why: the paste helper writes blind when the agent's composer was never observed, so a
// written prompt is not a delivered one. Claiming success there is how the whole handoff
// could vanish silently (#22479).
let deliveryUnconfirmed = false
const result = launchAgentInNewTab({
requestId: newAgentLaunchRequestId(),
const delivery = {
agent,
worktreeId,
...(groupId ? { groupId } : {}),
prompt,
promptDelivery: agent === 'claude' ? 'draft' : 'submit-after-ready',
promptDelivery: agent === 'claude' ? ('draft' as const) : ('submit-after-ready' as const),
launchSource,
...(initialCwd ? { initialCwd } : {}),
onPromptDeliveryUnconfirmed: () => {
@@ -101,7 +104,21 @@ export async function launchAgentSessionContinuation({
)
)
}
})
}
const request = {
...delivery,
requestId: newAgentLaunchRequestId(),
workspace: { kind: workspaceKindForWorktreeId(worktreeId), worktreeId },
tuiCustomization: { cwd: initialCwd }
}
// Capture preference routes before detection awaits; only deliberate picks demand a surface.
const plan = launchChoice
? adoptAgentSessionLaunchVerdict({ ...request, ...launchChoice, worktreeId })
: planAgentSessionLaunch(useAppStore.getState(), request)
if (!(await ensureAgentAvailable(agent, worktreeId))) {
return false
}
const result = launchAgentInNewTab({ ...delivery, agentSessionLaunchPlan: plan })
if (!result) {
notifyLaunchFailed(label)
return false
@@ -1,3 +1,8 @@
import {
structuredAgentLaunchSupport,
type AgentLaunchRoutingInput
} from '@/lib/agent-launch-routing'
import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY as CATALOG } from '../../../shared/protocol-version'
import type { GlobalSettings } from '../../../shared/global-settings-types'
import { isNativeChatProviderDisplayedOn } from '../../../shared/native-chat-providers'
import {
@@ -10,9 +15,48 @@ import { filterEnabledTuiAgents } from '../../../shared/tui-agent-selection'
import { orderTabLaunchAgents as orderAgents } from '@/components/tab-bar/tab-agent-launch-options'
/** What the workspace's machine says about opening an agent as a chat, installation aside. */
export type HostChatAnswer = 'yes' | 'not-yet-known' | 'ssh' | 'wsl' | 'not-on-host' | 'no-chats'
export type HostChatAnswer =
| 'yes'
| 'not-yet-known'
| 'ssh'
| 'wsl'
| 'subfolder'
| 'not-on-host'
| 'no-chats'
export type ChatUnavailableReason = 'check-failed' | 'not-installed' | 'ssh' | 'wsl' | 'not-on-host'
export function hostChatAnswer(input: AgentLaunchRoutingInput): HostChatAnswer {
const support = structuredAgentLaunchSupport(input)
if (support.supported) {
return 'yes'
}
switch (support.blocker) {
case 'runtime-capability-unknown':
return 'not-yet-known'
case 'agent-without-structured-session':
return !input.hostStructuredAgents && input.hostCapabilities?.includes(CATALOG) !== false
? 'not-yet-known'
: 'not-on-host'
case 'remote-execution-host':
return 'ssh'
case 'project-runtime':
return 'wsl'
case 'runtime-capability':
case 'client-capability':
return 'no-chats'
case 'custom-start-directory':
return 'subfolder'
case 'reused-terminal':
return 'yes'
}
}
export type ChatUnavailableReason =
| 'check-failed'
| 'not-installed'
| 'ssh'
| 'wsl'
| 'subfolder'
| 'not-on-host'
export type NewTabAgentMenuRow = (
| { kind: 'chat'; agent: TuiAgent }