diff --git a/packages/web/src/features/models/model-group-expansion.ts b/packages/web/src/features/models/model-group-expansion.ts index aad6d02..5f282ba 100644 --- a/packages/web/src/features/models/model-group-expansion.ts +++ b/packages/web/src/features/models/model-group-expansion.ts @@ -5,15 +5,23 @@ * are derived only after the model rows load (user-defined groups arrive with that * async response), so a collapsed-set default cannot express "everything collapsed * except DeepSeek" without knowing every group id up front. An expanded set survives - * late-arriving groups — anything not in it simply renders collapsed. Not persisted, - * matching the previous collapse behavior. + * late-arriving groups — anything not in it simply renders collapsed. + * + * The user's toggles persist per Project in localStorage (#224 follow-up: DeepSeek-only + * is the first-visit default, not a per-visit reset), mirroring the sidebar's persisted + * group-collapse sets — a `penguin.…` key namespaced by projectId holding a JSON array + * of ids. Unlike the sidebar sets, whose default IS the empty set, the default here is + * non-empty, so "nothing stored / invalid" (fall back to the default) is distinguished + * from a stored empty array (the user collapsed everything; honored as-is). Storage is + * injectable (draft-cache.ts convention: vitest runs in Node, no localStorage); search + * force-open below stays derived and never writes storage. */ /** - * Provider ids expanded on first paint: DeepSeek only — the default model's provider and - * the first group in MODEL_PROVIDERS, so the page opens with exactly its top group - * unfolded. Returns a fresh Set per call (React state must never share a module-level - * mutable instance). + * Provider ids expanded on first visit (nothing persisted yet): DeepSeek only — the + * default model's provider and the first group in MODEL_PROVIDERS, so the page opens + * with exactly its top group unfolded. Returns a fresh Set per call (React state must + * never share a module-level mutable instance). */ export function defaultExpandedProviders(): Set { return new Set(["deepseek"]); @@ -44,3 +52,75 @@ export function toggleExpandedProvider( else next.add(providerId); return next; } + +/** Minimal storage interface (the subset of localStorage used here); tests inject an in-memory implementation. */ +export interface ExpansionStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +} + +/** + * Storage key of one Project's expanded-group set (sidebar key-naming convention, + * `penguin.sidebarCollapsedGroups.` &c.). Provider ids — including + * user-defined group names — are Project-scoped, hence one key per Project. + */ +export const expandedGroupsKey = (projectId: string): string => + `penguin.modelsExpandedGroups.${projectId}`; + +/** Serialized form of an expanded set: a JSON array of provider ids. */ +export function serializeExpandedProviders(expanded: ReadonlySet): string { + return JSON.stringify([...expanded]); +} + +/** + * Parses a stored raw value. Returns null — not the default — for "nothing usable" + * (absent, malformed JSON, non-array): the caller owns the fallback. A valid array + * yields the set of its string elements (junk elements dropped), so a stored `[]` + * round-trips to the empty set: "user collapsed everything" is a persisted choice, + * never replaced by the default. + */ +export function parseExpandedProviders(raw: string | null): Set | null { + if (raw === null) return null; + try { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return null; + return new Set(parsed.filter((x): x is string => typeof x === "string")); + } catch { + return null; + } +} + +/** + * Reads a Project's persisted expanded set; no Project yet, nothing stored, or + * corrupted storage falls back to the DeepSeek-only default. Stored ids of + * since-deleted groups pass through unpruned — harmless, expansion is a pure + * membership test and a group no longer rendered is never asked about. + */ +export function loadExpandedProviders( + projectId: string | null, + storage: ExpansionStorage = localStorage, +): Set { + if (projectId === null) return defaultExpandedProviders(); + try { + return ( + parseExpandedProviders(storage.getItem(expandedGroupsKey(projectId))) ?? + defaultExpandedProviders() + ); + } catch { + return defaultExpandedProviders(); + } +} + +/** Writes a Project's expanded set on every toggle (best-effort: quota limits / private browsing fail silently). */ +export function saveExpandedProviders( + projectId: string | null, + expanded: ReadonlySet, + storage: ExpansionStorage = localStorage, +): void { + if (projectId === null) return; + try { + storage.setItem(expandedGroupsKey(projectId), serializeExpandedProviders(expanded)); + } catch { + /* best-effort persistence (quota limits / private browsing) */ + } +} diff --git a/packages/web/src/features/models/models-page.tsx b/packages/web/src/features/models/models-page.tsx index d733a8f..eb6d53a 100644 --- a/packages/web/src/features/models/models-page.tsx +++ b/packages/web/src/features/models/models-page.tsx @@ -72,8 +72,9 @@ import { import type { ModelProviderInfo } from "@prismshadow/penguin-core/model-catalog"; import { groupModelRows, isFreeModel, sameModelRef, userProviderInfo } from "./model-grouping"; import { - defaultExpandedProviders, isGroupExpanded, + loadExpandedProviders, + saveExpandedProviders, toggleExpandedProvider, } from "./model-group-expansion"; import { clearDraftModelRef } from "../chat/draft-cache"; @@ -358,11 +359,17 @@ export function ModelsPage() { const [addingTo, setAddingTo] = useState(null); const [query, setQuery] = useState(""); /** - * Expanded vendor groups — only DeepSeek on first paint; every other group (including - * user-defined ones, which arrive with the async row load) starts collapsed. Searching - * force-opens the rendered groups without touching this set (see model-group-expansion.ts). + * Expanded vendor groups — hydrated from this Project's persisted set (DeepSeek-only + * on a first visit; every other group, including user-defined ones arriving with the + * async row load, starts collapsed), written back on every toggle so the user's + * choices survive a refresh. Searching force-opens the rendered groups without + * touching this set (see model-group-expansion.ts). */ - const [expanded, setExpanded] = useState>(defaultExpandedProviders); + const [expanded, setExpanded] = useState>(() => loadExpandedProviders(projectId)); + // Project resolved on first load / switched: swap in that Project's persisted expansion set. + useEffect(() => { + setExpanded(loadExpandedProviders(projectId)); + }, [projectId]); /** Vendor group (provider id) currently having its API key configured in bulk. */ const [groupKeyFor, setGroupKeyFor] = useState(null); /** "Add group" popup (user-defined group): a valid name proceeds to that group's add-model dialog. */ @@ -528,11 +535,15 @@ export function ModelsPage() { /** * Header toggles are inert while searching: every rendered group is force-opened (see * isGroupExpanded), so a flip would change nothing visibly and only silently mutate the - * state restored once the query clears. + * state restored once the query clears. Computed outside the state updater (sidebar + * toggleGroup convention): the persistence write is a side effect, and updaters must + * stay pure (double-invoked in StrictMode). */ const toggleGroup = (id: string) => { if (searching) return; - setExpanded((prev) => toggleExpandedProvider(prev, id)); + const next = toggleExpandedProvider(expanded, id); + setExpanded(next); + saveExpandedProviders(projectId, next); }; return ( diff --git a/packages/web/test/model-group-expansion.test.ts b/packages/web/test/model-group-expansion.test.ts index b432262..8473263 100644 --- a/packages/web/test/model-group-expansion.test.ts +++ b/packages/web/test/model-group-expansion.test.ts @@ -1,22 +1,39 @@ /** * model-group-expansion.ts unit tests: the models page's default-collapsed vendor groups. - * Only DeepSeek (the catalog's first provider) is expanded on first paint; the state is a + * Only DeepSeek (the catalog's first provider) is expanded on a first visit; the state is a * set of EXPANDED ids so groups arriving late (user-defined groups load with the rows) * default to collapsed without being known up front. While a search query is active every * rendered group is force-opened — groupModelRows only returns match-holding groups when * searching, and a match hidden inside a collapsed group would look like a missing result — - * without touching the stored set. + * without touching the stored set. The user's toggles persist per Project (localStorage, + * injectable storage): nothing stored or corrupted storage falls back to the default, a + * stored empty array is honored as "all collapsed", and stale ids of since-deleted groups + * stay inert. */ import { describe, expect, it } from "vitest"; import { MODEL_PROVIDERS } from "@prismshadow/penguin-core/model-catalog"; import { defaultExpandedProviders, + expandedGroupsKey, isGroupExpanded, + loadExpandedProviders, + saveExpandedProviders, toggleExpandedProvider, } from "../src/features/models/model-group-expansion"; +import type { ExpansionStorage } from "../src/features/models/model-group-expansion"; import { groupModelRows } from "../src/features/models/model-grouping"; import type { ModelRowLike } from "../src/features/models/model-grouping"; +/** In-memory storage (vitest runs in a Node environment, no localStorage; draft-cache.test.ts convention). */ +function memStorage(): ExpansionStorage & { map: Map } { + const map = new Map(); + return { + map, + getItem: (k) => map.get(k) ?? null, + setItem: (k, v) => void map.set(k, v), + }; +} + describe("defaultExpandedProviders", () => { it("contains exactly deepseek, the first provider of the catalog", () => { expect([...defaultExpandedProviders()]).toEqual(["deepseek"]); @@ -87,3 +104,80 @@ describe("toggleExpandedProvider", () => { expect(withAnthropic.has("deepseek")).toBe(true); }); }); + +describe("persisted expansion (per-Project localStorage)", () => { + it("nothing stored — or no Project yet — falls back to the DeepSeek-only default", () => { + const s = memStorage(); + expect([...loadExpandedProviders("p1", s)]).toEqual(["deepseek"]); + expect([...loadExpandedProviders(null, s)]).toEqual(["deepseek"]); + expect(s.map.size).toBe(0); // load never writes; save without a Project is a no-op + saveExpandedProviders(null, new Set(["openai"]), s); + expect(s.map.size).toBe(0); + }); + + it("toggle → save → load round-trips the user's set", () => { + const s = memStorage(); + let set = loadExpandedProviders("p1", s); + set = toggleExpandedProvider(set, "anthropic"); // open anthropic + set = toggleExpandedProvider(set, "deepseek"); // close deepseek + saveExpandedProviders("p1", set, s); + const restored = loadExpandedProviders("p1", s); + expect(restored).toEqual(new Set(["anthropic"])); + expect(restored).not.toBe(set); // fresh instance per load (React state discipline) + }); + + it("a stored empty array IS a choice (all collapsed), not a fallback to the default", () => { + const s = memStorage(); + saveExpandedProviders("p1", new Set(), s); + expect(loadExpandedProviders("p1", s).size).toBe(0); + }); + + it("invalid JSON / non-array shapes fall back to the default; junk array elements are dropped", () => { + const s = memStorage(); + for (const raw of ["{not json", '"deepseek"', "42", "null", "{}", ""]) { + s.map.set(expandedGroupsKey("p1"), raw); + expect([...loadExpandedProviders("p1", s)]).toEqual(["deepseek"]); + } + // An array survives element-level junk: non-strings are filtered, strings kept. + s.map.set(expandedGroupsKey("p1"), '["anthropic", 7, null, {"x": 1}]'); + expect([...loadExpandedProviders("p1", s)]).toEqual(["anthropic"]); + }); + + it("Projects are isolated: each key holds its own set", () => { + const s = memStorage(); + saveExpandedProviders("p1", new Set(["openai"]), s); + saveExpandedProviders("p2", new Set(["moonshot", "my-proxy"]), s); + expect([...loadExpandedProviders("p1", s)]).toEqual(["openai"]); + expect(loadExpandedProviders("p2", s)).toEqual(new Set(["moonshot", "my-proxy"])); + // A third Project is untouched by the writes above and opens on the default. + expect([...loadExpandedProviders("p3", s)]).toEqual(["deepseek"]); + }); + + it("stale ids of since-deleted groups are harmless and survive round-trips", () => { + const s = memStorage(); + saveExpandedProviders("p1", new Set(["deepseek", "deleted-group"]), s); + const restored = loadExpandedProviders("p1", s); + // Membership renders nothing by itself: current groups derive exactly as before … + expect(isGroupExpanded(restored, "deepseek", false)).toBe(true); + expect(isGroupExpanded(restored, "anthropic", false)).toBe(false); + // … and the stored id cannot resurrect the group: only groups the rows produce render. + const rows: ModelRowLike[] = [{ provider: "deepseek", modelId: "deepseek-chat" }]; + expect(groupModelRows(rows, "").some((g) => g.provider.id === "deleted-group")).toBe(false); + // Toggling other groups keeps the stale id inert but persisted (pruning is not load's job). + saveExpandedProviders("p1", toggleExpandedProvider(restored, "anthropic"), s); + expect(loadExpandedProviders("p1", s).has("deleted-group")).toBe(true); + }); + + it("storage throwing (quota/private mode): save does not throw, load yields the default", () => { + const broken: ExpansionStorage = { + getItem: () => { + throw new Error("denied"); + }, + setItem: () => { + throw new Error("denied"); + }, + }; + expect(() => saveExpandedProviders("p1", new Set(["openai"]), broken)).not.toThrow(); + expect([...loadExpandedProviders("p1", broken)]).toEqual(["deepseek"]); + }); +});