feat(web): persist model-catalog group expansion per Project (#231)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string> {
|
||||
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.<projectId>` &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>): 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<string> | 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<string> {
|
||||
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<string>,
|
||||
storage: ExpansionStorage = localStorage,
|
||||
): void {
|
||||
if (projectId === null) return;
|
||||
try {
|
||||
storage.setItem(expandedGroupsKey(projectId), serializeExpandedProviders(expanded));
|
||||
} catch {
|
||||
/* best-effort persistence (quota limits / private browsing) */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string | null>(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<Set<string>>(defaultExpandedProviders);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => 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<string | null>(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 (
|
||||
|
||||
@@ -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<string, string> } {
|
||||
const map = new Map<string, string>();
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user