History

fix(web): restore manual sort drag and keep per-group expand state (#2221)

Mike Olson committed 6 months ago

Commit de05b0c1 parent 6d1505c8 files · +398 −44Browse files

Unified · Split

8 files
  1. apps/web/src/components/Sidebar.logic.test.ts +36 −0
  2. apps/web/src/components/Sidebar.tsx +6 −2
  3. apps/web/src/environments/runtime/service.ts +9 −1
  4. apps/web/src/hooks/useHandleNewThread.ts +2 −2
  5. apps/web/src/hooks/useSettings.ts +9 −0
  6. apps/web/src/logicalProject.ts +7 −0
  7. apps/web/src/uiStateStore.test.ts +233 −19
  8. apps/web/src/uiStateStore.ts +96 −20
apps/web/src/components/Sidebar.logic.test.ts+36−0
@@ -309,6 +309,42 @@ describe("orderItemsByPreferredIds", () => { · 308 unmodified lines
309309 ProjectId.make("project-1"),
310310 ]);
311311 });
312+
313+ it("honors projectOrder physical keys via getProjectOrderKey", async () => {
314+ // Regression guard for #1904 / the regression introduced by #2055:
315+ // `projectOrder` is populated with physical keys (envId + cwd-derived)
316+ // by the store and by drag-end handlers. Readers must identify projects
317+ // with the same key format, or manual sort silently snaps back.
318+ const { getProjectOrderKey } = await import("../logicalProject");
319+ const projects = [
320+ {
321+ environmentId: EnvironmentId.make("environment-local"),
322+ id: ProjectId.make("id-alpha"),
323+ cwd: "/work/alpha",
324+ },
325+ {
326+ environmentId: EnvironmentId.make("environment-local"),
327+ id: ProjectId.make("id-beta"),
328+ cwd: "/work/beta",
329+ },
330+ {
331+ environmentId: EnvironmentId.make("environment-local"),
332+ id: ProjectId.make("id-gamma"),
333+ cwd: "/work/gamma",
334+ },
335+ ];
336+ const ordered = orderItemsByPreferredIds({
337+ items: projects,
338+ preferredIds: [getProjectOrderKey(projects[2]!), getProjectOrderKey(projects[0]!)],
339+ getId: getProjectOrderKey,
340+ });
341+
342+ expect(ordered.map((project) => project.cwd)).toEqual([
343+ "/work/gamma",
344+ "/work/alpha",
345+ "/work/beta",
346+ ]);
347+ });
312348 });
313349
314350 describe("resolveAdjacentThreadId", () => {
apps/web/src/components/Sidebar.tsx+6−2
@@ -168,7 +168,11 @@ import { CommandDialogTrigger } from "./ui/command"; · 167 unmodified lines
168168 import { readEnvironmentApi } from "../environmentApi";
169169 import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
170170 import { useServerKeybindings } from "../rpc/serverState";
171−import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey } from "../logicalProject";
171+import {
172+ derivePhysicalProjectKey,
173+ deriveProjectGroupingOverrideKey,
174+ getProjectOrderKey,
175+} from "../logicalProject";
172176 import {
173177 useSavedEnvironmentRegistryStore,
174178 useSavedEnvironmentRuntimeStore,
@@ -2708,7 +2712,7 @@ export default function Sidebar() { · 2533 unmodified lines
27082712 return orderItemsByPreferredIds({
27092713 items: projects,
27102714 preferredIds: projectOrder,
2711− getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
2715+ getId: getProjectOrderKey,
27122716 });
27132717 }, [projectOrder, projects]);
27142718
apps/web/src/environments/runtime/service.ts+9−1
@@ -61,7 +61,11 @@ import { useTerminalStateStore } from "~/terminalStateStore"; · 60 unmodified lines
6161 import { useUiStateStore } from "~/uiStateStore";
6262 import { WsTransport } from "../../rpc/wsTransport";
6363 import { createWsRpcClient, type WsRpcClient } from "../../rpc/wsRpcClient";
64−import { derivePhysicalProjectKey } from "../../logicalProject";
64+import {
65+ deriveLogicalProjectKeyFromSettings,
66+ derivePhysicalProjectKey,
67+} from "../../logicalProject";
68+import { getClientSettings } from "~/hooks/useSettings";
6569
6670 type EnvironmentServiceState = {
6771 readonly queryClient: QueryClient;
@@ -468,9 +472,11 @@ function coalesceOrchestrationUiEvents( · 400 unmodified lines
468472
469473 function syncProjectUiFromStore() {
470474 const projects = selectProjectsAcrossEnvironments(useStore.getState());
475+ const clientSettings = getClientSettings();
471476 useUiStateStore.getState().syncProjects(
472477 projects.map((project) => ({
473478 key: derivePhysicalProjectKey(project),
479+ logicalKey: deriveLogicalProjectKeyFromSettings(project, clientSettings),
474480 cwd: project.cwd,
475481 })),
476482 );
@@ -541,9 +547,11 @@ function applyRecoveredEventBatch( · 64 unmodified lines
541547 useStore.getState().applyOrchestrationEvents(uiEvents, environmentId);
542548 if (needsProjectUiSync) {
543549 const projects = selectProjectsAcrossEnvironments(useStore.getState());
550+ const clientSettings = getClientSettings();
544551 useUiStateStore.getState().syncProjects(
545552 projects.map((project) => ({
546553 key: derivePhysicalProjectKey(project),
554+ logicalKey: deriveLogicalProjectKeyFromSettings(project, clientSettings),
547555 cwd: project.cwd,
548556 })),
549557 );
apps/web/src/hooks/useHandleNewThread.ts+2−2
@@ -10,7 +10,7 @@ import { · 9 unmodified lines
1010 } from "../composerDraftStore";
1111 import { newDraftId, newThreadId } from "../lib/utils";
1212 import { orderItemsByPreferredIds } from "../components/Sidebar.logic";
13−import { deriveLogicalProjectKeyFromSettings } from "../logicalProject";
13+import { deriveLogicalProjectKeyFromSettings, getProjectOrderKey } from "../logicalProject";
1414 import { selectProjectsAcrossEnvironments, useStore } from "../store";
1515 import { createThreadSelectorByRef } from "../storeSelectors";
1616 import { resolveThreadRouteTarget } from "../threadRoutes";
@@ -169,7 +169,7 @@ export function useHandleNewThread() { · 152 unmodified lines
169169 return orderItemsByPreferredIds({
170170 items: projects,
171171 preferredIds: projectOrder,
172− getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
172+ getId: getProjectOrderKey,
173173 });
174174 }, [projectOrder, projects]);
175175 const handleNewThread = useNewThreadState();
apps/web/src/hooks/useSettings.ts+9−0
@@ -123,6 +123,15 @@ function splitPatch(patch: Partial<UnifiedSettings>): { · 122 unmodified lines
123123 * only re-render when the slice they care about changes.
124124 */
125125
126+/**
127+ * Non-hook accessor for the current merged client settings snapshot.
128+ * Used by non-React code paths (e.g. runtime services) that need the latest
129+ * settings without subscribing.
130+ */
131+export function getClientSettings(): ClientSettings {
132+ return getClientSettingsSnapshot();
133+}
134+
126135 export function useSettings<T = UnifiedSettings>(selector?: (s: UnifiedSettings) => T): T {
127136 const serverSettings = useServerSettings();
128137 const clientSettings = useSyncExternalStore(
apps/web/src/logicalProject.ts+7−0
@@ -65,6 +65,13 @@ export function deriveProjectGroupingOverrideKey( · 64 unmodified lines
6565 return derivePhysicalProjectKey(project);
6666 }
6767
68+// Key under which a project's manual sort order (projectOrder) is stored.
69+// Must stay aligned with the writer side in `uiStateStore.syncProjects` and
70+// the drag handlers in `Sidebar` so readers and writers agree.
71+export function getProjectOrderKey(project: Pick<Project, "environmentId" | "cwd">): string {
72+ return derivePhysicalProjectKey(project);
73+}
74+
6875 export function resolveProjectGroupingMode(
6976 project: Pick<Project, "environmentId" | "cwd">,
7077 settings: ProjectGroupingSettings,
apps/web/src/uiStateStore.test.ts+233−19
@@ -1,9 +1,13 @@
11 import { ProjectId, ThreadId } from "@t3tools/contracts";
2−import { describe, expect, it } from "vitest";
2+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
33
44 import {
55 clearThreadUi,
6+ hydratePersistedProjectState,
67 markThreadUnread,
8+ PERSISTED_STATE_KEY,
9+ type PersistedUiState,
10+ persistState,
711 reorderProjects,
812 setProjectExpanded,
913 setThreadChangedFilesExpanded,
@@ -183,40 +187,43 @@ describe("uiStateStore pure functions", () => { · 173 unmodified lines
183187 });
184188
185189 const next = syncProjects(initialState, [
186− { key: project1, cwd: "/tmp/project-1" },
187− { key: project2, cwd: "/tmp/project-2" },
188− { key: project3, cwd: "/tmp/project-3" },
190+ { key: project1, logicalKey: project1, cwd: "/tmp/project-1" },
191+ { key: project2, logicalKey: project2, cwd: "/tmp/project-2" },
192+ { key: project3, logicalKey: project3, cwd: "/tmp/project-3" },
189193 ]);
190194
191195 expect(next.projectOrder).toEqual([project2, project1, project3]);
192196 expect(next.projectExpandedById[project2]).toBe(false);
193197 });
194198
195− it("syncProjects preserves manual order when a project is recreated with the same cwd", () => {
196− const oldProject1 = ProjectId.make("project-1");
197− const oldProject2 = ProjectId.make("project-2");
198− const recreatedProject2 = ProjectId.make("project-2b");
199+ it("syncProjects preserves manual order across project id churn at the same cwd", () => {
200+ // Under the current design, physical key and logical key are both
201+ // cwd-derived, so an internal project-id change doesn't alter the store
202+ // keys. This test locks in that stability: re-syncing the same cwds keeps
203+ // manual order and collapse state.
204+ const keyProject1 = "env-local:/tmp/project-1";
205+ const keyProject2 = "env-local:/tmp/project-2";
199206 const initialState = syncProjects(
200207 makeUiState({
201208 projectExpandedById: {
202− [oldProject1]: true,
203− [oldProject2]: false,
209+ [keyProject1]: true,
210+ [keyProject2]: false,
204211 },
205− projectOrder: [oldProject2, oldProject1],
212+ projectOrder: [keyProject2, keyProject1],
206213 }),
207214 [
208− { key: oldProject1, cwd: "/tmp/project-1" },
209− { key: oldProject2, cwd: "/tmp/project-2" },
215+ { key: keyProject1, logicalKey: keyProject1, cwd: "/tmp/project-1" },
216+ { key: keyProject2, logicalKey: keyProject2, cwd: "/tmp/project-2" },
210217 ],
211218 );
212219
213220 const next = syncProjects(initialState, [
214− { key: oldProject1, cwd: "/tmp/project-1" },
215− { key: recreatedProject2, cwd: "/tmp/project-2" },
221+ { key: keyProject1, logicalKey: keyProject1, cwd: "/tmp/project-1" },
222+ { key: keyProject2, logicalKey: keyProject2, cwd: "/tmp/project-2" },
216223 ]);
217224
218− expect(next.projectOrder).toEqual([recreatedProject2, oldProject1]);
219− expect(next.projectExpandedById[recreatedProject2]).toBe(false);
225+ expect(next.projectOrder).toEqual([keyProject2, keyProject1]);
226+ expect(next.projectExpandedById[keyProject2]).toBe(false);
220227 });
221228
222229 it("syncProjects returns a new state when only project cwd changes", () => {
@@ -228,16 +235,70 @@ describe("uiStateStore pure functions", () => { · 5 unmodified lines
228235 },
229236 projectOrder: [project1],
230237 }),
231− [{ key: project1, cwd: "/tmp/project-1" }],
238+ [{ key: project1, logicalKey: project1, cwd: "/tmp/project-1" }],
232239 );
233240
234− const next = syncProjects(initialState, [{ key: project1, cwd: "/tmp/project-1-renamed" }]);
241+ const next = syncProjects(initialState, [
242+ { key: project1, logicalKey: project1, cwd: "/tmp/project-1-renamed" },
243+ ]);
235244
236245 expect(next).not.toBe(initialState);
237246 expect(next.projectOrder).toEqual([project1]);
238247 expect(next.projectExpandedById[project1]).toBe(false);
239248 });
240249
250+ it("syncProjects keys projectExpandedById by the logical key, not the physical key", () => {
251+ // In repository grouping mode, multiple physical projects (different
252+ // environments or different repo-relative paths) collapse into one
253+ // logical group. The group's expand state must be keyed by the logical
254+ // key so clicks on the grouped row toggle the shared state, and so the
255+ // state survives subsequent syncProjects calls (which rebuild the map
256+ // from incoming inputs).
257+ const physicalLocal = "env-local:/repo/project";
258+ const physicalRemote = "env-remote:/repo/project";
259+ const logicalKey = "repo-canonical-key";
260+
261+ const initial = syncProjects(makeUiState(), [
262+ { key: physicalLocal, logicalKey, cwd: "/repo/project" },
263+ { key: physicalRemote, logicalKey, cwd: "/repo/project" },
264+ ]);
265+
266+ expect(initial.projectExpandedById).toEqual({ [logicalKey]: true });
267+
268+ const afterCollapse = { ...initial, projectExpandedById: { [logicalKey]: false } };
269+ const next = syncProjects(afterCollapse, [
270+ { key: physicalLocal, logicalKey, cwd: "/repo/project" },
271+ { key: physicalRemote, logicalKey, cwd: "/repo/project" },
272+ ]);
273+
274+ expect(next.projectExpandedById[logicalKey]).toBe(false);
275+ });
276+
277+ it("syncProjects preserves expand state when a project's logical key changes", () => {
278+ // Example: late-arriving repo metadata flips grouping identity from the
279+ // physical key to a canonical repository key. The row did not actually
280+ // change, so the user's collapse choice must carry over.
281+ const physicalKey = "env-local:/repo/project";
282+ const previousLogicalKey = physicalKey;
283+ const nextLogicalKey = "repo-canonical-key";
284+
285+ const initial = syncProjects(makeUiState(), [
286+ { key: physicalKey, logicalKey: previousLogicalKey, cwd: "/repo/project" },
287+ ]);
288+
289+ expect(initial.projectExpandedById[previousLogicalKey]).toBe(true);
290+
291+ const afterCollapse = {
292+ ...initial,
293+ projectExpandedById: { [previousLogicalKey]: false },
294+ };
295+ const next = syncProjects(afterCollapse, [
296+ { key: physicalKey, logicalKey: nextLogicalKey, cwd: "/repo/project" },
297+ ]);
298+
299+ expect(next.projectExpandedById[nextLogicalKey]).toBe(false);
300+ });
301+
241302 it("syncThreads prunes missing thread UI state", () => {
242303 const thread1 = ThreadId.make("thread-1");
243304 const thread2 = ThreadId.make("thread-2");
@@ -346,3 +407,156 @@ describe("uiStateStore pure functions", () => { · 102 unmodified lines
346407 expect(next.threadChangedFilesExpandedById).toEqual({});
347408 });
348409 });
410+
411+describe("uiStateStore persistence round-trip", () => {
412+ function createLocalStorageStub(): Storage {
413+ const store = new Map<string, string>();
414+ return {
415+ clear: () => {
416+ store.clear();
417+ },
418+ getItem: (key) => store.get(key) ?? null,
419+ key: (index) => [...store.keys()][index] ?? null,
420+ get length() {
421+ return store.size;
422+ },
423+ removeItem: (key) => {
424+ store.delete(key);
425+ },
426+ setItem: (key, value) => {
427+ store.set(key, value);
428+ },
429+ };
430+ }
431+
432+ let localStorageStub: Storage;
433+
434+ beforeEach(() => {
435+ localStorageStub = createLocalStorageStub();
436+ vi.stubGlobal("window", { localStorage: localStorageStub });
437+ vi.stubGlobal("localStorage", localStorageStub);
438+ // Reset module-level persistence state so tests don't bleed into each other.
439+ hydratePersistedProjectState({ collapsedProjectCwds: [], expandedProjectCwds: [] });
440+ });
441+
442+ afterEach(() => {
443+ vi.unstubAllGlobals();
444+ });
445+
446+ it("preserves all-collapsed project state across restart", () => {
447+ // Regression: pre-fix, persistState only wrote `expandedProjectCwds`, so
448+ // an empty array on rehydrate was indistinguishable from a fresh install
449+ // and the syncProjects fallback re-expanded every row.
450+ const projectA = { key: "kA", logicalKey: "kA", cwd: "/projA" };
451+ const projectB = { key: "kB", logicalKey: "kB", cwd: "/projB" };
452+
453+ let state = syncProjects(makeUiState(), [projectA, projectB]);
454+ state = setProjectExpanded(state, projectA.key, false);
455+ state = setProjectExpanded(state, projectB.key, false);
456+ persistState(state);
457+
458+ const persisted = JSON.parse(
459+ localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}",
460+ ) as PersistedUiState;
461+ hydratePersistedProjectState(persisted);
462+ const rehydrated = syncProjects(makeUiState(), [projectA, projectB]);
463+
464+ expect(rehydrated.projectExpandedById).toEqual({
465+ [projectA.key]: false,
466+ [projectB.key]: false,
467+ });
468+ });
469+
470+ it("respects mixed expand state on rehydrate and defaults new projects to expanded", () => {
471+ const projectA = { key: "kA", logicalKey: "kA", cwd: "/projA" };
472+ const projectB = { key: "kB", logicalKey: "kB", cwd: "/projB" };
473+ const projectC = { key: "kC", logicalKey: "kC", cwd: "/projC" };
474+
475+ let state = syncProjects(makeUiState(), [projectA, projectB]);
476+ state = setProjectExpanded(state, projectB.key, false);
477+ persistState(state);
478+
479+ const persisted = JSON.parse(
480+ localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}",
481+ ) as PersistedUiState;
482+ hydratePersistedProjectState(persisted);
483+ const rehydrated = syncProjects(makeUiState(), [projectA, projectB, projectC]);
484+
485+ expect(rehydrated.projectExpandedById).toEqual({
486+ [projectA.key]: true,
487+ [projectB.key]: false,
488+ [projectC.key]: true,
489+ });
490+ });
491+
492+ it("preserves legacy not-in-expanded-list = collapsed for one upgrade session", () => {
493+ // Pre-fix shape only stored expandedProjectCwds. Absence of
494+ // collapsedProjectCwds opts the session into the legacy fallback so
495+ // upgrade users do not see previously collapsed rows pop open.
496+ hydratePersistedProjectState({
497+ expandedProjectCwds: ["/projA"],
498+ });
499+
500+ const rehydrated = syncProjects(makeUiState(), [
501+ { key: "kA", logicalKey: "kA", cwd: "/projA" },
502+ { key: "kB", logicalKey: "kB", cwd: "/projB" },
503+ ]);
504+
505+ expect(rehydrated.projectExpandedById).toEqual({
506+ kA: true,
507+ kB: false,
508+ });
509+ });
510+
511+ it("preserves manual project order across restart", () => {
512+ const projectA = { key: "kOrderA", logicalKey: "kOrderA", cwd: "/order-projA" };
513+ const projectB = { key: "kOrderB", logicalKey: "kOrderB", cwd: "/order-projB" };
514+ const projectC = { key: "kOrderC", logicalKey: "kOrderC", cwd: "/order-projC" };
515+
516+ let state = syncProjects(makeUiState(), [projectA, projectB, projectC]);
517+ state = reorderProjects(state, [projectC.key], [projectA.key]);
518+ expect(state.projectOrder).toEqual([projectC.key, projectA.key, projectB.key]);
519+ persistState(state);
520+
521+ const persisted = JSON.parse(
522+ localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}",
523+ ) as PersistedUiState;
524+ expect(persisted.projectOrderCwds).toEqual([projectC.cwd, projectA.cwd, projectB.cwd]);
525+
526+ hydratePersistedProjectState(persisted);
527+ // Fresh state (empty projectOrder) so syncProjects derives order from
528+ // persistedProjectOrderCwds rather than the in-memory projectOrder branch.
529+ const rehydrated = syncProjects(makeUiState(), [projectA, projectB, projectC]);
530+
531+ expect(rehydrated.projectOrder).toEqual([projectC.key, projectA.key, projectB.key]);
532+ });
533+
534+ it("preserves expand state across restart when project's logical key changes", () => {
535+ // After restart, in-memory previousExpandedById is empty, so the
536+ // previousLogicalKey-to-state bridge in syncProjects cannot help. The
537+ // persisted-cwd fallback is the only mechanism that can carry collapse
538+ // state across a restart that also flips a project into a new logical
539+ // group (e.g. late-arriving repo metadata). This locks in that path.
540+ const physicalKey = "env-local:/lk-restart-proj";
541+ const previousLogicalKey = physicalKey;
542+ const cwd = "/lk-restart-proj";
543+
544+ let state = syncProjects(makeUiState(), [
545+ { key: physicalKey, logicalKey: previousLogicalKey, cwd },
546+ ]);
547+ state = setProjectExpanded(state, previousLogicalKey, false);
548+ persistState(state);
549+
550+ const persisted = JSON.parse(
551+ localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}",
552+ ) as PersistedUiState;
553+ hydratePersistedProjectState(persisted);
554+
555+ const nextLogicalKey = "lk-restart-canonical";
556+ const rehydrated = syncProjects(makeUiState(), [
557+ { key: physicalKey, logicalKey: nextLogicalKey, cwd },
558+ ]);
559+
560+ expect(rehydrated.projectExpandedById[nextLogicalKey]).toBe(false);
561+ });
562+});
apps/web/src/uiStateStore.ts+96−20
@@ -1,7 +1,7 @@
11 import { Debouncer } from "@tanstack/react-pacer";
22 import { create } from "zustand";
33
4−const PERSISTED_STATE_KEY = "t3code:ui-state:v1";
4+export const PERSISTED_STATE_KEY = "t3code:ui-state:v1";
55 const LEGACY_PERSISTED_STATE_KEYS = [
66 "t3code:renderer-state:v8",
77 "t3code:renderer-state:v7",
@@ -15,7 +15,8 @@ const LEGACY_PERSISTED_STATE_KEYS = [ · 7 unmodified lines
1515 "codething:renderer-state:v1",
1616 ] as const;
1717
18−interface PersistedUiState {
18+export interface PersistedUiState {
19+ collapsedProjectCwds?: string[];
1920 expandedProjectCwds?: string[];
2021 projectOrderCwds?: string[];
2122 threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>;
@@ -34,7 +35,10 @@ export interface UiThreadState { · 12 unmodified lines
3435 export interface UiState extends UiProjectState, UiThreadState {}
3536
3637 export interface SyncProjectInput {
38+ /** Physical project key (env + cwd). Used for manual sort order. */
3739 key: string;
40+ /** Logical group key. Used for expand/collapse state. */
41+ logicalKey: string;
3842 cwd: string;
3943 }
4044
@@ -50,9 +54,17 @@ const initialState: UiState = { · 9 unmodified lines
5054 threadChangedFilesExpandedById: {},
5155 };
5256
57+const persistedCollapsedProjectCwds = new Set<string>();
5358 const persistedExpandedProjectCwds = new Set<string>();
5459 const persistedProjectOrderCwds: string[] = [];
60+// Pre-fix persisted shape only listed expanded cwds, so anything not listed
61+// was treated as collapsed. Track whether the loaded blob carried the new
62+// `collapsedProjectCwds` field so we can preserve that legacy semantic for
63+// one session after upgrade, until persistState rewrites in the new shape.
64+let persistedProjectStateUsesLegacyShape = false;
5565 const currentProjectCwdById = new Map<string, string>();
66+const currentProjectCwdsByLogicalKey = new Map<string, string[]>();
67+const currentLogicalKeyByPhysicalKey = new Map<string, string>();
5668 let legacyKeysCleanedUp = false;
5769
5870 function readPersistedState(): UiState {
@@ -113,9 +125,16 @@ function sanitizePersistedThreadChangedFilesExpanded( · 54 unmodified lines
113125 return nextState;
114126 }
115127
116−function hydratePersistedProjectState(parsed: PersistedUiState): void {
128+export function hydratePersistedProjectState(parsed: PersistedUiState): void {
129+ persistedCollapsedProjectCwds.clear();
117130 persistedExpandedProjectCwds.clear();
118131 persistedProjectOrderCwds.length = 0;
132+ persistedProjectStateUsesLegacyShape = !Array.isArray(parsed.collapsedProjectCwds);
133+ for (const cwd of parsed.collapsedProjectCwds ?? []) {
134+ if (typeof cwd === "string" && cwd.length > 0) {
135+ persistedCollapsedProjectCwds.add(cwd);
136+ }
137+ }
119138 for (const cwd of parsed.expandedProjectCwds ?? []) {
120139 if (typeof cwd === "string" && cwd.length > 0) {
121140 persistedExpandedProjectCwds.add(cwd);
@@ -128,17 +147,20 @@ function hydratePersistedProjectState(parsed: PersistedUiState): void { · 6 unmodified lines
128147 }
129148 }
130149
131−function persistState(state: UiState): void {
150+export function persistState(state: UiState): void {
132151 if (typeof window === "undefined") {
133152 return;
134153 }
135154 try {
155+ // Persist collapsed cwds explicitly so an empty/missing field unambiguously
156+ // means "first install" rather than "user collapsed everything"; without
157+ // this, the syncProjects fallback would re-expand all rows on next launch.
158+ const collapsedProjectCwds = Object.entries(state.projectExpandedById)
159+ .filter(([, expanded]) => !expanded)
160+ .flatMap(([logicalKey]) => currentProjectCwdsByLogicalKey.get(logicalKey) ?? []);
136161 const expandedProjectCwds = Object.entries(state.projectExpandedById)
137162 .filter(([, expanded]) => expanded)
138− .flatMap(([projectId]) => {
139− const cwd = currentProjectCwdById.get(projectId);
140− return cwd ? [cwd] : [];
141− });
163+ .flatMap(([logicalKey]) => currentProjectCwdsByLogicalKey.get(logicalKey) ?? []);
142164 const projectOrderCwds = state.projectOrder.flatMap((projectId) => {
143165 const cwd = currentProjectCwdById.get(projectId);
144166 return cwd ? [cwd] : [];
@@ -154,6 +176,7 @@ function persistState(state: UiState): void { · 9 unmodified lines
154176 window.localStorage.setItem(
155177 PERSISTED_STATE_KEY,
156178 JSON.stringify({
179+ collapsedProjectCwds,
157180 expandedProjectCwds,
158181 projectOrderCwds,
159182 threadChangedFilesExpandedById,
@@ -211,12 +234,40 @@ function nestedBooleanRecordsEqual( · 51 unmodified lines
211234
212235 export function syncProjects(state: UiState, projects: readonly SyncProjectInput[]): UiState {
213236 const previousProjectCwdById = new Map(currentProjectCwdById);
214− const previousProjectIdByCwd = new Map(
215− [...previousProjectCwdById.entries()].map(([projectId, cwd]) => [cwd, projectId] as const),
216− );
237+ const previousLogicalKeyByPhysicalKey = new Map(currentLogicalKeyByPhysicalKey);
217238 currentProjectCwdById.clear();
239+ currentLogicalKeyByPhysicalKey.clear();
218240 for (const project of projects) {
219241 currentProjectCwdById.set(project.key, project.cwd);
242+ currentLogicalKeyByPhysicalKey.set(project.key, project.logicalKey);
243+ }
244+ currentProjectCwdsByLogicalKey.clear();
245+ for (const project of projects) {
246+ const cwds = currentProjectCwdsByLogicalKey.get(project.logicalKey);
247+ if (cwds) {
248+ if (!cwds.includes(project.cwd)) {
249+ cwds.push(project.cwd);
250+ }
251+ } else {
252+ currentProjectCwdsByLogicalKey.set(project.logicalKey, [project.cwd]);
253+ }
254+ }
255+ // Build reverse map: for each new logical key, which previous logical keys
256+ // did its member projects live under? Lets us preserve expand state when a
257+ // project's logical key changes (e.g. late-arriving repo metadata flips the
258+ // group identity).
259+ const previousLogicalKeysByNewLogicalKey = new Map<string, Set<string>>();
260+ for (const project of projects) {
261+ const previousLogicalKey = previousLogicalKeyByPhysicalKey.get(project.key);
262+ if (!previousLogicalKey || previousLogicalKey === project.logicalKey) {
263+ continue;
264+ }
265+ const set = previousLogicalKeysByNewLogicalKey.get(project.logicalKey);
266+ if (set) {
267+ set.add(previousLogicalKey);
268+ } else {
269+ previousLogicalKeysByNewLogicalKey.set(project.logicalKey, new Set([previousLogicalKey]));
270+ }
220271 }
221272 const cwdMappingChanged =
222273 previousProjectCwdById.size !== currentProjectCwdById.size ||
@@ -228,14 +279,38 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput · 5 unmodified lines
228279 persistedProjectOrderCwds.map((cwd, index) => [cwd, index] as const),
229280 );
230281 const mappedProjects = projects.map((project, index) => {
231− const previousProjectIdForCwd = previousProjectIdByCwd.get(project.cwd);
232− const expanded =
233− previousExpandedById[project.key] ??
234− (previousProjectIdForCwd ? previousExpandedById[previousProjectIdForCwd] : undefined) ??
235− (persistedExpandedProjectCwds.size > 0
236− ? persistedExpandedProjectCwds.has(project.cwd)
237− : true);
238− nextExpandedById[project.key] = expanded;
282+ if (!(project.logicalKey in nextExpandedById)) {
283+ const groupCwds = currentProjectCwdsByLogicalKey.get(project.logicalKey) ?? [project.cwd];
284+ const fallbackFromPreviousLogicalKey = (() => {
285+ const previousKeys = previousLogicalKeysByNewLogicalKey.get(project.logicalKey);
286+ if (!previousKeys) {
287+ return undefined;
288+ }
289+ for (const previousKey of previousKeys) {
290+ if (previousKey in previousExpandedById) {
291+ return previousExpandedById[previousKey];
292+ }
293+ }
294+ return undefined;
295+ })();
296+ const fallbackFromPersistedShape = (() => {
297+ if (groupCwds.some((cwd) => persistedExpandedProjectCwds.has(cwd))) {
298+ return true;
299+ }
300+ if (groupCwds.some((cwd) => persistedCollapsedProjectCwds.has(cwd))) {
301+ return false;
302+ }
303+ if (persistedProjectStateUsesLegacyShape && persistedExpandedProjectCwds.size > 0) {
304+ return false;
305+ }
306+ return true;
307+ })();
308+ const expanded =
309+ previousExpandedById[project.logicalKey] ??
310+ fallbackFromPreviousLogicalKey ??
311+ fallbackFromPersistedShape;
312+ nextExpandedById[project.logicalKey] = expanded;
313+ }
239314 return {
240315 id: project.key,
241316 cwd: project.cwd,
@@ -246,6 +321,7 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput · 4 unmodified lines
246321 const nextProjectOrder =
247322 state.projectOrder.length > 0
248323 ? (() => {
324+ const currentProjectIds = new Set(mappedProjects.map((project) => project.id));
249325 const nextProjectIdByCwd = new Map(
250326 mappedProjects.map((project) => [project.cwd, project.id] as const),
251327 );
@@ -254,7 +330,7 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput · 2 unmodified lines
254330
255331 for (const projectId of state.projectOrder) {
256332 const matchedProjectId =
257− (projectId in nextExpandedById ? projectId : undefined) ??
333+ (currentProjectIds.has(projectId) ? projectId : undefined) ??
258334 (() => {
259335 const previousCwd = previousProjectCwdById.get(projectId);
260336 return previousCwd ? nextProjectIdByCwd.get(previousCwd) : undefined;