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. */