authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-17 18:41:28-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-17 19:47:09-08:00
logf719aea0479a3de107d50e7e6559e88df239109b
treec4bf84cd59dfa90fe2a223e6296f60b5066d05ec
parentf6136bd4c2a23a37c41610ac352058bc17a198a8

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

1 files changed, 44 insertions(+), 77 deletions(-)

apps/web/src/components/ChatView.tsx+44-77
......@@ -96,7 +96,7 @@ import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu"
9696import { CursorIcon, Icon } from "./Icons";
9797import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
9898import { Badge } from "./ui/badge";
99import { Command, CommandItem, CommandList } from "./ui/command";
99import { Command, CommandInput, CommandItem, CommandList } from "./ui/command";
100100
101101function formatMessageMeta(createdAt: string, duration: string | null): string {
102102 if (!duration) return formatTimestamp(createdAt);
......@@ -220,27 +220,13 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: {
220220
221221const 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: {
271257
272258const 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() {
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() {
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() {
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() {
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() {
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() {
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() {
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() {
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 )}