History

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

Julius Marminge committed 8 months ago

Commit b67f3e61 parent f04b2d91 file · +157 −71Browse files

Unified · Split

apps/web/src/components/ChatView.tsx+157−71
@@ -32,7 +32,11 @@ import { serverConfigQueryOptions } from "~/lib/serverReactQuery"; · 31 unmodified lines
3232
3333 import { isElectron } from "../env";
3434 import { buildBootstrapInput } from "../historyBootstrap";
35−import { detectComposerTrigger, replaceTextRange } from "../composer-logic";
35+import {
36+ type ComposerTriggerKind,
37+ detectComposerTrigger,
38+ replaceTextRange,
39+} from "../composer-logic";
3640 import {
3741 DEFAULT_MODEL,
3842 DEFAULT_REASONING,
@@ -102,6 +106,14 @@ const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES · 63 unmodified lines
102106 const IMAGE_ONLY_BOOTSTRAP_PROMPT =
103107 "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";
104108 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+}));
105117
106118 function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
107119 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
209221 );
210222 });
211223
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+
212320 export default function ChatView() {
213321 const { state, dispatch } = useStore();
214322 const api = useNativeApi();
@@ -231,6 +339,7 @@ export default function ChatView() { · 16 unmodified lines
231339 const [nowTick, setNowTick] = useState(() => Date.now());
232340 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
233341 const [composerCursor, setComposerCursor] = useState(0);
342+ const [debouncedPathQuery, setDebouncedPathQuery] = useState("");
234343 const [composerMenuIndex, setComposerMenuIndex] = useState(0);
235344 const [composerKeyboardHighlight, setComposerKeyboardHighlight] = useState(false);
236345 const messagesScrollRef = useRef<HTMLDivElement>(null);
@@ -349,34 +458,55 @@ export default function ChatView() { · 112 unmodified lines
349458 () => detectComposerTrigger(prompt, composerCursor),
350459 [prompt, composerCursor],
351460 );
461+ const composerTriggerKind = composerTrigger?.kind ?? null;
462+ const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : "";
463+ const isPathTrigger = composerTriggerKind === "path";
352464 const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd));
353465 const keybindingsQuery = useQuery({
354466 ...serverConfigQueryOptions(api),
355467 select: (config) => config.keybindings,
356468 });
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]);
357485 const workspaceEntriesQuery = useQuery({
358486 queryKey: [
359487 "composer-workspace-entries",
360488 gitCwd,
361− composerTrigger?.kind === "path" ? composerTrigger.query : "",
489+ debouncedPathQuery,
362490 ],
363− enabled: Boolean(api && gitCwd && composerTrigger?.kind === "path"),
491+ enabled: Boolean(api && gitCwd && isPathTrigger),
364492 staleTime: 15_000,
493+ placeholderData: (previous) => previous,
365494 queryFn: async () => {
366− if (!api || !gitCwd || composerTrigger?.kind !== "path") {
495+ if (!api || !gitCwd || !isPathTrigger) {
367496 return { entries: [], truncated: false };
368497 }
369498 return api.projects.searchEntries({
370499 cwd: gitCwd,
371− query: composerTrigger.query,
500+ query: debouncedPathQuery,
372501 limit: 80,
373502 });
374503 },
375504 });
505+ const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES;
376506 const composerMenuItems = useMemo<ComposerCommandItem[]>(() => {
377507 if (!composerTrigger) return [];
378508 if (composerTrigger.kind === "path") {
379− return (workspaceEntriesQuery.data?.entries ?? []).map((entry) => ({
509+ return workspaceEntries.map((entry) => ({
380510 id: `path:${entry.kind}:${entry.path}`,
381511 type: "path",
382512 path: entry.path,
@@ -400,10 +530,10 @@ export default function ChatView() { · 17 unmodified lines
400530 ];
401531 }
402532
403− return MODEL_OPTIONS.filter(({ slug, name }) => {
533+ return SEARCHABLE_MODEL_OPTIONS.filter(({ searchSlug, searchName }) => {
404534 const query = composerTrigger.query.trim().toLowerCase();
405535 if (!query) return true;
406− return slug.toLowerCase().includes(query) || name.toLowerCase().includes(query);
536+ return searchSlug.includes(query) || searchName.includes(query);
407537 }).map(({ slug, name }) => ({
408538 id: `model:${slug}`,
409539 type: "model",
@@ -411,7 +541,7 @@ export default function ChatView() { · 1 unmodified line
411541 label: name,
412542 description: slug,
413543 }));
414− }, [composerTrigger, workspaceEntriesQuery.data?.entries]);
544+ }, [composerTrigger, workspaceEntries]);
415545 const composerMenuOpen = Boolean(composerTrigger);
416546 const activeComposerMenuItem =
417547 composerMenuItems[Math.min(composerMenuIndex, Math.max(0, composerMenuItems.length - 1))] ??
@@ -609,7 +739,7 @@ export default function ChatView() { · 191 unmodified lines
609739 `[data-composer-menu-index="${aheadIndex}"]`,
610740 );
611741 node?.scrollIntoView({ block: "nearest" });
612− }, [composerMenuIndex, composerMenuItems, composerMenuOpen]);
742+ }, [composerMenuIndex, composerMenuItems.length, composerMenuOpen]);
613743
614744 useEffect(() => {
615745 if (!activeThread?.id || activeThread.terminalOpen) return;
@@ -1163,6 +1293,12 @@ export default function ChatView() { · 547 unmodified lines
11631293 },
11641294 [applyPromptReplacement, composerTrigger, onModelSelect],
11651295 );
1296+ const onHighlightComposerMenuItem = useCallback((index: number) => {
1297+ setComposerKeyboardHighlight(false);
1298+ setComposerMenuIndex(index);
1299+ }, []);
1300+ const isComposerMenuLoading =
1301+ composerTriggerKind === "path" && (workspaceEntriesQuery.isLoading || workspaceEntriesQuery.isFetching);
11661302
11671303 const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => {
11681304 setPrompt(nextPrompt);
@@ -1291,67 +1427,17 @@ export default function ChatView() { · 122 unmodified lines
12911427 <div className="relative px-4 pt-4 pb-2">
12921428 {composerMenuOpen && (
12931429 <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+ />
13551441 </div>
13561442 )}
13571443