feat: instant draft-first worktree creation and multi-run launcher redesign (#741)
## Summary - **Instant worktree creation from chat draft**: selecting "+ New worktree" in the draft branch selector immediately creates a session draft and bootstraps the worktree in the background — no modal interruption - **Redesigned multi-run launcher**: compact 2-column grid layout in a right-sized dialog with scroll shadow, sticky footer, tooltips replacing verbose descriptions, and project icons in the selector - **Branch selector aligned across surfaces**: multi-run and agent manager branch pickers now use the shared git store and match NewWorktreeDialog behavior (same default resolution cascade, no synthetic HEAD option, all branches shown) - **Opaque model multi-select dropdown**: fixes text bleed-through on translucent backgrounds by compositing `--surface-elevated` over `--surface-background` - **"+ New" inline button in sidebar worktree headers** for faster worktree creation ## Why Worktree creation was behind modal flow that interrupted the user's train of thought. The draft-first approach lets users start typing immediately while the worktree bootstraps. The multi-run launcher had an oversized form layout with redundant explanations, and its branch picker behaved differently from the main worktree dialog - causing confusion about which branches were available and what the default was.
This commit is contained in:
committed by
GitHub
parent
c66d480782
commit
53c2a0d919
@@ -0,0 +1,119 @@
|
||||
import * as gitHttp from '@/lib/gitApiHttp';
|
||||
import type { RuntimeAPIs } from '@/lib/api/types';
|
||||
import type { GitWorktreeBootstrapStatus } from '@/lib/api/types';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs;
|
||||
}
|
||||
}
|
||||
|
||||
type WorktreeBootstrapState = GitWorktreeBootstrapStatus;
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const POLL_INTERVAL_MS = 250;
|
||||
|
||||
const normalizePath = (value: string): string => value.replace(/\\/g, '/').replace(/\/+$/, '') || value;
|
||||
|
||||
const state = new Map<string, WorktreeBootstrapState>();
|
||||
const waiters = new Map<string, Promise<void>>();
|
||||
|
||||
const getKey = (directory: string): string => normalizePath(directory);
|
||||
|
||||
const getGitWorktreeBootstrapStatus = async (directory: string): Promise<GitWorktreeBootstrapStatus> => {
|
||||
const runtimeGit = typeof window !== 'undefined' ? window.__OPENCHAMBER_RUNTIME_APIS__?.git : undefined;
|
||||
if (runtimeGit?.worktree?.bootstrapStatus) {
|
||||
return runtimeGit.worktree.bootstrapStatus(directory);
|
||||
}
|
||||
if (runtimeGit?.getGitWorktreeBootstrapStatus) {
|
||||
return runtimeGit.getGitWorktreeBootstrapStatus(directory);
|
||||
}
|
||||
return gitHttp.getGitWorktreeBootstrapStatus(directory);
|
||||
};
|
||||
|
||||
export const markWorktreeBootstrapPending = (directory: string): void => {
|
||||
const key = getKey(directory);
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
state.set(key, {
|
||||
status: 'pending',
|
||||
error: null,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
};
|
||||
|
||||
export const clearWorktreeBootstrapState = (directory: string): void => {
|
||||
const key = getKey(directory);
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
state.delete(key);
|
||||
waiters.delete(key);
|
||||
};
|
||||
|
||||
export const setWorktreeBootstrapState = (directory: string, next: WorktreeBootstrapState): void => {
|
||||
const key = getKey(directory);
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
state.set(key, next);
|
||||
if (next.status !== 'pending') {
|
||||
waiters.delete(key);
|
||||
}
|
||||
};
|
||||
|
||||
export const getWorktreeBootstrapState = (directory: string): WorktreeBootstrapState | null => {
|
||||
const key = getKey(directory);
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
return state.get(key) ?? null;
|
||||
};
|
||||
|
||||
const pollWorktreeBootstrapUntilSettled = async (directory: string, timeoutMs: number): Promise<void> => {
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const result = await getGitWorktreeBootstrapStatus(directory);
|
||||
setWorktreeBootstrapState(directory, result);
|
||||
|
||||
if (result.status === 'ready') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === 'failed') {
|
||||
throw new Error(result.error || 'Worktree bootstrap failed');
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||
}
|
||||
|
||||
throw new Error('Timed out waiting for worktree bootstrap');
|
||||
};
|
||||
|
||||
export const waitForWorktreeBootstrap = async (directory: string, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<void> => {
|
||||
const key = getKey(directory);
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = state.get(key);
|
||||
if (current?.status === 'ready') {
|
||||
return;
|
||||
}
|
||||
if (current?.status === 'failed') {
|
||||
throw new Error(current.error || 'Worktree bootstrap failed');
|
||||
}
|
||||
|
||||
const existing = waiters.get(key);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const pending = pollWorktreeBootstrapUntilSettled(directory, timeoutMs).finally(() => {
|
||||
waiters.delete(key);
|
||||
});
|
||||
waiters.set(key, pending);
|
||||
return pending;
|
||||
};
|
||||
Reference in New Issue
Block a user