feat(mobile): autocomplete redesign for touch surfaces

Command, file/agent, skill, and snippet autocompletes now stop at the top
of the chat area (Capacitor) or the visible viewport edge (mobile
browsers, which pan the page for the keyboard) and may grow that far,
measured live across keyboard settles and viewport changes. On mobile the
keyboard-hint footer and description lines are gone, rows center their
icons, and list overscroll no longer bounces the page behind. Selecting a
command no longer dismisses the keyboard (its rows now block the tap's
focus steal like the composer buttons), and the dead dismissKeyboard
option is removed.
This commit is contained in:
Bohdan Triapitsyn
2026-07-05 02:58:41 +03:00
parent da78de3a21
commit d762b69cec
6 changed files with 121 additions and 38 deletions
+6 -10
View File
@@ -2365,9 +2365,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}
}, [inputMode, getCurrentInputSnapshot, currentSessionId, sessionPhase, autoReviewRunning, followUpBehavior, handleQueueMessage]);
// Draft welcome presets: populate the composer and submit immediately.
// getCurrentInputSnapshot reads textareaRef.current.value first, so setting it
// synchronously lets handleSubmit pick up the preset text in the same tick.
// Draft welcome presets: submit immediately.
const submitPresetPrompt = React.useCallback((text: string) => {
// The text goes straight into the submit (see SubmitOptions.presetText)
// instead of through the composer input — the collapsed mobile pill has
@@ -2393,13 +2391,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}, []);
const handleDictationInsertAndSend = React.useCallback((text: string) => {
const textarea = textareaRef.current;
const next = appendInlineText(textarea?.value ?? messageRef.current, text);
if (textarea) {
textarea.value = next;
}
setMessage(next);
void handleSubmitRef.current();
// Same as preset chips: the composed text goes into the submit as an
// explicit override instead of being staged in the textarea, which may
// not be mounted (collapsed mobile pill).
const next = appendInlineText(textareaRef.current?.value ?? messageRef.current, text);
void handleSubmitRef.current({ presetText: next });
}, []);
// Preset chips rendered outside this component (e.g. under the welcome
@@ -9,6 +9,7 @@ import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n';
import { useUIStore } from '@/stores/useUIStore';
import { isVSCodeRuntime } from '@/lib/desktop';
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
type CommandSource = 'openchamber' | 'opencode' | 'skill';
@@ -49,7 +50,7 @@ const NEUTRAL_BADGE_CLASS = cn(
interface CommandAutocompleteProps {
searchQuery: string;
onCommandSelect: (command: CommandInfo, options?: { dismissKeyboard?: boolean }) => void;
onCommandSelect: (command: CommandInfo) => void;
onClose: () => void;
style?: React.CSSProperties;
}
@@ -81,6 +82,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 ignoreClickRef = React.useRef(false);
const pointerStartRef = React.useRef<{ x: number; y: number } | null>(null);
const pointerMovedRef = React.useRef(false);
@@ -346,9 +348,9 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
<div
ref={containerRef}
className="absolute z-[100] min-w-0 w-full max-w-[450px] max-h-64 bg-background border-2 border-border/60 rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col"
style={style}
style={mobileMaxHeight !== undefined ? { ...style, maxHeight: mobileMaxHeight } : style}
>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0 pb-2">
<ScrollableOverlay preventOverscroll outerClassName="flex-1 min-h-0" className="px-0 pb-2">
{loading ? (
<div className="flex items-center justify-center py-4">
<Icon name="refresh" className="h-4 w-4 animate-spin text-muted-foreground" />
@@ -363,9 +365,15 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
key={command.id}
ref={(el) => { itemRefs.current[index] = el; }}
className={cn(
"flex items-start gap-2 px-3 py-2 cursor-pointer rounded-lg",
"flex gap-2 px-3 py-2 cursor-pointer rounded-lg",
isMobile ? "items-center" : "items-start",
index === selectedIndex && "bg-interactive-selection"
)}
// Block the focus transfer the tap would perform: the textarea
// must stay focused so selecting a command doesn't dismiss the
// soft keyboard (the blur raced the keyboard-hide trigger and
// won against the deferred refocus).
onMouseDown={(event) => event.preventDefault()}
onPointerDown={(event) => {
if (event.pointerType !== 'touch') {
return;
@@ -396,7 +404,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
event.preventDefault();
event.stopPropagation();
ignoreClickRef.current = true;
onCommandSelect(command, { dismissKeyboard: true });
onCommandSelect(command);
}}
onPointerCancel={() => {
pointerStartRef.current = null;
@@ -414,7 +422,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
setSelectedIndex(index);
}}
>
<div className="mt-0.5">
<div className={cn(!isMobile && "mt-0.5")}>
{getCommandIcon(command)}
</div>
<div className="flex-1 min-w-0">
@@ -448,7 +456,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
</span>
)}
</div>
{command.description && (
{command.description && !isMobile && (
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
{command.description}
</div>
@@ -465,9 +473,11 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
</div>
)}
</ScrollableOverlay>
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
{t('chat.autocomplete.keyboardHint')}
</div>
{!isMobile && (
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
{t('chat.autocomplete.keyboardHint')}
</div>
)}
</div>
);
});
@@ -12,6 +12,8 @@ import { Icon } from "@/components/icon/Icon";
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
import { useI18n } from '@/lib/i18n';
import { useUIStore } from '@/stores/useUIStore';
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
type FileInfo = ProjectFileSearchHit;
type AgentInfo = {
@@ -77,6 +79,8 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
const labelRefs = React.useRef<(HTMLSpanElement | null)[]>([]);
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 normalizedSearchQuery = (searchQuery ?? '').trim();
const recentFiles = React.useMemo(() => {
if (!projectRoot || !projectTabs) {
@@ -442,9 +446,9 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
<div
ref={containerRef}
className="absolute z-[100] min-w-0 w-full max-w-[640px] max-h-64 bg-background border-2 border-border/60 rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col"
style={style}
style={mobileMaxHeight !== undefined ? { ...style, maxHeight: mobileMaxHeight } : style}
>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0">
<ScrollableOverlay preventOverscroll outerClassName="flex-1 min-h-0" className="px-0">
{loading ? (
<div className="flex items-center justify-center py-4">
<Icon name="refresh" className="h-4 w-4 animate-spin text-muted-foreground" />
@@ -466,7 +470,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
>
<div className="min-w-0 flex-1">
<div className="font-semibold truncate">@{agent.name}</div>
{agent.description ? (
{agent.description && !isMobile ? (
<div className="typography-meta text-muted-foreground truncate">{agent.description}</div>
) : null}
</div>
@@ -622,9 +626,11 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
</div>
)}
</ScrollableOverlay>
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
{t('chat.autocomplete.keyboardHint')}
</div>
{!isMobile && (
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
{t('chat.autocomplete.keyboardHint')}
</div>
)}
</div>
);
});
@@ -1,7 +1,9 @@
import React from 'react';
import { cn, fuzzyMatch } from '@/lib/utils';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { useUIStore } from '@/stores/useUIStore';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
interface SkillInfo {
name: string;
@@ -28,6 +30,8 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
style,
}, ref) => {
const containerRef = React.useRef<HTMLDivElement | null>(null);
const isMobile = useUIStore((state) => state.isMobile);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
const [selectedIndex, setSelectedIndex] = React.useState(0);
const selectedIndexRef = React.useRef(0);
const keyboardNavigationRef = React.useRef(false);
@@ -128,7 +132,8 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
itemRefs.current[index] = el;
}}
className={cn(
'flex items-start gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label',
'flex gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label',
isMobile ? 'items-center' : 'items-start',
index === selectedIndex && 'bg-interactive-selection'
)}
onClick={() => onSkillSelect(skill.name)}
@@ -152,7 +157,7 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
{source}
</span>
</div>
{skill.description && (
{skill.description && !isMobile && (
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
{skill.description}
</div>
@@ -166,9 +171,9 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
<div
ref={containerRef}
className="absolute z-[100] min-w-0 w-full max-w-[450px] max-h-60 bg-background border-2 border-border/60 rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col"
style={style}
style={mobileMaxHeight !== undefined ? { ...style, maxHeight: mobileMaxHeight } : style}
>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0 pb-2">
<ScrollableOverlay preventOverscroll outerClassName="flex-1 min-h-0" className="px-0 pb-2">
{filteredSkills.length ? (
<div>
{filteredSkills.map((skill, index) => renderSkill(skill, index))}
@@ -179,9 +184,11 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
</div>
)}
</ScrollableOverlay>
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
navigate Enter select Esc close
</div>
{!isMobile && (
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
navigate Enter select Esc close
</div>
)}
</div>
);
});
@@ -6,6 +6,7 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import type { Snippet } from '@/types/snippet';
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
export interface SnippetAutocompleteHandle {
handleKeyDown: (key: string) => void;
@@ -30,6 +31,8 @@ export const SnippetAutocomplete = React.forwardRef<SnippetAutocompleteHandle, S
}, ref) => {
const { t } = useI18n();
const containerRef = React.useRef<HTMLDivElement | null>(null);
const isMobile = useUIStore((state) => state.isMobile);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
const [selectedIndex, setSelectedIndex] = React.useState(0);
const selectedIndexRef = React.useRef(0);
const [filteredSnippets, setFilteredSnippets] = React.useState<Snippet[]>([]);
@@ -120,8 +123,8 @@ export const SnippetAutocomplete = React.forwardRef<SnippetAutocompleteHandle, S
}), [chooseSnippet, filteredSnippets, onClose, openNewSnippetSettings]);
return (
<div ref={containerRef} className="absolute z-[100] min-w-0 w-full max-w-[450px] max-h-60 bg-background border-2 border-border/60 rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col" style={style}>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0 pb-2">
<div ref={containerRef} className="absolute z-[100] min-w-0 w-full max-w-[450px] max-h-60 bg-background border-2 border-border/60 rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col" style={mobileMaxHeight !== undefined ? { ...style, maxHeight: mobileMaxHeight } : style}>
<ScrollableOverlay preventOverscroll outerClassName="flex-1 min-h-0" className="px-0 pb-2">
<div
ref={(el) => { itemRefs.current[0] = el; }}
className={cn('flex items-center gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label', selectedIndex === 0 && 'bg-interactive-selection')}
@@ -135,7 +138,7 @@ export const SnippetAutocomplete = React.forwardRef<SnippetAutocompleteHandle, S
<div
key={`${snippet.source}:${snippet.filePath}`}
ref={(el) => { itemRefs.current[index + 1] = el; }}
className={cn('flex items-start gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label', index + 1 === selectedIndex && 'bg-interactive-selection')}
className={cn('flex gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label', isMobile ? 'items-center' : 'items-start', index + 1 === selectedIndex && 'bg-interactive-selection')}
onClick={() => chooseSnippet(snippet)}
onMouseMove={() => setSelectedIndex(index + 1)}
>
@@ -144,14 +147,18 @@ export const SnippetAutocomplete = React.forwardRef<SnippetAutocompleteHandle, S
<span className="font-semibold truncate">#{snippet.name}</span>
<span className="text-[10px] leading-none uppercase font-bold tracking-tight px-1.5 py-1 rounded border flex-shrink-0 bg-[var(--surface-muted)] text-muted-foreground border-[var(--interactive-border)]/60">{t(`snippets.source.${snippet.source}`)}</span>
</div>
<div className="typography-meta text-muted-foreground mt-0.5 truncate">{snippetPreview(snippet)}</div>
{!isMobile && (
<div className="typography-meta text-muted-foreground mt-0.5 truncate">{snippetPreview(snippet)}</div>
)}
</div>
</div>
)) : (
<div className="px-3 py-2 typography-ui-label text-muted-foreground">{t('chat.snippetAutocomplete.empty')}</div>
)}
</ScrollableOverlay>
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">{t('chat.snippetAutocomplete.footer')}</div>
{!isMobile && (
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">{t('chat.snippetAutocomplete.footer')}</div>
)}
</div>
);
});
@@ -0,0 +1,57 @@
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.
*
* 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.
*/
export const useMobileAutocompleteMaxHeight = (
containerRef: React.RefObject<HTMLElement | null>,
enabled: boolean,
): number | undefined => {
const [maxHeight, setMaxHeight] = React.useState<number | undefined>(undefined);
React.useLayoutEffect(() => {
if (!enabled) return;
const measure = () => {
const el = containerRef.current;
if (!el) return;
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).
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));
setMaxHeight((prev) => (prev === next ? prev : next));
};
measure();
window.addEventListener('resize', measure);
window.addEventListener('oc:keyboard-settled', measure);
window.visualViewport?.addEventListener('resize', measure);
window.visualViewport?.addEventListener('scroll', measure);
return () => {
window.removeEventListener('resize', measure);
window.removeEventListener('oc:keyboard-settled', measure);
window.visualViewport?.removeEventListener('resize', measure);
window.visualViewport?.removeEventListener('scroll', measure);
};
});
return enabled ? maxHeight : undefined;
};