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
9 changes: 8 additions & 1 deletion apps/web/src/components/chat/ModelListRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const ModelListRow = memo(function ModelListRow(props: {
slug: string;
name: string;
subProvider?: string | undefined;
showSubProvider?: boolean;
instanceId: ProviderInstanceId;
driverKind: ProviderDriverKind;
providerDisplayName: string;
Expand Down Expand Up @@ -81,7 +82,13 @@ export const ModelListRow = memo(function ModelListRow(props: {
</div>
{props.isSelected ? <CheckIcon className="size-3.5 shrink-0" aria-hidden="true" /> : null}
{props.subProvider ? (
<span className="min-w-0 shrink truncate text-xs text-muted-foreground/70">
<span
className={
props.showSubProvider === false
? "sr-only"
: "min-w-0 shrink truncate text-xs text-muted-foreground/70"
}
>
{props.subProvider}
</span>
) : null}
Expand Down
147 changes: 133 additions & 14 deletions apps/web/src/components/chat/ModelPickerContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useAtomValue } from "@effect/atom-react";
import { formatProviderUpdateRequiredNotice } from "@t3tools/client-runtime/providerUpdateRequiredModels";
import { useNavigate } from "@tanstack/react-router";
import { LegendList, type LegendListRef } from "@legendapp/list/react";
import { ChevronRightIcon } from "lucide-react";
import {
memo,
useState,
Expand All @@ -28,10 +29,12 @@ import { getProviderStatusMessage, hasProviderSetup } from "./ProviderStatusBann
import {
modelPickerLegacySectionKey,
modelPickerModelKey,
modelPickerVendorSectionKey,
parseModelPickerLegacySectionKey,
parseModelPickerModelKey,
} from "./modelPickerKeys";
import { rankModelPickerSearch } from "@t3tools/client-runtime/model-search";
import { groupModelPickerVendors } from "./modelPickerVendorSections";
import {
Combobox,
ComboboxEmpty,
Expand Down Expand Up @@ -445,6 +448,12 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
: [],
),
);
const [vendorSectionOverrides, setVendorSectionOverrides] = useState(
() => new Map<string, boolean>(),
);
// Picks and stars keep a docked picker open, so sections open from its state at open.
const [openedWithModelKey] = useState(activeModelKey);
const [favoritesAtOpen] = useState(favoritesSet);
const serverKeybindings = useAtomValue(primaryServerKeybindingsAtom);
const keybindings = providedKeybindings ?? serverKeybindings;
const [searchHeight, setSearchHeight] = useState(0);
Expand Down Expand Up @@ -589,17 +598,30 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
);
};
const tabsWithLegacyRow = new Set<ProviderInstanceId>();
const vendorsByTab = new Map<ProviderInstanceId, Set<string>>();
for (const model of flatModels) {
if (!matchesLockedProvider(model)) continue;
const isFavorite = favoritesSet.has(providerModelKey(model.instanceId, model.slug));
if (isFavorite) addRow("favorites");
if (
model.subProvider &&
!model.isLegacy &&
!favoritesAtOpen.has(providerModelKey(model.instanceId, model.slug))
) {
const vendors = vendorsByTab.get(model.instanceId) ?? new Set<string>();
vendors.add(model.subProvider);
vendorsByTab.set(model.instanceId, vendors);
}
if (model.isLegacy && !isFavorite) {
tabsWithLegacyRow.add(model.instanceId);
} else {
addRow(model.instanceId);
}
}
for (const instanceId of tabsWithLegacyRow) addRow(instanceId);
for (const [instanceId, vendors] of vendorsByTab) {
if (vendors.size > 1) vendors.forEach(() => addRow(instanceId));
}
return Math.max(0, ...unitsByTab.values());
})();
const lockedDisabledInstanceIds = (() => {
Expand Down Expand Up @@ -691,14 +713,77 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
};
})();

