| ... | @@ -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" |
| 96 | import { CursorIcon, Icon } from "./Icons"; | 96 | import { CursorIcon, Icon } from "./Icons"; |
| 97 | import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; | 97 | import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; |
| 98 | import { Badge } from "./ui/badge"; | 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 | function formatMessageMeta(createdAt: string, duration: string | null): string { | 101 | function 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: { |
| 220 | | 220 | |
| 221 | const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { | 221 | const 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 | <CommandItem | 227 | <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: { |
| 271 | | 257 | |
| 272 | const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { | 258 | const 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 | <div | 269 | 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 | <ComposerCommandMenuItem | 280 | <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]); |
| 689 | | 676 | |
| 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]); | | |
| 721 | | 686 | |
| 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); |
| 1132 | | 1097 | |
| 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 | <ComposerCommandMenu | 1381 | <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 | )} |