mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
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:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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({
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user