diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index efe9f79a..4390c882 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -2365,9 +2365,7 @@ const ChatInputComponent: React.FC = ({ 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 = ({ 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 diff --git a/packages/ui/src/components/chat/CommandAutocomplete.tsx b/packages/ui/src/components/chat/CommandAutocomplete.tsx index 7985563b..95abb07b 100644 --- a/packages/ui/src/components/chat/CommandAutocomplete.tsx +++ b/packages/ui/src/components/chat/CommandAutocomplete.tsx @@ -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([]); const containerRef = React.useRef(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 - + {loading ? (
@@ -363,9 +365,15 @@ export const CommandAutocomplete = React.forwardRef { 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 { pointerStartRef.current = null; @@ -414,7 +422,7 @@ export const CommandAutocomplete = React.forwardRef -
+
{getCommandIcon(command)}
@@ -448,7 +456,7 @@ export const CommandAutocomplete = React.forwardRef )}
- {command.description && ( + {command.description && !isMobile && (
{command.description}
@@ -465,9 +473,11 @@ export const CommandAutocomplete = React.forwardRef )} -
- {t('chat.autocomplete.keyboardHint')} -
+ {!isMobile && ( +
+ {t('chat.autocomplete.keyboardHint')} +
+ )}
); }); diff --git a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx index b8afcecf..cef55209 100644 --- a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx +++ b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx @@ -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([]); const measureRefs = React.useRef<(HTMLSpanElement | null)[]>([]); const containerRef = React.useRef(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 - + {loading ? (
@@ -466,7 +470,7 @@ export const FileMentionAutocomplete = React.forwardRef
@{agent.name}
- {agent.description ? ( + {agent.description && !isMobile ? (
{agent.description}
) : null}
@@ -622,9 +626,11 @@ export const FileMentionAutocomplete = React.forwardRef )} -
- {t('chat.autocomplete.keyboardHint')} -
+ {!isMobile && ( +
+ {t('chat.autocomplete.keyboardHint')} +
+ )}
); }); diff --git a/packages/ui/src/components/chat/SkillAutocomplete.tsx b/packages/ui/src/components/chat/SkillAutocomplete.tsx index 7dae3359..b88fd3f3 100644 --- a/packages/ui/src/components/chat/SkillAutocomplete.tsx +++ b/packages/ui/src/components/chat/SkillAutocomplete.tsx @@ -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 { const containerRef = React.useRef(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 onSkillSelect(skill.name)} @@ -152,7 +157,7 @@ export const SkillAutocomplete = React.forwardRef
- {skill.description && ( + {skill.description && !isMobile && (
{skill.description}
@@ -166,9 +171,9 @@ export const SkillAutocomplete = React.forwardRef - + {filteredSkills.length ? (
{filteredSkills.map((skill, index) => renderSkill(skill, index))} @@ -179,9 +184,11 @@ export const SkillAutocomplete = React.forwardRef )} -
- ↑↓ navigate • Enter select • Esc close -
+ {!isMobile && ( +
+ ↑↓ navigate • Enter select • Esc close +
+ )}
); }); diff --git a/packages/ui/src/components/chat/SnippetAutocomplete.tsx b/packages/ui/src/components/chat/SnippetAutocomplete.tsx index b85165db..dbba82fd 100644 --- a/packages/ui/src/components/chat/SnippetAutocomplete.tsx +++ b/packages/ui/src/components/chat/SnippetAutocomplete.tsx @@ -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 { const { t } = useI18n(); const containerRef = React.useRef(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([]); @@ -120,8 +123,8 @@ export const SnippetAutocomplete = React.forwardRef - +
+
{ 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 { 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#{snippet.name} {t(`snippets.source.${snippet.source}`)}
-
{snippetPreview(snippet)}
+ {!isMobile && ( +
{snippetPreview(snippet)}
+ )}
)) : (
{t('chat.snippetAutocomplete.empty')}
)}
-
{t('chat.snippetAutocomplete.footer')}
+ {!isMobile && ( +
{t('chat.snippetAutocomplete.footer')}
+ )} ); }); diff --git a/packages/ui/src/components/chat/useMobileAutocompleteMaxHeight.ts b/packages/ui/src/components/chat/useMobileAutocompleteMaxHeight.ts new file mode 100644 index 00000000..59f7caa6 --- /dev/null +++ b/packages/ui/src/components/chat/useMobileAutocompleteMaxHeight.ts @@ -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 + * `
` 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, + enabled: boolean, +): number | undefined => { + const [maxHeight, setMaxHeight] = React.useState(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 + //
'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; +};