mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
Merge pull request #188 from nwparker/cloud/agents-picker
Orca Cloud agent pickers list only the agents the image ships
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Pressable: 'Pressable',
|
||||
StyleSheet: { create: (styles: unknown) => styles },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
vi.mock('../components/MobileAgentIcon', () => ({
|
||||
MobileAgentIcon: 'MobileAgentIcon'
|
||||
}))
|
||||
vi.mock('../i18n/use-mobile-translation', () => ({
|
||||
useMobileTranslation: () => (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
vi.mock('../theme/use-mobile-color-scheme', () => ({
|
||||
useMobileColorScheme: () => 'dark'
|
||||
}))
|
||||
|
||||
import {
|
||||
ORCA_CLOUD_DEFAULT_AGENT,
|
||||
ORCA_CLOUD_IMAGE_AGENTS,
|
||||
type OrcaCloudImageAgent
|
||||
} from '../../../src/shared/hosted-sandbox/orca-cloud-image-agents'
|
||||
import { CloudAgentPicker } from './CloudAgentPicker'
|
||||
|
||||
function render(props: {
|
||||
value: OrcaCloudImageAgent
|
||||
onChange: (agent: OrcaCloudImageAgent) => void
|
||||
disabled?: boolean
|
||||
}): ReactTestRenderer {
|
||||
const rendered: { tree: ReactTestRenderer | null } = { tree: null }
|
||||
act(() => {
|
||||
rendered.tree = create(createElement(CloudAgentPicker, props))
|
||||
})
|
||||
if (rendered.tree === null) {
|
||||
throw new Error('the picker did not render')
|
||||
}
|
||||
return rendered.tree
|
||||
}
|
||||
|
||||
function radios(tree: ReactTestRenderer): ReactTestInstance[] {
|
||||
return tree.root.findAll(
|
||||
(node: ReactTestInstance) =>
|
||||
String(node.type) === 'Pressable' && node.props.accessibilityRole === 'radio'
|
||||
)
|
||||
}
|
||||
|
||||
function iconOf(node: ReactTestInstance): string {
|
||||
return node.find((child: ReactTestInstance) => String(child.type) === 'MobileAgentIcon').props
|
||||
.agentId
|
||||
}
|
||||
|
||||
describe('CloudAgentPicker', () => {
|
||||
it('offers exactly the agents the Orca Cloud image ships, in image order', () => {
|
||||
const tree = render({
|
||||
value: ORCA_CLOUD_DEFAULT_AGENT,
|
||||
onChange: () => {}
|
||||
})
|
||||
expect(radios(tree).map(iconOf)).toEqual([...ORCA_CLOUD_IMAGE_AGENTS])
|
||||
})
|
||||
|
||||
it('is one radio group with the current agent checked', () => {
|
||||
const tree = render({ value: 'gemini', onChange: () => {} })
|
||||
const group = tree.root.find(
|
||||
(node: ReactTestInstance) => node.props.accessibilityRole === 'radiogroup'
|
||||
)
|
||||
expect(group.props.accessibilityLabel).toBe('Agent')
|
||||
const checked = radios(tree).filter((node) => node.props.accessibilityState.checked)
|
||||
expect(checked.map(iconOf)).toEqual(['gemini'])
|
||||
})
|
||||
|
||||
it('reports the pressed agent and holds every chip while disabled', () => {
|
||||
const picks: string[] = []
|
||||
const tree = render({
|
||||
value: 'claude',
|
||||
onChange: (agent) => picks.push(agent)
|
||||
})
|
||||
const cursor = radios(tree).find((node) => iconOf(node) === 'cursor')!
|
||||
act(() => cursor.props.onPress())
|
||||
expect(picks).toEqual(['cursor'])
|
||||
|
||||
const disabled = render({
|
||||
value: 'claude',
|
||||
onChange: () => {},
|
||||
disabled: true
|
||||
})
|
||||
expect(radios(disabled).every((node) => node.props.disabled === true)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,27 +1,23 @@
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
import { formatAgentTypeLabel } from '../../../src/shared/agent-status/presentation/agent-type-label'
|
||||
import { ClaudeIcon, OpenAIIcon } from '../components/AgentIcons'
|
||||
import {
|
||||
ORCA_CLOUD_IMAGE_AGENTS,
|
||||
type OrcaCloudImageAgent
|
||||
} from '../../../src/shared/hosted-sandbox/orca-cloud-image-agents'
|
||||
import { MobileAgentIcon } from '../components/MobileAgentIcon'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
import { colors, radii, spacing } from '../theme/mobile-theme'
|
||||
import { themedStyleSheet } from '../theme/themed-styles'
|
||||
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
|
||||
|
||||
/** The agent CLIs every Orca Cloud image ships. */
|
||||
const CLOUD_AGENTS = [
|
||||
{ id: 'claude', Icon: ClaudeIcon },
|
||||
{ id: 'codex', Icon: OpenAIIcon }
|
||||
] as const
|
||||
|
||||
export const CLOUD_DEFAULT_AGENT = 'claude'
|
||||
|
||||
/** Product names, from the app's one agent-name table. */
|
||||
export function cloudAgentLabel(agent: string): string {
|
||||
return formatAgentTypeLabel(agent)
|
||||
}
|
||||
|
||||
export function CloudAgentPicker(props: {
|
||||
value: string
|
||||
onChange: (agent: string) => void
|
||||
value: OrcaCloudImageAgent
|
||||
onChange: (agent: OrcaCloudImageAgent) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
useMobileColorScheme()
|
||||
@@ -32,7 +28,8 @@ export function CloudAgentPicker(props: {
|
||||
accessibilityRole="radiogroup"
|
||||
accessibilityLabel={t('cloud.agent.label', 'Agent')}
|
||||
>
|
||||
{CLOUD_AGENTS.map(({ id, Icon }) => {
|
||||
{/* Only what the image ships: the VM does not exist yet, so nothing else could start. */}
|
||||
{ORCA_CLOUD_IMAGE_AGENTS.map((id) => {
|
||||
const label = cloudAgentLabel(id)
|
||||
const checked = props.value === id
|
||||
return (
|
||||
@@ -50,7 +47,7 @@ export function CloudAgentPicker(props: {
|
||||
pressed && styles.pressed
|
||||
]}
|
||||
>
|
||||
<Icon size={15} />
|
||||
<MobileAgentIcon agentId={id} size={15} />
|
||||
<Text style={[styles.label, checked && styles.labelSelected]}>{label}</Text>
|
||||
</Pressable>
|
||||
)
|
||||
|
||||
@@ -4,7 +4,8 @@ import { Pressable, Text, TextInput, View } from 'react-native'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
|
||||
import { CloudAgentPicker, CLOUD_DEFAULT_AGENT } from './CloudAgentPicker'
|
||||
import { ORCA_CLOUD_DEFAULT_AGENT } from '../../../src/shared/hosted-sandbox/orca-cloud-image-agents'
|
||||
import { CloudAgentPicker } from './CloudAgentPicker'
|
||||
import { cloudWorkspaceScreenStyles as styles } from './cloud-workspace-screen-styles'
|
||||
|
||||
/** One card: the agent, the prompt, and Send. The send button keeps its shape in every state; when
|
||||
@@ -20,7 +21,7 @@ export function CloudPromptComposer(props: {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const [draft, setDraft] = useState('')
|
||||
const [agent, setAgent] = useState(CLOUD_DEFAULT_AGENT)
|
||||
const [agent, setAgent] = useState(ORCA_CLOUD_DEFAULT_AGENT)
|
||||
const canSend = draft.trim() !== '' && !props.disabled
|
||||
const send = async () => {
|
||||
if (canSend && (await props.onSend(draft.trim(), agent))) {
|
||||
|
||||
@@ -17,7 +17,8 @@ import { SettingsNavBar, SettingsNote, SettingsSection } from '../settings/setti
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import { themedStyleSheet } from '../theme/themed-styles'
|
||||
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
|
||||
import { CloudAgentPicker, CLOUD_DEFAULT_AGENT } from './CloudAgentPicker'
|
||||
import { ORCA_CLOUD_DEFAULT_AGENT } from '../../../src/shared/hosted-sandbox/orca-cloud-image-agents'
|
||||
import { CloudAgentPicker } from './CloudAgentPicker'
|
||||
import { CloudPhaseSteps } from './CloudPhaseSteps'
|
||||
import { createCloudWorkspace, renameCloudWorkspace } from './cloud-control-plane-client'
|
||||
import { enqueueCloudPrompt } from './cloud-prompt-queue'
|
||||
@@ -51,7 +52,7 @@ export function NewCloudWorkspaceScreen() {
|
||||
const existing = useCloudWorkspaces(useCloudEnrollment()).data
|
||||
const [repo, setRepo] = useState('')
|
||||
const [branch, setBranch] = useState('')
|
||||
const [agent, setAgent] = useState(CLOUD_DEFAULT_AGENT)
|
||||
const [agent, setAgent] = useState(ORCA_CLOUD_DEFAULT_AGENT)
|
||||
const [prompt, setPrompt] = useState('')
|
||||
const [startedAt, setStartedAt] = useState<number | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
@@ -9,8 +9,6 @@ import type {
|
||||
AgentPickerEntry
|
||||
} from '../../../shared/agent-launch/picker/agent-launch-picker'
|
||||
import { DirectWorkItemSetupDialog } from './new-workspace/DirectWorkItemSetupDialog'
|
||||
import { isOrcaCloudSelection } from './new-workspace/run-target-options'
|
||||
import { agentChoicePending } from '@/lib/host-chat-program'
|
||||
import type { AgentLaunchSurface } from '@/lib/agent-session/launch/routing/agent-session-launch-plan'
|
||||
import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useAppStore } from '@/store/index'
|
||||
@@ -28,13 +26,8 @@ import AgentSettingsDialog, {
|
||||
} from '@/components/agent/AgentSettingsDialog'
|
||||
import type { AddRepoDialogHostedController } from '@/components/sidebar/project-admission/host-selection/use-add-repo-hosted-controller'
|
||||
import { useComposerState } from '@/hooks/useComposerState'
|
||||
import {
|
||||
composerLaunchWorkspace,
|
||||
quickWorkspacePickerInputs,
|
||||
pickQuickWorkspaceAgent,
|
||||
resolveQuickWorkspaceAgentSelection
|
||||
} from '@/lib/quick-workspace-agent-selection'
|
||||
import { useAgentsOfferedForLaunch } from '@/lib/host-chat-program-acquisition'
|
||||
import { quickWorkspacePickerInputs } from '@/lib/quick-workspace-agent-selection'
|
||||
import { useComposerQuickAgent } from './new-workspace/use-composer-quick-agent'
|
||||
import { shouldAllowComposerEnterSubmitTarget } from '@/lib/new-workspace-enter-guard'
|
||||
import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
|
||||
import type { TuiAgent } from '../../../shared/agent-launch/catalog/tui-agent'
|
||||
@@ -165,38 +158,12 @@ function QuickTabBody({
|
||||
const [quickAgentOverride, setQuickAgentOverride] = useState<TuiAgent | null | undefined>(
|
||||
modalData.recovery?.agent
|
||||
)
|
||||
// The picker rows' workspace when known, so the offered list and the rows read one host.
|
||||
const launchWorkspace =
|
||||
cardProps.agentRows?.target.workspace ?? composerLaunchWorkspace(cardProps)
|
||||
const offeredAgentIds = useAgentsOfferedForLaunch(launchWorkspace, cardProps.detectedAgentIds)
|
||||
const detected = cardProps.detectedAgentIds ? [...cardProps.detectedAgentIds] : null
|
||||
const choicePending = (agent: TuiAgent): boolean =>
|
||||
agentChoicePending(useAppStore.getState(), agent, launchWorkspace, detected)
|
||||
// Pending discovery preserves the existing catalog fallback; current facts settle the default.
|
||||
const preferredQuickAgent = pickQuickWorkspaceAgent(
|
||||
settings?.defaultTuiAgent,
|
||||
cardProps.detectedAgentIds,
|
||||
settings?.disabledTuiAgents,
|
||||
offeredAgentIds,
|
||||
choicePending
|
||||
)
|
||||
// An explicit pick stays valid where it opens as a chat without its CLI.
|
||||
const resolvedQuickAgentSelection = resolveQuickWorkspaceAgentSelection({
|
||||
const { orcaCloud, orcaCloudModel, quickAgent, chipAgentIds } = useComposerQuickAgent(
|
||||
cardProps,
|
||||
settings,
|
||||
quickAgentOverride,
|
||||
preferredQuickAgent,
|
||||
detectedAgentIds: cardProps.detectedAgentIds,
|
||||
offeredAgentIds,
|
||||
selectedChoiceUnresolved: Boolean(quickAgentOverride && choicePending(quickAgentOverride)),
|
||||
disabledTuiAgents: settings?.disabledTuiAgents
|
||||
})
|
||||
if (resolvedQuickAgentSelection.quickAgentOverride !== quickAgentOverride) {
|
||||
// Repair invalid picks before children render an unavailable option.
|
||||
setQuickAgentOverride(resolvedQuickAgentSelection.quickAgentOverride)
|
||||
}
|
||||
const quickAgent = resolvedQuickAgentSelection.quickAgent
|
||||
const chipAgentIds =
|
||||
offeredAgentIds &&
|
||||
new Set([...offeredAgentIds, ...(quickAgent && choicePending(quickAgent) ? [quickAgent] : [])])
|
||||
setQuickAgentOverride
|
||||
)
|
||||
// Why keyed by agent: a pick repaired to another agent must not carry the old one's surface.
|
||||
const [quickAgentPick, setQuickAgentPick] = useState<
|
||||
Omit<NonNullable<ComposerModalData['recovery']>, 'note'>
|
||||
@@ -213,6 +180,7 @@ function QuickTabBody({
|
||||
: undefined
|
||||
const quickAgentSurface = quickAgentPick.agent === quickAgent ? quickAgentPick.surface : undefined
|
||||
const launchSelection = observation.selectionFor(quickAgent, quickAgentSurface)
|
||||
const agentMenuModel = orcaCloudModel ?? (cardProps.agentRows ? observation.model : null)
|
||||
const createDisabled = formDisabled || !launchSelection.ready
|
||||
|
||||
const handleQuickAgentChange = useCallback(
|
||||
@@ -223,9 +191,12 @@ function QuickTabBody({
|
||||
entry?: AgentPickerEntry
|
||||
) => {
|
||||
setQuickAgentOverride(agent)
|
||||
setQuickAgentPick({ agent, surface, draft, entry, contextKey: observation.contextKey })
|
||||
// Orca Cloud is terminal-only; keeping its surface would pin the agent to a terminal locally.
|
||||
setQuickAgentPick(
|
||||
orcaCloud ? { agent } : { agent, surface, draft, entry, contextKey: observation.contextKey }
|
||||
)
|
||||
},
|
||||
[observation.contextKey]
|
||||
[observation.contextKey, orcaCloud]
|
||||
)
|
||||
|
||||
const handleCreate = useCallback(async (): Promise<void> => {
|
||||
@@ -322,7 +293,7 @@ function QuickTabBody({
|
||||
// An Orca Cloud sandbox is a whole workspace, not a local worktree.
|
||||
const primaryActionLabel = isFolderWorkspaceTarget
|
||||
? getFolderWorkspacePrimaryActionLabel(t)
|
||||
: cardProps.selectedRepoIsGit && !isOrcaCloudSelection(cardProps)
|
||||
: cardProps.selectedRepoIsGit && !orcaCloud
|
||||
? t('auto.components.NewWorkspaceComposerModal.createWorktree', 'Create worktree')
|
||||
: t('auto.components.NewWorkspaceComposerModal.createWorkspace', 'Create workspace')
|
||||
|
||||
@@ -380,6 +351,8 @@ function QuickTabBody({
|
||||
quickAgent={quickAgent}
|
||||
agentPickerDraft={pickerDraft}
|
||||
agentSetupDisabled={
|
||||
// Orca Cloud's per-agent defaults live on a VM that does not exist yet.
|
||||
orcaCloud ||
|
||||
observation.model.state !== 'settled' ||
|
||||
observation.model.pickerContext?.executionHostId !== 'local'
|
||||
}
|
||||
@@ -389,9 +362,9 @@ function QuickTabBody({
|
||||
detectedAgentIds={chipAgentIds}
|
||||
createDisabled={createDisabled}
|
||||
agentSurfaceRows={
|
||||
cardProps.agentRows
|
||||
agentMenuModel
|
||||
? {
|
||||
model: observation.model,
|
||||
model: agentMenuModel,
|
||||
selection: launchSelection,
|
||||
surface: quickAgentSurface,
|
||||
contextKey: observation.contextKey,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, renderHook } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import { ORCA_CLOUD_IMAGE_AGENTS } from '../../../../shared/hosted-sandbox/orca-cloud-image-agents'
|
||||
import type { EphemeralVmRecipeOption } from './new-workspace-composer-card-props'
|
||||
|
||||
vi.mock('@/store/index', () => ({ useAppStore: { getState: () => ({}) } }))
|
||||
vi.mock('@/lib/host-chat-program', () => ({ agentChoicePending: () => false }))
|
||||
// This desktop has aider and claude; the host offers exactly what it detected.
|
||||
vi.mock('@/lib/host-chat-program-acquisition', () => ({
|
||||
useAgentsOfferedForLaunch: (_workspace: unknown, detected: ReadonlySet<TuiAgent> | null) =>
|
||||
detected
|
||||
}))
|
||||
|
||||
import { useComposerQuickAgent } from './use-composer-quick-agent'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const orcaCloudRecipe: EphemeralVmRecipeOption = {
|
||||
id: 'orca-cloud',
|
||||
name: 'Orca Cloud',
|
||||
create: 'orca-cloud',
|
||||
provider: 'orca-cloud'
|
||||
}
|
||||
const otherRecipe: EphemeralVmRecipeOption = { id: 'custom', name: 'Custom', create: './vm.sh' }
|
||||
|
||||
function renderComposerAgent(props: {
|
||||
recipe: EphemeralVmRecipeOption | null
|
||||
override?: TuiAgent | null
|
||||
settings?: Pick<GlobalSettings, 'defaultTuiAgent'>
|
||||
}) {
|
||||
return renderHook(
|
||||
({ recipe }: { recipe: EphemeralVmRecipeOption | null }) => {
|
||||
const [override, setOverride] = useState<TuiAgent | null | undefined>(props.override)
|
||||
return {
|
||||
override,
|
||||
...useComposerQuickAgent(
|
||||
{
|
||||
agentRows: null,
|
||||
detectedAgentIds: new Set<TuiAgent>(['aider', 'claude']),
|
||||
repoId: 'repo-1',
|
||||
selectedRepoIsGit: true,
|
||||
selectedRepoExecutionHostId: null,
|
||||
ephemeralVmRecipes: [orcaCloudRecipe, otherRecipe],
|
||||
selectedEphemeralVmRecipeId: recipe?.id ?? null
|
||||
},
|
||||
props.settings ?? null,
|
||||
override,
|
||||
setOverride
|
||||
)
|
||||
}
|
||||
},
|
||||
{ initialProps: { recipe: props.recipe } }
|
||||
)
|
||||
}
|
||||
|
||||
describe('useComposerQuickAgent', () => {
|
||||
it('offers only the image agents and an enabled terminal-only picker for Orca Cloud', () => {
|
||||
const { result } = renderComposerAgent({
|
||||
recipe: orcaCloudRecipe,
|
||||
settings: { defaultTuiAgent: 'aider' }
|
||||
})
|
||||
expect(result.current.orcaCloud).toBe(true)
|
||||
expect(result.current.quickAgent).toBe('claude')
|
||||
expect([...(result.current.chipAgentIds ?? [])]).toEqual([...ORCA_CLOUD_IMAGE_AGENTS])
|
||||
expect(result.current.orcaCloudModel?.state).toBe('settled')
|
||||
expect(result.current.orcaCloudModel?.rows.every((row) => row.kind === 'terminal')).toBe(true)
|
||||
})
|
||||
|
||||
it('never submits a local-only pick to Orca Cloud, and restores it on the local host', () => {
|
||||
const { result, rerender } = renderComposerAgent({ recipe: orcaCloudRecipe, override: 'aider' })
|
||||
expect(result.current.quickAgent).toBe('claude')
|
||||
// The pick itself is kept, so going back to this desktop restores it.
|
||||
expect(result.current.override).toBe('aider')
|
||||
rerender({ recipe: null })
|
||||
expect(result.current.orcaCloud).toBe(false)
|
||||
expect(result.current.quickAgent).toBe('aider')
|
||||
})
|
||||
|
||||
it('keeps a default the image ships', () => {
|
||||
const { result } = renderComposerAgent({
|
||||
recipe: orcaCloudRecipe,
|
||||
settings: { defaultTuiAgent: 'gemini' }
|
||||
})
|
||||
expect(result.current.quickAgent).toBe('gemini')
|
||||
})
|
||||
|
||||
it('leaves other VM recipes on this host inventory', () => {
|
||||
const { result } = renderComposerAgent({
|
||||
recipe: otherRecipe,
|
||||
settings: { defaultTuiAgent: 'aider' }
|
||||
})
|
||||
expect(result.current.orcaCloud).toBe(false)
|
||||
expect(result.current.orcaCloudModel).toBeNull()
|
||||
expect(result.current.quickAgent).toBe('aider')
|
||||
expect([...(result.current.chipAgentIds ?? [])].sort()).toEqual(['aider', 'claude'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useAppStore } from '@/store/index'
|
||||
import { agentChoicePending } from '@/lib/host-chat-program'
|
||||
import { useAgentsOfferedForLaunch } from '@/lib/host-chat-program-acquisition'
|
||||
import {
|
||||
composerLaunchWorkspace,
|
||||
pickQuickWorkspaceAgent,
|
||||
resolveQuickWorkspaceAgentSelection
|
||||
} from '@/lib/quick-workspace-agent-selection'
|
||||
import {
|
||||
ORCA_CLOUD_AGENT_IDS,
|
||||
orcaCloudAgentMenuModel,
|
||||
pickOrcaCloudAgent
|
||||
} from '@/lib/orca-cloud/orca-cloud-agent-picker'
|
||||
import type { TuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent'
|
||||
import type { NewTabAgentMenuSettings } from '../../../../shared/agent-launch/picker/agent-launch-picker-rows'
|
||||
import { isOrcaCloudSelection } from './run-target-options'
|
||||
import type { NewWorkspaceComposerCardProps } from './new-workspace-composer-card-props'
|
||||
|
||||
type ComposerQuickAgentProps = Pick<
|
||||
NewWorkspaceComposerCardProps,
|
||||
| 'agentRows'
|
||||
| 'detectedAgentIds'
|
||||
| 'repoId'
|
||||
| 'selectedRepoIsGit'
|
||||
| 'selectedRepoExecutionHostId'
|
||||
| 'ephemeralVmRecipes'
|
||||
| 'selectedEphemeralVmRecipeId'
|
||||
>
|
||||
|
||||
/** The composer's agent: the user's pick when the launch host can start it, else the default. */
|
||||
export function useComposerQuickAgent(
|
||||
cardProps: ComposerQuickAgentProps,
|
||||
settings: NewTabAgentMenuSettings | null | undefined,
|
||||
quickAgentOverride: TuiAgent | null | undefined,
|
||||
setQuickAgentOverride: (agent: TuiAgent | null | undefined) => void
|
||||
) {
|
||||
// The picker rows' workspace when known, so the offered list and the rows read one host.
|
||||
const launchWorkspace =
|
||||
cardProps.agentRows?.target.workspace ?? composerLaunchWorkspace(cardProps)
|
||||
const hostOfferedAgentIds = useAgentsOfferedForLaunch(launchWorkspace, cardProps.detectedAgentIds)
|
||||
// Orca Cloud has no VM yet: the image's agents are its inventory, never this desktop's CLIs.
|
||||
const orcaCloud = isOrcaCloudSelection(cardProps)
|
||||
const orcaCloudModel = useMemo(
|
||||
() => (orcaCloud ? orcaCloudAgentMenuModel(settings) : null),
|
||||
[orcaCloud, settings]
|
||||
)
|
||||
const detectedAgentIds = orcaCloud ? ORCA_CLOUD_AGENT_IDS : cardProps.detectedAgentIds
|
||||
const offeredAgentIds = orcaCloud ? ORCA_CLOUD_AGENT_IDS : hostOfferedAgentIds
|
||||
const detected = detectedAgentIds ? [...detectedAgentIds] : null
|
||||
const choicePending = (agent: TuiAgent): boolean =>
|
||||
!orcaCloud && agentChoicePending(useAppStore.getState(), agent, launchWorkspace, detected)
|
||||
// Pending discovery preserves the existing catalog fallback; current facts settle the default.
|
||||
const preferredQuickAgent = orcaCloud
|
||||
? pickOrcaCloudAgent(settings?.defaultTuiAgent, settings?.disabledTuiAgents)
|
||||
: pickQuickWorkspaceAgent(
|
||||
settings?.defaultTuiAgent,
|
||||
detectedAgentIds,
|
||||
settings?.disabledTuiAgents,
|
||||
offeredAgentIds,
|
||||
choicePending
|
||||
)
|
||||
// An explicit pick stays valid where it opens as a chat without its CLI.
|
||||
const resolved = resolveQuickWorkspaceAgentSelection({
|
||||
quickAgentOverride,
|
||||
preferredQuickAgent,
|
||||
detectedAgentIds,
|
||||
offeredAgentIds,
|
||||
selectedChoiceUnresolved: Boolean(quickAgentOverride && choicePending(quickAgentOverride)),
|
||||
disabledTuiAgents: settings?.disabledTuiAgents
|
||||
})
|
||||
// Repair invalid picks before children render them. Orca Cloud only derives its agent, so a
|
||||
// local-only pick returns when the user switches back to a host that has it.
|
||||
if (!orcaCloud && resolved.quickAgentOverride !== quickAgentOverride) {
|
||||
setQuickAgentOverride(resolved.quickAgentOverride)
|
||||
}
|
||||
const quickAgent = resolved.quickAgent
|
||||
// Chips list offered agents, keeping a pick whose chat is still being learned.
|
||||
const chipAgentIds =
|
||||
offeredAgentIds &&
|
||||
new Set([...offeredAgentIds, ...(quickAgent && choicePending(quickAgent) ? [quickAgent] : [])])
|
||||
return { orcaCloud, orcaCloudModel, quickAgent, chipAgentIds }
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ORCA_CLOUD_IMAGE_AGENTS } from '../../../../shared/hosted-sandbox/orca-cloud-image-agents'
|
||||
import { resolveQuickWorkspaceAgentSelection } from '@/lib/quick-workspace-agent-selection'
|
||||
import {
|
||||
ORCA_CLOUD_AGENT_IDS,
|
||||
orcaCloudAgentMenuModel,
|
||||
pickOrcaCloudAgent
|
||||
} from './orca-cloud-agent-picker'
|
||||
|
||||
describe('pickOrcaCloudAgent', () => {
|
||||
it('keeps a default the image ships', () => {
|
||||
expect(pickOrcaCloudAgent('gemini')).toBe('gemini')
|
||||
expect(pickOrcaCloudAgent('copilot')).toBe('copilot')
|
||||
})
|
||||
|
||||
it('falls back to the cloud default for an agent the image does not ship', () => {
|
||||
expect(pickOrcaCloudAgent('aider')).toBe('claude')
|
||||
expect(pickOrcaCloudAgent('goose')).toBe('claude')
|
||||
expect(pickOrcaCloudAgent(undefined)).toBe('claude')
|
||||
expect(pickOrcaCloudAgent(null)).toBe('claude')
|
||||
})
|
||||
|
||||
it('keeps Blank Terminal as the default when the user chose it', () => {
|
||||
expect(pickOrcaCloudAgent('blank')).toBeNull()
|
||||
})
|
||||
|
||||
it('skips disabled agents, staying inside the image', () => {
|
||||
expect(pickOrcaCloudAgent('gemini', ['gemini'])).toBe('claude')
|
||||
expect(pickOrcaCloudAgent('aider', ['claude'])).toBe('codex')
|
||||
expect(pickOrcaCloudAgent('aider', [...ORCA_CLOUD_IMAGE_AGENTS])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('orcaCloudAgentMenuModel', () => {
|
||||
it('lists exactly the image agents as terminal rows, default first', () => {
|
||||
const model = orcaCloudAgentMenuModel({ defaultTuiAgent: 'opencode' })
|
||||
expect(model.state).toBe('settled')
|
||||
expect(model.rows.every((row) => row.kind === 'terminal')).toBe(true)
|
||||
expect(model.rows.map((row) => row.agent)).toEqual([
|
||||
'opencode',
|
||||
'claude',
|
||||
'codex',
|
||||
'copilot',
|
||||
'gemini',
|
||||
'cursor'
|
||||
])
|
||||
expect(new Set(model.rows.map((row) => row.agent))).toEqual(new Set(ORCA_CLOUD_IMAGE_AGENTS))
|
||||
expect(model.detection.detectedIds).toEqual([...ORCA_CLOUD_IMAGE_AGENTS])
|
||||
expect(model.defaultTuiAgent).toBe('opencode')
|
||||
})
|
||||
|
||||
it('omits agents the user disabled and ignores a local-only default', () => {
|
||||
const model = orcaCloudAgentMenuModel({
|
||||
defaultTuiAgent: 'aider',
|
||||
disabledTuiAgents: ['codex']
|
||||
})
|
||||
const agents = model.rows.map((row) => row.agent)
|
||||
expect(agents).not.toContain('codex')
|
||||
expect(agents).not.toContain('aider')
|
||||
expect(agents).toHaveLength(ORCA_CLOUD_IMAGE_AGENTS.length - 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Orca Cloud composer selection repair', () => {
|
||||
it('repairs a local-only pick to the cloud default instead of submitting it', () => {
|
||||
const preferredQuickAgent = pickOrcaCloudAgent('aider')
|
||||
expect(
|
||||
resolveQuickWorkspaceAgentSelection({
|
||||
quickAgentOverride: 'aider',
|
||||
preferredQuickAgent,
|
||||
detectedAgentIds: ORCA_CLOUD_AGENT_IDS,
|
||||
offeredAgentIds: ORCA_CLOUD_AGENT_IDS
|
||||
})
|
||||
).toEqual({ quickAgent: 'claude', quickAgentOverride: 'claude' })
|
||||
})
|
||||
|
||||
it('keeps a pick the image ships', () => {
|
||||
expect(
|
||||
resolveQuickWorkspaceAgentSelection({
|
||||
quickAgentOverride: 'cursor',
|
||||
preferredQuickAgent: 'claude',
|
||||
detectedAgentIds: ORCA_CLOUD_AGENT_IDS,
|
||||
offeredAgentIds: ORCA_CLOUD_AGENT_IDS
|
||||
})
|
||||
).toEqual({ quickAgent: 'cursor', quickAgentOverride: 'cursor' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { TuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent'
|
||||
import { pickTuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent-selection'
|
||||
import {
|
||||
buildNewTabAgentMenuRows,
|
||||
type NewTabAgentMenuSettings
|
||||
} from '../../../../shared/agent-launch/picker/agent-launch-picker-rows'
|
||||
import {
|
||||
ORCA_CLOUD_DEFAULT_AGENT,
|
||||
ORCA_CLOUD_IMAGE_AGENTS,
|
||||
isOrcaCloudImageAgent
|
||||
} from '../../../../shared/hosted-sandbox/orca-cloud-image-agents'
|
||||
import type { NewTabAgentMenuModel } from '@/components/tab-bar/new-tab-agent-menu-model'
|
||||
|
||||
/**
|
||||
* The agents an Orca Cloud workspace can start before its VM exists: the image ships them, so the
|
||||
* composer treats them as installed instead of asking this desktop which CLIs it has.
|
||||
*/
|
||||
export const ORCA_CLOUD_AGENT_IDS: ReadonlySet<TuiAgent> = new Set(ORCA_CLOUD_IMAGE_AGENTS)
|
||||
|
||||
/** The user's default when the image ships it (or Blank Terminal), else the cloud default. */
|
||||
export function pickOrcaCloudAgent(
|
||||
preferred: TuiAgent | 'blank' | null | undefined,
|
||||
disabledTuiAgents?: Iterable<unknown> | null
|
||||
): TuiAgent | null {
|
||||
const start =
|
||||
preferred === 'blank' || (preferred && isOrcaCloudImageAgent(preferred))
|
||||
? preferred
|
||||
: ORCA_CLOUD_DEFAULT_AGENT
|
||||
return pickTuiAgent(start, ORCA_CLOUD_IMAGE_AGENTS, disabledTuiAgents)
|
||||
}
|
||||
|
||||
/**
|
||||
* Picker rows for the Orca Cloud image. Terminal only: whether an agent's chat runs on the VM is
|
||||
* not knowable before the VM exists, and the terminal is what create launches there today.
|
||||
*/
|
||||
export function orcaCloudAgentMenuModel(
|
||||
settings: NewTabAgentMenuSettings | null | undefined
|
||||
): Extract<NewTabAgentMenuModel, { state: 'settled' }> {
|
||||
const detectedIds: TuiAgent[] = [...ORCA_CLOUD_IMAGE_AGENTS]
|
||||
return {
|
||||
state: 'settled',
|
||||
rows: buildNewTabAgentMenuRows({
|
||||
settings,
|
||||
detectedOnHost: detectedIds,
|
||||
chatAgents: [],
|
||||
hostChatAnswer: () => 'no-chats',
|
||||
chatProgram: () => 'unknown'
|
||||
}),
|
||||
detection: {
|
||||
detectedIds,
|
||||
isLoading: false,
|
||||
detectionFailed: false,
|
||||
isRefreshing: false,
|
||||
needsServerUpdate: false,
|
||||
refresh: async () => detectedIds
|
||||
},
|
||||
defaultTuiAgent: settings?.defaultTuiAgent
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user