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<string, Record<string, boolean>>;} export interface UiProjectState { projectExpandedById: Record<string, boolean>; projectOrder: string[];} export interface UiThreadState { threadLastVisitedAtById: Record<string, string>; threadChangedFilesExpandedById: Record<string, Record<string, boolean>>;} 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<string>();const persistedExpandedProjectCwds = new Set<string>();const persistedProjectOrderCwds: string[] = [];// 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<string, string>();const currentProjectCwdsByLogicalKey = new Map<string, string[]>();const currentLogicalKeyByPhysicalKey = 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; } 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<string, Record<string, boolean>> { if (!value || typeof value !== "object") { return {}; } const nextState: Record<string, Record<string, boolean>> = {}; for (const [threadId, turns] of Object.entries(value)) { if (!threadId || !turns || typeof turns !== "object") { continue; } const nextTurns: Record<string, boolean> = {}; 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; 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 && !persistedProjectOrderCwds.includes(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<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]) );} function nestedBooleanRecordsEqual( left: Record<string, Record<string, boolean>>, right: Record<string, Record<string, boolean>>,): 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(); for (const project of projects) { const cwds = currentProjectCwdsByLogicalKey.get(project.logicalKey); if (cwds) { if (!cwds.includes(project.cwd)) { cwds.push(project.cwd); } } else { currentProjectCwdsByLogicalKey.set(project.logicalKey, [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<string, Set<string>>(); 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<string, boolean> = {}; 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<string>(); 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,Lines 1–500 of 667. Load more
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<string, Record<string, boolean>>; } export interface UiProjectState { projectExpandedById: Record<string, boolean>; projectOrder: string[]; } export interface UiThreadState { threadLastVisitedAtById: Record<string, string>; threadChangedFilesExpandedById: Record<string, Record<string, boolean>>; } 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<string>(); const persistedExpandedProjectCwds = new Set<string>(); const persistedProjectOrderCwds: string[] = []; // 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<string, string>(); const currentProjectCwdsByLogicalKey = new Map<string, string[]>(); const currentLogicalKeyByPhysicalKey = 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; } 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<string, Record<string, boolean>> { if (!value || typeof value !== "object") { return {}; } const nextState: Record<string, Record<string, boolean>> = {}; for (const [threadId, turns] of Object.entries(value)) { if (!threadId || !turns || typeof turns !== "object") { continue; } const nextTurns: Record<string, boolean> = {}; 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; 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 && !persistedProjectOrderCwds.includes(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<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]) ); } function nestedBooleanRecordsEqual( left: Record<string, Record<string, boolean>>, right: Record<string, Record<string, boolean>>, ): 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(); for (const project of projects) { const cwds = currentProjectCwdsByLogicalKey.get(project.logicalKey); if (cwds) { if (!cwds.includes(project.cwd)) { cwds.push(project.cwd); } } else { currentProjectCwdsByLogicalKey.set(project.logicalKey, [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<string, Set<string>>(); 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<string, boolean> = {}; 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<string>(); 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,