diff --git a/electron/ipc/channels.ts b/electron/ipc/channels.ts index 1ebfffe6..f496ca81 100644 --- a/electron/ipc/channels.ts +++ b/electron/ipc/channels.ts @@ -22,6 +22,7 @@ export enum IPC { GetFileDiff = 'get_file_diff', GetFileDiffFromBranch = 'get_file_diff_from_branch', GetGitignoredDirs = 'get_gitignored_dirs', + ListProjectEntries = 'list_project_entries', ListImportableWorktrees = 'list_importable_worktrees', GetWorktreeStatus = 'get_worktree_status', CheckMergeStatus = 'check_merge_status', @@ -100,6 +101,11 @@ export enum IPC { CancelAskAboutCode = 'cancel_ask_about_code', SetMinimaxApiKey = 'set_minimax_api_key', + // Setup / teardown commands (per-project, run on worktree create / remove) + RunSetupCommands = 'run_setup_commands', + RunTeardownCommands = 'run_teardown_commands', + CancelProjectCommands = 'cancel_project_commands', + // Docker CheckDockerAvailable = 'check_docker_available', CheckDockerImageExists = 'check_docker_image_exists', diff --git a/electron/ipc/git.ts b/electron/ipc/git.ts index d2f3d95c..57368136 100644 --- a/electron/ipc/git.ts +++ b/electron/ipc/git.ts @@ -489,21 +489,27 @@ export async function createWorktree( if (baseBranch) worktreeArgs.push(baseBranch); await exec('git', worktreeArgs, { cwd: repoRoot }); - // Symlink selected directories. `.claude` is handled separately below — it - // can't be a symlink because Claude Code's bwrap sandbox binds specific - // entries inside it, and bwrap refuses to bind-mount at symlink paths. + // Symlink selected directories (or files). `.claude` is handled separately + // below — it can't be a symlink because Claude Code's bwrap sandbox binds + // specific entries inside it, and bwrap refuses to bind-mount at symlink + // paths. Nested paths like `packages/app/node_modules` are supported: the + // parent directory is created with mkdir -p before the symlink is placed. for (const name of symlinkDirs) { if (name === '.claude') continue; - // Reject names that could escape the worktree directory - if (name.includes('/') || name.includes('\\') || name.includes('..') || name === '.') continue; - const source = path.join(repoRoot, name); - const target = path.join(worktreePath, name); + if (path.isAbsolute(name) || name === '.' || name === '') continue; + // Reject names that would escape the worktree root after normalization + // (blocks both leading `..` and embedded segments like `foo/../..`). + const normalized = path.normalize(name); + if (normalized.startsWith('..') || normalized === '.' || path.isAbsolute(normalized)) continue; + const source = path.join(repoRoot, normalized); + const target = path.join(worktreePath, normalized); try { if (!fs.existsSync(source)) continue; if (fs.existsSync(target)) continue; + fs.mkdirSync(path.dirname(target), { recursive: true }); fs.symlinkSync(source, target); } catch (err) { - console.warn(`Failed to symlink directory '${name}' into worktree:`, err); + console.warn(`Failed to symlink '${name}' into worktree:`, err); } } @@ -701,6 +707,30 @@ export async function removeWorktree( // --- IPC command functions --- +/** + * List immediate children of `projectRoot`. Used by `PathSelector` to + * autocomplete symlink-dir candidates. The `.git` directory is filtered out; + * other dotfiles are kept so users can pick e.g. `.env` or `.venv`. Users + * who need nested paths type them freely into the input. + */ +export async function listProjectEntries( + projectRoot: string, +): Promise> { + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(projectRoot, { withFileTypes: true }); + } catch { + return []; + } + return entries + .filter((e) => e.name !== '.git') + .map((e) => ({ name: e.name, isDir: e.isDirectory() })) + .sort((a, b) => { + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; + return a.name.localeCompare(b.name); + }); +} + export async function getGitIgnoredDirs(projectRoot: string): Promise { const results: string[] = []; for (const name of SYMLINK_CANDIDATES) { diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts index 08ebc24e..572deddb 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -29,6 +29,7 @@ import { startStepsWatcher, stopStepsWatcher, readStepsForWorktree } from './ste import { startRemoteServer } from '../remote/server.js'; import { getGitIgnoredDirs, + listProjectEntries, getMainBranch, getCurrentBranch, getChangedFiles, @@ -61,6 +62,7 @@ import { saveAppState, loadAppState } from './persistence.js'; import { spawn } from 'child_process'; import { askAboutCode, cancelAskAboutCode } from './ask-code.js'; import { setMinimaxApiKey } from './ask-code-minimax.js'; +import { runProjectCommands, cancelProjectCommands } from './setup.js'; import { getSystemMonospaceFonts } from './system-fonts.js'; import path from 'path'; import { @@ -326,6 +328,10 @@ export function registerAllHandlers(win: BrowserWindow): void { validatePath(args.projectRoot, 'projectRoot'); return getGitIgnoredDirs(args.projectRoot); }); + ipcMain.handle(IPC.ListProjectEntries, (_e, args) => { + validatePath(args.projectRoot, 'projectRoot'); + return listProjectEntries(args.projectRoot); + }); ipcMain.handle(IPC.ListImportableWorktrees, (_e, args) => { validatePath(args.projectRoot, 'projectRoot'); return listImportableWorktrees(args.projectRoot); @@ -557,6 +563,32 @@ export function registerAllHandlers(win: BrowserWindow): void { cancelAskAboutCode(args.requestId); }); + // --- Setup / teardown commands --- + const handleRunCommands = (_e: unknown, args: unknown) => { + const a = args as { + worktreePath: unknown; + projectRoot: unknown; + commands: unknown; + onOutput: { __CHANNEL_ID__: unknown }; + }; + validatePath(a.worktreePath, 'worktreePath'); + validatePath(a.projectRoot, 'projectRoot'); + assertStringArray(a.commands, 'commands'); + assertString(a.onOutput?.__CHANNEL_ID__, 'channelId'); + return runProjectCommands(win, { + worktreePath: a.worktreePath as string, + projectRoot: a.projectRoot as string, + commands: a.commands as string[], + channelId: a.onOutput.__CHANNEL_ID__ as string, + }); + }; + ipcMain.handle(IPC.RunSetupCommands, handleRunCommands); + ipcMain.handle(IPC.RunTeardownCommands, handleRunCommands); + ipcMain.handle(IPC.CancelProjectCommands, (_e, args) => { + assertString(args.channelId, 'channelId'); + cancelProjectCommands(args.channelId); + }); + // --- File links --- ipcMain.handle(IPC.OpenPath, (_e, args) => { validatePath(args.filePath, 'filePath'); diff --git a/electron/ipc/setup.ts b/electron/ipc/setup.ts new file mode 100644 index 00000000..4d74d599 --- /dev/null +++ b/electron/ipc/setup.ts @@ -0,0 +1,119 @@ +import { spawn } from 'child_process'; +import type { BrowserWindow } from 'electron'; + +// One AbortController per active setup/teardown channel. cancelProjectCommands +// aborts it; spawn({signal}) kills the child; close handler detects the abort +// via signal.aborted. +const controllers = new Map(); + +// Electron / Node internal env that must not leak into user shell commands. +// `NODE_OPTIONS=--inspect-brk` would silently open a debugger for `npm install`; +// `ELECTRON_RUN_AS_NODE=1` mis-directs child `node` processes; `LD_PRELOAD` +// is a common ptrace/injection hook and has no business in user scripts. +const STRIP_ENV_KEYS = [ + 'NODE_OPTIONS', + 'ELECTRON_RUN_AS_NODE', + 'ELECTRON_NO_ATTACH_CONSOLE', + 'ELECTRON_DEFAULT_ERROR_MODE', + 'ELECTRON_ENABLE_LOGGING', + 'ELECTRON_ENABLE_STACK_DUMPING', + 'LD_PRELOAD', +]; + +function cleanEnv(extra: Record): NodeJS.ProcessEnv { + const stripped = new Set(STRIP_ENV_KEYS); + const env: NodeJS.ProcessEnv = {}; + for (const [k, v] of Object.entries(process.env)) { + if (!stripped.has(k)) env[k] = v; + } + return { ...env, ...extra }; +} + +interface RunCommandsArgs { + worktreePath: string; + projectRoot: string; + commands: string[]; + channelId: string; +} + +/** + * Run a sequence of shell commands for setup or teardown. Aborts on the first + * non-zero exit or when the channel is cancelled. + * + * `$PROJECT_ROOT` and `$WORKTREE` are exposed as env vars rather than + * interpolated into the command string — this avoids shell-metacharacter + * injection when a path contains spaces, semicolons, or backticks. + */ +export async function runProjectCommands(win: BrowserWindow, args: RunCommandsArgs): Promise { + const { worktreePath, projectRoot, commands, channelId } = args; + + const controller = new AbortController(); + controllers.set(channelId, controller); + + const send = (msg: string) => { + if (!win.isDestroyed()) { + win.webContents.send(`channel:${channelId}`, msg); + } + }; + + try { + for (const cmd of commands) { + controller.signal.throwIfAborted(); + send(`$ ${cmd}\n`); + await runOne(cmd, worktreePath, projectRoot, controller.signal, send); + } + } finally { + if (controllers.get(channelId) === controller) { + controllers.delete(channelId); + } + } +} + +function runOne( + cmd: string, + cwd: string, + projectRoot: string, + signal: AbortSignal, + send: (msg: string) => void, +): Promise { + return new Promise((resolve, reject) => { + const proc = spawn('sh', ['-c', cmd], { + cwd, + env: cleanEnv({ PROJECT_ROOT: projectRoot, WORKTREE: cwd }), + stdio: ['ignore', 'pipe', 'pipe'], + signal, + }); + + proc.stdout?.on('data', (c: Buffer) => send(c.toString('utf8'))); + proc.stderr?.on('data', (c: Buffer) => send(c.toString('utf8'))); + + let settled = false; + proc.on('close', (code, sig) => { + if (settled) return; + settled = true; + if (signal.aborted) { + // Raise a typed AbortError so callers can distinguish cancellation + // from a genuine failure without string-matching. + const err = new Error('Aborted'); + err.name = 'AbortError'; + reject(err); + } else if (code === 0) { + resolve(); + } else if (sig) { + reject(new Error(`Command "${cmd}" killed by ${sig}`)); + } else { + reject(new Error(`Command "${cmd}" exited with code ${code}`)); + } + }); + proc.on('error', (err) => { + if (settled) return; + settled = true; + reject(err); + }); + }); +} + +/** Abort the running command for this channel, if any. */ +export function cancelProjectCommands(channelId: string): void { + controllers.get(channelId)?.abort(); +} diff --git a/electron/preload.cjs b/electron/preload.cjs index 23f327f8..fcceea45 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -25,6 +25,7 @@ const ALLOWED_CHANNELS = new Set([ 'get_all_file_diffs', 'get_all_file_diffs_from_branch', 'get_gitignored_dirs', + 'list_project_entries', 'list_importable_worktrees', 'get_worktree_status', 'commit_all', @@ -98,6 +99,10 @@ const ALLOWED_CHANNELS = new Set([ 'ask_about_code', 'cancel_ask_about_code', 'set_minimax_api_key', + // Setup / teardown + 'run_setup_commands', + 'run_teardown_commands', + 'cancel_project_commands', // System 'get_system_fonts', // File links diff --git a/src/components/CommandListEditor.tsx b/src/components/CommandListEditor.tsx new file mode 100644 index 00000000..8df8ad1b --- /dev/null +++ b/src/components/CommandListEditor.tsx @@ -0,0 +1,229 @@ +import { createSignal, For, Show } from 'solid-js'; +import { theme } from '../lib/theme'; + +export interface CommandVariable { + name: string; + description: string; + example: string; +} + +interface CommandListEditorProps { + label: string; + description?: string; + placeholder: string; + items: string[]; + onAdd: (item: string) => void; + onRemove: (index: number) => void; + variables?: CommandVariable[]; +} + +export function CommandListEditor(props: CommandListEditorProps) { + const [newItem, setNewItem] = createSignal(''); + let inputRef: HTMLInputElement | undefined; + + function add() { + const v = newItem().trim(); + if (!v) return; + props.onAdd(v); + setNewItem(''); + } + + function insertVariable(varName: string) { + if (!inputRef) return; + const token = `$${varName}`; + const start = inputRef.selectionStart ?? inputRef.value.length; + const end = inputRef.selectionEnd ?? start; + const before = inputRef.value.slice(0, start); + const after = inputRef.value.slice(end); + const updated = before + token + after; + setNewItem(updated); + // Restore cursor position after the inserted token + requestAnimationFrame(() => { + inputRef?.focus(); + const pos = start + token.length; + inputRef?.setSelectionRange(pos, pos); + }); + } + + return ( +
+ + + {props.description} + + 0}> +
+ + {(item, i) => ( +
+ + {item} + + +
+ )} +
+
+
+
+ setNewItem(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + add(); + } + }} + placeholder={props.placeholder} + style={{ + flex: '1', + background: theme.bgInput, + border: `1px solid ${theme.border}`, + 'border-radius': '8px', + padding: '8px 12px', + color: theme.fg, + 'font-size': '12px', + 'font-family': "'JetBrains Mono', monospace", + outline: 'none', + }} + /> + +
+ 0}> +
+ Variables: + + {(v) => insertVariable(v.name)} />} + +
+
+
+ ); +} + +function VariableChip(props: { variable: CommandVariable; onInsert: () => void }) { + const [showTooltip, setShowTooltip] = createSignal(false); + + return ( +
+ + +
+ {props.variable.description} + + e.g. {props.variable.example} + +
+
+
+ ); +} diff --git a/src/components/EditProjectDialog.tsx b/src/components/EditProjectDialog.tsx index 9105afd9..8802112d 100644 --- a/src/components/EditProjectDialog.tsx +++ b/src/components/EditProjectDialog.tsx @@ -11,8 +11,19 @@ import { sanitizeBranchPrefix, toBranchName } from '../lib/branch-name'; import { theme, sectionLabelStyle } from '../lib/theme'; import type { Project, TerminalBookmark, GitIsolationMode } from '../store/types'; import { SegmentedButtons } from './SegmentedButtons'; +import { CommandListEditor } from './CommandListEditor'; +import { PathSelector } from './PathSelector'; import { ImportWorktreesDialog } from './ImportWorktreesDialog'; +const COMMAND_VARIABLES = [ + { name: 'PROJECT_ROOT', description: 'Project root directory', example: '/Users/me/myproject' }, + { + name: 'WORKTREE', + description: "This task's worktree directory", + example: '/Users/me/myproject/.worktrees/task/some-branch', + }, +]; + interface EditProjectDialogProps { project: Project | null; onClose: () => void; @@ -32,6 +43,9 @@ export function EditProjectDialog(props: EditProjectDialogProps) { const [defaultBaseBranch, setDefaultBaseBranch] = createSignal(''); const [bookmarks, setBookmarks] = createSignal([]); const [newCommand, setNewCommand] = createSignal(''); + const [defaultSymlinkDirs, setDefaultSymlinkDirs] = createSignal([]); + const [setupCommands, setSetupCommands] = createSignal([]); + const [teardownCommands, setTeardownCommands] = createSignal([]); const [showImportDialog, setShowImportDialog] = createSignal(false); let nameRef!: HTMLInputElement; @@ -46,6 +60,9 @@ export function EditProjectDialog(props: EditProjectDialogProps) { setDefaultGitIsolation(p.defaultGitIsolation ?? 'worktree'); setDefaultBaseBranch(p.defaultBaseBranch ?? ''); setBookmarks(p.terminalBookmarks ? [...p.terminalBookmarks] : []); + setDefaultSymlinkDirs(p.defaultSymlinkDirs ? [...p.defaultSymlinkDirs] : []); + setSetupCommands(p.setupCommands ? [...p.setupCommands] : []); + setTeardownCommands(p.teardownCommands ? [...p.teardownCommands] : []); setNewCommand(''); requestAnimationFrame(() => nameRef?.focus()); }); @@ -79,6 +96,9 @@ export function EditProjectDialog(props: EditProjectDialogProps) { defaultGitIsolation: defaultGitIsolation(), defaultBaseBranch: defaultBaseBranch() || undefined, terminalBookmarks: bookmarks(), + defaultSymlinkDirs: defaultSymlinkDirs(), + setupCommands: setupCommands(), + teardownCommands: teardownCommands(), }); props.onClose(); } @@ -479,6 +499,40 @@ export function EditProjectDialog(props: EditProjectDialogProps) { + {/* Default symlink dirs */} + setDefaultSymlinkDirs([...defaultSymlinkDirs(), dir])} + onRemove={(i) => + setDefaultSymlinkDirs(defaultSymlinkDirs().filter((_, idx) => idx !== i)) + } + /> + + {/* Setup commands */} + setSetupCommands([...setupCommands(), cmd])} + onRemove={(i) => setSetupCommands(setupCommands().filter((_, idx) => idx !== i))} + /> + + {/* Teardown commands */} + setTeardownCommands([...teardownCommands(), cmd])} + onRemove={(i) => + setTeardownCommands(teardownCommands().filter((_, idx) => idx !== i)) + } + /> + {/* Buttons */}
(null); const [error, setError] = createSignal(''); const [loading, setLoading] = createSignal(false); - const [ignoredDirs, setIgnoredDirs] = createSignal([]); - const [selectedDirs, setSelectedDirs] = createSignal>(new Set()); + const [symlinkDirs, setSymlinkDirs] = createSignal([]); const [gitIsolation, setGitIsolation] = createSignal('worktree'); const [baseBranch, setBaseBranch] = createSignal(''); const [branches, setBranches] = createSignal([]); @@ -189,28 +189,30 @@ export function NewTaskDialog(props: NewTaskDialogProps) { }); }); - // Fetch gitignored dirs when project changes + // Seed symlink dirs when project changes: prefer per-project defaults, fall + // back to auto-detected gitignored dirs so a fresh project still works. createEffect(() => { const pid = selectedProjectId(); const path = pid ? getProjectPath(pid) : undefined; let cancelled = false; - if (!path) { - setIgnoredDirs([]); - setSelectedDirs(new Set()); + if (!path || !pid) { + setSymlinkDirs([]); + return; + } + + const configured = getProjectDefaultSymlinkDirs(pid); + if (configured) { + setSymlinkDirs([...configured]); return; } void (async () => { try { const dirs = await invoke(IPC.GetGitignoredDirs, { projectRoot: path }); - if (cancelled) return; - setIgnoredDirs(dirs); - setSelectedDirs(new Set(dirs)); // all checked by default + if (!cancelled) setSymlinkDirs(dirs); } catch { - if (cancelled) return; - setIgnoredDirs([]); - setSelectedDirs(new Set()); + if (!cancelled) setSymlinkDirs([]); } })(); @@ -500,7 +502,7 @@ export function NewTaskDialog(props: NewTaskDialogProps) { projectId, gitIsolation: gitIsolation(), baseBranch: baseBranch(), - symlinkDirs: gitIsolation() === 'worktree' ? [...selectedDirs()] : undefined, + symlinkDirs: gitIsolation() === 'worktree' ? [...symlinkDirs()] : undefined, branchPrefixOverride: gitIsolation() === 'worktree' ? prefix : undefined, initialPrompt: isFromDrop ? undefined : p, githubUrl: ghUrl, @@ -997,16 +999,12 @@ export function NewTaskDialog(props: NewTaskDialogProps) {
{/* end checkboxes group */} - 0 && gitIsolation() === 'worktree'}> - { - const next = new Set(selectedDirs()); - if (next.has(dir)) next.delete(dir); - else next.add(dir); - setSelectedDirs(next); - }} + + setSymlinkDirs([...symlinkDirs(), dir])} + onRemove={(index) => setSymlinkDirs(symlinkDirs().filter((_, i) => i !== index))} /> diff --git a/src/components/PathSelector.tsx b/src/components/PathSelector.tsx new file mode 100644 index 00000000..eedb2f0a --- /dev/null +++ b/src/components/PathSelector.tsx @@ -0,0 +1,256 @@ +import { createSignal, createEffect, createMemo, For, Show, onCleanup } from 'solid-js'; +import { invoke } from '../lib/ipc'; +import { IPC } from '../../electron/ipc/channels'; +import { theme } from '../lib/theme'; + +interface Entry { + name: string; + isDir: boolean; +} + +interface PathSelectorProps { + dirs: string[]; + projectRoot: string | undefined; + onAdd: (dir: string) => void; + onRemove: (index: number) => void; +} + +export function PathSelector(props: PathSelectorProps) { + const [query, setQuery] = createSignal(''); + const [showSuggestions, setShowSuggestions] = createSignal(false); + const [selectedIdx, setSelectedIdx] = createSignal(-1); + const [entries, setEntries] = createSignal([]); + const [dropdownPos, setDropdownPos] = createSignal({ top: 0, left: 0, width: 0 }); + let inputRef!: HTMLInputElement; + + // Fetch top-level entries once per project root. Nested paths are still + // addable — users type them freely into the input. + createEffect(() => { + const root = props.projectRoot; + if (!root) { + setEntries([]); + return; + } + let cancelled = false; + void invoke(IPC.ListProjectEntries, { projectRoot: root }) + .then((result) => { + if (!cancelled) setEntries(result); + }) + .catch(() => { + if (!cancelled) setEntries([]); + }); + onCleanup(() => { + cancelled = true; + }); + }); + + const filtered = createMemo(() => { + const q = query().toLowerCase(); + const added = new Set(props.dirs); + return entries().filter((e) => !added.has(e.name) && (!q || e.name.toLowerCase().includes(q))); + }); + + function updateDropdownPos() { + if (!inputRef) return; + const rect = inputRef.getBoundingClientRect(); + setDropdownPos({ top: rect.bottom + 2, left: rect.left, width: rect.width }); + } + + function addDir(name: string) { + const trimmed = name.trim().replace(/\/$/, ''); + if (!trimmed || props.dirs.includes(trimmed)) return; + props.onAdd(trimmed); + setQuery(''); + setShowSuggestions(false); + setSelectedIdx(-1); + inputRef?.focus(); + } + + function handleKeyDown(e: KeyboardEvent) { + const items = filtered(); + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIdx((i) => Math.min(i + 1, items.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIdx((i) => Math.max(i - 1, -1)); + } else if (e.key === 'Enter' || e.key === 'Tab') { + const idx = selectedIdx(); + const item = idx >= 0 && idx < items.length ? items[idx] : items[0]; + if (item) { + e.preventDefault(); + if (e.key === 'Enter') e.stopPropagation(); + addDir(item.name); + } else if (e.key === 'Enter' && query().trim()) { + e.preventDefault(); + e.stopPropagation(); + addDir(query()); + } + } else if (e.key === 'Escape') { + setShowSuggestions(false); + setSelectedIdx(-1); + } + } + + return ( +
+ +
+ + {(dir, index) => ( +
+ + {dir} + + +
+ )} +
+
+ { + setQuery(e.currentTarget.value); + setShowSuggestions(true); + setSelectedIdx(-1); + updateDropdownPos(); + }} + onFocus={() => { + updateDropdownPos(); + setShowSuggestions(true); + }} + onBlur={() => { + setTimeout(() => setShowSuggestions(false), 150); + }} + onKeyDown={handleKeyDown} + placeholder="Add path…" + style={{ + width: '100%', + 'box-sizing': 'border-box', + background: theme.bgInput, + border: `1px solid ${theme.border}`, + 'border-radius': '6px', + padding: '6px 10px', + color: theme.fg, + 'font-size': '11px', + 'font-family': "'JetBrains Mono', monospace", + outline: 'none', + }} + /> + 0}> +
+ + {(item, index) => ( +
{ + e.preventDefault(); + addDir(item.name); + }} + style={{ + padding: '6px 10px', + 'font-size': '11px', + 'font-family': "'JetBrains Mono', monospace", + color: theme.fg, + cursor: 'pointer', + background: index() === selectedIdx() ? theme.bgInput : 'transparent', + display: 'flex', + 'align-items': 'center', + gap: '6px', + }} + onMouseEnter={() => setSelectedIdx(index())} + > + + {item.isDir ? '/' : ''} + + {item.name} +
+ )} +
+
+
+
+
+
+ ); +} diff --git a/src/components/SetupBanner.tsx b/src/components/SetupBanner.tsx new file mode 100644 index 00000000..c2541e76 --- /dev/null +++ b/src/components/SetupBanner.tsx @@ -0,0 +1,117 @@ +import { Show, createEffect } from 'solid-js'; +import { theme } from '../lib/theme'; +import { retrySetup, skipSetup } from '../store/store'; +import type { Task } from '../store/types'; + +interface SetupBannerProps { + task: Task; +} + +export function SetupBanner(props: SetupBannerProps) { + let logRef: HTMLPreElement | undefined; + + // Auto-scroll log to bottom when content changes + createEffect(() => { + void props.task.setupLog; // track + if (logRef) logRef.scrollTop = logRef.scrollHeight; + }); + + return ( + +
+
+ + + Running setup commands... + + + Setup failed + + + {props.task.setupError} + + +
+ + +
+
+
+ +
+            {props.task.setupLog}
+          
+
+
+ +
+ ); +} diff --git a/src/components/SymlinkDirPicker.tsx b/src/components/SymlinkDirPicker.tsx deleted file mode 100644 index 540b83f8..00000000 --- a/src/components/SymlinkDirPicker.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { For } from 'solid-js'; -import { theme } from '../lib/theme'; - -interface SymlinkDirPickerProps { - dirs: string[]; - selectedDirs: Set; - onToggle: (dir: string) => void; -} - -export function SymlinkDirPicker(props: SymlinkDirPickerProps) { - return ( -
- -
- - {(dir) => { - const checked = () => props.selectedDirs.has(dir); - return ( - - ); - }} - -
-
- ); -} diff --git a/src/components/TaskPanel.tsx b/src/components/TaskPanel.tsx index edf516fe..c7e3cead 100644 --- a/src/components/TaskPanel.tsx +++ b/src/components/TaskPanel.tsx @@ -27,6 +27,7 @@ import { TaskNotesPanel } from './TaskNotesPanel'; import { TaskShellSection } from './TaskShellSection'; import { TaskStepsSection } from './TaskStepsSection'; import { TaskAITerminal } from './TaskAITerminal'; +import { SetupBanner } from './SetupBanner'; import { TaskClosingOverlay } from './TaskClosingOverlay'; import { invoke } from '../lib/ipc'; import { IPC } from '../../electron/ipc/channels'; @@ -263,14 +264,26 @@ export function TaskPanel(props: TaskPanelProps) { id: 'ai-terminal', minSize: 80, content: () => ( - { - setStepNav(fn ? { jump: fn, firstIndex: fromIdx } : undefined); +
+ > + +
+ { + setStepNav(fn ? { jump: fn, firstIndex: fromIdx } : undefined); + }} + /> +
+
), }; } diff --git a/src/store/projects.ts b/src/store/projects.ts index b7eb3111..3f739930 100644 --- a/src/store/projects.ts +++ b/src/store/projects.ts @@ -65,6 +65,9 @@ export function updateProject( | 'defaultGitIsolation' | 'defaultBaseBranch' | 'terminalBookmarks' + | 'defaultSymlinkDirs' + | 'setupCommands' + | 'teardownCommands' > >, ): void { @@ -84,10 +87,32 @@ export function updateProject( s.projects[idx].defaultBaseBranch = updates.defaultBaseBranch; if (updates.terminalBookmarks !== undefined) s.projects[idx].terminalBookmarks = updates.terminalBookmarks; + if (updates.defaultSymlinkDirs !== undefined) + s.projects[idx].defaultSymlinkDirs = updates.defaultSymlinkDirs; + if (updates.setupCommands !== undefined) + s.projects[idx].setupCommands = updates.setupCommands; + if (updates.teardownCommands !== undefined) + s.projects[idx].teardownCommands = updates.teardownCommands; }), ); } +/** Returns non-empty commands array, or undefined when unset/empty. */ +export function getProjectSetupCommands(projectId: string): string[] | undefined { + const cmds = store.projects.find((p) => p.id === projectId)?.setupCommands; + return cmds && cmds.length > 0 ? cmds : undefined; +} + +export function getProjectTeardownCommands(projectId: string): string[] | undefined { + const cmds = store.projects.find((p) => p.id === projectId)?.teardownCommands; + return cmds && cmds.length > 0 ? cmds : undefined; +} + +export function getProjectDefaultSymlinkDirs(projectId: string): string[] | undefined { + const dirs = store.projects.find((p) => p.id === projectId)?.defaultSymlinkDirs; + return dirs && dirs.length > 0 ? dirs : undefined; +} + export function getProjectBranchPrefix(projectId: string): string { const raw = store.projects.find((p) => p.id === projectId)?.branchPrefix ?? 'task'; return sanitizeBranchPrefix(raw); diff --git a/src/store/store.ts b/src/store/store.ts index 5671a30f..50a5a974 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -8,6 +8,7 @@ export { updateProject, getProjectPath, getProjectBranchPrefix, + getProjectDefaultSymlinkDirs, pickAndAddProject, validateProjectPaths, relinkProject, @@ -53,6 +54,8 @@ export { setStepsContent, setTaskStepsEnabled, setTaskLastInputAt, + retrySetup, + skipSetup, } from './tasks'; export { setActiveTask, diff --git a/src/store/tasks.ts b/src/store/tasks.ts index 53b0c9ad..ba94db4a 100644 --- a/src/store/tasks.ts +++ b/src/store/tasks.ts @@ -4,7 +4,14 @@ import { IPC } from '../../electron/ipc/channels'; import { store, setStore, cleanupPanelEntries } from './core'; import { saveState } from './persistence'; import { setTaskFocusedPanel } from './focus'; -import { getProject, getProjectPath, getProjectBranchPrefix, isProjectMissing } from './projects'; +import { + getProject, + getProjectPath, + getProjectBranchPrefix, + getProjectSetupCommands, + getProjectTeardownCommands, + isProjectMissing, +} from './projects'; import { setPendingShellCommand } from '../lib/bookmarks'; import { markAgentSpawned, @@ -204,6 +211,14 @@ export async function createTask(opts: CreateTaskOptions): Promise { initTaskInStore(taskId, task, agent, projectId, agentDef); saveState(); // fire-and-forget — errors handled internally + + // Run project setup commands after the worktree exists but while the agent + // is still initializing. The agent's initialPrompt is stashed so it can't + // fire off work before setup completes. + if (gitIsolation === 'worktree') { + runSetupForTask(taskId, worktreePath, projectId); + } + return taskId; } @@ -289,6 +304,16 @@ export async function closeTask(taskId: string): Promise { // Stop plan file watcher to prevent FSWatcher leak invoke(IPC.StopPlanWatcher, { taskId }).catch(console.error); + // Cancel in-flight setup if any — avoids zombie processes and the `.finally()` + // in runSetupForTask writing to a task that's about to be removed. Marking + // the taskId as cancelled lets the .catch handler distinguish an abort from + // a real failure without error-message string matching. + const setupChan = setupChannels.get(taskId); + if (setupChan) { + cancelledSetups.add(taskId); + invoke(IPC.CancelProjectCommands, { channelId: setupChan }).catch(console.error); + } + try { // Kill agents for (const agentId of agentIds) { @@ -300,6 +325,12 @@ export async function closeTask(taskId: string): Promise { // Skip git cleanup for direct mode (no worktree/branch) and imported worktrees (user-owned). if (task.gitIsolation === 'worktree' && !task.externalWorktree) { + // Run project teardown commands before removing the worktree. Best-effort: + // a failing teardown logs but does not block cleanup. + await runTeardownForTask(taskId, task.worktreePath, task.projectId).catch((err) => + console.warn('Teardown failed:', err), + ); + // Remove worktree + branch await invoke(IPC.DeleteTask, { taskId, @@ -452,6 +483,11 @@ export function updateTaskNotes(taskId: string, notes: string): void { export async function sendPrompt(taskId: string, agentId: string, text: string): Promise { const task = store.tasks[taskId]; + // Drop user input while project setup is still running. The SetupBanner is + // visible above the terminal, so dropping silently won't confuse the user, + // and it prevents prompts from firing at the agent mid-`npm install`. + if (task?.setupStatus === 'running') return; + // When steps tracking is enabled but no initial prompt was provided in the dialog, // the steps instruction was never injected in createTask. Append it to the first // prompt the user sends so the agent still knows to maintain steps.json. @@ -723,3 +759,146 @@ export function setTaskStepsEnabled(taskId: string, enabled: boolean): void { setStore('tasks', taskId, 'stepsEnabled', enabled || undefined); setStore('showSteps', enabled); // remember as default for future tasks } + +// --- Setup / teardown --- + +/** Cap the setupLog at this many bytes. Noisy commands like `npm install` can + * emit megabytes of output; letting setupLog grow unbounded becomes O(n²) in + * string concatenation and layout cost. Head is trimmed on overflow — tail + * is what the user usually cares about on failure. */ +const MAX_SETUP_LOG_BYTES = 64 * 1024; +const SETUP_LOG_TRIM_NOTICE = '…(earlier output trimmed)…\n'; +const TEARDOWN_TIMEOUT_MS = 30_000; + +// Task initialPrompt stashed while setup is running, restored on success or skip. +const stashedPrompts = new Map(); +// Active setup/teardown channel per task, used so closeTask can cancel in-flight work. +const setupChannels = new Map(); +// Tracks taskIds whose setup was deliberately cancelled, so the .catch handler +// can distinguish cancellation from a real failure without error-string matching. +const cancelledSetups = new Set(); + +function appendSetupLog(taskId: string, msg: string): void { + if (!store.tasks[taskId]) return; + const current = store.tasks[taskId].setupLog ?? ''; + const combined = current + msg; + if (combined.length <= MAX_SETUP_LOG_BYTES) { + setStore('tasks', taskId, 'setupLog', combined); + return; + } + const keep = MAX_SETUP_LOG_BYTES - SETUP_LOG_TRIM_NOTICE.length; + setStore('tasks', taskId, 'setupLog', SETUP_LOG_TRIM_NOTICE + combined.slice(-keep)); +} + +function restoreStashedPrompt(taskId: string): void { + const prompt = stashedPrompts.get(taskId); + if (prompt !== undefined) { + stashedPrompts.delete(taskId); + if (store.tasks[taskId]) setStore('tasks', taskId, 'initialPrompt', prompt); + } +} + +function runSetupForTask(taskId: string, worktreePath: string, projectId: string): void { + const task = store.tasks[taskId]; + if (!task) return; + + const commands = getProjectSetupCommands(projectId); + if (!commands) return; + + // Stash the initial prompt so the agent doesn't send it while setup runs. + if (task.initialPrompt) { + stashedPrompts.set(taskId, task.initialPrompt); + setStore('tasks', taskId, 'initialPrompt', undefined); + } + + setStore('tasks', taskId, 'setupStatus', 'running'); + setStore('tasks', taskId, 'setupLog', ''); + setStore('tasks', taskId, 'setupError', undefined); + + const channel = new Channel(); + setupChannels.set(taskId, channel.id); + cancelledSetups.delete(taskId); + + channel.onmessage = (msg: string) => appendSetupLog(taskId, msg); + + const projectRoot = getProjectPath(projectId) ?? worktreePath; + + invoke(IPC.RunSetupCommands, { + worktreePath, + projectRoot, + commands, + onOutput: channel, + }) + .then(() => { + if (!store.tasks[taskId]) return; + setStore('tasks', taskId, 'setupStatus', 'done'); + restoreStashedPrompt(taskId); + }) + .catch((err: unknown) => { + // Deliberate cancellation during close — not a failure to surface. + if (cancelledSetups.has(taskId)) return; + if (!store.tasks[taskId]) return; + setStore('tasks', taskId, 'setupStatus', 'failed'); + setStore('tasks', taskId, 'setupError', String(err)); + }) + .finally(() => { + if (setupChannels.get(taskId) === channel.id) setupChannels.delete(taskId); + cancelledSetups.delete(taskId); + stashedPrompts.delete(taskId); + channel.dispose(); + }); +} + +async function runTeardownForTask( + taskId: string, + worktreePath: string, + projectId: string, +): Promise { + const commands = getProjectTeardownCommands(projectId); + if (!commands) return; + + // Surface teardown output to console so failures are diagnosable. + const channel = new Channel(); + channel.onmessage = (msg: string) => { + console.warn(`[teardown ${taskId}]`, msg.replace(/\n$/, '')); + }; + + // Reuse setupChannels so closeTask's cancel path can abort a stuck teardown too. + setupChannels.set(taskId, channel.id); + + // Teardown commands like `docker compose down` can hang indefinitely; fall + // back to aborting after a generous timeout so closeTask never blocks forever. + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + invoke(IPC.CancelProjectCommands, { channelId: channel.id }).catch(console.error); + }, TEARDOWN_TIMEOUT_MS); + + const projectRoot = getProjectPath(projectId) ?? worktreePath; + try { + await invoke(IPC.RunTeardownCommands, { + worktreePath, + projectRoot, + commands, + onOutput: channel, + }); + } finally { + clearTimeout(timer); + if (setupChannels.get(taskId) === channel.id) setupChannels.delete(taskId); + channel.dispose(); + if (timedOut) console.warn(`[teardown ${taskId}] aborted after ${TEARDOWN_TIMEOUT_MS}ms`); + } +} + +export function retrySetup(taskId: string): void { + const task = store.tasks[taskId]; + if (!task) return; + runSetupForTask(taskId, task.worktreePath, task.projectId); +} + +export function skipSetup(taskId: string): void { + setStore('tasks', taskId, 'setupStatus', undefined); + setStore('tasks', taskId, 'setupLog', undefined); + setStore('tasks', taskId, 'setupError', undefined); + restoreStashedPrompt(taskId); +} diff --git a/src/store/types.ts b/src/store/types.ts index a04b50f5..284b13f4 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -21,6 +21,9 @@ export interface Project { defaultGitIsolation?: GitIsolationMode; defaultBaseBranch?: string; terminalBookmarks?: TerminalBookmark[]; + defaultSymlinkDirs?: string[]; + setupCommands?: string[]; + teardownCommands?: string[]; } export interface Agent { @@ -65,6 +68,9 @@ export interface Task { stepsEnabled?: boolean; stepsContent?: StepEntry[]; lastInputAt?: string; + setupStatus?: 'running' | 'done' | 'failed'; + setupLog?: string; + setupError?: string; } export interface Terminal {