| ... | ... | @@ -96,7 +96,7 @@ import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu" |
| 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: { |
| 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: { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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 | )} |