Derive composer hooks by host context for React Doctor

Key loaded hooks and issue commands by execution-host context so
repo/host switches no longer reset derived state in effects (the
static-analysis gate). Also wire SSH-aware workspace targets and
cancel-safe submit settlement for workspace creation.
This commit is contained in:
Jinjing
2026-08-05 00:30:02 -07:00
parent 646cad24f0
commit e2abd59222
16 changed files with 1155 additions and 250 deletions
@@ -1,4 +1,4 @@
import React, { Suspense, useCallback, useEffect, useMemo, useState } from 'react'
import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useAppStore } from '@/store'
import { lazyWithRetry } from '@/lib/lazy-with-retry'
import {
@@ -61,42 +61,29 @@ export default function NewWorkspaceComposerModal(): React.JSX.Element | null {
const modalData = useAppStore((s) => s.modalData as ComposerModalData | undefined)
const closeModal = useAppStore((s) => s.closeModal)
// Why: Dialog open-state transitions must be driven by the store, not a
// mirror useState, so palette/open-modal calls feel instantaneous and the
// modal doesn't linger with stale data after close.
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) {
closeModal()
}
},
[closeModal]
)
if (!visible) {
return null
}
return (
<ComposerModalBody
modalData={modalData ?? {}}
onClose={closeModal}
onOpenChange={handleOpenChange}
/>
)
return <ComposerModalBody modalData={modalData ?? {}} onClose={closeModal} />
}
function ComposerModalBody({
modalData,
onClose,
onOpenChange
onClose
}: {
modalData: ComposerModalData
onClose: () => void
onOpenChange: (open: boolean) => void
}): React.JSX.Element {
const submitCancelledRef = useRef(false)
const handleDismiss = useCallback(() => {
submitCancelledRef.current = true
onClose()
}, [onClose])
const isSubmissionCancelled = useCallback(() => submitCancelledRef.current, [])
return (
<Dialog open onOpenChange={onOpenChange}>
<Dialog open onOpenChange={(open) => !open && handleDismiss()}>
<DialogContent
className="flex max-h-[calc(100vh-2rem)] flex-col overflow-hidden sm:max-w-lg"
onOpenAutoFocus={(event) => {
@@ -109,7 +96,13 @@ function ComposerModalBody({
getWorkspaceComposerInitialFocusTarget(content)?.focus({ preventScroll: true })
}}
>
<QuickTabBody modalData={modalData} onClose={onClose} active />
<QuickTabBody
modalData={modalData}
onClose={onClose}
onDismiss={handleDismiss}
isSubmissionCancelled={isSubmissionCancelled}
active
/>
</DialogContent>
</Dialog>
)
@@ -118,10 +111,14 @@ function ComposerModalBody({
function QuickTabBody({
modalData,
onClose,
onDismiss,
isSubmissionCancelled,
active
}: {
modalData: ComposerModalData
onClose: () => void
onDismiss: () => void
isSubmissionCancelled: () => boolean
active: boolean
}): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
@@ -148,6 +145,7 @@ function QuickTabBody({
...(modalData.initialBaseBranch ? { initialBaseBranch: modalData.initialBaseBranch } : {}),
persistDraft: false,
onCreated: onClose,
isSubmissionCancelled,
...(modalData.telemetrySource ? { telemetrySource: modalData.telemetrySource } : {}),
enableIssueAutomation: modalData.enableIssueAutomation === true,
createGateMode: 'quick'
@@ -271,7 +269,7 @@ function QuickTabBody({
return
}
event.preventDefault()
onClose()
onDismiss()
return
}
@@ -291,7 +289,7 @@ function QuickTabBody({
}
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [active, composerRef, createDisabled, handleCreate, nestedDialogOpen, onClose])
}, [active, composerRef, createDisabled, handleCreate, nestedDialogOpen, onDismiss])
return (
<>
@@ -52,9 +52,9 @@ describe('TaskPage workspace creation source boundaries', () => {
'const submitQuick = useCallback(',
'const createGateInput = {'
)
expect(quickSubmit).toContain(
"ensureHooksConfirmed(useAppStore.getState(), repoId, 'issueCommand')"
)
expect(quickSubmit).toContain('readAndConfirmRuntimeIssueCommand(')
expect(quickSubmit).toContain('selectedRepoExecutionHostId')
expect(quickSubmit).toContain('isSubmissionCancelled')
expect(quickSubmit).toContain('...(issueCommand ? { issueCommand } : {})')
})
@@ -423,13 +423,13 @@ describe('useComposerState host-context boundaries', () => {
'const submit = useCallback',
'const submitQuick = useCallback'
)
const fullPolicySave = fullSubmit.indexOf('await persistSetupAgentStartupPolicy()')
const fullPolicySave = fullSubmit.indexOf('persistSetupAgentStartupPolicy()')
const fullCreate = fullSubmit.indexOf('const result = await createWorktree(')
expect(fullPolicySave).toBeGreaterThanOrEqual(0)
expect(fullCreate).toBeGreaterThan(fullPolicySave)
const quickSubmit = sourceBetween(HOOK_SOURCE, 'const submitQuick = useCallback', 'return {')
const quickPolicySave = quickSubmit.indexOf('await persistSetupAgentStartupPolicy()')
const quickPolicySave = quickSubmit.indexOf('persistSetupAgentStartupPolicy()')
const quickCreate = quickSubmit.indexOf('const request: WorktreeCreationRequest = {')
expect(quickPolicySave).toBeGreaterThanOrEqual(0)
expect(quickCreate).toBeGreaterThan(quickPolicySave)
@@ -464,8 +464,9 @@ describe('useComposerState host-context boundaries', () => {
)
expect(section).toContain('canResolveFolderSmartGitHubSubmit')
expect(section).toContain('hasFolderSourceRepos: folderSourceRepos.length > 0')
expect(section).toContain('? await resolvePendingSmartGitHubSubmit()')
expect(section).toContain(': null')
expect(section).toContain('? resolvePendingSmartGitHubSubmit()')
expect(section).toContain("Promise.resolve({ kind: 'none' } as const)")
expect(section).toContain('isSubmissionCancelled')
expect(section).not.toContain('folderSourceRequiresConnection')
})
@@ -0,0 +1,62 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { retargetGitHubPrStartPointSelection } from './useComposerState'
const HOOK_SOURCE = readFileSync(join(__dirname, 'useComposerState.ts'), 'utf8')
function sourceBetween(source: string, startPattern: string, endPattern: string): string {
const start = source.indexOf(startPattern)
expect(start).toBeGreaterThanOrEqual(0)
const end = source.indexOf(endPattern, start + startPattern.length)
expect(end).toBeGreaterThan(start)
return source.slice(start, end)
}
describe('useComposerState host retarget', () => {
it('re-resolves a seeded PR after switching its run host', () => {
const item = {
id: 'pr-42',
type: 'pr' as const,
number: 42,
title: 'Fix PR workspace creation',
state: 'open' as const,
url: 'https://github.com/stablyai/orca/pull/42',
labels: [],
updatedAt: '2026-08-04T00:00:00.000Z',
author: 'octocat',
repoId: 'repo-local'
}
const selection = {
repoId: 'repo-local',
item,
resolved: {
baseBranch: 'local-head',
compareBaseRef: 'origin/main'
}
}
expect(retargetGitHubPrStartPointSelection(selection, 'repo-ssh')).toEqual({
repoId: 'repo-ssh',
item
})
})
it('retains an explicit host setup when duplicate repos share an id', () => {
const targetSection = sourceBetween(
HOOK_SOURCE,
'const selectedWorkspaceTarget = useMemo',
'const selectedRepo ='
)
expect(targetSection).toContain('projectHostSetupId: selectedProjectHostSetupOverrideId')
const switchSection = sourceBetween(
HOOK_SOURCE,
'const handleProjectHostSetupChange',
'const handleProjectChange'
)
expect(switchSection).toContain('setSelectedProjectHostSetupOverrideId(option.id)')
expect(switchSection).toContain('preserveStartFrom: true')
expect(switchSection).toContain('forceResetStartFrom: true')
})
})
+364 -120
View File
@@ -15,7 +15,10 @@ import {
} from '@/lib/github-links'
import { activateAndRevealWorktree, type AgentStartedTelemetry } from '@/lib/worktree-activation'
import { runBackgroundWorktreeCreation } from '@/lib/worktree-creation-flow'
import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
import {
findPendingLinkedWorkItemCreationId,
type WorktreeCreationRequest
} from '@/lib/pending-worktree-creation'
import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup'
import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../shared/tui-agent-selection'
import { repoIsRemote } from '../../../shared/agent-launch-remote'
@@ -168,14 +171,19 @@ import {
shouldPreserveWorkspaceSourceOnRepoChange
} from '../../../shared/new-workspace/workspace-source'
import { CONTEXTUAL_TOUR_ENABLE_AUTO_WORKSPACE_NAME_EVENT } from '@/components/contextual-tours/contextual-tour-composer-events'
import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
import {
confirmRuntimeIssueCommandRead,
ensureHooksConfirmed,
readAndConfirmRuntimeIssueCommand
} from '@/lib/ensure-hooks-confirmed'
import { normalizeSparseDirectoryLines, sparseDirectoriesMatch } from '@/lib/sparse-paths'
import { joinPath } from '@/lib/path'
import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client'
import {
checkRuntimeHooks,
readRuntimeIssueCommand,
type HookCheckResult
type HookCheckResult,
type IssueCommandReadResult
} from '@/runtime/runtime-hooks-client'
import {
formatWorkspaceCreateError,
@@ -198,10 +206,10 @@ import {
import { translate } from '@/i18n/i18n'
import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context'
import { resolveJiraSourceHostId } from '@/lib/jira-source-host'
import {
buildTrustedComposerIssueCommand,
shouldPrepareComposerIssueCommand
} from '@/lib/composer-issue-command'
import { buildTrustedComposerIssueCommand } from '@/lib/composer-issue-command'
import { settleComposerSubmit } from '@/lib/composer-submit-cancellation'
const NEVER_CANCEL_COMPOSER_SUBMIT = (): boolean => false
export function canResolveFolderSmartGitHubSubmit({
hasFolderSourceRepos
@@ -244,6 +252,7 @@ export type UseComposerStateOptions = {
persistDraft: boolean
/** Invoked after a successful createWorktree; the caller usually closes its surface (palette modal, full page, etc.). */
onCreated?: () => void
isSubmissionCancelled?: () => boolean
/** External repoId override — used by TaskPage's work-item list, which drives repo selection from the page header, not the card. */
repoIdOverride?: string
onRepoIdOverrideChange?: (value: string) => void
@@ -545,6 +554,13 @@ export function getInitialGitHubPrStartPointSelection({
}
}
export function retargetGitHubPrStartPointSelection(
selection: SmartGitHubPrStartPointSelection | null,
repoId: string
): SmartGitHubPrStartPointSelection | null {
return selection ? { repoId, item: selection.item } : null
}
export function getMatchingLinkedTaskSourceContext(
item: LinkedWorkItemSummary | null | undefined,
context: TaskSourceContext | null | undefined
@@ -586,6 +602,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
initialBaseBranch,
persistDraft,
onCreated,
isSubmissionCancelled = NEVER_CANCEL_COMPOSER_SUBMIT,
repoIdOverride,
onRepoIdOverrideChange,
telemetrySource,
@@ -699,7 +716,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
[initialWorkspaceStatus, workspaceStatuses]
)
const resolvedInitialRepoId = resolveWorkspaceCreationRepoId({
const resolvedInitialWorkspaceTarget = resolveWorkspaceCreationTarget({
eligibleRepos,
projects,
projectHostSetups,
@@ -712,8 +729,19 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
focusedHostScope: workspaceHostScope,
actionableHostIds
})
const resolvedInitialRepoId =
resolvedInitialWorkspaceTarget.status === 'ready'
? resolvedInitialWorkspaceTarget.target.repoId
: ''
const [internalRepoId, setInternalRepoId] = useState<string>(resolvedInitialRepoId)
const [selectedProjectHostSetupOverrideId, setSelectedProjectHostSetupOverrideId] = useState<
string | null
>(
resolvedInitialWorkspaceTarget.status === 'ready'
? resolvedInitialWorkspaceTarget.target.projectHostSetupId
: null
)
const initialFolderProjectGroupId = initialProjectGroupId ?? draftProjectGroupId
const initialFolderProjectGroup = findActionableFolderProjectGroup({
projectGroups,
@@ -806,13 +834,29 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
projects,
projectHostSetups,
draftRepoId: repoId,
projectHostSetupId: selectedProjectHostSetupOverrideId,
focusedHostScope: workspaceHostScope,
actionableHostIds
}),
[actionableHostIds, eligibleRepos, projectHostSetups, projects, repoId, workspaceHostScope]
[
actionableHostIds,
eligibleRepos,
projectHostSetups,
projects,
repoId,
selectedProjectHostSetupOverrideId,
workspaceHostScope
]
)
const selectedRepo = eligibleRepos.find((repo) => repo.id === repoId)
const selectedRepo =
selectedWorkspaceTarget.status === 'ready' && selectedWorkspaceTarget.target.repoId === repoId
? selectedWorkspaceTarget.target.repo
: eligibleRepos.find((repo) => repo.id === repoId)
const selectedRepoIsGit = selectedRepo ? isGitRepoKind(selectedRepo) : false
const selectedRepoExecutionHostId = selectedRepo ? getRepoExecutionHostId(selectedRepo) : null
const selectedRepoHookContextKey = selectedRepo
? JSON.stringify([selectedRepoExecutionHostId ?? 'local', repoId])
: null
const selectedRepoAgentLaunchPlatform = useMemo(() => {
if (!selectedRepo) {
return CLIENT_PLATFORM
@@ -1163,9 +1207,16 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
)
const [yamlHooks, setYamlHooks] = useState<OrcaHooks | null>(null)
const [checkedHooksRepoId, setCheckedHooksRepoId] = useState<string | null>(null)
const [issueCommandTemplate, setIssueCommandTemplate] = useState('')
const [hasLoadedIssueCommand, setHasLoadedIssueCommand] = useState(false)
const [checkedHooksContextKey, setCheckedHooksContextKey] = useState<string | null>(null)
const [loadedIssueCommand, setLoadedIssueCommand] = useState<{
contextKey: string
result: IssueCommandReadResult
} | null>(null)
const currentIssueCommand =
loadedIssueCommand?.contextKey === selectedRepoHookContextKey ? loadedIssueCommand.result : null
const issueCommandTemplate = currentIssueCommand?.effectiveContent ?? ''
const hasLoadedIssueCommand =
!selectedRepoIsGit || !enableIssueAutomation || currentIssueCommand !== null
const [setupDecision, setSetupDecision] = useState<'run' | 'skip' | null>(null)
const [setupAgentStartupPolicy, setSetupAgentStartupPolicy] = useState<SetupAgentStartupPolicy>(
() => getRepoSetupAgentStartupPolicy(selectedRepo)
@@ -1373,26 +1424,33 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
key: string
promise: Promise<HookCheckResult>
} | null>(null)
const loadHookCheckForRepo = useCallback((targetRepoId: string): Promise<HookCheckResult> => {
const key = `${selectedRepoSettingsRef.current?.activeRuntimeEnvironmentId ?? 'local'}:${targetRepoId}`
const existing = hookCheckRef.current
if (existing?.key === key) {
return existing.promise
}
const promise = checkRuntimeHooks(selectedRepoSettingsRef.current, targetRepoId)
hookCheckRef.current = { key, promise }
return promise
}, [])
const loadHookCheckForRepo = useCallback(
(targetRepoId: string): Promise<HookCheckResult> => {
const key = JSON.stringify([selectedRepoExecutionHostId ?? 'local', targetRepoId])
const existing = hookCheckRef.current
if (existing?.key === key) {
return existing.promise
}
const promise = checkRuntimeHooks(
selectedRepoSettingsRef.current,
targetRepoId,
selectedRepoExecutionHostId ?? undefined
)
hookCheckRef.current = { key, promise }
return promise
},
[selectedRepoExecutionHostId]
)
const commitHookCheckIfCurrent = useCallback(
(targetRepoId: string, hooks: OrcaHooks | null): boolean => {
if (repoIdRef.current !== targetRepoId) {
(targetContextKey: string, hooks: OrcaHooks | null): boolean => {
if (selectedRepoHookContextKey !== targetContextKey) {
return false
}
setYamlHooks(hooks)
setCheckedHooksRepoId(targetRepoId)
setCheckedHooksContextKey(targetContextKey)
return true
},
[]
[selectedRepoHookContextKey]
)
useEffect(() => {
if (!selectedRepo || !selectedRepoPath || !selectedRepoIsGit) {
@@ -1490,9 +1548,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
}
return null
}, [linkedPR, name, selectedRepoSlug])
const currentYamlHooks = checkedHooksContextKey === selectedRepoHookContextKey ? yamlHooks : null
const setupConfig = useMemo(
() => (selectedRepoIsGit ? getSetupConfig(selectedRepo, yamlHooks) : null),
[selectedRepo, selectedRepoIsGit, yamlHooks]
() => (selectedRepoIsGit ? getSetupConfig(selectedRepo, currentYamlHooks) : null),
[currentYamlHooks, selectedRepo, selectedRepoIsGit]
)
const setupPolicy: SetupRunPolicy = selectedRepo?.hookSettings?.setupRunPolicy ?? 'run-by-default'
const linkedWorkItemProvider = linkedWorkItem ? getLinkedWorkItemProvider(linkedWorkItem) : null
@@ -1514,7 +1573,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
: setupPolicy === 'run-by-default'
? 'run'
: 'skip')
const isSetupCheckPending = Boolean(repoId) && checkedHooksRepoId !== repoId
const isSetupCheckPending =
selectedRepoIsGit &&
Boolean(selectedRepoHookContextKey) &&
checkedHooksContextKey !== selectedRepoHookContextKey
const shouldWaitForSetupCheck = Boolean(selectedRepo) && selectedRepoIsGit && isSetupCheckPending
// Why: blank name with no other seed → globally-unique creature name so workspaces don't collide across repos or on a literal default.
@@ -1723,38 +1785,25 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
// Per-repo: load yaml hooks + issue command template.
useEffect(() => {
if (!repoId) {
if (!repoId || !selectedRepoIsGit || !selectedRepoHookContextKey) {
return
}
let cancelled = false
setHasLoadedIssueCommand(false)
setIssueCommandTemplate('')
setYamlHooks(null)
setCheckedHooksRepoId(null)
if (!selectedRepoIsGit) {
setHasLoadedIssueCommand(true)
setCheckedHooksRepoId(repoId)
return () => {
cancelled = true
}
}
void loadHookCheckForRepo(repoId)
.then((result) => {
if (!cancelled) {
commitHookCheckIfCurrent(repoId, result.hooks)
commitHookCheckIfCurrent(selectedRepoHookContextKey, result.hooks)
}
})
.catch(() => {
if (!cancelled) {
commitHookCheckIfCurrent(repoId, null)
commitHookCheckIfCurrent(selectedRepoHookContextKey, null)
}
})
if (!enableIssueAutomation) {
setHasLoadedIssueCommand(true)
return () => {
cancelled = true
}
@@ -1766,27 +1815,36 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
}
}
void readRuntimeIssueCommand(selectedRepoSettingsRef.current, repoId)
void readRuntimeIssueCommand(
selectedRepoSettingsRef.current,
repoId,
selectedRepoExecutionHostId ?? undefined
)
.then((result) => {
if (!cancelled) {
setIssueCommandTemplate(result.effectiveContent ?? '')
setHasLoadedIssueCommand(true)
setLoadedIssueCommand({ contextKey: selectedRepoHookContextKey, result })
}
})
.catch(() => {
if (!cancelled) {
setIssueCommandTemplate('')
setHasLoadedIssueCommand(true)
setLoadedIssueCommand({
contextKey: selectedRepoHookContextKey,
result: {
status: 'error',
localContent: null,
sharedContent: null,
effectiveContent: null,
localFilePath: '',
source: 'none'
}
})
}
})
return () => {
cancelled = true
}
// Why: key on the stable runtime-env id, not the selectedRepoSettings object. `updateRepo`
// (e.g. saving the setup toggle from this very composer) replaces selectedRepo — and thus the
// memoized selectedRepoSettings — by reference; depending on the object would re-run this
// effect, blank yamlHooks to null, and make the whole setup section vanish for a frame.
// Why: repo identity fields stay stable when updateRepo replaces the repo object by reference.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
commitHookCheckIfCurrent,
@@ -1794,6 +1852,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
enableIssueAutomation,
loadHookCheckForRepo,
repoId,
selectedRepoExecutionHostId,
selectedRepoHookContextKey,
selectedRepoIsGit,
runtimeEnvironmentId
])
@@ -2614,6 +2674,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
): void => {
setProjectError(null)
if (value === repoId && !options.forceResetStartFrom) {
if (!options.preserveStartFrom) {
setSelectedProjectHostSetupOverrideId(null)
}
setRepoId(value)
return
}
@@ -2634,6 +2697,22 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
? getLinearLinkedWorkItemBranchName(linkedWorkItem)
: undefined
setRepoId(value)
if (!options.preserveStartFrom) {
setSelectedProjectHostSetupOverrideId(null)
}
if (options.preserveStartFrom && smartGitHubPrStartPointSelectionRef.current) {
smartGitHubPrStartPointSelectionRef.current = retargetGitHubPrStartPointSelection(
smartGitHubPrStartPointSelectionRef.current,
value
)
setBaseBranch(undefined)
setCompareBaseRef(undefined)
setPushTarget(undefined)
setBranchNameOverride(undefined)
setBranchNameOverridePreservesNameEdits(false)
branchAutoNameRef.current = ''
setForkPushWarning(null)
}
if (!options.preserveStartFrom) {
smartGitHubPrStartPointSelectionRef.current = null
setLinkedIssue('')
@@ -2697,7 +2776,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
return
}
// Why: switching run host for the same project must not erase the task/PR source the user is starting from.
handleRepoChange(option.repoId, { preserveStartFrom: true })
setSelectedProjectHostSetupOverrideId(option.id)
handleRepoChange(option.repoId, {
preserveStartFrom: true,
forceResetStartFrom: true
})
},
[handleRepoChange, projectHostSetupOptions]
)
@@ -3338,15 +3421,25 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const shouldResolveSmartGitHubSubmit = canResolveFolderSmartGitHubSubmit({
hasFolderSourceRepos: folderSourceRepos.length > 0
})
const smartGitHubResolution = shouldResolveSmartGitHubSubmit
? await resolvePendingSmartGitHubSubmit()
: ({ kind: 'none' } as const)
const smartGitHubSettlement = await settleComposerSubmit(
shouldResolveSmartGitHubSubmit
? resolvePendingSmartGitHubSubmit()
: Promise.resolve({ kind: 'none' } as const),
isSubmissionCancelled
)
if (smartGitHubSettlement.status === 'cancelled') {
return
}
const smartGitHubResolution = smartGitHubSettlement.value
const smartGitHubMetadata =
smartGitHubResolution.kind === 'none' ? null : smartGitHubResolution
const agent =
requestedAgent && isTuiAgentEnabled(requestedAgent, disabledTuiAgents)
? requestedAgent
: null
if (isSubmissionCancelled()) {
return
}
const folderWorkspaceCreated = await submitFolderWorkspaceCreate({
projectGroup: selectedProjectGroup,
name: smartGitHubMetadata?.workspaceName ?? name,
@@ -3394,6 +3487,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
})
}
} catch (error) {
if (isSubmissionCancelled()) {
return
}
const formattedError = formatWorkspaceCreateError(error)
setCreateError(formattedError)
toast.error(getWorkspaceCreateErrorToastMessage(formattedError))
@@ -3409,6 +3505,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
folderTargetIsRemote,
folderTargetRuntimeEnvironmentId,
folderSourceRepos.length,
isSubmissionCancelled,
linkedWorkItem,
name,
note,
@@ -3461,7 +3558,14 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setCreateError(null)
setCreating(true)
try {
const smartGitHubResolution = await resolvePendingSmartGitHubSubmit()
const smartGitHubSettlement = await settleComposerSubmit(
resolvePendingSmartGitHubSubmit(),
isSubmissionCancelled
)
if (smartGitHubSettlement.status === 'cancelled') {
return
}
const smartGitHubResolution = smartGitHubSettlement.value
const submitLinkedWorkItem =
smartGitHubResolution.kind === 'none'
? linkedWorkItem
@@ -3564,20 +3668,56 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
issueCommandTemplate.length > 0 &&
!submitShouldApplyLinkedOnlyTemplate
const setupTrustDecision = selectedRepoIsGit
? await ensureHooksConfirmed(useAppStore.getState(), repoId, 'setup')
: 'skip'
const setupTrustSettlement = await settleComposerSubmit(
selectedRepoIsGit
? ensureHooksConfirmed(
useAppStore.getState(),
repoId,
'setup',
selectedRepoExecutionHostId ?? undefined,
undefined,
isSubmissionCancelled
)
: Promise.resolve<'skip'>('skip'),
isSubmissionCancelled
)
if (setupTrustSettlement.status === 'cancelled') {
return
}
const setupTrustDecision = setupTrustSettlement.value
const effectiveSetupDecision: SetupDecision =
setupTrustDecision === 'skip'
? 'skip'
: ((resolvedSetupDecision ?? 'inherit') as SetupDecision)
let issueCommandTrustDecision: 'run' | 'skip' = 'run'
if (selectedRepoIsGit && submitShouldRunIssueAutomation) {
issueCommandTrustDecision =
setupTrustDecision === 'skip'
? 'skip'
: await ensureHooksConfirmed(useAppStore.getState(), repoId, 'issueCommand')
let confirmedIssueCommandTemplate = issueCommandTemplate
if (
selectedRepoIsGit &&
submitShouldRunIssueAutomation &&
currentIssueCommand &&
selectedRepoExecutionHostId
) {
if (setupTrustDecision === 'skip') {
issueCommandTrustDecision = 'skip'
} else {
const issueCommandSettlement = await settleComposerSubmit(
confirmRuntimeIssueCommandRead(
useAppStore.getState(),
repoId,
selectedRepoExecutionHostId,
currentIssueCommand,
isSubmissionCancelled
),
isSubmissionCancelled
)
if (issueCommandSettlement.status === 'cancelled') {
return
}
const confirmed = issueCommandSettlement.value
issueCommandTrustDecision = confirmed.trustDecision
confirmedIssueCommandTemplate = confirmed.template
}
}
const linkedLinearIssue =
@@ -3651,7 +3791,14 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
telemetry: composerTelemetry
}
: undefined
if (!(await persistSetupAgentStartupPolicy())) {
const startupPolicySettlement = await settleComposerSubmit(
persistSetupAgentStartupPolicy(),
isSubmissionCancelled
)
if (startupPolicySettlement.status === 'cancelled') {
return
}
if (!startupPolicySettlement.value) {
throw new Error(
translate(
'auto.hooks.useComposerState.setupAgentStartupPolicySaveFailed',
@@ -3659,6 +3806,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
)
)
}
if (isSubmissionCancelled()) {
return
}
const result = await createWorktree(
repoId,
workspaceName,
@@ -3704,7 +3854,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const issueCommand =
submitShouldRunIssueAutomation && issueCommandTrustDecision === 'run'
? {
command: renderIssueCommandTemplate(issueCommandTemplate, {
command: renderIssueCommandTemplate(confirmedIssueCommandTemplate, {
issueNumber: submitLinkedIssueNumber,
artifactUrl: submitLinkedWorkItem?.url ?? null
})
@@ -3766,6 +3916,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
onCreated?.()
queueWorkspaceActivationTerminalFocus(worktree.id, activation)
} catch (error) {
if (isSubmissionCancelled()) {
return
}
const formattedError = formatWorkspaceCreateError(error)
setCreateError(formattedError)
toast.error(getWorkspaceCreateErrorToastMessage(formattedError))
@@ -3781,9 +3934,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
clearNewWorkspaceDraft,
compareBaseRef,
createWorktree,
currentIssueCommand,
applyWorktreeMeta,
enableIssueAutomation,
issueCommandTemplate,
isSubmissionCancelled,
effectiveLinkedPR,
hasLoadedIssueCommand,
linkedGitLabIssue,
@@ -3804,6 +3959,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
resolvedInitialWorkspaceStatus,
selectedRepo,
selectedRepoAgentLaunchPlatform,
selectedRepoExecutionHostId,
selectedRepoIsRemote,
selectedRepoStartupShell,
selectedRepoIsGit,
@@ -3885,10 +4041,46 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
return
}
const workspaceRunContext: WorktreeCreationRequest['workspaceRunContext'] =
selectedWorkspaceTarget.status === 'ready'
? {
kind: 'workspace-run',
projectId: selectedWorkspaceTarget.target.projectId,
hostId: selectedWorkspaceTarget.target.hostId,
projectHostSetupId: selectedWorkspaceTarget.target.projectHostSetupId,
repoId: selectedWorkspaceTarget.target.repoId,
path: selectedWorkspaceTarget.target.repo.path
}
: null
const liveStore = useAppStore.getState()
const pendingCreationId = findPendingLinkedWorkItemCreationId(
liveStore.pendingWorktreeCreations,
{
repoId,
...(parsedLinkedIssueNumber != null ? { linkedIssue: parsedLinkedIssueNumber } : {}),
...(effectiveLinkedPR != null ? { linkedPR: effectiveLinkedPR } : {}),
workspaceRunContext
}
)
if (pendingCreationId) {
liveStore.setActivePendingWorktreeCreation(pendingCreationId)
liveStore.setActiveView('terminal')
liveStore.setSidebarOpen(true)
onCreated?.()
return
}
setCreateError(null)
setCreating(true)
try {
const smartGitHubResolution = await resolvePendingSmartGitHubSubmit()
const smartGitHubSettlement = await settleComposerSubmit(
resolvePendingSmartGitHubSubmit(),
isSubmissionCancelled
)
if (smartGitHubSettlement.status === 'cancelled') {
return
}
const smartGitHubResolution = smartGitHubSettlement.value
const submitLinkedWorkItem =
smartGitHubResolution.kind === 'none'
? linkedWorkItem
@@ -3957,14 +4149,25 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
let submitSetupConfig = setupConfig
let submitResolvedSetupDecision = resolvedSetupDecision
if (selectedRepoIsGit && checkedHooksRepoId !== repoId) {
if (
selectedRepoIsGit &&
selectedRepoHookContextKey &&
checkedHooksContextKey !== selectedRepoHookContextKey
) {
let hookCheck: HookCheckResult
try {
hookCheck = await loadHookCheckForRepo(repoId)
const hookCheckSettlement = await settleComposerSubmit(
loadHookCheckForRepo(repoId),
isSubmissionCancelled
)
if (hookCheckSettlement.status === 'cancelled') {
return
}
hookCheck = hookCheckSettlement.value
} catch {
hookCheck = { hasHooks: false, hooks: null, mayNeedUpdate: false }
}
if (!commitHookCheckIfCurrent(repoId, hookCheck.hooks)) {
if (!commitHookCheckIfCurrent(selectedRepoHookContextKey, hookCheck.hooks)) {
return
}
submitSetupConfig = getSetupConfig(selectedRepo, hookCheck.hooks)
@@ -3981,9 +4184,23 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
return
}
const trustDecision = selectedRepoIsGit
? await ensureHooksConfirmed(useAppStore.getState(), repoId, 'setup')
: 'skip'
const setupTrustSettlement = await settleComposerSubmit(
selectedRepoIsGit
? ensureHooksConfirmed(
useAppStore.getState(),
repoId,
'setup',
selectedRepoExecutionHostId ?? undefined,
undefined,
isSubmissionCancelled
)
: Promise.resolve<'skip'>('skip'),
isSubmissionCancelled
)
if (setupTrustSettlement.status === 'cancelled') {
return
}
const trustDecision = setupTrustSettlement.value
const effectiveSetupDecision: SetupDecision =
trustDecision === 'skip'
? 'skip'
@@ -3992,21 +4209,38 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const submitLinkedWorkItemProvider = submitLinkedWorkItem
? getLinkedWorkItemProvider(submitLinkedWorkItem)
: null
let submitIssueCommandTemplate = issueCommandTemplate
if (
const shouldReadIssueCommand =
enableIssueAutomation &&
selectedRepoIsGit &&
submitLinkedIssueNumber !== null &&
!hasLoadedIssueCommand
canUseIssueCommandForLinkedItemProvider(submitLinkedWorkItemProvider)
let submitIssueCommandTemplate = ''
let issueCommandTrustDecision: 'run' | 'skip' = 'skip'
if (
shouldReadIssueCommand &&
trustDecision !== 'skip' &&
selectedRepoExecutionHostId &&
selectedRepoHookContextKey
) {
try {
const result = await readRuntimeIssueCommand(selectedRepoSettingsRef.current, repoId)
submitIssueCommandTemplate = result.effectiveContent ?? ''
} catch {
submitIssueCommandTemplate = ''
const issueCommandSettlement = await settleComposerSubmit(
readAndConfirmRuntimeIssueCommand(
useAppStore.getState(),
repoId,
selectedRepoExecutionHostId,
isSubmissionCancelled
),
isSubmissionCancelled
)
if (issueCommandSettlement.status === 'cancelled') {
return
}
setIssueCommandTemplate(submitIssueCommandTemplate)
setHasLoadedIssueCommand(true)
const confirmedIssueCommand = issueCommandSettlement.value
submitIssueCommandTemplate = confirmedIssueCommand.template
issueCommandTrustDecision = confirmedIssueCommand.trustDecision
setLoadedIssueCommand({
contextKey: selectedRepoHookContextKey,
result: confirmedIssueCommand.result
})
}
const issueCommandInput = {
enabled: enableIssueAutomation && selectedRepoIsGit,
@@ -4015,12 +4249,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
template: submitIssueCommandTemplate,
artifactUrl: submitLinkedWorkItem?.url ?? null
}
const shouldRunIssueCommand = shouldPrepareComposerIssueCommand(issueCommandInput)
const issueCommandTrustDecision = shouldRunIssueCommand
? trustDecision === 'skip'
? 'skip'
: await ensureHooksConfirmed(useAppStore.getState(), repoId, 'issueCommand')
: 'skip'
const issueCommand = buildTrustedComposerIssueCommand({
...issueCommandInput,
trustDecision: issueCommandTrustDecision
@@ -4046,11 +4274,16 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
createBranchFromWorkspaceName:
smartGitHubResolution.kind === 'none' && smartNameMode === 'branches'
})
const submitBaseBranch = selectedRepoIsGit
? await resolveWorktreeCreateBaseBranch({
explicitBaseBranch: smartSubmitBaseBranch
})
: undefined
const baseBranchSettlement = await settleComposerSubmit(
selectedRepoIsGit
? resolveWorktreeCreateBaseBranch({ explicitBaseBranch: smartSubmitBaseBranch })
: Promise.resolve(undefined),
isSubmissionCancelled
)
if (baseBranchSettlement.status === 'cancelled') {
return
}
const submitBaseBranch = baseBranchSettlement.value
const createDisplayName =
smartGitHubResolution.kind === 'none'
? nameIsAutoManaged
@@ -4147,7 +4380,14 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
...(quickTelemetry ? { telemetry: quickTelemetry } : {})
}
: undefined
if (!(await persistSetupAgentStartupPolicy())) {
const startupPolicySettlement = await settleComposerSubmit(
persistSetupAgentStartupPolicy(),
isSubmissionCancelled
)
if (startupPolicySettlement.status === 'cancelled') {
return
}
if (!startupPolicySettlement.value) {
throw new Error(
translate(
'auto.hooks.useComposerState.setupAgentStartupPolicySaveFailed',
@@ -4155,25 +4395,24 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
)
)
}
let creationWorkspaceRunContext: WorktreeCreationRequest['workspaceRunContext'] =
selectedWorkspaceTarget.status === 'ready'
? {
kind: 'workspace-run',
projectId: selectedWorkspaceTarget.target.projectId,
hostId: selectedWorkspaceTarget.target.hostId,
projectHostSetupId: selectedWorkspaceTarget.target.projectHostSetupId,
repoId: selectedWorkspaceTarget.target.repoId,
path: selectedWorkspaceTarget.target.repo.path
}
: null
let ephemeralVmRecipe: WorktreeCreationRequest['ephemeralVmRecipe']
const activeEphemeralVmRecipeId = ephemeralVmsEnabled ? selectedEphemeralVmRecipeId : null
if (activeEphemeralVmRecipeId && selectedWorkspaceTarget.status === 'ready') {
const vmRecipeTrustDecision = await ensureHooksConfirmed(
useAppStore.getState(),
repoId,
'vmRecipe'
const vmRecipeTrustSettlement = await settleComposerSubmit(
ensureHooksConfirmed(
useAppStore.getState(),
repoId,
'vmRecipe',
selectedRepoExecutionHostId ?? undefined,
undefined,
isSubmissionCancelled
),
isSubmissionCancelled
)
if (vmRecipeTrustSettlement.status === 'cancelled') {
return
}
const vmRecipeTrustDecision = vmRecipeTrustSettlement.value
if (vmRecipeTrustDecision === 'skip') {
return
}
@@ -4195,9 +4434,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
...(taskSourceContext ? { taskSourceContext } : {}),
linkedWorkItem: toFolderWorkspaceLinkedTask(submitLinkedWorkItem),
linkedTaskSourceContext: taskSourceContext,
...(creationWorkspaceRunContext
? { workspaceRunContext: creationWorkspaceRunContext }
: {}),
...(workspaceRunContext ? { workspaceRunContext } : {}),
name: workspaceName,
...(createDisplayName ? { displayName: createDisplayName } : {}),
...(selectedRepoIsGit && submitBaseBranch ? { baseBranch: submitBaseBranch } : {}),
@@ -4247,6 +4484,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
}
// Why: git fetch + `git worktree add` can take 10–15s; run in the background so the modal isn't frozen.
if (isSubmissionCancelled()) {
return
}
if (persistDraft) {
clearNewWorkspaceDraft()
}
@@ -4258,6 +4498,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
onCreated?.()
}
} catch (error) {
if (isSubmissionCancelled()) {
return
}
const formattedError = formatWorkspaceCreateError(error)
setCreateError(formattedError)
toast.error(getWorkspaceCreateErrorToastMessage(formattedError))
@@ -4274,8 +4517,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
fallbackCreatureName,
effectiveLinkedPR,
enableIssueAutomation,
hasLoadedIssueCommand,
issueCommandTemplate,
isSubmissionCancelled,
linkedGitLabIssue,
linkedGitLabMR,
linkedPR,
@@ -4295,6 +4537,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
resolvedInitialWorkspaceStatus,
selectedRepo,
selectedRepoAgentLaunchPlatform,
selectedRepoExecutionHostId,
selectedRepoIsRemote,
selectedRepoStartupShell,
selectedRepoIsGit,
@@ -4318,11 +4561,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
effectivePresetId,
telemetrySource,
taskSourceContext,
checkedHooksRepoId,
checkedHooksContextKey,
commitHookCheckIfCurrent,
loadHookCheckForRepo,
setupConfig,
setupPolicy,
selectedRepoHookContextKey,
isProjectGroupTarget,
submitFolderTarget,
createMultiple,
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { settleComposerSubmit } from './composer-submit-cancellation'
describe('settleComposerSubmit', () => {
it('drops a resolved preflight result after cancellation', async () => {
let cancelled = false
let resolvePreflight: (value: string) => void = () => undefined
const preflight = new Promise<string>((resolve) => {
resolvePreflight = resolve
})
const settlement = settleComposerSubmit(preflight, () => cancelled)
cancelled = true
resolvePreflight('stale result')
await expect(settlement).resolves.toEqual({ status: 'cancelled' })
})
it('suppresses a rejected preflight after cancellation', async () => {
let cancelled = false
let rejectPreflight: (error: Error) => void = () => undefined
const preflight = new Promise<string>((_resolve, reject) => {
rejectPreflight = reject
})
const settlement = settleComposerSubmit(preflight, () => cancelled)
cancelled = true
rejectPreflight(new Error('late failure'))
await expect(settlement).resolves.toEqual({ status: 'cancelled' })
})
it('preserves successful and failed results while active', async () => {
const active = () => false
await expect(settleComposerSubmit(Promise.resolve('ready'), active)).resolves.toEqual({
status: 'completed',
value: 'ready'
})
await expect(
settleComposerSubmit(Promise.reject(new Error('failure')), active)
).rejects.toThrow('failure')
})
})
@@ -0,0 +1,18 @@
export type ComposerSubmitSettlement<T> =
| { status: 'completed'; value: T }
| { status: 'cancelled' }
export async function settleComposerSubmit<T>(
promise: Promise<T>,
isCancelled: () => boolean
): Promise<ComposerSubmitSettlement<T>> {
try {
const value = await promise
return isCancelled() ? { status: 'cancelled' } : { status: 'completed', value }
} catch (error) {
if (isCancelled()) {
return { status: 'cancelled' }
}
throw error
}
}
@@ -1,7 +1,11 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AppState } from '@/store/types'
import type { PersistedTrustedOrcaHooks } from '../../../shared/types'
import { __resetTrustPromptChainForTests, ensureHooksConfirmed } from './ensure-hooks-confirmed'
import {
__resetTrustPromptChainForTests,
ensureHooksConfirmed,
readAndConfirmRuntimeIssueCommand
} from './ensure-hooks-confirmed'
import { hashOrcaHookScript } from './orca-hook-trust'
import {
createCompatibleRuntimeStatusResponseIfNeeded,
@@ -388,6 +392,98 @@ describe('ensureHooksConfirmed', () => {
expect(readIssueCommandMock).toHaveBeenCalledWith({ repoId: 'repo-1', hostId: 'ssh:server' })
})
it('approves and returns the exact issue-command bytes from one host-qualified read', async () => {
const { state, pending } = createTestState({
repos: [
{ id: 'repo-1', displayName: 'Local Row' },
{ id: 'repo-1', displayName: 'SSH Row', connectionId: 'server' }
]
} as unknown as Partial<AppState>)
readIssueCommandMock
.mockResolvedValueOnce({
status: 'ok',
source: 'shared',
sharedContent: 'approved bytes',
localContent: null,
effectiveContent: 'approved bytes',
localFilePath: ''
})
.mockResolvedValueOnce({
status: 'ok',
source: 'shared',
sharedContent: 'changed bytes',
localContent: null,
effectiveContent: 'changed bytes',
localFilePath: ''
})
const promise = readAndConfirmRuntimeIssueCommand(state, 'repo-1', 'ssh:server')
await vi.waitFor(() => expect(pending).toHaveLength(1))
expect(pending[0].data.scriptContent).toBe('approved bytes')
pending[0].resolve('run')
await expect(promise).resolves.toMatchObject({
template: 'approved bytes',
trustDecision: 'run'
})
expect(readIssueCommandMock).toHaveBeenCalledTimes(1)
expect(readIssueCommandMock).toHaveBeenCalledWith({
repoId: 'repo-1',
hostId: 'ssh:server'
})
})
it('does not reuse repo-wide trust across duplicate execution hosts', async () => {
const { state, pending } = createTestState({
trustedOrcaHooks: { 'repo-1': { all: { approvedAt: 1 } } },
repos: [
{ id: 'repo-1', displayName: 'Runtime', executionHostId: 'runtime:env-1' },
{ id: 'repo-1', displayName: 'SSH', connectionId: 'server' }
]
} as unknown as Partial<AppState>)
readIssueCommandMock.mockResolvedValue({
status: 'ok',
source: 'shared',
sharedContent: 'host-specific bytes',
localContent: null,
effectiveContent: 'host-specific bytes',
localFilePath: ''
})
const promise = readAndConfirmRuntimeIssueCommand(state, 'repo-1', 'ssh:server')
await vi.waitFor(() => expect(pending).toHaveLength(1))
pending[0].resolve('skip')
await expect(promise).resolves.toMatchObject({ trustDecision: 'skip' })
})
it('does not open a trust prompt after its composer is cancelled mid-read', async () => {
const { state, pending } = createTestState()
let cancelled = false
let resolveRead: (result: Record<string, unknown>) => void = () => undefined
readIssueCommandMock.mockImplementation(
() =>
new Promise((resolve) => {
resolveRead = resolve
})
)
const promise = readAndConfirmRuntimeIssueCommand(state, 'repo-1', 'local', () => cancelled)
cancelled = true
resolveRead({
status: 'ok',
source: 'shared',
sharedContent: 'late bytes',
localContent: null,
effectiveContent: 'late bytes',
localFilePath: ''
})
await expect(promise).resolves.toMatchObject({ trustDecision: 'skip' })
expect(pending).toHaveLength(0)
})
it('fails closed when issueCommand inspection reports an error status', async () => {
const { state, pending } = createTestState()
readIssueCommandMock.mockResolvedValue({
+142 -31
View File
@@ -2,7 +2,11 @@ import type { AppState } from '@/store/types'
import type { OrcaHooks } from '../../../shared/types'
import { resolveHookCommandSourcePolicy } from '../../../shared/hook-command-source-policy'
import { hashOrcaHookScript, type OrcaHookScriptKind } from './orca-hook-trust'
import { checkRuntimeHooks, readRuntimeIssueCommand } from '@/runtime/runtime-hooks-client'
import {
checkRuntimeHooks,
readRuntimeIssueCommand,
type IssueCommandReadResult
} from '@/runtime/runtime-hooks-client'
import { getRuntimeEnvironmentIdForRepo } from './repo-runtime-owner'
import {
getRepoExecutionHostId,
@@ -12,6 +16,8 @@ import {
export type HookScriptKind = OrcaHookScriptKind
const NEVER_CANCEL_TRUST_CHECK = (): boolean => false
// Serialize the singleton modal callback so overlapping worktree actions cannot replace it.
let trustPromptChain: Promise<unknown> = Promise.resolve()
@@ -88,16 +94,147 @@ function settingsForHookRepoOwner(
: ({ activeRuntimeEnvironmentId: runtimeEnvironmentId } as AppState['settings'])
}
function canUseRepoWideTrust(state: AppState, repoId: string): boolean {
const hasDuplicateRepoId = state.repos.filter((repo) => repo.id === repoId).length > 1
return Boolean(state.trustedOrcaHooks[repoId]?.all) && !hasDuplicateRepoId
}
async function confirmScriptContent(
state: AppState,
repoId: string,
scriptKind: HookScriptKind,
scriptContent: string,
hostId?: ExecutionHostId,
isCancelled: () => boolean = NEVER_CANCEL_TRUST_CHECK
): Promise<'run' | 'skip'> {
if (isCancelled()) {
return 'skip'
}
if (canUseRepoWideTrust(state, repoId) || !scriptContent) {
return 'run'
}
const contentHash = await hashOrcaHookScript(scriptContent)
if (isCancelled()) {
return 'skip'
}
const existingHash = state.trustedOrcaHooks[repoId]?.[scriptKind]?.contentHash
if (existingHash === contentHash) {
return 'run'
}
const repo = findHookRepo(state, repoId, hostId)
const repoName = repo?.displayName ?? 'this repository'
const previouslyApproved = Boolean(existingHash)
return new Promise<'run' | 'skip'>((resolve) => {
state.openModal('confirm-orca-yaml-hooks', {
repoId,
repoName,
scriptKind,
scriptContent,
contentHash,
previouslyApproved,
onResolve: (decision: 'run' | 'skip') => resolve(decision)
})
})
}
function getIssueCommandTrustContent(result: IssueCommandReadResult): string {
if (result.source === 'local') {
return (result.localContent ?? '').trim()
}
if (result.source === 'shared') {
return (result.sharedContent ?? '').trim()
}
return ''
}
async function confirmIssueCommandReadResult(
state: AppState,
repoId: string,
hostId: ExecutionHostId,
result: IssueCommandReadResult,
isCancelled: () => boolean = NEVER_CANCEL_TRUST_CHECK
): Promise<'run' | 'skip'> {
if (isCancelled()) {
return 'skip'
}
if (result.source === 'local') {
return 'run'
}
if (result.status === 'error') {
return 'skip'
}
return confirmScriptContent(
state,
repoId,
'issueCommand',
getIssueCommandTrustContent(result),
hostId,
isCancelled
)
}
export type ConfirmedRuntimeIssueCommand = {
result: IssueCommandReadResult
template: string
trustDecision: 'run' | 'skip'
}
export function confirmRuntimeIssueCommandRead(
state: AppState,
repoId: string,
hostId: ExecutionHostId,
result: IssueCommandReadResult,
isCancelled: () => boolean = NEVER_CANCEL_TRUST_CHECK
): Promise<ConfirmedRuntimeIssueCommand> {
return enqueueTrustPrompt(async () => ({
result,
template: getIssueCommandTrustContent(result),
trustDecision: await confirmIssueCommandReadResult(state, repoId, hostId, result, isCancelled)
}))
}
export async function readAndConfirmRuntimeIssueCommand(
state: AppState,
repoId: string,
hostId: ExecutionHostId,
isCancelled: () => boolean = NEVER_CANCEL_TRUST_CHECK
): Promise<ConfirmedRuntimeIssueCommand> {
let result: IssueCommandReadResult
try {
result = await readRuntimeIssueCommand(
settingsForHookRepoOwner(state, repoId, hostId),
repoId,
hostId
)
} catch {
result = {
status: 'error',
localContent: null,
sharedContent: null,
effectiveContent: null,
localFilePath: '',
source: 'none'
}
}
return confirmRuntimeIssueCommandRead(state, repoId, hostId, result, isCancelled)
}
export async function ensureHooksConfirmed(
state: AppState,
repoId: string,
scriptKind: HookScriptKind,
hostId?: ExecutionHostId,
runtimeOwnerEnvironmentId?: string | null
runtimeOwnerEnvironmentId?: string | null,
isCancelled: () => boolean = NEVER_CANCEL_TRUST_CHECK
): Promise<'run' | 'skip'> {
return enqueueTrustPrompt(async () => {
const hasDuplicateRepoId = state.repos.filter((repo) => repo.id === repoId).length > 1
if (state.trustedOrcaHooks[repoId]?.all && !(hostId && hasDuplicateRepoId)) {
if (isCancelled()) {
return 'skip'
}
if (canUseRepoWideTrust(state, repoId)) {
return 'run'
}
@@ -155,32 +292,6 @@ export async function ensureHooksConfirmed(
return 'skip'
}
if (!scriptContent) {
return 'run'
}
const contentHash = await hashOrcaHookScript(scriptContent)
const existingHash = state.trustedOrcaHooks[repoId]?.[scriptKind]?.contentHash
if (existingHash === contentHash) {
return 'run'
}
const repo = findHookRepo(state, repoId, hostId)
const repoName = repo?.displayName ?? 'this repository'
// A non-empty existingHash that didn't match means the user approved a previous
// version of this script; the prompt is reappearing because orca.yaml changed.
const previouslyApproved = Boolean(existingHash)
return new Promise<'run' | 'skip'>((resolve) => {
state.openModal('confirm-orca-yaml-hooks', {
repoId,
repoName,
scriptKind,
scriptContent,
contentHash,
previouslyApproved,
onResolve: (decision: 'run' | 'skip') => resolve(decision)
})
})
return confirmScriptContent(state, repoId, scriptKind, scriptContent, hostId, isCancelled)
})
}
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest'
import {
findPendingLinkedWorkItemCreationId,
type PendingWorktreeCreation,
type WorktreeCreationRequest
} from './pending-worktree-creation'
function request(overrides: Partial<WorktreeCreationRequest> = {}): WorktreeCreationRequest {
return {
repoId: 'repo-1',
name: 'workspace',
setupDecision: 'inherit',
agent: null,
pendingFirstAgentMessageRename: false,
note: '',
startupPlan: null,
quickPrompt: '',
quickTelemetry: null,
...overrides
}
}
function pending(
creationId: string,
creationRequest: WorktreeCreationRequest
): PendingWorktreeCreation {
return {
creationId,
phase: 'preparing',
status: 'creating',
startedAt: 1,
indeterminate: false,
loaderVisible: true,
request: creationRequest
}
}
describe('findPendingLinkedWorkItemCreationId', () => {
it('deduplicates the same linked item on the same execution host', () => {
const existing = request({
linkedIssue: 42,
workspaceRunContext: {
kind: 'workspace-run',
projectId: 'project-1',
hostId: 'ssh:server',
projectHostSetupId: 'setup-1',
repoId: 'repo-1',
path: '/repo'
}
})
expect(
findPendingLinkedWorkItemCreationId(
{ existing: pending('existing', existing) },
request({
linkedIssue: 42,
workspaceRunContext: {
...existing.workspaceRunContext!,
path: '/renamed-repo'
}
})
)
).toBe('existing')
})
it('keeps the same linked item on different execution hosts distinct', () => {
const existing = request({
linkedPR: 77,
workspaceRunContext: {
kind: 'workspace-run',
projectId: 'project-1',
hostId: 'ssh:server-a',
projectHostSetupId: 'setup-a',
repoId: 'repo-1',
path: '/repo'
}
})
expect(
findPendingLinkedWorkItemCreationId(
{ existing: pending('existing', existing) },
request({
linkedPR: 77,
workspaceRunContext: {
...existing.workspaceRunContext!,
hostId: 'ssh:server-b',
projectHostSetupId: 'setup-b'
}
})
)
).toBeNull()
})
it('does not deduplicate unlinked workspace creation', () => {
expect(
findPendingLinkedWorkItemCreationId({ existing: pending('existing', request()) }, request())
).toBeNull()
})
})
@@ -123,6 +123,29 @@ export type PendingWorktreeCreation = {
request: WorktreeCreationRequest
}
export function findPendingLinkedWorkItemCreationId(
pendingCreations: Readonly<Record<string, PendingWorktreeCreation>>,
request: Pick<
WorktreeCreationRequest,
'repoId' | 'linkedIssue' | 'linkedPR' | 'workspaceRunContext'
>
): string | null {
if (request.linkedIssue == null && request.linkedPR == null) {
return null
}
const hostId = request.workspaceRunContext?.hostId ?? null
const match = Object.values(pendingCreations).find((entry) => {
const pending = entry.request
return (
pending.repoId === request.repoId &&
pending.linkedIssue === request.linkedIssue &&
pending.linkedPR === request.linkedPR &&
(pending.workspaceRunContext?.hostId ?? null) === hostId
)
})
return match?.creationId ?? null
}
/** Human-readable progress line for an in-flight create, shared by the in-frame
* loader and the sidebar row so the two never drift. Caller handles the error
* case; this only covers the in-progress states. */
@@ -91,6 +91,61 @@ describe('project-host workspace target resolution', () => {
).toBe('orca-ssh')
})
it('matches duplicate repo ids to the setup execution host', () => {
const localRepo = makeRepo('orca', { path: '/local/orca' })
const sshRepo = makeRepo('orca', {
path: '/remote/orca',
connectionId: 'builder'
})
const projects = [makeProject('github:stablyai/orca', ['orca'])]
const projectHostSetups = [
makeSetup('local-setup', 'github:stablyai/orca', 'local', 'orca'),
makeSetup('ssh-setup', 'github:stablyai/orca', 'ssh:builder', 'orca')
]
const resolution = resolveWorkspaceCreationTarget({
eligibleRepos: [localRepo, sshRepo],
projects,
projectHostSetups,
projectHostSetupId: 'ssh-setup'
})
expect(resolution).toMatchObject({
status: 'ready',
target: {
hostId: 'ssh:builder',
repo: { path: '/remote/orca', connectionId: 'builder' }
}
})
})
it('keeps a focused duplicate repo id on its selected host', () => {
const localRepo = makeRepo('orca', { path: '/local/orca' })
const sshRepo = makeRepo('orca', { path: '/remote/orca', connectionId: 'builder' })
const projects = [makeProject('github:stablyai/orca', ['orca'])]
const projectHostSetups = [
makeSetup('local-setup', 'github:stablyai/orca', 'local', 'orca'),
makeSetup('ssh-setup', 'github:stablyai/orca', 'ssh:builder', 'orca')
]
expect(
resolveWorkspaceCreationTarget({
eligibleRepos: [localRepo, sshRepo],
projects,
projectHostSetups,
draftRepoId: 'orca',
focusedHostScope: 'ssh:builder'
})
).toMatchObject({
status: 'ready',
target: {
hostId: 'ssh:builder',
projectHostSetupId: 'ssh-setup',
repo: { path: '/remote/orca', connectionId: 'builder' }
}
})
})
it('resolves an explicit project and host to the matching setup', () => {
const repos = [
makeRepo('orca-local'),
@@ -1,5 +1,6 @@
import {
ALL_EXECUTION_HOSTS_SCOPE,
getRepoExecutionHostId,
type ExecutionHostId,
type ExecutionHostScope
} from '../../../shared/execution-host'
@@ -78,9 +79,12 @@ function isReadySetup(setup: ProjectHostSetup): boolean {
function createTarget(
setup: ProjectHostSetup,
repoById: ReadonlyMap<string, Repo>
reposById: ReadonlyMap<string, readonly Repo[]>
): WorkspaceCreationTarget | null {
const repo = repoById.get(setup.repoId)
const candidates = reposById.get(setup.repoId) ?? []
const repo =
candidates.find((candidate) => getRepoExecutionHostId(candidate) === setup.hostId) ??
(candidates.length === 1 ? candidates[0] : null)
if (!repo) {
return null
}
@@ -96,14 +100,14 @@ function createTarget(
function findReadySetupTarget(
setups: readonly ProjectHostSetup[],
repoById: ReadonlyMap<string, Repo>,
reposById: ReadonlyMap<string, readonly Repo[]>,
predicate: (setup: ProjectHostSetup) => boolean
): WorkspaceCreationTarget | null {
for (const setup of setups) {
if (!isReadySetup(setup) || !predicate(setup)) {
continue
}
const target = createTarget(setup, repoById)
const target = createTarget(setup, reposById)
if (target) {
return target
}
@@ -120,7 +124,12 @@ export function resolveWorkspaceCreationTarget(
}
const model = getProjectSetupModel(input)
const repoById = new Map(eligibleRepos.map((repo) => [repo.id, repo]))
const reposById = new Map<string, Repo[]>()
for (const repo of eligibleRepos) {
const candidates = reposById.get(repo.id) ?? []
candidates.push(repo)
reposById.set(repo.id, candidates)
}
const actionableHostIds = input.actionableHostIds
const allSetups = model?.setups ?? []
const setups = actionableHostIds
@@ -147,9 +156,9 @@ export function resolveWorkspaceCreationTarget(
const canonical =
findReadySetupTarget(
setups,
repoById,
reposById,
(entry) => entry.projectId === setup.projectId && entry.hostId === setup.hostId
) ?? createTarget(setup, repoById)
) ?? createTarget(setup, reposById)
if (canonical) {
return { status: 'ready', target: canonical }
}
@@ -169,7 +178,7 @@ export function resolveWorkspaceCreationTarget(
}
const target = findReadySetupTarget(
setups,
repoById,
reposById,
(setup) => setup.projectId === projectId && setup.hostId === hostId
)
if (target) {
@@ -184,14 +193,14 @@ export function resolveWorkspaceCreationTarget(
const focusedTarget = focusedHostId
? findReadySetupTarget(
setups,
repoById,
reposById,
(setup) => setup.projectId === projectId && setup.hostId === focusedHostId
)
: null
if (focusedTarget) {
return { status: 'ready', target: focusedTarget }
}
const target = findReadySetupTarget(setups, repoById, (setup) => setup.projectId === projectId)
const target = findReadySetupTarget(setups, reposById, (setup) => setup.projectId === projectId)
if (target) {
return { status: 'ready', target }
}
@@ -199,7 +208,7 @@ export function resolveWorkspaceCreationTarget(
}
if (hostId) {
const target = findReadySetupTarget(setups, repoById, (setup) => setup.hostId === hostId)
const target = findReadySetupTarget(setups, reposById, (setup) => setup.hostId === hostId)
if (target) {
return { status: 'ready', target }
}
@@ -209,23 +218,33 @@ export function resolveWorkspaceCreationTarget(
}
const repoId = resolveComposerRepoId(input)
const legacyRepo = repoId ? repoById.get(repoId) : null
const legacyCandidates = repoId ? (reposById.get(repoId) ?? []) : []
const focusedLegacyRepo =
focusedHostScope && focusedHostScope !== ALL_EXECUTION_HOSTS_SCOPE
? legacyCandidates.find((candidate) => getRepoExecutionHostId(candidate) === focusedHostScope)
: null
const legacyRepo =
focusedLegacyRepo ?? (legacyCandidates.length === 1 ? legacyCandidates[0] : null)
if (!legacyRepo) {
return { status: 'unavailable', reason: 'no-eligible-repo' }
}
const projectedLegacySetup = projectHostSetupProjectionFromRepos([legacyRepo]).setups[0]
const legacyHostId = getRepoExecutionHostId(legacyRepo)
const legacySetup =
setups.find((setup) => setup.repoId === legacyRepo.id && isReadySetup(setup)) ??
setups.find(
(setup) =>
setup.repoId === legacyRepo.id && setup.hostId === legacyHostId && isReadySetup(setup)
) ??
(!actionableHostIds || actionableHostIds.has(projectedLegacySetup.hostId)
? projectedLegacySetup
: null)
const legacyTarget = legacySetup ? createTarget(legacySetup, repoById) : null
const legacyTarget = legacySetup ? createTarget(legacySetup, reposById) : null
if (legacyTarget) {
return { status: 'ready', target: legacyTarget }
}
const fallbackTarget = actionableHostIds
? findReadySetupTarget(setups, repoById, () => true)
? findReadySetupTarget(setups, reposById, () => true)
: null
return fallbackTarget
? { status: 'ready', target: fallbackTarget }
@@ -0,0 +1,110 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
PendingWorktreeCreation,
WorktreeCreationRequest
} from '@/lib/pending-worktree-creation'
const store = {
settings: {
activeRuntimeEnvironmentId: null as string | null
},
pendingWorktreeCreations: {} as Record<string, PendingWorktreeCreation>,
beginPendingWorktreeCreation: vi.fn(),
setActivePendingWorktreeCreation: vi.fn(),
setActiveView: vi.fn(),
setSidebarOpen: vi.fn(),
createWorktree: vi.fn()
}
vi.mock('@/store', () => ({
useAppStore: {
getState: () => store
}
}))
vi.mock('@/lib/browser-uuid', () => ({
createBrowserUuid: () => 'creation-new'
}))
vi.mock('@/lib/worktree-activation', () => ({
activateAndRevealWorktree: vi.fn(),
ensureWorktreeHasInitialTerminal: vi.fn()
}))
vi.mock('@/lib/workspace-activation-terminal-focus', () => ({
queueWorkspaceActivationTerminalFocus: vi.fn()
}))
vi.mock('@/lib/new-workspace', () => ({
ensureAgentStartupInTerminal: vi.fn()
}))
vi.mock('sonner', () => ({
toast: { error: vi.fn() }
}))
vi.mock('@/lib/ephemeral-vm-workspace-target', () => ({
prepareEphemeralVmWorkspaceTarget: vi.fn()
}))
import { runBackgroundWorktreeCreation } from './worktree-creation-flow'
function makeRequest(overrides: Partial<WorktreeCreationRequest> = {}): WorktreeCreationRequest {
return {
repoId: 'repo-1',
name: 'feature',
setupDecision: 'inherit',
agent: null,
pendingFirstAgentMessageRename: false,
note: '',
startupPlan: null,
quickPrompt: '',
quickTelemetry: null,
...overrides
}
}
function makePendingCreation(request: WorktreeCreationRequest): PendingWorktreeCreation {
return {
creationId: 'existing',
phase: 'preparing',
status: 'creating',
startedAt: 1,
indeterminate: false,
loaderVisible: true,
request
}
}
describe('runBackgroundWorktreeCreation linked-item dedupe', () => {
beforeEach(() => {
vi.clearAllMocks()
store.pendingWorktreeCreations = {}
})
it('reveals an existing linked-item creation instead of starting a duplicate', () => {
const linkedRequest = makeRequest({
linkedIssue: 42,
workspaceRunContext: {
kind: 'workspace-run',
projectId: 'project-1',
hostId: 'ssh:server',
projectHostSetupId: 'setup-1',
repoId: 'repo-1',
path: '/repo'
}
})
store.pendingWorktreeCreations = {
existing: makePendingCreation(linkedRequest)
}
const creationId = runBackgroundWorktreeCreation(linkedRequest)
expect(creationId).toBe('existing')
expect(store.setActivePendingWorktreeCreation).toHaveBeenCalledWith('existing')
expect(store.setActiveView).toHaveBeenCalledWith('terminal')
expect(store.setSidebarOpen).toHaveBeenCalledWith(true)
expect(store.beginPendingWorktreeCreation).not.toHaveBeenCalled()
expect(store.createWorktree).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,50 @@
import { useAppStore } from '@/store'
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import type { WorktreeStartupPayload } from '@/lib/worktree-activation'
import type {
WorktreeCreationPhase,
WorktreeCreationRequest
} from '@/lib/pending-worktree-creation'
// Why: mirrors the startup-opt the composer used to build inline. The renderer
// only seeds the first terminal when the backend did not already spawn it.
export function buildWorktreeCreationStartupOpt(
request: WorktreeCreationRequest,
backendSpawned: boolean
): WorktreeStartupPayload | undefined {
const plan = request.startupPlan
if (!plan || backendSpawned) {
return undefined
}
return {
command: plan.launchCommand,
...(plan.env ? { env: plan.env } : {}),
launchConfig: plan.launchConfig,
...(plan.launchToken ? { launchToken: plan.launchToken } : {}),
...(request.agent ? { launchAgent: request.agent } : {}),
...(plan.draftPrompt ? { draftPrompt: plan.draftPrompt } : {}),
// Why: view-mode only. An argv-prefill plan sets no draftPrompt, so this is
// the sole signal that this launch starts with unsent context in the TUI.
...(request.launchDraftPrompt ? { launchDraftText: request.launchDraftPrompt } : {}),
...(plan.startupCommandDelivery ? { startupCommandDelivery: plan.startupCommandDelivery } : {}),
// Why: command-code shows its prompt in the tab status before the first
// hook fires, so the prompt is threaded through here.
...(request.agent === 'command-code' && request.quickPrompt.trim().length > 0
? { initialAgentStatus: { agent: request.agent, prompt: request.quickPrompt.trim() } }
: {}),
...(request.quickTelemetry ? { telemetry: request.quickTelemetry } : {})
}
}
export function getWorktreeCreationIndeterminate(request: WorktreeCreationRequest): boolean {
if (request.worktreeCreateProgressMode) {
return request.worktreeCreateProgressMode === 'indeterminate'
}
return getActiveRuntimeTarget(useAppStore.getState().settings).kind !== 'local'
}
export function getInitialWorktreeCreationPhase(
request: WorktreeCreationRequest
): WorktreeCreationPhase {
return request.ephemeralVmRecipe && !request.ephemeralVmRuntimeId ? 'provisioning-vm' : 'fetching'
}
+24 -49
View File
@@ -4,12 +4,10 @@ import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
import {
activateAndRevealWorktree,
ensureWorktreeHasInitialTerminal,
type ActivateAndRevealResult,
type WorktreeStartupPayload
type ActivateAndRevealResult
} from '@/lib/worktree-activation'
import { ensureAgentStartupInTerminal } from '@/lib/new-workspace'
import { queueWorkspaceActivationTerminalFocus } from '@/lib/workspace-activation-terminal-focus'
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import {
attachEphemeralVmRuntimeToWorkspace,
cleanupEphemeralVmRuntimeForFailedCreate,
@@ -20,59 +18,24 @@ import {
getWorkspaceCreateErrorToastMessage
} from '@/lib/workspace-create-error-format'
import type { CreateWorktreeResult } from '../../../shared/types'
import type {
WorktreeCreationPhase,
WorktreeCreationRequest
import {
findPendingLinkedWorkItemCreationId,
type WorktreeCreationPhase,
type WorktreeCreationRequest
} from '@/lib/pending-worktree-creation'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { seedAgentTabStateAfterWorktreeCreate } from '@/lib/worktree-creation-agent-seeds'
import { resolveBackendDraftStartup } from '@/lib/worktree-draft-startup-view-mode'
import {
buildWorktreeCreationStartupOpt,
getInitialWorktreeCreationPhase,
getWorktreeCreationIndeterminate
} from '@/lib/worktree-creation-flow-startup'
type ContinueBackgroundWorktreeCreationOptions = {
revealCreationSurface?: boolean
}
// Why: mirrors the startup-opt the composer used to build inline. The renderer
// only seeds the first terminal when the backend did not already spawn it.
function buildStartupOpt(
request: WorktreeCreationRequest,
backendSpawned: boolean
): WorktreeStartupPayload | undefined {
const plan = request.startupPlan
if (!plan || backendSpawned) {
return undefined
}
return {
command: plan.launchCommand,
...(plan.env ? { env: plan.env } : {}),
launchConfig: plan.launchConfig,
...(plan.launchToken ? { launchToken: plan.launchToken } : {}),
...(request.agent ? { launchAgent: request.agent } : {}),
...(plan.draftPrompt ? { draftPrompt: plan.draftPrompt } : {}),
// Why: view-mode only. An argv-prefill plan sets no draftPrompt, so this is
// the sole signal that this launch starts with unsent context in the TUI.
...(request.launchDraftPrompt ? { launchDraftText: request.launchDraftPrompt } : {}),
...(plan.startupCommandDelivery ? { startupCommandDelivery: plan.startupCommandDelivery } : {}),
// Why: command-code shows its prompt in the tab status before the first
// hook fires, so the prompt is threaded through here.
...(request.agent === 'command-code' && request.quickPrompt.trim().length > 0
? { initialAgentStatus: { agent: request.agent, prompt: request.quickPrompt.trim() } }
: {}),
...(request.quickTelemetry ? { telemetry: request.quickTelemetry } : {})
}
}
function getWorktreeCreationIndeterminate(request: WorktreeCreationRequest): boolean {
if (request.worktreeCreateProgressMode) {
return request.worktreeCreateProgressMode === 'indeterminate'
}
return getActiveRuntimeTarget(useAppStore.getState().settings).kind !== 'local'
}
function getInitialWorktreeCreationPhase(request: WorktreeCreationRequest): WorktreeCreationPhase {
return request.ephemeralVmRecipe && !request.ephemeralVmRuntimeId ? 'provisioning-vm' : 'fetching'
}
// Why: activePendingCreationId can outlive the terminal route when the user
// switches app views; only the terminal route renders the creation panel.
function isPendingCreationSurfaceVisible(creationId: string): boolean {
@@ -220,7 +183,7 @@ async function executeWorktreeCreation(
// startup, so both halves of the handoff share one renderer-session token.
preparedRequest.startupPlan.launchToken = createBrowserUuid()
}
const startupOpt = buildStartupOpt(preparedRequest, backendSpawned)
const startupOpt = buildWorktreeCreationStartupOpt(preparedRequest, backendSpawned)
if (worktree.path) {
const repoConnectionId =
@@ -305,12 +268,24 @@ async function executeWorktreeCreation(
* immediately and the work outlives the now-closed modal. Progress and errors
* surface on the pending creation's sidebar row and content panel.
*/
export function runBackgroundWorktreeCreation(request: WorktreeCreationRequest): void {
export function runBackgroundWorktreeCreation(request: WorktreeCreationRequest): string {
const store = useAppStore.getState()
const existingCreationId = findPendingLinkedWorkItemCreationId(
store.pendingWorktreeCreations,
request
)
if (existingCreationId) {
store.setActivePendingWorktreeCreation(existingCreationId)
store.setActiveView('terminal')
store.setSidebarOpen(true)
return existingCreationId
}
// Why: crypto.randomUUID is undefined in non-secure browser contexts (LAN web
// client over plain HTTP). createBrowserUuid falls back to getRandomValues.
const creationId = createBrowserUuid()
revealPendingCreation(creationId, request, getInitialWorktreeCreationPhase(request))
void executeWorktreeCreation(creationId, request)
return creationId
}
/** Stage a pending entry before async preflight so the UI shows immediate progress. */