Add Claude workflow status groups

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-05-30 00:59:28 -07:00
co-authored by Orca
parent b8b1ad0b55
commit 64c4123cbd
28 changed files with 1709 additions and 70 deletions
@@ -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.
@@ -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<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
}
function readString(record: Record<string, unknown>, keys: string[]): string | undefined {
for (const key of keys) {
const value = trimClaudeWorkflowMessage(record[key])
if (value) {
return value
}
}
return undefined
}
function readNumber(record: Record<string, unknown>, 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<string>,
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<string, unknown>,
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<string, ClaudeWorkflowAgentSummary>()
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<string, ClaudeWorkflowAgentSummary>()
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<string, ClaudeWorkflowAgentSummary[]>()
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<ReturnType<typeof extractClaudeWorkflowDetectorInput>>
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<string, ClaudeWorkflowAgentSummary>()
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<string, LocalRunRecord>()
private dismissedIdentities = new Set<string>()
private pendingTimers = new Map<string, ReturnType<typeof setTimeout>>()
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()
+6
View File
@@ -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)
+10
View File
@@ -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
+18
View File
@@ -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
+7
View File
@@ -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<ClaudeWorkflowSnapshot>
drop: (id: string) => void
dropByWorktree: (worktreeId: string) => void
}
mobile: {
listNetworkInterfaces: () => Promise<{
interfaces: { name: string; address: string }[]
+18
View File
@@ -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<ClaudeWorkflowSnapshot> =>
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<SpeechModelManifest[]> => ipcRenderer.invoke('speech:getCatalog'),
getModelStates: (): Promise<SpeechModelState[]> => ipcRenderer.invoke('speech:getModelStates'),
@@ -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<DashboardAgentRowData> = {},
overrides: Partial<DashboardRealAgentRow> = {},
entryOverrides: Partial<AgentStatusEntry> = {}
): 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(
<TooltipProvider>
<DashboardAgentRow
@@ -1,3 +1,6 @@
/* eslint-disable max-lines -- Why: DashboardAgentRow owns the compact agent row
presentation for live agents and Claude workflow virtual rows; splitting
would scatter tightly-coupled row layout, expansion, and dismissal behavior. */
import React, { useState, useCallback } from 'react'
import { X, Wrench, ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
@@ -49,6 +52,9 @@ function formatTimeAgo(ts: number, now: number): string {
// drift away from the true transition moment. For past dones, stateHistory
// entries already store the per-transition `startedAt` so we read it directly.
function lastEnteredDoneAt(agent: DashboardAgentRowData): number | null {
if (agent.kind !== 'agent') {
return agent.state === 'done' ? agent.startedAt : null
}
const entry = agent.entry
if (entry.state === 'done') {
return entry.stateStartedAt
@@ -62,7 +68,7 @@ function lastEnteredDoneAt(agent: DashboardAgentRowData): number | null {
}
function stateDotTooltipLabel(agent: DashboardAgentRowData, dotState: AgentDotState): string {
if (agent.entry.interrupted === true) {
if (agent.kind === 'agent' && agent.entry.interrupted === true) {
return 'Interrupted by user'
}
return agentStateLabel(dotState)
@@ -149,9 +155,9 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
const handleDismiss = useCallback(
(e: React.MouseEvent) => {
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 && (
<div
data-agent-row-tool-slot=""
className="mt-0.5 min-w-0 pl-5 text-[10px] leading-snug text-muted-foreground/70"
@@ -23,41 +23,41 @@ export function applyAgentRowLineage(rows: DashboardAgentRow[]): DashboardAgentR
return rows.map((row) => ({ ...row, lineage: ROOT_LINEAGE }))
}
const rowsByPaneKey = new Map(rows.map((row) => [row.paneKey, row]))
const childrenByParentPaneKey = new Map<string, DashboardAgentRow[]>()
const childPaneKeys = new Set<string>()
const rowsById = new Map(rows.map((row) => [row.rowId, row]))
const childrenByParentRowId = new Map<string, DashboardAgentRow[]>()
const childRowIds = new Set<string>()
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<string>()
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)
@@ -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<string, Worktree[]>,
tabsByWorktree: Record<string, TerminalTab[]>,
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
migrationUnsupportedByPtyId: Record<string, MigrationUnsupportedPtyEntry>,
claudeWorkflowRunsById: Record<string, ClaudeWorkflowRunSummary>,
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
]
)
@@ -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: {
@@ -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<string, { row: DashboardAgentRow; worktreeId: string }>
type RetainedAgentSnapshot = Map<string, { row: DashboardRealAgentRow; worktreeId: string }>
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<string, { row: DashboardAgentRow; worktreeId: string }>
currentAgents: Map<string, { row: DashboardAgentRow; worktreeId: string }>
previousAgents: Map<string, { row: DashboardRealAgentRow; worktreeId: string }>
currentAgents: Map<string, { row: DashboardRealAgentRow; worktreeId: string }>
retainedAgentsByPaneKey: Record<string, RetainedAgentEntry>
retentionSuppressedPaneKeys: Record<string, true>
}): {
@@ -52,26 +52,36 @@ type AgentLineageModel = {
childrenByParentPaneKey: Map<string, DashboardAgentRowData[]>
}
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<string, DashboardAgentRowData[]>()
const childPaneKeys = new Set<string>()
const childRowIds = new Set<string>()
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<string> = 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<string, boolean> = {}
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<string> = 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 (
<React.Fragment key={agent.paneKey}>
<React.Fragment key={key}>
<DashboardAgentRow
agent={agent}
onDismiss={handleDismissAgent}
@@ -261,13 +284,11 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
// disclosure stripe below it. Variant B in the mockups.
childAgentCount={hasChildAgents ? childAgents.length : undefined}
childAgentsExpanded={expanded}
onToggleChildAgents={
hasChildAgents ? () => 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
@@ -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')
)
})
})
@@ -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<string, RetainedAgentEntry[]>
}
type WorkflowsByWorktreeCache = {
claudeWorkflowRunsById: WorktreeAgentRowsState['claudeWorkflowRunsById']
runsByWorktree: Map<string, ClaudeWorkflowRunSummary[]>
}
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<T>(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<string, ClaudeWorkflowRunSummary[]> {
const workflowsById = state.claudeWorkflowRunsById ?? {}
if (workflowsByWorktreeCache?.claudeWorkflowRunsById === workflowsById) {
return workflowsByWorktreeCache.runsByWorktree
}
const previous = workflowsByWorktreeCache?.runsByWorktree
const runsByWorktree = new Map<string, ClaudeWorkflowRunSummary[]>()
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])
}
+44
View File
@@ -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()
})
+2
View File
@@ -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<AppState>()((...a) => ({
...createRateLimitSlice(...a),
...createSshSlice(...a),
...createAgentStatusSlice(...a),
...createClaudeWorkflowsSlice(...a),
...createDiffCommentsSlice(...a),
...createDetectedAgentsSlice(...a),
...createWorktreeNavHistorySlice(...a),
@@ -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()
})
})
@@ -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<string, ClaudeWorkflowRunSummary>
claudeWorkflowEpoch: number
setClaudeWorkflowSnapshot: (snapshot: ClaudeWorkflowSnapshot) => void
dismissClaudeWorkflowRun: (id: string) => void
dismissClaudeWorkflowRunsByWorktree: (worktreeId: string) => void
pruneClaudeWorkflowRuns: (validWorktreeIds: Set<string>) => void
}
function snapshotToMap(snapshot: ClaudeWorkflowSnapshot): Record<string, ClaudeWorkflowRunSummary> {
const next: Record<string, ClaudeWorkflowRunSummary> = {}
for (const run of snapshot.runs) {
next[run.id] = run
}
return next
}
export const createClaudeWorkflowsSlice: StateCreator<AppState, [], [], ClaudeWorkflowsSlice> = (
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<string, ClaudeWorkflowRunSummary> = {}
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<string, ClaudeWorkflowRunSummary> = {}
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
})
}
})
@@ -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),
@@ -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),
@@ -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),
@@ -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),
@@ -1527,6 +1527,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
// "done" row. Sweep by worktreeId because retained snapshots can outlive
// their original tab.
get().dropAgentStatusByWorktree(worktreeId)
get().dismissClaudeWorkflowRunsByWorktree(worktreeId)
if (ptyIds.length === 0) {
return
+2
View File
@@ -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 &
+82
View File
@@ -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<ClaudeWorkflowAgentSummary, 'state'>
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')
})
})
+266
View File
@@ -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<string, unknown>): unknown {
const rawPayload = body.payload
if (typeof rawPayload === 'string') {
return parseEmbeddedJson(rawPayload)
}
return rawPayload
}
function readFirstString(record: Record<string, unknown>, 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<string, unknown>) => void,
depth = 0,
seen = new Set<unknown>()
): 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<string, unknown>
visitor(record)
for (const child of Object.values(record)) {
visitObjects(
typeof child === 'string' ? parseEmbeddedJson(child) : child,
visitor,
depth + 1,
seen
)
}
}
function isWorkflowToolRecord(record: Record<string, unknown>): 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<string, unknown>
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)
}