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"...@@ -96,7 +96,7 @@ import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu"
96import { CursorIcon, Icon } from "./Icons";96import { CursorIcon, Icon } from "./Icons";
97import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";97import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
98import { Badge } from "./ui/badge";98import { Badge } from "./ui/badge";
99import { Command, CommandItem, CommandList } from "./ui/command";99import { Command, CommandInput, CommandItem, CommandList } from "./ui/command";
100100
101function formatMessageMeta(createdAt: string, duration: string | null): string {101function formatMessageMeta(createdAt: string, duration: string | null): string {
102 if (!duration) return formatTimestamp(createdAt);102 if (!duration) return formatTimestamp(createdAt);
...@@ -220,27 +220,13 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: {...@@ -220,27 +220,13 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: {
220220
221const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {221const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
222 item: ComposerCommandItem;222 item: ComposerCommandItem;
223 index: number;
224 isHighlighted: boolean;
225 keyboardHighlight: boolean;
226 resolvedTheme: "light" | "dark";223 resolvedTheme: "light" | "dark";
227 onHighlight: (index: number) => void;
228 onSelect: (item: ComposerCommandItem) => void;224 onSelect: (item: ComposerCommandItem) => void;
229}) {225}) {
230 return (226 return (
231 <CommandItem227 <CommandItem
232 value={props.item.id}228 value={props.item.id}
233 data-composer-menu-index={props.index}229 className="cursor-pointer gap-2"
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 }}
244 onMouseDown={(event) => {230 onMouseDown={(event) => {
245 event.preventDefault();231 event.preventDefault();
246 }}232 }}
...@@ -271,31 +257,30 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {...@@ -271,31 +257,30 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
271257
272const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {258const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
273 items: ComposerCommandItem[];259 items: ComposerCommandItem[];
274 selectedIndex: number;
275 keyboardHighlight: boolean;
276 resolvedTheme: "light" | "dark";260 resolvedTheme: "light" | "dark";
277 isLoading: boolean;261 isLoading: boolean;
278 triggerKind: ComposerTriggerKind | null;262 triggerKind: ComposerTriggerKind | null;
279 onHighlight: (index: number) => void;263 onHighlightedItemChange: (itemId: string | null) => void;
280 onSelect: (item: ComposerCommandItem) => void;264 onSelect: (item: ComposerCommandItem) => void;
281 menuRef: RefObject<HTMLDivElement | null>;265 commandInputRef: RefObject<HTMLInputElement | null>;
282}) {266}) {
283 return (267 return (
284 <Command>268 <Command
285 <div269 mode="none"
286 ref={props.menuRef}270 onItemHighlighted={(highlightedValue) => {
287 className="overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs"271 props.onHighlightedItemChange(typeof highlightedValue === "string" ? highlightedValue : null);
288 >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>
289 <CommandList className="max-h-64">278 <CommandList className="max-h-64">
290 {props.items.map((item, index) => (279 {props.items.map((item) => (
291 <ComposerCommandMenuItem280 <ComposerCommandMenuItem
292 key={item.id}281 key={item.id}
293 item={item}282 item={item}
294 index={index}
295 isHighlighted={index === props.selectedIndex}
296 keyboardHighlight={props.keyboardHighlight}
297 resolvedTheme={props.resolvedTheme}283 resolvedTheme={props.resolvedTheme}
298 onHighlight={props.onHighlight}
299 onSelect={props.onSelect}284 onSelect={props.onSelect}
300 />285 />
301 ))}286 ))}
...@@ -337,14 +322,12 @@ export default function ChatView() {...@@ -337,14 +322,12 @@ export default function ChatView() {
337 const [nowTick, setNowTick] = useState(() => Date.now());322 const [nowTick, setNowTick] = useState(() => Date.now());
338 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);323 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
339 const [composerCursor, setComposerCursor] = useState(0);324 const [composerCursor, setComposerCursor] = useState(0);
340 const [composerMenuIndex, setComposerMenuIndex] = useState(0);325 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
341 const [composerKeyboardHighlight, setComposerKeyboardHighlight] = useState(false);
342 const messagesScrollRef = useRef<HTMLDivElement>(null);326 const messagesScrollRef = useRef<HTMLDivElement>(null);
343 const messagesEndRef = useRef<HTMLDivElement>(null);327 const messagesEndRef = useRef<HTMLDivElement>(null);
344 const shouldAutoScrollRef = useRef(true);328 const shouldAutoScrollRef = useRef(true);
345 const textareaRef = useRef<HTMLTextAreaElement>(null);329 const textareaRef = useRef<HTMLTextAreaElement>(null);
346 const composerMenuRef = useRef<HTMLDivElement>(null);330 const composerCommandInputRef = useRef<HTMLInputElement>(null);
347 const previousComposerMenuIndexRef = useRef(0);
348 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);331 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
349 const dragDepthRef = useRef(0);332 const dragDepthRef = useRef(0);
350 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});333 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});
...@@ -519,9 +502,13 @@ export default function ChatView() {...@@ -519,9 +502,13 @@ export default function ChatView() {
519 }));502 }));
520 }, [composerTrigger, workspaceEntries]);503 }, [composerTrigger, workspaceEntries]);
521 const composerMenuOpen = Boolean(composerTrigger);504 const composerMenuOpen = Boolean(composerTrigger);
522 const activeComposerMenuItem =505 const activeComposerMenuItem = useMemo(
523 composerMenuItems[Math.min(composerMenuIndex, Math.max(0, composerMenuItems.length - 1))] ??506 () =>
524 null;507 composerMenuItems.find((item) => item.id === composerHighlightedItemId) ??
508 composerMenuItems[0] ??
509 null,
510 [composerHighlightedItemId, composerMenuItems],
511 );
525 const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS;512 const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS;
526 // Default true while loading to avoid toolbar flicker.513 // Default true while loading to avoid toolbar flicker.
527 const isGitRepo = branchesQuery.data?.isRepo ?? true;514 const isGitRepo = branchesQuery.data?.isRepo ?? true;
...@@ -688,36 +675,14 @@ export default function ChatView() {...@@ -688,36 +675,14 @@ export default function ChatView() {
688 }, [activeThread?.id]);675 }, [activeThread?.id]);
689676
690 useEffect(() => {677 useEffect(() => {
691 setComposerMenuIndex(0);678 if (!composerMenuOpen) {
692 }, [composerTrigger?.kind, composerTrigger?.query, composerMenuItems.length]);679 setComposerHighlightedItemId(null);
693
694 useEffect(() => {
695 if (!composerMenuOpen) return;
696 setComposerKeyboardHighlight(false);
697 setComposerMenuIndex(0);
698 }, [composerMenuOpen]);
699
700 useEffect(() => {
701 if (!composerMenuOpen || composerMenuItems.length === 0) {
702 return;680 return;
703 }681 }
704 const clampedIndex = Math.min(composerMenuIndex, composerMenuItems.length - 1);682 setComposerHighlightedItemId((existing) =>
705 const previousIndex = previousComposerMenuIndexRef.current;683 existing && composerMenuItems.some((item) => item.id === existing) ? existing : null,
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}"]`,
718 );684 );
719 node?.scrollIntoView({ block: "nearest" });685 }, [composerMenuItems, composerMenuOpen]);
720 }, [composerMenuIndex, composerMenuItems.length, composerMenuOpen]);
721686
722 useEffect(() => {687 useEffect(() => {
723 if (!activeThread?.id || activeThread.terminalOpen) return;688 if (!activeThread?.id || activeThread.terminalOpen) return;
...@@ -739,7 +704,7 @@ export default function ChatView() {...@@ -739,7 +704,7 @@ export default function ChatView() {
739 return [];704 return [];
740 });705 });
741 setComposerCursor(0);706 setComposerCursor(0);
742 setComposerMenuIndex(0);707 setComposerHighlightedItemId(null);
743 dragDepthRef.current = 0;708 dragDepthRef.current = 0;
744 setIsDragOverComposer(false);709 setIsDragOverComposer(false);
745 setExpandedImage(null);710 setExpandedImage(null);
...@@ -1128,7 +1093,7 @@ export default function ChatView() {...@@ -1128,7 +1093,7 @@ export default function ChatView() {
1128 setPrompt("");1093 setPrompt("");
1129 setComposerImages([]);1094 setComposerImages([]);
1130 setComposerCursor(0);1095 setComposerCursor(0);
1131 setComposerMenuIndex(0);1096 setComposerHighlightedItemId(null);
11321097
1133 const sessionInfo = await ensureSession(sessionCwd);1098 const sessionInfo = await ensureSession(sessionCwd);
1134 if (!sessionInfo) return;1099 if (!sessionInfo) return;
...@@ -1271,9 +1236,15 @@ export default function ChatView() {...@@ -1271,9 +1236,15 @@ export default function ChatView() {
1271 },1236 },
1272 [applyPromptReplacement, composerTrigger, onModelSelect],1237 [applyPromptReplacement, composerTrigger, onModelSelect],
1273 );1238 );
1274 const onHighlightComposerMenuItem = useCallback((index: number) => {1239 const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => {
1275 setComposerKeyboardHighlight(false);1240 setComposerHighlightedItemId(itemId);
1276 setComposerMenuIndex(index);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 );
1277 }, []);1248 }, []);
1278 const isComposerMenuLoading =1249 const isComposerMenuLoading =
1279 composerTriggerKind === "path" &&1250 composerTriggerKind === "path" &&
...@@ -1291,14 +1262,12 @@ export default function ChatView() {...@@ -1291,14 +1262,12 @@ export default function ChatView() {
1291 if (composerMenuOpen && composerMenuItems.length > 0) {1262 if (composerMenuOpen && composerMenuItems.length > 0) {
1292 if (e.key === "ArrowDown") {1263 if (e.key === "ArrowDown") {
1293 e.preventDefault();1264 e.preventDefault();
1294 setComposerKeyboardHighlight(true);1265 nudgeComposerMenuHighlight("ArrowDown");
1295 setComposerMenuIndex((existing) => Math.min(existing + 1, composerMenuItems.length - 1));
1296 return;1266 return;
1297 }1267 }
1298 if (e.key === "ArrowUp") {1268 if (e.key === "ArrowUp") {
1299 e.preventDefault();1269 e.preventDefault();
1300 setComposerKeyboardHighlight(true);1270 nudgeComposerMenuHighlight("ArrowUp");
1301 setComposerMenuIndex((existing) => Math.max(existing - 1, 0));
1302 return;1271 return;
1303 }1272 }
1304 if (e.key === "Tab" || e.key === "Enter") {1273 if (e.key === "Tab" || e.key === "Enter") {
...@@ -1411,14 +1380,12 @@ export default function ChatView() {...@@ -1411,14 +1380,12 @@ export default function ChatView() {
1411 <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1">1380 <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1">
1412 <ComposerCommandMenu1381 <ComposerCommandMenu
1413 items={composerMenuItems}1382 items={composerMenuItems}
1414 selectedIndex={composerMenuIndex}
1415 keyboardHighlight={composerKeyboardHighlight}
1416 resolvedTheme={resolvedTheme}1383 resolvedTheme={resolvedTheme}
1417 isLoading={isComposerMenuLoading}1384 isLoading={isComposerMenuLoading}
1418 triggerKind={composerTriggerKind}1385 triggerKind={composerTriggerKind}
1419 onHighlight={onHighlightComposerMenuItem}1386 onHighlightedItemChange={onComposerMenuItemHighlighted}
1420 onSelect={onSelectComposerItem}1387 onSelect={onSelectComposerItem}
1421 menuRef={composerMenuRef}1388 commandInputRef={composerCommandInputRef}
1422 />1389 />
1423 </div>1390 </div>
1424 )}1391 )}