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 { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
|
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
|
||||||
import { commandMatchesSearch, mergeCommandAutocompleteItems } from './commandAutocompleteItems';
|
import { commandMatchesSearch, mergeCommandAutocompleteItems } from './commandAutocompleteItems';
|
||||||
|
import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip';
|
||||||
|
|
||||||
type CommandSource = 'openchamber' | 'opencode' | 'skill';
|
type CommandSource = 'openchamber' | 'opencode' | 'skill';
|
||||||
|
|
||||||
@@ -84,7 +85,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
const keyboardNavigationRef = React.useRef(false);
|
const keyboardNavigationRef = React.useRef(false);
|
||||||
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
||||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
|
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true);
|
||||||
const ignoreClickRef = React.useRef(false);
|
const ignoreClickRef = React.useRef(false);
|
||||||
const pointerStartRef = React.useRef<{ x: number; y: number } | null>(null);
|
const pointerStartRef = React.useRef<{ x: number; y: number } | null>(null);
|
||||||
const pointerMovedRef = React.useRef(false);
|
const pointerMovedRef = React.useRef(false);
|
||||||
@@ -376,6 +377,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
const isSystem = command.isBuiltIn;
|
const isSystem = command.isBuiltIn;
|
||||||
const isOpenChamberBadge = command.isOpenChamber;
|
const isOpenChamberBadge = command.isOpenChamber;
|
||||||
return (
|
return (
|
||||||
|
<AutocompleteRowTooltip description={command.description} active={index === selectedIndex}>
|
||||||
<div
|
<div
|
||||||
key={command.id}
|
key={command.id}
|
||||||
ref={(el) => { itemRefs.current[index] = el; }}
|
ref={(el) => { itemRefs.current[index] = el; }}
|
||||||
@@ -471,13 +473,9 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{command.description && !isMobile && (
|
|
||||||
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
|
|
||||||
{command.description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</AutocompleteRowTooltip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{commands.length === 0 && (
|
{commands.length === 0 && (
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
|||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
|
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
|
||||||
|
import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip';
|
||||||
|
|
||||||
type FileInfo = ProjectFileSearchHit;
|
type FileInfo = ProjectFileSearchHit;
|
||||||
type AgentInfo = {
|
type AgentInfo = {
|
||||||
@@ -80,7 +81,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
const measureRefs = React.useRef<(HTMLSpanElement | null)[]>([]);
|
const measureRefs = React.useRef<(HTMLSpanElement | null)[]>([]);
|
||||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
|
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true);
|
||||||
const normalizedSearchQuery = (searchQuery ?? '').trim();
|
const normalizedSearchQuery = (searchQuery ?? '').trim();
|
||||||
const recentFiles = React.useMemo(() => {
|
const recentFiles = React.useMemo(() => {
|
||||||
if (!projectRoot || !projectTabs) {
|
if (!projectRoot || !projectTabs) {
|
||||||
@@ -458,6 +459,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
{visibleAgents.map((agent, index) => {
|
{visibleAgents.map((agent, index) => {
|
||||||
const isSelected = selectedIndex === index;
|
const isSelected = selectedIndex === index;
|
||||||
return (
|
return (
|
||||||
|
<AutocompleteRowTooltip description={agent.description} active={isSelected}>
|
||||||
<div
|
<div
|
||||||
key={`agent-${agent.name}`}
|
key={`agent-${agent.name}`}
|
||||||
ref={(el) => { itemRefs.current[index] = el; }}
|
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="min-w-0 flex-1">
|
||||||
<div className="font-semibold truncate">@{agent.name}</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
</AutocompleteRowTooltip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{visibleAgents.length === 2 && normalizedSearchQuery.length === 0 && agents.length > 2 && (
|
{visibleAgents.length === 2 && normalizedSearchQuery.length === 0 && agents.length > 2 && (
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useSkillsStore } from '@/stores/useSkillsStore';
|
|||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
|
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
|
||||||
|
import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip';
|
||||||
|
|
||||||
interface SkillInfo {
|
interface SkillInfo {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -31,7 +32,7 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
|
|||||||
}, ref) => {
|
}, ref) => {
|
||||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
|
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true, 240);
|
||||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||||
const selectedIndexRef = React.useRef(0);
|
const selectedIndexRef = React.useRef(0);
|
||||||
const keyboardNavigationRef = React.useRef(false);
|
const keyboardNavigationRef = React.useRef(false);
|
||||||
@@ -126,6 +127,7 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
|
|||||||
const isProject = skill.scope === 'project';
|
const isProject = skill.scope === 'project';
|
||||||
const source = skill.source || 'opencode';
|
const source = skill.source || 'opencode';
|
||||||
return (
|
return (
|
||||||
|
<AutocompleteRowTooltip description={skill.description} active={index === selectedIndex}>
|
||||||
<div
|
<div
|
||||||
key={`${skill.name}-${skill.scope}`}
|
key={`${skill.name}-${skill.scope}`}
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
@@ -157,13 +159,9 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
|
|||||||
{source}
|
{source}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{skill.description && !isMobile && (
|
|
||||||
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
|
|
||||||
{skill.description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</AutocompleteRowTooltip>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export const SnippetAutocomplete = React.forwardRef<SnippetAutocompleteHandle, S
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
|
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true, 240);
|
||||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||||
const selectedIndexRef = React.useRef(0);
|
const selectedIndexRef = React.useRef(0);
|
||||||
const [filteredSnippets, setFilteredSnippets] = React.useState<Snippet[]>([]);
|
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';
|
import React from 'react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mobile: clamp an autocomplete popup (anchored above the composer via
|
* Clamp an autocomplete popup (anchored above the composer via `bottom-full`)
|
||||||
* `bottom-full`) so it never rises past the top of the chat area. The chat
|
* so it never rises past the top of the chat area. The chat `<main>` starts
|
||||||
* `<main>` starts below the app header in both the Capacitor shell and the
|
* below the app header, so its top edge is the correct boundary.
|
||||||
* mobile browser, so its top edge is the correct boundary for both.
|
|
||||||
*
|
*
|
||||||
* Re-measures on window resizes and when the native keyboard choreography
|
* Re-measures on window resizes and when the native keyboard choreography
|
||||||
* settles (the composer — and therefore the popup's anchor — moves with it).
|
* 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
|
* Returns an inline max-height only when the available space is smaller than
|
||||||
* inline value REPLACES any `max-h-*` class (it does not combine) — on mobile
|
* the popup's normal CSS height cap. This keeps the desktop cap intact while
|
||||||
* the popup is allowed to grow all the way to the boundary, unlike the
|
* still protecting the header on tall draft composers.
|
||||||
* desktop design cap.
|
|
||||||
*/
|
*/
|
||||||
export const useMobileAutocompleteMaxHeight = (
|
export const useMobileAutocompleteMaxHeight = (
|
||||||
containerRef: React.RefObject<HTMLElement | null>,
|
containerRef: React.RefObject<HTMLElement | null>,
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
|
normalMaxHeight = 256,
|
||||||
): number | undefined => {
|
): number | undefined => {
|
||||||
const [maxHeight, setMaxHeight] = React.useState<number | undefined>(undefined);
|
const [maxHeight, setMaxHeight] = React.useState<number | undefined>(undefined);
|
||||||
|
|
||||||
@@ -28,16 +27,15 @@ export const useMobileAutocompleteMaxHeight = (
|
|||||||
const main = el.closest('main');
|
const main = el.closest('main');
|
||||||
if (!main) return;
|
if (!main) return;
|
||||||
// Mobile browsers pan the page up to reveal the focused field, so
|
// Mobile browsers pan the page up to reveal the focused field, so
|
||||||
// <main>'s top can sit ABOVE the visible screen (negative client
|
// <main>'s top can sit above the visible screen. The binding
|
||||||
// coordinates). The binding boundary is whichever is lower: the
|
// boundary is whichever is lower: the chat area's top or the
|
||||||
// chat area's top or the visual viewport's top (its offsetTop is
|
// visual viewport's top.
|
||||||
// expressed in the same layout-viewport client coordinates).
|
|
||||||
const visualTop = window.visualViewport?.offsetTop ?? 0;
|
const visualTop = window.visualViewport?.offsetTop ?? 0;
|
||||||
const boundaryTop = Math.max(main.getBoundingClientRect().top, visualTop);
|
const boundaryTop = Math.max(main.getBoundingClientRect().top, visualTop);
|
||||||
// The popup's bottom edge is its anchor (composer top) and does not
|
// The popup's bottom edge is its anchor (composer top) and does not
|
||||||
// depend on its current height.
|
// depend on its current height.
|
||||||
const available = el.getBoundingClientRect().bottom - boundaryTop - 8;
|
const available = Math.max(0, Math.floor(el.getBoundingClientRect().bottom - boundaryTop - 8));
|
||||||
const next = Math.max(120, Math.floor(available));
|
const next = available < normalMaxHeight ? available : undefined;
|
||||||
setMaxHeight((prev) => (prev === next ? prev : next));
|
setMaxHeight((prev) => (prev === next ? prev : next));
|
||||||
};
|
};
|
||||||
measure();
|
measure();
|
||||||
|
|||||||
Reference in New Issue
Block a user