authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-17 12:20:07-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-17 19:47:09-08:00
logbd6a5c85e6f873997a3e0fc8dd3f773f9acc7893
tree6758a3e31b8ed36f54aeeb19158bb6fce9334a0e
parent9cce5090138a12314f3d121118b692f1d4a0660e

Refactor composer menu and debounce path entry search

- 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

1 files changed, 157 insertions(+), 71 deletions(-)

apps/web/src/components/ChatView.tsx+157-71
...@@ -32,7 +32,11 @@ import { serverConfigQueryOptions } from "~/lib/serverReactQuery";...@@ -32,7 +32,11 @@ import { serverConfigQueryOptions } from "~/lib/serverReactQuery";
3232
33import { isElectron } from "../env";33import { isElectron } from "../env";
34import { buildBootstrapInput } from "../historyBootstrap";34import { buildBootstrapInput } from "../historyBootstrap";
35import { detectComposerTrigger, replaceTextRange } from "../composer-logic";35import {
36 type ComposerTriggerKind,
37 detectComposerTrigger,
38 replaceTextRange,
39} from "../composer-logic";
36import {40import {
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...@@ -102,6 +106,14 @@ const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES
102const IMAGE_ONLY_BOOTSTRAP_PROMPT =106const 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).]";
104const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];108const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
109const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];
110const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;
111const SEARCHABLE_MODEL_OPTIONS = MODEL_OPTIONS.map(({ slug, name }) => ({
112 slug,
113 name,
114 searchSlug: slug.toLowerCase(),
115 searchName: name.toLowerCase(),
116}));
105117
106function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {118function 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: {...@@ -209,6 +221,102 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: {
209 );221 );
210});222});
211223
224const 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
275const 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
212export default function ChatView() {320export 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() {...@@ -231,6 +339,7 @@ export default function ChatView() {
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() {...@@ -349,34 +458,55 @@ export default function ChatView() {
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() {...@@ -400,10 +530,10 @@ export default function ChatView() {
400 ];530 ];
401 }531 }
402532
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() {...@@ -411,7 +541,7 @@ export default function ChatView() {
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))] ??
...@@ -611,7 +741,7 @@ export default function ChatView() {...@@ -611,7 +741,7 @@ export default function ChatView() {
611 `[data-composer-menu-index="${aheadIndex}"]`,741 `[data-composer-menu-index="${aheadIndex}"]`,
612 );742 );
613 node?.scrollIntoView({ block: "nearest" });743 node?.scrollIntoView({ block: "nearest" });
614 }, [composerMenuIndex, composerMenuItems, composerMenuOpen]);744 }, [composerMenuIndex, composerMenuItems.length, composerMenuOpen]);
615745
616 useEffect(() => {746 useEffect(() => {
617 if (!activeThread?.id || activeThread.terminalOpen) return;747 if (!activeThread?.id || activeThread.terminalOpen) return;
...@@ -1165,6 +1295,12 @@ export default function ChatView() {...@@ -1165,6 +1295,12 @@ export default function ChatView() {
1165 },1295 },
1166 [applyPromptReplacement, composerTrigger, onModelSelect],1296 [applyPromptReplacement, composerTrigger, onModelSelect],
1167 );1297 );
1298 const onHighlightComposerMenuItem = useCallback((index: number) => {
1299 setComposerKeyboardHighlight(false);
1300 setComposerMenuIndex(index);
1301 }, []);
1302 const isComposerMenuLoading =
1303 composerTriggerKind === "path" && (workspaceEntriesQuery.isLoading || workspaceEntriesQuery.isFetching);
11681304
1169 const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => {1305 const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => {
1170 setPrompt(nextPrompt);1306 setPrompt(nextPrompt);
...@@ -1293,67 +1429,17 @@ export default function ChatView() {...@@ -1293,67 +1429,17 @@ export default function ChatView() {
1293 <div className="relative px-4 pt-4 pb-2">1429 <div className="relative px-4 pt-4 pb-2">
1294 {composerMenuOpen && (1430 {composerMenuOpen && (
1295 <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1">1431 <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1">
1296 <Command>1432 <ComposerCommandMenu
1297 <div1433 items={composerMenuItems}
1298 ref={composerMenuRef}1434 selectedIndex={composerMenuIndex}
1299 className="overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs"1435 keyboardHighlight={composerKeyboardHighlight}
1300 >1436 resolvedTheme={resolvedTheme}
1301 <CommandList className="max-h-64">1437 isLoading={isComposerMenuLoading}
1302 {composerMenuItems.map((item, index) => (1438 triggerKind={composerTriggerKind}
1303 <CommandItem1439 onHighlight={onHighlightComposerMenuItem}
1304 key={item.id}1440 onSelect={onSelectComposerItem}
1305 value={item.id}1441 menuRef={composerMenuRef}
1306 data-composer-menu-index={index}1442 />
1307 className={cn(
1308 "cursor-pointer gap-2",
1309 composerKeyboardHighlight &&
1310 "data-highlighted:bg-transparent data-highlighted:text-inherit",
1311 index === composerMenuIndex && "bg-accent text-accent-foreground",
1312 )}
1313 onMouseEnter={() => {
1314 setComposerKeyboardHighlight(false);
1315 setComposerMenuIndex(index);
1316 }}
1317 onMouseDown={(event) => {
1318 event.preventDefault();
1319 }}
1320 onClick={() => {
1321 onSelectComposerItem(item);
1322 }}
1323 >
1324 {item.type === "path" ? (
1325 <VscodeEntryIcon
1326 pathValue={item.path}
1327 kind={item.pathKind}
1328 theme={resolvedTheme}
1329 />
1330 ) : null}
1331 {item.type === "slash-command" ? (
1332 <CursorIcon className="size-4 text-muted-foreground/80" />
1333 ) : null}
1334 {item.type === "model" ? (
1335 <Badge variant="outline" className="px-1.5 py-0 text-[10px]">
1336 model
1337 </Badge>
1338 ) : null}
1339 <span className="truncate">{item.label}</span>
1340 <span className="truncate text-muted-foreground/70 text-xs">
1341 {item.description}
1342 </span>
1343 </CommandItem>
1344 ))}
1345 </CommandList>
1346 {composerMenuItems.length === 0 && (
1347 <p className="px-3 py-2 text-muted-foreground/70 text-xs">
1348 {workspaceEntriesQuery.isLoading
1349 ? "Searching workspace files..."
1350 : composerTrigger?.kind === "path"
1351 ? "No matching files or folders."
1352 : "No matching command."}
1353 </p>
1354 )}
1355 </div>
1356 </Command>
1357 </div>1443 </div>
1358 )}1444 )}
13591445