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
55 changes: 35 additions & 20 deletions apps/web/src/components/chat/ModelPickerContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@ import {
isProviderInstancePickerVisible,
type ProviderInstanceEntry,
} from "../../providerInstances";
import { providerModelKey, sortProviderModelItems } from "../../modelOrdering";
import {
partitionLegacyModels,
providerModelKey,
sortProviderModelItems,
} from "../../modelOrdering";

type ModelPickerItem = {
slug: string;
Expand Down Expand Up @@ -238,16 +242,30 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
return favorites.length > 0 ? "favorites" : props.activeInstanceId;
},
);
const [expandedLegacyInstances, setExpandedLegacyInstances] = useState(
() =>
new Set<ProviderInstanceId>(
// Create a Set for efficient lookup. Favorites are keyed by
// `${instanceId}:${slug}`; the storage schema widened from ProviderDriverKind
// to ProviderInstanceId so pre-migration favorites keyed by driver slugs
// (e.g. `"codex:gpt-5"`) still resolve — the default instance id equals
// the driver slug.
const favoritesSet = useMemo(() => {
return new Set(favorites.map((fav) => providerModelKey(fav.provider, fav.model)));
}, [favorites]);
const [expandedLegacyInstances, setExpandedLegacyInstances] = useState(() => {
// Auto-expand the legacy group only when the active model actually lives
// there. A favorited legacy model is hoisted into the main list, so
// expanding the group for it would open an unrelated section.
const activeIsFavorite = favoritesSet.has(
providerModelKey(props.activeInstanceId, activeModelSlug),
);
return new Set<ProviderInstanceId>(
!activeIsFavorite &&
modelOptionsByInstance
.get(props.activeInstanceId)
?.some((model) => model.slug === activeModelSlug && model.isLegacy)
? [props.activeInstanceId]
: [],
),
);
? [props.activeInstanceId]
: [],
);
});
const serverKeybindings = useAtomValue(primaryServerKeybindingsAtom);
const keybindings = providedKeybindings ?? serverKeybindings;
const updateSettings = useUpdateClientSettings();
Expand Down Expand Up @@ -280,15 +298,6 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
};
}, [focusSearchInput]);

// Create a Set for efficient lookup. Favorites are keyed by
// `${instanceId}:${slug}`; the storage schema widened from ProviderDriverKind
// to ProviderInstanceId so pre-migration favorites keyed by driver slugs
// (e.g. `"codex:gpt-5"`) still resolve — the default instance id equals
// the driver slug.
const favoritesSet = useMemo(() => {
return new Set(favorites.map((fav) => providerModelKey(fav.provider, fav.model)));
}, [favorites]);

/**
* Lookup table keyed by `instanceId`. Used for display name + driver
* kind enrichment and for `ready`/enabled filtering before flattening
Expand Down Expand Up @@ -522,8 +531,14 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
if (isSearching || selectedInstanceId === "favorites") {
return null;
}
const currentModels = filteredModels.filter((model) => !model.isLegacy);
const legacyModels = filteredModels.filter((model) => model.isLegacy);
// Favorited models are hoisted to the top, and that hoist has to win over
// the legacy split: a favorited legacy model stays in the main list rather
// than being buried in the collapsed legacy group. Unfavoriting it drops it
// back into the legacy section on the next render.
const { current: currentModels, legacy: legacyModels } = partitionLegacyModels(
filteredModels,
(model) => favoritesSet.has(providerModelKey(model.instanceId, model.slug)),
);
if (legacyModels.length === 0) {
return null;
}
Expand All @@ -533,7 +548,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
legacyModels,
isExpanded: expandedLegacyInstances.has(selectedInstanceId),
};
}, [expandedLegacyInstances, filteredModels, isSearching, selectedInstanceId]);
}, [expandedLegacyInstances, favoritesSet, filteredModels, isSearching, selectedInstanceId]);

const visibleModels = useMemo(() => {
if (!legacySection) {
Expand Down
56 changes: 56 additions & 0 deletions apps/web/src/modelOrdering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test";
import { ProviderInstanceId } from "@t3tools/contracts";

import {
partitionLegacyModels,
providerModelKey,
sortModelsForProviderInstance,
sortProviderModelItems,
Expand Down Expand Up @@ -49,4 +50,59 @@ describe("model ordering", () => {
}).map((item) => item.slug),
).toEqual(["gpt-5.4-mini", "gpt-5.5", "crest-alpha", "claude-opus-4-6"]);
});

describe("partitionLegacyModels", () => {
const models = [
{ slug: "opus-5" },
{ slug: "sonnet-5" },
{ slug: "opus-4-8", isLegacy: true },
{ slug: "haiku-3", isLegacy: true },
];

it("keeps a favorited legacy model in the main list instead of the legacy group", () => {
const { current, legacy } = partitionLegacyModels(
models,
(model) => model.slug === "opus-4-8",
);

expect(current.map((model) => model.slug)).toEqual(["opus-5", "sonnet-5", "opus-4-8"]);
expect(legacy.map((model) => model.slug)).toEqual(["haiku-3"]);
});

it("returns legacy models to the legacy group once unfavorited", () => {
const { current, legacy } = partitionLegacyModels(models, () => false);

expect(current.map((model) => model.slug)).toEqual(["opus-5", "sonnet-5"]);
expect(legacy.map((model) => model.slug)).toEqual(["opus-4-8", "haiku-3"]);
});

it("returns empty partitions for an empty list", () => {
const { current, legacy } = partitionLegacyModels([], () => false);

expect(current).toEqual([]);
expect(legacy).toEqual([]);
});

it("treats an explicit isLegacy: false as a current model", () => {
const { current, legacy } = partitionLegacyModels(
[{ slug: "opus-5", isLegacy: false }],
() => false,
);

expect(current.map((model) => model.slug)).toEqual(["opus-5"]);
expect(legacy).toEqual([]);
});

it("leaves the legacy group empty when every legacy model is favorited", () => {
const { current, legacy } = partitionLegacyModels(models, (model) => model.isLegacy === true);

expect(current.map((model) => model.slug)).toEqual([
"opus-5",
"sonnet-5",
"opus-4-8",
"haiku-3",
]);
expect(legacy).toEqual([]);
});
});
});
26 changes: 26 additions & 0 deletions apps/web/src/modelOrdering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,32 @@ export function sortModelsForProviderInstance<T extends ModelSlugItem>(
return Arr.sort(models, Order.combineAll(orders));
}

export interface LegacyPartitionItem {
readonly isLegacy?: boolean | undefined;
}

/**
* Split an already-sorted model list into the main list and the collapsed
* legacy group. Favorited models stay in the main list even when they are
* legacy, so the favorite hoist wins over the legacy split; unfavoriting a
* legacy model returns it to the legacy group. `isFavorite` decides membership.
*/
export function partitionLegacyModels<T extends LegacyPartitionItem>(
models: ReadonlyArray<T>,
isFavorite: (model: T) => boolean,
): { readonly current: T[]; readonly legacy: T[] } {
const current: T[] = [];
const legacy: T[] = [];
for (const model of models) {
if (model.isLegacy === true && !isFavorite(model)) {
legacy.push(model);
} else {
current.push(model);
}
}
return { current, legacy };
}

export function sortProviderModelItems<T extends ProviderModelItem>(
items: ReadonlyArray<T>,
options?: {
Expand Down
Loading