diff --git a/electron/ipc/channels.ts b/electron/ipc/channels.ts index a656462d..0bbfcdf3 100644 --- a/electron/ipc/channels.ts +++ b/electron/ipc/channels.ts @@ -86,6 +86,11 @@ export enum IPC { ReadPlanContent = 'read_plan_content', StopPlanWatcher = 'stop_plan_watcher', + // Steps + StepsContent = 'steps_content', + ReadStepsContent = 'read_steps_content', + StopStepsWatcher = 'stop_steps_watcher', + // Ask about code AskAboutCode = 'ask_about_code', CancelAskAboutCode = 'cancel_ask_about_code', diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts index 3290b74c..8217ebb3 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -23,6 +23,7 @@ import { stopPlanWatcher, readPlanForWorktree, } from './plans.js'; +import { startStepsWatcher, stopStepsWatcher, readStepsForWorktree } from './steps.js'; import { startRemoteServer } from '../remote/server.js'; import { getGitIgnoredDirs, @@ -144,6 +145,11 @@ export function registerAllHandlers(win: BrowserWindow): void { } catch (err) { console.warn('Failed to start plan watcher:', err); } + try { + startStepsWatcher(win, args.taskId, args.cwd); + } catch (err) { + console.warn('Failed to start steps watcher:', err); + } } return result; }); @@ -434,6 +440,18 @@ export function registerAllHandlers(win: BrowserWindow): void { return readPlanForWorktree(args.worktreePath, fileName); }); + // --- Steps watcher cleanup --- + ipcMain.handle(IPC.StopStepsWatcher, (_e, args) => { + assertString(args.taskId, 'taskId'); + stopStepsWatcher(args.taskId); + }); + + // --- Steps content (one-shot read) --- + ipcMain.handle(IPC.ReadStepsContent, (_e, args) => { + validatePath(args.worktreePath, 'worktreePath'); + return readStepsForWorktree(args.worktreePath); + }); + // --- Ask about code --- ipcMain.handle(IPC.AskAboutCode, (_e, args) => { assertString(args.requestId, 'requestId'); diff --git a/electron/ipc/steps.ts b/electron/ipc/steps.ts new file mode 100644 index 00000000..5a65f4a0 --- /dev/null +++ b/electron/ipc/steps.ts @@ -0,0 +1,107 @@ +import fs from 'fs'; +import path from 'path'; +import type { BrowserWindow } from 'electron'; +import { IPC } from './channels.js'; + +interface StepsWatcher { + fsWatcher: fs.FSWatcher | null; + timeout: ReturnType | null; + stepsDir: string; + stepsFile: string; +} + +const watchers = new Map(); + +/** Sends parsed steps content for a task to the renderer. */ +function sendStepsContent(win: BrowserWindow, taskId: string, stepsFile: string): void { + if (win.isDestroyed()) return; + const steps = readStepsFile(stepsFile); + win.webContents.send(IPC.StepsContent, { taskId, steps }); +} + +/** Reads and parses `.claude/steps.json`. Returns the array or null. */ +function readStepsFile(stepsFile: string): unknown[] | null { + try { + const raw = fs.readFileSync(stepsFile, 'utf-8'); + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return null; + return parsed as unknown[]; + } catch { + return null; + } +} + +/** + * Watches the `.claude` directory for changes to `steps.json`. + * + * We watch the directory (not the file) because `fs.watch` on a single + * file is unreliable with atomic writes (temp-file-then-rename), + * especially on macOS. Changes are debounced (200ms) before reading. + * + * An initial read is performed after starting the watcher to handle + * the race condition where the agent writes before the watcher is set up. + */ +export function startStepsWatcher(win: BrowserWindow, taskId: string, worktreePath: string): void { + stopStepsWatcher(taskId); + + const stepsDir = path.join(worktreePath, '.claude'); + const stepsFile = path.join(stepsDir, 'steps.json'); + + const entry: StepsWatcher = { + fsWatcher: null, + timeout: null, + stepsDir, + stepsFile, + }; + + const onChange = () => { + const current = watchers.get(taskId); + if (!current) return; + if (current.timeout) clearTimeout(current.timeout); + current.timeout = setTimeout(() => { + current.timeout = null; + sendStepsContent(win, taskId, current.stepsFile); + }, 200); + }; + + // Start watching the .claude directory + if (fs.existsSync(stepsDir)) { + try { + entry.fsWatcher = fs.watch(stepsDir, onChange); + entry.fsWatcher.on('error', (err) => { + console.warn(`Steps watcher error for ${stepsDir}:`, err); + }); + } catch (err) { + console.warn(`Failed to watch steps directory ${stepsDir}:`, err); + } + } + + watchers.set(taskId, entry); + + // Initial read to catch files written before the watcher was set up + if (fs.existsSync(stepsFile)) { + sendStepsContent(win, taskId, stepsFile); + } +} + +/** Stops and removes the steps watcher for a given task. */ +export function stopStepsWatcher(taskId: string): void { + const entry = watchers.get(taskId); + if (!entry) return; + if (entry.timeout) clearTimeout(entry.timeout); + if (entry.fsWatcher) entry.fsWatcher.close(); + watchers.delete(taskId); +} + +/** Read steps.json from a worktree. Used for one-shot restore. */ +export function readStepsForWorktree(worktreePath: string): unknown[] | null { + const stepsFile = path.join(worktreePath, '.claude', 'steps.json'); + return readStepsFile(stepsFile); +} + +/** Stops all steps watchers. */ +export function stopAllStepsWatchers(): void { + for (const taskId of watchers.keys()) { + stopStepsWatcher(taskId); + } +} diff --git a/electron/ipc/tasks.ts b/electron/ipc/tasks.ts index b46d6776..2a7f7ef4 100644 --- a/electron/ipc/tasks.ts +++ b/electron/ipc/tasks.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'; import { createWorktree, removeWorktree } from './git.js'; import { killAgent, notifyAgentListChanged } from './pty.js'; import { stopPlanWatcher } from './plans.js'; +import { stopStepsWatcher } from './steps.js'; const MAX_SLUG_LEN = 72; @@ -57,6 +58,7 @@ interface DeleteTaskOpts { export async function deleteTask(opts: DeleteTaskOpts): Promise { if (opts.taskId) stopPlanWatcher(opts.taskId); + if (opts.taskId) stopStepsWatcher(opts.taskId); for (const agentId of opts.agentIds) { try { killAgent(agentId); diff --git a/electron/main.ts b/electron/main.ts index a33b56a2..17bd0822 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -6,6 +6,7 @@ import { execFileSync } from 'child_process'; import { registerAllHandlers } from './ipc/register.js'; import { killAllAgents } from './ipc/pty.js'; import { stopAllPlanWatchers } from './ipc/plans.js'; +import { stopAllStepsWatchers } from './ipc/steps.js'; import { IPC } from './ipc/channels.js'; const __filename = fileURLToPath(import.meta.url); @@ -159,6 +160,7 @@ app.whenReady().then(() => { app.on('before-quit', () => { killAllAgents(); stopAllPlanWatchers(); + stopAllStepsWatchers(); }); app.on('window-all-closed', () => { diff --git a/electron/preload.cjs b/electron/preload.cjs index 0abbb51f..95427d1c 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -81,6 +81,10 @@ const ALLOWED_CHANNELS = new Set([ 'plan_content', 'read_plan_content', 'stop_plan_watcher', + // Steps + 'steps_content', + 'read_steps_content', + 'stop_steps_watcher', // Docker 'check_docker_available', 'check_docker_image_exists', diff --git a/src/App.tsx b/src/App.tsx index 044ef79c..a66724ca 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -43,6 +43,7 @@ import { setNewTaskDropUrl, validateProjectPaths, setPlanContent, + setStepsContent, setDockerAvailable, } from './store/store'; import { isGitHubUrl } from './lib/github-url'; @@ -310,6 +311,21 @@ function App() { }); } + // Restore steps content for tasks that had steps before restart + for (const taskId of [...store.taskOrder, ...store.collapsedTaskOrder]) { + const task = store.tasks[taskId]; + if (!task?.worktreePath) continue; + invoke(IPC.ReadStepsContent, { + worktreePath: task.worktreePath, + }) + .then((result) => { + if (result) setStepsContent(taskId, result); + }) + .catch((err) => { + console.warn(`Failed to restore steps for task ${taskId}:`, err); + }); + } + await validateProjectPaths(); await restoreWindowState(); await captureWindowState(); @@ -326,6 +342,15 @@ function App() { } }); + // Listen for steps content pushed from backend steps watcher + const offStepsContent = window.electron.ipcRenderer.on(IPC.StepsContent, (data: unknown) => { + if (!data || typeof data !== 'object') return; + const msg = data as { taskId: string; steps: unknown[] | null }; + if (msg.taskId && store.tasks[msg.taskId]) { + setStepsContent(msg.taskId, msg.steps); + } + }); + const handlePaste = (e: ClipboardEvent) => { if (store.showNewTaskDialog || store.showHelpDialog || store.showSettingsDialog) return; const el = document.activeElement; @@ -584,6 +609,7 @@ function App() { stopTaskStatusPolling(); stopNotificationWatcher(); offPlanContent(); + offStepsContent(); unlistenFocusChanged?.(); unlistenResized?.(); unlistenMoved?.(); diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx index 1554a203..284e7c5d 100644 --- a/src/components/SettingsDialog.tsx +++ b/src/components/SettingsDialog.tsx @@ -14,6 +14,7 @@ import { setThemePreset, setAutoTrustFolders, setShowPlans, + setShowSteps, setShowPromptInput, setDesktopNotificationsEnabled, setInactiveColumnOpacity, @@ -192,6 +193,32 @@ export function SettingsDialog(props: SettingsDialogProps) { +