mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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 } : {})
|
||||
}))
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user