import { Debouncer } from "@tanstack/react-pacer"; import { create } from "zustand"; export const PERSISTED_STATE_KEY = "t3code:ui-state:v1"; const LEGACY_PERSISTED_STATE_KEYS = [ "t3code:renderer-state:v8", "t3code:renderer-state:v7", "t3code:renderer-state:v6", "t3code:renderer-state:v5", "t3code:renderer-state:v4", "t3code:renderer-state:v3", "codething:renderer-state:v4", "codething:renderer-state:v3", "codething:renderer-state:v2", "codething:renderer-state:v1", ] as const; export interface PersistedUiState { collapsedProjectCwds?: string[]; expandedProjectCwds?: string[]; projectOrderCwds?: string[]; defaultAdvertisedEndpointKey?: string | null; threadChangedFilesExpandedById?: Record>; } export interface UiProjectState { projectExpandedById: Record; projectOrder: string[]; } export interface UiThreadState { threadLastVisitedAtById: Record; threadChangedFilesExpandedById: Record>; } export interface UiEndpointState { defaultAdvertisedEndpointKey: string | null; } export interface UiState extends UiProjectState, UiThreadState, UiEndpointState {} export interface SyncProjectInput { /** Physical project key (env + cwd). Used for manual sort order. */ key: string; /** Logical group key. Used for expand/collapse state. */ logicalKey: string; cwd: string; } export interface SyncThreadInput { key: string; seedVisitedAt?: string | undefined; } const initialState: UiState = { projectExpandedById: {}, projectOrder: [], threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, }; const persistedCollapsedProjectCwds = new Set(); const persistedExpandedProjectCwds = new Set(); const persistedProjectOrderCwds: string[] = []; const persistedProjectOrderCwdSet = new Set(); // Pre-fix persisted shape only listed expanded cwds, so anything not listed // was treated as collapsed. Track whether the loaded blob carried the new // `collapsedProjectCwds` field so we can preserve that legacy semantic for // one session after upgrade, until persistState rewrites in the new shape. let persistedProjectStateUsesLegacyShape = false; const currentProjectCwdById = new Map(); const currentProjectCwdsByLogicalKey = new Map(); const currentLogicalKeyByPhysicalKey = new Map(); let legacyKeysCleanedUp = false; function readPersistedState(): UiState { if (typeof window === "undefined") { return initialState; } try { const raw = window.localStorage.getItem(PERSISTED_STATE_KEY); if (!raw) { for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { const legacyRaw = window.localStorage.getItem(legacyKey); if (!legacyRaw) { continue; } hydratePersistedProjectState(JSON.parse(legacyRaw) as PersistedUiState); return initialState; } return initialState; } const parsed = JSON.parse(raw) as PersistedUiState; hydratePersistedProjectState(parsed); return { ...initialState, defaultAdvertisedEndpointKey: typeof parsed.defaultAdvertisedEndpointKey === "string" && parsed.defaultAdvertisedEndpointKey.length > 0 ? parsed.defaultAdvertisedEndpointKey : null, threadChangedFilesExpandedById: sanitizePersistedThreadChangedFilesExpanded( parsed.threadChangedFilesExpandedById, ), }; } catch { return initialState; } } function sanitizePersistedThreadChangedFilesExpanded( value: PersistedUiState["threadChangedFilesExpandedById"], ): Record> { if (!value || typeof value !== "object") { return {}; } const nextState: Record> = {}; for (const [threadId, turns] of Object.entries(value)) { if (!threadId || !turns || typeof turns !== "object") { continue; } const nextTurns: Record = {}; for (const [turnId, expanded] of Object.entries(turns)) { if (turnId && typeof expanded === "boolean" && expanded === false) { nextTurns[turnId] = false; } } if (Object.keys(nextTurns).length > 0) { nextState[threadId] = nextTurns; } } return nextState; } export function hydratePersistedProjectState(parsed: PersistedUiState): void { persistedCollapsedProjectCwds.clear(); persistedExpandedProjectCwds.clear(); persistedProjectOrderCwds.length = 0; persistedProjectOrderCwdSet.clear(); persistedProjectStateUsesLegacyShape = !Array.isArray(parsed.collapsedProjectCwds); for (const cwd of parsed.collapsedProjectCwds ?? []) { if (typeof cwd === "string" && cwd.length > 0) { persistedCollapsedProjectCwds.add(cwd); } } for (const cwd of parsed.expandedProjectCwds ?? []) { if (typeof cwd === "string" && cwd.length > 0) { persistedExpandedProjectCwds.add(cwd); } } for (const cwd of parsed.projectOrderCwds ?? []) { if (typeof cwd === "string" && cwd.length > 0 && !persistedProjectOrderCwdSet.has(cwd)) { persistedProjectOrderCwdSet.add(cwd); persistedProjectOrderCwds.push(cwd); } } } export function persistState(state: UiState): void { if (typeof window === "undefined") { return; } try { // Persist collapsed cwds explicitly so an empty/missing field unambiguously // means "first install" rather than "user collapsed everything"; without // this, the syncProjects fallback would re-expand all rows on next launch. const collapsedProjectCwds = Object.entries(state.projectExpandedById) .filter(([, expanded]) => !expanded) .flatMap(([logicalKey]) => currentProjectCwdsByLogicalKey.get(logicalKey) ?? []); const expandedProjectCwds = Object.entries(state.projectExpandedById) .filter(([, expanded]) => expanded) .flatMap(([logicalKey]) => currentProjectCwdsByLogicalKey.get(logicalKey) ?? []); const projectOrderCwds = state.projectOrder.flatMap((projectId) => { const cwd = currentProjectCwdById.get(projectId); return cwd ? [cwd] : []; }); const threadChangedFilesExpandedById = Object.fromEntries( Object.entries(state.threadChangedFilesExpandedById).flatMap(([threadId, turns]) => { const nextTurns = Object.fromEntries( Object.entries(turns).filter(([, expanded]) => expanded === false), ); return Object.keys(nextTurns).length > 0 ? [[threadId, nextTurns]] : []; }), ); window.localStorage.setItem( PERSISTED_STATE_KEY, JSON.stringify({ collapsedProjectCwds, expandedProjectCwds, projectOrderCwds, defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, threadChangedFilesExpandedById, } satisfies PersistedUiState), ); if (!legacyKeysCleanedUp) { legacyKeysCleanedUp = true; for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { window.localStorage.removeItem(legacyKey); } } } catch { // Ignore quota/storage errors to avoid breaking chat UX. } } const debouncedPersistState = new Debouncer(persistState, { wait: 500 }); function recordsEqual(left: Record, right: Record): boolean { const leftEntries = Object.entries(left); const rightEntries = Object.entries(right); if (leftEntries.length !== rightEntries.length) { return false; } for (const [key, value] of leftEntries) { if (right[key] !== value) { return false; } } return true; } function projectOrdersEqual(left: readonly string[], right: readonly string[]): boolean { return ( left.length === right.length && left.every((projectId, index) => projectId === right[index]) ); } function nestedBooleanRecordsEqual( left: Record>, right: Record>, ): boolean { const leftEntries = Object.entries(left); const rightEntries = Object.entries(right); if (leftEntries.length !== rightEntries.length) { return false; } for (const [key, value] of leftEntries) { if (!(key in right) || !recordsEqual(value, right[key]!)) { return false; } } return true; } export function syncProjects(state: UiState, projects: readonly SyncProjectInput[]): UiState { const previousProjectCwdById = new Map(currentProjectCwdById); const previousLogicalKeyByPhysicalKey = new Map(currentLogicalKeyByPhysicalKey); currentProjectCwdById.clear(); currentLogicalKeyByPhysicalKey.clear(); for (const project of projects) { currentProjectCwdById.set(project.key, project.cwd); currentLogicalKeyByPhysicalKey.set(project.key, project.logicalKey); } currentProjectCwdsByLogicalKey.clear(); const currentProjectCwdSetsByLogicalKey = new Map>(); for (const project of projects) { const cwds = currentProjectCwdsByLogicalKey.get(project.logicalKey); if (cwds) { let cwdSet = currentProjectCwdSetsByLogicalKey.get(project.logicalKey); if (!cwdSet) { cwdSet = new Set(cwds); currentProjectCwdSetsByLogicalKey.set(project.logicalKey, cwdSet); } if (!cwdSet.has(project.cwd)) { cwdSet.add(project.cwd); cwds.push(project.cwd); } } else { currentProjectCwdsByLogicalKey.set(project.logicalKey, [project.cwd]); currentProjectCwdSetsByLogicalKey.set(project.logicalKey, new Set([project.cwd])); } } // Build reverse map: for each new logical key, which previous logical keys // did its member projects live under? Lets us preserve expand state when a // project's logical key changes (e.g. late-arriving repo metadata flips the // group identity). const previousLogicalKeysByNewLogicalKey = new Map>(); for (const project of projects) { const previousLogicalKey = previousLogicalKeyByPhysicalKey.get(project.key); if (!previousLogicalKey || previousLogicalKey === project.logicalKey) { continue; } const set = previousLogicalKeysByNewLogicalKey.get(project.logicalKey); if (set) { set.add(previousLogicalKey); } else { previousLogicalKeysByNewLogicalKey.set(project.logicalKey, new Set([previousLogicalKey])); } } const cwdMappingChanged = previousProjectCwdById.size !== currentProjectCwdById.size || projects.some((project) => previousProjectCwdById.get(project.key) !== project.cwd); const nextExpandedById: Record = {}; const previousExpandedById = state.projectExpandedById; const persistedOrderByCwd = new Map( persistedProjectOrderCwds.map((cwd, index) => [cwd, index] as const), ); const mappedProjects = projects.map((project, index) => { if (!(project.logicalKey in nextExpandedById)) { const groupCwds = currentProjectCwdsByLogicalKey.get(project.logicalKey) ?? [project.cwd]; const fallbackFromPreviousLogicalKey = (() => { const previousKeys = previousLogicalKeysByNewLogicalKey.get(project.logicalKey); if (!previousKeys) { return undefined; } for (const previousKey of previousKeys) { if (previousKey in previousExpandedById) { return previousExpandedById[previousKey]; } } return undefined; })(); const fallbackFromPersistedShape = (() => { if (groupCwds.some((cwd) => persistedExpandedProjectCwds.has(cwd))) { return true; } if (groupCwds.some((cwd) => persistedCollapsedProjectCwds.has(cwd))) { return false; } if (persistedProjectStateUsesLegacyShape && persistedExpandedProjectCwds.size > 0) { return false; } return true; })(); const expanded = previousExpandedById[project.logicalKey] ?? fallbackFromPreviousLogicalKey ?? fallbackFromPersistedShape; nextExpandedById[project.logicalKey] = expanded; } return { id: project.key, cwd: project.cwd, incomingIndex: index, }; }); const nextProjectOrder = state.projectOrder.length > 0 ? (() => { const currentProjectIds = new Set(mappedProjects.map((project) => project.id)); const nextProjectIdByCwd = new Map( mappedProjects.map((project) => [project.cwd, project.id] as const), ); const usedProjectIds = new Set(); const orderedProjectIds: string[] = []; for (const projectId of state.projectOrder) { const matchedProjectId = (currentProjectIds.has(projectId) ? projectId : undefined) ?? (() => { const previousCwd = previousProjectCwdById.get(projectId); return previousCwd ? nextProjectIdByCwd.get(previousCwd) : undefined; })(); if (!matchedProjectId || usedProjectIds.has(matchedProjectId)) { continue; } usedProjectIds.add(matchedProjectId); orderedProjectIds.push(matchedProjectId); } for (const project of mappedProjects) { if (usedProjectIds.has(project.id)) { continue; } orderedProjectIds.push(project.id); } return orderedProjectIds; })() : mappedProjects .map((project) => ({ id: project.id, incomingIndex: project.incomingIndex, orderIndex: persistedOrderByCwd.get(project.cwd) ?? persistedProjectOrderCwds.length + project.incomingIndex, })) .toSorted((left, right) => { const byOrder = left.orderIndex - right.orderIndex; if (byOrder !== 0) { return byOrder; } return left.incomingIndex - right.incomingIndex; }) .map((project) => project.id); if ( recordsEqual(state.projectExpandedById, nextExpandedById) && projectOrdersEqual(state.projectOrder, nextProjectOrder) && !cwdMappingChanged ) { return state; } return { ...state, projectExpandedById: nextExpandedById, projectOrder: nextProjectOrder, }; } export function syncThreads(state: UiState, threads: readonly SyncThreadInput[]): UiState { const retainedThreadIds = new Set(threads.map((thread) => thread.key)); const nextThreadLastVisitedAtById = Object.fromEntries( Object.entries(state.threadLastVisitedAtById).filter(([threadId]) => retainedThreadIds.has(threadId), ), ); for (const thread of threads) { if ( nextThreadLastVisitedAtById[thread.key] === undefined && thread.seedVisitedAt !== undefined && thread.seedVisitedAt.length > 0 ) { nextThreadLastVisitedAtById[thread.key] = thread.seedVisitedAt; } } const nextThreadChangedFilesExpandedById = Object.fromEntries( Object.entries(state.threadChangedFilesExpandedById).filter(([threadId]) => retainedThreadIds.has(threadId), ), ); if ( recordsEqual(state.threadLastVisitedAtById, nextThreadLastVisitedAtById) && nestedBooleanRecordsEqual( state.threadChangedFilesExpandedById, nextThreadChangedFilesExpandedById, ) ) { return state; } return { ...state, threadLastVisitedAtById: nextThreadLastVisitedAtById, threadChangedFilesExpandedById: nextThreadChangedFilesExpandedById, }; } export function markThreadVisited(state: UiState, threadId: string, visitedAt?: string): UiState { const at = visitedAt ?? new Date().toISOString(); const visitedAtMs = Date.parse(at); const previousVisitedAt = state.threadLastVisitedAtById[threadId]; const previousVisitedAtMs = previousVisitedAt ? Date.parse(previousVisitedAt) : NaN; if ( Number.isFinite(previousVisitedAtMs) && Number.isFinite(visitedAtMs) && previousVisitedAtMs >= visitedAtMs ) { return state; } return { ...state, threadLastVisitedAtById: { ...state.threadLastVisitedAtById, [threadId]: at, }, }; } export function markThreadUnread( state: UiState, threadId: string, latestTurnCompletedAt: string | null | undefined, ): UiState { if (!latestTurnCompletedAt) { return state; } const latestTurnCompletedAtMs = Date.parse(latestTurnCompletedAt); if (Number.isNaN(latestTurnCompletedAtMs)) { return state; } const unreadVisitedAt = new Date(latestTurnCompletedAtMs - 1).toISOString(); if (state.threadLastVisitedAtById[threadId] === unreadVisitedAt) { return state; } return { ...state, threadLastVisitedAtById: { ...state.threadLastVisitedAtById, [threadId]: unreadVisitedAt, }, }; } export function clearThreadUi(state: UiState, threadId: string): UiState { const hasVisitedState = threadId in state.threadLastVisitedAtById; const hasChangedFilesState = threadId in state.threadChangedFilesExpandedById; if (!hasVisitedState && !hasChangedFilesState) { return state; } const nextThreadLastVisitedAtById = { ...state.threadLastVisitedAtById }; const nextThreadChangedFilesExpandedById = { ...state.threadChangedFilesExpandedById }; delete nextThreadLastVisitedAtById[threadId]; delete nextThreadChangedFilesExpandedById[threadId]; return { ...state, threadLastVisitedAtById: nextThreadLastVisitedAtById, threadChangedFilesExpandedById: nextThreadChangedFilesExpandedById, }; } export function setThreadChangedFilesExpanded( state: UiState, threadId: string, turnId: string, expanded: boolean, ): UiState { const currentThreadState = state.threadChangedFilesExpandedById[threadId] ?? {}; const currentExpanded = currentThreadState[turnId] ?? true; if (currentExpanded === expanded) { return state; } if (expanded) { if (!(turnId in currentThreadState)) { return state; } const nextThreadState = { ...currentThreadState }; delete nextThreadState[turnId]; if (Object.keys(nextThreadState).length === 0) { const nextState = { ...state.threadChangedFilesExpandedById }; delete nextState[threadId]; return { ...state, threadChangedFilesExpandedById: nextState, }; } return { ...state, threadChangedFilesExpandedById: { ...state.threadChangedFilesExpandedById, [threadId]: nextThreadState, }, }; } return { ...state, threadChangedFilesExpandedById: { ...state.threadChangedFilesExpandedById, [threadId]: { ...currentThreadState, [turnId]: false, }, }, }; } export function setDefaultAdvertisedEndpointKey(state: UiState, key: string | null): UiState { const nextKey = key && key.length > 0 ? key : null; if (state.defaultAdvertisedEndpointKey === nextKey) { return state; } return { ...state, defaultAdvertisedEndpointKey: nextKey, }; } export function toggleProject(state: UiState, projectId: string): UiState { const expanded = state.projectExpandedById[projectId] ?? true; return { ...state, projectExpandedById: { ...state.projectExpandedById, [projectId]: !expanded, }, }; } export function setProjectExpanded(state: UiState, projectId: string, expanded: boolean): UiState { if ((state.projectExpandedById[projectId] ?? true) === expanded) { return state; } return { ...state, projectExpandedById: { ...state.projectExpandedById, [projectId]: expanded, }, }; } export function reorderProjects( state: UiState, draggedProjectIds: readonly string[], targetProjectIds: readonly string[], ): UiState { if (draggedProjectIds.length === 0) { return state; } const draggedSet = new Set(draggedProjectIds); const targetSet = new Set(targetProjectIds); if (draggedProjectIds.every((id) => targetSet.has(id))) { return state; } const originalTargetIndex = state.projectOrder.findIndex((id) => targetSet.has(id)); if (originalTargetIndex < 0) { return state; } const projectOrder = [...state.projectOrder]; const removed: string[] = []; let draggedBeforeTarget = 0; for (let i = projectOrder.length - 1; i >= 0; i--) { if (draggedSet.has(projectOrder[i]!)) { removed.unshift(projectOrder.splice(i, 1)[0]!); if (i < originalTargetIndex) { draggedBeforeTarget++; } } } if (removed.length === 0) { return state; } const insertIndex = originalTargetIndex - Math.max(0, draggedBeforeTarget - 1); projectOrder.splice(insertIndex, 0, ...removed); return { ...state, projectOrder, }; } interface UiStateStore extends UiState { syncProjects: (projects: readonly SyncProjectInput[]) => void; syncThreads: (threads: readonly SyncThreadInput[]) => void; markThreadVisited: (threadId: string, visitedAt?: string) => void; markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; clearThreadUi: (threadId: string) => void; setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; setDefaultAdvertisedEndpointKey: (key: string | null) => void; toggleProject: (projectId: string) => void; setProjectExpanded: (projectId: string, expanded: boolean) => void; reorderProjects: ( draggedProjectIds: readonly string[], targetProjectIds: readonly string[], ) => void; } export const useUiStateStore = create((set) => ({ ...readPersistedState(), syncProjects: (projects) => set((state) => syncProjects(state, projects)), syncThreads: (threads) => set((state) => syncThreads(state, threads)), markThreadVisited: (threadId, visitedAt) => set((state) => markThreadVisited(state, threadId, visitedAt)), markThreadUnread: (threadId, latestTurnCompletedAt) => set((state) => markThreadUnread(state, threadId, latestTurnCompletedAt)), clearThreadUi: (threadId) => set((state) => clearThreadUi(state, threadId)), setThreadChangedFilesExpanded: (threadId, turnId, expanded) => set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)), setDefaultAdvertisedEndpointKey: (key) => set((state) => setDefaultAdvertisedEndpointKey(state, key)), toggleProject: (projectId) => set((state) => toggleProject(state, projectId)), setProjectExpanded: (projectId, expanded) => set((state) => setProjectExpanded(state, projectId, expanded)), reorderProjects: (draggedProjectIds, targetProjectIds) => set((state) => reorderProjects(state, draggedProjectIds, targetProjectIds)), })); useUiStateStore.subscribe((state) => debouncedPersistState.maybeExecute(state)); if (typeof window !== "undefined" && typeof window.addEventListener === "function") { window.addEventListener("beforeunload", () => { debouncedPersistState.flush(); }); }