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:
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user