History

Optimize model picker search rerenders

Cursor Agent committed 4 months ago

Commit 5bd797e1 parent 7b9eef71 file · +140 −99Browse files

Unified · Split

apps/web/src/components/chat/ModelPickerContent.tsx+140−99
@@ -43,6 +43,10 @@ type ModelPickerItem = { · 42 unmodified lines
43 43
44const EMPTY_MODEL_JUMP_LABELS = new Map<string, string>();44const EMPTY_MODEL_JUMP_LABELS = new Map<string, string>();
45 45
46function normalizeModelPickerSearchQuery(value: string): string {
47 return value.trim();
48}
49
46// Split a `${instanceId}:${slug}` combobox key back into its pieces. Slugs50// Split a `${instanceId}:${slug}` combobox key back into its pieces. Slugs
47// can contain colons (e.g. some vendor model ids), so we only split on the51// can contain colons (e.g. some vendor model ids), so we only split on the
48// first colon — anything after that is the slug.52// first colon — anything after that is the slug.
@@ -57,6 +61,115 @@ function splitInstanceModelKey(key: string): { instanceId: ProviderInstanceId; s · 8 unmodified lines
57 };61 };
58}62}
59 63
64function useModelPickerJumpShortcuts(input: {
65 readonly keybindings: ResolvedKeybindingsConfig;
66 readonly modelJumpModelKeys: ReadonlyArray<string>;
67 readonly modelJumpShortcutContext: {
68 readonly terminalFocus: false;
69 readonly terminalOpen: boolean;
70 readonly modelPickerOpen: true;
71 };
72 readonly onModelSelect: (modelSlug: string, instanceId: ProviderInstanceId) => void;
73}) {
74 const { keybindings, modelJumpModelKeys, modelJumpShortcutContext, onModelSelect } = input;
75
76 useEffect(() => {
77 const onWindowKeyDown = (event: globalThis.KeyboardEvent) => {
78 if (event.defaultPrevented || event.repeat) {
79 return;
80 }
81
82 const command = resolveShortcutCommand(event, keybindings, {
83 platform: navigator.platform,
84 context: modelJumpShortcutContext,
85 });
86 const jumpIndex = modelPickerJumpIndexFromCommand(command ?? "");
87 if (jumpIndex === null) {
88 return;
89 }
90
91 const targetModelKey = modelJumpModelKeys[jumpIndex];
92 if (!targetModelKey) {
93 return;
94 }
95 const { instanceId, slug } = splitInstanceModelKey(targetModelKey);
96 event.preventDefault();
97 event.stopPropagation();
98 onModelSelect(slug, instanceId);
99 };
100
101 window.addEventListener("keydown", onWindowKeyDown, true);
102
103 return () => {
104 window.removeEventListener("keydown", onWindowKeyDown, true);
105 };
106 }, [keybindings, modelJumpModelKeys, modelJumpShortcutContext, onModelSelect]);
107}
108
109function ModelPickerSearchInput(props: {
110 readonly focusRequest: number;
111 readonly onSearchQueryChange: (query: string) => void;
112 readonly onRequestClose: (() => void) | undefined;
113 readonly onHighlightedModelSelect: () => boolean;
114}) {
115 const [rawSearchQuery, setRawSearchQuery] = useState("");
116 const searchInputRef = useRef<HTMLInputElement>(null);
117 const normalizedSearchQueryRef = useRef("");
118
119 useLayoutEffect(() => {
120 searchInputRef.current?.focus({ preventScroll: true });
121 const frame = window.requestAnimationFrame(() => {
122 searchInputRef.current?.focus({ preventScroll: true });
123 });
124 return () => {
125 window.cancelAnimationFrame(frame);
126 };
127 }, [props.focusRequest]);
128
129 return (
130 <ComboboxInput
131 ref={searchInputRef}
132 className="[&_input]:h-6.5 [&_input]:font-sans [&_input]:leading-6.5"
133 inputClassName="rounded-none bg-transparent text-sm"
134 placeholder="Search models..."
135 showTrigger={false}
136 startAddon={
137 <SearchIcon className="-translate-x-0.5 size-4 shrink-0 text-muted-foreground/55" />
138 }
139 value={rawSearchQuery}
140 onChange={(event) => {
141 const nextRawSearchQuery = event.target.value;
142 setRawSearchQuery(nextRawSearchQuery);
143 const nextNormalizedSearchQuery = normalizeModelPickerSearchQuery(nextRawSearchQuery);
144 if (nextNormalizedSearchQuery === normalizedSearchQueryRef.current) {
145 return;
146 }
147 normalizedSearchQueryRef.current = nextNormalizedSearchQuery;
148 props.onSearchQueryChange(nextNormalizedSearchQuery);
149 }}
150 onKeyDown={(event) => {
151 if (event.key === "Escape") {
152 event.preventDefault();
153 event.stopPropagation();
154 props.onRequestClose?.();
155 return;
156 }
157 if (event.key === "Enter" && props.onHighlightedModelSelect()) {
158 (event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.();
159 event.preventDefault();
160 event.stopPropagation();
161 return;
162 }
163 event.stopPropagation();
164 }}
165 onMouseDown={(event) => event.stopPropagation()}
166 onTouchStart={(event) => event.stopPropagation()}
167 size="sm"
168 unstyled
169 />
170 );
171}
172
60export const ModelPickerContent = memo(function ModelPickerContent(props: {173export const ModelPickerContent = memo(function ModelPickerContent(props: {
61 /** The instance currently selected in the composer (combobox "value"). */174 /** The instance currently selected in the composer (combobox "value"). */
62 activeInstanceId: ProviderInstanceId;175 activeInstanceId: ProviderInstanceId;
@@ -99,7 +212,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 36 unmodified lines
99 const [searchQuery, setSearchQuery] = useState("");212 const [searchQuery, setSearchQuery] = useState("");
100 const [showTopScrollFade, setShowTopScrollFade] = useState(false);213 const [showTopScrollFade, setShowTopScrollFade] = useState(false);
101 const [showBottomScrollFade, setShowBottomScrollFade] = useState(false);214 const [showBottomScrollFade, setShowBottomScrollFade] = useState(false);
102 const searchInputRef = useRef<HTMLInputElement>(null);215 const [searchFocusRequest, setSearchFocusRequest] = useState(0);
103 const modelListRef = useRef<LegendListRef | null>(null);216 const modelListRef = useRef<LegendListRef | null>(null);
104 const highlightedModelKeyRef = useRef<string | null>(null);217 const highlightedModelKeyRef = useRef<string | null>(null);
105 const favorites = useClientSettings((s) => s.favorites ?? []);218 const favorites = useClientSettings((s) => s.favorites ?? []);
@@ -119,33 +232,10 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 13 unmodified lines
119 );232 );
120 const updateSettings = useUpdateClientSettings();233 const updateSettings = useUpdateClientSettings();
121 234
122 const focusSearchInput = useCallback(() => {235 const handleSelectInstance = (instanceId: ProviderInstanceId | "favorites") => {
123 searchInputRef.current?.focus({ preventScroll: true });236 setSelectedInstanceId(instanceId);
124 }, []);237 setSearchFocusRequest((request) => request + 1);
125 238 };
126 const handleSelectInstance = useCallback(
127 (instanceId: ProviderInstanceId | "favorites") => {
128 setSelectedInstanceId(instanceId);
129 window.requestAnimationFrame(() => {
130 focusSearchInput();
131 });
132 },
133 [focusSearchInput],
134 );
135
136 useLayoutEffect(() => {
137 focusSearchInput();
138 const frame = window.requestAnimationFrame(() => {
139 focusSearchInput();
140 });
141 const timeout = window.setTimeout(() => {
142 focusSearchInput();
143 }, 0);
144 return () => {
145 window.cancelAnimationFrame(frame);
146 window.clearTimeout(timeout);
147 };
148 }, [focusSearchInput]);
149 239
150 // Create a Set for efficient lookup. Favorites are keyed by240 // Create a Set for efficient lookup. Favorites are keyed by
151 // `${instanceId}:${slug}`; the storage schema widened from ProviderDriverKind241 // `${instanceId}:${slug}`; the storage schema widened from ProviderDriverKind
@@ -221,7 +311,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 69 unmodified lines
221 }, [modelOptionsByInstance, entryByInstanceId, readyInstanceSet]);311 }, [modelOptionsByInstance, entryByInstanceId, readyInstanceSet]);
222 312
223 const isLocked = props.lockedProvider !== null;313 const isLocked = props.lockedProvider !== null;
224 const isSearching = searchQuery.trim().length > 0;314 const isSearching = searchQuery.length > 0;
225 const lockedDisabledInstanceIds = useMemo(() => {315 const lockedDisabledInstanceIds = useMemo(() => {
226 if (!isLocked) {316 if (!isLocked) {
227 return undefined;317 return undefined;
@@ -261,7 +351,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 33 unmodified lines
261 let result = flatModels;351 let result = flatModels;
262 352
263 // Apply tokenized fuzzy search across the combined provider/model search fields.353 // Apply tokenized fuzzy search across the combined provider/model search fields.
264 if (searchQuery.trim()) {354 if (searchQuery) {
265 const rankedMatches = result355 const rankedMatches = result
266 .map((model) => ({356 .map((model) => ({
267 model,357 model,
@@ -385,6 +475,15 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 117 unmodified lines
385 },475 },
386 [entryByInstanceId, getModelDisabledReason, modelOptionsByInstance, onInstanceModelChange],476 [entryByInstanceId, getModelDisabledReason, modelOptionsByInstance, onInstanceModelChange],
387 );477 );
478 const selectHighlightedModel = () => {
479 const highlightedModelKey = highlightedModelKeyRef.current;
480 if (!highlightedModelKey) {
481 return false;
482 }
483 const { instanceId, slug } = splitInstanceModelKey(highlightedModelKey);
484 handleModelSelect(slug, instanceId);
485 return true;
486 };
388 487
389 const toggleFavorite = useCallback(488 const toggleFavorite = useCallback(
390 (instanceId: ProviderInstanceId, model: string) => {489 (instanceId: ProviderInstanceId, model: string) => {
@@ -472,41 +571,14 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 81 unmodified lines
472 return mapping.size > 0 ? mapping : EMPTY_MODEL_JUMP_LABELS;571 return mapping.size > 0 ? mapping : EMPTY_MODEL_JUMP_LABELS;
473 }, [keybindings, modelJumpCommandByKey, modelJumpShortcutContext]);572 }, [keybindings, modelJumpCommandByKey, modelJumpShortcutContext]);
474 573
475 useEffect(() => {574 useModelPickerJumpShortcuts({
476 const onWindowKeyDown = (event: globalThis.KeyboardEvent) => {575 keybindings,
477 if (event.defaultPrevented || event.repeat) {576 modelJumpModelKeys,
478 return;577 modelJumpShortcutContext,
479 }578 onModelSelect: handleModelSelect,
480 579 });
481 const command = resolveShortcutCommand(event, keybindings, {
482 platform: navigator.platform,
483 context: modelJumpShortcutContext,
484 });
485 const jumpIndex = modelPickerJumpIndexFromCommand(command ?? "");
486 if (jumpIndex === null) {
487 return;
488 }
489
490 const targetModelKey = modelJumpModelKeys[jumpIndex];
491 if (!targetModelKey) {
492 return;
493 }
494 const { instanceId, slug } = splitInstanceModelKey(targetModelKey);
495 event.preventDefault();
496 event.stopPropagation();
497 handleModelSelect(slug, instanceId);
498 };
499
500 window.addEventListener("keydown", onWindowKeyDown, true);
501
502 return () => {
503 window.removeEventListener("keydown", onWindowKeyDown, true);
504 };
505 }, [handleModelSelect, keybindings, modelJumpModelKeys, modelJumpShortcutContext]);
506 580
507 useLayoutEffect(() => {581 useLayoutEffect(() => {
508 setShowTopScrollFade(false);
509 setShowBottomScrollFade(filteredModelKeys.length > 5);
510 let nestedFrame = 0;582 let nestedFrame = 0;
511 const frame = window.requestAnimationFrame(() => {583 const frame = window.requestAnimationFrame(() => {
512 updateModelListScrollFades();584 updateModelListScrollFades();
@@ -577,42 +649,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 64 unmodified lines
577 {/* Search bar */}649 {/* Search bar */}
578 <div className="px-4 pt-2.5">650 <div className="px-4 pt-2.5">
579 <div className="-translate-y-px border-b border-border/70 pb-2.5 transition-colors focus-within:border-ring">651 <div className="-translate-y-px border-b border-border/70 pb-2.5 transition-colors focus-within:border-ring">
580 <ComboboxInput652 <ModelPickerSearchInput
581 ref={searchInputRef}653 focusRequest={searchFocusRequest}
582 className="[&_input]:h-6.5 [&_input]:font-sans [&_input]:leading-6.5"654 onSearchQueryChange={setSearchQuery}
583 inputClassName="rounded-none bg-transparent text-sm"655 onRequestClose={props.onRequestClose}
584 placeholder="Search models..."656 onHighlightedModelSelect={selectHighlightedModel}
585 showTrigger={false}
586 startAddon={
587 <SearchIcon className="-translate-x-0.5 size-4 shrink-0 text-muted-foreground/55" />
588 }
589 value={searchQuery}
590 onChange={(e) => setSearchQuery(e.target.value)}
591 onKeyDown={(e) => {
592 if (e.key === "Escape") {
593 e.preventDefault();
594 e.stopPropagation();
595 props.onRequestClose?.();
596 return;
597 }
598 if (e.key === "Enter" && highlightedModelKeyRef.current) {
599 (
600 e as typeof e & { preventBaseUIHandler?: () => void }
601 ).preventBaseUIHandler?.();
602 e.preventDefault();
603 e.stopPropagation();
604 const { instanceId, slug } = splitInstanceModelKey(
605 highlightedModelKeyRef.current,
606 );
607 handleModelSelect(slug, instanceId);
608 return;
609 }
610 e.stopPropagation();
611 }}
612 onMouseDown={(e) => e.stopPropagation()}
613 onTouchStart={(e) => e.stopPropagation()}
614 size="sm"
615 unstyled
616 />657 />
617 </div>658 </div>
618 </div>659 </div>