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