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:
Jinjing 2026-08-05 02:34:13 -07:00 committed by GitHub
parent d15939c5fd
commit ade4354ddd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1514 additions and 294 deletions

View File

@ -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 (
<>

View File

@ -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(

View File

@ -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')
})
})

View File

@ -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')
})
})

View File

@ -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')
})

View File

@ -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')
})
})

View File

@ -15,7 +15,10 @@ import {
} from '@/lib/github-links'
import { activateAndRevealWorktree, type AgentStartedTelemetry } from '@/lib/worktree-activation'
import { runBackgroundWorktreeCreation } from '@/lib/worktree-creation-flow'
import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
import {
findPendingLinkedWorkItemCreationId,
type WorktreeCreationRequest
} from '@/lib/pending-worktree-creation'
import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup'
import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../shared/tui-agent-selection'
import { repoIsRemote } from '../../../shared/agent-launch-remote'
@ -168,14 +171,19 @@ import {
shouldPreserveWorkspaceSourceOnRepoChange
} from '../../../shared/new-workspace/workspace-source'
import { CONTEXTUAL_TOUR_ENABLE_AUTO_WORKSPACE_NAME_EVENT } from '@/components/contextual-tours/contextual-tour-composer-events'
import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
import {
confirmRuntimeIssueCommandRead,
ensureHooksConfirmed,
readAndConfirmRuntimeIssueCommand
} from '@/lib/ensure-hooks-confirmed'
import { normalizeSparseDirectoryLines, sparseDirectoriesMatch } from '@/lib/sparse-paths'
import { joinPath } from '@/lib/path'
import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client'
import {
checkRuntimeHooks,
readRuntimeIssueCommand,
type HookCheckResult
type HookCheckResult,
type IssueCommandReadResult
} from '@/runtime/runtime-hooks-client'
import {
formatWorkspaceCreateError,
@ -198,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 1015s; 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,

View File

@ -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)
})
})

View File

@ -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
})
}
}

View File

@ -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')
})
})

View File

@ -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
}
}

View File

