From 24f68e7aaa03dfad4066c0069cc2915747444dfe Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 23 May 2026 01:30:00 +0200 Subject: [PATCH] surface tmux teammates as a tile grid beside the lead (T-140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TeamPanelHost wraps the lead Claude surface and, on TeamMemberJoined, shows a resizable right pane with one tile per live teammate in a grid that wraps 1->2->3 columns by count. Each tile renders the teammate's conversation from its per-agent MessageBus channel; tiles drop on TeamMemberLeft. With no team, only the lead shows (unchanged). The Claude extension now starts a TeamObserver for the open workspace (restarting as the project changes) — wiring T-139 into the running app. ConversationView gains a wrapInSelectionArea flag so the whole grid shares one selection area (nested SelectionAreas are illegal), letting a drag-select span tiles. Member colours map to tile accents. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 5 + lib/builtin/claude/src/conversation_view.dart | 32 ++- lib/builtin/claude/src/extension.dart | 37 ++- lib/builtin/claude/src/team_panel_host.dart | 238 ++++++++++++++++++ test/builtin/claude/team_panel_host_test.dart | 100 ++++++++ 5 files changed, 399 insertions(+), 13 deletions(-) create mode 100644 lib/builtin/claude/src/team_panel_host.dart create mode 100644 test/builtin/claude/team_panel_host_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f7c8520..223c6cbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- tmux agent teams surface as native teammate tiles (T-139, T-140) — + when a Claude team is running, each teammate shows as a live + conversation tile beside the lead in a grid that wraps 1→2→3 columns, + with a resizable split. Identity and lifecycle come from the team + config; per-teammate content streams over the MessageBus. - Native composer in the Claude pane (T-138) — type below the conversation and press Enter to send (Shift+Enter for a newline). Submits via the tmux server (bracketed paste + Enter), so input diff --git a/lib/builtin/claude/src/conversation_view.dart b/lib/builtin/claude/src/conversation_view.dart index c63aa0c8..33593b3a 100644 --- a/lib/builtin/claude/src/conversation_view.dart +++ b/lib/builtin/claude/src/conversation_view.dart @@ -19,10 +19,19 @@ import 'package:clide/widgets/widgets.dart'; import 'package:flutter/widgets.dart'; class ConversationView extends StatefulWidget { - const ConversationView({super.key, required this.controller}); + const ConversationView({ + super.key, + required this.controller, + this.wrapInSelectionArea = true, + }); final ConversationController controller; + /// Whether to wrap the list in its own [ClideSelectionArea]. The team + /// grid sets this false and wraps all tiles in one shared area so + /// selection spans tiles — nesting SelectionAreas is illegal (T-140). + final bool wrapInSelectionArea; + @override State createState() => _ConversationViewState(); } @@ -77,19 +86,18 @@ class _ConversationViewState extends State { ); } + final list = ClideScrollbar( + controller: _scroll, + child: ListView.builder( + controller: _scroll, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + itemCount: items.length, + itemBuilder: (context, i) => _ConversationTurn(item: items[i], tokens: tokens), + ), + ); return ColoredBox( color: tokens.panelBackground, - child: ClideSelectionArea( - child: ClideScrollbar( - controller: _scroll, - child: ListView.builder( - controller: _scroll, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - itemCount: items.length, - itemBuilder: (context, i) => _ConversationTurn(item: items[i], tokens: tokens), - ), - ), - ), + child: widget.wrapInSelectionArea ? ClideSelectionArea(child: list) : list, ); } } diff --git a/lib/builtin/claude/src/extension.dart b/lib/builtin/claude/src/extension.dart index 0c497928..8e95de79 100644 --- a/lib/builtin/claude/src/extension.dart +++ b/lib/builtin/claude/src/extension.dart @@ -1,6 +1,10 @@ +import 'dart:async'; + import 'package:clide/clide.dart'; import 'package:clide/builtin/claude/src/claude_session_host.dart'; import 'package:clide/builtin/claude/src/session_naming.dart'; +import 'package:clide/builtin/claude/src/team_observer.dart'; +import 'package:clide/builtin/claude/src/team_panel_host.dart'; import 'package:clide/builtin/claude/src/tmux_session.dart' as tmux; import 'package:clide/extension/extension.dart'; import 'package:clide/kernel/kernel.dart'; @@ -19,6 +23,9 @@ class ClaudeExtension extends ClideExtension { ClideExtensionContext? _ctx; final GlobalKey _hostKey = GlobalKey(); + TeamObserver? _observer; + final List> _subs = []; + @override List get contributions => [ TabContribution( @@ -28,7 +35,7 @@ class ClaudeExtension extends ClideExtension { titleKey: 'tab.title', i18nNamespace: id, priority: 90, - build: (_) => ClaudeSessionHost(key: _hostKey), + build: (_) => TeamPanelHost(lead: ClaudeSessionHost(key: _hostKey)), ), CommandContribution( id: 'claude.new-secondary', @@ -59,10 +66,38 @@ class ClaudeExtension extends ClideExtension { // teardown, not on app quit / kill -9 / OOM. final primary = await _primarySessionName(); if (primary != null) await tmux.reapSecondaries(primary); + + // Observe a tmux agent team for the open workspace (T-139/T-140). The + // observer emits TeamMemberJoined/Left, which TeamPanelHost renders as + // teammate tiles. Restart it as the project changes. + if (ctx.project.current != null) _restartObserver(ctx.project.current!.path); + _subs.add(ctx.events.on().listen((e) => _restartObserver(e.path))); + _subs.add(ctx.events.on().listen((_) => _stopObserver())); + } + + void _restartObserver(String workspacePath) { + final ctx = _ctx; + if (ctx == null) return; + unawaited(_observer?.dispose()); + _observer = TeamObserver( + workspacePath: workspacePath, + events: ctx.events, + messages: ctx.messages, + )..start(); + } + + void _stopObserver() { + unawaited(_observer?.dispose()); + _observer = null; } @override Future deactivate() async { + for (final s in _subs) { + unawaited(s.cancel()); + } + _subs.clear(); + _stopObserver(); // Best-effort cleanup on explicit extension teardown. The cold- // start reap in activate is the actual safety net. final primary = await _primarySessionName(); diff --git a/lib/builtin/claude/src/team_panel_host.dart b/lib/builtin/claude/src/team_panel_host.dart new file mode 100644 index 00000000..5a83fb7d --- /dev/null +++ b/lib/builtin/claude/src/team_panel_host.dart @@ -0,0 +1,238 @@ +/// Faithful tiling for a Claude agent team (epic T-132, T-140). +/// +/// Wraps the lead Claude surface on the left; when the [TeamObserver] +/// emits [TeamMemberJoined], a resizable right pane appears holding a +/// teammate tile per live member, arranged in a responsive grid that +/// wraps 1→2→3 columns by count. Each tile renders the teammate's +/// conversation from its per-agent MessageBus channel (published by the +/// observer). Tiles vanish on [TeamMemberLeft]. With no team, only the +/// lead is shown. +library; + +import 'dart:async'; + +import 'package:clide/builtin/claude/src/conversation_controller.dart'; +import 'package:clide/builtin/claude/src/conversation_view.dart'; +import 'package:clide/builtin/claude/src/transcript_publisher.dart'; +import 'package:clide/kernel/kernel.dart'; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; + +class TeamPanelHost extends StatefulWidget { + const TeamPanelHost({super.key, required this.lead}); + + /// The lead Claude surface (normally the tabbed primary/secondary host). + final Widget lead; + + @override + State createState() => _TeamPanelHostState(); +} + +class _TeamPanelHostState extends State { + final List _members = []; + final Map _controllers = {}; + StreamSubscription? _joinSub; + StreamSubscription? _leftSub; + bool _subscribed = false; + + /// Fraction of the width given to the lead pane. + double _leadFraction = 0.5; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_subscribed) return; + _subscribed = true; + final kernel = ClideKernel.of(context); + _joinSub = kernel.events.on().listen(_onJoined); + _leftSub = kernel.events.on().listen(_onLeft); + } + + void _onJoined(TeamMemberJoined m) { + if (_controllers.containsKey(m.agentId)) return; + final kernel = ClideKernel.of(context); + _controllers[m.agentId] = ConversationController.fromBus( + messages: kernel.messages, + channel: ClaudeConversation.teammateChannel(m.agentId), + ); + setState(() => _members.add(m)); + } + + void _onLeft(TeamMemberLeft m) { + _controllers.remove(m.agentId)?.dispose(); + setState(() => _members.removeWhere((x) => x.agentId == m.agentId)); + } + + @override + void dispose() { + _joinSub?.cancel(); + _leftSub?.cancel(); + for (final c in _controllers.values) { + c.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_members.isEmpty) return widget.lead; + final tokens = ClideTheme.of(context).surface; + + return LayoutBuilder( + builder: (context, constraints) { + final total = constraints.maxWidth; + final leadW = (total * _leadFraction).clamp(240.0, total > 480 ? total - 240 : total); + return Row( + children: [ + SizedBox(width: leadW, child: widget.lead), + _Divider( + color: tokens.panelBorder, + onDrag: (dx) => setState(() { + _leadFraction = ((leadW + dx) / total).clamp(0.2, 0.8); + }), + ), + Expanded( + child: _TeammateGrid( + members: _members, + controllers: _controllers, + tokens: tokens, + ), + ), + ], + ); + }, + ); + } +} + +class _Divider extends StatelessWidget { + const _Divider({required this.color, required this.onDrag}); + final Color color; + final void Function(double dx) onDrag; + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: SystemMouseCursors.resizeColumn, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onHorizontalDragUpdate: (e) => onDrag(e.delta.dx), + child: SizedBox( + width: 7, + child: Center(child: Container(width: 1, color: color)), + ), + ), + ); + } +} + +/// Equal-sized teammate tiles in a grid that wraps 1→2→3 columns by count. +class _TeammateGrid extends StatelessWidget { + const _TeammateGrid({required this.members, required this.controllers, required this.tokens}); + + final List members; + final Map controllers; + final SurfaceTokens tokens; + + @override + Widget build(BuildContext context) { + final cols = members.length >= 3 ? 3 : members.length; + final rows = (members.length / cols).ceil(); + + // One selection area over the whole grid so a drag-select spans tiles. + return ClideSelectionArea( + child: Column( + children: [ + for (var r = 0; r < rows; r++) + Expanded( + child: Row( + children: [ + for (var c = 0; c < cols; c++) Expanded(child: _cell(r * cols + c)), + ], + ), + ), + ], + ), + ); + } + + Widget _cell(int i) { + if (i >= members.length) return const SizedBox.shrink(); + final m = members[i]; + return _TeammateTile(member: m, controller: controllers[m.agentId]!, tokens: tokens); + } +} + +class _TeammateTile extends StatelessWidget { + const _TeammateTile({required this.member, required this.controller, required this.tokens}); + + final TeamMemberJoined member; + final ConversationController controller; + final SurfaceTokens tokens; + + @override + Widget build(BuildContext context) { + final accent = teamColor(member.color, fallback: tokens.globalForeground); + return Container( + margin: const EdgeInsets.all(3), + decoration: BoxDecoration(border: Border.all(color: tokens.panelBorder)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: BoxDecoration( + color: tokens.panelHeader, + border: Border(bottom: BorderSide(color: tokens.panelBorder)), + ), + child: Row( + children: [ + Container(width: 3, height: 13, color: accent), + const SizedBox(width: 6), + ClideText(member.name, fontSize: clideFontSmall, color: accent, fontFamily: clideMonoFamily), + const SizedBox(width: 8), + Expanded( + child: ClideText( + [member.agentType, if (member.model != null) member.model!].join(' · '), + fontSize: clideFontSmall, + muted: true, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + Expanded(child: ConversationView(controller: controller, wrapInSelectionArea: false)), + ], + ), + ); + } +} + +/// Map a Claude Code team member colour name to a display [Color]. These +/// are CC-assigned identity colours (named strings), so they're fixed +/// constants here rather than theme tokens. +Color teamColor(String? name, {required Color fallback}) { + switch (name) { + case 'red': + return const Color(0xFFE06C75); + case 'orange': + return const Color(0xFFD97757); + case 'yellow': + return const Color(0xFFE5C07B); + case 'green': + return const Color(0xFF98C379); + case 'cyan': + return const Color(0xFF56B6C2); + case 'blue': + return const Color(0xFF61AFEF); + case 'magenta': + case 'purple': + return const Color(0xFFC678DD); + case 'pink': + return const Color(0xFFE39EC1); + default: + return fallback; + } +} diff --git a/test/builtin/claude/team_panel_host_test.dart b/test/builtin/claude/team_panel_host_test.dart new file mode 100644 index 00000000..f31a82b9 --- /dev/null +++ b/test/builtin/claude/team_panel_host_test.dart @@ -0,0 +1,100 @@ +/// Widget tests for the teammate tile grid (T-140): tiles appear on +/// TeamMemberJoined, disappear on TeamMemberLeft, and the lead shows +/// alone when there's no team. Events are emitted directly into the +/// fixture's event bus (the observer is exercised separately in +/// team_observer_test.dart). +library; + +import 'package:clide/builtin/claude/src/conversation_view.dart'; +import 'package:clide/builtin/claude/src/team_panel_host.dart'; +import 'package:clide/kernel/kernel.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +Widget _lead() => const Center(child: Text('LEAD', textDirection: TextDirection.ltr)); + +TeamMemberJoined _joined(String name, String pane, {String? color}) => TeamMemberJoined( + team: 'myteam', + agentId: '$name@myteam', + name: name, + agentType: 'researcher', + paneId: pane, + model: 'sonnet', + color: color, + ); + +void main() { + late KernelFixture f; + + setUp(() async => f = await KernelFixture.create()); + tearDown(() => f.dispose()); + + Future pumpHost(WidgetTester tester) async { + tester.view.physicalSize = const Size(1200, 700); + tester.view.devicePixelRatio = 1.0; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + await tester.pumpWidget(harness(f, TeamPanelHost(lead: _lead()))); + } + + Future emit(WidgetTester tester, ClideEvent e) async { + f.services.events.emit(e); + await tester.pump(); + await tester.pump(); + } + + testWidgets('lead shows alone with no team', (tester) async { + await pumpHost(tester); + expect(find.text('LEAD'), findsOneWidget); + expect(find.byType(ConversationView), findsNothing); + }); + + testWidgets('a teammate joining adds a tile; leaving removes it', (tester) async { + await pumpHost(tester); + + await emit(tester, _joined('alice', '%5', color: 'blue')); + expect(find.text('alice'), findsOneWidget); + expect(find.byType(ConversationView), findsOneWidget); + expect(find.text('LEAD'), findsOneWidget); // lead still present beside the grid + + await emit(tester, const TeamMemberLeft(team: 'myteam', agentId: 'alice@myteam', paneId: '%5')); + expect(find.text('alice'), findsNothing); + expect(find.byType(ConversationView), findsNothing); + expect(find.text('LEAD'), findsOneWidget); // back to lead-only + }); + + testWidgets('duplicate join is ignored', (tester) async { + await pumpHost(tester); + await emit(tester, _joined('alice', '%5')); + await emit(tester, _joined('alice', '%5')); + expect(find.text('alice'), findsOneWidget); + }); + + testWidgets('renders a tile per member as the team grows', (tester) async { + await pumpHost(tester); + await emit(tester, _joined('alice', '%5')); + await emit(tester, _joined('bob', '%6')); + await emit(tester, _joined('carol', '%7')); + await emit(tester, _joined('dave', '%8')); + for (final name in ['alice', 'bob', 'carol', 'dave']) { + expect(find.text(name), findsOneWidget); + } + expect(find.byType(ConversationView), findsNWidgets(4)); + }); + + group('teamColor', () { + test('maps known names and falls back', () { + const fallback = Color(0xFF000000); + expect(teamColor('blue', fallback: fallback), const Color(0xFF61AFEF)); + expect(teamColor('orange', fallback: fallback), const Color(0xFFD97757)); + expect(teamColor('purple', fallback: fallback), teamColor('magenta', fallback: fallback)); + expect(teamColor(null, fallback: fallback), fallback); + expect(teamColor('chartreuse', fallback: fallback), fallback); + }); + }); +}