orca/src/renderer/src/lib/worktree-creation-flow.ts

238 lines
9.1 KiB
TypeScript

import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
import {
activateAndRevealWorktree,
ensureWorktreeHasInitialTerminal,
type ActivateAndRevealResult,
type WorktreeStartupPayload
} from '@/lib/worktree-activation'
import { ensureAgentStartupInTerminal } from '@/lib/new-workspace'
import { queueNewWorkspaceTerminalFocus } from '@/lib/new-workspace-terminal-focus'
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import {
formatWorkspaceCreateError,
getWorkspaceCreateErrorToastMessage
} from '@/lib/workspace-create-error-format'
import type { CreateWorktreeResult } from '../../../shared/types'
import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
import { createBrowserUuid } from '@/lib/browser-uuid'
// 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 } : {}),
...(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 } : {})
}
}
async function preflightAgentTrust(request: WorktreeCreationRequest, path: string): Promise<void> {
// Why: trust-gated agents (cursor-agent, copilot) consume the bracketed paste
// as menu input on first launch. Pre-write the trust artifact before any
// terminal spawns. Best-effort — the worktree already exists, so a failure
// here must not strand it.
if (!request.agent || !window.api.agentTrust?.markTrusted) {
return
}
const preflight = TUI_AGENT_CONFIG[request.agent].preflightTrust
if (!preflight) {
return
}
try {
await window.api.agentTrust.markTrusted({ preset: preflight, workspacePath: path })
} catch {
// Best-effort: continue with launch.
}
}
async function executeWorktreeCreation(
creationId: string,
request: WorktreeCreationRequest
): Promise<void> {
let result: CreateWorktreeResult
try {
result = await useAppStore
.getState()
.createWorktree(
request.repoId,
request.name,
request.baseBranch,
request.setupDecision,
request.sparseCheckout,
request.telemetrySource,
request.displayName,
request.linkedIssue,
request.linkedPR,
request.pushTarget,
request.agent ?? undefined,
request.linkedLinearIssue,
request.branchNameOverride,
request.workspaceStatus,
request.linkedGitLabMR,
request.linkedGitLabIssue,
request.startup,
request.pendingFirstAgentMessageRename,
creationId,
request.linkedLinearIssueWorkspaceId,
request.linkedLinearIssueOrganizationUrlKey,
request.linkedBitbucketPR,
request.linkedAzureDevOpsPR,
request.linkedGiteaPR,
request.compareBaseRef
)
} catch (error) {
// Why: a missing entry means the user cancelled mid-flight — abandon
// silently rather than surfacing an error for work they already dismissed.
if (!useAppStore.getState().pendingWorktreeCreations[creationId]) {
return
}
const message = getWorkspaceCreateErrorToastMessage(formatWorkspaceCreateError(error))
// Why: an error must stay on the same creation surface that owns the faux
// tab strip, rather than falling back to stale previous-workspace tabs.
useAppStore.getState().updatePendingWorktreeCreation(creationId, {
status: 'error',
error: message
})
// Why: only toast when the panel isn't already showing this error (the user
// navigated away), so a visible failure isn't announced twice.
if (useAppStore.getState().activePendingCreationId !== creationId) {
toast.error(message)
}
return
}
const worktree = result.worktree
// Why: if the user dismissed/cancelled while the create was in flight, the entry
// is gone. Git already made the worktree on disk, but don't auto-provision (trust
// write, terminal, agent, note) work they abandoned — it surfaces as a plain row
// via worktrees:changed and provisions lazily on first open.
if (!useAppStore.getState().pendingWorktreeCreations[creationId]) {
return
}
const backendSpawned = result.startupTerminal?.spawned === true
const startupOpt = buildStartupOpt(request, backendSpawned)
if (worktree.path) {
await preflightAgentTrust(request, worktree.path)
}
// `createWorktree` already inserted the real worktree row. Whether we steal
// the view depends on whether the user is still watching this creation.
const stillActive = useAppStore.getState().activePendingCreationId === creationId
let activation: ActivateAndRevealResult | false = false
let primaryTabId: string | null
if (stillActive) {
activation = activateAndRevealWorktree(worktree.id, {
sidebarRevealBehavior: 'auto',
...(result.setup ? { setup: result.setup } : {}),
...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}),
...(startupOpt ? { startup: startupOpt } : {})
})
primaryTabId = activation === false ? null : activation.primaryTabId
} else {
// The user moved on. Seed the worktree's terminal + setup in the background
// (setActiveTab only writes global focus for the active worktree, so this is
// safe) without yanking them back to it.
primaryTabId = ensureWorktreeHasInitialTerminal(
useAppStore.getState(),
worktree.id,
startupOpt,
result.setup,
undefined,
result.defaultTabs
)
}
// Why: clearing synchronously right after activation lets React commit the
// panel→terminal swap in one frame — no two-row flicker, no empty-terminal flash.
useAppStore.getState().removePendingWorktreeCreation(creationId)
if (request.startupPlan && !backendSpawned) {
void ensureAgentStartupInTerminal({
worktreeId: worktree.id,
primaryTabId,
startup: request.startupPlan
})
}
if (stillActive) {
queueNewWorkspaceTerminalFocus(worktree.id, activation)
}
// Why: awaiting the note IPC before the swap would add a visible round-trip to
// the panel→terminal transition; it's cosmetic, so it runs last.
if (request.note) {
try {
await useAppStore.getState().updateWorktreeMeta(worktree.id, { comment: request.note })
} catch {
console.error('Failed to update worktree meta after creation')
}
}
}
/**
* Kick off a worktree create in the background. The caller (the composer) has
* already resolved every interactive decision into `request`, so this returns
* 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 {
// Why: crypto.randomUUID is undefined in non-secure browser contexts (LAN web
// client over plain HTTP). createBrowserUuid falls back to getRandomValues.
const creationId = createBrowserUuid()
const store = useAppStore.getState()
// Why: the remote/runtime create path emits no progress events, so the stepped
// checklist would freeze on step 1. Mark it indeterminate up front so the panel
// shows a single spinner instead of implying phase progress that never arrives.
const indeterminate = getActiveRuntimeTarget(store.settings).kind !== 'local'
store.beginPendingWorktreeCreation({
creationId,
phase: 'fetching',
status: 'creating',
indeterminate,
// Why: the creation surface owns the tab strip immediately. Delaying this
// caused the real workspace tab bar to flash out when the debounce elapsed.
loaderVisible: true,
request
})
// Why: the creation panel only renders under the terminal view (App content
// router), so force it active so the panel is what fills the content area.
store.setActiveView('terminal')
store.setSidebarOpen(true)
void executeWorktreeCreation(creationId, request)
}
/** Re-run a failed creation from its panel, reusing the captured request. */
export function retryBackgroundWorktreeCreation(creationId: string): void {
const store = useAppStore.getState()
const entry = store.pendingWorktreeCreations[creationId]
if (!entry) {
return
}
store.updatePendingWorktreeCreation(creationId, {
status: 'creating',
phase: 'fetching',
error: undefined
})
store.setActivePendingWorktreeCreation(creationId)
store.setActiveView('terminal')
store.setSidebarOpen(true)
void executeWorktreeCreation(creationId, entry.request)
}