Skip to content

feat(chat): surface Claude Code Workflow runs in the transcript - #1011

Merged
doomspork merged 14 commits into
mainfrom
doomspork/show-workflows
Jul 25, 2026
Merged

feat(chat): surface Claude Code Workflow runs in the transcript#1011
doomspork merged 14 commits into
mainfrom
doomspork/show-workflows

Conversation

@doomspork

Copy link
Copy Markdown
Member

Claude Code's Workflow tool 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 Agent call. The tool returns within a second:

Workflow launched in background. Task ID: w4stpeffj

…the agent finishes its turn, and the run keeps going for minutes before its <task-notification> arrives. Two consequences drive the design:

  1. "Finished" is not resultText.length > 0 — the idiom every other tool uses. That's true a second after launch. Completion arrives later as a task-notification, which useAgentStream maps onto agentStatus.
  2. A workflow outlives its turn. For most of a run's life the activity lives in completedTurns, not toolActivities.

Where the data comes from

system / task_progress carries workflow_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}

useAgentStream already handled task_progress for description / last_tool_name / usage; StreamEvent::System simply 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>.json is only written at terminal state (all 83 local ones are completed/killed, never running), and the live journal.jsonl holds only opaque cache keys with no labels or phases.

Changes

Plumbingworkflow_progress on StreamEvent::System, persisted as workflow_progress_json alongside agent_tool_calls_json (migration + checkpoint + fork). Every field 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.

Card — phases, per-agent state/model/tokens/tools/elapsed, latest tool summary, progress rail, cached / retry N / worktree badges. 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 fixupdateToolActivity now falls back to completed turns when the live list has no match (purely additive; those updates were previously dropped on the floor). Adds update_turn_tool_activity_progress, a targeted UPDATE by tool_use_id, called once on the terminal notification — save_turn_tool_activities ran 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.

SearchextractToolSummary gets a Workflow case so chat search stops matching against minified JavaScript.

Details worth a reviewer's eye

  • workflow_progress absent ≠ 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.
  • Elapsed is interpolated only on the live path. Progress ticks are throttled, so reading elapsed off the last tick looks frozen — but a run interrupted by an app restart replays with agents still marked progress, and interpolating those against now would report days of work that never happened.
  • meta.name extraction is brace-matched, not windowed. A fixed character window reads the name: of whatever declaration follows meta — which in practice is often const DIMENSIONS = [{ name: '...' }]. There's a test for exactly that.
  • The script stays reachable behind a disclosure in the card; it was visible in ToolActivityRow's expanded view before, and the card replaces that path.

Deliberately not built

  • log() narration — the CLI strips workflow_log entries before they reach the SDK stream. Not available at any price.
  • Pause / kill / retry / skip — these live on in-memory abortController / agentControllers inside 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 lint clean.
  • cargo test -p claudette-tauri --no-run after staging the CLI sidecar; claudette-mobile clippy + tests.
  • Docs site builds; new page registered in the sidebar nav.

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, so settings.mdx is unchanged.

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.
@doomspork
doomspork requested a review from a team as a code owner July 23, 2026 16:23
Copilot AI review requested due to automatic review settings July 23, 2026 16:23
@codecov

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.44444% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.40%. Comparing base (114e277) to head (7d764c8).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@            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:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_progress to StreamEvent::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.

Comment thread src/ui/src/stores/slices/chatSlice.ts
Comment thread src/ui/src/components/chat/WorkflowCard.tsx Outdated
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.
Copilot AI review requested due to automatic review settings July 23, 2026 20:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 39 out of 39 changed files in this pull request and generated 2 comments.

Comment thread src/ui/src/components/chat/WorkflowStatusPill.tsx
Comment thread src/ui/src/components/chat/WorkflowCard.module.css
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 AI review requested due to automatic review settings July 24, 2026 20:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 39 out of 39 changed files in this pull request and generated 1 comment.

Comment thread src/ui/src/types/workflow.ts
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.
Copilot AI review requested due to automatic review settings July 24, 2026 20:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 39 out of 39 changed files in this pull request and generated 1 comment.

Comment thread src/ui/src/components/chat/WorkflowCard.tsx Outdated
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.
Copilot AI review requested due to automatic review settings July 24, 2026 20:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 39 out of 39 changed files in this pull request and generated 1 comment.

Comment thread src/ui/src/utils/reconstructTurns.ts Outdated
`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.
Copilot AI review requested due to automatic review settings July 24, 2026 21:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 39 out of 39 changed files in this pull request and generated 1 comment.

Comment thread src/ui/src/types/workflow.ts
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.
Copilot AI review requested due to automatic review settings July 24, 2026 21:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 39 out of 39 changed files in this pull request and generated 2 comments.

Comment thread src/ui/src/components/chat/ToolActivitiesSection.tsx
Comment thread src/ui/src/components/chat/TurnSummary.tsx
`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.
Copilot AI review requested due to automatic review settings July 24, 2026 22:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 41 out of 41 changed files in this pull request and generated 1 comment.

Comment thread src/ui/src/hooks/useAgentStream.ts Outdated
Comment on lines +347 to +361
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),
});
});
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Copilot AI review requested due to automatic review settings July 25, 2026 00:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 43 out of 43 changed files in this pull request and generated 2 comments.

Comment thread src/ui/src/utils/reconstructTurns.ts Outdated
Comment on lines +128 to +132
* 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.
*

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +433 to +434
// `undefined`, not `[]` — a non-workflow activity stores "[]", and the
// card keys off `workflowProgress` being present at all.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Copilot AI review requested due to automatic review settings July 25, 2026 00:48

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 43 out of 43 changed files in this pull request and generated no new comments.

@doomspork
doomspork merged commit 14d5af8 into main Jul 25, 2026
17 checks passed
@doomspork
doomspork deleted the doomspork/show-workflows branch July 25, 2026 01:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants