From 83c800879a35a6abdd52f85cd2e3596d19fc053b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sun, 31 May 2026 18:48:38 +0100 Subject: [PATCH] fix(web): stop right pane width from drifting across tasks (#1201) --- .../task/dockview-layout-restore.ts | 29 ++++++-- .../components/task/dockview-layout-setup.ts | 33 --------- .../state/dockview-env-switch-pinned.test.ts | 6 ++ apps/web/lib/state/dockview-env-switch.ts | 19 ++++- .../dockview-layout-builders-fixups.test.ts | 38 ++++++++-- .../web/lib/state/dockview-layout-builders.ts | 72 ++++++++++++++----- 6 files changed, 135 insertions(+), 62 deletions(-) diff --git a/apps/web/components/task/dockview-layout-restore.ts b/apps/web/components/task/dockview-layout-restore.ts index f6c12ad2e2..57242bfdf4 100644 --- a/apps/web/components/task/dockview-layout-restore.ts +++ b/apps/web/components/task/dockview-layout-restore.ts @@ -2,10 +2,12 @@ import type { DockviewReadyEvent, SerializedDockview } from "dockview-react"; import type { StoreApi } from "zustand"; import { useDockviewStore } from "@/lib/state/dockview-store"; import { applyLayoutFixups } from "@/lib/state/dockview-layout-builders"; +import { savedRightColumnWidth } from "@/lib/state/dockview-env-switch"; import { isLayoutShapeHealthy } from "@/lib/state/dockview-layout-health"; import { measureDockviewContainer } from "@/lib/state/dockview-measure"; import { isEnvScopedDockviewComponent } from "@/lib/state/dockview-env-scoped-components"; import type { LayoutState } from "@/lib/state/layout-manager"; +import { setPinnedTarget } from "@/lib/state/layout-manager"; import type { AppState } from "@/lib/state/store"; import { getEnvLayout, getEnvMaximizeState, removeEnvMaximizeState } from "@/lib/local-storage"; import { createDebugLogger, IS_DEBUG } from "@/lib/debug/log"; @@ -152,11 +154,21 @@ type SavedMax = ReturnType; * maximize state into the store. Single source of truth for both restore * call sites — keeping `preMaximizeLayout` and `maximizedGroupId` in lockstep. */ -function applySavedMaximize(api: DockviewReadyEvent["api"], savedMax: NonNullable): void { +function applySavedMaximize( + api: DockviewReadyEvent["api"], + savedMax: NonNullable, + savedRightWidth?: number, +): void { api.fromJSON(savedMax.maximizedDockviewJson as SerializedDockview); const { width, height } = measureDockviewContainer(api); api.layout(width, height); const ids = applyLayoutFixups(api); + // The maximize JSON is 2-column — captureRightTarget skips it (sv.length < 3). + // Seed the right target directly so enforcePinnedTargets can snap the column + // back to the saved width when the user exits maximize mode. + if (savedRightWidth !== undefined && savedRightWidth > 0) { + setPinnedTarget("right", savedRightWidth); + } useDockviewStore.setState({ ...ids, preMaximizeLayout: savedMax.preMaximizeLayout as unknown as LayoutState, @@ -164,12 +176,19 @@ function applySavedMaximize(api: DockviewReadyEvent["api"], savedMax: NonNullabl }); } -function applyFixupsWithMaximize(api: DockviewReadyEvent["api"], envId: string | null): void { +function applyFixupsWithMaximize( + api: DockviewReadyEvent["api"], + envId: string | null, + savedRightWidth?: number, +): void { const savedMax = envId ? getEnvMaximizeState(envId) : null; if (savedMax) { - applySavedMaximize(api, savedMax); + applySavedMaximize(api, savedMax, savedRightWidth); } else { - const ids = applyLayoutFixups(api); + // Anchor the right column to its per-env saved width (see + // `captureRightTarget`) so a page reload restores the task's remembered + // width instead of resetting it to the default. + const ids = applyLayoutFixups(api, savedRightWidth); useDockviewStore.setState(ids); } } @@ -229,7 +248,7 @@ function tryRestoreEnvLayout( }); } api.fromJSON(sanitized as SerializedDockview); - applyFixupsWithMaximize(api, envId); + applyFixupsWithMaximize(api, envId, savedRightColumnWidth(sanitized as SerializedDockview)); return true; } diff --git a/apps/web/components/task/dockview-layout-setup.ts b/apps/web/components/task/dockview-layout-setup.ts index bc8fc87a3d..4bfff11ab0 100644 --- a/apps/web/components/task/dockview-layout-setup.ts +++ b/apps/web/components/task/dockview-layout-setup.ts @@ -153,36 +153,6 @@ export function setupSashDragCapToggle(api: DockviewReadyEvent["api"]): () => vo }; } -function trackPinnedWidths(api: DockviewReadyEvent["api"]): void { - const store = useDockviewStore.getState(); - if (store.isRestoringLayout) return; - if (api.hasMaximizedGroup() || store.preMaximizeLayout !== null) return; - const sv = getRootSplitview(api); - if (!sv || sv.length < 2) return; - try { - // The sidebar width is NOT tracked here: it is a global pref written only - // by a genuine sash drag (see setupSashDragCapToggle). Capturing the live - // width on every layout change would persist dockview's transient - // proportional-rebalance widths (e.g. 320→213 on a monitor shrink) and - // fight enforcePinnedTargets. Right column is still mirrored below. - // - // Right column is the last grid index when present. Skip when there is - // no right column (compact preset, rightPanelsVisible=false). - if (store.rightPanelsVisible) { - const rightIdx = sv.length - 1; - const rightW = sv.getViewSize(rightIdx); - if (rightW > 50) { - const current = store.pinnedWidths.get("right"); - if (current !== rightW) { - store.setPinnedWidth("right", rightW); - } - } - } - } catch { - /* noop */ - } -} - /** * Keep dockview's internal grid width in sync with the live DOM container. * @@ -225,11 +195,8 @@ export function setupGroupTracking(api: DockviewReadyEvent["api"]): () => void { useDockviewStore.setState({ activeGroupId: group?.id ?? null }); }); useDockviewStore.setState({ activeGroupId: api.activeGroup?.id ?? null }); - const d2 = api.onDidLayoutChange(() => trackPinnedWidths(api)); - trackPinnedWidths(api); return () => { d1.dispose(); - d2.dispose(); }; } diff --git a/apps/web/lib/state/dockview-env-switch-pinned.test.ts b/apps/web/lib/state/dockview-env-switch-pinned.test.ts index d9d0ceefe8..2828b14bec 100644 --- a/apps/web/lib/state/dockview-env-switch-pinned.test.ts +++ b/apps/web/lib/state/dockview-env-switch-pinned.test.ts @@ -43,6 +43,7 @@ vi.mock("./layout-manager", () => { import { getEnvLayout } from "@/lib/local-storage"; import { getRootSplitview, savedLayoutMatchesLive } from "./layout-manager"; +import { applyLayoutFixups } from "./dockview-layout-builders"; function makeMockApi(): EnvSwitchParams["api"] { return { @@ -188,5 +189,10 @@ describe("performEnvSwitch — pinned column resize after fast-path", () => { expect(resizeView).toHaveBeenCalledWith(0, 300); expect(resizeView).toHaveBeenCalledWith(2, 420); expect(resizeView).not.toHaveBeenCalledWith(1, expect.anything()); + + // The saved right width is also forwarded to applyLayoutFixups so the + // fixups pass anchors the right target to the per-env saved width (420) + // rather than re-capturing dockview's transient post-fromJSON live size. + expect(applyLayoutFixups).toHaveBeenCalledWith(expect.anything(), 420); }); }); diff --git a/apps/web/lib/state/dockview-env-switch.ts b/apps/web/lib/state/dockview-env-switch.ts index a2d611e3b1..e80e7f7efd 100644 --- a/apps/web/lib/state/dockview-env-switch.ts +++ b/apps/web/lib/state/dockview-env-switch.ts @@ -334,7 +334,22 @@ function tryFastEnvSwitch(params: EnvSwitchParams): LayoutGroupIds | null { applyPinnedColumnSizes(api, saved as SerializedDockview | null, params.safeWidth); api.layout(params.safeWidth, params.safeHeight); - return applyLayoutFixups(api); + return applyLayoutFixups(api, savedRightColumnWidth(saved as SerializedDockview | null)); +} + +/** + * The per-env saved width of the right column (the last grid-root child) for a + * default-preset layout, or undefined when the saved layout has no distinct + * right column. Forwarded to `applyLayoutFixups` so the fixups pass anchors the + * pinned right target to this stable saved width instead of dockview's + * transient post-`fromJSON` live size (the dockview-wrong-width drift). + */ +export function savedRightColumnWidth(saved: SerializedDockview | null): number | undefined { + if (!saved) return undefined; + const sizes = extractSavedColumnSizes(saved); + if (!sizes || sizes.length < 3) return undefined; + const w = sizes[sizes.length - 1]; + return Number.isFinite(w) && w > 0 ? w : undefined; } /** Extract per-column sizes from a saved SerializedDockview grid root. */ @@ -497,7 +512,7 @@ export function performEnvSwitch(params: EnvSwitchParams): LayoutGroupIds { livePanelIdsAfter: api.panels.map((p) => p.id), }); } - return applyLayoutFixups(api); + return applyLayoutFixups(api, savedRightColumnWidth(saved as SerializedDockview)); } catch (err) { console.warn("performEnvSwitch: fromJSON threw", err); debug("performEnvSwitch: fromJSON threw, falling through to default", { newEnvId, err }); diff --git a/apps/web/lib/state/dockview-layout-builders-fixups.test.ts b/apps/web/lib/state/dockview-layout-builders-fixups.test.ts index 525718d740..8a7c401b9d 100644 --- a/apps/web/lib/state/dockview-layout-builders-fixups.test.ts +++ b/apps/web/lib/state/dockview-layout-builders-fixups.test.ts @@ -54,10 +54,14 @@ const SIDEBAR_GROUP = "group-sidebar"; const CENTER_GROUP = "group-center"; const RIGHT_BOTTOM_GROUP = "group-right-bottom"; +let mockResizeView: ReturnType; + function mockSplitview(sizesByIndex: number[]): void { + mockResizeView = vi.fn(); vi.mocked(getRootSplitview).mockReturnValue({ length: sizesByIndex.length, getViewSize: (idx: number) => sizesByIndex[idx], + resizeView: mockResizeView, } as unknown as NonNullable>); } @@ -117,14 +121,36 @@ describe("applyLayoutFixups — pinned target capture", () => { expect(setPinnedTarget).toHaveBeenCalledWith("sidebar", SIDEBAR_CAP); }); - it("records both targets in a real 3-column layout", () => { - mockSplitview([350, 720, 400]); // sidebar, center, right + it("uses the column default for the pinned right column when no saved width is given", () => { + // Regression (dockview-wrong-width): the default-preset right column must + // anchor to a STABLE width — the per-env saved width, or the column default + // when none — NOT dockview's post-fromJSON live size (400 here). Capturing + // the live size ratcheted the column wider on every restore. Mirrors + // captureSidebarTarget, which anchors to the pref/default, never the live + // size. getPinnedWidth is mocked to 350 (the default). + mockSplitview([350, 720, 400]); // live right = 400 (transient/rescaled) const api = makeApi([SIDEBAR_GROUP, CENTER_GROUP, RIGHT_TOP_GROUP, RIGHT_BOTTOM_GROUP]); applyLayoutFixups(api); expect(setPinnedTarget).toHaveBeenCalledWith("sidebar", 350); - expect(setPinnedTarget).toHaveBeenCalledWith("right", 400); + expect(setPinnedTarget).toHaveBeenCalledWith("right", 350); // default, NOT live 400 + expect(setPinnedTarget).not.toHaveBeenCalledWith("right", 400); + }); + + it("anchors the pinned right target to the saved per-env width, not the live size", () => { + // A restore passes the env's saved right width; it wins over both the live + // size (400) and the default (350) so a deliberately-resized task restores + // its own remembered width. Also asserts that the column is physically + // resized to the stable width (not left at the transient live size). + mockSplitview([350, 720, 400]); + const api = makeApi([SIDEBAR_GROUP, CENTER_GROUP, RIGHT_TOP_GROUP, RIGHT_BOTTOM_GROUP]); + + applyLayoutFixups(api, 420); + + expect(setPinnedTarget).toHaveBeenCalledWith("right", 420); + expect(setPinnedTarget).not.toHaveBeenCalledWith("right", 400); + expect(mockResizeView).toHaveBeenCalledWith(2, 420); }); it("derives the caps from api.width, not the window.innerWidth fallback", () => { @@ -153,11 +179,11 @@ describe("applyLayoutFixups — pinned target capture", () => { expect(setPinnedTarget).toHaveBeenCalledWith("right", 420); }); - it("clamps an over-cap right target down to the cap", () => { - mockSplitview([350, 200, 1200]); // right (1200) exceeds RIGHT_CAP (1029) + it("clamps an over-cap saved right width down to the cap", () => { + mockSplitview([350, 200, 800]); const api = makeApi([SIDEBAR_GROUP, CENTER_GROUP, RIGHT_TOP_GROUP]); - applyLayoutFixups(api); + applyLayoutFixups(api, 1200); // saved 1200 exceeds RIGHT_CAP (1029) expect(setPinnedTarget).toHaveBeenCalledWith("right", RIGHT_CAP); }); diff --git a/apps/web/lib/state/dockview-layout-builders.ts b/apps/web/lib/state/dockview-layout-builders.ts index 5c82a359cd..6512a48c9c 100644 --- a/apps/web/lib/state/dockview-layout-builders.ts +++ b/apps/web/lib/state/dockview-layout-builders.ts @@ -56,7 +56,7 @@ function captureCallerChain(): string { * Apply loose runtime caps so the user can drag freely; the just-restored * widths become the new pinned targets, and `enforcePinnedTargets` restores * the column to that target on every subsequent rebalance. */ -export function applyLayoutFixups(api: DockviewApi): LayoutGroupIds { +export function applyLayoutFixups(api: DockviewApi, savedRightWidth?: number): LayoutGroupIds { const sv = getRootSplitviewImpl(api); captureSidebarTarget(api, sv); @@ -65,7 +65,7 @@ export function applyLayoutFixups(api: DockviewApi): LayoutGroupIds { const oldFiles = api.getPanel("all-files"); if (oldFiles) oldFiles.api.setTitle("Files"); - captureRightTarget(api, sv); + captureRightTarget(api, sv, savedRightWidth); logFixupsCapture(api, sv); @@ -121,28 +121,51 @@ function captureSidebarTarget(api: DockviewApi, sv: any): void { } } +/** Resolve the pinned right column's target width (clamped to the cap): the + * per-env SAVED width when the restore supplies one, else the column default + * for the current measured layout. Never the live splitview size — see + * `captureRightTarget`. */ +function resolveRightTarget( + cap: number, + totalWidth: number | undefined, + savedRightWidth: number | undefined, +): number { + if (savedRightWidth !== undefined && savedRightWidth > 0) return Math.min(savedRightWidth, cap); + const width = + totalWidth ?? + (typeof window !== "undefined" && window.innerWidth > 0 ? window.innerWidth : cap); + return Math.min(getPinnedWidth({ id: "right", pinned: true, groups: [] }, width, undefined), cap); +} + /** Constrain the default layout's right column groups and record the side * column's target width, clamped to the cap. * - * The target is recorded whenever a distinct last column exists - * (`sv.length >= 3`). It must NOT be gated on the well-known RIGHT_TOP/BOTTOM - * group ids: the vscode/preview/plan presets put their side column in a group - * with a generated id, so gating on those ids would skip the capture and let - * the PREVIOUS task's right target leak in — switching to one of those tasks - * would then snap its side column to whatever width the last task left. + * For the DEFAULT preset (the right column is pinned — identified by the + * well-known RIGHT_TOP_GROUP), the target is anchored to a STABLE width: the + * per-env saved width passed by the restore, or the column default when none. + * It is deliberately NOT read from the live splitview: dockview's + * post-`fromJSON` proportional rebalance reports a transient/rescaled size, + * and persisting that as the target ratcheted the right column wider on every + * restore (the dockview-wrong-width drift). The sidebar is handled the same + * way in `captureSidebarTarget` — neither pinned column captures its live size. * - * `sv.length >= 3` still excludes the 2-column global-fallback restore - * (sidebar + center, right panels stripped as env-scoped), where the last - * splitview child is the CENTER column — recording its width as the "right" - * target would inflate the real right column once the full layout materializes - * and persist that into the env layout. */ + * For the vscode/preview/plan presets the side column has a generated group id + * and is NOT pinned, so there is no saved/default width to fall back on; there + * we keep recording the just-restored live width so switching to one of those + * tasks doesn't leak the previous task's right target. + * + * `sv.length >= 3` excludes the 2-column global-fallback restore (sidebar + + * center, right panels stripped as env-scoped), where the last splitview child + * is the CENTER column — recording its width as the "right" target would + * inflate the real right column once the full layout materializes. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -function captureRightTarget(api: DockviewApi, sv: any): void { +function captureRightTarget(api: DockviewApi, sv: any, savedRightWidth?: number): void { // Constrain the default preset's right column groups (stable well-known IDs). // Other presets' side columns aren't pinned and carry no max-width cap. // Use the measured grid width, not the window.innerWidth fallback (see // captureSidebarTarget). - const rightCap = computeRightMaxPx(layoutWidth(api)); + const measuredWidth = layoutWidth(api); + const rightCap = computeRightMaxPx(measuredWidth); for (const gid of [RIGHT_TOP_GROUP, RIGHT_BOTTOM_GROUP]) { const group = api.groups.find((g) => g.id === gid); if (group) { @@ -150,7 +173,24 @@ function captureRightTarget(api: DockviewApi, sv: any): void { } } if (!sv || sv.length < 3) return; - const liveRight = sv.getViewSize(sv.length - 1); + const idx = sv.length - 1; + // Default preset (pinned right column): anchor to saved-or-default, resize the + // column to it, and record it as the target. Never the live size. + if (api.groups.some((g) => g.id === RIGHT_TOP_GROUP || g.id === RIGHT_BOTTOM_GROUP)) { + const target = resolveRightTarget(rightCap, measuredWidth, savedRightWidth); + const cur = sv.getViewSize(idx); + if (typeof cur === "number" && cur > 0 && Math.abs(cur - target) > 1) { + try { + sv?.resizeView?.(idx, target); + } catch { + /* dockview rejects unreachable sizes — ignore */ + } + } + setPinnedTarget("right", target); + return; + } + // Non-default presets: side column is not pinned — keep the live width. + const liveRight = sv.getViewSize(idx); if (typeof liveRight === "number" && liveRight > 0) { setPinnedTarget("right", Math.min(liveRight, rightCap)); }