fix(chat): improve autocomplete popups
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user