Open History to see the last commit
1import { 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 setProjectExpanded,
15 type UiState,
16} from "./uiStateStore";
17
18function makeUiState(overrides: Partial<UiState> = {}): UiState {
19 return {
20 projectExpandedById: {},
21 projectOrder: [],
22 sidebarProjectScopeKeys: [],
23 threadLastVisitedAtById: {},
24 threadChangedFilesExpandedById: {},
25 defaultAdvertisedEndpointKey: null,
26 pullRequestMergeMethod: "merge",
27 ...overrides,
28 };
29}
30
31describe("uiStateStore pure functions", () => {
32 it("stores server timestamps without moving visit state backwards", () => {
33 const threadId = ThreadId.make("thread-1");
34 const initialState = makeUiState();
35 const visited = markThreadVisited(initialState, threadId, "2026-02-25T12:30:00.700Z");
36
37 expect(visited.threadLastVisitedAtById[threadId]).toBe("2026-02-25T12:30:00.700Z");
38 expect(markThreadVisited(visited, threadId, "2026-02-25T12:30:00.000Z")).toBe(visited);
39 expect(markThreadVisited(visited, threadId, "not-a-date")).toBe(visited);
40 });
41
42 it("marks a completed thread unread using the server completion timestamp", () => {
43 const threadId = ThreadId.make("thread-1");
44 const initialState = makeUiState({
45 threadLastVisitedAtById: {
46 [threadId]: "2026-02-25T12:35:00.000Z",
47 },
48 });
49
50 const next = markThreadUnread(initialState, threadId, "2026-02-25T12:30:00.000Z");
51
52 expect(next.threadLastVisitedAtById[threadId]).toBe("2026-02-25T12:29:59.999Z");
53 expect(markThreadUnread(next, threadId, null)).toBe(next);
54 });
55
56 it("resolves project expansion from logical, physical, and legacy preference keys", () => {
57 const physicalKey = "environment:/repo/project";
58 const legacyKey = legacyProjectCwdPreferenceKey("/repo/project");
59
60 expect(resolveProjectExpanded({ logical: false, [physicalKey]: true }, ["logical"])).toBe(
61 false,
62 );
63 expect(resolveProjectExpanded({ [physicalKey]: false }, ["new-logical", physicalKey])).toBe(
64 false,
65 );
66 expect(resolveProjectExpanded({ [legacyKey]: false }, ["new-logical", legacyKey])).toBe(false);
67 expect(resolveProjectExpanded({}, ["new-logical"])).toBe(true);
68 });
69
70 it("sets expansion for every stable key belonging to a logical project", () => {
71 const initialState = makeUiState();
72 const keys = ["logical", "environment-a:/repo", "environment-b:/repo"];
73
74 const next = setProjectExpanded(initialState, keys, false);
75
76 expect(next.projectExpandedById).toEqual({
77 logical: false,
78 "environment-a:/repo": false,
79 "environment-b:/repo": false,
80 });
81 expect(setProjectExpanded(next, keys, false)).toBe(next);
82 });
83
84 it("moves grouped project members together", () => {
85 const keyALocal = "env-local:proj-a";
86 const keyARemote = "env-remote:proj-a";
87 const keyB = "env-local:proj-b";
88 const keyC = "env-local:proj-c";
89 const currentOrder = [keyALocal, keyARemote, keyB, keyC];
90
91 const next = reorderProjects(makeUiState(), currentOrder, [keyALocal, keyARemote], [keyC]);
92
93 expect(next.projectOrder).toEqual([keyB, keyC, keyALocal, keyARemote]);
94 });
95});
96
97describe("parsePersistedState", () => {
98 it("hydrates raw UI-owned state without server entities", () => {
99 const parsed = parsePersistedState({
100 projectExpandedById: {
101 logical: false,
102 invalid: "no" as unknown as boolean,
103 },
104 projectOrder: ["physical-b", "", "physical-a", "physical-b"],
105 threadLastVisitedAtById: {
106 "environment:thread-1": "2026-02-25T12:35:00.000Z",
107 invalid: "not-a-date",
108 },
109 defaultAdvertisedEndpointKey: "desktop-core:lan:http",
110 threadChangedFilesExpansionVersion: 2,
111 threadChangedFilesExpandedById: {
112 "environment:thread-1": {
113 "turn-1": false,
114 "turn-2": true,
115 },
116 },
117 });
118
119 expect(parsed).toEqual({
120 projectExpandedById: {
121 logical: false,
122 },
123 projectOrder: ["physical-b", "physical-a"],
124 threadLastVisitedAtById: {
125 "environment:thread-1": "2026-02-25T12:35:00.000Z",
126 },
127 defaultAdvertisedEndpointKey: "desktop-core:lan:http",
128 sidebarProjectScopeKeys: [],
129 pullRequestMergeMethod: "merge",
130 threadChangedFilesExpandedById: {
131 "environment:thread-1": {
132 "turn-1": false,
133 "turn-2": true,
134 },
135 },
136 });
137 });
138
139 it.each([undefined, 1])("ignores changed-file expansion version %s", (version) => {
140 const parsed = parsePersistedState({
141 ...(version === undefined ? {} : { threadChangedFilesExpansionVersion: version }),
142 threadChangedFilesExpandedById: {
143 "environment:thread-1": {
144 "turn-1": false,
145 },
146 },
147 });
148
149 expect(parsed.threadChangedFilesExpandedById).toEqual({});
150 });
151
152 it("migrates legacy CWD project preferences into local alias keys", () => {
153 const parsed = parsePersistedState({
154 collapsedProjectCwds: ["/repo/b"],
155 expandedProjectCwds: ["/repo/a"],
156 projectOrderCwds: ["/repo/b", "/repo/a"],
157 });
158 const projectAKey = legacyProjectCwdPreferenceKey("/repo/a");
159 const projectBKey = legacyProjectCwdPreferenceKey("/repo/b");
160
161 expect(parsed.projectOrder).toEqual([projectBKey, projectAKey]);
162 expect(resolveProjectExpanded(parsed.projectExpandedById, [projectAKey])).toBe(true);
163 expect(resolveProjectExpanded(parsed.projectExpandedById, [projectBKey])).toBe(false);
164 expect(resolveProjectExpanded(parsed.projectExpandedById, ["unknown"])).toBe(true);
165 });
166});
167
168function createLocalStorageStub(): Storage {
169 const store = new Map<string, string>();
170 return {
171 clear: () => {
172 store.clear();
173 },
174 getItem: (key) => store.get(key) ?? null,
175 key: (index) => [...store.keys()][index] ?? null,
176 get length() {
177 return store.size;
178 },
179 removeItem: (key) => {
180 store.delete(key);
181 },
182 setItem: (key, value) => {
183 store.set(key, value);
184 },
185 };
186}
187
188describe("uiStateStore persistence", () => {
189 let localStorageStub: Storage;
190
191 beforeEach(() => {
192 localStorageStub = createLocalStorageStub();
193 vi.stubGlobal("window", { localStorage: localStorageStub });
194 vi.stubGlobal("localStorage", localStorageStub);
195 });
196
197 afterEach(() => {
198 vi.unstubAllGlobals();
199 });
200
201 it("persists raw UI preferences including thread visit markers", () => {
202 const state = makeUiState({
203 projectExpandedById: {
204 logical: false,
205 },
206 projectOrder: ["physical-b", "physical-a"],
207 threadLastVisitedAtById: {
208 "environment:thread-1": "2026-02-25T12:35:00.000Z",
209 },
210 threadChangedFilesExpandedById: {
211 "environment:thread-1": {
212 "turn-1": false,
213 "turn-2": true,
214 },
215 },
216 defaultAdvertisedEndpointKey: "desktop-core:lan:http",
217 });
218
219 persistState(state);
220
221 const persisted = JSON.parse(
222 localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}",
223 ) as PersistedUiState;
224 expect(persisted).toEqual({
225 projectExpandedById: {
226 logical: false,
227 },
228 projectOrder: ["physical-b", "physical-a"],
229 threadLastVisitedAtById: {
230 "environment:thread-1": "2026-02-25T12:35:00.000Z",
231 },
232 defaultAdvertisedEndpointKey: "desktop-core:lan:http",
233 sidebarProjectScopeKeys: [],
234 sidebarProjectScopeKey: null,
235 threadChangedFilesExpansionVersion: 2,
236 threadChangedFilesExpandedById: {
237 "environment:thread-1": {
238 "turn-1": false,
239 "turn-2": true,
240 },
241 },
242 pullRequestMergeMethod: "merge",
243 });
244 expect(parsePersistedState(persisted)).toEqual({
245 ...state,
246 });
247 });
248
249 it("restores the sidebar project scope across reloads", () => {
250 const keys = ["github.com/pingdotgg/t3code", "github.com/pingdotgg/uploadthing"];
251 persistState(makeUiState({ sidebarProjectScopeKeys: keys }));
252
253 const persisted = JSON.parse(
254 localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}",
255 ) as PersistedUiState;
256
257 expect(parsePersistedState(persisted).sidebarProjectScopeKeys).toEqual(keys);
258 });
259
260 it("keeps a single-project scope readable by older clients", () => {
261 persistState(makeUiState({ sidebarProjectScopeKeys: ["github.com/pingdotgg/t3code"] }));
262
263 const persisted = JSON.parse(
264 localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}",
265 ) as PersistedUiState;
266
267 expect(persisted.sidebarProjectScopeKey).toBe("github.com/pingdotgg/t3code");
268 });
269
270 it("keeps legacy projects omitted from the expanded list collapsed across a save and reload", () => {
271 const projectAKey = legacyProjectCwdPreferenceKey("/repo/a");
272 const projectBKey = legacyProjectCwdPreferenceKey("/repo/b");
273 const migrated = parsePersistedState({ expandedProjectCwds: ["/repo/a"] });
274 expect(resolveProjectExpanded(migrated.projectExpandedById, [projectBKey])).toBe(false);
275
276 persistState({ ...migrated, pullRequestMergeMethod: "squash" });
277 const reloaded = parsePersistedState(
278 JSON.parse(localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}") as PersistedUiState,
279 );
280
281 expect(resolveProjectExpanded(reloaded.projectExpandedById, [projectAKey])).toBe(true);
282 expect(resolveProjectExpanded(reloaded.projectExpandedById, [projectBKey])).toBe(false);
283 });
284
285 it("restores a single-project scope saved before multi-project filtering", () => {
286 expect(
287 parsePersistedState({ sidebarProjectScopeKey: "github.com/pingdotgg/t3code" })
288 .sidebarProjectScopeKeys,
289 ).toEqual(["github.com/pingdotgg/t3code"]);
290 });
291});