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
@@ -868,15 +868,11 @@ Nice-to-have:
|
||||
}
|
||||
|
||||
toast.success('Worktree created', {
|
||||
description: `${metadata.branch || metadata.name}${sourceLabel ? ` from ${sourceLabel}` : ''}`,
|
||||
description: `${metadata.branch || metadata.name}${sourceLabel ? ` from ${sourceLabel}` : ''} - bootstrapping in background`,
|
||||
});
|
||||
|
||||
try {
|
||||
await loadSessions();
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
|
||||
void loadSessions().catch(() => undefined);
|
||||
|
||||
onOpenChange(false);
|
||||
|
||||
if (createdSessionId) {
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from '@/lib/openchamberConfig';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { createWorktreeOnly } from '@/lib/worktreeSessionCreator';
|
||||
import { createWorktreeDraft } from '@/lib/worktreeSessionCreator';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ProjectNotesTodoPanelProps {
|
||||
@@ -228,22 +228,18 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
||||
}
|
||||
setSendingTodoId(todoId);
|
||||
try {
|
||||
const newWorktreePath = await createWorktreeOnly();
|
||||
routeToChat();
|
||||
const newWorktreePath = await createWorktreeDraft({ initialPrompt: todoText });
|
||||
if (!newWorktreePath) {
|
||||
return;
|
||||
}
|
||||
routeToChat();
|
||||
openNewSessionDraft({
|
||||
directoryOverride: newWorktreePath,
|
||||
initialPrompt: todoText,
|
||||
});
|
||||
toast.success('Todo sent to new worktree session');
|
||||
onActionComplete?.();
|
||||
} finally {
|
||||
setSendingTodoId(null);
|
||||
}
|
||||
},
|
||||
[canCreateWorktree, onActionComplete, openNewSessionDraft, projectRef, routeToChat]
|
||||
[canCreateWorktree, onActionComplete, projectRef, routeToChat]
|
||||
);
|
||||
|
||||
if (!projectRef) {
|
||||
|
||||
@@ -66,6 +66,9 @@ export const SessionDialogs: React.FC = () => {
|
||||
archiveSessions,
|
||||
loadSessions,
|
||||
getWorktreeMetadata,
|
||||
newSessionDraft,
|
||||
setNewSessionDraftTarget,
|
||||
setDraftBootstrapPendingDirectory,
|
||||
} = useSessionStore();
|
||||
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
|
||||
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
|
||||
@@ -381,12 +384,34 @@ export const SessionDialogs: React.FC = () => {
|
||||
deleteLocalBranch: boolean
|
||||
): Promise<boolean> => {
|
||||
const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches;
|
||||
const projectRef = getProjectRefForWorktree(worktree);
|
||||
const normalizedWorktreePath = normalizeProjectDirectory(worktree.path);
|
||||
const normalizedProjectPath = normalizeProjectDirectory(projectRef.path);
|
||||
try {
|
||||
await removeProjectWorktree(
|
||||
getProjectRefForWorktree(worktree),
|
||||
projectRef,
|
||||
worktree,
|
||||
{ deleteRemoteBranch: shouldRemoveRemote, deleteLocalBranch }
|
||||
);
|
||||
|
||||
const draftDirectory = normalizeProjectDirectory(newSessionDraft?.directoryOverride);
|
||||
if (
|
||||
newSessionDraft?.open
|
||||
&& normalizedWorktreePath
|
||||
&& draftDirectory === normalizedWorktreePath
|
||||
&& normalizedProjectPath
|
||||
) {
|
||||
setDraftBootstrapPendingDirectory(null);
|
||||
setNewSessionDraftTarget({
|
||||
projectId: projectRef.id,
|
||||
directoryOverride: normalizedProjectPath,
|
||||
}, { force: true });
|
||||
}
|
||||
|
||||
if (normalizeProjectDirectory(currentDirectory) === normalizedWorktreePath && normalizedProjectPath) {
|
||||
useDirectoryStore.getState().setDirectory(normalizedProjectPath, { showOverlay: false });
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error('Failed to remove worktree', {
|
||||
@@ -394,7 +419,7 @@ export const SessionDialogs: React.FC = () => {
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}, [canRemoveRemoteBranches, deleteDialogShouldRemoveRemote, getProjectRefForWorktree]);
|
||||
}, [canRemoveRemoteBranches, currentDirectory, deleteDialogShouldRemoveRemote, getProjectRefForWorktree, newSessionDraft?.directoryOverride, newSessionDraft?.open, setDraftBootstrapPendingDirectory, setNewSessionDraftTarget]);
|
||||
|
||||
const handleConfirmDelete = React.useCallback(async () => {
|
||||
if (!deleteDialog) {
|
||||
|
||||
@@ -1293,6 +1293,14 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
openNewSessionDraft();
|
||||
}, [mobileVariant, openNewSessionDraft, setActiveMainTab, setSessionSwitcherOpen]);
|
||||
|
||||
const handleOpenMultiRunFromHeader = React.useCallback(() => {
|
||||
setActiveMainTab('chat');
|
||||
if (mobileVariant) {
|
||||
setSessionSwitcherOpen(false);
|
||||
}
|
||||
openMultiRunLauncher();
|
||||
}, [mobileVariant, openMultiRunLauncher, setActiveMainTab, setSessionSwitcherOpen]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={sessionSearchContainerRef}
|
||||
@@ -1336,6 +1344,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
setProjectNotesPanelOpen={setProjectNotesPanelOpen}
|
||||
activeProjectRefForHeader={activeProjectRefForHeader}
|
||||
activeProjectLabelForHeader={activeProjectLabelForHeader}
|
||||
canOpenMultiRun={projects.length > 0}
|
||||
openMultiRunLauncher={handleOpenMultiRunFromHeader}
|
||||
stableActiveProjectIsRepo={stableActiveProjectIsRepo}
|
||||
headerActionIconClass={headerActionIconClass}
|
||||
reserveHeaderActionsSpace={reserveHeaderActionsSpace}
|
||||
@@ -1376,7 +1386,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
setSessionSwitcherOpen={setSessionSwitcherOpen}
|
||||
openNewSessionDraft={openNewSessionDraft}
|
||||
openNewWorktreeDialog={openNewWorktreeDialog}
|
||||
openMultiRunLauncher={openMultiRunLauncher}
|
||||
openProjectEditDialog={setEditingProjectDialogId}
|
||||
removeProject={removeProject}
|
||||
projectHeaderSentinelRefs={projectHeaderSentinelRefs}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
RiExpandUpDownLine,
|
||||
RiStickyNoteLine,
|
||||
} from '@remixicon/react';
|
||||
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
||||
import type { ProjectRef } from '@/lib/openchamberConfig';
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
import { ProjectNotesTodoPanel } from '../ProjectNotesTodoPanel';
|
||||
@@ -31,6 +32,8 @@ type Props = {
|
||||
setProjectNotesPanelOpen: (open: boolean) => void;
|
||||
activeProjectRefForHeader: ProjectRef | null;
|
||||
activeProjectLabelForHeader: string | null;
|
||||
canOpenMultiRun: boolean;
|
||||
openMultiRunLauncher: () => void;
|
||||
stableActiveProjectIsRepo: boolean;
|
||||
headerActionIconClass: string;
|
||||
reserveHeaderActionsSpace: boolean;
|
||||
@@ -56,6 +59,8 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
setProjectNotesPanelOpen,
|
||||
activeProjectRefForHeader,
|
||||
activeProjectLabelForHeader,
|
||||
canOpenMultiRun,
|
||||
openMultiRunLauncher,
|
||||
stableActiveProjectIsRepo,
|
||||
headerActionIconClass,
|
||||
reserveHeaderActionsSpace,
|
||||
@@ -113,6 +118,21 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openMultiRunLauncher}
|
||||
className={headerActionButtonClass}
|
||||
aria-label="New multi-run"
|
||||
disabled={!canOpenMultiRun}
|
||||
>
|
||||
<ArrowsMerge className={headerActionIconClass} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>New multi-run</p></TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{useMobileNotesPanel ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -54,7 +54,6 @@ type Props = {
|
||||
setSessionSwitcherOpen: (open: boolean) => void;
|
||||
openNewSessionDraft: (options?: { directoryOverride?: string | null }) => void;
|
||||
openNewWorktreeDialog: () => void;
|
||||
openMultiRunLauncher: () => void;
|
||||
openProjectEditDialog: (id: string) => void;
|
||||
removeProject: (id: string) => void;
|
||||
projectHeaderSentinelRefs: React.MutableRefObject<Map<string, HTMLDivElement | null>>;
|
||||
@@ -185,10 +184,6 @@ export function SidebarProjectsList(props: Props): React.ReactNode {
|
||||
if (props.mobileVariant) props.setSessionSwitcherOpen(false);
|
||||
props.openNewWorktreeDialog();
|
||||
}}
|
||||
onOpenMultiRunLauncher={() => {
|
||||
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
|
||||
props.openMultiRunLauncher();
|
||||
}}
|
||||
onRenameStart={() => props.openProjectEditDialog(projectKey)}
|
||||
onClose={() => props.removeProject(projectKey)}
|
||||
sentinelRef={(el) => { props.projectHeaderSentinelRefs.current.set(projectKey, el); }}
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
RiNodeTree,
|
||||
RiPencilAiLine,
|
||||
} from '@remixicon/react';
|
||||
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
@@ -43,7 +42,6 @@ export interface SortableProjectItemProps {
|
||||
onHoverChange: (hovered: boolean) => void;
|
||||
onNewSession: () => void;
|
||||
onNewWorktreeSession?: () => void;
|
||||
onOpenMultiRunLauncher: () => void;
|
||||
onRenameStart: () => void;
|
||||
onClose: () => void;
|
||||
sentinelRef: (el: HTMLDivElement | null) => void;
|
||||
@@ -79,7 +77,6 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
onHoverChange,
|
||||
onNewSession,
|
||||
onNewWorktreeSession,
|
||||
onOpenMultiRunLauncher,
|
||||
onRenameStart,
|
||||
onClose,
|
||||
sentinelRef,
|
||||
@@ -267,12 +264,6 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
New Session
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showCreateButtons && isRepo && !hideDirectoryControls && (
|
||||
<DropdownMenuItem onClick={onOpenMultiRunLauncher}>
|
||||
<ArrowsMerge className="mr-1.5 h-4 w-4" />
|
||||
New Multi-Run
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={onRenameStart}>
|
||||
<RiPencilAiLine className="mr-1.5 h-4 w-4" />
|
||||
Rename
|
||||
|
||||
Reference in New Issue
Block a user