Open GitHub items in workspace composer with issue automation (#12653)
* Open TaskPage GitHub items directly in workspace composer Remove background creation indirection. The composer prefills with issue/pull-request metadata from the GitHub work item. * Generalize workspace composer tests to support multiple sources Rename test from task-page-github-composer-boundary to task-page-workspace-composer-boundary. Add test verifying Linear items open directly in the workspace composer, expanding beyond GitHub-specific routing. * Support issue command automation in workspace composer - Pass GitHub work items through composer for issue context - Quick create resolves commands at workspace creation time - Extract command building and trust logic to dedicated module * Derive composer hooks by host context for React Doctor Key loaded hooks and issue commands by execution-host context so repo/host switches no longer reset derived state in effects (the static-analysis gate). Also wire SSH-aware workspace targets and cancel-safe submit settlement for workspace creation. * Support duplicate repo IDs across hosts in workspace creation When the same repo exists on multiple hosts (local and SSH), resolve the workspace creation target to the ready setup on the preferred host instead of failing closed. Also add resilience to hook checks by clearing the cache on transient IPC failures.
This commit is contained in:
parent
d15939c5fd
commit
ade4354ddd
|
|
@ -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 (
|
||||
<ComposerModalBody
|
||||
modalData={modalData ?? {}}
|
||||
onClose={closeModal}
|
||||
onOpenChange={handleOpenChange}
|
||||
/>
|
||||
)
|
||||
return <ComposerModalBody modalData={modalData ?? {}} onClose={closeModal} />
|
||||
}
|
||||
|
||||
function ComposerModalBody({
|
||||
modalData,
|
||||
onClose,
|
||||
onOpenChange
|
||||
onClose
|
||||
}: {
|
||||
modalData: ComposerModalData
|
||||
onClose: () => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
}): React.JSX.Element {
|
||||
const submitCancelledRef = useRef(false)
|
||||
const handleDismiss = useCallback(() => {
|
||||
submitCancelledRef.current = true
|
||||
onClose()
|
||||
}, [onClose])
|
||||
const isSubmissionCancelled = useCallback(() => submitCancelledRef.current, [])
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={onOpenChange}>
|
||||
<Dialog open onOpenChange={(open) => !open && handleDismiss()}>
|
||||
<DialogContent
|
||||
className="flex max-h-[calc(100vh-2rem)] flex-col overflow-hidden sm:max-w-lg"
|
||||
onOpenAutoFocus={(event) => {
|
||||
|
|
@ -106,7 +96,13 @@ function ComposerModalBody({
|
|||
getWorkspaceComposerInitialFocusTarget(content)?.focus({ preventScroll: true })
|
||||
}}
|
||||
>
|
||||
<QuickTabBody modalData={modalData} onClose={onClose} active />
|
||||
<QuickTabBody
|
||||
modalData={modalData}
|
||||
onClose={onClose}
|
||||
onDismiss={handleDismiss}
|
||||
isSubmissionCancelled={isSubmissionCancelled}
|
||||
active
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { retargetGitHubPrStartPointSelection } from './useComposerState'
|
||||
|
||||
const HOOK_SOURCE = readFileSync(join(__dirname, 'useComposerState.ts'), 'utf8')
|
||||
|
||||
function sourceBetween(source: string, startPattern: string, endPattern: string): string {
|
||||
const start = source.indexOf(startPattern)
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
const end = source.indexOf(endPattern, start + startPattern.length)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
return source.slice(start, end)
|
||||
}
|
||||
|
||||
describe('useComposerState host retarget', () => {
|
||||
it('re-resolves a seeded PR after switching its run host', () => {
|
||||
const item = {
|
||||
id: 'pr-42',
|
||||
type: 'pr' as const,
|
||||
number: 42,
|
||||
title: 'Fix PR workspace creation',
|
||||
state: 'open' as const,
|
||||
url: 'https://github.com/stablyai/orca/pull/42',
|
||||
labels: [],
|
||||
updatedAt: '2026-08-04T00:00:00.000Z',
|
||||
author: 'octocat',
|
||||
repoId: 'repo-local'
|
||||
}
|
||||
const selection = {
|
||||
repoId: 'repo-local',
|
||||
item,
|
||||
resolved: {
|
||||
baseBranch: 'local-head',
|
||||
compareBaseRef: 'origin/main'
|
||||
}
|
||||
}
|
||||
|
||||
expect(retargetGitHubPrStartPointSelection(selection, 'repo-ssh')).toEqual({
|
||||
repoId: 'repo-ssh',
|
||||
item
|
||||
})
|
||||
})
|
||||
|
||||
it('retains an explicit host setup when duplicate repos share an id', () => {
|
||||
const targetSection = sourceBetween(
|
||||
HOOK_SOURCE,
|
||||
'const selectedWorkspaceTarget = useMemo',
|
||||
'const selectedRepo ='
|
||||
)
|
||||
expect(targetSection).toContain('projectHostSetupId: selectedProjectHostSetupOverrideId')
|
||||
|
||||
const switchSection = sourceBetween(
|
||||
HOOK_SOURCE,
|
||||
'const handleProjectHostSetupChange',
|
||||
'const handleProjectChange'
|
||||
)
|
||||
expect(switchSection).toContain('setSelectedProjectHostSetupOverrideId(option.id)')
|
||||
expect(switchSection).toContain('preserveStartFrom: true')
|
||||
expect(switchSection).toContain('forceResetStartFrom: true')
|
||||
})
|
||||
})
|
||||
|
|
@ -15,7 +15,10 @@ import {
|
|||
} from '@/lib/github-links'
|
||||
import { activateAndRevealWorktree, type AgentStartedTelemetry } from '@/lib/worktree-activation'
|
||||
import { runBackgroundWorktreeCreation } from '@/lib/worktree-creation-flow'
|
||||
import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
|
||||
import {
|
||||
findPendingLinkedWorkItemCreationId,
|
||||
type WorktreeCreationRequest
|
||||
} from '@/lib/pending-worktree-creation'
|
||||
import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup'
|
||||
import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../shared/tui-agent-selection'
|
||||
import { repoIsRemote } from '../../../shared/agent-launch-remote'
|
||||
|
|
@ -168,14 +171,19 @@ import {
|
|||
shouldPreserveWorkspaceSourceOnRepoChange
|
||||
} from '../../../shared/new-workspace/workspace-source'
|
||||
import { CONTEXTUAL_TOUR_ENABLE_AUTO_WORKSPACE_NAME_EVENT } from '@/components/contextual-tours/contextual-tour-composer-events'
|
||||
import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
|
||||
import {
|
||||
confirmRuntimeIssueCommandRead,
|
||||
ensureHooksConfirmed,
|
||||
readAndConfirmRuntimeIssueCommand
|
||||
} from '@/lib/ensure-hooks-confirmed'
|
||||
import { normalizeSparseDirectoryLines, sparseDirectoriesMatch } from '@/lib/sparse-paths'
|
||||
import { joinPath } from '@/lib/path'
|
||||
import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client'
|
||||
import {
|
||||
checkRuntimeHooks,
|
||||
readRuntimeIssueCommand,
|
||||
type HookCheckResult
|
||||
type HookCheckResult,
|
||||
type IssueCommandReadResult
|
||||
} from '@/runtime/runtime-hooks-client'
|
||||
import {
|
||||
formatWorkspaceCreateError,
|
||||
|
|
@ -198,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<string>(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<OrcaHooks | null>(null)
|
||||
const [checkedHooksRepoId, setCheckedHooksRepoId] = useState<string | null>(null)
|
||||
const [issueCommandTemplate, setIssueCommandTemplate] = useState('')
|
||||
const [hasLoadedIssueCommand, setHasLoadedIssueCommand] = useState(false)
|
||||
const [checkedHooksContextKey, setCheckedHooksContextKey] = useState<string | null>(null)
|
||||
const [loadedIssueCommand, setLoadedIssueCommand] = useState<{
|
||||
contextKey: string
|
||||
result: IssueCommandReadResult
|
||||
} | null>(null)
|
||||
const currentIssueCommand =
|
||||
loadedIssueCommand?.contextKey === selectedRepoHookContextKey ? loadedIssueCommand.result : null
|
||||
const issueCommandTemplate = currentIssueCommand?.effectiveContent ?? ''
|
||||
const hasLoadedIssueCommand =
|
||||
!selectedRepoIsGit || !enableIssueAutomation || currentIssueCommand !== null
|
||||
const [setupDecision, setSetupDecision] = useState<'run' | 'skip' | null>(null)
|
||||
const [setupAgentStartupPolicy, setSetupAgentStartupPolicy] = useState<SetupAgentStartupPolicy>(
|
||||
() => getRepoSetupAgentStartupPolicy(selectedRepo)
|
||||
|
|
@ -1184,7 +1268,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
const noteRef = useRef<string>(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<SmartGitHubPrStartPointSelection | null>(null)
|
||||
const smartGitHubPrStartPointSelectionRef = useRef<SmartGitHubPrStartPointSelection | null>(
|
||||
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<HookCheckResult>
|
||||
} | null>(null)
|
||||
const loadHookCheckForRepo = useCallback((targetRepoId: string): Promise<HookCheckResult> => {
|
||||
const key = `${selectedRepoSettingsRef.current?.activeRuntimeEnvironmentId ?? 'local'}:${targetRepoId}`
|
||||
const existing = hookCheckRef.current
|
||||
if (existing?.key === key) {
|
||||
return existing.promise
|
||||
}
|
||||
const promise = checkRuntimeHooks(selectedRepoSettingsRef.current, targetRepoId)
|
||||
hookCheckRef.current = { key, promise }
|
||||
return promise
|
||||
}, [])
|
||||
const loadHookCheckForRepo = useCallback(
|
||||
(targetRepoId: string): Promise<HookCheckResult> => {
|
||||
const key = JSON.stringify([selectedRepoExecutionHostId ?? 'local', targetRepoId])
|
||||
const existing = hookCheckRef.current
|
||||
if (existing?.key === key) {
|
||||
return existing.promise
|
||||
}
|
||||
// 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<HookCheckResult> = 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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { settleComposerSubmit } from './composer-submit-cancellation'
|
||||
|
||||
describe('settleComposerSubmit', () => {
|
||||
it('drops a resolved preflight result after cancellation', async () => {
|
||||
let cancelled = false
|
||||
let resolvePreflight: (value: string) => void = () => undefined
|
||||
const preflight = new Promise<string>((resolve) => {
|
||||
resolvePreflight = resolve
|
||||
})
|
||||
const settlement = settleComposerSubmit(preflight, () => cancelled)
|
||||
|
||||
cancelled = true
|
||||
resolvePreflight('stale result')
|
||||
|
||||
await expect(settlement).resolves.toEqual({ status: 'cancelled' })
|
||||
})
|
||||
|
||||
it('suppresses a rejected preflight after cancellation', async () => {
|
||||
let cancelled = false
|
||||
let rejectPreflight: (error: Error) => void = () => undefined
|
||||
const preflight = new Promise<string>((_resolve, reject) => {
|
||||
rejectPreflight = reject
|
||||
})
|
||||
const settlement = settleComposerSubmit(preflight, () => cancelled)
|
||||
|
||||
cancelled = true
|
||||
rejectPreflight(new Error('late failure'))
|
||||
|
||||
await expect(settlement).resolves.toEqual({ status: 'cancelled' })
|
||||
})
|
||||
|
||||
it('preserves successful and failed results while active', async () => {
|
||||
const active = () => false
|
||||
|
||||
await expect(settleComposerSubmit(Promise.resolve('ready'), active)).resolves.toEqual({
|
||||
status: 'completed',
|
||||
value: 'ready'
|
||||
})
|
||||
await expect(
|
||||
settleComposerSubmit(Promise.reject(new Error('failure')), active)
|
||||
).rejects.toThrow('failure')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
export type ComposerSubmitSettlement<T> =
|
||||
| { status: 'completed'; value: T }
|
||||
| { status: 'cancelled' }
|
||||
|
||||
export async function settleComposerSubmit<T>(
|
||||
promise: Promise<T>,
|
||||
isCancelled: () => boolean
|
||||
): Promise<ComposerSubmitSettlement<T>> {
|
||||
try {
|
||||
const value = await promise
|
||||
return isCancelled() ? { status: 'cancelled' } : { status: 'completed', value }
|
||||
} catch (error) {
|
||||
if (isCancelled()) {
|
||||
return { status: 'cancelled' }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { PersistedTrustedOrcaHooks } from '../../../shared/types'
|
||||
import { __resetTrustPromptChainForTests, ensureHooksConfirmed } from './ensure-hooks-confirmed'
|
||||
import {
|
||||
__resetTrustPromptChainForTests,
|
||||
ensureHooksConfirmed,
|
||||
readAndConfirmRuntimeIssueCommand
|
||||
} from './ensure-hooks-confirmed'
|
||||
import { hashOrcaHookScript } from './orca-hook-trust'
|
||||
import {
|
||||
createCompatibleRuntimeStatusResponseIfNeeded,
|
||||
|
|
@ -388,6 +392,98 @@ describe('ensureHooksConfirmed', () => {
|
|||
expect(readIssueCommandMock).toHaveBeenCalledWith({ repoId: 'repo-1', hostId: 'ssh:server' })
|
||||
})
|
||||
|
||||
it('approves and returns the exact issue-command bytes from one host-qualified read', async () => {
|
||||
const { state, pending } = createTestState({
|
||||
repos: [
|
||||
{ id: 'repo-1', displayName: 'Local Row' },
|
||||
{ id: 'repo-1', displayName: 'SSH Row', connectionId: 'server' }
|
||||
]
|
||||
} as unknown as Partial<AppState>)
|
||||
readIssueCommandMock
|
||||
.mockResolvedValueOnce({
|
||||
status: 'ok',
|
||||
source: 'shared',
|
||||
sharedContent: 'approved bytes',
|
||||
localContent: null,
|
||||
effectiveContent: 'approved bytes',
|
||||
localFilePath: ''
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 'ok',
|
||||
source: 'shared',
|
||||
sharedContent: 'changed bytes',
|
||||
localContent: null,
|
||||
effectiveContent: 'changed bytes',
|
||||
localFilePath: ''
|
||||
})
|
||||
|
||||
const promise = readAndConfirmRuntimeIssueCommand(state, 'repo-1', 'ssh:server')
|
||||
|
||||
await vi.waitFor(() => expect(pending).toHaveLength(1))
|
||||
expect(pending[0].data.scriptContent).toBe('approved bytes')
|
||||
pending[0].resolve('run')
|
||||
|
||||
await expect(promise).resolves.toMatchObject({
|
||||
template: 'approved bytes',
|
||||
trustDecision: 'run'
|
||||
})
|
||||
expect(readIssueCommandMock).toHaveBeenCalledTimes(1)
|
||||
expect(readIssueCommandMock).toHaveBeenCalledWith({
|
||||
repoId: 'repo-1',
|
||||
hostId: 'ssh:server'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not reuse repo-wide trust across duplicate execution hosts', async () => {
|
||||
const { state, pending } = createTestState({
|
||||
trustedOrcaHooks: { 'repo-1': { all: { approvedAt: 1 } } },
|
||||
repos: [
|
||||
{ id: 'repo-1', displayName: 'Runtime', executionHostId: 'runtime:env-1' },
|
||||
{ id: 'repo-1', displayName: 'SSH', connectionId: 'server' }
|
||||
]
|
||||
} as unknown as Partial<AppState>)
|
||||
readIssueCommandMock.mockResolvedValue({
|
||||
status: 'ok',
|
||||
source: 'shared',
|
||||
sharedContent: 'host-specific bytes',
|
||||
localContent: null,
|
||||
effectiveContent: 'host-specific bytes',
|
||||
localFilePath: ''
|
||||
})
|
||||
|
||||
const promise = readAndConfirmRuntimeIssueCommand(state, 'repo-1', 'ssh:server')
|
||||
|
||||
await vi.waitFor(() => expect(pending).toHaveLength(1))
|
||||
pending[0].resolve('skip')
|
||||
await expect(promise).resolves.toMatchObject({ trustDecision: 'skip' })
|
||||
})
|
||||
|
||||
it('does not open a trust prompt after its composer is cancelled mid-read', async () => {
|
||||
const { state, pending } = createTestState()
|
||||
let cancelled = false
|
||||
let resolveRead: (result: Record<string, unknown>) => void = () => undefined
|
||||
readIssueCommandMock.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveRead = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const promise = readAndConfirmRuntimeIssueCommand(state, 'repo-1', 'local', () => cancelled)
|
||||
cancelled = true
|
||||
resolveRead({
|
||||
status: 'ok',
|
||||
source: 'shared',
|
||||
sharedContent: 'late bytes',
|
||||
localContent: null,
|
||||
effectiveContent: 'late bytes',
|
||||
localFilePath: ''
|
||||
})
|
||||
|
||||
await expect(promise).resolves.toMatchObject({ trustDecision: 'skip' })
|
||||
expect(pending).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('fails closed when issueCommand inspection reports an error status', async () => {
|
||||
const { state, pending } = createTestState()
|
||||
readIssueCommandMock.mockResolvedValue({
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@ import type { AppState } from '@/store/types'
|
|||
import type { OrcaHooks } from '../../../shared/types'
|
||||
import { resolveHookCommandSourcePolicy } from '../../../shared/hook-command-source-policy'
|
||||
import { hashOrcaHookScript, type OrcaHookScriptKind } from './orca-hook-trust'
|
||||
import { checkRuntimeHooks, readRuntimeIssueCommand } from '@/runtime/runtime-hooks-client'
|
||||
import {
|
||||
checkRuntimeHooks,
|
||||
readRuntimeIssueCommand,
|
||||
type IssueCommandReadResult
|
||||
} from '@/runtime/runtime-hooks-client'
|
||||
import { getRuntimeEnvironmentIdForRepo } from './repo-runtime-owner'
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
|
|
@ -12,6 +16,8 @@ import {
|
|||
|
||||
export type HookScriptKind = OrcaHookScriptKind
|
||||
|
||||
const NEVER_CANCEL_TRUST_CHECK = (): boolean => false
|
||||
|
||||
// Serialize the singleton modal callback so overlapping worktree actions cannot replace it.
|
||||
let trustPromptChain: Promise<unknown> = Promise.resolve()
|
||||
|
||||
|
|
@ -88,16 +94,147 @@ function settingsForHookRepoOwner(
|
|||
: ({ activeRuntimeEnvironmentId: runtimeEnvironmentId } as AppState['settings'])
|
||||
}
|
||||
|
||||
function canUseRepoWideTrust(state: AppState, repoId: string): boolean {
|
||||
const hasDuplicateRepoId = state.repos.filter((repo) => repo.id === repoId).length > 1
|
||||
return Boolean(state.trustedOrcaHooks[repoId]?.all) && !hasDuplicateRepoId
|
||||
}
|
||||
|
||||
async function confirmScriptContent(
|
||||
state: AppState,
|
||||
repoId: string,
|
||||
scriptKind: HookScriptKind,
|
||||
scriptContent: string,
|
||||
hostId?: ExecutionHostId,
|
||||
isCancelled: () => boolean = NEVER_CANCEL_TRUST_CHECK
|
||||
): Promise<'run' | 'skip'> {
|
||||
if (isCancelled()) {
|
||||
return 'skip'
|
||||
}
|
||||
if (canUseRepoWideTrust(state, repoId) || !scriptContent) {
|
||||
return 'run'
|
||||
}
|
||||
|
||||
const contentHash = await hashOrcaHookScript(scriptContent)
|
||||
if (isCancelled()) {
|
||||
return 'skip'
|
||||
}
|
||||
const existingHash = state.trustedOrcaHooks[repoId]?.[scriptKind]?.contentHash
|
||||
if (existingHash === contentHash) {
|
||||
return 'run'
|
||||
}
|
||||
|
||||
const repo = findHookRepo(state, repoId, hostId)
|
||||
const repoName = repo?.displayName ?? 'this repository'
|
||||
const previouslyApproved = Boolean(existingHash)
|
||||
|
||||
return new Promise<'run' | 'skip'>((resolve) => {
|
||||
state.openModal('confirm-orca-yaml-hooks', {
|
||||
repoId,
|
||||
repoName,
|
||||
scriptKind,
|
||||
scriptContent,
|
||||
contentHash,
|
||||
previouslyApproved,
|
||||
onResolve: (decision: 'run' | 'skip') => resolve(decision)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function getIssueCommandTrustContent(result: IssueCommandReadResult): string {
|
||||
if (result.source === 'local') {
|
||||
return (result.localContent ?? '').trim()
|
||||
}
|
||||
if (result.source === 'shared') {
|
||||
return (result.sharedContent ?? '').trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
async function confirmIssueCommandReadResult(
|
||||
state: AppState,
|
||||
repoId: string,
|
||||
hostId: ExecutionHostId,
|
||||
result: IssueCommandReadResult,
|
||||
isCancelled: () => boolean = NEVER_CANCEL_TRUST_CHECK
|
||||
): Promise<'run' | 'skip'> {
|
||||
if (isCancelled()) {
|
||||
return 'skip'
|
||||
}
|
||||
if (result.source === 'local') {
|
||||
return 'run'
|
||||
}
|
||||
if (result.status === 'error') {
|
||||
return 'skip'
|
||||
}
|
||||
return confirmScriptContent(
|
||||
state,
|
||||
repoId,
|
||||
'issueCommand',
|
||||
getIssueCommandTrustContent(result),
|
||||
hostId,
|
||||
isCancelled
|
||||
)
|
||||
}
|
||||
|
||||
export type ConfirmedRuntimeIssueCommand = {
|
||||
result: IssueCommandReadResult
|
||||
template: string
|
||||
trustDecision: 'run' | 'skip'
|
||||
}
|
||||
|
||||
export function confirmRuntimeIssueCommandRead(
|
||||
state: AppState,
|
||||
repoId: string,
|
||||
hostId: ExecutionHostId,
|
||||
result: IssueCommandReadResult,
|
||||
isCancelled: () => boolean = NEVER_CANCEL_TRUST_CHECK
|
||||
): Promise<ConfirmedRuntimeIssueCommand> {
|
||||
return enqueueTrustPrompt(async () => ({
|
||||
result,
|
||||
template: getIssueCommandTrustContent(result),
|
||||
trustDecision: await confirmIssueCommandReadResult(state, repoId, hostId, result, isCancelled)
|
||||
}))
|
||||
}
|
||||
|
||||
export async function readAndConfirmRuntimeIssueCommand(
|
||||
state: AppState,
|
||||
repoId: string,
|
||||
hostId: ExecutionHostId,
|
||||
isCancelled: () => boolean = NEVER_CANCEL_TRUST_CHECK
|
||||
): Promise<ConfirmedRuntimeIssueCommand> {
|
||||
let result: IssueCommandReadResult
|
||||
try {
|
||||
result = await readRuntimeIssueCommand(
|
||||
settingsForHookRepoOwner(state, repoId, hostId),
|
||||
repoId,
|
||||
hostId
|
||||
)
|
||||
} catch {
|
||||
result = {
|
||||
status: 'error',
|
||||
localContent: null,
|
||||
sharedContent: null,
|
||||
effectiveContent: null,
|
||||
localFilePath: '',
|
||||
source: 'none'
|
||||
}
|
||||
}
|
||||
return confirmRuntimeIssueCommandRead(state, repoId, hostId, result, isCancelled)
|
||||
}
|
||||
|
||||
export async function ensureHooksConfirmed(
|
||||
state: AppState,
|
||||
repoId: string,
|
||||
scriptKind: HookScriptKind,
|
||||
hostId?: ExecutionHostId,
|
||||
runtimeOwnerEnvironmentId?: string | null
|
||||
runtimeOwnerEnvironmentId?: string | null,
|
||||
isCancelled: () => boolean = NEVER_CANCEL_TRUST_CHECK
|
||||
): Promise<'run' | 'skip'> {
|
||||
return enqueueTrustPrompt(async () => {
|
||||
const hasDuplicateRepoId = state.repos.filter((repo) => repo.id === repoId).length > 1
|
||||
if (state.trustedOrcaHooks[repoId]?.all && !(hostId && hasDuplicateRepoId)) {
|
||||
if (isCancelled()) {
|
||||
return 'skip'
|
||||
}
|
||||
if (canUseRepoWideTrust(state, repoId)) {
|
||||
return 'run'
|
||||
}
|
||||
|
||||
|
|
@ -155,32 +292,6 @@ export async function ensureHooksConfirmed(
|
|||
return 'skip'
|
||||
}
|
||||
|
||||
if (!scriptContent) {
|
||||
return 'run'
|
||||
}
|
||||
|
||||
const contentHash = await hashOrcaHookScript(scriptContent)
|
||||
const existingHash = state.trustedOrcaHooks[repoId]?.[scriptKind]?.contentHash
|
||||
if (existingHash === contentHash) {
|
||||
return 'run'
|
||||
}
|
||||
|
||||
const repo = findHookRepo(state, repoId, hostId)
|
||||
const repoName = repo?.displayName ?? 'this repository'
|
||||
// A non-empty existingHash that didn't match means the user approved a previous
|
||||
// version of this script; the prompt is reappearing because orca.yaml changed.
|
||||
const previouslyApproved = Boolean(existingHash)
|
||||
|
||||
return new Promise<'run' | 'skip'>((resolve) => {
|
||||
state.openModal('confirm-orca-yaml-hooks', {
|
||||
repoId,
|
||||
repoName,
|
||||
scriptKind,
|
||||
scriptContent,
|
||||
contentHash,
|
||||
previouslyApproved,
|
||||
onResolve: (decision: 'run' | 'skip') => resolve(decision)
|
||||
})
|
||||
})
|
||||
return confirmScriptContent(state, repoId, scriptKind, scriptContent, hostId, isCancelled)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
findPendingLinkedWorkItemCreationId,
|
||||
type PendingWorktreeCreation,
|
||||
type WorktreeCreationRequest
|
||||
} from './pending-worktree-creation'
|
||||
|
||||
function request(overrides: Partial<WorktreeCreationRequest> = {}): WorktreeCreationRequest {
|
||||
return {
|
||||
repoId: 'repo-1',
|
||||
name: 'workspace',
|
||||
setupDecision: 'inherit',
|
||||
agent: null,
|
||||
pendingFirstAgentMessageRename: false,
|
||||
note: '',
|
||||
startupPlan: null,
|
||||
quickPrompt: '',
|
||||
quickTelemetry: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function pending(
|
||||
creationId: string,
|
||||
creationRequest: WorktreeCreationRequest
|
||||
): PendingWorktreeCreation {
|
||||
return {
|
||||
creationId,
|
||||
phase: 'preparing',
|
||||
status: 'creating',
|
||||
startedAt: 1,
|
||||
indeterminate: false,
|
||||
loaderVisible: true,
|
||||
request: creationRequest
|
||||
}
|
||||
}
|
||||
|
||||
describe('findPendingLinkedWorkItemCreationId', () => {
|
||||
it('deduplicates the same linked item on the same execution host', () => {
|
||||
const existing = request({
|
||||
linkedIssue: 42,
|
||||
workspaceRunContext: {
|
||||
kind: 'workspace-run',
|
||||
projectId: 'project-1',
|
||||
hostId: 'ssh:server',
|
||||
projectHostSetupId: 'setup-1',
|
||||
repoId: 'repo-1',
|
||||
path: '/repo'
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
findPendingLinkedWorkItemCreationId(
|
||||
{ existing: pending('existing', existing) },
|
||||
request({
|
||||
linkedIssue: 42,
|
||||
workspaceRunContext: {
|
||||
...existing.workspaceRunContext!,
|
||||
path: '/renamed-repo'
|
||||
}
|
||||
})
|
||||
)
|
||||
).toBe('existing')
|
||||
})
|
||||
|
||||
it('keeps the same linked item on different execution hosts distinct', () => {
|
||||
const existing = request({
|
||||
linkedPR: 77,
|
||||
workspaceRunContext: {
|
||||
kind: 'workspace-run',
|
||||
projectId: 'project-1',
|
||||
hostId: 'ssh:server-a',
|
||||
projectHostSetupId: 'setup-a',
|
||||
repoId: 'repo-1',
|
||||
path: '/repo'
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
findPendingLinkedWorkItemCreationId(
|
||||
{ existing: pending('existing', existing) },
|
||||
request({
|
||||
linkedPR: 77,
|
||||
workspaceRunContext: {
|
||||
...existing.workspaceRunContext!,
|
||||
hostId: 'ssh:server-b',
|
||||
projectHostSetupId: 'setup-b'
|
||||
}
|
||||
})
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not deduplicate unlinked workspace creation', () => {
|
||||
expect(
|
||||
findPendingLinkedWorkItemCreationId({ existing: pending('existing', request()) }, request())
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -123,6 +123,29 @@ export type PendingWorktreeCreation = {
|
|||
request: WorktreeCreationRequest
|
||||
}
|
||||
|
||||
export function findPendingLinkedWorkItemCreationId(
|
||||
pendingCreations: Readonly<Record<string, PendingWorktreeCreation>>,
|
||||
request: Pick<
|
||||
WorktreeCreationRequest,
|
||||
'repoId' | 'linkedIssue' | 'linkedPR' | 'workspaceRunContext'
|
||||
>
|
||||
): string | null {
|
||||
if (request.linkedIssue == null && request.linkedPR == null) {
|
||||
return null
|
||||
}
|
||||
const hostId = request.workspaceRunContext?.hostId ?? null
|
||||
const match = Object.values(pendingCreations).find((entry) => {
|
||||
const pending = entry.request
|
||||
return (
|
||||
pending.repoId === request.repoId &&
|
||||
pending.linkedIssue === request.linkedIssue &&
|
||||
pending.linkedPR === request.linkedPR &&
|
||||
(pending.workspaceRunContext?.hostId ?? null) === hostId
|
||||
)
|
||||
})
|
||||
return match?.creationId ?? null
|
||||
}
|
||||
|
||||
/** Human-readable progress line for an in-flight create, shared by the in-frame
|
||||
* loader and the sidebar row so the two never drift. Caller handles the error
|
||||
* case; this only covers the in-progress states. */
|
||||
|
|
|
|||
|
|
@ -91,6 +91,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'),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
ALL_EXECUTION_HOSTS_SCOPE,
|
||||
getRepoExecutionHostId,
|
||||
type ExecutionHostId,
|
||||
type ExecutionHostScope
|
||||
} from '../../../shared/execution-host'
|
||||
|
|
@ -78,9 +79,12 @@ function isReadySetup(setup: ProjectHostSetup): boolean {
|
|||
|
||||
function createTarget(
|
||||
setup: ProjectHostSetup,
|
||||
repoById: ReadonlyMap<string, Repo>
|
||||
reposById: ReadonlyMap<string, readonly Repo[]>
|
||||
): WorkspaceCreationTarget | null {
|
||||
const repo = repoById.get(setup.repoId)
|
||||
const candidates = reposById.get(setup.repoId) ?? []
|
||||
const repo =
|
||||
candidates.find((candidate) => getRepoExecutionHostId(candidate) === setup.hostId) ??
|
||||
(candidates.length === 1 ? candidates[0] : null)
|
||||
if (!repo) {
|
||||
return null
|
||||
}
|
||||
|
|
@ -96,14 +100,14 @@ function createTarget(
|
|||
|
||||
function findReadySetupTarget(
|
||||
setups: readonly ProjectHostSetup[],
|
||||
repoById: ReadonlyMap<string, Repo>,
|
||||
reposById: ReadonlyMap<string, readonly Repo[]>,
|
||||
predicate: (setup: ProjectHostSetup) => boolean
|
||||
): WorkspaceCreationTarget | null {
|
||||
for (const setup of setups) {
|
||||
if (!isReadySetup(setup) || !predicate(setup)) {
|
||||
continue
|
||||
}
|
||||
const target = createTarget(setup, repoById)
|
||||
const target = createTarget(setup, reposById)
|
||||
if (target) {
|
||||
return target
|
||||
}
|
||||
|
|
@ -120,7 +124,12 @@ export function resolveWorkspaceCreationTarget(
|
|||
}
|
||||
|
||||
const model = getProjectSetupModel(input)
|
||||
const repoById = new Map(eligibleRepos.map((repo) => [repo.id, repo]))
|
||||
const reposById = new Map<string, Repo[]>()
|
||||
for (const repo of eligibleRepos) {
|
||||
const candidates = reposById.get(repo.id) ?? []
|
||||
candidates.push(repo)
|
||||
reposById.set(repo.id, candidates)
|
||||
}
|
||||
const actionableHostIds = input.actionableHostIds
|
||||
const allSetups = model?.setups ?? []
|
||||
const setups = actionableHostIds
|
||||
|
|
@ -147,9 +156,9 @@ export function resolveWorkspaceCreationTarget(
|
|||
const canonical =
|
||||
findReadySetupTarget(
|
||||
setups,
|
||||
repoById,
|
||||
reposById,
|
||||
(entry) => entry.projectId === setup.projectId && entry.hostId === setup.hostId
|
||||
) ?? createTarget(setup, repoById)
|
||||
) ?? createTarget(setup, reposById)
|
||||
if (canonical) {
|
||||
return { status: 'ready', target: canonical }
|
||||
}
|
||||
|
|
@ -169,7 +178,7 @@ export function resolveWorkspaceCreationTarget(
|
|||
}
|
||||
const target = findReadySetupTarget(
|
||||
setups,
|
||||
repoById,
|
||||
reposById,
|
||||
(setup) => setup.projectId === projectId && setup.hostId === hostId
|
||||
)
|
||||
if (target) {
|
||||
|
|
@ -184,14 +193,14 @@ export function resolveWorkspaceCreationTarget(
|
|||
const focusedTarget = focusedHostId
|
||||
? findReadySetupTarget(
|
||||
setups,
|
||||
repoById,
|
||||
reposById,
|
||||
(setup) => setup.projectId === projectId && setup.hostId === focusedHostId
|
||||
)
|
||||
: null
|
||||
if (focusedTarget) {
|
||||
return { status: 'ready', target: focusedTarget }
|
||||
}
|
||||
const target = findReadySetupTarget(setups, repoById, (setup) => setup.projectId === projectId)
|
||||
const target = findReadySetupTarget(setups, reposById, (setup) => setup.projectId === projectId)
|
||||
if (target) {
|
||||
return { status: 'ready', target }
|
||||
}
|
||||
|
|
@ -199,7 +208,7 @@ export function resolveWorkspaceCreationTarget(
|
|||
}
|
||||
|
||||
if (hostId) {
|
||||
const target = findReadySetupTarget(setups, repoById, (setup) => setup.hostId === hostId)
|
||||
const target = findReadySetupTarget(setups, reposById, (setup) => setup.hostId === hostId)
|
||||
if (target) {
|
||||
return { status: 'ready', target }
|
||||
}
|
||||
|
|
@ -209,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 {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
PendingWorktreeCreation,
|
||||
WorktreeCreationRequest
|
||||
} from '@/lib/pending-worktree-creation'
|
||||
|
||||
const store = {
|
||||
settings: {
|
||||
activeRuntimeEnvironmentId: null as string | null
|
||||
},
|
||||
pendingWorktreeCreations: {} as Record<string, PendingWorktreeCreation>,
|
||||
beginPendingWorktreeCreation: vi.fn(),
|
||||
setActivePendingWorktreeCreation: vi.fn(),
|
||||
setActiveView: vi.fn(),
|
||||
setSidebarOpen: vi.fn(),
|
||||
createWorktree: vi.fn()
|
||||
}
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => store
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/browser-uuid', () => ({
|
||||
createBrowserUuid: () => 'creation-new'
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-activation', () => ({
|
||||
activateAndRevealWorktree: vi.fn(),
|
||||
ensureWorktreeHasInitialTerminal: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspace-activation-terminal-focus', () => ({
|
||||
queueWorkspaceActivationTerminalFocus: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/new-workspace', () => ({
|
||||
ensureAgentStartupInTerminal: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { error: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/ephemeral-vm-workspace-target', () => ({
|
||||
prepareEphemeralVmWorkspaceTarget: vi.fn()
|
||||
}))
|
||||
|
||||
import { runBackgroundWorktreeCreation } from './worktree-creation-flow'
|
||||
|
||||
function makeRequest(overrides: Partial<WorktreeCreationRequest> = {}): WorktreeCreationRequest {
|
||||
return {
|
||||
repoId: 'repo-1',
|
||||
name: 'feature',
|
||||
setupDecision: 'inherit',
|
||||
agent: null,
|
||||
pendingFirstAgentMessageRename: false,
|
||||
note: '',
|
||||
startupPlan: null,
|
||||
quickPrompt: '',
|
||||
quickTelemetry: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makePendingCreation(request: WorktreeCreationRequest): PendingWorktreeCreation {
|
||||
return {
|
||||
creationId: 'existing',
|
||||
phase: 'preparing',
|
||||
status: 'creating',
|
||||
startedAt: 1,
|
||||
indeterminate: false,
|
||||
loaderVisible: true,
|
||||
request
|
||||
}
|
||||
}
|
||||
|
||||
describe('runBackgroundWorktreeCreation linked-item dedupe', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
store.pendingWorktreeCreations = {}
|
||||
})
|
||||
|
||||
it('reveals an existing linked-item creation instead of starting a duplicate', () => {
|
||||
const linkedRequest = makeRequest({
|
||||
linkedIssue: 42,
|
||||
workspaceRunContext: {
|
||||
kind: 'workspace-run',
|
||||
projectId: 'project-1',
|
||||
hostId: 'ssh:server',
|
||||
projectHostSetupId: 'setup-1',
|
||||
repoId: 'repo-1',
|
||||
path: '/repo'
|
||||
}
|
||||
})
|
||||
store.pendingWorktreeCreations = {
|
||||
existing: makePendingCreation(linkedRequest)
|
||||
}
|
||||
|
||||
const creationId = runBackgroundWorktreeCreation(linkedRequest)
|
||||
|
||||
expect(creationId).toBe('existing')
|
||||
expect(store.setActivePendingWorktreeCreation).toHaveBeenCalledWith('existing')
|
||||
expect(store.setActiveView).toHaveBeenCalledWith('terminal')
|
||||
expect(store.setSidebarOpen).toHaveBeenCalledWith(true)
|
||||
expect(store.beginPendingWorktreeCreation).not.toHaveBeenCalled()
|
||||
expect(store.createWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { useAppStore } from '@/store'
|
||||
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import type { WorktreeStartupPayload } from '@/lib/worktree-activation'
|
||||
import type {
|
||||
WorktreeCreationPhase,
|
||||
WorktreeCreationRequest
|
||||
} from '@/lib/pending-worktree-creation'
|
||||
|
||||
// Why: mirrors the startup-opt the composer used to build inline. The renderer
|
||||
// only seeds the first terminal when the backend did not already spawn it.
|
||||
export function buildWorktreeCreationStartupOpt(
|
||||
request: WorktreeCreationRequest,
|
||||
backendSpawned: boolean
|
||||
): WorktreeStartupPayload | undefined {
|
||||
const plan = request.startupPlan
|
||||
if (!plan || backendSpawned) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
command: plan.launchCommand,
|
||||
...(plan.env ? { env: plan.env } : {}),
|
||||
launchConfig: plan.launchConfig,
|
||||
...(plan.launchToken ? { launchToken: plan.launchToken } : {}),
|
||||
...(request.agent ? { launchAgent: request.agent } : {}),
|
||||
...(plan.draftPrompt ? { draftPrompt: plan.draftPrompt } : {}),
|
||||
// Why: view-mode only. An argv-prefill plan sets no draftPrompt, so this is
|
||||
// the sole signal that this launch starts with unsent context in the TUI.
|
||||
...(request.launchDraftPrompt ? { launchDraftText: request.launchDraftPrompt } : {}),
|
||||
...(plan.startupCommandDelivery ? { startupCommandDelivery: plan.startupCommandDelivery } : {}),
|
||||
// Why: command-code shows its prompt in the tab status before the first
|
||||
// hook fires, so the prompt is threaded through here.
|
||||
...(request.agent === 'command-code' && request.quickPrompt.trim().length > 0
|
||||
? { initialAgentStatus: { agent: request.agent, prompt: request.quickPrompt.trim() } }
|
||||
: {}),
|
||||
...(request.quickTelemetry ? { telemetry: request.quickTelemetry } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorktreeCreationIndeterminate(request: WorktreeCreationRequest): boolean {
|
||||
if (request.worktreeCreateProgressMode) {
|
||||
return request.worktreeCreateProgressMode === 'indeterminate'
|
||||
}
|
||||
return getActiveRuntimeTarget(useAppStore.getState().settings).kind !== 'local'
|
||||
}
|
||||
|
||||
export function getInitialWorktreeCreationPhase(
|
||||
request: WorktreeCreationRequest
|
||||
): WorktreeCreationPhase {
|
||||
return request.ephemeralVmRecipe && !request.ephemeralVmRuntimeId ? 'provisioning-vm' : 'fetching'
|
||||
}
|
||||
|
|
@ -4,12 +4,10 @@ import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
|
|||
import {
|
||||
activateAndRevealWorktree,
|
||||
ensureWorktreeHasInitialTerminal,
|
||||
type ActivateAndRevealResult,
|
||||
type WorktreeStartupPayload
|
||||
type ActivateAndRevealResult
|
||||
} from '@/lib/worktree-activation'
|
||||
import { ensureAgentStartupInTerminal } from '@/lib/new-workspace'
|
||||
import { queueWorkspaceActivationTerminalFocus } from '@/lib/workspace-activation-terminal-focus'
|
||||
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import {
|
||||
attachEphemeralVmRuntimeToWorkspace,
|
||||
cleanupEphemeralVmRuntimeForFailedCreate,
|
||||
|
|
@ -20,59 +18,24 @@ import {
|
|||
getWorkspaceCreateErrorToastMessage
|
||||
} from '@/lib/workspace-create-error-format'
|
||||
import type { CreateWorktreeResult } from '../../../shared/types'
|
||||
import type {
|
||||
WorktreeCreationPhase,
|
||||
WorktreeCreationRequest
|
||||
import {
|
||||
findPendingLinkedWorkItemCreationId,
|
||||
type WorktreeCreationPhase,
|
||||
type WorktreeCreationRequest
|
||||
} from '@/lib/pending-worktree-creation'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { seedAgentTabStateAfterWorktreeCreate } from '@/lib/worktree-creation-agent-seeds'
|
||||
import { resolveBackendDraftStartup } from '@/lib/worktree-draft-startup-view-mode'
|
||||
import {
|
||||
buildWorktreeCreationStartupOpt,
|
||||
getInitialWorktreeCreationPhase,
|
||||
getWorktreeCreationIndeterminate
|
||||
} from '@/lib/worktree-creation-flow-startup'
|
||||
|
||||
type ContinueBackgroundWorktreeCreationOptions = {
|
||||
revealCreationSurface?: boolean
|
||||
}
|
||||
|
||||
// Why: mirrors the startup-opt the composer used to build inline. The renderer
|
||||
// only seeds the first terminal when the backend did not already spawn it.
|
||||
function buildStartupOpt(
|
||||
request: WorktreeCreationRequest,
|
||||
backendSpawned: boolean
|
||||
): WorktreeStartupPayload | undefined {
|
||||
const plan = request.startupPlan
|
||||
if (!plan || backendSpawned) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
command: plan.launchCommand,
|
||||
...(plan.env ? { env: plan.env } : {}),
|
||||
launchConfig: plan.launchConfig,
|
||||
...(plan.launchToken ? { launchToken: plan.launchToken } : {}),
|
||||
...(request.agent ? { launchAgent: request.agent } : {}),
|
||||
...(plan.draftPrompt ? { draftPrompt: plan.draftPrompt } : {}),
|
||||
// Why: view-mode only. An argv-prefill plan sets no draftPrompt, so this is
|
||||
// the sole signal that this launch starts with unsent context in the TUI.
|
||||
...(request.launchDraftPrompt ? { launchDraftText: request.launchDraftPrompt } : {}),
|
||||
...(plan.startupCommandDelivery ? { startupCommandDelivery: plan.startupCommandDelivery } : {}),
|
||||
// Why: command-code shows its prompt in the tab status before the first
|
||||
// hook fires, so the prompt is threaded through here.
|
||||
...(request.agent === 'command-code' && request.quickPrompt.trim().length > 0
|
||||
? { initialAgentStatus: { agent: request.agent, prompt: request.quickPrompt.trim() } }
|
||||
: {}),
|
||||
...(request.quickTelemetry ? { telemetry: request.quickTelemetry } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function getWorktreeCreationIndeterminate(request: WorktreeCreationRequest): boolean {
|
||||
if (request.worktreeCreateProgressMode) {
|
||||
return request.worktreeCreateProgressMode === 'indeterminate'
|
||||
}
|
||||
return getActiveRuntimeTarget(useAppStore.getState().settings).kind !== 'local'
|
||||
}
|
||||
|
||||
function getInitialWorktreeCreationPhase(request: WorktreeCreationRequest): WorktreeCreationPhase {
|
||||
return request.ephemeralVmRecipe && !request.ephemeralVmRuntimeId ? 'provisioning-vm' : 'fetching'
|
||||
}
|
||||
|
||||
// Why: activePendingCreationId can outlive the terminal route when the user
|
||||
// switches app views; only the terminal route renders the creation panel.
|
||||
function isPendingCreationSurfaceVisible(creationId: string): boolean {
|
||||
|
|
@ -220,7 +183,7 @@ async function executeWorktreeCreation(
|
|||
// startup, so both halves of the handoff share one renderer-session token.
|
||||
preparedRequest.startupPlan.launchToken = createBrowserUuid()
|
||||
}
|
||||
const startupOpt = buildStartupOpt(preparedRequest, backendSpawned)
|
||||
const startupOpt = buildWorktreeCreationStartupOpt(preparedRequest, backendSpawned)
|
||||
|
||||
if (worktree.path) {
|
||||
const repoConnectionId =
|
||||
|
|
@ -305,12 +268,24 @@ async function executeWorktreeCreation(
|
|||
* immediately and the work outlives the now-closed modal. Progress and errors
|
||||
* surface on the pending creation's sidebar row and content panel.
|
||||
*/
|
||||
export function runBackgroundWorktreeCreation(request: WorktreeCreationRequest): void {
|
||||
export function runBackgroundWorktreeCreation(request: WorktreeCreationRequest): string {
|
||||
const store = useAppStore.getState()
|
||||
const existingCreationId = findPendingLinkedWorkItemCreationId(
|
||||
store.pendingWorktreeCreations,
|
||||
request
|
||||
)
|
||||
if (existingCreationId) {
|
||||
store.setActivePendingWorktreeCreation(existingCreationId)
|
||||
store.setActiveView('terminal')
|
||||
store.setSidebarOpen(true)
|
||||
return existingCreationId
|
||||
}
|
||||
// Why: crypto.randomUUID is undefined in non-secure browser contexts (LAN web
|
||||
// client over plain HTTP). createBrowserUuid falls back to getRandomValues.
|
||||
const creationId = createBrowserUuid()
|
||||
revealPendingCreation(creationId, request, getInitialWorktreeCreationPhase(request))
|
||||
void executeWorktreeCreation(creationId, request)
|
||||
return creationId
|
||||
}
|
||||
|
||||
/** Stage a pending entry before async preflight so the UI shows immediate progress. */
|
||||
|
|
|
|||
Loading…
Reference in New Issue