Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/web/src/components/chat/ModelListRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const ModelListRow = memo(function ModelListRow(props: {
useTriggerLabel?: boolean;
showNewBadge?: boolean;
jumpLabel?: string | null;
isLast?: boolean;
onToggleFavorite: () => void;
}) {
const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.driverKind] ?? null;
Expand All @@ -47,7 +48,8 @@ export const ModelListRow = memo(function ModelListRow(props: {
contentClassName="flex w-full items-start gap-2"
className={cn(
"w-full cursor-pointer rounded px-3 py-2 transition-colors group",
"data-highlighted:bg-muted data-selected:bg-accent data-selected:text-foreground",
"border-b data-highlighted:bg-muted data-selected:bg-accent data-selected:text-foreground",
props.isLast && "border-b-0",
)}
>
<Tooltip>
Expand Down
117 changes: 88 additions & 29 deletions apps/web/src/components/chat/ModelPickerContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,20 @@ import {
type ResolvedKeybindingsConfig,
} from "@t3tools/contracts";
import { resolveSelectableModel } from "@t3tools/shared/model";
import { LegendList, type LegendListRef } from "@legendapp/list/react";
import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react";
import { SearchIcon } from "lucide-react";
import { ModelListRow } from "./ModelListRow";
import { ModelPickerSidebar } from "./ModelPickerSidebar";
import { isModelPickerNewModel } from "./modelPickerModelHighlights";
import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch";
import { Combobox, ComboboxEmpty, ComboboxInput, ComboboxList } from "../ui/combobox";
import {
Combobox,
ComboboxEmpty,
ComboboxInput,
ComboboxList,
ComboboxListVirtualized,
} from "../ui/combobox";
import { ModelEsque, PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils";
import {
modelPickerJumpCommandForIndex,
Expand All @@ -37,6 +44,10 @@ type ModelPickerItem = {
};

const EMPTY_MODEL_JUMP_LABELS = new Map<string, string>();
const MODEL_PICKER_VIRTUALIZATION_THRESHOLD = 60;
const MODEL_PICKER_ESTIMATED_ROW_HEIGHT = 53;
const MODEL_PICKER_LIST_MAX_HEIGHT = 335;
const MODEL_PICKER_LIST_MAX_HEIGHT_NO_SIDEBAR = 289;

// Split a `${instanceId}:${slug}` combobox key back into its pieces. Slugs
// can contain colons (e.g. some vendor model ids), so we only split on the
Expand Down Expand Up @@ -92,6 +103,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
const [searchQuery, setSearchQuery] = useState("");
const searchInputRef = useRef<HTMLInputElement>(null);
const listRegionRef = useRef<HTMLDivElement>(null);
const modelListRef = useRef<LegendListRef | null>(null);
const highlightedModelKeyRef = useRef<string | null>(null);
const favorites = useSettings((s) => s.favorites ?? []);
const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | "favorites">(
Expand Down Expand Up @@ -220,6 +232,9 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
);
const showLockedInstanceSidebar = isLocked && lockedInstanceEntries.length > 1;
const showSidebar = !isSearching && (!isLocked || showLockedInstanceSidebar);
const modelPickerListMaxHeight = showSidebar
? MODEL_PICKER_LIST_MAX_HEIGHT
: MODEL_PICKER_LIST_MAX_HEIGHT_NO_SIDEBAR;
const sidebarInstanceEntries = showLockedInstanceSidebar
? lockedInstanceEntries
: instanceEntries;
Expand Down Expand Up @@ -418,6 +433,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
new Map(filteredModels.map((model) => [`${model.instanceId}:${model.slug}`, model] as const)),
[filteredModels],
);
const shouldVirtualizeModelList =
filteredModelKeys.length > MODEL_PICKER_VIRTUALIZATION_THRESHOLD;
const modelJumpShortcutContext = useMemo(
() =>
({
Expand Down Expand Up @@ -478,6 +495,10 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
}, [handleModelSelect, keybindings, modelJumpModelKeys, modelJumpShortcutContext]);

useLayoutEffect(() => {
if (shouldVirtualizeModelList) {
return;
}

const listRegion = listRegionRef.current;
if (!listRegion) {
return;
Expand Down Expand Up @@ -518,7 +539,43 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
window.cancelAnimationFrame(nestedFrame);
window.clearTimeout(timeout);
};
}, [filteredModelKeys]);
}, [filteredModelKeys, shouldVirtualizeModelList]);

const renderModelRow = useCallback(
(modelKey: string, index: number) => {
const model = filteredModelByKey.get(modelKey);
if (!model) {
return null;
}
return (
<ModelListRow
key={modelKey}
index={index}
model={model}
instanceId={model.instanceId}
driverKind={model.driverKind}
providerDisplayName={model.instanceDisplayName}
providerAccentColor={model.instanceAccentColor}
isFavorite={favoritesSet.has(modelKey)}
showProvider={!isLocked || showLockedInstanceSidebar}
preferShortName={!isLocked}
useTriggerLabel={isLocked && !showLockedInstanceSidebar}
showNewBadge={isModelPickerNewModel(model.driverKind, model.slug)}
jumpLabel={modelJumpLabelByKey.get(modelKey) ?? null}
isLast={index === filteredModelKeys.length - 1}
onToggleFavorite={() => toggleFavorite(model.instanceId, model.slug)}
/>
);
},
[
favoritesSet,
filteredModelByKey,
isLocked,
modelJumpLabelByKey,
showLockedInstanceSidebar,
toggleFavorite,
],
);

return (
<TooltipProvider delay={0}>
Expand Down Expand Up @@ -555,9 +612,20 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
filter={null}
autoHighlight
open
virtualized={shouldVirtualizeModelList}
value={`${props.activeInstanceId}:${props.model}`}
onItemHighlighted={(modelKey) => {
onItemHighlighted={(modelKey, eventDetails) => {
highlightedModelKeyRef.current = typeof modelKey === "string" ? modelKey : null;
if (
shouldVirtualizeModelList &&
eventDetails.index >= 0 &&
eventDetails.reason === "keyboard"
) {
modelListRef.current?.scrollIndexIntoView?.({
index: eventDetails.index,
animated: false,
});
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Virtual list scroll not reset

Medium Severity

With virtualization enabled, the LegendList keeps its scroll offset when filteredModelKeys changes (search or sidebar), but there is no scroll-to-top like the branch picker. scrollIndexIntoView runs only for keyboard highlights, so after filtering the highlighted row can stay off-screen while the viewport shows unrelated models.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b22f0f9. Configure here.

}}
onValueChange={(modelKey) => {
if (typeof modelKey !== "string") {
Expand Down Expand Up @@ -616,32 +684,23 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
ref={listRegionRef}
className="relative min-h-0 flex-1 before:pointer-events-none before:absolute before:inset-0 before:bg-muted/40"
>
<ComboboxList className="model-picker-list size-full divide-y px-2 py-1">
{filteredModelKeys.map((modelKey, index) => {
const model = filteredModelByKey.get(modelKey);
if (!model) {
return null;
}
return (
<ModelListRow
key={modelKey}
index={index}
model={model}
instanceId={model.instanceId}
driverKind={model.driverKind}
providerDisplayName={model.instanceDisplayName}
providerAccentColor={model.instanceAccentColor}
isFavorite={favoritesSet.has(modelKey)}
showProvider={!isLocked || showLockedInstanceSidebar}
preferShortName={!isLocked}
useTriggerLabel={isLocked && !showLockedInstanceSidebar}
showNewBadge={isModelPickerNewModel(model.driverKind, model.slug)}
jumpLabel={modelJumpLabelByKey.get(modelKey) ?? null}
onToggleFavorite={() => toggleFavorite(model.instanceId, model.slug)}
/>
);
})}
</ComboboxList>
{shouldVirtualizeModelList ? (
<ComboboxListVirtualized className="model-picker-list size-full px-2 py-1">
<LegendList<string>
ref={modelListRef}
data={filteredModelKeys}
keyExtractor={(item) => item}
renderItem={({ item, index }) => renderModelRow(item, index)}
estimatedItemSize={MODEL_PICKER_ESTIMATED_ROW_HEIGHT}
drawDistance={modelPickerListMaxHeight}
style={{ maxHeight: modelPickerListMaxHeight }}
/>
</ComboboxListVirtualized>
) : (
<ComboboxList className="model-picker-list size-full px-2 py-1">
{filteredModelKeys.map((modelKey, index) => renderModelRow(modelKey, index))}
</ComboboxList>
)}
</div>
<ComboboxEmpty className="not-empty:py-6 empty:h-0 text-xs font-normal leading-snug">
No models found
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/chat/ModelPickerSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
<ScrollArea
hideScrollbars
scrollFade
className="w-12 shrink-0 border-r bg-muted/30"
className="w-12 shrink-0 bg-muted/30"
data-model-picker-sidebar="true"
>
<div className="flex min-h-full flex-col gap-1 p-1">
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/ui/popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ function PopoverPopup({
tooltipStyle
? "py-1 [--viewport-inline-padding:--spacing(2)]"
: "not-data-transitioning:overflow-y-auto",
'rounded-b-[inherit]'
)}
data-slot="popover-viewport"
>
Expand Down
Loading