feat(web): persist model-catalog group expansion per Project (#231)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-08-07 17:00:57 +08:00
committed by GitHub
parent 2ed8c2f36a
commit a6e54488b8
3 changed files with 200 additions and 15 deletions
@@ -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 (