migrate the @-mention typeahead onto ClideTypeahead (T-288)

Both the sidebar and the full-pane chat composers hand-rolled the same
LayerLink + OverlayEntry + _showOverlay/_removeOverlay + _AtOverlay. Replace
both with ClideTypeahead driven by the suggestion list; delete _AtOverlay and
the per-copy overlay plumbing. The text parsing/completion (activeAtQuery,
filterAtNames, completeAt, parseAtTag) and the Esc handler stay in the hosts.

Behaviour is unchanged (the popover now uses the shared dropdown styling);
team_chat_sidebar_test stays green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 22:31:47 +02:00
co-authored by Claude Opus 4.8
parent 3f27dab187
commit 60312eff3b
+8 -122
View File
@@ -45,8 +45,6 @@ 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;
@@ -62,7 +60,6 @@ class _TeamChatSidebarState extends State<TeamChatSidebar> {
@override
void dispose() {
_sub?.cancel();
_removeOverlay();
_controller.removeListener(_onTextChanged);
_controller.dispose();
_focusNode.dispose();
@@ -93,28 +90,6 @@ class _TeamChatSidebarState extends State<TeamChatSidebar> {
_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) {
@@ -125,7 +100,6 @@ class _TeamChatSidebarState extends State<TeamChatSidebar> {
text: result.text,
selection: TextSelection.collapsed(offset: result.cursor),
);
_removeOverlay();
setState(() {
_suggestions = const [];
_activeQuery = null;
@@ -138,12 +112,10 @@ class _TeamChatSidebarState extends State<TeamChatSidebar> {
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;
@@ -199,8 +171,10 @@ class _TeamChatSidebarState extends State<TeamChatSidebar> {
_ChatRow(key: ValueKey(msg.at.microsecondsSinceEpoch), message: msg, tokens: tokens),
const SizedBox(height: 6),
// Quick-post composer.
CompositedTransformTarget(
link: _layerLink,
ClideTypeahead(
suggestions: _suggestions,
onSelect: _completeName,
formatLabel: (n) => '@$n',
child: Focus(
onKeyEvent: _handleKeyEvent,
child: _ChatInputField(
@@ -243,9 +217,7 @@ 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;
@@ -274,7 +246,6 @@ class _TeamChatPaneState extends State<TeamChatPane> {
@override
void dispose() {
_sub?.cancel();
_removeOverlay();
_controller.removeListener(_onTextChanged);
_controller.dispose();
_focusNode.dispose();
@@ -306,28 +277,6 @@ class _TeamChatPaneState extends State<TeamChatPane> {
_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) {
@@ -338,7 +287,6 @@ class _TeamChatPaneState extends State<TeamChatPane> {
text: result.text,
selection: TextSelection.collapsed(offset: result.cursor),
);
_removeOverlay();
setState(() {
_suggestions = const [];
_activeQuery = null;
@@ -355,12 +303,10 @@ class _TeamChatPaneState extends State<TeamChatPane> {
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;
@@ -453,8 +399,10 @@ class _TeamChatPaneState extends State<TeamChatPane> {
),
const SizedBox(height: 4),
// Input field.
CompositedTransformTarget(
link: _layerLink,
ClideTypeahead(
suggestions: _suggestions,
onSelect: _completeName,
formatLabel: (n) => '@$n',
child: Focus(
onKeyEvent: _handleKeyEvent,
child: _ChatInputField(
@@ -572,65 +520,3 @@ class _ChatInputField extends StatelessWidget {
);
}
}
/// @-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),
),
),
),
],
),
),
),
),
);
}
}