diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 92d084f2d..1c371480f 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -258,3 +258,70 @@ describe("store read model sync", () => { expect(next.projects.map((project) => project.id)).toEqual([project2, project1, project3]); }); }); + +describe("sidebar cache hydration", () => { + it("syncServerReadModel replaces cached threads and sets threadsHydrated", () => { + const cachedState: AppState = { + projects: [ + { + id: ProjectId.makeUnsafe("stale-project"), + name: "Stale Project", + cwd: "/tmp/stale", + model: DEFAULT_MODEL_BY_PROVIDER.codex, + expanded: true, + scripts: [], + }, + ], + threads: [ + makeThread({ + id: ThreadId.makeUnsafe("stale-thread"), + projectId: ProjectId.makeUnsafe("stale-project"), + title: "Stale Thread", + }), + ], + threadsHydrated: false, + }; + + const readModel = makeReadModel( + makeReadModelThread({ title: "Fresh Thread" }), + ); + const next = syncServerReadModel(cachedState, readModel); + + expect(next.threadsHydrated).toBe(true); + expect(next.threads).toHaveLength(1); + expect(next.threads[0]?.title).toBe("Fresh Thread"); + }); + + it("cached threads with empty defaults are valid input to syncServerReadModel", () => { + const cachedState: AppState = { + projects: [ + { + id: ProjectId.makeUnsafe("project-1"), + name: "Project", + cwd: "/tmp/project", + model: DEFAULT_MODEL_BY_PROVIDER.codex, + expanded: true, + scripts: [], + }, + ], + threads: [ + makeThread({ + session: null, + messages: [], + activities: [], + proposedPlans: [], + turnDiffSummaries: [], + latestTurn: null, + }), + ], + threadsHydrated: false, + }; + + const readModel = makeReadModel(makeReadModelThread()); + const next = syncServerReadModel(cachedState, readModel); + + expect(next.threadsHydrated).toBe(true); + expect(next.threads[0]?.session).toBeNull(); + expect(next.threads[0]?.messages).toEqual([]); + }); +}); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index faebe4b0f..afcf140e6 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -13,7 +13,13 @@ import { resolveModelSlugForProvider, } from "@t3tools/shared/model"; import { create } from "zustand"; -import { type ChatMessage, type Project, type Thread } from "./types"; +import { + type ChatMessage, + DEFAULT_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + type Project, + type Thread, +} from "./types"; import { Debouncer } from "@tanstack/react-pacer"; // ── State ──────────────────────────────────────────────────────────── @@ -55,6 +61,22 @@ function readPersistedState(): AppState { const parsed = JSON.parse(raw) as { expandedProjectCwds?: string[]; projectOrderCwds?: string[]; + cachedProjects?: Array<{ + id: string; + name: string; + cwd: string; + model: string; + expanded: boolean; + scripts: Project["scripts"]; + }>; + cachedThreads?: Array<{ + id: string; + projectId: string; + title: string; + createdAt: string; + branch: string | null; + worktreePath: string | null; + }>; }; persistedExpandedProjectCwds.clear(); persistedProjectOrderCwds.length = 0; @@ -68,7 +90,37 @@ function readPersistedState(): AppState { persistedProjectOrderCwds.push(cwd); } } - return { ...initialState }; + + const projects: Project[] = (parsed.cachedProjects ?? []).map((p) => ({ + id: p.id as Project["id"], + name: p.name, + cwd: p.cwd, + model: p.model, + expanded: p.expanded, + scripts: p.scripts ?? [], + })); + + const threads: Thread[] = (parsed.cachedThreads ?? []).map((t) => ({ + id: t.id as Thread["id"], + codexThreadId: null, + projectId: t.projectId as Thread["projectId"], + title: t.title, + model: DEFAULT_MODEL_BY_PROVIDER.codex, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_INTERACTION_MODE, + session: null, + messages: [], + proposedPlans: [], + error: null, + createdAt: t.createdAt, + latestTurn: null, + branch: t.branch, + worktreePath: t.worktreePath, + turnDiffSummaries: [], + activities: [], + })); + + return { projects, threads, threadsHydrated: false }; } catch { return initialState; } @@ -86,6 +138,22 @@ function persistState(state: AppState): void { .filter((project) => project.expanded) .map((project) => project.cwd), projectOrderCwds: state.projects.map((project) => project.cwd), + cachedProjects: state.projects.map((project) => ({ + id: project.id, + name: project.name, + cwd: project.cwd, + model: project.model, + expanded: project.expanded, + scripts: project.scripts, + })), + cachedThreads: state.threads.map((thread) => ({ + id: thread.id, + projectId: thread.projectId, + title: thread.title, + createdAt: thread.createdAt, + branch: thread.branch, + worktreePath: thread.worktreePath, + })), }), ); if (!legacyKeysCleanedUp) {