From 96a80036f6050e8dd078429f61873e85aaae5c9c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 18 Feb 2026 11:58:57 +0700 Subject: [PATCH] Improve desktop command autocomplete to match combobox behavior (#53) * feat(app): align command autocomplete with desktop combobox * refactor(app): extract shared autocomplete UI and command hook * refactor(app): rename autocomplete hooks to generic naming --- .../app/src/components/agent-input-area.tsx | 139 ++-------- .../src/components/command-autocomplete.tsx | 170 ------------ .../components/ui/autocomplete-utils.test.ts | 62 +++++ .../src/components/ui/autocomplete-utils.ts | 56 ++++ .../app/src/components/ui/autocomplete.tsx | 243 ++++++++++++++++++ .../app/src/hooks/use-agent-autocomplete.ts | 125 +++++++++ packages/app/src/hooks/use-autocomplete.ts | 120 +++++++++ 7 files changed, 631 insertions(+), 284 deletions(-) delete mode 100644 packages/app/src/components/command-autocomplete.tsx create mode 100644 packages/app/src/components/ui/autocomplete-utils.test.ts create mode 100644 packages/app/src/components/ui/autocomplete-utils.ts create mode 100644 packages/app/src/components/ui/autocomplete.tsx create mode 100644 packages/app/src/hooks/use-agent-autocomplete.ts create mode 100644 packages/app/src/hooks/use-autocomplete.ts diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 0c8452f21..49aa47c82 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -5,7 +5,7 @@ import { ActivityIndicator, Platform, } from "react-native"; -import { useState, useEffect, useRef, useCallback, useMemo } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native"; import Animated, { useAnimatedStyle } from "react-native-reanimated"; @@ -25,11 +25,7 @@ import { type MessageInputRef, } from "./message-input"; import { Theme } from "@/styles/theme"; -import { CommandAutocomplete } from "./command-autocomplete"; -import { - useAgentCommandsQuery, - type DraftCommandConfig, -} from "@/hooks/use-agent-commands-query"; +import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query"; import { encodeImages } from "@/utils/encode-images"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { focusWithRetries } from "@/utils/web-focus"; @@ -37,6 +33,8 @@ import { useVoiceOptional } from "@/contexts/voice-context"; import { useToast } from "@/contexts/toast-context"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Shortcut } from "@/components/ui/shortcut"; +import { Autocomplete } from "@/components/ui/autocomplete"; +import { useAgentAutocomplete } from "@/hooks/use-agent-autocomplete"; type QueuedMessage = { id: string; @@ -117,61 +115,20 @@ export function AgentInputArea({ const [selectedImages, setSelectedImages] = useState([]); const [isCancellingAgent, setIsCancellingAgent] = useState(false); const [sendError, setSendError] = useState(null); - const [commandSelectedIndex, setCommandSelectedIndex] = useState(0); const lastHandledMessageInputActionRequestIdRef = useRef(null); + const messageInputRef = useRef(null); - // Command autocomplete logic - const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" "); - const commandFilter = showCommandAutocomplete ? userInput.slice(1) : ""; - const normalizedCommandDraftConfig = useMemo(() => { - if (!commandDraftConfig) { - return undefined; - } - - const cwd = commandDraftConfig.cwd.trim(); - if (!cwd) { - return undefined; - } - - const modeId = commandDraftConfig.modeId?.trim() ?? ""; - const model = commandDraftConfig.model?.trim() ?? ""; - const thinkingOptionId = commandDraftConfig.thinkingOptionId?.trim() ?? ""; - return { - provider: commandDraftConfig.provider, - cwd, - ...(modeId ? { modeId } : {}), - ...(model ? { model } : {}), - ...(thinkingOptionId ? { thinkingOptionId } : {}), - }; - }, [commandDraftConfig]); - - // Prefetch commands for real agents and for draft screens when context is available. - const isRealAgent = agentId && !agentId.startsWith("__"); - const commandQueryDraftConfig = isRealAgent - ? undefined - : normalizedCommandDraftConfig; - const canLoadCommands = Boolean(serverId) && (isRealAgent || !!commandQueryDraftConfig); - const { commands } = useAgentCommandsQuery({ + const autocomplete = useAgentAutocomplete({ + userInput, + setUserInput, serverId, agentId, - enabled: canLoadCommands, - draftConfig: commandQueryDraftConfig, + draftConfig: commandDraftConfig, + onAutocompleteApplied: () => { + messageInputRef.current?.focus(); + }, }); - // Filter commands for keyboard navigation - const filteredCommands = useMemo(() => { - if (!showCommandAutocomplete) return []; - const filterLower = commandFilter.toLowerCase(); - return commands.filter((cmd) => - cmd.name.toLowerCase().includes(filterLower) - ); - }, [commands, commandFilter, showCommandAutocomplete]); - - // Reset selection when filter changes - useEffect(() => { - setCommandSelectedIndex(0); - }, [commandFilter]); - // Clear send error when user edits the input useEffect(() => { if (sendError && userInput) { @@ -185,7 +142,6 @@ export function AgentInputArea({ ((agentId: string, text: string, images?: ImageAttachment[]) => Promise) | null >(null); const onSubmitMessageRef = useRef(onSubmitMessage); - const messageInputRef = useRef(null); // Expose addImages function to parent for drag-and-drop support const addImages = useCallback((images: ImageAttachment[]) => { @@ -592,17 +548,9 @@ export function AgentInputArea({ queueMessage(payload.text, payload.images); }, []); - // Handle command selection from autocomplete - const handleCommandSelect = useCallback( - (cmd: { name: string; description: string; argumentHint: string }) => { - setUserInput(`/${cmd.name} `); - messageInputRef.current?.focus(); - }, - [setUserInput] - ); const hasSendableContent = userInput.trim().length > 0 || selectedImages.length > 0; - // Handle keyboard navigation for command autocomplete + // Handle keyboard navigation for command autocomplete and stop action. const handleCommandKeyPress = useCallback( (event: { key: string; preventDefault: () => void }) => { if ( @@ -617,53 +565,15 @@ export function AgentInputArea({ return true; } - if (!showCommandAutocomplete || filteredCommands.length === 0) { - return false; - } - - if (event.key === "ArrowUp") { - event.preventDefault(); - setCommandSelectedIndex((prev) => - prev > 0 ? prev - 1 : filteredCommands.length - 1 - ); - return true; - } - - if (event.key === "ArrowDown") { - event.preventDefault(); - setCommandSelectedIndex((prev) => - prev < filteredCommands.length - 1 ? prev + 1 : 0 - ); - return true; - } - - if (event.key === "Tab" || event.key === "Enter") { - event.preventDefault(); - const selected = filteredCommands[commandSelectedIndex]; - if (selected) { - handleCommandSelect(selected); - } - return true; - } - - if (event.key === "Escape") { - event.preventDefault(); - setUserInput(""); - return true; - } - - return false; + return autocomplete.onKeyPress(event); }, [ - showCommandAutocomplete, - filteredCommands, - commandSelectedIndex, - handleCommandSelect, + autocomplete, hasSendableContent, isAgentRunning, isCancellingAgent, isConnected, - setUserInput, + handleCancelAgent, ] ); @@ -775,14 +685,15 @@ export function AgentInputArea({ )} {/* Command autocomplete dropdown */} - {showCommandAutocomplete && canLoadCommands && ( - )} diff --git a/packages/app/src/components/command-autocomplete.tsx b/packages/app/src/components/command-autocomplete.tsx deleted file mode 100644 index 29e03f5e4..000000000 --- a/packages/app/src/components/command-autocomplete.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { View, Text, Pressable, ScrollView } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { - useAgentCommandsQuery, - type DraftCommandConfig, -} from "@/hooks/use-agent-commands-query"; -import { Fonts } from "@/constants/theme"; -import { Theme } from "@/styles/theme"; - -interface AgentSlashCommand { - name: string; - description: string; - argumentHint: string; -} - -interface CommandAutocompleteProps { - serverId: string; - agentId: string; - filter: string; - selectedIndex: number; - onSelect: (command: AgentSlashCommand) => void; - draftConfig?: DraftCommandConfig; -} - -export function CommandAutocomplete({ - serverId, - agentId, - filter, - selectedIndex, - onSelect, - draftConfig, -}: CommandAutocompleteProps) { - const { theme } = useUnistyles(); - const { commands, isLoading, isError, error } = useAgentCommandsQuery({ - serverId, - agentId, - enabled: true, - draftConfig, - }); - - // Filter commands based on input after / - const filterLower = filter.toLowerCase(); - const filteredCommands = commands.filter((cmd) => - cmd.name.toLowerCase().includes(filterLower) - ); - - if (isLoading) { - return ( - - - Loading commands... - - - ); - } - - if (isError) { - return ( - - - Error: {error?.message ?? "Failed to load"} - - - ); - } - - if (filteredCommands.length === 0) { - return ( - - - No commands found - - - ); - } - - return ( - - - {filteredCommands.map((cmd, index) => { - const isSelected = index === selectedIndex; - return ( - onSelect(cmd)} - style={[ - styles.commandItem, - isSelected && { - backgroundColor: theme.colors.accent, - }, - ]} - > - - /{cmd.name} - {cmd.argumentHint && ( - {cmd.argumentHint} - )} - - - {cmd.description} - - - ); - })} - - - ); -} - -export function useCommandAutocomplete(commands: AgentSlashCommand[], filter: string) { - const filterLower = filter.toLowerCase(); - return commands.filter((cmd) => cmd.name.toLowerCase().includes(filterLower)); -} - -const styles = StyleSheet.create(((theme: Theme) => ({ - container: { - backgroundColor: theme.colors.surface2, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - borderRadius: theme.borderRadius.lg, - maxHeight: 200, - }, - scrollView: { - flexGrow: 0, - flexShrink: 1, - }, - commandItem: { - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[2], - borderBottomWidth: theme.borderWidth[1], - borderBottomColor: theme.colors.border, - }, - commandHeader: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, - commandName: { - color: theme.colors.foreground, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.medium, - fontFamily: Fonts.mono, - }, - commandArgs: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - fontFamily: Fonts.mono, - }, - commandDescription: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - marginTop: 2, - }, - loadingItem: { - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[3], - }, - loadingText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - }, - emptyItem: { - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[3], - }, - emptyText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - }, -})) as any) as Record; diff --git a/packages/app/src/components/ui/autocomplete-utils.test.ts b/packages/app/src/components/ui/autocomplete-utils.test.ts new file mode 100644 index 000000000..924051933 --- /dev/null +++ b/packages/app/src/components/ui/autocomplete-utils.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; + +import { + getAutocompleteFallbackIndex, + getAutocompleteScrollOffset, + orderAutocompleteOptions, +} from "./autocomplete-utils"; + +const OPTIONS = ["alpha", "beta", "gamma"]; + +describe("orderAutocompleteOptions", () => { + it("keeps first logical option closest to the input by default", () => { + expect(orderAutocompleteOptions(OPTIONS)).toEqual([ + "gamma", + "beta", + "alpha", + ]); + }); + + it("keeps normal top-down order when below-input is selected", () => { + expect(orderAutocompleteOptions(OPTIONS, "below-input")).toEqual([ + "alpha", + "beta", + "gamma", + ]); + }); +}); + +describe("getAutocompleteFallbackIndex", () => { + it("picks the option nearest the input by default", () => { + expect(getAutocompleteFallbackIndex(3)).toBe(2); + expect(getAutocompleteFallbackIndex(0)).toBe(-1); + }); + + it("picks top item when below-input ordering is used", () => { + expect(getAutocompleteFallbackIndex(3, "below-input")).toBe(0); + }); +}); + +describe("getAutocompleteScrollOffset", () => { + it("scrolls up when the active item is above the viewport", () => { + expect( + getAutocompleteScrollOffset({ + currentOffset: 120, + viewportHeight: 80, + itemTop: 90, + itemHeight: 20, + }) + ).toBe(90); + }); + + it("scrolls down when the active item is below the viewport", () => { + expect( + getAutocompleteScrollOffset({ + currentOffset: 0, + viewportHeight: 100, + itemTop: 150, + itemHeight: 24, + }) + ).toBe(74); + }); +}); diff --git a/packages/app/src/components/ui/autocomplete-utils.ts b/packages/app/src/components/ui/autocomplete-utils.ts new file mode 100644 index 000000000..cc22ecda1 --- /dev/null +++ b/packages/app/src/components/ui/autocomplete-utils.ts @@ -0,0 +1,56 @@ +import { getNextActiveIndex } from "./combobox-keyboard"; + +export type AutocompleteOptionsPosition = "above-input" | "below-input"; + +export function orderAutocompleteOptions( + options: readonly T[], + position: AutocompleteOptionsPosition = "above-input" +): T[] { + if (position === "below-input") { + return [...options]; + } + return [...options].reverse(); +} + +export function getAutocompleteFallbackIndex( + itemCount: number, + position: AutocompleteOptionsPosition = "above-input" +): number { + if (itemCount <= 0) { + return -1; + } + return position === "above-input" ? itemCount - 1 : 0; +} + +export function getAutocompleteNextIndex(args: { + currentIndex: number; + itemCount: number; + key: "ArrowDown" | "ArrowUp"; +}): number { + return getNextActiveIndex(args); +} + +export function getAutocompleteScrollOffset(args: { + currentOffset: number; + viewportHeight: number; + itemTop: number; + itemHeight: number; +}): number { + if (args.viewportHeight <= 0) { + return args.currentOffset; + } + + const itemBottom = args.itemTop + args.itemHeight; + const viewportTop = args.currentOffset; + const viewportBottom = args.currentOffset + args.viewportHeight; + + if (args.itemTop < viewportTop) { + return Math.max(0, args.itemTop); + } + + if (itemBottom > viewportBottom) { + return Math.max(0, itemBottom - args.viewportHeight); + } + + return args.currentOffset; +} diff --git a/packages/app/src/components/ui/autocomplete.tsx b/packages/app/src/components/ui/autocomplete.tsx new file mode 100644 index 000000000..3c5fdbc6f --- /dev/null +++ b/packages/app/src/components/ui/autocomplete.tsx @@ -0,0 +1,243 @@ +import { useCallback, useEffect, useRef } from "react"; +import { ScrollView, Text, View, Pressable, type LayoutChangeEvent } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { Theme } from "@/styles/theme"; +import { getAutocompleteScrollOffset } from "./autocomplete-utils"; + +export interface AutocompleteOption { + id: string; + label: string; + detail?: string; + description?: string; +} + +interface AutocompleteProps { + options: readonly AutocompleteOption[]; + selectedIndex: number; + onSelect: (option: AutocompleteOption) => void; + isLoading?: boolean; + errorMessage?: string; + loadingText?: string; + emptyText?: string; + maxHeight?: number; +} + +export function Autocomplete({ + options, + selectedIndex, + onSelect, + isLoading = false, + errorMessage, + loadingText = "Loading...", + emptyText = "No results found", + maxHeight = 220, +}: AutocompleteProps) { + const scrollRef = useRef(null); + const rowLayoutsRef = useRef>(new Map()); + const viewportHeightRef = useRef(0); + const scrollOffsetRef = useRef(0); + + const ensureActiveItemVisible = useCallback(() => { + if (selectedIndex < 0) { + return; + } + + const layout = rowLayoutsRef.current.get(selectedIndex); + if (!layout) { + return; + } + + const nextOffset = getAutocompleteScrollOffset({ + currentOffset: scrollOffsetRef.current, + viewportHeight: viewportHeightRef.current, + itemTop: layout.top, + itemHeight: layout.height, + }); + + if (Math.abs(nextOffset - scrollOffsetRef.current) < 1) { + return; + } + + scrollOffsetRef.current = nextOffset; + scrollRef.current?.scrollTo({ y: nextOffset, animated: false }); + }, [selectedIndex]); + + const pinToBottom = useCallback(() => { + scrollRef.current?.scrollToEnd({ animated: false }); + requestAnimationFrame(() => { + scrollRef.current?.scrollToEnd({ animated: false }); + }); + }, []); + + useEffect(() => { + rowLayoutsRef.current.clear(); + scrollOffsetRef.current = 0; + }, [options]); + + useEffect(() => { + if (options.length === 0) { + return; + } + pinToBottom(); + }, [options, pinToBottom]); + + useEffect(() => { + const raf = requestAnimationFrame(ensureActiveItemVisible); + return () => { + cancelAnimationFrame(raf); + }; + }, [ensureActiveItemVisible, options.length]); + + const handleScrollViewLayout = useCallback( + (event: LayoutChangeEvent) => { + viewportHeightRef.current = event.nativeEvent.layout.height; + ensureActiveItemVisible(); + }, + [ensureActiveItemVisible] + ); + + const handleRowLayout = useCallback( + (index: number, event: LayoutChangeEvent) => { + rowLayoutsRef.current.set(index, { + top: event.nativeEvent.layout.y, + height: event.nativeEvent.layout.height, + }); + ensureActiveItemVisible(); + }, + [ensureActiveItemVisible] + ); + + if (isLoading) { + return ( + + + {loadingText} + + + ); + } + + if (errorMessage) { + return ( + + + Error: {errorMessage} + + + ); + } + + if (options.length === 0) { + return ( + + + {emptyText} + + + ); + } + + return ( + + { + scrollOffsetRef.current = event.nativeEvent.contentOffset.y; + }} + scrollEventThrottle={16} + style={styles.scrollView} + contentContainerStyle={styles.scrollContent} + keyboardShouldPersistTaps="always" + > + {options.map((option, index) => { + const isSelected = index === selectedIndex; + return ( + handleRowLayout(index, event)} + onPress={() => onSelect(option)} + style={({ hovered = false, pressed }) => [ + styles.item, + (hovered || pressed || isSelected) && styles.itemActive, + ]} + > + + + {option.label} + {option.detail ? ( + {option.detail} + ) : null} + + {option.description ? ( + + {option.description} + + ) : null} + + + ); + })} + + + ); +} + +const styles = StyleSheet.create(((theme: Theme) => ({ + container: { + backgroundColor: theme.colors.surface0, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.lg, + overflow: "hidden", + }, + scrollView: { + flexGrow: 0, + flexShrink: 1, + }, + scrollContent: { + paddingVertical: theme.spacing[1], + }, + item: { + flexDirection: "row", + alignItems: "center", + minHeight: 36, + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + }, + itemActive: { + backgroundColor: theme.colors.surface1, + }, + itemMain: { + flex: 1, + minWidth: 0, + }, + itemHeader: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + itemLabel: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + }, + itemDetail: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, + itemDescription: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + marginTop: 2, + }, + emptyItem: { + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[3], + }, + emptyText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, +})) as any) as Record; diff --git a/packages/app/src/hooks/use-agent-autocomplete.ts b/packages/app/src/hooks/use-agent-autocomplete.ts new file mode 100644 index 000000000..00fa35ffb --- /dev/null +++ b/packages/app/src/hooks/use-agent-autocomplete.ts @@ -0,0 +1,125 @@ +import { useCallback, useMemo } from "react"; +import type { AutocompleteOption } from "@/components/ui/autocomplete"; +import { useAgentCommandsQuery, type DraftCommandConfig } from "./use-agent-commands-query"; +import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils"; +import { useAutocomplete } from "./use-autocomplete"; + +interface UseAgentAutocompleteInput { + userInput: string; + setUserInput: (nextValue: string) => void; + serverId: string; + agentId: string; + draftConfig?: DraftCommandConfig; + onAutocompleteApplied?: () => void; +} + +interface AgentAutocompleteResult { + isVisible: boolean; + options: AutocompleteOption[]; + selectedIndex: number; + isLoading: boolean; + errorMessage?: string; + onSelectOption: (option: AutocompleteOption) => void; + onKeyPress: (event: { key: string; preventDefault: () => void }) => boolean; +} + +function normalizeDraftCommandConfig( + draftConfig?: DraftCommandConfig +): DraftCommandConfig | undefined { + if (!draftConfig) { + return undefined; + } + + const cwd = draftConfig.cwd.trim(); + if (!cwd) { + return undefined; + } + + const modeId = draftConfig.modeId?.trim() ?? ""; + const model = draftConfig.model?.trim() ?? ""; + const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? ""; + return { + provider: draftConfig.provider, + cwd, + ...(modeId ? { modeId } : {}), + ...(model ? { model } : {}), + ...(thinkingOptionId ? { thinkingOptionId } : {}), + }; +} + +export function useAgentAutocomplete( + input: UseAgentAutocompleteInput +): AgentAutocompleteResult { + const { + userInput, + setUserInput, + serverId, + agentId, + draftConfig, + onAutocompleteApplied, + } = input; + + const showAutocomplete = userInput.startsWith("/") && !userInput.includes(" "); + const filterQuery = showAutocomplete ? userInput.slice(1) : ""; + + const normalizedDraftConfig = useMemo( + () => normalizeDraftCommandConfig(draftConfig), + [draftConfig] + ); + + const isRealAgent = Boolean(agentId) && !agentId.startsWith("__"); + const queryDraftConfig = isRealAgent ? undefined : normalizedDraftConfig; + const canLoadOptions = Boolean(serverId) && (isRealAgent || !!queryDraftConfig); + const isVisible = showAutocomplete && canLoadOptions; + + const { commands, isLoading, isError, error } = useAgentCommandsQuery({ + serverId, + agentId, + enabled: canLoadOptions, + draftConfig: queryDraftConfig, + }); + + const options = useMemo(() => { + if (!isVisible) { + return []; + } + + const filterLower = filterQuery.toLowerCase(); + const matches = commands.filter((cmd) => + cmd.name.toLowerCase().includes(filterLower) + ); + const orderedMatches = orderAutocompleteOptions(matches); + return orderedMatches.map((cmd) => ({ + id: cmd.name, + label: `/${cmd.name}`, + detail: cmd.argumentHint || undefined, + description: cmd.description, + })); + }, [commands, filterQuery, isVisible]); + + const onSelectOption = useCallback( + (option: AutocompleteOption) => { + setUserInput(`/${option.id} `); + onAutocompleteApplied?.(); + }, + [onAutocompleteApplied, setUserInput] + ); + + const { selectedIndex, onKeyPress } = useAutocomplete({ + isVisible, + options, + query: filterQuery, + onSelectOption, + onEscape: () => setUserInput(""), + }); + + return { + isVisible, + options, + selectedIndex, + isLoading, + errorMessage: isError ? (error?.message ?? "Failed to load") : undefined, + onSelectOption, + onKeyPress, + }; +} diff --git a/packages/app/src/hooks/use-autocomplete.ts b/packages/app/src/hooks/use-autocomplete.ts new file mode 100644 index 000000000..6a566870a --- /dev/null +++ b/packages/app/src/hooks/use-autocomplete.ts @@ -0,0 +1,120 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + getAutocompleteFallbackIndex, + getAutocompleteNextIndex, + type AutocompleteOptionsPosition, +} from "@/components/ui/autocomplete-utils"; + +interface UseAutocompleteInput { + isVisible: boolean; + options: readonly TOption[]; + query: string; + onSelectOption: (option: TOption) => void; + onEscape?: () => void; + optionsPosition?: AutocompleteOptionsPosition; +} + +interface UseAutocompleteResult { + selectedIndex: number; + onKeyPress: (event: { key: string; preventDefault: () => void }) => boolean; +} + +export function useAutocomplete( + input: UseAutocompleteInput +): UseAutocompleteResult { + const [selectedIndex, setSelectedIndex] = useState(-1); + const previousQueryRef = useRef(""); + + useEffect(() => { + if (!input.isVisible) { + previousQueryRef.current = input.query; + setSelectedIndex(-1); + return; + } + + const queryChanged = previousQueryRef.current !== input.query; + previousQueryRef.current = input.query; + + setSelectedIndex((current) => { + if (input.options.length === 0) { + return -1; + } + + const fallbackIndex = getAutocompleteFallbackIndex( + input.options.length, + input.optionsPosition + ); + + if (queryChanged) { + return fallbackIndex; + } + if (current < 0 || current >= input.options.length) { + return fallbackIndex; + } + return current; + }); + }, [input.isVisible, input.options.length, input.query, input.optionsPosition]); + + const onKeyPress = useCallback( + (event: { key: string; preventDefault: () => void }) => { + if (!input.isVisible || input.options.length === 0) { + return false; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + setSelectedIndex((current) => + getAutocompleteNextIndex({ + currentIndex: current, + itemCount: input.options.length, + key: "ArrowUp", + }) + ); + return true; + } + + if (event.key === "ArrowDown") { + event.preventDefault(); + setSelectedIndex((current) => + getAutocompleteNextIndex({ + currentIndex: current, + itemCount: input.options.length, + key: "ArrowDown", + }) + ); + return true; + } + + if (event.key === "Tab" || event.key === "Enter") { + event.preventDefault(); + const fallbackIndex = getAutocompleteFallbackIndex( + input.options.length, + input.optionsPosition + ); + const resolvedIndex = + selectedIndex >= 0 && selectedIndex < input.options.length + ? selectedIndex + : fallbackIndex; + const selectedOption = input.options[resolvedIndex]; + if (selectedOption) { + input.onSelectOption(selectedOption); + } + return true; + } + + if (event.key === "Escape" && input.onEscape) { + event.preventDefault(); + input.onEscape(); + return true; + } + + return false; + }, + [input, selectedIndex] + ); + + return { + selectedIndex, + onKeyPress, + }; +}