The + menu can start a "(CLI)" agent as a chat (#34587)

* feat(tab-bar): "(CLI)" rows in the + menu offer a chat button

An agent set to open as a CLI shows as "Claude (CLI)" in the + menu with no
way to start it as a chat, while chat rows already carry a trailing terminal
button for the CLI. A CLI row whose agent can open as a chat on this host now
carries the mirror button: the row starts the CLI, the button (or ArrowRight,
then Enter) starts the chat.

* Test agent menu activation and alternate route work

* Use activated agent menu action and skip unused route work

* Fix agent menu lint and formatting

* Cover CLI row annotations in picker and search

* Share prepared agent menu answer context and verify route work

* Align agent menu fixture imports with static checks
This commit is contained in:
Brennan Benson
2026-10-27 21:23:03 -07:00
committed by GitHub
parent b4131707fd
commit c0a7aca7ee
10 changed files with 901 additions and 124 deletions
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ReactElement } from 'react'
import { AGENT_CATALOG } from '@/lib/agent-catalog'
@@ -32,7 +32,7 @@ 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: 'gemini', chat: true },
{ kind: 'cli', agent: 'grok' }
]
@@ -89,6 +89,7 @@ describe('AgentCombobox surface rows', () => {
fireEvent.click(screen.getByRole('combobox'))
expect(option('Gemini').textContent).toBe('Gemini (CLI)')
expect(within(option('Gemini')).queryByRole('button')).toBeNull()
expect(option('Grok').getAttribute('aria-disabled')).toBe('true')
expect(option('Grok').textContent).toContain('No chat in a subfolder')
fireEvent.click(option('Codex'))
@@ -126,6 +127,10 @@ describe('AgentCombobox surface rows', () => {
fireEvent.pointerMove(option('Claude'))
fireEvent.keyDown(input, { key: 'Enter' })
expect(onPick).toHaveBeenLastCalledWith('claude', 'chat')
fireEvent.click(screen.getByRole('combobox'))
fireEvent.click(option('Gemini'))
expect(onPick).toHaveBeenLastCalledWith('gemini', 'terminal')
})
it('shows a terminal pick as the CLI and keeps its button lit on reopen', () => {
@@ -0,0 +1,34 @@
import React from 'react'
import { MessageSquare, SquareTerminal } from 'lucide-react'
import { cn } from '@/lib/utils'
type AgentSurfaceLaunchButtonProps = Omit<React.ComponentProps<'button'>, 'children' | 'title'> & {
title: string
/** The surface this button opens instead of its row's own. */
surface: 'terminal' | 'chat'
}
/** The trailing control on an agent's row that starts it on the other surface: a chat row's CLI,
* or a CLI row's chat. Hidden until its row is highlighted; each list reveals it with its own selector. */
export function AgentSurfaceLaunchButton({
title,
surface,
className,
...props
}: AgentSurfaceLaunchButtonProps): 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}
>
{surface === 'chat' ? <MessageSquare /> : <SquareTerminal />}
</button>
)
}
@@ -1,31 +0,0 @@
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>
)
}
@@ -16,7 +16,7 @@ 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'
import { AgentSurfaceLaunchButton } from './AgentSurfaceLaunchButton'
/** The "+" menu's rows in the picker: a chat row picks the chat, its terminal button the CLI. */
export type AgentComboboxSurfaceRows = {
@@ -194,7 +194,8 @@ export function SurfaceAgentItems({
{chatUnavailableLabel(row.reason)}
</span>
) : rowOffersTerminal(model, row) ? (
<AgentTerminalLaunchButton
<AgentSurfaceLaunchButton
surface="terminal"
title={terminalPickTitle(agentLabel)}
aria-pressed={pressed}
className={cn(
@@ -1,7 +1,14 @@
// @vitest-environment happy-dom
import type { ReactNode } from 'react'
import { cleanup, fireEvent, render, renderHook } from '@testing-library/react'
import {
act,
cleanup,
fireEvent,
render as renderElement,
renderHook,
screen
} from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { StructuredLaunchState } from '@/lib/agent-session/launch/structured-agent-session-launch-registry'
import type { StructuredLaunchAttempt } from '@/lib/structured-agent-session-launch-request'
@@ -23,6 +30,11 @@ vi.mock('@/store', async () => {
repos: [TEST_REPO],
activeWorktreeId: 'worktree-1',
tabsByWorktree: { 'worktree-1': [{ id: 'starting-tab', ptyId: null }] },
agentStatusByPaneKey: {},
agentStatusEpoch: 0,
unifiedTabsByWorktree: {},
terminalLayoutsByTabId: {},
runtimePaneTitlesByTabId: {},
ptyIdsByTabId: {},
openSettingsPage: vi.fn(),
openSettingsTarget: vi.fn()
@@ -39,19 +51,6 @@ vi.mock('@/lib/agent-catalog', () => ({
],
AgentIcon: ({ agent }: { agent: string }) => <span>{agent}</span>
}))
vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenuItem: ({
children,
disabled,
title,
onSelect
}: { children: ReactNode } & DivProps) => (
<div aria-disabled={disabled ? 'true' : 'false'} title={title} onClick={onSelect}>
{children}
</div>
),
DropdownMenuShortcut: ({ children }: { children: ReactNode }) => <span>{children}</span>
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, values?: Record<string, string>) =>
fallback.replace('{{value0}}', values?.value0 ?? '')
@@ -63,9 +62,19 @@ vi.mock('sonner', () => ({
toast: { error: toastError, message: toastMessage, success: vi.fn() }
}))
vi.mock('@/lib/launch-agent-in-new-tab', () => ({ launchAgentInNewTab: launchMock }))
vi.mock('@/components/sidebar/useWorktreeAgentRows', () => ({ useWorktreeAgentRows: () => [] }))
vi.mock('@/runtime/structured-chat-name-observation', () => ({
useStructuredChatTabNames: () => new Map(),
getStructuredChatTabNames: () => new Map()
}))
vi.mock('@/hooks/use-now', () => ({ useNow: () => 0 }))
type DivProps = { disabled?: boolean; title?: string; onSelect?: () => void }
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { ReviewNotesSendMenuContent } from '@/components/editor/ReviewNotesSendMenuContent'
import { QuickLaunchAgentMenuItems, ProjectedAgentMenuItems } from './QuickLaunchButton'
import { useTabBarCreateMenuController } from './use-tab-bar-create-menu-controller'
import * as menuRows from './use-new-tab-agent-menu-rows'
@@ -76,6 +85,272 @@ import {
const WORKTREE_ID = 'worktree-1'
function render(children: ReactNode, defaultOpen = true): ReturnType<typeof renderElement> {
return renderElement(
<DropdownMenu defaultOpen={defaultOpen} modal={false}>
<DropdownMenuTrigger>Agents</DropdownMenuTrigger>
<DropdownMenuContent>{children}</DropdownMenuContent>
</DropdownMenu>
)
}
function reopenMenu(): void {
fireEvent.pointerDown(screen.getByRole('button', { name: 'Agents' }), {
button: 0,
ctrlKey: false
})
}
function choiceModel(kind: 'cli' | 'chat'): menuRows.NewTabAgentMenuModel {
return {
state: 'settled',
rows: [
kind === 'cli'
? { kind, agent: 'codex', chat: true, executionHostId: 'runtime:server' }
: { kind, agent: 'codex', executionHostId: 'runtime:server' }
],
detection: {
detectedIds: ['codex'],
detectionFailed: false,
needsServerUpdate: false,
isLoading: false,
isRefreshing: false,
refresh: async () => ['codex']
}
}
}
function renderChoice(
kind: 'cli' | 'chat',
disabled = false,
defaultOpen = true
): { row: HTMLElement; button: HTMLElement } {
vi.spyOn(menuRows, 'useNewTabAgentMenuRows').mockReturnValue(choiceModel(kind))
launchMock.mockReset().mockReturnValue({ surface: { kind: 'host-published' } })
render(
<QuickLaunchAgentMenuItems
worktreeId={WORKTREE_ID}
groupId="group-1"
onFocusTerminal={vi.fn()}
disabled={disabled}
/>,
defaultOpen
)
if (!defaultOpen) {
reopenMenu()
}
return {
row: screen.getByRole('menuitem', {
name: kind === 'cli' ? 'Launch Codex in a new terminal' : 'Start a chat with Codex'
}),
button: screen.getByRole('button', {
name: kind === 'cli' ? 'Start a chat with Codex' : 'Launch Codex in a new terminal'
})
}
}
function hover(button: HTMLElement): void {
const matches = button.matches.bind(button)
vi.spyOn(button, 'matches').mockImplementation(
(selector) => selector === ':hover' || matches(selector)
)
}
function expectLaunch(surface: 'chat' | 'terminal'): void {
expect(launchMock).toHaveBeenCalledOnce()
expect(launchMock.mock.calls[0][0]).toMatchObject({
requestedSurface: surface,
selectedExecutionHostId: 'runtime:server'
})
expect(screen.queryByRole('menu')).toBeNull()
}
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it.each(['cli', 'chat'] as const)(
'activates the focused %s control despite parked alternate hover (R1)',
async (kind) => {
for (const action of [
'return-left',
'return-up',
'space',
'row-click',
'button-click',
'button-enter',
'button-space',
'pointer'
] as const) {
const { row, button } = renderChoice(kind)
if (action !== 'button-click') {
hover(button)
}
act(() => row.focus())
if (action === 'return-left' || action === 'return-up' || action.startsWith('button-')) {
fireEvent.keyDown(row, { key: 'ArrowRight' })
expect(document.activeElement).toBe(button)
}
if (action === 'return-left') {
fireEvent.keyDown(button, { key: 'ArrowLeft' })
}
if (action === 'return-up') {
fireEvent.keyDown(button, { key: 'ArrowDown' })
await vi.waitFor(() =>
expect(document.activeElement?.textContent).toContain('Agent settings')
)
fireEvent.keyDown(document.activeElement!, { key: 'ArrowUp' })
await vi.waitFor(() => expect(document.activeElement).toBe(row))
}
if (action === 'row-click') {
act(() => {
button.focus()
row.click()
})
} else if (action === 'button-click') {
act(() => {
row.focus()
button.click()
})
} else if (action === 'pointer') {
fireEvent.pointerDown(button, { pointerType: 'mouse' })
fireEvent.pointerUp(button, { pointerType: 'mouse' })
fireEvent.click(button)
} else {
const alternate = action.startsWith('button-')
if (!alternate) {
expect(document.activeElement).toBe(row)
}
fireEvent.keyDown(alternate ? button : row, {
key: action.endsWith('space') ? ' ' : 'Enter'
})
}
expectLaunch(
action.startsWith('button-') || action === 'pointer'
? kind === 'cli'
? 'chat'
: 'terminal'
: kind === 'cli'
? 'terminal'
: 'chat'
)
cleanup()
}
}
)
it.each(['cli', 'chat'] as const)(
'keeps %s drag release exact-once, expires unused release, and respects disabled',
async (kind) => {
for (const alternate of [true, false]) {
const { row, button } = renderChoice(kind, false, false)
hover(button)
fireEvent.pointerUp(alternate ? button : row, { pointerType: 'mouse' })
expectLaunch(
alternate ? (kind === 'cli' ? 'chat' : 'terminal') : kind === 'cli' ? 'terminal' : 'chat'
)
cleanup()
}
for (const deferred of [false, true]) {
const { row, button } = renderChoice(kind)
hover(button)
act(() => {
fireEvent.pointerDown(row, { pointerType: 'mouse' })
fireEvent.pointerUp(button, { pointerType: 'mouse' })
expect(launchMock).not.toHaveBeenCalled()
if (!deferred) {
row.click()
}
})
if (deferred) {
await act(async () => {
await Promise.resolve()
row.click()
})
}
expectLaunch(kind === 'cli' ? 'terminal' : 'chat')
cleanup()
}
const { row, button } = renderChoice(kind, true)
hover(button)
fireEvent.pointerUp(button, { pointerType: 'mouse' })
fireEvent.click(button)
fireEvent.keyDown(button, { key: 'Enter' })
fireEvent.keyDown(button, { key: ' ' })
act(() => row.click())
expect(launchMock).not.toHaveBeenCalled()
expect(screen.queryByRole('menu')).not.toBeNull()
}
)
it.each(['chat', 'cli'] as const)(
'preserves notes inputs and failure outcomes on the %s row chat action',
async (kind) => {
vi.spyOn(menuRows, 'useNewTabAgentMenuRows').mockReturnValue(choiceModel(kind))
for (const outcome of ['delivered', 'unconfirmed', 'notified', 'no-command'] as const) {
const onPromptDelivered = vi.fn()
const onPromptHandedOff = vi.fn()
launchMock.mockReset().mockReturnValue(
outcome === 'no-command'
? null
: {
surface: { kind: 'host-published' },
promptDeliveryResult: Promise.resolve({
delivered: outcome === 'delivered',
failureNotified: outcome === 'notified',
unconfirmed: outcome === 'unconfirmed'
})
}
)
toastError.mockClear()
const prompt = 'Review notes\nsrc/example.ts:5 — keep this context'
render(
<ReviewNotesSendMenuContent
worktreeId={WORKTREE_ID}
groupId="group-1"
prompt={prompt}
onPromptDelivered={onPromptDelivered}
onPromptHandedOff={onPromptHandedOff}
/>
)
fireEvent.click(
screen.getByRole(kind === 'chat' ? 'menuitem' : 'button', {
name: 'Start a chat with Codex'
})
)
expectLaunch('chat')
expect(launchMock.mock.calls[0][0]).toMatchObject({
prompt,
promptDelivery: 'submit-after-ready',
launchSource: 'notes_send',
promptKeptByCaller: true,
groupId: 'group-1',
onPromptDelivered
})
if (outcome === 'no-command') {
expect(onPromptHandedOff).not.toHaveBeenCalled()
} else {
expect(onPromptHandedOff).toHaveBeenCalledOnce()
await expect(onPromptHandedOff.mock.calls[0][0]).resolves.toMatchObject({
delivered: outcome === 'delivered'
})
}
expect(toastError).toHaveBeenCalledTimes(
outcome === 'unconfirmed' || outcome === 'no-command' ? 1 : 0
)
if (outcome === 'unconfirmed') {
expect(toastError).toHaveBeenCalledWith(expect.stringContaining("Couldn't send to"), {
description: expect.stringContaining(
"Orca couldn't confirm your message reached the agent"
)
})
}
cleanup()
}
}
)
it('gives static/search no agent actions while loading, retaining settings and generic terminal', () => {
const model = vi
.spyOn(menuRows, 'useNewTabAgentMenuRows')
@@ -189,10 +464,10 @@ function registerLaunch(
} satisfies StructuredLaunchState)
}
function agentRowDisabled(label: string): string | null | undefined {
return document
.querySelector(`[title="Launch ${label} in a new terminal"]`)
?.getAttribute('aria-disabled')
function agentRowDisabled(label: string): string {
const row = screen.getByTitle(`Launch ${label} in a new terminal`)
expect(row.getAttribute('role')).toBe('menuitem')
return row.getAttribute('aria-disabled') ?? 'false'
}
describe('QuickLaunchAgentMenuItems launches', () => {
@@ -200,7 +475,6 @@ describe('QuickLaunchAgentMenuItems launches', () => {
localStorage.clear()
resetStructuredAgentLaunchRegistryForTests()
})
afterEach(cleanup)
it('keeps every agent launchable while chats start, fail or retry', () => {
registerLaunch('claude', 'pending')
@@ -244,7 +518,8 @@ describe('QuickLaunchAgentMenuItems launches', () => {
)
const codexRow = document.querySelector('[title="Launch Codex in a new terminal"]')!
fireEvent.click(codexRow)
fireEvent.click(codexRow)
reopenMenu()
fireEvent.click(document.querySelector('[title="Launch Codex in a new terminal"]')!)
const requestIds = launchMock.mock.calls.map(([args]) => args.requestId)
expect(requestIds).toHaveLength(2)
@@ -254,7 +529,7 @@ describe('QuickLaunchAgentMenuItems launches', () => {
// Why: the notes menu holds what it sent until this result, so a second send leaves them out.
it('opens the CLI once with notes from the row button, then the row itself starts a chat', async () => {
const model = vi.spyOn(menuRows, 'useNewTabAgentMenuRows').mockReturnValueOnce({
vi.spyOn(menuRows, 'useNewTabAgentMenuRows').mockReturnValue({
state: 'settled',
rows: [{ kind: 'chat', agent: 'codex', executionHostId: 'runtime:server' }],
detection: {
@@ -284,7 +559,6 @@ describe('QuickLaunchAgentMenuItems launches', () => {
onPromptHandedOff={onPromptHandedOff}
/>
)
model.mockRestore()
const terminalButton = document.querySelector<HTMLButtonElement>(
'button[title="Launch Codex in a new terminal"]'
)!
@@ -303,6 +577,7 @@ describe('QuickLaunchAgentMenuItems launches', () => {
expect(launchMock.mock.calls.at(-1)?.[0].groupId).toBe('group-1')
expect(onPromptHandedOff).toHaveBeenCalledOnce()
await expect(onPromptHandedOff.mock.calls[0][0]).resolves.toEqual({ delivered: true })
reopenMenu()
fireEvent.click(document.querySelector('[title="Start a chat with Codex"]')!)
expect(launchMock).toHaveBeenCalledTimes(2)
expect(launchMock.mock.calls[1][0]).toMatchObject({
@@ -6,7 +6,7 @@ import { captureNativeChatComposerFocusIntent } from '@/components/native-chat/u
import { newAgentPromptOutcome } from '@/lib/new-agent-prompt-outcome'
import { activeAgentNotesSendFailureMessage } from '@/lib/active-agent-note-send-result'
import { Settings as SettingsIcon } from 'lucide-react'
import { AgentTerminalLaunchButton } from '@/components/agent/AgentTerminalLaunchButton'
import { AgentSurfaceLaunchButton } from '@/components/agent/AgentSurfaceLaunchButton'
import {
DropdownMenuItem,
DropdownMenuSeparator,
@@ -260,13 +260,20 @@ export function ProjectedAgentMenuItems({
) : null}
</>
)
const other = otherSurfaceRow(row, detectedIds)
return (
<React.Fragment key={rowId(row)}>
{startsCliGroup ? <DropdownMenuSeparator /> : null}
{row.kind === 'chat' && detectedIds?.includes(agent) ? (
<ChatRow row={row} label={label} disabled={disabled} runLaunch={runLaunch}>
{other ? (
<SurfaceChoiceRow
row={row}
other={other}
label={label}
disabled={disabled}
runLaunch={runLaunch}
>
{rowContent}
</ChatRow>
</SurfaceChoiceRow>
) : (
<DropdownMenuItem
variant="launch"
@@ -285,51 +292,80 @@ export function ProjectedAgentMenuItems({
)
}
// Why: the row starts the chat; its trailing button (or ArrowRight, then Enter) opens the CLI.
function ChatRow({
/** The same agent on the other surface: a chat's detected CLI, or a CLI-set agent's chat. */
function otherSurfaceRow(
row: Exclude<NewTabAgentMenuRow, { kind: 'chat-unavailable' }>,
detectedIds: readonly TuiAgent[] | null
): NewTabAgentMenuRow | null {
const { agent, executionHostId } = row
if (row.kind === 'chat') {
return detectedIds?.includes(agent) ? { kind: 'cli', agent, executionHostId } : null
}
return row.kind === 'cli' && row.chat ? { kind: 'chat', agent, executionHostId } : null
}
// Why: the row starts its own surface; its trailing button (or ArrowRight, then Enter) opens the other.
function SurfaceChoiceRow({
row,
other,
label,
disabled,
runLaunch,
children
}: {
row: Extract<NewTabAgentMenuRow, { kind: 'chat' }>
row: NewTabAgentMenuRow
other: NewTabAgentMenuRow
label: string
disabled: boolean
runLaunch: (row: NewTabAgentMenuRow) => void
children: React.ReactNode
}): React.JSX.Element {
const cliButton = useRef<HTMLButtonElement>(null)
const cliRow: NewTabAgentMenuRow = { ...row, kind: 'cli' }
const cliTitle = rowTitle(cliRow, label)
const chatTitle = rowTitle(row, label)
const otherButton = useRef<HTMLButtonElement>(null)
const releaseTarget = useRef<{ event: PointerEvent; target: EventTarget } | null>(null)
const title = rowTitle(row, label)
return (
<DropdownMenuItem
variant="launch"
disabled={disabled}
title={chatTitle}
aria-label={chatTitle}
// Radix selects the row on nested clicks or drag release; pointer/focus on the trailing button chooses CLI launch.
onSelect={() => {
const button = cliButton.current
const opensCli =
button !== null && (button.matches(':hover') || button === document.activeElement)
runLaunch(opensCli ? cliRow : row)
title={title}
aria-label={title}
onPointerUp={(event) => {
// Radix synchronously clicks the row on drag release, discarding the release target.
const release = { event: event.nativeEvent, target: event.target }
releaseTarget.current = release
queueMicrotask(() => {
if (releaseTarget.current === release) {
releaseTarget.current = null
}
})
}}
onClick={(event) => {
const release = releaseTarget.current
releaseTarget.current = null
if (disabled) {
return
}
const target =
event.target === event.currentTarget && release && release.event.eventPhase !== Event.NONE
? release.target
: event.target
runLaunch(target instanceof Node && otherButton.current?.contains(target) ? other : row)
}}
onKeyDown={(event) => {
if (event.key === 'ArrowRight') {
event.preventDefault()
cliButton.current?.focus()
otherButton.current?.focus()
}
}}
>
{children}
<AgentTerminalLaunchButton
ref={cliButton}
title={cliTitle}
<AgentSurfaceLaunchButton
ref={otherButton}
surface={other.kind === 'chat' ? 'chat' : 'terminal'}
title={rowTitle(other, label)}
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.
// Stopped so the row's own Enter handling does not start the row's surface instead.
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
event.stopPropagation()
@@ -103,12 +103,20 @@ describe('tab agent launch options', () => {
it('projects the same CLI label, unavailable reason and stable chat ID as static rows', () => {
const unavailable = buildTabAgentLaunchOptionsFromRows([
{ kind: 'chat-unavailable', agent: 'claude', reason: 'ssh' },
{ kind: 'cli', agent: 'claude' }
{ kind: 'cli', agent: 'claude' },
{ kind: 'cli', agent: 'codex', chat: true }
])
expect(unavailable).toMatchObject([
{ id: 'chat:claude', label: 'Claude', reason: 'No chat over SSH', disabled: true },
{ id: 'cli:claude', label: 'Claude (CLI)', disabled: false }
{ id: 'cli:claude', label: 'Claude (CLI)', disabled: false },
{ id: 'cli:codex', label: 'Codex (CLI)', disabled: false }
])
// Typing the agent's name and pressing Enter launches its CLI, not the disabled chat.
expect(findMatchingTabAgentLaunchOptions('claude', unavailable)[0]?.id).toBe('cli:claude')
expect(
findMatchingTabAgentLaunchOptions('codex', unavailable).map((option) => [
option.id,
option.row?.kind
])
).toEqual([['cli:codex', 'cli']])
})
@@ -2,7 +2,25 @@
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import type { TuiAgent } from '../../../../shared/tui-agent'
import { useAgentDetectionTargetForWorktree } from '@/hooks/useAgentDetectionTarget'
import { getDefaultSettings } from '../../../../shared/constants'
import {
makeFolderWorkspace,
makeWorktree,
TEST_REPO
} from '@/store/slices/worktrees-slice-test-fixtures'
import type * as RouteInput from '@/lib/agent-launch-route-input'
import type * as ChatProgram from '@/lib/host-chat-program'
import type * as MenuRows from '@/lib/new-tab-agent-menu-rows'
import type * as ZustandShallow from 'zustand/react/shallow'
import type * as HostAgentSettingsTransport from '@/components/settings/agent-launch-settings-transport'
import type { RuntimeEnvironmentStatus } from '../../../../shared/runtime-host-status'
import { useTabBarCreateMenuController } from './use-tab-bar-create-menu-controller'
import {
buildTabAgentLaunchOptionsFromRows,
findMatchingTabAgentLaunchOptions
} from './tab-agent-launch-options'
import {
readLocalRuntimeCapabilitiesOrUnknown,
setLocalRuntimeCapabilitiesForTests
@@ -20,8 +38,59 @@ const mocks = vi.hoisted(() => ({
catalog: vi.fn(),
failed: vi.fn(),
load: vi.fn(),
detection: vi.fn()
detection: vi.fn(),
realRoutes: false,
routes: Array<RouteInput.AgentLaunchRouteArgs>(),
answers: Array<Parameters<typeof MenuRows.hostChatAnswer>[0]>(),
selectors: 0,
records: 0,
lists: 0,
metadata: 0,
countedLists: new WeakSet<object>(),
program:
vi.fn<
(
...args: Parameters<typeof ChatProgram.readChatProgram>
) => ChatProgram.ChatProgramObservation | undefined
>()
}))
vi.mock('zustand/react/shallow', async (importOriginal) => {
const actual = await importOriginal<typeof ZustandShallow>()
return {
useShallow: <S, U>(selector: (state: S) => U) =>
actual.useShallow((state: S) => {
mocks.selectors++
return selector(state)
})
}
})
vi.mock('@/lib/new-tab-agent-menu-rows', async (importOriginal) => {
const actual = await importOriginal<typeof MenuRows>()
return {
...actual,
buildNewTabAgentMenuRows: (...args: Parameters<typeof actual.buildNewTabAgentMenuRows>) => {
const rows = actual.buildNewTabAgentMenuRows(...args)
mocks.metadata += rows.length
return rows
},
hostChatAnswer: (input: Parameters<typeof actual.hostChatAnswer>[0]) => {
mocks.answers.push(input)
if (mocks.realRoutes) {
if (input.hostCapabilities) {
counted(input.hostCapabilities)
}
if (input.hostStructuredAgents) {
counted(input.hostStructuredAgents)
}
}
return actual.hostChatAnswer(input)
}
}
})
vi.mock('@/components/settings/agent-launch-settings-transport', async (importOriginal) => {
const actual = await importOriginal<typeof HostAgentSettingsTransport>()
return { ...actual, readHostAgentLaunchSettings: vi.fn().mockResolvedValue(null) }
})
vi.mock('@/runtime/runtime-rpc-subscribe', () => ({ subscribeRuntimeRpc: mocks.subscribe }))
vi.mock('@/lib/structured-agent-session-host-admission', () => ({
askHostCreateSupport: mocks.support
@@ -39,32 +108,61 @@ vi.mock('@/runtime/host-structured-agents', () => ({
hostStructuredAgentsVersion: () => 0,
subscribeHostStructuredAgents: () => () => {}
}))
vi.mock('@/lib/agent-launch-route-input', () => ({
workspaceKindForWorktreeId: () => 'git-worktree',
resolveAgentLaunchHost: () => ({
executionHostId: 'local',
hostCapabilities:
readLocalRuntimeCapabilitiesOrUnknown() === null
? null
: [CHAT, CLIENT, CATALOG, ...(mocks.publishing ? [AVAILABILITY] : [])]
}),
buildAgentLaunchRouteInput: (_state: unknown, { agent }: { agent: string }) => ({
agent,
executionHostId: 'local',
hostCapabilities:
readLocalRuntimeCapabilitiesOrUnknown() === null
? null
: [CHAT, CLIENT, CATALOG, ...(mocks.publishing ? [AVAILABILITY] : [])],
clientCapabilities: [CHAT, CLIENT],
hostStructuredAgents: ['claude', 'codex', 'opencode']
})
}))
import { useAgentMenuObservation, useNewTabAgentMenuRows } from './use-new-tab-agent-menu-rows'
vi.mock('@/lib/agent-launch-route-input', async (importOriginal) => {
const actual = await importOriginal<typeof RouteInput>()
return {
...actual,
resolveAgentLaunchHost: (...args: Parameters<typeof actual.resolveAgentLaunchHost>) =>
mocks.realRoutes
? actual.resolveAgentLaunchHost(...args)
: {
executionHostId: 'local',
hostCapabilities:
readLocalRuntimeCapabilitiesOrUnknown() === null
? null
: [CHAT, CLIENT, CATALOG, ...(mocks.publishing ? [AVAILABILITY] : [])]
},
buildAgentLaunchRouteInput: (...args: Parameters<typeof actual.buildAgentLaunchRouteInput>) => {
const [state, { agent }] = args
if (mocks.realRoutes) {
mocks.routes.push(args[1])
return actual.buildAgentLaunchRouteInput(state, args[1])
}
return {
agent,
executionHostId: 'local',
hostCapabilities:
readLocalRuntimeCapabilitiesOrUnknown() === null
? null
: [CHAT, CLIENT, CATALOG, ...(mocks.publishing ? [AVAILABILITY] : [])],
clientCapabilities: [CHAT, CLIENT],
hostStructuredAgents: ['claude', 'codex', 'opencode']
}
}
}
})
vi.mock('@/lib/host-chat-program', async (importOriginal) => {
const actual = await importOriginal<typeof ChatProgram>()
return {
...actual,
readChatProgram: (...args: Parameters<typeof actual.readChatProgram>) =>
mocks.program(...args) ?? actual.readChatProgram(...args)
}
})
import {
useAgentMenuObservation,
useNewTabAgentMenuRows,
type AgentMenuRowsTarget,
type NewTabAgentMenuModel
} from './use-new-tab-agent-menu-rows'
const actualRouteInput = await vi.importActual<typeof RouteInput>('@/lib/agent-launch-route-input')
const initial = useAppStore.getState()
beforeEach(() => {
mocks.support.mockReset()
mocks.subscribe.mockReset().mockResolvedValue({ unsubscribe: () => {} })
mocks.publishing = false
mocks.realRoutes = false
mocks.program.mockReset()
mocks.catalog
.mockReset()
.mockReturnValue(['claude', 'codex', 'opencode'].map((agent) => ({ agent, capabilities: {} })))
@@ -88,6 +186,238 @@ beforeEach(() => {
})
afterEach(cleanup)
function counted<T extends readonly unknown[]>(values: T): T {
if (mocks.countedLists.has(values)) {
return values
}
mocks.countedLists.add(values)
values.forEach((value, index) =>
Object.defineProperty(values, index, {
get: () => {
mocks.lists++
return value
}
})
)
return values
}
function resetWork(): void {
mocks.routes.length =
mocks.answers.length =
mocks.selectors =
mocks.records =
mocks.lists =
mocks.metadata =
0
mocks.program.mockClear()
mocks.catalog.mockClear()
}
function work() {
return {
routes: mocks.routes.length,
selectors: mocks.selectors,
records: mocks.records,
answers: mocks.answers.length,
programs: mocks.program.mock.calls.length,
lists: mocks.lists,
catalogs: mocks.catalog.mock.calls.length,
metadata: mocks.metadata
}
}
type OwnerProps = { target: string | AgentMenuRowsTarget; phase: string }
type OwnerResult = {
model: NewTabAgentMenuModel
selectionFor?: ReturnType<typeof useAgentMenuObservation>['selectionFor']
controller?: ReturnType<typeof useTabBarCreateMenuController>
}
function useMenuOwner({ target }: OwnerProps): OwnerResult {
return { model: useNewTabAgentMenuRows(target, true, { kind: 'local' }) }
}
function usePickerOwner({ target }: OwnerProps): OwnerResult {
return useAgentMenuObservation(target, true, { kind: 'local' })
}
function useTabOwner({ target, phase }: OwnerProps): OwnerResult {
const controller = useTabBarCreateMenuController({
worktreeId: typeof target === 'string' ? target : (target.workspace.worktreeId ?? ''),
resolvedGroupId: 'group',
terminalOnly: false,
showAgentLaunchItems: phase !== 'hidden',
mobileEmulatorEnabled: false,
managedBrowserCreationEnabled: false,
mobileEmulatorCreationEnabled: false,
workspaceHasSimulatorTab: false,
showWindowsShellMenu: false,
projectRuntimeShellMenuMode: null,
defaultWindowsShell: 'powershell',
defaultWindowsPowerShellImplementation: 'auto',
agentDetectionTarget: { kind: 'local' },
windowsTerminalCapabilities: {
wslAvailable: false,
wslDistros: [],
pwshAvailable: false,
gitBashAvailable: false,
hostPlatform: null,
isLoading: false
},
onNewTerminalTab: () => {},
onNewBrowserTab: () => {}
})
return { model: controller.agentMenu, controller }
}
it('shares real answer preparation at N and 10N across candidates, owners and selections', async () => {
mocks.realRoutes = true
const candidates = ['opencode', 'pi', 'omp'] as const
mocks.catalog.mockReturnValue(
counted(['claude', 'codex', ...candidates].map((agent) => ({ agent, capabilities: {} })))
)
const previous = new Map<string, ReturnType<typeof work>>()
for (const count of [100, 1000]) {
const worktreeId = `${TEST_REPO.id}::/work/${count - 1}`
const worktrees = Array.from({ length: count }, (_, index) => {
const id = `${TEST_REPO.id}::/work/${index}`
return Object.defineProperty(
makeWorktree({ id, path: `/work/${index}`, repoId: TEST_REPO.id, hostId: 'local' }),
'id',
{
get: () => {
mocks.records++
return id
},
enumerable: true
}
)
})
for (const useOwner of [useMenuOwner, usePickerOwner, useTabOwner]) {
for (const launchCwd of useOwner === useTabOwner ? [undefined] : [undefined, '.']) {
const zero = new Map<string, ReturnType<typeof work>>()
for (const viable of [false, true]) {
const inventory: TuiAgent[] = ['claude', 'codex', ...(viable ? candidates : [])]
const ids = counted(inventory)
mocks.detection.mockReturnValue({ detectedIds: ids })
mocks.program.mockReset()
useAppStore.setState({
repos: [TEST_REPO],
worktreesByRepo: { [TEST_REPO.id]: worktrees },
detectedWorktreesByRepo: {},
activeWorktreeId: worktreeId,
activeWorkspaceExecutionHostId: 'local',
settings: {
...getDefaultSettings('/home/me'),
experimentalNativeChat: true,
nativeChatProviders: { opencode: false, pi: false, omp: false },
disabledTuiAgents: []
}
})
resetWork()
const target = launchCwd
? { workspace: { kind: 'git-worktree' as const, worktreeId }, launchCwd }
: worktreeId
const hook = renderHook(useOwner, { initialProps: { target, phase: 'static' } })
console.info('agent menu cold work', {
owner: useOwner.name,
count,
launchCwd,
viable,
work: work()
})
await act(async () => {})
const phases =
useOwner === useTabOwner
? ['static', 'search', 'closed', 'hidden', 'loading', 'missing-program', 'settled']
: useOwner === usePickerOwner
? ['static', 'loading', 'missing-program']
: ['static']
for (const phase of phases) {
if (phase === 'hidden') {
act(() => useAppStore.setState({ activeWorktreeId: 'elsewhere' }))
}
mocks.program.mockImplementation((_state, agent) =>
phase === 'missing-program' && agent !== 'claude' && agent !== 'codex'
? 'missing'
: undefined
)
act(() => useAppStore.setState({ detectedAgentIds: phase === 'loading' ? null : ids }))
mocks.detection.mockReturnValue({ detectedIds: phase === 'loading' ? null : ids })
const controller = hook.result.current.controller
if (controller) {
act(() => {
controller.setNewTabMenuOpen(!['closed', 'hidden'].includes(phase))
controller.setCreateMenuQuery(phase === 'search' ? 'opencode' : '')
})
}
hook.rerender({ target, phase })
resetWork()
hook.rerender({ target, phase })
const measured = work(),
{ model, selectionFor } = hook.result.current
expect(measured.routes).toBe(measured.selectors + 1 + Number(Boolean(launchCwd)))
expect(measured.records).toBeGreaterThanOrEqual(count * measured.routes)
expect(measured.records).toBeLessThanOrEqual(count * (measured.routes + 8))
expect(measured.answers).toBe(
4 +
(viable && !['loading', 'missing-program'].includes(phase) ? candidates.length : 0)
)
expect(measured.metadata).toBe(
2 + (viable && phase !== 'loading' ? candidates.length : 0)
)
expect(measured.programs).toBeLessThanOrEqual(6 * (2 + candidates.length))
expect(measured.lists + measured.catalogs).toBeLessThanOrEqual(
200 * (measured.selectors + 1)
)
if (phase !== 'loading') {
expect(model.state).toBe('settled')
const rows = model.state === 'settled' ? model.rows : []
expect(rows.filter((row) => row.kind === 'cli' && row.chat)).toHaveLength(
viable && phase !== 'missing-program' ? candidates.length : 0
)
expect(rows).toContainEqual({
kind: 'chat',
agent: 'codex',
executionHostId: 'local'
})
const search = findMatchingTabAgentLaunchOptions(
'opencode',
buildTabAgentLaunchOptionsFromRows(rows)
)
expect(search.map((option) => option.row?.kind)).toEqual(viable ? ['cli'] : [])
} else {
expect(model.state).toBe('loading')
}
for (const agent of ['codex', ...candidates] as const) {
selectionFor?.(agent, 'terminal')
}
expect(mocks.routes.length).toBe(measured.routes)
expect(mocks.records).toBe(measured.records)
expect(mocks.answers.length - measured.answers).toBe(selectionFor ? 4 : 0)
if (!viable) {
zero.set(phase, measured)
} else {
expect(measured.records).toBe(zero.get(phase)?.records)
}
const key = `${useOwner.name}:${launchCwd}:${viable}:${phase}`,
prior = previous.get(key)
if (prior) {
expect(measured.records).toBeLessThanOrEqual(prior.records * 10)
}
previous.set(key, measured)
console.info('agent menu work', {
owner: useOwner.name,
count,
launchCwd,
viable,
phase,
measured
})
}
hook.unmount()
}
}
}
}
})
it("reads a publishing host's one snapshot, holding a placeholder only until it lands", async () => {
mocks.publishing = true
mocks.catalog.mockReturnValue(undefined)
@@ -142,6 +472,20 @@ it('shows an older host from client facts and never asks it per agent', () => {
expect(mocks.support).not.toHaveBeenCalled()
})
it('offers a chat on the CLI row of an agent set to open as a CLI', () => {
const { settings } = useAppStore.getState()
useAppStore.setState({
settings: settings && { ...settings, nativeChatProviders: { claude: false } }
})
const hook = renderHook(() => useNewTabAgentMenuRows('one', true, { kind: 'local' }))
const rows = hook.result.current.state === 'settled' ? hook.result.current.rows : []
expect(rows.filter((row) => row.agent === 'claude')).toEqual([
{ kind: 'cli', agent: 'claude', chat: true, executionHostId: 'local' }
])
// An agent with no chat on this host keeps a plain CLI row.
expect(rows).toContainEqual({ kind: 'cli', agent: 'gemini', executionHostId: 'local' })
})
it('waits for a real catalog read, then preserves Gemini CLI after failure without retrying', () => {
mocks.catalog.mockReturnValue(undefined)
const hook = renderHook(() => useNewTabAgentMenuRows('one', true, { kind: 'local' }))
@@ -194,20 +538,109 @@ it('holds initial capability acquisition, then exposes truthful failure and know
})
// Last: the host's published snapshot is module state that later cases would read.
it('picks a host-run chat without its CLI, but never its terminal', () => {
it('preserves per-agent published answers and direct route inputs across context changes', async () => {
mocks.realRoutes = true
mocks.publishing = true
setLocalRuntimeCapabilitiesForTests([CHAT, CLIENT, CATALOG, AVAILABILITY])
mocks.detection.mockReturnValue({ detectedIds: ['claude'] })
const hook = renderHook(() => useAgentMenuObservation('one', true, { kind: 'local' }))
act(() =>
mocks.subscribe.mock.calls[0][3].onEvent({
type: 'availability',
agents: [{ agent: 'codex', state: 'available', chatProgram: 'installable' }]
})
const status: RuntimeEnvironmentStatus = {
checkedAt: 1,
status: {
runtimeId: 'prepared-context',
capabilities: [CHAT, CLIENT, CATALOG, AVAILABILITY],
rendererGraphEpoch: 0,
graphStatus: 'ready',
authoritativeWindowId: null,
liveTabCount: 0,
liveLeafCount: 0
}
}
const hook = renderHook(
({ target }: { target: AgentMenuRowsTarget }) => {
const detection = useAgentDetectionTargetForWorktree(target.workspace.worktreeId ?? null)
return useAgentMenuObservation(target, true, detection)
},
{ initialProps: { target: { workspace: { kind: 'git-worktree', worktreeId: 'one' } } } }
)
const { model, selectionFor } = hook.result.current
const rows = model.state === 'settled' ? model.rows : []
expect(rows).toContainEqual({ kind: 'chat', agent: 'codex', executionHostId: 'local' })
const chat = selectionFor('codex', 'chat')
expect(chat.ready && chat.choice?.route).toBe('structured-native-chat')
expect(selectionFor('codex', 'terminal').ready).toBe(false)
for (const host of ['local', 'runtime:prepared'] as const) {
for (const folder of [false, true]) {
const worktreeId = folder ? 'folder:f1' : 'one'
act(() =>
useAppStore.setState({
activeWorktreeId: worktreeId,
activeWorkspaceExecutionHostId: host,
worktreesByRepo: {
[TEST_REPO.id]: [
makeWorktree({ id: 'one', repoId: TEST_REPO.id, hostId: host, path: '/work' })
]
},
folderWorkspaces: [
makeFolderWorkspace({ id: 'f1', executionHostId: host, folderPath: '/work' })
],
repos: [TEST_REPO],
runtimeStatusByEnvironmentId: new Map([['prepared', status]])
})
)
for (const launchCwd of [undefined, '/work/.', '/work/sub']) {
const target: AgentMenuRowsTarget = {
workspace: { kind: folder ? 'folder' : 'git-worktree', worktreeId },
launchCwd
}
mocks.program.mockClear()
hook.rerender({ target })
await act(async () => {})
expect(mocks.program.mock.calls.length).toBeGreaterThan(0)
for (const [, , baseline] of mocks.program.mock.calls) {
expect(baseline).toHaveProperty('startsOutsideWorkspaceRoot', false)
}
for (const refused of ['claude', 'codex'] as const) {
const stream = mocks.subscribe.mock.calls.findLast((call) =>
host === 'local' ? call[0].kind === 'local' : call[0].environmentId === 'prepared'
)
expect(stream).toBeDefined()
act(() =>
stream![3].onEvent({
type: 'availability',
agents: ['claude', 'codex'].map((agent) => ({
agent,
state: agent === refused ? 'refused' : 'available',
reason: 'agent',
chatProgram: 'installable'
}))
})
)
for (const agent of ['claude', 'codex'] as const) {
mocks.answers.length = 0
const before = mocks.routes.length
const selection = hook.result.current.selectionFor(agent)
expect(mocks.routes.length).toBe(before)
const expected = actualRouteInput.buildAgentLaunchRouteInput(useAppStore.getState(), {
agent,
workspace: target.workspace,
...(launchCwd ? { tuiCustomization: { cwd: launchCwd } } : {})
})
expected.hostStructuredAgents = ['claude', 'codex']
expect(mocks.answers).toEqual([expected])
expect(selection.ready).toBe(
agent === 'claude' || (launchCwd !== '/work/sub' && agent !== refused)
)
expect(selection.choice).toMatchObject({
agent,
executionHostId: host,
route:
launchCwd === '/work/sub' || agent === refused
? 'terminal-tui'
: 'structured-native-chat'
})
}
if (!launchCwd && refused === 'claude') {
expect(
hook.result.current.model.state === 'settled' && hook.result.current.model.rows
).toContainEqual({ kind: 'chat', agent: 'codex', executionHostId: host })
expect(hook.result.current.selectionFor('codex', 'terminal').ready).toBe(false)
}
}
}
}
}
})
@@ -222,15 +222,18 @@ export function useAgentMenuObservation(
availability?.state === 'pending' ||
hostCatalogPending ||
localCatalogPending)))
// Workspace preparation is shared by answers, while agent and published verdict stay per question.
let answerContext = launchCwd ? undefined : input
const answerFor = (agent: TuiAgent): HostChatAnswer => {
if (unresolvedTarget) {
return 'not-yet-known'
}
const route = buildAgentLaunchRouteInput(state, {
answerContext ??= buildAgentLaunchRouteInput(state, {
agent,
workspace,
...(launchCwd ? { tuiCustomization: { cwd: launchCwd } } : {})
})
const route = { ...answerContext, agent }
const preliminary = hostChatAnswer(roster ? { ...route, hostStructuredAgents: roster } : route)
if (!roster || !['yes', 'not-yet-known', 'not-on-host'].includes(preliminary)) {
return preliminary
@@ -62,7 +62,8 @@ export type ChatUnavailableReason =
export type NewTabAgentMenuRow = (
| { kind: 'chat'; agent: TuiAgent }
| { kind: 'chat-unavailable'; agent: TuiAgent; reason: ChatUnavailableReason }
| { kind: 'cli'; agent: TuiAgent }
/** `chat`: set to open as a CLI, though it can open as a chat here too. */
| { kind: 'cli'; agent: TuiAgent; chat?: true }
| { kind: 'terminal'; agent: TuiAgent }
) & { executionHostId?: ExecutionHostId }
@@ -91,11 +92,19 @@ export function buildNewTabAgentMenuRows(args: {
chatProgramOffered(args.chatProgram(agent)) ||
(args.chatProgram(agent) === 'unknown' && installed.includes(agent))
const providedHere = chatAgents.filter(canOpen)
const chatToo = new Set<TuiAgent>()
for (const agent of chatAgents) {
if (!isNativeChatProviderDisplayedOn(settings, agent, providedHere)) {
const displayed = isNativeChatProviderDisplayedOn(settings, agent, providedHere)
if (!displayed && (!installed.includes(agent) || !canOpen(agent))) {
continue
}
const answer = args.hostChatAnswer(agent)
if (!displayed) {
if (answer === 'yes') {
chatToo.add(agent)
}
continue
}
const program = args.chatProgram(agent)
if (answer === 'yes' && canOpen(agent)) {
runnable.push(agent)
@@ -126,6 +135,10 @@ export function buildNewTabAgentMenuRows(args: {
...orderAgents(
defaultAgent,
installed.filter((agent) => !runnable.includes(agent))
).map((agent) => ({ kind: 'cli' as const, agent }))
).map((agent) => ({
kind: 'cli' as const,
agent,
...(chatToo.has(agent) ? { chat: true as const } : {})
}))
]
}