History

Use Command highlighting state for composer menu navigation

- Switch composer menu arrow-key navigation to `Command`'s internal highlight handling - Track highlighted item by id instead of manual index/keyboard state - Remove custom hover/highlight plumbing and hidden-scroll sync logic

Julius Marminge committed 8 months ago

Commit 2136fee1 parent b73b2c91 file · +44 −77Browse files

Unified · Split

apps/web/src/components/ChatView.tsx+44−77
@@ -96,7 +96,7 @@ import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu" · 95 unmodified lines
9696 import { CursorIcon, Icon } from "./Icons";
9797 import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
9898 import { Badge } from "./ui/badge";
99−import { Command, CommandItem, CommandList } from "./ui/command";
99+import { Command, CommandInput, CommandItem, CommandList } from "./ui/command";
100100
101101 function formatMessageMeta(createdAt: string, duration: string | null): string {
102102 if (!duration) return formatTimestamp(createdAt);
@@ -220,27 +220,13 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { · 117 unmodified lines
220220
221221 const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
222222 item: ComposerCommandItem;
223− index: number;
224− isHighlighted: boolean;
225− keyboardHighlight: boolean;
226223 resolvedTheme: "light" | "dark";
227− onHighlight: (index: number) => void;
228224 onSelect: (item: ComposerCommandItem) => void;
229225 }) {
230226 return (
231227 <CommandItem
232228 value={props.item.id}
233− data-composer-menu-index={props.index}
234− className={cn(
235− "cursor-pointer gap-2",
236− props.keyboardHighlight &&
237− !props.isHighlighted &&
238− "data-highlighted:bg-transparent data-highlighted:text-inherit",
239− props.isHighlighted && "bg-accent text-accent-foreground",
240− )}
241− onMouseEnter={() => {
242− props.onHighlight(props.index);
243− }}
229+ className="cursor-pointer gap-2"
244230 onMouseDown={(event) => {
245231 event.preventDefault();
246232 }}
@@ -271,31 +257,30 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { · 24 unmodified lines
271257
272258 const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
273259 items: ComposerCommandItem[];
274− selectedIndex: number;
275− keyboardHighlight: boolean;
276260 resolvedTheme: "light" | "dark";
277261 isLoading: boolean;
278262 triggerKind: ComposerTriggerKind | null;
279− onHighlight: (index: number) => void;
263+ onHighlightedItemChange: (itemId: string | null) => void;
280264 onSelect: (item: ComposerCommandItem) => void;
281− menuRef: RefObject<HTMLDivElement | null>;
265+ commandInputRef: RefObject<HTMLInputElement | null>;
282266 }) {
283267 return (
284− <Command>
285− <div
286− ref={props.menuRef}
287− className="overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs"
288− >
268+ <Command
269+ mode="none"
270+ onItemHighlighted={(highlightedValue) => {
271+ props.onHighlightedItemChange(typeof highlightedValue === "string" ? highlightedValue : null);
272+ }}
273+ >
274+ <div className="relative overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs">
275+ <div className="pointer-events-none absolute h-0 w-0 overflow-hidden opacity-0">
276+ <CommandInput autoFocus={false} ref={props.commandInputRef} />
277+ </div>
289278 <CommandList className="max-h-64">
290− {props.items.map((item, index) => (
279+ {props.items.map((item) => (
291280 <ComposerCommandMenuItem
292281 key={item.id}
293282 item={item}
294− index={index}
295− isHighlighted={index === props.selectedIndex}
296− keyboardHighlight={props.keyboardHighlight}
297283 resolvedTheme={props.resolvedTheme}
298− onHighlight={props.onHighlight}
299284 onSelect={props.onSelect}
300285 />
301286 ))}
@@ -337,14 +322,12 @@ export default function ChatView() { · 35 unmodified lines
337322 const [nowTick, setNowTick] = useState(() => Date.now());
338323 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
339324 const [composerCursor, setComposerCursor] = useState(0);
340− const [composerMenuIndex, setComposerMenuIndex] = useState(0);
341− const [composerKeyboardHighlight, setComposerKeyboardHighlight] = useState(false);
325+ const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
342326 const messagesScrollRef = useRef<HTMLDivElement>(null);
343327 const messagesEndRef = useRef<HTMLDivElement>(null);
344328 const shouldAutoScrollRef = useRef(true);
345329 const textareaRef = useRef<HTMLTextAreaElement>(null);
346− const composerMenuRef = useRef<HTMLDivElement>(null);
347− const previousComposerMenuIndexRef = useRef(0);
330+ const composerCommandInputRef = useRef<HTMLInputElement>(null);
348331 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
349332 const dragDepthRef = useRef(0);
350333 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});
@@ -519,9 +502,13 @@ export default function ChatView() { · 168 unmodified lines
519502 }));
520503 }, [composerTrigger, workspaceEntries]);
521504 const composerMenuOpen = Boolean(composerTrigger);
522− const activeComposerMenuItem =
523− composerMenuItems[Math.min(composerMenuIndex, Math.max(0, composerMenuItems.length - 1))] ??
524− null;
505+ const activeComposerMenuItem = useMemo(
506+ () =>
507+ composerMenuItems.find((item) => item.id === composerHighlightedItemId) ??
508+ composerMenuItems[0] ??
509+ null,
510+ [composerHighlightedItemId, composerMenuItems],
511+ );
525512 const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS;
526513 // Default true while loading to avoid toolbar flicker.
527514 const isGitRepo = branchesQuery.data?.isRepo ?? true;
@@ -688,36 +675,14 @@ export default function ChatView() { · 160 unmodified lines
688675 }, [activeThread?.id]);
689676
690677 useEffect(() => {
691− setComposerMenuIndex(0);
692− }, [composerTrigger?.kind, composerTrigger?.query, composerMenuItems.length]);
693−
694− useEffect(() => {
695− if (!composerMenuOpen) return;
696− setComposerKeyboardHighlight(false);
697− setComposerMenuIndex(0);
698− }, [composerMenuOpen]);
699−
700− useEffect(() => {
701− if (!composerMenuOpen || composerMenuItems.length === 0) {
678+ if (!composerMenuOpen) {
679+ setComposerHighlightedItemId(null);
702680 return;
703681 }
704− const clampedIndex = Math.min(composerMenuIndex, composerMenuItems.length - 1);
705− const previousIndex = previousComposerMenuIndexRef.current;
706− const direction = clampedIndex - previousIndex;
707− previousComposerMenuIndexRef.current = clampedIndex;
708−
709− const aheadIndex =
710− direction > 0
711− ? Math.min(clampedIndex + 1, composerMenuItems.length - 1)
712− : direction < 0
713− ? Math.max(clampedIndex - 1, 0)
714− : clampedIndex;
715−
716− const node = composerMenuRef.current?.querySelector<HTMLElement>(
717− `[data-composer-menu-index="${aheadIndex}"]`,
682+ setComposerHighlightedItemId((existing) =>
683+ existing && composerMenuItems.some((item) => item.id === existing) ? existing : null,
718684 );
719− node?.scrollIntoView({ block: "nearest" });
720− }, [composerMenuIndex, composerMenuItems.length, composerMenuOpen]);
685+ }, [composerMenuItems, composerMenuOpen]);
721686
722687 useEffect(() => {
723688 if (!activeThread?.id || activeThread.terminalOpen) return;
@@ -739,7 +704,7 @@ export default function ChatView() { · 15 unmodified lines
739704 return [];
740705 });
741706 setComposerCursor(0);
742− setComposerMenuIndex(0);
707+ setComposerHighlightedItemId(null);
743708 dragDepthRef.current = 0;
744709 setIsDragOverComposer(false);
745710 setExpandedImage(null);
@@ -1128,7 +1093,7 @@ export default function ChatView() { · 382 unmodified lines
11281093 setPrompt("");
11291094 setComposerImages([]);
11301095 setComposerCursor(0);
1131− setComposerMenuIndex(0);
1096+ setComposerHighlightedItemId(null);
11321097
11331098 const sessionInfo = await ensureSession(sessionCwd);
11341099 if (!sessionInfo) return;
@@ -1271,9 +1236,15 @@ export default function ChatView() { · 136 unmodified lines
12711236 },
12721237 [applyPromptReplacement, composerTrigger, onModelSelect],
12731238 );
1274− const onHighlightComposerMenuItem = useCallback((index: number) => {
1275− setComposerKeyboardHighlight(false);
1276− setComposerMenuIndex(index);
1239+ const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => {
1240+ setComposerHighlightedItemId(itemId);
1241+ }, []);
1242+ const nudgeComposerMenuHighlight = useCallback((key: "ArrowDown" | "ArrowUp") => {
1243+ const commandInput = composerCommandInputRef.current;
1244+ if (!commandInput) return;
1245+ commandInput.dispatchEvent(
1246+ new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
1247+ );
12771248 }, []);
12781249 const isComposerMenuLoading =
12791250 composerTriggerKind === "path" &&
@@ -1291,14 +1262,12 @@ export default function ChatView() { · 11 unmodified lines
12911262 if (composerMenuOpen && composerMenuItems.length > 0) {
12921263 if (e.key === "ArrowDown") {
12931264 e.preventDefault();
1294− setComposerKeyboardHighlight(true);
1295− setComposerMenuIndex((existing) => Math.min(existing + 1, composerMenuItems.length - 1));
1265+ nudgeComposerMenuHighlight("ArrowDown");
12961266 return;
12971267 }
12981268 if (e.key === "ArrowUp") {
12991269 e.preventDefault();
1300− setComposerKeyboardHighlight(true);
1301− setComposerMenuIndex((existing) => Math.max(existing - 1, 0));
1270+ nudgeComposerMenuHighlight("ArrowUp");
13021271 return;
13031272 }
13041273 if (e.key === "Tab" || e.key === "Enter") {
@@ -1411,14 +1380,12 @@ export default function ChatView() { · 106 unmodified lines
14111380 <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1">
14121381 <ComposerCommandMenu
14131382 items={composerMenuItems}
1414− selectedIndex={composerMenuIndex}
1415− keyboardHighlight={composerKeyboardHighlight}
14161383 resolvedTheme={resolvedTheme}
14171384 isLoading={isComposerMenuLoading}
14181385 triggerKind={composerTriggerKind}
1419− onHighlight={onHighlightComposerMenuItem}
1386+ onHighlightedItemChange={onComposerMenuItemHighlighted}
14201387 onSelect={onSelectComposerItem}
1421− menuRef={composerMenuRef}
1388+ commandInputRef={composerCommandInputRef}
14221389 />
14231390 </div>
14241391 )}