fix(web): make left sidebar width global + steady monitor-switch settling (#1140)

This commit is contained in:
Carlos Florêncio
2026-05-30 19:16:26 +01:00
committed by GitHub
parent 11f5be3f84
commit d35f36ae73
16 changed files with 466 additions and 93 deletions
@@ -11,7 +11,7 @@ import {
RIGHT_BOTTOM_GROUP,
setPinnedTarget,
} from "@/lib/state/layout-manager";
import { setEnvLayout } from "@/lib/local-storage";
import { setEnvLayout, setGlobalSidebarWidth } from "@/lib/local-storage";
import { panelPortalManager } from "@/lib/layout/panel-portal-manager";
import { stopVscode } from "@/lib/api/domains/vscode-api";
import { parkUserShell, stopUserShell } from "@/lib/api/domains/user-shell-api";
@@ -123,7 +123,15 @@ export function setupSashDragCapToggle(api: DockviewReadyEvent["api"]): () => vo
const sv = getRootSplitview(api);
if (!sv) return;
const store = useDockviewStore.getState();
if (store.sidebarVisible) setPinnedTarget("sidebar", sv.getViewSize(0));
if (store.sidebarVisible) {
// A genuine drag is the ONLY writer of the global sidebar width pref.
// Persist it globally and mirror into the store's pinnedWidths so the
// override path (resolvePresetPinnedWidths / classifyColumns) agrees.
const sidebarW = sv.getViewSize(0);
setPinnedTarget("sidebar", sidebarW);
setGlobalSidebarWidth(sidebarW);
store.setPinnedWidth("sidebar", sidebarW);
}
if (store.rightPanelsVisible) setPinnedTarget("right", sv.getViewSize(sv.length - 1));
if (IS_DEBUG) {
debugWidths(`sash-drag-end ${formatWidthsSnapshot(snapshotColumnWidths(api))}`);
@@ -152,18 +160,12 @@ function trackPinnedWidths(api: DockviewReadyEvent["api"]): void {
const sv = getRootSplitview(api);
if (!sv || sv.length < 2) return;
try {
// Sidebar is grid index 0 *only when sidebar is visible*. Without the
// visibility guard, hiding the sidebar makes index 0 the center column,
// and we'd persist the center width as the sidebar's preferred width.
if (store.sidebarVisible) {
const sidebarW = sv.getViewSize(0);
if (sidebarW > 50) {
const current = store.pinnedWidths.get("sidebar");
if (current !== sidebarW) {
store.setPinnedWidth("sidebar", sidebarW);
}
}
}
// 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) {
@@ -208,6 +208,29 @@ function SavedLayoutItems({
));
}
/** The built-in preset menu items. Applying any preset resets widths
* (resetWidths=true), which is how a user clears a custom sidebar width. */
function BuiltInPresetItems({ onApply }: { onApply: (presetId: BuiltInPreset) => void }) {
return BUILT_IN_PRESETS.map((preset) => {
const Icon = preset.icon;
return (
<DropdownMenuItem
key={preset.id}
data-testid="layout-preset-item"
data-preset-id={preset.id}
onClick={() => onApply(preset.id)}
className="cursor-pointer"
>
<Icon className="h-4 w-4 mr-2 shrink-0" />
<div className="flex flex-col min-w-0">
<span className="text-xs font-medium">{preset.label}</span>
<span className="text-[10px] text-muted-foreground truncate">{preset.description}</span>
</div>
</DropdownMenuItem>
);
});
}
export function LayoutPresetSelector() {
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
@@ -261,7 +284,12 @@ export function LayoutPresetSelector() {
<Tooltip open={tooltipOpen} onOpenChange={handleTooltipOpenChange}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline" className="cursor-pointer px-2">
<Button
size="sm"
variant="outline"
className="cursor-pointer px-2"
data-testid="layout-preset-trigger"
>
<IconLayout className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
@@ -271,24 +299,7 @@ export function LayoutPresetSelector() {
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuGroup>
<DropdownMenuLabel className="text-xs">Presets</DropdownMenuLabel>
{BUILT_IN_PRESETS.map((preset) => {
const Icon = preset.icon;
return (
<DropdownMenuItem
key={preset.id}
onClick={() => applyBuiltInPreset(preset.id, true)}
className="cursor-pointer"
>
<Icon className="h-4 w-4 mr-2 shrink-0" />
<div className="flex flex-col min-w-0">
<span className="text-xs font-medium">{preset.label}</span>
<span className="text-[10px] text-muted-foreground truncate">
{preset.description}
</span>
</div>
</DropdownMenuItem>
);
})}
<BuiltInPresetItems onApply={(id) => applyBuiltInPreset(id, true)} />
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
+5
View File
@@ -184,6 +184,11 @@ export async function resizeColumnViaSplitview(
if (typeof w.__setPinnedTarget__ === "function") {
w.__setPinnedTarget__(col, actual);
}
// The sidebar width is a global pref persisted on a real drag's mouseup.
// Mirror that so cross-task / reload assertions see the same width.
if (col === "sidebar" && typeof w.__setGlobalSidebarWidth__ === "function") {
w.__setGlobalSidebarWidth__(actual);
}
// `sv.resizeView` alone does not fire `onDidLayoutChange` in dockview
// 4.x — without that, the debounced layout-persistence handler never
// saves the new width. Call the exposed test helper to force-flush the
@@ -58,3 +58,84 @@ test.describe("Sidebar resize — viewport-proportional cap", () => {
expectApproxWidth(after, before, 20);
});
});
test.describe("Sidebar width — global across tasks", () => {
test("a width set in one task applies to a different task", async ({
testPage,
apiClient,
seedData,
}) => {
// Task A: drag the sidebar to a distinctive width.
const sessionA = await openWideTask(testPage, apiClient, seedData, "Sidebar global A");
const widthA = await resizeColumnViaSplitview(testPage, "sidebar", 440);
// Task B is a separate task (separate env). It must open at task A's width
// because the left sidebar width is now a single global preference.
const taskB = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Sidebar global B",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await testPage.goto(`/t/${taskB.id}`);
await sessionA.waitForLoad();
await sessionA.waitForDockviewReady();
const widthB = await getDockviewGroupWidth(testPage, "sidebar");
expectApproxWidth(widthB, widthA, 12);
});
test("clamps to fit a smaller screen, then restores on a wider one", async ({
testPage,
apiClient,
seedData,
}) => {
// Set a wide sidebar on a 1600px screen.
const session = await openWideTask(testPage, apiClient, seedData, "Sidebar clamp");
const wide = await resizeColumnViaSplitview(testPage, "sidebar", 520);
expect(wide).toBeGreaterThan(450);
// Shrink the viewport. The sidebar must settle within the smaller screen's
// cap (max(350, 30% of 1100) = 350, bounded by viewport-300) — not stay at
// 520 and not drift.
await testPage.setViewportSize({ width: 1100, height: 800 });
await session.waitForDockviewReady();
await testPage.waitForTimeout(400);
const small = await getDockviewGroupWidth(testPage, "sidebar");
const smallCap = Math.max(350, Math.round(1100 * 0.3));
expect(small).toBeLessThanOrEqual(smallCap + 12);
// Back to a wide screen: the raw 520 preference is restored (clamp does not
// overwrite stored width).
await testPage.setViewportSize({ width: 1600, height: 900 });
await session.waitForDockviewReady();
await testPage.waitForTimeout(400);
const restored = await getDockviewGroupWidth(testPage, "sidebar");
expectApproxWidth(restored, wide, 16);
});
test("Default layout preset resets the custom width", async ({
testPage,
apiClient,
seedData,
}) => {
const session = await openWideTask(testPage, apiClient, seedData, "Sidebar reset");
const custom = await resizeColumnViaSplitview(testPage, "sidebar", 480);
expect(custom).toBeGreaterThan(420);
// Apply the "Default" layout preset (resetWidths=true).
await testPage.getByTestId("layout-preset-trigger").click();
await testPage.locator('[data-testid="layout-preset-item"][data-preset-id="default"]').click();
await session.waitForDockviewReady();
await testPage.waitForTimeout(400);
// Sidebar returns to the ratio default (1600*0.25=400, clamped to 350).
const afterReset = await getDockviewGroupWidth(testPage, "sidebar");
expectApproxWidth(afterReset, 350, 16);
});
});
+37
View File
@@ -1,8 +1,11 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
cleanupTaskStorage,
clearGlobalSidebarWidth,
getGlobalSidebarWidth,
markPRClosedBannerDismissed,
markPRMergedBannerDismissed,
setGlobalSidebarWidth,
wasPRClosedBannerDismissed,
wasPRMergedBannerDismissed,
} from "./local-storage";
@@ -66,3 +69,37 @@ describe("PR closed banner dismissal storage", () => {
expect(wasPRClosedBannerDismissed("task-b")).toBe(true);
});
});
describe("global sidebar width storage", () => {
beforeEach(() => {
window.localStorage.clear();
});
it("returns null when no width has been stored", () => {
expect(getGlobalSidebarWidth()).toBeNull();
});
it("persists a width globally and reads it back (rounded)", () => {
setGlobalSidebarWidth(412.6);
expect(getGlobalSidebarWidth()).toBe(413);
});
it("ignores non-positive or non-finite widths", () => {
setGlobalSidebarWidth(0);
setGlobalSidebarWidth(-100);
setGlobalSidebarWidth(Number.NaN);
expect(getGlobalSidebarWidth()).toBeNull();
});
it("clears the stored width", () => {
setGlobalSidebarWidth(320);
clearGlobalSidebarWidth();
expect(getGlobalSidebarWidth()).toBeNull();
});
it("is NOT removed by cleanupTaskStorage (it is global, not task-scoped)", () => {
setGlobalSidebarWidth(320);
cleanupTaskStorage("task-a", []);
expect(getGlobalSidebarWidth()).toBe(320);
});
});
+22
View File
@@ -288,6 +288,28 @@ export function setEnvLayout(envId: string, layout: object): void {
}
}
// --- Dockview global left-sidebar width (localStorage) ---
// The LEFT sidebar width is a single GLOBAL preference shared across every
// task env (unlike the per-env layout above, which keys widths by envId).
// Stores the user's raw, unclamped width — clamping to the current screen
// happens at apply time. Written only by a genuine sash drag; read by every
// layout build/restore/switch via getPinnedWidth.
const DOCKVIEW_GLOBAL_SIDEBAR_WIDTH_KEY = "kandev.dockview.sidebar-width";
export function getGlobalSidebarWidth(): number | null {
const v = getLocalStorage<number | null>(DOCKVIEW_GLOBAL_SIDEBAR_WIDTH_KEY, null);
return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : null;
}
export function setGlobalSidebarWidth(width: number): void {
if (!Number.isFinite(width) || width <= 0) return;
setLocalStorage(DOCKVIEW_GLOBAL_SIDEBAR_WIDTH_KEY, Math.round(width));
}
export function clearGlobalSidebarWidth(): void {
removeLocalStorage(DOCKVIEW_GLOBAL_SIDEBAR_WIDTH_KEY);
}
// --- Dockview per-env maximize state (sessionStorage) ---
// v2: bumped alongside DOCKVIEW_ENV_LAYOUT_PREFIX. The maximize blob
// references the pre-maximize layout, which can carry the same oversized
@@ -8,6 +8,9 @@ vi.mock("@/lib/local-storage", () => ({
getEnvMaximizeState: vi.fn(() => null),
setEnvMaximizeState: vi.fn(),
removeEnvMaximizeState: vi.fn(),
getGlobalSidebarWidth: vi.fn(() => null),
setGlobalSidebarWidth: vi.fn(),
clearGlobalSidebarWidth: vi.fn(),
}));
vi.mock("@/lib/layout/panel-portal-manager", () => ({
@@ -30,7 +30,10 @@ vi.mock("./layout-manager", () => {
savedLayoutMatchesLive: vi.fn(() => false),
layoutStructuresMatch: vi.fn(() => true),
getRootSplitview: vi.fn(),
getPinnedWidth: vi.fn(() => 350),
// Distinct per-column defaults so we can prove the sidebar branch ignores
// saved per-env sizes (it routes through getPinnedWidth, which honors the
// global pref) while the right branch keeps using saved sizes.
getPinnedWidth: vi.fn((col: { id: string }) => (col.id === "sidebar" ? 300 : 350)),
// Used by applyPinnedColumnSizes and (indirectly) by the
// formatWidthsSnapshot debug log it now emits.
setPinnedTarget: vi.fn(),
@@ -87,15 +90,16 @@ describe("performEnvSwitch — pinned column resize after fast-path", () => {
performEnvSwitch(makeParams());
expect(resizeView).toHaveBeenCalledWith(0, 350);
expect(resizeView).toHaveBeenCalledWith(2, 350);
expect(resizeView).toHaveBeenCalledWith(0, 300); // sidebar default (global)
expect(resizeView).toHaveBeenCalledWith(2, 350); // right default
// center column is at index 1 and is not pinned — must not be resized.
expect(resizeView).not.toHaveBeenCalledWith(1, expect.anything());
});
it("uses the saved layout's per-column sizes when present", () => {
// When a saved layout exists for the incoming env, its serialized
// grid.root.data[i].size wins over the ratio-based default.
it("ignores the saved sidebar size and uses the global width instead", () => {
// The sidebar is a GLOBAL pref shared across tasks, so the incoming env's
// saved sidebar size (420 below) must NOT win — it routes through
// getPinnedWidth (mocked to 300 for the sidebar).
const savedLayout = {
grid: {
root: {
@@ -131,15 +135,12 @@ describe("performEnvSwitch — pinned column resize after fast-path", () => {
performEnvSwitch(makeParams());
expect(resizeView).toHaveBeenCalledWith(0, 420);
// Only sidebar/right are resized; with 2 sv slots and 3 columns, the
// loop bound is min(3, 2) = 2, so center (index 1) is the last iterated
// but since columns[1] = "center" (not pinned), no resize fires. Right
// is at index 2 in the column list which exceeds sv.length so isn't
// hit; this scenario covers the saved-sidebar restoration path only.
// Saved sidebar size (420) is ignored; the global default (300) wins.
expect(resizeView).toHaveBeenCalledWith(0, 300);
expect(resizeView).not.toHaveBeenCalledWith(0, 420);
});
it("applies saved widths to both sidebar AND right when sv.length matches", () => {
it("uses the saved size for RIGHT but the global width for the sidebar", () => {
// Cover the 3-column saved-layout path so the right column's
// saved-size branch is exercised. The previous test exits the loop
// before reaching the right column because sv.length = 2.
@@ -181,9 +182,10 @@ describe("performEnvSwitch — pinned column resize after fast-path", () => {
performEnvSwitch(makeParams());
// Both pinned columns get their saved size; center (index 1) is not
// Sidebar ignores its saved size (420) → global default 300; the right
// column still restores its saved size (420). Center (index 1) is not
// pinned and is skipped.
expect(resizeView).toHaveBeenCalledWith(0, 420);
expect(resizeView).toHaveBeenCalledWith(0, 300);
expect(resizeView).toHaveBeenCalledWith(2, 420);
expect(resizeView).not.toHaveBeenCalledWith(1, expect.anything());
});
+6 -1
View File
@@ -386,7 +386,12 @@ function applyPinnedColumnSizes(
for (let i = 0; i < liveLayout.columns.length && i < sv.length; i++) {
const col = liveLayout.columns[i];
if (col.id !== "sidebar" && col.id !== "right") continue;
const target = targetPinnedWidth(col, i, savedSizes, totalWidth);
// Sidebar uses the GLOBAL width pref (single source of truth across tasks),
// so it ignores this env's saved size. Right keeps per-env saved sizes.
const target =
col.id === "sidebar"
? getPinnedWidth(col, totalWidth, undefined)
: targetPinnedWidth(col, i, savedSizes, totalWidth);
if (typeof target !== "number" || target <= 0) continue;
try {
sv.resizeView(i, target);
@@ -15,6 +15,10 @@ vi.mock("@/lib/debug/log", () => ({
IS_DEBUG: false,
}));
vi.mock("@/lib/local-storage", () => ({
getGlobalSidebarWidth: vi.fn(() => null),
}));
vi.mock("./layout-manager", () => ({
SIDEBAR_LOCK: "no-drop-target",
SIDEBAR_GROUP: "group-sidebar",
@@ -24,6 +28,7 @@ vi.mock("./layout-manager", () => ({
LAYOUT_PINNED_MIN_PX: 180,
computeSidebarMaxPx: vi.fn(() => SIDEBAR_CAP),
computeRightMaxPx: vi.fn(() => RIGHT_CAP),
getPinnedWidth: vi.fn(() => 350),
getRootSplitview: vi.fn(),
resolveGroupIds: vi.fn(() => ({
sidebarGroupId: "group-sidebar",
@@ -35,7 +40,15 @@ vi.mock("./layout-manager", () => ({
}));
import { applyLayoutFixups } from "./dockview-layout-builders";
import { getRootSplitview, setPinnedTarget, RIGHT_TOP_GROUP } from "./layout-manager";
import {
getRootSplitview,
setPinnedTarget,
computeSidebarMaxPx,
computeRightMaxPx,
getPinnedWidth,
RIGHT_TOP_GROUP,
} from "./layout-manager";
import { getGlobalSidebarWidth } from "@/lib/local-storage";
const SIDEBAR_GROUP = "group-sidebar";
const CENTER_GROUP = "group-center";
@@ -65,6 +78,8 @@ function makeApi(groupIds: string[]): DockviewApi {
describe("applyLayoutFixups — pinned target capture", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getGlobalSidebarWidth).mockReturnValue(null);
vi.mocked(getPinnedWidth).mockReturnValue(350);
});
it("does NOT record a right target in the 2-column fallback (center is last)", () => {
@@ -80,9 +95,20 @@ describe("applyLayoutFixups — pinned target capture", () => {
expect(setPinnedTarget).not.toHaveBeenCalledWith("right", expect.anything());
});
it("clamps an over-cap sidebar target down to the cap", () => {
// sidebar live (474) exceeds the cap (441); the constraint pins it at the
// cap, so the target must be clamped or enforcePinnedTargets spins forever.
it("uses the default sidebar target instead of an env-saved live width when no global pref exists", () => {
// Slow fromJSON restore can bring back an env-specific sidebar width. With
// sidebar now a global pref, no pref means "use the default", not the
// env-saved live width.
mockSplitview([420, 1050]);
const api = makeApi([SIDEBAR_GROUP, CENTER_GROUP]);
applyLayoutFixups(api);
expect(setPinnedTarget).toHaveBeenCalledWith("sidebar", 350);
});
it("clamps a global sidebar pref down to the cap", () => {
vi.mocked(getGlobalSidebarWidth).mockReturnValue(900);
mockSplitview([474, 996]);
const api = makeApi([SIDEBAR_GROUP, CENTER_GROUP]);
@@ -101,6 +127,19 @@ describe("applyLayoutFixups — pinned target capture", () => {
expect(setPinnedTarget).toHaveBeenCalledWith("right", 400);
});
it("derives the caps from api.width, not the window.innerWidth fallback", () => {
// Regression: caps computed without the measured width fall back to
// window.innerWidth, which can be transiently stale and clamp the captured
// target too narrow. They must be derived from api.width (1470 here).
mockSplitview([350, 720, 400]);
const api = makeApi([SIDEBAR_GROUP, CENTER_GROUP, RIGHT_TOP_GROUP]);
applyLayoutFixups(api);
expect(computeSidebarMaxPx).toHaveBeenCalledWith(1470);
expect(computeRightMaxPx).toHaveBeenCalledWith(1470);
});
it("records the side-column target for a 3-column preset without RIGHT_TOP_GROUP", () => {
// Regression: vscode/preview/plan presets put their side column in a group
// with a generated id (not RIGHT_TOP_GROUP). The target must still be
+51 -7
View File
@@ -8,11 +8,13 @@ import {
LAYOUT_PINNED_MIN_PX,
computeSidebarMaxPx,
computeRightMaxPx,
getPinnedWidth,
getRootSplitview as getRootSplitviewImpl,
resolveGroupIds,
setPinnedTarget,
} from "./layout-manager";
import type { LayoutGroupIds } from "./layout-manager";
import { getGlobalSidebarWidth } from "@/lib/local-storage";
import { createDebugLogger, IS_DEBUG } from "@/lib/debug/log";
// Re-export for consumers that import from this module
@@ -20,6 +22,13 @@ export { getRootSplitview } from "./layout-manager";
const debugWidths = createDebugLogger("dockview:widths");
/** Dockview's measured grid width, or undefined when not yet laid out — passed
* to the cap helpers so they don't fall back to a possibly-stale
* `window.innerWidth`. */
function layoutWidth(api: DockviewApi): number | undefined {
return api.width > 0 ? api.width : undefined;
}
/** Best-effort caller chain for the fixups-capture debug log: pull the first
* few stack frames above `applyLayoutFixups` so we can see WHICH layout path
* (env-switch / restore / custom-layout / maximize) recorded a given target.
@@ -63,6 +72,20 @@ export function applyLayoutFixups(api: DockviewApi): LayoutGroupIds {
return resolveGroupIds(api);
}
/** Resolve the sidebar's target width (clamped to the cap): the GLOBAL width
* pref when set, else the default width for the current measured layout. */
function resolveSidebarTarget(cap: number, totalWidth: number | undefined): number | undefined {
const pref = getGlobalSidebarWidth();
if (pref !== null) return Math.min(pref, cap);
const width =
totalWidth ??
(typeof window !== "undefined" && window.innerWidth > 0 ? window.innerWidth : cap);
return Math.min(
getPinnedWidth({ id: "sidebar", pinned: true, groups: [] }, width, undefined),
cap,
);
}
/** Lock + constrain the sidebar group and record its target width, clamped to
* the cap. The constraint pins the column at `sidebarCap`, so a target above
* it is unreachable and makes `enforcePinnedTargets` spin forever. */
@@ -70,13 +93,31 @@ export function applyLayoutFixups(api: DockviewApi): LayoutGroupIds {
function captureSidebarTarget(api: DockviewApi, sv: any): void {
const sb = api.getPanel("sidebar");
if (!sb) return;
const sidebarCap = computeSidebarMaxPx();
// Derive the cap from dockview's measured grid width, not the implicit
// window.innerWidth fallback: innerWidth can read transiently stale during
// route transitions / devtools toggles, yielding a too-small cap that would
// clamp the captured target too narrow and persist it — the width drift this
// pipeline exists to prevent.
const measuredWidth = layoutWidth(api);
const sidebarCap = computeSidebarMaxPx(measuredWidth);
sb.group.locked = SIDEBAR_LOCK;
sb.group.header.hidden = false;
sb.group.api.setConstraints({ maximumWidth: sidebarCap, minimumWidth: LAYOUT_PINNED_MIN_PX });
const live = sv?.getViewSize?.(0) ?? sb.group.width;
if (typeof live === "number" && live > 0) {
setPinnedTarget("sidebar", Math.min(live, sidebarCap));
// Slow-path env restore: fromJSON brought back this env's saved sidebar
// pixel width, but the sidebar is a GLOBAL pref. Seed the target from the
// pref (clamped to fit) and resize the column so the restore honors it; fall
// back to the default sidebar width when no pref exists.
const target = resolveSidebarTarget(sidebarCap, measuredWidth);
if (target !== undefined) {
const cur = sv?.getViewSize?.(0);
if (typeof cur === "number" && cur > 0 && Math.abs(cur - target) > 1) {
try {
sv?.resizeView?.(0, target);
} catch {
/* dockview rejects unreachable sizes — ignore */
}
}
setPinnedTarget("sidebar", target);
}
}
@@ -99,7 +140,9 @@ function captureSidebarTarget(api: DockviewApi, sv: any): void {
function captureRightTarget(api: DockviewApi, sv: any): 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.
const rightCap = computeRightMaxPx();
// Use the measured grid width, not the window.innerWidth fallback (see
// captureSidebarTarget).
const rightCap = computeRightMaxPx(layoutWidth(api));
for (const gid of [RIGHT_TOP_GROUP, RIGHT_BOTTOM_GROUP]) {
const group = api.groups.find((g) => g.id === gid);
if (group) {
@@ -123,7 +166,8 @@ function logFixupsCapture(api: DockviewApi, sv: any): void {
// that makes `enforcePinnedTargets` spin forever. `cols` shows whether the
// layout was complete at capture (cols<3 → no real right column). api.width
// vs window.innerWidth surfaces the window-fallback cap divergence.
const sidebarCap = computeSidebarMaxPx();
const w = layoutWidth(api);
const sidebarCap = computeSidebarMaxPx(w);
const innerW = typeof window !== "undefined" ? window.innerWidth : -1;
const liveSidebar = sv?.getViewSize?.(0);
// Threshold matches captureRightTarget (>= 3): in a 2-column layout the last
@@ -134,7 +178,7 @@ function logFixupsCapture(api: DockviewApi, sv: any): void {
debugWidths(
`fixups-capture caller=${captureCallerChain()} apiW=${api.width} innerW=${innerW} ` +
`cols=${sv?.length ?? 0} sidebarCap=${Math.round(sidebarCap)} liveSidebar=${r(liveSidebar)} ` +
`rightCap=${Math.round(computeRightMaxPx())} liveRight=${r(liveRight)} ` +
`rightCap=${Math.round(computeRightMaxPx(w))} liveRight=${r(liveRight)} ` +
`sidebarOverCap=${typeof liveSidebar === "number" && liveSidebar > sidebarCap + 1}`,
);
}
+13 -2
View File
@@ -19,7 +19,7 @@
* `sv.resizeView`.
*/
import type { DockviewApi } from "dockview-react";
import { getRootSplitview, getPinnedTarget } from "./layout-manager";
import { getRootSplitview, getPinnedTarget, computeSidebarMaxPx } from "./layout-manager";
import { createDebugLogger, IS_DEBUG } from "@/lib/debug/log";
const debugWidths = createDebugLogger("dockview:widths");
@@ -97,7 +97,18 @@ export function enforcePinnedTargets(api: DockviewApi, ctx: EnforcePinnedTargets
if (!sv || sv.length < 2) return;
enforcing = true;
try {
if (ctx.sidebarVisible) restoreColumnToTarget(sv, 0, getPinnedTarget("sidebar"));
if (ctx.sidebarVisible) {
// Clamp the (global, raw) sidebar target to the current screen so a width
// set on a wide monitor fits a narrower one. Storage keeps the raw value,
// so returning to the wide monitor restores it.
const raw = getPinnedTarget("sidebar");
// Guard against an unmeasured container (api.width === 0): passing 0 to
// computeSidebarMaxPx bypasses its window.innerWidth fallback and clamps
// the target down to LAYOUT_PINNED_MIN_PX. undefined lets it fall back.
const safeWidth = api.width > 0 ? api.width : undefined;
const clamped = raw === undefined ? undefined : Math.min(raw, computeSidebarMaxPx(safeWidth));
restoreColumnToTarget(sv, 0, clamped);
}
if (ctx.rightPanelsVisible) {
restoreColumnToTarget(sv, sv.length - 1, getPinnedTarget("right"));
}
+51 -9
View File
@@ -5,6 +5,8 @@ import {
resolvePresetPinnedWidths,
collectPinnedWidthUpdates,
} from "./dockview-store";
import { getGlobalSidebarWidth, setGlobalSidebarWidth } from "@/lib/local-storage";
import { getPinnedTarget, setPinnedTarget, clearPinnedTarget } from "./layout-manager";
type ActivePanelEvent = { id: string };
type CapturedHandlers = {
@@ -122,7 +124,7 @@ describe("resolvePresetPinnedWidths", () => {
expect(result.has("center")).toBe(false); // not pinned
});
it("keeps live widths for columns in the target layout when not resetting", () => {
it("keeps right live widths for columns in the target layout when not resetting", () => {
const live = new Map([
["sidebar", 519],
["right", 900],
@@ -130,10 +132,24 @@ describe("resolvePresetPinnedWidths", () => {
const result = resolvePresetPinnedWidths(live, cols, 1600, false);
expect(result.get("sidebar")).toBe(519);
expect(result.has("sidebar")).toBe(false);
expect(result.get("right")).toBe(900);
});
it("does not carry a live sidebar override on non-reset switches", () => {
setGlobalSidebarWidth(520);
const live = new Map([
["sidebar", 350],
["right", 900],
]);
const result = resolvePresetPinnedWidths(live, cols, 1600, false);
expect(result.has("sidebar")).toBe(false);
expect(result.get("right")).toBe(900);
expect(getGlobalSidebarWidth()).toBe(520);
});
it("drops live overrides for columns absent from the target layout", () => {
// e.g. switching to a layout without a "right" column must not leak the
// old right width into the new layout.
@@ -148,7 +164,7 @@ describe("resolvePresetPinnedWidths", () => {
const result = resolvePresetPinnedWidths(live, noRight, 1600, false);
expect(result.get("sidebar")).toBe(300);
expect(result.has("sidebar")).toBe(false);
expect(result.has("right")).toBe(false);
});
@@ -157,20 +173,48 @@ describe("resolvePresetPinnedWidths", () => {
resolvePresetPinnedWidths(live, cols, 1600, false);
expect(live.get("sidebar")).toBe(300);
});
describe("Default layout reset clears the global sidebar width", () => {
beforeEach(() => {
window.localStorage.clear();
clearPinnedTarget("sidebar");
});
it("clears the global pref + runtime target and uses the ratio default", () => {
setGlobalSidebarWidth(520);
setPinnedTarget("sidebar", 520);
const result = resolvePresetPinnedWidths(new Map(), cols, 1600, true);
// pref cleared → ratio default (1600*0.25=400, clamped to sidebar cap 350)
expect(result.get("sidebar")).toBe(350);
expect(getGlobalSidebarWidth()).toBeNull();
expect(getPinnedTarget("sidebar")).toBeUndefined();
});
it("does NOT clear the pref/target on a non-reset (programmatic) switch", () => {
setGlobalSidebarWidth(520);
setPinnedTarget("sidebar", 520);
resolvePresetPinnedWidths(new Map([["sidebar", 480]]), cols, 1600, false);
expect(getGlobalSidebarWidth()).toBe(520);
expect(getPinnedTarget("sidebar")).toBe(520);
});
});
});
describe("collectPinnedWidthUpdates", () => {
const size = (i: number) => [350, 560, 560][i]; // sidebar, center, last
it("tracks sidebar + right when both are visible", () => {
it("tracks right but not sidebar when both are visible", () => {
const columns = [{ id: "sidebar" }, { id: "center" }, { id: "right" }];
const updates = collectPinnedWidthUpdates(columns, size, {
sidebarVisible: true,
rightPanelsVisible: true,
});
expect(updates.get("sidebar")).toBe(350);
expect(updates.has("sidebar")).toBe(false);
expect(updates.get("right")).toBe(560);
});
@@ -182,19 +226,17 @@ describe("collectPinnedWidthUpdates", () => {
const columns = [{ id: "sidebar" }, { id: "center" }, { id: "right" }];
const updates = collectPinnedWidthUpdates(columns, size, {
sidebarVisible: true,
rightPanelsVisible: false,
});
expect(updates.has("right")).toBe(false);
expect(updates.get("sidebar")).toBe(350);
expect(updates.has("sidebar")).toBe(false);
});
it("skips collapsed/transient widths <= 50px", () => {
const columns = [{ id: "sidebar" }, { id: "right" }];
const updates = collectPinnedWidthUpdates(columns, () => 40, {
sidebarVisible: true,
rightPanelsVisible: true,
});
+30 -21
View File
@@ -6,8 +6,10 @@ import {
getEnvMaximizeState,
setEnvMaximizeState,
removeEnvMaximizeState,
clearGlobalSidebarWidth,
setGlobalSidebarWidth,
} from "@/lib/local-storage";
import { setPinnedTarget } from "./layout-manager";
import { setPinnedTarget, clearPinnedTarget } from "./layout-manager";
import { applyLayoutFixups, focusOrAddPanel } from "./dockview-layout-builders";
import {
SIDEBAR_GROUP,
@@ -217,37 +219,36 @@ function applyDeferredPanelActions(api: DockviewApi, actions: DeferredPanelActio
}
}
/** Read live column widths from dockview's splitview and persist them as pinned overrides.
* Only syncs widths for columns identified as "sidebar" or "right" to avoid
* capturing plan/preview/vscode column widths as stale "right" overrides. */
/**
* Build the pinnedWidths updates for a width sync, tracking a column only when
* it is the VISIBLE default sidebar/right.
* Build the pinnedWidths updates for a width sync, tracking only the VISIBLE
* default right column.
*
* In plan/preview/vscode layouts the side column inherits merged files/changes
* panels and `fromDockviewApi` mislabels it "right"; storing its width as the
* right override would then leak into the default layout when toggling back
* (e.g. plan-mode off snapping the right column to the plan column's width).
* Mirrors the visibility guards in `trackPinnedWidths`. Widths <= 50px are
* treated as transient/collapsed and skipped.
*
* Sidebar is intentionally excluded: its persisted width is a global pref
* written only by explicit sash drag, and syncing live layout-change widths
* would overwrite the raw pref with viewport-clamped transient widths.
* Widths <= 50px are treated as transient/collapsed and skipped.
*/
export function collectPinnedWidthUpdates(
columns: { id: string }[],
getSize: (index: number) => number,
visibility: { sidebarVisible: boolean; rightPanelsVisible: boolean },
visibility: { rightPanelsVisible: boolean },
): Map<string, number> {
const updates = new Map<string, number>();
columns.forEach((col, i) => {
const tracked =
(col.id === "sidebar" && visibility.sidebarVisible) ||
(col.id === "right" && visibility.rightPanelsVisible);
if (!tracked) return;
if (col.id !== "right" || !visibility.rightPanelsVisible) return;
const w = getSize(i);
if (w > 50) updates.set(col.id, w);
});
return updates;
}
/** Read live column widths from dockview's splitview and persist the visible
* right width as a pinned override (see `collectPinnedWidthUpdates`). */
function syncPinnedWidthsFromApi(api: DockviewApi, set: StoreSet): void {
if (api.hasMaximizedGroup()) return;
const sv = getRootSplitview(api);
@@ -255,9 +256,8 @@ function syncPinnedWidthsFromApi(api: DockviewApi, set: StoreSet): void {
try {
const state = fromDockviewApi(api);
if (state.columns.length !== sv.length) return;
const { sidebarVisible, rightPanelsVisible } = useDockviewStore.getState();
const { rightPanelsVisible } = useDockviewStore.getState();
const updates = collectPinnedWidthUpdates(state.columns, (i) => sv.getViewSize(i), {
sidebarVisible,
rightPanelsVisible,
});
if (updates.size > 0) {
@@ -278,7 +278,6 @@ function syncPinnedWidthsFromApi(api: DockviewApi, set: StoreSet): void {
}
}
/** Capture the live sidebar/right pixel widths into pinnedWidths before a layout rebuild. */
/**
* Decide which pinned-width overrides to apply when switching to a preset.
*
@@ -290,9 +289,10 @@ function syncPinnedWidthsFromApi(api: DockviewApi, set: StoreSet): void {
* `fromJSON` out at a transient narrower width, and that shrunken size would
* be captured as the pinned target and then enforced, leaving the columns
* too narrow.
* - otherwise (programmatic switch, e.g. plan-mode toggle): keep the live
* widths, minus overrides for columns absent in the target layout, so the
* user's current sidebar/right widths carry across the switch.
* - otherwise (programmatic switch, e.g. plan-mode toggle): keep the live right
* width, minus overrides for columns absent in the target layout. Sidebar is
* always resolved from the global pref/default instead of an in-memory
* override.
*/
export function resolvePresetPinnedWidths(
liveWidths: Map<string, number>,
@@ -301,6 +301,11 @@ export function resolvePresetPinnedWidths(
resetWidths: boolean,
): Map<string, number> {
if (resetWidths) {
// "Default layout" is the reset gesture: drop any custom global sidebar
// width (and its runtime target) so getPinnedWidth returns the fresh
// ratio default for the current screen instead of re-reading the pref.
clearGlobalSidebarWidth();
clearPinnedTarget("sidebar");
const defaults = new Map<string, number>();
for (const col of columns) {
if (col.pinned) defaults.set(col.id, getPinnedWidth(col, totalWidth, undefined));
@@ -310,11 +315,12 @@ export function resolvePresetPinnedWidths(
const targetColumnIds = new Set(columns.map((c) => c.id));
const cleaned = new Map(liveWidths);
for (const key of cleaned.keys()) {
if (!targetColumnIds.has(key)) cleaned.delete(key);
if (key === "sidebar" || !targetColumnIds.has(key)) cleaned.delete(key);
}
return cleaned;
}
/** Capture the live right pixel width into pinnedWidths before a layout rebuild. */
function captureLiveWidths(api: DockviewApi, set: StoreSet): Map<string, number> {
if (api.hasMaximizedGroup()) {
api.exitMaximizedGroup();
@@ -868,12 +874,15 @@ export const useDockviewStore = create<DockviewStore>((set, get) => ({
type TestWindow = {
__dockviewApi__: DockviewApi | null;
__setPinnedTarget__?: typeof setPinnedTarget;
__setGlobalSidebarWidth__?: typeof setGlobalSidebarWidth;
};
const w = window as unknown as TestWindow;
w.__dockviewApi__ = api;
// E2E test helpers: let `resizeColumnViaSplitview` update the target
// width after a programmatic resize (mirroring the sash-drag mouseup).
// width after a programmatic resize (mirroring the sash-drag mouseup),
// including persisting the global sidebar-width pref like a real drag.
w.__setPinnedTarget__ = setPinnedTarget;
w.__setGlobalSidebarWidth__ = setGlobalSidebarWidth;
}
if (api) {
const resolveFilePath = (panelId: string | undefined): string | null => {
@@ -0,0 +1,49 @@
import { beforeEach, describe, expect, it } from "vitest";
import { getPinnedWidth } from "./sizing";
import { computeSidebarMaxPx } from "./caps";
import type { LayoutColumn } from "./types";
import { getGlobalSidebarWidth, setGlobalSidebarWidth } from "@/lib/local-storage";
const sidebar = (extra: Partial<LayoutColumn> = {}): LayoutColumn =>
({ id: "sidebar", pinned: true, groups: [], ...extra }) as LayoutColumn;
const right = (extra: Partial<LayoutColumn> = {}): LayoutColumn =>
({ id: "right", pinned: true, groups: [], ...extra }) as LayoutColumn;
const TOTAL = 1600; // computeSidebarMaxPx(1600) = max(350, 480) bounded = 480
describe("getPinnedWidth — global sidebar width pref", () => {
beforeEach(() => {
window.localStorage.clear();
});
it("uses the stored pref for the sidebar when it fits the screen", () => {
setGlobalSidebarWidth(400); // < cap 480
expect(getPinnedWidth(sidebar(), TOTAL, undefined)).toBe(400);
});
it("clamps the sidebar pref to the screen cap without mutating storage", () => {
const cap = computeSidebarMaxPx(TOTAL);
setGlobalSidebarWidth(900); // > cap → clamp to fit
expect(getPinnedWidth(sidebar(), TOTAL, undefined)).toBe(cap);
// Raw value preserved so a wider monitor restores it.
expect(getGlobalSidebarWidth()).toBe(900);
});
it("falls back to the legacy ratio default when no pref is set", () => {
// ratio 0.25 * 1600 = 400, clamped to the legacy sidebar initial cap (350).
expect(getGlobalSidebarWidth()).toBeNull();
expect(getPinnedWidth(sidebar(), TOTAL, undefined)).toBe(350);
});
it("lets an explicit override win over the pref", () => {
setGlobalSidebarWidth(400);
// maxWidth pins the runtime cap so the override isn't clamped by innerWidth.
expect(getPinnedWidth(sidebar({ maxWidth: 600 }), TOTAL, 500)).toBe(500);
});
it("does NOT apply the sidebar pref to the right column", () => {
setGlobalSidebarWidth(200);
// right: ratio 0.25 * 1600 = 400, clamped to legacy right cap (450) → 400.
expect(getPinnedWidth(right(), TOTAL, undefined)).toBe(400);
});
});
+12 -1
View File
@@ -1,6 +1,7 @@
import type { LayoutColumn, LayoutGroup } from "./types";
import { LAYOUT_INITIAL_RATIO } from "./constants";
import { computePinnedMaxPxFor, LAYOUT_PINNED_MIN_PX } from "./caps";
import { computePinnedMaxPxFor, computeSidebarMaxPx, LAYOUT_PINNED_MIN_PX } from "./caps";
import { getGlobalSidebarWidth } from "@/lib/local-storage";
// Legacy hard caps used to clamp the *initial* default width. Users can still
// drag past these via setConstraints (which uses the larger runtime cap),
@@ -27,6 +28,16 @@ export function getPinnedWidth(
if (override !== undefined) {
return Math.max(min, Math.min(override, runtimeMax));
}
// No override: the left sidebar honors the persisted GLOBAL width pref so it
// opens identically across every task. Clamp-to-fit against the current
// screen (the raw value stays in storage, so a wider monitor restores it).
if (column.id === "sidebar") {
const pref = getGlobalSidebarWidth();
if (pref !== null) {
const max = column.maxWidth ?? computeSidebarMaxPx(totalWidth);
return Math.max(min, Math.min(pref, max));
}
}
const ratioWidth = Math.round(totalWidth * LAYOUT_INITIAL_RATIO);
const initialCap =
column.maxWidth ??