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:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user