import { Debouncer } from "@tanstack/react-pacer";import { create } from "zustand"; 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; interface PersistedUiState { expandedProjectCwds?: string[]; projectOrderCwds?: string[];} export interface UiProjectState { projectExpandedById: Record<string, boolean>; projectOrder: string[];} export interface UiThreadState { threadLastVisitedAtById: Record<string, string>;} export interface UiState extends UiProjectState, UiThreadState {} export interface SyncProjectInput { key: string; cwd: string;} export interface SyncThreadInput { key: string; seedVisitedAt?: string | undefined;} const initialState: UiState = { projectExpandedById: {}, projectOrder: [], threadLastVisitedAtById: {},}; const persistedExpandedProjectCwds = new Set<string>();const persistedProjectOrderCwds: string[] = [];const currentProjectCwdById = new Map<string, string>();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; } hydratePersistedProjectState(JSON.parse(raw) as PersistedUiState); return initialState; } catch { return initialState; }} function hydratePersistedProjectState(parsed: PersistedUiState): void { persistedExpandedProjectCwds.clear(); persistedProjectOrderCwds.length = 0; 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 && !persistedProjectOrderCwds.includes(cwd)) { persistedProjectOrderCwds.push(cwd); } }} function persistState(state: UiState): void { if (typeof window === "undefined") { return; } try { const expandedProjectCwds = Object.entries(state.projectExpandedById) .filter(([, expanded]) => expanded) .flatMap(([projectId]) => { const cwd = currentProjectCwdById.get(projectId); return cwd ? [cwd] : []; }); const projectOrderCwds = state.projectOrder.flatMap((projectId) => { const cwd = currentProjectCwdById.get(projectId); return cwd ? [cwd] : []; }); window.localStorage.setItem( PERSISTED_STATE_KEY, JSON.stringify({ expandedProjectCwds, projectOrderCwds, } 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<T>(left: Record<string, T>, right: Record<string, T>): 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]) );} export function syncProjects(state: UiState, projects: readonly SyncProjectInput[]): UiState { const previousProjectCwdById = new Map(currentProjectCwdById); const previousProjectIdByCwd = new Map( [...previousProjectCwdById.entries()].map(([projectId, cwd]) => [cwd, projectId] as const), ); currentProjectCwdById.clear(); for (const project of projects) { currentProjectCwdById.set(project.key, project.cwd); } const cwdMappingChanged = previousProjectCwdById.size !== currentProjectCwdById.size || projects.some((project) => previousProjectCwdById.get(project.key) !== project.cwd); const nextExpandedById: Record<string, boolean> = {}; const previousExpandedById = state.projectExpandedById; const persistedOrderByCwd = new Map( persistedProjectOrderCwds.map((cwd, index) => [cwd, index] as const), ); const mappedProjects = projects.map((project, index) => { const previousProjectIdForCwd = previousProjectIdByCwd.get(project.cwd); const expanded = previousExpandedById[project.key] ?? (previousProjectIdForCwd ? previousExpandedById[previousProjectIdForCwd] : undefined) ?? (persistedExpandedProjectCwds.size > 0 ? persistedExpandedProjectCwds.has(project.cwd) : true); nextExpandedById[project.key] = expanded; return { id: project.key, cwd: project.cwd, incomingIndex: index, }; }); const nextProjectOrder = state.projectOrder.length > 0 ? (() => { const nextProjectIdByCwd = new Map( mappedProjects.map((project) => [project.cwd, project.id] as const), ); const usedProjectIds = new Set<string>(); const orderedProjectIds: string[] = []; for (const projectId of state.projectOrder) { const matchedProjectId = (projectId in nextExpandedById ? 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; } } if (recordsEqual(state.threadLastVisitedAtById, nextThreadLastVisitedAtById)) { return state; } return { ...state, threadLastVisitedAtById: nextThreadLastVisitedAtById, };} 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 { if (!(threadId in state.threadLastVisitedAtById)) { return state; } const nextThreadLastVisitedAtById = { ...state.threadLastVisitedAtById }; delete nextThreadLastVisitedAtById[threadId]; return { ...state, threadLastVisitedAtById: nextThreadLastVisitedAtById, };} 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, draggedProjectId: string, targetProjectId: string,): UiState { if (draggedProjectId === targetProjectId) { return state; } const draggedIndex = state.projectOrder.findIndex((projectId) => projectId === draggedProjectId); const targetIndex = state.projectOrder.findIndex((projectId) => projectId === targetProjectId); if (draggedIndex < 0 || targetIndex < 0) { return state; } const projectOrder = [...state.projectOrder]; const [draggedProject] = projectOrder.splice(draggedIndex, 1); if (!draggedProject) { return state; } projectOrder.splice(targetIndex, 0, draggedProject); 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; toggleProject: (projectId: string) => void; setProjectExpanded: (projectId: string, expanded: boolean) => void; reorderProjects: (draggedProjectId: string, targetProjectId: string) => void;} export const useUiStateStore = create<UiStateStore>((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)), toggleProject: (projectId) => set((state) => toggleProject(state, projectId)), setProjectExpanded: (projectId, expanded) => set((state) => setProjectExpanded(state, projectId, expanded)), reorderProjects: (draggedProjectId, targetProjectId) => set((state) => reorderProjects(state, draggedProjectId, targetProjectId)),})); useUiStateStore.subscribe((state) => debouncedPersistState.maybeExecute(state)); if (typeof window !== "undefined" && typeof window.addEventListener === "function") { window.addEventListener("beforeunload", () => { debouncedPersistState.flush(); });}
import { Debouncer } from "@tanstack/react-pacer";
import { create } from "zustand";
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;
interface PersistedUiState {
expandedProjectCwds?: string[];
projectOrderCwds?: string[];
}
export interface UiProjectState {
projectExpandedById: Record<string, boolean>;
projectOrder: string[];
}
export interface UiThreadState {
threadLastVisitedAtById: Record<string, string>;
}
export interface UiState extends UiProjectState, UiThreadState {}
export interface SyncProjectInput {
key: string;
cwd: string;
}
export interface SyncThreadInput {
key: string;
seedVisitedAt?: string | undefined;
}
const initialState: UiState = {
projectExpandedById: {},
projectOrder: [],
threadLastVisitedAtById: {},
};
const persistedExpandedProjectCwds = new Set<string>();
const persistedProjectOrderCwds: string[] = [];
const currentProjectCwdById = new Map<string, string>();
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;
}
hydratePersistedProjectState(JSON.parse(raw) as PersistedUiState);
return initialState;
} catch {
return initialState;
}
}
function hydratePersistedProjectState(parsed: PersistedUiState): void {
persistedExpandedProjectCwds.clear();
persistedProjectOrderCwds.length = 0;
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 && !persistedProjectOrderCwds.includes(cwd)) {
persistedProjectOrderCwds.push(cwd);
}
}
}
function persistState(state: UiState): void {
if (typeof window === "undefined") {
return;
}
try {
const expandedProjectCwds = Object.entries(state.projectExpandedById)
.filter(([, expanded]) => expanded)
.flatMap(([projectId]) => {
const cwd = currentProjectCwdById.get(projectId);
return cwd ? [cwd] : [];
});
const projectOrderCwds = state.projectOrder.flatMap((projectId) => {
const cwd = currentProjectCwdById.get(projectId);
return cwd ? [cwd] : [];
});
window.localStorage.setItem(
PERSISTED_STATE_KEY,
JSON.stringify({
expandedProjectCwds,
projectOrderCwds,
} 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<T>(left: Record<string, T>, right: Record<string, T>): 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])
);
}
export function syncProjects(state: UiState, projects: readonly SyncProjectInput[]): UiState {
const previousProjectCwdById = new Map(currentProjectCwdById);
const previousProjectIdByCwd = new Map(
[...previousProjectCwdById.entries()].map(([projectId, cwd]) => [cwd, projectId] as const),
);
currentProjectCwdById.clear();
for (const project of projects) {
currentProjectCwdById.set(project.key, project.cwd);
}
const cwdMappingChanged =
previousProjectCwdById.size !== currentProjectCwdById.size ||
projects.some((project) => previousProjectCwdById.get(project.key) !== project.cwd);
const nextExpandedById: Record<string, boolean> = {};
const previousExpandedById = state.projectExpandedById;
const persistedOrderByCwd = new Map(
persistedProjectOrderCwds.map((cwd, index) => [cwd, index] as const),
);
const mappedProjects = projects.map((project, index) => {
const previousProjectIdForCwd = previousProjectIdByCwd.get(project.cwd);
const expanded =
previousExpandedById[project.key] ??
(previousProjectIdForCwd ? previousExpandedById[previousProjectIdForCwd] : undefined) ??
(persistedExpandedProjectCwds.size > 0
? persistedExpandedProjectCwds.has(project.cwd)
: true);
nextExpandedById[project.key] = expanded;
return {
id: project.key,
cwd: project.cwd,
incomingIndex: index,
};
});
const nextProjectOrder =
state.projectOrder.length > 0
? (() => {
const nextProjectIdByCwd = new Map(
mappedProjects.map((project) => [project.cwd, project.id] as const),
);
const usedProjectIds = new Set<string>();
const orderedProjectIds: string[] = [];
for (const projectId of state.projectOrder) {
const matchedProjectId =
(projectId in nextExpandedById ? 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;
}
}
if (recordsEqual(state.threadLastVisitedAtById, nextThreadLastVisitedAtById)) {
return state;
}
return {
...state,
threadLastVisitedAtById: nextThreadLastVisitedAtById,
};
}
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 {
if (!(threadId in state.threadLastVisitedAtById)) {
return state;
}
const nextThreadLastVisitedAtById = { ...state.threadLastVisitedAtById };
delete nextThreadLastVisitedAtById[threadId];
return {
...state,
threadLastVisitedAtById: nextThreadLastVisitedAtById,
};
}
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,
draggedProjectId: string,
targetProjectId: string,
): UiState {
if (draggedProjectId === targetProjectId) {
return state;
}
const draggedIndex = state.projectOrder.findIndex((projectId) => projectId === draggedProjectId);
const targetIndex = state.projectOrder.findIndex((projectId) => projectId === targetProjectId);
if (draggedIndex < 0 || targetIndex < 0) {
return state;
}
const projectOrder = [...state.projectOrder];
const [draggedProject] = projectOrder.splice(draggedIndex, 1);
if (!draggedProject) {
return state;
}
projectOrder.splice(targetIndex, 0, draggedProject);
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;
toggleProject: (projectId: string) => void;
setProjectExpanded: (projectId: string, expanded: boolean) => void;
reorderProjects: (draggedProjectId: string, targetProjectId: string) => void;
}
export const useUiStateStore = create<UiStateStore>((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)),
toggleProject: (projectId) => set((state) => toggleProject(state, projectId)),
setProjectExpanded: (projectId, expanded) =>
set((state) => setProjectExpanded(state, projectId, expanded)),
reorderProjects: (draggedProjectId, targetProjectId) =>
set((state) => reorderProjects(state, draggedProjectId, targetProjectId)),
}));
useUiStateStore.subscribe((state) => debouncedPersistState.maybeExecute(state));
if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
window.addEventListener("beforeunload", () => {
debouncedPersistState.flush();
});
}