From 44338889030952b3983178609954a8f9eb5d6caf Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 7 Jan 2026 12:08:53 +0200 Subject: [PATCH] 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. --- .../desktop/src-tauri/src/commands/files.rs | 206 +++++++- packages/desktop/src-tauri/src/main.rs | 5 +- packages/desktop/src/api/files.ts | 88 ++++ .../components/multirun/ModelMultiSelect.tsx | 8 +- .../components/multirun/MultiRunLauncher.tsx | 129 ++++- .../src/components/session/SessionDialogs.tsx | 495 ++++++++++++------ .../src/components/session/SessionSidebar.tsx | 62 +-- .../agent-manager/AgentManagerEmptyState.tsx | 143 +++++ packages/ui/src/lib/api/types.ts | 12 + packages/ui/src/lib/git/worktreeService.ts | 176 +++++++ packages/ui/src/lib/openchamberConfig.ts | 195 +++++++ packages/ui/src/stores/useMultiRunStore.ts | 39 +- packages/ui/src/types/multirun.ts | 2 + packages/vscode/src/bridge.ts | 110 ++++ packages/vscode/webview/api/files.ts | 121 +++-- packages/web/server/index.js | 278 ++++++++++ 16 files changed, 1789 insertions(+), 280 deletions(-) create mode 100644 packages/ui/src/lib/openchamberConfig.ts diff --git a/packages/desktop/src-tauri/src/commands/files.rs b/packages/desktop/src-tauri/src/commands/files.rs index 50884ce6..edb4f58f 100644 --- a/packages/desktop/src-tauri/src/commands/files.rs +++ b/packages/desktop/src-tauri/src/commands/files.rs @@ -1,9 +1,10 @@ use crate::path_utils::expand_tilde_path; use crate::{DesktopRuntime, SettingsStore}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::{ collections::{HashSet, VecDeque}, path::{Path, PathBuf}, + process::Command, time::UNIX_EPOCH, }; use tokio::fs; @@ -622,3 +623,206 @@ fn relative_path(root: &Path, target: &Path) -> String { .map(|relative| normalize_path(relative)) .unwrap_or_else(|_| normalize_path(target)) } + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReadFileResponse { + content: String, + path: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WriteFileResponse { + success: bool, + path: String, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CommandResult { + command: String, + success: bool, + exit_code: Option, + stdout: Option, + stderr: Option, + error: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExecCommandsResponse { + success: bool, + results: Vec, +} + +#[tauri::command] +pub async fn read_file( + path: String, + state: tauri::State<'_, DesktopRuntime>, +) -> Result { + let trimmed = path.trim(); + if trimmed.is_empty() { + return Err("Path is required".to_string()); + } + + let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await; + let resolved_path = resolve_sandboxed_path(Some(trimmed.to_string()), &workspace_roots, default_root.as_ref()) + .await + .map_err(|_| "File not found or access denied".to_string())?; + + let metadata = fs::metadata(&resolved_path) + .await + .map_err(|_| "File not found".to_string())?; + + if !metadata.is_file() { + return Err("Specified path is not a file".to_string()); + } + + let content = fs::read_to_string(&resolved_path) + .await + .map_err(|err| format!("Failed to read file: {}", err))?; + + Ok(ReadFileResponse { + content, + path: normalize_path(&resolved_path), + }) +} + +#[tauri::command] +pub async fn write_file( + path: String, + content: String, + state: tauri::State<'_, DesktopRuntime>, +) -> Result { + let trimmed = path.trim(); + if trimmed.is_empty() { + return Err("Path is required".to_string()); + } + + let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await; + let resolved_path = resolve_creatable_path(trimmed, &workspace_roots, default_root.as_ref()) + .await + .map_err(|err| err.to_create_message())?; + + // Ensure parent directory exists + if let Some(parent) = resolved_path.parent() { + fs::create_dir_all(parent) + .await + .map_err(|err| format!("Failed to create parent directory: {}", err))?; + } + + fs::write(&resolved_path, content) + .await + .map_err(|err| format!("Failed to write file: {}", err))?; + + Ok(WriteFileResponse { + success: true, + path: normalize_path(&resolved_path), + }) +} + +#[tauri::command] +pub async fn exec_commands( + commands: Vec, + cwd: String, + state: tauri::State<'_, DesktopRuntime>, +) -> Result { + if commands.is_empty() { + return Err("Commands array is required".to_string()); + } + + let cwd_trimmed = cwd.trim(); + if cwd_trimmed.is_empty() { + return Err("Working directory (cwd) is required".to_string()); + } + + let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await; + let resolved_cwd = resolve_sandboxed_path(Some(cwd_trimmed.to_string()), &workspace_roots, default_root.as_ref()) + .await + .map_err(|_| "Working directory not found or access denied".to_string())?; + + let metadata = fs::metadata(&resolved_cwd) + .await + .map_err(|_| "Working directory not found".to_string())?; + + if !metadata.is_dir() { + return Err("Specified cwd is not a directory".to_string()); + } + + let shell = std::env::var("SHELL").unwrap_or_else(|_| { + if cfg!(windows) { + "cmd.exe".to_string() + } else { + "/bin/sh".to_string() + } + }); + + let shell_flag = if cfg!(windows) { "/c" } else { "-c" }; + + let mut results = Vec::new(); + + for cmd in commands { + let cmd_trimmed = cmd.trim(); + if cmd_trimmed.is_empty() { + results.push(CommandResult { + command: cmd.clone(), + success: false, + exit_code: None, + stdout: None, + stderr: None, + error: Some("Invalid command".to_string()), + }); + continue; + } + + let cwd_clone = resolved_cwd.clone(); + let shell_clone = shell.clone(); + let cmd_clone = cmd_trimmed.to_string(); + + // Run command synchronously in blocking task + let result = tokio::task::spawn_blocking(move || { + match Command::new(&shell_clone) + .arg(shell_flag) + .arg(&cmd_clone) + .current_dir(&cwd_clone) + .output() + { + Ok(output) => CommandResult { + command: cmd_clone, + success: output.status.success(), + exit_code: output.status.code(), + stdout: Some(String::from_utf8_lossy(&output.stdout).trim().to_string()), + stderr: Some(String::from_utf8_lossy(&output.stderr).trim().to_string()), + error: None, + }, + Err(err) => CommandResult { + command: cmd_clone, + success: false, + exit_code: None, + stdout: None, + stderr: None, + error: Some(err.to_string()), + }, + } + }) + .await + .unwrap_or_else(|err| CommandResult { + command: cmd.clone(), + success: false, + exit_code: None, + stdout: None, + stderr: None, + error: Some(format!("Task failed: {}", err)), + }); + + results.push(result); + } + + let all_succeeded = results.iter().all(|r| r.success); + + Ok(ExecCommandsResponse { + success: all_succeeded, + results, + }) +} diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 68a60d89..697e24b2 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -28,7 +28,7 @@ use axum::{ routing::{any, get, post}, Json, Router, }; -use commands::files::{create_directory, list_directory, search_files}; +use commands::files::{create_directory, exec_commands, list_directory, read_file, search_files, write_file}; use commands::git::{ add_git_worktree, check_is_git_repository, checkout_branch, create_branch, create_git_commit, create_git_identity, delete_git_branch, delete_git_identity, delete_remote_branch, @@ -835,6 +835,9 @@ fn main() { list_directory, search_files, create_directory, + read_file, + write_file, + exec_commands, request_directory_access, start_accessing_directory, stop_accessing_directory, diff --git a/packages/desktop/src/api/files.ts b/packages/desktop/src/api/files.ts index 27f3e11c..c0d586d3 100644 --- a/packages/desktop/src/api/files.ts +++ b/packages/desktop/src/api/files.ts @@ -111,4 +111,92 @@ export const createDesktopFilesAPI = (): FilesAPI => ({ throw new Error(message || 'Failed to create directory'); } }, + + async readFile(path: string): Promise<{ content: string; path: string }> { + try { + const normalizedPath = normalizePath(path); + const result = await safeInvoke<{ content: string; path: string }>('read_file', { + path: normalizedPath + }, { + timeout: 10000, + onCancel: () => { + console.warn('[FilesAPI] Read file operation timed out'); + } + }); + + return { + content: result?.content ?? '', + path: result?.path ? normalizePath(result.path) : normalizedPath, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(message || 'Failed to read file'); + } + }, + + async writeFile(path: string, content: string): Promise<{ success: boolean; path: string }> { + try { + const normalizedPath = normalizePath(path); + const result = await safeInvoke<{ success: boolean; path: string }>('write_file', { + path: normalizedPath, + content + }, { + timeout: 10000, + onCancel: () => { + console.warn('[FilesAPI] Write file operation timed out'); + } + }); + + return { + success: Boolean(result?.success), + path: result?.path ? normalizePath(result.path) : normalizedPath, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(message || 'Failed to write file'); + } + }, + + async execCommands(commands: string[], cwd: string): Promise<{ + success: boolean; + results: Array<{ + command: string; + success: boolean; + exitCode?: number; + stdout?: string; + stderr?: string; + error?: string; + }>; + }> { + try { + const normalizedCwd = normalizePath(cwd); + const result = await safeInvoke<{ + success: boolean; + results: Array<{ + command: string; + success: boolean; + exitCode?: number; + stdout?: string; + stderr?: string; + error?: string; + }>; + }>('exec_commands', { + commands, + cwd: normalizedCwd + }, { + timeout: 120000, // 2 minutes for command execution + onCancel: () => { + console.warn('[FilesAPI] Exec commands operation timed out'); + } + }); + + return { + success: Boolean(result?.success), + results: result?.results ?? [], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(message || 'Failed to execute commands'); + } + }, }); \ No newline at end of file diff --git a/packages/ui/src/components/multirun/ModelMultiSelect.tsx b/packages/ui/src/components/multirun/ModelMultiSelect.tsx index 9290fdc6..eabdaec5 100644 --- a/packages/ui/src/components/multirun/ModelMultiSelect.tsx +++ b/packages/ui/src/components/multirun/ModelMultiSelect.tsx @@ -309,7 +309,7 @@ export const ModelMultiSelect: React.FC = ({ // Build flat list for keyboard navigation type FlatModelItem = { model: Record; 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 = ({ {/* Models list */} - @@ -477,11 +477,11 @@ export const ModelMultiSelect: React.FC = ({ ); })} - + {/* Validation hint */} {minModels !== undefined && selectedModels.length < minModels && (

- Select at least {minModels} model{minModels > 1 ? 's' : ''} {maxModels !== undefined ? `and at most ${maxModels} models` : ''}. + Select from {minModels} {maxModels !== undefined ? `to ${maxModels} models` : ''}.

)} diff --git a/packages/ui/src/components/multirun/MultiRunLauncher.tsx b/packages/ui/src/components/multirun/MultiRunLauncher.tsx index 2de59ae9..a9a0b189 100644 --- a/packages/ui/src/components/multirun/MultiRunLauncher.tsx +++ b/packages/ui/src/components/multirun/MultiRunLauncher.tsx @@ -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 = ({ const [selectedAgent, setSelectedAgent] = React.useState(''); const [attachedFiles, setAttachedFiles] = React.useState([]); const [isSubmitting, setIsSubmitting] = React.useState(false); + const [setupCommands, setSetupCommands] = React.useState([]); + const [isSetupCommandsOpen, setIsSetupCommandsOpen] = React.useState(false); + const [isLoadingSetupCommands, setIsLoadingSetupCommands] = React.useState(false); const fileInputRef = React.useRef(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(() => { @@ -102,6 +136,31 @@ export const MultiRunLauncher: React.FC = ({ } }, [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 = ({ 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 = ({ 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 = ({ {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_WORKTREE_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 selection */} diff --git a/packages/ui/src/components/session/SessionDialogs.tsx b/packages/ui/src/components/session/SessionDialogs.tsx index eb2d36b1..37b1985e 100644 --- a/packages/ui/src/components/session/SessionDialogs.tsx +++ b/packages/ui/src/components/session/SessionDialogs.tsx @@ -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(null); const [isCheckingGitRepository, setIsCheckingGitRepository] = React.useState(false); const [isGitRepository, setIsGitRepository] = React.useState(null); + const [mainWorktreeBranch, setMainWorktreeBranch] = React.useState(null); const [isCreatingWorktree, setIsCreatingWorktree] = React.useState(false); const [worktreeManagerProjectId, setWorktreeManagerProjectId] = React.useState(null); const ensuredIgnoreDirectories = React.useRef>(new Set()); @@ -131,6 +135,9 @@ export const SessionDialogs: React.FC = () => { const [deleteDialogSummaries, setDeleteDialogSummaries] = React.useState>([]); const [deleteDialogShouldRemoveRemote, setDeleteDialogShouldRemoveRemote] = React.useState(false); const [isProcessingDelete, setIsProcessingDelete] = React.useState(false); + const [isSetupCommandsOpen, setIsSetupCommandsOpen] = React.useState(false); + const [setupCommands, setSetupCommands] = React.useState([]); + 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(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 = ( -
- {} -
-
+
+ {/* Create worktree section */} +
+

Create worktree

-

- Branch-specific directory under {WORKTREE_ROOT}. +

+ Branch-specific directory under {WORKTREE_ROOT}

- { - setWorktreeCreateMode(value); - setWorktreeError(null); - - if (value === 'existing' && !existingWorktreeBranch) { - const firstLocal = availableWorktreeBaseBranches.find((option) => option.group === 'local')?.value ?? ''; - if (firstLocal) { - setExistingWorktreeBranch(firstLocal); + +
+ + + {/* Branch name input - inline when "New branch" is selected */} + {worktreeCreateMode === 'new' && ( + 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(); + } + }} + /> + )} +
+ + {/* Base branch selector - only shown when "New branch" is selected */} + {worktreeCreateMode === 'new' && ( <> - - -
- 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(); - } - }} - /> - -
- - ) : ( - <> - -
- - -
- {!isLoadingWorktreeBaseBranches && !availableWorktreeBaseBranches.some((option) => option.group === 'local') ? ( -

- No local branches found. Fetch or create a branch first. -

- ) : null} )} + {/* Preview info */} {worktreeTargetBranch ? (

{worktreeCreateMode === 'existing' ? ( <> Uses branch{' '} {worktreeTargetBranch} - {' '}at{' '} - - {formatPathForDisplay(worktreePreviewPath, homeDirectory)} - ) : ( <> - Creates branch{' '} + Creates{' '} {worktreeTargetBranch} {' '}from{' '} {selectedWorktreeBaseLabel} - {' '}at{' '} - - {formatPathForDisplay(worktreePreviewPath, homeDirectory)} - )}

) : null} + + {/* Create button */} +
{worktreeError &&

{worktreeError}

} @@ -963,50 +1086,100 @@ export const SessionDialogs: React.FC = () => { )}
- {} -
-
-

Existing worktrees

-
+ {/* Setup commands section */} + + +

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

+ +
+ +
+

+ Commands run in the new worktree. Use $ROOT_WORKTREE_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" + /> + +
+ ))} + +
+ )} +
+
+
+ + {/* Existing worktrees section */} +
+

Existing worktrees

{isLoadingWorktrees ? (

Loading worktrees…

) : availableWorktrees.length === 0 ? (

- No worktrees found under {WORKTREE_ROOT}. + No worktrees found under {WORKTREE_ROOT}

) : ( -
- {availableWorktrees.map((worktree) => { - - const relativePath = worktree.relativePath - || (worktree.path.startsWith(projectDirectory + '/') - ? worktree.path.slice(projectDirectory.length + 1) - : worktree.path); - return ( -
+ {availableWorktrees.map((worktree) => ( +
+

+ {worktree.label || worktree.branch || 'Detached HEAD'} +

+ -
- ); - })} + + +
+ ))}
)}
@@ -1153,7 +1326,7 @@ export const SessionDialogs: React.FC = () => { Worktree Manager {worktreeManagerBody} - {worktreeManagerActions} + {worktreeManagerActions} )} diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index d0b9ae83..7a115a9e 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -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 = ({ const safeStorage = React.useMemo(() => getSafeStorage(), []); const [collapsedGroups, setCollapsedGroups] = React.useState>(new Set()); const [collapsedProjects, setCollapsedProjects] = React.useState>(new Set()); - const [pendingProjectClose, setPendingProjectClose] = React.useState<{ - id: string; - label: string; - } | null>(null); + const [projectRepoStatus, setProjectRepoStatus] = React.useState>(new Map()); const [expandedSessionGroups, setExpandedSessionGroups] = React.useState>(new Set()); const [hoveredGroupId, setHoveredGroupId] = React.useState(null); @@ -783,21 +772,6 @@ export const SessionSidebar: React.FC = ({ } }, [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 = ({ } 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 = ({ )} - { - if (!open) { - setPendingProjectClose(null); - } - }} - > - - - Close project? - - This removes it from the sidebar. You can add it again later. - - - -
- {pendingProjectClose?.label} -
- - - - - -
-
); }; diff --git a/packages/ui/src/components/views/agent-manager/AgentManagerEmptyState.tsx b/packages/ui/src/components/views/agent-manager/AgentManagerEmptyState.tsx index 627bfbfe..900fea5d 100644 --- a/packages/ui/src/components/views/agent-manager/AgentManagerEmptyState.tsx +++ b/packages/ui/src/components/views/agent-manager/AgentManagerEmptyState.tsx @@ -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 = ({ const [baseBranch, setBaseBranch] = React.useState('HEAD'); const [attachedFiles, setAttachedFiles] = React.useState([]); const [isSubmitting, setIsSubmitting] = React.useState(false); + const [setupCommands, setSetupCommands] = React.useState([]); + const [isSetupCommandsOpen, setIsSetupCommandsOpen] = React.useState(false); + const [isLoadingSetupCommands, setIsLoadingSetupCommands] = React.useState(false); const fileInputRef = React.useRef(null); const textareaRef = React.useRef(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 = ({ })) : 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 = ({ 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 = ({

+ {/* 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_WORKTREE_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 Selection */}