1import { Debouncer } from "@tanstack/react-pacer";
2import type { PullRequestMergeMethod } from "@t3tools/contracts";
3import { create } from "zustand";
4import { normalizeProjectPathForComparison } from "./lib/projectPaths";
5
6export const PERSISTED_STATE_KEY = "t3code:ui-state:v1";
7// Version 1 stored card visibility, not folder expansion.
8const THREAD_CHANGED_FILES_EXPANSION_VERSION = 2;
9const LEGACY_PERSISTED_STATE_KEYS = [
10 "t3code:renderer-state:v8",
11 "t3code:renderer-state:v7",
12 "t3code:renderer-state:v6",
13 "t3code:renderer-state:v5",
14 "t3code:renderer-state:v4",
15 "t3code:renderer-state:v3",
16 "codething:renderer-state:v4",
17 "codething:renderer-state:v3",
18 "codething:renderer-state:v2",
19 "codething:renderer-state:v1",
20] as const;
21
22export interface PersistedUiState {
23 projectExpandedById?: Record<string, boolean>;
24 projectOrder?: string[];
25 threadLastVisitedAtById?: Record<string, string>;
26 collapsedProjectCwds?: string[];
27 expandedProjectCwds?: string[];
28 projectOrderCwds?: string[];
29 defaultAdvertisedEndpointKey?: string | null;
30 sidebarProjectScopeKey?: string | null;
31 threadChangedFilesExpansionVersion?: number;
32 threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>;
33 pullRequestMergeMethod?: string;
34}
35
36export interface UiProjectState {
37 projectExpandedById: Record<string, boolean>;
38 projectOrder: string[];
39 // Logical project key the sidebar list is scoped to, or null for "all
40 // projects". Lives here so routes that unmount the sidebar (Settings)
41 // cannot reset the filter.
42 sidebarProjectScopeKey: string | null;
43}
44
45export interface UiThreadState {
46 threadLastVisitedAtById: Record<string, string>;
47 threadChangedFilesExpandedById: Record<string, Record<string, boolean>>;
48}
49
50export interface UiEndpointState {
51 defaultAdvertisedEndpointKey: string | null;
52}
53
54export interface UiPullRequestState {
55 pullRequestMergeMethod: PullRequestMergeMethod;
56}
57
58export interface UiState
59 extends UiProjectState, UiThreadState, UiEndpointState, UiPullRequestState {}
60
61const initialState: UiState = {
62 projectExpandedById: {},
63 projectOrder: [],
64 sidebarProjectScopeKey: null,
65 threadLastVisitedAtById: {},
66 threadChangedFilesExpandedById: {},
67 defaultAdvertisedEndpointKey: null,
68 pullRequestMergeMethod: "merge",
69};
70
71const LEGACY_PROJECT_CWD_PREFERENCE_PREFIX = "legacy-project-cwd:";
72const LEGACY_PROJECT_EXPANSION_DEFAULT_KEY = "legacy-project-expansion-default";
73let legacyKeysCleanedUp = false;
74
75export function legacyProjectCwdPreferenceKey(cwd: string): string {
76 return `${LEGACY_PROJECT_CWD_PREFERENCE_PREFIX}${normalizeProjectPathForComparison(cwd)}`;
77}
78
79function sanitizeStringArray(value: unknown): string[] {
80 if (!Array.isArray(value)) {
81 return [];
82 }
83 return [
84 ...new Set(
85 value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0),
86 ),
87 ];
88}
89
90function sanitizeBooleanRecord(value: unknown): Record<string, boolean> {
91 if (!value || typeof value !== "object") {
92 return {};
93 }
94 return Object.fromEntries(
95 Object.entries(value).filter(
96 (entry): entry is [string, boolean] => entry[0].length > 0 && typeof entry[1] === "boolean",
97 ),
98 );
99}
100
101function sanitizeOptionalKey(value: unknown): string | null {
102 return typeof value === "string" && value.length > 0 ? value : null;
103}
104
105function sanitizeTimestampRecord(value: unknown): Record<string, string> {
106 if (!value || typeof value !== "object") {
107 return {};
108 }
109 return Object.fromEntries(
110 Object.entries(value).filter(
111 (entry): entry is [string, string] =>
112 entry[0].length > 0 &&
113 typeof entry[1] === "string" &&
114 entry[1].length > 0 &&
115 Number.isFinite(Date.parse(entry[1])),
116 ),
117 );
118}
119
120function isPullRequestMergeMethod(value: unknown): value is PullRequestMergeMethod {
121 return value === "merge" || value === "squash" || value === "rebase";
122}
123
124export function parsePersistedState(parsed: PersistedUiState): UiState {
125 const projectExpandedById =
126 parsed.projectExpandedById === undefined
127 ? (() => {
128 const migrated: Record<string, boolean> = {};
129 const collapsedProjectCwds = sanitizeStringArray(parsed.collapsedProjectCwds);
130 const expandedProjectCwds = sanitizeStringArray(parsed.expandedProjectCwds);
131 for (const cwd of collapsedProjectCwds) {
132 migrated[legacyProjectCwdPreferenceKey(cwd)] = false;
133 }
134 for (const cwd of expandedProjectCwds) {
135 migrated[legacyProjectCwdPreferenceKey(cwd)] = true;
136 }
137 if (!Array.isArray(parsed.collapsedProjectCwds) && expandedProjectCwds.length > 0) {
138 migrated[LEGACY_PROJECT_EXPANSION_DEFAULT_KEY] = false;
139 }
140 return migrated;
141 })()
142 : sanitizeBooleanRecord(parsed.projectExpandedById);
143 const projectOrder =
144 parsed.projectOrder === undefined
145 ? sanitizeStringArray(parsed.projectOrderCwds).map(legacyProjectCwdPreferenceKey)
146 : sanitizeStringArray(parsed.projectOrder);
147
148 return {
149 projectExpandedById,
150 projectOrder,
151 threadLastVisitedAtById: sanitizeTimestampRecord(parsed.threadLastVisitedAtById),
152 threadChangedFilesExpandedById:
153 parsed.threadChangedFilesExpansionVersion === THREAD_CHANGED_FILES_EXPANSION_VERSION
154 ? sanitizePersistedThreadChangedFilesExpanded(parsed.threadChangedFilesExpandedById)
155 : {},
156 defaultAdvertisedEndpointKey: sanitizeOptionalKey(parsed.defaultAdvertisedEndpointKey),
157 sidebarProjectScopeKey: sanitizeOptionalKey(parsed.sidebarProjectScopeKey),
158 pullRequestMergeMethod: isPullRequestMergeMethod(parsed.pullRequestMergeMethod)
159 ? parsed.pullRequestMergeMethod
160 : initialState.pullRequestMergeMethod,
161 };
162}
163
164function readPersistedState(): UiState {
165 if (typeof window === "undefined") {
166 return initialState;
167 }
168 try {
169 const raw = window.localStorage.getItem(PERSISTED_STATE_KEY);
170 if (!raw) {
171 for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) {
172 const legacyRaw = window.localStorage.getItem(legacyKey);
173 if (!legacyRaw) {
174 continue;
175 }
176 return parsePersistedState(JSON.parse(legacyRaw) as PersistedUiState);
177 }
178 return initialState;
179 }
180 return parsePersistedState(JSON.parse(raw) as PersistedUiState);
181 } catch {
182 return initialState;
183 }
184}
185
186function sanitizePersistedThreadChangedFilesExpanded(
187 value: PersistedUiState["threadChangedFilesExpandedById"],
188): Record<string, Record<string, boolean>> {
189 if (!value || typeof value !== "object") {
190 return {};
191 }
192
193 const nextState: Record<string, Record<string, boolean>> = {};
194 for (const [threadId, turns] of Object.entries(value)) {
195 if (!threadId || !turns || typeof turns !== "object") {
196 continue;
197 }
198
199 const nextTurns: Record<string, boolean> = {};
200 for (const [turnId, expanded] of Object.entries(turns)) {
201 if (turnId && typeof expanded === "boolean") {
202 nextTurns[turnId] = expanded;
203 }
204 }
205
206 if (Object.keys(nextTurns).length > 0) {
207 nextState[threadId] = nextTurns;
208 }
209 }
210
211 return nextState;
212}
213
214export function persistState(state: UiState): void {
215 if (typeof window === "undefined") {
216 return;
217 }
218 try {
219 const projectExpandedById = Object.fromEntries(
220 Object.entries(state.projectExpandedById).filter(
221 ([key]) => key !== LEGACY_PROJECT_EXPANSION_DEFAULT_KEY,
222 ),
223 );
224 window.localStorage.setItem(
225 PERSISTED_STATE_KEY,
226 JSON.stringify({
227 projectExpandedById,
228 projectOrder: state.projectOrder,
229 threadLastVisitedAtById: state.threadLastVisitedAtById,
230 defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey,
231 sidebarProjectScopeKey: state.sidebarProjectScopeKey,
232 threadChangedFilesExpansionVersion: THREAD_CHANGED_FILES_EXPANSION_VERSION,
233 threadChangedFilesExpandedById: state.threadChangedFilesExpandedById,
234 pullRequestMergeMethod: state.pullRequestMergeMethod,
235 } satisfies PersistedUiState),
236 );
237 if (!legacyKeysCleanedUp) {
238 legacyKeysCleanedUp = true;
239 for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) {
240 window.localStorage.removeItem(legacyKey);
241 }
242 }
243 } catch {
244 // Ignore quota/storage errors to avoid breaking chat UX.
245 }
246}
247
248const debouncedPersistState = new Debouncer(persistState, { wait: 500 });
249
250export function markThreadVisited(state: UiState, threadId: string, visitedAt: string): UiState {
251 const visitedAtMs = Date.parse(visitedAt);
252 if (!Number.isFinite(visitedAtMs)) {
253 return state;
254 }
255 const previousVisitedAt = state.threadLastVisitedAtById[threadId];
256 const previousVisitedAtMs = previousVisitedAt ? Date.parse(previousVisitedAt) : NaN;
257 if (
258 Number.isFinite(previousVisitedAtMs) &&
259 Number.isFinite(visitedAtMs) &&
260 previousVisitedAtMs >= visitedAtMs
261 ) {
262 return state;
263 }
264 return {
265 ...state,
266 threadLastVisitedAtById: {
267 ...state.threadLastVisitedAtById,
268 [threadId]: visitedAt,
269 },
270 };
271}
272
273export function markThreadUnread(
274 state: UiState,
275 threadId: string,
276 latestTurnCompletedAt: string | null | undefined,
277): UiState {
278 if (!latestTurnCompletedAt) {
279 return state;
280 }
281 const latestTurnCompletedAtMs = Date.parse(latestTurnCompletedAt);
282 if (Number.isNaN(latestTurnCompletedAtMs)) {
283 return state;
284 }
285 const unreadVisitedAt = new Date(latestTurnCompletedAtMs - 1).toISOString();
286 if (state.threadLastVisitedAtById[threadId] === unreadVisitedAt) {
287 return state;
288 }
289 return {
290 ...state,
291 threadLastVisitedAtById: {
292 ...state.threadLastVisitedAtById,
293 [threadId]: unreadVisitedAt,
294 },
295 };
296}
297
298export function setThreadChangedFilesExpanded(
299 state: UiState,
300 threadId: string,
301 turnId: string,
302 expanded: boolean,
303): UiState {
304 const currentThreadState = state.threadChangedFilesExpandedById[threadId] ?? {};
305 if (currentThreadState[turnId] === expanded) {
306 return state;
307 }
308
309 return {
310 ...state,
311 threadChangedFilesExpandedById: {
312 ...state.threadChangedFilesExpandedById,
313 [threadId]: {
314 ...currentThreadState,
315 [turnId]: expanded,
316 },
317 },
318 };
319}
320
321export function setDefaultAdvertisedEndpointKey(state: UiState, key: string | null): UiState {
322 const nextKey = key && key.length > 0 ? key : null;
323 if (state.defaultAdvertisedEndpointKey === nextKey) {
324 return state;
325 }
326 return {
327 ...state,
328 defaultAdvertisedEndpointKey: nextKey,
329 };
330}
331
332export function setSidebarProjectScopeKey(state: UiState, projectKey: string | null): UiState {
333 const nextKey = sanitizeOptionalKey(projectKey);
334 if (state.sidebarProjectScopeKey === nextKey) {
335 return state;
336 }
337 return {
338 ...state,
339 sidebarProjectScopeKey: nextKey,
340 };
341}
342
343function setPullRequestMergeMethod(state: UiState, method: PullRequestMergeMethod): UiState {
344 return state.pullRequestMergeMethod === method
345 ? state
346 : { ...state, pullRequestMergeMethod: method };
347}
348
349export function resolveProjectExpanded(
350 projectExpandedById: Readonly<Record<string, boolean>>,
351 preferenceKeys: readonly string[],
352): boolean {
353 for (const key of preferenceKeys) {
354 const expanded = projectExpandedById[key];
355 if (expanded !== undefined) {
356 return expanded;
357 }
358 }
359 return projectExpandedById[LEGACY_PROJECT_EXPANSION_DEFAULT_KEY] ?? true;
360}
361
362export function setProjectExpanded(
363 state: UiState,
364 projectIds: string | readonly string[],
365 expanded: boolean,
366): UiState {
367 const ids = typeof projectIds === "string" ? [projectIds] : projectIds;
368 const nextEntries = ids.filter((projectId) => state.projectExpandedById[projectId] !== expanded);
369 if (nextEntries.length === 0) {
370 return state;
371 }
372 const projectExpandedById = { ...state.projectExpandedById };
373 for (const projectId of nextEntries) {
374 projectExpandedById[projectId] = expanded;
375 }
376 return {
377 ...state,
378 projectExpandedById,
379 };
380}
381
382export function reorderProjects(
383 state: UiState,
384 currentProjectOrder: readonly string[],
385 draggedProjectIds: readonly string[],
386 targetProjectIds: readonly string[],
387): UiState {
388 if (draggedProjectIds.length === 0) {
389 return state;
390 }
391 const draggedSet = new Set(draggedProjectIds);
392 const targetSet = new Set(targetProjectIds);
393 if (draggedProjectIds.every((id) => targetSet.has(id))) {
394 return state;
395 }
396
397 const originalTargetIndex = currentProjectOrder.findIndex((id) => targetSet.has(id));
398 if (originalTargetIndex < 0) {
399 return state;
400 }
401
402 const projectOrder = [...currentProjectOrder];
403
404 const removed: string[] = [];
405 let draggedBeforeTarget = 0;
406 for (let i = projectOrder.length - 1; i >= 0; i--) {
407 if (draggedSet.has(projectOrder[i]!)) {
408 removed.unshift(projectOrder.splice(i, 1)[0]!);
409 if (i < originalTargetIndex) {
410 draggedBeforeTarget++;
411 }
412 }
413 }
414 if (removed.length === 0) {
415 return state;
416 }
417
418 const insertIndex = originalTargetIndex - Math.max(0, draggedBeforeTarget - 1);
419 projectOrder.splice(insertIndex, 0, ...removed);
420 return {
421 ...state,
422 projectOrder,
423 };
424}
425
426interface UiStateStore extends UiState {
427 markThreadVisited: (threadId: string, visitedAt: string) => void;
428 markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void;
429 setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void;
430 setDefaultAdvertisedEndpointKey: (key: string | null) => void;
431 setSidebarProjectScopeKey: (projectKey: string | null) => void;
432 setPullRequestMergeMethod: (method: PullRequestMergeMethod) => void;
433 setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void;
434 reorderProjects: (
435 currentProjectOrder: readonly string[],
436 draggedProjectIds: readonly string[],
437 targetProjectIds: readonly string[],
438 ) => void;
439}
440
441export const useUiStateStore = create<UiStateStore>((set) => ({
442 ...readPersistedState(),
443 markThreadVisited: (threadId, visitedAt) =>
444 set((state) => markThreadVisited(state, threadId, visitedAt)),
445 markThreadUnread: (threadId, latestTurnCompletedAt) =>
446 set((state) => markThreadUnread(state, threadId, latestTurnCompletedAt)),
447 setThreadChangedFilesExpanded: (threadId, turnId, expanded) =>
448 set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)),
449 setDefaultAdvertisedEndpointKey: (key) =>
450 set((state) => setDefaultAdvertisedEndpointKey(state, key)),
451 setSidebarProjectScopeKey: (projectKey) =>
452 set((state) => setSidebarProjectScopeKey(state, projectKey)),
453 setPullRequestMergeMethod: (method) => set((state) => setPullRequestMergeMethod(state, method)),
454 setProjectExpanded: (projectIds, expanded) =>
455 set((state) => setProjectExpanded(state, projectIds, expanded)),
456 reorderProjects: (currentProjectOrder, draggedProjectIds, targetProjectIds) =>
457 set((state) =>
458 reorderProjects(state, currentProjectOrder, draggedProjectIds, targetProjectIds),
459 ),
460}));
461
462useUiStateStore.subscribe((state) => debouncedPersistState.maybeExecute(state));
463
464if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
465 window.addEventListener("beforeunload", () => {
466 debouncedPersistState.flush();
467 });
468}