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
@@ -1,8 +1,12 @@
import * as gitHttp from '@/lib/gitApiHttp';
import type { GitWorktreeBootstrapStatus } from '@/lib/api/types';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { toast } from '@/components/ui';
import { formatMessage, useI18nStore, type I18nKey, type I18nParams } from '@/lib/i18n';
type WorktreeBootstrapState = GitWorktreeBootstrapStatus;
type WorktreeBootstrapFailureHandler = (status: GitWorktreeBootstrapStatus) => void;
type WorktreeBootstrapReadyHandler = (status: GitWorktreeBootstrapStatus) => void;
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
const POLL_INTERVAL_MS = 250;
@@ -11,6 +15,7 @@ const normalizePath = (value: string): string => value.replace(/\\/g, '/').repla
const state = new Map<string, WorktreeBootstrapState>();
const waiters = new Map<string, Promise<void>>();
const watchers = new Map<string, { cancelled: boolean; promise: Promise<void> }>();
const getKey = (directory: string): string => normalizePath(directory);
@@ -42,6 +47,11 @@ export const clearWorktreeBootstrapState = (directory: string): void => {
if (!key) {
return;
}
const watcher = watchers.get(key);
if (watcher) {
watcher.cancelled = true;
watchers.delete(key);
}
state.delete(key);
waiters.delete(key);
};
@@ -65,6 +75,28 @@ export const getWorktreeBootstrapState = (directory: string): WorktreeBootstrapS
return state.get(key) ?? null;
};
const t = (key: I18nKey, params?: I18nParams): string => {
const dictionary = useI18nStore.getState().dictionary;
return formatMessage(dictionary, key, params);
};
const createFailedStatus = (error: string): GitWorktreeBootstrapStatus => ({
status: 'failed',
error,
updatedAt: Date.now(),
});
const markBootstrapFailed = (
directory: string,
error: string,
onFailed?: WorktreeBootstrapFailureHandler,
): GitWorktreeBootstrapStatus => {
const failed = createFailedStatus(error);
setWorktreeBootstrapState(directory, failed);
onFailed?.(failed);
return failed;
};
const pollWorktreeBootstrapUntilSettled = async (directory: string, timeoutMs: number): Promise<void> => {
const startedAt = Date.now();
@@ -83,7 +115,100 @@ const pollWorktreeBootstrapUntilSettled = async (directory: string, timeoutMs: n
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
throw new Error('Timed out waiting for worktree bootstrap');
const failed = markBootstrapFailed(directory, t('worktree.bootstrap.toast.timeoutDescription'));
throw new Error(failed.error || 'Timed out waiting for worktree bootstrap');
};
const pollWorktreeBootstrapInBackground = async (
directory: string,
watcher: { cancelled: boolean },
timeoutMs: number,
pollIntervalMs: number,
onFailed?: WorktreeBootstrapFailureHandler,
onReady?: WorktreeBootstrapReadyHandler,
): Promise<void> => {
const startedAt = Date.now();
while (!watcher.cancelled && Date.now() - startedAt < timeoutMs) {
const result = await getGitWorktreeBootstrapStatus(directory);
if (watcher.cancelled) {
return;
}
setWorktreeBootstrapState(directory, result);
if (result.status === 'ready') {
onReady?.(result);
return;
}
if (result.status === 'failed') {
onFailed?.(result);
toast.error(t('worktree.bootstrap.toast.failed'), {
description: result.error || t('worktree.bootstrap.toast.failedDescription'),
});
return;
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
if (!watcher.cancelled) {
const failed = markBootstrapFailed(directory, t('worktree.bootstrap.toast.timeoutDescription'), onFailed);
toast.error(t('worktree.bootstrap.toast.failed'), {
description: failed.error || t('worktree.bootstrap.toast.failedDescription'),
});
}
};
export const startWorktreeBootstrapWatcher = (
directory: string,
options?: {
timeoutMs?: number;
pollIntervalMs?: number;
onFailed?: WorktreeBootstrapFailureHandler;
onReady?: WorktreeBootstrapReadyHandler;
},
): void => {
const key = getKey(directory);
if (!key) {
return;
}
const current = state.get(key);
if (current?.status !== 'pending') {
return;
}
if (watchers.has(key)) {
return;
}
const watcher = { cancelled: false, promise: Promise.resolve() };
watcher.promise = pollWorktreeBootstrapInBackground(
directory,
watcher,
options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
options?.pollIntervalMs ?? POLL_INTERVAL_MS,
options?.onFailed,
options?.onReady,
).catch((error) => {
if (watcher.cancelled) {
return;
}
const failed = markBootstrapFailed(
directory,
error instanceof Error ? error.message : String(error),
options?.onFailed,
);
toast.error(t('worktree.bootstrap.toast.failed'), {
description: failed.error || t('worktree.bootstrap.toast.failedDescription'),
});
}).finally(() => {
if (watchers.get(key) === watcher) {
watchers.delete(key);
}
});
watchers.set(key, watcher);
};
export const waitForWorktreeBootstrap = async (directory: string, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<void> => {