authorgravatar for mwolson@member.fsf.orgMike Olson <mwolson@member.fsf.org> 2026-04-20 02:29:56-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-19 23:29:56-07:00
logde05b0c94e257e69afc346ae6daaea85fcfb4e65
treeb531b5a6323f84e54e4764f4dac9f9beb7de91f5
parent6d1505c9fb6ef9a7e147133bb9827f2f36e6945c
signature Signed by PGP key B5690EEEBB952194

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


8 files changed, 398 insertions(+), 44 deletions(-)

apps/web/src/components/Sidebar.logic.test.ts+36
...@@ -309,6 +309,42 @@ describe("orderItemsByPreferredIds", () => {...@@ -309,6 +309,42 @@ describe("orderItemsByPreferredIds", () => {
309 ProjectId.make("project-1"),309 ProjectId.make("project-1"),
310 ]);310 ]);
311 });311 });
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 });
312});348});
313349
314describe("resolveAdjacentThreadId", () => {350describe("resolveAdjacentThreadId", () => {
apps/web/src/components/Sidebar.tsx+6-2
...@@ -168,7 +168,11 @@ import { CommandDialogTrigger } from "./ui/command";...@@ -168,7 +168,11 @@ import { CommandDialogTrigger } from "./ui/command";
168import { readEnvironmentApi } from "../environmentApi";168import { readEnvironmentApi } from "../environmentApi";
169import { useSettings, useUpdateSettings } from "~/hooks/useSettings";169import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
170import { useServerKeybindings } from "../rpc/serverState";170import { useServerKeybindings } from "../rpc/serverState";
171import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey } from "../logicalProject";171import {
172 derivePhysicalProjectKey,
173 deriveProjectGroupingOverrideKey,
174 getProjectOrderKey,
175} from "../logicalProject";
172import {176import {
173 useSavedEnvironmentRegistryStore,177 useSavedEnvironmentRegistryStore,
174 useSavedEnvironmentRuntimeStore,178 useSavedEnvironmentRuntimeStore,
...@@ -2708,7 +2712,7 @@ export default function Sidebar() {...@@ -2708,7 +2712,7 @@ export default function Sidebar() {
2708 return orderItemsByPreferredIds({2712 return orderItemsByPreferredIds({
2709 items: projects,2713 items: projects,
2710 preferredIds: projectOrder,2714 preferredIds: projectOrder,
2711 getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),2715 getId: getProjectOrderKey,
2712 });2716 });
2713 }, [projectOrder, projects]);2717 }, [projectOrder, projects]);
27142718
apps/web/src/environments/runtime/service.ts+9-1
...@@ -61,7 +61,11 @@ import { useTerminalStateStore } from "~/terminalStateStore";...@@ -61,7 +61,11 @@ import { useTerminalStateStore } from "~/terminalStateStore";
61import { useUiStateStore } from "~/uiStateStore";61import { useUiStateStore } from "~/uiStateStore";
62import { WsTransport } from "../../rpc/wsTransport";62import { WsTransport } from "../../rpc/wsTransport";
63import { createWsRpcClient, type WsRpcClient } from "../../rpc/wsRpcClient";63import { createWsRpcClient, type WsRpcClient } from "../../rpc/wsRpcClient";
64import { derivePhysicalProjectKey } from "../../logicalProject";64import {
65 deriveLogicalProjectKeyFromSettings,
66 derivePhysicalProjectKey,
67} from "../../logicalProject";
68import { getClientSettings } from "~/hooks/useSettings";
6569
66type EnvironmentServiceState = {70type EnvironmentServiceState = {
67 readonly queryClient: QueryClient;71 readonly queryClient: QueryClient;
...@@ -468,9 +472,11 @@ function coalesceOrchestrationUiEvents(...@@ -468,9 +472,11 @@ function coalesceOrchestrationUiEvents(
468472
469function syncProjectUiFromStore() {473function syncProjectUiFromStore() {
470 const projects = selectProjectsAcrossEnvironments(useStore.getState());474 const projects = selectProjectsAcrossEnvironments(useStore.getState());
475 const clientSettings = getClientSettings();
471 useUiStateStore.getState().syncProjects(476 useUiStateStore.getState().syncProjects(
472 projects.map((project) => ({477 projects.map((project) => ({
473 key: derivePhysicalProjectKey(project),478 key: derivePhysicalProjectKey(project),
479 logicalKey: deriveLogicalProjectKeyFromSettings(project, clientSettings),
474 cwd: project.cwd,480 cwd: project.cwd,
475 })),481 })),
476 );482 );
...@@ -541,9 +547,11 @@ function applyRecoveredEventBatch(...@@ -541,9 +547,11 @@ function applyRecoveredEventBatch(
541 useStore.getState().applyOrchestrationEvents(uiEvents, environmentId);547 useStore.getState().applyOrchestrationEvents(uiEvents, environmentId);
542 if (needsProjectUiSync) {548 if (needsProjectUiSync) {
543 const projects = selectProjectsAcrossEnvironments(useStore.getState());549 const projects = selectProjectsAcrossEnvironments(useStore.getState());
550 const clientSettings = getClientSettings();
544 useUiStateStore.getState().syncProjects(551 useUiStateStore.getState().syncProjects(
545 projects.map((project) => ({552 projects.map((project) => ({
546 key: derivePhysicalProjectKey(project),553 key: derivePhysicalProjectKey(project),
554 logicalKey: deriveLogicalProjectKeyFromSettings(project, clientSettings),
547 cwd: project.cwd,555 cwd: project.cwd,
548 })),556 })),
549 );557 );
apps/web/src/hooks/useHandleNewThread.ts+2-2
...@@ -10,7 +10,7 @@ import {...@@ -10,7 +10,7 @@ import {
10} from "../composerDraftStore";10} from "../composerDraftStore";
11import { newDraftId, newThreadId } from "../lib/utils";11import { newDraftId, newThreadId } from "../lib/utils";
12import { orderItemsByPreferredIds } from "../components/Sidebar.logic";12import { orderItemsByPreferredIds } from "../components/Sidebar.logic";
13import { deriveLogicalProjectKeyFromSettings } from "../logicalProject";13import { deriveLogicalProjectKeyFromSettings, getProjectOrderKey } from "../logicalProject";
14import { selectProjectsAcrossEnvironments, useStore } from "../store";14import { selectProjectsAcrossEnvironments, useStore } from "../store";
15import { createThreadSelectorByRef } from "../storeSelectors";15import { createThreadSelectorByRef } from "../storeSelectors";
16import { resolveThreadRouteTarget } from "../threadRoutes";16import { resolveThreadRouteTarget } from "../threadRoutes";
...@@ -169,7 +169,7 @@ export function useHandleNewThread() {...@@ -169,7 +169,7 @@ export function useHandleNewThread() {
169 return orderItemsByPreferredIds({169 return orderItemsByPreferredIds({
170 items: projects,170 items: projects,
171 preferredIds: projectOrder,171 preferredIds: projectOrder,
172 getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),172 getId: getProjectOrderKey,
173 });173 });
174 }, [projectOrder, projects]);174 }, [projectOrder, projects]);
175 const handleNewThread = useNewThreadState();175 const handleNewThread = useNewThreadState();
apps/web/src/hooks/useSettings.ts+9
...@@ -123,6 +123,15 @@ function splitPatch(patch: Partial<UnifiedSettings>): {...@@ -123,6 +123,15 @@ function splitPatch(patch: Partial<UnifiedSettings>): {
123 * only re-render when the slice they care about changes.123 * only re-render when the slice they care about changes.
124 */124 */
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 */
131export function getClientSettings(): ClientSettings {
132 return getClientSettingsSnapshot();
133}
134
126export function useSettings<T = UnifiedSettings>(selector?: (s: UnifiedSettings) => T): T {135export function useSettings<T = UnifiedSettings>(selector?: (s: UnifiedSettings) => T): T {
127 const serverSettings = useServerSettings();136 const serverSettings = useServerSettings();
128 const clientSettings = useSyncExternalStore(137 const clientSettings = useSyncExternalStore(
apps/web/src/logicalProject.ts+7
...@@ -65,6 +65,13 @@ export function deriveProjectGroupingOverrideKey(...@@ -65,6 +65,13 @@ export function deriveProjectGroupingOverrideKey(
65 return derivePhysicalProjectKey(project);65 return derivePhysicalProjectKey(project);
66}66}
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.
71export function getProjectOrderKey(project: Pick<Project, "environmentId" | "cwd">): string {
72 return derivePhysicalProjectKey(project);
73}
74
68export function resolveProjectGroupingMode(75export function resolveProjectGroupingMode(
69 project: Pick<Project, "environmentId" | "cwd">,76 project: Pick<Project, "environmentId" | "cwd">,
70 settings: ProjectGroupingSettings,77 settings: ProjectGroupingSettings,
apps/web/src/uiStateStore.test.ts+233-19
...@@ -1,9 +1,13 @@...@@ -1,9 +1,13 @@
1import { ProjectId, ThreadId } from "@t3tools/contracts";1import { ProjectId, ThreadId } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";2import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
33
4import {4import {
5 clearThreadUi,5 clearThreadUi,
6 hydratePersistedProjectState,
6 markThreadUnread,7 markThreadUnread,
8 PERSISTED_STATE_KEY,
9 type PersistedUiState,
10 persistState,
7 reorderProjects,11 reorderProjects,
8 setProjectExpanded,12 setProjectExpanded,
9 setThreadChangedFilesExpanded,13 setThreadChangedFilesExpanded,
...@@ -183,40 +187,43 @@ describe("uiStateStore pure functions", () => {...@@ -183,40 +187,43 @@ describe("uiStateStore pure functions", () => {
183 });187 });
184188
185 const next = syncProjects(initialState, [189 const next = syncProjects(initialState, [
186 { key: project1, cwd: "/tmp/project-1" },190 { key: project1, logicalKey: project1, cwd: "/tmp/project-1" },
187 { key: project2, cwd: "/tmp/project-2" },191 { key: project2, logicalKey: project2, cwd: "/tmp/project-2" },
188 { key: project3, cwd: "/tmp/project-3" },192 { key: project3, logicalKey: project3, cwd: "/tmp/project-3" },
189 ]);193 ]);
190194
191 expect(next.projectOrder).toEqual([project2, project1, project3]);195 expect(next.projectOrder).toEqual([project2, project1, project3]);
192 expect(next.projectExpandedById[project2]).toBe(false);196 expect(next.projectExpandedById[project2]).toBe(false);
193 });197 });
194198
195 it("syncProjects preserves manual order when a project is recreated with the same cwd", () => {199 it("syncProjects preserves manual order across project id churn at the same cwd", () => {
196 const oldProject1 = ProjectId.make("project-1");200 // Under the current design, physical key and logical key are both
197 const oldProject2 = ProjectId.make("project-2");201 // cwd-derived, so an internal project-id change doesn't alter the store
198 const recreatedProject2 = ProjectId.make("project-2b");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";
199 const initialState = syncProjects(206 const initialState = syncProjects(
200 makeUiState({207 makeUiState({
201 projectExpandedById: {208 projectExpandedById: {
202 [oldProject1]: true,209 [keyProject1]: true,
203 [oldProject2]: false,210 [keyProject2]: false,
204 },211 },
205 projectOrder: [oldProject2, oldProject1],212 projectOrder: [keyProject2, keyProject1],
206 }),213 }),
207 [214 [
208 { key: oldProject1, cwd: "/tmp/project-1" },215 { key: keyProject1, logicalKey: keyProject1, cwd: "/tmp/project-1" },
209 { key: oldProject2, cwd: "/tmp/project-2" },216 { key: keyProject2, logicalKey: keyProject2, cwd: "/tmp/project-2" },
210 ],217 ],
211 );218 );
212219
213 const next = syncProjects(initialState, [220 const next = syncProjects(initialState, [
214 { key: oldProject1, cwd: "/tmp/project-1" },221 { key: keyProject1, logicalKey: keyProject1, cwd: "/tmp/project-1" },
215 { key: recreatedProject2, cwd: "/tmp/project-2" },222 { key: keyProject2, logicalKey: keyProject2, cwd: "/tmp/project-2" },
216 ]);223 ]);
217224
218 expect(next.projectOrder).toEqual([recreatedProject2, oldProject1]);225 expect(next.projectOrder).toEqual([keyProject2, keyProject1]);
219 expect(next.projectExpandedById[recreatedProject2]).toBe(false);226 expect(next.projectExpandedById[keyProject2]).toBe(false);
220 });227 });
221228
222 it("syncProjects returns a new state when only project cwd changes", () => {229 it("syncProjects returns a new state when only project cwd changes", () => {
...@@ -228,16 +235,70 @@ describe("uiStateStore pure functions", () => {...@@ -228,16 +235,70 @@ describe("uiStateStore pure functions", () => {
228 },235 },
229 projectOrder: [project1],236 projectOrder: [project1],
230 }),237 }),
231 [{ key: project1, cwd: "/tmp/project-1" }],238 [{ key: project1, logicalKey: project1, cwd: "/tmp/project-1" }],
232 );239 );
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
236 expect(next).not.toBe(initialState);245 expect(next).not.toBe(initialState);
237 expect(next.projectOrder).toEqual([project1]);246 expect(next.projectOrder).toEqual([project1]);
238 expect(next.projectExpandedById[project1]).toBe(false);247 expect(next.projectExpandedById[project1]).toBe(false);
239 });248 });
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
241 it("syncThreads prunes missing thread UI state", () => {302 it("syncThreads prunes missing thread UI state", () => {
242 const thread1 = ThreadId.make("thread-1");303 const thread1 = ThreadId.make("thread-1");
243 const thread2 = ThreadId.make("thread-2");304 const thread2 = ThreadId.make("thread-2");
...@@ -346,3 +407,156 @@ describe("uiStateStore pure functions", () => {...@@ -346,3 +407,156 @@ describe("uiStateStore pure functions", () => {
346 expect(next.threadChangedFilesExpandedById).toEqual({});407 expect(next.threadChangedFilesExpandedById).toEqual({});
347 });408 });
348});409});
410
411describe("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 @@...@@ -1,7 +1,7 @@
1import { Debouncer } from "@tanstack/react-pacer";1import { Debouncer } from "@tanstack/react-pacer";
2import { create } from "zustand";2import { create } from "zustand";
33
4const PERSISTED_STATE_KEY = "t3code:ui-state:v1";4export const PERSISTED_STATE_KEY = "t3code:ui-state:v1";
5const LEGACY_PERSISTED_STATE_KEYS = [5const LEGACY_PERSISTED_STATE_KEYS = [
6 "t3code:renderer-state:v8",6 "t3code:renderer-state:v8",
7 "t3code:renderer-state:v7",7 "t3code:renderer-state:v7",
...@@ -15,7 +15,8 @@ const LEGACY_PERSISTED_STATE_KEYS = [...@@ -15,7 +15,8 @@ const LEGACY_PERSISTED_STATE_KEYS = [
15 "codething:renderer-state:v1",15 "codething:renderer-state:v1",
16] as const;16] as const;
1717
18interface PersistedUiState {18export interface PersistedUiState {
19 collapsedProjectCwds?: string[];
19 expandedProjectCwds?: string[];20 expandedProjectCwds?: string[];
20 projectOrderCwds?: string[];21 projectOrderCwds?: string[];
21 threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>;22 threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>;
...@@ -34,7 +35,10 @@ export interface UiThreadState {...@@ -34,7 +35,10 @@ export interface UiThreadState {
34export interface UiState extends UiProjectState, UiThreadState {}35export interface UiState extends UiProjectState, UiThreadState {}
3536
36export interface SyncProjectInput {37export interface SyncProjectInput {
38 /** Physical project key (env + cwd). Used for manual sort order. */
37 key: string;39 key: string;
40 /** Logical group key. Used for expand/collapse state. */
41 logicalKey: string;
38 cwd: string;42 cwd: string;
39}43}
4044
...@@ -50,9 +54,17 @@ const initialState: UiState = {...@@ -50,9 +54,17 @@ const initialState: UiState = {
50 threadChangedFilesExpandedById: {},54 threadChangedFilesExpandedById: {},
51};55};
5256
57const persistedCollapsedProjectCwds = new Set<string>();
53const persistedExpandedProjectCwds = new Set<string>();58const persistedExpandedProjectCwds = new Set<string>();
54const persistedProjectOrderCwds: string[] = [];59const 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.
64let persistedProjectStateUsesLegacyShape = false;
55const currentProjectCwdById = new Map<string, string>();65const currentProjectCwdById = new Map<string, string>();
66const currentProjectCwdsByLogicalKey = new Map<string, string[]>();
67const currentLogicalKeyByPhysicalKey = new Map<string, string>();
56let legacyKeysCleanedUp = false;68let legacyKeysCleanedUp = false;
5769
58function readPersistedState(): UiState {70function readPersistedState(): UiState {
...@@ -113,9 +125,16 @@ function sanitizePersistedThreadChangedFilesExpanded(...@@ -113,9 +125,16 @@ function sanitizePersistedThreadChangedFilesExpanded(
113 return nextState;125 return nextState;
114}126}
115127
116function hydratePersistedProjectState(parsed: PersistedUiState): void {128export function hydratePersistedProjectState(parsed: PersistedUiState): void {
129 persistedCollapsedProjectCwds.clear();
117 persistedExpandedProjectCwds.clear();130 persistedExpandedProjectCwds.clear();
118 persistedProjectOrderCwds.length = 0;131 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 }
119 for (const cwd of parsed.expandedProjectCwds ?? []) {138 for (const cwd of parsed.expandedProjectCwds ?? []) {
120 if (typeof cwd === "string" && cwd.length > 0) {139 if (typeof cwd === "string" && cwd.length > 0) {
121 persistedExpandedProjectCwds.add(cwd);140 persistedExpandedProjectCwds.add(cwd);
...@@ -128,17 +147,20 @@ function hydratePersistedProjectState(parsed: PersistedUiState): void {...@@ -128,17 +147,20 @@ function hydratePersistedProjectState(parsed: PersistedUiState): void {
128 }147 }
129}148}
130149
131function persistState(state: UiState): void {150export function persistState(state: UiState): void {
132 if (typeof window === "undefined") {151 if (typeof window === "undefined") {
133 return;152 return;
134 }153 }
135 try {154 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) ?? []);
136 const expandedProjectCwds = Object.entries(state.projectExpandedById)161 const expandedProjectCwds = Object.entries(state.projectExpandedById)
137 .filter(([, expanded]) => expanded)162 .filter(([, expanded]) => expanded)
138 .flatMap(([projectId]) => {163 .flatMap(([logicalKey]) => currentProjectCwdsByLogicalKey.get(logicalKey) ?? []);
139 const cwd = currentProjectCwdById.get(projectId);
140 return cwd ? [cwd] : [];
141 });
142 const projectOrderCwds = state.projectOrder.flatMap((projectId) => {164 const projectOrderCwds = state.projectOrder.flatMap((projectId) => {
143 const cwd = currentProjectCwdById.get(projectId);165 const cwd = currentProjectCwdById.get(projectId);
144 return cwd ? [cwd] : [];166 return cwd ? [cwd] : [];
...@@ -154,6 +176,7 @@ function persistState(state: UiState): void {...@@ -154,6 +176,7 @@ function persistState(state: UiState): void {
154 window.localStorage.setItem(176 window.localStorage.setItem(
155 PERSISTED_STATE_KEY,177 PERSISTED_STATE_KEY,
156 JSON.stringify({178 JSON.stringify({
179 collapsedProjectCwds,
157 expandedProjectCwds,180 expandedProjectCwds,
158 projectOrderCwds,181 projectOrderCwds,
159 threadChangedFilesExpandedById,182 threadChangedFilesExpandedById,
...@@ -211,12 +234,40 @@ function nestedBooleanRecordsEqual(...@@ -211,12 +234,40 @@ function nestedBooleanRecordsEqual(
211234
212export function syncProjects(state: UiState, projects: readonly SyncProjectInput[]): UiState {235export function syncProjects(state: UiState, projects: readonly SyncProjectInput[]): UiState {
213 const previousProjectCwdById = new Map(currentProjectCwdById);236 const previousProjectCwdById = new Map(currentProjectCwdById);
214 const previousProjectIdByCwd = new Map(237 const previousLogicalKeyByPhysicalKey = new Map(currentLogicalKeyByPhysicalKey);
215 [...previousProjectCwdById.entries()].map(([projectId, cwd]) => [cwd, projectId] as const),
216 );
217 currentProjectCwdById.clear();238 currentProjectCwdById.clear();
239 currentLogicalKeyByPhysicalKey.clear();
218 for (const project of projects) {240 for (const project of projects) {
219 currentProjectCwdById.set(project.key, project.cwd);241 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 }
220 }271 }
221 const cwdMappingChanged =272 const cwdMappingChanged =
222 previousProjectCwdById.size !== currentProjectCwdById.size ||273 previousProjectCwdById.size !== currentProjectCwdById.size ||
...@@ -228,14 +279,38 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput...@@ -228,14 +279,38 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput
228 persistedProjectOrderCwds.map((cwd, index) => [cwd, index] as const),279 persistedProjectOrderCwds.map((cwd, index) => [cwd, index] as const),
229 );280 );
230 const mappedProjects = projects.map((project, index) => {281 const mappedProjects = projects.map((project, index) => {
231 const previousProjectIdForCwd = previousProjectIdByCwd.get(project.cwd);282 if (!(project.logicalKey in nextExpandedById)) {
232 const expanded =283 const groupCwds = currentProjectCwdsByLogicalKey.get(project.logicalKey) ?? [project.cwd];
233 previousExpandedById[project.key] ??284 const fallbackFromPreviousLogicalKey = (() => {
234 (previousProjectIdForCwd ? previousExpandedById[previousProjectIdForCwd] : undefined) ??285 const previousKeys = previousLogicalKeysByNewLogicalKey.get(project.logicalKey);
235 (persistedExpandedProjectCwds.size > 0286 if (!previousKeys) {
236 ? persistedExpandedProjectCwds.has(project.cwd)287 return undefined;
237 : true);288 }
238 nextExpandedById[project.key] = expanded;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 }
239 return {314 return {
240 id: project.key,315 id: project.key,
241 cwd: project.cwd,316 cwd: project.cwd,
...@@ -246,6 +321,7 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput...@@ -246,6 +321,7 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput
246 const nextProjectOrder =321 const nextProjectOrder =
247 state.projectOrder.length > 0322 state.projectOrder.length > 0
248 ? (() => {323 ? (() => {
324 const currentProjectIds = new Set(mappedProjects.map((project) => project.id));
249 const nextProjectIdByCwd = new Map(325 const nextProjectIdByCwd = new Map(
250 mappedProjects.map((project) => [project.cwd, project.id] as const),326 mappedProjects.map((project) => [project.cwd, project.id] as const),
251 );327 );
...@@ -254,7 +330,7 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput...@@ -254,7 +330,7 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput
254330
255 for (const projectId of state.projectOrder) {331 for (const projectId of state.projectOrder) {
256 const matchedProjectId =332 const matchedProjectId =
257 (projectId in nextExpandedById ? projectId : undefined) ??333 (currentProjectIds.has(projectId) ? projectId : undefined) ??
258 (() => {334 (() => {
259 const previousCwd = previousProjectCwdById.get(projectId);335 const previousCwd = previousProjectCwdById.get(projectId);
260 return previousCwd ? nextProjectIdByCwd.get(previousCwd) : undefined;336 return previousCwd ? nextProjectIdByCwd.get(previousCwd) : undefined;