@ -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({

View File

@ -2,7 +2,11 @@ import type { AppState } from '@/store/types'
import type { OrcaHooks } from '../../../shared/types'
import { resolveHookCommandSourcePolicy } from '../../../shared/hook-command-source-policy'
import { hashOrcaHookScript, type OrcaHookScriptKind } from './orca-hook-trust'
import { checkRuntimeHooks, readRuntimeIssueCommand } from '@/runtime/runtime-hooks-client'
import {
checkRuntimeHooks,
readRuntimeIssueCommand,
type IssueCommandReadResult
} from '@/runtime/runtime-hooks-client'
import { getRuntimeEnvironmentIdForRepo } from './repo-runtime-owner'
import {
getRepoExecutionHostId,
@ -12,6 +16,8 @@ import {
export type HookScriptKind = OrcaHookScriptKind
const NEVER_CANCEL_TRUST_CHECK = (): boolean => false
// Serialize the singleton modal callback so overlapping worktree actions cannot replace it.
let trustPromptChain: Promise<unknown> = Promise.resolve()
@ -88,16 +94,147 @@ function settingsForHookRepoOwner(
: ({ activeRuntimeEnvironmentId: runtimeEnvironmentId } as AppState['settings'])
}
function canUseRepoWideTrust(state: AppState, repoId: string): boolean {
const hasDuplicateRepoId = state.repos.filter((repo) => repo.id === repoId).length > 1
return Boolean(state.trustedOrcaHooks[repoId]?.all) && !hasDuplicateRepoId
}
async function confirmScriptContent(
state: AppState,
repoId: string,
scriptKind: HookScriptKind,
scriptContent: string,
hostId?: ExecutionHostId,
isCancelled: () => boolean = NEVER_CANCEL_TRUST_CHECK
): Promise<'run' | 'skip'> {
if (isCancelled()) {
return 'skip'
}
if (canUseRepoWideTrust(state, repoId) || !scriptContent) {
return 'run'
}
const contentHash = await hashOrcaHookScript(scriptContent)
if (isCancelled()) {
return 'skip'
}
const existingHash = state.trustedOrcaHooks[repoId]?.[scriptKind]?.contentHash
if (existingHash === contentHash) {
return 'run'
}
const repo = findHookRepo(state, repoId, hostId)
const repoName = repo?.displayName ?? 'this repository'
const previouslyApproved = Boolean(existingHash)
return new Promise<'run' | 'skip'>((resolve) => {
state.openModal('confirm-orca-yaml-hooks', {
repoId,
repoName,
scriptKind,
scriptContent,
contentHash,
previouslyApproved,
onResolve: (decision: 'run' | 'skip') => resolve(decision)
})
})
}
function getIssueCommandTrustContent(result: IssueCommandReadResult): string {
if (result.source === 'local') {
return (result.localContent ?? '').trim()
}
if (result.source === 'shared') {
return (result.sharedContent ?? '').trim()
}
return ''
}
async function confirmIssueCommandReadResult(
state: AppState,
repoId: string,
hostId: ExecutionHostId,
result: IssueCommandReadResult,
isCancelled: () => boolean = NEVER_CANCEL_TRUST_CHECK
): Promise<'run' | 'skip'> {
if (isCancelled()) {
return 'skip'
}
if (result.source === 'local') {
return 'run'
}
if (result.status === 'error') {
return 'skip'
}
return confirmScriptContent(
state,
repoId,
'issueCommand',
getIssueCommandTrustContent(result),
hostId,
isCancelled
)
}
export type ConfirmedRuntimeIssueCommand = {
result: IssueCommandReadResult
template: string
trustDecision: 'run' | 'skip'
}
export function confirmRuntimeIssueCommandRead(
state: AppState,
repoId: string,
hostId: ExecutionHostId,
result: IssueCommandReadResult,
isCancelled: () => boolean = NEVER_CANCEL_TRUST_CHECK
): Promise<ConfirmedRuntimeIssueCommand> {
return enqueueTrustPrompt(async () => ({
result,
template: getIssueCommandTrustContent(result),
trustDecision: await confirmIssueCommandReadResult(state, repoId, hostId, result, isCancelled)
}))
}
export async function readAndConfirmRuntimeIssueCommand(
state: AppState,
repoId: string,
hostId: ExecutionHostId,
isCancelled: () => boolean = NEVER_CANCEL_TRUST_CHECK
): Promise<ConfirmedRuntimeIssueCommand> {
let result: IssueCommandReadResult
try {
result = await readRuntimeIssueCommand(
settingsForHookRepoOwner(state, repoId, hostId),
repoId,
hostId
)
} catch {
result = {
status: 'error',
localContent: null,
sharedContent: null,
effectiveContent: null,
localFilePath: '',
source: 'none'
}
}
return confirmRuntimeIssueCommandRead(state, repoId, hostId, result, isCancelled)
}
export async function ensureHooksConfirmed(
state: AppState,
repoId: string,
scriptKind: HookScriptKind,
hostId?: ExecutionHostId,
runtimeOwnerEnvironmentId?: string | null
runtimeOwnerEnvironmentId?: string | null,
isCancelled: () => boolean = NEVER_CANCEL_TRUST_CHECK
): Promise<'run' | 'skip'> {
return enqueueTrustPrompt(async () => {
const hasDuplicateRepoId = state.repos.filter((repo) => repo.id === repoId).length > 1
if (state.trustedOrcaHooks[repoId]?.all && !(hostId && hasDuplicateRepoId)) {
if (isCancelled()) {
return 'skip'
}
if (canUseRepoWideTrust(state, repoId)) {
return 'run'
}
@ -155,32 +292,6 @@ export async function ensureHooksConfirmed(
return 'skip'
}
if (!scriptContent) {
return 'run'
}
const contentHash = await hashOrcaHookScript(scriptContent)
const existingHash = state.trustedOrcaHooks[repoId]?.[scriptKind]?.contentHash
if (existingHash === contentHash) {
return 'run'
}
const repo = findHookRepo(state, repoId, hostId)
const repoName = repo?.displayName ?? 'this repository'
// A non-empty existingHash that didn't match means the user approved a previous
// version of this script; the prompt is reappearing because orca.yaml changed.
const previouslyApproved = Boolean(existingHash)
return new Promise<'run' | 'skip'>((resolve) => {
state.openModal('confirm-orca-yaml-hooks', {
repoId,
repoName,
scriptKind,
scriptContent,
contentHash,
previouslyApproved,
onResolve: (decision: 'run' | 'skip') => resolve(decision)
})
})
return confirmScriptContent(state, repoId, scriptKind, scriptContent, hostId, isCancelled)
})
}

View File

@ -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)

View File

@ -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()
})
})

View File

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

View File

@ -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'),

View File

@ -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 {

View File

@ -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()
})
})

View File

@ -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'
}

View File

