mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 02:57:36 +00:00
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:
@@ -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()}
|
||||
|
||||
+94
-111
@@ -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' }
|
||||
})
|
||||
})
|
||||
|
||||
+77
-66
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user