Files
clide/lib/builtin/claude/src/team_chat_sidebar.dart
T
jpmschweitzerandClaude Opus 4.8 16fe71cb3f refactor(i18n): route claude pane labels through the catalog (T-464)
Migrate the hardcoded user-facing strings across builtin.claude — composer,
conversation cards/segments, permission + AskUserQuestion prompts, task dock,
meta-sidebar (activity/config/team/roster), session/model pickers — to
ClideSettings.i18n.string/.interpolated, English kept as the placeholder;
builtin.claude_en_us.json extended to cover them. Context threaded into the
context-free render helpers (toolInputBody chain, _ConversationTurn) the same
way the mono family already is. No en_US behaviour change (D-21).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:19:19 +02:00

505 lines
18 KiB
Dart

/// 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');
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();
_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;
});
}
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),
);
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();
}
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) {
setState(() {
_suggestions = const [];
_activeQuery = null;
});
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
final tokens = ClideSettings.theme.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(
ClideSettings.i18n.string(context, 'teamChat.section.messages', namespace: 'builtin.claude', placeholder: 'MESSAGES'),
fontSize: clideFontSmall,
color: tokens.globalTextMuted,
),
),
Semantics(
button: true,
label: ClideSettings.i18n.string(context, 'teamChat.popOut.semantics', namespace: 'builtin.claude', placeholder: 'Open full chat pane'),
excludeSemantics: true,
onTap: widget.onPopOut,
child: ClideTappable(
tooltip: ClideSettings.i18n.string(context, 'teamChat.popOut.tooltip', namespace: 'builtin.claude', placeholder: 'Open full chat'),
onTap: widget.onPopOut,
builder: (ctx, hovered, _) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 1),
child: ClideIcon(PhosphorIcons.byName('arrows-out-simple'), 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(
ClideSettings.i18n.string(context, 'teamChat.empty', namespace: 'builtin.claude', placeholder: '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.
ClideTypeahead(
suggestions: _suggestions,
onSelect: _completeName,
formatLabel: (n) => '@$n',
child: Focus(
onKeyEvent: _handleKeyEvent,
child: _ChatInputField(
controller: _controller,
focusNode: _focusNode,
tokens: tokens,
onSubmit: _submit,
placeholder: ClideSettings.i18n.string(context, 'teamChat.composer.placeholder', namespace: 'builtin.claude', 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 _scrollController = ScrollController();
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();
_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;
});
}
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),
);
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();
}
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) {
setState(() {
_suggestions = const [];
_activeQuery = null;
});
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
final tokens = ClideSettings.theme.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(
ClideSettings.i18n.string(context, 'teamChat.pane.title', namespace: 'builtin.claude', placeholder: 'Team Chat'),
fontSize: clideFontSmall,
color: tokens.globalTextMuted,
),
),
// Timeline.
Expanded(
child: messages.isEmpty
? Center(
child: ClideText(
ClideSettings.i18n.string(context, 'teamChat.empty', namespace: 'builtin.claude', placeholder: '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: ClideSettings.i18n.string(
context,
'teamChat.interrupt.semantics',
namespace: 'builtin.claude',
placeholder: '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.byName('check'), size: 9, color: tokens.globalFocus)) : null,
),
),
ClideText(
ClideSettings.i18n.string(context, 'teamChat.interrupt.label', namespace: 'builtin.claude', placeholder: 'Interrupt'),
fontSize: clideFontSmall,
color: _interrupt ? tokens.globalForeground : tokens.globalTextMuted,
),
],
),
),
),
const SizedBox(height: 4),
// Input field.
ClideTypeahead(
suggestions: _suggestions,
onSelect: _completeName,
formatLabel: (n) => '@$n',
child: Focus(
onKeyEvent: _handleKeyEvent,
child: _ChatInputField(
controller: _controller,
focusNode: _focusNode,
tokens: tokens,
onSubmit: _submit,
placeholder: ClideSettings.i18n.string(
context,
'teamChat.composer.placeholder',
namespace: 'builtin.claude',
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,
),
);
}
}