feat(chat): surface Claude Code Workflow runs in the transcript - #1011
Conversation
Claude Code's `Workflow` tool runs as a background task, so the transcript saw a single tool_use carrying a multi-hundred-line JS script, then silence for minutes, then a task-notification. The run's phase/agent tree was already on the wire and we were discarding it. The CLI emits it on `system`/`task_progress` as `workflow_progress` — a full replacement snapshot of the tree, attached only on real state transitions. Throttled no-transition ticks omit the key, which means "unchanged"; the stream handler only assigns when the key is present so a mid-run tick can't blank the tree. Everything on `WorkflowAgentProgress` is `#[serde(default)]` and unknown entry kinds map to `Unknown`, so one renamed field upstream costs an attribute rather than the whole progress tick. Persisted as `workflow_progress_json` alongside `agent_tool_calls_json` so a finished run stays readable after a reload, in replayed history, and in forked sessions. No UI yet — this is the plumbing.
Replaces the single tool row — whose summary was a truncated dump of the minified script — with a card showing the run's phases, per-agent state, model, token/tool counts, elapsed time, and latest tool summary, plus a progress rail. Two workflow-specific traps the card has to avoid: - "finished" is NOT `resultText.length > 0`, the idiom every other tool uses. A workflow's tool_result lands a second after launch while the run takes minutes; completion arrives later as a task-notification, which `useAgentStream` maps onto `agentStatus`. - Elapsed time is interpolated from `startedAt` only on the live path. Progress ticks are throttled to 10s, so reading elapsed off the last tick would look frozen — but a run interrupted by an app restart replays with agents still marked `progress`, and interpolating those against the current clock would report days of work that never happened. Workflows default to expanded, inverting the "collapsed is quiet" stance used for agents and tool groups: the card is the only indication that a backgrounded run is progressing at all. The script stays reachable behind a disclosure — it was visible in ToolActivityRow's expanded view before, and the card replaces that path. `extractToolSummary` gets a Workflow case so chat search stops matching against minified JavaScript.
A backgrounded workflow normally outlives the turn that started it: the
tool_result ("launched in background") lands within a second, the agent
finishes its turn, and the run keeps emitting task_progress for minutes
before its completion notification arrives.
`updateToolActivity` only ever touched `toolActivities[sessionId]`, so
every one of those updates was dropped once `finalizeTurn` moved the
activity into `completedTurns` — the card froze at whatever the tree looked
like at turn end and never showed the run finishing. It now falls back to
searching completed turns (newest first) when the live list has no match.
Purely additive: those updates were previously discarded, so no
currently-working path changes.
Persistence has the same problem — `save_turn_tool_activities` ran minutes
before the run ended. Adds `update_turn_tool_activity_progress`, a targeted
UPDATE by tool_use_id, called once on the terminal notification. COALESCE
on the status column so a progress-only write can't blank a resolved
status.
The card is the real surface, but a workflow's launching turn ends within seconds of starting it — so for nearly the whole multi-minute run the card is buried under whatever the agent did next. The pill pins name, current phase, and agent counts above the composer, and clicking it scrolls the card back into view. Sourced from completed turns as well as the live turn, for the same reason: `toolActivities` holds the activity only until the turn is finalized, which is a small fraction of a run's lifetime. Card lookup goes through a shared `data-workflow-tool-use-id` attribute rather than a ref — the two components sit in unrelated subtrees, and threading a ref would route it through MessagesWithTurns and TurnSummary, both god files. The spinner honors prefers-reduced-motion and only spins while agents are actually in flight.
Covers the transcript card, the status pill, persistence behavior, and the limits that come from what Claude Code puts on the stream — no log() output, no run controls, throttled progress, Claude backends only. Also documents the Claude-Code-side gating (paid plans, off by default on Pro, CLAUDE_CODE_DISABLE_WORKFLOWS, the keyword-trigger setting), since "why don't I see this" resolves there rather than in Claudette.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #1011 +/- ##
==========================================
+ Coverage 81.33% 81.40% +0.06%
==========================================
Files 122 122
Lines 45373 45554 +181
==========================================
+ Hits 36905 37082 +177
- Misses 8468 8472 +4 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
Surfaces Claude Code Workflow background runs as first-class transcript UI in Claudette by plumbing the workflow_progress phase/agent tree through the stream event model, persisting it with checkpoints, and rendering it via a dedicated Workflow card plus a live status pill.
Changes:
- Add
workflow_progresstoStreamEvent::System, persist it (workflow_progress_json) through checkpoint save/load, migrations, and fork. - Introduce UI types + summarization for workflow progress entries, and integrate Workflow rendering into both live tool activities and completed turns.
- Add a composer-adjacent status pill that tracks in-flight workflows across both live turns and completed turns, with scroll-to-card behavior and improved search summaries.
Reviewed changes
Copilot reviewed 39 out of 39 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/ui/src/utils/reconstructTurns.ts | Rehydrates workflow_progress_json into workflowProgress for completed turns. |
| src/ui/src/utils/reconstructTurns.test.ts | Updates fixtures to include workflow_progress_json. |
| src/ui/src/types/workflow.ts | Defines workflow progress entry types and summarization logic for UI rendering. |
| src/ui/src/types/workflow.test.ts | Adds unit tests for workflow summarization and terminal-state detection. |
| src/ui/src/types/checkpoint.ts | Extends persisted tool-activity data shape with workflow_progress_json. |
| src/ui/src/types/agent-events.ts | Adds optional workflow_progress to system stream events. |
| src/ui/src/stores/useAppStore.workflowProgress.test.ts | Tests updateToolActivity fallback into completedTurns for background workflows. |
| src/ui/src/stores/slices/chatSlice.ts | Adds workflowProgress to ToolActivity and implements completed-turn fallback updates. |
| src/ui/src/services/tauri/checkpoints.ts | Adds Tauri invoke wrapper for targeted progress persistence update. |
| src/ui/src/hooks/useLiveWorkflows.ts | New hook to find in-flight workflows across live + completed turns. |
| src/ui/src/hooks/useAgentStream.ts | Plumbs stream workflow_progress into activity updates and persists final tree on terminal notification. |
| src/ui/src/hooks/toolSummary.ts | Adds Workflow summary extraction so search doesn’t match minified scripts. |
| src/ui/src/components/chat/WorkflowStatusPill.tsx | New live pill UI for background workflows with scroll-to-card behavior. |
| src/ui/src/components/chat/WorkflowStatusPill.test.tsx | UI tests for pill rendering, dedupe, terminal hiding, and scroll behavior. |
| src/ui/src/components/chat/WorkflowStatusPill.module.css | Styling for the workflow status pill. |
| src/ui/src/components/chat/workflowMeta.ts | Extracts stable workflow name/description from tool input (meta literal / path). |
| src/ui/src/components/chat/workflowMeta.test.ts | Tests meta extraction correctness and edge cases (brace matching, path parsing). |
| src/ui/src/components/chat/WorkflowCard.tsx | New transcript card rendering phases/agents/progress rail/script disclosure. |
| src/ui/src/components/chat/WorkflowCard.test.tsx | UI tests for card rendering, progress semantics, script disclosure, and live elapsed interpolation. |
| src/ui/src/components/chat/WorkflowCard.module.css | Styling for the workflow card (separate module to avoid ChatPanel CSS growth). |
| src/ui/src/components/chat/workflowAnchor.ts | Shared data-attr constant for pill ↔ card scroll anchoring. |
| src/ui/src/components/chat/TurnSummary.tsx | Renders workflow activities using WorkflowCard in completed-turn summaries. |
| src/ui/src/components/chat/toolActivityGroups.ts | Treats workflow activities as first-class groups with stable labels. |
| src/ui/src/components/chat/ToolActivitiesSection.tsx | Adds live rendering path for workflow groups (inline vs grouped). |
| src/ui/src/components/chat/collapsedToolGroupKey.ts | Adds workflow: collapse-key discriminator for persistence across turn boundary. |
| src/ui/src/components/chat/ChatPanelSessionView.tsx | Mounts the workflow status pill above the composer. |
| src/ui/src/components/chat/chatHelpers.ts | Assigns workflow color in TOOL_COLORS. |
| src/model/checkpoint.rs | Extends TurnToolActivity model with workflow_progress_json (defaulted for backward compatibility). |
| src/migrations/mod.rs | Registers the new migration for workflow progress persistence. |
| src/migrations/20260723155203_turn_tool_activity_workflow_progress.sql | Adds workflow_progress_json column to turn_tool_activities. |
| src/fork.rs | Copies workflow_progress_json when forking session history; updates tests/fixtures. |
| src/db/checkpoint.rs | Writes/reads workflow_progress_json and adds targeted UPDATE by tool_use_id. |
| src/agent/types.rs | Adds Rust types for workflow progress entries and plumbs optional field into StreamEvent::System. |
| src/agent/mod.rs | Re-exports workflow progress types. |
| src/agent/codex_app_server.rs | Updates synthetic system event construction to include workflow_progress: None. |
| src-tauri/src/main.rs | Registers the new Tauri command update_turn_tool_activity_progress. |
| src-tauri/src/commands/chat/checkpoint.rs | Implements update_turn_tool_activity_progress Tauri command wrapper. |
| site/src/content/docs/features/workflows.mdx | New user docs page for workflow card/pill behavior, limits, and enabling in Claude Code. |
| site/astro.config.mjs | Adds Workflows docs page to the site sidebar nav. |
Two Copilot review points, both correct:
- `updateToolActivity` returned `{}` on a miss. Zustand shallow-merges an
updater's return, so `{}` allocates a new top-level state object and fires
every subscriber — on the common case, since most progress ticks reaching
the completed-turn fallback match nothing there. Return the existing state
so a miss is a true no-op.
- The WorkflowCard phase grouping ordered sections by first-seen agent,
which interleaves under a pipeline (a later phase's agent can arrive
before an earlier phase's slow one) — contradicting both the code comment
and the docs, which promise declaration order. Order sections by
`summary.phases` instead, appending any undeclared phase (and the
no-phase bucket) after the declared ones in first-seen order.
Adds regression tests for both: declaration-order rendering with
interleaved agents, undeclared-phase placement, and top-level state
identity preservation on a miss.
Two Copilot review points from the second pass, both correct:
- The status pill's aria-label reused the visual `counts` shorthand, which
is tuned for pill width. Before any agent was reported that produced
"Workflow x, starting agents complete", and otherwise spoke "1/3" as a
bare fraction. The spoken string is now built separately ("1 of 3 agents
complete"). The failure count is folded in as well, because aria-label
replaces the button's accessible name outright — the visual failure badge
was not announced at all.
- WorkflowCard's header carried cursor:pointer plus hover/focus styling
unconditionally, but the inline form (completed turns, and the inline
tool-display setting) has no chevron, click handler, or role. Those
affordances now scope to `[role="button"]`, which is set by exactly the
condition that wires onToggle.
Adds tests for the three aria-label shapes and for the header carrying the
button role only when a toggle is wired.
Copilot's third-pass point, and a real bug: `summarizeWorkflowProgress` fell back to the last declared phase whenever `currentPhaseTitle` was null, without checking whether anything was actually running. A script that calls agent() before any phase() produces in-flight agents with no phaseTitle — the pill would then confidently name the last declared phase while no agent was working in it. The code comment already claimed "no in-flight agent to borrow a phase from"; the condition never checked that. Now gated on `!hasInFlight`, which also becomes the single source for `running` so the two can't drift. Adds `phaseTitleOf`, normalizing the three no-phase shapes (undefined, explicit null, empty/whitespace) in one place, and uses it for the card's grouping too. Previously a whitespace-only title would key its own phase group and render as a nameless section separate from the unphased bucket. Six tests: the gated fallback, a later phased agent still winning, whitespace handling in both the summarizer and the normalizer. Verified the fallback tests fail against the ungated version.
A collapsed <details> doesn't paint its contents, but the text nodes still exist, and a long session accumulates one workflow card per run inside a transcript that already holds every completed turn. The <pre> now mounts on open via a small ScriptDisclosure component. State is driven by the toggle event rather than the `open` attribute, so the element stays uncontrolled and keeps native disclosure behavior — keyboard activation and find-in-page auto-expand both still sync it. Also corrects the comment on META_SCAN_CHARS that claimed scripts "run to hundreds of KB". Measured across 90 real runs: median ~10 KB, max ~30 KB (352 lines). The saving here is modest per card; it's worth taking because the cost is pure waste and the fix is four lines, but the original figure was wrong and had already been cited downstream.
`parseWorkflowProgress` filtered with a predicate asserting
`WorkflowProgressEntry` while only checking that `type` was a string, so
`{type: "bogus"}` was admitted and typed as a fully-validated union member.
Harmless today — every consumer re-discriminates on `type` and silently
skips what it doesn't recognize — but it is a trap for the first consumer
that trusts the type instead: an exhaustive switch with a `never` default,
or reading `.label` off a presumed agent.
Replaced with `isWorkflowProgressEntry`, colocated with the union it
validates and admitting only the kinds Rust can persist (including
`Unknown`, which Rust writes for any incoming kind it doesn't recognize).
Anything else is foreign or corrupt column data and is now dropped at the
boundary rather than carried through the type system.
Tests cover the guard directly and the persistence boundary: modeled kinds
rehydrate, unmodeled ones are dropped, and a column with nothing valid
returns undefined rather than an empty array.
Follow-up to f818663. The guard checked only that `type` was a known kind, so a corrupted entry of a known kind still passed and was asserted into the union — and `phaseTitleOf` calls `.trim()`, which throws on a non-string. An unhandled throw there takes out the whole transcript render, not one row. The guard now validates the fields each kind declares as required, making the predicate honest about everything the interface marks non-optional. Optional fields are deliberately still unchecked — per-field validation of ~25 attributes to guard data we serialized from Rust ourselves is not worth it. That trade has a consequence, so it is handled rather than assumed: `readOptionalString` / `readOptionalNumber` back every accessor that calls a method on, or accumulates, an optional field. Note the two layers are both needed — {type, index, label, state, phaseTitle: 1} passes required- field validation and would still have thrown. readOptionalNumber also rejects NaN and infinities, which would otherwise poison a running total as surely as a string does. Tests: required-field rejection per kind, the helpers directly, and the user-visible outcome — a card whose agent has four corrupt optional fields renders instead of throwing. Verified both accessor tests fail without the guards.
`workflow:<toolUseId>` is deliberately shared between the live card and the completed-turn group so a collapse choice survives the turn ending, but the two sides were spending it differently. While live, collapse hid only the agent tree and kept the header and progress rail. Once the turn ended, the enclosing `TurnSummary` chevron unmounted the whole card — so the finished run's agent count, failure badge, and token total vanished, which is exactly the summary someone collapsing a completed run wants to keep. `TurnSummary` now forwards a grouped workflow's `collapsed`/`onToggle` to the `WorkflowCard` instead of to its own chevron. The card is already a single-activity group with its own header, chevron, and rail, so the key means one thing on both sides and the run renders one chevron, not two. Keeping the card inside `TurnSummary` (rather than early-returning it the way skill markers do) preserves the turn footer and `TaskProgressBar`, which the standalone footer path does not render. Workflow groups also seed from `false` rather than `turn.collapsed`, which defaults to true and would otherwise fold an untouched card the instant its turn ended, reversing what the user was just watching. They no longer sync the legacy `turn.collapsed` flag, since they never read it. Four tests cover the collapsed header/badges/rail, the expanded default under a collapsed turn, the single collapse control, and persistence under the shared live key; all four fail against the previous behavior.
| if ( | ||
| streamEvent.subtype === "task_notification" && | ||
| updates.workflowProgress !== undefined | ||
| ) { | ||
| void updateTurnToolActivityProgress( | ||
| streamEvent.tool_use_id, | ||
| JSON.stringify(updates.workflowProgress), | ||
| updates.agentStatus ?? null, | ||
| ).catch((err) => { | ||
| debugChat("stream", "persist workflow progress failed", { | ||
| toolUseId: streamEvent.tool_use_id, | ||
| error: String(err), | ||
| }); | ||
| }); | ||
| } |
There was a problem hiding this comment.
Confirmed and fixed in 62b4eae — and this one was a real defect, not a theoretical one. Thanks for catching it.
I didn't want to settle this from comments and fixtures, so I decompiled the CLI. There is exactly one construction site for the event, and it's byte-identical across versions 2.1.208, 2.1.218, and 2.1.219:
function Vp(e,t,r){ if(!aRu(e))return;
lv({type:"system",subtype:"task_notification",task_id:e,tool_use_id:r?.toolUseId,
status:t,output_file:r?.outputFile??"",summary:r?.summary??"",usage:r?.usage,
...r?.skipTranscript!==void 0&&{skip_transcript:r.skipTranscript}}) }No workflow_progress, and the CLI's own zod schema for the subtype enumerates the same closed set. The only emitter that attaches a tree is the task_progress one. The completion path does receive the final tree — it just spends it on <agents_done>/<agents_error> counts inside the notification's body text instead of putting it on the wire.
So it wasn't "may be absent" — it's never present, and the branch was dead code from the day it landed.
The blast radius was worse than the gate itself. The row stayed exactly as save_turn_tool_activities wrote it seconds after launch: "[]" with a null status, because the tool_result ("launched in background") arrives before any agent has run. "[]" rehydrates to undefined, so every completed run replayed as the literal text "Starting workflow…" with no rail and no agent badge — and because the row still said running, useLiveWorkflows kept a status pill above the composer with an infinite spinner, one per historical workflow, stacking uncapped with no dismiss. That directly contradicts workflows.mdx: "The pill disappears when the run finishes." It also wasn't reload-only — hydrateCompletedTurns clobbers correct in-memory state on scroll-up pagination, /clear, rollback, and first session open.
Fix. Read the tree from the store on task_notification, as you suggested. That's sound for a reason worth recording: the CLI calls flushNow() after the run resolves but before the task leaves running, and a batch containing any non-progress state bypasses the 10s throttle — so the last task_progress Claudette receives carries the complete all-done tree. The store genuinely holds the final state at notification time. By then the activity has migrated into completedTurns, so the lookup checks both places via a new findToolActivity that mirrors the search updateToolActivity already does on the write side.
One thing beyond your suggestion. The guard also coupled status persistence to tree presence, so both orderings lost data — status-without-tree wrote nothing, and tree-without-status left agent_status stuck at "running" via the existing COALESCE. Both columns now COALESCE and the caller passes null for a tree it doesn't have, so a status-only write resolves the status without blanking a good row. That required widening update_turn_tool_activity_progress to Option<&str> through the Tauri command and TS wrapper.
Tests. There were none on this path — updateTurnToolActivityProgress had exactly one non-definition reference in the repo, the call site itself, and no frontend test constructed a task_notification at all. Added five hook tests (tree persisted from store; activity found in completedTurns; status-only write passes null; no write on progress ticks; non-Workflow activities ignored) plus a db test for the tree COALESCE. I confirmed the three behavioral hook tests fail against the old gate.
The terminal-notification write was gated on the notification carrying
`workflow_progress`. It never does. Decompiling the Claude CLI (byte-
identical across 2.1.208/218/219) shows one `task_notification` emitter,
and its payload is `{task_id, tool_use_id, status, output_file, summary,
usage, skip_transcript}` — the CLI's own zod schema for the subtype
enumerates the same closed set. The only emitter attaching a tree is the
`task_progress` one; the workflow completion path receives the final tree
but spends it on `<agents_done>` counts in the notification's body text
rather than putting it on the wire.
So the gate never opened and `updateTurnToolActivityProgress` was dead
code. The row stayed as `save_turn_tool_activities` wrote it seconds
after launch — `"[]"` with a null status, because the tool_result
("launched in background") lands before any agent has run. Every finished
run replayed as "Starting workflow…" with no rail, behind a status pill
that spun forever because the stale row still said "running", one per
historical run, stacking uncapped. That contradicts workflows.mdx's "The
pill disappears when the run finishes". It was not reload-only:
`hydrateCompletedTurns` clobbers correct in-memory state on scroll-up
pagination, `/clear`, rollback, and first session open.
Read the tree from the store instead. The CLI flushes a last
`task_progress` carrying the complete all-done tree before the task
leaves `running`, so the store holds the final state when the
notification arrives. By then the activity has migrated into
`completedTurns`, hence `findToolActivity`, which mirrors the search
`updateToolActivity` already does on the write side.
Also decouples the two columns: the guard previously tied status
persistence to tree presence, so both orderings lost data. Both now
COALESCE, and the caller passes `null` for a tree it doesn't have, so a
status-only write can't blank a good row.
Five hook tests and one db test; the three behavioral hook tests fail
against the old gate.
| * Returns `undefined` rather than `[]` for the empty case so a non-workflow | ||
| * activity (every one of which stores `"[]"`) doesn't come back looking | ||
| * like a workflow that produced no agents — `WorkflowCard` keys off | ||
| * `workflowProgress` being present at all. | ||
| * |
There was a problem hiding this comment.
Correct — fixed in 7d764c8. I checked before accepting: summarizeWorkflowProgress does entries ?? [] (types/workflow.ts:208), so undefined and [] produce an identical summary for both WorkflowCard and useLiveWorkflows. Whether a card renders at all is decided by toolName === "Workflow" via isWorkflowActivity, exactly as you say. A non-workflow activity storing "[]" was never going to surface as a workflow, so the stated reason didn't hold up.
Worth noting the distinction is still load-bearing — just for a reason that only came into existence in 62b4eae, one commit before you flagged this. The terminal-notification write now sends null for an absent tree so the stored column is COALESCEd rather than overwritten with "[]". Collapsing undefined into [] here would let a run whose activity was rehydrated without a tree blank a good row that an earlier write had already put in the database.
So the comment now says that instead, and explicitly notes that rendering does not need the distinction — which is the part that was misleading. Comments only; no behavior change.
| // `undefined`, not `[]` — a non-workflow activity stores "[]", and the | ||
| // card keys off `workflowProgress` being present at all. |
There was a problem hiding this comment.
Fixed alongside its source comment in 7d764c8 — see the reconstructTurns.ts thread for the verification.
Short version: you're right that the rationale was wrong, and the test comment inherited it verbatim from the doc comment above the function. Both now give the accurate reason — the undefined vs [] distinction feeds the terminal-notification write's null-means-COALESCE contract, not card rendering — and both say plainly that rendering treats the two identically.
Agreed on the framing, too: this is exactly the kind of stale "why" that misleads a future refactor into thinking the empty case is a rendering concern and collapsing it, which would now silently blank stored trees.
The doc comment on `parseWorkflowProgress` and its matching test comment both claimed `WorkflowCard` keys off `workflowProgress` being present. It doesn't: `summarizeWorkflowProgress` coerces `undefined` and `[]` to the same empty summary, and whether a card renders is decided by `toolName === "Workflow"` via `isWorkflowActivity`. A non-workflow activity was never going to render as a workflow regardless. The distinction is still load-bearing, but for a reason introduced in 62b4eae: the terminal-notification write sends `null` for an absent tree so the stored column is COALESCEd rather than overwritten with `"[]"`. Collapsing the two here would let a run rehydrated without a tree blank a good row. Comments only — no behavior change.
Claude Code's
Workflowtool orchestrates many subagents from a script. Claudette rendered a run as one tool row whose summary was a truncated dump of the minified script, followed by several minutes of nothing, then a task-notification. The run's phase/agent tree was already on the wire — we were discarding it.What a workflow actually is
Worth stating up front, because it's what most of the tricky parts follow from: a workflow is a background task, not a nested
Agentcall. The tool returns within a second:…the agent finishes its turn, and the run keeps going for minutes before its
<task-notification>arrives. Two consequences drive the design:resultText.length > 0— the idiom every other tool uses. That's true a second after launch. Completion arrives later as a task-notification, whichuseAgentStreammaps ontoagentStatus.completedTurns, nottoolActivities.Where the data comes from
system/task_progresscarriesworkflow_progress: a full replacement snapshot of the tree.{"type":"workflow_agent","index":1,"label":"review:completeness","phaseIndex":1, "phaseTitle":"Review","model":"claude-opus-4-8","state":"done", "lastToolSummary":"No functional completeness gaps","tokens":84669, "toolCalls":38,"durationMs":180027}useAgentStreamalready handledtask_progressfordescription/last_tool_name/usage;StreamEvent::Systemsimply had no field for the tree. The schema here was derived from 971 real entries across 83 completed runs on disk, plus the CLI's emit sites.The on-disk artifacts are not a viable source, for the record:
wf_<runId>.jsonis only written at terminal state (all 83 local ones arecompleted/killed, neverrunning), and the livejournal.jsonlholds only opaque cache keys with no labels or phases.Changes
Plumbing —
workflow_progressonStreamEvent::System, persisted asworkflow_progress_jsonalongsideagent_tool_calls_json(migration + checkpoint + fork). Every field onWorkflowAgentProgressis#[serde(default)]and unknown entry kinds map toUnknown, so one renamed field upstream costs an attribute rather than the whole progress tick.Card — phases, per-agent state/model/tokens/tools/elapsed, latest tool summary, progress rail,
cached/retry N/worktreebadges. Defaults to expanded, inverting the "collapsed is quiet" stance used for agents and tool groups: the card is the only sign a backgrounded run is progressing.Lifecycle fix —
updateToolActivitynow falls back to completed turns when the live list has no match (purely additive; those updates were previously dropped on the floor). Addsupdate_turn_tool_activity_progress, a targeted UPDATE bytool_use_id, called once on the terminal notification —save_turn_tool_activitiesran minutes earlier.Pill — name, current phase, agent counts above the composer; click scrolls to the card. Sourced from completed turns too, for the reason above.
Search —
extractToolSummarygets aWorkflowcase so chat search stops matching against minified JavaScript.Details worth a reviewer's eye
workflow_progressabsent ≠ empty. The CLI attaches the tree on state transitions and throttles otherwise (10s). Assigning unconditionally would blank the card mid-run; the handler only touches it when the key is present.progress, and interpolating those against now would report days of work that never happened.meta.nameextraction is brace-matched, not windowed. A fixed character window reads thename:of whatever declaration followsmeta— which in practice is oftenconst DIMENSIONS = [{ name: '...' }]. There's a test for exactly that.ToolActivityRow's expanded view before, and the card replaces that path.Deliberately not built
log()narration — the CLI stripsworkflow_logentries before they reach the SDK stream. Not available at any price.abortController/agentControllersinside the CLI process and aren't exposed over the stream protocol. The card is read-only.Codex Native has no equivalent tool; the card never appears there.
Testing
cargo test -p claudette -p claudette-server -p claudette-cli --all-features— 1499 pass, incl. 4 stream-parse tests (real payload, unknown entry kind, all-fields-missing, absent-tree-not-reserialized) and 3 for the targeted DB update.bun run test— 2846 pass; new: 14 card, 9 pill, 6 store-fallback, 15 meta-extraction, 11 summarizer.cargo clippy(all three crates,--all-targets --all-features) clean;cargo fmt --check,bunx tsc -b,bun run lint:css,bun run lintclean.cargo test -p claudette-tauri --no-runafter staging the CLI sidecar;claudette-mobileclippy + tests.Docs:
site/src/content/docs/features/workflows.mdx, including the Claude-Code-side gating (paid plans, off by default on Pro,CLAUDE_CODE_DISABLE_WORKFLOWS, keyword-trigger setting) — "why don't I see this" resolves there, not in Claudette. No Settings panel controls added, sosettings.mdxis unchanged.