diff --git a/mobile/src/cloud/CloudAgentPicker.test.tsx b/mobile/src/cloud/CloudAgentPicker.test.tsx index b482871bd4f..5ef293ffb38 100644 --- a/mobile/src/cloud/CloudAgentPicker.test.tsx +++ b/mobile/src/cloud/CloudAgentPicker.test.tsx @@ -12,7 +12,8 @@ vi.mock('../components/MobileAgentIcon', () => ({ MobileAgentIcon: 'MobileAgentIcon' })) vi.mock('../i18n/use-mobile-translation', () => ({ - useMobileTranslation: () => (_key: string, fallback: string) => fallback + useMobileTranslation: () => (_key: string, fallback: string, values?: Record) => + fallback.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => values?.[name] ?? '') })) vi.mock('../theme/use-mobile-color-scheme', () => ({ useMobileColorScheme: () => 'dark' @@ -69,6 +70,15 @@ describe('CloudAgentPicker', () => { expect(group.props.accessibilityLabel).toBe('Agent') const checked = radios(tree).filter((node) => node.props.accessibilityState.checked) expect(checked.map(iconOf)).toEqual(['gemini']) + // Each chip says its name, plus its state when selected, with catalog names. + expect(radios(tree).map((node) => node.props.accessibilityLabel)).toEqual([ + 'Claude', + 'Codex', + 'OpenCode', + 'Gemini, selected', + 'Cursor', + 'GitHub Copilot' + ]) }) it('reports the pressed agent and holds every chip while disabled', () => { diff --git a/mobile/src/cloud/CloudAgentPicker.tsx b/mobile/src/cloud/CloudAgentPicker.tsx index 23193a4ff17..e59a783bb01 100644 --- a/mobile/src/cloud/CloudAgentPicker.tsx +++ b/mobile/src/cloud/CloudAgentPicker.tsx @@ -36,6 +36,12 @@ export function CloudAgentPicker(props: { props.onChange(id)} diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json index acd220d6e19..cfc360c5d23 100644 --- a/mobile/src/i18n/locales/en.json +++ b/mobile/src/i18n/locales/en.json @@ -4968,7 +4968,8 @@ "deviceLabelConnected": "{{device}}, connected {{date}}" }, "agent": { - "label": "Agent" + "label": "Agent", + "chipSelected": "{{agent}}, selected" }, "started": { "title": "{{agent}} started with your prompt", diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index b13712a7f9e..95692576d13 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -158,12 +158,8 @@ function QuickTabBody({ const [quickAgentOverride, setQuickAgentOverride] = useState( modalData.recovery?.agent ) - const { orcaCloud, orcaCloudModel, quickAgent, chipAgentIds } = useComposerQuickAgent( - cardProps, - settings, - quickAgentOverride, - setQuickAgentOverride - ) + const { orcaCloud, orcaCloudModel, quickAgent, chipAgentIds, agentNotice } = + useComposerQuickAgent(cardProps, settings, quickAgentOverride, setQuickAgentOverride) // Why keyed by agent: a pick repaired to another agent must not carry the old one's surface. const [quickAgentPick, setQuickAgentPick] = useState< Omit, 'note'> @@ -361,6 +357,7 @@ function QuickTabBody({ // Chips list offered agents, keeping a pick whose chat is still being learned. detectedAgentIds={chipAgentIds} createDisabled={createDisabled} + agentNotice={agentNotice} agentSurfaceRows={ agentMenuModel ? { diff --git a/src/renderer/src/components/new-workspace/NewWorkspaceComposerAgentSection.tsx b/src/renderer/src/components/new-workspace/NewWorkspaceComposerAgentSection.tsx index 734097669ee..506f773730e 100644 --- a/src/renderer/src/components/new-workspace/NewWorkspaceComposerAgentSection.tsx +++ b/src/renderer/src/components/new-workspace/NewWorkspaceComposerAgentSection.tsx @@ -12,6 +12,7 @@ type NewWorkspaceComposerAgentSectionProps = Pick< NewWorkspaceComposerCardProps, | 'quickAgent' | 'agentSurfaceRows' + | 'agentNotice' | 'agentPickerDraft' | 'agentSetupDisabled' | 'selectedRepoIsGit' @@ -36,6 +37,7 @@ type NewWorkspaceComposerAgentSectionProps = Pick< export function NewWorkspaceComposerAgentSection({ quickAgent, agentSurfaceRows, + agentNotice, agentPickerDraft, agentSetupDisabled, selectedRepoIsGit, @@ -100,6 +102,7 @@ export function NewWorkspaceComposerAgentSection({ onSelectBlankTerminal={() => onQuickAgentChange(null)} onPick={(agent, draft, entry) => onQuickAgentChange(agent, draft?.surface, draft, entry)} /> + {agentNotice ?

{agentNotice}

: null}
diff --git a/src/renderer/src/components/new-workspace/OrcaCloudAgentPicker.fixture.tsx b/src/renderer/src/components/new-workspace/OrcaCloudAgentPicker.fixture.tsx new file mode 100644 index 00000000000..d2d4937c26a --- /dev/null +++ b/src/renderer/src/components/new-workspace/OrcaCloudAgentPicker.fixture.tsx @@ -0,0 +1,76 @@ +// The composer's agent picker with Orca Cloud as the run target: the image's six agents, as installed. +import React from 'react' +import { defineFixture } from '../../component-fixtures/component-fixture' +import { AgentLaunchPicker } from '@/components/agent/AgentLaunchPicker' +import { orcaCloudAgentMenuModel } from '@/lib/orca-cloud/orca-cloud-agent-picker' +import type { TuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent' + +const noop = (): void => {} +const settle = (): Promise => new Promise((resolve) => setTimeout(resolve, 80)) + +function Picker(props: { + agent: TuiAgent + disabled?: TuiAgent[] + notice?: string +}): React.JSX.Element { + const model = orcaCloudAgentMenuModel({ + defaultTuiAgent: 'claude', + disabledTuiAgents: props.disabled ?? [], + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {} + } as unknown as Parameters[0]) + return ( +
+
+ + + {props.notice ?

{props.notice}

: null} +
+
+ ) +} + +const open = async (frame: HTMLElement): Promise => { + // Radix menus open on pointerdown, not click. + frame + .querySelector('button[aria-haspopup]') + ?.dispatchEvent( + new PointerEvent('pointerdown', { bubbles: true, button: 0, pointerType: 'mouse' }) + ) + await settle() +} + +export const closed = defineFixture({ width: 480, render: () => }) + +export const openSix = defineFixture({ + width: 480, + render: () => , + interact: open +}) + +export const openTwoDisabled = defineFixture({ + width: 480, + render: () => , + interact: open +}) + +/** The user's default (Aider) isn't on the image: said once under the picker, not swapped silently. */ +export const fallback = defineFixture({ + width: 480, + render: () => ( + + ) +}) diff --git a/src/renderer/src/components/new-workspace/new-workspace-composer-card-props.ts b/src/renderer/src/components/new-workspace/new-workspace-composer-card-props.ts index 1fa4bc9b435..ca566812044 100644 --- a/src/renderer/src/components/new-workspace/new-workspace-composer-card-props.ts +++ b/src/renderer/src/components/new-workspace/new-workspace-composer-card-props.ts @@ -48,6 +48,8 @@ export type NewWorkspaceComposerCardProps = { nameInputRef?: React.RefObject quickAgent: TuiAgent | null agentSurfaceRows?: AgentComboboxSurfaceRows + /** One muted line under the agent picker (Orca Cloud: why another agent is selected). */ + agentNotice?: string agentPickerDraft?: AgentPickerDraft agentSetupDisabled?: boolean onQuickAgentChange: ( diff --git a/src/renderer/src/components/new-workspace/use-composer-quick-agent.ts b/src/renderer/src/components/new-workspace/use-composer-quick-agent.ts index e4570289ffb..2178b08b831 100644 --- a/src/renderer/src/components/new-workspace/use-composer-quick-agent.ts +++ b/src/renderer/src/components/new-workspace/use-composer-quick-agent.ts @@ -1,5 +1,7 @@ import { useMemo } from 'react' import { useAppStore } from '@/store/index' +import { useTranslate } from '@/i18n/use-translate' +import { formatAgentTypeLabel } from '../../../../shared/agent-status/presentation/agent-type-label' import { agentChoicePending } from '@/lib/host-chat-program' import { useAgentsOfferedForLaunch } from '@/lib/host-chat-program-acquisition' import { @@ -9,6 +11,7 @@ import { } from '@/lib/quick-workspace-agent-selection' import { ORCA_CLOUD_AGENT_IDS, + orcaCloudAgentFallback, orcaCloudAgentMenuModel, pickOrcaCloudAgent } from '@/lib/orca-cloud/orca-cloud-agent-picker' @@ -35,6 +38,7 @@ export function useComposerQuickAgent( quickAgentOverride: TuiAgent | null | undefined, setQuickAgentOverride: (agent: TuiAgent | null | undefined) => void ) { + const t = useTranslate() // The picker rows' workspace when known, so the offered list and the rows read one host. const launchWorkspace = cardProps.agentRows?.target.workspace ?? composerLaunchWorkspace(cardProps) @@ -79,5 +83,23 @@ export function useComposerQuickAgent( const chipAgentIds = offeredAgentIds && new Set([...offeredAgentIds, ...(quickAgent && choicePending(quickAgent) ? [quickAgent] : [])]) - return { orcaCloud, orcaCloudModel, quickAgent, chipAgentIds } + // Said, not silent: the agent the user wanted isn't on the image, so another one is selected. + const orcaCloudFallback = orcaCloud + ? orcaCloudAgentFallback({ + override: quickAgentOverride, + defaultAgent: settings?.defaultTuiAgent, + selected: quickAgent + }) + : null + const agentNotice = orcaCloudFallback + ? t( + 'components.newWorkspace.orcaCloudAgentFallback', + '{{agent}} isn’t available on Orca Cloud yet, so {{fallback}} is selected.', + { + agent: formatAgentTypeLabel(orcaCloudFallback.wanted), + fallback: formatAgentTypeLabel(orcaCloudFallback.selected) + } + ) + : undefined + return { orcaCloud, orcaCloudModel, quickAgent, chipAgentIds, agentNotice } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 860d93848f2..e1781c034f2 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -22944,7 +22944,8 @@ "yamlSource": "orca.yaml", "bothSources": "orca.yaml + local", "localSource": "local settings" - } + }, + "orcaCloudAgentFallback": "{{agent}} isn’t available on Orca Cloud yet, so {{fallback}} is selected." }, "JiraIssueWorkspace": { "pendingChange": "Another issue change is in progress." diff --git a/src/renderer/src/lib/orca-cloud/orca-cloud-agent-picker.test.ts b/src/renderer/src/lib/orca-cloud/orca-cloud-agent-picker.test.ts index e271d1bdbd5..84ed412b784 100644 --- a/src/renderer/src/lib/orca-cloud/orca-cloud-agent-picker.test.ts +++ b/src/renderer/src/lib/orca-cloud/orca-cloud-agent-picker.test.ts @@ -4,7 +4,8 @@ import { resolveQuickWorkspaceAgentSelection } from '@/lib/quick-workspace-agent import { ORCA_CLOUD_AGENT_IDS, orcaCloudAgentMenuModel, - pickOrcaCloudAgent + pickOrcaCloudAgent, + orcaCloudAgentFallback } from './orca-cloud-agent-picker' describe('pickOrcaCloudAgent', () => { @@ -85,3 +86,29 @@ describe('Orca Cloud composer selection repair', () => { ).toEqual({ quickAgent: 'cursor', quickAgentOverride: 'cursor' }) }) }) + +describe('orcaCloudAgentFallback', () => { + it('names the agent the user wanted when the image does not ship it', () => { + expect( + orcaCloudAgentFallback({ override: 'aider', defaultAgent: 'claude', selected: 'claude' }) + ).toEqual({ wanted: 'aider', selected: 'claude' }) + expect( + orcaCloudAgentFallback({ override: undefined, defaultAgent: 'goose', selected: 'claude' }) + ).toEqual({ wanted: 'goose', selected: 'claude' }) + }) + + it('says nothing once the pick is on the image, for Blank Terminal, or with nothing selected', () => { + expect( + orcaCloudAgentFallback({ override: 'gemini', defaultAgent: 'goose', selected: 'gemini' }) + ).toBeNull() + expect( + orcaCloudAgentFallback({ override: undefined, defaultAgent: 'codex', selected: 'codex' }) + ).toBeNull() + expect( + orcaCloudAgentFallback({ override: null, defaultAgent: 'goose', selected: null }) + ).toBeNull() + expect( + orcaCloudAgentFallback({ override: undefined, defaultAgent: 'blank', selected: null }) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/orca-cloud-agent-picker.ts b/src/renderer/src/lib/orca-cloud/orca-cloud-agent-picker.ts index 955bb49d95f..d0b0ffd4216 100644 --- a/src/renderer/src/lib/orca-cloud/orca-cloud-agent-picker.ts +++ b/src/renderer/src/lib/orca-cloud/orca-cloud-agent-picker.ts @@ -57,3 +57,24 @@ export function orcaCloudAgentMenuModel( defaultTuiAgent: settings?.defaultTuiAgent } } + +/** + * The agent the user wanted (a local pick made before choosing Orca Cloud, or their default) when the + * image doesn't ship it and another agent is selected instead; null when nothing was substituted. + */ +export function orcaCloudAgentFallback(args: { + override: TuiAgent | null | undefined + defaultAgent: TuiAgent | 'blank' | null | undefined + selected: TuiAgent | null +}): { wanted: TuiAgent; selected: TuiAgent } | null { + const wanted = + args.override !== undefined && args.override !== null + ? args.override + : args.override === null || args.defaultAgent === 'blank' + ? null + : (args.defaultAgent ?? null) + if (!wanted || !args.selected || isOrcaCloudImageAgent(wanted) || wanted === args.selected) { + return null + } + return { wanted, selected: args.selected } +}