diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index 2c183d6f..34a3c175 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -2292,3 +2292,5 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-181', 'status', 'backlog', 'in_progress', NULL, '2026-05-30 21:14:56', '2026-05-30 21:14:56', '2026-05-30 21:14:56', NULL, '6426dc0355b68f0fd572e52d53101fc4', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-181', 'status', 'in_progress', 'done', NULL, '2026-05-30 21:25:11', '2026-05-30 21:25:11', '2026-05-30 21:25:11', NULL, '81d8d388c7e4f53a4514f05cf5dbf37b', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-172', 'status', 'backlog', 'in_progress', NULL, '2026-05-30 21:25:12', '2026-05-30 21:25:12', '2026-05-30 21:25:12', NULL, 'e42556ec9d67731533b85e6da2a1bc9c', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-172', 'status', 'in_progress', 'done', NULL, '2026-05-30 21:34:56', '2026-05-30 21:34:56', '2026-05-30 21:34:56', NULL, 'f97c3bb01ea2bc5f969b34d4de09617a', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-180', 'status', 'backlog', 'in_progress', NULL, '2026-05-30 21:34:57', '2026-05-30 21:34:57', '2026-05-30 21:34:57', NULL, 'd73e085a28c3c4b2a7ce515d33012de4', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index 6288cea3..c61e19a5 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -2867,3 +2867,8 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority, --- Refinement (2026-05-30, /whats-next): --fork-session CONFIRMED against the live binary (claude --help: "When resuming, create a new session ID instead of reusing the original (use with --resume or --continue)"). Fork argv = [''--resume'', , ''--fork-session''] (no --session-id; the new branch gets its own id from the init event). Add forkSourceSessionId to SpawnSpec (session_orchestrator.dart); build argv via a forkSessionArgs helper in session_naming.dart; add ''fork'' to kClideOwnedCommands + a case in claude_pane._send; add a fork button to _memberRow in claude_meta_sidebar.dart. SEQUENCE AFTER T-171 (shares _memberRow + orchestrator).', 'in_progress', 'low', NULL, NULL, 'D-77', '2026-05-24 16:27:16', '2026-05-30 21:25:12', NULL, '1aaeba013d95c3d2baa5488f20183775', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-185', 'task', 'T-164', 'Capture the forked session''s real claude session-id from its init event', 'T-172 follow-up. A forked session is spawned with --resume --fork-session and no --session-id, so claude assigns the branch a NEW session-id that only arrives in the stream-json init event. Today ManagedSession.sessionId for a fork holds a placeholder UUID (the orchestrator key), and the real claude session-id is never captured back. Consequence: the fork can''t itself be resumed/forked by id later, and any UI showing the session-id is wrong for forks. Do: capture session_id from the init/system event (StreamJsonSession already parses init for status — surface the session_id) and update the ManagedSession (make sessionId mutable or store a separate claudeSessionId that the orchestrator fills on init). Acceptance: after a fork''s first init event, the ManagedSession exposes the branch''s real claude session-id; a test drives an init event through a fake process and asserts the captured id. Surfaced by T-172; the fork argv/spawn/roster/original-unaffected behaviour already works without it.', 'backlog', 'low', NULL, NULL, 'D-77', '2026-05-30 21:34:26', '2026-05-30 21:34:26', NULL, 'f041094e4dea938f3e00997226e21edf', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-172', 'task', 'T-164', 'P2: fork-into-a-pane (--fork-session)', 'Branch a conversation into a new managed session/pane, reusing the orchestrator''s spawn plumbing. ''Fork'' = spawn a managed session seeded from another session''s context via --resume + --fork-session (resume the source, but create a new session-id so the branch diverges without touching the original). Surface it as a slash action and a sidebar/roster button on a session. Acceptance: forking an active session opens a new pane continuing from its context, independent thereafter; the original is unaffected; tests for the fork argv + the new managed session appearing in the roster. Blocked by T-169 (orchestrator). + +--- +Refinement (2026-05-30, /whats-next): --fork-session CONFIRMED against the live binary (claude --help: "When resuming, create a new session ID instead of reusing the original (use with --resume or --continue)"). Fork argv = [''--resume'', , ''--fork-session''] (no --session-id; the new branch gets its own id from the init event). Add forkSourceSessionId to SpawnSpec (session_orchestrator.dart); build argv via a forkSessionArgs helper in session_naming.dart; add ''fork'' to kClideOwnedCommands + a case in claude_pane._send; add a fork button to _memberRow in claude_meta_sidebar.dart. SEQUENCE AFTER T-171 (shares _memberRow + orchestrator).', 'done', 'low', NULL, NULL, 'D-77', '2026-05-24 16:27:16', '2026-05-30 21:34:56', NULL, '8e8e43c735d6376e3eb1531a4f682b52', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-180', 'task', 'T-164', 'Team message inbox — broker chat (sidebar widget + poppable pane)', 'A team chat / message inbox: render the clide broker traffic as a chat-client timeline and make the user a first-class participant. (1) Timeline: every inter-agent message (send_message/broadcast) shown as chat rows with a colour-coded sender chip (tyre/gestalt/lead/you/...). (2) User as a broker participant: extend the T-170 broker so send_message(to: user) delivers into the inbox, and the team-awareness roster injected into each agent lists "you" as addressable; the user can post into the channel. (3) @-routing from the user: a recipient picker / @name parses to a broker route, @team (or no tag) broadcasts; reuse the slash-typeahead machinery for @-completion. (4) Interrupt tickbox on the composer: unchecked queues the message into the target next turn (normal broker delivery); checked sends an interrupt control_request to the target session (cancels its current turn) then delivers; default OFF. (5) One chat model, two surfaces: a compact sidebar widget in the cockpit (live feed + quick @-post) that pops out into a full chat pane. Wireframes: docs/design/wireframes/claude-prompts/05-team-cockpit-sidebar.png (widget) + 06-team-chat-pane.png (pane). Acceptance: broker messages render as chat; user posts route by @tag (one agent) or broadcast; interrupt tickbox cancels+delivers vs queues; widget and pane share one model; tests for routing + interrupt + render. Resolves the user-participation gaps from the team-mode screen analysis. Blocked by T-170 (broker).', 'in_progress', 'medium', NULL, NULL, 'D-77', '2026-05-25 09:15:58', '2026-05-30 21:34:57', NULL, 'caa8f38a5ae02650d2b3552a00ab796e', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/CHANGELOG.md b/CHANGELOG.md index e45b0872..b8a4f183 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. - Fork a Claude conversation into a new pane — `/fork` (or a roster Fork button / `clide.agent.fork`) branches a session via `--resume … --fork-session`, opening an independent continuation that leaves the original untouched. (T-172, D-77) +- Team chat inbox — broker traffic renders as a chat timeline (colour-coded + sender chips), in a compact cockpit widget that pops out to a full pane. The + user is a first-class participant: post with `@name` routing (or broadcast), + with an interrupt tickbox that cancels the target's turn before delivery. (T-180, D-77) - Team coordination broker (T-170, D-77) — clide hosts an in-process MCP server (`clide-team`) for managed sessions over the stream-json control channel, giving agents tools to message each other, broadcast, see the diff --git a/lib/builtin/claude/src/at_commands.dart b/lib/builtin/claude/src/at_commands.dart new file mode 100644 index 00000000..def54713 --- /dev/null +++ b/lib/builtin/claude/src/at_commands.dart @@ -0,0 +1,91 @@ +/// Pure helpers for `@`-name completion in the team chat composer (T-180). +/// +/// Mirrors the slash_commands.dart API so the composer can use the same +/// LayerLink + OverlayEntry overlay pattern for both typeaheads. +/// Flutter-free — cheap to unit-test. +library; + +bool _isWs(String c) => c == ' ' || c == '\t' || c == '\n'; + +/// An in-progress `@name` query at the cursor — the `@` position and the +/// word typed after it so far. +class AtQuery { + const AtQuery({required this.start, required this.query}); + + /// Index of the `@` in the text. + final int start; + + /// Text between the `@` and the cursor (no leading `@`, no whitespace). + final String query; + + @override + bool operator ==(Object other) => other is AtQuery && other.start == start && other.query == query; + + @override + int get hashCode => Object.hash(start, query); +} + +/// The `@` query at [cursor] in [text], or null when the cursor isn't inside +/// an `@` token. Matches `@name` at the start of the text or right after +/// whitespace; does NOT match mid-word `@` (e.g. an email address). +AtQuery? activeAtQuery(String text, int cursor) { + if (cursor < 0 || cursor > text.length) return null; + var start = cursor; + while (start > 0 && !_isWs(text[start - 1])) { + start--; + } + if (start >= cursor) return null; // empty run + if (text[start] != '@') return null; // run doesn't start with @ + return AtQuery(start: start, query: text.substring(start + 1, cursor)); +} + +/// Member names matching [query] (case-insensitive prefix), de-duplicated, +/// sorted, capped at [limit]. Always includes `team` (broadcast alias) as the +/// first entry when the query is empty or matches. +/// +/// [names] should be the broker's member display names (including `user`); +/// pass them all — this helper will NOT filter out `user` because the +/// composer may legitimately let an agent @-reply to the user. +List filterAtNames(String query, Iterable names, {int limit = 8}) { + const broadcast = 'team'; + final q = query.toLowerCase(); + final seen = {}; + final matches = []; + // Broadcast alias first. + if (broadcast.startsWith(q) && seen.add(broadcast)) matches.add(broadcast); + for (final n in names) { + if (n.toLowerCase().startsWith(q) && seen.add(n)) matches.add(n); + } + matches.sort((a, b) { + // Keep `team` pinned first when it's present. + if (a == broadcast) return -1; + if (b == broadcast) return 1; + return a.compareTo(b); + }); + return matches.length > limit ? matches.sublist(0, limit) : matches; +} + +/// Replace the `@` token described by [q] in [text] with `@ `, +/// returning the new text and the cursor offset just past the inserted space. +({String text, int cursor}) completeAt(String text, AtQuery q, String name) { + final insert = '@$name '; + final end = q.start + 1 + q.query.length; + return (text: text.replaceRange(q.start, end, insert), cursor: q.start + insert.length); +} + +/// Parse a leading `@name` tag from the start of [text] (trimmed). Returns +/// the recipient name and the remaining body, or `(null, text)` when there +/// is no tag. `@team` resolves to null (broadcast). +({String? recipient, String body}) parseAtTag(String text) { + final trimmed = text.trimLeft(); + if (!trimmed.startsWith('@')) return (recipient: null, body: trimmed); + final ws = trimmed.indexOf(RegExp(r'\s')); + if (ws < 0) { + // Entire text is just the tag — no body. + final tag = trimmed.substring(1); + return (recipient: tag == 'team' ? null : tag, body: ''); + } + final tag = trimmed.substring(1, ws); + final body = trimmed.substring(ws).trimLeft(); + return (recipient: tag == 'team' ? null : tag, body: body); +} diff --git a/lib/builtin/claude/src/claude_meta_sidebar.dart b/lib/builtin/claude/src/claude_meta_sidebar.dart index 97871531..b48da93a 100644 --- a/lib/builtin/claude/src/claude_meta_sidebar.dart +++ b/lib/builtin/claude/src/claude_meta_sidebar.dart @@ -29,6 +29,7 @@ import 'package:clide/builtin/claude/src/claude_stats.dart'; import 'package:clide/builtin/claude/src/claude_status.dart' show formatTokenCount, permissionModeLabel, shortModelLabel; import 'package:clide/builtin/claude/src/session_orchestrator.dart'; import 'package:clide/builtin/claude/src/team_broker.dart' show TeamBroker, TeamTask; +import 'package:clide/builtin/claude/src/team_chat_sidebar.dart' show TeamChatSidebar; import 'package:clide/builtin/claude/src/team_panel_host.dart' show teamColor; import 'package:clide/builtin/claude/src/transcript_publisher.dart' show ClaudeConversation; import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus; @@ -348,10 +349,17 @@ class _ClaudeMetaSidebarState extends State { children.add(_taskSection(tokens)); } - // MESSAGES section: placeholder seam for T-180 to fill. - // T-180 will replace this Container with the live message feed. - children.add(const SizedBox(height: 12)); - children.add(_messagesSectionPlaceholder(tokens)); + // MESSAGES section (T-180): live broker chat feed + quick-post composer. + final chatModel = _orchestrator?.chatModel; + final broker = _orchestrator?.broker; + if (chatModel != null && broker != null) { + children.add(const SizedBox(height: 12)); + children.add(TeamChatSidebar( + model: chatModel, + broker: broker, + onPopOut: _openChatPane, + )); + } return ListView( padding: const EdgeInsets.all(12), @@ -370,9 +378,9 @@ class _ClaudeMetaSidebarState extends State { ); } - /// Minimal seam for T-180 — the message feed and composer will land here. - Widget _messagesSectionPlaceholder(SurfaceTokens tokens) { - return ClideText('MESSAGES', fontSize: clideFontSmall, color: tokens.globalTextMuted); + /// Open the full team chat pane in the workspace (T-180). + void _openChatPane() { + ClideKernel.of(context).panels.activateTab(Slots.workspace, 'claude.team-chat'); } // --- Config --------------------------------------------------------------- diff --git a/lib/builtin/claude/src/extension.dart b/lib/builtin/claude/src/extension.dart index 3e5a0270..d439a19a 100644 --- a/lib/builtin/claude/src/extension.dart +++ b/lib/builtin/claude/src/extension.dart @@ -9,6 +9,7 @@ import 'package:clide/builtin/claude/src/pane_context_status.dart'; import 'package:clide/builtin/claude/src/claude_meta_sidebar.dart'; import 'package:clide/builtin/claude/src/session_index.dart'; import 'package:clide/builtin/claude/src/session_storage.dart'; +import 'package:clide/builtin/claude/src/team_chat_sidebar.dart' show TeamChatPane; import 'package:clide/builtin/claude/src/team_panel_host.dart'; import 'package:clide/extension/extension.dart'; import 'package:clide/kernel/kernel.dart'; @@ -174,6 +175,56 @@ class ClaudeExtension extends ClideExtension { return IpcResponse.ok(id: '', data: {'taskId': taskId, 'toId': toId, 'ok': ok}); }, ), + // T-180: full team chat pane opened as a workspace tab. + // Shares the TeamChatModel with the sidebar widget. + TabContribution( + id: 'claude.team-chat', + slot: Slots.workspace, + title: 'Team Chat', + titleKey: 'tab.title', + i18nNamespace: id, + priority: 85, + build: (_) { + final orch = _orchestrator; + if (orch == null) return const SizedBox.shrink(); + return TeamChatPane(model: orch.chatModel, broker: orch.broker); + }, + ), + // CLI parity: open the team chat pane from the shell. + // Usage: clide claude.team-chat.open + CommandContribution( + id: 'claude.team-chat.open', + command: 'claude.team-chat.open', + title: 'Claude: open the team chat pane', + run: (args) async { + _ctx?.panels.activateTab(Slots.workspace, 'claude.team-chat'); + return IpcResponse.ok(id: '', data: const {'status': 'opened'}); + }, + ), + // Usage: clide claude.team-chat.post [@name] + // Posts a message into the broker channel as the user. + // Leading @name tag selects the recipient; omit for broadcast. + CommandContribution( + id: 'claude.team-chat.post', + command: 'claude.team-chat.post', + title: 'Claude: post a message into the team channel as the user', + run: (args) async { + if (args.isEmpty) return IpcResponse.ok(id: '', data: const {'error': 'usage: [@name] '}); + final raw = args.join(' '); + String? recipient; + String body = raw; + if (raw.startsWith('@')) { + final ws = raw.indexOf(RegExp(r'\s')); + if (ws > 0) { + final tag = raw.substring(1, ws); + recipient = (tag == 'team' || tag.isEmpty) ? null : tag; + body = raw.substring(ws).trim(); + } + } + _orchestrator?.chatModel.postAsUser(body, toName: recipient); + return IpcResponse.ok(id: '', data: {'status': 'posted', if (recipient != null) 'to': recipient}); + }, + ), // claude.agent.fork: branch a managed session into a new fork session // (T-172, D-6 CLI/UI parity for the roster fork button). // Usage: clide claude.agent.fork [] diff --git a/lib/builtin/claude/src/session_orchestrator.dart b/lib/builtin/claude/src/session_orchestrator.dart index f5173127..acae534f 100644 --- a/lib/builtin/claude/src/session_orchestrator.dart +++ b/lib/builtin/claude/src/session_orchestrator.dart @@ -19,6 +19,7 @@ import 'package:clide/builtin/claude/src/conversation_controller.dart'; import 'package:clide/builtin/claude/src/session_naming.dart'; import 'package:clide/builtin/claude/src/stream_json_session.dart'; import 'package:clide/builtin/claude/src/team_broker.dart'; +import 'package:clide/builtin/claude/src/team_chat_model.dart'; import 'package:clide/builtin/claude/src/transcript_reader.dart'; import 'package:flutter/foundation.dart'; @@ -146,7 +147,12 @@ class ManagedSession { ClaudeSessionOrchestrator? activeSessionOrchestrator; class ClaudeSessionOrchestrator extends ChangeNotifier { - ClaudeSessionOrchestrator({ProcessFactory? processFactory}) : _factory = processFactory ?? _spawnClaude; + ClaudeSessionOrchestrator({ProcessFactory? processFactory}) : _factory = processFactory ?? _spawnClaude { + _chatModel = TeamChatModel( + broker: broker, + sessionResolver: (name) => byMemberName(name)?.session, + ); + } final ProcessFactory _factory; final _sessions = {}; @@ -156,6 +162,13 @@ class ClaudeSessionOrchestrator extends ChangeNotifier { /// session's next turn (T-170). late final TeamBroker broker = TeamBroker(deliver: _deliverToSession); + /// The shared chat timeline and user-post logic (T-180). Both the compact + /// sidebar widget and the full workspace pane read from this model. + late final TeamChatModel _chatModel; + + /// Exposes the shared chat model to widgets and panes. + TeamChatModel get chatModel => _chatModel; + void _deliverToSession(String toId, String text) => _sessions[toId]?.session.send(text); static Future _spawnClaude({required List sessionArgs, required String cwd, Map? env}) => @@ -328,6 +341,8 @@ class ClaudeSessionOrchestrator extends ChangeNotifier { m.conversation.dispose(); } _sessions.clear(); + _chatModel.dispose(); + broker.dispose(); super.dispose(); } } diff --git a/lib/builtin/claude/src/team_broker.dart b/lib/builtin/claude/src/team_broker.dart index 1171d958..c5a7fc8e 100644 --- a/lib/builtin/claude/src/team_broker.dart +++ b/lib/builtin/claude/src/team_broker.dart @@ -35,14 +35,26 @@ class TeamMemberRef { /// A message left for a member, in arrival order. class TeamMessage { - const TeamMessage({required this.from, required this.text, required this.at, this.broadcast = false}); + const TeamMessage({ + required this.from, + required this.text, + required this.at, + this.to, + this.broadcast = false, + }); final String from; + + /// Recipient name: a single member's display name (direct message), `null` + /// for a broadcast (every member), or the special value `'user'` when the + /// broker surfaces the message to the chat model rather than a session. + final String? to; final String text; final DateTime at; final bool broadcast; Map toJson() => { 'from': from, + if (to != null) 'to': to, 'text': text, 'at': at.toIso8601String(), if (broadcast) 'broadcast': true, @@ -78,7 +90,12 @@ typedef MessageDelivery = void Function(String toMemberId, String text); /// broadcast [StreamController]; consumers must not assume it fires on the /// Flutter event loop. class TeamBroker { - TeamBroker({MessageDelivery? deliver}) : _deliver = deliver; + TeamBroker({MessageDelivery? deliver}) : _deliver = deliver { + // The user is always a virtual team participant — agents can address them + // by name; messages routed to `user` surface in the chat model only (no + // stdin delivery). Registered at construction so the roster is consistent. + addMember(const TeamMemberRef(id: 'user', name: 'user', role: 'user')); + } final MessageDelivery? _deliver; final _members = {}; @@ -90,11 +107,17 @@ class TeamBroker { // --- Observability --------------------------------------------------------- final _changeCtl = StreamController.broadcast(); + final _messageCtl = StreamController.broadcast(); /// Fires a void event whenever the task list or message state mutates. /// Broadcast — multiple listeners are supported. Flutter-free. Stream get changes => _changeCtl.stream; + /// Every inter-agent message (send_message / broadcast) in arrival order. + /// Also includes messages directed `to: 'user'` so the chat model can surface + /// them. Broadcast — multiple listeners are supported. Flutter-free. + Stream get messages => _messageCtl.stream; + void _notify() { if (!_changeCtl.isClosed) _changeCtl.add(null); } @@ -161,9 +184,10 @@ class TeamBroker { return true; } - /// Dispose — closes the [changes] stream controller. + /// Dispose — closes the [changes] and [messages] stream controllers. void dispose() { _changeCtl.close(); + _messageCtl.close(); } /// All members in registration order. @@ -181,13 +205,34 @@ class TeamBroker { // --- Tool operations (scoped to the caller [fromId]) --------------------- + /// Post [text] from the human user into the channel. When [to] is null or + /// omitted the message is broadcast; otherwise it is delivered only to the + /// named member. The `user` member is the caller's virtual id — it is + /// excluded from the recipient list in the same way senders are excluded + /// from their own broadcasts. + void sendAsUser(String text, {String? to}) { + if (to == null || to.isEmpty || to == 'team') { + // Broadcast: deliver to every non-user member. + // emitToStream=false: the chat model already recorded the local entry. + for (final m in _members.values) { + if (m.id == 'user') continue; + _enqueue(m.id, TeamMessage(from: 'user', to: null, text: text, at: DateTime.now(), broadcast: true), broadcast: true, emitToStream: false); + } + } else { + // Directed: deliver to the named member. + final target = _byName(to); + if (target == null || target.id == 'user') return; + _enqueue(target.id, TeamMessage(from: 'user', to: target.name, text: text, at: DateTime.now()), emitToStream: false); + } + } + /// Deliver [text] to the single member named [toName]. Map sendMessage(String fromId, String toName, String text) { final target = _byName(toName); if (target == null) { return {'ok': false, 'error': 'No teammate named "$toName". Use list_teammates to see who is on the team.'}; } - _enqueue(target.id, TeamMessage(from: _nameOf(fromId), text: text, at: DateTime.now())); + _enqueue(target.id, TeamMessage(from: _nameOf(fromId), to: target.name, text: text, at: DateTime.now())); return {'ok': true, 'to': target.name}; } @@ -197,7 +242,7 @@ class TeamBroker { final recipients = []; for (final m in _members.values) { if (m.id == fromId) continue; - _enqueue(m.id, TeamMessage(from: fromName, text: text, at: DateTime.now(), broadcast: true), broadcast: true); + _enqueue(m.id, TeamMessage(from: fromName, to: null, text: text, at: DateTime.now(), broadcast: true), broadcast: true); recipients.add(m.name); } return {'ok': true, 'recipients': recipients}; @@ -263,8 +308,20 @@ class TeamBroker { }; } - void _enqueue(String toId, TeamMessage msg, {bool broadcast = false}) { + void _enqueue(String toId, TeamMessage msg, {bool broadcast = false, bool emitToStream = true}) { (_inboxes[toId] ??= []).add(msg); + // Emit every message to the chat model stream before (possibly) delivering + // to the session stdin. The `user` member is a virtual participant — it has + // no session stdin, so delivery is skipped for it. + // + // [emitToStream] is false for user-originated messages that [TeamChatModel] + // already recorded locally — avoids double-adding them to the timeline. + if (emitToStream && !_messageCtl.isClosed) _messageCtl.add(msg); + if (toId == 'user') { + // User member: surfaced in the chat model only, no stdin delivery. + _notify(); + return; + } final tag = broadcast ? '${msg.from} (broadcast)' : msg.from; // Gate delivery: muted members still accumulate inbox messages but the // live session callback is suppressed until unmuted (T-171). diff --git a/lib/builtin/claude/src/team_chat_model.dart b/lib/builtin/claude/src/team_chat_model.dart new file mode 100644 index 00000000..8df1dd81 --- /dev/null +++ b/lib/builtin/claude/src/team_chat_model.dart @@ -0,0 +1,104 @@ +/// The shared chat model for team broker traffic (T-180). +/// +/// Subscribes to the broker's [TeamBroker.messages] stream and keeps an +/// append-only timeline of [TeamMessage]s. Both the compact sidebar widget +/// and the full workspace pane read from this one model — they share state, +/// they do NOT each hold their own copy. +/// +/// [postAsUser] is the user's write path: it routes by @tag (one agent or +/// broadcast) and, when the interrupt flag is set, calls [interrupt()] on the +/// target session THEN delivers the message. +/// +/// Flutter-free on purpose: this module (like [TeamBroker]) runs under +/// `dart test`. Use [dart:async] Stream/StreamController for observability; +/// do NOT use [ChangeNotifier]. +library; + +import 'dart:async'; + +import 'package:clide/builtin/claude/src/team_broker.dart'; +import 'package:clide/builtin/claude/src/stream_json_session.dart'; + +/// Resolves a session by orchestrator member name — injected by the +/// orchestrator so the model doesn't depend on the Flutter-coupled +/// [ClaudeSessionOrchestrator] type directly. +typedef SessionResolver = StreamJsonSession? Function(String memberName); + +/// The shared timeline + user-post logic for team broker chat (T-180). +/// +/// Lifetime matches the orchestrator: created once, subscribed to the broker, +/// disposed when the orchestrator is torn down. +class TeamChatModel { + TeamChatModel({ + required TeamBroker broker, + SessionResolver? sessionResolver, + }) : _broker = broker, + _sessionResolver = sessionResolver { + _sub = broker.messages.listen(_onMessage); + } + + final TeamBroker _broker; + final SessionResolver? _sessionResolver; + late final StreamSubscription _sub; + + final _messages = []; + + final _changeCtl = StreamController.broadcast(); + + /// All broker messages in arrival order. Unmodifiable snapshot; new messages + /// are signalled via [changes]. + List get messages => List.unmodifiable(_messages); + + /// Fires a void event whenever a new message is appended. Broadcast — + /// multiple listeners are supported. Flutter-free. + Stream get changes => _changeCtl.stream; + + void _onMessage(TeamMessage msg) { + _messages.add(msg); + if (!_changeCtl.isClosed) _changeCtl.add(null); + } + + // --------------------------------------------------------------------------- + // User post path + // --------------------------------------------------------------------------- + + /// Post [text] as the user into the broker channel. + /// + /// - [toName] `null` or `'team'` → broadcast to all agents. + /// - [toName] a specific member name → `send_message(to: toName)`. + /// - [interrupt] `true` → call [StreamJsonSession.interrupt] on the target + /// session first (cancels its current turn), then deliver. Default false. + /// + /// The message is also appended to the local timeline immediately so the + /// user sees it without waiting for the broker echo. + void postAsUser(String text, {String? toName, bool interrupt = false}) { + final from = 'user'; + final isTeam = toName == null || toName.isEmpty || toName == 'team'; + + if (interrupt && !isTeam) { + // isTeam is false only when toName is a non-null, non-empty, non-'team' + // string, so Dart's flow analysis promotes it to non-null here. + _sessionResolver?.call(toName)?.interrupt(); + } + + if (isTeam) { + // Broadcast: create a local record and deliver via the broker. + final msg = TeamMessage(from: from, to: null, text: text, at: DateTime.now(), broadcast: true); + _messages.add(msg); + if (!_changeCtl.isClosed) _changeCtl.add(null); + _broker.sendAsUser(text); + } else { + // Directed message. + final msg = TeamMessage(from: from, to: toName, text: text, at: DateTime.now()); + _messages.add(msg); + if (!_changeCtl.isClosed) _changeCtl.add(null); + _broker.sendAsUser(text, to: toName); + } + } + + /// Dispose — cancels the broker subscription and closes the changes stream. + void dispose() { + _sub.cancel(); + _changeCtl.close(); + } +} diff --git a/lib/builtin/claude/src/team_chat_sidebar.dart b/lib/builtin/claude/src/team_chat_sidebar.dart new file mode 100644 index 00000000..72a63f3d --- /dev/null +++ b/lib/builtin/claude/src/team_chat_sidebar.dart @@ -0,0 +1,636 @@ +/// Compact MESSAGES section for the Team cockpit sidebar (T-180, part 5). +/// +/// Displays the live broker chat timeline as colour-coded rows and provides a +/// quick @-post composer. Tapping the pop-out icon opens the full chat pane +/// ([claude.team-chat] workspace tab). +/// +/// Both this widget and [TeamChatPane] read from the same [TeamChatModel] — +/// there is one model, two surfaces. +library; + +import 'dart:async'; + +import 'package:clide/builtin/claude/src/at_commands.dart'; +import 'package:clide/builtin/claude/src/team_broker.dart' show TeamBroker, TeamMessage; +import 'package:clide/builtin/claude/src/team_chat_model.dart'; +import 'package:clide/builtin/claude/src/team_panel_host.dart' show teamColor; +import 'package:clide/kernel/kernel.dart'; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/services.dart' show KeyDownEvent, LogicalKeyboardKey; +import 'package:flutter/widgets.dart'; + +/// Compact broker chat section embedded in the Team sidebar. +/// +/// [model] is the shared [TeamChatModel] from the orchestrator. +/// [broker] is used to read the current roster for @-completion. +/// [onPopOut] is called when the user taps the pop-out icon to open the full +/// pane — the extension wires this to `panels.activateTab`. +class TeamChatSidebar extends StatefulWidget { + const TeamChatSidebar({ + super.key, + required this.model, + required this.broker, + required this.onPopOut, + }); + + final TeamChatModel model; + final TeamBroker broker; + final VoidCallback onPopOut; + + @override + State createState() => _TeamChatSidebarState(); +} + +class _TeamChatSidebarState extends State { + StreamSubscription? _sub; + final _controller = TextEditingController(); + final _focusNode = FocusNode(debugLabel: 'team-chat-sidebar'); + final _layerLink = LayerLink(); + OverlayEntry? _overlay; + List _suggestions = const []; + AtQuery? _activeQuery; + + @override + void initState() { + super.initState(); + _sub = widget.model.changes.listen((_) { + if (mounted) setState(() {}); + }); + _controller.addListener(_onTextChanged); + } + + @override + void dispose() { + _sub?.cancel(); + _removeOverlay(); + _controller.removeListener(_onTextChanged); + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _onTextChanged() { + final text = _controller.text; + final cursor = _controller.selection.baseOffset; + if (cursor < 0) { + _updateSuggestions(null); + return; + } + final q = activeAtQuery(text, cursor); + if (q == null) { + _updateSuggestions(null); + return; + } + final names = widget.broker.members.map((m) => m.name).where((n) => n != 'user'); + final matches = filterAtNames(q.query, names); + _updateSuggestions(matches.isEmpty ? null : matches, query: q); + } + + void _updateSuggestions(List? suggestions, {AtQuery? query}) { + final newSuggestions = suggestions ?? const []; + if (newSuggestions == _suggestions && query == _activeQuery) return; + setState(() { + _suggestions = newSuggestions; + _activeQuery = query; + }); + if (newSuggestions.isEmpty) { + _removeOverlay(); + } else { + _showOverlay(); + } + } + + void _showOverlay() { + _removeOverlay(); + final entry = OverlayEntry( + builder: (_) => _AtOverlay( + layerLink: _layerLink, + suggestions: _suggestions, + onSelect: _completeName, + )); + _overlay = entry; + Overlay.of(context).insert(entry); + } + + void _removeOverlay() { + _overlay?.remove(); + _overlay = null; + } + + void _completeName(String name) { + final q = _activeQuery; + if (q == null) return; + final result = completeAt(_controller.text, q, name); + _controller.value = TextEditingValue( + text: result.text, + selection: TextSelection.collapsed(offset: result.cursor), + ); + _removeOverlay(); + setState(() { + _suggestions = const []; + _activeQuery = null; + }); + } + + void _submit(String raw) { + final text = raw.trim(); + if (text.isEmpty) return; + final parsed = parseAtTag(text); + widget.model.postAsUser(parsed.body.isEmpty ? text : parsed.body, toName: parsed.recipient); + _controller.clear(); + _removeOverlay(); + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) { + _removeOverlay(); + setState(() { + _suggestions = const []; + _activeQuery = null; + }); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + final messages = widget.model.messages; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Section header row with pop-out icon. + Row( + children: [ + Expanded( + child: ClideText('MESSAGES', fontSize: clideFontSmall, color: tokens.globalTextMuted), + ), + Semantics( + button: true, + label: 'Open full chat pane', + excludeSemantics: true, + onTap: widget.onPopOut, + child: ClideTappable( + tooltip: 'Open full chat', + onTap: widget.onPopOut, + builder: (ctx, hovered, _) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 1), + child: ClideIcon( + PhosphorIcons.arrowsOutSimple, + size: 10, + color: hovered ? tokens.globalForeground : tokens.globalTextMuted, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 4), + // Last 5 messages (compact feed). + if (messages.isEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: ClideText('No messages yet.', muted: true, fontSize: clideFontSmall), + ) + else + for (final msg in messages.length > 5 ? messages.sublist(messages.length - 5) : messages) + _ChatRow(key: ValueKey(msg.at.microsecondsSinceEpoch), message: msg, tokens: tokens), + const SizedBox(height: 6), + // Quick-post composer. + CompositedTransformTarget( + link: _layerLink, + child: Focus( + onKeyEvent: _handleKeyEvent, + child: _ChatInputField( + controller: _controller, + focusNode: _focusNode, + tokens: tokens, + onSubmit: _submit, + placeholder: '@name or @team …', + ), + ), + ), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// Full team chat pane (workspace tab) +// --------------------------------------------------------------------------- + +/// Full-height broker chat pane opened as a workspace tab (T-180). +/// +/// Reads from the same [TeamChatModel] as [TeamChatSidebar]. Supports the +/// interrupt tickbox and full @-completion. +class TeamChatPane extends StatefulWidget { + const TeamChatPane({ + super.key, + required this.model, + required this.broker, + }); + + final TeamChatModel model; + final TeamBroker broker; + + @override + State createState() => _TeamChatPaneState(); +} + +class _TeamChatPaneState extends State { + StreamSubscription? _sub; + final _controller = TextEditingController(); + final _focusNode = FocusNode(debugLabel: 'team-chat-pane'); + final _layerLink = LayerLink(); + final _scrollController = ScrollController(); + OverlayEntry? _overlay; + List _suggestions = const []; + AtQuery? _activeQuery; + bool _interrupt = false; + + @override + void initState() { + super.initState(); + _sub = widget.model.changes.listen((_) { + if (mounted) { + setState(() {}); + // Scroll to bottom on new message. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + ); + } + }); + } + }); + _controller.addListener(_onTextChanged); + } + + @override + void dispose() { + _sub?.cancel(); + _removeOverlay(); + _controller.removeListener(_onTextChanged); + _controller.dispose(); + _focusNode.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + void _onTextChanged() { + final text = _controller.text; + final cursor = _controller.selection.baseOffset; + if (cursor < 0) { + _updateSuggestions(null); + return; + } + final q = activeAtQuery(text, cursor); + if (q == null) { + _updateSuggestions(null); + return; + } + final names = widget.broker.members.map((m) => m.name).where((n) => n != 'user'); + final matches = filterAtNames(q.query, names); + _updateSuggestions(matches.isEmpty ? null : matches, query: q); + } + + void _updateSuggestions(List? suggestions, {AtQuery? query}) { + final newSuggestions = suggestions ?? const []; + if (newSuggestions == _suggestions && query == _activeQuery) return; + setState(() { + _suggestions = newSuggestions; + _activeQuery = query; + }); + if (newSuggestions.isEmpty) { + _removeOverlay(); + } else { + _showOverlay(); + } + } + + void _showOverlay() { + _removeOverlay(); + final entry = OverlayEntry( + builder: (_) => _AtOverlay( + layerLink: _layerLink, + suggestions: _suggestions, + onSelect: _completeName, + )); + _overlay = entry; + Overlay.of(context).insert(entry); + } + + void _removeOverlay() { + _overlay?.remove(); + _overlay = null; + } + + void _completeName(String name) { + final q = _activeQuery; + if (q == null) return; + final result = completeAt(_controller.text, q, name); + _controller.value = TextEditingValue( + text: result.text, + selection: TextSelection.collapsed(offset: result.cursor), + ); + _removeOverlay(); + setState(() { + _suggestions = const []; + _activeQuery = null; + }); + } + + void _submit(String raw) { + final text = raw.trim(); + if (text.isEmpty) return; + final parsed = parseAtTag(text); + widget.model.postAsUser( + parsed.body.isEmpty ? text : parsed.body, + toName: parsed.recipient, + interrupt: _interrupt, + ); + _controller.clear(); + _removeOverlay(); + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) { + _removeOverlay(); + setState(() { + _suggestions = const []; + _activeQuery = null; + }); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + final messages = widget.model.messages; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Pane header. + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: tokens.panelBorder)), + ), + child: ClideText('Team Chat', fontSize: clideFontSmall, color: tokens.globalTextMuted), + ), + // Timeline. + Expanded( + child: messages.isEmpty + ? Center(child: ClideText('No messages yet.', muted: true, fontSize: clideFontSmall)) + : ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + itemCount: messages.length, + itemBuilder: (_, i) => _ChatRow( + key: ValueKey(messages[i].at.microsecondsSinceEpoch), + message: messages[i], + tokens: tokens, + ), + ), + ), + // Composer + interrupt tickbox. + Container( + padding: const EdgeInsets.fromLTRB(12, 6, 12, 8), + decoration: BoxDecoration( + border: Border(top: BorderSide(color: tokens.panelBorder)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Interrupt tickbox. + GestureDetector( + onTap: () => setState(() => _interrupt = !_interrupt), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Semantics( + checked: _interrupt, + label: 'Interrupt target session', + excludeSemantics: true, + onTap: () => setState(() => _interrupt = !_interrupt), + child: Container( + width: 12, + height: 12, + margin: const EdgeInsets.only(right: 5), + decoration: BoxDecoration( + color: _interrupt ? tokens.globalFocus.withAlpha(40) : const Color(0x00000000), + border: Border.all( + color: _interrupt ? tokens.globalFocus : tokens.globalTextMuted, + width: 1, + ), + borderRadius: BorderRadius.circular(2), + ), + child: _interrupt + ? Center( + child: ClideIcon(PhosphorIcons.check, size: 9, color: tokens.globalFocus), + ) + : null, + ), + ), + ClideText( + 'Interrupt', + fontSize: clideFontSmall, + color: _interrupt ? tokens.globalForeground : tokens.globalTextMuted, + ), + ], + ), + ), + ), + const SizedBox(height: 4), + // Input field. + CompositedTransformTarget( + link: _layerLink, + child: Focus( + onKeyEvent: _handleKeyEvent, + child: _ChatInputField( + controller: _controller, + focusNode: _focusNode, + tokens: tokens, + onSubmit: _submit, + placeholder: '@name or @team …', + ), + ), + ), + ], + ), + ), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// Shared sub-widgets +// --------------------------------------------------------------------------- + +/// One chat row: colour-coded sender chip + optional `to` label + message text. +class _ChatRow extends StatelessWidget { + const _ChatRow({super.key, required this.message, required this.tokens}); + + final TeamMessage message; + final SurfaceTokens tokens; + + @override + Widget build(BuildContext context) { + final senderColor = _senderColor(message.from, tokens); + final toLabel = message.broadcast + ? '→ all' + : message.to != null + ? '→ ${message.to}' + : null; + + return Padding( + padding: const EdgeInsets.only(bottom: 3), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Colour-coded sender chip. + Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + margin: const EdgeInsets.only(right: 5, top: 1), + decoration: BoxDecoration( + color: senderColor.withAlpha(30), + borderRadius: BorderRadius.circular(2), + ), + child: ClideText(message.from, fontSize: clideFontSmall, color: senderColor), + ), + if (toLabel != null) + Padding( + padding: const EdgeInsets.only(right: 5, top: 1), + child: ClideText(toLabel, fontSize: clideFontSmall, color: tokens.globalTextMuted), + ), + Expanded( + child: ClideText(message.text, fontSize: clideFontSmall, color: tokens.globalForeground), + ), + ], + ), + ); + } + + Color _senderColor(String from, SurfaceTokens tokens) { + // User is always the focus colour. + if (from == 'user') return tokens.globalFocus; + // Agents use teamColor by name (same logic as the roster dot). + return teamColor(from.toLowerCase(), fallback: tokens.globalForeground); + } +} + +/// Inline text input for the chat composer. +class _ChatInputField extends StatelessWidget { + const _ChatInputField({ + required this.controller, + required this.focusNode, + required this.tokens, + required this.onSubmit, + required this.placeholder, + }); + + final TextEditingController controller; + final FocusNode focusNode; + final SurfaceTokens tokens; + final void Function(String text) onSubmit; + final String placeholder; + + @override + Widget build(BuildContext context) { + return Container( + height: 24, + padding: const EdgeInsets.symmetric(horizontal: 6), + decoration: BoxDecoration( + color: tokens.panelBackground, + border: Border.all(color: tokens.panelBorder), + borderRadius: BorderRadius.circular(3), + ), + child: EditableText( + controller: controller, + focusNode: focusNode, + style: TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: clideFontSmall, + color: tokens.globalForeground, + height: 1.4, + ), + cursorColor: tokens.globalFocus, + backgroundCursorColor: tokens.globalTextMuted, + onSubmitted: onSubmit, + ), + ); + } +} + +/// @-completion overlay, attached via [CompositedTransformTarget] / +/// [CompositedTransformFollower] so it tracks the input field. +class _AtOverlay extends StatelessWidget { + const _AtOverlay({ + required this.layerLink, + required this.suggestions, + required this.onSelect, + }); + + final LayerLink layerLink; + final List suggestions; + final void Function(String name) onSelect; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + return Positioned( + // Overlay is attached relative to the layerLink; height is open so the + // follower drives layout. The CompositedTransformFollower handles x/y. + width: 0, + height: 0, + child: CompositedTransformFollower( + link: layerLink, + showWhenUnlinked: false, + offset: const Offset(0, -4), + child: Align( + alignment: Alignment.bottomLeft, + child: Container( + constraints: const BoxConstraints(maxWidth: 180, maxHeight: 140), + decoration: BoxDecoration( + color: tokens.panelBackground, + border: Border.all(color: tokens.panelBorder), + borderRadius: BorderRadius.circular(4), + ), + child: ListView( + padding: const EdgeInsets.symmetric(vertical: 4), + shrinkWrap: true, + children: [ + for (final name in suggestions) + Semantics( + button: true, + label: '@$name', + excludeSemantics: true, + onTap: () => onSelect(name), + child: ClideTappable( + onTap: () => onSelect(name), + builder: (ctx, hovered, _) => Container( + color: hovered ? tokens.globalFocus.withAlpha(20) : null, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + child: ClideText('@$name', fontSize: clideFontSmall, color: tokens.globalForeground), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/test/builtin/claude/at_commands_test.dart b/test/builtin/claude/at_commands_test.dart new file mode 100644 index 00000000..3052a1df --- /dev/null +++ b/test/builtin/claude/at_commands_test.dart @@ -0,0 +1,158 @@ +/// Unit tests for the @-name completion helpers (T-180, at_commands.dart). +library; + +import 'package:clide/builtin/claude/src/at_commands.dart'; +import 'package:test/test.dart'; + +void main() { + group('activeAtQuery', () { + test('returns null when text is empty', () { + expect(activeAtQuery('', 0), isNull); + }); + + test('returns null when cursor is at a non-@ run', () { + expect(activeAtQuery('hello world', 5), isNull); + }); + + test('detects @name at start of text', () { + const text = '@tyre'; + final q = activeAtQuery(text, text.length); + expect(q, isNotNull); + expect(q!.start, 0); + expect(q.query, 'tyre'); + }); + + test('detects @name after whitespace', () { + const text = 'hi @tyre'; + final q = activeAtQuery(text, text.length); + expect(q, isNotNull); + expect(q!.start, 3); + expect(q.query, 'tyre'); + }); + + test('returns null when @ is mid-word (e.g. email address)', () { + const text = 'user@example.com'; + final q = activeAtQuery(text, text.length); + expect(q, isNull); + }); + + test('returns empty query when cursor is right after @', () { + const text = '@'; + final q = activeAtQuery(text, 1); + expect(q, isNotNull); + expect(q!.query, ''); + }); + + test('partial name is captured up to the cursor', () { + const text = '@ty'; + final q = activeAtQuery(text, 3); + expect(q!.query, 'ty'); + }); + + test('returns null after a space follows the tag (token complete)', () { + const text = '@tyre '; + // Cursor is past the space — no active @-token. + final q = activeAtQuery(text, text.length); + expect(q, isNull); + }); + }); + + group('filterAtNames', () { + const names = ['lead', 'tyre', 'qatux']; + + test('empty query returns team first then all names sorted', () { + final results = filterAtNames('', names); + expect(results.first, 'team'); + // The rest are alphabetically sorted names. + expect(results.sublist(1).toSet(), containsAll(names)); + }); + + test('query prefix filters names case-insensitively', () { + final results = filterAtNames('t', names); + expect(results, containsAll(['team', 'tyre'])); + expect(results, isNot(contains('lead'))); + expect(results, isNot(contains('qatux'))); + }); + + test('team alias is always included when query matches', () { + final results = filterAtNames('te', names); + expect(results, contains('team')); + }); + + test('team alias is excluded when query does not match', () { + final results = filterAtNames('z', names); + expect(results, isNot(contains('team'))); + }); + + test('de-duplicates names', () { + final results = filterAtNames('', ['lead', 'lead']); + expect(results.where((n) => n == 'lead'), hasLength(1)); + }); + + test('caps results at limit', () { + final manyNames = List.generate(20, (i) => 'member$i'); + final results = filterAtNames('', manyNames, limit: 5); + expect(results.length, lessThanOrEqualTo(5)); + }); + + test('team is pinned first even when other names sort before it', () { + final results = filterAtNames('', ['alpha', 'beta']); + expect(results.first, 'team'); + }); + }); + + group('completeAt', () { + test('replaces the @ token with the selected name + trailing space', () { + // Cursor is right after "ty"; the space + rest of line come after. + // replaceRange(0, 3, '@tyre ') over '@ty rest' gives '@tyre rest' + // (keeps the existing space). That is the correct contract — the + // completion inserts its own trailing space; callers clear the space + // if they want to avoid double spacing. + const text = '@ty rest'; + final q = activeAtQuery(text, 3)!; // cursor after "ty" + final result = completeAt(text, q, 'tyre'); + expect(result.text, '@tyre rest'); // double-space: inserted + existing + expect(result.cursor, '@tyre '.length); + }); + + test('works at the start of the text', () { + const text = '@'; + final q = activeAtQuery(text, 1)!; + final result = completeAt(text, q, 'lead'); + expect(result.text, '@lead '); + expect(result.cursor, '@lead '.length); + }); + }); + + group('parseAtTag', () { + test('no leading @ returns null recipient and full text as body', () { + final r = parseAtTag('hello world'); + expect(r.recipient, isNull); + expect(r.body, 'hello world'); + }); + + test('leading @name splits into recipient + body', () { + final r = parseAtTag('@tyre pick up T-9'); + expect(r.recipient, 'tyre'); + expect(r.body, 'pick up T-9'); + }); + + test('@team normalises to null recipient (broadcast)', () { + final r = parseAtTag('@team hello everyone'); + expect(r.recipient, isNull); + expect(r.body, 'hello everyone'); + }); + + test('just @name with no body', () { + final r = parseAtTag('@lead'); + expect(r.recipient, 'lead'); + expect(r.body, ''); + }); + + test('leading whitespace is trimmed before @-tag parsing', () { + final r = parseAtTag(' @tyre hello'); + expect(r.recipient, 'tyre'); + expect(r.body, 'hello'); + }); + }); +} diff --git a/test/builtin/claude/claude_meta_sidebar_test.dart b/test/builtin/claude/claude_meta_sidebar_test.dart index c014b4e0..f24b3c7f 100644 --- a/test/builtin/claude/claude_meta_sidebar_test.dart +++ b/test/builtin/claude/claude_meta_sidebar_test.dart @@ -304,21 +304,23 @@ void main() { final semantics = tester.ensureSemantics(); final orch = await orchWithMember(tester); - // Before tap: no inject field visible. - expect(find.byType(EditableText), findsNothing); + // 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(find.byType(EditableText), findsOneWidget); + expect(injectFinder, findsOneWidget); // Tapping the cancel (×) icon dismisses it. final cancelTap = find.bySemanticsLabel('Cancel').first; await tester.tap(cancelTap); await tester.pump(); - expect(find.byType(EditableText), findsNothing); + expect(injectFinder, findsNothing); semantics.dispose(); orch.dispose(); @@ -353,15 +355,16 @@ void main() { // Open inject field. await tester.tap(find.bySemanticsLabel('Inject message').first); await tester.pump(); - expect(find.byType(EditableText), findsOneWidget); + final injectFinder = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel?.startsWith('inject-') == true); + expect(injectFinder, findsOneWidget); // Type and submit. - await tester.enterText(find.byType(EditableText).first, 'hello agent'); + await tester.enterText(injectFinder, 'hello agent'); await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pump(); - // Field dismissed after submit. - expect(find.byType(EditableText), findsNothing); + // Inject field dismissed after submit (chat composer remains). + expect(injectFinder, findsNothing); semantics.dispose(); orch.dispose(); diff --git a/test/builtin/claude/session_lifecycle_test.dart b/test/builtin/claude/session_lifecycle_test.dart index c0317a3d..3ec5d14e 100644 --- a/test/builtin/claude/session_lifecycle_test.dart +++ b/test/builtin/claude/session_lifecycle_test.dart @@ -261,7 +261,9 @@ void main() { memberName: 'tyre', )); expect(orch.sessions, hasLength(2)); - expect(orch.broker.members, hasLength(2)); + // Broker has 2 agent members + 1 virtual 'user' member (T-180). + final agentMembers = orch.broker.members.where((m) => m.id != 'user'); + expect(agentMembers, hasLength(2)); final ids = orch.sessions.map((m) => m.id).toList(); for (final id in ids) { @@ -269,7 +271,9 @@ void main() { } expect(orch.sessions, isEmpty); - expect(orch.broker.members, isEmpty); + // Only the virtual 'user' member remains after closing all sessions. + final agentMembersAfter = orch.broker.members.where((m) => m.id != 'user'); + expect(agentMembersAfter, isEmpty); }); }); } diff --git a/test/builtin/claude/session_orchestrator_test.dart b/test/builtin/claude/session_orchestrator_test.dart index 19f2fd1d..21b32ce8 100644 --- a/test/builtin/claude/session_orchestrator_test.dart +++ b/test/builtin/claude/session_orchestrator_test.dart @@ -98,9 +98,12 @@ void main() { test('team sessions register in the broker; solo sessions do not', () async { await orch.spawn(spec('solo')); - expect(orch.broker.members, isEmpty); + // The 'user' virtual member is always registered in the broker (T-180). + final agentNames = orch.broker.members.where((m) => m.id != 'user').map((m) => m.name); + expect(agentNames, isEmpty); await orch.spawn(teamSpec('primary', 'lead', 'lead')); - expect(orch.broker.members.map((m) => m.name), ['lead']); + final agentNamesAfter = orch.broker.members.where((m) => m.id != 'user').map((m) => m.name); + expect(agentNamesAfter, ['lead']); }); test('a message between team members is delivered into the target session stdin', () async { @@ -121,7 +124,9 @@ void main() { await orch.spawn(teamSpec('primary', 'lead', 'lead')); await orch.spawn(teamSpec('teammate:tyre', 'tyre', 'teammate')); await orch.close('teammate:tyre'); - expect(orch.broker.members.map((m) => m.name), ['lead']); + // 'user' is always in the broker (T-180); only agent members checked here. + final agentNames = orch.broker.members.where((m) => m.id != 'user').map((m) => m.name); + expect(agentNames, ['lead']); }); }); diff --git a/test/builtin/claude/team_broker_test.dart b/test/builtin/claude/team_broker_test.dart index 031f57a0..dd973450 100644 --- a/test/builtin/claude/team_broker_test.dart +++ b/test/builtin/claude/team_broker_test.dart @@ -52,14 +52,22 @@ void main() { test('broadcast reaches every other member but not the sender', () async { broker.addMember(const TeamMemberRef(id: 'teammate:qatux', name: 'qatux', role: 'teammate')); final r = decode(await lead.callTool('broadcast', {'text': 'standup'})); - expect((r['recipients'] as List).toSet(), {'tyre', 'qatux'}); - expect(delivered.map((d) => d.$1).toSet(), {'teammate:tyre', 'teammate:qatux'}); + // 'user' is also in the recipients (T-180 virtual member), but has no + // stdin delivery (handled by the chat model, not the MessageDelivery callback). + final recipients = (r['recipients'] as List).toSet(); + expect(recipients, containsAll({'tyre', 'qatux'})); + // Only real sessions get stdin delivery; 'user' is skipped in _enqueue. + expect(delivered.map((d) => d.$1).toSet(), containsAll({'teammate:tyre', 'teammate:qatux'})); + expect(delivered.map((d) => d.$1), isNot(contains('user'))); }); test('list_teammates returns the other members with roles', () async { final r = decode(await lead.callTool('list_teammates', {})); final mates = r['teammates'] as List; - expect(mates.single, {'name': 'tyre', 'role': 'teammate'}); + // Now includes the virtual 'user' member (T-180) in addition to 'tyre'. + expect(mates.map((m) => m['name']).toSet(), contains('tyre')); + final tyreMate = mates.firstWhere((m) => m['name'] == 'tyre') as Map; + expect(tyreMate['role'], 'teammate'); }); test('a claimed task is visible to every member as shared state', () async { @@ -118,7 +126,8 @@ void main() { test('removing an unknown member is a no-op', () { broker.removeMember('teammate:ghost'); - expect(broker.members.map((m) => m.name).toSet(), {'lead', 'tyre'}); + // 'user' is always present (T-180 virtual member). + expect(broker.members.map((m) => m.name).toSet(), {'lead', 'tyre', 'user'}); }); test('the MCP tool surface lists all six team tools', () { diff --git a/test/builtin/claude/team_chat_model_test.dart b/test/builtin/claude/team_chat_model_test.dart new file mode 100644 index 00000000..668f4754 --- /dev/null +++ b/test/builtin/claude/team_chat_model_test.dart @@ -0,0 +1,199 @@ +/// Unit tests for TeamChatModel (T-180). +/// +/// All tests are pure Dart (no Flutter widgets). TeamChatModel and TeamBroker +/// are both Flutter-free; this file runs under `flutter test` but does NOT +/// import any Flutter package. +library; + +import 'package:clide/builtin/claude/src/team_broker.dart'; +import 'package:clide/builtin/claude/src/team_chat_model.dart'; +import 'package:test/test.dart'; + +void main() { + late TeamBroker broker; + late List<(String, String)> delivered; // (toMemberId, text) + late TeamChatModel model; + + setUp(() { + delivered = []; + broker = TeamBroker(deliver: (to, text) => delivered.add((to, text))); + broker.addMember(const TeamMemberRef(id: 'primary', name: 'lead', role: 'lead')); + broker.addMember(const TeamMemberRef(id: 'teammate:tyre', name: 'tyre', role: 'teammate')); + model = TeamChatModel(broker: broker); + }); + + tearDown(() { + model.dispose(); + broker.dispose(); + }); + + // --------------------------------------------------------------------------- + // Message stream → timeline + // --------------------------------------------------------------------------- + + group('message stream', () { + test('model starts with an empty timeline', () { + expect(model.messages, isEmpty); + }); + + test('appends a message when the broker delivers one', () async { + final events = []; + final sub = model.changes.listen((_) => events.add(null)); + broker.sendMessage('primary', 'tyre', 'hello tyre'); + await Future.delayed(Duration.zero); + await sub.cancel(); + expect(model.messages, hasLength(1)); + expect(model.messages.single.from, 'lead'); + expect(model.messages.single.text, 'hello tyre'); + expect(model.messages.single.to, 'tyre'); + expect(events, hasLength(1)); + }); + + test('broadcast messages are appended for each recipient', () async { + broker.broadcast('primary', 'standup'); + await Future.delayed(Duration.zero); + // One message for 'tyre', one for 'user' (both are non-sender members). + expect(model.messages.length, greaterThanOrEqualTo(1)); + expect(model.messages.every((m) => m.text == 'standup'), isTrue); + }); + + test('direct send_message to user lands in the timeline', () async { + broker.sendMessage('primary', 'user', 'attention user'); + await Future.delayed(Duration.zero); + expect(model.messages.single.text, 'attention user'); + expect(model.messages.single.to, 'user'); + // User has no stdin delivery. + expect(delivered, isEmpty); + }); + + test('changes stream fires on each message', () async { + final events = []; + final sub = model.changes.listen((_) => events.add(null)); + broker.sendMessage('primary', 'tyre', 'one'); + broker.sendMessage('primary', 'tyre', 'two'); + await Future.delayed(Duration.zero); + await sub.cancel(); + expect(events, hasLength(2)); + }); + + test('messages list is append-only (oldest first)', () async { + broker.sendMessage('primary', 'tyre', 'first'); + broker.sendMessage('primary', 'tyre', 'second'); + await Future.delayed(Duration.zero); + expect(model.messages[0].text, 'first'); + expect(model.messages[1].text, 'second'); + }); + }); + + // --------------------------------------------------------------------------- + // postAsUser — routing + // --------------------------------------------------------------------------- + + group('postAsUser routing', () { + test('postAsUser with no toName broadcasts to all agents', () async { + model.postAsUser('hello team'); + await Future.delayed(Duration.zero); + // Delivered to tyre (lead is the sender-equivalent; user has no delivery). + expect(delivered.any((d) => d.$1 == 'teammate:tyre'), isTrue); + }); + + test('postAsUser with toName=team broadcasts', () async { + model.postAsUser('standup', toName: 'team'); + await Future.delayed(Duration.zero); + expect(delivered.any((d) => d.$1 == 'teammate:tyre'), isTrue); + }); + + test('postAsUser with a member name delivers to that member only', () async { + model.postAsUser('hey tyre', toName: 'tyre'); + await Future.delayed(Duration.zero); + expect(delivered.length, 1); + expect(delivered.single.$1, 'teammate:tyre'); + }); + + test('postAsUser creates a local timeline entry immediately', () { + model.postAsUser('quick post', toName: 'tyre'); + // Synchronous: the entry is in messages before any async event. + expect(model.messages, hasLength(1)); + expect(model.messages.single.from, 'user'); + expect(model.messages.single.to, 'tyre'); + }); + + test('postAsUser broadcast creates a local entry with broadcast=true', () { + model.postAsUser('broadcast text'); + expect(model.messages.single.broadcast, isTrue); + }); + + test('postAsUser fires the changes stream synchronously', () { + var fired = false; + model.changes.listen((_) => fired = true); + model.postAsUser('sync'); + // The stream is broadcast but the listener is called asynchronously by + // the Dart event loop — wait one microtask. + expect(fired, isFalse); // not yet + }); + }); + + // --------------------------------------------------------------------------- + // postAsUser — interrupt flag + // --------------------------------------------------------------------------- + + group('postAsUser interrupt', () { + test('interrupt=true calls the session resolver', () async { + String? resolvedName; + final interruptModel = TeamChatModel( + broker: broker, + sessionResolver: (name) { + resolvedName = name; + return null; // no real session in unit tests + }, + ); + interruptModel.postAsUser('cancel that', toName: 'tyre', interrupt: true); + await Future.delayed(Duration.zero); + expect(resolvedName, 'tyre'); + interruptModel.dispose(); + }); + + test('interrupt=true on a broadcast does NOT call the resolver', () async { + String? resolvedName; + final interruptModel = TeamChatModel( + broker: broker, + sessionResolver: (name) { + resolvedName = name; + return null; + }, + ); + interruptModel.postAsUser('abort all', interrupt: true); + await Future.delayed(Duration.zero); + // Broadcast → resolver not called (no single target to interrupt). + expect(resolvedName, isNull); + interruptModel.dispose(); + }); + + test('interrupt=false never calls the resolver', () async { + String? resolvedName; + final interruptModel = TeamChatModel( + broker: broker, + sessionResolver: (name) { + resolvedName = name; + return null; + }, + ); + interruptModel.postAsUser('no interrupt', toName: 'tyre', interrupt: false); + await Future.delayed(Duration.zero); + expect(resolvedName, isNull); + interruptModel.dispose(); + }); + }); + + // --------------------------------------------------------------------------- + // Dispose + // --------------------------------------------------------------------------- + + test('dispose closes the changes stream', () async { + var done = false; + model.changes.listen(null, onDone: () => done = true); + model.dispose(); + await Future.delayed(Duration.zero); + expect(done, isTrue); + }); +} diff --git a/test/builtin/claude/team_chat_sidebar_test.dart b/test/builtin/claude/team_chat_sidebar_test.dart new file mode 100644 index 00000000..c809025e --- /dev/null +++ b/test/builtin/claude/team_chat_sidebar_test.dart @@ -0,0 +1,274 @@ +/// Widget tests for TeamChatSidebar and TeamChatPane (T-180). +library; + +import 'package:clide/builtin/claude/src/team_broker.dart'; +import 'package:clide/builtin/claude/src/team_chat_model.dart'; +import 'package:clide/builtin/claude/src/team_chat_sidebar.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +void main() { + late KernelFixture f; + late TeamBroker broker; + late TeamChatModel model; + + setUp(() async { + f = await KernelFixture.create(); + broker = TeamBroker(deliver: (_, __) {}); + broker.addMember(const TeamMemberRef(id: 'primary', name: 'lead', role: 'lead')); + broker.addMember(const TeamMemberRef(id: 'teammate:tyre', name: 'tyre', role: 'teammate')); + model = TeamChatModel(broker: broker); + }); + + tearDown(() { + model.dispose(); + broker.dispose(); + f.dispose(); + }); + + // --------------------------------------------------------------------------- + // TeamChatSidebar + // --------------------------------------------------------------------------- + + group('TeamChatSidebar', () { + Widget sidebar({VoidCallback? onPopOut}) => harness( + f, + SizedBox( + width: 220, + height: 400, + child: TeamChatSidebar( + model: model, + broker: broker, + onPopOut: onPopOut ?? () {}, + ), + ), + ); + + testWidgets('renders MESSAGES header', (tester) async { + await tester.pumpWidget(sidebar()); + expect(find.text('MESSAGES'), findsOneWidget); + }); + + testWidgets('shows placeholder when no messages', (tester) async { + await tester.pumpWidget(sidebar()); + expect(find.text('No messages yet.'), findsOneWidget); + }); + + testWidgets('renders broker messages from the model', (tester) async { + // Post via model directly so the message is already in the timeline + // before we build the widget — avoids a pump/settle cycle. + model.postAsUser('hello tyre', toName: 'tyre'); + await tester.pumpWidget(sidebar()); + await tester.pump(); + expect(find.text('hello tyre'), findsOneWidget); + expect(find.text('No messages yet.'), findsNothing); + }); + + testWidgets('updates when a new message arrives', (tester) async { + await tester.pumpWidget(sidebar()); + await tester.pump(); + expect(find.text('No messages yet.'), findsOneWidget); + + // postAsUser adds to _messages synchronously and fires _changeCtl.add, + // which synchronously calls setState() in the sidebar's listener. + // Two pumps: one to process the microtask queue (stream event delivery), + // one to render the resulting rebuild frame. + model.postAsUser('live message', toName: 'tyre'); + await tester.pump(); // deliver stream event → setState + await tester.pump(); // render rebuild frame + + expect(find.text('live message'), findsOneWidget); + }); + + testWidgets('postAsUser adds a message from the user', (tester) async { + await tester.pumpWidget(sidebar()); + await tester.pump(); + + model.postAsUser('hello team'); + await tester.pump(); // deliver stream event → setState + await tester.pump(); // render rebuild frame + + // The message text appears in the timeline. + expect(find.text('hello team'), findsOneWidget); + }); + + testWidgets('has a chat input field', (tester) async { + await tester.pumpWidget(sidebar()); + await tester.pumpAndSettle(); + final chatField = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-sidebar'); + expect(chatField, findsOneWidget); + }); + + testWidgets('submitting the composer calls postAsUser', (tester) async { + await tester.pumpWidget(sidebar()); + await tester.pumpAndSettle(); + + final chatField = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-sidebar'); + await tester.enterText(chatField, 'broadcast msg'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + + expect(model.messages.any((m) => m.text == 'broadcast msg'), isTrue); + }); + + testWidgets('@name tag routes to the named member', (tester) async { + await tester.pumpWidget(sidebar()); + await tester.pumpAndSettle(); + + final chatField = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-sidebar'); + await tester.enterText(chatField, '@tyre pick this up'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + + expect(model.messages.any((m) => m.to == 'tyre' && m.text == 'pick this up'), isTrue); + }); + + testWidgets('pop-out icon calls onPopOut', (tester) async { + var popped = false; + // Use a tall harness so the MESSAGES header (and its pop-out icon) is + // always in view and tappable. + await tester.pumpWidget(harness( + f, + SizedBox( + width: 300, + height: 800, + child: TeamChatSidebar( + model: model, + broker: broker, + onPopOut: () => popped = true, + ), + ), + )); + await tester.pump(); + + // The pop-out icon is wired via Semantics(label: 'Open full chat pane'). + // Use byWidgetPredicate to traverse the full widget tree regardless of + // viewport clipping (canSizeOverlay harness pitfall — see T-180 notes). + final popOutFinder = find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Open full chat pane'); + expect(popOutFinder, findsOneWidget); + await tester.tap(popOutFinder); + await tester.pump(); + expect(popped, isTrue); + }); + + testWidgets('shows only last 5 messages in compact feed', (tester) async { + // Post 8 messages directly via model (synchronous, no broker stream delay). + for (var i = 0; i < 8; i++) { + model.postAsUser('message $i', toName: 'tyre'); + } + await tester.pumpWidget(sidebar()); + await tester.pump(); + // Only messages 3-7 visible (last 5). + expect(find.text('message 7'), findsOneWidget); + expect(find.text('message 0'), findsNothing); + }); + }); + + // --------------------------------------------------------------------------- + // TeamChatPane + // --------------------------------------------------------------------------- + + group('TeamChatPane', () { + Widget pane() => harness( + f, + SizedBox( + width: 400, + height: 600, + child: TeamChatPane(model: model, broker: broker), + ), + ); + + testWidgets('renders Team Chat header', (tester) async { + await tester.pumpWidget(pane()); + expect(find.text('Team Chat'), findsOneWidget); + }); + + testWidgets('shows placeholder when empty', (tester) async { + await tester.pumpWidget(pane()); + expect(find.text('No messages yet.'), findsOneWidget); + }); + + testWidgets('renders messages from the model', (tester) async { + model.postAsUser('pane message', toName: 'tyre'); + await tester.pumpWidget(pane()); + await tester.pump(); + expect(find.text('pane message'), findsOneWidget); + }); + + testWidgets('has an Interrupt tickbox starting unchecked', (tester) async { + await tester.pumpWidget(pane()); + await tester.pump(); + expect(find.text('Interrupt'), findsOneWidget); + // The tickbox is implemented as a Container that is empty when unchecked + // and shows a check icon when checked. Verify by checking that the check + // icon (PhosphorIcons.check) is NOT rendered when unchecked. + // We use the Semantics widget's checked property which maps to isChecked. + final interruptFinder = find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Interrupt target session'); + // Widget exists in tree. + expect(interruptFinder, findsOneWidget); + // Semantics.checked is false when unchecked. + final sem = interruptFinder.evaluate().single.widget as Semantics; + expect(sem.properties.checked, isFalse); + }); + + testWidgets('tapping Interrupt toggles the tickbox', (tester) async { + await tester.pumpWidget(pane()); + await tester.pump(); + + final interruptArea = find.text('Interrupt'); + await tester.tap(interruptArea); + await tester.pump(); + + // Re-find: the Semantics widget's checked property is now true. + final interruptFinder = find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Interrupt target session'); + final sem = interruptFinder.evaluate().single.widget as Semantics; + expect(sem.properties.checked, isTrue); + }); + + testWidgets('submitting the pane composer posts as user', (tester) async { + await tester.pumpWidget(pane()); + await tester.pumpAndSettle(); + + final chatField = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-pane'); + await tester.enterText(chatField, '@tyre check this'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + + expect(model.messages.any((m) => m.to == 'tyre' && m.text == 'check this'), isTrue); + }); + + testWidgets('sidebar and pane share the same model (both surfaces update)', (tester) async { + await tester.pumpWidget(harness( + f, + SizedBox( + width: 800, + height: 600, + child: Row( + children: [ + SizedBox( + width: 220, + child: TeamChatSidebar( + model: model, + broker: broker, + onPopOut: () {}, + ), + ), + Expanded(child: TeamChatPane(model: model, broker: broker)), + ], + ), + ), + )); + await tester.pump(); + + // Posting from the model shows up in both surfaces. + model.postAsUser('shared message'); + await tester.pump(); // deliver stream events → setState in each widget + await tester.pump(); // render rebuild frames + + expect(find.text('shared message'), findsNWidgets(2)); + }); + }); +}