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:
Bohdan Triapitsyn
2026-03-22 22:31:29 +02:00
committed by GitHub
parent c66d480782
commit 53c2a0d919
40 changed files with 1850 additions and 909 deletions
+18
View File
@@ -3323,6 +3323,24 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
return { id, type, success: true, data: result };
}
case 'api:git/worktrees/bootstrap-status': {
const { directory } = (payload || {}) as { directory?: string };
if (!directory) {
return { id, type, success: false, error: 'Directory is required' };
}
const result = await gitService.getWorktreeBootstrapStatus(directory);
return { id, type, success: true, data: result };
}
case 'api:git/worktrees/preview': {
const { directory } = (payload || {}) as { directory?: string };
if (!directory) {
return { id, type, success: false, error: 'Directory is required' };
}
const result = await gitService.previewWorktreeCreate(directory, (payload || {}) as gitService.CreateGitWorktreePayload);
return { id, type, success: true, data: result };
}
case 'api:git/diff': {
const { directory, path: filePath, staged, contextLines } = (payload || {}) as {
directory?: string;
+158 -32
View File
@@ -14,6 +14,39 @@ import type { API as GitAPI, Repository, GitExtension, Status } from './git.d';
let gitApi: GitAPI | null = null;
let gitExtensionEnabled = false;
const worktreeBootstrapState = new Map<string, { status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }>();
const WORKTREE_BOOTSTRAP_PENDING = 'pending' as const;
const WORKTREE_BOOTSTRAP_READY = 'ready' as const;
const WORKTREE_BOOTSTRAP_FAILED = 'failed' as const;
const toBootstrapStateKey = (directory: string): string => {
const normalized = normalizeDirectoryPath(directory);
if (!normalized) {
return '';
}
return path.resolve(normalized);
};
const setWorktreeBootstrapState = (directory: string, status: 'pending' | 'ready' | 'failed', error: string | null = null): void => {
const key = toBootstrapStateKey(directory);
if (!key) {
return;
}
worktreeBootstrapState.set(key, {
status,
error: typeof error === 'string' && error.trim().length > 0 ? error.trim() : null,
updatedAt: Date.now(),
});
};
const clearWorktreeBootstrapState = (directory: string): void => {
const key = toBootstrapStateKey(directory);
if (!key) {
return;
}
worktreeBootstrapState.delete(key);
};
const execFileAsync = promisify(execFile);
const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf'];
@@ -1290,30 +1323,80 @@ const syncProjectSandboxRemove = async (projectID: string, primaryWorktree: stri
});
};
const queueWorktreeStartScripts = (directory: string, projectID: string, startCommand: string | undefined) => {
const runWorktreeStartScripts = async (directory: string, projectID: string, startCommand: string | undefined) => {
const projectStart = await loadProjectStartCommand(projectID);
if (projectStart) {
const projectResult = await runWorktreeStartCommand(directory, projectStart);
if (!projectResult.success) {
console.warn('[GitService] Worktree project start command failed:', projectResult.message || projectResult.stderr || projectResult.stdout);
return;
}
}
const extraCommand = String(startCommand || '').trim();
if (!extraCommand) {
return;
}
const extraResult = await runWorktreeStartCommand(directory, extraCommand);
if (!extraResult.success) {
console.warn('[GitService] Worktree start command failed:', extraResult.message || extraResult.stderr || extraResult.stdout);
}
};
const queueWorktreeBootstrap = (args: {
directory: string;
projectID: string;
primaryWorktree: string;
localBranch: string;
setUpstream: boolean;
upstreamRemote: string;
upstreamBranch: string;
ensureRemoteName: string;
ensureRemoteUrl: string;
startCommand: string | undefined;
}) => {
const {
directory,
projectID,
primaryWorktree,
localBranch,
setUpstream,
upstreamRemote,
upstreamBranch,
ensureRemoteName,
ensureRemoteUrl,
startCommand,
} = args;
setTimeout(() => {
const run = async () => {
const projectStart = await loadProjectStartCommand(projectID);
if (projectStart) {
const projectResult = await runWorktreeStartCommand(directory, projectStart);
if (!projectResult.success) {
console.warn('[GitService] Worktree project start command failed:', projectResult.message || projectResult.stderr || projectResult.stdout);
return;
}
}
const extraCommand = String(startCommand || '').trim();
if (!extraCommand) {
return;
}
const extraResult = await runWorktreeStartCommand(directory, extraCommand);
if (!extraResult.success) {
console.warn('[GitService] Worktree start command failed:', extraResult.message || extraResult.stderr || extraResult.stdout);
await runGitCommandOrThrow(directory, ['reset', '--hard'], 'Failed to populate worktree');
if (setUpstream) {
await applyUpstreamConfiguration({
primaryWorktree,
worktreeDirectory: directory,
localBranch,
setUpstream,
upstreamRemote,
upstreamBranch,
ensureRemoteName,
ensureRemoteUrl,
}).catch((error) => {
console.warn('[GitService] Worktree upstream configuration failed:', error instanceof Error ? error.message : String(error));
});
}
await runWorktreeStartScripts(directory, projectID, startCommand).catch((error) => {
console.warn('[GitService] Worktree start script task failed:', error instanceof Error ? error.message : String(error));
});
setWorktreeBootstrapState(directory, WORKTREE_BOOTSTRAP_READY);
};
void run().catch((error) => {
console.warn('[GitService] Worktree start script task failed:', error instanceof Error ? error.message : String(error));
setWorktreeBootstrapState(
directory,
WORKTREE_BOOTSTRAP_FAILED,
error instanceof Error ? error.message : String(error)
);
console.warn('[GitService] Worktree bootstrap task failed:', error instanceof Error ? error.message : String(error));
});
}, 0);
};
@@ -1584,6 +1667,28 @@ export async function validateWorktreeCreate(directory: string, input: CreateGit
}
}
export async function previewWorktreeCreate(directory: string, input: CreateGitWorktreePayload = {}): Promise<GitWorktreeInfo> {
const mode = input?.mode === 'existing' ? 'existing' : 'new';
const context = await resolveWorktreeProjectContext(directory);
await fs.promises.mkdir(context.worktreeRoot, { recursive: true });
const preferredName = String(input?.worktreeName || input?.name || '').trim();
const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim());
const candidate = await resolveCandidateDirectory(
context.worktreeRoot,
preferredName,
mode === 'new' && preferredBranchName ? preferredBranchName : '',
context.primaryWorktree
);
return {
name: candidate.name,
branch: mode === 'new' ? candidate.branch : preferredBranchName,
path: candidate.directory,
head: '',
};
}
export async function createWorktree(directory: string, input: CreateGitWorktreePayload = {}): Promise<GitWorktreeInfo> {
const mode = input?.mode === 'existing' ? 'existing' : 'new';
const context = await resolveWorktreeProjectContext(directory);
@@ -1675,7 +1780,6 @@ export async function createWorktree(directory: string, input: CreateGitWorktree
}
await runGitCommandOrThrow(context.primaryWorktree, worktreeAddArgs, 'Failed to create git worktree');
await runGitCommandOrThrow(candidate.directory, ['reset', '--hard'], 'Failed to populate worktree');
try {
await syncProjectSandboxAdd(context.projectID, context.primaryWorktree, candidate.directory);
@@ -1687,20 +1791,20 @@ export async function createWorktree(directory: string, input: CreateGitWorktree
const upstreamRemote = String(input?.upstreamRemote || inferredUpstream?.remote || '').trim();
const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim();
if (shouldSetUpstream) {
await applyUpstreamConfiguration({
primaryWorktree: context.primaryWorktree,
worktreeDirectory: candidate.directory,
localBranch,
setUpstream: shouldSetUpstream,
upstreamRemote,
upstreamBranch,
ensureRemoteName,
ensureRemoteUrl,
});
}
setWorktreeBootstrapState(candidate.directory, WORKTREE_BOOTSTRAP_PENDING);
queueWorktreeStartScripts(candidate.directory, context.projectID, input?.startCommand);
queueWorktreeBootstrap({
directory: candidate.directory,
projectID: context.projectID,
primaryWorktree: context.primaryWorktree,
localBranch,
setUpstream: shouldSetUpstream,
upstreamRemote,
upstreamBranch,
ensureRemoteName,
ensureRemoteUrl,
startCommand: input?.startCommand,
});
const headResult = await runGitCommand(candidate.directory, ['rev-parse', 'HEAD']);
const head = String(headResult.stdout || '').trim();
@@ -1713,6 +1817,24 @@ export async function createWorktree(directory: string, input: CreateGitWorktree
};
}
export async function getWorktreeBootstrapStatus(directory: string): Promise<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }> {
const key = toBootstrapStateKey(directory);
if (!key) {
throw new Error('Worktree directory is required');
}
const current = worktreeBootstrapState.get(key);
if (current) {
return current;
}
return {
status: WORKTREE_BOOTSTRAP_READY,
error: null,
updatedAt: Date.now(),
};
}
export async function removeWorktree(directory: string, input: RemoveGitWorktreePayload): Promise<boolean> {
const targetDirectory = normalizeDirectoryPath(input?.directory);
if (!targetDirectory) {
@@ -1754,6 +1876,8 @@ export async function removeWorktree(directory: string, input: RemoveGitWorktree
console.warn('[GitService] Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error));
}
clearWorktreeBootstrapState(targetDirectory);
return true;
}
@@ -1780,6 +1904,8 @@ export async function removeWorktree(directory: string, input: RemoveGitWorktree
console.warn('[GitService] Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error));
}
clearWorktreeBootstrapState(matchedEntry.worktree);
return true;
}