authorgravatar for 97427849+flamboh@users.noreply.github.comoliver <97427849+flamboh@users.noreply.github.com> 2026-10-03 13:27:38-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 17:27:38-03:00
logfffe6e6c82c12769e0cbc4d89a23ec845c5abed1
tree7470f9cac23c4e224933e81d1868e89d1ff5f44d
parent71dbaf1f94b580491d033f2e347a9b1c507fd793
signature Signed by PGP key B5690EEEBB952194

perf(web): virtualize command palette results (#15266)


8 files changed, 400 insertions(+), 19 deletions(-)

apps/web/src/components/CommandPalette.logic.test.ts+45
...@@ -5,12 +5,14 @@ import { makeThreadFixture } from "../test-fixtures";...@@ -5,12 +5,14 @@ import { makeThreadFixture } from "../test-fixtures";
5import {5import {
6 buildBrowseGroups,6 buildBrowseGroups,
7 buildCommandPaletteProjectMetadata,7 buildCommandPaletteProjectMetadata,
8 buildCommandPaletteRows,
8 buildProjectActionItems,9 buildProjectActionItems,
9 buildThreadActionItems,10 buildThreadActionItems,
10 buildLinkedThreadActionItems,11 buildLinkedThreadActionItems,
11 enumerateCommandPaletteItems,12 enumerateCommandPaletteItems,
12 filterPinnedBrowseEntries,13 filterPinnedBrowseEntries,
13 filterCommandPaletteGroups,14 filterCommandPaletteGroups,
15 findHighlightedCommandPaletteItem,
14 reduceCommandPaletteUiState,16 reduceCommandPaletteUiState,
15 type CommandPaletteActionItem,17 type CommandPaletteActionItem,
16 type CommandPaletteGroup,18 type CommandPaletteGroup,
...@@ -849,3 +851,46 @@ describe("filterCommandPaletteGroups", () => {...@@ -849,3 +851,46 @@ describe("filterCommandPaletteGroups", () => {
849 ]);851 ]);
850 });852 });
851});853});
854
855describe("virtualized command palette rows", () => {
856 const action = (value: string, disabled = false): CommandPaletteActionItem => ({
857 kind: "action",
858 value,
859 searchTerms: [],
860 title: value,
861 icon: null,
862 ...(disabled ? { disabled } : {}),
863 run: async () => {},
864 });
865 const groups: CommandPaletteGroup[] = [
866 { value: "actions", label: "Actions", items: [action("new-thread"), action("offline", true)] },
867 { value: "threads", label: "Threads", items: [action("thread-a"), action("thread-b")] },
868 ];
869
870 it("keeps group order and headings while indexing only enabled items", () => {
871 const { rows, itemValues, rowIndexByItemIndex } = buildCommandPaletteRows(groups);
872
873 expect(rows.map((row) => (row.kind === "label" ? `# ${row.label}` : row.key))).toEqual([
874 "# Actions",
875 "actions:new-thread",
876 "actions:offline",
877 "# Threads",
878 "threads:thread-a",
879 "threads:thread-b",
880 ]);
881 expect(itemValues).toEqual(["new-thread", "thread-a", "thread-b"]);
882 expect(rowIndexByItemIndex).toEqual([1, 4, 5]);
883 expect(rows.flatMap((row) => (row.kind === "item" ? [row.itemIndex] : []))).toEqual([
884 0,
885 null,
886 1,
887 2,
888 ]);
889 });
890
891 it("resolves Enter to the highlighted item without needing its row mounted", () => {
892 expect(findHighlightedCommandPaletteItem(groups, "thread-b")?.value).toBe("thread-b");
893 expect(findHighlightedCommandPaletteItem(groups, "offline")).toBeNull();
894 expect(findHighlightedCommandPaletteItem(groups, null)).toBeNull();
895 });
896});
apps/web/src/components/CommandPalette.logic.ts+59
...@@ -172,6 +172,65 @@ export interface CommandPaletteView {...@@ -172,6 +172,65 @@ export interface CommandPaletteView {
172 readonly initialQuery?: string;172 readonly initialQuery?: string;
173}173}
174174
175export type CommandPaletteRow =
176 | {
177 readonly kind: "label";
178 readonly key: string;
179 readonly label: string;
180 readonly first: boolean;
181 }
182 | {
183 readonly kind: "item";
184 readonly key: string;
185 readonly item: CommandPaletteActionItem | CommandPaletteSubmenuItem;
186 /** Position among enabled items, or null for disabled rows the keyboard skips. */
187 readonly itemIndex: number | null;
188 };
189
190/**
191 * Flattens groups into the rows a virtualized list renders. `itemValues` is the
192 * highlightable item order Base UI navigates; `rowIndexByItemIndex` maps a
193 * highlight back to its row for scrolling.
194 */
195export function buildCommandPaletteRows(groups: ReadonlyArray<CommandPaletteGroup>) {
196 const rows: CommandPaletteRow[] = [];
197 const itemValues: string[] = [];
198 const rowIndexByItemIndex: number[] = [];
199 for (const group of groups) {
200 if (group.label) {
201 rows.push({
202 kind: "label",
203 key: `group:${group.value}`,
204 label: group.label,
205 first: rows.length === 0,
206 });
207 }
208 for (const item of group.items) {
209 const itemIndex = item.disabled ? null : itemValues.length;
210 if (itemIndex !== null) {
211 itemValues.push(item.value);
212 rowIndexByItemIndex.push(rows.length);
213 }
214 rows.push({ kind: "item", key: `${group.value}:${item.value}`, item, itemIndex });
215 }
216 }
217 return { rows, itemValues, rowIndexByItemIndex };
218}
219
220/** The enabled item Enter should run for a highlight, whether or not its row is mounted. */
221export function findHighlightedCommandPaletteItem(
222 groups: ReadonlyArray<CommandPaletteGroup>,
223 highlightedItemValue: string | null,
224): CommandPaletteActionItem | CommandPaletteSubmenuItem | null {
225 if (highlightedItemValue === null) return null;
226 for (const group of groups) {
227 for (const item of group.items) {
228 if (item.value === highlightedItemValue && !item.disabled) return item;
229 }
230 }
231 return null;
232}
233
175export function enumerateCommandPaletteItems(234export function enumerateCommandPaletteItems(
176 items: ReadonlyArray<CommandPaletteActionItem>,235 items: ReadonlyArray<CommandPaletteActionItem>,
177): CommandPaletteActionItem[] {236): CommandPaletteActionItem[] {
apps/web/src/components/CommandPalette.tsx+73-10
...@@ -77,6 +77,7 @@ import {...@@ -77,6 +77,7 @@ import {
77 type KeyboardEvent,77 type KeyboardEvent,
78 type ReactNode,78 type ReactNode,
79} from "react";79} from "react";
80import type { LegendListRef } from "@legendapp/list/react";
80import { useAtomValue } from "@effect/atom-react";81import { useAtomValue } from "@effect/atom-react";
8182
82import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";83import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";
...@@ -157,7 +158,9 @@ import {...@@ -157,7 +158,9 @@ import {
157 buildRootGroups,158 buildRootGroups,
158 buildThreadActionItems,159 buildThreadActionItems,
159 buildLinkedThreadActionItems,160 buildLinkedThreadActionItems,
161 buildCommandPaletteRows,
160 enumerateCommandPaletteItems,162 enumerateCommandPaletteItems,
163 findHighlightedCommandPaletteItem,
161 type CommandPaletteActionItem,164 type CommandPaletteActionItem,
162 type CommandPaletteOpenIntent,165 type CommandPaletteOpenIntent,
163 type CommandPaletteSubmenuItem,166 type CommandPaletteSubmenuItem,
...@@ -174,7 +177,10 @@ import {...@@ -174,7 +177,10 @@ import {
174import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic";177import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic";
175import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";178import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";
176import { CommandPaletteContent } from "./CommandPaletteContent";179import { CommandPaletteContent } from "./CommandPaletteContent";
177import { CommandPaletteResults } from "./CommandPaletteResults";180import {
181 CommandPaletteVirtualizedResults,
182 scrollCommandPaletteRowIntoView,
183} from "./CommandPaletteResults";
178import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon, ForgejoIcon } from "./Icons";184import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon, ForgejoIcon } from "./Icons";
179import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";185import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
180import { Checkbox } from "./ui/checkbox";186import { Checkbox } from "./ui/checkbox";
...@@ -694,6 +700,15 @@ function OpenCommandPaletteDialog(props: {...@@ -694,6 +700,15 @@ function OpenCommandPaletteDialog(props: {
694 const deferredQuery = useDeferredValue(query);700 const deferredQuery = useDeferredValue(query);
695 const isActionsOnly = deferredQuery.startsWith(">");701 const isActionsOnly = deferredQuery.startsWith(">");
696 const [highlightedItemValue, setHighlightedItemValue] = useState<string | null>(null);702 const [highlightedItemValue, setHighlightedItemValue] = useState<string | null>(null);
703 const resultListRef = useRef<LegendListRef | null>(null);
704 // Typing or entering a submenu clears the highlight. Base UI keeps its own on the
705 // first row, but the palette shows none until the user navigates, and the first
706 // ArrowDown lands on it.
707 const highlightClearedRef = useRef(false);
708 function clearTypedHighlight(): void {
709 highlightClearedRef.current = true;
710 setHighlightedItemValue(null);
711 }
697 const clientSettings = useClientSettings();712 const clientSettings = useClientSettings();
698 const createProject = useAtomCommand(projectEnvironment.create, {713 const createProject = useAtomCommand(projectEnvironment.create, {
699 reportFailure: false,714 reportFailure: false,
...@@ -1425,7 +1440,7 @@ function OpenCommandPaletteDialog(props: {...@@ -1425,7 +1440,7 @@ function OpenCommandPaletteDialog(props: {
1425 ? {1440 ? {
1426 source: match.source,1441 source: match.source,
1427 snippet: match.snippet,1442 snippet: match.snippet,
1428 query: threadSearchQuery,1443 query: threadSearch.query,
1429 }1444 }
1430 : undefined;1445 : undefined;
1431 },1446 },
...@@ -1446,7 +1461,7 @@ function OpenCommandPaletteDialog(props: {...@@ -1446,7 +1461,7 @@ function OpenCommandPaletteDialog(props: {
1446 projectTitleById,1461 projectTitleById,
1447 providerEntryByEnvironmentAndInstanceId,1462 providerEntryByEnvironmentAndInstanceId,
1448 threadContentMatchByKey,1463 threadContentMatchByKey,
1449 threadSearchQuery,1464 threadSearch.query,
1450 threads,1465 threads,
1451 ],1466 ],
1452 );1467 );
...@@ -1463,6 +1478,7 @@ function OpenCommandPaletteDialog(props: {...@@ -1463,6 +1478,7 @@ function OpenCommandPaletteDialog(props: {
1463 ...(view.initialQuery ? { initialQuery: view.initialQuery } : {}),1478 ...(view.initialQuery ? { initialQuery: view.initialQuery } : {}),
1464 },1479 },
1465 ]);1480 ]);
1481 highlightClearedRef.current = true;
1466 setHighlightedItemValue(null);1482 setHighlightedItemValue(null);
1467 setQuery(view.initialQuery ?? "");1483 setQuery(view.initialQuery ?? "");
1468 },1484 },
...@@ -1494,7 +1510,7 @@ function OpenCommandPaletteDialog(props: {...@@ -1494,7 +1510,7 @@ function OpenCommandPaletteDialog(props: {
14941510
1495 function handleQueryChange(nextQuery: string): void {1511 function handleQueryChange(nextQuery: string): void {
1496 browseNavigation.invalidate();1512 browseNavigation.invalidate();
1497 setHighlightedItemValue(null);1513 clearTypedHighlight();
1498 setQuery(nextQuery);1514 setQuery(nextQuery);
1499 if (nextQuery === "" && currentView?.initialQuery) {1515 if (nextQuery === "" && currentView?.initialQuery) {
1500 popView();1516 popView();
...@@ -2938,6 +2954,9 @@ function OpenCommandPaletteDialog(props: {...@@ -2938,6 +2954,9 @@ function OpenCommandPaletteDialog(props: {
2938 } else if (isBrowsing) {2954 } else if (isBrowsing) {
2939 displayedGroups = relativePathNeedsActiveProject ? [] : browseGroups;2955 displayedGroups = relativePathNeedsActiveProject ? [] : browseGroups;
2940 }2956 }
2957 const resultRows = buildCommandPaletteRows(displayedGroups);
2958 const autoHighlightsFirstRow =
2959 !isBrowsing && !isRemoteProjectCloneFlow && newProjectFlow === null;
29412960
2942 const inputPlaceholder =2961 const inputPlaceholder =
2943 newProjectFlow !== null2962 newProjectFlow !== null
...@@ -3083,6 +3102,43 @@ function OpenCommandPaletteDialog(props: {...@@ -3083,6 +3102,43 @@ function OpenCommandPaletteDialog(props: {
3083 if (event.key === "Backspace" && query === "" && isSubmenu) {3102 if (event.key === "Backspace" && query === "" && isSubmenu) {
3084 event.preventDefault();3103 event.preventDefault();
3085 popView();3104 popView();
3105 return;
3106 }
3107
3108 // Base UI ignores navigation keys with modifiers, so these fallbacks do too.
3109 if (event.ctrlKey || event.shiftKey || event.altKey || event.metaKey) return;
3110 // Base UI only keeps a hidden highlight on the first row when it auto-highlights.
3111 const firstItemValue = autoHighlightsFirstRow ? resultRows.itemValues[0] : undefined;
3112 if (
3113 event.key === "ArrowDown" &&
3114 highlightClearedRef.current &&
3115 firstItemValue &&
3116 !event.nativeEvent.isComposing
3117 ) {
3118 (event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.();
3119 event.preventDefault();
3120 highlightClearedRef.current = false;
3121 setHighlightedItemValue(firstItemValue);
3122 scrollCommandPaletteRowIntoView(
3123 resultListRef.current,
3124 resultRows.rowIndexByItemIndex[0] ?? 0,
3125 );
3126 return;
3127 }
3128
3129 // Base UI clicks the highlighted row on Enter, which does nothing once the
3130 // virtualized list has unmounted it, so run the tracked highlight directly.
3131 if (event.key === "Enter" && !event.nativeEvent.isComposing && event.keyCode !== 229) {
3132 const highlightedItem = findHighlightedCommandPaletteItem(
3133 displayedGroups,
3134 highlightedItemValue ?? firstItemValue ?? null,
3135 );
3136 if (highlightedItem) {
3137 (event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.();
3138 event.preventDefault();
3139 event.stopPropagation();
3140 executeItem(highlightedItem);
3141 }
3086 }3142 }
3087 }3143 }
30883144
...@@ -3349,9 +3405,7 @@ function OpenCommandPaletteDialog(props: {...@@ -3349,9 +3405,7 @@ function OpenCommandPaletteDialog(props: {
3349 <CommandPaletteContent3405 <CommandPaletteContent
3350 key={`${viewStack.length}-${browseGeneration}-${isBrowsing}-${newProjectFlow ? "new-project" : (addProjectCloneFlow?.step ?? "none")}`}3406 key={`${viewStack.length}-${browseGeneration}-${isBrowsing}-${newProjectFlow ? "new-project" : (addProjectCloneFlow?.step ?? "none")}`}
3351 aria-label="Command palette"3407 aria-label="Command palette"
3352 autoHighlight={3408 autoHighlight={autoHighlightsFirstRow ? "always" : false}
3353 isBrowsing || isRemoteProjectCloneFlow || newProjectFlow !== null ? false : "always"
3354 }
3355 footerActionLabel={footerActionLabel}3409 footerActionLabel={footerActionLabel}
3356 footerTrailing={footerTrailing}3410 footerTrailing={footerTrailing}
3357 inputAccessory={inputAccessory}3411 inputAccessory={inputAccessory}
...@@ -3387,8 +3441,16 @@ function OpenCommandPaletteDialog(props: {...@@ -3387,8 +3441,16 @@ function OpenCommandPaletteDialog(props: {
3387 onKeyDown: handleKeyDown,3441 onKeyDown: handleKeyDown,
3388 }}3442 }}
3389 mode="none"3443 mode="none"
3390 onItemHighlighted={(value) => {3444 items={resultRows.itemValues}
3445 virtualized
3446 onItemHighlighted={(value, eventDetails) => {
3447 if (eventDetails.reason === "none" && highlightClearedRef.current) return;
3448 highlightClearedRef.current = false;
3391 setHighlightedItemValue(typeof value === "string" ? value : null);3449 setHighlightedItemValue(typeof value === "string" ? value : null);
3450 const rowIndex = resultRows.rowIndexByItemIndex[eventDetails.index];
3451 if (eventDetails.reason === "keyboard" && rowIndex !== undefined) {
3452 scrollCommandPaletteRowIntoView(resultListRef.current, rowIndex);
3453 }
3392 }}3454 }}
3393 onValueChange={handleQueryChange}3455 onValueChange={handleQueryChange}
3394 showBackHint={isSubmenu}3456 showBackHint={isSubmenu}
...@@ -3426,8 +3488,9 @@ function OpenCommandPaletteDialog(props: {...@@ -3426,8 +3488,9 @@ function OpenCommandPaletteDialog(props: {
3426 </div>3488 </div>
3427 </div>3489 </div>
3428 ) : null}3490 ) : null}
3429 <CommandPaletteResults3491 <CommandPaletteVirtualizedResults
3430 groups={displayedGroups}3492 rows={resultRows.rows}
3493 listRef={resultListRef}
3431 highlightedItemValue={highlightedItemValue}3494 highlightedItemValue={highlightedItemValue}
3432 isActionsOnly={isActionsOnly}3495 isActionsOnly={isActionsOnly}
3433 keybindings={keybindings}3496 keybindings={keybindings}
apps/web/src/components/CommandPaletteResults.tsx+102-8
...@@ -1,9 +1,13 @@...@@ -1,9 +1,13 @@
1import { type ResolvedKeybindingsConfig } from "@t3tools/contracts";1import { type ResolvedKeybindingsConfig } from "@t3tools/contracts";
2import { LegendList, type LegendListRef } from "@legendapp/list/react";
2import { ChevronRightIcon } from "lucide-react";3import { ChevronRightIcon } from "lucide-react";
4import { type RefObject, useCallback, useState } from "react";
5import { cn } from "~/lib/utils";
3import { shortcutLabelForCommand } from "../keybindings";6import { shortcutLabelForCommand } from "../keybindings";
4import {7import {
5 type CommandPaletteActionItem,8 type CommandPaletteActionItem,
6 type CommandPaletteGroup,9 type CommandPaletteGroup,
10 type CommandPaletteRow,
7 type CommandPaletteSubmenuItem,11 type CommandPaletteSubmenuItem,
8} from "./CommandPalette.logic";12} from "./CommandPalette.logic";
9import {13import {
...@@ -12,8 +16,11 @@ import {...@@ -12,8 +16,11 @@ import {
12 CommandGroupLabel,16 CommandGroupLabel,
13 CommandItem,17 CommandItem,
14 CommandList,18 CommandList,
19 CommandListHeading,
20 CommandListVirtualized,
15 CommandShortcut,21 CommandShortcut,
16} from "./ui/command";22} from "./ui/command";
23import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area";
17import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch";24import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch";
1825
19interface CommandPaletteResultsProps {26interface CommandPaletteResultsProps {
...@@ -25,16 +32,20 @@ interface CommandPaletteResultsProps {...@@ -25,16 +32,20 @@ interface CommandPaletteResultsProps {
25 onExecuteItem: (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => void;32 onExecuteItem: (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => void;
26}33}
2734
35function CommandPaletteEmptyState(props: { emptyStateMessage?: string; isActionsOnly: boolean }) {
36 return (
37 <div className="py-10 text-center text-sm text-muted-foreground">
38 {props.emptyStateMessage ??
39 (props.isActionsOnly
40 ? "No matching actions."
41 : "No matching commands, projects, or threads.")}
42 </div>
43 );
44}
45
28export function CommandPaletteResults(props: CommandPaletteResultsProps) {46export function CommandPaletteResults(props: CommandPaletteResultsProps) {
29 if (props.groups.length === 0) {47 if (props.groups.length === 0) {
30 return (48 return <CommandPaletteEmptyState {...props} />;
31 <div className="py-10 text-center text-sm text-muted-foreground">
32 {props.emptyStateMessage ??
33 (props.isActionsOnly
34 ? "No matching actions."
35 : "No matching commands, projects, or threads.")}
36 </div>
37 );
38 }49 }
3950
40 return (51 return (
...@@ -63,6 +74,87 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) {...@@ -63,6 +74,87 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) {
63 );74 );
64}75}
6576
77/**
78 * Scrolls a keyboard highlight into view the way the unvirtualized list did:
79 * nearest edge, clear of the scroll fade. Rows outside the rendered window
80 * fall back to the list's own scrolling.
81 */
82export function scrollCommandPaletteRowIntoView(list: LegendListRef | null, rowIndex: number) {
83 const element = list?.getState?.().elementAtIndex(rowIndex);
84 if (element instanceof HTMLElement) {
85 element.scrollIntoView({ block: "nearest" });
86 return;
87 }
88 void list?.scrollIndexIntoView?.({ index: rowIndex, animated: false });
89}
90
91interface CommandPaletteVirtualizedResultsProps extends Omit<CommandPaletteResultsProps, "groups"> {
92 rows: ReadonlyArray<CommandPaletteRow>;
93 listRef: RefObject<LegendListRef | null>;
94}
95
96/**
97 * Renders only the visible rows. The parent passes the flat item order to the
98 * Command root as `items` with `virtualized`, scrolls keyboard highlights into
99 * view through `listRef`, and runs Enter itself since the row may be unmounted.
100 */
101export function CommandPaletteVirtualizedResults(props: CommandPaletteVirtualizedResultsProps) {
102 const { listRef } = props;
103 const [scrollFade, setScrollFade] = useState({ top: false, bottom: false });
104 const updateScrollFade = useCallback(() => {
105 const scrollElement = listRef.current?.getScrollableNode?.();
106 if (!(scrollElement instanceof HTMLElement)) return;
107 const top = scrollElement.scrollTop > 1;
108 const bottom =
109 scrollElement.scrollHeight - scrollElement.clientHeight - scrollElement.scrollTop > 1;
110 setScrollFade((current) =>
111 current.top === top && current.bottom === bottom ? current : { top, bottom },
112 );
113 }, [listRef]);
114
115 if (props.rows.length === 0) {
116 return <CommandPaletteEmptyState {...props} />;
117 }
118
119 return (
120 <CommandListVirtualized>
121 <LegendList<CommandPaletteRow>
122 ref={listRef}
123 data={props.rows}
124 keyExtractor={(row) => row.key}
125 getItemType={(row) => row.kind}
126 extraData={props.highlightedItemValue}
127 renderItem={({ item: row }) =>
128 row.kind === "label" ? (
129 <div className={row.first ? undefined : "pt-1.5"}>
130 <CommandListHeading>{row.label}</CommandListHeading>
131 </div>
132 ) : row.itemIndex === null ? (
133 <DisabledCommandPaletteResultRow item={row.item} />
134 ) : (
135 <CommandPaletteResultRow
136 index={row.itemIndex}
137 item={row.item}
138 keybindings={props.keybindings}
139 isActive={props.highlightedItemValue === row.item.value}
140 onExecuteItem={props.onExecuteItem}
141 />
142 )
143 }
144 estimatedItemSize={40}
145 drawDistance={400}
146 onLayout={updateScrollFade}
147 onScroll={updateScrollFade}
148 contentContainerClassName="px-2"
149 className={cn(
150 "min-h-0 scroll-py-6 overflow-x-hidden overscroll-y-contain py-2",
151 getVirtualizedScrollFadeClassName(scrollFade),
152 )}
153 />
154 </CommandListVirtualized>
155 );
156}
157
66function DisabledCommandPaletteResultRow(props: {158function DisabledCommandPaletteResultRow(props: {
67 item: CommandPaletteActionItem | CommandPaletteSubmenuItem;159 item: CommandPaletteActionItem | CommandPaletteSubmenuItem;
68}) {160}) {
...@@ -96,6 +188,7 @@ function DisabledCommandPaletteResultRow(props: {...@@ -96,6 +188,7 @@ function DisabledCommandPaletteResultRow(props: {
96}188}
97189
98function CommandPaletteResultRow(props: {190function CommandPaletteResultRow(props: {
191 index?: number;
99 item: CommandPaletteActionItem | CommandPaletteSubmenuItem;192 item: CommandPaletteActionItem | CommandPaletteSubmenuItem;
100 isActive: boolean;193 isActive: boolean;
101 keybindings: ResolvedKeybindingsConfig;194 keybindings: ResolvedKeybindingsConfig;
...@@ -107,6 +200,7 @@ function CommandPaletteResultRow(props: {...@@ -107,6 +200,7 @@ function CommandPaletteResultRow(props: {
107200
108 return (201 return (
109 <CommandItem202 <CommandItem
203 {...(props.index === undefined ? {} : { index: props.index })}
110 value={props.item.value}204 value={props.item.value}
111 active={props.isActive}205 active={props.isActive}
112 onMouseDown={(event) => {206 onMouseDown={(event) => {
apps/web/src/components/ui/autocomplete.tsx+27-1
...@@ -146,11 +146,35 @@ function AutocompleteGroup({ className, ...props }: AutocompletePrimitive.Group....@@ -146,11 +146,35 @@ function AutocompleteGroup({ className, ...props }: AutocompletePrimitive.Group.
146 );146 );
147}147}
148148
149const GROUP_LABEL_CLASS_NAME = "px-2 py-1.5 font-medium text-muted-foreground text-xs";
150
149function AutocompleteGroupLabel({ className, ...props }: AutocompletePrimitive.GroupLabel.Props) {151function AutocompleteGroupLabel({ className, ...props }: AutocompletePrimitive.GroupLabel.Props) {
150 return (152 return (
151 <AutocompletePrimitive.GroupLabel153 <AutocompletePrimitive.GroupLabel
152 className={cn("px-2 py-1.5 font-medium text-muted-foreground text-xs", className)}154 className={cn(GROUP_LABEL_CLASS_NAME, className)}
155 data-slot="autocomplete-group-label"
156 {...props}
157 />
158 );
159}
160
161// A group label for virtualized lists, where rows cannot be wrapped in a group.
162function AutocompleteListHeading({ className, ...props }: React.ComponentProps<"div">) {
163 return (
164 <div
165 className={cn(GROUP_LABEL_CLASS_NAME, className)}
153 data-slot="autocomplete-group-label"166 data-slot="autocomplete-group-label"
167 role="presentation"
168 {...props}
169 />
170 );
171}
172
173function AutocompleteListVirtualized({ className, ...props }: AutocompletePrimitive.List.Props) {
174 return (
175 <AutocompletePrimitive.List
176 className={cn("size-full min-w-0", className)}
177 data-slot="autocomplete-list"
154 {...props}178 {...props}
155 />179 />
156 );180 );
...@@ -226,6 +250,8 @@ export {...@@ -226,6 +250,8 @@ export {
226 AutocompleteGroupLabel,250 AutocompleteGroupLabel,
227 AutocompleteEmpty,251 AutocompleteEmpty,
228 AutocompleteList,252 AutocompleteList,
253 AutocompleteListHeading,
254 AutocompleteListVirtualized,
229 AutocompleteClear,255 AutocompleteClear,
230 AutocompleteCollection,256 AutocompleteCollection,
231};257};
apps/web/src/components/ui/command.tsx+27
...@@ -12,6 +12,8 @@ import {...@@ -12,6 +12,8 @@ import {
12 AutocompleteInput,12 AutocompleteInput,
13 AutocompleteItem,13 AutocompleteItem,
14 AutocompleteList,14 AutocompleteList,
15 AutocompleteListHeading,
16 AutocompleteListVirtualized,
15} from "~/components/ui/autocomplete";17} from "~/components/ui/autocomplete";
16import { DIALOG_BACKDROP_CLASS, DIALOG_POPUP_CLASS } from "~/components/ui/dialog-styles";18import { DIALOG_BACKDROP_CLASS, DIALOG_POPUP_CLASS } from "~/components/ui/dialog-styles";
17import { Button } from "~/components/ui/button";19import { Button } from "~/components/ui/button";
...@@ -123,6 +125,29 @@ function CommandList({ className, ...props }: React.ComponentProps<typeof Autoco...@@ -123,6 +125,29 @@ function CommandList({ className, ...props }: React.ComponentProps<typeof Autoco
123 );125 );
124}126}
125127
128// Pair with a LegendList child; the list owns scrolling and spacing.
129function CommandListVirtualized({
130 className,
131 ...props
132}: React.ComponentProps<typeof AutocompleteListVirtualized>) {
133 return (
134 <AutocompleteListVirtualized
135 className={cn("flex max-h-[inherit] flex-col", className)}
136 data-slot="command-list"
137 {...props}
138 />
139 );
140}
141
142function CommandListHeading({
143 className,
144 ...props
145}: React.ComponentProps<typeof AutocompleteListHeading>) {
146 return (
147 <AutocompleteListHeading className={className} data-slot="command-group-label" {...props} />
148 );
149}
150
126function CommandPanel({ className, ...props }: React.ComponentProps<"div">) {151function CommandPanel({ className, ...props }: React.ComponentProps<"div">) {
127 return (152 return (
128 <div153 <div
...@@ -229,6 +254,8 @@ export {...@@ -229,6 +254,8 @@ export {
229 CommandInput,254 CommandInput,
230 CommandItem,255 CommandItem,
231 CommandList,256 CommandList,
257 CommandListHeading,
258 CommandListVirtualized,
232 CommandPanel,259 CommandPanel,
233 CommandShortcut,260 CommandShortcut,
234};261};
apps/web/src/state/queries.threadSearch.test.tsx created+64
...@@ -0,0 +1,64 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { act } from "react";
3import { create, type ReactTestRenderer } from "react-test-renderer";
4import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
5
6import { useThreadSearch } from "./queries";
7
8const searchResults = vi.hoisted(() => ({
9 matches: [{ threadId: "thread-1", source: "user", snippet: "a link" }],
10 isLoading: false,
11}));
12vi.mock("@effect/atom-react", async (importOriginal) => ({
13 ...(await importOriginal<typeof import("@effect/atom-react")>()),
14 useAtomValue: (atom: { label?: ReadonlyArray<unknown> }) =>
15 String(atom.label?.[0]).includes("empty") ? { matches: [], isLoading: false } : searchResults,
16}));
17
18const environmentIds = [EnvironmentId.make("local")];
19type ThreadSearch = ReturnType<typeof useThreadSearch>;
20
21function Probe(props: { query: string; onResult: (result: ThreadSearch) => void }) {
22 props.onResult(useThreadSearch(environmentIds, props.query));
23 return null;
24}
25
26describe("useThreadSearch", () => {
27 let renderer: ReactTestRenderer | null = null;
28 beforeEach(() => {
29 vi.useFakeTimers();
30 vi.stubGlobal("window", globalThis);
31 });
32 afterEach(() => {
33 act(() => renderer?.unmount());
34 renderer = null;
35 vi.unstubAllGlobals();
36 vi.useRealTimers();
37 });
38
39 it("keeps the settled query and matches stable while typing, then reports the settled query", () => {
40 const results: ThreadSearch[] = [];
41 const render = (query: string) => {
42 const element = <Probe query={query} onResult={(result) => results.push(result)} />;
43 act(() => {
44 if (renderer) renderer.update(element);
45 else renderer = create(element);
46 });
47 return results.at(-1)!;
48 };
49
50 render("");
51 const typing = ["li", "lin", "link "].map(render);
52 expect(typing.map((result) => result.query)).toEqual(["", "", ""]);
53 expect(new Set(typing.map((result) => result.matches)).size).toBe(1);
54 expect(typing.every((result) => result.isPending)).toBe(true);
55
56 act(() => {
57 vi.advanceTimersByTime(200);
58 });
59 const settled = results.at(-1)!;
60 expect(settled.query).toBe("link");
61 expect(settled.matches).toBe(searchResults.matches);
62 expect(settled.isPending).toBe(false);
63 });
64});
apps/web/src/state/queries.ts+3
...@@ -72,6 +72,8 @@ export function useThreadSearch(...@@ -72,6 +72,8 @@ export function useThreadSearch(
72 query: string,72 query: string,
73): {73): {
74 readonly matches: ReadonlyArray<EnvironmentThreadSearchMatch>;74 readonly matches: ReadonlyArray<EnvironmentThreadSearchMatch>;
75 /** The settled query `matches` came from; it only changes when results do. */
76 readonly query: string;
75 readonly isPending: boolean;77 readonly isPending: boolean;
76} {78} {
77 const normalizedQuery = query.trim();79 const normalizedQuery = query.trim();
...@@ -88,6 +90,7 @@ export function useThreadSearch(...@@ -88,6 +90,7 @@ export function useThreadSearch(
88 const isDebouncing = canSearch && normalizedQuery !== debouncedQuery;90 const isDebouncing = canSearch && normalizedQuery !== debouncedQuery;
89 return {91 return {
90 matches: isDebouncing ? EMPTY_THREAD_SEARCH_MATCHES : result.matches,92 matches: isDebouncing ? EMPTY_THREAD_SEARCH_MATCHES : result.matches,
93 query: settledQuery ?? "",
91 isPending: canSearch && (isDebouncing || result.isLoading),94 isPending: canSearch && (isDebouncing || result.isLoading),
92 };95 };
93}96}