From 60312eff3bc9ba42311ae201a379df8d40169e80 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 8 Jun 2026 22:31:47 +0200 Subject: [PATCH] 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) --- lib/builtin/claude/src/team_chat_sidebar.dart | 130 ++---------------- 1 file changed, 8 insertions(+), 122 deletions(-) diff --git a/lib/builtin/claude/src/team_chat_sidebar.dart b/lib/builtin/claude/src/team_chat_sidebar.dart index 72a63f3d..95f4c0b9 100644 --- a/lib/builtin/claude/src/team_chat_sidebar.dart +++ b/lib/builtin/claude/src/team_chat_sidebar.dart @@ -45,8 +45,6 @@ class _TeamChatSidebarState extends State { StreamSubscription? _sub; final _controller = TextEditingController(); final _focusNode = FocusNode(debugLabel: 'team-chat-sidebar'); - final _layerLink = LayerLink(); - OverlayEntry? _overlay; List _suggestions = const []; AtQuery? _activeQuery; @@ -62,7 +60,6 @@ class _TeamChatSidebarState extends State { @override void dispose() { _sub?.cancel(); - _removeOverlay(); _controller.removeListener(_onTextChanged); _controller.dispose(); _focusNode.dispose(); @@ -93,28 +90,6 @@ class _TeamChatSidebarState extends State { _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 { text: result.text, selection: TextSelection.collapsed(offset: result.cursor), ); - _removeOverlay(); setState(() { _suggestions = const []; _activeQuery = null; @@ -138,12 +112,10 @@ class _TeamChatSidebarState extends State { 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 { _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 { StreamSubscription? _sub; final _controller = TextEditingController(); final _focusNode = FocusNode(debugLabel: 'team-chat-pane'); - final _layerLink = LayerLink(); final _scrollController = ScrollController(); - OverlayEntry? _overlay; List _suggestions = const []; AtQuery? _activeQuery; bool _interrupt = false; @@ -274,7 +246,6 @@ class _TeamChatPaneState extends State { @override void dispose() { _sub?.cancel(); - _removeOverlay(); _controller.removeListener(_onTextChanged); _controller.dispose(); _focusNode.dispose(); @@ -306,28 +277,6 @@ class _TeamChatPaneState extends State { _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 { text: result.text, selection: TextSelection.collapsed(offset: result.cursor), ); - _removeOverlay(); setState(() { _suggestions = const []; _activeQuery = null; @@ -355,12 +303,10 @@ class _TeamChatPaneState extends State { 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 { ), 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 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), - ), - ), - ), - ], - ), - ), - ), - ), - ); - } -}