add the Claude team cockpit: roster controls + live task list

The meta sidebar's Team tab becomes a control surface for clide-managed
agents instead of a read-only roster. Each row gains show/hide, mute,
close, and inject-a-message; a live task list renders from the broker
with reassign. The broker grows a Dart change-stream (kept Flutter-free
for dart test) plus tasks/reassign; the orchestrator gains mute/unmute,
injectMessage, and member-name session resolution. Every new UI action
has a matching clide command (D-6 parity).

T-171.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-30 23:11:22 +02:00
co-authored by Claude
parent 80401a3228
commit 4220d97a49
7 changed files with 1071 additions and 36 deletions
@@ -1,16 +1,42 @@
import 'dart:async';
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/widgets.dart' show 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 implements StreamJsonProcess {
final _ctl = StreamController<String>.broadcast();
final List<String> writes = [];
bool killed = false;
@override
Stream<String> get lines => _ctl.stream;
@override
void writeLine(String line) => writes.add(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());
@@ -28,11 +54,13 @@ void main() {
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,
);
@@ -169,4 +197,255 @@ void main() {
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: no inject field visible.
expect(find.byType(EditableText), findsNothing);
final injectTap = find.bySemanticsLabel('Inject message').first;
await tester.tap(injectTap);
await tester.pump();
// After tap: inject field appears.
expect(find.byType(EditableText), findsOneWidget);
// Tapping the cancel (×) icon dismisses it.
final cancelTap = find.bySemanticsLabel('Cancel').first;
await tester.tap(cancelTap);
await tester.pump();
expect(find.byType(EditableText), 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();
expect(find.byType(EditableText), findsOneWidget);
// Type and submit.
await tester.enterText(find.byType(EditableText).first, 'hello agent');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
// Field dismissed after submit.
expect(find.byType(EditableText), 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();
});
});
}
+131
View File
@@ -125,4 +125,135 @@ void main() {
final names = lead.tools.map((t) => t['name']).toSet();
expect(names, {'send_message', 'broadcast', 'list_teammates', 'inbox', 'claim_task', 'task_status'});
});
// T-171 additions -----------------------------------------------------------
group('changes stream (T-171)', () {
test('fires when a message is enqueued', () async {
final events = <void>[];
final sub = broker.changes.listen((_) => events.add(null));
await lead.callTool('send_message', {'to': 'tyre', 'text': 'ping'});
await sub.cancel();
expect(events, hasLength(1));
});
test('fires when a task is created via claim_task', () async {
final events = <void>[];
final sub = broker.changes.listen((_) => events.add(null));
await tyre.callTool('claim_task', {'title': 'new task'});
await sub.cancel();
expect(events, hasLength(1));
});
test('fires when a task status is updated', () async {
final created = decode(await tyre.callTool('claim_task', {'title': 'update me'}));
final id = (created['task'] as Map)['id'] as String;
final events = <void>[];
final sub = broker.changes.listen((_) => events.add(null));
await lead.callTool('task_status', {'id': id, 'status': 'done'});
await sub.cancel();
expect(events, hasLength(1));
});
test('fires when a member is removed', () async {
final events = <void>[];
final sub = broker.changes.listen((_) => events.add(null));
broker.removeMember('teammate:tyre');
await Future<void>.delayed(Duration.zero); // let the broadcast event deliver
await sub.cancel();
expect(events, hasLength(1));
});
test('stream is closed after dispose', () async {
var done = false;
broker.changes.listen(null, onDone: () => done = true);
broker.dispose();
await Future<void>.delayed(Duration.zero);
expect(done, isTrue);
});
});
group('tasks getter (T-171)', () {
test('returns all tasks in creation order', () async {
await lead.callTool('claim_task', {'title': 'alpha'});
await tyre.callTool('claim_task', {'title': 'beta'});
final titles = broker.tasks.map((t) => t.title).toList();
expect(titles, ['alpha', 'beta']);
});
test('returns an empty list when no tasks exist', () {
expect(broker.tasks, isEmpty);
});
});
group('reassignTask (T-171)', () {
test('reassigns to a known member by id', () async {
final created = decode(await tyre.callTool('claim_task', {'title': 'reassignable'}));
final id = (created['task'] as Map)['id'] as String;
final ok = broker.reassignTask(id, 'primary');
expect(ok, isTrue);
final t = broker.tasks.firstWhere((t) => t.id == id);
expect(t.owner, 'lead'); // display name from roster
});
test('returns false for an unknown task id', () {
expect(broker.reassignTask('task-999', 'primary'), isFalse);
});
test('sets status to claimed when task was open', () async {
decode(await lead.callTool('task_status', {'title': 'open task'}));
final id = broker.tasks.last.id;
expect(broker.tasks.last.status, 'open');
broker.reassignTask(id, 'teammate:tyre');
expect(broker.tasks.last.status, 'claimed');
});
test('fires the changes stream', () async {
final created = decode(await tyre.callTool('claim_task', {'title': 'fire-stream'}));
final id = (created['task'] as Map)['id'] as String;
final events = <void>[];
final sub = broker.changes.listen((_) => events.add(null));
broker.reassignTask(id, 'primary');
await Future<void>.delayed(Duration.zero); // let the broadcast event deliver
await sub.cancel();
expect(events, hasLength(1));
});
});
group('muted delivery gating (T-171)', () {
test('muted member does not receive stdin delivery', () async {
broker.mute('teammate:tyre');
await lead.callTool('send_message', {'to': 'tyre', 'text': 'quiet'});
expect(delivered, isEmpty);
});
test('muted member still receives the inbox message', () async {
broker.mute('teammate:tyre');
await lead.callTool('send_message', {'to': 'tyre', 'text': 'silent'});
final box = decode(await tyre.callTool('inbox', {}));
expect((box['messages'] as List).single['text'], 'silent');
});
test('unmuting re-enables delivery', () async {
broker.mute('teammate:tyre');
broker.unmute('teammate:tyre');
await lead.callTool('send_message', {'to': 'tyre', 'text': 'back'});
expect(delivered.single.$1, 'teammate:tyre');
});
test('isMuted reflects current state', () {
expect(broker.isMuted('teammate:tyre'), isFalse);
broker.mute('teammate:tyre');
expect(broker.isMuted('teammate:tyre'), isTrue);
broker.unmute('teammate:tyre');
expect(broker.isMuted('teammate:tyre'), isFalse);
});
test('broadcast is also gated for muted members', () async {
broker.mute('teammate:tyre');
await lead.callTool('broadcast', {'text': 'all-hands'});
// tyre is muted → not in delivered; if there are other members they appear
expect(delivered.map((d) => d.$1), isNot(contains('teammate:tyre')));
});
});
}