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
+62 -19
View File
@@ -63,6 +63,7 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
import { useGitBranches, useGitStore } from '@/stores/useGitStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { createWorktreeDraft } from '@/lib/worktreeSessionCreator';
import { usePermissionStore } from '@/stores/permissionStore';
const MAX_VISIBLE_TEXTAREA_LINES = 8;
@@ -2375,10 +2376,22 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
}, [availableWorktreesByProject, projectRootBranchOption?.value, selectedDraftProject, selectedDraftProjectPath]);
const selectedDraftDirectory = React.useMemo(
() => normalizePath(newSessionDraft?.directoryOverride ?? null) ?? selectedDraftProjectPath,
[newSessionDraft?.directoryOverride, selectedDraftProjectPath],
() => normalizePath(newSessionDraft?.bootstrapPendingDirectory ?? null)
?? normalizePath(newSessionDraft?.directoryOverride ?? null)
?? selectedDraftProjectPath,
[newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.directoryOverride, selectedDraftProjectPath],
);
const shouldKeepMissingSelectedDraftDirectory = React.useMemo(() => {
const pendingDirectory = normalizePath(newSessionDraft?.bootstrapPendingDirectory ?? null);
return Boolean(
newSessionDraft?.preserveDirectoryOverride
||
newSessionDraft?.pendingWorktreeRequestId
|| (pendingDirectory && pendingDirectory === selectedDraftDirectory)
);
}, [newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.pendingWorktreeRequestId, newSessionDraft?.preserveDirectoryOverride, selectedDraftDirectory]);
const draftBranchItems = React.useMemo(() => {
const baseItems: Array<{ value: string; label: string }> = [];
if (projectRootBranchOption) {
@@ -2392,11 +2405,14 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
if (baseItems.some((option) => option.value === selectedDraftDirectory)) {
return baseItems;
}
if (!shouldKeepMissingSelectedDraftDirectory) {
return baseItems;
}
return [
...baseItems,
{ value: selectedDraftDirectory, label: formatDirectoryName(selectedDraftDirectory) },
];
}, [projectRootBranchOption, selectedDraftDirectory, worktreeBranchOptions]);
}, [projectRootBranchOption, selectedDraftDirectory, shouldKeepMissingSelectedDraftDirectory, worktreeBranchOptions]);
const selectedDraftBranchLabel = React.useMemo(() => {
const selectedValue = selectedDraftDirectory ?? draftBranchItems[0]?.value ?? null;
@@ -2416,6 +2432,16 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
return worktreeBranchOptions.some((option) => option.value === selectedDraftDirectory);
}, [projectRootBranchOption?.value, selectedDraftDirectory, worktreeBranchOptions]);
React.useEffect(() => {
if (!newSessionDraft?.open || !newSessionDraft?.preserveDirectoryOverride) {
return;
}
if (!selectedDraftDirectory || !selectedDraftBranchIsKnown) {
return;
}
useSessionStore.getState().setDraftPreserveDirectoryOverride(false);
}, [newSessionDraft?.open, newSessionDraft?.preserveDirectoryOverride, selectedDraftBranchIsKnown, selectedDraftDirectory]);
const shouldShowDraftBranchSelector = React.useMemo(() => {
if (isDiscoveringDraftBranches) {
return false;
@@ -2427,6 +2453,10 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
}, [isDiscoveringDraftBranches, projectRootBranchOption, worktreeBranchOptions.length]);
const handleDraftProjectChange = React.useCallback((projectId: string) => {
const draft = useSessionStore.getState().newSessionDraft;
if (draft?.pendingWorktreeRequestId || draft?.bootstrapPendingDirectory || draft?.preserveDirectoryOverride) {
return;
}
const project = projects.find((entry) => entry.id === projectId);
if (!project) {
return;
@@ -2437,17 +2467,21 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
setNewSessionDraftTarget({
projectId,
directoryOverride: project.path,
});
}, { force: true });
}, [activeProjectId, projects, setActiveProjectIdOnly, setNewSessionDraftTarget]);
const handleDraftDirectoryChange = React.useCallback((directory: string) => {
const draft = useSessionStore.getState().newSessionDraft;
if (draft?.pendingWorktreeRequestId || draft?.bootstrapPendingDirectory || draft?.preserveDirectoryOverride) {
return;
}
if (!selectedDraftProject) {
return;
}
setNewSessionDraftTarget({
projectId: selectedDraftProject.id,
directoryOverride: directory,
});
}, { force: true });
}, [selectedDraftProject, setNewSessionDraftTarget]);
const renderProjectLabelWithIcon = React.useCallback((project: {
@@ -2492,6 +2526,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
if (!showDraftTargetSelectors || !selectedDraftProject || !selectedDraftDirectory) {
return;
}
if (newSessionDraft?.pendingWorktreeRequestId || newSessionDraft?.bootstrapPendingDirectory || newSessionDraft?.preserveDirectoryOverride) {
return;
}
const valid = draftBranchItems.some((option) => option.value === selectedDraftDirectory);
if (valid) {
return;
@@ -2500,7 +2537,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
projectId: selectedDraftProject.id,
directoryOverride: selectedDraftProject.path,
});
}, [draftBranchItems, selectedDraftDirectory, selectedDraftProject, setNewSessionDraftTarget, showDraftTargetSelectors]);
}, [draftBranchItems, newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.pendingWorktreeRequestId, newSessionDraft?.preserveDirectoryOverride, selectedDraftDirectory, selectedDraftProject, setNewSessionDraftTarget, showDraftTargetSelectors]);
const footerPaddingClass = isMobile ? 'px-1.5 py-1.5' : (isVSCode ? 'px-1.5 py-1' : 'px-2.5 py-1.5');
const buttonSizeClass = isMobile ? 'h-8 w-8' : (isVSCode ? 'h-5 w-5' : 'h-6 w-6');
@@ -2986,19 +3023,25 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
</SelectItem>
</SelectGroup>
) : null}
{worktreeBranchOptions.length > 0 ? (
<>
{projectRootBranchOption ? <SelectSeparator /> : null}
<SelectGroup>
<SelectLabel>Worktrees</SelectLabel>
{worktreeBranchOptions.map((option) => (
<SelectItem key={option.value} value={option.value} className="max-w-[24rem] truncate">
{option.label}
</SelectItem>
))}
</SelectGroup>
</>
) : null}
{projectRootBranchOption ? <SelectSeparator /> : null}
<SelectGroup>
<div className="flex items-center justify-between px-2 py-1.5">
<span className="text-muted-foreground typography-meta">Worktrees</span>
<button
type="button"
className="text-muted-foreground typography-meta hover:text-foreground cursor-pointer"
onPointerDown={(e) => { e.stopPropagation(); }}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); void createWorktreeDraft(); }}
>
+ New
</button>
</div>
{worktreeBranchOptions.map((option) => (
<SelectItem key={option.value} value={option.value} className="max-w-[24rem] truncate">
{option.label}
</SelectItem>
))}
</SelectGroup>
{selectedDraftDirectory && !selectedDraftBranchIsKnown ? (
<SelectItem value={selectedDraftDirectory} className="max-w-[24rem] truncate">
{selectedDraftBranchLabel}
@@ -347,13 +347,13 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
return filename || path;
};
const resolveDisplayName = (file: FilePart): string => {
const resolveDisplayName = React.useCallback((file: FilePart): string => {
const isGitHubLink = getGitHubLinkKind(file) !== null;
if (isGitHubLink && typeof file.filename === 'string' && file.filename.trim().length > 0) {
return file.filename.trim();
}
return extractFilename(file.filename || file.url);
};
}, []);
const formatFileSize = (bytes?: number) => {
if (!bytes || !Number.isFinite(bytes) || bytes <= 0) return '';
@@ -377,7 +377,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
size: file.size,
}];
}),
[imageFiles]
[imageFiles, resolveDisplayName]
);
const handleImageClick = React.useCallback((index: number) => {