From 76ba94dabc2527336eb3a20cea405a236049dd56 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Sun, 19 Apr 2026 23:24:03 -0400 Subject: [PATCH 1/5] fix(web): restore manual sort drag and keep per-group expand state #2055 silently regressed two sidebar behaviors by changing how projects are keyed in `uiStateStore`: - Manual-sort drag reorder snapped projects back to their original position. Writers populated `projectOrder` with physical keys (env + cwd) but readers matched items by scoped keys (env + project id), so `preferredIds` never matched and manual order was discarded. - Expand/collapse state was wiped whenever projects were grouped (grouping != `separate`), because `syncProjects` seeded `projectExpandedById` by physical key while the UI read and wrote it by logical (group) key. Route readers and writers through a single source of truth: - `getProjectOrderKey` centralizes the physical-key derivation used by `projectOrder` so future callers cannot silently drift again. - `SyncProjectInput` now carries both a physical `key` (sort order) and a `logicalKey` (expand/collapse). `syncProjects` keys `projectExpandedById` by `logicalKey`, and persistence tracks the member cwds per group so localStorage hydration still works for grouped projects. --- apps/web/src/components/Sidebar.logic.test.ts | 36 ++++++++++ apps/web/src/components/Sidebar.tsx | 8 ++- apps/web/src/environments/runtime/service.ts | 10 ++- apps/web/src/hooks/useHandleNewThread.ts | 4 +- apps/web/src/hooks/useSettings.ts | 9 +++ apps/web/src/logicalProject.ts | 7 ++ apps/web/src/uiStateStore.test.ts | 68 ++++++++++++++----- apps/web/src/uiStateStore.ts | 43 +++++++----- 8 files changed, 146 insertions(+), 39 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 6aa042a169..f92f2f628c 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -309,6 +309,42 @@ describe("orderItemsByPreferredIds", () => { ProjectId.make("project-1"), ]); }); + + it("honors projectOrder physical keys via getProjectOrderKey", async () => { + // Regression guard for #1904 / the regression introduced by #2055: + // `projectOrder` is populated with physical keys (envId + cwd-derived) + // by the store and by drag-end handlers. Readers must identify projects + // with the same key format, or manual sort silently snaps back. + const { getProjectOrderKey } = await import("../logicalProject"); + const projects = [ + { + environmentId: EnvironmentId.make("environment-local"), + id: ProjectId.make("id-alpha"), + cwd: "/work/alpha", + }, + { + environmentId: EnvironmentId.make("environment-local"), + id: ProjectId.make("id-beta"), + cwd: "/work/beta", + }, + { + environmentId: EnvironmentId.make("environment-local"), + id: ProjectId.make("id-gamma"), + cwd: "/work/gamma", + }, + ]; + const ordered = orderItemsByPreferredIds({ + items: projects, + preferredIds: [getProjectOrderKey(projects[2]!), getProjectOrderKey(projects[0]!)], + getId: getProjectOrderKey, + }); + + expect(ordered.map((project) => project.cwd)).toEqual([ + "/work/gamma", + "/work/alpha", + "/work/beta", + ]); + }); }); describe("resolveAdjacentThreadId", () => { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8335042c11..d25f930c0d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -168,7 +168,11 @@ import { CommandDialogTrigger } from "./ui/command"; import { readEnvironmentApi } from "../environmentApi"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; -import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey } from "../logicalProject"; +import { + derivePhysicalProjectKey, + deriveProjectGroupingOverrideKey, + getProjectOrderKey, +} from "../logicalProject"; import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, @@ -2708,7 +2712,7 @@ export default function Sidebar() { return orderItemsByPreferredIds({ items: projects, preferredIds: projectOrder, - getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + getId: getProjectOrderKey, }); }, [projectOrder, projects]); diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts index 40f8f8f2c0..775df0184e 100644 --- a/apps/web/src/environments/runtime/service.ts +++ b/apps/web/src/environments/runtime/service.ts @@ -61,7 +61,11 @@ import { useTerminalStateStore } from "~/terminalStateStore"; import { useUiStateStore } from "~/uiStateStore"; import { WsTransport } from "../../rpc/wsTransport"; import { createWsRpcClient, type WsRpcClient } from "../../rpc/wsRpcClient"; -import { derivePhysicalProjectKey } from "../../logicalProject"; +import { + deriveLogicalProjectKeyFromSettings, + derivePhysicalProjectKey, +} from "../../logicalProject"; +import { getClientSettings } from "~/hooks/useSettings"; type EnvironmentServiceState = { readonly queryClient: QueryClient; @@ -468,9 +472,11 @@ function coalesceOrchestrationUiEvents( function syncProjectUiFromStore() { const projects = selectProjectsAcrossEnvironments(useStore.getState()); + const clientSettings = getClientSettings(); useUiStateStore.getState().syncProjects( projects.map((project) => ({ key: derivePhysicalProjectKey(project), + logicalKey: deriveLogicalProjectKeyFromSettings(project, clientSettings), cwd: project.cwd, })), ); @@ -541,9 +547,11 @@ function applyRecoveredEventBatch( useStore.getState().applyOrchestrationEvents(uiEvents, environmentId); if (needsProjectUiSync) { const projects = selectProjectsAcrossEnvironments(useStore.getState()); + const clientSettings = getClientSettings(); useUiStateStore.getState().syncProjects( projects.map((project) => ({ key: derivePhysicalProjectKey(project), + logicalKey: deriveLogicalProjectKeyFromSettings(project, clientSettings), cwd: project.cwd, })), ); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index d512b6c7e7..3630171bf5 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -10,7 +10,7 @@ import { } from "../composerDraftStore"; import { newDraftId, newThreadId } from "../lib/utils"; import { orderItemsByPreferredIds } from "../components/Sidebar.logic"; -import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; +import { deriveLogicalProjectKeyFromSettings, getProjectOrderKey } from "../logicalProject"; import { selectProjectsAcrossEnvironments, useStore } from "../store"; import { createThreadSelectorByRef } from "../storeSelectors"; import { resolveThreadRouteTarget } from "../threadRoutes"; @@ -169,7 +169,7 @@ export function useHandleNewThread() { return orderItemsByPreferredIds({ items: projects, preferredIds: projectOrder, - getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + getId: getProjectOrderKey, }); }, [projectOrder, projects]); const handleNewThread = useNewThreadState(); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 18b2668a60..37a6872bd9 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -123,6 +123,15 @@ function splitPatch(patch: Partial): { * only re-render when the slice they care about changes. */ +/** + * Non-hook accessor for the current merged client settings snapshot. + * Used by non-React code paths (e.g. runtime services) that need the latest + * settings without subscribing. + */ +export function getClientSettings(): ClientSettings { + return getClientSettingsSnapshot(); +} + export function useSettings(selector?: (s: UnifiedSettings) => T): T { const serverSettings = useServerSettings(); const clientSettings = useSyncExternalStore( diff --git a/apps/web/src/logicalProject.ts b/apps/web/src/logicalProject.ts index d30bb60ca0..f19b64ac80 100644 --- a/apps/web/src/logicalProject.ts +++ b/apps/web/src/logicalProject.ts @@ -65,6 +65,13 @@ export function deriveProjectGroupingOverrideKey( return derivePhysicalProjectKey(project); } +// Key under which a project's manual sort order (projectOrder) is stored. +// Must stay aligned with the writer side in `uiStateStore.syncProjects` and +// the drag handlers in `Sidebar` so readers and writers agree. +export function getProjectOrderKey(project: Pick): string { + return derivePhysicalProjectKey(project); +} + export function resolveProjectGroupingMode( project: Pick, settings: ProjectGroupingSettings, diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index c906bbc1d7..faf7e62774 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -183,40 +183,43 @@ describe("uiStateStore pure functions", () => { }); const next = syncProjects(initialState, [ - { key: project1, cwd: "/tmp/project-1" }, - { key: project2, cwd: "/tmp/project-2" }, - { key: project3, cwd: "/tmp/project-3" }, + { key: project1, logicalKey: project1, cwd: "/tmp/project-1" }, + { key: project2, logicalKey: project2, cwd: "/tmp/project-2" }, + { key: project3, logicalKey: project3, cwd: "/tmp/project-3" }, ]); expect(next.projectOrder).toEqual([project2, project1, project3]); expect(next.projectExpandedById[project2]).toBe(false); }); - it("syncProjects preserves manual order when a project is recreated with the same cwd", () => { - const oldProject1 = ProjectId.make("project-1"); - const oldProject2 = ProjectId.make("project-2"); - const recreatedProject2 = ProjectId.make("project-2b"); + it("syncProjects preserves manual order across project id churn at the same cwd", () => { + // Under the current design, physical key and logical key are both + // cwd-derived, so an internal project-id change doesn't alter the store + // keys. This test locks in that stability: re-syncing the same cwds keeps + // manual order and collapse state. + const keyProject1 = "env-local:/tmp/project-1"; + const keyProject2 = "env-local:/tmp/project-2"; const initialState = syncProjects( makeUiState({ projectExpandedById: { - [oldProject1]: true, - [oldProject2]: false, + [keyProject1]: true, + [keyProject2]: false, }, - projectOrder: [oldProject2, oldProject1], + projectOrder: [keyProject2, keyProject1], }), [ - { key: oldProject1, cwd: "/tmp/project-1" }, - { key: oldProject2, cwd: "/tmp/project-2" }, + { key: keyProject1, logicalKey: keyProject1, cwd: "/tmp/project-1" }, + { key: keyProject2, logicalKey: keyProject2, cwd: "/tmp/project-2" }, ], ); const next = syncProjects(initialState, [ - { key: oldProject1, cwd: "/tmp/project-1" }, - { key: recreatedProject2, cwd: "/tmp/project-2" }, + { key: keyProject1, logicalKey: keyProject1, cwd: "/tmp/project-1" }, + { key: keyProject2, logicalKey: keyProject2, cwd: "/tmp/project-2" }, ]); - expect(next.projectOrder).toEqual([recreatedProject2, oldProject1]); - expect(next.projectExpandedById[recreatedProject2]).toBe(false); + expect(next.projectOrder).toEqual([keyProject2, keyProject1]); + expect(next.projectExpandedById[keyProject2]).toBe(false); }); it("syncProjects returns a new state when only project cwd changes", () => { @@ -228,16 +231,45 @@ describe("uiStateStore pure functions", () => { }, projectOrder: [project1], }), - [{ key: project1, cwd: "/tmp/project-1" }], + [{ key: project1, logicalKey: project1, cwd: "/tmp/project-1" }], ); - const next = syncProjects(initialState, [{ key: project1, cwd: "/tmp/project-1-renamed" }]); + const next = syncProjects(initialState, [ + { key: project1, logicalKey: project1, cwd: "/tmp/project-1-renamed" }, + ]); expect(next).not.toBe(initialState); expect(next.projectOrder).toEqual([project1]); expect(next.projectExpandedById[project1]).toBe(false); }); + it("syncProjects keys projectExpandedById by the logical key, not the physical key", () => { + // In repository grouping mode, multiple physical projects (different + // environments or different repo-relative paths) collapse into one + // logical group. The group's expand state must be keyed by the logical + // key so clicks on the grouped row toggle the shared state, and so the + // state survives subsequent syncProjects calls (which rebuild the map + // from incoming inputs). + const physicalLocal = "env-local:/repo/project"; + const physicalRemote = "env-remote:/repo/project"; + const logicalKey = "repo-canonical-key"; + + const initial = syncProjects(makeUiState(), [ + { key: physicalLocal, logicalKey, cwd: "/repo/project" }, + { key: physicalRemote, logicalKey, cwd: "/repo/project" }, + ]); + + expect(initial.projectExpandedById).toEqual({ [logicalKey]: true }); + + const afterCollapse = { ...initial, projectExpandedById: { [logicalKey]: false } }; + const next = syncProjects(afterCollapse, [ + { key: physicalLocal, logicalKey, cwd: "/repo/project" }, + { key: physicalRemote, logicalKey, cwd: "/repo/project" }, + ]); + + expect(next.projectExpandedById[logicalKey]).toBe(false); + }); + it("syncThreads prunes missing thread UI state", () => { const thread1 = ThreadId.make("thread-1"); const thread2 = ThreadId.make("thread-2"); diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 7ae7232063..eab7a68fe1 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -34,7 +34,10 @@ export interface UiThreadState { export interface UiState extends UiProjectState, UiThreadState {} 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; } @@ -53,6 +56,7 @@ const initialState: UiState = { const persistedExpandedProjectCwds = new Set(); const persistedProjectOrderCwds: string[] = []; const currentProjectCwdById = new Map(); +const currentProjectCwdsByLogicalKey = new Map(); let legacyKeysCleanedUp = false; function readPersistedState(): UiState { @@ -135,10 +139,7 @@ function persistState(state: UiState): void { try { const expandedProjectCwds = Object.entries(state.projectExpandedById) .filter(([, expanded]) => expanded) - .flatMap(([projectId]) => { - const cwd = currentProjectCwdById.get(projectId); - return cwd ? [cwd] : []; - }); + .flatMap(([logicalKey]) => currentProjectCwdsByLogicalKey.get(logicalKey) ?? []); const projectOrderCwds = state.projectOrder.flatMap((projectId) => { const cwd = currentProjectCwdById.get(projectId); return cwd ? [cwd] : []; @@ -211,13 +212,21 @@ function nestedBooleanRecordsEqual( 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); } + 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]); + } + } const cwdMappingChanged = previousProjectCwdById.size !== currentProjectCwdById.size || projects.some((project) => previousProjectCwdById.get(project.key) !== project.cwd); @@ -228,14 +237,15 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput 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; + if (!(project.logicalKey in nextExpandedById)) { + const groupCwds = currentProjectCwdsByLogicalKey.get(project.logicalKey) ?? [project.cwd]; + const expanded = + previousExpandedById[project.logicalKey] ?? + (persistedExpandedProjectCwds.size > 0 + ? groupCwds.some((cwd) => persistedExpandedProjectCwds.has(cwd)) + : true); + nextExpandedById[project.logicalKey] = expanded; + } return { id: project.key, cwd: project.cwd, @@ -246,6 +256,7 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput 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), ); @@ -254,7 +265,7 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput for (const projectId of state.projectOrder) { const matchedProjectId = - (projectId in nextExpandedById ? projectId : undefined) ?? + (currentProjectIds.has(projectId) ? projectId : undefined) ?? (() => { const previousCwd = previousProjectCwdById.get(projectId); return previousCwd ? nextProjectIdByCwd.get(previousCwd) : undefined; From f166d1ad7890164bdfdb2a280b8d4aec8dc8b330 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Sun, 19 Apr 2026 23:32:06 -0400 Subject: [PATCH 2/5] fix(web): preserve project expand state when logical key changes When late-arriving project metadata flips the grouping identity for a project (e.g. physical key to repository canonical key), the row itself did not change but its entry in `projectExpandedById` moved to a new key. Before this change, we fell through to the persisted-cwds fallback, which often meant silently defaulting the row back to expanded. Track the previous logical key per physical key across syncs, then if a project's new logical key is unseen, fall back to the previous logical key's expand state before touching the persisted fallback. --- apps/web/src/uiStateStore.test.ts | 25 +++++++++++++++++++++++ apps/web/src/uiStateStore.ts | 34 +++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index faf7e62774..9014fbe645 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -270,6 +270,31 @@ describe("uiStateStore pure functions", () => { expect(next.projectExpandedById[logicalKey]).toBe(false); }); + it("syncProjects preserves expand state when a project's logical key changes", () => { + // Example: late-arriving repo metadata flips grouping identity from the + // physical key to a canonical repository key. The row did not actually + // change, so the user's collapse choice must carry over. + const physicalKey = "env-local:/repo/project"; + const previousLogicalKey = physicalKey; + const nextLogicalKey = "repo-canonical-key"; + + const initial = syncProjects(makeUiState(), [ + { key: physicalKey, logicalKey: previousLogicalKey, cwd: "/repo/project" }, + ]); + + expect(initial.projectExpandedById[previousLogicalKey]).toBe(true); + + const afterCollapse = { + ...initial, + projectExpandedById: { [previousLogicalKey]: false }, + }; + const next = syncProjects(afterCollapse, [ + { key: physicalKey, logicalKey: nextLogicalKey, cwd: "/repo/project" }, + ]); + + expect(next.projectExpandedById[nextLogicalKey]).toBe(false); + }); + it("syncThreads prunes missing thread UI state", () => { const thread1 = ThreadId.make("thread-1"); const thread2 = ThreadId.make("thread-2"); diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index eab7a68fe1..d76f49c040 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -57,6 +57,7 @@ const persistedExpandedProjectCwds = new Set(); const persistedProjectOrderCwds: string[] = []; const currentProjectCwdById = new Map(); const currentProjectCwdsByLogicalKey = new Map(); +const currentLogicalKeyByPhysicalKey = new Map(); let legacyKeysCleanedUp = false; function readPersistedState(): UiState { @@ -212,9 +213,12 @@ function nestedBooleanRecordsEqual( 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) { @@ -227,6 +231,23 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput 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>(); + 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); @@ -239,8 +260,21 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput 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 expanded = previousExpandedById[project.logicalKey] ?? + fallbackFromPreviousLogicalKey ?? (persistedExpandedProjectCwds.size > 0 ? groupCwds.some((cwd) => persistedExpandedProjectCwds.has(cwd)) : true); From 803cf3a762a912f954a5f6a70979fe61d282d712 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Mon, 20 Apr 2026 00:58:25 -0400 Subject: [PATCH 3/5] fix(web): preserve all-collapsed project state across restart When the user collapsed every sidebar project row, the next launch re-expanded them all. `persistState` only wrote cwds of expanded projects, and an empty `expandedProjectCwds` array on rehydrate is indistinguishable from a fresh install, so the `syncProjects` fallback returned `true`. Persist `collapsedProjectCwds` alongside `expandedProjectCwds` so the fallback can tell "user collapsed everything" apart from "no info yet". For one session after upgrade, sessions whose persisted blob predates this field keep the old "not in expanded list = collapsed" semantic so nothing flips on the first restart; the next persistState writes the new shape and the legacy branch goes dormant. --- apps/web/src/uiStateStore.ts | 37 +++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index d76f49c040..336dc37c45 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -16,6 +16,7 @@ const LEGACY_PERSISTED_STATE_KEYS = [ ] as const; interface PersistedUiState { + collapsedProjectCwds?: string[]; expandedProjectCwds?: string[]; projectOrderCwds?: string[]; threadChangedFilesExpandedById?: Record>; @@ -53,8 +54,14 @@ const initialState: UiState = { threadChangedFilesExpandedById: {}, }; +const persistedCollapsedProjectCwds = new Set(); const persistedExpandedProjectCwds = new Set(); 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(); const currentProjectCwdsByLogicalKey = new Map(); const currentLogicalKeyByPhysicalKey = new Map(); @@ -119,8 +126,15 @@ function sanitizePersistedThreadChangedFilesExpanded( } 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); @@ -138,6 +152,12 @@ function persistState(state: UiState): void { 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) ?? []); @@ -156,6 +176,7 @@ function persistState(state: UiState): void { window.localStorage.setItem( PERSISTED_STATE_KEY, JSON.stringify({ + collapsedProjectCwds, expandedProjectCwds, projectOrderCwds, threadChangedFilesExpandedById, @@ -272,12 +293,22 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput } 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 ?? - (persistedExpandedProjectCwds.size > 0 - ? groupCwds.some((cwd) => persistedExpandedProjectCwds.has(cwd)) - : true); + fallbackFromPersistedShape; nextExpandedById[project.logicalKey] = expanded; } return { From cbf34237e15f834e732756db4498c8480b68f214 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Mon, 20 Apr 2026 01:18:02 -0400 Subject: [PATCH 4/5] test(web): add round-trip tests for project expand-state persistence Locks in the persistState/hydrate/syncProjects contract behind the collapsed/expanded fix: - All-collapsed state survives a restart (the original regression). - Mixed expand state is preserved and a brand-new project defaults to expanded under the new shape. - Legacy "not in expandedProjectCwds = collapsed" semantic is preserved for one upgrade session when collapsedProjectCwds is missing. Exports PERSISTED_STATE_KEY, PersistedUiState, hydratePersistedProjectState, and persistState so the tests can drive a deterministic localStorage round-trip without spinning up the zustand store. --- apps/web/src/uiStateStore.test.ts | 107 +++++++++++++++++++++++++++++- apps/web/src/uiStateStore.ts | 8 +-- 2 files changed, 110 insertions(+), 5 deletions(-) diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index 9014fbe645..f325240f06 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -1,9 +1,13 @@ import { ProjectId, ThreadId } from "@t3tools/contracts"; -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { clearThreadUi, + hydratePersistedProjectState, markThreadUnread, + PERSISTED_STATE_KEY, + type PersistedUiState, + persistState, reorderProjects, setProjectExpanded, setThreadChangedFilesExpanded, @@ -403,3 +407,104 @@ describe("uiStateStore pure functions", () => { expect(next.threadChangedFilesExpandedById).toEqual({}); }); }); + +describe("uiStateStore persistence round-trip", () => { + function createLocalStorageStub(): Storage { + const store = new Map(); + return { + clear: () => { + store.clear(); + }, + getItem: (key) => store.get(key) ?? null, + key: (index) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + removeItem: (key) => { + store.delete(key); + }, + setItem: (key, value) => { + store.set(key, value); + }, + }; + } + + let localStorageStub: Storage; + + beforeEach(() => { + localStorageStub = createLocalStorageStub(); + vi.stubGlobal("window", { localStorage: localStorageStub }); + vi.stubGlobal("localStorage", localStorageStub); + // Reset module-level persistence state so tests don't bleed into each other. + hydratePersistedProjectState({ collapsedProjectCwds: [], expandedProjectCwds: [] }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("preserves all-collapsed project state across restart", () => { + // Regression: pre-fix, persistState only wrote `expandedProjectCwds`, so + // an empty array on rehydrate was indistinguishable from a fresh install + // and the syncProjects fallback re-expanded every row. + const projectA = { key: "kA", logicalKey: "kA", cwd: "/projA" }; + const projectB = { key: "kB", logicalKey: "kB", cwd: "/projB" }; + + let state = syncProjects(makeUiState(), [projectA, projectB]); + state = setProjectExpanded(state, projectA.key, false); + state = setProjectExpanded(state, projectB.key, false); + persistState(state); + + const persisted = JSON.parse( + localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", + ) as PersistedUiState; + hydratePersistedProjectState(persisted); + const rehydrated = syncProjects(makeUiState(), [projectA, projectB]); + + expect(rehydrated.projectExpandedById).toEqual({ + [projectA.key]: false, + [projectB.key]: false, + }); + }); + + it("respects mixed expand state on rehydrate and defaults new projects to expanded", () => { + const projectA = { key: "kA", logicalKey: "kA", cwd: "/projA" }; + const projectB = { key: "kB", logicalKey: "kB", cwd: "/projB" }; + const projectC = { key: "kC", logicalKey: "kC", cwd: "/projC" }; + + let state = syncProjects(makeUiState(), [projectA, projectB]); + state = setProjectExpanded(state, projectB.key, false); + persistState(state); + + const persisted = JSON.parse( + localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", + ) as PersistedUiState; + hydratePersistedProjectState(persisted); + const rehydrated = syncProjects(makeUiState(), [projectA, projectB, projectC]); + + expect(rehydrated.projectExpandedById).toEqual({ + [projectA.key]: true, + [projectB.key]: false, + [projectC.key]: true, + }); + }); + + it("preserves legacy not-in-expanded-list = collapsed for one upgrade session", () => { + // Pre-fix shape only stored expandedProjectCwds. Absence of + // collapsedProjectCwds opts the session into the legacy fallback so + // upgrade users do not see previously collapsed rows pop open. + hydratePersistedProjectState({ + expandedProjectCwds: ["/projA"], + }); + + const rehydrated = syncProjects(makeUiState(), [ + { key: "kA", logicalKey: "kA", cwd: "/projA" }, + { key: "kB", logicalKey: "kB", cwd: "/projB" }, + ]); + + expect(rehydrated.projectExpandedById).toEqual({ + kA: true, + kB: false, + }); + }); +}); diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 336dc37c45..8bd65ffc56 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -1,7 +1,7 @@ import { Debouncer } from "@tanstack/react-pacer"; import { create } from "zustand"; -const PERSISTED_STATE_KEY = "t3code:ui-state:v1"; +export const PERSISTED_STATE_KEY = "t3code:ui-state:v1"; const LEGACY_PERSISTED_STATE_KEYS = [ "t3code:renderer-state:v8", "t3code:renderer-state:v7", @@ -15,7 +15,7 @@ const LEGACY_PERSISTED_STATE_KEYS = [ "codething:renderer-state:v1", ] as const; -interface PersistedUiState { +export interface PersistedUiState { collapsedProjectCwds?: string[]; expandedProjectCwds?: string[]; projectOrderCwds?: string[]; @@ -125,7 +125,7 @@ function sanitizePersistedThreadChangedFilesExpanded( return nextState; } -function hydratePersistedProjectState(parsed: PersistedUiState): void { +export function hydratePersistedProjectState(parsed: PersistedUiState): void { persistedCollapsedProjectCwds.clear(); persistedExpandedProjectCwds.clear(); persistedProjectOrderCwds.length = 0; @@ -147,7 +147,7 @@ function hydratePersistedProjectState(parsed: PersistedUiState): void { } } -function persistState(state: UiState): void { +export function persistState(state: UiState): void { if (typeof window === "undefined") { return; } From fb544d9d8e0353bb1bfc028cab1b448df139aa50 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Mon, 20 Apr 2026 01:31:34 -0400 Subject: [PATCH 5/5] test(web): cover persisted order and cross-restart logical-key migration Closes coverage gaps Codex flagged in the prior commit: - projectOrderCwds round-trip: reorder, persist, hydrate, sync, and assert the manual order survives. Catches regressions where ordering persistence breaks while the expand-state tests still pass. - Restart with logical-key change: covers the persisted-cwd fallback in syncProjects, which is the only path that can carry collapse state across a restart when a project also moves into a new logical group. The existing in-memory pure-function test does not exercise this because it only covers same-session migration. --- apps/web/src/uiStateStore.test.ts | 52 +++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index f325240f06..f63208d9ba 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -507,4 +507,56 @@ describe("uiStateStore persistence round-trip", () => { kB: false, }); }); + + it("preserves manual project order across restart", () => { + const projectA = { key: "kOrderA", logicalKey: "kOrderA", cwd: "/order-projA" }; + const projectB = { key: "kOrderB", logicalKey: "kOrderB", cwd: "/order-projB" }; + const projectC = { key: "kOrderC", logicalKey: "kOrderC", cwd: "/order-projC" }; + + let state = syncProjects(makeUiState(), [projectA, projectB, projectC]); + state = reorderProjects(state, [projectC.key], [projectA.key]); + expect(state.projectOrder).toEqual([projectC.key, projectA.key, projectB.key]); + persistState(state); + + const persisted = JSON.parse( + localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", + ) as PersistedUiState; + expect(persisted.projectOrderCwds).toEqual([projectC.cwd, projectA.cwd, projectB.cwd]); + + hydratePersistedProjectState(persisted); + // Fresh state (empty projectOrder) so syncProjects derives order from + // persistedProjectOrderCwds rather than the in-memory projectOrder branch. + const rehydrated = syncProjects(makeUiState(), [projectA, projectB, projectC]); + + expect(rehydrated.projectOrder).toEqual([projectC.key, projectA.key, projectB.key]); + }); + + it("preserves expand state across restart when project's logical key changes", () => { + // After restart, in-memory previousExpandedById is empty, so the + // previousLogicalKey-to-state bridge in syncProjects cannot help. The + // persisted-cwd fallback is the only mechanism that can carry collapse + // state across a restart that also flips a project into a new logical + // group (e.g. late-arriving repo metadata). This locks in that path. + const physicalKey = "env-local:/lk-restart-proj"; + const previousLogicalKey = physicalKey; + const cwd = "/lk-restart-proj"; + + let state = syncProjects(makeUiState(), [ + { key: physicalKey, logicalKey: previousLogicalKey, cwd }, + ]); + state = setProjectExpanded(state, previousLogicalKey, false); + persistState(state); + + const persisted = JSON.parse( + localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", + ) as PersistedUiState; + hydratePersistedProjectState(persisted); + + const nextLogicalKey = "lk-restart-canonical"; + const rehydrated = syncProjects(makeUiState(), [ + { key: physicalKey, logicalKey: nextLogicalKey, cwd }, + ]); + + expect(rehydrated.projectExpandedById[nextLogicalKey]).toBe(false); + }); });