surface Claude Code Workflow runs in convo + sidebar (T-416)

A `Workflow` tool-use launches its multi-agent run in the background and
returns immediately; the real fan-out arrives out-of-band on stream-json
`type:"system"` task_* events (task_started / task_progress / task_updated /
task_notification) keyed by the launching tool-use id — which clide was
dropping. (Wire shape captured by two live stream-json probes; recorded on
the ticket.)

- workflow_run.dart: a pure, Flutter-free WorkflowRun/WorkflowAgent model
  that folds those events (phases, per-agent start→progress→done deltas,
  usage) into a snapshot.
- StreamJsonSession recognises the events, accumulates a
  Map<toolUseId, WorkflowRun>, and exposes `workflows` + `workflowsStream`.
- A `Workflow` tool-use with a live run renders a dedicated run card —
  phase groups, per-agent rows with spinner/check status, usage, and the
  script — falling back to the generic tool card pre-progress or on reload.
  The run breaks the activity cluster so it's always first-class (like T-342).
- The sidebar Activity tab adds a WORKFLOWS section: one row per run with its
  done/total agent count, tinted by running/done state.

Closes T-416 and the T-410 epic (all children done).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 21:44:41 +02:00
co-authored by Claude Opus 4.8
parent 57978c54c5
commit b399acedbf
17 changed files with 871 additions and 4 deletions
+18
View File
@@ -4367,3 +4367,21 @@ answered headless in stream-json (free, num_turns 0) with parseable text
(session %, week % all-models, week % Sonnet). The Activity tab now renders it
via parseUsageText + a user-initiated refresh control. Remaining scope for this
ticket would be per-member/team budget split, if still wanted.', NULL, '2026-06-12 12:11:28', '2026-06-12 12:11:28', '2026-06-12 12:11:28', NULL, '5e45a2e89f2234f8b642e1b9968d7ef1', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPQ8QNGJFFK7G24CBWQAR2C', 'description', 'Claude Code''s Workflow mechanism (multi-agent orchestration: wf_<id> runs, phases, agent fan-outs, live progress) currently renders in clide as ordinary tool-use cards at best. Integrate it properly: (1) CONVO PANEL — recognise a Workflow tool-use and render a dedicated run card: phase groups, per-agent rows with live status, and the run''s result summary; reuse the collapser/agent-card machinery (T-305/T-342) rather than inventing new chrome. (2) STATUS/SIDEBAR — while a workflow runs, show an aggregate indicator (run id, phase, agents active/done) in the Claude sidebar Activity tab and/or status line.
SPIKE FIRST: capture what the stream-json wire actually emits during a Workflow run (tool_use input shape, progress/notification events, sidechain attribution for workflow-spawned agents) same probe method as T-410''s. Scope the rendering to what the wire really carries; if progress only exists in the harness UI and not on the wire, document that limit and render what''s available (start/end + result). Filed from user request 2026-06-12.', 'Claude Code''s Workflow mechanism (multi-agent orchestration: wf_<id> runs, phases, agent fan-outs, live progress) currently renders in clide as ordinary tool-use cards at best. Integrate it properly: (1) CONVO PANEL recognise a Workflow tool-use and render a dedicated run card: phase groups, per-agent rows with live status, and the run''s result summary; reuse the collapser/agent-card machinery (T-305/T-342) rather than inventing new chrome. (2) STATUS/SIDEBAR while a workflow runs, show an aggregate indicator (run id, phase, agents active/done) in the Claude sidebar Activity tab and/or status line.
SPIKE FIRST: capture what the stream-json wire actually emits during a Workflow run (tool_use input shape, progress/notification events, sidechain attribution for workflow-spawned agents) same probe method as T-410''s. Scope the rendering to what the wire really carries; if progress only exists in the harness UI and not on the wire, document that limit and render what''s available (start/end + result). Filed from user request 2026-06-12.
--- SPIKE FINDINGS (2026-06-12, claude 2.1.175 stream-json probe) ---
ANCHOR: Workflow run is a normal assistant tool_use {name:''Workflow'', input:{script}}. Its tool_result returns IMMEDIATELY: ''Workflow launched in background. Task ID: <id>''. The run is async today clide shows only the generic tool card + that result, no fan-out.
PROGRESS: carried on type:''system'' events (clide currently drops these), keyed by tool_use_id + task_id:
- task_started: task_id, tool_use_id, description, task_type:''local_workflow'', workflow_name, prompt(script source)
- task_progress (repeated): usage{total_tokens,tool_uses,duration_ms}, summary, workflow_progress[] = per-agent {index,label,model,state:start->progress->done,agentId} DELTAS (partial; merge by index)
- task_updated: patch{status,end_time}
- task_notification: terminal status:''completed'', output_file, summary, usage
LIMITS: (1) workflow agents are NOT sidechain messages (no separate assistant/user wire events, unlike Task) they exist only as workflow_progress telemetry; existing parentToolUseId nesting does not apply. (2) system events are ephemeral (not in resumed transcript JSONL) live progress shows during the session; on reload only the tool card + result summary survive.
PLAN: parse system task_* into a WorkflowRun model keyed by tool_use_id in StreamJsonSession; upgrade the Workflow tool_use card to render agent rows + status; add an aggregate active-workflow indicator to the Activity tab. Raw probe wire: /tmp/wf-probe/wire.jsonl.', NULL, '2026-06-12 14:22:42', '2026-06-12 14:22:42', '2026-06-12 14:22:42', NULL, '0b91f092dbd940481e385ccef6e45e6c', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPQ8QNGJFFK7G24CBWQAR2C', 'status', 'ready', 'in_progress', NULL, '2026-06-12 14:22:45', '2026-06-12 14:22:45', '2026-06-12 14:22:45', NULL, 'bff47e0f2b4ebfbf5a4ca6b9c7b45825', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPQ8QNGJFFK7G24CBWQAR2C', 'status', 'in_progress', 'done', NULL, '2026-06-12 14:52:31', '2026-06-12 14:52:31', '2026-06-12 14:52:31', NULL, 'd05d502773832fcfb5c8baf19a606b1c', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3EZC7AJANXZVF3D91QYWM', 'status', 'ready', 'done', NULL, '2026-06-12 19:44:20', '2026-06-12 19:44:20', '2026-06-12 19:44:20', NULL, '3637ac53f5fc94698f604db89fdcb7e0', 2) ON CONFLICT(hash) DO NOTHING;
+49
View File
@@ -4793,3 +4793,52 @@ answered headless in stream-json (free, num_turns 0) with parseable text
(session %, week % all-models, week % Sonnet). The Activity tab now renders it
via parseUsageText + a user-initiated refresh control. Remaining scope for this
ticket would be per-member/team budget split, if still wanted.', 'backlog', 'low', NULL, NULL, NULL, '2026-05-23 20:48:38', '2026-06-12 12:11:28', NULL, '98e11d00c142b8377584e35a45b39cdd', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPQ8QNGJFFK7G24CBWQAR2C', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Surface Claude Code Workflow runs in the convo + status surfaces', 'Claude Code''s Workflow mechanism (multi-agent orchestration: wf_<id> runs, phases, agent fan-outs, live progress) currently renders in clide as ordinary tool-use cards at best. Integrate it properly: (1) CONVO PANEL — recognise a Workflow tool-use and render a dedicated run card: phase groups, per-agent rows with live status, and the run''s result summary; reuse the collapser/agent-card machinery (T-305/T-342) rather than inventing new chrome. (2) STATUS/SIDEBAR — while a workflow runs, show an aggregate indicator (run id, phase, agents active/done) in the Claude sidebar Activity tab and/or status line.
SPIKE FIRST: capture what the stream-json wire actually emits during a Workflow run (tool_use input shape, progress/notification events, sidechain attribution for workflow-spawned agents) same probe method as T-410''s. Scope the rendering to what the wire really carries; if progress only exists in the harness UI and not on the wire, document that limit and render what''s available (start/end + result). Filed from user request 2026-06-12.
--- SPIKE FINDINGS (2026-06-12, claude 2.1.175 stream-json probe) ---
ANCHOR: Workflow run is a normal assistant tool_use {name:''Workflow'', input:{script}}. Its tool_result returns IMMEDIATELY: ''Workflow launched in background. Task ID: <id>''. The run is async today clide shows only the generic tool card + that result, no fan-out.
PROGRESS: carried on type:''system'' events (clide currently drops these), keyed by tool_use_id + task_id:
- task_started: task_id, tool_use_id, description, task_type:''local_workflow'', workflow_name, prompt(script source)
- task_progress (repeated): usage{total_tokens,tool_uses,duration_ms}, summary, workflow_progress[] = per-agent {index,label,model,state:start->progress->done,agentId} DELTAS (partial; merge by index)
- task_updated: patch{status,end_time}
- task_notification: terminal status:''completed'', output_file, summary, usage
LIMITS: (1) workflow agents are NOT sidechain messages (no separate assistant/user wire events, unlike Task) they exist only as workflow_progress telemetry; existing parentToolUseId nesting does not apply. (2) system events are ephemeral (not in resumed transcript JSONL) live progress shows during the session; on reload only the tool card + result summary survive.
PLAN: parse system task_* into a WorkflowRun model keyed by tool_use_id in StreamJsonSession; upgrade the Workflow tool_use card to render agent rows + status; add an aggregate active-workflow indicator to the Activity tab. Raw probe wire: /tmp/wf-probe/wire.jsonl.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-12 10:25:00', '2026-06-12 14:22:42', NULL, '41ff693db584a23f2e5938f873384c2b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPQ8QNGJFFK7G24CBWQAR2C', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Surface Claude Code Workflow runs in the convo + status surfaces', 'Claude Code''s Workflow mechanism (multi-agent orchestration: wf_<id> runs, phases, agent fan-outs, live progress) currently renders in clide as ordinary tool-use cards at best. Integrate it properly: (1) CONVO PANEL — recognise a Workflow tool-use and render a dedicated run card: phase groups, per-agent rows with live status, and the run''s result summary; reuse the collapser/agent-card machinery (T-305/T-342) rather than inventing new chrome. (2) STATUS/SIDEBAR — while a workflow runs, show an aggregate indicator (run id, phase, agents active/done) in the Claude sidebar Activity tab and/or status line.
SPIKE FIRST: capture what the stream-json wire actually emits during a Workflow run (tool_use input shape, progress/notification events, sidechain attribution for workflow-spawned agents) same probe method as T-410''s. Scope the rendering to what the wire really carries; if progress only exists in the harness UI and not on the wire, document that limit and render what''s available (start/end + result). Filed from user request 2026-06-12.
--- SPIKE FINDINGS (2026-06-12, claude 2.1.175 stream-json probe) ---
ANCHOR: Workflow run is a normal assistant tool_use {name:''Workflow'', input:{script}}. Its tool_result returns IMMEDIATELY: ''Workflow launched in background. Task ID: <id>''. The run is async today clide shows only the generic tool card + that result, no fan-out.
PROGRESS: carried on type:''system'' events (clide currently drops these), keyed by tool_use_id + task_id:
- task_started: task_id, tool_use_id, description, task_type:''local_workflow'', workflow_name, prompt(script source)
- task_progress (repeated): usage{total_tokens,tool_uses,duration_ms}, summary, workflow_progress[] = per-agent {index,label,model,state:start->progress->done,agentId} DELTAS (partial; merge by index)
- task_updated: patch{status,end_time}
- task_notification: terminal status:''completed'', output_file, summary, usage
LIMITS: (1) workflow agents are NOT sidechain messages (no separate assistant/user wire events, unlike Task) they exist only as workflow_progress telemetry; existing parentToolUseId nesting does not apply. (2) system events are ephemeral (not in resumed transcript JSONL) live progress shows during the session; on reload only the tool card + result summary survive.
PLAN: parse system task_* into a WorkflowRun model keyed by tool_use_id in StreamJsonSession; upgrade the Workflow tool_use card to render agent rows + status; add an aggregate active-workflow indicator to the Activity tab. Raw probe wire: /tmp/wf-probe/wire.jsonl.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-12 10:25:00', '2026-06-12 14:22:45', NULL, 'fad55deb0db50c438acb9efe72c6e76c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPQ8QNGJFFK7G24CBWQAR2C', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Surface Claude Code Workflow runs in the convo + status surfaces', 'Claude Code''s Workflow mechanism (multi-agent orchestration: wf_<id> runs, phases, agent fan-outs, live progress) currently renders in clide as ordinary tool-use cards at best. Integrate it properly: (1) CONVO PANEL — recognise a Workflow tool-use and render a dedicated run card: phase groups, per-agent rows with live status, and the run''s result summary; reuse the collapser/agent-card machinery (T-305/T-342) rather than inventing new chrome. (2) STATUS/SIDEBAR — while a workflow runs, show an aggregate indicator (run id, phase, agents active/done) in the Claude sidebar Activity tab and/or status line.
SPIKE FIRST: capture what the stream-json wire actually emits during a Workflow run (tool_use input shape, progress/notification events, sidechain attribution for workflow-spawned agents) same probe method as T-410''s. Scope the rendering to what the wire really carries; if progress only exists in the harness UI and not on the wire, document that limit and render what''s available (start/end + result). Filed from user request 2026-06-12.
--- SPIKE FINDINGS (2026-06-12, claude 2.1.175 stream-json probe) ---
ANCHOR: Workflow run is a normal assistant tool_use {name:''Workflow'', input:{script}}. Its tool_result returns IMMEDIATELY: ''Workflow launched in background. Task ID: <id>''. The run is async today clide shows only the generic tool card + that result, no fan-out.
PROGRESS: carried on type:''system'' events (clide currently drops these), keyed by tool_use_id + task_id:
- task_started: task_id, tool_use_id, description, task_type:''local_workflow'', workflow_name, prompt(script source)
- task_progress (repeated): usage{total_tokens,tool_uses,duration_ms}, summary, workflow_progress[] = per-agent {index,label,model,state:start->progress->done,agentId} DELTAS (partial; merge by index)
- task_updated: patch{status,end_time}
- task_notification: terminal status:''completed'', output_file, summary, usage
LIMITS: (1) workflow agents are NOT sidechain messages (no separate assistant/user wire events, unlike Task) they exist only as workflow_progress telemetry; existing parentToolUseId nesting does not apply. (2) system events are ephemeral (not in resumed transcript JSONL) live progress shows during the session; on reload only the tool card + result summary survive.
PLAN: parse system task_* into a WorkflowRun model keyed by tool_use_id in StreamJsonSession; upgrade the Workflow tool_use card to render agent rows + status; add an aggregate active-workflow indicator to the Activity tab. Raw probe wire: /tmp/wf-probe/wire.jsonl.', 'done', 'medium', NULL, NULL, NULL, '2026-06-12 10:25:00', '2026-06-12 14:52:31', NULL, 'b810aebc9678105a6e4e3628810172cb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3EZC7AJANXZVF3D91QYWM', 'epic', NULL, 'Claude sidebar — power control panel + full harness-command capture', 'PROBLEM. clide runs the Claude CLI headless (stream-json). The CLI''s interactive TUI commands don''t exist there: a typed /effort (observed live, 2026-06-12) is forwarded and the CLI answers with a raw system/local_command error — ''/effort isn''t available in this environment.'' Today clide owns only {clear, resume, fork, model} (slash_commands.dart kClideOwnedCommands); every other TUI-only builtin (/permissions /status /cost /help /config /memory /mcp /agents /usage-adjacent…) falls through to that raw error. Meanwhile the sidebar''s Config/Activity tabs (T-182/T-183) are mostly read-only rows — the lone interactions are the permission badge and T-408''s model picker (which lives in the pane''s interaction zone, not the sidebar).
EVIDENCE / GROUNDING. clide already caches the CLI initialize handshake per version (~/.config/clide/claude/init-<version>.json, T-151). The advertised slash_commands list = skills + a small headless-capable builtin set (clear, compact, context, init, review, security-review, usage, ). TUI-only builtins are NOT advertised that''s the authoritative signal for what forwards safely. Current CLI 2.1.175.
DESIGN three layers:
1. ROUTING TABLE (one source of truth, replaces kClideOwnedCommands): every command token forward (advertised by the live probe: skills, compact, context, ) | owned (clide implements natively: clear/resume/fork/model today; effort, permissions, config, memory, mcp, agents, todos, status, help as they land) | unavailable (known TUI-only with no equivalent: doctor, login, logout, terminal-setup, ide, exit friendly explanation card, never the CLI''s raw error). Probe-driven so new CLI versions stay current without code changes.
2. REACTIVE CATCH-ALL: any system/local_command response whose stdout matches "isn''t available in this environment" renders as a muted hint card (pointing at the matching sidebar control when one exists) instead of raw harness output. This captures FUTURE unknown commands automatically the guarantee ''nothing surfaces as a raw harness error'', not just a hand-maintained list.
3. SIDEBAR = POWER CONTROL PANEL (D-6 parity): every owned command gets a sidebar interaction and every sidebar control is reachable as a slash command. Config tab gains inline pickers (model T-408, permission T-275, effort new); Activity tab gains session controls (clear/compact/fork/resume) and a usage/cost block (/usage IS advertised in stream-json per the probes revisit T-158''s upstream blocker).
Child stories carry the implementation slices. Refs: slash_commands.dart, claude_pane._send, claude_meta_sidebar.dart (+T-395 split), claude_config.dart probe, T-408 set_model spike pattern.', 'done', 'high', NULL, NULL, NULL, '2026-06-12 08:58:29', '2026-06-12 19:44:20', NULL, 'ab48816b1f3007884c4b29ac3acd7104', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+5
View File
@@ -18,6 +18,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Added
- **Claude Code Workflow runs surface in the conversation and sidebar.** A
`Workflow` tool-use renders a dedicated run card — phase groups, per-agent
rows with live spinner/check status, usage, and the script — driven by the
harness's out-of-band progress events. The Activity tab adds a WORKFLOWS
section showing each run's done/total agent count. (T-416)
- **Session controls and live usage in the Claude sidebar Activity tab.** A
SESSION strip offers clear/compact/fork/resume buttons (same code path as the
typed commands), and a refresh control fetches `/usage` — plan usage renders
@@ -171,6 +171,9 @@ bool _isFoldable(ConversationItem item, FoldLevel level, Map<String, String> too
// breaks the cluster at every level, including L3, so parallel agents
// never merge into one Activity card.
if (isAgentTool(name)) return false;
// A Workflow run is a first-class orchestration card too (T-416): it owns
// the live agent fan-out, so it never folds into a generic Activity card.
if (name == 'Workflow') return false;
// The Edit/Write call stays first-class with its diff at L1/L2.
if (level == FoldLevel.everything) return true;
return !isDiffTool(name);
@@ -39,6 +39,7 @@ import 'package:clide/builtin/claude/src/team_broker.dart' show TeamBroker, Team
import 'package:clide/builtin/claude/src/claude_status.dart' show ClaudeUsage, parseUsageText;
import 'package:clide/builtin/claude/src/transcript_publisher.dart' show ClaudeConversation;
import 'package:clide/builtin/claude/src/transcript_reader.dart' show AssistantTextMessage, ConversationItem, SessionStatus;
import 'package:clide/builtin/claude/src/workflow_run.dart' show WorkflowRun;
import 'package:clide/kernel/kernel.dart';
import 'package:flutter/widgets.dart';
@@ -87,7 +88,9 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
StreamSubscription<Message>? _tabSub;
StreamSubscription<SessionStatus>? _primarySub;
StreamSubscription<ConversationItem>? _primaryItemsSub;
StreamSubscription<Map<String, WorkflowRun>>? _primaryWorkflowsSub;
ClaudeUsage? _usage;
Map<String, WorkflowRun> _workflows = const {};
StreamSubscription<void>? _brokerChangeSub;
Timer? _timer;
late final Future<ClaudeStats> Function() _load;
@@ -204,15 +207,32 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
_primarySub = null;
_primaryItemsSub?.cancel();
_primaryItemsSub = null;
_primaryWorkflowsSub?.cancel();
_primaryWorkflowsSub = null;
if (session == null) {
if (_primaryStatus != null && mounted) setState(() => _primaryStatus = null);
if (mounted && (_primaryStatus != null || _workflows.isNotEmpty)) {
setState(() {
_primaryStatus = null;
_workflows = const {};
});
}
return;
}
final seed = session.status;
if (mounted) setState(() => _primaryStatus = seed);
if (mounted) {
setState(() {
_primaryStatus = seed;
_workflows = session.workflows;
});
}
_primarySub = session.statusStream.listen((s) {
if (mounted) setState(() => _primaryStatus = s);
});
// The Activity tab's WORKFLOWS section tracks the primary session's live
// workflow runs (T-416).
_primaryWorkflowsSub = session.workflowsStream.listen((w) {
if (mounted) setState(() => _workflows = w);
});
// Watch for /usage responses: CLI-local output arrives as synthetic
// assistant text; when it parses as usage, the Activity block updates
// (T-415). Driven by the refresh control publishing '/usage'.
@@ -271,6 +291,7 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
_tabSub?.cancel();
_primarySub?.cancel();
_primaryItemsSub?.cancel();
_primaryWorkflowsSub?.cancel();
_brokerChangeSub?.cancel();
_injectCtl.dispose();
_config?.removeListener(_onConfigChange);
@@ -286,7 +307,7 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
SidebarTabStrip(current: _tab, memberCount: _members.length, onPick: (t) => setState(() => _tab = t)),
Expanded(
child: switch (_tab) {
SidebarTab.activity => ActivityTabView(stats: _stats, primaryStatus: _primaryStatus, config: _config, usage: _usage),
SidebarTab.activity => ActivityTabView(stats: _stats, primaryStatus: _primaryStatus, config: _config, usage: _usage, workflows: _workflows),
SidebarTab.team => TeamTabView(
members: _members,
memberStatus: _memberStatus,
+16
View File
@@ -25,6 +25,7 @@ import 'slash_commands.dart';
import 'stream_json_session.dart';
import 'task_list.dart';
import 'transcript_reader.dart';
import 'workflow_run.dart';
/// The Claude conversation pane. Drives `claude` over the stream-json control
/// protocol (D-77/D-78): a [StreamJsonSession] owns the process, its events
@@ -76,6 +77,7 @@ class _ClaudePaneState extends State<ClaudePane> {
StreamSubscription<ProjectOpened>? _projectSub;
StreamSubscription<Message>? _commandSub;
StreamSubscription<String>? _modelErrorSub;
StreamSubscription<Map<String, WorkflowRun>>? _workflowsSub;
ConversationController? _conversation;
StreamJsonSession? _session;
SessionStatus _status = const SessionStatus();
@@ -213,6 +215,8 @@ class _ClaudePaneState extends State<ClaudePane> {
_endSub = null;
_modelErrorSub?.cancel();
_modelErrorSub = null;
_workflowsSub?.cancel();
_workflowsSub = null;
// The orchestrator owns the session, so disposing this pane does NOT kill
// it — that's what lets a hidden/kept-alive pane keep its session (T-169).
// A secondary tab being *closed* is a real teardown, so close its session;
@@ -268,6 +272,8 @@ class _ClaudePaneState extends State<ClaudePane> {
_endSub = null;
_modelErrorSub?.cancel();
_modelErrorSub = null;
_workflowsSub?.cancel();
_workflowsSub = null;
_modelPickerOpen = false;
_effortPickerOpen = false;
_permissionPickerOpen = false;
@@ -385,6 +391,13 @@ class _ClaudePaneState extends State<ClaudePane> {
if (!mounted) return;
setState(() => _status = s);
});
// Workflow runs arrive on out-of-band system events that add no
// conversation item, so the view won't rebuild on its own — drive a
// rebuild as the run map changes so the workflow card updates live (T-416).
_workflowsSub = managed.session.workflowsStream.listen((_) {
if (!mounted) return;
setState(() {});
});
// A rejected /model change (unknown name) rolls back silently in the
// status — say why out loud (T-408).
_modelErrorSub = managed.session.modelErrors.listen((msg) {
@@ -671,6 +684,8 @@ class _ClaudePaneState extends State<ClaudePane> {
_endSub = null;
_modelErrorSub?.cancel();
_modelErrorSub = null;
_workflowsSub?.cancel();
_workflowsSub = null;
_modelPickerOpen = false;
_effortPickerOpen = false;
_permissionPickerOpen = false;
@@ -729,6 +744,7 @@ class _ClaudePaneState extends State<ClaudePane> {
hiddenToolUseIds: _session?.promptedToolUseIds ?? const <String>{},
toolUseOutcomes: _session?.toolUseOutcomes ?? const <String, bool>{},
quietErrorToolUseIds: _session?.quietErrorToolUseIds ?? const <String>{},
workflows: _session?.workflows ?? const <String, WorkflowRun>{},
emptyState: ClaudeBanner(
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
workspace: _repoRoot,
@@ -15,12 +15,14 @@ import 'dart:io';
import 'package:clide/builtin/claude/src/activity_cluster.dart';
import 'package:clide/builtin/claude/src/bash_tail_source.dart';
import 'package:clide/builtin/claude/src/claude_status.dart' show shortModelLabel;
import 'package:clide/builtin/claude/src/conversation_card.dart';
import 'package:clide/builtin/claude/src/conversation_controller.dart';
import 'package:clide/builtin/claude/src/file_tail_follower.dart';
import 'package:clide/builtin/claude/src/image_thumbnail.dart';
import 'package:clide/builtin/claude/src/prompt_card.dart';
import 'package:clide/builtin/claude/src/transcript_reader.dart';
import 'package:clide/builtin/claude/src/workflow_run.dart';
import 'package:clide/kernel/src/facade.dart';
import 'package:clide/kernel/src/syntax/language_map.dart';
import 'package:clide/kernel/src/theme/controller.dart';
@@ -38,11 +40,18 @@ class ConversationView extends StatefulWidget {
this.hiddenToolUseIds = const <String>{},
this.toolUseOutcomes = const <String, bool>{},
this.quietErrorToolUseIds = const <String>{},
this.workflows = const <String, WorkflowRun>{},
this.foldLevel = FoldLevel.tools,
});
final ConversationController controller;
/// Live Workflow runs keyed by their launching `Workflow` tool-use id
/// (T-416). A `Workflow` tool-use card with a matching run renders the
/// dedicated run card (phases, agent rows, status) instead of the generic
/// tool card; absent (pre-progress, or on reload) it falls back to generic.
final Map<String, WorkflowRun> workflows;
/// How aggressively consecutive meta items (tool calls/results, thinking)
/// fold into collapsible activity cards (T-230). Default L1 ([FoldLevel.tools]).
final FoldLevel foldLevel;
@@ -332,6 +341,7 @@ class _ConversationViewState extends State<ConversationView> {
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: fold.promptsByToolUseId,
runByToolUseId: fold.runByToolUseId,
workflows: widget.workflows,
),
FoldedCluster(:final items) => _ActivityCard(
key: ValueKey('cluster.${items.first.uuid}'),
@@ -343,6 +353,7 @@ class _ConversationViewState extends State<ConversationView> {
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: fold.promptsByToolUseId,
runByToolUseId: fold.runByToolUseId,
workflows: widget.workflows,
),
EditRun(:final edits) => _EditRunCard(
key: ValueKey('edits.${edits.first.uuid}'),
@@ -503,6 +514,7 @@ class _ConversationTurn extends StatelessWidget {
this.resultByToolUseId = const <String, ToolResultMessage>{},
this.promptsByToolUseId = const <String, List<UserMessage>>{},
this.runByToolUseId = const <String, List<ConversationItem>>{},
this.workflows = const <String, WorkflowRun>{},
});
final ConversationItem item;
@@ -538,6 +550,9 @@ class _ConversationTurn extends StatelessWidget {
/// thinking, tool cards) nested under the Agent card in a holder (T-264).
final Map<String, List<ConversationItem>> runByToolUseId;
/// Live Workflow runs keyed by launching tool-use id (T-416).
final Map<String, WorkflowRun> workflows;
@override
Widget build(BuildContext context) {
final i = item;
@@ -697,6 +712,13 @@ class _ConversationTurn extends StatelessWidget {
/// and its own per-item mark. An Agent/Task call also nests its visible
/// sub-agent run in a second collapser below (T-264).
Widget _toolUseCollapser(AssistantToolUse t) {
// A Workflow tool-use with a live run (T-416) renders the dedicated run
// card — phases, agent rows, status — instead of the generic tool card. No
// run yet (pre-progress, or on reload where the system events are gone)
// falls through to the generic collapser below.
if (t.name == 'Workflow' && workflows[t.toolUseId] != null) {
return _workflowCard(t, workflows[t.toolUseId]!);
}
final outcome = toolUseOutcomes[t.toolUseId];
final color = outcome == null ? tokens.globalFocus : (outcome ? tokens.statusSuccess : tokens.statusError);
final collapser = ClideCollapserCard(
@@ -740,6 +762,99 @@ class _ConversationTurn extends StatelessWidget {
);
}
/// A dedicated card for a Workflow run (T-416): the harness's multi-agent
/// orchestration. The collapser header carries the run's live status (spinner
/// while running, check when done) and a `done/total agents` counter; the body
/// lists each fanned-out agent — grouped under phase headers when the workflow
/// declared phases — plus the run's usage and the orchestration script.
Widget _workflowCard(AssistantToolUse t, WorkflowRun run) {
final title = run.name ?? 'workflow';
final color = run.done ? tokens.statusSuccess : tokens.globalFocus;
final counter = run.agentCount == 0 ? 'starting' : '${run.doneCount}/${run.agentCount} agents';
final detail = run.done ? (run.summary ?? run.description) : run.description;
final collapsedSummary = (detail == null || detail == title) ? title : '$title · $detail';
return ClideCollapserCard(
label: 'workflow',
color: color,
collapsedSummary: collapsedSummary,
counter: counter,
status: run.done ? ClideRunStatus.success : ClideRunStatus.running,
children: [_workflowBody(t, run)],
);
}
Widget _workflowBody(AssistantToolUse t, WorkflowRun run) {
final agents = run.orderedAgents;
final phases = run.orderedPhases;
final rows = <Widget>[];
if (phases.isEmpty) {
rows.addAll(agents.map(_workflowAgentRow));
} else {
for (final p in phases) {
rows.add(
Padding(
padding: const EdgeInsets.only(top: 6, bottom: 2),
child: ClideText(p.title.toUpperCase(), muted: true, fontSize: clideFontMeta - 1, fontWeight: FontWeight.w600),
),
);
rows.addAll(agents.where((a) => a.phaseIndex == p.index).map(_workflowAgentRow));
}
// Agents the deltas never tagged with a phase still render, after the
// phased groups, so nothing fanned out is silently dropped.
rows.addAll(agents.where((a) => a.phaseIndex == null).map(_workflowAgentRow));
}
if (rows.isEmpty) {
rows.add(ClideText('Launching…', muted: true, fontSize: clideFontMeta));
}
final script = t.input['script'];
return ConversationCard(
variant: ConversationCardVariant.bordered,
accent: run.done ? tokens.statusSuccess : tokens.globalFocus,
label: run.name ?? 'workflow',
copyText: script is String ? script : const JsonEncoder.withIndent(' ').convert(t.input),
body: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: rows),
extraSegments: [
if (run.totalTokens != null && run.totalTokens! > 0)
CardSegment(
label: 'usage',
child: ClideText('${run.totalTokens} tokens${run.durationMs != null ? ' · ${run.durationMs} ms' : ''}', muted: true, fontSize: clideFontMeta),
),
if (script is String)
CardSegment(
label: 'script',
child: ClideCodeBlock(source: script, language: 'javascript'),
),
],
margin: const EdgeInsets.only(bottom: kClideCardHeaderPadH),
);
}
/// One agent row in a workflow card: a state glyph (spinner while running, a
/// muted check once done), the agent's label, and its model (T-416).
Widget _workflowAgentRow(WorkflowAgent a) {
final done = a.state == WorkflowAgentState.done;
final Widget glyph = done
? ClideIcon(PhosphorIcons.byName('check'), size: 12, color: tokens.statusSuccess)
: ClideSpinner(size: 12, color: tokens.globalTextMuted);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
SizedBox(width: 16, child: Center(child: glyph)),
const SizedBox(width: 6),
Expanded(
child: ClideText(a.label, fontSize: clideFontMeta, maxLines: 1, overflow: TextOverflow.ellipsis),
),
if (a.model != null && a.model!.isNotEmpty) ...[
const SizedBox(width: 8),
ClideText(shortModelLabel(a.model!), muted: true, fontSize: clideFontMeta - 1),
],
],
),
);
}
/// The inner content card for a tool use (T-305): the call body + folded
/// CALL/PROMPT/RESULT segments + its own per-item status mark, with NO own
/// collapse caret — the enclosing collapser owns collapse. Used both as a
@@ -907,6 +1022,7 @@ class _ActivityCard extends StatelessWidget {
required this.resultByToolUseId,
required this.promptsByToolUseId,
required this.runByToolUseId,
this.workflows = const <String, WorkflowRun>{},
});
final List<ConversationItem> items;
@@ -917,6 +1033,7 @@ class _ActivityCard extends StatelessWidget {
final Map<String, ToolResultMessage> resultByToolUseId;
final Map<String, List<UserMessage>> promptsByToolUseId;
final Map<String, List<ConversationItem>> runByToolUseId;
final Map<String, WorkflowRun> workflows;
@override
Widget build(BuildContext context) {
@@ -938,6 +1055,7 @@ class _ActivityCard extends StatelessWidget {
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: promptsByToolUseId,
runByToolUseId: runByToolUseId,
workflows: workflows,
),
],
);
@@ -9,12 +9,20 @@ import 'package:clide/builtin/claude/src/claude_stats.dart';
import 'package:clide/builtin/claude/src/claude_status.dart' show ClaudeUsage, formatTokenCount, permissionModeLabel, shortModelLabel;
import 'package:clide/builtin/claude/src/meta_sidebar/models.dart';
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus;
import 'package:clide/builtin/claude/src/workflow_run.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
class ActivityTabView extends StatelessWidget {
const ActivityTabView({super.key, required this.stats, required this.primaryStatus, required this.config, this.usage});
const ActivityTabView({
super.key,
required this.stats,
required this.primaryStatus,
required this.config,
this.usage,
this.workflows = const <String, WorkflowRun>{},
});
final ClaudeStats stats;
final SessionStatus? primaryStatus;
@@ -24,6 +32,11 @@ class ActivityTabView extends StatelessWidget {
/// control (T-415). Null until the first refresh.
final ClaudeUsage? usage;
/// Live Workflow runs in the primary session, keyed by launching tool-use id
/// (T-416). Rendered as an aggregate WORKFLOWS section — one row per run with
/// its done/total agent count and running/done state.
final Map<String, WorkflowRun> workflows;
/// Publish a slash command for the primary pane to execute — the session
/// controls are the same code path as typing the command (D-6).
void _command(BuildContext context, String text) {
@@ -36,6 +49,7 @@ class ActivityTabView extends StatelessWidget {
final latest = stats.latest;
final u = usage;
final sections = <MetaSection>[
..._workflowSection(tokens),
if (u != null)
MetaSection('USAGE', [
if (u.session != null) MetaRow('session', u.session!),
@@ -94,6 +108,24 @@ class ActivityTabView extends StatelessWidget {
);
}
/// An aggregate WORKFLOWS section while one or more workflow runs exist this
/// session (T-416): a row per run — its name and `done/total agents`, tinted
/// focus while running and success once complete.
List<MetaSection> _workflowSection(SurfaceTokens tokens) {
final runs = workflows.values.toList();
if (runs.isEmpty) return const [];
return [
MetaSection('WORKFLOWS', [
for (final r in runs)
MetaRow(
r.name ?? r.taskId ?? 'workflow',
r.agentCount == 0 ? (r.done ? 'done' : 'starting') : '${r.doneCount}/${r.agentCount} agents${r.done ? '' : ''}',
valueColor: r.done ? tokens.statusSuccess : tokens.globalFocus,
),
]),
];
}
List<MetaSection> _runtimeSection(SurfaceTokens tokens) {
final st = primaryStatus;
final skills = config?.skills.length;
@@ -19,6 +19,7 @@ import 'dart:convert';
import 'dart:io';
import 'package:clide/builtin/claude/src/transcript_reader.dart';
import 'package:clide/builtin/claude/src/workflow_run.dart';
import 'package:clide/src/util/value_stream.dart';
/// The claude subprocess, abstracted so tests drive it without spawning.
@@ -377,6 +378,20 @@ class StreamJsonSession {
Map<String, bool> get toolUseOutcomes => _toolUseOutcome;
Set<String> get quietErrorToolUseIds => _quietErrorToolUses;
/// Live Workflow runs, keyed by their launching `Workflow` tool-use id
/// (T-416). Accumulated from the out-of-band `system` task_* events the
/// harness emits while a workflow runs in the background; the conversation
/// card and the sidebar indicator both read this snapshot. Ephemeral — the
/// events aren't in the resumed transcript, so this is empty on reload.
final _workflows = <String, WorkflowRun>{};
final _workflowsCtl = ValueStream<Map<String, WorkflowRun>>.seeded(const {});
/// The current workflow runs, keyed by launching tool-use id.
Map<String, WorkflowRun> get workflows => Map.unmodifiable(_workflows);
/// Emits the workflow-run map whenever a `system` task event updates it.
Stream<Map<String, WorkflowRun>> get workflowsStream => _workflowsCtl.stream;
/// Whether a turn is in flight (between a send and claude's `result`). Drives
/// the composer's Stop affordance.
bool _busy = false;
@@ -501,6 +516,15 @@ class StreamJsonSession {
return;
}
// Workflow run progress (T-416): the harness reports a backgrounded Workflow
// tool's fan-out on out-of-band `system` task_* events keyed by the
// launching tool-use id. Fold them into the run snapshot and notify; they
// carry no conversation item, so don't fall through to the parser.
if (isWorkflowSystemEvent(ev)) {
_onWorkflowEvent(ev);
return;
}
// Finalise a streamed reply: when the real text `assistant` event for a
// message we streamed arrives, reuse the placeholder's `partial-<id>` uuid
// so the controller replaces the placeholder in place rather than appending
@@ -574,6 +598,15 @@ class StreamJsonSession {
}
}
/// Fold one workflow `system` task event into its run snapshot, keyed by the
/// launching tool-use id, and publish the updated map (T-416).
void _onWorkflowEvent(Map<String, dynamic> ev) {
final id = ev['tool_use_id'] as String;
final prior = _workflows[id] ?? WorkflowRun(toolUseId: id);
_workflows[id] = prior.foldEvent(ev);
_workflowsCtl.add(Map.unmodifiable(_workflows));
}
/// Handle an inbound `control_request`. `can_use_tool` becomes a [ToolPrompt]
/// item the UI resolves; every other subtype is answered with an error so
/// the turn never hangs waiting on us (D-78).
@@ -938,6 +971,7 @@ class StreamJsonSession {
await _proc.kill();
await _items.close();
await _statusCtl.close();
await _workflowsCtl.close();
await _sessionIdCtl.close();
await _pendingCtl.close();
await _busyCtl.close();
+234
View File
@@ -0,0 +1,234 @@
/// Live state of a Claude Code Workflow run (T-416).
///
/// A Workflow is the harness's multi-agent orchestration tool. The model calls
/// it as an ordinary `tool_use` (`name: "Workflow"`, `input: {script}`); the
/// tool returns immediately ("launched in background") and the run's real
/// progress arrives out-of-band on stream-json `type: "system"` events keyed by
/// the launching tool-use id. This file is the pure, Flutter-free model that
/// folds those events into a snapshot the conversation/sidebar surfaces render.
///
/// Wire shape (captured by the T-416 spike, claude 2.1.175):
/// - `task_started` — task_id, tool_use_id, description, workflow_name,
/// prompt (script source)
/// - `task_progress` — usage{total_tokens,tool_uses,duration_ms}, summary,
/// and `workflow_progress[]`, a DELTA list mixing
/// `{type:"workflow_phase", index, title}` and
/// `{type:"workflow_agent", index, label, phaseIndex?,
/// phaseTitle?, model, state(start|progress|done),
/// agentId?}` — partial, merged by index.
/// - `task_updated` — patch{status, end_time}
/// - `task_notification` — terminal status:"completed", summary, usage
///
/// Limit: these events are ephemeral (not persisted to the resumed transcript
/// JSONL), so live progress shows during the session; on reload only the tool
/// card + its "launched in background" result survive.
library;
/// Lifecycle of a single workflow agent, from its `state` field.
enum WorkflowAgentState { start, progress, done, unknown }
WorkflowAgentState parseWorkflowAgentState(Object? raw) => switch (raw) {
'start' || 'queued' || 'running' => WorkflowAgentState.start,
'progress' => WorkflowAgentState.progress,
'done' || 'complete' || 'completed' => WorkflowAgentState.done,
_ => WorkflowAgentState.unknown,
};
/// One phase declared by `meta.phases` / a `phase()` call.
class WorkflowPhase {
const WorkflowPhase({required this.index, required this.title});
final int index;
final String title;
}
/// One agent fanned out by the workflow. Fields accrete across `task_progress`
/// deltas — a later delta fills in `agentId` / upgrades `model` / advances
/// `state`, so [mergeDelta] overlays non-null fields onto the prior snapshot.
class WorkflowAgent {
const WorkflowAgent({
required this.index,
required this.label,
this.model,
this.state = WorkflowAgentState.start,
this.agentId,
this.phaseIndex,
this.phaseTitle,
});
final int index;
final String label;
final String? model;
final WorkflowAgentState state;
final String? agentId;
final int? phaseIndex;
final String? phaseTitle;
/// Fold a raw `workflow_agent` delta entry onto this snapshot, keeping prior
/// values where the delta omits a field.
WorkflowAgent mergeDelta(Map<String, dynamic> e) => WorkflowAgent(
index: index,
label: (e['label'] as String?)?.isNotEmpty == true ? e['label'] as String : label,
model: (e['model'] as String?) ?? model,
state: e.containsKey('state') ? parseWorkflowAgentState(e['state']) : state,
agentId: (e['agentId'] as String?) ?? agentId,
phaseIndex: (e['phaseIndex'] as num?)?.toInt() ?? phaseIndex,
phaseTitle: (e['phaseTitle'] as String?) ?? phaseTitle,
);
static WorkflowAgent fromDelta(Map<String, dynamic> e) => WorkflowAgent(
index: (e['index'] as num).toInt(),
label: (e['label'] as String?) ?? '',
model: e['model'] as String?,
state: parseWorkflowAgentState(e['state']),
agentId: e['agentId'] as String?,
phaseIndex: (e['phaseIndex'] as num?)?.toInt(),
phaseTitle: e['phaseTitle'] as String?,
);
}
/// An immutable snapshot of one workflow run. [foldEvent] returns a new snapshot
/// with a single `system` task event applied (the session keeps one per
/// launching tool-use id and replaces it as events arrive).
class WorkflowRun {
const WorkflowRun({
required this.toolUseId,
this.taskId,
this.name,
this.description,
this.summary,
this.done = false,
this.totalTokens,
this.toolUses,
this.durationMs,
this.phases = const {},
this.agents = const {},
});
/// The launching `Workflow` tool-use id — the join key to the conversation
/// card and across all of this run's system events.
final String toolUseId;
/// The harness task id (e.g. `wy01fihjt`), assigned at `task_started`.
final String? taskId;
/// `workflow_name` from `meta.name`.
final String? name;
final String? description;
final String? summary;
/// True once a `task_updated{status:completed}` or `task_notification`
/// terminal event lands.
final bool done;
final int? totalTokens;
final int? toolUses;
final int? durationMs;
/// Phase index → phase. Empty for a phase-less workflow.
final Map<int, WorkflowPhase> phases;
/// Agent index → agent snapshot.
final Map<int, WorkflowAgent> agents;
bool get running => !done;
int get agentCount => agents.length;
int get doneCount => agents.values.where((a) => a.state == WorkflowAgentState.done).length;
/// Agents in index order — the order the script fanned them out.
List<WorkflowAgent> get orderedAgents {
final list = agents.values.toList()..sort((a, b) => a.index.compareTo(b.index));
return list;
}
/// Phases in index order.
List<WorkflowPhase> get orderedPhases {
final list = phases.values.toList()..sort((a, b) => a.index.compareTo(b.index));
return list;
}
WorkflowRun _copyWith({
String? taskId,
String? name,
String? description,
String? summary,
bool? done,
int? totalTokens,
int? toolUses,
int? durationMs,
Map<int, WorkflowPhase>? phases,
Map<int, WorkflowAgent>? agents,
}) => WorkflowRun(
toolUseId: toolUseId,
taskId: taskId ?? this.taskId,
name: name ?? this.name,
description: description ?? this.description,
summary: summary ?? this.summary,
done: done ?? this.done,
totalTokens: totalTokens ?? this.totalTokens,
toolUses: toolUses ?? this.toolUses,
durationMs: durationMs ?? this.durationMs,
phases: phases ?? this.phases,
agents: agents ?? this.agents,
);
/// Apply one `system` task event ([ev]) and return the updated snapshot.
/// [ev] must already be the decoded envelope; unknown subtypes return `this`.
WorkflowRun foldEvent(Map<String, dynamic> ev) {
switch (ev['subtype']) {
case 'task_started':
return _copyWith(taskId: ev['task_id'] as String?, name: ev['workflow_name'] as String?, description: ev['description'] as String?);
case 'task_progress':
return _foldProgress(ev);
case 'task_updated':
final patch = ev['patch'];
final status = patch is Map ? patch['status'] as String? : null;
return _copyWith(done: status == 'completed' || status == 'failed' ? true : null);
case 'task_notification':
final status = ev['status'] as String?;
return _copyWith(done: status == 'completed' || status == 'failed' ? true : null, summary: ev['summary'] as String?)._foldUsage(ev['usage']);
default:
return this;
}
}
WorkflowRun _foldProgress(Map<String, dynamic> ev) {
final phases = Map<int, WorkflowPhase>.from(this.phases);
final agents = Map<int, WorkflowAgent>.from(this.agents);
final progress = ev['workflow_progress'];
if (progress is List) {
for (final raw in progress) {
if (raw is! Map) continue;
final e = raw.cast<String, dynamic>();
final idx = (e['index'] as num?)?.toInt();
if (idx == null) continue;
switch (e['type']) {
case 'workflow_phase':
phases[idx] = WorkflowPhase(index: idx, title: (e['title'] as String?) ?? 'phase $idx');
case 'workflow_agent':
final prior = agents[idx];
agents[idx] = prior != null ? prior.mergeDelta(e) : WorkflowAgent.fromDelta(e);
}
}
}
return _copyWith(summary: ev['summary'] as String?, phases: phases, agents: agents)._foldUsage(ev['usage']);
}
WorkflowRun _foldUsage(Object? usage) {
if (usage is! Map) return this;
return _copyWith(
totalTokens: (usage['total_tokens'] as num?)?.toInt(),
toolUses: (usage['tool_uses'] as num?)?.toInt(),
durationMs: (usage['duration_ms'] as num?)?.toInt(),
);
}
}
/// The `system` subtypes that carry workflow run progress (T-416). Other system
/// subtypes (`init`, `hook_*`, `thinking_tokens`) are unrelated and left alone.
const Set<String> kWorkflowSystemSubtypes = {'task_started', 'task_progress', 'task_updated', 'task_notification'};
/// True when [ev] is a `system` event carrying workflow run progress that names
/// a launching tool-use id we can key on.
bool isWorkflowSystemEvent(Map<String, dynamic> ev) =>
ev['type'] == 'system' && kWorkflowSystemSubtypes.contains(ev['subtype']) && (ev['tool_use_id'] as String?)?.isNotEmpty == true;
@@ -76,6 +76,16 @@ void main() {
expect(groupConversation(const [], FoldLevel.tools), isEmpty);
});
test('a Workflow run stays first-class even at L3, never folded (T-416)', () {
// At every fold level the Workflow tool-use owns its own card so the live
// run card can render — it must not fold into a generic Activity cluster.
for (final level in FoldLevel.values) {
final groups = groupConversation([_tool('1', 'Workflow'), _result('1')], level);
expect(groups.first, isA<StickyItem>(), reason: '$level');
expect((groups.first as StickyItem).item, isA<AssistantToolUse>(), reason: '$level');
}
});
test('an image card stays first-class even at L3 (everything)', () {
final img = ImageMessage(uuid: 'i${_n++}', timestamp: _ts, isSidechain: false, path: '/abs/shot.png');
final groups = groupConversation([_tool('1', 'Bash'), _result('1'), img], FoldLevel.everything);
@@ -5,6 +5,7 @@ library;
import 'package:clide/builtin/claude/src/claude_stats.dart';
import 'package:clide/builtin/claude/src/claude_status.dart' show ClaudeUsage;
import 'package:clide/builtin/claude/src/meta_sidebar/activity_tab.dart';
import 'package:clide/builtin/claude/src/workflow_run.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
@@ -34,4 +35,28 @@ void main() {
expect(find.text('No activity recorded yet.'), findsOneWidget);
expect(find.text('USAGE'), findsNothing);
});
testWidgets('renders a WORKFLOWS row per live run with its done/total count (T-416)', (tester) async {
var run = const WorkflowRun(toolUseId: 'x1', name: 'parallel-words');
run = run.foldEvent({
'subtype': 'task_progress',
'tool_use_id': 'x1',
'workflow_progress': [
{'type': 'workflow_agent', 'index': 1, 'label': 'a', 'state': 'done'},
{'type': 'workflow_agent', 'index': 2, 'label': 'b', 'state': 'start'},
],
});
await tester.pumpWidget(harness(f, ActivityTabView(stats: const ClaudeStats(), primaryStatus: null, config: null, workflows: {'x1': run})));
await tester.pump();
expect(find.text('WORKFLOWS'), findsOneWidget);
expect(find.text('parallel-words'), findsOneWidget);
expect(find.text('1/2 agents'), findsOneWidget);
});
testWidgets('no workflows → no WORKFLOWS section', (tester) async {
await tester.pumpWidget(harness(f, const ActivityTabView(stats: ClaudeStats(), primaryStatus: null, config: null)));
await tester.pump();
expect(find.text('WORKFLOWS'), findsNothing);
});
}
@@ -1109,4 +1109,35 @@ void main() {
expect(find.text('MCP SERVERS · 0'), findsOneWidget);
});
});
group('T-416 workflow runs in the Activity tab', () {
testWidgets('a primary workflow run surfaces as a WORKFLOWS row', (tester) async {
_FakeProc? proc;
final orch = ClaudeSessionOrchestrator(processFactory: ({required sessionArgs, required cwd, env}) async => proc = _FakeProc());
await orch.spawn(const SpawnSpec(id: 'primary', role: 'primary', sessionId: 'p-uuid', cwd: '/repo'));
await tester.pumpWidget(harness(f, sidebar(orchestrator: orch, initialTab: SidebarTab.activity)));
await tester.pump();
// The harness emits the workflow progress on the primary session's wire.
proc!._ctl.add(
jsonEncode({
'type': 'system',
'subtype': 'task_progress',
'tool_use_id': 'toolu_wf',
'summary': 'orchestrating',
'workflow_progress': [
{'type': 'workflow_agent', 'index': 1, 'label': 'a', 'state': 'done'},
{'type': 'workflow_agent', 'index': 2, 'label': 'b', 'state': 'start'},
],
}),
);
await tester.pump();
await tester.pump();
expect(find.text('WORKFLOWS'), findsOneWidget);
expect(find.text('1/2 agents'), findsOneWidget);
orch.dispose();
});
});
}
+38
View File
@@ -167,6 +167,44 @@ void main() {
expect(proc.writes.any((w) => w.contains('hello there')), isTrue);
});
testWidgets('a Workflow run renders its dedicated card in the conversation (T-416)', (tester) async {
await mount(tester, const ClaudePane(showChrome: false));
final proc = created.single;
final semantics = tester.ensureSemantics();
await act(tester, () {
proc.feed({
'type': 'assistant',
'uuid': 'a-wf',
'message': {
'role': 'assistant',
'content': [
{
'type': 'tool_use',
'id': 'toolu_wf',
'name': 'Workflow',
'input': {'script': 'await parallel([])'},
},
],
},
});
proc.feed({
'type': 'system',
'subtype': 'task_progress',
'tool_use_id': 'toolu_wf',
'workflow_progress': [
{'type': 'workflow_agent', 'index': 1, 'label': 'first agent', 'state': 'done'},
{'type': 'workflow_agent', 'index': 2, 'label': 'second agent', 'state': 'start'},
],
});
});
// The dedicated workflow collapser, with its live done/total agent counter.
expect(find.bySemanticsLabel('workflow, 1/2 agents, collapsed'), findsOneWidget);
semantics.dispose();
});
testWidgets('/clear empties the deterministic session in place', (tester) async {
await mount(tester, const ClaudePane(showChrome: false));
final firstProc = created.single;
@@ -13,6 +13,7 @@ import 'package:clide/builtin/claude/src/conversation_view.dart';
import 'package:clide/builtin/claude/src/image_thumbnail.dart';
import 'package:clide/builtin/claude/src/transcript_publisher.dart';
import 'package:clide/builtin/claude/src/transcript_reader.dart';
import 'package:clide/builtin/claude/src/workflow_run.dart';
import 'package:clide/kernel/src/events/message_bus.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/services.dart';
@@ -154,6 +155,7 @@ void main() {
Set<String> hiddenToolUseIds = const {},
Map<String, bool> toolUseOutcomes = const {},
Set<String> quietErrorToolUseIds = const {},
Map<String, WorkflowRun> workflows = const {},
FoldLevel foldLevel = FoldLevel.none,
}) async {
tester.view.physicalSize = const Size(900, 700);
@@ -178,6 +180,7 @@ void main() {
hiddenToolUseIds: hiddenToolUseIds,
toolUseOutcomes: toolUseOutcomes,
quietErrorToolUseIds: quietErrorToolUseIds,
workflows: workflows,
foldLevel: foldLevel,
),
),
@@ -196,6 +199,45 @@ void main() {
expect(find.text('Waiting for Claude…'), findsOneWidget);
});
testWidgets('a Workflow tool-use with a live run renders the workflow card (T-416)', (tester) async {
var run = const WorkflowRun(toolUseId: 'x1', name: 'parallel-words');
run = run.foldEvent({
'subtype': 'task_progress',
'tool_use_id': 'x1',
'workflow_progress': [
{'type': 'workflow_agent', 'index': 1, 'label': 'do alpha', 'model': 'haiku', 'state': 'done'},
{'type': 'workflow_agent', 'index': 2, 'label': 'do beta', 'model': 'haiku', 'state': 'start'},
],
});
await pumpWith(
tester,
[
_tool('Workflow', const {'script': 'await parallel([])'}),
],
workflows: {'x1': run},
);
// Collapsed: the dedicated workflow collapser with its done/total counter
// and the workflow name as the summary (no description set → no duplicate).
expect(find.bySemanticsLabel('workflow, 1/2 agents, collapsed'), findsOneWidget);
expect(find.text('parallel-words'), findsOneWidget);
expect(find.text('do alpha'), findsNothing); // folded while collapsed
// Expand → the per-agent rows show.
await tester.tap(find.bySemanticsLabel('workflow, 1/2 agents, collapsed'));
await tester.pumpAndSettle();
expect(find.text('do alpha'), findsOneWidget);
expect(find.text('do beta'), findsOneWidget);
});
testWidgets('a Workflow tool-use with no run yet falls back to the generic tool card (T-416)', (tester) async {
await pumpWith(tester, [
_tool('Workflow', const {'script': 'await parallel([])'}),
]);
// No run snapshot → the generic tool collapser labeled by the tool name.
expect(find.bySemanticsLabel('Workflow, 1 step, collapsed'), findsOneWidget);
});
testWidgets('unfolded conversation cards carry stable per-item identity keys (T-285)', (tester) async {
tester.view.physicalSize = const Size(900, 800);
tester.view.devicePixelRatio = 1.0;
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:clide/builtin/claude/src/stream_json_session.dart';
import 'package:clide/builtin/claude/src/transcript_reader.dart';
import 'package:clide/builtin/claude/src/workflow_run.dart';
import 'package:test/test.dart';
class _FakeProc extends StreamJsonProcess {
@@ -945,4 +946,57 @@ void main() {
expect(b.lines, ['line 2', 'line 3', 'line 4']);
});
});
group('workflow runs (T-416)', () {
test('accumulates a run from system task_* events keyed by tool_use_id', () async {
final p = _FakeProc();
final session = StreamJsonSession(p)..start();
final snapshots = <Map<String, WorkflowRun>>[];
session.workflowsStream.listen(snapshots.add);
p.emit(
jsonEncode({
'type': 'system',
'subtype': 'task_started',
'task_id': 'wy01fihjt',
'tool_use_id': 'toolu_wf',
'description': 'Two agents',
'workflow_name': 'parallel-words',
}),
);
p.emit(
jsonEncode({
'type': 'system',
'subtype': 'task_progress',
'tool_use_id': 'toolu_wf',
'workflow_progress': [
{'type': 'workflow_agent', 'index': 1, 'label': 'alpha', 'state': 'start'},
{'type': 'workflow_agent', 'index': 2, 'label': 'beta', 'state': 'done'},
],
}),
);
p.emit(jsonEncode({'type': 'system', 'subtype': 'task_notification', 'tool_use_id': 'toolu_wf', 'status': 'completed', 'summary': 'done'}));
await Future<void>.delayed(Duration.zero);
final run = session.workflows['toolu_wf'];
expect(run, isNotNull);
expect(run!.name, 'parallel-words');
expect(run.agentCount, 2);
expect(run.doneCount, 1);
expect(run.done, isTrue);
expect(run.summary, 'done');
expect(snapshots, isNotEmpty);
});
test('a workflow system event produces no conversation item', () async {
final p = _FakeProc();
final session = StreamJsonSession(p)..start();
final items = <ConversationItem>[];
session.items.listen(items.add);
p.emit(jsonEncode({'type': 'system', 'subtype': 'task_progress', 'tool_use_id': 'toolu_wf', 'workflow_progress': const []}));
await Future<void>.delayed(Duration.zero);
expect(items, isEmpty);
expect(session.workflows.containsKey('toolu_wf'), isTrue);
});
});
}
+137
View File
@@ -0,0 +1,137 @@
/// Tests for the WorkflowRun model (T-416): folding stream-json `system`
/// task_* events — the exact shapes captured by the spike — into a snapshot.
library;
import 'package:clide/builtin/claude/src/workflow_run.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('isWorkflowSystemEvent', () {
test('accepts task_* system events that name a tool_use_id', () {
for (final s in kWorkflowSystemSubtypes) {
expect(isWorkflowSystemEvent({'type': 'system', 'subtype': s, 'tool_use_id': 'toolu_1'}), isTrue, reason: s);
}
});
test('rejects unrelated system subtypes and non-system events', () {
expect(isWorkflowSystemEvent({'type': 'system', 'subtype': 'init', 'tool_use_id': 'x'}), isFalse);
expect(isWorkflowSystemEvent({'type': 'system', 'subtype': 'thinking_tokens'}), isFalse);
expect(isWorkflowSystemEvent({'type': 'assistant', 'subtype': 'task_progress', 'tool_use_id': 'x'}), isFalse);
expect(isWorkflowSystemEvent({'type': 'system', 'subtype': 'task_progress'}), isFalse); // no tool_use_id
});
});
group('foldEvent — phase-less run', () {
test('task_started seeds name/description/taskId', () {
const run = WorkflowRun(toolUseId: 'toolu_1');
final r = run.foldEvent({
'type': 'system',
'subtype': 'task_started',
'task_id': 'wy01fihjt',
'tool_use_id': 'toolu_1',
'description': 'Two agents return one word each',
'workflow_name': 'parallel-words',
'prompt': 'export const meta = ...',
});
expect(r.taskId, 'wy01fihjt');
expect(r.name, 'parallel-words');
expect(r.description, 'Two agents return one word each');
expect(r.running, isTrue);
expect(r.agentCount, 0);
});
test('task_progress merges workflow_agent deltas by index', () {
var run = const WorkflowRun(toolUseId: 'toolu_1');
// First delta: two agents start, then get their agentIds + real model.
run = run.foldEvent({
'type': 'system',
'subtype': 'task_progress',
'tool_use_id': 'toolu_1',
'summary': 'Two agents return one word each',
'usage': {'total_tokens': 0, 'tool_uses': 0, 'duration_ms': 28},
'workflow_progress': [
{'type': 'workflow_agent', 'index': 1, 'label': 'alpha', 'model': 'haiku', 'state': 'start'},
{'type': 'workflow_agent', 'index': 2, 'label': 'beta', 'model': 'haiku', 'state': 'start'},
{'type': 'workflow_agent', 'index': 1, 'agentId': 'ae51341336dd3a4a0', 'model': 'claude-haiku-4-5-20251001', 'state': 'start'},
],
});
expect(run.agentCount, 2);
expect(run.agents[1]!.label, 'alpha'); // kept from earlier delta
expect(run.agents[1]!.agentId, 'ae51341336dd3a4a0'); // filled by later delta
expect(run.agents[1]!.model, 'claude-haiku-4-5-20251001'); // upgraded
expect(run.summary, 'Two agents return one word each');
// Second delta: agent 2 advances to done.
run = run.foldEvent({
'type': 'system',
'subtype': 'task_progress',
'tool_use_id': 'toolu_1',
'workflow_progress': [
{'type': 'workflow_agent', 'index': 2, 'agentId': 'a210f9290a5f5d089', 'state': 'done'},
],
});
expect(run.agents[2]!.state, WorkflowAgentState.done);
expect(run.agents[1]!.state, WorkflowAgentState.start); // untouched
expect(run.doneCount, 1);
});
test('task_updated and task_notification mark the run done', () {
var run = const WorkflowRun(toolUseId: 'toolu_1');
run = run.foldEvent({
'type': 'system',
'subtype': 'task_updated',
'tool_use_id': 'toolu_1',
'patch': {'status': 'completed', 'end_time': 1},
});
expect(run.done, isTrue);
var run2 = const WorkflowRun(toolUseId: 'toolu_1');
run2 = run2.foldEvent({
'type': 'system',
'subtype': 'task_notification',
'tool_use_id': 'toolu_1',
'status': 'completed',
'summary': 'Dynamic workflow completed',
'usage': {'total_tokens': 19306, 'tool_uses': 0, 'duration_ms': 1009},
});
expect(run2.done, isTrue);
expect(run2.summary, 'Dynamic workflow completed');
expect(run2.totalTokens, 19306);
expect(run2.durationMs, 1009);
});
});
group('foldEvent — phased run', () {
test('workflow_phase entries register phases; agents carry phase tags', () {
var run = const WorkflowRun(toolUseId: 'toolu_1');
run = run.foldEvent({
'type': 'system',
'subtype': 'task_progress',
'tool_use_id': 'toolu_1',
'workflow_progress': [
{'type': 'workflow_phase', 'index': 1, 'title': 'Scan'},
{'type': 'workflow_phase', 'index': 2, 'title': 'Fix'},
{'type': 'workflow_agent', 'index': 1, 'label': 'scan it', 'phaseIndex': 1, 'phaseTitle': 'Scan', 'model': 'haiku', 'state': 'start'},
],
});
expect(run.orderedPhases.map((p) => p.title), ['Scan', 'Fix']);
expect(run.agents[1]!.phaseIndex, 1);
expect(run.agents[1]!.phaseTitle, 'Scan');
});
});
test('orderedAgents sorts by fan-out index', () {
var run = const WorkflowRun(toolUseId: 'toolu_1');
run = run.foldEvent({
'type': 'system',
'subtype': 'task_progress',
'tool_use_id': 'toolu_1',
'workflow_progress': [
{'type': 'workflow_agent', 'index': 3, 'label': 'c', 'state': 'start'},
{'type': 'workflow_agent', 'index': 1, 'label': 'a', 'state': 'start'},
{'type': 'workflow_agent', 'index': 2, 'label': 'b', 'state': 'start'},
],
});
expect(run.orderedAgents.map((a) => a.label), ['a', 'b', 'c']);
});
}