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
@@ -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);
});
});
}