Resolve mobile new-worktree agent before commit (#3238)

This commit is contained in:
Neil 2026-05-30 19:19:00 -07:00 committed by GitHub
parent f2fa3ee904
commit 7366c3d32e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 218 additions and 67 deletions

View File

@ -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<string> | 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<Repo[]>([])
const [selectedRepo, setSelectedRepo] = useState<Repo | null>(null)
const [showRepoPicker, setShowRepoPicker] = useState(false)
const [selectedAgent, setSelectedAgent] = useState<AgentOption>(AGENT_OPTIONS[0]!)
const [selectedAgentState, setSelectedAgent] = useState<AgentOption>(AGENT_OPTIONS[0]!)
const [runtimeSettings, setRuntimeSettings] = useState<RuntimeSettings | null>(null)
const [detectedAgentIds, setDetectedAgentIds] = useState<Set<string> | null>(null)
const [agentOverridden, setAgentOverridden] = useState(false)
const [agentOverriddenState, setAgentOverridden] = useState(false)
const [showAgentPicker, setShowAgentPicker] = useState(false)
const [sshState, setSshState] = useState<SshConnectionState | null>(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

View File

@ -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__')
})
})

View File

@ -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<string> | null
): NewWorktreeAgentOption {
return newWorktreeAgentOptionFor(
pickWorkspaceAgent(
{
defaultTuiAgent: settings?.defaultTuiAgent,
disabledTuiAgents: settings?.disabledTuiAgents
},
detectedAgentIds
)
)
}
function isSelectableAgent(
agent: NewWorktreeAgentOption,
settings: NewWorktreeRuntimeSettings | null,
detectedAgentIds: Set<string> | 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<string> | 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 }
}