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:
Bohdan Triapitsyn
2026-01-07 22:01:49 +02:00
parent b8463b787b
commit 4433888903
16 changed files with 1789 additions and 280 deletions
@@ -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">