@ -4,12 +4,10 @@ import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
import {
activateAndRevealWorktree,
ensureWorktreeHasInitialTerminal,
type ActivateAndRevealResult,
type WorktreeStartupPayload
type ActivateAndRevealResult
} from '@/lib/worktree-activation'
import { ensureAgentStartupInTerminal } from '@/lib/new-workspace'
import { queueWorkspaceActivationTerminalFocus } from '@/lib/workspace-activation-terminal-focus'
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import {
attachEphemeralVmRuntimeToWorkspace,
cleanupEphemeralVmRuntimeForFailedCreate,
@ -20,59 +18,24 @@ import {
getWorkspaceCreateErrorToastMessage
} from '@/lib/workspace-create-error-format'
import type { CreateWorktreeResult } from '../../../shared/types'
import type {
WorktreeCreationPhase,
WorktreeCreationRequest
import {
findPendingLinkedWorkItemCreationId,
type WorktreeCreationPhase,
type WorktreeCreationRequest
} from '@/lib/pending-worktree-creation'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { seedAgentTabStateAfterWorktreeCreate } from '@/lib/worktree-creation-agent-seeds'
import { resolveBackendDraftStartup } from '@/lib/worktree-draft-startup-view-mode'
import {
buildWorktreeCreationStartupOpt,
getInitialWorktreeCreationPhase,
getWorktreeCreationIndeterminate
} from '@/lib/worktree-creation-flow-startup'
type ContinueBackgroundWorktreeCreationOptions = {
revealCreationSurface?: boolean
}
// Why: mirrors the startup-opt the composer used to build inline. The renderer
// only seeds the first terminal when the backend did not already spawn it.
function buildStartupOpt(
request: WorktreeCreationRequest,
backendSpawned: boolean
): WorktreeStartupPayload | undefined {
const plan = request.startupPlan
if (!plan || backendSpawned) {
return undefined
}
return {
command: plan.launchCommand,
...(plan.env ? { env: plan.env } : {}),
launchConfig: plan.launchConfig,
...(plan.launchToken ? { launchToken: plan.launchToken } : {}),
...(request.agent ? { launchAgent: request.agent } : {}),
...(plan.draftPrompt ? { draftPrompt: plan.draftPrompt } : {}),
// Why: view-mode only. An argv-prefill plan sets no draftPrompt, so this is
// the sole signal that this launch starts with unsent context in the TUI.
...(request.launchDraftPrompt ? { launchDraftText: request.launchDraftPrompt } : {}),
...(plan.startupCommandDelivery ? { startupCommandDelivery: plan.startupCommandDelivery } : {}),
// Why: command-code shows its prompt in the tab status before the first
// hook fires, so the prompt is threaded through here.
...(request.agent === 'command-code' && request.quickPrompt.trim().length > 0
? { initialAgentStatus: { agent: request.agent, prompt: request.quickPrompt.trim() } }
: {}),
...(request.quickTelemetry ? { telemetry: request.quickTelemetry } : {})
}
}
function getWorktreeCreationIndeterminate(request: WorktreeCreationRequest): boolean {
if (request.worktreeCreateProgressMode) {
return request.worktreeCreateProgressMode === 'indeterminate'
}
return getActiveRuntimeTarget(useAppStore.getState().settings).kind !== 'local'
}
function getInitialWorktreeCreationPhase(request: WorktreeCreationRequest): WorktreeCreationPhase {
return request.ephemeralVmRecipe && !request.ephemeralVmRuntimeId ? 'provisioning-vm' : 'fetching'
}
// Why: activePendingCreationId can outlive the terminal route when the user
// switches app views; only the terminal route renders the creation panel.
function isPendingCreationSurfaceVisible(creationId: string): boolean {
@ -220,7 +183,7 @@ async function executeWorktreeCreation(
// startup, so both halves of the handoff share one renderer-session token.
preparedRequest.startupPlan.launchToken = createBrowserUuid()
}
const startupOpt = buildStartupOpt(preparedRequest, backendSpawned)
const startupOpt = buildWorktreeCreationStartupOpt(preparedRequest, backendSpawned)
if (worktree.path) {
const repoConnectionId =
@ -305,12 +268,24 @@ async function executeWorktreeCreation(
* immediately and the work outlives the now-closed modal. Progress and errors
* surface on the pending creation's sidebar row and content panel.
*/
export function runBackgroundWorktreeCreation(request: WorktreeCreationRequest): void {
export function runBackgroundWorktreeCreation(request: WorktreeCreationRequest): string {
const store = useAppStore.getState()
const existingCreationId = findPendingLinkedWorkItemCreationId(
store.pendingWorktreeCreations,
request
)
if (existingCreationId) {
store.setActivePendingWorktreeCreation(existingCreationId)
store.setActiveView('terminal')
store.setSidebarOpen(true)
return existingCreationId
}
// Why: crypto.randomUUID is undefined in non-secure browser contexts (LAN web
// client over plain HTTP). createBrowserUuid falls back to getRandomValues.
const creationId = createBrowserUuid()
revealPendingCreation(creationId, request, getInitialWorktreeCreationPhase(request))
void executeWorktreeCreation(creationId, request)
return creationId
}
/** Stage a pending entry before async preflight so the UI shows immediate progress. */