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
+10 -2
View File
@@ -118,6 +118,9 @@ export type NewSessionDraftState = {
open: boolean;
selectedProjectId?: string | null;
directoryOverride: string | null;
pendingWorktreeRequestId?: string | null;
bootstrapPendingDirectory?: string | null;
preserveDirectoryOverride?: boolean;
parentID: string | null;
title?: string;
initialPrompt?: string;
@@ -216,8 +219,13 @@ export interface SessionStore {
setSessionAgentEditMode: (sessionId: string, agentName: string | undefined, mode: EditPermissionMode, defaultMode?: EditPermissionMode) => void;
loadSessions: () => Promise<void>;
openNewSessionDraft: (options?: { projectId?: string | null; directoryOverride?: string | null; parentID?: string | null; title?: string; initialPrompt?: string; syntheticParts?: SyntheticContextPart[]; targetFolderId?: string }) => void;
setNewSessionDraftTarget: (target: { projectId?: string | null; directoryOverride?: string | null }) => void;
openNewSessionDraft: (options?: { projectId?: string | null; directoryOverride?: string | null; pendingWorktreeRequestId?: string | null; bootstrapPendingDirectory?: string | null; preserveDirectoryOverride?: boolean; parentID?: string | null; title?: string; initialPrompt?: string; syntheticParts?: SyntheticContextPart[]; targetFolderId?: string }) => void;
overrideNewSessionDraftTarget: (options: { projectId?: string | null; directoryOverride?: string | null; pendingWorktreeRequestId?: string | null; bootstrapPendingDirectory?: string | null; preserveDirectoryOverride?: boolean; title?: string; initialPrompt?: string }) => void;
setNewSessionDraftTarget: (target: { projectId?: string | null; directoryOverride?: string | null }, options?: { force?: boolean }) => void;
setPendingDraftWorktreeRequest: (requestId: string | null) => void;
resolvePendingDraftWorktreeTarget: (requestId: string, directory: string | null, options?: { projectId?: string | null; bootstrapPendingDirectory?: string | null; preserveDirectoryOverride?: boolean }) => void;
setDraftBootstrapPendingDirectory: (directory: string | null) => void;
setDraftPreserveDirectoryOverride: (value: boolean) => void;
closeNewSessionDraft: () => void;
createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null) => Promise<Session | null>;
+163 -14
View File
@@ -24,6 +24,8 @@ import { flattenAssistantTextParts } from "@/lib/messages/messageText";
import { normalizeMessageRecordsForProjection } from "./utils/messageProjectors";
import type { ProjectEntry } from "@/lib/api/types";
import type { WorktreeMetadata } from "@/types/worktree";
import { waitForWorktreeBootstrap } from "@/lib/worktrees/worktreeBootstrap";
import { waitForPendingDraftWorktreeRequest } from "@/lib/worktrees/pendingDraftWorktree";
export type { AttachedFile, EditPermissionMode };
export { MEMORY_LIMITS, ACTIVE_SESSION_WINDOW } from "./types/sessionTypes";
@@ -242,7 +244,7 @@ export const useSessionStore = create<SessionStore>()(
pendingInputText: null,
pendingInputMode: 'replace',
pendingSyntheticParts: null,
newSessionDraft: { open: true, selectedProjectId: null, directoryOverride: null, parentID: null },
newSessionDraft: { open: true, selectedProjectId: null, directoryOverride: null, pendingWorktreeRequestId: null, bootstrapPendingDirectory: null, preserveDirectoryOverride: false, parentID: null },
// Voice state (initialized to disconnected/idle)
voiceStatus: 'disconnected',
@@ -338,6 +340,9 @@ export const useSessionStore = create<SessionStore>()(
open: true,
selectedProjectId: selectedProject?.id ?? null,
directoryOverride: directory,
pendingWorktreeRequestId: options?.pendingWorktreeRequestId ?? null,
bootstrapPendingDirectory: normalizePath(options?.bootstrapPendingDirectory ?? null),
preserveDirectoryOverride: options?.preserveDirectoryOverride === true,
parentID: options?.parentID ?? null,
title: options?.title,
initialPrompt: options?.initialPrompt,
@@ -376,7 +381,52 @@ export const useSessionStore = create<SessionStore>()(
}
},
setNewSessionDraftTarget: ({ projectId, directoryOverride }) => {
overrideNewSessionDraftTarget: (options) => {
const projectsState = useProjectsStore.getState();
const projects = projectsState.projects;
const availableWorktreesByProject = get().availableWorktreesByProject;
const explicitDirectory = normalizePath(options?.directoryOverride ?? null);
const explicitProject = options?.projectId
? projects.find((project) => project.id === options.projectId) ?? null
: null;
const inferredProject = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, explicitDirectory);
const fallbackProject = explicitProject ?? inferredProject ?? projectsState.getActiveProject() ?? projects[0] ?? null;
const selectedProject = explicitProject ?? inferredProject ?? fallbackProject;
const nextDirectory = explicitDirectory ?? normalizePath(selectedProject?.path ?? null);
persistDraftTarget({
projectId: selectedProject?.id ?? null,
directory: nextDirectory,
});
set((state) => {
const previousDraft = state.newSessionDraft;
const hasPendingWorktreeRequestId = Object.prototype.hasOwnProperty.call(options, 'pendingWorktreeRequestId');
const hasBootstrapPendingDirectory = Object.prototype.hasOwnProperty.call(options, 'bootstrapPendingDirectory');
return {
newSessionDraft: {
...previousDraft,
open: true,
selectedProjectId: selectedProject?.id ?? null,
directoryOverride: nextDirectory,
pendingWorktreeRequestId: hasPendingWorktreeRequestId
? (options.pendingWorktreeRequestId ?? null)
: (previousDraft.pendingWorktreeRequestId ?? null),
bootstrapPendingDirectory: hasBootstrapPendingDirectory
? normalizePath(options.bootstrapPendingDirectory ?? null)
: (previousDraft.bootstrapPendingDirectory ?? null),
preserveDirectoryOverride: options?.preserveDirectoryOverride === true,
title: options?.title ?? previousDraft.title,
initialPrompt: options?.initialPrompt ?? previousDraft.initialPrompt,
},
currentSessionId: null,
error: null,
...(options?.initialPrompt ? { pendingInputText: options.initialPrompt, pendingInputMode: 'replace' as const } : {}),
};
});
},
setNewSessionDraftTarget: ({ projectId, directoryOverride }, options) => {
const projects = useProjectsStore.getState().projects;
const project = projectId
? projects.find((entry) => entry.id === projectId) ?? null
@@ -385,6 +435,52 @@ export const useSessionStore = create<SessionStore>()(
const normalizedProjectPath = normalizePath(project?.path ?? null);
const nextDirectory = normalizedDirectory ?? normalizedProjectPath ?? null;
let didUpdate = false;
set((state) => {
if (!state.newSessionDraft?.open) {
return state;
}
if (
options?.force !== true
&& (
state.newSessionDraft.pendingWorktreeRequestId
|| state.newSessionDraft.bootstrapPendingDirectory
|| state.newSessionDraft.preserveDirectoryOverride
)
) {
return state;
}
didUpdate = true;
return {
newSessionDraft: {
...state.newSessionDraft,
selectedProjectId: project?.id ?? null,
directoryOverride: nextDirectory,
pendingWorktreeRequestId: null,
bootstrapPendingDirectory: null,
preserveDirectoryOverride: false,
parentID: null,
},
};
});
if (didUpdate) {
persistDraftTarget({
projectId: project?.id ?? null,
directory: nextDirectory,
});
}
},
closeNewSessionDraft: () => {
const realCurrentSessionId = useSessionManagementStore.getState().currentSessionId;
set({
newSessionDraft: { open: false, selectedProjectId: null, directoryOverride: null, pendingWorktreeRequestId: null, bootstrapPendingDirectory: null, preserveDirectoryOverride: false, parentID: null, title: undefined, initialPrompt: undefined, syntheticParts: undefined, targetFolderId: undefined },
currentSessionId: realCurrentSessionId,
});
},
setPendingDraftWorktreeRequest: (requestId) => {
set((state) => {
if (!state.newSessionDraft?.open) {
return state;
@@ -392,24 +488,55 @@ export const useSessionStore = create<SessionStore>()(
return {
newSessionDraft: {
...state.newSessionDraft,
selectedProjectId: project?.id ?? null,
directoryOverride: nextDirectory,
parentID: null,
pendingWorktreeRequestId: requestId,
},
};
});
},
persistDraftTarget({
projectId: project?.id ?? null,
directory: nextDirectory,
resolvePendingDraftWorktreeTarget: (requestId, directory, options) => {
set((state) => {
if (!state.newSessionDraft?.open || state.newSessionDraft.pendingWorktreeRequestId !== requestId) {
return state;
}
return {
newSessionDraft: {
...state.newSessionDraft,
selectedProjectId: options?.projectId ?? state.newSessionDraft.selectedProjectId ?? null,
directoryOverride: normalizePath(directory),
pendingWorktreeRequestId: null,
bootstrapPendingDirectory: normalizePath(options?.bootstrapPendingDirectory ?? state.newSessionDraft.bootstrapPendingDirectory ?? null),
preserveDirectoryOverride: options?.preserveDirectoryOverride ?? true,
},
};
});
},
closeNewSessionDraft: () => {
const realCurrentSessionId = useSessionManagementStore.getState().currentSessionId;
set({
newSessionDraft: { open: false, selectedProjectId: null, directoryOverride: null, parentID: null, title: undefined, initialPrompt: undefined, syntheticParts: undefined, targetFolderId: undefined },
currentSessionId: realCurrentSessionId,
setDraftBootstrapPendingDirectory: (directory) => {
set((state) => {
if (!state.newSessionDraft?.open) {
return state;
}
return {
newSessionDraft: {
...state.newSessionDraft,
bootstrapPendingDirectory: normalizePath(directory),
},
};
});
},
setDraftPreserveDirectoryOverride: (value) => {
set((state) => {
if (!state.newSessionDraft?.open) {
return state;
}
return {
newSessionDraft: {
...state.newSessionDraft,
preserveDirectoryOverride: value,
},
};
});
},
@@ -579,9 +706,14 @@ export const useSessionStore = create<SessionStore>()(
if (draft?.open) {
const draftTargetFolderId = draft.targetFolderId;
const draftDirectoryOverride = draft.directoryOverride ?? null;
let draftDirectoryOverride = draft.bootstrapPendingDirectory ?? draft.directoryOverride ?? null;
const draftProjectId = draft.selectedProjectId ?? null;
if (draft.pendingWorktreeRequestId) {
draftDirectoryOverride = await waitForPendingDraftWorktreeRequest(draft.pendingWorktreeRequestId);
get().resolvePendingDraftWorktreeTarget(draft.pendingWorktreeRequestId, draftDirectoryOverride);
}
const created = await useSessionManagementStore
.getState()
.createSession(draft.title, draftDirectoryOverride, draft.parentID ?? null);
@@ -647,6 +779,7 @@ export const useSessionStore = create<SessionStore>()(
const draftSyntheticParts = draft.syntheticParts;
get().closeNewSessionDraft();
await get().setCurrentSession(created.id);
// Assign to target folder if session was created from folder's + button
if (draftTargetFolderId) {
@@ -663,6 +796,11 @@ export const useSessionStore = create<SessionStore>()(
? [...(additionalParts || []), ...draftSyntheticParts]
: additionalParts;
const createdDirectory = normalizePath(draftDirectoryOverride ?? created.directory ?? null);
if (createdDirectory) {
await waitForWorktreeBootstrap(createdDirectory);
}
try {
markPendingUserSendAnimation(created.id);
return await useMessageStore
@@ -715,6 +853,13 @@ export const useSessionStore = create<SessionStore>()(
}
}
const currentSessionDirectory = currentSessionId
? normalizePath(useSessionManagementStore.getState().getDirectoryForSession(currentSessionId))
: null;
if (currentSessionDirectory) {
await waitForWorktreeBootstrap(currentSessionDirectory);
}
// Notify server that user sent a message in this session
if (currentSessionId) {
fetch(`/api/sessions/${currentSessionId}/message-sent`, { method: 'POST' })
@@ -1268,6 +1413,10 @@ useDirectoryStore.subscribe((state, prevState) => {
return;
}
if (draft.pendingWorktreeRequestId || draft.bootstrapPendingDirectory || draft.preserveDirectoryOverride) {
return;
}
const draftDirectory = normalizePath(draft.directoryOverride);
if (draftDirectory && draftDirectory !== prevDirectory) {
return;