From f8d9d7e209677e699d9b4d11f216465374f02bb6 Mon Sep 17 00:00:00 2001 From: TonybynMp4 <97451137+TonybynMp4@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:26:24 +0200 Subject: [PATCH 1/5] fix(web): hoist favorited legacy models to the top of the model picker Co-Authored-By: Claude Opus 4.8 --- .../components/chat/ModelPickerContent.tsx | 16 ++++++++--- apps/web/src/modelOrdering.test.ts | 27 +++++++++++++++++++ apps/web/src/modelOrdering.ts | 26 ++++++++++++++++++ 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index fc8447571f5e..3797ee6dfc7b 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -61,7 +61,11 @@ import { isProviderInstancePickerVisible, type ProviderInstanceEntry, } from "../../providerInstances"; -import { providerModelKey, sortProviderModelItems } from "../../modelOrdering"; +import { + partitionLegacyModels, + providerModelKey, + sortProviderModelItems, +} from "../../modelOrdering"; type ModelPickerItem = { slug: string; @@ -660,8 +664,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; } diff --git a/apps/web/src/modelOrdering.test.ts b/apps/web/src/modelOrdering.test.ts index 852d3b0e13dc..7b7b3af8eb56 100644 --- a/apps/web/src/modelOrdering.test.ts +++ b/apps/web/src/modelOrdering.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { ProviderInstanceId } from "@t3tools/contracts"; import { + partitionLegacyModels, providerModelKey, sortModelsForProviderInstance, sortProviderModelItems, @@ -49,4 +50,30 @@ 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"]); + }); + }); }); diff --git a/apps/web/src/modelOrdering.ts b/apps/web/src/modelOrdering.ts index c8b5d9cec643..69b5399ce152 100644 --- a/apps/web/src/modelOrdering.ts +++ b/apps/web/src/modelOrdering.ts @@ -55,6 +55,32 @@ export function sortModelsForProviderInstance( 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( + models: ReadonlyArray, + 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( items: ReadonlyArray, options?: { From 3eed0437a9db1f744617598071cab1cb581aa089 Mon Sep 17 00:00:00 2001 From: TonybynMp4 <97451137+TonybynMp4@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:33:22 +0200 Subject: [PATCH 2/5] fix(web): don't auto-expand legacy group for a favorited active model Co-Authored-By: Claude Opus 4.8 --- .../components/chat/ModelPickerContent.tsx | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 3797ee6dfc7b..4fcfaed484c0 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -433,16 +433,22 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { return favorites.length > 0 ? "favorites" : props.activeInstanceId; }, ); - const [expandedLegacyInstances, setExpandedLegacyInstances] = useState( - () => - new Set( + 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 = favorites.some( + (fav) => fav.provider === props.activeInstanceId && fav.model === activeModelSlug, + ); + return new Set( + !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 [searchHeight, setSearchHeight] = useState(0); From 3da789f9bb73cac4df6d7518c43d8c920c1b974e Mon Sep 17 00:00:00 2001 From: TonybynMp4 <97451137+TonybynMp4@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:58:02 +0200 Subject: [PATCH 3/5] fix(web): optimize lookup --- .../components/chat/ModelPickerContent.tsx | 10 +++---- apps/web/src/modelOrdering.test.ts | 29 +++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 4fcfaed484c0..cb6002579633 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -433,12 +433,15 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { return favorites.length > 0 ? "favorites" : props.activeInstanceId; }, ); + const favoritesSet = (() => { + return new Set(favorites.map((fav) => providerModelKey(fav.provider, fav.model))); + })(); 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 = favorites.some( - (fav) => fav.provider === props.activeInstanceId && fav.model === activeModelSlug, + const activeIsFavorite = favoritesSet.has( + providerModelKey(props.activeInstanceId, activeModelSlug), ); return new Set( !activeIsFavorite && @@ -525,9 +528,6 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { const lockedProvider = props.lockedProvider; const lockedContinuationGroupKey = props.lockedContinuationGroupKey; const canOpenProviderSetup = onOpenProviderSetup !== undefined; - const favoritesSet = (() => { - return new Set(favorites.map((fav) => providerModelKey(fav.provider, fav.model))); - })(); const entryByInstanceId = new Map(instanceEntries.map((entry) => [entry.instanceId, entry])); const matchesLockedProvider = ( diff --git a/apps/web/src/modelOrdering.test.ts b/apps/web/src/modelOrdering.test.ts index 7b7b3af8eb56..4d15b3b9fb64 100644 --- a/apps/web/src/modelOrdering.test.ts +++ b/apps/web/src/modelOrdering.test.ts @@ -75,5 +75,34 @@ describe("model ordering", () => { 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([]); + }); }); }); From 353891670f30e6a8e042bb98adb22abfb2dafec0 Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:07:27 +0200 Subject: [PATCH 4/5] refactor(web): simplify favoritesSet initializer Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/chat/ModelPickerContent.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index cb6002579633..10b037ee3a0f 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -433,9 +433,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { return favorites.length > 0 ? "favorites" : props.activeInstanceId; }, ); - const favoritesSet = (() => { - return new Set(favorites.map((fav) => providerModelKey(fav.provider, fav.model))); - })(); + const favoritesSet = new Set(favorites.map((fav) => providerModelKey(fav.provider, fav.model))); 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 From e46f144e829dfac3d82abadccc3423d57026511d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 16:28:44 +0000 Subject: [PATCH 5/5] fix(web): keep legacy favorites in the model picker list --- .../components/chat/ModelPickerContent.tsx | 46 +++---- .../chat/ProviderModelPicker.browser.test.tsx | 126 +++++++++++++++++- apps/web/src/modelOrdering.test.ts | 56 -------- apps/web/src/modelOrdering.ts | 26 ---- 4 files changed, 141 insertions(+), 113 deletions(-) diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 10b037ee3a0f..7da239e0c3c7 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -61,11 +61,7 @@ import { isProviderInstancePickerVisible, type ProviderInstanceEntry, } from "../../providerInstances"; -import { - partitionLegacyModels, - providerModelKey, - sortProviderModelItems, -} from "../../modelOrdering"; +import { providerModelKey, sortProviderModelItems } from "../../modelOrdering"; type ModelPickerItem = { slug: string; @@ -434,22 +430,15 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { }, ); const favoritesSet = new Set(favorites.map((fav) => providerModelKey(fav.provider, fav.model))); - 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( - !activeIsFavorite && - modelOptionsByInstance - .get(props.activeInstanceId) - ?.some((model) => model.slug === activeModelSlug && model.isLegacy) - ? [props.activeInstanceId] - : [], - ); - }); + const [expandedLegacyInstances, setExpandedLegacyInstances] = useState( + () => + new Set( + activeModel?.isLegacy && + !favoritesSet.has(providerModelKey(props.activeInstanceId, activeModelSlug)) + ? [props.activeInstanceId] + : [], + ), + ); const serverKeybindings = useAtomValue(primaryServerKeybindingsAtom); const keybindings = providedKeybindings ?? serverKeybindings; const [searchHeight, setSearchHeight] = useState(0); @@ -668,14 +657,13 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { if (isSearching || selectedInstanceId === "favorites") { return null; } - // 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)), - ); + const currentModels: ModelPickerItem[] = []; + const legacyModels: ModelPickerItem[] = []; + for (const model of filteredModels) { + const isLegacy = + model.isLegacy && !favoritesSet.has(providerModelKey(model.instanceId, model.slug)); + (isLegacy ? legacyModels : currentModels).push(model); + } if (legacyModels.length === 0) { return null; } diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx index 537e701ba2c9..d6632a5d2c3a 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx @@ -4,14 +4,47 @@ import { ProviderInstanceId, type ServerProvider, } from "@t3tools/contracts"; +import { RegistryContext, useAtomSet, useAtomValue } from "@effect/atom-react"; +import { Atom, AtomRegistry } from "effect/reactivity"; import { act, type ReactNode } from "react"; -import { createRoot } from "react-dom/client"; +import { createRoot, type Root } from "react-dom/client"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import "~/index.css"; import { deriveProviderInstanceEntries } from "../../providerInstances"; import { ProviderModelPicker } from "./ProviderModelPicker"; import type { ModelEsque } from "./providerIconUtils"; +const favoritesAtom = Atom.make>( + [], +).pipe(Atom.keepAlive); + +vi.mock(import("~/hooks/useSettings"), async (original) => ({ + ...(await original()), + useEnvironmentModelPreferences: () => ({ + favorites: useAtomValue(favoritesAtom), + providerModelPreferences: {}, + }), + useToggleEnvironmentModelFavorite: () => { + const setFavorites = useAtomSet(favoritesAtom); + return async (provider: ProviderInstanceId, model: string) => { + setFavorites((favorites) => + favorites.filter((favorite) => favorite.provider !== provider || favorite.model !== model), + ); + }; + }, +})); + +vi.mock(import("@tanstack/react-router"), async (original) => ({ + ...(await original()), + useNavigate: () => vi.fn(), +})); + +let pickerRoot: Root | undefined; +let pickerRegistry: AtomRegistry.AtomRegistry | undefined; + function providerEntry(instanceId: string, driver: string) { const provider: ServerProvider = { instanceId: ProviderInstanceId.make(instanceId), @@ -29,7 +62,11 @@ function providerEntry(instanceId: string, driver: string) { return deriveProviderInstanceEntries([provider])[0]!; } -afterEach(() => { +afterEach(async () => { + if (pickerRoot) await act(async () => pickerRoot!.unmount()); + pickerRoot = undefined; + pickerRegistry?.dispose(); + pickerRegistry = undefined; document.body.replaceChildren(); vi.unstubAllGlobals(); }); @@ -70,7 +107,92 @@ function renderPicker(input: { ); } +async function click(target: Parameters[0]) { + await act(async () => userEvent.click(target)); +} + +async function openLegacyPicker(activeModel: string) { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const entry = providerEntry("codex", "codex"); + pickerRegistry = AtomRegistry.make(); + pickerRegistry.set(favoritesAtom, [ + { provider: entry.instanceId, model: "current-favorite" }, + { provider: entry.instanceId, model: "legacy-favorite" }, + ]); + pickerRoot = createRoot(document.body.appendChild(document.createElement("div"))); + await act(async () => + pickerRoot!.render( + + {}} + /> + , + ), + ); + await click(document.querySelector("button")!); + await click(page.getByRole("button", { name: entry.displayName, exact: true })); +} + describe("ProviderModelPicker", () => { + it("hoists legacy favorites in provider order and returns an unfavorited model to legacy", async () => { + await openLegacyPicker("current"); + const legacyFavorite = page.getByRole("option", { name: /Legacy favorite/ }); + const currentFavorite = page.getByRole("option", { name: /Current favorite/ }); + const current = page.getByRole("option", { name: /Current model/ }); + const legacySection = page.getByRole("option", { name: /Legacy models/ }); + + await expect.element(legacyFavorite).toBeVisible(); + await expect.element(currentFavorite).toBeVisible(); + await expect.element(current).toBeVisible(); + expect(legacyFavorite.element().getBoundingClientRect().top).toBeLessThan( + currentFavorite.element().getBoundingClientRect().top, + ); + expect(currentFavorite.element().getBoundingClientRect().top).toBeLessThan( + current.element().getBoundingClientRect().top, + ); + + await click(legacyFavorite.getByRole("button", { name: "Remove from favorites" })); + await expect.element(legacyFavorite).not.toBeInTheDocument(); + await click(legacySection); + await expect.element(legacyFavorite).toBeVisible(); + await expect.element(page.getByRole("option", { name: /Other legacy/ })).toBeVisible(); + expect(legacyFavorite.element().getBoundingClientRect().top).toBeGreaterThan( + legacySection.element().getBoundingClientRect().top, + ); + await expect + .element(legacyFavorite.getByRole("button", { name: "Add to favorites" })) + .toBeVisible(); + }); + + it("keeps unrelated legacy models collapsed when the active model is a favorite", async () => { + await openLegacyPicker("legacy-favorite"); + + await expect.element(page.getByRole("option", { name: /Legacy favorite/ })).toBeVisible(); + await expect + .element(page.getByRole("option", { name: /Other legacy/ })) + .not.toBeInTheDocument(); + await click(page.getByRole("option", { name: /Legacy models/ })); + await expect.element(page.getByRole("option", { name: /Other legacy/ })).toBeVisible(); + }); + it("shows a neutral aggregate value without a representative model or availability badge", async () => { const label = await renderPicker({ instanceId: "codex_personal", diff --git a/apps/web/src/modelOrdering.test.ts b/apps/web/src/modelOrdering.test.ts index 4d15b3b9fb64..852d3b0e13dc 100644 --- a/apps/web/src/modelOrdering.test.ts +++ b/apps/web/src/modelOrdering.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vite-plus/test"; import { ProviderInstanceId } from "@t3tools/contracts"; import { - partitionLegacyModels, providerModelKey, sortModelsForProviderInstance, sortProviderModelItems, @@ -50,59 +49,4 @@ 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([]); - }); - }); }); diff --git a/apps/web/src/modelOrdering.ts b/apps/web/src/modelOrdering.ts index 69b5399ce152..c8b5d9cec643 100644 --- a/apps/web/src/modelOrdering.ts +++ b/apps/web/src/modelOrdering.ts @@ -55,32 +55,6 @@ export function sortModelsForProviderInstance( 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( - models: ReadonlyArray, - 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( items: ReadonlyArray, options?: {