Skip to content
Merged
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
18 changes: 10 additions & 8 deletions apps/web/src/components/chat/ModelPickerContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -429,12 +429,12 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
return favorites.length > 0 ? "favorites" : props.activeInstanceId;
},
);
const favoritesSet = new Set(favorites.map((fav) => providerModelKey(fav.provider, fav.model)));
const [expandedLegacyInstances, setExpandedLegacyInstances] = useState(
() =>
new Set<ProviderInstanceId>(
modelOptionsByInstance
.get(props.activeInstanceId)
?.some((model) => model.slug === activeModelSlug && model.isLegacy)
activeModel?.isLegacy &&
!favoritesSet.has(providerModelKey(props.activeInstanceId, activeModelSlug))
? [props.activeInstanceId]
: [],
),
Expand Down Expand Up @@ -515,9 +515,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 = (
Expand Down Expand Up @@ -660,8 +657,13 @@ 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);
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;
}
Expand Down
126 changes: 124 additions & 2 deletions apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReadonlyArray<{ provider: ProviderInstanceId; model: string }>>(
[],
).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),
Expand All @@ -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();
});
Expand Down Expand Up @@ -70,7 +107,92 @@ function renderPicker(input: {
);
}

async function click(target: Parameters<typeof userEvent.click>[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(
<RegistryContext.Provider value={pickerRegistry!}>
<ProviderModelPicker
environmentId={null}
activeInstanceId={entry.instanceId}
model={activeModel}
lockedProvider={null}
instanceEntries={[entry]}
modelOptionsByInstance={
new Map([
[
entry.instanceId,
[
{ slug: "current", name: "Current model" },
{ slug: "legacy-favorite", name: "Legacy favorite", isLegacy: true },
{ slug: "current-favorite", name: "Current favorite" },
{ slug: "other-legacy", name: "Other legacy", isLegacy: true },
],
],
])
}
onInstanceModelChange={() => {}}
/>
</RegistryContext.Provider>,
),
);
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",
Expand Down
Loading