const visibleModels = (() => {
if (!legacySection) {
return filteredModels;
const vendorGroups = (() => {
if (isSearching || selectedInstanceId === "favorites") {
return null;
}
const grouped = groupModelPickerVendors(
filteredModels,
(model) => favoritesSet.has(providerModelKey(model.instanceId, model.slug)),
(model) => favoritesAtOpen.has(providerModelKey(model.instanceId, model.slug)),
flatModels.find(
(model) =>
model.instanceId === selectedInstanceId &&
modelPickerModelKey(model.instanceId, model.slug) === openedWithModelKey,
)?.subProvider,
);
if (!grouped) {
return null;
}
return {
pinned: grouped.pinned,
sections: grouped.sections.map((section) => {
const key = modelPickerVendorSectionKey(selectedInstanceId, section.vendor);
const expandedByDefault = section.vendor === grouped.defaultVendor;
return {
...section,
key,
expandedByDefault,
isExpanded: vendorSectionOverrides.get(key) ?? expandedByDefault,
};
}),
};
})();
const vendorSectionByKey = new Map(
vendorGroups?.sections.map((section) => [section.key, section] as const),
);

const toggleVendorSection = (section: { key: string; expandedByDefault: boolean }) => {
setVendorSectionOverrides((overrides) =>
new Map(overrides).set(
section.key,
!(overrides.get(section.key) ?? section.expandedByDefault),
),
);
};

const { visibleModels, filteredItemKeys, sectionedModelKeys } = (() => {
const models: ModelPickerItem[] = [];
const keys: string[] = [];
const sectioned = new Set<string>();
const pushModels = (list: ReadonlyArray<ModelPickerItem>, inSection = false) => {
for (const model of list) {
const key = modelPickerModelKey(model.instanceId, model.slug);
models.push(model);
keys.push(key);
if (inSection) sectioned.add(key);
}
};
if (vendorGroups) {
pushModels(vendorGroups.pinned);
for (const section of vendorGroups.sections) {
keys.push(section.key);
if (section.isExpanded) pushModels(section.models, true);
}
} else {
pushModels(legacySection?.currentModels ?? filteredModels);
}
// The toggle trails the list, so expanding it reveals older models above it.
if (legacySection) {
if (legacySection.isExpanded) pushModels(legacySection.legacyModels);
keys.push(legacySection.key);
}
return [
...legacySection.currentModels,
...(legacySection.isExpanded ? legacySection.legacyModels : []),
];
return { visibleModels: models, filteredItemKeys: keys, sectionedModelKeys: sectioned };
})();

const selectedEntry =
Expand Down Expand Up @@ -737,14 +822,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
.filter((model) => model.isLegacy)
.map((model) => modelPickerLegacySectionKey(model.instanceId)),
),
...vendorSectionByKey.keys(),
])();
const filteredItemKeys = ((): string[] => {
const modelKeys = visibleModels.map((model) =>
modelPickerModelKey(model.instanceId, model.slug),
);
// The toggle trails the list, so expanding it reveals older models above it.
return legacySection ? [...modelKeys, legacySection.key] : modelKeys;
})();
const filteredModelByKey = ((): ReadonlyMap<string, ModelPickerItem> =>
new Map(
visibleModels.map(
Expand All @@ -768,6 +847,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
modelJumpLabelByKey,
activeModelKey,
selectedModelKeySet,
vendorSectionByKey,
};

const onWindowKeyDown = (event: globalThis.KeyboardEvent) => {
Expand Down Expand Up @@ -892,6 +972,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
if (typeof modelKey !== "string") {
return;
}
const vendorSection = vendorSectionByKey.get(modelKey);
if (vendorSection) {
toggleVendorSection(vendorSection);
return;
}
const legacyInstanceId = parseModelPickerLegacySectionKey(modelKey);
if (legacyInstanceId) {
toggleLegacySection(legacyInstanceId);
Expand Down Expand Up @@ -982,6 +1067,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
(e as typeof e & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.();
e.preventDefault();
e.stopPropagation();
const vendorSection = vendorSectionByKey.get(highlightedModelKeyRef.current);
if (vendorSection) {
toggleVendorSection(vendorSection);
return;
}
const legacyInstanceId = parseModelPickerLegacySectionKey(
highlightedModelKeyRef.current,
);
Expand Down Expand Up @@ -1021,6 +1111,34 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
</ComboboxItem>
);
}
const vendorSection = vendorSectionByKey.get(modelKey);
if (vendorSection) {
return (
<ComboboxItem
hideIndicator
index={index}
value={modelKey}
aria-expanded={vendorSection.isExpanded}
className="group w-full cursor-pointer"
>
<ChevronRightIcon
aria-hidden="true"
className={cn(
"size-3.5 shrink-0 text-muted-foreground",
vendorSection.isExpanded && "rotate-90",
)}
/>
<span className="min-w-0 flex-1 truncate text-xs font-medium leading-snug text-muted-foreground group-hover:text-foreground group-data-highlighted:text-foreground">
{vendorSection.vendor}
</span>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground/70">
<span className="sr-only">, </span>
{vendorSection.models.length}
<span className="sr-only"> models</span>
</span>
</ComboboxItem>
);
}
const model = filteredModelByKey.get(modelKey);
if (!model) {
return null;
Expand All @@ -1034,6 +1152,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
slug={model.slug}
name={model.name}
subProvider={model.subProvider}
showSubProvider={!sectionedModelKeys.has(modelKey)}
instanceId={model.instanceId}
driverKind={model.driverKind}
providerDisplayName={model.instanceDisplayName}
Expand Down
70 changes: 70 additions & 0 deletions apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,76 @@ it("keeps a docked picker open after a pick and hands focus to that model's effo
await expect.element(search).not.toBeInTheDocument();
});

it("folds an aggregator's vendors into sections that open on click and stay searchable", async () => {
const instanceId = ProviderInstanceId.make("opencode");
const entries = deriveProviderInstanceEntries([
{
instanceId,
driver: ProviderDriverKind.make("opencode"),
enabled: true,
installed: true,
version: null,
status: "ready",
auth: { status: "authenticated" },
checkedAt: "2026-01-01T00:00:00.000Z",
models: [],
slashCommands: [],
skills: [],
},
]);
const options = new Map([
[
instanceId,
[
{ slug: "anthropic/claude-sonnet", name: "Claude Sonnet", subProvider: "Anthropic" },
{ slug: "openai/gpt-pro", name: "GPT Pro", subProvider: "OpenAI" },
],
],
]);

function Picker() {
const [model, setModel] = useState("anthropic/claude-sonnet");
return (
<ProviderModelPicker
environmentId={null}
activeInstanceId={instanceId}
model={model}
lockedProvider={null}
instanceEntries={entries}
modelOptionsByInstance={options}
triggerAriaLabel="Choose model"
onInstanceModelChange={(_, selected) => setModel(selected)}
effortColumn={[]}
/>
);
}

await using screen = await render(<Picker />);
await screen.getByRole("button", { name: "Choose model" }).click();
const gptPro = page.getByRole("option", { name: /GPT Pro/ });
await expect
.element(page.getByRole("option", { name: /Claude Sonnet.*Anthropic/ }))
.toBeVisible();
await expect.element(gptPro).not.toBeInTheDocument();

await userEvent.keyboard("gpt");
await expect.element(gptPro).toBeVisible();
await userEvent.clear(page.getByPlaceholder("Search models..."));
await expect.element(gptPro).not.toBeInTheDocument();

const openAiSection = page.getByRole("option", { name: /^OpenAI/ });
await expect.element(openAiSection).toHaveAttribute("aria-expanded", "false");
await openAiSection.click();
await expect.element(gptPro).toBeVisible();
await expect.element(openAiSection).toHaveAttribute("aria-expanded", "true");

await gptPro.click();
await expect
.element(page.getByRole("option", { name: /^Anthropic/ }))
.toHaveAttribute("aria-expanded", "true");
await expect.element(page.getByRole("option", { name: /Claude Sonnet/ })).toBeVisible();
});

it("opens on the active provider when every favorite belongs to a disabled provider", async () => {
const entries = deriveProviderInstanceEntries(
["Codex", "Claude"].map((name) => ({
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/components/chat/modelPickerKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ProviderInstanceId } from "@t3tools/contracts";

const MODEL_KEY_PREFIX = "model:";
const LEGACY_SECTION_KEY_PREFIX = "legacy-models:";
const VENDOR_SECTION_KEY_PREFIX = "vendor-section:";

export function modelPickerModelKey(instanceId: ProviderInstanceId, slug: string): string {
return `${MODEL_KEY_PREFIX}${instanceId.length}:${instanceId}${slug}`;
Expand Down Expand Up @@ -36,6 +37,13 @@ export function parseModelPickerModelKey(
};
}

export function modelPickerVendorSectionKey(
instanceId: ProviderInstanceId,
vendor: string,
): string {
return `${VENDOR_SECTION_KEY_PREFIX}${instanceId.length}:${instanceId}${vendor}`;
}

export function modelPickerLegacySectionKey(instanceId: ProviderInstanceId): string {
return `${LEGACY_SECTION_KEY_PREFIX}${instanceId}`;
}
Expand Down
Loading
Loading