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
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user