add the team chat inbox over the broker
Renders broker traffic as a chat timeline and makes the user a first-class participant. The broker grows a Stream<TeamMessage> and a recipient field, auto-registers a virtual `user` member, and gains sendAsUser. A Flutter-free TeamChatModel (owned by the orchestrator) accumulates the feed and exposes postAsUser with @-routing (a new at_commands helper mirroring slash) and an optional interrupt that cancels the target's turn before delivery. One model backs two surfaces: a compact cockpit widget that pops out into a full workspace chat pane. CLI parity via clide.team-chat.open / .post. T-180. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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', '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-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', '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;
|
||||||
|
|||||||
@@ -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'', <sourceSessionId>, ''--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);
|
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'', <sourceSessionId>, ''--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 <source> --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-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 <source> --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'', <sourceSessionId>, ''--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);
|
||||||
|
|||||||
@@ -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
|
- Fork a Claude conversation into a new pane — `/fork` (or a roster Fork
|
||||||
button / `clide.agent.fork`) branches a session via `--resume … --fork-session`,
|
button / `clide.agent.fork`) branches a session via `--resume … --fork-session`,
|
||||||
opening an independent continuation that leaves the original untouched. (T-172, D-77)
|
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
|
- Team coordination broker (T-170, D-77) — clide hosts an in-process MCP
|
||||||
server (`clide-team`) for managed sessions over the stream-json control
|
server (`clide-team`) for managed sessions over the stream-json control
|
||||||
channel, giving agents tools to message each other, broadcast, see the
|
channel, giving agents tools to message each other, broadcast, see the
|
||||||
|
|||||||
@@ -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<String> filterAtNames(String query, Iterable<String> names, {int limit = 8}) {
|
||||||
|
const broadcast = 'team';
|
||||||
|
final q = query.toLowerCase();
|
||||||
|
final seen = <String>{};
|
||||||
|
final matches = <String>[];
|
||||||
|
// 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 `@<name> `,
|
||||||
|
/// 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);
|
||||||
|
}
|
||||||
@@ -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/claude_status.dart' show formatTokenCount, permissionModeLabel, shortModelLabel;
|
||||||
import 'package:clide/builtin/claude/src/session_orchestrator.dart';
|
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_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/team_panel_host.dart' show teamColor;
|
||||||
import 'package:clide/builtin/claude/src/transcript_publisher.dart' show ClaudeConversation;
|
import 'package:clide/builtin/claude/src/transcript_publisher.dart' show ClaudeConversation;
|
||||||
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus;
|
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus;
|
||||||
@@ -348,10 +349,17 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
|||||||
children.add(_taskSection(tokens));
|
children.add(_taskSection(tokens));
|
||||||
}
|
}
|
||||||
|
|
||||||
// MESSAGES section: placeholder seam for T-180 to fill.
|
// MESSAGES section (T-180): live broker chat feed + quick-post composer.
|
||||||
// T-180 will replace this Container with the live message feed.
|
final chatModel = _orchestrator?.chatModel;
|
||||||
children.add(const SizedBox(height: 12));
|
final broker = _orchestrator?.broker;
|
||||||
children.add(_messagesSectionPlaceholder(tokens));
|
if (chatModel != null && broker != null) {
|
||||||
|
children.add(const SizedBox(height: 12));
|
||||||
|
children.add(TeamChatSidebar(
|
||||||
|
model: chatModel,
|
||||||
|
broker: broker,
|
||||||
|
onPopOut: _openChatPane,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
@@ -370,9 +378,9 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Minimal seam for T-180 — the message feed and composer will land here.
|
/// Open the full team chat pane in the workspace (T-180).
|
||||||
Widget _messagesSectionPlaceholder(SurfaceTokens tokens) {
|
void _openChatPane() {
|
||||||
return ClideText('MESSAGES', fontSize: clideFontSmall, color: tokens.globalTextMuted);
|
ClideKernel.of(context).panels.activateTab(Slots.workspace, 'claude.team-chat');
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Config ---------------------------------------------------------------
|
// --- Config ---------------------------------------------------------------
|
||||||
|
|||||||
@@ -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/claude_meta_sidebar.dart';
|
||||||
import 'package:clide/builtin/claude/src/session_index.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/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/builtin/claude/src/team_panel_host.dart';
|
||||||
import 'package:clide/extension/extension.dart';
|
import 'package:clide/extension/extension.dart';
|
||||||
import 'package:clide/kernel/kernel.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});
|
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] <text...>
|
||||||
|
// 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] <text>'});
|
||||||
|
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
|
// claude.agent.fork: branch a managed session into a new fork session
|
||||||
// (T-172, D-6 CLI/UI parity for the roster fork button).
|
// (T-172, D-6 CLI/UI parity for the roster fork button).
|
||||||
// Usage: clide claude.agent.fork <sourceSessionId> [<cwd>]
|
// Usage: clide claude.agent.fork <sourceSessionId> [<cwd>]
|
||||||
|
|||||||
@@ -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/session_naming.dart';
|
||||||
import 'package:clide/builtin/claude/src/stream_json_session.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_broker.dart';
|
||||||
|
import 'package:clide/builtin/claude/src/team_chat_model.dart';
|
||||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
@@ -146,7 +147,12 @@ class ManagedSession {
|
|||||||
ClaudeSessionOrchestrator? activeSessionOrchestrator;
|
ClaudeSessionOrchestrator? activeSessionOrchestrator;
|
||||||
|
|
||||||
class ClaudeSessionOrchestrator extends ChangeNotifier {
|
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 ProcessFactory _factory;
|
||||||
final _sessions = <String, ManagedSession>{};
|
final _sessions = <String, ManagedSession>{};
|
||||||
@@ -156,6 +162,13 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
|
|||||||
/// session's next turn (T-170).
|
/// session's next turn (T-170).
|
||||||
late final TeamBroker broker = TeamBroker(deliver: _deliverToSession);
|
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);
|
void _deliverToSession(String toId, String text) => _sessions[toId]?.session.send(text);
|
||||||
|
|
||||||
static Future<StreamJsonProcess> _spawnClaude({required List<String> sessionArgs, required String cwd, Map<String, String>? env}) =>
|
static Future<StreamJsonProcess> _spawnClaude({required List<String> sessionArgs, required String cwd, Map<String, String>? env}) =>
|
||||||
@@ -328,6 +341,8 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
|
|||||||
m.conversation.dispose();
|
m.conversation.dispose();
|
||||||
}
|
}
|
||||||
_sessions.clear();
|
_sessions.clear();
|
||||||
|
_chatModel.dispose();
|
||||||
|
broker.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,14 +35,26 @@ class TeamMemberRef {
|
|||||||
|
|
||||||
/// A message left for a member, in arrival order.
|
/// A message left for a member, in arrival order.
|
||||||
class TeamMessage {
|
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;
|
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 String text;
|
||||||
final DateTime at;
|
final DateTime at;
|
||||||
final bool broadcast;
|
final bool broadcast;
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'from': from,
|
'from': from,
|
||||||
|
if (to != null) 'to': to,
|
||||||
'text': text,
|
'text': text,
|
||||||
'at': at.toIso8601String(),
|
'at': at.toIso8601String(),
|
||||||
if (broadcast) 'broadcast': true,
|
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
|
/// broadcast [StreamController]; consumers must not assume it fires on the
|
||||||
/// Flutter event loop.
|
/// Flutter event loop.
|
||||||
class TeamBroker {
|
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 MessageDelivery? _deliver;
|
||||||
final _members = <String, TeamMemberRef>{};
|
final _members = <String, TeamMemberRef>{};
|
||||||
@@ -90,11 +107,17 @@ class TeamBroker {
|
|||||||
// --- Observability ---------------------------------------------------------
|
// --- Observability ---------------------------------------------------------
|
||||||
|
|
||||||
final _changeCtl = StreamController<void>.broadcast();
|
final _changeCtl = StreamController<void>.broadcast();
|
||||||
|
final _messageCtl = StreamController<TeamMessage>.broadcast();
|
||||||
|
|
||||||
/// Fires a void event whenever the task list or message state mutates.
|
/// Fires a void event whenever the task list or message state mutates.
|
||||||
/// Broadcast — multiple listeners are supported. Flutter-free.
|
/// Broadcast — multiple listeners are supported. Flutter-free.
|
||||||
Stream<void> get changes => _changeCtl.stream;
|
Stream<void> 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<TeamMessage> get messages => _messageCtl.stream;
|
||||||
|
|
||||||
void _notify() {
|
void _notify() {
|
||||||
if (!_changeCtl.isClosed) _changeCtl.add(null);
|
if (!_changeCtl.isClosed) _changeCtl.add(null);
|
||||||
}
|
}
|
||||||
@@ -161,9 +184,10 @@ class TeamBroker {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dispose — closes the [changes] stream controller.
|
/// Dispose — closes the [changes] and [messages] stream controllers.
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_changeCtl.close();
|
_changeCtl.close();
|
||||||
|
_messageCtl.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// All members in registration order.
|
/// All members in registration order.
|
||||||
@@ -181,13 +205,34 @@ class TeamBroker {
|
|||||||
|
|
||||||
// --- Tool operations (scoped to the caller [fromId]) ---------------------
|
// --- 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].
|
/// Deliver [text] to the single member named [toName].
|
||||||
Map<String, dynamic> sendMessage(String fromId, String toName, String text) {
|
Map<String, dynamic> sendMessage(String fromId, String toName, String text) {
|
||||||
final target = _byName(toName);
|
final target = _byName(toName);
|
||||||
if (target == null) {
|
if (target == null) {
|
||||||
return {'ok': false, 'error': 'No teammate named "$toName". Use list_teammates to see who is on the team.'};
|
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};
|
return {'ok': true, 'to': target.name};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +242,7 @@ class TeamBroker {
|
|||||||
final recipients = <String>[];
|
final recipients = <String>[];
|
||||||
for (final m in _members.values) {
|
for (final m in _members.values) {
|
||||||
if (m.id == fromId) continue;
|
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);
|
recipients.add(m.name);
|
||||||
}
|
}
|
||||||
return {'ok': true, 'recipients': recipients};
|
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] ??= <TeamMessage>[]).add(msg);
|
(_inboxes[toId] ??= <TeamMessage>[]).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;
|
final tag = broadcast ? '${msg.from} (broadcast)' : msg.from;
|
||||||
// Gate delivery: muted members still accumulate inbox messages but the
|
// Gate delivery: muted members still accumulate inbox messages but the
|
||||||
// live session callback is suppressed until unmuted (T-171).
|
// live session callback is suppressed until unmuted (T-171).
|
||||||
|
|||||||
@@ -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<TeamMessage> _sub;
|
||||||
|
|
||||||
|
final _messages = <TeamMessage>[];
|
||||||
|
|
||||||
|
final _changeCtl = StreamController<void>.broadcast();
|
||||||
|
|
||||||
|
/// All broker messages in arrival order. Unmodifiable snapshot; new messages
|
||||||
|
/// are signalled via [changes].
|
||||||
|
List<TeamMessage> get messages => List.unmodifiable(_messages);
|
||||||
|
|
||||||
|
/// Fires a void event whenever a new message is appended. Broadcast —
|
||||||
|
/// multiple listeners are supported. Flutter-free.
|
||||||
|
Stream<void> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TeamChatSidebar> createState() => _TeamChatSidebarState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TeamChatSidebarState extends State<TeamChatSidebar> {
|
||||||
|
StreamSubscription<void>? _sub;
|
||||||
|
final _controller = TextEditingController();
|
||||||
|
final _focusNode = FocusNode(debugLabel: 'team-chat-sidebar');
|
||||||
|
final _layerLink = LayerLink();
|
||||||
|
OverlayEntry? _overlay;
|
||||||
|
List<String> _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<String>? suggestions, {AtQuery? query}) {
|
||||||
|
final newSuggestions = suggestions ?? const <String>[];
|
||||||
|
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<TeamChatPane> createState() => _TeamChatPaneState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TeamChatPaneState extends State<TeamChatPane> {
|
||||||
|
StreamSubscription<void>? _sub;
|
||||||
|
final _controller = TextEditingController();
|
||||||
|
final _focusNode = FocusNode(debugLabel: 'team-chat-pane');
|
||||||
|
final _layerLink = LayerLink();
|
||||||
|
final _scrollController = ScrollController();
|
||||||
|
OverlayEntry? _overlay;
|
||||||
|
List<String> _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<String>? suggestions, {AtQuery? query}) {
|
||||||
|
final newSuggestions = suggestions ?? const <String>[];
|
||||||
|
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<String> 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -304,21 +304,23 @@ void main() {
|
|||||||
final semantics = tester.ensureSemantics();
|
final semantics = tester.ensureSemantics();
|
||||||
final orch = await orchWithMember(tester);
|
final orch = await orchWithMember(tester);
|
||||||
|
|
||||||
// Before tap: no inject field visible.
|
// Before tap: only the chat-composer field is visible (T-180).
|
||||||
expect(find.byType(EditableText), findsNothing);
|
// 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;
|
final injectTap = find.bySemanticsLabel('Inject message').first;
|
||||||
await tester.tap(injectTap);
|
await tester.tap(injectTap);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// After tap: inject field appears.
|
// After tap: inject field appears.
|
||||||
expect(find.byType(EditableText), findsOneWidget);
|
expect(injectFinder, findsOneWidget);
|
||||||
|
|
||||||
// Tapping the cancel (×) icon dismisses it.
|
// Tapping the cancel (×) icon dismisses it.
|
||||||
final cancelTap = find.bySemanticsLabel('Cancel').first;
|
final cancelTap = find.bySemanticsLabel('Cancel').first;
|
||||||
await tester.tap(cancelTap);
|
await tester.tap(cancelTap);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(find.byType(EditableText), findsNothing);
|
expect(injectFinder, findsNothing);
|
||||||
|
|
||||||
semantics.dispose();
|
semantics.dispose();
|
||||||
orch.dispose();
|
orch.dispose();
|
||||||
@@ -353,15 +355,16 @@ void main() {
|
|||||||
// Open inject field.
|
// Open inject field.
|
||||||
await tester.tap(find.bySemanticsLabel('Inject message').first);
|
await tester.tap(find.bySemanticsLabel('Inject message').first);
|
||||||
await tester.pump();
|
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.
|
// 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.testTextInput.receiveAction(TextInputAction.done);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// Field dismissed after submit.
|
// Inject field dismissed after submit (chat composer remains).
|
||||||
expect(find.byType(EditableText), findsNothing);
|
expect(injectFinder, findsNothing);
|
||||||
|
|
||||||
semantics.dispose();
|
semantics.dispose();
|
||||||
orch.dispose();
|
orch.dispose();
|
||||||
|
|||||||
@@ -261,7 +261,9 @@ void main() {
|
|||||||
memberName: 'tyre',
|
memberName: 'tyre',
|
||||||
));
|
));
|
||||||
expect(orch.sessions, hasLength(2));
|
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();
|
final ids = orch.sessions.map((m) => m.id).toList();
|
||||||
for (final id in ids) {
|
for (final id in ids) {
|
||||||
@@ -269,7 +271,9 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expect(orch.sessions, isEmpty);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,9 +98,12 @@ void main() {
|
|||||||
|
|
||||||
test('team sessions register in the broker; solo sessions do not', () async {
|
test('team sessions register in the broker; solo sessions do not', () async {
|
||||||
await orch.spawn(spec('solo'));
|
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'));
|
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 {
|
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('primary', 'lead', 'lead'));
|
||||||
await orch.spawn(teamSpec('teammate:tyre', 'tyre', 'teammate'));
|
await orch.spawn(teamSpec('teammate:tyre', 'tyre', 'teammate'));
|
||||||
await orch.close('teammate:tyre');
|
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']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -52,14 +52,22 @@ void main() {
|
|||||||
test('broadcast reaches every other member but not the sender', () async {
|
test('broadcast reaches every other member but not the sender', () async {
|
||||||
broker.addMember(const TeamMemberRef(id: 'teammate:qatux', name: 'qatux', role: 'teammate'));
|
broker.addMember(const TeamMemberRef(id: 'teammate:qatux', name: 'qatux', role: 'teammate'));
|
||||||
final r = decode(await lead.callTool('broadcast', {'text': 'standup'}));
|
final r = decode(await lead.callTool('broadcast', {'text': 'standup'}));
|
||||||
expect((r['recipients'] as List).toSet(), {'tyre', 'qatux'});
|
// 'user' is also in the recipients (T-180 virtual member), but has no
|
||||||
expect(delivered.map((d) => d.$1).toSet(), {'teammate:tyre', 'teammate:qatux'});
|
// 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 {
|
test('list_teammates returns the other members with roles', () async {
|
||||||
final r = decode(await lead.callTool('list_teammates', {}));
|
final r = decode(await lead.callTool('list_teammates', {}));
|
||||||
final mates = r['teammates'] as List;
|
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 {
|
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', () {
|
test('removing an unknown member is a no-op', () {
|
||||||
broker.removeMember('teammate:ghost');
|
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', () {
|
test('the MCP tool surface lists all six team tools', () {
|
||||||
|
|||||||
@@ -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 = <void>[];
|
||||||
|
final sub = model.changes.listen((_) => events.add(null));
|
||||||
|
broker.sendMessage('primary', 'tyre', 'hello tyre');
|
||||||
|
await Future<void>.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<void>.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<void>.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 = <void>[];
|
||||||
|
final sub = model.changes.listen((_) => events.add(null));
|
||||||
|
broker.sendMessage('primary', 'tyre', 'one');
|
||||||
|
broker.sendMessage('primary', 'tyre', 'two');
|
||||||
|
await Future<void>.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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.delayed(Duration.zero);
|
||||||
|
expect(done, isTrue);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user