fix(chat): improve autocomplete popups

This commit is contained in:
Bohdan Triapitsyn
2026-08-22 01:10:19 +03:00
parent 64006cf2cd
commit 3992e65c7a
6 changed files with 65 additions and 31 deletions
@@ -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<CommandAutocompleteHandle, C
const keyboardNavigationRef = React.useRef(false);
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const containerRef = React.useRef<HTMLDivElement | null>(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<CommandAutocompleteHandle, C
const isSystem = command.isBuiltIn;
const isOpenChamberBadge = command.isOpenChamber;
return (
<AutocompleteRowTooltip description={command.description} active={index === selectedIndex}>
<div
key={command.id}
ref={(el) => { itemRefs.current[index] = el; }}
@@ -471,13 +473,9 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
</span>
)}
</div>
{command.description && !isMobile && (
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
{command.description}
</div>
)}
</div>
</div>
</AutocompleteRowTooltip>
);
})}
{commands.length === 0 && (
@@ -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<FileMentionHandle, FileM
const measureRefs = React.useRef<(HTMLSpanElement | null)[]>([]);
const containerRef = React.useRef<HTMLDivElement | null>(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<FileMentionHandle, FileM
{visibleAgents.map((agent, index) => {
const isSelected = selectedIndex === index;
return (
<AutocompleteRowTooltip description={agent.description} active={isSelected}>
<div
key={`agent-${agent.name}`}
ref={(el) => { itemRefs.current[index] = el; }}
@@ -470,11 +472,9 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
>
<div className="min-w-0 flex-1">
<div className="font-semibold truncate">@{agent.name}</div>
{agent.description && !isMobile ? (
<div className="typography-meta text-muted-foreground truncate">{agent.description}</div>
) : null}
</div>
</div>
</AutocompleteRowTooltip>
);
})}
{visibleAgents.length === 2 && normalizedSearchQuery.length === 0 && agents.length > 2 && (
@@ -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<SkillAutocompleteHandle, Skill
}, ref) => {
const containerRef = React.useRef<HTMLDivElement | null>(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<SkillAutocompleteHandle, Skill
const isProject = skill.scope === 'project';
const source = skill.source || 'opencode';
return (
<AutocompleteRowTooltip description={skill.description} active={index === selectedIndex}>
<div
key={`${skill.name}-${skill.scope}`}
ref={(el) => {
@@ -157,13 +159,9 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
{source}
</span>
</div>
{skill.description && !isMobile && (
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
{skill.description}
</div>
)}
</div>
</div>
</AutocompleteRowTooltip>
);
};
@@ -32,7 +32,7 @@ export const SnippetAutocomplete = React.forwardRef<SnippetAutocompleteHandle, S
const { t } = useI18n();
const containerRef = React.useRef<HTMLDivElement | null>(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<Snippet[]>([]);
@@ -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 (
<Tooltip delayDuration={0} open={active && delayedActive} onOpenChange={() => {}}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
{active && delayedActive ? (
<TooltipContent
side="right"
sideOffset={8}
className="max-w-xs text-left transition-none data-[starting-style]:opacity-100 data-[starting-style]:scale-100 data-[ending-style]:opacity-100 data-[ending-style]:scale-100"
>
<p className="typography-meta whitespace-pre-wrap">{description}</p>
</TooltipContent>
) : null}
</Tooltip>
);
}
@@ -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
* `<main>` 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 `<main>` 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<HTMLElement | null>,
enabled: boolean,
normalMaxHeight = 256,
): number | undefined => {
const [maxHeight, setMaxHeight] = React.useState<number | undefined>(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
// <main>'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).
// <main>'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();