- 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 | import { isElectron } from "../env"; | 33 | import { isElectron } from "../env"; |
| 34 | import { buildBootstrapInput } from "../historyBootstrap"; | 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 | import { | 40 | import { |
| 37 | DEFAULT_MODEL, | 41 | DEFAULT_MODEL, |
| 38 | DEFAULT_REASONING, | 42 | DEFAULT_REASONING, |
| @@ -102,6 +106,14 @@ const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES · 63 unmodified lines | |||
| 102 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = | 106 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = |
| 103 | "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; | 107 | "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; |
| 104 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; | 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 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { | 118 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { |
| 107 | if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; | 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 | export default function ChatView() { | 320 | export default function ChatView() { |
| 213 | const { state, dispatch } = useStore(); | 321 | const { state, dispatch } = useStore(); |
| 214 | const api = useNativeApi(); | 322 | const api = useNativeApi(); |
| @@ -231,6 +339,7 @@ export default function ChatView() { · 16 unmodified lines | |||
| 231 | const [nowTick, setNowTick] = useState(() => Date.now()); | 339 | const [nowTick, setNowTick] = useState(() => Date.now()); |
| 232 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); | 340 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); |
| 233 | const [composerCursor, setComposerCursor] = useState(0); | 341 | const [composerCursor, setComposerCursor] = useState(0); |
| 342 | const [debouncedPathQuery, setDebouncedPathQuery] = useState(""); | ||
| 234 | const [composerMenuIndex, setComposerMenuIndex] = useState(0); | 343 | const [composerMenuIndex, setComposerMenuIndex] = useState(0); |
| 235 | const [composerKeyboardHighlight, setComposerKeyboardHighlight] = useState(false); | 344 | const [composerKeyboardHighlight, setComposerKeyboardHighlight] = useState(false); |
| 236 | const messagesScrollRef = useRef<HTMLDivElement>(null); | 345 | const messagesScrollRef = useRef<HTMLDivElement>(null); |
| @@ -349,34 +458,55 @@ export default function ChatView() { · 112 unmodified lines | |||
| 349 | () => detectComposerTrigger(prompt, composerCursor), | 458 | () => detectComposerTrigger(prompt, composerCursor), |
| 350 | [prompt, composerCursor], | 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 | const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd)); | 464 | const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd)); |
| 353 | const keybindingsQuery = useQuery({ | 465 | const keybindingsQuery = useQuery({ |
| 354 | ...serverConfigQueryOptions(api), | 466 | ...serverConfigQueryOptions(api), |
| 355 | select: (config) => config.keybindings, | 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 | const workspaceEntriesQuery = useQuery({ | 485 | const workspaceEntriesQuery = useQuery({ |
| 358 | queryKey: [ | 486 | queryKey: [ |
| 359 | "composer-workspace-entries", | 487 | "composer-workspace-entries", |
| 360 | gitCwd, | 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 | staleTime: 15_000, | 492 | staleTime: 15_000, |
| 493 | placeholderData: (previous) => previous, | ||
| 365 | queryFn: async () => { | 494 | queryFn: async () => { |
| 366 | if (!api || !gitCwd || composerTrigger?.kind !== "path") { | 495 | if (!api || !gitCwd || !isPathTrigger) { |
| 367 | return { entries: [], truncated: false }; | 496 | return { entries: [], truncated: false }; |
| 368 | } | 497 | } |
| 369 | return api.projects.searchEntries({ | 498 | return api.projects.searchEntries({ |
| 370 | cwd: gitCwd, | 499 | cwd: gitCwd, |
| 371 | query: composerTrigger.query, | 500 | query: debouncedPathQuery, |
| 372 | limit: 80, | 501 | limit: 80, |
| 373 | }); | 502 | }); |
| 374 | }, | 503 | }, |
| 375 | }); | 504 | }); |
| 505 | const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES; | ||
| 376 | const composerMenuItems = useMemo<ComposerCommandItem[]>(() => { | 506 | const composerMenuItems = useMemo<ComposerCommandItem[]>(() => { |
| 377 | if (!composerTrigger) return []; | 507 | if (!composerTrigger) return []; |
| 378 | if (composerTrigger.kind === "path") { | 508 | if (composerTrigger.kind === "path") { |
| 379 | return (workspaceEntriesQuery.data?.entries ?? []).map((entry) => ({ | 509 | return workspaceEntries.map((entry) => ({ |
| 380 | id: `path:${entry.kind}:${entry.path}`, | 510 | id: `path:${entry.kind}:${entry.path}`, |
| 381 | type: "path", | 511 | type: "path", |
| 382 | path: entry.path, | 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 | const query = composerTrigger.query.trim().toLowerCase(); | 534 | const query = composerTrigger.query.trim().toLowerCase(); |
| 405 | if (!query) return true; | 535 | if (!query) return true; |
| 406 | return slug.toLowerCase().includes(query) || name.toLowerCase().includes(query); | 536 | return searchSlug.includes(query) || searchName.includes(query); |
| 407 | }).map(({ slug, name }) => ({ | 537 | }).map(({ slug, name }) => ({ |
| 408 | id: `model:${slug}`, | 538 | id: `model:${slug}`, |
| 409 | type: "model", | 539 | type: "model", |
| @@ -411,7 +541,7 @@ export default function ChatView() { · 1 unmodified line | |||
| 411 | label: name, | 541 | label: name, |
| 412 | description: slug, | 542 | description: slug, |
| 413 | })); | 543 | })); |
| 414 | }, [composerTrigger, workspaceEntriesQuery.data?.entries]); | 544 | }, [composerTrigger, workspaceEntries]); |
| 415 | const composerMenuOpen = Boolean(composerTrigger); | 545 | const composerMenuOpen = Boolean(composerTrigger); |
| 416 | const activeComposerMenuItem = | 546 | const activeComposerMenuItem = |
| 417 | composerMenuItems[Math.min(composerMenuIndex, Math.max(0, composerMenuItems.length - 1))] ?? | 547 | composerMenuItems[Math.min(composerMenuIndex, Math.max(0, composerMenuItems.length - 1))] ?? |
| @@ -609,7 +739,7 @@ export default function ChatView() { · 191 unmodified lines | |||
| 609 | `[data-composer-menu-index="${aheadIndex}"]`, | 739 | `[data-composer-menu-index="${aheadIndex}"]`, |
| 610 | ); | 740 | ); |
| 611 | node?.scrollIntoView({ block: "nearest" }); | 741 | node?.scrollIntoView({ block: "nearest" }); |
| 612 | }, [composerMenuIndex, composerMenuItems, composerMenuOpen]); | 742 | }, [composerMenuIndex, composerMenuItems.length, composerMenuOpen]); |
| 613 | 743 | ||
| 614 | useEffect(() => { | 744 | useEffect(() => { |
| 615 | if (!activeThread?.id || activeThread.terminalOpen) return; | 745 | if (!activeThread?.id || activeThread.terminalOpen) return; |
| @@ -1163,6 +1293,12 @@ export default function ChatView() { · 547 unmodified lines | |||
| 1163 | }, | 1293 | }, |
| 1164 | [applyPromptReplacement, composerTrigger, onModelSelect], | 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 | const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => { | 1303 | const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => { |
| 1168 | setPrompt(nextPrompt); | 1304 | setPrompt(nextPrompt); |
| @@ -1291,67 +1427,17 @@ export default function ChatView() { · 122 unmodified lines | |||
| 1291 | <div className="relative px-4 pt-4 pb-2"> | 1427 | <div className="relative px-4 pt-4 pb-2"> |
| 1292 | {composerMenuOpen && ( | 1428 | {composerMenuOpen && ( |
| 1293 | <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1"> | 1429 | <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1"> |
| 1294 | <Command> | 1430 | <ComposerCommandMenu |
| 1295 | <div | 1431 | items={composerMenuItems} |
| 1296 | ref={composerMenuRef} | 1432 | selectedIndex={composerMenuIndex} |
| 1297 | className="overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs" | 1433 | keyboardHighlight={composerKeyboardHighlight} |
| 1298 | > | 1434 | resolvedTheme={resolvedTheme} |
| 1299 | <CommandList className="max-h-64"> | 1435 | isLoading={isComposerMenuLoading} |
| 1300 | {composerMenuItems.map((item, index) => ( | 1436 | triggerKind={composerTriggerKind} |
| 1301 | <CommandItem | 1437 | onHighlight={onHighlightComposerMenuItem} |
| 1302 | key={item.id} | 1438 | onSelect={onSelectComposerItem} |
| 1303 | value={item.id} | 1439 | menuRef={composerMenuRef} |
| 1304 | data-composer-menu-index={index} | 1440 | /> |
| 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> | ||
| 1355 | </div> | 1441 | </div> |
| 1356 | )} | 1442 | )} |
| 1357 | 1443 | ||