feat: implement worktree setup commands management
- Introduced `readFile` and `writeFile` methods in the FilesAPI for reading and writing files. - Added `execCommands` method to execute shell commands in a specified directory. - Implemented `runWorktreeSetupCommands` function to handle setup commands for worktrees. - Created `OpenChamberConfig` service for managing project-specific configuration, including setup commands. - Enhanced `useMultiRunStore` to save and execute setup commands during multi-run creation. - Updated VSCode bridge to handle file read/write and command execution requests. - Added server endpoints for reading and writing files, and executing shell commands.
This commit is contained in:
@@ -309,7 +309,7 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
|
||||
// Build flat list for keyboard navigation
|
||||
type FlatModelItem = { model: Record<string, unknown>; providerID: string; modelID: string; section: string };
|
||||
const flatModelList: FlatModelItem[] = [];
|
||||
|
||||
|
||||
filteredFavorites.forEach(({ model, providerID, modelID }) => {
|
||||
flatModelList.push({ model, providerID, modelID, section: 'fav' });
|
||||
});
|
||||
@@ -387,7 +387,7 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Models list */}
|
||||
<ScrollableOverlay
|
||||
<ScrollableOverlay
|
||||
outerClassName="flex-1"
|
||||
style={{ maxHeight: availableHeight ? `${availableHeight}px` : '300px' }}
|
||||
>
|
||||
@@ -477,11 +477,11 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Validation hint */}
|
||||
{minModels !== undefined && selectedModels.length < minModels && (
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Select at least {minModels} model{minModels > 1 ? 's' : ''} {maxModels !== undefined ? `and at most ${maxModels} models` : ''}.
|
||||
Select from {minModels} {maxModels !== undefined ? `to ${maxModels} models` : ''}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import React from 'react';
|
||||
import { RiAttachment2, RiCloseLine, RiFileImageLine, RiFileLine, RiPlayLine } from '@remixicon/react';
|
||||
import { RiAddLine, RiArrowDownSLine, RiAttachment2, RiCloseLine, RiFileImageLine, RiFileLine, RiPlayLine } from '@remixicon/react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useMultiRunStore } from '@/stores/useMultiRunStore';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||||
import type { CreateMultiRunParams, MultiRunModelSelection } from '@/types/multirun';
|
||||
import { ModelMultiSelect, generateInstanceId, type ModelSelectionWithId } from './ModelMultiSelect';
|
||||
import { BranchSelector, useBranchOptions } from './BranchSelector';
|
||||
@@ -54,9 +57,40 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
||||
const [selectedAgent, setSelectedAgent] = React.useState<string>('');
|
||||
const [attachedFiles, setAttachedFiles] = React.useState<MultiRunAttachedFile[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
const [setupCommands, setSetupCommands] = React.useState<string[]>([]);
|
||||
const [isSetupCommandsOpen, setIsSetupCommandsOpen] = React.useState(false);
|
||||
const [isLoadingSetupCommands, setIsLoadingSetupCommands] = React.useState(false);
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory ?? null);
|
||||
|
||||
const vscodeWorkspaceFolder = React.useMemo(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const folder = (window as unknown as { __VSCODE_CONFIG__?: { workspaceFolder?: unknown } }).__VSCODE_CONFIG__?.workspaceFolder;
|
||||
return typeof folder === 'string' && folder.trim().length > 0 ? folder.trim() : null;
|
||||
}, []);
|
||||
|
||||
// Get project directory for setup commands
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const projectDirectory = React.useMemo(() => {
|
||||
if (activeProjectId) {
|
||||
const project = projects.find((p) => p.id === activeProjectId);
|
||||
if (project?.path) return project.path;
|
||||
}
|
||||
|
||||
const base = currentDirectory ?? vscodeWorkspaceFolder;
|
||||
if (!base) return null;
|
||||
|
||||
const normalized = base.replace(/\\/g, '/').replace(/\/+$/, '') || base;
|
||||
const marker = '/.openchamber/';
|
||||
const markerIndex = normalized.indexOf(marker);
|
||||
if (markerIndex > 0) return normalized.slice(0, markerIndex);
|
||||
if (normalized.endsWith('/.openchamber')) return normalized.slice(0, normalized.length - '/.openchamber'.length);
|
||||
return normalized;
|
||||
}, [activeProjectId, projects, currentDirectory, vscodeWorkspaceFolder]);
|
||||
const isSidebarOpen = useUIStore((state) => state.isSidebarOpen);
|
||||
|
||||
const [isDesktopApp, setIsDesktopApp] = React.useState<boolean>(() => {
|
||||
@@ -102,6 +136,31 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
||||
}
|
||||
}, [initialPrompt]);
|
||||
|
||||
// Load setup commands from config
|
||||
React.useEffect(() => {
|
||||
if (!projectDirectory) return;
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoadingSetupCommands(true);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const commands = await getWorktreeSetupCommands(projectDirectory);
|
||||
if (!cancelled) {
|
||||
setSetupCommands(commands);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors, start with empty commands
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoadingSetupCommands(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [projectDirectory]);
|
||||
|
||||
const handleAddModel = (model: ModelSelectionWithId) => {
|
||||
if (selectedModels.length >= MAX_MODELS) {
|
||||
return;
|
||||
@@ -189,6 +248,9 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
||||
url: f.dataUrl,
|
||||
}));
|
||||
|
||||
// Filter setup commands
|
||||
const commandsForStore = setupCommands.filter(cmd => cmd.trim().length > 0);
|
||||
|
||||
const params: CreateMultiRunParams = {
|
||||
name: name.trim(),
|
||||
prompt: prompt.trim(),
|
||||
@@ -196,6 +258,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
||||
agent: selectedAgent || undefined,
|
||||
worktreeBaseBranch,
|
||||
files: filesForStore.length > 0 ? filesForStore : undefined,
|
||||
setupCommands: commandsForStore.length > 0 ? commandsForStore : undefined,
|
||||
};
|
||||
|
||||
const result = await createMultiRun(params);
|
||||
@@ -304,6 +367,70 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
||||
<code className="font-mono text-xs text-muted-foreground">{worktreeBaseBranch || 'HEAD'}</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Setup commands collapsible */}
|
||||
<Collapsible open={isSetupCommandsOpen} onOpenChange={setIsSetupCommandsOpen}>
|
||||
<CollapsibleTrigger className="w-full flex items-center justify-between py-1 hover:opacity-80 transition-opacity">
|
||||
<p className="typography-ui-label font-medium text-foreground">
|
||||
Setup commands
|
||||
{setupCommands.filter(cmd => cmd.trim()).length > 0 && (
|
||||
<span className="font-normal text-muted-foreground/70">
|
||||
{' '}({setupCommands.filter(cmd => cmd.trim()).length} configured)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<RiArrowDownSLine className={cn(
|
||||
'h-4 w-4 text-muted-foreground transition-transform duration-200',
|
||||
isSetupCommandsOpen && 'rotate-180'
|
||||
)} />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="pt-2 space-y-2">
|
||||
<p className="typography-micro text-muted-foreground/70">
|
||||
Commands run in each new worktree. Use <code className="font-mono text-xs">$ROOT_WORKTREE_PATH</code> for project root.
|
||||
</p>
|
||||
{isLoadingSetupCommands ? (
|
||||
<p className="typography-meta text-muted-foreground/70">Loading...</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{setupCommands.map((command, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
value={command}
|
||||
onChange={(e) => {
|
||||
const newCommands = [...setupCommands];
|
||||
newCommands[index] = e.target.value;
|
||||
setSetupCommands(newCommands);
|
||||
}}
|
||||
placeholder="e.g., bun install"
|
||||
className="h-8 flex-1 font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newCommands = setupCommands.filter((_, i) => i !== index);
|
||||
setSetupCommands(newCommands);
|
||||
}}
|
||||
className="flex-shrink-0 flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label="Remove command"
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSetupCommands([...setupCommands, ''])}
|
||||
className="flex items-center gap-1.5 typography-meta text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
Add command
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
{/* Agent selection */}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { toast } from 'sonner';
|
||||
import { AnimatedTabs } from '@/components/ui/animated-tabs';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -21,7 +21,8 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { RiCheckboxBlankLine, RiCheckboxLine, RiDeleteBinLine } from '@remixicon/react';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { RiAddLine, RiArrowDownSLine, RiCheckboxBlankLine, RiCheckboxLine, RiCloseLine, RiDeleteBinLine } from '@remixicon/react';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { DirectoryExplorerDialog } from './DirectoryExplorerDialog';
|
||||
import { cn, formatPathForDisplay } from '@/lib/utils';
|
||||
@@ -34,7 +35,9 @@ import {
|
||||
listWorktrees as listGitWorktrees,
|
||||
mapWorktreeToMetadata,
|
||||
removeWorktree,
|
||||
runWorktreeSetupCommands,
|
||||
} from '@/lib/git/worktreeService';
|
||||
import { getWorktreeSetupCommands, saveWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||||
import { checkIsGitRepository, ensureOpenChamberIgnored, getGitBranches } from '@/lib/gitApi';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
@@ -124,6 +127,7 @@ export const SessionDialogs: React.FC = () => {
|
||||
const [worktreeError, setWorktreeError] = React.useState<string | null>(null);
|
||||
const [isCheckingGitRepository, setIsCheckingGitRepository] = React.useState(false);
|
||||
const [isGitRepository, setIsGitRepository] = React.useState<boolean | null>(null);
|
||||
const [mainWorktreeBranch, setMainWorktreeBranch] = React.useState<string | null>(null);
|
||||
const [isCreatingWorktree, setIsCreatingWorktree] = React.useState(false);
|
||||
const [worktreeManagerProjectId, setWorktreeManagerProjectId] = React.useState<string | null>(null);
|
||||
const ensuredIgnoreDirectories = React.useRef<Set<string>>(new Set());
|
||||
@@ -131,6 +135,9 @@ export const SessionDialogs: React.FC = () => {
|
||||
const [deleteDialogSummaries, setDeleteDialogSummaries] = React.useState<Array<{ session: Session; metadata: WorktreeMetadata }>>([]);
|
||||
const [deleteDialogShouldRemoveRemote, setDeleteDialogShouldRemoveRemote] = React.useState(false);
|
||||
const [isProcessingDelete, setIsProcessingDelete] = React.useState(false);
|
||||
const [isSetupCommandsOpen, setIsSetupCommandsOpen] = React.useState(false);
|
||||
const [setupCommands, setSetupCommands] = React.useState<string[]>([]);
|
||||
const [isLoadingSetupCommands, setIsLoadingSetupCommands] = React.useState(false);
|
||||
|
||||
const {
|
||||
sessions,
|
||||
@@ -166,12 +173,6 @@ export const SessionDialogs: React.FC = () => {
|
||||
[existingWorktreeBranch, sanitizedNewBranchName, worktreeCreateMode],
|
||||
);
|
||||
const sanitizedWorktreeSlug = React.useMemo(() => sanitizeWorktreeSlug(worktreeTargetBranch), [worktreeTargetBranch]);
|
||||
const worktreePreviewPath = React.useMemo(() => {
|
||||
if (!projectDirectory || !sanitizedWorktreeSlug) {
|
||||
return '';
|
||||
}
|
||||
return joinWorktreePath(projectDirectory, sanitizedWorktreeSlug);
|
||||
}, [projectDirectory, sanitizedWorktreeSlug]);
|
||||
const isGitRepo = isGitRepository === true;
|
||||
const selectedWorktreeBaseLabel = React.useMemo(() => {
|
||||
const match = availableWorktreeBaseBranches.find((option) => option.value === worktreeBaseBranch);
|
||||
@@ -307,6 +308,8 @@ export const SessionDialogs: React.FC = () => {
|
||||
setIsCheckingGitRepository(false);
|
||||
setIsGitRepository(null);
|
||||
setIsCreatingWorktree(false);
|
||||
setSetupCommands([]);
|
||||
setIsSetupCommandsOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -357,6 +360,9 @@ export const SessionDialogs: React.FC = () => {
|
||||
: 'Current (HEAD)';
|
||||
worktreeBaseOptions.push({ value: 'HEAD', label: headLabel, group: 'special' });
|
||||
|
||||
// Store the main worktree's current branch for exclusion
|
||||
setMainWorktreeBranch(branches?.current ?? null);
|
||||
|
||||
if (branches) {
|
||||
const localBranches = branches.all
|
||||
.filter((name) => !name.startsWith('remotes/'))
|
||||
@@ -416,6 +422,35 @@ export const SessionDialogs: React.FC = () => {
|
||||
};
|
||||
}, [isSessionCreateDialogOpen, projectDirectory]);
|
||||
|
||||
// Load setup commands when dialog opens
|
||||
React.useEffect(() => {
|
||||
if (!isSessionCreateDialogOpen || !projectDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoadingSetupCommands(true);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const commands = await getWorktreeSetupCommands(projectDirectory);
|
||||
if (!cancelled) {
|
||||
setSetupCommands(commands);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors, just start with empty commands
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoadingSetupCommands(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isSessionCreateDialogOpen, projectDirectory]);
|
||||
|
||||
const openDeleteDialog = React.useCallback((payload: { sessions: Session[]; dateLabel?: string; mode?: 'session' | 'worktree'; worktree?: WorktreeMetadata | null }) => {
|
||||
setDeleteDialog({
|
||||
sessions: payload.sessions,
|
||||
@@ -539,6 +574,52 @@ export const SessionDialogs: React.FC = () => {
|
||||
} catch { /* ignored */ }
|
||||
}, [projectDirectory, isGitRepository]);
|
||||
|
||||
// Branches already used in worktrees (cannot be reused for existing branch selection)
|
||||
// Includes both .openchamber worktrees and the main workspace's current branch
|
||||
const branchesInWorktrees = React.useMemo(() => {
|
||||
const branches = availableWorktrees
|
||||
.map((wt) => wt.branch?.replace(/^refs\/heads\//, '') || wt.label)
|
||||
.filter(Boolean);
|
||||
// Also exclude the main worktree's current branch
|
||||
if (mainWorktreeBranch) {
|
||||
branches.push(mainWorktreeBranch);
|
||||
}
|
||||
return new Set(branches);
|
||||
}, [availableWorktrees, mainWorktreeBranch]);
|
||||
|
||||
// Available branches for existing branch selection (local + remote, excluding those already in worktrees)
|
||||
const availableExistingBranches = React.useMemo(() =>
|
||||
availableWorktreeBaseBranches.filter((option) => {
|
||||
if (option.group !== 'local' && option.group !== 'remote') return false;
|
||||
|
||||
// For local branches, check direct match
|
||||
if (option.group === 'local') {
|
||||
return !branchesInWorktrees.has(option.value);
|
||||
}
|
||||
|
||||
// For remote branches (e.g., "origin/main"), extract the branch name after the remote prefix
|
||||
// and check if that local branch is already in a worktree
|
||||
const remoteMatch = option.value.match(/^[^/]+\/(.+)$/);
|
||||
const localBranchName = remoteMatch ? remoteMatch[1] : option.value;
|
||||
return !branchesInWorktrees.has(localBranchName);
|
||||
}),
|
||||
[availableWorktreeBaseBranches, branchesInWorktrees]
|
||||
);
|
||||
|
||||
// Auto-select first available branch if current selection is not available
|
||||
React.useEffect(() => {
|
||||
if (availableExistingBranches.length === 0) {
|
||||
setExistingWorktreeBranch('');
|
||||
return;
|
||||
}
|
||||
const isCurrentSelectionAvailable = availableExistingBranches.some(
|
||||
(option) => option.value === existingWorktreeBranch
|
||||
);
|
||||
if (!isCurrentSelectionAvailable) {
|
||||
setExistingWorktreeBranch(availableExistingBranches[0].value);
|
||||
}
|
||||
}, [availableExistingBranches, existingWorktreeBranch]);
|
||||
|
||||
const prevDeleteDialogRef = React.useRef<DeleteDialogState | null>(null);
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -633,10 +714,56 @@ export const SessionDialogs: React.FC = () => {
|
||||
// Ensure directory-scoped caches and session lists include the new worktree.
|
||||
setDirectory(metadata.path, { showOverlay: false });
|
||||
|
||||
// Refresh sessions list so sidebar shows the new session immediately
|
||||
try {
|
||||
await loadSessions();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
await refreshWorktrees();
|
||||
setBranchName('');
|
||||
setExistingWorktreeBranch('');
|
||||
toast.success('Worktree created');
|
||||
|
||||
// Save setup commands if any were configured
|
||||
const commandsToRun = setupCommands.filter(cmd => cmd.trim().length > 0);
|
||||
if (commandsToRun.length > 0) {
|
||||
// Save commands to config (fire and forget)
|
||||
saveWorktreeSetupCommands(projectDirectory, commandsToRun).catch(() => {
|
||||
console.warn('Failed to save worktree setup commands');
|
||||
});
|
||||
|
||||
// Run setup commands in background (non-blocking)
|
||||
toast.success('Worktree created', {
|
||||
description: renderToastDescription(`Running ${commandsToRun.length} setup command${commandsToRun.length === 1 ? '' : 's'}...`),
|
||||
});
|
||||
|
||||
runWorktreeSetupCommands(metadata.path, projectDirectory, commandsToRun).then((result) => {
|
||||
if (result.success) {
|
||||
toast.success('Setup commands completed', {
|
||||
description: renderToastDescription(`All ${result.results.length} command${result.results.length === 1 ? '' : 's'} succeeded.`),
|
||||
});
|
||||
} else {
|
||||
const failed = result.results.filter(r => !r.success);
|
||||
const succeeded = result.results.filter(r => r.success);
|
||||
toast.error('Setup commands failed', {
|
||||
description: renderToastDescription(
|
||||
`${failed.length} of ${result.results.length} command${result.results.length === 1 ? '' : 's'} failed.` +
|
||||
(succeeded.length > 0 ? ` ${succeeded.length} succeeded.` : '')
|
||||
),
|
||||
});
|
||||
}
|
||||
}).catch(() => {
|
||||
toast.error('Setup commands failed', {
|
||||
description: renderToastDescription('Could not execute setup commands.'),
|
||||
});
|
||||
});
|
||||
} else {
|
||||
toast.success('Worktree created');
|
||||
}
|
||||
|
||||
// Close dialog after successful creation
|
||||
setSessionCreateDialogOpen(false);
|
||||
} catch (error) {
|
||||
if (cleanupMetadata) {
|
||||
await removeWorktree({ projectDirectory, path: cleanupMetadata.path, force: true }).catch(() => undefined);
|
||||
@@ -752,42 +879,115 @@ export const SessionDialogs: React.FC = () => {
|
||||
}
|
||||
}, [deleteDialog, deleteDialogShouldRemoveRemote, deleteSession, deleteSessions, closeDeleteDialog, shouldArchiveWorktree, isWorktreeDelete, canRemoveRemoteBranches, projectDirectory, loadSessions]);
|
||||
|
||||
// Special value for "New branch" option in the unified branch selector
|
||||
const NEW_BRANCH_VALUE = '__new_branch__';
|
||||
|
||||
const worktreeManagerBody = (
|
||||
<div className="space-y-4 w-full min-w-0">
|
||||
{}
|
||||
<div className="space-y-3 rounded-xl border border-border/40 bg-sidebar/60 p-3">
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-5 w-full min-w-0">
|
||||
{/* Create worktree section */}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<p className="typography-ui-label font-medium text-foreground">Create worktree</p>
|
||||
<p className="typography-meta text-muted-foreground/80">
|
||||
Branch-specific directory under <code className="font-mono text-xs text-muted-foreground">{WORKTREE_ROOT}</code>.
|
||||
<p className="typography-micro text-muted-foreground/70">
|
||||
Branch-specific directory under <code className="font-mono text-xs">{WORKTREE_ROOT}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<AnimatedTabs
|
||||
tabs={[
|
||||
{ value: 'new', label: 'New branch' },
|
||||
{ value: 'existing', label: 'Existing branch' },
|
||||
]}
|
||||
value={worktreeCreateMode}
|
||||
onValueChange={(value) => {
|
||||
setWorktreeCreateMode(value);
|
||||
setWorktreeError(null);
|
||||
|
||||
if (value === 'existing' && !existingWorktreeBranch) {
|
||||
const firstLocal = availableWorktreeBaseBranches.find((option) => option.group === 'local')?.value ?? '';
|
||||
if (firstLocal) {
|
||||
setExistingWorktreeBranch(firstLocal);
|
||||
<label className="typography-meta font-medium text-foreground" htmlFor="worktree-branch-select">
|
||||
Branch
|
||||
</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Select
|
||||
value={worktreeCreateMode === 'new' ? NEW_BRANCH_VALUE : existingWorktreeBranch}
|
||||
onValueChange={(value) => {
|
||||
setWorktreeError(null);
|
||||
if (value === NEW_BRANCH_VALUE) {
|
||||
setWorktreeCreateMode('new');
|
||||
} else {
|
||||
setWorktreeCreateMode('existing');
|
||||
setExistingWorktreeBranch(value);
|
||||
}
|
||||
}
|
||||
}}
|
||||
animate={false}
|
||||
/>
|
||||
}}
|
||||
disabled={!isGitRepo || isCheckingGitRepository || isLoadingWorktreeBaseBranches}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="worktree-branch-select"
|
||||
size="lg"
|
||||
className={cn(
|
||||
'typography-meta text-foreground',
|
||||
worktreeCreateMode === 'new' ? 'w-auto' : 'w-auto max-w-full'
|
||||
)}
|
||||
>
|
||||
<SelectValue placeholder={isLoadingWorktreeBaseBranches ? 'Loading branches…' : 'Select a branch'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value={NEW_BRANCH_VALUE}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
New branch
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
|
||||
{worktreeCreateMode === 'new' ? (
|
||||
{availableExistingBranches.some((option) => option.group === 'local') && (
|
||||
<>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>Local branches</SelectLabel>
|
||||
{availableExistingBranches
|
||||
.filter((option) => option.group === 'local')
|
||||
.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
{availableExistingBranches.some((option) => option.group === 'remote') && (
|
||||
<>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>Remote branches</SelectLabel>
|
||||
{availableExistingBranches
|
||||
.filter((option) => option.group === 'remote')
|
||||
.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Branch name input - inline when "New branch" is selected */}
|
||||
{worktreeCreateMode === 'new' && (
|
||||
<Input
|
||||
id="worktree-branch-input"
|
||||
value={branchName}
|
||||
onChange={(e) => handleBranchInputChange(e.target.value)}
|
||||
placeholder="feature/new-branch"
|
||||
className="h-8 flex-1 min-w-0 typography-meta text-foreground placeholder:text-muted-foreground/70"
|
||||
disabled={!isGitRepo || isCheckingGitRepository}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !isCreatingWorktree) {
|
||||
handleCreateWorktree();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Base branch selector - only shown when "New branch" is selected */}
|
||||
{worktreeCreateMode === 'new' && (
|
||||
<>
|
||||
<label className="typography-meta font-medium text-foreground" htmlFor="worktree-base-branch-select">
|
||||
Base branch
|
||||
From
|
||||
</label>
|
||||
<Select
|
||||
value={worktreeBaseBranch}
|
||||
@@ -797,7 +997,7 @@ export const SessionDialogs: React.FC = () => {
|
||||
<SelectTrigger
|
||||
id="worktree-base-branch-select"
|
||||
size="lg"
|
||||
className="w-full typography-meta text-foreground"
|
||||
className="w-auto max-w-full typography-meta text-foreground"
|
||||
>
|
||||
<SelectValue placeholder={isLoadingWorktreeBaseBranches ? 'Loading branches…' : 'Select a branch'} />
|
||||
</SelectTrigger>
|
||||
@@ -846,113 +1046,36 @@ export const SessionDialogs: React.FC = () => {
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<label className="typography-meta font-medium text-foreground" htmlFor="worktree-branch-input">
|
||||
New branch name
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="worktree-branch-input"
|
||||
value={branchName}
|
||||
onChange={(e) => handleBranchInputChange(e.target.value)}
|
||||
placeholder="feature/new-branch"
|
||||
className="h-8 flex-1 typography-meta text-foreground placeholder:text-muted-foreground/70"
|
||||
disabled={!isGitRepo || isCheckingGitRepository}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !isCreatingWorktree) {
|
||||
handleCreateWorktree();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleCreateWorktree}
|
||||
disabled={isCreatingWorktree || isLoading || !isGitRepo || !worktreeTargetBranch}
|
||||
className="h-8"
|
||||
>
|
||||
{isCreatingWorktree ? 'Creating…' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className="typography-meta font-medium text-foreground" htmlFor="worktree-existing-branch-select">
|
||||
Existing branch
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={existingWorktreeBranch}
|
||||
onValueChange={(value) => {
|
||||
setExistingWorktreeBranch(value);
|
||||
setWorktreeError(null);
|
||||
}}
|
||||
disabled={
|
||||
!isGitRepo
|
||||
|| isCheckingGitRepository
|
||||
|| isLoadingWorktreeBaseBranches
|
||||
|| !availableWorktreeBaseBranches.some((option) => option.group === 'local')
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="worktree-existing-branch-select"
|
||||
size="lg"
|
||||
className="flex-1 typography-meta text-foreground"
|
||||
>
|
||||
<SelectValue placeholder={isLoadingWorktreeBaseBranches ? 'Loading branches…' : 'Select a branch'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Local branches</SelectLabel>
|
||||
{availableWorktreeBaseBranches
|
||||
.filter((option) => option.group === 'local')
|
||||
.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
onClick={handleCreateWorktree}
|
||||
disabled={isCreatingWorktree || isLoading || !isGitRepo || !worktreeTargetBranch}
|
||||
className="h-8"
|
||||
>
|
||||
{isCreatingWorktree ? 'Creating…' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
{!isLoadingWorktreeBaseBranches && !availableWorktreeBaseBranches.some((option) => option.group === 'local') ? (
|
||||
<p className="typography-micro text-muted-foreground/70">
|
||||
No local branches found. Fetch or create a branch first.
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Preview info */}
|
||||
{worktreeTargetBranch ? (
|
||||
<p className="typography-micro text-muted-foreground/70">
|
||||
{worktreeCreateMode === 'existing' ? (
|
||||
<>
|
||||
Uses branch{' '}
|
||||
<code className="font-mono text-xs text-muted-foreground">{worktreeTargetBranch}</code>
|
||||
{' '}at{' '}
|
||||
<code className="font-mono text-xs text-muted-foreground break-all">
|
||||
{formatPathForDisplay(worktreePreviewPath, homeDirectory)}
|
||||
</code>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Creates branch{' '}
|
||||
Creates{' '}
|
||||
<code className="font-mono text-xs text-muted-foreground">{worktreeTargetBranch}</code>
|
||||
{' '}from{' '}
|
||||
<code className="font-mono text-xs text-muted-foreground">{selectedWorktreeBaseLabel}</code>
|
||||
{' '}at{' '}
|
||||
<code className="font-mono text-xs text-muted-foreground break-all">
|
||||
{formatPathForDisplay(worktreePreviewPath, homeDirectory)}
|
||||
</code>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Create button */}
|
||||
<Button
|
||||
onClick={handleCreateWorktree}
|
||||
disabled={isCreatingWorktree || isLoading || !isGitRepo || !worktreeTargetBranch}
|
||||
className="h-8"
|
||||
>
|
||||
{isCreatingWorktree ? 'Creating…' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{worktreeError && <p className="typography-meta text-destructive">{worktreeError}</p>}
|
||||
@@ -963,50 +1086,100 @@ export const SessionDialogs: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="space-y-3 rounded-xl border border-border/40 bg-sidebar/60 p-3 overflow-hidden min-w-0">
|
||||
<div className="space-y-1">
|
||||
<p className="typography-ui-label font-medium text-foreground">Existing worktrees</p>
|
||||
</div>
|
||||
{/* Setup commands section */}
|
||||
<Collapsible open={isSetupCommandsOpen} onOpenChange={setIsSetupCommandsOpen}>
|
||||
<CollapsibleTrigger className="w-full flex items-center justify-between py-1 hover:opacity-80 transition-opacity">
|
||||
<p className="typography-ui-label font-medium text-foreground">
|
||||
Setup commands
|
||||
{setupCommands.filter(cmd => cmd.trim()).length > 0 && (
|
||||
<span className="font-normal text-muted-foreground/70">
|
||||
{' '}({setupCommands.filter(cmd => cmd.trim()).length} configured)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<RiArrowDownSLine className={cn(
|
||||
'h-4 w-4 text-muted-foreground transition-transform duration-200',
|
||||
isSetupCommandsOpen && 'rotate-180'
|
||||
)} />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="pt-2 space-y-2">
|
||||
<p className="typography-micro text-muted-foreground/70">
|
||||
Commands run in the new worktree. Use <code className="font-mono text-xs">$ROOT_WORKTREE_PATH</code> for project root.
|
||||
</p>
|
||||
{isLoadingSetupCommands ? (
|
||||
<p className="typography-meta text-muted-foreground/70">Loading...</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{setupCommands.map((command, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
value={command}
|
||||
onChange={(e) => {
|
||||
const newCommands = [...setupCommands];
|
||||
newCommands[index] = e.target.value;
|
||||
setSetupCommands(newCommands);
|
||||
}}
|
||||
placeholder="e.g., bun install"
|
||||
className="h-8 flex-1 font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newCommands = setupCommands.filter((_, i) => i !== index);
|
||||
setSetupCommands(newCommands);
|
||||
}}
|
||||
className="flex-shrink-0 flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label="Remove command"
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSetupCommands([...setupCommands, ''])}
|
||||
className="flex items-center gap-1.5 typography-meta text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
Add command
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Existing worktrees section */}
|
||||
<div className="space-y-2 min-w-0">
|
||||
<p className="typography-ui-label font-medium text-foreground">Existing worktrees</p>
|
||||
|
||||
{isLoadingWorktrees ? (
|
||||
<p className="typography-meta text-muted-foreground/70">Loading worktrees…</p>
|
||||
) : availableWorktrees.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground/70">
|
||||
No worktrees found under <code className="font-mono text-xs text-muted-foreground">{WORKTREE_ROOT}</code>.
|
||||
No worktrees found under <code className="font-mono text-xs">{WORKTREE_ROOT}</code>
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1.5 min-w-0">
|
||||
{availableWorktrees.map((worktree) => {
|
||||
|
||||
const relativePath = worktree.relativePath
|
||||
|| (worktree.path.startsWith(projectDirectory + '/')
|
||||
? worktree.path.slice(projectDirectory.length + 1)
|
||||
: worktree.path);
|
||||
return (
|
||||
<div
|
||||
key={worktree.path}
|
||||
className="flex items-center gap-2 rounded-lg border border-border/30 bg-sidebar-accent/20 px-3 py-2 min-w-0"
|
||||
<div className="space-y-0.5 min-w-0">
|
||||
{availableWorktrees.map((worktree) => (
|
||||
<div
|
||||
key={worktree.path}
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1 hover:bg-sidebar-accent/30 transition-colors min-w-0 group"
|
||||
>
|
||||
<p className="flex-1 typography-meta text-foreground truncate min-w-0">
|
||||
{worktree.label || worktree.branch || 'Detached HEAD'}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteWorktree(worktree)}
|
||||
className="flex-shrink-0 flex h-6 w-6 items-center justify-center rounded text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10 opacity-0 group-hover:opacity-100 transition-opacity focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={`Delete worktree ${worktree.branch || worktree.label}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="typography-meta font-medium text-foreground">
|
||||
{worktree.label || worktree.branch || 'Detached HEAD'}
|
||||
</p>
|
||||
<p className="typography-micro text-muted-foreground/70 break-all">
|
||||
{relativePath}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteWorktree(worktree)}
|
||||
className="flex-shrink-0 flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={`Delete worktree ${worktree.branch || worktree.label}`}
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1153,7 +1326,7 @@ export const SessionDialogs: React.FC = () => {
|
||||
<DialogTitle>Worktree Manager</DialogTitle>
|
||||
</DialogHeader>
|
||||
{worktreeManagerBody}
|
||||
<DialogFooter className="mt-2 gap-2 pt-1 pb-1">{worktreeManagerActions}</DialogFooter>
|
||||
<DialogFooter className="gap-2 pt-1 pb-1">{worktreeManagerActions}</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
@@ -24,15 +24,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||
import {
|
||||
@@ -347,10 +339,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const safeStorage = React.useMemo(() => getSafeStorage(), []);
|
||||
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(new Set());
|
||||
const [collapsedProjects, setCollapsedProjects] = React.useState<Set<string>>(new Set());
|
||||
const [pendingProjectClose, setPendingProjectClose] = React.useState<{
|
||||
id: string;
|
||||
label: string;
|
||||
} | null>(null);
|
||||
|
||||
const [projectRepoStatus, setProjectRepoStatus] = React.useState<Map<string, boolean | null>>(new Map());
|
||||
const [expandedSessionGroups, setExpandedSessionGroups] = React.useState<Set<string>>(new Set());
|
||||
const [hoveredGroupId, setHoveredGroupId] = React.useState<string | null>(null);
|
||||
@@ -783,21 +772,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
}
|
||||
}, [addProject, isDesktopRuntime]);
|
||||
|
||||
const confirmPendingProjectClose = React.useCallback(() => {
|
||||
const pending = pendingProjectClose;
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
|
||||
removeProject(pending.id);
|
||||
setPendingProjectClose(null);
|
||||
toast.success('Project closed', { description: pending.label });
|
||||
}, [pendingProjectClose, removeProject]);
|
||||
|
||||
const cancelPendingProjectClose = React.useCallback(() => {
|
||||
setPendingProjectClose(null);
|
||||
}, []);
|
||||
|
||||
const toggleParent = React.useCallback((sessionId: string) => {
|
||||
setExpandedParents((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -1637,7 +1611,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
}
|
||||
openMultiRunLauncher();
|
||||
}}
|
||||
onClose={() => setPendingProjectClose({ id: projectKey, label: projectLabel })}
|
||||
onClose={() => removeProject(projectKey)}
|
||||
sentinelRef={(el) => { projectHeaderSentinelRefs.current.set(projectKey, el); }}
|
||||
>
|
||||
{!isCollapsed ? (
|
||||
@@ -1731,36 +1705,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
|
||||
<Dialog
|
||||
open={pendingProjectClose !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPendingProjectClose(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Close project?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This removes it from the sidebar. You can add it again later.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="typography-ui font-medium">
|
||||
{pendingProjectClose?.label}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button type="button" variant="secondary" onClick={cancelPendingProjectClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" variant="destructive" onClick={confirmPendingProjectClose}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
RiAddCircleLine,
|
||||
RiAddLine,
|
||||
RiArrowDownSLine,
|
||||
RiCloseLine,
|
||||
RiFileImageLine,
|
||||
RiFileLine,
|
||||
@@ -12,11 +14,14 @@ import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { ModelMultiSelect, generateInstanceId, type ModelSelectionWithId } from '@/components/multirun/ModelMultiSelect';
|
||||
import { BranchSelector, useBranchOptions } from '@/components/multirun/BranchSelector';
|
||||
import { AgentSelector } from '@/components/multirun/AgentSelector';
|
||||
import { isIMECompositionEvent } from '@/lib/ime';
|
||||
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||||
import type { CreateMultiRunParams, MultiRunFileAttachment } from '@/types/multirun';
|
||||
|
||||
/** Max file size in bytes (10MB) */
|
||||
@@ -53,12 +58,82 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
const [baseBranch, setBaseBranch] = React.useState('HEAD');
|
||||
const [attachedFiles, setAttachedFiles] = React.useState<AttachedFile[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
const [setupCommands, setSetupCommands] = React.useState<string[]>([]);
|
||||
const [isSetupCommandsOpen, setIsSetupCommandsOpen] = React.useState(false);
|
||||
const [isLoadingSetupCommands, setIsLoadingSetupCommands] = React.useState(false);
|
||||
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory ?? null);
|
||||
const { isGitRepository, isLoading: isLoadingBranches } = useBranchOptions(currentDirectory);
|
||||
|
||||
const vscodeWorkspaceFolder = React.useMemo(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const folder = (window as unknown as { __VSCODE_CONFIG__?: { workspaceFolder?: unknown } }).__VSCODE_CONFIG__?.workspaceFolder;
|
||||
return typeof folder === 'string' && folder.trim().length > 0 ? folder.trim() : null;
|
||||
}, []);
|
||||
|
||||
const isVSCodeRuntime = React.useMemo(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
const apis = (window as unknown as { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { isVSCode?: boolean } } }).__OPENCHAMBER_RUNTIME_APIS__;
|
||||
return Boolean(apis?.runtime?.isVSCode);
|
||||
}, []);
|
||||
|
||||
// Get project directory for setup commands
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const projectDirectory = React.useMemo(() => {
|
||||
// VS Code panel should always use the current workspace root.
|
||||
if (isVSCodeRuntime && vscodeWorkspaceFolder) {
|
||||
return vscodeWorkspaceFolder;
|
||||
}
|
||||
|
||||
if (activeProjectId) {
|
||||
const project = projects.find((p) => p.id === activeProjectId);
|
||||
if (project?.path) return project.path;
|
||||
}
|
||||
|
||||
const base = currentDirectory ?? vscodeWorkspaceFolder;
|
||||
if (!base) return null;
|
||||
|
||||
|
||||
const normalized = base.replace(/\\/g, '/').replace(/\/+$/, '') || base;
|
||||
const marker = '/.openchamber/';
|
||||
const markerIndex = normalized.indexOf(marker);
|
||||
if (markerIndex > 0) return normalized.slice(0, markerIndex);
|
||||
if (normalized.endsWith('/.openchamber')) return normalized.slice(0, normalized.length - '/.openchamber'.length);
|
||||
return normalized;
|
||||
}, [activeProjectId, projects, currentDirectory, vscodeWorkspaceFolder]);
|
||||
|
||||
// Load setup commands from config
|
||||
React.useEffect(() => {
|
||||
if (!projectDirectory) return;
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoadingSetupCommands(true);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const commands = await getWorktreeSetupCommands(projectDirectory);
|
||||
if (!cancelled) {
|
||||
setSetupCommands(commands);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors, start with empty commands
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoadingSetupCommands(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [projectDirectory]);
|
||||
|
||||
const handleAddModel = React.useCallback((model: ModelSelectionWithId) => {
|
||||
if (selectedModels.length >= MAX_MODELS) {
|
||||
@@ -153,6 +228,9 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
// Filter setup commands
|
||||
const commandsToRun = setupCommands.filter(cmd => cmd.trim().length > 0);
|
||||
|
||||
await onCreateGroup?.({
|
||||
name: groupName.trim(),
|
||||
prompt: prompt.trim(),
|
||||
@@ -160,6 +238,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
agent: selectedAgent || undefined,
|
||||
worktreeBaseBranch: baseBranch,
|
||||
files,
|
||||
setupCommands: commandsToRun.length > 0 ? commandsToRun : undefined,
|
||||
});
|
||||
|
||||
// Reset form on success - only after onCreateGroup completes
|
||||
@@ -228,6 +307,70 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Setup commands collapsible */}
|
||||
<Collapsible open={isSetupCommandsOpen} onOpenChange={setIsSetupCommandsOpen}>
|
||||
<CollapsibleTrigger className="w-full flex items-center justify-between py-1 hover:opacity-80 transition-opacity">
|
||||
<p className="typography-ui-label font-medium text-foreground">
|
||||
Setup commands
|
||||
{setupCommands.filter(cmd => cmd.trim()).length > 0 && (
|
||||
<span className="font-normal text-muted-foreground/70">
|
||||
{' '}({setupCommands.filter(cmd => cmd.trim()).length} configured)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<RiArrowDownSLine className={cn(
|
||||
'h-4 w-4 text-muted-foreground transition-transform duration-200',
|
||||
isSetupCommandsOpen && 'rotate-180'
|
||||
)} />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="pt-2 space-y-2">
|
||||
<p className="typography-micro text-muted-foreground/70">
|
||||
Commands run in each new worktree. Use <code className="font-mono text-xs">$ROOT_WORKTREE_PATH</code> for project root.
|
||||
</p>
|
||||
{isLoadingSetupCommands ? (
|
||||
<p className="typography-meta text-muted-foreground/70">Loading...</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{setupCommands.map((command, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
value={command}
|
||||
onChange={(e) => {
|
||||
const newCommands = [...setupCommands];
|
||||
newCommands[index] = e.target.value;
|
||||
setSetupCommands(newCommands);
|
||||
}}
|
||||
placeholder="e.g., bun install"
|
||||
className="h-8 flex-1 font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newCommands = setupCommands.filter((_, i) => i !== index);
|
||||
setSetupCommands(newCommands);
|
||||
}}
|
||||
className="flex-shrink-0 flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label="Remove command"
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSetupCommands([...setupCommands, ''])}
|
||||
className="flex items-center gap-1.5 typography-meta text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
Add command
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Agent Selection */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="typography-ui-label font-medium text-foreground">
|
||||
|
||||
@@ -316,10 +316,22 @@ export interface FileSearchResult {
|
||||
preview?: string[];
|
||||
}
|
||||
|
||||
export interface CommandExecResult {
|
||||
command: string;
|
||||
success: boolean;
|
||||
exitCode?: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface FilesAPI {
|
||||
listDirectory(path: string): Promise<DirectoryListResult>;
|
||||
search(payload: FileSearchQuery): Promise<FileSearchResult[]>;
|
||||
createDirectory(path: string): Promise<{ success: boolean; path: string }>;
|
||||
readFile?(path: string): Promise<{ content: string; path: string }>;
|
||||
writeFile?(path: string, content: string): Promise<{ success: boolean; path: string }>;
|
||||
execCommands?(commands: string[], cwd: string): Promise<{ success: boolean; results: CommandExecResult[] }>;
|
||||
}
|
||||
|
||||
export interface ProjectEntry {
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import { addGitWorktree, deleteGitBranch, deleteRemoteBranch, getGitStatus, listGitWorktrees, removeGitWorktree, type GitAddWorktreePayload, type GitWorktreeInfo } from '@/lib/gitApi';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import type { FilesAPI, RuntimeAPIs } from '@/lib/api/types';
|
||||
import { getWorktreeSetupCommands, substituteCommandVariables } from '@/lib/openchamberConfig';
|
||||
|
||||
const WORKTREE_ROOT = '.openchamber';
|
||||
const DEFAULT_BASE_URL = import.meta.env.VITE_OPENCODE_URL || '/api';
|
||||
|
||||
/**
|
||||
* Get the runtime Files API if available (Desktop/VSCode).
|
||||
*/
|
||||
function getRuntimeFilesAPI(): FilesAPI | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const apis = (window as typeof window & { __OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs }).__OPENCHAMBER_RUNTIME_APIS__;
|
||||
if (apis?.files) {
|
||||
return apis.files;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalize = (value: string): string => {
|
||||
if (!value) {
|
||||
@@ -167,3 +182,164 @@ export function mapWorktreeToMetadata(projectDirectory: string, info: GitWorktre
|
||||
: normalizedPath,
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorktreeSetupResult {
|
||||
success: boolean;
|
||||
results: Array<{
|
||||
command: string;
|
||||
success: boolean;
|
||||
exitCode?: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run worktree setup commands in the background.
|
||||
* This does not block - it returns a promise that resolves when all commands complete.
|
||||
*
|
||||
* @param worktreePath - The path to the new worktree where commands will run
|
||||
* @param projectDirectory - The root project directory (for $ROOT_WORKTREE_PATH substitution)
|
||||
* @param commands - Optional commands to run. If not provided, reads from config.
|
||||
* @returns Promise resolving to setup results
|
||||
*/
|
||||
export async function runWorktreeSetupCommands(
|
||||
worktreePath: string,
|
||||
projectDirectory: string,
|
||||
commands?: string[]
|
||||
): Promise<WorktreeSetupResult> {
|
||||
const commandsToRun = commands ?? await getWorktreeSetupCommands(projectDirectory);
|
||||
|
||||
if (commandsToRun.length === 0) {
|
||||
return { success: true, results: [] };
|
||||
}
|
||||
|
||||
// Substitute variables in commands
|
||||
const substitutedCommands = commandsToRun.map(cmd =>
|
||||
substituteCommandVariables(cmd, { rootWorktreePath: projectDirectory })
|
||||
);
|
||||
|
||||
console.log('[worktreeService] Running setup commands:', { worktreePath, projectDirectory, commands: substitutedCommands });
|
||||
|
||||
try {
|
||||
// Try runtime API first (Desktop/VSCode)
|
||||
const runtimeFiles = getRuntimeFilesAPI();
|
||||
if (runtimeFiles?.execCommands) {
|
||||
console.log('[worktreeService] Using runtime API for exec');
|
||||
try {
|
||||
// Don't use background mode - we want actual results for toast notifications
|
||||
// The bridge now uses async exec (not execSync) so it won't block other operations
|
||||
const result = await runtimeFiles.execCommands(substitutedCommands, worktreePath);
|
||||
console.log('[worktreeService] Runtime exec result:', result);
|
||||
return result as WorktreeSetupResult;
|
||||
} catch (error) {
|
||||
console.error('[worktreeService] Runtime exec error:', error);
|
||||
return {
|
||||
success: false,
|
||||
results: substitutedCommands.map(cmd => ({
|
||||
command: cmd,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to web API
|
||||
console.log('[worktreeService] Using web API for exec');
|
||||
|
||||
const startResponse = await fetch(`${DEFAULT_BASE_URL}/fs/exec`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
// Use background job so we don't hold long-lived HTTP connections.
|
||||
body: JSON.stringify({
|
||||
commands: substitutedCommands,
|
||||
cwd: worktreePath,
|
||||
background: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const startPayload = await startResponse.json().catch(() => null);
|
||||
|
||||
if (startResponse.status === 202 && startPayload && typeof startPayload.jobId === 'string') {
|
||||
const jobId = startPayload.jobId as string;
|
||||
const pollIntervalMs = 800;
|
||||
const timeoutMs = Math.max(5 * 60_000, substitutedCommands.length * 60_000);
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||
|
||||
const pollResponse = await fetch(`${DEFAULT_BASE_URL}/fs/exec/${jobId}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
const pollPayload = await pollResponse.json().catch(() => null);
|
||||
if (!pollResponse.ok) {
|
||||
return {
|
||||
success: false,
|
||||
results: substitutedCommands.map((cmd) => ({
|
||||
command: cmd,
|
||||
success: false,
|
||||
error: (pollPayload && pollPayload.error) || 'Failed to poll exec job',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const status = pollPayload?.status;
|
||||
if (status === 'done') {
|
||||
const results = Array.isArray(pollPayload?.results) ? pollPayload.results : [];
|
||||
const success = pollPayload?.success === true;
|
||||
return { success, results } as WorktreeSetupResult;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
results: substitutedCommands.map((cmd) => ({
|
||||
command: cmd,
|
||||
success: false,
|
||||
error: 'Setup commands timed out',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
if (!startResponse.ok) {
|
||||
const error = (startPayload && startPayload.error) || 'Request failed';
|
||||
console.error('[worktreeService] Web exec failed:', startPayload);
|
||||
return {
|
||||
success: false,
|
||||
results: substitutedCommands.map((cmd) => ({
|
||||
command: cmd,
|
||||
success: false,
|
||||
error,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Back-compat: older servers may still return results synchronously.
|
||||
console.log('[worktreeService] Web exec result:', startPayload);
|
||||
return startPayload as WorktreeSetupResult;
|
||||
} catch (error) {
|
||||
console.error('[worktreeService] Exec exception:', error);
|
||||
return {
|
||||
success: false,
|
||||
results: substitutedCommands.map(cmd => ({
|
||||
command: cmd,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if worktree setup commands are configured for a project.
|
||||
*/
|
||||
export async function hasWorktreeSetupCommands(projectDirectory: string): Promise<boolean> {
|
||||
const commands = await getWorktreeSetupCommands(projectDirectory);
|
||||
return commands.length > 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* OpenChamber project-level configuration service.
|
||||
* Manages .openchamber/openchamber.json file for project-specific settings.
|
||||
*/
|
||||
|
||||
import { opencodeClient } from './opencode/client';
|
||||
import type { FilesAPI, RuntimeAPIs } from './api/types';
|
||||
|
||||
const CONFIG_FILENAME = 'openchamber.json';
|
||||
const CONFIG_DIR = '.openchamber';
|
||||
|
||||
/**
|
||||
* Get the runtime Files API if available (Desktop/VSCode).
|
||||
*/
|
||||
function getRuntimeFilesAPI(): FilesAPI | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const apis = (window as typeof window & { __OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs }).__OPENCHAMBER_RUNTIME_APIS__;
|
||||
if (apis?.files) {
|
||||
return apis.files;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface OpenChamberConfig {
|
||||
'setup-worktree'?: string[];
|
||||
}
|
||||
|
||||
const normalize = (value: string): string => {
|
||||
if (!value) return '';
|
||||
const replaced = value.replace(/\\/g, '/');
|
||||
return replaced === '/' ? '/' : replaced.replace(/\/+$/, '');
|
||||
};
|
||||
|
||||
const joinPath = (base: string, segment: string): string => {
|
||||
const normalizedBase = normalize(base);
|
||||
const cleanSegment = segment.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
if (!normalizedBase || normalizedBase === '/') {
|
||||
return `/${cleanSegment}`;
|
||||
}
|
||||
return `${normalizedBase}/${cleanSegment}`;
|
||||
};
|
||||
|
||||
const getConfigPath = (projectDirectory: string): string => {
|
||||
return joinPath(joinPath(projectDirectory, CONFIG_DIR), CONFIG_FILENAME);
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the openchamber.json config file for a project.
|
||||
* Returns null if file doesn't exist or is invalid.
|
||||
*/
|
||||
export async function readOpenChamberConfig(projectDirectory: string): Promise<OpenChamberConfig | null> {
|
||||
const configPath = getConfigPath(projectDirectory);
|
||||
|
||||
try {
|
||||
// Try runtime API first (Desktop/VSCode)
|
||||
const runtimeFiles = getRuntimeFilesAPI();
|
||||
if (runtimeFiles?.readFile) {
|
||||
try {
|
||||
const result = await runtimeFiles.readFile(configPath);
|
||||
if (!result.content.trim()) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(result.content);
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null;
|
||||
}
|
||||
return parsed as OpenChamberConfig;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to web API
|
||||
const response = await fetch(`${getBaseUrl()}/fs/read?path=${encodeURIComponent(configPath)}`);
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(text);
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed as OpenChamberConfig;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the openchamber.json config file for a project.
|
||||
*/
|
||||
export async function writeOpenChamberConfig(
|
||||
projectDirectory: string,
|
||||
config: OpenChamberConfig
|
||||
): Promise<boolean> {
|
||||
const configPath = getConfigPath(projectDirectory);
|
||||
const configDir = joinPath(projectDirectory, CONFIG_DIR);
|
||||
|
||||
try {
|
||||
// Ensure .openchamber directory exists
|
||||
await opencodeClient.createDirectory(configDir);
|
||||
|
||||
// Try runtime API first (Desktop/VSCode)
|
||||
const runtimeFiles = getRuntimeFilesAPI();
|
||||
if (runtimeFiles?.writeFile) {
|
||||
try {
|
||||
const result = await runtimeFiles.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||
return result.success;
|
||||
} catch (error) {
|
||||
console.error('Failed to write openchamber config via runtime API:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to web API
|
||||
const response = await fetch(`${getBaseUrl()}/fs/write`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
path: configPath,
|
||||
content: JSON.stringify(config, null, 2),
|
||||
}),
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('Failed to write openchamber config:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update specific keys in the config, preserving other values.
|
||||
*/
|
||||
export async function updateOpenChamberConfig(
|
||||
projectDirectory: string,
|
||||
updates: Partial<OpenChamberConfig>
|
||||
): Promise<boolean> {
|
||||
const existing = await readOpenChamberConfig(projectDirectory) || {};
|
||||
const merged = { ...existing, ...updates };
|
||||
return writeOpenChamberConfig(projectDirectory, merged);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get worktree setup commands from config.
|
||||
*/
|
||||
export async function getWorktreeSetupCommands(projectDirectory: string): Promise<string[]> {
|
||||
const config = await readOpenChamberConfig(projectDirectory);
|
||||
return config?.['setup-worktree'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Save worktree setup commands to config.
|
||||
*/
|
||||
export async function saveWorktreeSetupCommands(
|
||||
projectDirectory: string,
|
||||
commands: string[]
|
||||
): Promise<boolean> {
|
||||
// Filter out empty commands
|
||||
const filtered = commands.filter(cmd => cmd.trim().length > 0);
|
||||
return updateOpenChamberConfig(projectDirectory, { 'setup-worktree': filtered });
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute variables in a command string.
|
||||
* Supported variables:
|
||||
* - $ROOT_WORKTREE_PATH: The root project directory path
|
||||
*/
|
||||
export function substituteCommandVariables(
|
||||
command: string,
|
||||
variables: { rootWorktreePath: string }
|
||||
): string {
|
||||
return command
|
||||
.replace(/\$ROOT_WORKTREE_PATH/g, variables.rootWorktreePath)
|
||||
.replace(/\$\{ROOT_WORKTREE_PATH\}/g, variables.rootWorktreePath);
|
||||
}
|
||||
|
||||
function getBaseUrl(): string {
|
||||
const defaultBaseUrl = import.meta.env.VITE_OPENCODE_URL || '/api';
|
||||
if (defaultBaseUrl.startsWith('/')) {
|
||||
return defaultBaseUrl;
|
||||
}
|
||||
return defaultBaseUrl;
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
import type { CreateMultiRunParams, CreateMultiRunResult } from '@/types/multirun';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { createWorktree } from '@/lib/git/worktreeService';
|
||||
import { createWorktree, runWorktreeSetupCommands } from '@/lib/git/worktreeService';
|
||||
import { saveWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||||
import { checkIsGitRepository } from '@/lib/gitApi';
|
||||
import { useSessionStore } from './sessionStore';
|
||||
import { useDirectoryStore } from './useDirectoryStore';
|
||||
@@ -100,7 +101,7 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
||||
createMultiRun: async (params: CreateMultiRunParams) => {
|
||||
const groupName = params.name.trim();
|
||||
const prompt = params.prompt.trim();
|
||||
const { models, agent, files } = params;
|
||||
const { models, agent, files, setupCommands } = params;
|
||||
|
||||
if (!groupName) {
|
||||
set({ error: 'Group name is required' });
|
||||
@@ -151,6 +152,8 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
||||
modelID: string;
|
||||
}> = [];
|
||||
|
||||
const commandsToRun = setupCommands?.filter((cmd) => cmd.trim().length > 0) ?? [];
|
||||
|
||||
// Count occurrences of each model to handle duplicates
|
||||
const modelCounts = new Map<string, number>();
|
||||
for (const model of models) {
|
||||
@@ -212,12 +215,21 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
||||
providerID: model.providerID,
|
||||
modelID: model.modelID,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
// Best-effort: allow partial success
|
||||
console.warn('[MultiRun] Failed to create session:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Save setup commands to config if any were provided (for future worktree creation)
|
||||
const commandsToSave = setupCommands?.filter(cmd => cmd.trim().length > 0) ?? [];
|
||||
if (commandsToSave.length > 0) {
|
||||
saveWorktreeSetupCommands(directory, commandsToSave).catch(() => {
|
||||
console.warn('[MultiRun] Failed to save worktree setup commands');
|
||||
});
|
||||
}
|
||||
|
||||
const sessionIds = createdRuns.map((r) => r.sessionId);
|
||||
const firstSessionId = createdRuns[0]?.sessionId ?? null;
|
||||
|
||||
@@ -236,6 +248,29 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
||||
url: f.url,
|
||||
}));
|
||||
|
||||
// Refresh sessions list so sidebar shows the new sessions immediately
|
||||
try {
|
||||
await useSessionStore.getState().loadSessions();
|
||||
} catch {
|
||||
// Ignore refresh errors
|
||||
}
|
||||
|
||||
// Kick off setup commands after sessions are visible.
|
||||
if (commandsToRun.length > 0) {
|
||||
for (const run of createdRuns) {
|
||||
void runWorktreeSetupCommands(run.worktreePath, directory, commandsToRun)
|
||||
.then((result) => {
|
||||
if (!result.success) {
|
||||
const failed = result.results.filter((r) => !r.success);
|
||||
console.warn(`[MultiRun] Setup commands failed for ${run.worktreePath}:`, failed);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn(`[MultiRun] Setup commands error for ${run.worktreePath}:`, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await Promise.allSettled(
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface CreateMultiRunParams {
|
||||
worktreeBaseBranch?: string;
|
||||
/** Files to attach to all runs */
|
||||
files?: MultiRunFileAttachment[];
|
||||
/** Setup commands to run in each new worktree after creation */
|
||||
setupCommands?: string[];
|
||||
}
|
||||
|
||||
export interface CreateMultiRunResult {
|
||||
|
||||
Reference in New Issue
Block a user