feat: support fast worktree-backed session flows

Add a directory-created fast path for worktree creation so session and send flows can continue once the target directory exists while Git attachment and bootstrap finish in the background.

Track bootstrap status explicitly in shared UI contracts, including pending, ready, and failed states. Background watchers now surface failures and timeouts, update stored worktree metadata, and keep web and VS Code runtime behavior in parity.

Move GitHub issue/PR worktree sessions and assistant-answer fork sessions onto the unified send path so provider, model, agent, and variant selections are preserved. The assistant-answer fork dialog can optionally create a worktree outside VS Code.

Make worktree deletion dialogs close after linked-session cleanup while removing the worktree in the background, and clean up failed fast-create artifacts safely without recursively deleting user or agent-written files.

Validation: bun test packages/ui/src/lib/worktrees/worktreeBootstrap.test.ts packages/ui/src/lib/worktrees/worktreeManager.test.ts; bun run type-check; bun run lint.
This commit is contained in:
Bohdan Triapitsyn
2026-06-06 23:25:39 +03:00
parent d4ef1fd8c0
commit e0113c637d
34 changed files with 901 additions and 264 deletions
+84 -23
View File
@@ -28,7 +28,6 @@ import { getSafeStorage } from "@/stores/utils/safeStorage"
import { markPendingUserSendAnimation } from "@/lib/userSendAnimation"
import { flattenAssistantTextParts } from "@/lib/messages/messageText"
import { composeForkSessionMessage } from "@/lib/messages/executionMeta"
import { waitForWorktreeBootstrap } from "@/lib/worktrees/worktreeBootstrap"
import { waitForPendingDraftWorktreeRequest } from "@/lib/worktrees/pendingDraftWorktree"
import { resolveProjectForSessionDirectory } from "@/lib/projectResolution"
import {
@@ -158,6 +157,15 @@ type SendMessageOptions = {
sessionId?: string
}
type AssistantMessageSessionExecution = {
providerID: string
modelID: string
variant: string
agent: string
instructions: string
createWorktree?: boolean
}
function notifyMessageSent(sessionId: string): void {
runtimeFetch(`/api/sessions/${sessionId}/message-sent`, { method: "POST" })
.catch(() => { /* ignore */ })
@@ -272,7 +280,7 @@ export type SessionUIState = {
forkFromMessage: (sessionId: string, messageId: string) => Promise<void>
handleSlashUndo: (sessionId: string) => Promise<void>
handleSlashRedo: (sessionId: string, options?: { fullUnrevert?: boolean }) => Promise<void>
createSessionFromAssistantMessage: (sourceMessageId: string, execution: { providerID: string; modelID: string; variant: string; agent: string; instructions: string }) => Promise<void>
createSessionFromAssistantMessage: (sourceMessageId: string, execution: AssistantMessageSessionExecution) => Promise<void>
// Data access helpers (read from sync)
getSessionsByDirectory: (directory: string) => Session[]
@@ -874,10 +882,6 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
? [...(additionalParts || []), ...draftSyntheticParts]
: additionalParts
if (createdDirectory) {
await waitForWorktreeBootstrap(createdDirectory)
}
notifyMessageSent(created.id)
markPendingUserSendAnimation(created.id)
@@ -922,8 +926,13 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
const configAgentName = useConfigStore.getState().currentAgentName
const effectiveAgent = trimmedAgent || sessionAgentSelection || configAgentName || undefined
if (targetSessionId) {
useSelectionStore.getState().saveSessionModelSelection(targetSessionId, providerID, modelID)
}
if (targetSessionId && effectiveAgent) {
useSelectionStore.getState().saveSessionAgentSelection(targetSessionId, effectiveAgent)
useSelectionStore.getState().saveAgentModelForSession(targetSessionId, effectiveAgent, providerID, modelID)
useSelectionStore.getState().saveAgentModelVariantForSession(targetSessionId, effectiveAgent, providerID, modelID, variant)
}
@@ -947,10 +956,6 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
const currentSessionDirectory = targetSessionId
? normalizePath(get().getDirectoryForSession(targetSessionId))
: null
if (currentSessionDirectory) {
await waitForWorktreeBootstrap(currentSessionDirectory)
}
if (targetSessionId) {
notifyMessageSent(targetSessionId)
}
@@ -1203,25 +1208,81 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
sourceSessionId ?? null,
(sid) => get().worktreeMetadata.get(sid),
)
const session = await get().createSession(undefined, directory ?? null, null)
if (!session) return
const sourceWorktreeMetadata = sourceSessionId ? get().worktreeMetadata.get(sourceSessionId) : undefined
const pID = execution.providerID || useSelectionStore.getState().lastUsedProvider?.providerID
const mID = execution.modelID || useSelectionStore.getState().lastUsedProvider?.modelID
if (!pID || !mID) return
const sessionDirectory = normalizePath(directory ?? session.directory ?? null)
await opencodeClient.sendMessage({
id: session.id,
providerID: pID,
modelID: mID,
variant: execution.variant || undefined,
text: composeForkSessionMessage(execution.instructions, assistantPlanText),
agent: execution.agent || undefined,
directory: sessionDirectory,
})
const sourceDirectory = normalizePath(directory ?? opencodeClient.getDirectory() ?? null)
let sessionDirectory = sourceDirectory
let createdWorktree: WorktreeMetadata | null = null
let createdWorktreeProject: { id: string; path: string } | null = null
if (execution.createWorktree) {
const projects = useProjectsStore.getState().projects
const project = resolveProjectForSessionDirectory(
projects,
get().availableWorktreesByProject,
sourceDirectory,
) ?? resolveProjectForSessionDirectory(
projects,
get().availableWorktreesByProject,
sourceWorktreeMetadata?.projectDirectory ?? null,
)
if (!project?.path) {
throw new Error("Project is not registered in OpenChamber")
}
const [branchNameModule, configModule, createModule] = await Promise.all([
import("@/lib/git/branchNameGenerator"),
import("@/lib/openchamberConfig"),
import("@/lib/worktrees/worktreeCreate"),
])
const branchName = branchNameModule.generateBranchName()
createdWorktreeProject = { id: project.id, path: project.path }
const setupCommands = await configModule.getWorktreeSetupCommands(createdWorktreeProject)
createdWorktree = await createModule.createWorktreeWithDefaults(createdWorktreeProject, {
preferredName: branchName,
mode: "new",
branchName,
worktreeName: branchName,
setupCommands,
returnAfterDirectoryCreated: true,
})
sessionDirectory = normalizePath(createdWorktree.path)
}
const session = await get().createSession(undefined, sessionDirectory || null, null)
if (!session) {
if (createdWorktree && createdWorktreeProject) {
const { removeProjectWorktree } = await import("@/lib/worktrees/worktreeManager")
await removeProjectWorktree(createdWorktreeProject, createdWorktree, { deleteLocalBranch: true }).catch(() => undefined)
}
return
}
if (createdWorktree) {
get().setWorktreeMetadata(session.id, {
...createdWorktree,
kind: "standard",
})
useDirectoryStore.getState().setDirectory(createdWorktree.path, { showOverlay: false })
}
await get().sendMessage(
composeForkSessionMessage(execution.instructions, assistantPlanText),
pID,
mID,
execution.agent || undefined,
undefined,
undefined,
undefined,
execution.variant || undefined,
undefined,
{ sessionId: session.id },
)
},
// ---------------------------------------------------------------------------
@@ -20,7 +20,7 @@ export type WorktreeCanonicalizationResult = {
cwd: string | null;
branch: string | null;
headState: 'branch' | 'detached' | 'unborn';
worktreeStatus: 'ready' | 'missing' | 'invalid' | 'not-a-repo';
worktreeStatus: 'pending' | 'ready' | 'missing' | 'invalid' | 'not-a-repo';
legacy: boolean;
degraded: boolean;
attentionReason?: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null;
@@ -143,8 +143,12 @@ export function resolveSessionWorktreeState(
};
}
export function formatSessionWorktreeBadge(attachment: SessionWorktreeAttachment): string {
export function formatSessionWorktreeBadge(
attachment: SessionWorktreeAttachment,
labels?: { pending?: string }
): string {
if (attachment.legacy) return 'Legacy session';
if (attachment.worktreeStatus === 'pending') return labels?.pending ?? 'Needs attention';
if (attachment.worktreeStatus === 'missing') return 'Worktree missing';
if (attachment.worktreeStatus === 'not-a-repo') return 'Not a repo';
if (attachment.worktreeStatus === 'invalid') return 'Needs attention';