1import { ProjectId, ThreadId } from "@t3tools/contracts";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
3
4import {
5 legacyProjectCwdPreferenceKey,
6 markThreadUnread,
7 markThreadVisited,
8 parsePersistedState,
9 PERSISTED_STATE_KEY,
10 type PersistedUiState,
11 persistState,
12 reorderProjects,
13 resolveProjectExpanded,
14 setDefaultAdvertisedEndpointKey,
15 setProjectExpanded,
16 setSidebarProjectScopeKey,
17 setThreadChangedFilesExpanded,
18 type UiState,
19} from "./uiStateStore";
20
21function makeUiState(overrides: Partial<UiState> = {}): UiState {
22 return {
23 projectExpandedById: {},
24 projectOrder: [],
25 sidebarProjectScopeKey: null,
26 threadLastVisitedAtById: {},
27 threadChangedFilesExpandedById: {},
28 defaultAdvertisedEndpointKey: null,
29 pullRequestMergeMethod: "merge",
30 ...overrides,
31 };
32}
33
34describe("uiStateStore pure functions", () => {
35 it("stores server timestamps without moving visit state backwards", () => {
36 const threadId = ThreadId.make("thread-1");
37 const initialState = makeUiState();
38 const visited = markThreadVisited(initialState, threadId, "2026-02-25T12:30:00.700Z");
39
40 expect(visited.threadLastVisitedAtById[threadId]).toBe("2026-02-25T12:30:00.700Z");
41 expect(markThreadVisited(visited, threadId, "2026-02-25T12:30:00.000Z")).toBe(visited);
42 expect(markThreadVisited(visited, threadId, "not-a-date")).toBe(visited);
43 });
44
45 it("marks a completed thread unread using the server completion timestamp", () => {
46 const threadId = ThreadId.make("thread-1");
47 const initialState = makeUiState({
48 threadLastVisitedAtById: {
49 [threadId]: "2026-02-25T12:35:00.000Z",
50 },
51 });
52
53 const next = markThreadUnread(initialState, threadId, "2026-02-25T12:30:00.000Z");
54
55 expect(next.threadLastVisitedAtById[threadId]).toBe("2026-02-25T12:29:59.999Z");
56 expect(markThreadUnread(next, threadId, null)).toBe(next);
57 });
58
59 it("resolves project expansion from logical, physical, and legacy preference keys", () => {
60 const physicalKey = "environment:/repo/project";
61 const legacyKey = legacyProjectCwdPreferenceKey("/repo/project");
62
63 expect(resolveProjectExpanded({ logical: false, [physicalKey]: true }, ["logical"])).toBe(
64 false,
65 );
66 expect(resolveProjectExpanded({ [physicalKey]: false }, ["new-logical", physicalKey])).toBe(
67 false,
68 );
69 expect(resolveProjectExpanded({ [legacyKey]: false }, ["new-logical", legacyKey])).toBe(false);
70 expect(resolveProjectExpanded({}, ["new-logical"])).toBe(true);
71 });
72
73 it("sets expansion for every stable key belonging to a logical project", () => {
74 const initialState = makeUiState();
75 const keys = ["logical", "environment-a:/repo", "environment-b:/repo"];
76
77 const next = setProjectExpanded(initialState, keys, false);
78
79 expect(next.projectExpandedById).toEqual({
80 logical: false,
81 "environment-a:/repo": false,
82 "environment-b:/repo": false,
83 });
84 expect(setProjectExpanded(next, keys, false)).toBe(next);
85 });
86
87 it("reorders from the current atom-derived project order", () => {
88 const project1 = ProjectId.make("project-1");
89 const project2 = ProjectId.make("project-2");
90 const project3 = ProjectId.make("project-3");
91 const currentOrder = [project1, project2, project3];
92
93 const next = reorderProjects(makeUiState(), currentOrder, [project1], [project3]);
94
95 expect(next.projectOrder).toEqual([project2, project3, project1]);
96 });
97
98 it("moves grouped project members together", () => {
99 const keyALocal = "env-local:proj-a";
100 const keyARemote = "env-remote:proj-a";
101 const keyB = "env-local:proj-b";
102 const keyC = "env-local:proj-c";
103 const currentOrder = [keyALocal, keyARemote, keyB, keyC];
104
105 const next = reorderProjects(makeUiState(), currentOrder, [keyALocal, keyARemote], [keyC]);
106
107 expect(next.projectOrder).toEqual([keyB, keyC, keyALocal, keyARemote]);
108 });
109
110 it("does not reorder missing or identical groups", () => {
111 const currentOrder = ["env-local:proj-a", "env-local:proj-b"];
112 const state = makeUiState();
113
114 expect(reorderProjects(state, currentOrder, ["env-local:missing"], ["env-local:proj-b"])).toBe(
115 state,
116 );
117 expect(reorderProjects(state, currentOrder, ["env-local:proj-a"], ["env-local:proj-a"])).toBe(
118 state,
119 );
120 });
121
122 it("stores explicit changed-file expansion choices", () => {
123 const threadId = ThreadId.make("thread-1");
124 const collapsed = setThreadChangedFilesExpanded(makeUiState(), threadId, "turn-1", false);
125
126 expect(collapsed.threadChangedFilesExpandedById).toEqual({
127 [threadId]: {
128 "turn-1": false,
129 },
130 });
131 expect(
132 setThreadChangedFilesExpanded(collapsed, threadId, "turn-1", true)
133 .threadChangedFilesExpandedById,
134 ).toEqual({
135 [threadId]: {
136 "turn-1": true,
137 },
138 });
139 });
140
141 it("stores the endpoint preference by stable key", () => {
142 const next = setDefaultAdvertisedEndpointKey(makeUiState(), "desktop-core:lan:http");
143
144 expect(next.defaultAdvertisedEndpointKey).toBe("desktop-core:lan:http");
145 expect(setDefaultAdvertisedEndpointKey(next, "desktop-core:lan:http")).toBe(next);
146 expect(setDefaultAdvertisedEndpointKey(next, "")).toMatchObject({
147 defaultAdvertisedEndpointKey: null,
148 });
149 });
150
151 it("stores the sidebar project scope and resets it to all projects", () => {
152 const scoped = setSidebarProjectScopeKey(makeUiState(), "github.com/pingdotgg/t3code");
153
154 expect(scoped.sidebarProjectScopeKey).toBe("github.com/pingdotgg/t3code");
155 expect(setSidebarProjectScopeKey(scoped, "github.com/pingdotgg/t3code")).toBe(scoped);
156 expect(setSidebarProjectScopeKey(scoped, null).sidebarProjectScopeKey).toBeNull();
157 expect(setSidebarProjectScopeKey(scoped, "").sidebarProjectScopeKey).toBeNull();
158 });
159});
160
161describe("parsePersistedState", () => {
162 it("hydrates the last selected pull request merge method", () => {
163 const parsed = parsePersistedState({
164 pullRequestMergeMethod: "squash",
165 });
166 const invalid = parsePersistedState({
167 pullRequestMergeMethod: "fast-forward",
168 });
169
170 expect(parsed.pullRequestMergeMethod).toBe("squash");
171 expect(invalid.pullRequestMergeMethod).toBe("merge");
172 });
173
174 it("hydrates raw UI-owned state without server entities", () => {
175 const parsed = parsePersistedState({
176 projectExpandedById: {
177 logical: false,
178 invalid: "no" as unknown as boolean,
179 },
180 projectOrder: ["physical-b", "", "physical-a", "physical-b"],
181 threadLastVisitedAtById: {
182 "environment:thread-1": "2026-02-25T12:35:00.000Z",
183 invalid: "not-a-date",
184 },
185 defaultAdvertisedEndpointKey: "desktop-core:lan:http",
186 threadChangedFilesExpansionVersion: 2,
187 threadChangedFilesExpandedById: {
188 "environment:thread-1": {
189 "turn-1": false,
190 "turn-2": true,
191 },
192 },
193 });
194
195 expect(parsed).toEqual({
196 projectExpandedById: {
197 logical: false,
198 },
199 projectOrder: ["physical-b", "physical-a"],
200 threadLastVisitedAtById: {
201 "environment:thread-1": "2026-02-25T12:35:00.000Z",
202 },
203 defaultAdvertisedEndpointKey: "desktop-core:lan:http",
204 sidebarProjectScopeKey: null,
205 pullRequestMergeMethod: "merge",
206 threadChangedFilesExpandedById: {
207 "environment:thread-1": {
208 "turn-1": false,
209 "turn-2": true,
210 },
211 },
212 });
213 });
214
215 it.each([undefined, 1])("ignores changed-file expansion version %s", (version) => {
216 const parsed = parsePersistedState({
217 ...(version === undefined ? {} : { threadChangedFilesExpansionVersion: version }),
218 threadChangedFilesExpandedById: {
219 "environment:thread-1": {
220 "turn-1": false,
221 },
222 },
223 });
224
225 expect(parsed.threadChangedFilesExpandedById).toEqual({});
226 });
227
228 it("migrates legacy CWD project preferences into local alias keys", () => {
229 const parsed = parsePersistedState({
230 collapsedProjectCwds: ["/repo/b"],
231 expandedProjectCwds: ["/repo/a"],
232 projectOrderCwds: ["/repo/b", "/repo/a"],
233 });
234 const projectAKey = legacyProjectCwdPreferenceKey("/repo/a");
235 const projectBKey = legacyProjectCwdPreferenceKey("/repo/b");
236
237 expect(parsed.projectOrder).toEqual([projectBKey, projectAKey]);
238 expect(resolveProjectExpanded(parsed.projectExpandedById, [projectAKey])).toBe(true);
239 expect(resolveProjectExpanded(parsed.projectExpandedById, [projectBKey])).toBe(false);
240 expect(resolveProjectExpanded(parsed.projectExpandedById, ["unknown"])).toBe(true);
241 });
242
243 it("preserves legacy expanded-only semantics for one-way migration", () => {
244 const parsed = parsePersistedState({
245 expandedProjectCwds: ["/repo/a"],
246 });
247
248 expect(
249 resolveProjectExpanded(parsed.projectExpandedById, [
250 legacyProjectCwdPreferenceKey("/repo/a"),
251 ]),
252 ).toBe(true);
253 expect(
254 resolveProjectExpanded(parsed.projectExpandedById, [
255 legacyProjectCwdPreferenceKey("/repo/b"),
256 ]),
257 ).toBe(false);
258 });
259});
260
261function createLocalStorageStub(): Storage {
262 const store = new Map<string, string>();
263 return {
264 clear: () => {
265 store.clear();
266 },
267 getItem: (key) => store.get(key) ?? null,
268 key: (index) => [...store.keys()][index] ?? null,
269 get length() {
270 return store.size;
271 },
272 removeItem: (key) => {
273 store.delete(key);
274 },
275 setItem: (key, value) => {
276 store.set(key, value);
277 },
278 };
279}
280
281describe("uiStateStore persistence", () => {
282 let localStorageStub: Storage;
283
284 beforeEach(() => {
285 localStorageStub = createLocalStorageStub();
286 vi.stubGlobal("window", { localStorage: localStorageStub });
287 vi.stubGlobal("localStorage", localStorageStub);
288 });
289
290 afterEach(() => {
291 vi.unstubAllGlobals();
292 });
293
294 it("persists raw UI preferences including thread visit markers", () => {
295 const state = makeUiState({
296 projectExpandedById: {
297 logical: false,
298 },
299 projectOrder: ["physical-b", "physical-a"],
300 threadLastVisitedAtById: {
301 "environment:thread-1": "2026-02-25T12:35:00.000Z",
302 },
303 threadChangedFilesExpandedById: {
304 "environment:thread-1": {
305 "turn-1": false,
306 "turn-2": true,
307 },
308 },
309 defaultAdvertisedEndpointKey: "desktop-core:lan:http",
310 });
311
312 persistState(state);
313
314 const persisted = JSON.parse(
315 localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}",
316 ) as PersistedUiState;
317 expect(persisted).toEqual({
318 projectExpandedById: {
319 logical: false,
320 },
321 projectOrder: ["physical-b", "physical-a"],
322 threadLastVisitedAtById: {
323 "environment:thread-1": "2026-02-25T12:35:00.000Z",
324 },
325 defaultAdvertisedEndpointKey: "desktop-core:lan:http",
326 sidebarProjectScopeKey: null,
327 threadChangedFilesExpansionVersion: 2,
328 threadChangedFilesExpandedById: {
329 "environment:thread-1": {
330 "turn-1": false,
331 "turn-2": true,
332 },
333 },
334 pullRequestMergeMethod: "merge",
335 });
336 expect(parsePersistedState(persisted)).toEqual({
337 ...state,
338 });
339 });
340
341 it("restores the sidebar project scope across reloads", () => {
342 persistState(makeUiState({ sidebarProjectScopeKey: "github.com/pingdotgg/t3code" }));
343
344 const persisted = JSON.parse(
345 localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}",
346 ) as PersistedUiState;
347
348 expect(parsePersistedState(persisted).sidebarProjectScopeKey).toBe(
349 "github.com/pingdotgg/t3code",
350 );
351 });
352
353 it("drops the temporary expanded-only migration fallback when rewriting state", () => {
354 const migrated = parsePersistedState({
355 expandedProjectCwds: ["/repo/a"],
356 });
357
358 persistState(migrated);
359
360 const persisted = JSON.parse(
361 localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}",
362 ) as PersistedUiState;
363 expect(resolveProjectExpanded(persisted.projectExpandedById ?? {}, ["unknown"])).toBe(true);
364 expect(persisted).not.toHaveProperty("threadPanelOpen");
365 });
366});