diff --git a/docs/reference/claude-workflow-status-groups.md b/docs/reference/claude-workflow-status-groups.md new file mode 100644 index 00000000000..be5ad960329 --- /dev/null +++ b/docs/reference/claude-workflow-status-groups.md @@ -0,0 +1,148 @@ +# Claude Workflow Status Groups + +## Problem + +Claude Code dynamic workflows spawn many subagents from one Claude turn, but Orca currently shows agent activity as independent pane rows. The shared status model carries one pane entry at a time (`src/shared/agent-status-types.ts:67`) and only has orchestration metadata for Orca-dispatched workers (`src/shared/agent-status-types.ts:58`). Worktree agent rows are built by indexing live entries by tab/pane (`src/renderer/src/components/sidebar/useWorktreeAgentRows.ts:89`) and then rendering parent/child lineage only when `entry.orchestration.parentPaneKey` exists (`src/renderer/src/components/sidebar/WorktreeCardAgents.tsx:55`). Claude workflow subagents are invisible to that lineage because they run inside Claude's own workflow files, not Orca's orchestration dispatch. + +## Goal + +Surface a Claude dynamic workflow as one compact workflow group under the parent Claude row, with its phase/subagent children visible in the same WorktreeCard agent-status list and dashboard data model. The user should understand "this Claude turn spawned 4 workflow agents, 3 done, 1 working" without opening Claude's transcript. + +## Non-goals + +- Do not reimplement Claude's `Workflow` tool or execute workflow JS. +- Do not add a new Orca orchestration skill or modify the existing orchestration skill. +- Do not create separate real terminals for Claude workflow subagents in this slice. +- Do not add resume/actions/detail panels; those belong to the sibling implementations. + +## Design + +1. Add workflow-specific shared types in a concrete file such as `src/shared/claude-workflow-status.ts`. + - Model `ClaudeWorkflowRun`, `ClaudeWorkflowAgent`, `ClaudeWorkflowPhase`, and `ClaudeWorkflowState`. + - Keep fields small: stable id, parent pane key, parent tab id when known, worktree id, connection id, run id, script path/transcript dir only when local, state, started/updated timestamps, label, last message, token count when available. + - Add explicit wire/snapshot types. Renderer payloads must contain summaries only, never raw transcript bodies or untrusted filesystem paths for remote runs. + - Use `.ts`, not `.d.ts`. + +2. Add a workflow detector/index beside the hook server. + - Tap the raw parsed Claude hook body in `src/main/agent-hooks/server.ts` before `normalizeHookPayload(...)`. The normalized `AgentStatusPayload` only preserves prompt/tool/message previews, so `scriptPath`, `runId`, `transcriptDir`, and raw `Workflow` tool result fields cannot be recovered later. + - Detect `Workflow` tool events/results only from Claude raw payloads when `tool_name`/result metadata and `scriptPath`, `runId`, or `transcriptDir` are present. Treat missing identifiers as "no workflow evidence", not as a best-guess scan. + - Scan only the discovered workflow directory and cache by `connectionId + parentPaneKey + scriptPath + runId`; do not crawl all of `~/.claude`. + - Parse JSON/JSONL defensively. Missing files, partial writes, and invalid JSON produce a degraded run with an error note, not an exception. + - Debounce file reads per run, cap bytes/lines read from JSONL previews, and reuse unchanged run objects so one JSONL append does not invalidate every worktree card. + - Use Node `path` APIs for path parsing/normalization. Do not split paths on `/`; Claude can run on Windows and SSH remotes. + +3. Expose workflow status through IPC beside agent status. + - Add preload API `claudeWorkflows.getSnapshot()` and `claudeWorkflows.onDidUpdate(...)`. + - Main sends snapshots after each successful index update and on startup replay, mirroring the existing `agentStatus:getSnapshot` pattern (`src/main/ipc/agent-hooks.ts:70`), but broadcast to all live renderer windows rather than retaining one stale `webContents`. + - Add a fire-and-forget `claudeWorkflows:drop` IPC. Renderer-only dismissal will be resurrected by the next main snapshot unless the main index records the drop. + - Local filesystem indexing is allowed only for `connectionId === null`. For SSH, either extend the relay to run the same detector/index remotely and forward summary payloads, or explicitly no-op/degrade remote workflow detection for this slice. Never interpret a remote `scriptPath` as a local path. + +4. Add a renderer store slice for workflow runs. + - Keep a `claudeWorkflowRunsById` map plus an epoch. + - Add drop/dismiss APIs per workflow id and per worktree, matching the live/retained agent-status cleanup shape in `src/renderer/src/store/slices/agent-status.ts:1`. + - Do not store transcript bodies in the renderer; keep only previews needed for list rows. + - Integrate the slice into `src/renderer/src/store/index.ts` and `src/renderer/src/store/types.ts`; wire startup snapshot/update listeners in `useIpcEvents.ts` after `workspaceSessionReady`, using the same unknown-pane retry/snapshot discipline as agent status. + +5. Convert workflow runs into virtual dashboard rows. + - Extend `DashboardAgentRow` with a discriminant: `kind: 'agent' | 'workflow' | 'workflow-agent'`. Do not cast workflow rows to `AgentStatusEntry`. + - `workflow` row is the group parent; `workflow-agent` rows are children. + - Use stable synthetic row ids prefixed with `claude-workflow:` so they cannot collide with real `${tabId}:${leafId}` pane keys. + - Keep row identity separate from activation. Existing click handling parses `paneKey` and would reject synthetic ids, so workflow rows need `activationPaneKey`/`activationTabId` pointing at the parent Claude pane, plus `dismissId` for workflow drops. + - If the parent pane no longer resolves, render the row as non-routable with no focus attempt; do not guess another Claude tab in the worktree. + - Add workflow-by-worktree indexes to `useWorktreeAgentRows` and `useDashboardData`; preserve stable arrays for unchanged worktrees. + - Update `applyAgentRowLineage` and the sidebar lineage model to read a row-level `parentRowId` instead of `entry.orchestration` only. The existing visual depth cap can remain, but the logical tree must support Claude row -> workflow row -> workflow-agent rows. + +6. Render the group in `WorktreeCardAgents`. + - Reuse existing `DashboardAgentRow` styling, leading state dot, chevron, compact timestamp, and child disclosure (`src/renderer/src/components/sidebar/WorktreeCardAgents.tsx:224`), but update the component props/helpers for the row union instead of reading `agent.entry.*` unconditionally. + - Parent label: workflow title or script filename. Secondary text: phase summary such as `planning -> parallel review -> synthesis`. + - Children: one row per Claude workflow subagent with label, state, last preview, and elapsed/updated time. + - Keep the group expanded while any child is working, waiting, or blocked; collapse by default once all children are done unless the user manually toggled that workflow id in this session. + +## Data flow + +```text +Claude hook payload + -> agentHookServer receives raw body + -> normal agent-status normalization/fanout continues unchanged + -> Claude workflow detector sees raw Claude Workflow tool result + -> reads discovered workflow run files/subagent JSONL previews + -> main IPC snapshot/update + -> renderer workflow slice + -> useWorktreeAgentRows/useDashboardData virtual rows + -> WorktreeCardAgents/DashboardAgentRow +``` + +## Edge cases + +- Claude writes run JSON before all subagent JSONL files exist. +- A workflow is resumed and reuses the same `runId`. +- The parent Claude pane exits but the workflow files keep changing briefly. +- Two Claude panes in the same worktree create workflows at the same time. +- `scriptPath` points outside the local machine on SSH. +- Workflow rows outlive their real parent tab after a crash/relaunch. +- A malformed workflow run forms a cycle-like child relationship; render flat rather than hiding rows. +- Large runs with 100+ subagents must not re-render every worktree card on every JSONL append. +- User dismisses a workflow while the underlying run files are still changing; main must suppress that run until a new stable run identity appears. +- Startup snapshot arrives before tabs/worktrees hydrate; renderer must retry or wait rather than permanently dropping routable rows. +- Claude changes its private workflow file shape; parser must return a degraded summary from whatever identifiers/previews are still available. + +## Test plan + +- Unit: parser tests for workflow tool-result extraction, run JSON parsing, partial JSONL handling, and state rollup. +- Unit: store reducer tests for insert/update/drop/prune and stable selector references. +- Unit: IPC/drop tests proving renderer dismissal updates main state and does not resurrect on snapshot. +- Unit: `buildWorktreeAgentRows`/dashboard data tests for virtual group ordering, collapse defaults, stale parent pane fallback, no pane-key collision, and stable unchanged-worktree selectors. +- Unit: SSH/remote tests proving local index ignores remote paths, or relay summary ingestion works when remote support is included. +- Component: `WorktreeCardAgents` renders one workflow group with children, focuses the parent pane on click, renders non-routable stale-parent rows without focusing, and dismisses virtual rows without dropping real agent rows. +- Electron: start a Claude workflow fixture or mocked IPC snapshot; verify the worktree card shows group, expanded working state, completed collapsed state, and adjacent normal agent rows. + +## UI quality bar + +- The group must read as one workflow, not as a noisy pile of unrelated agents. +- Child rows must preserve the existing agent-status visual rhythm: leading state column, Claude identity, concise prompt, passive timestamp. +- No new colors beyond existing state dots and sidebar/accent tokens. +- Rows cannot overlap, jump width, or clip labels on narrow sidebar widths. +- A normal single-agent worktree should look unchanged. + +## Review screenshots + +1. Sidebar worktree card with a running Claude workflow group expanded and mixed child states. +2. Sidebar worktree card with a completed workflow group collapsed. +3. Dashboard/worktree overview with workflow rows and normal agent rows together. +4. Narrow sidebar width with long workflow and child labels. +5. Adjacent smoke: normal Claude/Codex agent row still renders and activates unchanged. + +## Rollout + +1. Add shared workflow types and parser/index tests. +2. Add main-process workflow index and IPC snapshot/update surface. +3. Add renderer store slice and virtual-row derivation. +4. Update `DashboardAgentRow`/`WorktreeCardAgents` rendering for virtual groups. +5. Add tests and Electron validation screenshots. + +## Lightweight Eng Review + +- Scope: Kept to detection plus virtual status rows. Removed terminal creation, resume, and detail inspection from this branch so it does one visible job. +- Architecture/data flow: Workflow status is a sibling IPC/store slice to agent status, then joins at the row-derivation layer. This avoids bloating `AgentStatusEntry` with Claude-only fields while preserving the existing card renderer. +- Technical correction: detection must use raw Claude hook bodies before normalization; `AgentStatusEntry` is intentionally too small to carry workflow file pointers. +- Failure modes covered: + - Partial or malformed Claude workflow files degrade to an error/unknown run row. + - SSH paths are either indexed on the relay and forwarded as summaries, or ignored/degraded locally; local main never opens them. + - Parent pane missing focuses nothing and shows a non-routable row rather than guessing. + - Duplicate/resumed run ids merge by `scriptPath + runId + parentPaneKey`. + - Large JSONL appends are summarized in main before IPC. + - Dismissed runs are suppressed in main so updates/snapshots do not resurrect them. +- Test coverage required: + - Parser/index unit tests for partial writes and resumed runs. + - Store/selector unit tests for stable references and pruning. + - Component tests for grouped rows, child disclosure, dismiss, and activation. + - Electron screenshots for running, completed, narrow, and adjacent-agent states. +- Performance/blast radius: Watch only discovered workflow directories. Renderer stores summaries, not transcript bodies. Existing indexed worktree selectors must stay O(changed workflow worktrees), not O(cards x runs). +- UI quality bar: Quiet list-row integration using existing agent row components, no new decorative cards, no color expansion, no layout jitter. +- Required review screenshots: + 1. Running expanded group. + 2. Completed collapsed group. + 3. Dashboard mixed rows. + 4. Narrow sidebar labels. + 5. Normal agent row smoke. +- Residual risks: Claude's private workflow file shape may change. The parser must be version-tolerant and fixture-backed from observed real runs rather than assuming a stable public API. diff --git a/src/main/agent-hooks/claude-workflow-index.ts b/src/main/agent-hooks/claude-workflow-index.ts new file mode 100644 index 00000000000..242656cc438 --- /dev/null +++ b/src/main/agent-hooks/claude-workflow-index.ts @@ -0,0 +1,529 @@ +/* eslint-disable max-lines -- Why: this file keeps Claude workflow evidence + detection, tolerant private-file parsing, dismissal suppression, and snapshot + emission together so the raw-hook-to-summary contract stays in one place. */ +import { createHash } from 'crypto' +import { existsSync, readdirSync, readFileSync, statSync } from 'fs' +import path from 'path' + +import { + extractClaudeWorkflowDetectorInput, + normalizeClaudeWorkflowState, + rollupClaudeWorkflowState, + trimClaudeWorkflowMessage, + type ClaudeWorkflowAgentSummary, + type ClaudeWorkflowEvidence, + type ClaudeWorkflowPhaseSummary, + type ClaudeWorkflowRunSummary, + type ClaudeWorkflowSnapshot, + type ClaudeWorkflowState +} from '../../shared/claude-workflow-status' +import { parsePaneKey } from '../../shared/stable-pane-id' + +type RawHookProcessArgs = { + body: unknown + connectionId: string | null +} + +type LocalRunRecord = { + summary: ClaudeWorkflowRunSummary + evidence: ClaudeWorkflowEvidence + directory: string | null + suppressedIdentity: string +} + +type UpdateListener = (snapshot: ClaudeWorkflowSnapshot) => void + +const WORKFLOW_READ_DEBOUNCE_MS = 120 +const MAX_JSON_FILE_BYTES = 2_000_000 +const MAX_JSONL_TAIL_BYTES = 128_000 +const MAX_JSONL_LINES = 240 +const MAX_AGENTS = 200 + +function hashWorkflowIdentity(input: string): string { + return createHash('sha256').update(input).digest('hex').slice(0, 24) +} + +function workflowIdentity(fields: { + connectionId: string | null + paneKey: string + scriptPath?: string + runId?: string + transcriptDir?: string +}): string { + return [ + fields.connectionId ?? 'local', + fields.paneKey, + fields.scriptPath ?? '', + fields.runId ?? '', + fields.transcriptDir ?? '' + ].join('\0') +} + +function basenameFromLocalPath(value: string | undefined): string | undefined { + if (!value) { + return undefined + } + return path.basename(path.normalize(value)) +} + +function resolveWorkflowDirectory(evidence: ClaudeWorkflowEvidence): string | null { + if (evidence.transcriptDir && path.isAbsolute(evidence.transcriptDir)) { + return path.normalize(evidence.transcriptDir) + } + if (evidence.scriptPath && path.isAbsolute(evidence.scriptPath)) { + return path.dirname(path.normalize(evidence.scriptPath)) + } + return null +} + +function readJsonFile(filePath: string): unknown { + const stat = statSync(filePath) + if (!stat.isFile() || stat.size > MAX_JSON_FILE_BYTES) { + return null + } + return JSON.parse(readFileSync(filePath, 'utf8')) +} + +function safeReadJsonFile(filePath: string): { value: unknown; error?: string } { + try { + return { value: readJsonFile(filePath) } + } catch (err) { + return { value: null, error: err instanceof Error ? err.message : String(err) } + } +} + +function readJsonlTail(filePath: string): { records: unknown[]; error?: string } { + try { + const stat = statSync(filePath) + if (!stat.isFile()) { + return { records: [] } + } + const bytesToRead = Math.min(stat.size, MAX_JSONL_TAIL_BYTES) + const buffer = readFileSync(filePath) + const tail = buffer.subarray(buffer.length - bytesToRead).toString('utf8') + const lines = tail + .split(/\r?\n/) + .filter((line) => line.trim().length > 0) + .slice(-MAX_JSONL_LINES) + const records: unknown[] = [] + let malformed = 0 + for (const line of lines) { + try { + records.push(JSON.parse(line)) + } catch { + malformed += 1 + } + } + return { + records, + ...(malformed > 0 ? { error: `${malformed} partial workflow preview lines skipped` } : {}) + } + } catch (err) { + return { records: [], error: err instanceof Error ? err.message : String(err) } + } +} + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null +} + +function readString(record: Record, keys: string[]): string | undefined { + for (const key of keys) { + const value = trimClaudeWorkflowMessage(record[key]) + if (value) { + return value + } + } + return undefined +} + +function readNumber(record: Record, keys: string[]): number | undefined { + for (const key of keys) { + const value = record[key] + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + } + return undefined +} + +function collectArraysByKey( + root: unknown, + keys: ReadonlySet, + out: unknown[][], + depth = 0 +): void { + if (depth > 5 || out.length > 20) { + return + } + if (Array.isArray(root)) { + for (const item of root) { + collectArraysByKey(item, keys, out, depth + 1) + } + return + } + const record = asRecord(root) + if (!record) { + return + } + for (const [key, value] of Object.entries(record)) { + if (keys.has(key) && Array.isArray(value)) { + out.push(value) + } else { + collectArraysByKey(value, keys, out, depth + 1) + } + } +} + +function agentFromRecord( + record: Record, + fallbackId: string, + now: number +): ClaudeWorkflowAgentSummary | null { + const label = readString(record, [ + 'label', + 'name', + 'title', + 'agentType', + 'agent_type', + 'task', + 'description' + ]) + const id = + readString(record, ['id', 'agentId', 'agent_id', 'subagentId', 'subagent_id']) ?? fallbackId + const lastMessage = readString(record, [ + 'lastMessage', + 'last_message', + 'message', + 'summary', + 'preview', + 'result' + ]) + if (!label && !lastMessage) { + return null + } + const timestamp = + readNumber(record, [ + 'updatedAt', + 'updated_at', + 'completedAt', + 'completed_at', + 'startedAt', + 'started_at' + ]) ?? now + return { + id, + label: label ?? id, + state: normalizeClaudeWorkflowState(record.state ?? record.status ?? record.resultStatus), + startedAt: + readNumber(record, ['startedAt', 'started_at', 'createdAt', 'created_at']) ?? timestamp, + updatedAt: timestamp, + lastMessage, + tokenCount: readNumber(record, ['tokenCount', 'token_count', 'tokens']), + phaseId: readString(record, ['phaseId', 'phase_id', 'phase']) + } +} + +function collectAgentsFromJson(root: unknown, now: number): ClaudeWorkflowAgentSummary[] { + const arrays: unknown[][] = [] + collectArraysByKey( + root, + new Set(['agents', 'subagents', 'children', 'workers', 'tasks', 'steps']), + arrays + ) + const byId = new Map() + let index = 0 + for (const array of arrays) { + for (const item of array) { + if (byId.size >= MAX_AGENTS) { + return Array.from(byId.values()) + } + const record = asRecord(item) + if (!record) { + continue + } + const agent = agentFromRecord(record, `agent-${index}`, now) + index += 1 + if (agent) { + byId.set(agent.id, { ...byId.get(agent.id), ...agent }) + } + } + } + return Array.from(byId.values()) +} + +function mergeAgentPreview( + existing: ClaudeWorkflowAgentSummary | undefined, + preview: ClaudeWorkflowAgentSummary +): ClaudeWorkflowAgentSummary { + if (!existing) { + return preview + } + return { + ...existing, + ...preview, + startedAt: Math.min(existing.startedAt, preview.startedAt), + updatedAt: Math.max(existing.updatedAt, preview.updatedAt), + lastMessage: preview.lastMessage ?? existing.lastMessage + } +} + +function collectAgentsFromJsonl(records: unknown[], now: number): ClaudeWorkflowAgentSummary[] { + const byId = new Map() + let index = 0 + for (const item of records) { + const record = asRecord(item) + if (!record || byId.size >= MAX_AGENTS) { + continue + } + const nested = asRecord(record.agent) ?? asRecord(record.subagent) ?? record + const agent = agentFromRecord(nested, `preview-${index}`, now) + index += 1 + if (!agent) { + continue + } + byId.set(agent.id, mergeAgentPreview(byId.get(agent.id), agent)) + } + return Array.from(byId.values()) +} + +function collectPhases( + root: unknown, + agents: ClaudeWorkflowAgentSummary[] +): ClaudeWorkflowPhaseSummary[] { + const arrays: unknown[][] = [] + collectArraysByKey(root, new Set(['phases', 'phaseSummaries']), arrays) + const phases: ClaudeWorkflowPhaseSummary[] = [] + for (const array of arrays) { + for (const item of array) { + const record = asRecord(item) + if (!record) { + continue + } + const id = + readString(record, ['id', 'phaseId', 'phase_id', 'name']) ?? `phase-${phases.length}` + const label = readString(record, ['label', 'name', 'title']) ?? id + const agentIds = agents.filter((agent) => agent.phaseId === id).map((agent) => agent.id) + phases.push({ + id, + label, + state: normalizeClaudeWorkflowState(record.state ?? record.status), + agentIds + }) + } + } + if (phases.length === 0) { + const grouped = new Map() + for (const agent of agents) { + if (!agent.phaseId) { + continue + } + const bucket = grouped.get(agent.phaseId) + if (bucket) { + bucket.push(agent) + } else { + grouped.set(agent.phaseId, [agent]) + } + } + for (const [phaseId, phaseAgents] of grouped) { + phases.push({ + id: phaseId, + label: phaseId, + state: rollupClaudeWorkflowState(phaseAgents).state, + agentIds: phaseAgents.map((agent) => agent.id) + }) + } + } + return phases.slice(0, 12) +} + +function chooseRunJson(files: string[], evidence: ClaudeWorkflowEvidence): string | null { + const jsonFiles = files.filter((file) => file.toLowerCase().endsWith('.json')) + if (evidence.runId) { + const byRunId = jsonFiles.find((file) => file.includes(evidence.runId ?? '')) + if (byRunId) { + return byRunId + } + } + return jsonFiles.find((file) => /run|workflow/i.test(file)) ?? jsonFiles[0] ?? null +} + +function listWorkflowDirectory(directory: string | null): { files: string[]; error?: string } { + if (!directory) { + return { files: [], error: 'Workflow directory unavailable' } + } + try { + if (!existsSync(directory) || !statSync(directory).isDirectory()) { + return { files: [], error: 'Workflow directory not found yet' } + } + return { files: readdirSync(directory) } + } catch (err) { + return { files: [], error: err instanceof Error ? err.message : String(err) } + } +} + +function buildSummary(args: { + previous?: ClaudeWorkflowRunSummary + id: string + input: NonNullable> + connectionId: string | null + directory: string | null +}): ClaudeWorkflowRunSummary { + const now = Date.now() + const { files, error: listError } = listWorkflowDirectory(args.directory) + const jsonFile = chooseRunJson(files, args.input.evidence) + const jsonResult = jsonFile + ? safeReadJsonFile(path.join(args.directory ?? '', jsonFile)) + : { value: null } + const jsonAgents = collectAgentsFromJson(jsonResult.value, now) + const jsonlRecords: unknown[] = [] + let jsonlError: string | undefined + for (const file of files + .filter((entry) => entry.toLowerCase().endsWith('.jsonl')) + .slice(0, MAX_AGENTS)) { + const result = readJsonlTail(path.join(args.directory ?? '', file)) + jsonlRecords.push(...result.records) + jsonlError ??= result.error + } + const previewAgents = collectAgentsFromJsonl(jsonlRecords, now) + const byId = new Map() + for (const agent of jsonAgents) { + byId.set(agent.id, agent) + } + for (const agent of previewAgents) { + byId.set(agent.id, mergeAgentPreview(byId.get(agent.id), agent)) + } + const agents = Array.from(byId.values()) + const phases = collectPhases(jsonResult.value, agents) + const rollup = rollupClaudeWorkflowState(agents) + const state: ClaudeWorkflowState = + agents.length === 0 && (listError || jsonResult.error || jsonlError) ? 'error' : rollup.state + const scriptName = basenameFromLocalPath(args.input.evidence.scriptPath) + const label = + args.input.evidence.label ?? + readString(asRecord(jsonResult.value) ?? {}, ['title', 'label', 'name']) ?? + scriptName ?? + 'Claude workflow' + const lastMessage = agents + .slice() + .sort((a, b) => b.updatedAt - a.updatedAt) + .find((agent) => agent.lastMessage)?.lastMessage + return { + id: args.id, + parentPaneKey: args.input.paneKey, + parentTabId: args.input.tabId ?? parsePaneKey(args.input.paneKey)?.tabId, + worktreeId: args.input.worktreeId, + connectionId: args.connectionId, + runId: args.input.evidence.runId, + label, + scriptName, + state, + startedAt: args.previous?.startedAt ?? agents[0]?.startedAt ?? now, + updatedAt: Math.max(now, ...agents.map((agent) => agent.updatedAt)), + lastMessage: lastMessage ?? listError ?? jsonResult.error ?? jsonlError, + phaseSummary: phases.map((phase) => phase.label).join(' -> ') || undefined, + agents, + phases, + counts: + agents.length === 0 && state === 'error' ? { ...rollup.counts, error: 1 } : rollup.counts, + error: listError ?? jsonResult.error ?? jsonlError + } +} + +export class ClaudeWorkflowIndex { + private runsById = new Map() + private dismissedIdentities = new Set() + private pendingTimers = new Map>() + private updateListener: UpdateListener | null = null + + setUpdateListener(listener: UpdateListener | null): void { + this.updateListener = listener + } + + processRawHookBody({ body, connectionId }: RawHookProcessArgs): void { + if (connectionId !== null) { + return + } + const input = extractClaudeWorkflowDetectorInput(body) + if (!input) { + return + } + const identity = workflowIdentity({ + connectionId, + paneKey: input.paneKey, + scriptPath: input.evidence.scriptPath, + runId: input.evidence.runId, + transcriptDir: input.evidence.transcriptDir + }) + if (this.dismissedIdentities.has(identity)) { + return + } + const id = `claude-workflow:${hashWorkflowIdentity(identity)}` + const directory = resolveWorkflowDirectory(input.evidence) + const existing = this.runsById.get(id) + if (this.pendingTimers.has(id)) { + clearTimeout(this.pendingTimers.get(id)) + } + const timer = setTimeout(() => { + this.pendingTimers.delete(id) + const summary = buildSummary({ + previous: existing?.summary, + id, + input, + connectionId, + directory + }) + this.runsById.set(id, { + summary, + evidence: input.evidence, + directory, + suppressedIdentity: identity + }) + this.emitUpdate() + }, WORKFLOW_READ_DEBOUNCE_MS) + this.pendingTimers.set(id, timer) + if (typeof timer.unref === 'function') { + timer.unref() + } + } + + getSnapshot(): ClaudeWorkflowSnapshot { + return { runs: Array.from(this.runsById.values(), (run) => run.summary), updatedAt: Date.now() } + } + + dropRun(id: string): void { + const existing = this.runsById.get(id) + if (existing) { + this.dismissedIdentities.add(existing.suppressedIdentity) + this.runsById.delete(id) + this.emitUpdate() + return + } + if (id.startsWith('claude-workflow:')) { + this.dismissedIdentities.add(id) + } + } + + dropRunsByWorktree(worktreeId: string): void { + let changed = false + for (const [id, run] of this.runsById) { + if (run.summary.worktreeId !== worktreeId) { + continue + } + this.dismissedIdentities.add(run.suppressedIdentity) + this.runsById.delete(id) + changed = true + } + if (changed) { + this.emitUpdate() + } + } + + private emitUpdate(): void { + this.updateListener?.(this.getSnapshot()) + } +} + +export const claudeWorkflowIndex = new ClaudeWorkflowIndex() diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 636b1e4affb..b46734d74a5 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -50,6 +50,7 @@ import { } from '../../shared/agent-interrupt-intent' import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id' import type { LegacyPaneKeyAliasEntry } from '../../shared/types' +import { claudeWorkflowIndex } from './claude-workflow-index' export type { AgentHookSource } @@ -1038,6 +1039,11 @@ export class AgentHookServer { trackEmptyPaneKeyHook(body) const aliasedBody = this.normalizeHookBodyPaneKeyAlias(body) + if (source === 'claude') { + // Why: Claude workflow file pointers are present only in the raw hook + // body; the normalized status intentionally drops them before IPC. + claudeWorkflowIndex.processRawHookBody({ body: aliasedBody, connectionId: null }) + } const normalized = normalizeHookPayload(this.state, source, aliasedBody, this.env) if (normalized) { const enriched = this.applyNormalizedStatus(normalized) diff --git a/src/main/index.ts b/src/main/index.ts index 8ba2c81c749..e31bbd4b28a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -73,6 +73,7 @@ import { ClaudeAccountService } from './claude-accounts/service' import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service' import { StarNagService } from './star-nag/service' import { agentHookServer } from './agent-hooks/server' +import { claudeWorkflowIndex } from './agent-hooks/claude-workflow-index' import { maybeAutoRenameBranchOnFirstWork } from './agent-hooks/first-work-branch-rename' import { setMigrationUnsupportedPtyListener } from './agent-hooks/migration-unsupported-pty-state' import { @@ -518,6 +519,7 @@ function openMainWindow(): BrowserWindow { // replay-loop through lastStatusByPaneKey runs only on deliberate // window recreations instead of stacking on top of stale listeners. agentHookServer.setListener(null) + claudeWorkflowIndex.setUpdateListener(null) setMigrationUnsupportedPtyListener(null) // Why: any running synthesized-title spinner timer would fire into a // destroyed webContents; stop it here instead of deferring to per-pane @@ -574,6 +576,14 @@ function openMainWindow(): BrowserWindow { } } ) + claudeWorkflowIndex.setUpdateListener((snapshot) => { + for (const candidate of BrowserWindow.getAllWindows()) { + if (candidate.isDestroyed() || candidate.webContents.isDestroyed()) { + continue + } + candidate.webContents.send('claudeWorkflows:update', snapshot) + } + }) setMigrationUnsupportedPtyListener((event) => { if (mainWindow?.isDestroyed()) { return diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index 2287db0ea4e..ded37614b24 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -4,8 +4,10 @@ import type { AgentStatusIpcPayload, MigrationUnsupportedPtyEntry } from '../../shared/agent-status-types' +import type { ClaudeWorkflowSnapshot } from '../../shared/claude-workflow-status' import type { AgentInterruptInferenceRequest } from '../../shared/agent-interrupt-intent' import { agentHookServer, isValidPaneKey } from '../agent-hooks/server' +import { claudeWorkflowIndex } from '../agent-hooks/claude-workflow-index' import { ampHookService } from '../amp/hook-service' import { clearMigrationUnsupportedPtysForPaneKey, @@ -47,11 +49,13 @@ export function registerAgentHookHandlers(): void { ipcMain.removeHandler('agentStatus:getSnapshot') ipcMain.removeHandler('agentStatus:inferInterrupt') ipcMain.removeHandler('agentStatus:getMigrationUnsupportedSnapshot') + ipcMain.removeHandler('claudeWorkflows:getSnapshot') // Why: agentStatus:drop is sent fire-and-forget from the renderer via // ipcRenderer.send(); we listen with ipcMain.on (not handle) so we don't // round-trip a response. Removing first keeps re-registration safe even // though the module-level registered guard already prevents re-entry today. ipcMain.removeAllListeners('agentStatus:drop') + ipcMain.removeAllListeners('claudeWorkflows:drop') ipcMain.on('agentStatus:drop', (_event, paneKey: unknown) => { if (typeof paneKey !== 'string' || !isValidPaneKey(paneKey)) { return @@ -72,6 +76,20 @@ export function registerAgentHookHandlers(): void { // lose replayed statuses while its local store is still empty. return agentHookServer.getStatusSnapshot() }) + ipcMain.handle('claudeWorkflows:getSnapshot', (): ClaudeWorkflowSnapshot => { + return claudeWorkflowIndex.getSnapshot() + }) + ipcMain.on('claudeWorkflows:drop', (_event, idOrWorktree: unknown) => { + if (typeof idOrWorktree !== 'string' || idOrWorktree.trim().length === 0) { + return + } + const value = idOrWorktree.trim() + if (value.startsWith('worktree:')) { + claudeWorkflowIndex.dropRunsByWorktree(value.slice('worktree:'.length)) + return + } + claudeWorkflowIndex.dropRun(value) + }) ipcMain.handle('agentStatus:inferInterrupt', (_event, request: unknown): boolean => { if (typeof request !== 'object' || request === null) { return false diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index fce1dc1d607..5a20dbbf5d7 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -173,6 +173,7 @@ import type { AgentStatusIpcPayload, MigrationUnsupportedPtyEntry } from '../shared/agent-status-types' +import type { ClaudeWorkflowSnapshot } from '../shared/claude-workflow-status' import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent' import type { RuntimeBrowserDriverState, @@ -2158,6 +2159,12 @@ export type PreloadApi = { * last-status file. Fire-and-forget. */ drop: (paneKey: string) => void } + claudeWorkflows: { + onDidUpdate: (callback: (snapshot: ClaudeWorkflowSnapshot) => void) => () => void + getSnapshot: () => Promise + drop: (id: string) => void + dropByWorktree: (worktreeId: string) => void + } mobile: { listNetworkInterfaces: () => Promise<{ interfaces: { name: string; address: string }[] diff --git a/src/preload/index.ts b/src/preload/index.ts index 26e016048c7..fbd61ee8b23 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -109,6 +109,7 @@ import type { AgentStatusIpcPayload, MigrationUnsupportedPtyEntry } from '../shared/agent-status-types' +import type { ClaudeWorkflowSnapshot } from '../shared/claude-workflow-status' import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent' import type { SpeechErrorEvent, @@ -3366,6 +3367,23 @@ const api = { } }, + claudeWorkflows: { + onDidUpdate: (callback: (snapshot: ClaudeWorkflowSnapshot) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, snapshot: ClaudeWorkflowSnapshot) => + callback(snapshot) + ipcRenderer.on('claudeWorkflows:update', listener) + return () => ipcRenderer.removeListener('claudeWorkflows:update', listener) + }, + getSnapshot: (): Promise => + ipcRenderer.invoke('claudeWorkflows:getSnapshot'), + drop: (id: string): void => { + ipcRenderer.send('claudeWorkflows:drop', id) + }, + dropByWorktree: (worktreeId: string): void => { + ipcRenderer.send('claudeWorkflows:drop', `worktree:${worktreeId}`) + } + }, + speech: { getCatalog: (): Promise => ipcRenderer.invoke('speech:getCatalog'), getModelStates: (): Promise => ipcRenderer.invoke('speech:getModelStates'), diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx index a695ab3e03f..602549002b8 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx @@ -4,14 +4,14 @@ import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import type { TerminalTab } from '../../../../shared/types' import { TooltipProvider } from '../ui/tooltip' import DashboardAgentRow from './DashboardAgentRow' -import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData' +import type { DashboardRealAgentRow } from './useDashboardData' const NOW = 120_000 function makeAgent( - overrides: Partial = {}, + overrides: Partial = {}, entryOverrides: Partial = {} -): DashboardAgentRowData { +): DashboardRealAgentRow { const paneKey = overrides.paneKey ?? 'tab-1:leaf-1' const tab: TerminalTab = { id: 'tab-1', @@ -35,6 +35,8 @@ function makeAgent( } return { + kind: 'agent', + rowId: paneKey, paneKey, entry, tab, @@ -45,7 +47,7 @@ function makeAgent( } } -function renderRow(agent: DashboardAgentRowData): string { +function renderRow(agent: DashboardRealAgentRow): string { return renderToStaticMarkup( { e.stopPropagation() - onDismiss(agent.paneKey) + onDismiss(agent.dismissId ?? agent.paneKey) }, - [onDismiss, agent.paneKey] + [onDismiss, agent.dismissId, agent.paneKey] ) // Why: the chevron toggles expand-collapse and must not propagate — clicks // on it would otherwise bubble to the row's activate handler and navigate @@ -182,13 +188,25 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ const handleActivate = useCallback( (e: React.MouseEvent) => { e.stopPropagation() - onActivate(agent.tab.id, agent.paneKey) + const activationTabId = + agent.activationTabId ?? (agent.kind === 'agent' ? agent.tab.id : undefined) + const activationPaneKey = + agent.activationPaneKey ?? (agent.kind === 'agent' ? agent.paneKey : undefined) + if (!activationTabId || !activationPaneKey) { + return + } + onActivate(activationTabId, activationPaneKey) }, - [onActivate, agent.tab.id, agent.paneKey] + [onActivate, agent] ) const startedAt = agent.startedAt > 0 ? agent.startedAt : null const doneAt = lastEnteredDoneAt(agent) - const prompt = agent.entry.prompt.trim() + const prompt = + agent.kind === 'agent' + ? agent.entry.prompt.trim() + : agent.kind === 'workflow' + ? agent.label.trim() + : agent.label.trim() // Why: `agent.entry.prompt` is normalized to '' when the prompt is unknown // (fresh agent, missing telemetry). Rendering the row with an empty primary // slot would collapse the text column and leave the row with no human- @@ -202,10 +220,14 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ // fields on `state === 'working'`. The assistant message is the opposite // — it's the reply, most useful on `done`, so we always show it. const isWorking = agent.state === 'working' - const toolName = isWorking ? (agent.entry.toolName?.trim() ?? '') : '' - const toolInput = isWorking ? (agent.entry.toolInput?.trim() ?? '') : '' - const lastAssistantMessage = agent.entry.lastAssistantMessage?.trim() ?? '' - const isInterrupted = agent.entry.interrupted === true + const showToolSlot = isWorking && agent.kind === 'agent' + const toolName = showToolSlot ? (agent.entry.toolName?.trim() ?? '') : '' + const toolInput = showToolSlot ? (agent.entry.toolInput?.trim() ?? '') : '' + const lastAssistantMessage = + agent.kind === 'agent' + ? (agent.entry.lastAssistantMessage?.trim() ?? '') + : (agent.secondaryText?.trim() ?? '') + const isInterrupted = agent.kind === 'agent' && agent.entry.interrupted === true const lineage = agent.lineage const isLineageChild = lineage?.depth === 1 const lineageChildCount = lineage?.childCount ?? 0 @@ -221,6 +243,9 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ // explanation without competing with the prompt or timestamp. const dotState: AgentDotState = isInterrupted ? 'interrupted' : asDotState(agent.state) const dotTooltipLabel = stateDotTooltipLabel(agent, dotState) + const isRoutable = + agent.kind === 'agent' || + (typeof agent.activationTabId === 'string' && typeof agent.activationPaneKey === 'string') // Why: always show the chevron to keep the row's right edge stable — a // conditional control would appear/disappear as agent content grows and @@ -254,7 +279,8 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ // Why: inline agent rows sit inside a hoverable workspace card, so // their hover wash must stay softer than the parent card highlight. // The focused-pane state reuses the same class via data attribute. - 'cursor-pointer rounded-sm worktree-agent-row-hover' + 'rounded-sm worktree-agent-row-hover', + isRoutable && 'cursor-pointer' )} data-focused-agent-pane={isFocusedPane ? 'true' : undefined} title={tsParts.length > 0 ? tsParts.join(' • ') : undefined} @@ -470,7 +496,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ slot must be a real line box instead of whitespace that can collapse. Tool slot only reserves height while working, since done/blocked rows shouldn't show a dangling wrench. */} - {isWorking && ( + {showToolSlot && (
({ ...row, lineage: ROOT_LINEAGE })) } - const rowsByPaneKey = new Map(rows.map((row) => [row.paneKey, row])) - const childrenByParentPaneKey = new Map() - const childPaneKeys = new Set() + const rowsById = new Map(rows.map((row) => [row.rowId, row])) + const childrenByParentRowId = new Map() + const childRowIds = new Set() for (const row of rows) { - const parentPaneKey = row.entry.orchestration?.parentPaneKey - if (!parentPaneKey || !rowsByPaneKey.has(parentPaneKey)) { + const parentRowId = row.parentRowId + if (!parentRowId || !rowsById.has(parentRowId)) { continue } - childPaneKeys.add(row.paneKey) - const siblings = childrenByParentPaneKey.get(parentPaneKey) + childRowIds.add(row.rowId) + const siblings = childrenByParentRowId.get(parentRowId) if (siblings) { siblings.push(row) } else { - childrenByParentPaneKey.set(parentPaneKey, [row]) + childrenByParentRowId.set(parentRowId, [row]) } } - if (childPaneKeys.size === 0) { + if (childRowIds.size === 0) { return rows.map((row) => ({ ...row, lineage: ROOT_LINEAGE })) } const ordered: DashboardAgentRowWithLineage[] = [] const emitted = new Set() const emitRow = (row: DashboardAgentRow, lineage: AgentRowLineagePresentation): boolean => { - if (emitted.has(row.paneKey)) { + if (emitted.has(row.rowId)) { return false } - emitted.add(row.paneKey) + emitted.add(row.rowId) ordered.push({ ...row, lineage }) return true } const emitSubtree = (row: DashboardAgentRow, lineage: AgentRowLineagePresentation): void => { - const children = childrenByParentPaneKey.get(row.paneKey) ?? [] + const children = childrenByParentRowId.get(row.rowId) ?? [] if (!emitRow(row, { ...lineage, childCount: children.length })) { return } @@ -75,7 +75,7 @@ export function applyAgentRowLineage(rows: DashboardAgentRow[]): DashboardAgentR } for (const row of rows) { - if (childPaneKeys.has(row.paneKey)) { + if (childRowIds.has(row.rowId)) { continue } emitSubtree(row, ROOT_LINEAGE) diff --git a/src/renderer/src/components/dashboard/useDashboardData.ts b/src/renderer/src/components/dashboard/useDashboardData.ts index 3bc98eee1ae..985ab63438f 100644 --- a/src/renderer/src/components/dashboard/useDashboardData.ts +++ b/src/renderer/src/components/dashboard/useDashboardData.ts @@ -11,19 +11,27 @@ import { import type { Repo, Worktree, TerminalTab } from '../../../../shared/types' import { parsePaneKey } from '../../../../shared/stable-pane-id' import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' +import type { + ClaudeWorkflowAgentSummary, + ClaudeWorkflowRunSummary +} from '../../../../shared/claude-workflow-status' // ─── Shared data types ──────────────────────────────────────────────────────── -export type DashboardAgentRow = { +export type DashboardAgentRowBase = { + kind: 'agent' | 'workflow' | 'workflow-agent' + rowId: string paneKey: string - entry: AgentStatusEntry - tab: TerminalTab agentType: AgentType state: AgentStatusState | 'idle' /** When this agent first began reporting status. Derived from the oldest * stateHistory entry, falling back to updatedAt when no history exists yet. * Used to sort agents by when they started. */ startedAt: number + parentRowId?: string + activationPaneKey?: string + activationTabId?: string + dismissId?: string lineage?: { depth: 0 | 1 isFirstSibling: boolean @@ -32,6 +40,34 @@ export type DashboardAgentRow = { } } +export type DashboardRealAgentRow = DashboardAgentRowBase & { + kind: 'agent' + entry: AgentStatusEntry + tab: TerminalTab +} + +export type DashboardWorkflowRow = DashboardAgentRowBase & { + kind: 'workflow' + workflow: ClaudeWorkflowRunSummary + label: string + secondaryText?: string + tab?: TerminalTab +} + +export type DashboardWorkflowAgentRow = DashboardAgentRowBase & { + kind: 'workflow-agent' + workflow: ClaudeWorkflowRunSummary + workflowAgent: ClaudeWorkflowAgentSummary + label: string + secondaryText?: string + tab?: TerminalTab +} + +export type DashboardAgentRow = + | DashboardRealAgentRow + | DashboardWorkflowRow + | DashboardWorkflowAgentRow + // Why: the shape here is deliberately minimal. The per-card rendering pipeline // is separate (WorktreeCardAgents + useWorktreeAgentRows read retained entries // directly from the store). @@ -79,11 +115,14 @@ function buildAgentRowsForWorktree( !isFresh && (entry.state === 'working' || entry.state === 'blocked' || entry.state === 'waiting') rows.push({ + kind: 'agent', + rowId: entry.paneKey, paneKey: entry.paneKey, entry, tab, agentType: entry.agentType ?? 'unknown', state: shouldDecay ? 'idle' : entry.state, + parentRowId: entry.orchestration?.parentPaneKey, // Why: the oldest stateHistory entry's startedAt is the agent's original // "first seen" timestamp. When history is empty the entry has never // transitioned state, so stateStartedAt (the moment the current — and @@ -100,12 +139,70 @@ function buildAgentRowsForWorktree( return rows } +function workflowStateToAgentState(state: ClaudeWorkflowRunSummary['state']): AgentStatusState { + return state === 'error' ? 'blocked' : state +} + +export function buildClaudeWorkflowRowsForWorktree(args: { + worktreeId: string + workflows: ClaudeWorkflowRunSummary[] + tabs: TerminalTab[] +}): DashboardAgentRow[] { + const rows: DashboardAgentRow[] = [] + const tabById = new Map(args.tabs.map((tab) => [tab.id, tab])) + for (const workflow of args.workflows) { + const parsed = parsePaneKey(workflow.parentPaneKey) + const parentTabId = workflow.parentTabId ?? parsed?.tabId + const parentTab = parentTabId ? tabById.get(parentTabId) : undefined + const parentPaneExists = parentTab !== undefined && parsed !== null + const workflowRowId = workflow.id + const state = workflowStateToAgentState(workflow.state) + rows.push({ + kind: 'workflow', + rowId: workflowRowId, + paneKey: workflowRowId, + workflow, + tab: parentTab, + agentType: 'claude', + state, + startedAt: workflow.startedAt, + parentRowId: parentPaneExists ? workflow.parentPaneKey : undefined, + activationPaneKey: parentPaneExists ? workflow.parentPaneKey : undefined, + activationTabId: parentPaneExists ? parentTabId : undefined, + dismissId: workflow.id, + label: workflow.label, + secondaryText: workflow.phaseSummary + }) + for (const agent of workflow.agents) { + rows.push({ + kind: 'workflow-agent', + rowId: `${workflow.id}:agent:${agent.id}`, + paneKey: `${workflow.id}:agent:${agent.id}`, + workflow, + workflowAgent: agent, + tab: parentTab, + agentType: 'claude', + state: workflowStateToAgentState(agent.state), + startedAt: agent.startedAt, + parentRowId: workflowRowId, + activationPaneKey: parentPaneExists ? workflow.parentPaneKey : undefined, + activationTabId: parentPaneExists ? parentTabId : undefined, + dismissId: workflow.id, + label: agent.label, + secondaryText: agent.lastMessage + }) + } + } + return rows +} + function buildDashboardData( repos: Repo[], worktreesByRepo: Record, tabsByWorktree: Record, agentStatusByPaneKey: Record, migrationUnsupportedByPtyId: Record, + claudeWorkflowRunsById: Record, now: number ): DashboardProjectGroup[] { // Why: build a tabId -> entries index once per computation instead of @@ -146,7 +243,20 @@ function buildDashboardData( const worktrees = (worktreesByRepo[repo.id] ?? []) .filter((w) => !w.isArchived) .map((worktree) => { - const agents = buildAgentRowsForWorktree(worktree.id, tabsByWorktree, entriesByTabId, now) + const baseAgents = buildAgentRowsForWorktree( + worktree.id, + tabsByWorktree, + entriesByTabId, + now + ) + const workflowRows = buildClaudeWorkflowRowsForWorktree({ + worktreeId: worktree.id, + workflows: Object.values(claudeWorkflowRunsById).filter( + (workflow) => workflow.worktreeId === worktree.id + ), + tabs: tabsByWorktree[worktree.id] ?? [] + }) + const agents = [...baseAgents, ...workflowRows] return { repo, worktree, agents } satisfies DashboardWorktreeCard }) @@ -168,6 +278,7 @@ export function useDashboardData(): DashboardProjectGroup[] { const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey) const migrationUnsupportedByPtyId = useAppStore((s) => s.migrationUnsupportedByPtyId) + const claudeWorkflowRunsById = useAppStore((s) => s.claudeWorkflowRunsById) // Why: agentStatusEpoch is included in the dependency array (but not in the // computation itself) so the memo recomputes when freshness boundaries expire, // even if no new PTY data arrives. @@ -185,6 +296,7 @@ export function useDashboardData(): DashboardProjectGroup[] { tabsByWorktree, agentStatusByPaneKey, migrationUnsupportedByPtyId, + claudeWorkflowRunsById, Date.now() ), // eslint-disable-next-line react-hooks/exhaustive-deps @@ -194,6 +306,7 @@ export function useDashboardData(): DashboardProjectGroup[] { tabsByWorktree, agentStatusByPaneKey, migrationUnsupportedByPtyId, + claudeWorkflowRunsById, agentStatusEpoch ] ) diff --git a/src/renderer/src/components/dashboard/useRetainedAgents.test.ts b/src/renderer/src/components/dashboard/useRetainedAgents.test.ts index cfab009a172..abe8421fcdd 100644 --- a/src/renderer/src/components/dashboard/useRetainedAgents.test.ts +++ b/src/renderer/src/components/dashboard/useRetainedAgents.test.ts @@ -16,6 +16,8 @@ function makeAgentRow(args: { paneKey: string; state: AgentStatusState; interrup } return { + kind: 'agent' as const, + rowId: args.paneKey, paneKey: args.paneKey, entry, tab: { diff --git a/src/renderer/src/components/dashboard/useRetainedAgents.ts b/src/renderer/src/components/dashboard/useRetainedAgents.ts index 9cd4dea2bad..858e8568586 100644 --- a/src/renderer/src/components/dashboard/useRetainedAgents.ts +++ b/src/renderer/src/components/dashboard/useRetainedAgents.ts @@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' import { isExplicitAgentStatusFresh } from '@/lib/agent-status' -import { type DashboardAgentRow } from './useDashboardData' +import { type DashboardRealAgentRow } from './useDashboardData' import type { RetainedAgentEntry } from '@/store/slices/agent-status' import type { Repo, TerminalTab, Worktree } from '../../../../shared/types' import { @@ -17,7 +17,7 @@ import { parsePaneKey } from '../../../../shared/stable-pane-id' // per-card agents list render the done row until the user dismisses it, rather // than having the row wink out the moment the terminal process exits. -type RetainedAgentSnapshot = Map +type RetainedAgentSnapshot = Map type RetainedAgentsSyncInputs = { repos: Repo[] @@ -88,12 +88,15 @@ export function buildRetainedAgentsSyncSnapshot(args: RetainedAgentsSyncSnapshot (entry.state === 'working' || entry.state === 'blocked' || entry.state === 'waiting') currentAgents.set(paneKey, { row: { + kind: 'agent', + rowId: paneKey, paneKey, entry, tab: owner.tab, agentType: entry.agentType ?? 'unknown', state: shouldDecay ? 'idle' : entry.state, - startedAt: agentStartedAt(entry) + startedAt: agentStartedAt(entry), + parentRowId: entry.orchestration?.parentPaneKey }, worktreeId: owner.worktreeId }) @@ -105,6 +108,7 @@ export function buildRetainedAgentsSyncSnapshot(args: RetainedAgentsSyncSnapshot export function useRetainedAgentsSync(): void { const retainAgents = useAppStore((s) => s.retainAgents) const pruneRetainedAgents = useAppStore((s) => s.pruneRetainedAgents) + const pruneClaudeWorkflowRuns = useAppStore((s) => s.pruneClaudeWorkflowRuns) const clearRetentionSuppressedPaneKeys = useAppStore((s) => s.clearRetentionSuppressedPaneKeys) const [repos, worktreesByRepo, tabsByWorktree, agentStatusEpoch] = useAppStore( useShallow((s) => [s.repos, s.worktreesByRepo, s.tabsByWorktree, s.agentStatusEpoch] as const) @@ -142,6 +146,7 @@ export function useRetainedAgentsSync(): void { prevAgentsRef.current = currentAgents pruneRetainedAgents(existingWorktreeIds) + pruneClaudeWorkflowRuns(existingWorktreeIds) if (consumedSuppressedPaneKeys.length > 0) { clearRetentionSuppressedPaneKeys(consumedSuppressedPaneKeys) } @@ -152,13 +157,14 @@ export function useRetainedAgentsSync(): void { agentStatusEpoch, retainAgents, pruneRetainedAgents, + pruneClaudeWorkflowRuns, clearRetentionSuppressedPaneKeys ]) } export function collectRetainedAgentsOnDisappear(args: { - previousAgents: Map - currentAgents: Map + previousAgents: Map + currentAgents: Map retainedAgentsByPaneKey: Record retentionSuppressedPaneKeys: Record }): { diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx index 16fea6e98d1..eabb5dc724d 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx @@ -52,26 +52,36 @@ type AgentLineageModel = { childrenByParentPaneKey: Map } +function rowKey(agent: DashboardAgentRowData): string { + return agent.rowId ?? agent.paneKey +} + +function rowParentKey(agent: DashboardAgentRowData): string | undefined { + return ( + agent.parentRowId ?? ('entry' in agent ? agent.entry.orchestration?.parentPaneKey : undefined) + ) +} + function buildAgentLineageModel(agents: DashboardAgentRowData[]): AgentLineageModel { - const agentPaneKeys = new Set(agents.map((agent) => agent.paneKey)) + const agentRowIds = new Set(agents.map(rowKey)) const childrenByParentPaneKey = new Map() - const childPaneKeys = new Set() + const childRowIds = new Set() for (const agent of agents) { - const parentPaneKey = agent.entry.orchestration?.parentPaneKey - if (!parentPaneKey || !agentPaneKeys.has(parentPaneKey)) { + const parentRowId = rowParentKey(agent) + if (!parentRowId || !agentRowIds.has(parentRowId)) { continue } - childPaneKeys.add(agent.paneKey) - const siblings = childrenByParentPaneKey.get(parentPaneKey) + childRowIds.add(rowKey(agent)) + const siblings = childrenByParentPaneKey.get(parentRowId) if (siblings) { siblings.push(agent) } else { - childrenByParentPaneKey.set(parentPaneKey, [agent]) + childrenByParentPaneKey.set(parentRowId, [agent]) } } - const rootAgents = agents.filter((agent) => !childPaneKeys.has(agent.paneKey)) + const rootAgents = agents.filter((agent) => !childRowIds.has(rowKey(agent))) if (rootAgents.length === 0 && agents.length > 0) { // Why: malformed orchestration metadata can theoretically form a cycle. // Keep every row visible instead of recursing forever or hiding the list. @@ -83,13 +93,14 @@ function buildAgentLineageModel(agents: DashboardAgentRowData[]): AgentLineageMo agent: DashboardAgentRowData, ancestorPaneKeys: ReadonlySet = new Set() ): void => { - if (reachablePaneKeys.has(agent.paneKey) || ancestorPaneKeys.has(agent.paneKey)) { + const key = rowKey(agent) + if (reachablePaneKeys.has(key) || ancestorPaneKeys.has(key)) { return } - reachablePaneKeys.add(agent.paneKey) + reachablePaneKeys.add(key) const descendantAncestorPaneKeys = new Set(ancestorPaneKeys) - descendantAncestorPaneKeys.add(agent.paneKey) - for (const childAgent of childrenByParentPaneKey.get(agent.paneKey) ?? []) { + descendantAncestorPaneKeys.add(key) + for (const childAgent of childrenByParentPaneKey.get(key) ?? []) { markReachable(childAgent, descendantAncestorPaneKeys) } } @@ -98,14 +109,15 @@ function buildAgentLineageModel(agents: DashboardAgentRowData[]): AgentLineageMo } for (const agent of agents) { - if (reachablePaneKeys.has(agent.paneKey)) { + const key = rowKey(agent) + if (reachablePaneKeys.has(key)) { continue } // Why: a partial cycle alongside a valid root has no true root, so it // would otherwise disappear. Render malformed participants as flat rows // and drop their child edges, matching the dashboard lineage fallback. rootAgents.push(agent) - childrenByParentPaneKey.delete(agent.paneKey) + childrenByParentPaneKey.delete(key) } return { rootAgents, childrenByParentPaneKey } @@ -130,6 +142,10 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ const unvisitedByPaneKey = useMemo(() => { const out: Record = {} for (const a of agents) { + if (a.kind !== 'agent') { + out[a.paneKey] = false + continue + } const ackAt = acknowledgedAgentsByPaneKey[a.paneKey] ?? 0 out[a.paneKey] = ackAt < a.entry.stateStartedAt } @@ -137,9 +153,13 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ }, [agents, acknowledgedAgentsByPaneKey]) const handleDismissAgent = useCallback( - (paneKey: string) => { - dropAgentStatus(paneKey) - dismissRetainedAgent(paneKey) + (id: string) => { + if (id.startsWith('claude-workflow:')) { + useAppStore.getState().dismissClaudeWorkflowRun(id) + return + } + dropAgentStatus(id) + dismissRetainedAgent(id) }, [dropAgentStatus, dismissRetainedAgent] ) @@ -225,18 +245,21 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ agent: DashboardAgentRowData, ancestorPaneKeys: ReadonlySet = new Set() ): React.ReactNode => { - if (ancestorPaneKeys.has(agent.paneKey)) { + const key = rowKey(agent) + if (ancestorPaneKeys.has(key)) { // Why: orchestration metadata is external state and can be malformed. // Bail out of repeated ancestors instead of recursing forever. return null } - const childAgents = childrenByParentPaneKey.get(agent.paneKey) ?? [] + const childAgents = childrenByParentPaneKey.get(key) ?? [] const hasChildAgents = childAgents.length > 0 - const expanded = expandedLineageParents.has(agent.paneKey) + const expanded = + expandedLineageParents.has(key) || + (agent.kind === 'workflow' && hasChildAgents && agent.state !== 'done') const descendantAncestorPaneKeys = new Set(ancestorPaneKeys) - descendantAncestorPaneKeys.add(agent.paneKey) + descendantAncestorPaneKeys.add(key) return ( - + toggleLineageParent(agent.paneKey) : undefined - } + onToggleChildAgents={hasChildAgents ? () => toggleLineageParent(key) : undefined} // Why: keep leaf rows aligned with parent rows in the same card — // see anyRootHasChildren above. reserveDisclosureGutter={anyRootHasChildren && !hasChildAgents} - isFocusedPane={agent.paneKey === focusedAgentPaneKey} + isFocusedPane={(agent.activationPaneKey ?? agent.paneKey) === focusedAgentPaneKey} // Why: the disclosure variant uses chevron + indentation to show // hierarchy. The legacy L-connector / vertical-trunk decorations // are pinned to a fixed left offset that doesn't match the diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts index 99283baa9b6..94bd811c39e 100644 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts @@ -1,3 +1,6 @@ +/* eslint-disable max-lines -- Why: the row derivation tests cover live, + retained, migration-unsupported, lineage, and Claude workflow virtual rows + in one fixture set so selector stability regressions are easier to spot. */ import { describe, expect, it } from 'vitest' import { AGENT_STATUS_STALE_AFTER_MS, @@ -8,6 +11,7 @@ import type { TerminalTab } from '../../../../shared/types' import type { RetainedAgentEntry } from '@/store/slices/agent-status' import { buildWorktreeAgentRows, + selectClaudeWorkflowRunsForWorktree, selectLiveAgentStatusEntriesForWorktree, selectMigrationUnsupportedEntriesForWorktree, selectRetainedAgentEntriesForWorktree @@ -89,7 +93,8 @@ describe('buildWorktreeAgentRows', () => { }) expect(rows).toHaveLength(1) - expect(rows[0].entry).toBe(liveEntry) + expect(rows[0].kind).toBe('agent') + expect(rows[0].kind === 'agent' ? rows[0].entry : null).toBe(liveEntry) expect(rows[0].startedAt).toBe(2000) }) @@ -116,6 +121,69 @@ describe('buildWorktreeAgentRows', () => { expect(working?.state).toBe('idle') expect(done?.state).toBe('done') }) + + it('adds Claude workflow group rows with synthetic ids and parent-pane activation', () => { + const rows = applyAgentRowLineage( + buildWorktreeAgentRows({ + tabs: [makeTab('tab-1')], + entries: [makeEntry(PANE_KEY_1, 1000, { state: 'working' })], + retained: [], + workflows: [ + { + id: 'claude-workflow:run-1', + parentPaneKey: PANE_KEY_1, + parentTabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + runId: 'run-1', + label: 'Review workflow', + state: 'working', + startedAt: 1100, + updatedAt: 1200, + phaseSummary: 'planning -> review', + agents: [ + { + id: 'agent-1', + label: 'Plan reviewer', + state: 'working', + startedAt: 1150, + updatedAt: 1200, + lastMessage: 'Checking plan' + } + ], + phases: [], + counts: { + total: 1, + done: 0, + working: 1, + waiting: 0, + blocked: 0, + error: 0 + } + } + ], + now: 2000 + }) + ) + + expect(rows.map((row) => row.rowId)).toEqual([ + PANE_KEY_1, + 'claude-workflow:run-1', + 'claude-workflow:run-1:agent:agent-1' + ]) + expect(rows[1]).toMatchObject({ + kind: 'workflow', + paneKey: 'claude-workflow:run-1', + parentRowId: PANE_KEY_1, + activationPaneKey: PANE_KEY_1, + activationTabId: 'tab-1', + dismissId: 'claude-workflow:run-1' + }) + expect(rows[2]).toMatchObject({ + kind: 'workflow-agent', + parentRowId: 'claude-workflow:run-1' + }) + }) }) describe('applyAgentRowLineage', () => { @@ -331,3 +399,19 @@ describe('selectRetainedAgentEntriesForWorktree', () => { expect(secondWt2[0]?.startedAt).toBe(1100) }) }) + +describe('selectClaudeWorkflowRunsForWorktree', () => { + it('returns stable empty references for worktrees without Claude workflow runs', () => { + const state = { + tabsByWorktree: {}, + agentStatusByPaneKey: {}, + migrationUnsupportedByPtyId: {}, + retainedAgentsByPaneKey: {}, + claudeWorkflowRunsById: {} + } + + expect(selectClaudeWorkflowRunsForWorktree(state, 'wt-1')).toBe( + selectClaudeWorkflowRunsForWorktree(state, 'wt-2') + ) + }) +}) diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts index 0396bf1f929..8af39e2e34b 100644 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts @@ -1,7 +1,13 @@ +/* eslint-disable max-lines -- Why: this module owns worktree-scoped indexing, + retained/live agent merging, migration placeholders, and Claude workflow + virtual-row derivation so per-card selectors keep one stable contract. */ import { useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' -import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' +import { + buildClaudeWorkflowRowsForWorktree, + type DashboardAgentRow +} from '@/components/dashboard/useDashboardData' import { isExplicitAgentStatusFresh } from '@/lib/agent-status' import type { RetainedAgentEntry } from '@/store/slices/agent-status' import type { AppState } from '@/store/types' @@ -11,6 +17,7 @@ import { type AgentStatusEntry, type MigrationUnsupportedPtyEntry } from '../../../../shared/agent-status-types' +import type { ClaudeWorkflowRunSummary } from '../../../../shared/claude-workflow-status' import { parsePaneKey } from '../../../../shared/stable-pane-id' import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' import { applyAgentRowLineage } from '@/components/dashboard/agent-row-lineage' @@ -22,6 +29,7 @@ import { applyAgentRowLineage } from '@/components/dashboard/agent-row-lineage' const EMPTY_LIVE_ENTRIES: AgentStatusEntry[] = [] const EMPTY_MIGRATION_UNSUPPORTED_ENTRIES: MigrationUnsupportedPtyEntry[] = [] const EMPTY_RETAINED: RetainedAgentEntry[] = [] +const EMPTY_WORKFLOWS: ClaudeWorkflowRunSummary[] = [] type WorktreeAgentRowsState = Pick< AppState, @@ -29,7 +37,9 @@ type WorktreeAgentRowsState = Pick< | 'migrationUnsupportedByPtyId' | 'retainedAgentsByPaneKey' | 'tabsByWorktree' -> +> & { + claudeWorkflowRunsById?: AppState['claudeWorkflowRunsById'] +} type TabWorktreeIndexCache = { tabsByWorktree: WorktreeAgentRowsState['tabsByWorktree'] @@ -53,10 +63,16 @@ type RetainedEntriesByWorktreeCache = { entriesByWorktree: Map } +type WorkflowsByWorktreeCache = { + claudeWorkflowRunsById: WorktreeAgentRowsState['claudeWorkflowRunsById'] + runsByWorktree: Map +} + let tabWorktreeIndexCache: TabWorktreeIndexCache | null = null let liveEntriesByWorktreeCache: LiveEntriesByWorktreeCache | null = null let migrationUnsupportedByWorktreeCache: MigrationUnsupportedByWorktreeCache | null = null let retainedEntriesByWorktreeCache: RetainedEntriesByWorktreeCache | null = null +let workflowsByWorktreeCache: WorkflowsByWorktreeCache | null = null function reuseArrayIfEqual(previous: T[] | undefined, next: T[]): T[] { if (!previous || previous.length !== next.length) { @@ -192,6 +208,37 @@ function getRetainedEntriesByWorktree( return entriesByWorktree } +function getWorkflowsByWorktree( + state: WorktreeAgentRowsState +): Map { + const workflowsById = state.claudeWorkflowRunsById ?? {} + if (workflowsByWorktreeCache?.claudeWorkflowRunsById === workflowsById) { + return workflowsByWorktreeCache.runsByWorktree + } + + const previous = workflowsByWorktreeCache?.runsByWorktree + const runsByWorktree = new Map() + for (const run of Object.values(workflowsById)) { + if (!run.worktreeId) { + continue + } + const bucket = runsByWorktree.get(run.worktreeId) + if (bucket) { + bucket.push(run) + } else { + runsByWorktree.set(run.worktreeId, [run]) + } + } + for (const [worktreeId, runs] of runsByWorktree) { + runsByWorktree.set(worktreeId, reuseArrayIfEqual(previous?.get(worktreeId), runs)) + } + workflowsByWorktreeCache = { + claudeWorkflowRunsById: workflowsById, + runsByWorktree + } + return runsByWorktree +} + export function selectLiveAgentStatusEntriesForWorktree( state: WorktreeAgentRowsState, worktreeId: string @@ -215,10 +262,18 @@ export function selectRetainedAgentEntriesForWorktree( return getRetainedEntriesByWorktree(state).get(worktreeId) ?? EMPTY_RETAINED } +export function selectClaudeWorkflowRunsForWorktree( + state: WorktreeAgentRowsState, + worktreeId: string +): ClaudeWorkflowRunSummary[] { + return getWorkflowsByWorktree(state).get(worktreeId) ?? EMPTY_WORKFLOWS +} + export function buildWorktreeAgentRows(args: { tabs: TerminalTab[] entries: AgentStatusEntry[] retained: RetainedAgentEntry[] + workflows?: ClaudeWorkflowRunSummary[] now: number }): DashboardAgentRow[] { const rows: DashboardAgentRow[] = [] @@ -246,12 +301,15 @@ export function buildWorktreeAgentRows(args: { !isFresh && (entry.state === 'working' || entry.state === 'blocked' || entry.state === 'waiting') rows.push({ + kind: 'agent', + rowId: entry.paneKey, paneKey: entry.paneKey, entry, tab, agentType: entry.agentType ?? 'unknown', state: shouldDecay ? 'idle' : entry.state, - startedAt: entry.stateHistory[0]?.startedAt ?? entry.stateStartedAt + startedAt: entry.stateHistory[0]?.startedAt ?? entry.stateStartedAt, + parentRowId: entry.orchestration?.parentPaneKey }) seenPaneKeys.add(entry.paneKey) } @@ -262,15 +320,28 @@ export function buildWorktreeAgentRows(args: { continue } rows.push({ + kind: 'agent', + rowId: ra.entry.paneKey, paneKey: ra.entry.paneKey, entry: ra.entry, tab: ra.tab, agentType: ra.agentType, state: 'done', - startedAt: ra.startedAt + startedAt: ra.startedAt, + parentRowId: ra.entry.orchestration?.parentPaneKey }) } + if (args.workflows && args.workflows.length > 0) { + rows.push( + ...buildClaudeWorkflowRowsForWorktree({ + worktreeId: args.tabs[0]?.worktreeId ?? '', + workflows: args.workflows, + tabs: args.tabs + }) + ) + } + rows.sort((a, b) => a.startedAt - b.startedAt) return rows } @@ -304,6 +375,9 @@ export function useWorktreeAgentRows(worktreeId: string): DashboardAgentRow[] { const retained = useAppStore( useShallow((s) => selectRetainedAgentEntriesForWorktree(s, worktreeId)) ) + const workflows = useAppStore( + useShallow((s) => selectClaudeWorkflowRunsForWorktree(s, worktreeId)) + ) // Why: agentStatusEpoch is included in the dependency array (but not in the // computation itself) so the memo recomputes when freshness boundaries // expire, even if no new PTY data arrives — same rationale as @@ -330,9 +404,10 @@ export function useWorktreeAgentRows(worktreeId: string): DashboardAgentRow[] { tabs: tabs ?? [], entries, retained, + workflows, now }) ) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tabs, liveEntries, migrationUnsupported, retained, agentStatusEpoch]) + }, [tabs, liveEntries, migrationUnsupported, retained, workflows, agentStatusEpoch]) } diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 5424f6d696d..e153c5277df 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -44,6 +44,7 @@ import { normalizeAgentStatusPayload, type AgentStatusIpcPayload } from '../../../shared/agent-status-types' +import type { ClaudeWorkflowSnapshot } from '../../../shared/claude-workflow-status' import { isGitRepoKind } from '../../../shared/repo-kind' import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' @@ -2144,6 +2145,41 @@ export function useIpcEvents(): void { let snapshotRequestedForReadyWindow = false let snapshotRequestId = 0 + let workflowSnapshotRequestedForReadyWindow = false + let workflowSnapshotRequestId = 0 + const applyClaudeWorkflowSnapshot = (snapshot: ClaudeWorkflowSnapshot): void => { + const store = useAppStore.getState() + if (!store.workspaceSessionReady) { + return + } + store.setClaudeWorkflowSnapshot(snapshot) + } + const requestClaudeWorkflowSnapshotIfReady = (): void => { + const store = useAppStore.getState() + if (!store.workspaceSessionReady) { + workflowSnapshotRequestedForReadyWindow = false + return + } + if (workflowSnapshotRequestedForReadyWindow) { + return + } + const getSnapshot = window.api.claudeWorkflows?.getSnapshot + if (typeof getSnapshot !== 'function') { + return + } + workflowSnapshotRequestedForReadyWindow = true + const requestId = ++workflowSnapshotRequestId + void getSnapshot() + .then((snapshot) => { + if (requestId !== workflowSnapshotRequestId) { + return + } + applyClaudeWorkflowSnapshot(snapshot) + }) + .catch((err) => { + console.warn('[claude-workflows] failed to load startup snapshot:', err) + }) + } const requestAgentStatusSnapshotIfReady = (): void => { const store = useAppStore.getState() if (!store.workspaceSessionReady) { @@ -2226,15 +2262,23 @@ export function useIpcEvents(): void { if (unsubscribeMigrationUnsupportedClear) { unsubs.push(unsubscribeMigrationUnsupportedClear) } + const unsubscribeClaudeWorkflows = window.api.claudeWorkflows?.onDidUpdate?.((snapshot) => { + applyClaudeWorkflowSnapshot(snapshot) + }) + if (unsubscribeClaudeWorkflows) { + unsubs.push(unsubscribeClaudeWorkflows) + } // Why: the main hook server is the durable source of truth. Pull a // snapshot only after workspace tabs are ready, so early startup pushes // can be safely ignored instead of buffered against partially hydrated // renderer state. requestAgentStatusSnapshotIfReady() + requestClaudeWorkflowSnapshotIfReady() unsubs.push( useAppStore.subscribe(() => { requestAgentStatusSnapshotIfReady() + requestClaudeWorkflowSnapshotIfReady() flushPendingAgentStatuses() syncAgentHookCompletionNotificationSettings() }) diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 57bfcb274e4..5560059e070 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -23,6 +23,7 @@ import { createBrowserSlice } from './slices/browser' import { createRateLimitSlice } from './slices/rate-limits' import { createSshSlice } from './slices/ssh' import { createAgentStatusSlice } from './slices/agent-status' +import { createClaudeWorkflowsSlice } from './slices/claude-workflows' import { createDiffCommentsSlice } from './slices/diffComments' import { createDetectedAgentsSlice } from './slices/detected-agents' import { createWorktreeNavHistorySlice } from './slices/worktree-nav-history' @@ -55,6 +56,7 @@ export const useAppStore = create()((...a) => ({ ...createRateLimitSlice(...a), ...createSshSlice(...a), ...createAgentStatusSlice(...a), + ...createClaudeWorkflowsSlice(...a), ...createDiffCommentsSlice(...a), ...createDetectedAgentsSlice(...a), ...createWorktreeNavHistorySlice(...a), diff --git a/src/renderer/src/store/slices/claude-workflows.test.ts b/src/renderer/src/store/slices/claude-workflows.test.ts new file mode 100644 index 00000000000..c0a62301dc5 --- /dev/null +++ b/src/renderer/src/store/slices/claude-workflows.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest' +import { createTestStore } from './store-test-helpers' +import type { ClaudeWorkflowRunSummary } from '../../../../shared/claude-workflow-status' + +function makeRun(id: string, worktreeId = 'wt-1'): ClaudeWorkflowRunSummary { + return { + id, + parentPaneKey: 'tab-1:550e8400-e29b-41d4-a716-446655440000', + parentTabId: 'tab-1', + worktreeId, + connectionId: null, + runId: 'run-1', + label: 'Review workflow', + state: 'working', + startedAt: 1, + updatedAt: 2, + agents: [], + phases: [], + counts: { + total: 0, + done: 0, + working: 0, + waiting: 0, + blocked: 0, + error: 0 + } + } +} + +describe('Claude workflow store slice', () => { + it('stores snapshots and dismisses through main so snapshots do not resurrect dropped runs', () => { + const drop = vi.fn() + vi.stubGlobal('window', { api: { claudeWorkflows: { drop } } }) + const store = createTestStore() + + store + .getState() + .setClaudeWorkflowSnapshot({ runs: [makeRun('claude-workflow:1')], updatedAt: 2 }) + expect(Object.keys(store.getState().claudeWorkflowRunsById)).toEqual(['claude-workflow:1']) + + store.getState().dismissClaudeWorkflowRun('claude-workflow:1') + + expect(store.getState().claudeWorkflowRunsById).toEqual({}) + expect(drop).toHaveBeenCalledWith('claude-workflow:1') + vi.unstubAllGlobals() + }) + + it('dismisses all workflow runs for a worktree', () => { + const dropByWorktree = vi.fn() + vi.stubGlobal('window', { api: { claudeWorkflows: { dropByWorktree } } }) + const store = createTestStore() + store.getState().setClaudeWorkflowSnapshot({ + runs: [makeRun('claude-workflow:1', 'wt-1'), makeRun('claude-workflow:2', 'wt-2')], + updatedAt: 2 + }) + + store.getState().dismissClaudeWorkflowRunsByWorktree('wt-1') + + expect(Object.keys(store.getState().claudeWorkflowRunsById)).toEqual(['claude-workflow:2']) + expect(dropByWorktree).toHaveBeenCalledWith('wt-1') + vi.unstubAllGlobals() + }) +}) diff --git a/src/renderer/src/store/slices/claude-workflows.ts b/src/renderer/src/store/slices/claude-workflows.ts new file mode 100644 index 00000000000..e22c4e33b1c --- /dev/null +++ b/src/renderer/src/store/slices/claude-workflows.ts @@ -0,0 +1,106 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { + ClaudeWorkflowRunSummary, + ClaudeWorkflowSnapshot +} from '../../../../shared/claude-workflow-status' + +export type ClaudeWorkflowsSlice = { + claudeWorkflowRunsById: Record + claudeWorkflowEpoch: number + setClaudeWorkflowSnapshot: (snapshot: ClaudeWorkflowSnapshot) => void + dismissClaudeWorkflowRun: (id: string) => void + dismissClaudeWorkflowRunsByWorktree: (worktreeId: string) => void + pruneClaudeWorkflowRuns: (validWorktreeIds: Set) => void +} + +function snapshotToMap(snapshot: ClaudeWorkflowSnapshot): Record { + const next: Record = {} + for (const run of snapshot.runs) { + next[run.id] = run + } + return next +} + +export const createClaudeWorkflowsSlice: StateCreator = ( + set +) => ({ + claudeWorkflowRunsById: {}, + claudeWorkflowEpoch: 0, + + setClaudeWorkflowSnapshot: (snapshot) => { + set((s) => { + const next = snapshotToMap(snapshot) + const currentKeys = Object.keys(s.claudeWorkflowRunsById) + const nextKeys = Object.keys(next) + if ( + currentKeys.length === nextKeys.length && + nextKeys.every((key) => s.claudeWorkflowRunsById[key] === next[key]) + ) { + return s + } + return { + claudeWorkflowRunsById: next, + claudeWorkflowEpoch: s.claudeWorkflowEpoch + 1 + } + }) + }, + + dismissClaudeWorkflowRun: (id) => { + set((s) => { + if (!(id in s.claudeWorkflowRunsById)) { + return s + } + const next = { ...s.claudeWorkflowRunsById } + delete next[id] + return { + claudeWorkflowRunsById: next, + claudeWorkflowEpoch: s.claudeWorkflowEpoch + 1 + } + }) + if (typeof window !== 'undefined') { + window.api?.claudeWorkflows?.drop?.(id) + } + }, + + dismissClaudeWorkflowRunsByWorktree: (worktreeId) => { + let changed = false + set((s) => { + const next: Record = {} + for (const [id, run] of Object.entries(s.claudeWorkflowRunsById)) { + if (run.worktreeId === worktreeId) { + changed = true + continue + } + next[id] = run + } + if (!changed) { + return s + } + return { + claudeWorkflowRunsById: next, + claudeWorkflowEpoch: s.claudeWorkflowEpoch + 1 + } + }) + if (changed && typeof window !== 'undefined') { + window.api?.claudeWorkflows?.dropByWorktree?.(worktreeId) + } + }, + + pruneClaudeWorkflowRuns: (validWorktreeIds) => { + set((s) => { + let changed = false + const next: Record = {} + for (const [id, run] of Object.entries(s.claudeWorkflowRunsById)) { + if (run.worktreeId && !validWorktreeIds.has(run.worktreeId)) { + changed = true + continue + } + next[id] = run + } + return changed + ? { claudeWorkflowRunsById: next, claudeWorkflowEpoch: s.claudeWorkflowEpoch + 1 } + : s + }) + } +}) diff --git a/src/renderer/src/store/slices/diffComments.test.ts b/src/renderer/src/store/slices/diffComments.test.ts index a7bf5b3d4f0..f8a50d9b66f 100644 --- a/src/renderer/src/store/slices/diffComments.test.ts +++ b/src/renderer/src/store/slices/diffComments.test.ts @@ -130,6 +130,7 @@ import { createBrowserSlice } from './browser' import { createRateLimitSlice } from './rate-limits' import { createSshSlice } from './ssh' import { createAgentStatusSlice } from './agent-status' +import { createClaudeWorkflowsSlice } from './claude-workflows' import { createDiffCommentsSlice } from './diffComments' import { createDetectedAgentsSlice } from './detected-agents' import { createWorktreeNavHistorySlice } from './worktree-nav-history' @@ -161,6 +162,7 @@ function createTestStore() { ...createRateLimitSlice(...a), ...createSshSlice(...a), ...createAgentStatusSlice(...a), + ...createClaudeWorkflowsSlice(...a), ...createDiffCommentsSlice(...a), ...createDetectedAgentsSlice(...a), ...createWorktreeNavHistorySlice(...a), diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index cc6bd4bdde2..c6a03d33ae6 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -131,6 +131,7 @@ import { createBrowserSlice } from './browser' import { createRateLimitSlice } from './rate-limits' import { createSshSlice } from './ssh' import { createAgentStatusSlice } from './agent-status' +import { createClaudeWorkflowsSlice } from './claude-workflows' import { createDiffCommentsSlice } from './diffComments' import { createDetectedAgentsSlice } from './detected-agents' import { createWorktreeNavHistorySlice } from './worktree-nav-history' @@ -162,6 +163,7 @@ function createTestStore() { ...createRateLimitSlice(...a), ...createSshSlice(...a), ...createAgentStatusSlice(...a), + ...createClaudeWorkflowsSlice(...a), ...createDiffCommentsSlice(...a), ...createDetectedAgentsSlice(...a), ...createWorktreeNavHistorySlice(...a), diff --git a/src/renderer/src/store/slices/store-test-helpers.ts b/src/renderer/src/store/slices/store-test-helpers.ts index bb0f813ee5a..1473dc527e3 100644 --- a/src/renderer/src/store/slices/store-test-helpers.ts +++ b/src/renderer/src/store/slices/store-test-helpers.ts @@ -31,6 +31,7 @@ import { createBrowserSlice } from './browser' import { createRateLimitSlice } from './rate-limits' import { createSshSlice } from './ssh' import { createAgentStatusSlice } from './agent-status' +import { createClaudeWorkflowsSlice } from './claude-workflows' import { createDiffCommentsSlice } from './diffComments' import { createDetectedAgentsSlice } from './detected-agents' import { createWorktreeNavHistorySlice } from './worktree-nav-history' @@ -70,6 +71,7 @@ export function createTestStore() { ...createRateLimitSlice(...a), ...createSshSlice(...a), ...createAgentStatusSlice(...a), + ...createClaudeWorkflowsSlice(...a), ...createDiffCommentsSlice(...a), ...createDetectedAgentsSlice(...a), ...createWorktreeNavHistorySlice(...a), diff --git a/src/renderer/src/store/slices/tabs.test.ts b/src/renderer/src/store/slices/tabs.test.ts index 95d021baf40..4c827142ba6 100644 --- a/src/renderer/src/store/slices/tabs.test.ts +++ b/src/renderer/src/store/slices/tabs.test.ts @@ -127,6 +127,7 @@ import { createBrowserSlice } from './browser' import { createRateLimitSlice } from './rate-limits' import { createSshSlice } from './ssh' import { createAgentStatusSlice } from './agent-status' +import { createClaudeWorkflowsSlice } from './claude-workflows' import { createDiffCommentsSlice } from './diffComments' import { createDetectedAgentsSlice } from './detected-agents' import { createWorktreeNavHistorySlice } from './worktree-nav-history' @@ -160,6 +161,7 @@ function createTestStore() { ...createRateLimitSlice(...a), ...createSshSlice(...a), ...createAgentStatusSlice(...a), + ...createClaudeWorkflowsSlice(...a), ...createDiffCommentsSlice(...a), ...createDetectedAgentsSlice(...a), ...createWorktreeNavHistorySlice(...a), diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 981a34708c3..939cc5bbc0d 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -1527,6 +1527,7 @@ export const createTerminalSlice: StateCreator // "done" row. Sweep by worktreeId because retained snapshots can outlive // their original tab. get().dropAgentStatusByWorktree(worktreeId) + get().dismissClaudeWorkflowRunsByWorktree(worktreeId) if (ptyIds.length === 0) { return diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts index 46b4aa4e6e7..69d8aa9548f 100644 --- a/src/renderer/src/store/types.ts +++ b/src/renderer/src/store/types.ts @@ -21,6 +21,7 @@ import type { BrowserSlice } from './slices/browser' import type { RateLimitSlice } from './slices/rate-limits' import type { SshSlice } from './slices/ssh' import type { AgentStatusSlice } from './slices/agent-status' +import type { ClaudeWorkflowsSlice } from './slices/claude-workflows' import type { DiffCommentsSlice } from './slices/diffComments' import type { DetectedAgentsSlice } from './slices/detected-agents' import type { WorktreeNavHistorySlice } from './slices/worktree-nav-history' @@ -50,6 +51,7 @@ export type AppState = RepoSlice & RateLimitSlice & SshSlice & AgentStatusSlice & + ClaudeWorkflowsSlice & DiffCommentsSlice & DetectedAgentsSlice & WorktreeNavHistorySlice & diff --git a/src/shared/claude-workflow-status.test.ts b/src/shared/claude-workflow-status.test.ts new file mode 100644 index 00000000000..a9d5874a750 --- /dev/null +++ b/src/shared/claude-workflow-status.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { + extractClaudeWorkflowDetectorInput, + normalizeClaudeWorkflowState, + rollupClaudeWorkflowState, + type ClaudeWorkflowAgentSummary +} from './claude-workflow-status' + +describe('Claude workflow status parser', () => { + it('extracts Workflow tool evidence from a raw Claude hook payload before normalization', () => { + const input = extractClaudeWorkflowDetectorInput({ + paneKey: 'tab-1:550e8400-e29b-41d4-a716-446655440000', + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: JSON.stringify({ + hook_event_name: 'PostToolUse', + tool_name: 'Workflow', + tool_input: { scriptPath: '/tmp/workflows/review.js' }, + tool_response: { + runId: 'run-123', + transcriptDir: '/tmp/workflows/run-123' + } + }) + }) + + expect(input).toMatchObject({ + paneKey: 'tab-1:550e8400-e29b-41d4-a716-446655440000', + tabId: 'tab-1', + worktreeId: 'wt-1', + evidence: { + scriptPath: '/tmp/workflows/review.js', + runId: 'run-123', + transcriptDir: '/tmp/workflows/run-123' + } + }) + }) + + it('ignores non-Workflow Claude tool events even when path-like fields are present', () => { + expect( + extractClaudeWorkflowDetectorInput({ + paneKey: 'tab-1:550e8400-e29b-41d4-a716-446655440000', + payload: { + tool_name: 'Read', + scriptPath: '/tmp/workflows/review.js', + runId: 'run-123' + } + }) + ).toBeNull() + }) + + it('requires at least one stable workflow identifier', () => { + expect( + extractClaudeWorkflowDetectorInput({ + paneKey: 'tab-1:550e8400-e29b-41d4-a716-446655440000', + payload: { tool_name: 'Workflow', label: 'Review' } + }) + ).toBeNull() + }) + + it('rolls child states up to the most actionable workflow state', () => { + const base = { + id: 'a', + label: 'agent', + startedAt: 1, + updatedAt: 1 + } satisfies Omit + + expect( + rollupClaudeWorkflowState([ + { ...base, id: 'done', state: 'done' }, + { ...base, id: 'waiting', state: 'waiting' } + ]).state + ).toBe('waiting') + expect( + rollupClaudeWorkflowState([ + { ...base, id: 'working', state: 'working' }, + { ...base, id: 'blocked', state: 'blocked' } + ]).state + ).toBe('blocked') + expect(normalizeClaudeWorkflowState('failed')).toBe('error') + }) +}) diff --git a/src/shared/claude-workflow-status.ts b/src/shared/claude-workflow-status.ts new file mode 100644 index 00000000000..eee7df1262c --- /dev/null +++ b/src/shared/claude-workflow-status.ts @@ -0,0 +1,266 @@ +import type { AgentStatusState } from './agent-status-types' + +export type ClaudeWorkflowState = AgentStatusState | 'error' + +export type ClaudeWorkflowAgentSummary = { + id: string + label: string + state: ClaudeWorkflowState + startedAt: number + updatedAt: number + lastMessage?: string + tokenCount?: number + phaseId?: string +} + +export type ClaudeWorkflowPhaseSummary = { + id: string + label: string + state: ClaudeWorkflowState + agentIds: string[] +} + +export type ClaudeWorkflowRunSummary = { + id: string + parentPaneKey: string + parentTabId?: string + worktreeId?: string + connectionId: string | null + runId?: string + label: string + scriptName?: string + state: ClaudeWorkflowState + startedAt: number + updatedAt: number + lastMessage?: string + phaseSummary?: string + agents: ClaudeWorkflowAgentSummary[] + phases: ClaudeWorkflowPhaseSummary[] + counts: { + total: number + done: number + working: number + waiting: number + blocked: number + error: number + } + error?: string +} + +export type ClaudeWorkflowSnapshot = { + runs: ClaudeWorkflowRunSummary[] + updatedAt: number +} + +export type ClaudeWorkflowEvidence = { + scriptPath?: string + runId?: string + transcriptDir?: string + label?: string +} + +export type ClaudeWorkflowDetectorInput = { + paneKey: string + tabId?: string + worktreeId?: string + evidence: ClaudeWorkflowEvidence +} + +const MAX_STRING_FIELD = 240 +const MAX_MESSAGE_FIELD = 500 +const WORKFLOW_TOOL_NAME = 'workflow' + +function normalizeSingleLine(value: unknown, maxLength = MAX_STRING_FIELD): string | undefined { + if (typeof value !== 'string') { + return undefined + } + const normalized = value.trim().replace(/[\r\n\u2028\u2029]+/g, ' ') + if (!normalized) { + return undefined + } + return normalized.length > maxLength ? normalized.slice(0, maxLength) : normalized +} + +function parseEmbeddedJson(value: string): unknown { + const trimmed = value.trim() + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) { + return value + } + try { + return JSON.parse(trimmed) + } catch { + return value + } +} + +function getHookPayload(body: Record): unknown { + const rawPayload = body.payload + if (typeof rawPayload === 'string') { + return parseEmbeddedJson(rawPayload) + } + return rawPayload +} + +function readFirstString(record: Record, keys: string[]): string | undefined { + for (const key of keys) { + const value = normalizeSingleLine(record[key]) + if (value) { + return value + } + } + return undefined +} + +function visitObjects( + value: unknown, + visitor: (record: Record) => void, + depth = 0, + seen = new Set() +): void { + if (depth > 6 || typeof value !== 'object' || value === null || seen.has(value)) { + return + } + seen.add(value) + if (Array.isArray(value)) { + for (const item of value) { + visitObjects(item, visitor, depth + 1, seen) + } + return + } + const record = value as Record + visitor(record) + for (const child of Object.values(record)) { + visitObjects( + typeof child === 'string' ? parseEmbeddedJson(child) : child, + visitor, + depth + 1, + seen + ) + } +} + +function isWorkflowToolRecord(record: Record): boolean { + const toolName = readFirstString(record, [ + 'tool_name', + 'toolName', + 'name', + 'tool', + 'functionName' + ]) + return toolName?.toLowerCase() === WORKFLOW_TOOL_NAME +} + +function collectWorkflowEvidence(root: unknown): ClaudeWorkflowEvidence | null { + let sawWorkflowTool = false + const evidence: ClaudeWorkflowEvidence = {} + visitObjects(root, (record) => { + if (isWorkflowToolRecord(record)) { + sawWorkflowTool = true + } + evidence.scriptPath ??= readFirstString(record, [ + 'scriptPath', + 'script_path', + 'workflowScriptPath', + 'workflow_script_path' + ]) + evidence.runId ??= readFirstString(record, ['runId', 'run_id', 'workflowRunId']) + evidence.transcriptDir ??= readFirstString(record, [ + 'transcriptDir', + 'transcript_dir', + 'transcriptsDir', + 'workflowDir', + 'workflow_dir' + ]) + evidence.label ??= normalizeSingleLine( + record.title ?? record.label ?? record.workflowName ?? record.workflow_name + ) + }) + if (!sawWorkflowTool) { + return null + } + if (!evidence.scriptPath && !evidence.runId && !evidence.transcriptDir) { + return null + } + return evidence +} + +export function extractClaudeWorkflowDetectorInput( + body: unknown +): ClaudeWorkflowDetectorInput | null { + if (typeof body !== 'object' || body === null) { + return null + } + const record = body as Record + const paneKey = normalizeSingleLine(record.paneKey) + if (!paneKey) { + return null + } + const payload = getHookPayload(record) + if (typeof payload !== 'object' || payload === null) { + return null + } + const evidence = collectWorkflowEvidence(payload) + if (!evidence) { + return null + } + return { + paneKey, + tabId: normalizeSingleLine(record.tabId), + worktreeId: normalizeSingleLine(record.worktreeId), + evidence + } +} + +export function normalizeClaudeWorkflowState(value: unknown): ClaudeWorkflowState { + const normalized = normalizeSingleLine(value, 80)?.toLowerCase() + switch (normalized) { + case 'working': + case 'running': + case 'active': + case 'in_progress': + case 'in-progress': + return 'working' + case 'blocked': + case 'error': + case 'failed': + case 'failure': + return normalized === 'blocked' ? 'blocked' : 'error' + case 'waiting': + case 'queued': + case 'pending': + return 'waiting' + case 'done': + case 'complete': + case 'completed': + case 'success': + case 'succeeded': + return 'done' + default: + return 'working' + } +} + +export function rollupClaudeWorkflowState(agents: readonly ClaudeWorkflowAgentSummary[]): { + state: ClaudeWorkflowState + counts: ClaudeWorkflowRunSummary['counts'] +} { + const counts = { total: agents.length, done: 0, working: 0, waiting: 0, blocked: 0, error: 0 } + for (const agent of agents) { + counts[agent.state] += 1 + } + const state: ClaudeWorkflowState = + counts.error > 0 + ? 'error' + : counts.blocked > 0 + ? 'blocked' + : counts.working > 0 + ? 'working' + : counts.waiting > 0 + ? 'waiting' + : 'done' + return { state, counts } +} + +export function trimClaudeWorkflowMessage(value: unknown): string | undefined { + return normalizeSingleLine(value, MAX_MESSAGE_FIELD) +}