Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions electron/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
18 changes: 18 additions & 0 deletions electron/ipc/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
});
Expand Down Expand Up @@ -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');
Expand Down
107 changes: 107 additions & 0 deletions electron/ipc/steps.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setTimeout> | null;
stepsDir: string;
stepsFile: string;
}

const watchers = new Map<string, StepsWatcher>();

/** 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);
}
}
2 changes: 2 additions & 0 deletions electron/ipc/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -57,6 +58,7 @@ interface DeleteTaskOpts {

export async function deleteTask(opts: DeleteTaskOpts): Promise<void> {
if (opts.taskId) stopPlanWatcher(opts.taskId);
if (opts.taskId) stopStepsWatcher(opts.taskId);
for (const agentId of opts.agentIds) {
try {
killAgent(agentId);
Expand Down
2 changes: 2 additions & 0 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -159,6 +160,7 @@ app.whenReady().then(() => {
app.on('before-quit', () => {
killAllAgents();
stopAllPlanWatchers();
stopAllStepsWatchers();
});

app.on('window-all-closed', () => {
Expand Down
4 changes: 4 additions & 0 deletions electron/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
26 changes: 26 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
setNewTaskDropUrl,
validateProjectPaths,
setPlanContent,
setStepsContent,
setDockerAvailable,
} from './store/store';
import { isGitHubUrl } from './lib/github-url';
Expand Down Expand Up @@ -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<unknown[] | null>(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();
Expand All @@ -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;
Expand Down Expand Up @@ -584,6 +609,7 @@ function App() {
stopTaskStatusPolling();
stopNotificationWatcher();
offPlanContent();
offStepsContent();
unlistenFocusChanged?.();
unlistenResized?.();
unlistenMoved?.();
Expand Down
27 changes: 27 additions & 0 deletions src/components/SettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
setThemePreset,
setAutoTrustFolders,
setShowPlans,
setShowSteps,
setShowPromptInput,
setDesktopNotificationsEnabled,
setInactiveColumnOpacity,
Expand Down Expand Up @@ -192,6 +193,32 @@ export function SettingsDialog(props: SettingsDialogProps) {
</span>
</div>
</label>
<label
style={{
display: 'flex',
'align-items': 'center',
gap: '10px',
cursor: 'pointer',
padding: '8px 12px',
'border-radius': '8px',
background: theme.bgInput,
border: `1px solid ${theme.border}`,
}}
>
<input
type="checkbox"
checked={store.showSteps}
onChange={(e) => setShowSteps(e.currentTarget.checked)}
style={{ 'accent-color': theme.accent, cursor: 'pointer' }}
/>
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '2px' }}>
<span style={{ 'font-size': '13px', color: theme.fg }}>Steps tracking</span>
<span style={{ 'font-size': '11px', color: theme.fgSubtle }}>
Show agent progress from steps.json in a collapsible panel. Agents are instructed to
maintain a .claude/steps.json file via the initial prompt.
</span>
</div>
</label>
<label
style={{
display: 'flex',
Expand Down
4 changes: 3 additions & 1 deletion src/components/StatusDot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { theme } from '../lib/theme';
const SIZES = { sm: 6, md: 8 } as const;

function getDotColor(status: TaskDotStatus): string {
return { busy: theme.fgMuted, waiting: '#e5a800', ready: theme.success }[status];
return { busy: theme.fgMuted, waiting: '#e5a800', ready: theme.success, review: '#c084fc' }[
status
];
}

export function StatusDot(props: { status: TaskDotStatus; size?: 'sm' | 'md' }) {
Expand Down
15 changes: 15 additions & 0 deletions src/components/TaskPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { TaskTitleBar } from './TaskTitleBar';
import { TaskBranchInfoBar } from './TaskBranchInfoBar';
import { TaskNotesPanel } from './TaskNotesPanel';
import { TaskShellSection } from './TaskShellSection';
import { TaskStepsSection } from './TaskStepsSection';
import { TaskAITerminal } from './TaskAITerminal';
import { TaskClosingOverlay } from './TaskClosingOverlay';
import { theme } from '../lib/theme';
Expand Down Expand Up @@ -148,6 +149,19 @@ export function TaskPanel(props: TaskPanelProps) {
};
}

function stepsSection(): PanelChild {
return {
id: 'steps-section',
initialSize: 28,
minSize: 28,
get fixed() {
return !props.task.stepsContent?.length;
},
requestSize: () => (props.task.stepsContent?.length ? 150 : 28),
content: () => <TaskStepsSection task={props.task} isActive={props.isActive} />,
};
}

function notesAndFiles(): PanelChild {
return {
id: 'notes-files',
Expand Down Expand Up @@ -245,6 +259,7 @@ export function TaskPanel(props: TaskPanelProps) {
children={[
titleBar(),
branchInfoBar(),
...(store.showSteps ? [stepsSection()] : []),
notesAndFiles(),
shellSection(),
aiTerminal(),
Expand Down
Loading
Loading