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
4343
4444 const EMPTY_MODEL_JUMP_LABELS = new Map<string, string>();
4545
46+function normalizeModelPickerSearchQuery(value: string): string {
47+ return value.trim();
48+}
49+
4650 // Split a `${instanceId}:${slug}` combobox key back into its pieces. Slugs
4751 // can contain colons (e.g. some vendor model ids), so we only split on the
4852 // first colon — anything after that is the slug.
@@ -57,6 +61,115 @@ function splitInstanceModelKey(key: string): { instanceId: ProviderInstanceId; s · 8 unmodified lines
5761 };
5862 }
5963
64+function 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+
109+function 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+
60173 export const ModelPickerContent = memo(function ModelPickerContent(props: {
61174 /** The instance currently selected in the composer (combobox "value"). */
62175 activeInstanceId: ProviderInstanceId;
@@ -99,7 +212,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 36 unmodified lines
99212 const [searchQuery, setSearchQuery] = useState("");
100213 const [showTopScrollFade, setShowTopScrollFade] = useState(false);
101214 const [showBottomScrollFade, setShowBottomScrollFade] = useState(false);
102− const searchInputRef = useRef<HTMLInputElement>(null);
215+ const [searchFocusRequest, setSearchFocusRequest] = useState(0);
103216 const modelListRef = useRef<LegendListRef | null>(null);
104217 const highlightedModelKeyRef = useRef<string | null>(null);
105218 const favorites = useClientSettings((s) => s.favorites ?? []);
@@ -119,33 +232,10 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 13 unmodified lines
119232 );
120233 const updateSettings = useUpdateClientSettings();
121234
122− const focusSearchInput = useCallback(() => {
123− searchInputRef.current?.focus({ preventScroll: true });
124− }, []);
125−
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]);
235+ const handleSelectInstance = (instanceId: ProviderInstanceId | "favorites") => {
236+ setSelectedInstanceId(instanceId);
237+ setSearchFocusRequest((request) => request + 1);
238+ };
149239
150240 // Create a Set for efficient lookup. Favorites are keyed by
151241 // `${instanceId}:${slug}`; the storage schema widened from ProviderDriverKind
@@ -221,7 +311,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 69 unmodified lines
221311 }, [modelOptionsByInstance, entryByInstanceId, readyInstanceSet]);
222312
223313 const isLocked = props.lockedProvider !== null;
224− const isSearching = searchQuery.trim().length > 0;
314+ const isSearching = searchQuery.length > 0;
225315 const lockedDisabledInstanceIds = useMemo(() => {
226316 if (!isLocked) {
227317 return undefined;
@@ -261,7 +351,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 33 unmodified lines
261351 let result = flatModels;
262352
263353 // Apply tokenized fuzzy search across the combined provider/model search fields.
264− if (searchQuery.trim()) {
354+ if (searchQuery) {
265355 const rankedMatches = result
266356 .map((model) => ({
267357 model,
@@ -385,6 +475,15 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 117 unmodified lines
385475 },
386476 [entryByInstanceId, getModelDisabledReason, modelOptionsByInstance, onInstanceModelChange],
387477 );
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+ };
388487
389488 const toggleFavorite = useCallback(
390489 (instanceId: ProviderInstanceId, model: string) => {
@@ -472,41 +571,14 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 81 unmodified lines
472571 return mapping.size > 0 ? mapping : EMPTY_MODEL_JUMP_LABELS;
473572 }, [keybindings, modelJumpCommandByKey, modelJumpShortcutContext]);
474573
475− useEffect(() => {
476− const onWindowKeyDown = (event: globalThis.KeyboardEvent) => {
477− if (event.defaultPrevented || event.repeat) {
478− return;
479− }
480−
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]);
574+ useModelPickerJumpShortcuts({
575+ keybindings,
576+ modelJumpModelKeys,
577+ modelJumpShortcutContext,
578+ onModelSelect: handleModelSelect,
579+ });
506580
507581 useLayoutEffect(() => {
508− setShowTopScrollFade(false);
509− setShowBottomScrollFade(filteredModelKeys.length > 5);
510582 let nestedFrame = 0;
511583 const frame = window.requestAnimationFrame(() => {
512584 updateModelListScrollFades();
@@ -577,42 +649,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 64 unmodified lines
577649 {/* Search bar */}
578650 <div className="px-4 pt-2.5">
579651 <div className="-translate-y-px border-b border-border/70 pb-2.5 transition-colors focus-within:border-ring">
580− <ComboboxInput
581− ref={searchInputRef}
582− className="[&_input]:h-6.5 [&_input]:font-sans [&_input]:leading-6.5"
583− inputClassName="rounded-none bg-transparent text-sm"
584− placeholder="Search models..."
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
652+ <ModelPickerSearchInput
653+ focusRequest={searchFocusRequest}
654+ onSearchQueryChange={setSearchQuery}
655+ onRequestClose={props.onRequestClose}
656+ onHighlightedModelSelect={selectHighlightedModel}
616657 />
617658 </div>
618659 </div>