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
+40
View File
@@ -12529,6 +12529,46 @@ async function main(options = {}) {
}
});
app.post('/api/git/worktrees/preview', async (req, res) => {
const { previewWorktreeCreate } = await getGitLibraries();
if (typeof previewWorktreeCreate !== 'function') {
return res.status(501).json({ error: 'Worktree preview is not available' });
}
try {
const directory = req.query.directory;
if (!directory || typeof directory !== 'string') {
return res.status(400).json({ error: 'directory parameter is required' });
}
const preview = await previewWorktreeCreate(directory, req.body || {});
res.json(preview);
} catch (error) {
console.error('Failed to preview worktree:', error);
res.status(500).json({ error: error.message || 'Failed to preview worktree' });
}
});
app.get('/api/git/worktrees/bootstrap-status', async (req, res) => {
const { getWorktreeBootstrapStatus } = await getGitLibraries();
if (typeof getWorktreeBootstrapStatus !== 'function') {
return res.status(501).json({ error: 'Worktree bootstrap status is not available' });
}
try {
const directory = req.query.directory;
if (!directory || typeof directory !== 'string') {
return res.status(400).json({ error: 'directory parameter is required' });
}
const status = await getWorktreeBootstrapStatus(directory);
res.json(status);
} catch (error) {
console.error('Failed to get worktree bootstrap status:', error);
res.status(500).json({ error: error.message || 'Failed to get worktree bootstrap status' });
}
});
app.delete('/api/git/worktrees', async (req, res) => {
const { removeWorktree } = await getGitLibraries();
if (typeof removeWorktree !== 'function') {
+146 -32
View File
@@ -9,6 +9,39 @@ const fsp = fs.promises;
const execFileAsync = promisify(execFile);
const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf'];
let resolvedGitBinary = null;
const worktreeBootstrapState = new Map();
const WORKTREE_BOOTSTRAP_PENDING = 'pending';
const WORKTREE_BOOTSTRAP_READY = 'ready';
const WORKTREE_BOOTSTRAP_FAILED = 'failed';
const toBootstrapStateKey = (directory) => {
const normalized = normalizeDirectoryPath(directory);
if (!normalized) {
return '';
}
return path.resolve(normalized);
};
const setWorktreeBootstrapState = (directory, status, error = null) => {
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) => {
const key = toBootstrapStateKey(directory);
if (!key) {
return;
}
worktreeBootstrapState.delete(key);
};
const isExecutableFile = (candidate) => {
if (typeof candidate !== 'string' || candidate.trim().length === 0) {
@@ -845,30 +878,69 @@ const syncProjectSandboxRemove = async (projectID, primaryWorktree, sandboxPath)
});
};
const queueWorktreeStartScripts = (directory, projectID, startCommand) => {
const runWorktreeStartScripts = async (directory, projectID, startCommand) => {
const projectStart = await loadProjectStartCommand(projectID);
if (projectStart) {
const projectResult = await runWorktreeStartCommand(directory, projectStart);
if (!projectResult.success) {
console.warn('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('Worktree start command failed:', extraResult.message || extraResult.stderr || extraResult.stdout);
}
};
const queueWorktreeBootstrap = (args) => {
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('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('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('Worktree upstream configuration failed:', error instanceof Error ? error.message : String(error));
});
}
await runWorktreeStartScripts(directory, projectID, startCommand).catch((error) => {
console.warn('Worktree start script task failed:', error instanceof Error ? error.message : String(error));
});
setWorktreeBootstrapState(directory, WORKTREE_BOOTSTRAP_READY);
};
void run().catch((error) => {
console.warn('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('Worktree bootstrap task failed:', error instanceof Error ? error.message : String(error));
});
}, 0);
};
@@ -2226,6 +2298,27 @@ export async function validateWorktreeCreate(directory, input = {}) {
}
}
export async function previewWorktreeCreate(directory, input = {}) {
const mode = input?.mode === 'existing' ? 'existing' : 'new';
const context = await resolveWorktreeProjectContext(directory);
await fsp.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,
};
}
export async function createWorktree(directory, input = {}) {
const mode = input?.mode === 'existing' ? 'existing' : 'new';
const context = await resolveWorktreeProjectContext(directory);
@@ -2317,7 +2410,6 @@ export async function createWorktree(directory, input = {}) {
}
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);
@@ -2329,20 +2421,20 @@ export async function createWorktree(directory, input = {}) {
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();
@@ -2355,6 +2447,24 @@ export async function createWorktree(directory, input = {}) {
};
}
export async function getWorktreeBootstrapStatus(directory) {
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, input = {}) {
const targetDirectory = normalizeDirectoryPath(input?.directory);
if (!targetDirectory) {
@@ -2396,6 +2506,8 @@ export async function removeWorktree(directory, input = {}) {
console.warn('Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error));
}
clearWorktreeBootstrapState(targetDirectory);
return true;
}
@@ -2422,6 +2534,8 @@ export async function removeWorktree(directory, input = {}) {
console.warn('Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error));
}
clearWorktreeBootstrapState(matchedEntry.worktree);
return true;
}