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
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user