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:
2026-05-31 10:49:04 +02:00
co-authored by Claude
parent e3c0b0146b
commit a0636c2e5a
17 changed files with 1656 additions and 31 deletions
+91
View File
@@ -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/session_orchestrator.dart';
import 'package:clide/builtin/claude/src/team_broker.dart' show TeamBroker, TeamTask;
import 'package:clide/builtin/claude/src/team_chat_sidebar.dart' show TeamChatSidebar;
import 'package:clide/builtin/claude/src/team_panel_host.dart' show teamColor;
import 'package:clide/builtin/claude/src/transcript_publisher.dart' show ClaudeConversation;
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus;
@@ -348,10 +349,17 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
children.add(_taskSection(tokens));
}
// MESSAGES section: placeholder seam for T-180 to fill.
// T-180 will replace this Container with the live message feed.
children.add(const SizedBox(height: 12));
children.add(_messagesSectionPlaceholder(tokens));
// MESSAGES section (T-180): live broker chat feed + quick-post composer.
final chatModel = _orchestrator?.chatModel;
final broker = _orchestrator?.broker;
if (chatModel != null && broker != null) {
children.add(const SizedBox(height: 12));
children.add(TeamChatSidebar(
model: chatModel,
broker: broker,
onPopOut: _openChatPane,
));
}
return ListView(
padding: const EdgeInsets.all(12),
@@ -370,9 +378,9 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
);
}
/// Minimal seam for T-180 — the message feed and composer will land here.
Widget _messagesSectionPlaceholder(SurfaceTokens tokens) {
return ClideText('MESSAGES', fontSize: clideFontSmall, color: tokens.globalTextMuted);
/// Open the full team chat pane in the workspace (T-180).
void _openChatPane() {
ClideKernel.of(context).panels.activateTab(Slots.workspace, 'claude.team-chat');
}
// --- Config ---------------------------------------------------------------
+51
View File
@@ -9,6 +9,7 @@ import 'package:clide/builtin/claude/src/pane_context_status.dart';
import 'package:clide/builtin/claude/src/claude_meta_sidebar.dart';
import 'package:clide/builtin/claude/src/session_index.dart';
import 'package:clide/builtin/claude/src/session_storage.dart';
import 'package:clide/builtin/claude/src/team_chat_sidebar.dart' show TeamChatPane;
import 'package:clide/builtin/claude/src/team_panel_host.dart';
import 'package:clide/extension/extension.dart';
import 'package:clide/kernel/kernel.dart';
@@ -174,6 +175,56 @@ class ClaudeExtension extends ClideExtension {
return IpcResponse.ok(id: '', data: {'taskId': taskId, 'toId': toId, 'ok': ok});
},
),
// T-180: full team chat pane opened as a workspace tab.
// Shares the TeamChatModel with the sidebar widget.
TabContribution(
id: 'claude.team-chat',
slot: Slots.workspace,
title: 'Team Chat',
titleKey: 'tab.title',
i18nNamespace: id,
priority: 85,
build: (_) {
final orch = _orchestrator;
if (orch == null) return const SizedBox.shrink();
return TeamChatPane(model: orch.chatModel, broker: orch.broker);
},
),
// CLI parity: open the team chat pane from the shell.
// Usage: clide claude.team-chat.open
CommandContribution(
id: 'claude.team-chat.open',
command: 'claude.team-chat.open',
title: 'Claude: open the team chat pane',
run: (args) async {
_ctx?.panels.activateTab(Slots.workspace, 'claude.team-chat');
return IpcResponse.ok(id: '', data: const {'status': 'opened'});
},
),
// Usage: clide claude.team-chat.post [@name] <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
// (T-172, D-6 CLI/UI parity for the roster fork button).
// 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/stream_json_session.dart';
import 'package:clide/builtin/claude/src/team_broker.dart';
import 'package:clide/builtin/claude/src/team_chat_model.dart';
import 'package:clide/builtin/claude/src/transcript_reader.dart';
import 'package:flutter/foundation.dart';
@@ -146,7 +147,12 @@ class ManagedSession {
ClaudeSessionOrchestrator? activeSessionOrchestrator;
class ClaudeSessionOrchestrator extends ChangeNotifier {
ClaudeSessionOrchestrator({ProcessFactory? processFactory}) : _factory = processFactory ?? _spawnClaude;
ClaudeSessionOrchestrator({ProcessFactory? processFactory}) : _factory = processFactory ?? _spawnClaude {
_chatModel = TeamChatModel(
broker: broker,
sessionResolver: (name) => byMemberName(name)?.session,
);
}
final ProcessFactory _factory;
final _sessions = <String, ManagedSession>{};
@@ -156,6 +162,13 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
/// session's next turn (T-170).
late final TeamBroker broker = TeamBroker(deliver: _deliverToSession);
/// The shared chat timeline and user-post logic (T-180). Both the compact
/// sidebar widget and the full workspace pane read from this model.
late final TeamChatModel _chatModel;
/// Exposes the shared chat model to widgets and panes.
TeamChatModel get chatModel => _chatModel;
void _deliverToSession(String toId, String text) => _sessions[toId]?.session.send(text);
static Future<StreamJsonProcess> _spawnClaude({required List<String> sessionArgs, required String cwd, Map<String, String>? env}) =>
@@ -328,6 +341,8 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
m.conversation.dispose();
}
_sessions.clear();
_chatModel.dispose();
broker.dispose();
super.dispose();
}
}
+63 -6
View File
@@ -35,14 +35,26 @@ class TeamMemberRef {
/// A message left for a member, in arrival order.
class TeamMessage {
const TeamMessage({required this.from, required this.text, required this.at, this.broadcast = false});
const TeamMessage({
required this.from,
required this.text,
required this.at,
this.to,
this.broadcast = false,
});
final String from;
/// Recipient name: a single member's display name (direct message), `null`
/// for a broadcast (every member), or the special value `'user'` when the
/// broker surfaces the message to the chat model rather than a session.
final String? to;
final String text;
final DateTime at;
final bool broadcast;
Map<String, dynamic> toJson() => {
'from': from,
if (to != null) 'to': to,
'text': text,
'at': at.toIso8601String(),
if (broadcast) 'broadcast': true,
@@ -78,7 +90,12 @@ typedef MessageDelivery = void Function(String toMemberId, String text);
/// broadcast [StreamController]; consumers must not assume it fires on the
/// Flutter event loop.
class TeamBroker {
TeamBroker({MessageDelivery? deliver}) : _deliver = deliver;
TeamBroker({MessageDelivery? deliver}) : _deliver = deliver {
// The user is always a virtual team participant — agents can address them
// by name; messages routed to `user` surface in the chat model only (no
// stdin delivery). Registered at construction so the roster is consistent.
addMember(const TeamMemberRef(id: 'user', name: 'user', role: 'user'));
}
final MessageDelivery? _deliver;
final _members = <String, TeamMemberRef>{};
@@ -90,11 +107,17 @@ class TeamBroker {
// --- Observability ---------------------------------------------------------
final _changeCtl = StreamController<void>.broadcast();
final _messageCtl = StreamController<TeamMessage>.broadcast();
/// Fires a void event whenever the task list or message state mutates.
/// Broadcast — multiple listeners are supported. Flutter-free.
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() {
if (!_changeCtl.isClosed) _changeCtl.add(null);
}
@@ -161,9 +184,10 @@ class TeamBroker {
return true;
}
/// Dispose — closes the [changes] stream controller.
/// Dispose — closes the [changes] and [messages] stream controllers.
void dispose() {
_changeCtl.close();
_messageCtl.close();
}
/// All members in registration order.
@@ -181,13 +205,34 @@ class TeamBroker {
// --- Tool operations (scoped to the caller [fromId]) ---------------------
/// Post [text] from the human user into the channel. When [to] is null or
/// omitted the message is broadcast; otherwise it is delivered only to the
/// named member. The `user` member is the caller's virtual id — it is
/// excluded from the recipient list in the same way senders are excluded
/// from their own broadcasts.
void sendAsUser(String text, {String? to}) {
if (to == null || to.isEmpty || to == 'team') {
// Broadcast: deliver to every non-user member.
// emitToStream=false: the chat model already recorded the local entry.
for (final m in _members.values) {
if (m.id == 'user') continue;
_enqueue(m.id, TeamMessage(from: 'user', to: null, text: text, at: DateTime.now(), broadcast: true), broadcast: true, emitToStream: false);
}
} else {
// Directed: deliver to the named member.
final target = _byName(to);
if (target == null || target.id == 'user') return;
_enqueue(target.id, TeamMessage(from: 'user', to: target.name, text: text, at: DateTime.now()), emitToStream: false);
}
}
/// Deliver [text] to the single member named [toName].
Map<String, dynamic> sendMessage(String fromId, String toName, String text) {
final target = _byName(toName);
if (target == null) {
return {'ok': false, 'error': 'No teammate named "$toName". Use list_teammates to see who is on the team.'};
}
_enqueue(target.id, TeamMessage(from: _nameOf(fromId), text: text, at: DateTime.now()));
_enqueue(target.id, TeamMessage(from: _nameOf(fromId), to: target.name, text: text, at: DateTime.now()));
return {'ok': true, 'to': target.name};
}
@@ -197,7 +242,7 @@ class TeamBroker {
final recipients = <String>[];
for (final m in _members.values) {
if (m.id == fromId) continue;
_enqueue(m.id, TeamMessage(from: fromName, text: text, at: DateTime.now(), broadcast: true), broadcast: true);
_enqueue(m.id, TeamMessage(from: fromName, to: null, text: text, at: DateTime.now(), broadcast: true), broadcast: true);
recipients.add(m.name);
}
return {'ok': true, 'recipients': recipients};
@@ -263,8 +308,20 @@ class TeamBroker {
};
}
void _enqueue(String toId, TeamMessage msg, {bool broadcast = false}) {
void _enqueue(String toId, TeamMessage msg, {bool broadcast = false, bool emitToStream = true}) {
(_inboxes[toId] ??= <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;
// Gate delivery: muted members still accumulate inbox messages but the
// live session callback is suppressed until unmuted (T-171).
+104
View File
@@ -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),
),
),
),
],
),
),
),
),
);
}
}