From f27f8907577529e1d2a661fdd15bc7da5988199d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Apr 2026 17:55:27 +0000 Subject: [PATCH 1/3] feat: add steps.json status card with collapsible history Add a new "Steps tracking" feature that watches .claude/steps.json in each task's worktree and renders agent progress as a collapsible panel row. Backend: New file watcher (electron/ipc/steps.ts) monitors the .claude directory for steps.json changes, with 200ms debounce and initial read on startup. Three new IPC channels (StepsContent, ReadStepsContent, StopStepsWatcher) with proper cleanup on task deletion and app quit. Frontend: New TaskStepsSection component shows latest step expanded with status badge, summary, detail, files touched, and timestamp. Previous steps render as a collapsible history list. StatusDot gains a 'review' state (purple) when the latest step is 'awaiting_review'. Settings: Global "Steps tracking" toggle (off by default) controls panel visibility. When enabled, the initial prompt is prepended with instructions telling the agent to maintain the steps.json file. Also extracts badgeStyle() to shared src/lib/badgeStyle.ts utility. https://claude.ai/code/session_01AH4Mv3esftK1SvajXcMJ7p --- electron/ipc/channels.ts | 5 + electron/ipc/register.ts | 18 ++ electron/ipc/steps.ts | 107 +++++++++ electron/ipc/tasks.ts | 2 + electron/main.ts | 2 + electron/preload.cjs | 4 + src/App.tsx | 26 +++ src/components/SettingsDialog.tsx | 27 +++ src/components/StatusDot.tsx | 4 +- src/components/TaskPanel.tsx | 15 ++ src/components/TaskStepsSection.tsx | 339 ++++++++++++++++++++++++++++ src/components/TaskTitleBar.tsx | 15 +- src/ipc/types.ts | 8 + src/lib/badgeStyle.ts | 13 ++ src/store/autosave.ts | 1 + src/store/core.ts | 1 + src/store/persistence.ts | 5 + src/store/store.ts | 2 + src/store/taskStatus.ts | 9 +- src/store/tasks.ts | 21 +- src/store/types.ts | 6 +- src/store/ui.ts | 4 + 22 files changed, 616 insertions(+), 18 deletions(-) create mode 100644 electron/ipc/steps.ts create mode 100644 src/components/TaskStepsSection.tsx create mode 100644 src/lib/badgeStyle.ts 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) { +