diff --git a/src/main/index.ts b/src/main/index.ts index 61e04e6..83dbf5a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, ipcMain, safeStorage, screen } from 'electron' +import { app, BrowserWindow, safeStorage, screen } from 'electron' import { join } from 'path' import { readFileSync } from 'fs' import { createPtyManager, type PtyManager } from './pty-manager' @@ -13,7 +13,6 @@ import type { WorkflowEngine } from './workflow-engine' import { createWorktreeManager, type WorktreeManager } from './worktree-manager' import { createWslGitPort } from './git-port' import { createCostTracker, type CostTracker } from './cost-tracker' -import { SAFE_ID_RE } from './validation' /** Read persisted theme at startup to match BrowserWindow background to the active theme */ const THEME_BG0: Record = { @@ -48,6 +47,7 @@ import { registerSkillHandlers, registerWorktreeHandlers, registerHomeHandlers, + registerCostHandlers, costHistory, reviewTracker, } from './ipc' @@ -266,42 +266,7 @@ app costTracker = createCostTracker(mainWindow, [createClaudeAdapter(), createCodexAdapter()]) } - ipcMain.handle( - 'cost:bind', - ( - _, - sessionId: string, - opts: { agent: string; projectPath: string; cwd: string; spawnAt: number }, - ) => { - // R3-01: Validate sessionId with SAFE_ID_RE consistent with all other IPC handlers - if (typeof sessionId !== 'string' || !SAFE_ID_RE.test(sessionId)) { - throw new Error('cost:bind requires a valid sessionId') - } - if (!opts || typeof opts !== 'object') { - throw new Error('cost:bind requires an opts object') - } - if (typeof opts.agent !== 'string' || !opts.agent) { - throw new Error('cost:bind requires a non-empty agent') - } - if (typeof opts.cwd !== 'string' || !opts.cwd) { - throw new Error('cost:bind requires a non-empty cwd') - } - // R2-23: Validate spawnAt and projectPath types - if (typeof opts.spawnAt !== 'number' || !Number.isFinite(opts.spawnAt)) { - throw new Error('cost:bind requires a finite numeric spawnAt') - } - if (opts.projectPath !== undefined && typeof opts.projectPath !== 'string') { - throw new Error('cost:bind requires a string projectPath') - } - costTracker?.bindSession(sessionId, opts) - }, - ) - ipcMain.handle('cost:unbind', (_, sessionId: string) => { - if (typeof sessionId !== 'string' || !SAFE_ID_RE.test(sessionId)) { - throw new Error('cost:unbind requires a valid sessionId') - } - costTracker?.unbindSession(sessionId) - }) + registerCostHandlers(() => costTracker) // Warn renderer if encryption is unavailable (secrets stored as plaintext) if (!safeStorage.isEncryptionAvailable() && mainWindow) { diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index db443d7..a4e738d 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -15,3 +15,4 @@ export { registerUtilHandlers } from './ipc-utils' export { registerSkillHandlers } from './ipc-skills' export { registerWorktreeHandlers } from './ipc-worktree' export { registerHomeHandlers, costHistory, reviewTracker } from './ipc-home' +export { registerCostHandlers } from './ipc-cost' diff --git a/src/main/ipc/ipc-cost.ts b/src/main/ipc/ipc-cost.ts new file mode 100644 index 0000000..3ee96ef --- /dev/null +++ b/src/main/ipc/ipc-cost.ts @@ -0,0 +1,44 @@ +import { ipcMain } from 'electron' +import { SAFE_ID_RE } from '../validation' +import type { CostTracker } from '../cost-tracker' + +/** + * Cost IPC handlers: bind/unbind session cost tracking. + */ +export function registerCostHandlers(getCostTracker: () => CostTracker | null): void { + ipcMain.handle( + 'cost:bind', + ( + _, + sessionId: string, + opts: { agent: string; projectPath: string; cwd: string; spawnAt: number }, + ) => { + if (typeof sessionId !== 'string' || !SAFE_ID_RE.test(sessionId)) { + throw new Error('cost:bind requires a valid sessionId') + } + if (!opts || typeof opts !== 'object') { + throw new Error('cost:bind requires an opts object') + } + if (typeof opts.agent !== 'string' || !opts.agent) { + throw new Error('cost:bind requires a non-empty agent') + } + if (typeof opts.cwd !== 'string' || !opts.cwd) { + throw new Error('cost:bind requires a non-empty cwd') + } + if (typeof opts.spawnAt !== 'number' || !Number.isFinite(opts.spawnAt)) { + throw new Error('cost:bind requires a finite numeric spawnAt') + } + if (opts.projectPath !== undefined && typeof opts.projectPath !== 'string') { + throw new Error('cost:bind requires a string projectPath') + } + getCostTracker()?.bindSession(sessionId, opts) + }, + ) + + ipcMain.handle('cost:unbind', (_, sessionId: string) => { + if (typeof sessionId !== 'string' || !SAFE_ID_RE.test(sessionId)) { + throw new Error('cost:unbind requires a valid sessionId') + } + getCostTracker()?.unbindSession(sessionId) + }) +} diff --git a/src/main/workflow-engine.ts b/src/main/workflow-engine.ts index 4aa1dd8..ba6f099 100644 --- a/src/main/workflow-engine.ts +++ b/src/main/workflow-engine.ts @@ -189,106 +189,12 @@ export function createWorkflowEngine( return 'false' } - // ── Process a single node ────────────────────────────────────── - async function processNode( + // ── Retry-and-record helper ──────────────────────────────────── + /** Execute a non-condition node with retry logic and record the result. */ + async function executeWithRetry( node: WorkflowNode, - scheduler: ReturnType, - loopEdgesByCondition: Map, - loopCounters: Map, - ): Promise { - if (stopped) return - - // Condition nodes: evaluate inline, no process spawned - if (node.type === 'condition') { - const condStartTime = Date.now() - nodeExecCount.set(node.id, (nodeExecCount.get(node.id) ?? 0) + 1) - - push(workflow.id, { - type: 'node:started', - workflowId: workflow.id, - nodeId: node.id, - message: `Evaluating ${node.name}`, - }) - - const branch = evaluateCondition(node) - push(workflow.id, { - type: 'node:done', - workflowId: workflow.id, - nodeId: node.id, - message: `Condition: ${branch}`, - branch, - }) - scheduler.resolveCondition(node.id, branch) - - const condFinishTime = Date.now() - const condNodeRun: WorkflowNodeRun = { - nodeId: node.id, - nodeName: node.name, - status: 'done', - startedAt: condStartTime, - finishedAt: condFinishTime, - durationMs: condFinishTime - condStartTime, - branchTaken: branch, - } - const condExecN = nodeExecCount.get(node.id) ?? 1 - if (condExecN > 1) condNodeRun.loopIterations = condExecN - recorder.recordNode(condNodeRun) - - // Handle loop edges - const condLoops = loopEdgesByCondition.get(node.id) ?? [] - for (const le of condLoops) { - if (le.branch === branch) { - const count = (loopCounters.get(le.id) ?? 0) + 1 - loopCounters.set(le.id, count) - if (count <= (le.maxIterations ?? 1)) { - push(workflow.id, { - type: 'node:loopIteration', - workflowId: workflow.id, - nodeId: node.id, - iteration: count, - maxIterations: le.maxIterations, - message: `Loop iteration ${String(count)}/${String(le.maxIterations)}`, - }) - const resetIds = scheduler.resetLoopSubgraph(le.toNodeId, node.id) - // REL-7: Clear loop counters for inner loop edges within the reset subgraph - // so nested loops restart correctly on each outer iteration. - // BUG-5/CDX-5: Also check toNodeId is in resetIds — prevents sibling loop - // edges from the same condition node from having their counters cleared - for (const innerLoops of loopEdgesByCondition.values()) { - for (const innerLe of innerLoops) { - if ( - innerLe.id !== le.id && - resetIds.has(innerLe.fromNodeId) && - resetIds.has(innerLe.toNodeId) - ) { - loopCounters.delete(innerLe.id) - } - } - } - // PERF-4: Clear output maps for re-executing nodes to prevent unbounded growth - for (const nid of resetIds) { - nodeOutputs.delete(nid) - conditionOutputs.delete(nid) - } - } - } - } - return - } - - // Build context summary from upstream node outputs - const upstreamEdges = workflow.edges.filter( - (e) => e.toNodeId === node.id && e.edgeType !== 'loop', - ) - const contextSummary = upstreamEdges - .map((e) => { - const out = nodeOutputs.get(e.fromNodeId) - return out ? `[${e.fromNodeId}]: ${out.slice(-4000)}` : '' - }) - .filter(Boolean) - .join('\n\n') - - // Run with retry + contextSummary: string, + ): Promise<'success' | 'failed' | 'stopped'> { const maxAttempts = (node.retryCount ?? 0) + 1 const retryDelay = node.retryDelayMs ?? 2000 let lastError: Error | undefined @@ -297,7 +203,7 @@ export function createWorkflowEngine( nodeExecCount.set(node.id, (nodeExecCount.get(node.id) ?? 0) + 1) for (let attempt = 1; attempt <= maxAttempts; attempt++) { - if (stopped) return + if (stopped) return 'stopped' if (attempt > 1) { push(workflow.id, { type: 'node:retry', @@ -337,7 +243,7 @@ export function createWorkflowEngine( message: node.message ?? 'Waiting for user to continue...', }) await onCheckpoint(node.id) - if (stopped) return + if (stopped) return 'stopped' push(workflow.id, { type: 'node:resumed', workflowId: workflow.id, @@ -354,7 +260,6 @@ export function createWorkflowEngine( nodeId: node.id, message: `${node.name} completed`, }) - scheduler.completeNode(node.id) // Record success in run history const doneTime = Date.now() @@ -371,7 +276,7 @@ export function createWorkflowEngine( if (execN > 1) doneNodeRun.loopIterations = execN recorder.recordNode(doneNodeRun) - return // success, no more retries + return 'success' } catch (err) { runningNodeIds.delete(node.id) lastError = err instanceof Error ? err : new Error(String(err)) @@ -407,11 +312,123 @@ export function createWorkflowEngine( if (errExecN > 1) errNodeRun.loopIterations = errExecN recorder.recordNode(errNodeRun) - if (node.continueOnError) { - scheduler.completeNode(node.id) // treat as done for scheduling + return 'failed' + } + + // ── Process a single node ────────────────────────────────────── + async function processNode( + node: WorkflowNode, + scheduler: ReturnType, + loopEdgesByCondition: Map, + loopCounters: Map, + ): Promise { + if (stopped) return + + // Condition nodes: evaluate inline, no process spawned + if (node.type === 'condition') { + const condStartTime = Date.now() + nodeExecCount.set(node.id, (nodeExecCount.get(node.id) ?? 0) + 1) + + push(workflow.id, { + type: 'node:started', + workflowId: workflow.id, + nodeId: node.id, + message: `Evaluating ${node.name}`, + }) + + const branch = evaluateCondition(node) + push(workflow.id, { + type: 'node:done', + workflowId: workflow.id, + nodeId: node.id, + message: `Condition: ${branch}`, + branch, + }) + scheduler.resolveCondition(node.id, branch) + + const condFinishTime = Date.now() + const condNodeRun: WorkflowNodeRun = { + nodeId: node.id, + nodeName: node.name, + status: 'done', + startedAt: condStartTime, + finishedAt: condFinishTime, + durationMs: condFinishTime - condStartTime, + branchTaken: branch, + } + const condExecN = nodeExecCount.get(node.id) ?? 1 + if (condExecN > 1) condNodeRun.loopIterations = condExecN + recorder.recordNode(condNodeRun) + + // Handle loop edges + const condLoops = loopEdgesByCondition.get(node.id) ?? [] + for (const le of condLoops) { + if (le.branch === branch) { + const count = (loopCounters.get(le.id) ?? 0) + 1 + loopCounters.set(le.id, count) + if (count <= (le.maxIterations ?? 1)) { + push(workflow.id, { + type: 'node:loopIteration', + workflowId: workflow.id, + nodeId: node.id, + iteration: count, + maxIterations: le.maxIterations, + message: `Loop iteration ${String(count)}/${String(le.maxIterations)}`, + }) + const resetIds = scheduler.resetLoopSubgraph(le.toNodeId, node.id) + // REL-7: Clear loop counters for inner loop edges within the reset subgraph + // so nested loops restart correctly on each outer iteration. + // BUG-5/CDX-5: Also check toNodeId is in resetIds — prevents sibling loop + // edges from the same condition node from having their counters cleared + for (const innerLoops of loopEdgesByCondition.values()) { + for (const innerLe of innerLoops) { + if ( + innerLe.id !== le.id && + resetIds.has(innerLe.fromNodeId) && + resetIds.has(innerLe.toNodeId) + ) { + loopCounters.delete(innerLe.id) + } + } + } + // PERF-4: Clear output maps for re-executing nodes to prevent unbounded growth + for (const nid of resetIds) { + nodeOutputs.delete(nid) + conditionOutputs.delete(nid) + } + } + } + } + return + } + + // Build context summary from upstream node outputs + const upstreamEdges = workflow.edges.filter( + (e) => e.toNodeId === node.id && e.edgeType !== 'loop', + ) + const contextSummary = upstreamEdges + .map((e) => { + const out = nodeOutputs.get(e.fromNodeId) + return out ? `[${e.fromNodeId}]: ${out.slice(-4000)}` : '' + }) + .filter(Boolean) + .join('\n\n') + + // Run with retry, record result + const result = await executeWithRetry(node, contextSummary) + + if (result === 'stopped') return + + if (result === 'success') { + scheduler.completeNode(node.id) } else { - scheduler.failNode(node.id) - stopped = true + // result === 'failed' + if (node.continueOnError) { + scheduler.completeNode(node.id) // treat as done for scheduling + } else { + scheduler.failNode(node.id) + stopped = true + } } } diff --git a/src/renderer/components/NewProjectWizard/NewProjectWizard.css b/src/renderer/components/NewProjectWizard/NewProjectWizard.css index 115bf05..c71d7c8 100644 --- a/src/renderer/components/NewProjectWizard/NewProjectWizard.css +++ b/src/renderer/components/NewProjectWizard/NewProjectWizard.css @@ -430,3 +430,9 @@ .wizard-section-sep { margin-top: var(--sp-6); } + +@media (prefers-reduced-motion: reduce) { + .wizard-step-panel.active { + animation: none; + } +} diff --git a/src/renderer/components/ProjectSettings/ProjectSettings.css b/src/renderer/components/ProjectSettings/ProjectSettings.css index 9b88f3e..0688330 100644 --- a/src/renderer/components/ProjectSettings/ProjectSettings.css +++ b/src/renderer/components/ProjectSettings/ProjectSettings.css @@ -742,6 +742,12 @@ margin-top: 2px; } +@media (prefers-reduced-motion: reduce) { + .settings-tab-panel { + animation: none; + } +} + /* Responsive: stack form rows on narrow windows */ @media (max-width: 900px) { .form-row { diff --git a/src/renderer/components/RightPanel/ActivityTab.css b/src/renderer/components/RightPanel/ActivityTab.css index 24afe6c..e292684 100644 --- a/src/renderer/components/RightPanel/ActivityTab.css +++ b/src/renderer/components/RightPanel/ActivityTab.css @@ -114,3 +114,9 @@ opacity: 0.4; } } + +@media (prefers-reduced-motion: reduce) { + .activity-dot.active { + animation: none; + } +} diff --git a/src/renderer/components/StatusBar/StatusBar.css b/src/renderer/components/StatusBar/StatusBar.css index faa6ae9..f62e8af 100644 --- a/src/renderer/components/StatusBar/StatusBar.css +++ b/src/renderer/components/StatusBar/StatusBar.css @@ -67,7 +67,7 @@ border: 1px solid var(--border); color: var(--text1); padding: 0 var(--sp-2); - height: 22px; + min-height: 32px; border-radius: 3px; font-size: var(--fs-xs); cursor: pointer; diff --git a/src/renderer/components/Terminal/TerminalPane.css b/src/renderer/components/Terminal/TerminalPane.css index 262032b..710d3d0 100644 --- a/src/renderer/components/Terminal/TerminalPane.css +++ b/src/renderer/components/Terminal/TerminalPane.css @@ -147,3 +147,9 @@ background: var(--bg3); margin: var(--sp-1) 0; } + +@media (prefers-reduced-motion: reduce) { + .term-copy-flash { + animation: none; + } +} diff --git a/src/renderer/components/Terminal/TerminalSearchBar.css b/src/renderer/components/Terminal/TerminalSearchBar.css index 581b18a..fcf3bac 100644 --- a/src/renderer/components/Terminal/TerminalSearchBar.css +++ b/src/renderer/components/Terminal/TerminalSearchBar.css @@ -78,6 +78,8 @@ .term-search-toggle { width: 26px; height: 26px; + min-width: 32px; + min-height: 32px; display: flex; align-items: center; justify-content: center; @@ -107,6 +109,8 @@ .term-search-nav { width: 26px; height: 26px; + min-width: 32px; + min-height: 32px; display: flex; align-items: center; justify-content: center; @@ -135,6 +139,8 @@ .term-search-close { width: 26px; height: 26px; + min-width: 32px; + min-height: 32px; display: flex; align-items: center; justify-content: center; @@ -152,3 +158,16 @@ background: var(--bg3); color: var(--red); } + +@media (prefers-reduced-motion: reduce) { + .term-search-bar { + animation: none; + } + + .term-search-input, + .term-search-toggle, + .term-search-nav, + .term-search-close { + transition: none; + } +} diff --git a/src/renderer/components/Titlebar/Titlebar.css b/src/renderer/components/Titlebar/Titlebar.css index e74667c..e4a75c4 100644 --- a/src/renderer/components/Titlebar/Titlebar.css +++ b/src/renderer/components/Titlebar/Titlebar.css @@ -195,6 +195,8 @@ .tab-close { width: 20px; height: 20px; + min-width: 32px; + min-height: 32px; border-radius: var(--r); display: flex; align-items: center; diff --git a/src/renderer/components/shared/Toggle.css b/src/renderer/components/shared/Toggle.css index 02e9618..71582c9 100644 --- a/src/renderer/components/shared/Toggle.css +++ b/src/renderer/components/shared/Toggle.css @@ -3,6 +3,7 @@ align-items: center; gap: var(--sp-2); cursor: pointer; + min-height: 32px; } .toggle { @@ -13,7 +14,8 @@ border: 1px solid var(--border); background: var(--bg4); cursor: pointer; - padding: 0; + padding: 7px 0; + box-sizing: content-box; flex-shrink: 0; transition: background var(--duration-slow), @@ -27,7 +29,7 @@ .toggle-thumb { position: absolute; - top: 1px; + top: 8px; left: 1px; width: 14px; height: 14px; diff --git a/src/renderer/screens/WorkflowEditor/WorkflowEditor.css b/src/renderer/screens/WorkflowEditor/WorkflowEditor.css index ac28f12..481f1b3 100644 --- a/src/renderer/screens/WorkflowEditor/WorkflowEditor.css +++ b/src/renderer/screens/WorkflowEditor/WorkflowEditor.css @@ -302,3 +302,9 @@ text-align: center; border-bottom: 1px solid var(--red); } + +@media (prefers-reduced-motion: reduce) { + .wf-status-dot.running { + animation: none; + } +} diff --git a/src/renderer/screens/WorkflowEditor/WorkflowLogPanel.css b/src/renderer/screens/WorkflowEditor/WorkflowLogPanel.css index aa1d4a5..ec70042 100644 --- a/src/renderer/screens/WorkflowEditor/WorkflowLogPanel.css +++ b/src/renderer/screens/WorkflowEditor/WorkflowLogPanel.css @@ -263,3 +263,9 @@ border-color: var(--purple); background: rgba(var(--purple-rgb), 0.2); } + +@media (prefers-reduced-motion: reduce) { + .wf-log-node-hdr-dot.run { + animation: none; + } +} diff --git a/src/renderer/screens/WorkflowEditor/WorkflowNodeEditorPanel.css b/src/renderer/screens/WorkflowEditor/WorkflowNodeEditorPanel.css index 8ff2369..92a1e2e 100644 --- a/src/renderer/screens/WorkflowEditor/WorkflowNodeEditorPanel.css +++ b/src/renderer/screens/WorkflowEditor/WorkflowNodeEditorPanel.css @@ -447,3 +447,9 @@ .wf-ne-summary:hover { color: var(--text0); } + +@media (prefers-reduced-motion: reduce) { + .wf-ne-role-form { + animation: none; + } +} diff --git a/src/renderer/store/slices/sessions.ts b/src/renderer/store/slices/sessions.ts index 318151f..fb0a626 100644 --- a/src/renderer/store/slices/sessions.ts +++ b/src/renderer/store/slices/sessions.ts @@ -43,6 +43,14 @@ export interface SessionsSlice { totalCostUsd: number }, ) => void + + // Worktree isolation paths (per-session) + worktreePaths: Record + setWorktreePath: ( + sessionId: string, + result: { path: string; isolated: boolean; branch?: string | undefined }, + ) => void + clearWorktreePath: (sessionId: string) => void } export const createSessionsSlice: StateCreator = (set, get) => ({ @@ -230,4 +238,14 @@ export const createSessionsSlice: StateCreator [sessionId]: [], }, })), + + // Worktree isolation paths + worktreePaths: {}, + setWorktreePath: (sessionId, result) => + set((s) => ({ worktreePaths: { ...s.worktreePaths, [sessionId]: result } })), + clearWorktreePath: (sessionId) => + set((s) => { + const { [sessionId]: _, ...rest } = s.worktreePaths + return { worktreePaths: rest } + }), }) diff --git a/src/renderer/store/slices/ui.ts b/src/renderer/store/slices/ui.ts index e832e53..f40ea5a 100644 --- a/src/renderer/store/slices/ui.ts +++ b/src/renderer/store/slices/ui.ts @@ -65,14 +65,6 @@ export interface UiSlice { // Theme theme: string setTheme: (name: string) => void - - // Worktree isolation paths (per-session) - worktreePaths: Record - setWorktreePath: ( - sessionId: string, - result: { path: string; isolated: boolean; branch?: string | undefined }, - ) => void - clearWorktreePath: (sessionId: string) => void } export const createUiSlice: StateCreator = (set, get) => ({ @@ -244,14 +236,4 @@ export const createUiSlice: StateCreator = (set, get) window.agentDeck.theme.set(name) set({ theme: name }) }, - - // Worktree isolation paths - worktreePaths: {}, - setWorktreePath: (sessionId, result) => - set((s) => ({ worktreePaths: { ...s.worktreePaths, [sessionId]: result } })), - clearWorktreePath: (sessionId) => - set((s) => { - const { [sessionId]: _, ...rest } = s.worktreePaths - return { worktreePaths: rest } - }), })