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