From 3992e65c7a839b3876078ed10271d9a5f4e78d65 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 22 Aug 2026 01:04:02 +0300 Subject: [PATCH] fix(chat): improve autocomplete popups --- .../components/chat/CommandAutocomplete.tsx | 10 ++--- .../chat/FileMentionAutocomplete.tsx | 8 ++-- .../src/components/chat/SkillAutocomplete.tsx | 10 ++--- .../components/chat/SnippetAutocomplete.tsx | 2 +- .../composer/ui/AutocompleteRowTooltip.tsx | 40 +++++++++++++++++++ .../chat/useMobileAutocompleteMaxHeight.ts | 26 ++++++------ 6 files changed, 65 insertions(+), 31 deletions(-) create mode 100644 packages/ui/src/components/chat/composer/ui/AutocompleteRowTooltip.tsx diff --git a/packages/ui/src/components/chat/CommandAutocomplete.tsx b/packages/ui/src/components/chat/CommandAutocomplete.tsx index 77070181..61873d19 100644 --- a/packages/ui/src/components/chat/CommandAutocomplete.tsx +++ b/packages/ui/src/components/chat/CommandAutocomplete.tsx @@ -11,6 +11,7 @@ import { useUIStore } from '@/stores/useUIStore'; import { isVSCodeRuntime } from '@/lib/desktop'; import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight'; import { commandMatchesSearch, mergeCommandAutocompleteItems } from './commandAutocompleteItems'; +import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip'; type CommandSource = 'openchamber' | 'opencode' | 'skill'; @@ -84,7 +85,7 @@ export const CommandAutocomplete = React.forwardRef([]); const containerRef = React.useRef(null); - const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile); + const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true); const ignoreClickRef = React.useRef(false); const pointerStartRef = React.useRef<{ x: number; y: number } | null>(null); const pointerMovedRef = React.useRef(false); @@ -376,6 +377,7 @@ export const CommandAutocomplete = React.forwardRef
{ itemRefs.current[index] = el; }} @@ -471,13 +473,9 @@ export const CommandAutocomplete = React.forwardRef )}
- {command.description && !isMobile && ( -
- {command.description} -
- )} + ); })} {commands.length === 0 && ( diff --git a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx index cef55209..89bef76a 100644 --- a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx +++ b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx @@ -14,6 +14,7 @@ import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored'; import { useI18n } from '@/lib/i18n'; import { useUIStore } from '@/stores/useUIStore'; import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight'; +import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip'; type FileInfo = ProjectFileSearchHit; type AgentInfo = { @@ -80,7 +81,7 @@ export const FileMentionAutocomplete = React.forwardRef([]); const containerRef = React.useRef(null); const isMobile = useUIStore((state) => state.isMobile); - const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile); + const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true); const normalizedSearchQuery = (searchQuery ?? '').trim(); const recentFiles = React.useMemo(() => { if (!projectRoot || !projectTabs) { @@ -458,6 +459,7 @@ export const FileMentionAutocomplete = React.forwardRef { const isSelected = selectedIndex === index; return ( +
{ itemRefs.current[index] = el; }} @@ -470,11 +472,9 @@ export const FileMentionAutocomplete = React.forwardRef
@{agent.name}
- {agent.description && !isMobile ? ( -
{agent.description}
- ) : null}
+
); })} {visibleAgents.length === 2 && normalizedSearchQuery.length === 0 && agents.length > 2 && ( diff --git a/packages/ui/src/components/chat/SkillAutocomplete.tsx b/packages/ui/src/components/chat/SkillAutocomplete.tsx index b88fd3f3..42bfe583 100644 --- a/packages/ui/src/components/chat/SkillAutocomplete.tsx +++ b/packages/ui/src/components/chat/SkillAutocomplete.tsx @@ -4,6 +4,7 @@ import { useSkillsStore } from '@/stores/useSkillsStore'; import { useUIStore } from '@/stores/useUIStore'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight'; +import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip'; interface SkillInfo { name: string; @@ -31,7 +32,7 @@ export const SkillAutocomplete = React.forwardRef { const containerRef = React.useRef(null); const isMobile = useUIStore((state) => state.isMobile); - const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile); + const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true, 240); const [selectedIndex, setSelectedIndex] = React.useState(0); const selectedIndexRef = React.useRef(0); const keyboardNavigationRef = React.useRef(false); @@ -126,6 +127,7 @@ export const SkillAutocomplete = React.forwardRef
{ @@ -157,13 +159,9 @@ export const SkillAutocomplete = React.forwardRef
- {skill.description && !isMobile && ( -
- {skill.description} -
- )} + ); }; diff --git a/packages/ui/src/components/chat/SnippetAutocomplete.tsx b/packages/ui/src/components/chat/SnippetAutocomplete.tsx index dbba82fd..87125618 100644 --- a/packages/ui/src/components/chat/SnippetAutocomplete.tsx +++ b/packages/ui/src/components/chat/SnippetAutocomplete.tsx @@ -32,7 +32,7 @@ export const SnippetAutocomplete = React.forwardRef(null); const isMobile = useUIStore((state) => state.isMobile); - const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile); + const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true, 240); const [selectedIndex, setSelectedIndex] = React.useState(0); const selectedIndexRef = React.useRef(0); const [filteredSnippets, setFilteredSnippets] = React.useState([]); diff --git a/packages/ui/src/components/chat/composer/ui/AutocompleteRowTooltip.tsx b/packages/ui/src/components/chat/composer/ui/AutocompleteRowTooltip.tsx new file mode 100644 index 00000000..f85d77b8 --- /dev/null +++ b/packages/ui/src/components/chat/composer/ui/AutocompleteRowTooltip.tsx @@ -0,0 +1,40 @@ +import React from 'react'; + +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; + +interface AutocompleteRowTooltipProps { + description?: string; + active: boolean; + children: React.ReactElement; +} + +export function AutocompleteRowTooltip({ description, active, children }: AutocompleteRowTooltipProps) { + const [delayedActive, setDelayedActive] = React.useState(false); + + React.useEffect(() => { + if (!active || !description) { + setDelayedActive(false); + return; + } + + const timeout = window.setTimeout(() => setDelayedActive(true), 200); + return () => window.clearTimeout(timeout); + }, [active, description]); + + if (!description) return children; + + return ( + {}}> + {children} + {active && delayedActive ? ( + +

{description}

+
+ ) : null} +
+ ); +} diff --git a/packages/ui/src/components/chat/useMobileAutocompleteMaxHeight.ts b/packages/ui/src/components/chat/useMobileAutocompleteMaxHeight.ts index 59f7caa6..31e36129 100644 --- a/packages/ui/src/components/chat/useMobileAutocompleteMaxHeight.ts +++ b/packages/ui/src/components/chat/useMobileAutocompleteMaxHeight.ts @@ -1,22 +1,21 @@ import React from 'react'; /** - * Mobile: clamp an autocomplete popup (anchored above the composer via - * `bottom-full`) so it never rises past the top of the chat area. The chat - * `
` starts below the app header in both the Capacitor shell and the - * mobile browser, so its top edge is the correct boundary for both. + * Clamp an autocomplete popup (anchored above the composer via `bottom-full`) + * so it never rises past the top of the chat area. The chat `
` starts + * below the app header, so its top edge is the correct boundary. * * Re-measures on window resizes and when the native keyboard choreography * settles (the composer — and therefore the popup's anchor — moves with it). * - * Returns an inline max-height in px, or undefined when disabled. NOTE: the - * inline value REPLACES any `max-h-*` class (it does not combine) — on mobile - * the popup is allowed to grow all the way to the boundary, unlike the - * desktop design cap. + * Returns an inline max-height only when the available space is smaller than + * the popup's normal CSS height cap. This keeps the desktop cap intact while + * still protecting the header on tall draft composers. */ export const useMobileAutocompleteMaxHeight = ( containerRef: React.RefObject, enabled: boolean, + normalMaxHeight = 256, ): number | undefined => { const [maxHeight, setMaxHeight] = React.useState(undefined); @@ -28,16 +27,15 @@ export const useMobileAutocompleteMaxHeight = ( const main = el.closest('main'); if (!main) return; // Mobile browsers pan the page up to reveal the focused field, so - //
's top can sit ABOVE the visible screen (negative client - // coordinates). The binding boundary is whichever is lower: the - // chat area's top or the visual viewport's top (its offsetTop is - // expressed in the same layout-viewport client coordinates). + //
's top can sit above the visible screen. The binding + // boundary is whichever is lower: the chat area's top or the + // visual viewport's top. const visualTop = window.visualViewport?.offsetTop ?? 0; const boundaryTop = Math.max(main.getBoundingClientRect().top, visualTop); // The popup's bottom edge is its anchor (composer top) and does not // depend on its current height. - const available = el.getBoundingClientRect().bottom - boundaryTop - 8; - const next = Math.max(120, Math.floor(available)); + const available = Math.max(0, Math.floor(el.getBoundingClientRect().bottom - boundaryTop - 8)); + const next = available < normalMaxHeight ? available : undefined; setMaxHeight((prev) => (prev === next ? prev : next)); }; measure();