diff --git a/src/renderer/src/lib/github-work-item-background-create.test.ts b/src/renderer/src/lib/github-work-item-background-create.test.ts index 17a70124d38..a4e6d8f0adf 100644 --- a/src/renderer/src/lib/github-work-item-background-create.test.ts +++ b/src/renderer/src/lib/github-work-item-background-create.test.ts @@ -97,6 +97,13 @@ function makeDeps(store = makeStore()) { resolveSetupDecision: vi.fn().mockResolvedValue({ kind: 'decided', decision: 'inherit' }), resolvePrStartPoint: vi.fn(), confirmHooks: vi.fn().mockResolvedValue('run'), + readIssueCommand: vi.fn().mockResolvedValue({ + effectiveContent: null, + localContent: null, + sharedContent: null, + localFilePath: '', + source: 'none' + }), beginBackgroundCreate: vi.fn(() => 'creation-1'), continueBackgroundCreate: vi.fn(() => true), activatePendingCreate: vi.fn(), @@ -607,4 +614,206 @@ describe('createGitHubWorkItemWorkspaceInBackground', () => { expect(request.startup?.command).toBe('codex --prompt-file') expect(buildAgentStartupPlan).not.toHaveBeenCalled() }) + + it('attaches the rendered issue command when the repo configured one and trust runs', async () => { + const deps = makeDeps() + deps.readIssueCommand.mockResolvedValueOnce({ + effectiveContent: 'gh issue view {{issue}} --repo {{artifact_url}}', + localContent: null, + sharedContent: 'gh issue view {{issue}} --repo {{artifact_url}}', + localFilePath: '', + source: 'shared' + }) + + await createGitHubWorkItemWorkspaceInBackground( + { + item: makeIssue(), + repoId: 'repo-1', + openModalFallback: vi.fn() + }, + deps + ) + + expect(deps.confirmHooks).toHaveBeenCalledWith(expect.anything(), 'repo-1', 'setup') + expect(deps.confirmHooks).toHaveBeenCalledWith(expect.anything(), 'repo-1', 'issueCommand') + const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined + expect(continueCall).toBeDefined() + const request = continueCall?.[1] as WorktreeCreationRequest + expect(request.issueCommand?.command).toBe( + 'gh issue view 42 --repo https://github.com/stablyai/orca/issues/42' + ) + }) + + it('omits the issue command when the repo configured none', async () => { + const deps = makeDeps() + + await createGitHubWorkItemWorkspaceInBackground( + { + item: makeIssue(), + repoId: 'repo-1', + openModalFallback: vi.fn() + }, + deps + ) + + const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined + expect(continueCall).toBeDefined() + const request = continueCall?.[1] as WorktreeCreationRequest + expect(request.issueCommand).toBeUndefined() + expect(deps.confirmHooks).not.toHaveBeenCalledWith(expect.anything(), 'repo-1', 'issueCommand') + }) + + it('skips the issue command when setup trust was declined', async () => { + const deps = makeDeps() + deps.readIssueCommand.mockResolvedValueOnce({ + effectiveContent: 'echo {{issue}}', + localContent: 'echo {{issue}}', + sharedContent: null, + localFilePath: '/repo/.orca/issue-command', + source: 'local' + }) + // Why: the setup confirmHooks call resolves 'skip', which mirrors the + // composer suppressing the issue command when setup trust is declined. + deps.confirmHooks.mockResolvedValue('skip') + + await createGitHubWorkItemWorkspaceInBackground( + { + item: makeIssue(), + repoId: 'repo-1', + openModalFallback: vi.fn() + }, + deps + ) + + const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined + expect(continueCall).toBeDefined() + const request = continueCall?.[1] as WorktreeCreationRequest + expect(request.issueCommand).toBeUndefined() + expect(deps.confirmHooks).not.toHaveBeenCalledWith(expect.anything(), 'repo-1', 'issueCommand') + // Why: a declined setup trust short-circuits before the (up-to-15s) read, + // so workspace creation is never stalled to fetch a command we will drop. + expect(deps.readIssueCommand).not.toHaveBeenCalled() + }) + + it('does not run the issue command for PR items', async () => { + const deps = makeDeps() + deps.resolvePrStartPoint.mockResolvedValueOnce({ + baseBranch: 'feature/from-pr', + pushTarget: { remote: 'origin', branch: 'feature/from-pr' }, + branchNameOverride: 'feature/from-pr', + compareBaseRef: 'main' + }) + deps.readIssueCommand.mockResolvedValueOnce({ + effectiveContent: 'echo {{issue}}', + localContent: 'echo {{issue}}', + sharedContent: null, + localFilePath: '/repo/.orca/issue-command', + source: 'local' + }) + + await createGitHubWorkItemWorkspaceInBackground( + { + item: makeIssue({ type: 'pr', number: 7, url: 'https://github.com/stablyai/orca/pull/7' }), + repoId: 'repo-1', + openModalFallback: vi.fn() + }, + deps + ) + + const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined + expect(continueCall).toBeDefined() + const request = continueCall?.[1] as WorktreeCreationRequest + expect(request.issueCommand).toBeUndefined() + expect(deps.confirmHooks).not.toHaveBeenCalledWith(expect.anything(), 'repo-1', 'issueCommand') + }) + + it('fails closed when reading the issue command rejects', async () => { + const deps = makeDeps() + deps.readIssueCommand.mockRejectedValueOnce(new Error('runtime offline')) + + const result = await createGitHubWorkItemWorkspaceInBackground( + { + item: makeIssue(), + repoId: 'repo-1', + openModalFallback: vi.fn() + }, + deps + ) + + expect(result).toEqual({ kind: 'background-started' }) + const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined + expect(continueCall).toBeDefined() + const request = continueCall?.[1] as WorktreeCreationRequest + expect(request.issueCommand).toBeUndefined() + }) + + it('re-reads the store for each trust check so mid-flow trust updates are honored', async () => { + const snapshots = [makeStore(), makeStore(), makeStore()] + let getStoreCall = 0 + const deps = makeDeps(snapshots[0]) + deps.getStore = vi.fn( + () => snapshots[Math.min(getStoreCall++, snapshots.length - 1)] ?? snapshots[0] + ) + deps.readIssueCommand.mockResolvedValueOnce({ + effectiveContent: 'echo {{issue}}', + localContent: null, + sharedContent: 'echo {{issue}}', + localFilePath: '', + source: 'shared' + }) + + await createGitHubWorkItemWorkspaceInBackground( + { + item: makeIssue(), + repoId: 'repo-1', + openModalFallback: vi.fn() + }, + deps + ) + + // Why: an "Always trust" stamped by the setup prompt only exists in a fresh + // snapshot. Each trust check must read the store at call time instead of + // reusing the snapshot captured when the flow began. + const setupCall = deps.confirmHooks.mock.calls.find((call) => call[2] === 'setup') + const issueCall = deps.confirmHooks.mock.calls.find((call) => call[2] === 'issueCommand') + expect(setupCall?.[0]).toBeDefined() + expect(issueCall?.[0]).toBeDefined() + expect(setupCall?.[0]).not.toBe(snapshots[0]) + expect(issueCall?.[0]).not.toBe(snapshots[0]) + expect(issueCall?.[0]).not.toBe(setupCall?.[0]) + }) + + it('keeps the seeded agent prompt as the bare issue URL (not Complete )', async () => { + const store = makeStore({ + ensureDetectedAgents: vi.fn().mockResolvedValue(['codex']) + }) + const deps = makeDeps(store) + deps.readIssueCommand.mockResolvedValueOnce({ + effectiveContent: 'echo {{issue}}', + localContent: 'echo {{issue}}', + sharedContent: null, + localFilePath: '/repo/.orca/issue-command', + source: 'local' + }) + + await createGitHubWorkItemWorkspaceInBackground( + { + item: makeIssue(), + repoId: 'repo-1', + openModalFallback: vi.fn() + }, + deps + ) + + const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined + expect(continueCall).toBeDefined() + const request = continueCall?.[1] as WorktreeCreationRequest + // Why: Brennan's confirmed scope keeps the quick-start prompt the bare link; + // the issue command runs as a side-pane split, not as the agent prompt. + expect(request.startupPlan?.draftPrompt).toBe('https://github.com/stablyai/orca/issues/42') + expect(request.startupPlan?.draftPrompt ?? '').not.toContain('Complete') + expect(request.quickPrompt).not.toContain('Complete') + // The issue command itself still threads through as a side-pane split. + expect(request.issueCommand?.command).toBe('echo 42') + }) }) diff --git a/src/renderer/src/lib/github-work-item-background-create.ts b/src/renderer/src/lib/github-work-item-background-create.ts index edaaa92bb92..7fa94fd9d2b 100644 --- a/src/renderer/src/lib/github-work-item-background-create.ts +++ b/src/renderer/src/lib/github-work-item-background-create.ts @@ -19,7 +19,10 @@ import { } from '@/lib/launch-work-item-direct-preflight' import { agentLaunchCommandErrorMessage } from '@/lib/launch-work-item-direct-messages' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' +import { renderIssueCommandTemplate } from '@/lib/new-workspace' import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { readRuntimeIssueCommand } from '@/runtime/runtime-hooks-client' +import { isGitRepoKind } from '../../../shared/repo-kind' import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host' import { evaluateRuntimeCompat } from '../../../shared/protocol-compat' import { @@ -50,8 +53,9 @@ type BackgroundGitHubWorkItemCreateDeps = { confirmHooks: ( store: GitHubWorkItemBackgroundStoreSnapshot, repoId: string, - scope: 'setup' + scope: 'setup' | 'issueCommand' ) => ReturnType + readIssueCommand: typeof readRuntimeIssueCommand beginBackgroundCreate: typeof beginBackgroundWorktreePreparation continueBackgroundCreate: typeof continueBackgroundWorktreeCreation activatePendingCreate: (creationId: string) => void @@ -78,8 +82,9 @@ const DEFAULT_DEPS: BackgroundGitHubWorkItemCreateDeps = { useAppStore.getState().activePendingCreationId === creationId, resolveSetupDecision: resolveDirectSetupDecision, resolvePrStartPoint: resolveDirectPrStartPoint, - confirmHooks: (store, repoId, scope) => + confirmHooks: (store, repoId, scope: 'setup' | 'issueCommand') => ensureHooksConfirmed(store as ReturnType, repoId, scope), + readIssueCommand: readRuntimeIssueCommand, beginBackgroundCreate: beginBackgroundWorktreePreparation, continueBackgroundCreate: continueBackgroundWorktreeCreation, activatePendingCreate: (creationId) => { @@ -222,7 +227,10 @@ export async function createGitHubWorkItemWorkspaceInBackground( } } - const trustDecision = await deps.confirmHooks(store, args.repoId, 'setup') + // Why: trust prompts are serialized app-wide, so read the store fresh at + // each check — an "Always trust" stamped by an earlier prompt (including + // this flow's own setup prompt) must short-circuit instead of re-prompting. + const trustDecision = await deps.confirmHooks(deps.getStore(), args.repoId, 'setup') if (!deps.hasPendingCreate(creationId)) { return { kind: 'background-started' } } @@ -246,6 +254,49 @@ export async function createGitHubWorkItemWorkspaceInBackground( } const backendStartup = buildGitHubWorkItemBackendStartup(agent, startupPlan, quickTelemetry) + // Why: mirror the composer's trust-gated issue-command split. Only GitHub + // issues (numeric issue number) on git repos run it; PRs/Linear/folders never + // do. Reuse the setup trust decision: a 'skip' there also skips the command. + let issueCommand: WorktreeCreationRequest['issueCommand'] + // Why: a declined setup trust also skips the issue command, so short-circuit + // before the (up-to-15s) read rather than reading just to drop the result. + if ( + trustDecision !== 'skip' && + isGitRepoKind(repo) && + args.item.type === 'issue' && + typeof args.item.number === 'number' + ) { + // Why: read failures fail closed (no command), so create still proceeds. + let effectiveContent = '' + try { + const issueCommandRead = await deps.readIssueCommand(repoOwnerSettings, args.repoId) + effectiveContent = (issueCommandRead.effectiveContent ?? '').trim() + } catch { + effectiveContent = '' + } + if (!deps.hasPendingCreate(creationId)) { + return { kind: 'background-started' } + } + if (effectiveContent.length > 0) { + const issueCommandTrust = await deps.confirmHooks( + deps.getStore(), + args.repoId, + 'issueCommand' + ) + if (!deps.hasPendingCreate(creationId)) { + return { kind: 'background-started' } + } + if (issueCommandTrust === 'run') { + issueCommand = { + command: renderIssueCommandTemplate(effectiveContent, { + issueNumber: args.item.number, + artifactUrl: args.item.url ?? null + }) + } + } + } + } + const request: WorktreeCreationRequest = { ...initialRequest, ...(baseBranch ? { baseBranch } : {}), @@ -255,6 +306,7 @@ export async function createGitHubWorkItemWorkspaceInBackground( agent, ...(branchNameOverride ? { branchNameOverride } : {}), ...(backendStartup ? { startup: backendStartup } : {}), + ...(issueCommand ? { issueCommand } : {}), startupPlan, quickPrompt, quickTelemetry diff --git a/src/renderer/src/lib/hook-command-delayed-delivery.test.ts b/src/renderer/src/lib/hook-command-delayed-delivery.test.ts new file mode 100644 index 00000000000..63fafaaae55 --- /dev/null +++ b/src/renderer/src/lib/hook-command-delayed-delivery.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { + queueHookCommandsForFirstWorktreeTab, + resetHookCommandDelayedDeliveryForTests +} from './hook-command-delayed-delivery' + +type AppState = ReturnType + +const initialTabsByWorktree = useAppStore.getState().tabsByWorktree +const initialGetKnownWorktreeById = useAppStore.getState().getKnownWorktreeById + +function setStorePartial(partial: Record): void { + useAppStore.setState(partial as Partial) +} + +function markWorktreeKnown(worktreeId: string): void { + setStorePartial({ + getKnownWorktreeById: ((id: string) => + id === worktreeId ? { id } : undefined) as unknown as AppState['getKnownWorktreeById'] + }) +} + +afterEach(() => { + resetHookCommandDelayedDeliveryForTests() + setStorePartial({ + tabsByWorktree: initialTabsByWorktree, + getKnownWorktreeById: initialGetKnownWorktreeById + }) +}) + +describe('queueHookCommandsForFirstWorktreeTab', () => { + it('holds the delivery until the first worktree tab lands, then delivers exactly once', () => { + markWorktreeKnown('wt-1') + setStorePartial({ tabsByWorktree: {} }) + const deliver = vi.fn() + + queueHookCommandsForFirstWorktreeTab({ worktreeId: 'wt-1', deliver }) + expect(deliver).not.toHaveBeenCalled() + + setStorePartial({ tabsByWorktree: { 'wt-1': [{ id: 'mirror-tab-1' }] } }) + + expect(deliver).toHaveBeenCalledTimes(1) + expect(deliver).toHaveBeenCalledWith(expect.anything(), 'mirror-tab-1') + + // Later tab churn must not re-deliver the consumed entry. + setStorePartial({ tabsByWorktree: { 'wt-1': [{ id: 'mirror-tab-1' }, { id: 'tab-2' }] } }) + expect(deliver).toHaveBeenCalledTimes(1) + }) + + it('delivers immediately when the worktree already has a tab at queue time', () => { + markWorktreeKnown('wt-1') + setStorePartial({ tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] } }) + const deliver = vi.fn() + + queueHookCommandsForFirstWorktreeTab({ worktreeId: 'wt-1', deliver }) + + expect(deliver).toHaveBeenCalledTimes(1) + expect(deliver).toHaveBeenCalledWith(expect.anything(), 'tab-1') + }) + + it('drops the pending delivery when the worktree is no longer known', () => { + setStorePartial({ + tabsByWorktree: {}, + getKnownWorktreeById: (() => undefined) as unknown as AppState['getKnownWorktreeById'] + }) + const deliver = vi.fn() + + queueHookCommandsForFirstWorktreeTab({ worktreeId: 'wt-gone', deliver }) + + // A tab appearing later (e.g. an id reused by mirror churn) must not + // deliver commands for a worktree that was dropped while unknown. + markWorktreeKnown('wt-gone') + setStorePartial({ tabsByWorktree: { 'wt-gone': [{ id: 'tab-1' }] } }) + expect(deliver).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/hook-command-delayed-delivery.ts b/src/renderer/src/lib/hook-command-delayed-delivery.ts new file mode 100644 index 00000000000..234126f73ad --- /dev/null +++ b/src/renderer/src/lib/hook-command-delayed-delivery.ts @@ -0,0 +1,67 @@ +import { useAppStore } from '@/store' + +type AppStoreSnapshot = ReturnType + +type PendingWorktreeHookCommandDelivery = { + worktreeId: string + deliver: (state: AppStoreSnapshot, firstTerminalTabId: string) => void +} + +// Why: runtime-owned worktrees mirror their session tabs asynchronously, so a +// fresh create usually has no tab to queue setup/issue commands on yet. Hold +// the delivery until the first mirrored terminal tab lands instead of +// dropping it. Mirrors agent-startup-delayed-delivery's lazy-subscription +// shape: subscribed only while something is pending. +const pendingHookCommandDeliveries = new Map() +let unsubscribePendingHookCommandDeliveries: (() => void) | null = null + +export function queueHookCommandsForFirstWorktreeTab( + delivery: PendingWorktreeHookCommandDelivery +): void { + pendingHookCommandDeliveries.set(delivery.worktreeId, delivery) + ensurePendingHookCommandSubscription() + flushPendingHookCommandDeliveries() +} + +export function resetHookCommandDelayedDeliveryForTests(): void { + pendingHookCommandDeliveries.clear() + unsubscribePendingHookCommandDeliveries?.() + unsubscribePendingHookCommandDeliveries = null +} + +function ensurePendingHookCommandSubscription(): void { + if (unsubscribePendingHookCommandDeliveries) { + return + } + unsubscribePendingHookCommandDeliveries = useAppStore.subscribe(() => { + flushPendingHookCommandDeliveries() + }) +} + +function stopPendingHookCommandSubscriptionIfIdle(): void { + if (pendingHookCommandDeliveries.size > 0 || !unsubscribePendingHookCommandDeliveries) { + return + } + unsubscribePendingHookCommandDeliveries() + unsubscribePendingHookCommandDeliveries = null +} + +function flushPendingHookCommandDeliveries(): void { + const state = useAppStore.getState() + for (const [worktreeId, delivery] of pendingHookCommandDeliveries) { + const firstTerminalTabId = state.tabsByWorktree[worktreeId]?.[0]?.id + if (!firstTerminalTabId) { + // Why: a worktree can be removed before its tabs ever mirror; drop the + // entry so the subscription does not stay armed forever. + if (!state.getKnownWorktreeById(worktreeId)) { + pendingHookCommandDeliveries.delete(worktreeId) + } + continue + } + // Delete before delivering so store writes inside deliver cannot re-enter + // this entry through the subscription. + pendingHookCommandDeliveries.delete(worktreeId) + delivery.deliver(state, firstTerminalTabId) + } + stopPendingHookCommandSubscriptionIfIdle() +} diff --git a/src/renderer/src/lib/pending-worktree-creation.ts b/src/renderer/src/lib/pending-worktree-creation.ts index cad46eab62d..b7f042a7b36 100644 --- a/src/renderer/src/lib/pending-worktree-creation.ts +++ b/src/renderer/src/lib/pending-worktree-creation.ts @@ -77,6 +77,9 @@ export type WorktreeCreationRequest = { * agent launch is self-contained; otherwise the renderer drives startup via * `startupPlan`. */ startup?: WorktreeStartupLaunch + /** Repo Custom GitHub Issue Command to run in a side-pane split after the + * workspace's first terminal is created. Mirrors the composer's trust-gated issueCommand. */ + issueCommand?: { command: string; env?: Record } pendingFirstAgentMessageRename: boolean /** Post-create note persisted as the worktree comment. */ note: string diff --git a/src/renderer/src/lib/worktree-activation.test.ts b/src/renderer/src/lib/worktree-activation.test.ts index a1937bbe8e2..fb7e201123d 100644 --- a/src/renderer/src/lib/worktree-activation.test.ts +++ b/src/renderer/src/lib/worktree-activation.test.ts @@ -2,8 +2,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { SetupScriptLaunchMode } from '../../../shared/types' import { activateAndRevealWorktree, ensureWorktreeHasInitialTerminal } from './worktree-activation' +import { resetHookCommandDelayedDeliveryForTests } from './hook-command-delayed-delivery' import { useAppStore } from '@/store' +type AppStoreState = ReturnType + +const initialTabsByWorktree = useAppStore.getState().tabsByWorktree +const initialGetKnownWorktreeById = useAppStore.getState().getKnownWorktreeById +const initialPendingIssueCommandSplitByTabId = + useAppStore.getState().pendingIssueCommandSplitByTabId + function setSetupScriptLaunchMode(mode: SetupScriptLaunchMode | null): void { useAppStore.setState((state) => ({ settings: state.settings @@ -22,6 +30,12 @@ afterEach(() => { : ({ activeRuntimeEnvironmentId: null } as unknown as typeof state.settings) })) setSetupScriptLaunchMode('new-tab') + resetHookCommandDelayedDeliveryForTests() + useAppStore.setState({ + tabsByWorktree: initialTabsByWorktree, + getKnownWorktreeById: initialGetKnownWorktreeById, + pendingIssueCommandSplitByTabId: initialPendingIssueCommandSplitByTabId + } as Partial) }) function createMockStore(overrides: Record = {}) { @@ -266,6 +280,40 @@ describe('ensureWorktreeHasInitialTerminal', () => { ) }) + it('holds the issue command for the first mirrored web runtime tab when none exists yet', () => { + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true + useAppStore.setState((state) => ({ + settings: state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: 'web-runtime-1' } + : ({ activeRuntimeEnvironmentId: 'web-runtime-1' } as unknown as typeof state.settings) + })) + useAppStore.setState({ + tabsByWorktree: {}, + getKnownWorktreeById: ((id: string) => + id === 'wt-1' + ? { id: 'wt-1' } + : undefined) as unknown as AppStoreState['getKnownWorktreeById'] + } as Partial) + const store = createMockStore() + + const result = ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, undefined, { + command: 'gh issue view 42' + }) + + // Why: runtime session tabs mirror in asynchronously — the command must be + // held for the first mirrored tab rather than silently dropped. + expect(result).toBeNull() + expect(useAppStore.getState().pendingIssueCommandSplitByTabId).toEqual({}) + + useAppStore.setState({ + tabsByWorktree: { 'wt-1': [{ id: 'mirror-tab-1' }] } + } as unknown as Partial) + + expect(useAppStore.getState().pendingIssueCommandSplitByTabId['mirror-tab-1']).toEqual({ + command: 'gh issue view 42' + }) + }) + it('creates a local initial terminal for explicitly local worktrees while a runtime is focused', () => { useAppStore.setState((state) => ({ settings: state.settings diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 840361c651b..894e9d38ad9 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -49,6 +49,7 @@ import { import { isTuiAgent } from '../../../shared/tui-agent-config' import { repoIsRemote } from '../../../shared/agent-launch-remote' import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session' +import { queueHookCommandsForFirstWorktreeTab } from '@/lib/hook-command-delayed-delivery' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { getRuntimeEnvironmentIdForWorktree, @@ -506,6 +507,24 @@ export function ensureWorktreeHasInitialTerminal( ) return existingTerminalTabId } + if (setup || issueCommand) { + // Why: runtime-owned worktrees mirror their session tabs asynchronously, + // so right after create there is usually no tab yet. Hold the commands + // for the first mirrored tab instead of silently dropping them. + queueHookCommandsForFirstWorktreeTab({ + worktreeId, + deliver: (state, firstTerminalTabId) => + queueSetupAndIssueCommands( + state, + worktreeId, + firstTerminalTabId, + setup, + issueCommand, + wrappedSetupCommandStr, + opts + ) + }) + } return null } diff --git a/src/renderer/src/lib/worktree-creation-flow.test.ts b/src/renderer/src/lib/worktree-creation-flow.test.ts index f03cf282d25..1ceebd2093f 100644 --- a/src/renderer/src/lib/worktree-creation-flow.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow.test.ts @@ -504,6 +504,70 @@ describe('staged background worktree creation', () => { }) }) + // Why: one-click "Start workspace from issue" commonly backgrounds, so the + // user-moved-on path is the common delivery for the repo's issue command; it + // must thread through as the 5th positional arg, not be dropped to undefined. + it('threads the request issue command into the background terminal seed', async () => { + store.activeView = 'tasks' + store.createWorktree.mockResolvedValueOnce({ + worktree: { + id: 'wt-1', + repoId: 'repo-1' + } + }) + + const started = continueBackgroundWorktreeCreation( + 'creation-1', + makeRequest({ issueCommand: { command: 'gh issue view 42' } }), + { revealCreationSurface: false } + ) + + expect(started).toBe(true) + // Why: vi.waitFor instead of a fixed microtask flush — the await count in + // executeWorktreeCreation grows over time (e.g. VM preflight), and a fixed + // flush silently starves this assertion in merged builds. + await vi.waitFor(() => + expect(ensureWorktreeHasInitialTerminal).toHaveBeenCalledWith( + store, + 'wt-1', + undefined, + undefined, + { command: 'gh issue view 42' }, + undefined, + { activateCreatedTabs: false } + ) + ) + }) + + // Why: the still-watching path activates the worktree directly, so the issue + // command must reach activateAndRevealWorktree too — both branches carry it. + it('threads the request issue command into the active reveal', async () => { + store.activeView = 'terminal' + store.activePendingCreationId = 'creation-1' + store.createWorktree.mockResolvedValueOnce({ + worktree: { + id: 'wt-1', + repoId: 'repo-1', + path: '/repo/wt-1' + } + }) + vi.mocked(activateAndRevealWorktree).mockReturnValueOnce({ primaryTabId: 'tab-1' }) + + const started = continueBackgroundWorktreeCreation( + 'creation-1', + makeRequest({ issueCommand: { command: 'gh issue view 42' } }) + ) + + expect(started).toBe(true) + await vi.waitFor(() => + expect(activateAndRevealWorktree).toHaveBeenCalledWith( + 'wt-1', + expect.objectContaining({ issueCommand: { command: 'gh issue view 42' } }) + ) + ) + expect(ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled() + }) + it('toasts a staged create error after the user leaves the creation surface', async () => { store.activeView = 'tasks' store.createWorktree.mockRejectedValueOnce(new Error('create failed')) diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index f4938e44013..9af1dc61ebe 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -225,7 +225,8 @@ async function executeWorktreeCreation( sidebarRevealBehavior: 'auto', ...(result.setup ? { setup: result.setup } : {}), ...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}), - ...(startupOpt ? { startup: startupOpt } : {}) + ...(startupOpt ? { startup: startupOpt } : {}), + ...(preparedRequest.issueCommand ? { issueCommand: preparedRequest.issueCommand } : {}) }) primaryTabId = activation === false ? null : activation.primaryTabId } else { @@ -237,7 +238,7 @@ async function executeWorktreeCreation( worktree.id, startupOpt, result.setup, - undefined, + preparedRequest.issueCommand, result.defaultTabs, { activateCreatedTabs: false } )