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.broadcast(); final List 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 get lines => _ctl.stream; @override void writeLine(String line) { writes.add(line); onWrite?.call(line); } @override Future 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 = []; 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 = []; 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 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) orchCapturing() { final writes = []; 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)> 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; 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; 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 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 loadedConfig( WidgetTester tester, Directory root, { List<({String name, String dir})> skills = const [], List commands = const [], List agents = const [], Map 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.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 = []; 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 = []; 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 = []; 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(); }); }); }