refactor: stop persisting workspace state in the browser
This commit is contained in:
@@ -6,8 +6,9 @@ import {
|
||||
renameSession, setSessionGroup, archiveSession, unarchiveSession,
|
||||
deleteSession, getSessionDocuments,
|
||||
} from "./sessions";
|
||||
import { workspacePreferences } from "../workspaces/preferences";
|
||||
|
||||
test("createSession migrates legacy selections and POSTs browser preferences", async () => {
|
||||
test("createSession seeds ephemeral selections and posts them", async () => {
|
||||
localStorage.clear();
|
||||
let body: unknown = null;
|
||||
server.use(
|
||||
@@ -33,15 +34,16 @@ test("createSession migrates legacy selections and POSTs browser preferences", a
|
||||
expect(body).toEqual({
|
||||
question: "q", workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low",
|
||||
});
|
||||
expect(JSON.parse(localStorage.getItem("thothii.workspace-registry.v1.preferences")!)).toEqual({
|
||||
expect(workspacePreferences.load()).toEqual({
|
||||
workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low",
|
||||
});
|
||||
expect(localStorage.getItem("thothii.workspace-registry.v1.preferences")).toBeNull();
|
||||
});
|
||||
|
||||
test("createSession does not POST when a selected summary aliases another workspace name", async () => {
|
||||
localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({
|
||||
workspacePreferences.save({
|
||||
workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low",
|
||||
}));
|
||||
});
|
||||
let posted = false;
|
||||
server.use(
|
||||
http.get("/api/workspaces", () => HttpResponse.json([{
|
||||
@@ -74,9 +76,9 @@ test.each([
|
||||
["an unknown revision field", { generation: 1 }],
|
||||
["a malformed revision", { commit: "not-a-commit" }],
|
||||
])("createSession does not POST when the selected summary revision has %s", async (_case, revisionPatch) => {
|
||||
localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({
|
||||
workspacePreferences.save({
|
||||
workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low",
|
||||
}));
|
||||
});
|
||||
const revision = {
|
||||
id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40),
|
||||
snapshotPath: "/snapshot", ...revisionPatch,
|
||||
@@ -107,10 +109,10 @@ test.each([
|
||||
expect(posted).toBe(false);
|
||||
});
|
||||
|
||||
test("createSession replaces a stale browser workspace with the current installation default", async () => {
|
||||
localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({
|
||||
test("createSession replaces a stale ephemeral workspace with the current installation default", async () => {
|
||||
workspacePreferences.save({
|
||||
workspaceId: "retired-workspace", provider: "zai", model: "glm-5.2", thinking: "low",
|
||||
}));
|
||||
});
|
||||
let body: unknown;
|
||||
server.use(
|
||||
http.get("/api/settings", () => HttpResponse.json({ workspace: "psd-clinical" })),
|
||||
@@ -137,15 +139,15 @@ test("createSession replaces a stale browser workspace with the current installa
|
||||
question: "q", workspaceId: "psd-clinical",
|
||||
provider: "zai", model: "glm-5.2", thinking: "low",
|
||||
});
|
||||
expect(JSON.parse(localStorage.getItem("thothii.workspace-registry.v1.preferences")!)).toEqual({
|
||||
expect(workspacePreferences.load()).toEqual({
|
||||
workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low",
|
||||
});
|
||||
});
|
||||
|
||||
test("createSession preserves a local legacy selection when the registry is empty", async () => {
|
||||
localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({
|
||||
workspacePreferences.save({
|
||||
workspaceId: "legacy-workspace", provider: "zai", model: "glm-5.2", thinking: "low",
|
||||
}));
|
||||
});
|
||||
let body: unknown;
|
||||
server.use(
|
||||
http.get("/api/workspaces", () => HttpResponse.json([])),
|
||||
@@ -182,7 +184,7 @@ test("createSession rejects a workspace summary that omits the canonical revisio
|
||||
);
|
||||
|
||||
await expect(createSession({ question: "q" })).rejects.toMatchObject({
|
||||
message: "Could not load selected workspace policy. Please retry.",
|
||||
message: "Could not load workspace registry. Please retry.",
|
||||
});
|
||||
expect(posted).toBe(false);
|
||||
});
|
||||
|
||||
@@ -15,8 +15,8 @@ async function selectedPreferences(): Promise<WorkspacePreference> {
|
||||
const saved = workspacePreferences.load();
|
||||
if (saved.workspaceId && saved.provider && saved.model && saved.thinking) return saved;
|
||||
|
||||
// A direct new-session entry point can run before the composer has mounted. Seed its
|
||||
// browser-local preferences from the legacy read-only defaults once, then keep them local.
|
||||
// A direct new-session entry point can run before the composer has mounted. Seed current
|
||||
// application memory from the installation defaults once, then keep choices ephemeral.
|
||||
const legacy = await getSettings();
|
||||
return workspacePreferences.save({
|
||||
workspaceId: saved.workspaceId ?? legacy.workspace,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { server } from "../test/msw";
|
||||
import { FakeEventSource } from "../test/fakeEventSource";
|
||||
import { useSessionStore } from "../store/sessionStore";
|
||||
import { AppShell } from "./AppShell";
|
||||
import { workspacePreferences } from "../workspaces/preferences";
|
||||
|
||||
function renderShell() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
@@ -143,8 +144,7 @@ test("model selector shows the three Pi-enabled models and stores the selected p
|
||||
]);
|
||||
});
|
||||
await userEvent.selectOptions(select, "qwen3.6-35b-a3b");
|
||||
await waitFor(() => expect(JSON.parse(localStorage.getItem("thothii.workspace-registry.v1.preferences")!))
|
||||
.toEqual({
|
||||
await waitFor(() => expect(workspacePreferences.load()).toEqual({
|
||||
workspaceId: "default", provider: "local-qwen", model: "qwen3.6-35b-a3b", thinking: "low",
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { server } from "../test/msw";
|
||||
import { canonicalWorkspaceFixture, workspaceRevisionFixture, workspaceSummaryFixture } from "../test/workspace-fixtures";
|
||||
import { NewSessionDialog } from "./NewSessionDialog";
|
||||
import { workspacePreferences } from "../workspaces/preferences";
|
||||
|
||||
function renderDialog() {
|
||||
const onCreated = vi.fn();
|
||||
@@ -28,7 +29,7 @@ test("the form has only a question field (no workspace/model/provider/thinking)"
|
||||
expect(screen.queryByLabelText(/thinking/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("submitting includes browser-local migrated preferences and calls onCreated", async () => {
|
||||
test("submitting includes ephemeral migrated preferences and calls onCreated", async () => {
|
||||
localStorage.clear();
|
||||
let body: unknown = null;
|
||||
server.use(
|
||||
@@ -71,9 +72,9 @@ test("first-run direct dialog creation waits for registry policy without a mount
|
||||
const revision = {
|
||||
id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot",
|
||||
};
|
||||
localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({
|
||||
workspacePreferences.save({
|
||||
workspaceId: "psd-clinical", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium",
|
||||
}));
|
||||
});
|
||||
server.use(
|
||||
http.get("/api/workspaces", () => {
|
||||
summaryRequestStarted = true;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { server } from "../test/msw";
|
||||
import { canonicalWorkspaceFixture, workspaceSummaryFixture } from "../test/workspace-fixtures";
|
||||
import { useSessionStore } from "../store/sessionStore";
|
||||
import { workspacePreferences } from "../workspaces/preferences";
|
||||
import { ComposerFooter, ContextGauge, SteerInput } from "./SteerInput";
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -17,11 +18,11 @@ beforeEach(() => {
|
||||
);
|
||||
});
|
||||
|
||||
test("new sessions send the browser-selected workspace, model, provider, and thinking", async () => {
|
||||
test("new sessions send the ephemeral workspace, model, provider, and thinking", async () => {
|
||||
let body: unknown;
|
||||
localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({
|
||||
workspacePreferences.save({
|
||||
workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "high",
|
||||
}));
|
||||
});
|
||||
server.use(
|
||||
http.get("/api/workspaces", () => HttpResponse.json([{
|
||||
...workspaceSummaryFixture("psd-clinical", {
|
||||
@@ -203,9 +204,9 @@ test("footer limits model choices to the selected workspace policy", async () =>
|
||||
|
||||
test("switching workspaces replaces an out-of-policy model before session creation", async () => {
|
||||
let body: unknown;
|
||||
localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({
|
||||
workspacePreferences.save({
|
||||
workspaceId: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium",
|
||||
}));
|
||||
});
|
||||
const revision = (id: string) => ({
|
||||
id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot",
|
||||
});
|
||||
@@ -256,9 +257,9 @@ test("immediate submit waits for a switched workspace policy before creating a s
|
||||
let releasePolicy!: () => void;
|
||||
let policyRequestStarted = false;
|
||||
const policyMayFinish = new Promise<void>((resolve) => { releasePolicy = resolve; });
|
||||
localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({
|
||||
workspacePreferences.save({
|
||||
workspaceId: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium",
|
||||
}));
|
||||
});
|
||||
const revision = (id: string) => ({
|
||||
id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot",
|
||||
});
|
||||
@@ -312,9 +313,9 @@ test("initial restored workspace waits for its delayed policy before creating a
|
||||
let releasePolicy!: () => void;
|
||||
let policyRequestStarted = false;
|
||||
const policyMayFinish = new Promise<void>((resolve) => { releasePolicy = resolve; });
|
||||
localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({
|
||||
workspacePreferences.save({
|
||||
workspaceId: "psd-clinical", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium",
|
||||
}));
|
||||
});
|
||||
const revision = (id: string) => ({
|
||||
id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot",
|
||||
});
|
||||
@@ -359,9 +360,9 @@ test("initial restored workspace waits for its delayed policy before creating a
|
||||
test("initial submit rejects a workspace summary that omits the canonical revision", async () => {
|
||||
let body: unknown;
|
||||
let failure: string | undefined;
|
||||
localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({
|
||||
workspacePreferences.save({
|
||||
workspaceId: "broken-workspace", provider: "zai", model: "glm-5.2", thinking: "medium",
|
||||
}));
|
||||
});
|
||||
server.use(
|
||||
http.get("/api/settings", () => HttpResponse.json({ workspace: "broken-workspace" })),
|
||||
http.get("/api/workspaces", () => HttpResponse.json([{
|
||||
@@ -384,7 +385,7 @@ test("initial submit rejects a workspace summary that omits the canonical revisi
|
||||
await userEvent.type(screen.getByRole("textbox", { name: /new question/i }), "q");
|
||||
await userEvent.click(screen.getByRole("button", { name: /send/i }));
|
||||
|
||||
await waitFor(() => expect(failure).toBe("Could not load selected workspace policy. Please retry."));
|
||||
await waitFor(() => expect(failure).toBe("Could not load workspace registry. Please retry."));
|
||||
expect(body).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -392,9 +393,9 @@ test("failed workspace summaries block creation and report a safe error", async
|
||||
let body: unknown;
|
||||
let failure: string | undefined;
|
||||
let summaryRequestFailed = false;
|
||||
localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({
|
||||
workspacePreferences.save({
|
||||
workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium",
|
||||
}));
|
||||
});
|
||||
server.use(
|
||||
http.get("/api/settings", () => HttpResponse.json({ workspace: "psd-clinical" })),
|
||||
http.get("/api/workspaces", () => {
|
||||
@@ -432,9 +433,9 @@ test("submit follows a rapid workspace switch instead of waiting for an abandone
|
||||
let bPolicyRequestStarted = false;
|
||||
let cPolicyRequestStarted = false;
|
||||
const cPolicyMayFinish = new Promise<void>((resolve) => { releaseC = resolve; });
|
||||
localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({
|
||||
workspacePreferences.save({
|
||||
workspaceId: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium",
|
||||
}));
|
||||
});
|
||||
const revision = (id: string) => ({
|
||||
id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot",
|
||||
});
|
||||
|
||||
@@ -156,7 +156,7 @@ export function SteerInput({
|
||||
|
||||
/**
|
||||
* Status strip beneath the composer. Left: the active workspace selector.
|
||||
* Right: live context usage plus browser-local workspace/model/thinking selectors.
|
||||
* Right: live context usage plus ephemeral workspace/model/thinking selectors.
|
||||
*/
|
||||
export function ComposerFooter() {
|
||||
const { data: settings } = useQuery({ queryKey: ["settings"], queryFn: getSettings });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { server } from "./msw";
|
||||
import { workspacePreferences } from "../workspaces/preferences";
|
||||
|
||||
// Node 25 ships an experimental global `localStorage` that shadows jsdom's Storage and
|
||||
// lacks spec methods like clear(), so tests calling localStorage.clear()/setItem() throw.
|
||||
@@ -37,5 +38,6 @@ if (!window.matchMedia) {
|
||||
}
|
||||
|
||||
beforeAll(() => server.listen());
|
||||
beforeEach(() => workspacePreferences.reset());
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
|
||||
@@ -1,129 +1,18 @@
|
||||
import { beforeEach, expect, test } from "vitest";
|
||||
import type { CanonicalWorkspace } from "../api/workspaces";
|
||||
import {
|
||||
sanitizeCanonicalWorkspace,
|
||||
workspaceBootstrapDrafts,
|
||||
workspacePreferences,
|
||||
type WorkspaceBootstrapDraft,
|
||||
} from "./drafts";
|
||||
import { expect, test } from "vitest";
|
||||
import { canonicalWorkspaceFixture } from "../test/workspace-fixtures";
|
||||
import * as workspaceModule from "./drafts";
|
||||
|
||||
const workspace: CanonicalWorkspace = {
|
||||
workspace: { schema_version: 3, id: "psd-clinical", name: "PSD Clinical", language: "en" },
|
||||
dwh: {
|
||||
engine: "postgres", database: "clinical", schema: "datawarehouse",
|
||||
supported_transports: ["postgres_direct"],
|
||||
},
|
||||
semantic_index: {
|
||||
vector_store: {
|
||||
engine: "qdrant", collection: "clinical", dimensions: 1024, distance: "cosine",
|
||||
},
|
||||
embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 },
|
||||
},
|
||||
llm_policy: { allowed: ["zai/glm-5.2"] },
|
||||
};
|
||||
|
||||
const bootstrapDraft: WorkspaceBootstrapDraft = {
|
||||
workspaceId: "psd-clinical",
|
||||
baseCommit: "a".repeat(40),
|
||||
workspace,
|
||||
updatedAt: "2026-08-04T10:00:00.000Z",
|
||||
};
|
||||
|
||||
const policy = { max_chunk_chars: 8_000, retain_published_generations: 5 };
|
||||
|
||||
const evidenceWorkspace: CanonicalWorkspace = {
|
||||
...workspace,
|
||||
evidence: {
|
||||
source: {
|
||||
type: "filesystem",
|
||||
uri: "psd-clinical/evidence",
|
||||
patterns: ["documents/**/*.pdf", "notes/*.md"],
|
||||
max_bytes: 12_000_000,
|
||||
},
|
||||
policy,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
test("keeps an anonymous user's model selection in browser storage", () => {
|
||||
workspacePreferences.save({
|
||||
workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium",
|
||||
});
|
||||
|
||||
expect(workspacePreferences.load()).toMatchObject({ model: "glm-5.2" });
|
||||
test("workspace module exposes validation and in-memory coordination but no draft storage", () => {
|
||||
expect(workspaceModule.sanitizeCanonicalWorkspace(canonicalWorkspaceFixture("psd-clinical")))
|
||||
.toEqual(canonicalWorkspaceFixture("psd-clinical"));
|
||||
expect(workspaceModule).not.toHaveProperty("workspaceBootstrapDrafts");
|
||||
expect(workspaceModule).not.toHaveProperty("workspaceDrafts");
|
||||
expect(workspaceModule).not.toHaveProperty("workspaceDeletionDrafts");
|
||||
});
|
||||
|
||||
test("reloads a bootstrap draft from the v2 browser-storage key and discards it by workspace ID", () => {
|
||||
workspaceBootstrapDrafts.save(bootstrapDraft);
|
||||
|
||||
expect(workspaceBootstrapDrafts.load("psd-clinical")).toEqual(bootstrapDraft);
|
||||
expect(localStorage.getItem("thothii.workspace-registry.v2.bootstrap.psd-clinical")).toContain('"baseCommit"');
|
||||
expect(localStorage.getItem("thothii.workspace-registry.v2.bootstrap.psd-clinical")).not.toContain("baseBlob");
|
||||
|
||||
workspaceBootstrapDrafts.discard("psd-clinical");
|
||||
expect(workspaceBootstrapDrafts.load("psd-clinical")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("purges known v1 update and deletion keys without touching unrelated localStorage", () => {
|
||||
localStorage.setItem("thothii.workspace-registry.v1.draft.psd-clinical", JSON.stringify({
|
||||
workspaceId: "psd-clinical",
|
||||
baseCommit: "a".repeat(40),
|
||||
baseBlob: "b".repeat(40),
|
||||
workspace,
|
||||
updatedAt: "2026-08-04T10:00:00.000Z",
|
||||
}));
|
||||
localStorage.setItem("thothii.workspace-registry.v1.delete.psd-clinical", JSON.stringify({
|
||||
id: "psd-clinical",
|
||||
baseCommit: "a".repeat(40),
|
||||
baseBlob: "b".repeat(40),
|
||||
updatedAt: "2026-08-04T10:00:00.000Z",
|
||||
}));
|
||||
localStorage.setItem("unrelated", "keep-me");
|
||||
|
||||
expect(workspaceBootstrapDrafts.load("psd-clinical")).toBeUndefined();
|
||||
expect(localStorage.getItem("thothii.workspace-registry.v1.draft.psd-clinical")).toBeNull();
|
||||
expect(localStorage.getItem("thothii.workspace-registry.v1.delete.psd-clinical")).toBeNull();
|
||||
expect(localStorage.getItem("unrelated")).toBe("keep-me");
|
||||
});
|
||||
|
||||
test("rejects draft payloads that still carry baseBlob from the removed update flow", () => {
|
||||
localStorage.setItem("thothii.workspace-registry.v2.bootstrap.psd-clinical", JSON.stringify({
|
||||
...bootstrapDraft,
|
||||
baseBlob: "b".repeat(40),
|
||||
}));
|
||||
|
||||
expect(workspaceBootstrapDrafts.load("psd-clinical")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("accepts only the workspace directory evidence root for filesystem sources", () => {
|
||||
const sanitized = sanitizeCanonicalWorkspace(evidenceWorkspace);
|
||||
|
||||
expect(sanitized).toEqual(evidenceWorkspace);
|
||||
expect(sanitized).not.toBe(evidenceWorkspace);
|
||||
expect(sanitized?.evidence).not.toBe(evidenceWorkspace.evidence);
|
||||
});
|
||||
|
||||
test("rejects filesystem Evidence that still points at workspace-content", () => {
|
||||
const invalid: CanonicalWorkspace = {
|
||||
...evidenceWorkspace,
|
||||
evidence: {
|
||||
source: {
|
||||
type: "filesystem",
|
||||
uri: "workspace-content/psd-clinical/evidence",
|
||||
patterns: ["documents/**/*.pdf", "notes/*.md"],
|
||||
max_bytes: 12_000_000,
|
||||
},
|
||||
policy,
|
||||
},
|
||||
};
|
||||
|
||||
expect(sanitizeCanonicalWorkspace(invalid)).toBeUndefined();
|
||||
workspaceBootstrapDrafts.save({ ...bootstrapDraft, workspace: invalid });
|
||||
expect(workspaceBootstrapDrafts.load("psd-clinical")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("continues to sanitize workspaces without Evidence", () => {
|
||||
expect(sanitizeCanonicalWorkspace(workspace)).toEqual(workspace);
|
||||
expect(sanitizeCanonicalWorkspace(workspace)).not.toHaveProperty("evidence");
|
||||
test("canonical sanitizer rejects unknown fields rather than persisting them", () => {
|
||||
expect(workspaceModule.sanitizeCanonicalWorkspace({
|
||||
...canonicalWorkspaceFixture("psd-clinical"),
|
||||
localDraft: { secret: "must-not-survive" },
|
||||
})).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -4,16 +4,6 @@ import type {
|
||||
} from "../api/workspaces";
|
||||
export { workspacePreferences, type WorkspacePreference } from "./preferences";
|
||||
|
||||
export interface WorkspaceBootstrapDraft {
|
||||
workspaceId: string;
|
||||
baseCommit: string;
|
||||
workspace: CanonicalWorkspace;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** @deprecated Use WorkspaceBootstrapDraft. */
|
||||
export type WorkspaceDraft = WorkspaceBootstrapDraft;
|
||||
|
||||
export const WORKSPACE_SUMMARY_ERROR = "Could not load workspace registry. Please retry.";
|
||||
export const WORKSPACE_POLICY_ERROR = "Could not load selected workspace policy. Please retry.";
|
||||
|
||||
@@ -117,14 +107,6 @@ export const workspacePolicyGate = {
|
||||
},
|
||||
};
|
||||
|
||||
const LEGACY_PREFIX = "thothii.workspace-registry.v1";
|
||||
const PREFIX = "thothii.workspace-registry.v2";
|
||||
const DRAFT_PREFIX = `${PREFIX}.bootstrap.`;
|
||||
|
||||
function storage(): Storage | undefined {
|
||||
try { return window.localStorage; } catch { return undefined; }
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
@@ -473,108 +455,3 @@ export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace |
|
||||
...(evidence ? { evidence } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function repairBootstrapWorkspace(value: unknown, id: string): CanonicalWorkspace | undefined {
|
||||
const direct = sanitizeCanonicalWorkspace(value);
|
||||
if (direct) return direct;
|
||||
const source = exactRecord(value, [
|
||||
"workspace", "dwh", "semantic_index", "llm_policy", "diagnostics", "evidence",
|
||||
]);
|
||||
const evidence = exactRecord(source?.evidence, ["source", "policy"]);
|
||||
const evidenceSource = exactRecord(evidence?.source, ["type", "uri", "patterns", "max_bytes"]);
|
||||
if (
|
||||
source
|
||||
&& evidence
|
||||
&& evidenceSource?.type === "filesystem"
|
||||
&& typeof evidenceSource.uri === "string"
|
||||
&& /^[a-z][a-z0-9-]{2,62}\/evidence$/.test(evidenceSource.uri)
|
||||
) {
|
||||
return sanitizeCanonicalWorkspace({
|
||||
...source,
|
||||
evidence: {
|
||||
...evidence,
|
||||
source: {
|
||||
...evidenceSource,
|
||||
uri: `${id}/evidence`,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalize(value: unknown): WorkspaceBootstrapDraft | undefined {
|
||||
const source = exactRecord(value, ["workspaceId", "baseCommit", "workspace", "updatedAt"]);
|
||||
const id = workspaceId(source?.workspaceId);
|
||||
const workspace = id ? repairBootstrapWorkspace(source?.workspace, id) : undefined;
|
||||
const baseCommit = typeof source?.baseCommit === "string" && /^[0-9a-f]{40}$/.test(source.baseCommit) ? source.baseCommit : undefined;
|
||||
const updatedAt = typeof source?.updatedAt === "string" && Number.isFinite(Date.parse(source.updatedAt)) ? source.updatedAt : undefined;
|
||||
if (!source || !workspace || !id || id !== workspace.workspace.id || !baseCommit || !updatedAt) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
workspaceId: id,
|
||||
baseCommit,
|
||||
workspace,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function key(id: string): string {
|
||||
return `${DRAFT_PREFIX}${encodeURIComponent(id)}`;
|
||||
}
|
||||
|
||||
function purgeLegacyRegistryDrafts(): void {
|
||||
const store = storage();
|
||||
if (!store) return;
|
||||
const keys = Array.from({ length: store.length }, (_, index) => store.key(index)).filter((value): value is string => value !== null);
|
||||
for (const entry of keys) {
|
||||
if (entry.startsWith(`${LEGACY_PREFIX}.draft.`) || entry.startsWith(`${LEGACY_PREFIX}.delete.`)) {
|
||||
store.removeItem(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Browser-only bootstrap drafts. Saving or editing one never calls the server. */
|
||||
export const workspaceBootstrapDrafts = {
|
||||
load(id: string): WorkspaceBootstrapDraft | undefined {
|
||||
purgeLegacyRegistryDrafts();
|
||||
try {
|
||||
const raw = storage()?.getItem(key(id));
|
||||
return raw ? normalize(JSON.parse(raw)) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
|
||||
save(draft: WorkspaceBootstrapDraft): void {
|
||||
purgeLegacyRegistryDrafts();
|
||||
const safe = normalize(draft);
|
||||
if (!safe) return;
|
||||
try { storage()?.setItem(key(safe.workspaceId), JSON.stringify(safe)); } catch { /* storage is optional */ }
|
||||
},
|
||||
|
||||
discard(id: string): void {
|
||||
purgeLegacyRegistryDrafts();
|
||||
try { storage()?.removeItem(key(id)); } catch { /* storage is optional */ }
|
||||
},
|
||||
};
|
||||
|
||||
/** @deprecated Use workspaceBootstrapDrafts. */
|
||||
export const workspaceDrafts = workspaceBootstrapDrafts;
|
||||
|
||||
|
||||
/** @deprecated Removed in P1.1; existing workspaces are curator-owned and deletions are not drafted in-browser. */
|
||||
export interface WorkspaceDeletionDraft {
|
||||
id: string;
|
||||
baseCommit: string;
|
||||
baseBlob: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** @deprecated Removed in P1.1; kept temporarily so legacy imports compile during the UI transition. */
|
||||
export const workspaceDeletionDrafts = {
|
||||
load(_id: string): WorkspaceDeletionDraft | undefined { return undefined; },
|
||||
save(_draft: WorkspaceDeletionDraft): void {},
|
||||
discard(_id: string): void {},
|
||||
};
|
||||
|
||||
@@ -1,15 +1,39 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { beforeEach, expect, test } from "vitest";
|
||||
import { workspacePreferences } from "./preferences";
|
||||
|
||||
test("migrates legacy selections once and keeps later browser choices", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
workspacePreferences.reset();
|
||||
});
|
||||
|
||||
test("keeps selections only in current application memory", () => {
|
||||
localStorage.setItem("unrelated", "keep-me");
|
||||
workspacePreferences.save({
|
||||
workspaceId: "research",
|
||||
provider: "deepseek",
|
||||
model: "deepseek-v4-pro",
|
||||
thinking: "high",
|
||||
});
|
||||
|
||||
expect(workspacePreferences.load()).toEqual({
|
||||
workspaceId: "research",
|
||||
provider: "deepseek",
|
||||
model: "deepseek-v4-pro",
|
||||
thinking: "high",
|
||||
});
|
||||
expect(localStorage.length).toBe(1);
|
||||
expect(localStorage.getItem("unrelated")).toBe("keep-me");
|
||||
expect(localStorage.getItem("thothii.workspace-registry.v1.preferences")).toBeNull();
|
||||
});
|
||||
|
||||
test("seeds memory from backend settings once and returns defensive copies", () => {
|
||||
expect(workspacePreferences.migrate({
|
||||
workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium",
|
||||
})).toMatchObject({ workspaceId: "psd-clinical", model: "glm-5.2" });
|
||||
workspacePreferences.save({ workspaceId: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "high" });
|
||||
const snapshot = workspacePreferences.load();
|
||||
snapshot.workspaceId = "mutated-copy";
|
||||
|
||||
expect(workspacePreferences.migrate({ workspaceId: "legacy" })).toMatchObject({
|
||||
workspaceId: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "high",
|
||||
workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,36 +5,31 @@ export interface WorkspacePreference {
|
||||
thinking?: string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "thothii.workspace-registry.v1.preferences";
|
||||
let current: WorkspacePreference | undefined;
|
||||
|
||||
function storage(): Storage | undefined {
|
||||
try { return window.localStorage; } catch { return undefined; }
|
||||
function snapshot(value: WorkspacePreference | undefined): WorkspacePreference {
|
||||
return value === undefined ? {} : { ...value };
|
||||
}
|
||||
|
||||
function parse(raw: string | null): WorkspacePreference | undefined {
|
||||
if (raw === null) return undefined;
|
||||
try {
|
||||
const value = JSON.parse(raw);
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as WorkspacePreference : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Anonymous, non-secret browser choices for the workspace session composer. */
|
||||
/** Ephemeral workspace choices for the current application process. */
|
||||
export const workspacePreferences = {
|
||||
load(): WorkspacePreference {
|
||||
return parse(storage()?.getItem(STORAGE_KEY) ?? null) ?? {};
|
||||
return snapshot(current);
|
||||
},
|
||||
|
||||
save(value: WorkspacePreference): WorkspacePreference {
|
||||
try { storage()?.setItem(STORAGE_KEY, JSON.stringify(value)); } catch { /* storage is optional */ }
|
||||
return value;
|
||||
current = { ...value };
|
||||
return snapshot(current);
|
||||
},
|
||||
|
||||
/** Copy the pre-local-storage server values only when this browser has no saved choice. */
|
||||
migrate(legacy: WorkspacePreference): WorkspacePreference {
|
||||
const current = storage()?.getItem(STORAGE_KEY);
|
||||
return current === null || current === undefined ? this.save(legacy) : this.load();
|
||||
/** Seed current memory from backend installation settings only before the first local choice. */
|
||||
migrate(settings: WorkspacePreference): WorkspacePreference {
|
||||
if (current === undefined) current = { ...settings };
|
||||
return snapshot(current);
|
||||
},
|
||||
|
||||
/** Clear ephemeral state on application/test lifecycle reset. */
|
||||
reset(): void {
|
||||
current = undefined;
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user