From ade4354ddd60cb7d7c4953d88b9fd0028ce067ac Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:34:13 -0700 Subject: [PATCH] Open GitHub items in workspace composer with issue automation (#12653) * Open TaskPage GitHub items directly in workspace composer Remove background creation indirection. The composer prefills with issue/pull-request metadata from the GitHub work item. * Generalize workspace composer tests to support multiple sources Rename test from task-page-github-composer-boundary to task-page-workspace-composer-boundary. Add test verifying Linear items open directly in the workspace composer, expanding beyond GitHub-specific routing. * Support issue command automation in workspace composer - Pass GitHub work items through composer for issue context - Quick create resolves commands at workspace creation time - Extract command building and trust logic to dedicated module * 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. * Support duplicate repo IDs across hosts in workspace creation When the same repo exists on multiple hosts (local and SSH), resolve the workspace creation target to the ready setup on the preferred host instead of failing closed. Also add resilience to hook checks by clearing the cache on transient IPC failures. --- .../components/NewWorkspaceComposerModal.tsx | 58 +- src/renderer/src/components/TaskPage.tsx | 13 +- ...-github-background-create-boundary.test.ts | 44 -- ...k-page-workspace-composer-boundary.test.ts | 127 +++++ ...poserState-host-context-boundaries.test.ts | 56 +- .../useComposerState-host-retarget.test.ts | 62 ++ src/renderer/src/hooks/useComposerState.ts | 534 ++++++++++++++---- .../src/lib/composer-issue-command.test.ts | 30 + .../src/lib/composer-issue-command.ts | 37 ++ .../lib/composer-submit-cancellation.test.ts | 44 ++ .../src/lib/composer-submit-cancellation.ts | 18 + .../src/lib/ensure-hooks-confirmed.test.ts | 98 +++- .../src/lib/ensure-hooks-confirmed.ts | 173 +++++- .../src/lib/new-workspace-create-gates.ts | 3 +- .../src/lib/pending-worktree-creation.test.ts | 99 ++++ .../src/lib/pending-worktree-creation.ts | 23 + .../lib/project-host-workspace-target.test.ts | 84 +++ .../src/lib/project-host-workspace-target.ts | 72 ++- .../lib/worktree-creation-flow-dedupe.test.ts | 110 ++++ .../src/lib/worktree-creation-flow-startup.ts | 50 ++ .../src/lib/worktree-creation-flow.ts | 73 +-- 21 files changed, 1514 insertions(+), 294 deletions(-) delete mode 100644 src/renderer/src/components/task-page-github-background-create-boundary.test.ts create mode 100644 src/renderer/src/components/task-page-workspace-composer-boundary.test.ts create mode 100644 src/renderer/src/hooks/useComposerState-host-retarget.test.ts create mode 100644 src/renderer/src/lib/composer-issue-command.test.ts create mode 100644 src/renderer/src/lib/composer-issue-command.ts create mode 100644 src/renderer/src/lib/composer-submit-cancellation.test.ts create mode 100644 src/renderer/src/lib/composer-submit-cancellation.ts create mode 100644 src/renderer/src/lib/pending-worktree-creation.test.ts create mode 100644 src/renderer/src/lib/worktree-creation-flow-dedupe.test.ts create mode 100644 src/renderer/src/lib/worktree-creation-flow-startup.ts diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index f04a8f80d..aa46a10f7 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -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 { @@ -20,6 +20,7 @@ import type { LinkedWorkItemSummary } from '@/lib/new-workspace' import { shouldAllowComposerEnterSubmitTarget } from '@/lib/new-workspace-enter-guard' import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' import type { + GitHubWorkItem, TuiAgent, WorkspaceCreateTelemetrySource, WorkspaceStatus @@ -41,9 +42,11 @@ type ComposerModalData = { initialEphemeralVmRecipeId?: string initialProjectGroupId?: string linkedWorkItem?: LinkedWorkItemSummary | null + initialGitHubWorkItem?: GitHubWorkItem | null taskSourceContext?: TaskSourceContext | null initialBaseBranch?: string initialWorkspaceStatus?: WorkspaceStatus + enableIssueAutomation?: boolean /** Telemetry surface that opened the composer. Set by each * `openModal('new-workspace-composer', ...)` site so * `workspace_created.source` carries the right value. Falls back to @@ -58,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 ( - - ) + return } 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 ( - + !open && handleDismiss()}> { @@ -106,7 +96,13 @@ function ComposerModalBody({ getWorkspaceComposerInitialFocusTarget(content)?.focus({ preventScroll: true }) }} > - + ) @@ -115,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) @@ -136,6 +136,7 @@ function QuickTabBody({ // intentionally ignored even if older callers still send it. initialPrompt: '', initialLinkedWorkItem: modalData.linkedWorkItem ?? null, + initialGitHubWorkItem: modalData.initialGitHubWorkItem ?? null, initialTaskSourceContext: modalData.taskSourceContext ?? null, initialRepoId: modalData.initialRepoId, initialEphemeralVmRecipeId: modalData.initialEphemeralVmRecipeId, @@ -144,8 +145,9 @@ function QuickTabBody({ ...(modalData.initialBaseBranch ? { initialBaseBranch: modalData.initialBaseBranch } : {}), persistDraft: false, onCreated: onClose, + isSubmissionCancelled, ...(modalData.telemetrySource ? { telemetrySource: modalData.telemetrySource } : {}), - enableIssueAutomation: false, + enableIssueAutomation: modalData.enableIssueAutomation === true, createGateMode: 'quick' }) // Why: the composer's built-in `onOpenAgentSettings` handler navigates to @@ -267,7 +269,7 @@ function QuickTabBody({ return } event.preventDefault() - onClose() + onDismiss() return } @@ -287,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 ( <> diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 2a998577f..31b571fd6 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -146,7 +146,6 @@ import { } from '@/lib/linear-issue-workspace-attachment' import { openLinearIssueWorkspaceOrStart } from '@/lib/linear-issue-workspace-open' import { folderWorkspaceToWorktree } from '../../../shared/folder-workspace-worktree' -import { createGitHubWorkItemWorkspaceInBackground } from '@/lib/github-work-item-background-create' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' import GitHubItemDialog, { type ItemDialogTab } from '@/components/GitHubItemDialog' @@ -6747,9 +6746,11 @@ export default function TaskPage(): React.JSX.Element { } openModal('new-workspace-composer', { linkedWorkItem, + initialGitHubWorkItem: item, taskSourceContext: getTaskPageRepoSourceContext(repoMap.get(item.repoId), 'github'), prefilledName: getGitHubWorkItemWorkspaceSeed(item), initialRepoId: item.repoId, + enableIssueAutomation: item.type === 'issue', telemetrySource: 'sidebar' }) }, @@ -6759,15 +6760,9 @@ export default function TaskPage(): React.JSX.Element { const handleUseWorkItem = useCallback( (item: GitHubWorkItem): void => { useAppStore.getState().recordFeatureInteraction('github-tasks') - void createGitHubWorkItemWorkspaceInBackground({ - item, - repoId: item.repoId, - taskSourceContext: getTaskPageRepoSourceContext(repoMap.get(item.repoId), 'github'), - telemetrySource: 'sidebar', - openModalFallback: () => openComposerForItem(item) - }) + openComposerForItem(item) }, - [openComposerForItem, repoMap] + [openComposerForItem] ) const handleOpenOrUseGitHubWorkItem = useCallback( diff --git a/src/renderer/src/components/task-page-github-background-create-boundary.test.ts b/src/renderer/src/components/task-page-github-background-create-boundary.test.ts deleted file mode 100644 index 1b036921b..000000000 --- a/src/renderer/src/components/task-page-github-background-create-boundary.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -const TASK_PAGE_SOURCE = readFileSync(join(__dirname, 'TaskPage.tsx'), 'utf8') -const PROJECT_VIEW_SOURCE = readFileSync( - join(__dirname, 'github-project', 'ProjectViewWrapper.tsx'), - '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('GitHub workspace creation source boundaries', () => { - it('routes the TaskPage GitHub create path through background creation first', () => { - const section = sourceBetween( - TASK_PAGE_SOURCE, - 'const handleUseWorkItem = useCallback(', - 'const handleOpenOrUseGitHubWorkItem = useCallback(' - ) - - expect(section).toContain('createGitHubWorkItemWorkspaceInBackground({') - expect(section).toContain('openModalFallback: () => openComposerForItem(item)') - expect(section).not.toContain("openModal('new-workspace-composer'") - }) - - it('keeps project-view GitHub actions on the direct start-work path for issue #4756', () => { - const section = sourceBetween( - PROJECT_VIEW_SOURCE, - '// Why: issue #4756 keeps project-view actions on the direct', - 'openModalFallback: () => {' - ) - - expect(PROJECT_VIEW_SOURCE).toContain('issue #4756') - expect(section).toContain('void launchWorkItemDirect({') - expect(section).toContain("launchSource: 'task_page'") - expect(section).not.toContain('createGitHubWorkItemWorkspaceInBackground') - }) -}) diff --git a/src/renderer/src/components/task-page-workspace-composer-boundary.test.ts b/src/renderer/src/components/task-page-workspace-composer-boundary.test.ts new file mode 100644 index 000000000..0f74cc3a4 --- /dev/null +++ b/src/renderer/src/components/task-page-workspace-composer-boundary.test.ts @@ -0,0 +1,127 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const TASK_PAGE_SOURCE = readFileSync(join(__dirname, 'TaskPage.tsx'), 'utf8') +const PROJECT_VIEW_SOURCE = readFileSync( + join(__dirname, 'github-project', 'ProjectViewWrapper.tsx'), + 'utf8' +) +const COMPOSER_MODAL_SOURCE = readFileSync(join(__dirname, 'NewWorkspaceComposerModal.tsx'), 'utf8') +const COMPOSER_STATE_SOURCE = readFileSync(join(__dirname, '../hooks/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('TaskPage workspace creation source boundaries', () => { + it('prefills the workspace composer for GitHub issues and pull requests', () => { + const section = sourceBetween( + TASK_PAGE_SOURCE, + 'const openComposerForItem = useCallback(', + 'const handleUseWorkItem = useCallback(' + ) + + expect(section).toContain("provider: 'github'") + expect(section).toContain('type: item.type') + expect(section).toContain('number: item.number') + expect(section).toContain('title: item.title') + expect(section).toContain('url: item.url') + expect(section).toContain("openModal('new-workspace-composer', {") + expect(section).toContain("getTaskPageRepoSourceContext(repoMap.get(item.repoId), 'github')") + expect(section).toContain('prefilledName: getGitHubWorkItemWorkspaceSeed(item)') + expect(section).toContain('initialRepoId: item.repoId') + expect(section).toContain('initialGitHubWorkItem: item') + expect(section).toContain("enableIssueAutomation: item.type === 'issue'") + expect(section).toContain("telemetrySource: 'sidebar'") + }) + + it('forwards PR start-point data and issue automation through quick submit', () => { + expect(COMPOSER_MODAL_SOURCE).toContain( + 'initialGitHubWorkItem: modalData.initialGitHubWorkItem ?? null' + ) + expect(COMPOSER_MODAL_SOURCE).toContain( + 'enableIssueAutomation: modalData.enableIssueAutomation === true' + ) + const quickSubmit = sourceBetween( + COMPOSER_STATE_SOURCE, + 'const submitQuick = useCallback(', + 'const createGateInput = {' + ) + expect(quickSubmit).toContain('readAndConfirmRuntimeIssueCommand(') + expect(quickSubmit).toContain('selectedRepoExecutionHostId') + expect(quickSubmit).toContain('isSubmissionCancelled') + expect(quickSubmit).toContain('...(issueCommand ? { issueCommand } : {})') + }) + + it('routes TaskPage GitHub starts directly to the composer', () => { + const section = sourceBetween( + TASK_PAGE_SOURCE, + 'const handleUseWorkItem = useCallback(', + 'const handleOpenOrUseGitHubWorkItem = useCallback(' + ) + + expect(section).toContain("recordFeatureInteraction('github-tasks')") + expect(section).toContain('openComposerForItem(item)') + expect(section).not.toContain('createGitHubWorkItemWorkspaceInBackground') + expect(TASK_PAGE_SOURCE).not.toContain('@/lib/github-work-item-background-create') + }) + + it('routes TaskPage Linear starts directly to the composer', () => { + const composerSection = sourceBetween( + TASK_PAGE_SOURCE, + 'const openComposerForLinearItem = useCallback(', + 'const handleUseLinearItem = useCallback(' + ) + const handlerSection = sourceBetween( + TASK_PAGE_SOURCE, + 'const handleUseLinearItem = useCallback(', + 'const handleOpenOrUseLinearItem = useCallback(' + ) + + expect(composerSection).toContain('buildLinearIssueLinkedWorkItem(issue)') + expect(composerSection).toContain("openModal('new-workspace-composer', {") + expect(composerSection).toContain('taskSourceContext: linearTaskSourceContext') + expect(composerSection).toContain('prefilledName: getLinearIssueWorkspaceName(issue)') + expect(composerSection).toContain("telemetrySource: 'sidebar'") + expect(handlerSection).toContain("recordFeatureInteraction('linear-tasks')") + expect(handlerSection).toContain('openComposerForLinearItem(issue)') + }) + + it('resumes an attachment from the primary action and composes when none exists', () => { + const section = sourceBetween( + TASK_PAGE_SOURCE, + 'const handleOpenOrUseGitHubWorkItem = useCallback(', + 'const openComposerForGitLabItem = useCallback(' + ) + + expect(section).toContain('findGithubWorkItemWorkspaceAttachment(') + expect(section).toContain('if (!currentAttached)') + expect(section).toContain('handleUseWorkItem(item)') + expect(section).toContain('activateAndRevealWorktree(currentAttached.id)') + }) + + it('uses the shared composer handler from GitHub detail and start-new actions', () => { + expect(TASK_PAGE_SOURCE.match(/onUse=\{\(item\) => \{/g)).toHaveLength(2) + expect(TASK_PAGE_SOURCE.match(/onSelect=\{\(\) => handleUseWorkItem\(item\)\}/g)).toHaveLength( + 2 + ) + }) + + it('keeps project-view GitHub actions on the direct start-work path for issue #4756', () => { + const section = sourceBetween( + PROJECT_VIEW_SOURCE, + '// Why: issue #4756 keeps project-view actions on the direct', + 'openModalFallback: () => {' + ) + + expect(PROJECT_VIEW_SOURCE).toContain('issue #4756') + expect(section).toContain('void launchWorkItemDirect({') + expect(section).toContain("launchSource: 'task_page'") + expect(section).not.toContain('createGitHubWorkItemWorkspaceInBackground') + }) +}) diff --git a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts index 6cfc0e8f1..f7aa99c82 100644 --- a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts +++ b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest' import { canResolveFolderSmartGitHubSubmit, getInitialAutoManagedWorkspaceName, + getInitialGitHubPrStartPointSelection, getMatchingLinkedTaskSourceContext, isExplicitWorkspaceNameInput, resolveSmartGitHubCreateNames, @@ -25,6 +26,52 @@ function sourceBetween(source: string, startPattern: string, endPattern: string) } describe('useComposerState host-context boundaries', () => { + it('seeds TaskPage pull requests as submit-time PR start points', () => { + 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', + branchName: 'fix-pr-workspace', + baseRefName: 'main', + isCrossRepository: true, + repoId: 'repo-1' + } + + expect( + getInitialGitHubPrStartPointSelection({ + item, + linkedWorkItem: { + provider: 'github', + type: 'pr', + number: 42, + title: item.title, + url: item.url, + repoId: item.repoId + }, + repoId: 'repo-1' + }) + ).toEqual({ repoId: 'repo-1', item }) + expect( + getInitialGitHubPrStartPointSelection({ + item, + linkedWorkItem: { + provider: 'github', + type: 'pr', + number: 43, + title: item.title, + url: 'https://github.com/stablyai/orca/pull/43' + }, + repoId: 'repo-1' + }) + ).toBeNull() + }) + it('treats typed workspace names as user-authored, not auto-managed', () => { expect(isExplicitWorkspaceNameInput({ name: 'keep-my-name', lastAutoName: '' })).toBe(true) expect( @@ -376,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) @@ -417,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("smartGitHubSettlement.status === 'cancelled'") expect(section).not.toContain('folderSourceRequiresConnection') }) diff --git a/src/renderer/src/hooks/useComposerState-host-retarget.test.ts b/src/renderer/src/hooks/useComposerState-host-retarget.test.ts new file mode 100644 index 000000000..7e2c35cb7 --- /dev/null +++ b/src/renderer/src/hooks/useComposerState-host-retarget.test.ts @@ -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') + }) +}) diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 7d5c09c8f..6281e81d1 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -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,6 +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 } from '@/lib/composer-issue-command' +import { settleComposerSubmit } from '@/lib/composer-submit-cancellation' + +const NEVER_CANCEL_COMPOSER_SUBMIT = (): boolean => false export function canResolveFolderSmartGitHubSubmit({ hasFolderSourceRepos @@ -231,6 +243,7 @@ export type UseComposerStateOptions = { initialName?: string initialPrompt?: string initialLinkedWorkItem?: LinkedWorkItemSummary | null + initialGitHubWorkItem?: GitHubWorkItem | null initialTaskSourceContext?: TaskSourceContext | null initialWorkspaceStatus?: WorkspaceStatus /** Seeds the Start-from selection on open; the Create-from → Quick fallback uses it so a PR pick lands with the resolved PR head as base. */ @@ -239,12 +252,13 @@ 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 /** Telemetry surface that opened this composer; threaded into createWorktree so workspace_created.source reflects the entry point. Defaults to unknown. */ telemetrySource?: WorkspaceCreateTelemetrySource - /** Quick-create skips the issueCommand probe (no automation), which the full composer needs for linked-item prompt previews. */ + /** Enables linked-item prompt and issue-command automation for this composer entry point. */ enableIssueAutomation?: boolean createGateMode?: 'full' | 'quick' } @@ -513,6 +527,40 @@ function normalizeGitHubLinkedWorkItem( return { ...item, type: identity.type, number: identity.number } } +export function getInitialGitHubPrStartPointSelection({ + item, + linkedWorkItem, + repoId +}: { + item: GitHubWorkItem | null | undefined + linkedWorkItem: LinkedWorkItemSummary | null | undefined + repoId: string | null | undefined +}): SmartGitHubPrStartPointSelection | null { + if (!item || !repoId) { + return null + } + const itemIdentity = resolveGitHubWorkItemIdentity(item) + const linkedIdentity = getGitHubLinkedWorkItemIdentity(linkedWorkItem) + if ( + itemIdentity.type !== 'pr' || + linkedIdentity?.type !== 'pr' || + itemIdentity.number !== linkedIdentity.number + ) { + return null + } + return { + repoId, + item: { ...item, type: itemIdentity.type, number: itemIdentity.number } + } +} + +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 @@ -548,11 +596,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS initialName = '', initialPrompt = '', initialLinkedWorkItem = null, + initialGitHubWorkItem = null, initialTaskSourceContext = null, initialWorkspaceStatus, initialBaseBranch, persistDraft, onCreated, + isSubmissionCancelled = NEVER_CANCEL_COMPOSER_SUBMIT, repoIdOverride, onRepoIdOverrideChange, telemetrySource, @@ -666,7 +716,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS [initialWorkspaceStatus, workspaceStatuses] ) - const resolvedInitialRepoId = resolveWorkspaceCreationRepoId({ + const resolvedInitialWorkspaceTarget = resolveWorkspaceCreationTarget({ eligibleRepos, projects, projectHostSetups, @@ -679,8 +729,19 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS focusedHostScope: workspaceHostScope, actionableHostIds }) + const resolvedInitialRepoId = + resolvedInitialWorkspaceTarget.status === 'ready' + ? resolvedInitialWorkspaceTarget.target.repoId + : '' const [internalRepoId, setInternalRepoId] = useState(resolvedInitialRepoId) + const [selectedProjectHostSetupOverrideId, setSelectedProjectHostSetupOverrideId] = useState< + string | null + >( + resolvedInitialWorkspaceTarget.status === 'ready' + ? resolvedInitialWorkspaceTarget.target.projectHostSetupId + : null + ) const initialFolderProjectGroupId = initialProjectGroupId ?? draftProjectGroupId const initialFolderProjectGroup = findActionableFolderProjectGroup({ projectGroups, @@ -773,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 @@ -1130,9 +1207,16 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS ) const [yamlHooks, setYamlHooks] = useState(null) - const [checkedHooksRepoId, setCheckedHooksRepoId] = useState(null) - const [issueCommandTemplate, setIssueCommandTemplate] = useState('') - const [hasLoadedIssueCommand, setHasLoadedIssueCommand] = useState(false) + const [checkedHooksContextKey, setCheckedHooksContextKey] = useState(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( () => getRepoSetupAgentStartupPolicy(selectedRepo) @@ -1184,7 +1268,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const noteRef = useRef(note) noteRef.current = note // Why: PR checkout refs resolve async, so submit can still see the linked PR as a checkout source if Create fires before the resolver settles. - const smartGitHubPrStartPointSelectionRef = useRef(null) + const smartGitHubPrStartPointSelectionRef = useRef( + getInitialGitHubPrStartPointSelection({ + item: initialGitHubWorkItem, + linkedWorkItem: initialLinkedWorkItemSeed, + repoId: selectedRepo?.id ?? initialRepoId + }) + ) useEffect(() => { const clearAutoManagedName = (): void => { if (nameRef.current === lastAutoNameRef.current) { @@ -1334,26 +1424,40 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS key: string promise: Promise } | null>(null) - const loadHookCheckForRepo = useCallback((targetRepoId: string): Promise => { - 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 => { + const key = JSON.stringify([selectedRepoExecutionHostId ?? 'local', targetRepoId]) + const existing = hookCheckRef.current + if (existing?.key === key) { + return existing.promise + } + // Why: drop the cache entry on failure so a transient IPC error doesn't pin every later + // check for this repo/host to the same rejection. + const promise: Promise = checkRuntimeHooks( + selectedRepoSettingsRef.current, + targetRepoId, + selectedRepoExecutionHostId ?? undefined + ).catch((error: unknown) => { + if (hookCheckRef.current?.promise === promise) { + hookCheckRef.current = null + } + throw error + }) + 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) { @@ -1451,9 +1555,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 @@ -1475,7 +1580,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. @@ -1684,70 +1792,75 @@ 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 } } - void readRuntimeIssueCommand(selectedRepoSettingsRef.current, repoId) + if (createGateMode === 'quick') { + return () => { + cancelled = true + } + } + + 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, + createGateMode, enableIssueAutomation, loadHookCheckForRepo, repoId, + selectedRepoExecutionHostId, + selectedRepoHookContextKey, selectedRepoIsGit, runtimeEnvironmentId ]) @@ -2568,6 +2681,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS ): void => { setProjectError(null) if (value === repoId && !options.forceResetStartFrom) { + if (!options.preserveStartFrom) { + setSelectedProjectHostSetupOverrideId(null) + } setRepoId(value) return } @@ -2588,6 +2704,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('') @@ -2651,7 +2783,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] ) @@ -3292,15 +3428,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, @@ -3348,6 +3494,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS }) } } catch (error) { + if (isSubmissionCancelled()) { + return + } const formattedError = formatWorkspaceCreateError(error) setCreateError(formattedError) toast.error(getWorkspaceCreateErrorToastMessage(formattedError)) @@ -3363,6 +3512,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS folderTargetIsRemote, folderTargetRuntimeEnvironmentId, folderSourceRepos.length, + isSubmissionCancelled, linkedWorkItem, name, note, @@ -3415,7 +3565,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 @@ -3518,20 +3675,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 = @@ -3605,7 +3798,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', @@ -3613,6 +3813,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS ) ) } + if (isSubmissionCancelled()) { + return + } const result = await createWorktree( repoId, workspaceName, @@ -3658,7 +3861,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const issueCommand = submitShouldRunIssueAutomation && issueCommandTrustDecision === 'run' ? { - command: renderIssueCommandTemplate(issueCommandTemplate, { + command: renderIssueCommandTemplate(confirmedIssueCommandTemplate, { issueNumber: submitLinkedIssueNumber, artifactUrl: submitLinkedWorkItem?.url ?? null }) @@ -3720,6 +3923,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)) @@ -3735,9 +3941,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS clearNewWorkspaceDraft, compareBaseRef, createWorktree, + currentIssueCommand, applyWorktreeMeta, enableIssueAutomation, issueCommandTemplate, + isSubmissionCancelled, effectiveLinkedPR, hasLoadedIssueCommand, linkedGitLabIssue, @@ -3758,6 +3966,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS resolvedInitialWorkspaceStatus, selectedRepo, selectedRepoAgentLaunchPlatform, + selectedRepoExecutionHostId, selectedRepoIsRemote, selectedRepoStartupShell, selectedRepoIsGit, @@ -3839,10 +4048,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 @@ -3911,14 +4156,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) @@ -3935,9 +4191,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' @@ -3946,6 +4216,50 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const submitLinkedWorkItemProvider = submitLinkedWorkItem ? getLinkedWorkItemProvider(submitLinkedWorkItem) : null + const shouldReadIssueCommand = + enableIssueAutomation && + selectedRepoIsGit && + submitLinkedIssueNumber !== null && + canUseIssueCommandForLinkedItemProvider(submitLinkedWorkItemProvider) + let submitIssueCommandTemplate = '' + let issueCommandTrustDecision: 'run' | 'skip' = 'skip' + if ( + shouldReadIssueCommand && + trustDecision !== 'skip' && + selectedRepoExecutionHostId && + selectedRepoHookContextKey + ) { + const issueCommandSettlement = await settleComposerSubmit( + readAndConfirmRuntimeIssueCommand( + useAppStore.getState(), + repoId, + selectedRepoExecutionHostId, + isSubmissionCancelled + ), + isSubmissionCancelled + ) + if (issueCommandSettlement.status === 'cancelled') { + return + } + const confirmedIssueCommand = issueCommandSettlement.value + submitIssueCommandTemplate = confirmedIssueCommand.template + issueCommandTrustDecision = confirmedIssueCommand.trustDecision + setLoadedIssueCommand({ + contextKey: selectedRepoHookContextKey, + result: confirmedIssueCommand.result + }) + } + const issueCommandInput = { + enabled: enableIssueAutomation && selectedRepoIsGit, + provider: submitLinkedWorkItemProvider, + issueNumber: submitLinkedIssueNumber, + template: submitIssueCommandTemplate, + artifactUrl: submitLinkedWorkItem?.url ?? null + } + const issueCommand = buildTrustedComposerIssueCommand({ + ...issueCommandInput, + trustDecision: issueCommandTrustDecision + }) const linkedLinearIssue = submitLinkedWorkItem && submitLinkedWorkItemProvider === 'linear' ? submitLinkedWorkItem.linearIdentifier @@ -3967,11 +4281,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 @@ -4068,7 +4387,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', @@ -4076,25 +4402,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 } @@ -4116,9 +4441,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 } : {}), @@ -4157,6 +4480,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS ? { linkedGitLabIssue } : {}), ...(backendStartup ? { startup: backendStartup } : {}), + ...(issueCommand ? { issueCommand } : {}), pendingFirstAgentMessageRename, note: trimmedNote, startupPlan, @@ -4167,6 +4491,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() } @@ -4178,6 +4505,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS onCreated?.() } } catch (error) { + if (isSubmissionCancelled()) { + return + } const formattedError = formatWorkspaceCreateError(error) setCreateError(formattedError) toast.error(getWorkspaceCreateErrorToastMessage(formattedError)) @@ -4193,6 +4523,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS clearNewWorkspaceDraft, fallbackCreatureName, effectiveLinkedPR, + enableIssueAutomation, + isSubmissionCancelled, linkedGitLabIssue, linkedGitLabMR, linkedPR, @@ -4212,6 +4544,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS resolvedInitialWorkspaceStatus, selectedRepo, selectedRepoAgentLaunchPlatform, + selectedRepoExecutionHostId, selectedRepoIsRemote, selectedRepoStartupShell, selectedRepoIsGit, @@ -4235,11 +4568,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS effectivePresetId, telemetrySource, taskSourceContext, - checkedHooksRepoId, + checkedHooksContextKey, commitHookCheckIfCurrent, loadHookCheckForRepo, setupConfig, setupPolicy, + selectedRepoHookContextKey, isProjectGroupTarget, submitFolderTarget, createMultiple, diff --git a/src/renderer/src/lib/composer-issue-command.test.ts b/src/renderer/src/lib/composer-issue-command.test.ts new file mode 100644 index 000000000..f9245af41 --- /dev/null +++ b/src/renderer/src/lib/composer-issue-command.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { + buildTrustedComposerIssueCommand, + shouldPrepareComposerIssueCommand +} from './composer-issue-command' + +const readyInput = { + enabled: true, + provider: 'github' as const, + issueNumber: 42, + template: 'gh issue view {{issue}} --repo {{artifact_url}}', + artifactUrl: 'https://github.com/stablyai/orca/issues/42' +} + +describe('composer issue command', () => { + it('renders a trusted GitHub issue command', () => { + expect(buildTrustedComposerIssueCommand({ ...readyInput, trustDecision: 'run' })).toEqual({ + command: 'gh issue view 42 --repo https://github.com/stablyai/orca/issues/42' + }) + }) + + it('skips untrusted, disabled, PR, and empty commands', () => { + expect( + buildTrustedComposerIssueCommand({ ...readyInput, trustDecision: 'skip' }) + ).toBeUndefined() + expect(shouldPrepareComposerIssueCommand({ ...readyInput, enabled: false })).toBe(false) + expect(shouldPrepareComposerIssueCommand({ ...readyInput, issueNumber: null })).toBe(false) + expect(shouldPrepareComposerIssueCommand({ ...readyInput, template: ' ' })).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/composer-issue-command.ts b/src/renderer/src/lib/composer-issue-command.ts new file mode 100644 index 000000000..ddb841bb7 --- /dev/null +++ b/src/renderer/src/lib/composer-issue-command.ts @@ -0,0 +1,37 @@ +import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' +import { + canUseIssueCommandForLinkedItemProvider, + renderIssueCommandTemplate +} from '@/lib/new-workspace' +import type { FolderWorkspaceLinkedTask } from '../../../shared/types' + +type ComposerIssueCommandInput = { + enabled: boolean + provider: FolderWorkspaceLinkedTask['provider'] | null + issueNumber: number | null + template: string + artifactUrl: string | null +} + +export function shouldPrepareComposerIssueCommand(input: ComposerIssueCommandInput): boolean { + return ( + input.enabled && + canUseIssueCommandForLinkedItemProvider(input.provider) && + input.issueNumber !== null && + input.template.trim().length > 0 + ) +} + +export function buildTrustedComposerIssueCommand( + input: ComposerIssueCommandInput & { trustDecision: 'run' | 'skip' } +): WorktreeCreationRequest['issueCommand'] | undefined { + if (input.trustDecision !== 'run' || !shouldPrepareComposerIssueCommand(input)) { + return undefined + } + return { + command: renderIssueCommandTemplate(input.template.trim(), { + issueNumber: input.issueNumber, + artifactUrl: input.artifactUrl + }) + } +} diff --git a/src/renderer/src/lib/composer-submit-cancellation.test.ts b/src/renderer/src/lib/composer-submit-cancellation.test.ts new file mode 100644 index 000000000..bde48dc8b --- /dev/null +++ b/src/renderer/src/lib/composer-submit-cancellation.test.ts @@ -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((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((_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') + }) +}) diff --git a/src/renderer/src/lib/composer-submit-cancellation.ts b/src/renderer/src/lib/composer-submit-cancellation.ts new file mode 100644 index 000000000..55fbe3eb4 --- /dev/null +++ b/src/renderer/src/lib/composer-submit-cancellation.ts @@ -0,0 +1,18 @@ +export type ComposerSubmitSettlement = + | { status: 'completed'; value: T } + | { status: 'cancelled' } + +export async function settleComposerSubmit( + promise: Promise, + isCancelled: () => boolean +): Promise> { + try { + const value = await promise + return isCancelled() ? { status: 'cancelled' } : { status: 'completed', value } + } catch (error) { + if (isCancelled()) { + return { status: 'cancelled' } + } + throw error + } +} diff --git a/src/renderer/src/lib/ensure-hooks-confirmed.test.ts b/src/renderer/src/lib/ensure-hooks-confirmed.test.ts index 1bb0053da..00b8621a3 100644 --- a/src/renderer/src/lib/ensure-hooks-confirmed.test.ts +++ b/src/renderer/src/lib/ensure-hooks-confirmed.test.ts @@ -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) + 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) + 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) => 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({ diff --git a/src/renderer/src/lib/ensure-hooks-confirmed.ts b/src/renderer/src/lib/ensure-hooks-confirmed.ts index f04b1e655..c98638ded 100644 --- a/src/renderer/src/lib/ensure-hooks-confirmed.ts +++ b/src/renderer/src/lib/ensure-hooks-confirmed.ts @@ -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 = 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 { + 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 { + 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) }) } diff --git a/src/renderer/src/lib/new-workspace-create-gates.ts b/src/renderer/src/lib/new-workspace-create-gates.ts index 6936eca44..684f84909 100644 --- a/src/renderer/src/lib/new-workspace-create-gates.ts +++ b/src/renderer/src/lib/new-workspace-create-gates.ts @@ -31,8 +31,7 @@ export function getFullComposerCreateDisabled(input: ComposerCreateGateInput): b } export function getQuickComposerCreateDisabled(input: ComposerCreateGateInput): boolean { - // Why: Cmd/Ctrl+N quick create can resolve setup hooks inside the submit - // handler, and it never runs issue-command automation. Keeping those + // Why: quick create resolves setup hooks and optional issue automation inside submit. Keeping those // background probes out of the disabled gate makes the primary action usable // as soon as the form has enough local state to submit. return hasBlockingCreateState(input) diff --git a/src/renderer/src/lib/pending-worktree-creation.test.ts b/src/renderer/src/lib/pending-worktree-creation.test.ts new file mode 100644 index 000000000..b015e3ce9 --- /dev/null +++ b/src/renderer/src/lib/pending-worktree-creation.test.ts @@ -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 { + 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() + }) +}) diff --git a/src/renderer/src/lib/pending-worktree-creation.ts b/src/renderer/src/lib/pending-worktree-creation.ts index b06a1e4f6..d635e8255 100644 --- a/src/renderer/src/lib/pending-worktree-creation.ts +++ b/src/renderer/src/lib/pending-worktree-creation.ts @@ -123,6 +123,29 @@ export type PendingWorktreeCreation = { request: WorktreeCreationRequest } +export function findPendingLinkedWorkItemCreationId( + pendingCreations: Readonly>, + 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. */ diff --git a/src/renderer/src/lib/project-host-workspace-target.test.ts b/src/renderer/src/lib/project-host-workspace-target.test.ts index 528773d8e..08484c99b 100644 --- a/src/renderer/src/lib/project-host-workspace-target.test.ts +++ b/src/renderer/src/lib/project-host-workspace-target.test.ts @@ -91,6 +91,90 @@ 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 duplicate repo ids to a ready setup when no host is focused', () => { + 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: 'all', + actionableHostIds: new Set(['local', 'ssh:builder']) + }) + ).toMatchObject({ + status: 'ready', + target: { + hostId: 'local', + projectHostSetupId: 'local-setup', + repoId: 'orca', + repo: { path: '/local/orca' } + } + }) + }) + it('resolves an explicit project and host to the matching setup', () => { const repos = [ makeRepo('orca-local'), diff --git a/src/renderer/src/lib/project-host-workspace-target.ts b/src/renderer/src/lib/project-host-workspace-target.ts index a4234f768..09a633091 100644 --- a/src/renderer/src/lib/project-host-workspace-target.ts +++ b/src/renderer/src/lib/project-host-workspace-target.ts @@ -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 + reposById: ReadonlyMap ): 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, + reposById: ReadonlyMap, 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() + 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,27 +218,38 @@ export function resolveWorkspaceCreationTarget( } const repoId = resolveComposerRepoId(input) - const legacyRepo = repoId ? repoById.get(repoId) : null - if (!legacyRepo) { - return { status: 'unavailable', reason: 'no-eligible-repo' } + 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) + let legacyTarget: WorkspaceCreationTarget | null = null + if (legacyRepo) { + const projectedLegacySetup = projectHostSetupProjectionFromRepos([legacyRepo]).setups[0] + const legacyHostId = getRepoExecutionHostId(legacyRepo) + const legacySetup = + setups.find( + (setup) => + setup.repoId === legacyRepo.id && setup.hostId === legacyHostId && isReadySetup(setup) + ) ?? + (!actionableHostIds || actionableHostIds.has(projectedLegacySetup.hostId) + ? projectedLegacySetup + : null) + legacyTarget = legacySetup ? createTarget(legacySetup, reposById) : null + } else if (repoId) { + // Why: duplicate repo ids across hosts leave no single legacy repo. Stay on the resolved id's + // own setup instead of failing closed and letting the composer re-pick an arbitrary repo. + legacyTarget = findReadySetupTarget(setups, reposById, (setup) => setup.repoId === repoId) } - - const projectedLegacySetup = projectHostSetupProjectionFromRepos([legacyRepo]).setups[0] - const legacySetup = - setups.find((setup) => setup.repoId === legacyRepo.id && isReadySetup(setup)) ?? - (!actionableHostIds || actionableHostIds.has(projectedLegacySetup.hostId) - ? projectedLegacySetup - : null) - const legacyTarget = legacySetup ? createTarget(legacySetup, repoById) : null if (legacyTarget) { return { status: 'ready', target: legacyTarget } } - const fallbackTarget = actionableHostIds - ? findReadySetupTarget(setups, repoById, () => true) - : null + const fallbackTarget = findReadySetupTarget(setups, reposById, () => true) return fallbackTarget ? { status: 'ready', target: fallbackTarget } - : { status: 'unavailable', reason: 'setup-not-found' } + : { status: 'unavailable', reason: legacyRepo ? 'setup-not-found' : 'no-eligible-repo' } } export function resolveWorkspaceCreationRepoId(input: ProjectHostWorkspaceTargetInput): string { diff --git a/src/renderer/src/lib/worktree-creation-flow-dedupe.test.ts b/src/renderer/src/lib/worktree-creation-flow-dedupe.test.ts new file mode 100644 index 000000000..5271779a2 --- /dev/null +++ b/src/renderer/src/lib/worktree-creation-flow-dedupe.test.ts @@ -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, + 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 { + 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() + }) +}) diff --git a/src/renderer/src/lib/worktree-creation-flow-startup.ts b/src/renderer/src/lib/worktree-creation-flow-startup.ts new file mode 100644 index 000000000..4c946e0cf --- /dev/null +++ b/src/renderer/src/lib/worktree-creation-flow-startup.ts @@ -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' +} diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index d433520ca..83451b8ce 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -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. */