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
32 32
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 · 63 unmodified lines
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}));
105 117
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: { · 101 unmodified lines
209 );221 );
210});222});
211 223
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() { · 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 <div1431 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 <CommandItem1437 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