surface tmux teammates as a tile grid beside the lead (T-140)
test / unit + widget + golden + a11y (push) Failing after 27s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 27s

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 01:30:00 +02:00
co-authored by Claude Opus 4.7
parent 4a59ea4137
commit 24f68e7aaa
5 changed files with 399 additions and 13 deletions
+5
View File
@@ -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
+15 -7
View File
@@ -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<ConversationView> createState() => _ConversationViewState();
}
@@ -77,10 +86,7 @@ class _ConversationViewState extends State<ConversationView> {
);
}
return ColoredBox(
color: tokens.panelBackground,
child: ClideSelectionArea(
child: ClideScrollbar(
final list = ClideScrollbar(
controller: _scroll,
child: ListView.builder(
controller: _scroll,
@@ -88,8 +94,10 @@ class _ConversationViewState extends State<ConversationView> {
itemCount: items.length,
itemBuilder: (context, i) => _ConversationTurn(item: items[i], tokens: tokens),
),
),
),
);
return ColoredBox(
color: tokens.panelBackground,
child: widget.wrapInSelectionArea ? ClideSelectionArea(child: list) : list,
);
}
}
+36 -1
View File
@@ -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<ClaudeSessionHostState> _hostKey = GlobalKey();
TeamObserver? _observer;
final List<StreamSubscription<dynamic>> _subs = [];
@override
List<ContributionPoint> 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<ProjectOpened>().listen((e) => _restartObserver(e.path)));
_subs.add(ctx.events.on<ProjectClosed>().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<void> 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();
+238
View File
@@ -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<TeamPanelHost> createState() => _TeamPanelHostState();
}
class _TeamPanelHostState extends State<TeamPanelHost> {
final List<TeamMemberJoined> _members = [];
final Map<String, ConversationController> _controllers = {};
StreamSubscription<TeamMemberJoined>? _joinSub;
StreamSubscription<TeamMemberLeft>? _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<TeamMemberJoined>().listen(_onJoined);
_leftSub = kernel.events.on<TeamMemberLeft>().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<TeamMemberJoined> members;
final Map<String, ConversationController> 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;
}
}
@@ -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<void> 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<void> 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);
});
});
}