- Extract composer menu into memoized components for cleaner ChatView rendering - Debounce path trigger queries and keep previous results while fetching - Precompute searchable model fields to reduce repeated lowercase work
Unified · Split
| @@ -32,7 +32,11 @@ import { serverConfigQueryOptions } from "~/lib/serverReactQuery"; · 31 unmodified lines | ||
| 32 | 32 | |
| 33 | 33 | import { isElectron } from "../env"; |
| 34 | 34 | import { buildBootstrapInput } from "../historyBootstrap"; |
| 35 | −import { detectComposerTrigger, replaceTextRange } from "../composer-logic"; | |
| 35 | +import { | |
| 36 | + type ComposerTriggerKind, | |
| 37 | + detectComposerTrigger, | |
| 38 | + replaceTextRange, | |
| 39 | +} from "../composer-logic"; | |
| 36 | 40 | import { |
| 37 | 41 | DEFAULT_MODEL, |
| 38 | 42 | DEFAULT_REASONING, |
| @@ -102,6 +106,14 @@ const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES · 63 unmodified lines | ||
| 102 | 106 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = |
| 103 | 107 | "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; |
| 104 | 108 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| 109 | +const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; | |
| 110 | +const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; | |
| 111 | +const SEARCHABLE_MODEL_OPTIONS = MODEL_OPTIONS.map(({ slug, name }) => ({ | |
| 112 | + slug, | |
| 113 | + name, | |
| 114 | + searchSlug: slug.toLowerCase(), | |
| 115 | + searchName: name.toLowerCase(), | |
| 116 | +})); | |
| 105 | 117 | |
| 106 | 118 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { |
| 107 | 119 | if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; |
| @@ -209,6 +221,102 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { · 101 unmodified lines | ||
| 209 | 221 | ); |
| 210 | 222 | }); |
| 211 | 223 | |
| 224 | +const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { | |
| 225 | + item: ComposerCommandItem; | |
| 226 | + index: number; | |
| 227 | + isHighlighted: boolean; | |
| 228 | + keyboardHighlight: boolean; | |
| 229 | + resolvedTheme: "light" | "dark"; | |
| 230 | + onHighlight: (index: number) => void; | |
| 231 | + onSelect: (item: ComposerCommandItem) => void; | |
| 232 | +}) { | |
| 233 | + return ( | |
| 234 | + <CommandItem | |
| 235 | + value={props.item.id} | |
| 236 | + data-composer-menu-index={props.index} | |
| 237 | + className={cn( | |
| 238 | + "cursor-pointer gap-2", | |
| 239 | + props.keyboardHighlight && | |
| 240 | + !props.isHighlighted && | |
| 241 | + "data-highlighted:bg-transparent data-highlighted:text-inherit", | |
| 242 | + props.isHighlighted && "bg-accent text-accent-foreground", | |
| 243 | + )} | |
| 244 | + onMouseEnter={() => { | |
| 245 | + props.onHighlight(props.index); | |
| 246 | + }} | |
| 247 | + onMouseDown={(event) => { | |
| 248 | + event.preventDefault(); | |
| 249 | + }} | |
| 250 | + onClick={() => { | |
| 251 | + props.onSelect(props.item); | |
| 252 | + }} | |
| 253 | + > | |
| 254 | + {props.item.type === "path" ? ( | |
| 255 | + <VscodeEntryIcon | |
| 256 | + pathValue={props.item.path} | |
| 257 | + kind={props.item.pathKind} | |
| 258 | + theme={props.resolvedTheme} | |
| 259 | + /> | |
| 260 | + ) : null} | |
| 261 | + {props.item.type === "slash-command" ? ( | |
| 262 | + <CursorIcon className="size-4 text-muted-foreground/80" /> | |
| 263 | + ) : null} | |
| 264 | + {props.item.type === "model" ? ( | |
| 265 | + <Badge variant="outline" className="px-1.5 py-0 text-[10px]"> | |
| 266 | + model | |
| 267 | + </Badge> | |
| 268 | + ) : null} | |
| 269 | + <span className="truncate">{props.item.label}</span> | |
| 270 | + <span className="truncate text-muted-foreground/70 text-xs">{props.item.description}</span> | |
| 271 | + </CommandItem> | |
| 272 | + ); | |
| 273 | +}); | |
| 274 | + | |
| 275 | +const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { | |
| 276 | + items: ComposerCommandItem[]; | |
| 277 | + selectedIndex: number; | |
| 278 | + keyboardHighlight: boolean; | |
| 279 | + resolvedTheme: "light" | "dark"; | |
| 280 | + isLoading: boolean; | |
| 281 | + triggerKind: ComposerTriggerKind | null; | |
| 282 | + onHighlight: (index: number) => void; | |
| 283 | + onSelect: (item: ComposerCommandItem) => void; | |
| 284 | + menuRef: RefObject<HTMLDivElement | null>; | |
| 285 | +}) { | |
| 286 | + return ( | |
| 287 | + <Command> | |
| 288 | + <div | |
| 289 | + ref={props.menuRef} | |
| 290 | + className="overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs" | |
| 291 | + > | |
| 292 | + <CommandList className="max-h-64"> | |
| 293 | + {props.items.map((item, index) => ( | |
| 294 | + <ComposerCommandMenuItem | |
| 295 | + key={item.id} | |
| 296 | + item={item} | |
| 297 | + index={index} | |
| 298 | + isHighlighted={index === props.selectedIndex} | |
| 299 | + keyboardHighlight={props.keyboardHighlight} | |
| 300 | + resolvedTheme={props.resolvedTheme} | |
| 301 | + onHighlight={props.onHighlight} | |
| 302 | + onSelect={props.onSelect} | |
| 303 | + /> | |
| 304 | + ))} | |
| 305 | + </CommandList> | |
| 306 | + {props.items.length === 0 && ( | |
| 307 | + <p className="px-3 py-2 text-muted-foreground/70 text-xs"> | |
| 308 | + {props.isLoading | |
| 309 | + ? "Searching workspace files..." | |
| 310 | + : props.triggerKind === "path" | |
| 311 | + ? "No matching files or folders." | |
| 312 | + : "No matching command."} | |
| 313 | + </p> | |
| 314 | + )} | |
| 315 | + </div> | |
| 316 | + </Command> | |
| 317 | + ); | |
| 318 | +}); | |
| 319 | + | |
| 212 | 320 | export default function ChatView() { |
| 213 | 321 | const { state, dispatch } = useStore(); |
| 214 | 322 | const api = useNativeApi(); |
| @@ -231,6 +339,7 @@ export default function ChatView() { · 16 unmodified lines | ||
| 231 | 339 | const [nowTick, setNowTick] = useState(() => Date.now()); |
| 232 | 340 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); |
| 233 | 341 | const [composerCursor, setComposerCursor] = useState(0); |
| 342 | + const [debouncedPathQuery, setDebouncedPathQuery] = useState(""); | |
| 234 | 343 | const [composerMenuIndex, setComposerMenuIndex] = useState(0); |
| 235 | 344 | const [composerKeyboardHighlight, setComposerKeyboardHighlight] = useState(false); |
| 236 | 345 | const messagesScrollRef = useRef<HTMLDivElement>(null); |
| @@ -349,34 +458,55 @@ export default function ChatView() { · 112 unmodified lines | ||
| 349 | 458 | () => detectComposerTrigger(prompt, composerCursor), |
| 350 | 459 | [prompt, composerCursor], |
| 351 | 460 | ); |
| 461 | + const composerTriggerKind = composerTrigger?.kind ?? null; | |
| 462 | + const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; | |
| 463 | + const isPathTrigger = composerTriggerKind === "path"; | |
| 352 | 464 | const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd)); |
| 353 | 465 | const keybindingsQuery = useQuery({ |
| 354 | 466 | ...serverConfigQueryOptions(api), |
| 355 | 467 | select: (config) => config.keybindings, |
| 356 | 468 | }); |
| 469 | + useEffect(() => { | |
| 470 | + if (!isPathTrigger) { | |
| 471 | + setDebouncedPathQuery(""); | |
| 472 | + return; | |
| 473 | + } | |
| 474 | + if (pathTriggerQuery.length === 0) { | |
| 475 | + setDebouncedPathQuery(""); | |
| 476 | + return; | |
| 477 | + } | |
| 478 | + const timer = window.setTimeout(() => { | |
| 479 | + setDebouncedPathQuery(pathTriggerQuery); | |
| 480 | + }, COMPOSER_PATH_QUERY_DEBOUNCE_MS); | |
| 481 | + return () => { | |
| 482 | + window.clearTimeout(timer); | |
| 483 | + }; | |
| 484 | + }, [isPathTrigger, pathTriggerQuery]); | |
| 357 | 485 | const workspaceEntriesQuery = useQuery({ |
| 358 | 486 | queryKey: [ |
| 359 | 487 | "composer-workspace-entries", |
| 360 | 488 | gitCwd, |
| 361 | − composerTrigger?.kind === "path" ? composerTrigger.query : "", | |
| 489 | + debouncedPathQuery, | |
| 362 | 490 | ], |
| 363 | − enabled: Boolean(api && gitCwd && composerTrigger?.kind === "path"), | |
| 491 | + enabled: Boolean(api && gitCwd && isPathTrigger), | |
| 364 | 492 | staleTime: 15_000, |
| 493 | + placeholderData: (previous) => previous, | |
| 365 | 494 | queryFn: async () => { |
| 366 | − if (!api || !gitCwd || composerTrigger?.kind !== "path") { | |
| 495 | + if (!api || !gitCwd || !isPathTrigger) { | |
| 367 | 496 | return { entries: [], truncated: false }; |
| 368 | 497 | } |
| 369 | 498 | return api.projects.searchEntries({ |
| 370 | 499 | cwd: gitCwd, |
| 371 | − query: composerTrigger.query, | |
| 500 | + query: debouncedPathQuery, | |
| 372 | 501 | limit: 80, |
| 373 | 502 | }); |
| 374 | 503 | }, |
| 375 | 504 | }); |
| 505 | + const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES; | |
| 376 | 506 | const composerMenuItems = useMemo<ComposerCommandItem[]>(() => { |
| 377 | 507 | if (!composerTrigger) return []; |
| 378 | 508 | if (composerTrigger.kind === "path") { |
| 379 | − return (workspaceEntriesQuery.data?.entries ?? []).map((entry) => ({ | |
| 509 | + return workspaceEntries.map((entry) => ({ | |
| 380 | 510 | id: `path:${entry.kind}:${entry.path}`, |
| 381 | 511 | type: "path", |
| 382 | 512 | path: entry.path, |
| @@ -400,10 +530,10 @@ export default function ChatView() { · 17 unmodified lines | ||
| 400 | 530 | ]; |
| 401 | 531 | } |
| 402 | 532 | |
| 403 | − return MODEL_OPTIONS.filter(({ slug, name }) => { | |
| 533 | + return SEARCHABLE_MODEL_OPTIONS.filter(({ searchSlug, searchName }) => { | |
| 404 | 534 | const query = composerTrigger.query.trim().toLowerCase(); |
| 405 | 535 | if (!query) return true; |
| 406 | − return slug.toLowerCase().includes(query) || name.toLowerCase().includes(query); | |
| 536 | + return searchSlug.includes(query) || searchName.includes(query); | |
| 407 | 537 | }).map(({ slug, name }) => ({ |
| 408 | 538 | id: `model:${slug}`, |
| 409 | 539 | type: "model", |
| @@ -411,7 +541,7 @@ export default function ChatView() { · 1 unmodified line | ||
| 411 | 541 | label: name, |
| 412 | 542 | description: slug, |
| 413 | 543 | })); |
| 414 | − }, [composerTrigger, workspaceEntriesQuery.data?.entries]); | |
| 544 | + }, [composerTrigger, workspaceEntries]); | |
| 415 | 545 | const composerMenuOpen = Boolean(composerTrigger); |
| 416 | 546 | const activeComposerMenuItem = |
| 417 | 547 | composerMenuItems[Math.min(composerMenuIndex, Math.max(0, composerMenuItems.length - 1))] ?? |
| @@ -609,7 +739,7 @@ export default function ChatView() { · 191 unmodified lines | ||
| 609 | 739 | `[data-composer-menu-index="${aheadIndex}"]`, |
| 610 | 740 | ); |
| 611 | 741 | node?.scrollIntoView({ block: "nearest" }); |
| 612 | − }, [composerMenuIndex, composerMenuItems, composerMenuOpen]); | |
| 742 | + }, [composerMenuIndex, composerMenuItems.length, composerMenuOpen]); | |
| 613 | 743 | |
| 614 | 744 | useEffect(() => { |
| 615 | 745 | if (!activeThread?.id || activeThread.terminalOpen) return; |
| @@ -1163,6 +1293,12 @@ export default function ChatView() { · 547 unmodified lines | ||
| 1163 | 1293 | }, |
| 1164 | 1294 | [applyPromptReplacement, composerTrigger, onModelSelect], |
| 1165 | 1295 | ); |
| 1296 | + const onHighlightComposerMenuItem = useCallback((index: number) => { | |
| 1297 | + setComposerKeyboardHighlight(false); | |
| 1298 | + setComposerMenuIndex(index); | |
| 1299 | + }, []); | |
| 1300 | + const isComposerMenuLoading = | |
| 1301 | + composerTriggerKind === "path" && (workspaceEntriesQuery.isLoading || workspaceEntriesQuery.isFetching); | |
| 1166 | 1302 | |
| 1167 | 1303 | const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => { |
| 1168 | 1304 | setPrompt(nextPrompt); |
| @@ -1291,67 +1427,17 @@ export default function ChatView() { · 122 unmodified lines | ||
| 1291 | 1427 | <div className="relative px-4 pt-4 pb-2"> |
| 1292 | 1428 | {composerMenuOpen && ( |
| 1293 | 1429 | <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1"> |
| 1294 | − <Command> | |
| 1295 | − <div | |
| 1296 | − ref={composerMenuRef} | |
| 1297 | − className="overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs" | |
| 1298 | − > | |
| 1299 | − <CommandList className="max-h-64"> | |
| 1300 | − {composerMenuItems.map((item, index) => ( | |
| 1301 | − <CommandItem | |
| 1302 | − key={item.id} | |
| 1303 | − value={item.id} | |
| 1304 | − data-composer-menu-index={index} | |
| 1305 | − className={cn( | |
| 1306 | − "cursor-pointer gap-2", | |
| 1307 | − composerKeyboardHighlight && | |
| 1308 | − "data-highlighted:bg-transparent data-highlighted:text-inherit", | |
| 1309 | − index === composerMenuIndex && "bg-accent text-accent-foreground", | |
| 1310 | − )} | |
| 1311 | − onMouseEnter={() => { | |
| 1312 | − setComposerKeyboardHighlight(false); | |
| 1313 | − setComposerMenuIndex(index); | |
| 1314 | − }} | |
| 1315 | − onMouseDown={(event) => { | |
| 1316 | − event.preventDefault(); | |
| 1317 | − }} | |
| 1318 | − onClick={() => { | |
| 1319 | − onSelectComposerItem(item); | |
| 1320 | − }} | |
| 1321 | − > | |
| 1322 | − {item.type === "path" ? ( | |
| 1323 | − <VscodeEntryIcon | |
| 1324 | − pathValue={item.path} | |
| 1325 | − kind={item.pathKind} | |
| 1326 | − theme={resolvedTheme} | |
| 1327 | − /> | |
| 1328 | − ) : null} | |
| 1329 | − {item.type === "slash-command" ? ( | |
| 1330 | − <CursorIcon className="size-4 text-muted-foreground/80" /> | |
| 1331 | − ) : null} | |
| 1332 | − {item.type === "model" ? ( | |
| 1333 | − <Badge variant="outline" className="px-1.5 py-0 text-[10px]"> | |
| 1334 | − model | |
| 1335 | − </Badge> | |
| 1336 | − ) : null} | |
| 1337 | − <span className="truncate">{item.label}</span> | |
| 1338 | − <span className="truncate text-muted-foreground/70 text-xs"> | |
| 1339 | − {item.description} | |
| 1340 | − </span> | |
| 1341 | − </CommandItem> | |
| 1342 | − ))} | |
| 1343 | − </CommandList> | |
| 1344 | − {composerMenuItems.length === 0 && ( | |
| 1345 | − <p className="px-3 py-2 text-muted-foreground/70 text-xs"> | |
| 1346 | − {workspaceEntriesQuery.isLoading | |
| 1347 | − ? "Searching workspace files..." | |
| 1348 | − : composerTrigger?.kind === "path" | |
| 1349 | − ? "No matching files or folders." | |
| 1350 | − : "No matching command."} | |
| 1351 | − </p> | |
| 1352 | − )} | |
| 1353 | − </div> | |
| 1354 | − </Command> | |
| 1430 | + <ComposerCommandMenu | |
| 1431 | + items={composerMenuItems} | |
| 1432 | + selectedIndex={composerMenuIndex} | |
| 1433 | + keyboardHighlight={composerKeyboardHighlight} | |
| 1434 | + resolvedTheme={resolvedTheme} | |
| 1435 | + isLoading={isComposerMenuLoading} | |
| 1436 | + triggerKind={composerTriggerKind} | |
| 1437 | + onHighlight={onHighlightComposerMenuItem} | |
| 1438 | + onSelect={onSelectComposerItem} | |
| 1439 | + menuRef={composerMenuRef} | |
| 1440 | + /> | |
| 1355 | 1441 | </div> |
| 1356 | 1442 | )} |
| 1357 | 1443 | |