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[]; 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 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: {}, threadChangedFilesExpandedById: {},}; 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; } const parsed = JSON.parse(raw) as PersistedUiState; hydratePersistedProjectState(parsed); return { ...initialState, 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;} 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] : []; }); 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({ expandedProjectCwds, projectOrderCwds, 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 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; } } 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 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; }Lines 1–500 of 565. Load more
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[]; 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 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: {}, threadChangedFilesExpandedById: {}, }; 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; } const parsed = JSON.parse(raw) as PersistedUiState; hydratePersistedProjectState(parsed); return { ...initialState, 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; } 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] : []; }); 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({ expandedProjectCwds, projectOrderCwds, 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 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; } } 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 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; }