mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 08:03:13 +00:00
Add Claude workflow status groups
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }[]
|
||||
|
||||
@@ -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,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()
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 &
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user