- 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
Unified · Split
| @@ -96,7 +96,7 @@ import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu" · 95 unmodified lines | ||
| 96 | 96 | import { CursorIcon, Icon } from "./Icons"; |
| 97 | 97 | import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; |
| 98 | 98 | import { Badge } from "./ui/badge"; |
| 99 | −import { Command, CommandItem, CommandList } from "./ui/command"; | |
| 99 | +import { Command, CommandInput, CommandItem, CommandList } from "./ui/command"; | |
| 100 | 100 | |
| 101 | 101 | function formatMessageMeta(createdAt: string, duration: string | null): string { |
| 102 | 102 | if (!duration) return formatTimestamp(createdAt); |
| @@ -220,27 +220,13 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { · 117 unmodified lines | ||
| 220 | 220 | |
| 221 | 221 | const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { |
| 222 | 222 | item: ComposerCommandItem; |
| 223 | − index: number; | |
| 224 | − isHighlighted: boolean; | |
| 225 | − keyboardHighlight: boolean; | |
| 226 | 223 | resolvedTheme: "light" | "dark"; |
| 227 | − onHighlight: (index: number) => void; | |
| 228 | 224 | onSelect: (item: ComposerCommandItem) => void; |
| 229 | 225 | }) { |
| 230 | 226 | return ( |
| 231 | 227 | <CommandItem |
| 232 | 228 | 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" | |
| 244 | 230 | onMouseDown={(event) => { |
| 245 | 231 | event.preventDefault(); |
| 246 | 232 | }} |
| @@ -271,31 +257,30 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { · 24 unmodified lines | ||
| 271 | 257 | |
| 272 | 258 | const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { |
| 273 | 259 | items: ComposerCommandItem[]; |
| 274 | − selectedIndex: number; | |
| 275 | − keyboardHighlight: boolean; | |
| 276 | 260 | resolvedTheme: "light" | "dark"; |
| 277 | 261 | isLoading: boolean; |
| 278 | 262 | triggerKind: ComposerTriggerKind | null; |
| 279 | − onHighlight: (index: number) => void; | |
| 263 | + onHighlightedItemChange: (itemId: string | null) => void; | |
| 280 | 264 | onSelect: (item: ComposerCommandItem) => void; |
| 281 | − menuRef: RefObject<HTMLDivElement | null>; | |
| 265 | + commandInputRef: RefObject<HTMLInputElement | null>; | |
| 282 | 266 | }) { |
| 283 | 267 | 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> | |
| 289 | 278 | <CommandList className="max-h-64"> |
| 290 | − {props.items.map((item, index) => ( | |
| 279 | + {props.items.map((item) => ( | |
| 291 | 280 | <ComposerCommandMenuItem |
| 292 | 281 | key={item.id} |
| 293 | 282 | item={item} |
| 294 | − index={index} | |
| 295 | − isHighlighted={index === props.selectedIndex} | |
| 296 | − keyboardHighlight={props.keyboardHighlight} | |
| 297 | 283 | resolvedTheme={props.resolvedTheme} |
| 298 | − onHighlight={props.onHighlight} | |
| 299 | 284 | onSelect={props.onSelect} |
| 300 | 285 | /> |
| 301 | 286 | ))} |
| @@ -337,14 +322,12 @@ export default function ChatView() { · 35 unmodified lines | ||
| 337 | 322 | const [nowTick, setNowTick] = useState(() => Date.now()); |
| 338 | 323 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); |
| 339 | 324 | 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); | |
| 342 | 326 | const messagesScrollRef = useRef<HTMLDivElement>(null); |
| 343 | 327 | const messagesEndRef = useRef<HTMLDivElement>(null); |
| 344 | 328 | const shouldAutoScrollRef = useRef(true); |
| 345 | 329 | const textareaRef = useRef<HTMLTextAreaElement>(null); |
| 346 | − const composerMenuRef = useRef<HTMLDivElement>(null); | |
| 347 | − const previousComposerMenuIndexRef = useRef(0); | |
| 330 | + const composerCommandInputRef = useRef<HTMLInputElement>(null); | |
| 348 | 331 | const composerImagesRef = useRef<ComposerImageAttachment[]>([]); |
| 349 | 332 | const dragDepthRef = useRef(0); |
| 350 | 333 | const terminalOpenByThreadRef = useRef<Record<string, boolean>>({}); |
| @@ -519,9 +502,13 @@ export default function ChatView() { · 168 unmodified lines | ||
| 519 | 502 | })); |
| 520 | 503 | }, [composerTrigger, workspaceEntries]); |
| 521 | 504 | 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 | + ); | |
| 525 | 512 | const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS; |
| 526 | 513 | // Default true while loading to avoid toolbar flicker. |
| 527 | 514 | const isGitRepo = branchesQuery.data?.isRepo ?? true; |
| @@ -688,36 +675,14 @@ export default function ChatView() { · 160 unmodified lines | ||
| 688 | 675 | }, [activeThread?.id]); |
| 689 | 676 | |
| 690 | 677 | 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); | |
| 702 | 680 | return; |
| 703 | 681 | } |
| 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, | |
| 718 | 684 | ); |
| 719 | − node?.scrollIntoView({ block: "nearest" }); | |
| 720 | − }, [composerMenuIndex, composerMenuItems.length, composerMenuOpen]); | |
| 685 | + }, [composerMenuItems, composerMenuOpen]); | |
| 721 | 686 | |
| 722 | 687 | useEffect(() => { |
| 723 | 688 | if (!activeThread?.id || activeThread.terminalOpen) return; |
| @@ -739,7 +704,7 @@ export default function ChatView() { · 15 unmodified lines | ||
| 739 | 704 | return []; |
| 740 | 705 | }); |
| 741 | 706 | setComposerCursor(0); |
| 742 | − setComposerMenuIndex(0); | |
| 707 | + setComposerHighlightedItemId(null); | |
| 743 | 708 | dragDepthRef.current = 0; |
| 744 | 709 | setIsDragOverComposer(false); |
| 745 | 710 | setExpandedImage(null); |
| @@ -1128,7 +1093,7 @@ export default function ChatView() { · 382 unmodified lines | ||
| 1128 | 1093 | setPrompt(""); |
| 1129 | 1094 | setComposerImages([]); |
| 1130 | 1095 | setComposerCursor(0); |
| 1131 | − setComposerMenuIndex(0); | |
| 1096 | + setComposerHighlightedItemId(null); | |
| 1132 | 1097 | |
| 1133 | 1098 | const sessionInfo = await ensureSession(sessionCwd); |
| 1134 | 1099 | if (!sessionInfo) return; |
| @@ -1271,9 +1236,15 @@ export default function ChatView() { · 136 unmodified lines | ||
| 1271 | 1236 | }, |
| 1272 | 1237 | [applyPromptReplacement, composerTrigger, onModelSelect], |
| 1273 | 1238 | ); |
| 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 | + ); | |
| 1277 | 1248 | }, []); |
| 1278 | 1249 | const isComposerMenuLoading = |
| 1279 | 1250 | composerTriggerKind === "path" && |
| @@ -1291,14 +1262,12 @@ export default function ChatView() { · 11 unmodified lines | ||
| 1291 | 1262 | if (composerMenuOpen && composerMenuItems.length > 0) { |
| 1292 | 1263 | if (e.key === "ArrowDown") { |
| 1293 | 1264 | e.preventDefault(); |
| 1294 | − setComposerKeyboardHighlight(true); | |
| 1295 | − setComposerMenuIndex((existing) => Math.min(existing + 1, composerMenuItems.length - 1)); | |
| 1265 | + nudgeComposerMenuHighlight("ArrowDown"); | |
| 1296 | 1266 | return; |
| 1297 | 1267 | } |
| 1298 | 1268 | if (e.key === "ArrowUp") { |
| 1299 | 1269 | e.preventDefault(); |
| 1300 | − setComposerKeyboardHighlight(true); | |
| 1301 | − setComposerMenuIndex((existing) => Math.max(existing - 1, 0)); | |
| 1270 | + nudgeComposerMenuHighlight("ArrowUp"); | |
| 1302 | 1271 | return; |
| 1303 | 1272 | } |
| 1304 | 1273 | if (e.key === "Tab" || e.key === "Enter") { |
| @@ -1411,14 +1380,12 @@ export default function ChatView() { · 106 unmodified lines | ||
| 1411 | 1380 | <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1"> |
| 1412 | 1381 | <ComposerCommandMenu |
| 1413 | 1382 | items={composerMenuItems} |
| 1414 | − selectedIndex={composerMenuIndex} | |
| 1415 | − keyboardHighlight={composerKeyboardHighlight} | |
| 1416 | 1383 | resolvedTheme={resolvedTheme} |
| 1417 | 1384 | isLoading={isComposerMenuLoading} |
| 1418 | 1385 | triggerKind={composerTriggerKind} |
| 1419 | − onHighlight={onHighlightComposerMenuItem} | |
| 1386 | + onHighlightedItemChange={onComposerMenuItemHighlighted} | |
| 1420 | 1387 | onSelect={onSelectComposerItem} |
| 1421 | − menuRef={composerMenuRef} | |
| 1388 | + commandInputRef={composerCommandInputRef} | |
| 1422 | 1389 | /> |
| 1423 | 1390 | </div> |
| 1424 | 1391 | )} |