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
|
### 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
|
- Native composer in the Claude pane (T-138) — type below the
|
||||||
conversation and press Enter to send (Shift+Enter for a newline).
|
conversation and press Enter to send (Shift+Enter for a newline).
|
||||||
Submits via the tmux server (bracketed paste + Enter), so input
|
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';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
class ConversationView extends StatefulWidget {
|
class ConversationView extends StatefulWidget {
|
||||||
const ConversationView({super.key, required this.controller});
|
const ConversationView({
|
||||||
|
super.key,
|
||||||
|
required this.controller,
|
||||||
|
this.wrapInSelectionArea = true,
|
||||||
|
});
|
||||||
|
|
||||||
final ConversationController controller;
|
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
|
@override
|
||||||
State<ConversationView> createState() => _ConversationViewState();
|
State<ConversationView> createState() => _ConversationViewState();
|
||||||
}
|
}
|
||||||
@@ -77,19 +86,18 @@ class _ConversationViewState extends State<ConversationView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
return ColoredBox(
|
||||||
color: tokens.panelBackground,
|
color: tokens.panelBackground,
|
||||||
child: ClideSelectionArea(
|
child: widget.wrapInSelectionArea ? ClideSelectionArea(child: list) : list,
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:clide/clide.dart';
|
import 'package:clide/clide.dart';
|
||||||
import 'package:clide/builtin/claude/src/claude_session_host.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/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/builtin/claude/src/tmux_session.dart' as tmux;
|
||||||
import 'package:clide/extension/extension.dart';
|
import 'package:clide/extension/extension.dart';
|
||||||
import 'package:clide/kernel/kernel.dart';
|
import 'package:clide/kernel/kernel.dart';
|
||||||
@@ -19,6 +23,9 @@ class ClaudeExtension extends ClideExtension {
|
|||||||
ClideExtensionContext? _ctx;
|
ClideExtensionContext? _ctx;
|
||||||
final GlobalKey<ClaudeSessionHostState> _hostKey = GlobalKey();
|
final GlobalKey<ClaudeSessionHostState> _hostKey = GlobalKey();
|
||||||
|
|
||||||
|
TeamObserver? _observer;
|
||||||
|
final List<StreamSubscription<dynamic>> _subs = [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<ContributionPoint> get contributions => [
|
List<ContributionPoint> get contributions => [
|
||||||
TabContribution(
|
TabContribution(
|
||||||
@@ -28,7 +35,7 @@ class ClaudeExtension extends ClideExtension {
|
|||||||
titleKey: 'tab.title',
|
titleKey: 'tab.title',
|
||||||
i18nNamespace: id,
|
i18nNamespace: id,
|
||||||
priority: 90,
|
priority: 90,
|
||||||
build: (_) => ClaudeSessionHost(key: _hostKey),
|
build: (_) => TeamPanelHost(lead: ClaudeSessionHost(key: _hostKey)),
|
||||||
),
|
),
|
||||||
CommandContribution(
|
CommandContribution(
|
||||||
id: 'claude.new-secondary',
|
id: 'claude.new-secondary',
|
||||||
@@ -59,10 +66,38 @@ class ClaudeExtension extends ClideExtension {
|
|||||||
// teardown, not on app quit / kill -9 / OOM.
|
// teardown, not on app quit / kill -9 / OOM.
|
||||||
final primary = await _primarySessionName();
|
final primary = await _primarySessionName();
|
||||||
if (primary != null) await tmux.reapSecondaries(primary);
|
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
|
@override
|
||||||
Future<void> deactivate() async {
|
Future<void> deactivate() async {
|
||||||
|
for (final s in _subs) {
|
||||||
|
unawaited(s.cancel());
|
||||||
|
}
|
||||||
|
_subs.clear();
|
||||||
|
_stopObserver();
|
||||||
// Best-effort cleanup on explicit extension teardown. The cold-
|
// Best-effort cleanup on explicit extension teardown. The cold-
|
||||||
// start reap in activate is the actual safety net.
|
// start reap in activate is the actual safety net.
|
||||||
final primary = await _primarySessionName();
|
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