From c0a7aca7eea11ff214a7f21acc2dedeaee03190c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:23:03 -0700 Subject: [PATCH] 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 --- .../agent/AgentCombobox.surface.test.tsx | 9 +- .../agent/AgentSurfaceLaunchButton.tsx | 34 ++ .../agent/AgentTerminalLaunchButton.tsx | 31 -- .../agent/agent-combobox-surface-items.tsx | 5 +- .../QuickLaunchButton.launch-status.test.tsx | 323 ++++++++++- .../components/tab-bar/QuickLaunchButton.tsx | 84 ++- .../tab-bar/tab-agent-launch-options.test.ts | 12 +- .../use-new-tab-agent-menu-rows.test.tsx | 503 ++++++++++++++++-- .../tab-bar/use-new-tab-agent-menu-rows.ts | 5 +- .../src/lib/new-tab-agent-menu-rows.ts | 19 +- 10 files changed, 901 insertions(+), 124 deletions(-) create mode 100644 src/renderer/src/components/agent/AgentSurfaceLaunchButton.tsx delete mode 100644 src/renderer/src/components/agent/AgentTerminalLaunchButton.tsx diff --git a/src/renderer/src/components/agent/AgentCombobox.surface.test.tsx b/src/renderer/src/components/agent/AgentCombobox.surface.test.tsx index 792e582db66..2e05f5683d1 100644 --- a/src/renderer/src/components/agent/AgentCombobox.surface.test.tsx +++ b/src/renderer/src/components/agent/AgentCombobox.surface.test.tsx @@ -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', () => { diff --git a/src/renderer/src/components/agent/AgentSurfaceLaunchButton.tsx b/src/renderer/src/components/agent/AgentSurfaceLaunchButton.tsx new file mode 100644 index 00000000000..2e30cce8125 --- /dev/null +++ b/src/renderer/src/components/agent/AgentSurfaceLaunchButton.tsx @@ -0,0 +1,34 @@ +import React from 'react' +import { MessageSquare, SquareTerminal } from 'lucide-react' +import { cn } from '@/lib/utils' + +type AgentSurfaceLaunchButtonProps = Omit, '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 ( + + ) +} diff --git a/src/renderer/src/components/agent/AgentTerminalLaunchButton.tsx b/src/renderer/src/components/agent/AgentTerminalLaunchButton.tsx deleted file mode 100644 index 18d4d22b1b2..00000000000 --- a/src/renderer/src/components/agent/AgentTerminalLaunchButton.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from 'react' -import { SquareTerminal } from 'lucide-react' -import { cn } from '@/lib/utils' - -type AgentTerminalLaunchButtonProps = Omit, '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 ( - - ) -} diff --git a/src/renderer/src/components/agent/agent-combobox-surface-items.tsx b/src/renderer/src/components/agent/agent-combobox-surface-items.tsx index 08077349287..36977d2ef6f 100644 --- a/src/renderer/src/components/agent/agent-combobox-surface-items.tsx +++ b/src/renderer/src/components/agent/agent-combobox-surface-items.tsx @@ -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)} ) : rowOffersTerminal(model, row) ? ( - { 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 }) => {agent} })) -vi.mock('@/components/ui/dropdown-menu', () => ({ - DropdownMenuItem: ({ - children, - disabled, - title, - onSelect - }: { children: ReactNode } & DivProps) => ( -
- {children} -
- ), - DropdownMenuShortcut: ({ children }: { children: ReactNode }) => {children} -})) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string, values?: Record) => 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 { + return renderElement( + + Agents + {children} + + ) +} + +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( + , + 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( + + ) + 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( '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({ diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index ae2d6262128..40fbe26cf98 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -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 ( {startsCliGroup ? : null} - {row.kind === 'chat' && detectedIds?.includes(agent) ? ( - + {other ? ( + {rowContent} - + ) : ( , + 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 + row: NewTabAgentMenuRow + other: NewTabAgentMenuRow label: string disabled: boolean runLaunch: (row: NewTabAgentMenuRow) => void children: React.ReactNode }): React.JSX.Element { - const cliButton = useRef(null) - const cliRow: NewTabAgentMenuRow = { ...row, kind: 'cli' } - const cliTitle = rowTitle(cliRow, label) - const chatTitle = rowTitle(row, label) + const otherButton = useRef(null) + const releaseTarget = useRef<{ event: PointerEvent; target: EventTarget } | null>(null) + const title = rowTitle(row, label) return ( { - 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} - { - // 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() diff --git a/src/renderer/src/components/tab-bar/tab-agent-launch-options.test.ts b/src/renderer/src/components/tab-bar/tab-agent-launch-options.test.ts index a9c3e8b5e65..7f19200f14d 100644 --- a/src/renderer/src/components/tab-bar/tab-agent-launch-options.test.ts +++ b/src/renderer/src/components/tab-bar/tab-agent-launch-options.test.ts @@ -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']]) }) diff --git a/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-rows.test.tsx b/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-rows.test.tsx index 5cbf1f0421f..4f4c7165971 100644 --- a/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-rows.test.tsx +++ b/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-rows.test.tsx @@ -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(), + answers: Array[0]>(), + selectors: 0, + records: 0, + lists: 0, + metadata: 0, + countedLists: new WeakSet(), + program: + vi.fn< + ( + ...args: Parameters + ) => ChatProgram.ChatProgramObservation | undefined + >() })) +vi.mock('zustand/react/shallow', async (importOriginal) => { + const actual = await importOriginal() + return { + useShallow: (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() + return { + ...actual, + buildNewTabAgentMenuRows: (...args: Parameters) => { + const rows = actual.buildNewTabAgentMenuRows(...args) + mocks.metadata += rows.length + return rows + }, + hostChatAnswer: (input: Parameters[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() + 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() + return { + ...actual, + resolveAgentLaunchHost: (...args: Parameters) => + mocks.realRoutes + ? actual.resolveAgentLaunchHost(...args) + : { + executionHostId: 'local', + hostCapabilities: + readLocalRuntimeCapabilitiesOrUnknown() === null + ? null + : [CHAT, CLIENT, CATALOG, ...(mocks.publishing ? [AVAILABILITY] : [])] + }, + buildAgentLaunchRouteInput: (...args: Parameters) => { + 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() + return { + ...actual, + readChatProgram: (...args: Parameters) => + 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('@/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(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['selectionFor'] + controller?: ReturnType +} +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>() + 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>() + 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) + } + } + } + } + } }) diff --git a/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-rows.ts b/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-rows.ts index 98d29424d46..92284eb81b8 100644 --- a/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-rows.ts +++ b/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-rows.ts @@ -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 diff --git a/src/renderer/src/lib/new-tab-agent-menu-rows.ts b/src/renderer/src/lib/new-tab-agent-menu-rows.ts index 7a850ac3299..8b4e6a11cde 100644 --- a/src/renderer/src/lib/new-tab-agent-menu-rows.ts +++ b/src/renderer/src/lib/new-tab-agent-menu-rows.ts @@ -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() 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 } : {}) + })) ] }