diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 05887656..2a7ff0f2 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ onOpenSettings, scrollToBo ) : null} - {worktreeBranchOptions.length > 0 ? ( - <> - {projectRootBranchOption ? : null} - - Worktrees - {worktreeBranchOptions.map((option) => ( - - {option.label} - - ))} - - - ) : null} + {projectRootBranchOption ? : null} + +
+ Worktrees + +
+ {worktreeBranchOptions.map((option) => ( + + {option.label} + + ))} +
{selectedDraftDirectory && !selectedDraftBranchIsKnown ? ( {selectedDraftBranchLabel} diff --git a/packages/ui/src/components/chat/FileAttachment.tsx b/packages/ui/src/components/chat/FileAttachment.tsx index 8016a6ba..24b93817 100644 --- a/packages/ui/src/components/chat/FileAttachment.tsx +++ b/packages/ui/src/components/chat/FileAttachment.tsx @@ -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) => { diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 21e983b0..4fd67a54 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -592,7 +592,7 @@ export const Header: React.FC = ({ if (!state.newSessionDraft?.open) { return ''; } - return normalize(state.newSessionDraft.directoryOverride ?? ''); + return normalize(state.newSessionDraft.bootstrapPendingDirectory ?? state.newSessionDraft.directoryOverride ?? ''); }); const openDirectory = React.useMemo(() => { diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index 505506da..f1aa42c8 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -23,7 +23,7 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { cn } from '@/lib/utils'; import { isDesktopShell } from '@/lib/desktop'; -import { ChatView, PlanView, GitView, DiffView, TerminalView, FilesView, SettingsView, SettingsWindow } from '@/components/views'; +import { ChatView, PlanView, GitView, DiffView, TerminalView, FilesView, SettingsView, SettingsWindow, MultiRunWindow } from '@/components/views'; // Mobile drawer width as screen percentage const MOBILE_DRAWER_WIDTH_PERCENT = 85; @@ -645,7 +645,7 @@ export const MainLayout: React.FC = () => { setRightSidebarOpen, }}> {/* Mobile: header + drawer mode */} - {!(isSettingsDialogOpen || isMultiRunLauncherOpen) &&
{ if (isRightSidebarOpen) { setRightSidebarOpen(false); @@ -779,7 +779,7 @@ export const MainLayout: React.FC = () => {
@@ -791,25 +791,20 @@ export const MainLayout: React.FC = () => { {secondaryView}
)} + {isMultiRunLauncherOpen && ( +
+ + setMultiRunLauncherOpen(false)} + onCancel={() => setMultiRunLauncherOpen(false)} + /> + +
+ )} - {/* Mobile multi-run launcher: full screen */} - {isMultiRunLauncherOpen && ( -
- - setMultiRunLauncherOpen(false)} - onCancel={() => setMultiRunLauncherOpen(false)} - /> - -
- )} - {/* Mobile settings: full screen */} {isSettingsDialogOpen && (
{ 'absolute inset-0 flex overflow-hidden', isDesktopShellRuntime ? 'bg-[color:var(--sidebar-overlay-strong)] backdrop-blur supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]' - : 'bg-sidebar', - isMultiRunLauncherOpen && 'invisible' + : 'bg-sidebar' )}> {isSidebarOpen ? ( <> @@ -951,18 +945,6 @@ export const MainLayout: React.FC = () => {
- {/* Multi-Run Launcher: replaces tabs content only */} - {isMultiRunLauncherOpen && ( -
- - setMultiRunLauncherOpen(false)} - onCancel={() => setMultiRunLauncherOpen(false)} - /> - -
- )} {/* Desktop settings: windowed dialog with blur */} @@ -970,6 +952,11 @@ export const MainLayout: React.FC = () => { open={isSettingsDialogOpen} onOpenChange={setSettingsDialogOpen} /> + )} diff --git a/packages/ui/src/components/multirun/AgentSelector.tsx b/packages/ui/src/components/multirun/AgentSelector.tsx index 7cedba9f..022869e1 100644 --- a/packages/ui/src/components/multirun/AgentSelector.tsx +++ b/packages/ui/src/components/multirun/AgentSelector.tsx @@ -7,6 +7,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { cn } from '@/lib/utils'; import { useConfigStore } from '@/stores/useConfigStore'; export interface AgentSelectorProps { @@ -85,7 +86,14 @@ export const AgentSelector: React.FC = ({ diff --git a/packages/ui/src/components/multirun/BranchSelector.tsx b/packages/ui/src/components/multirun/BranchSelector.tsx index 6aefb86d..d1ca9517 100644 --- a/packages/ui/src/components/multirun/BranchSelector.tsx +++ b/packages/ui/src/components/multirun/BranchSelector.tsx @@ -9,8 +9,12 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { checkIsGitRepository, getGitBranches } from '@/lib/gitApi'; -import { resolveRootTrackingRemote } from '@/lib/worktrees/worktreeCreate'; +import { useGitStore, useGitBranches } from '@/stores/useGitStore'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; + +/** localStorage key matching NewWorktreeDialog */ +const LAST_SOURCE_BRANCH_KEY = 'oc:lastWorktreeSourceBranch'; export type WorktreeBaseOption = { value: string; @@ -34,125 +38,60 @@ export interface BranchSelectorProps { } export interface BranchSelectorState { - branches: WorktreeBaseOption[]; + localBranches: string[]; + remoteBranches: string[]; isLoading: boolean; isGitRepository: boolean | null; } -const parseTrackingRemote = (tracking: string | null | undefined): string | null => { - const value = String(tracking || '').trim().replace(/^remotes\//, ''); - if (!value) { - return null; - } - const slashIndex = value.indexOf('/'); - if (slashIndex <= 0) { - return null; - } - return value.slice(0, slashIndex); -}; - /** * Hook to load available git branches for a directory. + * Uses the shared useGitStore (same as NewWorktreeDialog). */ // eslint-disable-next-line react-refresh/only-export-components -- Hook is tightly coupled with BranchSelector export function useBranchOptions(directory: string | null): BranchSelectorState { - const [branches, setBranches] = React.useState([ - { value: 'HEAD', label: 'Current (HEAD)', group: 'special' }, - ]); - const [isLoading, setIsLoading] = React.useState(false); - const [isGitRepository, setIsGitRepository] = React.useState(null); + const { git } = useRuntimeAPIs(); + const branches = useGitBranches(directory); + const isLoading = useGitStore((state) => state.isLoadingBranches); + const fetchBranches = useGitStore((state) => state.fetchBranches); + // Fetch branches if not cached React.useEffect(() => { - let cancelled = false; + if (!directory || !git) return; + if (branches?.all) return; // Already cached + void fetchBranches(directory, git); + }, [directory, git, branches?.all, fetchBranches]); - if (!directory) { - setIsGitRepository(null); - setIsLoading(false); - setBranches([{ value: 'HEAD', label: 'Current (HEAD)', group: 'special' }]); - return; - } + // Compute local and remote branch lists (same as NewWorktreeDialog) + const localBranches = React.useMemo(() => { + if (!branches?.all) return []; + return branches.all + .filter((branchName: string) => !branchName.startsWith('remotes/')) + .sort(); + }, [branches]); - setIsLoading(true); - setIsGitRepository(null); + const remoteBranches = React.useMemo(() => { + if (!branches?.all) return []; + return branches.all + .filter((branchName: string) => branchName.startsWith('remotes/')) + .map((branchName: string) => branchName.replace(/^remotes\//, '')) + .sort(); + }, [branches]); - (async () => { - try { - const isGit = await checkIsGitRepository(directory); - if (cancelled) return; + // isGitRepository: true if we got branches, false if fetch returned empty, null if not yet loaded + const isGitRepository = React.useMemo(() => { + if (!directory) return null; + if (isLoading) return null; + if (!branches) return null; + return Boolean(branches.all); + }, [directory, isLoading, branches]); - setIsGitRepository(isGit); - - if (!isGit) { - setBranches([{ value: 'HEAD', label: 'Current (HEAD)', group: 'special' }]); - return; - } - - const branchData = await getGitBranches(directory).catch(() => null); - if (cancelled) return; - - const rootTrackingRemote = await resolveRootTrackingRemote(directory).catch(() => null); - if (cancelled) return; - - const worktreeBaseOptions: WorktreeBaseOption[] = []; - const headLabel = branchData?.current ? `Current (HEAD: ${branchData.current})` : 'Current (HEAD)'; - worktreeBaseOptions.push({ value: 'HEAD', label: headLabel, group: 'special' }); - - if (branchData) { - const localBranches = branchData.all - .filter((branchName) => !branchName.startsWith('remotes/')) - .filter((branchName) => { - if (!rootTrackingRemote) { - return true; - } - const tracking = branchData.branches?.[branchName]?.tracking; - const trackingRemote = parseTrackingRemote(tracking); - if (!trackingRemote) { - return true; - } - return trackingRemote === rootTrackingRemote; - }) - .sort((a, b) => a.localeCompare(b)); - localBranches.forEach((branchName) => { - worktreeBaseOptions.push({ value: branchName, label: branchName, group: 'local' }); - }); - - const remoteBranches = branchData.all - .filter((branchName) => branchName.startsWith('remotes/')) - .map((branchName) => branchName.replace(/^remotes\//, '')) - .filter((branchName) => { - if (!rootTrackingRemote) { - return true; - } - const slashIndex = branchName.indexOf('/'); - if (slashIndex <= 0) { - return false; - } - return branchName.slice(0, slashIndex) === rootTrackingRemote; - }) - .sort((a, b) => a.localeCompare(b)); - remoteBranches.forEach((branchName) => { - worktreeBaseOptions.push({ value: branchName, label: branchName, group: 'remote' }); - }); - } - - setBranches(worktreeBaseOptions); - } finally { - if (!cancelled) { - setIsLoading(false); - } - } - })(); - - return () => { - cancelled = true; - }; - }, [directory]); - - return { branches, isLoading, isGitRepository }; + return { localBranches, remoteBranches, isLoading, isGitRepository }; } /** - * Branch selector dropdown for selecting a base branch for worktree creation. + * Branch selector dropdown for selecting a source branch for worktree creation. + * Matches the NewWorktreeDialog source branch selector exactly. */ export const BranchSelector: React.FC = ({ directory, @@ -162,23 +101,46 @@ export const BranchSelector: React.FC = ({ disabled, id, }) => { - const { branches, isLoading, isGitRepository } = useBranchOptions(directory); - const selectedLabel = React.useMemo(() => { - return branches.find((option) => option.value === value)?.label ?? null; - }, [branches, value]); + const { localBranches, remoteBranches, isLoading, isGitRepository } = useBranchOptions(directory); + const allBranches = React.useMemo( + () => [...localBranches, ...remoteBranches.map(b => `remotes/${b}`)], + [localBranches, remoteBranches], + ); - // Update value if it's no longer valid + // Resolve default source branch (same priority as NewWorktreeDialog) React.useEffect(() => { - const isValid = branches.some((option) => option.value === value); - if (!isValid && branches.length > 0) { - onChange('HEAD'); - } - }, [branches, value, onChange]); + if (disabled || isLoading || allBranches.length === 0) return; + // If current value is valid, keep it + if (value && allBranches.includes(value)) return; + + const resolve = async () => { + try { + const rootBranch = directory ? await getRootBranch(directory).catch(() => null) : null; + const saved = localStorage.getItem(LAST_SOURCE_BRANCH_KEY); + + if (saved && allBranches.includes(saved)) { + onChange(saved); + } else if (rootBranch && allBranches.includes(rootBranch)) { + onChange(rootBranch); + } else if (allBranches.includes('main')) { + onChange('main'); + } else if (allBranches.includes('master')) { + onChange('master'); + } else if (allBranches[0]) { + onChange(allBranches[0]); + } + } catch { + // ignore + } + }; + + void resolve(); + }, [allBranches, directory, disabled, isLoading, onChange, value]); const isDisabled = disabled || !isGitRepository || isLoading; return ( -
+
- + {isGitRepository === false && ( -

Not in a git repository.

+

Not in a git repository.

)}
); diff --git a/packages/ui/src/components/multirun/ModelMultiSelect.tsx b/packages/ui/src/components/multirun/ModelMultiSelect.tsx index 997e6247..2ca6649a 100644 --- a/packages/ui/src/components/multirun/ModelMultiSelect.tsx +++ b/packages/ui/src/components/multirun/ModelMultiSelect.tsx @@ -97,6 +97,8 @@ export interface ModelMultiSelectProps { showChips?: boolean; /** Maximum models allowed */ maxModels?: number; + /** Optional className for add model trigger button */ + addButtonClassName?: string; } /** @@ -111,6 +113,7 @@ export const ModelMultiSelect: React.FC = ({ addButtonLabel = 'Add model', showChips = true, maxModels, + addButtonClassName, }) => { const { providers, modelsMetadata } = useConfigStore(); const { favoriteModelsList, recentModelsList } = useModelLists(); @@ -201,15 +204,29 @@ export const ModelMultiSelect: React.FC = ({ const hasResults = filteredFavorites.length > 0 || filteredRecents.length > 0 || filteredProviders.length > 0; - // Calculate available height when dropdown opens + // Calculate available height: space above trigger within visible area React.useEffect(() => { - if (isOpen && triggerRef.current) { - const rect = triggerRef.current.getBoundingClientRect(); - // Space above trigger minus padding from top edge - const spaceAbove = rect.top - 100; - // Cap at 400px max, minimum 150px - setAvailableHeight(Math.max(150, Math.min(400, spaceAbove))); + if (!isOpen || !triggerRef.current) return; + + const triggerRect = triggerRef.current.getBoundingClientRect(); + + // Find the nearest dialog or overflow ancestor to constrain within + let container: HTMLElement | null = triggerRef.current.parentElement; + while (container) { + if (container.getAttribute('role') === 'dialog' || container.hasAttribute('data-scroll-shadow')) { + break; + } + const style = getComputedStyle(container); + if (style.overflow === 'auto' || style.overflow === 'hidden' || style.overflowY === 'auto' || style.overflowY === 'hidden') { + break; + } + container = container.parentElement; } + + const topBound = container ? container.getBoundingClientRect().top : 0; + const spaceAbove = triggerRect.top - topBound - 16; + // Cap: min 150, max 300 + setAvailableHeight(Math.max(150, Math.min(300, spaceAbove))); }, [isOpen]); // Focus search input when opened @@ -308,7 +325,15 @@ export const ModelMultiSelect: React.FC = ({ type="button" variant="outline" size="sm" - className={CHIP_HEIGHT_CLASS} + className={cn( + CHIP_HEIGHT_CLASS, + '!border-border/80 !bg-[var(--surface-subtle)]/95 !backdrop-blur-sm hover:!bg-[var(--interactive-hover)]/70', + addButtonClassName, + )} + style={{ + backdropFilter: 'blur(10px)', + WebkitBackdropFilter: 'blur(10px)', + }} onClick={() => setIsOpen(!isOpen)} > @@ -379,7 +404,12 @@ export const ModelMultiSelect: React.FC = ({ let currentFlatIndex = 0; return ( -
+
{/* Search input */}
@@ -411,10 +441,7 @@ export const ModelMultiSelect: React.FC = ({ {/* Favorites Section */} {filteredFavorites.length > 0 && ( <> -
+
Favorites
@@ -429,10 +456,7 @@ export const ModelMultiSelect: React.FC = ({ {filteredRecents.length > 0 && ( <> {filteredFavorites.length > 0 &&
} -
+
Recent
@@ -452,7 +476,7 @@ export const ModelMultiSelect: React.FC = ({ {filteredProviders.map((provider, index) => ( {index > 0 &&
} -
+
= ({ onUpdate(index, { ...model, variant: nextVariant }); }} > - + void; /** Called when user cancels */ onCancel?: () => void; + /** Rendered inside dialog window with no local header */ + isWindowed?: boolean; } +/** Info tooltip - small icon that shows helper text on hover */ +const InfoTip: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + + + + + + {children} + + +); + +/** Compact field label */ +const FieldLabel: React.FC<{ + htmlFor?: string; + required?: boolean; + children: React.ReactNode; + info?: React.ReactNode; +}> = ({ htmlFor, required, children, info }) => ( +
+ + {info && info} +
+); + /** * Launcher form for creating a new Multi-Run group. - * Replaces the main content area (tabs) with a form. + * Compact, centered card layout with adaptive grid. */ export const MultiRunLauncher: React.FC = ({ initialPrompt, onCreated, onCancel, + isWindowed = false, }) => { const [name, setName] = React.useState(''); const [prompt, setPrompt] = React.useState(() => initialPrompt ?? ''); @@ -64,6 +102,7 @@ export const MultiRunLauncher: React.FC = ({ const fileInputRef = React.useRef(null); const currentDirectory = useDirectoryStore((state) => state.currentDirectory ?? null); + const homeDirectory = useDirectoryStore((state) => state.homeDirectory ?? null); const vscodeWorkspaceFolder = React.useMemo(() => { if (typeof window === 'undefined') { @@ -75,13 +114,72 @@ export const MultiRunLauncher: React.FC = ({ // Get project directory for setup commands const activeProjectId = useProjectsStore((state) => state.activeProjectId); + const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly); const projects = useProjectsStore((state) => state.projects); - const projectRef = React.useMemo(() => { + const [selectedProjectId, setSelectedProjectId] = React.useState(() => activeProjectId ?? null); + + React.useEffect(() => { if (activeProjectId) { - const project = projects.find((p) => p.id === activeProjectId); - if (project?.path) { - return { id: project.id, path: project.path }; - } + setSelectedProjectId(activeProjectId); + return; + } + if (!selectedProjectId && projects.length > 0) { + setSelectedProjectId(projects[0].id); + } + }, [activeProjectId, projects, selectedProjectId]); + + const selectedProject = React.useMemo(() => { + if (!selectedProjectId) { + return null; + } + return projects.find((project) => project.id === selectedProjectId) ?? null; + }, [projects, selectedProjectId]); + + const selectedProjectDirectory = selectedProject?.path ?? currentDirectory; + + const handleProjectChange = React.useCallback((projectId: string) => { + setSelectedProjectId(projectId); + if (projectId !== activeProjectId) { + setActiveProjectIdOnly(projectId); + } + }, [activeProjectId, setActiveProjectIdOnly]); + + const { currentTheme } = useThemeSystem(); + + const renderProjectLabel = React.useCallback((project: ProjectEntry) => { + const displayLabel = project.label?.trim() || formatDirectoryName(project.path, homeDirectory); + const imageUrl = getProjectIconImageUrl( + { id: project.id, iconImage: project.iconImage ?? null }, + { + themeVariant: currentTheme.metadata.variant, + iconColor: currentTheme.colors.surface.foreground, + }, + ); + const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null; + const iconColor = project.color ? PROJECT_COLOR_MAP[project.color] : undefined; + + return ( + + {imageUrl ? ( + + + + ) : ProjectIcon ? ( + + ) : ( + + )} + {displayLabel} + + ); + }, [homeDirectory, currentTheme.metadata.variant, currentTheme.colors.surface.foreground]); + + const projectRef = React.useMemo(() => { + if (selectedProject?.path) { + return { id: selectedProject.id, path: selectedProject.path }; } const base = currentDirectory ?? vscodeWorkspaceFolder; @@ -90,7 +188,7 @@ export const MultiRunLauncher: React.FC = ({ } return { id: `path:${base}`, path: base }; - }, [activeProjectId, projects, currentDirectory, vscodeWorkspaceFolder]); + }, [selectedProject, currentDirectory, vscodeWorkspaceFolder]); const [isDesktopApp, setIsDesktopApp] = React.useState(() => { if (typeof window === 'undefined') { @@ -193,8 +291,8 @@ export const MultiRunLauncher: React.FC = ({ }, [onCancel]); // Use the BranchSelector hook for branch state management - const [worktreeBaseBranch, setWorktreeBaseBranch] = React.useState('HEAD'); - const { isLoading: isLoadingWorktreeBaseBranches, isGitRepository } = useBranchOptions(currentDirectory); + const [worktreeBaseBranch, setWorktreeBaseBranch] = React.useState(''); + const { isLoading: isLoadingWorktreeBaseBranches, isGitRepository } = useBranchOptions(selectedProjectDirectory); const createMultiRun = useMultiRunStore((state) => state.createMultiRun); const error = useMultiRunStore((state) => state.error); @@ -311,6 +409,10 @@ export const MultiRunLauncher: React.FC = ({ clearError(); try { + if (selectedProjectId && selectedProjectId !== activeProjectId) { + setActiveProjectIdOnly(selectedProjectId); + } + // Strip instanceId before passing to store (UI-only field) // eslint-disable-next-line @typescript-eslint/no-unused-vars const modelsForStore: MultiRunModelSelection[] = selectedModels.map(({ instanceId: _instanceId, ...rest }) => rest); @@ -350,254 +452,275 @@ export const MultiRunLauncher: React.FC = ({ }; const isValid = Boolean( - name.trim() && prompt.trim() && selectedModels.length >= 2 && isGitRepository && !isLoadingWorktreeBaseBranches + name.trim() && prompt.trim() && selectedModels.length >= 2 && worktreeBaseBranch && isGitRepository && !isLoadingWorktreeBaseBranches ); + const configuredSetupCount = setupCommands.filter(cmd => cmd.trim()).length; + return ( -
- {/* Header - same height as app header (h-12 = 48px) */} -
-

New Multi-Run

- {onCancel && ( -
- - - - - -

Close (Esc)

-
-
-
- )} -
- - {/* Content with chat-column max-width */} -
-
-
- {/* Group name (required) */} -
- - setName(e.target.value)} - placeholder="e.g. feature-auth, bugfix-login" - className="typography-body max-w-full sm:max-w-xs" - required - /> -

- Used for worktree directory and branch names -

+ + {!isWindowed ? ( +
+

New Multi-Run

+ {onCancel && ( +
+ + + + + +

Close (Esc)

+
+
+ )} +
+ ) : null} - {/* Worktree creation */} -
-
-

Worktrees

-

- Create one worktree per model by creating a new branch from a base branch. -

+ {/* Scrollable content */} + +
+
+ + {/* ── Config grid: 2-column on sm+, single column on narrow ── */} +
+ {/* Project */} +
+ Project + {projects.length > 0 ? ( + + ) : ( +

Add a project first.

+ )}
-
-
+ + {/* Base branch */} +
+ New branch created from this base per model} > Base branch - + -

- Creates new branches from{' '} - {worktreeBaseBranch || 'HEAD'}. -

- {/* Setup commands collapsible */} - - -

- Setup commands - {setupCommands.filter(cmd => cmd.trim()).length > 0 && ( - - {' '}({setupCommands.filter(cmd => cmd.trim()).length} configured) - - )} -

- -
- -
-

- Commands run in each new worktree. Use $ROOT_PROJECT_PATH for project root. -

- {isLoadingSetupCommands ? ( -

Loading...

- ) : ( -
- {setupCommands.map((command, index) => ( -
- { - const newCommands = [...setupCommands]; - newCommands[index] = e.target.value; - setSetupCommands(newCommands); - }} - placeholder="e.g., bun install" - className="h-8 flex-1 font-mono text-xs" - /> - -
- ))} - -
- )} -
-
-
+ {/* Agent */} +
+ Agent used for all runs. Defaults to your configured agent.} + > + Agent + + +
- {/* Agent selection */} -
- - -

- Defaults to your configured default agent. -

-
+ {/* ── Setup commands (collapsible, full width) ── */} + + + + + Setup commands + + {configuredSetupCount > 0 && ( + + {configuredSetupCount} + + )} + + + +
+ {isLoadingSetupCommands ? ( +

Loading...

+ ) : ( + <> + {setupCommands.map((command, index) => ( +
+ { + const newCommands = [...setupCommands]; + newCommands[index] = e.target.value; + setSetupCommands(newCommands); + }} + placeholder="bun install" + className="h-8 flex-1 font-mono text-xs" + /> + +
+ ))} + + + )} +
+
+
- {/* Prompt */} -
- + {/* ── Prompt ── */} +
+ Prompt