Files
clide/test/builtin/claude/claude_meta_sidebar_test.dart
T
jpmschweitzerandClaude Opus 4.8 b399acedbf 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>
2026-06-12 21:44:41 +02:00

1144 lines
43 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:clide/builtin/claude/src/claude_config.dart';
import 'package:clide/builtin/claude/src/claude_meta_sidebar.dart';
import 'package:clide/builtin/claude/src/claude_stats.dart';
import 'package:clide/builtin/claude/src/session_orchestrator.dart';
import 'package:clide/builtin/claude/src/stream_json_session.dart';
import 'package:clide/builtin/claude/src/transcript_publisher.dart';
import 'package:clide/builtin/claude/src/transcript_reader.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:flutter/services.dart' show LogicalKeyboardKey;
import 'package:flutter/widgets.dart' show ActivateIntent, Actions, EditableText, SizedBox, Semantics;
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
// ---------------------------------------------------------------------------
// Minimal fake process so orchestrator tests don't need a real `claude` binary.
// ---------------------------------------------------------------------------
class _FakeProc extends StreamJsonProcess {
final _ctl = StreamController<String>.broadcast();
final List<String> writes = [];
bool killed = false;
/// Optional callback fired on every [writeLine] — used by T-181 tests to
/// mirror writes into a shared list across sessions.
void Function(String)? onWrite;
@override
Stream<String> get lines => _ctl.stream;
@override
void writeLine(String line) {
writes.add(line);
onWrite?.call(line);
}
@override
Future<void> kill() async => killed = true;
}
ClaudeSessionOrchestrator _fakeOrchestrator() {
return ClaudeSessionOrchestrator(processFactory: ({required sessionArgs, required cwd, env}) async => _FakeProc());
}
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() => f.dispose());
const stats = ClaudeStats(
lastComputed: '2026-05-22',
daily: [
DailyActivity(date: '2026-05-01', messageCount: 100, sessionCount: 2, toolCallCount: 30),
DailyActivity(date: '2026-05-22', messageCount: 250, sessionCount: 5, toolCallCount: 80),
],
);
ClaudeMetaSidebar sidebar({
ClaudeStats stats = const ClaudeStats(),
ClaudeConfig? config,
SidebarTab initialTab = SidebarTab.activity,
ClaudeSessionOrchestrator? orchestrator,
}) => ClaudeMetaSidebar(statsLoader: () async => stats, pollInterval: Duration.zero, config: config, orchestrator: orchestrator, initialTab: initialTab);
testWidgets('Activity tab shows today + lifetime stats on the table', (tester) async {
await tester.pumpWidget(harness(f, sidebar(stats: stats)));
await tester.pumpAndSettle();
expect(find.text('TODAY'), findsOneWidget);
expect(find.text('LIFETIME'), findsOneWidget);
expect(find.text('250'), findsOneWidget); // today messages
expect(find.text('80'), findsOneWidget); // today tool calls
expect(find.text('350'), findsOneWidget); // lifetime messages
expect(find.text('messages'), findsNWidgets(2)); // today + lifetime rows
});
testWidgets('defaults to Activity, not the roster', (tester) async {
await tester.pumpWidget(harness(f, sidebar(stats: stats)));
await tester.pumpAndSettle();
expect(find.text('No team active.'), findsNothing);
expect(find.text('TODAY'), findsOneWidget);
});
testWidgets('tapping a sub-tab switches the body', (tester) async {
await tester.pumpWidget(harness(f, sidebar(stats: stats)));
await tester.pumpAndSettle();
await tester.tap(find.text('Team'));
await tester.pump();
expect(find.text('No team active.'), findsOneWidget);
expect(find.text('TODAY'), findsNothing);
await tester.tap(find.text('Activity'));
await tester.pump();
expect(find.text('TODAY'), findsOneWidget);
});
testWidgets('a sub-tab is keyboard-activatable, not pointer-only (T-182)', (tester) async {
await tester.pumpWidget(harness(f, sidebar(stats: stats)));
await tester.pumpAndSettle();
// The tab wraps its label in a ClideTappable, so the keymap's Enter/Space
// → ActivateIntent reaches it. Dispatch ActivateIntent through the real
// Actions path (what KeymapService does) — no pointer tap — and the body
// switches. Mirrors test/widgets/src/clide_tappable_test.dart.
Actions.invoke(tester.element(find.text('Team')), const ActivateIntent());
await tester.pump();
expect(find.text('No team active.'), findsOneWidget);
expect(find.text('TODAY'), findsNothing);
});
testWidgets('Config tab renders the settings table over ClaudeConfig', (tester) async {
final dir = Directory.systemTemp.createTempSync('cfg');
addTearDown(() => dir.deleteSync(recursive: true));
final config = ClaudeConfig(globalDir: dir, cacheDir: dir);
await tester.pumpWidget(harness(f, sidebar(config: config, initialTab: SidebarTab.config)));
await tester.pumpAndSettle();
expect(find.text('SETTINGS'), findsOneWidget);
expect(find.text('output style'), findsOneWidget);
expect(find.text('permission mode'), findsOneWidget);
expect(find.text('~/.claude + .claude'), findsOneWidget);
});
testWidgets('Config tab degrades when no environment is loaded', (tester) async {
await tester.pumpWidget(harness(f, sidebar(initialTab: SidebarTab.config)));
await tester.pumpAndSettle();
expect(find.text('Claude environment not loaded.'), findsOneWidget);
});
testWidgets('Activity session controls publish their slash commands (T-415)', (tester) async {
final published = <Message>[];
final sub = f.services.messages.subscribe(publisher: 'builtin.claude', channel: 'command').listen(published.add);
addTearDown(sub.cancel);
await tester.pumpWidget(harness(f, sidebar(stats: stats)));
await tester.pumpAndSettle();
expect(find.text('SESSION'), findsOneWidget);
await tester.tap(find.bySemanticsLabel('clear session'));
await tester.tap(find.bySemanticsLabel('compact session'));
await tester.tap(find.bySemanticsLabel('refresh usage session'));
await tester.pump();
expect(published.map((m) => m.data['text']), ['/clear', '/compact', '/usage']);
});
testWidgets('a settings control publishes its slash command on pick (T-414)', (tester) async {
final dir = Directory.systemTemp.createTempSync('cfg');
addTearDown(() => dir.deleteSync(recursive: true));
final config = ClaudeConfig(globalDir: dir, cacheDir: dir);
final published = <Message>[];
final sub = f.services.messages.subscribe(publisher: 'builtin.claude', channel: 'command').listen(published.add);
addTearDown(sub.cancel);
await tester.pumpWidget(harness(f, sidebar(config: config, initialTab: SidebarTab.config)));
await tester.pumpAndSettle();
// The three live controls render alongside the read-only rows.
expect(find.text('model'), findsOneWidget);
expect(find.text('effort'), findsOneWidget);
expect(find.text('permission mode'), findsOneWidget);
// Open the effort control and pick a level → the explicit slash command
// goes out on the bus (the primary pane executes it via _send, D-6).
await tester.tap(find.bySemanticsLabel(RegExp('effort: .*Click to change.')));
await tester.pumpAndSettle();
await tester.tap(find.textContaining('xhigh'));
await tester.pumpAndSettle();
expect(published, hasLength(1));
expect(published.single.data['text'], '/effort xhigh');
});
testWidgets('a meta.tab message switches the sub-tab (T-413 slash navigation)', (tester) async {
await tester.pumpWidget(harness(f, sidebar(stats: stats)));
await tester.pumpAndSettle();
expect(find.text('TODAY'), findsOneWidget); // starts on Activity
// /config (and /mcp, /agents, /hooks) publish this from the Claude pane.
f.services.messages.publish('builtin.claude', 'meta.tab', {'tab': 'config'});
await tester.pump();
await tester.pump();
expect(find.text('Claude environment not loaded.'), findsOneWidget); // Config tab (no env in fixture)
f.services.messages.publish('builtin.claude', 'meta.tab', {'tab': 'activity'});
await tester.pump();
await tester.pump();
expect(find.text('TODAY'), findsOneWidget); // back on Activity
// An unknown tab name is ignored.
f.services.messages.publish('builtin.claude', 'meta.tab', {'tab': 'bogus'});
await tester.pump();
await tester.pump();
expect(find.text('TODAY'), findsOneWidget);
});
testWidgets('a team spawn auto-fronts the Team tab', (tester) async {
await tester.pumpWidget(harness(f, sidebar(stats: stats)));
await tester.pumpAndSettle();
expect(find.text('TODAY'), findsOneWidget); // starts on Activity
f.services.events.emit(
const TeamMemberJoined(team: 't', agentId: 'a1', name: 'Scout', agentType: 'explorer', paneId: '%1', model: 'claude-opus-4-7', color: 'blue'),
);
await tester.pump();
await tester.pump();
// Fronted to Team without a tap.
expect(find.text('Scout'), findsOneWidget);
expect(find.text('explorer · opus 4.7'), findsOneWidget);
expect(find.text('TODAY'), findsNothing);
});
testWidgets('removing the last member leaves the Team tab empty', (tester) async {
await tester.pumpWidget(harness(f, sidebar()));
await tester.pumpAndSettle();
f.services.events.emit(const TeamMemberJoined(team: 't', agentId: 'a1', name: 'Scout', agentType: 'explorer', paneId: '%1', color: 'blue'));
await tester.pump();
await tester.pump();
expect(find.text('Scout'), findsOneWidget);
f.services.events.emit(const TeamMemberLeft(team: 't', agentId: 'a1', paneId: '%1'));
await tester.pump();
await tester.pump();
expect(find.text('Scout'), findsNothing);
expect(find.text('No team active.'), findsOneWidget);
});
testWidgets('folds live per-member status into the roster row', (tester) async {
await tester.pumpWidget(harness(f, sidebar()));
await tester.pumpAndSettle();
f.services.events.emit(const TeamMemberJoined(team: 't', agentId: 'a1', name: 'Scout', agentType: 'explorer', paneId: '%1', color: 'blue'));
await tester.pump();
await tester.pump();
f.services.messages.publish(
ClaudeConversation.publisher,
ClaudeConversation.memberStatusChannel,
ClaudeConversation.memberStatusData('a1', const SessionStatus(model: 'claude-opus-4-7', permissionMode: 'acceptEdits', contextTokens: 21000)),
);
await tester.pump();
await tester.pump();
expect(find.text('explorer · opus 4.7 · accept-edits · 21k ctx'), findsOneWidget);
});
testWidgets('Activity degrades to a no-activity message with empty stats', (tester) async {
await tester.pumpWidget(harness(f, sidebar()));
await tester.pumpAndSettle();
expect(find.text('No activity recorded yet.'), findsOneWidget);
});
// T-171: roster controls + task list ----------------------------------------
group('T-171 roster controls', () {
Future<ClaudeSessionOrchestrator> orchWithMember(WidgetTester tester, {String name = 'Scout', String agentId = 'a1'}) async {
final orch = _fakeOrchestrator();
await orch.spawn(SpawnSpec(id: 'teammate:$name', role: 'teammate', sessionId: '$name-uuid', cwd: '/repo', team: true, memberName: name));
await tester.pumpWidget(harness(f, sidebar(orchestrator: orch, initialTab: SidebarTab.team)));
f.services.events.emit(TeamMemberJoined(team: 't', agentId: agentId, name: name, agentType: 'coder', paneId: '%1', color: 'blue'));
await tester.pump();
await tester.pump();
return orch;
}
testWidgets('team tab shows MESSAGES placeholder seam', (tester) async {
await orchWithMember(tester);
expect(find.text('MESSAGES'), findsOneWidget);
});
testWidgets('show/hide toggle changes managed session visibility', (tester) async {
final semantics = tester.ensureSemantics();
final orch = await orchWithMember(tester);
final managed = orch.byId('teammate:Scout')!;
expect(managed.visible, isTrue);
// The eye icon tooltips are "Hide pane" and "Show pane".
// We can find the first ClideTappable for hide (the eye icon).
// Tap by tooltip text (via Semantics).
final hideTap = find.bySemanticsLabel('Hide pane').first;
await tester.tap(hideTap);
await tester.pump();
expect(managed.visible, isFalse);
final showTap = find.bySemanticsLabel('Show pane').first;
await tester.tap(showTap);
await tester.pump();
expect(managed.visible, isTrue);
semantics.dispose();
orch.dispose();
});
testWidgets('mute toggle gates broker delivery', (tester) async {
final semantics = tester.ensureSemantics();
final orch = await orchWithMember(tester);
final managed = orch.byId('teammate:Scout')!;
expect(managed.muted, isFalse);
final muteTap = find.bySemanticsLabel('Mute messages').first;
await tester.tap(muteTap);
await tester.pump();
expect(managed.muted, isTrue);
expect(orch.broker.isMuted('teammate:Scout'), isTrue);
final unmuteTap = find.bySemanticsLabel('Unmute messages').first;
await tester.tap(unmuteTap);
await tester.pump();
expect(managed.muted, isFalse);
semantics.dispose();
orch.dispose();
});
testWidgets('close button kills the session', (tester) async {
final semantics = tester.ensureSemantics();
final orch = await orchWithMember(tester);
expect(orch.byId('teammate:Scout'), isNotNull);
final closeTap = find.bySemanticsLabel('Close session').first;
await tester.tap(closeTap);
await tester.pump();
await tester.pump(); // allow async close to complete
expect(orch.byId('teammate:Scout'), isNull);
semantics.dispose();
orch.dispose();
});
testWidgets('inject-message affordance toggles the text field', (tester) async {
final semantics = tester.ensureSemantics();
final orch = await orchWithMember(tester);
// Before tap: only the chat-composer field is visible (T-180).
// find by focusNode debugLabel to count only the inject field (not the chat composer).
final injectFinder = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel?.startsWith('inject-') == true);
expect(injectFinder, findsNothing);
final injectTap = find.bySemanticsLabel('Inject message').first;
await tester.tap(injectTap);
await tester.pump();
// After tap: inject field appears.
expect(injectFinder, findsOneWidget);
// Tapping the cancel (×) icon dismisses it.
final cancelTap = find.bySemanticsLabel('Cancel').first;
await tester.tap(cancelTap);
await tester.pump();
expect(injectFinder, findsNothing);
semantics.dispose();
orch.dispose();
});
testWidgets('submitting inject field sends the text to the session', (tester) async {
final semantics = tester.ensureSemantics();
final orch = _fakeOrchestrator();
// We cannot intercept the proc easily through the public API — verify
// injectMessage wired up by checking the managed session is the right one.
await orch.spawn(SpawnSpec(id: 'teammate:Alpha', role: 'teammate', sessionId: 'alpha-uuid', cwd: '/repo', team: true, memberName: 'Alpha'));
await tester.pumpWidget(harness(f, sidebar(orchestrator: orch, initialTab: SidebarTab.team)));
f.services.events.emit(const TeamMemberJoined(team: 't', agentId: 'a2', name: 'Alpha', agentType: 'coder', paneId: '%2', color: 'green'));
await tester.pump();
await tester.pump();
// Open inject field.
await tester.tap(find.bySemanticsLabel('Inject message').first);
await tester.pump();
final injectFinder = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel?.startsWith('inject-') == true);
expect(injectFinder, findsOneWidget);
// Type and submit.
await tester.enterText(injectFinder, 'hello agent');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
// Inject field dismissed after submit (chat composer remains).
expect(injectFinder, findsNothing);
semantics.dispose();
orch.dispose();
// Note: we cannot assert on _FakeProc.writes here because the process
// factory closed over the outer list; the session's injectMessage call
// is verified by the orchestrator unit test in session_orchestrator_test.
});
});
group('T-171 task list', () {
testWidgets('task list renders live from broker on changes stream', (tester) async {
final orch = _fakeOrchestrator();
await orch.spawn(SpawnSpec(id: 'primary', role: 'primary', sessionId: 'primary-uuid', cwd: '/repo', team: true, memberName: 'lead'));
await orch.spawn(SpawnSpec(id: 'teammate:tyre', role: 'teammate', sessionId: 'tyre-uuid', cwd: '/repo', team: true, memberName: 'tyre'));
await tester.pumpWidget(harness(f, sidebar(orchestrator: orch, initialTab: SidebarTab.team)));
f.services.events.emit(const TeamMemberJoined(team: 't', agentId: 'a1', name: 'lead', agentType: 'lead', paneId: '%1', color: 'blue'));
await tester.pump();
await tester.pump();
// No tasks yet.
expect(find.text('TASKS'), findsNothing);
// Add a task via the broker.
orch.broker.claimTask('primary', title: 'wire-the-sidebar');
await tester.pump();
await tester.pump();
expect(find.text('TASKS'), findsOneWidget);
expect(find.text('wire-the-sidebar'), findsOneWidget);
orch.dispose();
});
testWidgets('reassign button cycles task owner', (tester) async {
final orch = _fakeOrchestrator();
await orch.spawn(SpawnSpec(id: 'primary', role: 'primary', sessionId: 'p-uuid', cwd: '/repo', team: true, memberName: 'lead'));
await orch.spawn(SpawnSpec(id: 'teammate:tyre', role: 'teammate', sessionId: 't-uuid', cwd: '/repo', team: true, memberName: 'tyre'));
// Sized box so the ListView gets a real (tall) viewport — under the
// shared canSizeOverlay harness the sidebar's scrollable otherwise gets a
// degenerate viewport and clips the task section out of the semantics
// tree, so the reassign button below the roster isn't findable by label.
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(orchestrator: orch, initialTab: SidebarTab.team),
),
),
);
f.services.events.emit(const TeamMemberJoined(team: 't', agentId: 'a1', name: 'lead', agentType: 'lead', paneId: '%1', color: 'cyan'));
await tester.pump();
await tester.pump();
orch.broker.claimTask('primary', title: 'the-task');
await tester.pump();
await tester.pump();
final originalOwner = orch.broker.tasks.first.owner;
// Find the reassign button at the widget level (its Semantics carries the
// label). We don't use find.bySemanticsLabel here because the shared
// canSizeOverlay test harness gives the sidebar's ListView a degenerate
// viewport that clips the lower task section out of the semantics *tree*
// (the widget is built and tappable; only the semantics node is dropped).
final reassign = find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Reassign task');
await tester.tap(reassign.first);
await tester.pump();
await tester.pump();
expect(orch.broker.tasks.first.owner, isNot(originalOwner));
orch.dispose();
});
});
// T-181: permission-mode badge -----------------------------------------------
group('T-181 permission-mode badge', () {
/// Build an orchestrator that captures all writes from the spawned session's
/// stdin. The factory captures the fake proc and mirrors its writeLine calls
/// into [writes] so tests can assert on control_requests sent.
(ClaudeSessionOrchestrator, List<String>) orchCapturing() {
final writes = <String>[];
final orch = ClaudeSessionOrchestrator(
processFactory: ({required sessionArgs, required cwd, env}) async {
final p = _FakeProc();
p.onWrite = writes.add;
return p;
},
);
return (orch, writes);
}
Future<(ClaudeSessionOrchestrator, List<String>)> spawnAndShow(WidgetTester tester, {String name = 'Scout', String agentId = 'b1'}) async {
final (orch, writes) = orchCapturing();
await orch.spawn(SpawnSpec(id: 'teammate:$name', role: 'teammate', sessionId: '$name-uuid', cwd: '/repo', team: true, memberName: name));
await tester.pumpWidget(harness(f, sidebar(orchestrator: orch, initialTab: SidebarTab.team)));
f.services.events.emit(TeamMemberJoined(team: 't', agentId: agentId, name: name, agentType: 'coder', paneId: '%1', color: 'blue'));
await tester.pump();
await tester.pump();
return (orch, writes);
}
testWidgets('badge renders with label D when permissionMode is null/default', (tester) async {
final semantics = tester.ensureSemantics();
final (orch, _) = await spawnAndShow(tester);
// The badge Semantics label is 'Permission mode: D' for the default mode.
expect(find.bySemanticsLabel('Permission mode: D'), findsOneWidget);
semantics.dispose();
orch.dispose();
});
testWidgets('badge label reflects live permissionMode from status (A for acceptEdits)', (tester) async {
final semantics = tester.ensureSemantics();
final (orch, _) = await spawnAndShow(tester);
// Push a live status update with acceptEdits.
f.services.messages.publish(
ClaudeConversation.publisher,
ClaudeConversation.memberStatusChannel,
ClaudeConversation.memberStatusData('b1', const SessionStatus(permissionMode: 'acceptEdits')),
);
await tester.pump();
await tester.pump();
expect(find.bySemanticsLabel('Permission mode: A'), findsOneWidget);
semantics.dispose();
orch.dispose();
});
testWidgets('plain click cycles default → acceptEdits and writes set_permission_mode', (tester) async {
final semantics = tester.ensureSemantics();
final (orch, writes) = await spawnAndShow(tester);
final preCount = writes.length;
await tester.tap(find.bySemanticsLabel('Permission mode: D').first);
await tester.pump();
// One new write for the set_permission_mode control_request.
expect(writes.length, preCount + 1);
final sent = jsonDecode(writes.last) as Map<String, dynamic>;
expect(sent['type'], 'control_request');
expect((sent['request'] as Map)['subtype'], 'set_permission_mode');
expect((sent['request'] as Map)['mode'], 'acceptEdits');
semantics.dispose();
orch.dispose();
});
testWidgets('shift-click shows the bypass confirm inline', (tester) async {
final semantics = tester.ensureSemantics();
final (orch, _) = await spawnAndShow(tester);
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
await tester.tap(find.bySemanticsLabel('Permission mode: D').first);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
await tester.pump();
// The inline confirm prompt should be visible.
expect(find.text('Enable bypassPermissions? All tool calls will be auto-allowed.'), findsOneWidget);
expect(find.bySemanticsLabel('Confirm bypass'), findsOneWidget);
expect(find.bySemanticsLabel('Cancel bypass'), findsOneWidget);
semantics.dispose();
orch.dispose();
});
testWidgets('bypass confirm OK sends bypassPermissions and dismisses the prompt', (tester) async {
final semantics = tester.ensureSemantics();
final (orch, writes) = await spawnAndShow(tester);
final preCount = writes.length;
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
await tester.tap(find.bySemanticsLabel('Permission mode: D').first);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
await tester.pump();
await tester.tap(find.bySemanticsLabel('Confirm bypass').first);
await tester.pump();
// Prompt is gone.
expect(find.text('Enable bypassPermissions? All tool calls will be auto-allowed.'), findsNothing);
// bypassPermissions was sent to the session.
expect(writes.length, preCount + 1);
final sent = jsonDecode(writes.last) as Map<String, dynamic>;
expect((sent['request'] as Map)['subtype'], 'set_permission_mode');
expect((sent['request'] as Map)['mode'], 'bypassPermissions');
semantics.dispose();
orch.dispose();
});
testWidgets('bypass confirm Cancel dismisses without sending', (tester) async {
final semantics = tester.ensureSemantics();
final (orch, writes) = await spawnAndShow(tester);
final preCount = writes.length;
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
await tester.tap(find.bySemanticsLabel('Permission mode: D').first);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
await tester.pump();
await tester.tap(find.bySemanticsLabel('Cancel bypass').first);
await tester.pump();
// Prompt dismissed, no extra write.
expect(find.text('Enable bypassPermissions? All tool calls will be auto-allowed.'), findsNothing);
expect(writes.length, preCount);
semantics.dispose();
orch.dispose();
});
});
// T-172: fork button in the roster -------------------------------------------
group('T-172 fork session button', () {
Future<ClaudeSessionOrchestrator> orchWithMember(WidgetTester tester, {String name = 'Forker', String agentId = 'f1'}) async {
final orch = _fakeOrchestrator();
await orch.spawn(SpawnSpec(id: 'teammate:$name', role: 'teammate', sessionId: '$name-uuid', cwd: '/repo', team: true, memberName: name));
await tester.pumpWidget(harness(f, sidebar(orchestrator: orch, initialTab: SidebarTab.team)));
f.services.events.emit(TeamMemberJoined(team: 't', agentId: agentId, name: name, agentType: 'coder', paneId: '%1', color: 'teal'));
await tester.pump();
await tester.pump();
return orch;
}
testWidgets('fork button appears in the roster controls', (tester) async {
final semantics = tester.ensureSemantics();
final orch = await orchWithMember(tester);
// The fork button is an _IconButton with tooltip 'Fork session'.
expect(find.bySemanticsLabel('Fork session'), findsOneWidget);
semantics.dispose();
orch.dispose();
});
testWidgets('tapping fork button spawns a new fork session in the orchestrator', (tester) async {
final semantics = tester.ensureSemantics();
final orch = await orchWithMember(tester);
expect(orch.sessions, hasLength(1)); // just the source
await tester.tap(find.bySemanticsLabel('Fork session').first);
await tester.pump();
await tester.pump();
// A second (fork) session is now registered.
expect(orch.sessions, hasLength(2));
final forkSession = orch.sessions.last;
expect(forkSession.isFork, isTrue);
expect(forkSession.forkSourceSessionId, 'Forker-uuid');
semantics.dispose();
orch.dispose();
});
testWidgets('fork leaves the source session untouched', (tester) async {
final semantics = tester.ensureSemantics();
final orch = await orchWithMember(tester);
final source = orch.byId('teammate:Forker')!;
await tester.tap(find.bySemanticsLabel('Fork session').first);
await tester.pump();
await tester.pump();
// Source session is still present and unchanged.
expect(orch.byId('teammate:Forker'), same(source));
expect(source.visible, isTrue);
semantics.dispose();
orch.dispose();
});
});
// T-183: Config tab accordion browser ----------------------------------------
group('T-183 Config accordion browser', () {
/// Build a fully-loaded [ClaudeConfig] via [tester.runAsync] so real I/O
/// and async work run outside FakeAsync. Returns a config whose load() has
/// completed; caller must call dispose() (via addTearDown).
Future<ClaudeConfig> loadedConfig(
WidgetTester tester,
Directory root, {
List<({String name, String dir})> skills = const [],
List<String> commands = const [],
List<String> agents = const [],
Map<String, Object?> settings = const {},
}) async {
final result = await tester.runAsync(() async {
final globalDir = Directory('${root.path}/global')..createSync(recursive: true);
final cacheDir = Directory('${root.path}/cache')..createSync(recursive: true);
for (final s in skills) {
final d = Directory('${globalDir.path}/skills/${s.dir}')..createSync(recursive: true);
await File('${d.path}/SKILL.md').writeAsString('---\nname: ${s.name}\n---\nbody');
}
for (final cmd in commands) {
final d = Directory('${globalDir.path}/commands')..createSync(recursive: true);
await File('${d.path}/$cmd.md').writeAsString('# $cmd');
}
for (final agent in agents) {
final d = Directory('${globalDir.path}/agents')..createSync(recursive: true);
await File('${d.path}/$agent.md').writeAsString('# $agent');
}
if (settings.isNotEmpty) {
await File('${globalDir.path}/settings.json').writeAsString(jsonEncode(settings));
}
final c = ClaudeConfig(
globalDir: globalDir,
cacheDir: cacheDir,
versionRunner: () async => '2.1.0 (Claude Code)\n',
initProbe: () async => null,
watch: (_) => const Stream<void>.empty(),
debounce: Duration.zero,
);
await c.load();
return c;
});
// tester.runAsync returns T? — the result is non-null here because the
// body always returns successfully.
return result!;
}
testWidgets('pinned SETTINGS table renders with model / output style / permission mode / source', (tester) async {
final dir = Directory.systemTemp.createTempSync('t183_settings');
addTearDown(() => dir.deleteSync(recursive: true));
final config = await loadedConfig(tester, dir);
addTearDown(config.dispose);
await tester.pumpWidget(harness(f, sidebar(config: config, initialTab: SidebarTab.config)));
await tester.pump();
await tester.pump();
expect(find.text('SETTINGS'), findsOneWidget);
expect(find.text('model'), findsOneWidget);
expect(find.text('output style'), findsOneWidget);
expect(find.text('permission mode'), findsOneWidget);
expect(find.text('source'), findsOneWidget);
expect(find.text('~/.claude + .claude'), findsOneWidget);
});
testWidgets('accordion sections appear for each kind with correct counts', (tester) async {
final dir = Directory.systemTemp.createTempSync('t183_counts');
addTearDown(() => dir.deleteSync(recursive: true));
final config = await loadedConfig(
tester,
dir,
skills: [(name: 'git-commit', dir: 'git-commit'), (name: 'pql', dir: 'pql')],
commands: ['deploy', 'test'],
agents: ['planner'],
settings: {
'mcpServers': {'brave': {}, 'clide': {}},
'permissions': {
'allow': ['Bash(git *)'],
'deny': ['Bash(rm -rf *)'],
},
},
);
addTearDown(config.dispose);
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(config: config, initialTab: SidebarTab.config),
),
),
);
await tester.pump();
await tester.pump();
// Section headers with counts are rendered by ClideAccordion as "$label · $count".
expect(find.text('SKILLS · 2'), findsOneWidget);
expect(find.text('AGENTS · 1'), findsOneWidget);
expect(find.text('COMMANDS · 2'), findsOneWidget);
expect(find.text('PERMISSIONS · 2'), findsOneWidget);
expect(find.text('MCP SERVERS · 2'), findsOneWidget);
});
testWidgets('expanding SKILLS section shows all skill names (no ellipsis)', (tester) async {
final dir = Directory.systemTemp.createTempSync('t183_skills');
addTearDown(() => dir.deleteSync(recursive: true));
final config = await loadedConfig(
tester,
dir,
skills: [(name: 'git-commit', dir: 'git-commit'), (name: 'pql', dir: 'pql'), (name: 'deep-research', dir: 'deep-research')],
);
addTearDown(config.dispose);
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(config: config, initialTab: SidebarTab.config),
),
),
);
await tester.pump();
await tester.pump();
// Skills are hidden until expanded.
expect(find.text('git-commit'), findsNothing);
// Tap the SKILLS accordion header.
await tester.tap(find.text('SKILLS · 3'));
await tester.pump();
// All three skills render — no truncation.
expect(find.text('git-commit'), findsOneWidget);
expect(find.text('pql'), findsOneWidget);
expect(find.text('deep-research'), findsOneWidget);
});
testWidgets('expanding AGENTS section shows all agent names', (tester) async {
final dir = Directory.systemTemp.createTempSync('t183_agents');
addTearDown(() => dir.deleteSync(recursive: true));
final config = await loadedConfig(tester, dir, agents: ['planner', 'coder']);
addTearDown(config.dispose);
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(config: config, initialTab: SidebarTab.config),
),
),
);
await tester.pump();
await tester.pump();
await tester.tap(find.text('AGENTS · 2'));
await tester.pump();
expect(find.text('planner'), findsOneWidget);
expect(find.text('coder'), findsOneWidget);
});
testWidgets('expanding COMMANDS section shows all command names', (tester) async {
final dir = Directory.systemTemp.createTempSync('t183_cmds');
addTearDown(() => dir.deleteSync(recursive: true));
final config = await loadedConfig(tester, dir, commands: ['deploy', 'test', 'lint']);
addTearDown(config.dispose);
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(config: config, initialTab: SidebarTab.config),
),
),
);
await tester.pump();
await tester.pump();
await tester.tap(find.text('COMMANDS · 3'));
await tester.pump();
expect(find.text('deploy'), findsOneWidget);
expect(find.text('test'), findsOneWidget);
expect(find.text('lint'), findsOneWidget);
});
testWidgets('expanding PERMISSIONS shows rules grouped by allow / ask / deny', (tester) async {
final dir = Directory.systemTemp.createTempSync('t183_perms');
addTearDown(() => dir.deleteSync(recursive: true));
final config = await loadedConfig(
tester,
dir,
settings: {
'permissions': {
'allow': ['Bash(git *)'],
'ask': ['Write'],
'deny': ['Bash(rm -rf *)'],
},
},
);
addTearDown(config.dispose);
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(config: config, initialTab: SidebarTab.config),
),
),
);
await tester.pump();
await tester.pump();
await tester.tap(find.text('PERMISSIONS · 3'));
await tester.pump();
// Group labels and rules should all appear.
expect(find.text('allow'), findsOneWidget);
expect(find.text('ask'), findsOneWidget);
expect(find.text('deny'), findsOneWidget);
expect(find.text('Bash(git *)'), findsOneWidget);
expect(find.text('Write'), findsOneWidget);
expect(find.text('Bash(rm -rf *)'), findsOneWidget);
});
testWidgets('expanding MCP SERVERS shows all server names', (tester) async {
final dir = Directory.systemTemp.createTempSync('t183_mcp');
addTearDown(() => dir.deleteSync(recursive: true));
final config = await loadedConfig(
tester,
dir,
settings: {
'mcpServers': {'brave': {}, 'clide': {}, 'github': {}},
},
);
addTearDown(config.dispose);
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(config: config, initialTab: SidebarTab.config),
),
),
);
await tester.pump();
await tester.pump();
await tester.tap(find.text('MCP SERVERS · 3'));
await tester.pump();
expect(find.text('brave'), findsOneWidget);
expect(find.text('clide'), findsOneWidget);
expect(find.text('github'), findsOneWidget);
});
testWidgets('tapping a file-backed skill publishes to builtin.markdown selection (T-187)', (tester) async {
final dir = Directory.systemTemp.createTempSync('t183_click');
addTearDown(() => dir.deleteSync(recursive: true));
final config = await loadedConfig(tester, dir, skills: [(name: 'my-skill', dir: 'my-skill')]);
addTearDown(config.dispose);
// Capture markdown selection messages from the kernel MessageBus.
final published = <Message>[];
final sub = f.services.messages.subscribe(publisher: 'builtin.markdown', channel: 'selection').listen(published.add);
addTearDown(sub.cancel);
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(config: config, initialTab: SidebarTab.config),
),
),
);
await tester.pump();
await tester.pump();
// Expand SKILLS, then tap the skill row.
await tester.tap(find.text('SKILLS · 1'));
await tester.pump();
// The skill name is the tappable label.
await tester.tap(find.text('my-skill'));
await tester.pump();
await pumpAsync(tester);
expect(published, hasLength(1));
expect(published.first.data['path'] as String?, endsWith('my-skill/SKILL.md'));
});
testWidgets('tapping a file-backed command publishes to builtin.markdown selection (T-187)', (tester) async {
final dir = Directory.systemTemp.createTempSync('t183_cmd_click');
addTearDown(() => dir.deleteSync(recursive: true));
final config = await loadedConfig(tester, dir, commands: ['deploy']);
addTearDown(config.dispose);
final published = <Message>[];
final sub = f.services.messages.subscribe(publisher: 'builtin.markdown', channel: 'selection').listen(published.add);
addTearDown(sub.cancel);
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(config: config, initialTab: SidebarTab.config),
),
),
);
await tester.pump();
await tester.pump();
await tester.tap(find.text('COMMANDS · 1'));
await tester.pump();
await tester.tap(find.text('deploy'));
await tester.pump();
await pumpAsync(tester);
expect(published, hasLength(1));
expect(published.first.data['path'] as String?, endsWith('deploy.md'));
});
testWidgets('tapping a file-backed agent publishes to builtin.markdown selection (T-187)', (tester) async {
final dir = Directory.systemTemp.createTempSync('t183_agent_click');
addTearDown(() => dir.deleteSync(recursive: true));
final config = await loadedConfig(tester, dir, agents: ['planner']);
addTearDown(config.dispose);
final published = <Message>[];
final sub = f.services.messages.subscribe(publisher: 'builtin.markdown', channel: 'selection').listen(published.add);
addTearDown(sub.cancel);
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(config: config, initialTab: SidebarTab.config),
),
),
);
await tester.pump();
await tester.pump();
await tester.tap(find.text('AGENTS · 1'));
await tester.pump();
await tester.tap(find.text('planner'));
await tester.pump();
await pumpAsync(tester);
expect(published, hasLength(1));
expect(published.first.data['path'] as String?, endsWith('planner.md'));
});
testWidgets('accordion collapses when toggled a second time', (tester) async {
final dir = Directory.systemTemp.createTempSync('t183_collapse');
addTearDown(() => dir.deleteSync(recursive: true));
final config = await loadedConfig(tester, dir, skills: [(name: 'git-commit', dir: 'git-commit')]);
addTearDown(config.dispose);
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(config: config, initialTab: SidebarTab.config),
),
),
);
await tester.pump();
await tester.pump();
// Expand.
await tester.tap(find.text('SKILLS · 1'));
await tester.pump();
expect(find.text('git-commit'), findsOneWidget);
// Collapse.
await tester.tap(find.text('SKILLS · 1'));
await tester.pump();
expect(find.text('git-commit'), findsNothing);
});
testWidgets('Config tab with empty sections shows zero counts on all headers', (tester) async {
final dir = Directory.systemTemp.createTempSync('t183_empty');
addTearDown(() => dir.deleteSync(recursive: true));
final config = await loadedConfig(tester, dir);
addTearDown(config.dispose);
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(config: config, initialTab: SidebarTab.config),
),
),
);
await tester.pump();
await tester.pump();
expect(find.text('SKILLS · 0'), findsOneWidget);
expect(find.text('AGENTS · 0'), findsOneWidget);
expect(find.text('COMMANDS · 0'), findsOneWidget);
expect(find.text('HOOKS · 0'), findsOneWidget);
expect(find.text('PERMISSIONS · 0'), findsOneWidget);
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();
});
});
}