diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx
index 69da770f1f84b..1c44f0a7e60ed 100644
--- a/apps/web/src/components/chat/ModelListRow.tsx
+++ b/apps/web/src/components/chat/ModelListRow.tsx
@@ -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;
@@ -81,7 +82,13 @@ export const ModelListRow = memo(function ModelListRow(props: {
{props.isSelected ? : null}
{props.subProvider ? (
-
+
{props.subProvider}
) : null}
diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx
index 4cbd23af0965e..2a6cffc25ef16 100644
--- a/apps/web/src/components/chat/ModelPickerContent.tsx
+++ b/apps/web/src/components/chat/ModelPickerContent.tsx
@@ -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,
@@ -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,
@@ -445,6 +448,12 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
: [],
),
);
+ const [vendorSectionOverrides, setVendorSectionOverrides] = useState(
+ () => new Map(),
+ );
+ // 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);
@@ -589,10 +598,20 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
);
};
const tabsWithLegacyRow = new Set();
+ const vendorsByTab = new Map>();
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();
+ vendors.add(model.subProvider);
+ vendorsByTab.set(model.instanceId, vendors);
+ }
if (model.isLegacy && !isFavorite) {
tabsWithLegacyRow.add(model.instanceId);
} else {
@@ -600,6 +619,9 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
}
}
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 = (() => {
@@ -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();
+ const pushModels = (list: ReadonlyArray, 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 =
@@ -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 =>
new Map(
visibleModels.map(
@@ -768,6 +847,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
modelJumpLabelByKey,
activeModelKey,
selectedModelKeySet,
+ vendorSectionByKey,
};
const onWindowKeyDown = (event: globalThis.KeyboardEvent) => {
@@ -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);
@@ -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,
);
@@ -1021,6 +1111,34 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
);
}
+ const vendorSection = vendorSectionByKey.get(modelKey);
+ if (vendorSection) {
+ return (
+
+
+
+ {vendorSection.vendor}
+
+
+ ,
+ {vendorSection.models.length}
+ models
+
+
+ );
+ }
const model = filteredModelByKey.get(modelKey);
if (!model) {
return null;
@@ -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}
diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx
index a3c927b4c272b..433dc5a7379e8 100644
--- a/apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx
+++ b/apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx
@@ -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 (
+ setModel(selected)}
+ effortColumn={[]}
+ />
+ );
+ }
+
+ await using screen = await render();
+ 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) => ({
diff --git a/apps/web/src/components/chat/modelPickerKeys.ts b/apps/web/src/components/chat/modelPickerKeys.ts
index 0b4f0845e116f..2b9a62038b390 100644
--- a/apps/web/src/components/chat/modelPickerKeys.ts
+++ b/apps/web/src/components/chat/modelPickerKeys.ts
@@ -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}`;
@@ -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}`;
}
diff --git a/apps/web/src/components/chat/modelPickerVendorSections.test.ts b/apps/web/src/components/chat/modelPickerVendorSections.test.ts
new file mode 100644
index 0000000000000..7fa7768531cb2
--- /dev/null
+++ b/apps/web/src/components/chat/modelPickerVendorSections.test.ts
@@ -0,0 +1,86 @@
+import { describe, expect, it } from "vite-plus/test";
+import { groupModelPickerVendors } from "./modelPickerVendorSections";
+
+const model = (slug: string, subProvider?: string) => ({
+ slug,
+ ...(subProvider ? { subProvider } : {}),
+});
+
+describe("groupModelPickerVendors", () => {
+ it("folds a multi-vendor catalog into sorted vendor sections and keeps pinned models above them", () => {
+ const grouped = groupModelPickerVendors(
+ [
+ model("zen/big-pickle", "OpenCode Zen"),
+ model("anthropic/claude-sonnet", "Anthropic"),
+ model("custom"),
+ model("openai/gpt", "openai"),
+ model("anthropic/claude-opus", "Anthropic"),
+ ],
+ (candidate) => candidate.slug === "anthropic/claude-opus",
+ () => false,
+ undefined,
+ );
+
+ expect(grouped).toEqual({
+ pinned: [model("custom"), model("anthropic/claude-opus", "Anthropic")],
+ sections: [
+ { vendor: "Anthropic", models: [model("anthropic/claude-sonnet", "Anthropic")] },
+ { vendor: "openai", models: [model("openai/gpt", "openai")] },
+ { vendor: "OpenCode Zen", models: [model("zen/big-pickle", "OpenCode Zen")] },
+ ],
+ defaultVendor: "Anthropic",
+ });
+ });
+
+ it("keeps the preferred vendor open after its model is pinned", () => {
+ const models = [
+ model("a/sonnet", "Anthropic"),
+ model("o/pro", "OpenAI"),
+ model("o/mini", "OpenAI"),
+ ];
+
+ expect(
+ groupModelPickerVendors(
+ models,
+ (candidate) => candidate.slug === "o/pro",
+ () => false,
+ "OpenAI",
+ )?.defaultVendor,
+ ).toBe("OpenAI");
+ });
+
+ it("keeps sections after starring the last model of one of two vendors", () => {
+ expect(
+ groupModelPickerVendors(
+ [model("a/sonnet", "Anthropic"), model("o/pro", "OpenAI")],
+ (candidate) => candidate.slug === "a/sonnet",
+ () => false,
+ "Anthropic",
+ ),
+ ).toEqual({
+ pinned: [model("a/sonnet", "Anthropic")],
+ sections: [{ vendor: "OpenAI", models: [model("o/pro", "OpenAI")] }],
+ defaultVendor: "Anthropic",
+ });
+ });
+
+ it("leaves a single-vendor catalog as a plain list", () => {
+ const legacy = { ...model("o/legacy", "OpenAI"), isLegacy: true };
+ const models = [model("a/current", "Anthropic"), legacy];
+ for (const isPinned of [
+ () => false,
+ (candidate: (typeof models)[number]) => candidate === legacy,
+ ]) {
+ expect(groupModelPickerVendors(models, isPinned, () => false, "Anthropic")).toBeNull();
+ }
+
+ expect(
+ groupModelPickerVendors(
+ [model("a", "Anthropic"), model("b", "Anthropic"), model("c")],
+ () => false,
+ () => false,
+ "Anthropic",
+ ),
+ ).toBeNull();
+ });
+});
diff --git a/apps/web/src/components/chat/modelPickerVendorSections.ts b/apps/web/src/components/chat/modelPickerVendorSections.ts
new file mode 100644
index 0000000000000..d7d2a704debce
--- /dev/null
+++ b/apps/web/src/components/chat/modelPickerVendorSections.ts
@@ -0,0 +1,42 @@
+import * as Arr from "effect/Array";
+
+// Sectioning and the open section come from the pins at open, so starring a model never reflows them.
+export function groupModelPickerVendors<
+ T extends { readonly subProvider?: string; readonly isLegacy?: boolean | undefined },
+>(
+ models: ReadonlyArray,
+ isPinned: (model: T) => boolean,
+ isPinnedAtOpen: (model: T) => boolean,
+ preferredVendor: string | undefined,
+) {
+ const currentModels = models.filter((model) => !model.isLegacy || isPinned(model));
+ const sortedVendors = (pinned: (model: T) => boolean, candidates = currentModels) =>
+ Arr.dedupe(
+ candidates.flatMap((model) =>
+ model.subProvider !== undefined && !pinned(model) ? [model.subProvider] : [],
+ ),
+ ).toSorted((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
+ const vendorsAtOpen = sortedVendors(
+ isPinnedAtOpen,
+ models.filter((model) => !model.isLegacy),
+ );
+ if (vendorsAtOpen.length < 2) {
+ return null;
+ }
+ const inSection = (model: T) => model.subProvider !== undefined && !isPinned(model);
+ const modelsByVendor = Arr.groupBy(
+ currentModels.filter(inSection),
+ (model) => model.subProvider ?? "",
+ );
+ return {
+ pinned: currentModels.filter((model) => !inSection(model)),
+ sections: sortedVendors(isPinned).map((vendor) => ({
+ vendor,
+ models: modelsByVendor[vendor]!,
+ })),
+ defaultVendor:
+ preferredVendor && vendorsAtOpen.includes(preferredVendor)
+ ? preferredVendor
+ : vendorsAtOpen[0],
+ };
+}