diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index 365de5316..05856e3bb 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -17,7 +17,6 @@ import { BottomDrawer } from './BottomDrawer' import { MobileAgentIcon } from './MobileAgentIcon' import { getSuggestedCreatureName } from './worktree-name-suggestion' import { deriveWorkspaceSshGate, workspaceSshStatusLabel } from '../tasks/workspace-ssh-gate' -import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog' import { WORKTREE_CREATE_TIMEOUT_MS } from '../tasks/workspace-create-timeout' import { isSetupHookTrusted, @@ -27,13 +26,19 @@ import { type SetupHookTrust } from '../tasks/setup-hook-trust' import { - filterEnabledMobileTuiAgents, isMobileTuiAgent, isMobileTuiAgentEnabled, MOBILE_TUI_AGENT_LAUNCH_COMMANDS } from '../tasks/mobile-tui-agents' import type { PersistedTrustedOrcaHooks, TuiAgent } from '../../../src/shared/types' import type { SshConnectionState } from '../../../src/shared/ssh-types' +import { + NEW_WORKTREE_AGENT_OPTIONS as AGENT_OPTIONS, + NEW_WORKTREE_BLANK_AGENT as BLANK_TERMINAL, + pickPreferredNewWorktreeAgent, + resolveNewWorktreeAgentSelection, + type NewWorktreeAgentOption as AgentOption +} from './new-worktree-agent-selection' type Repo = { id: string @@ -71,51 +76,6 @@ type SetupTrustPrompt = { previouslyApproved: boolean } -type AgentOption = { - id: TuiAgent | '__blank__' - label: string - faviconDomain?: string -} - -const AGENT_OPTIONS: AgentOption[] = MOBILE_AGENT_CATALOG - -const BLANK_TERMINAL: AgentOption = { id: '__blank__', label: 'Blank Terminal' } - -function agentOptionFor(id: string | null | undefined): AgentOption | null { - if (!id) return null - if (id === 'blank' || id === '__blank__') return BLANK_TERMINAL - return AGENT_OPTIONS.find((agent) => agent.id === id) ?? null -} - -function pickPreferredAgent( - settings: RuntimeSettings | null, - detectedAgentIds: Set | null -): AgentOption { - const preferred = agentOptionFor(settings?.defaultTuiAgent) - if (preferred?.id === '__blank__') { - return preferred - } - if ( - preferred && - isMobileTuiAgent(preferred.id) && - isMobileTuiAgentEnabled(preferred.id, settings?.disabledTuiAgents) && - (detectedAgentIds === null || detectedAgentIds.has(preferred.id)) - ) { - return preferred - } - const enabledAgents = filterEnabledMobileTuiAgents( - MOBILE_AGENT_CATALOG.map((agent) => agent.id), - settings?.disabledTuiAgents - ) - const detectedOption = AGENT_OPTIONS.find( - (agent) => - agent.id !== '__blank__' && - enabledAgents.includes(agent.id) && - (detectedAgentIds === null || detectedAgentIds.has(agent.id)) - ) - return detectedOption ?? BLANK_TERMINAL -} - function repoColor(name: string): string { const palette = ['#f97316', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#f59e0b', '#6366f1'] let hash = 0 @@ -209,10 +169,10 @@ export function NewWorktreeModal({ const [repos, setRepos] = useState([]) const [selectedRepo, setSelectedRepo] = useState(null) const [showRepoPicker, setShowRepoPicker] = useState(false) - const [selectedAgent, setSelectedAgent] = useState(AGENT_OPTIONS[0]!) + const [selectedAgentState, setSelectedAgent] = useState(AGENT_OPTIONS[0]!) const [runtimeSettings, setRuntimeSettings] = useState(null) const [detectedAgentIds, setDetectedAgentIds] = useState | null>(null) - const [agentOverridden, setAgentOverridden] = useState(false) + const [agentOverriddenState, setAgentOverridden] = useState(false) const [showAgentPicker, setShowAgentPicker] = useState(false) const [sshState, setSshState] = useState(null) const [sshConnecting, setSshConnecting] = useState(false) @@ -246,6 +206,23 @@ export function NewWorktreeModal({ state: sshState, connecting: sshConnecting }) + const selectedAgentResolution = resolveNewWorktreeAgentSelection({ + visible, + selectedAgent: selectedAgentState, + agentOverridden: agentOverriddenState, + runtimeSettings, + detectedAgentIds + }) + // Why: agent preference repair is pure render dataflow; doing it here + // avoids a stale selected-agent commit while preserving user overrides. + if ( + selectedAgentState.id !== selectedAgentResolution.selectedAgent.id || + agentOverriddenState !== selectedAgentResolution.agentOverridden + ) { + setSelectedAgent(selectedAgentResolution.selectedAgent) + setAgentOverridden(selectedAgentResolution.agentOverridden) + } + const selectedAgent = selectedAgentResolution.selectedAgent useEffect(() => { if (!visible) { @@ -383,23 +360,6 @@ export function NewWorktreeModal({ } }, [client, selectedRepoConnectionId, sshGate.status, visible]) - useEffect(() => { - if (!visible || agentOverridden) return - setSelectedAgent(pickPreferredAgent(runtimeSettings, detectedAgentIds)) - }, [agentOverridden, detectedAgentIds, runtimeSettings, visible]) - - useEffect(() => { - if (!visible || detectedAgentIds === null || selectedAgent.id === '__blank__') return - if ( - detectedAgentIds.has(selectedAgent.id) && - isMobileTuiAgentEnabled(selectedAgent.id, runtimeSettings?.disabledTuiAgents) - ) { - return - } - setSelectedAgent(pickPreferredAgent(runtimeSettings, detectedAgentIds)) - setAgentOverridden(false) - }, [detectedAgentIds, runtimeSettings, selectedAgent.id, visible]) - useEffect(() => { if (!client || !selectedRepo) { setSetupCommand(null) @@ -526,7 +486,7 @@ export function NewWorktreeModal({ selectedAgent.id !== '__blank__' && !isMobileTuiAgentEnabled(selectedAgent.id, latestRuntimeSettings?.disabledTuiAgents) ) { - setSelectedAgent(pickPreferredAgent(latestRuntimeSettings, detectedAgentIds)) + setSelectedAgent(pickPreferredNewWorktreeAgent(latestRuntimeSettings, detectedAgentIds)) setAgentOverridden(false) setError('Selected agent is disabled. Choose an enabled agent before creating.') return diff --git a/mobile/src/components/new-worktree-agent-selection.test.ts b/mobile/src/components/new-worktree-agent-selection.test.ts new file mode 100644 index 000000000..dde450d66 --- /dev/null +++ b/mobile/src/components/new-worktree-agent-selection.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' + +import { + NEW_WORKTREE_BLANK_AGENT, + newWorktreeAgentOptionFor, + pickPreferredNewWorktreeAgent, + resolveNewWorktreeAgentSelection +} from './new-worktree-agent-selection' + +describe('new worktree agent selection', () => { + it('picks the preferred detected agent when there is no user override', () => { + const selected = newWorktreeAgentOptionFor('claude') + const resolved = resolveNewWorktreeAgentSelection({ + visible: true, + selectedAgent: selected, + agentOverridden: false, + runtimeSettings: { defaultTuiAgent: 'codex' }, + detectedAgentIds: new Set(['claude', 'codex']) + }) + + expect(resolved).toEqual({ + selectedAgent: newWorktreeAgentOptionFor('codex'), + agentOverridden: false + }) + }) + + it('keeps an available user override', () => { + const selected = newWorktreeAgentOptionFor('codex') + const resolved = resolveNewWorktreeAgentSelection({ + visible: true, + selectedAgent: selected, + agentOverridden: true, + runtimeSettings: { defaultTuiAgent: 'claude' }, + detectedAgentIds: new Set(['claude', 'codex']) + }) + + expect(resolved).toEqual({ selectedAgent: selected, agentOverridden: true }) + }) + + it('clears an unavailable user override after detection completes', () => { + const resolved = resolveNewWorktreeAgentSelection({ + visible: true, + selectedAgent: newWorktreeAgentOptionFor('codex'), + agentOverridden: true, + runtimeSettings: { defaultTuiAgent: 'claude' }, + detectedAgentIds: new Set(['claude']) + }) + + expect(resolved).toEqual({ + selectedAgent: newWorktreeAgentOptionFor('claude'), + agentOverridden: false + }) + }) + + it('clears a disabled user override after detection completes', () => { + const resolved = resolveNewWorktreeAgentSelection({ + visible: true, + selectedAgent: newWorktreeAgentOptionFor('codex'), + agentOverridden: true, + runtimeSettings: { defaultTuiAgent: 'claude', disabledTuiAgents: ['codex'] }, + detectedAgentIds: new Set(['claude', 'codex']) + }) + + expect(resolved).toEqual({ + selectedAgent: newWorktreeAgentOptionFor('claude'), + agentOverridden: false + }) + }) + + it('keeps blank terminal as an explicit override', () => { + const resolved = resolveNewWorktreeAgentSelection({ + visible: true, + selectedAgent: NEW_WORKTREE_BLANK_AGENT, + agentOverridden: true, + runtimeSettings: { defaultTuiAgent: 'claude' }, + detectedAgentIds: new Set(['claude']) + }) + + expect(resolved).toEqual({ + selectedAgent: NEW_WORKTREE_BLANK_AGENT, + agentOverridden: true + }) + }) + + it('leaves closed modal state untouched', () => { + const selected = newWorktreeAgentOptionFor('codex') + const resolved = resolveNewWorktreeAgentSelection({ + visible: false, + selectedAgent: selected, + agentOverridden: true, + runtimeSettings: { defaultTuiAgent: 'claude' }, + detectedAgentIds: new Set(['claude']) + }) + + expect(resolved).toEqual({ selectedAgent: selected, agentOverridden: true }) + }) + + it('uses blank when no detected agent is known', () => { + expect(pickPreferredNewWorktreeAgent({ defaultTuiAgent: null }, new Set()).id).toBe('__blank__') + }) +}) diff --git a/mobile/src/components/new-worktree-agent-selection.ts b/mobile/src/components/new-worktree-agent-selection.ts new file mode 100644 index 000000000..22101c00a --- /dev/null +++ b/mobile/src/components/new-worktree-agent-selection.ts @@ -0,0 +1,90 @@ +import type { TuiAgent } from '../../../src/shared/types' +import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog' +import { isMobileTuiAgentEnabled } from '../tasks/mobile-tui-agents' +import { pickWorkspaceAgent } from '../tasks/workspace-agent-selection' + +export type NewWorktreeRuntimeSettings = { + defaultTuiAgent?: TuiAgent | 'blank' | null + disabledTuiAgents?: TuiAgent[] +} + +export type NewWorktreeAgentOption = { + id: TuiAgent | '__blank__' + label: string + faviconDomain?: string +} + +export const NEW_WORKTREE_AGENT_OPTIONS: NewWorktreeAgentOption[] = MOBILE_AGENT_CATALOG + +export const NEW_WORKTREE_BLANK_AGENT: NewWorktreeAgentOption = { + id: '__blank__', + label: 'Blank Terminal' +} + +export function newWorktreeAgentOptionFor(id: string | null | undefined): NewWorktreeAgentOption { + if (id === 'blank' || id === '__blank__') { + return NEW_WORKTREE_BLANK_AGENT + } + return NEW_WORKTREE_AGENT_OPTIONS.find((agent) => agent.id === id) ?? NEW_WORKTREE_BLANK_AGENT +} + +export function pickPreferredNewWorktreeAgent( + settings: NewWorktreeRuntimeSettings | null, + detectedAgentIds: Set | null +): NewWorktreeAgentOption { + return newWorktreeAgentOptionFor( + pickWorkspaceAgent( + { + defaultTuiAgent: settings?.defaultTuiAgent, + disabledTuiAgents: settings?.disabledTuiAgents + }, + detectedAgentIds + ) + ) +} + +function isSelectableAgent( + agent: NewWorktreeAgentOption, + settings: NewWorktreeRuntimeSettings | null, + detectedAgentIds: Set | null +): boolean { + if (agent.id === '__blank__') { + return true + } + if (!isMobileTuiAgentEnabled(agent.id, settings?.disabledTuiAgents)) { + return false + } + return detectedAgentIds === null || detectedAgentIds.has(agent.id) +} + +export function resolveNewWorktreeAgentSelection({ + visible, + selectedAgent, + agentOverridden, + runtimeSettings, + detectedAgentIds +}: { + visible: boolean + selectedAgent: NewWorktreeAgentOption + agentOverridden: boolean + runtimeSettings: NewWorktreeRuntimeSettings | null + detectedAgentIds: Set | null +}): { selectedAgent: NewWorktreeAgentOption; agentOverridden: boolean } { + if (!visible) { + return { selectedAgent, agentOverridden } + } + + const preferred = pickPreferredNewWorktreeAgent(runtimeSettings, detectedAgentIds) + if (!agentOverridden) { + return { selectedAgent: preferred, agentOverridden: false } + } + + if ( + detectedAgentIds !== null && + !isSelectableAgent(selectedAgent, runtimeSettings, detectedAgentIds) + ) { + return { selectedAgent: preferred, agentOverridden: false } + } + + return { selectedAgent, agentOverridden: true } +}