From e488f23f4181fa869d258e593c261782eff67f92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= Date: Fri, 22 May 2026 00:48:57 +0100 Subject: [PATCH] refactor(layout): swap pane width caps for viewport-proportional runtime cap Replace the hard 350px sidebar / 450px right caps with max(800, viewport*0.7) so wide displays get a roomy file/terminal panel and the sidebar can grow with the screen. Pinned minimum is raised from 50px to 180px so panels don't collapse into unusable slivers. Caps now live in lib/state/layout-manager/caps.ts; constants module no longer exports per-column max-px constants. --- .../tasks/[id]/office-dockview-layout.tsx | 2 -- .../web/lib/state/dockview-layout-builders.ts | 9 ++--- apps/web/lib/state/dockview-store.ts | 4 +-- apps/web/lib/state/layout-manager/applier.ts | 23 +++++++++---- .../web/lib/state/layout-manager/caps.test.ts | 34 +++++++++++++++++++ apps/web/lib/state/layout-manager/caps.ts | 27 +++++++++++++++ .../web/lib/state/layout-manager/constants.ts | 5 ++- apps/web/lib/state/layout-manager/index.ts | 5 +-- .../lib/state/layout-manager/presets.test.ts | 11 +++--- apps/web/lib/state/layout-manager/presets.ts | 10 ++---- .../lib/state/layout-manager/serializer.ts | 15 ++------ apps/web/lib/state/layout-manager/sizing.ts | 10 +++--- 12 files changed, 107 insertions(+), 48 deletions(-) create mode 100644 apps/web/lib/state/layout-manager/caps.test.ts create mode 100644 apps/web/lib/state/layout-manager/caps.ts diff --git a/apps/web/app/office/tasks/[id]/office-dockview-layout.tsx b/apps/web/app/office/tasks/[id]/office-dockview-layout.tsx index 33c3d63887..8d986177f8 100644 --- a/apps/web/app/office/tasks/[id]/office-dockview-layout.tsx +++ b/apps/web/app/office/tasks/[id]/office-dockview-layout.tsx @@ -11,7 +11,6 @@ import { CENTER_GROUP, RIGHT_TOP_GROUP, RIGHT_BOTTOM_GROUP, - LAYOUT_RIGHT_MAX_PX, panel, } from "@/lib/state/layout-manager/constants"; import type { LayoutState } from "@/lib/state/layout-manager/types"; @@ -46,7 +45,6 @@ function officeLayout(): LayoutState { id: "right", pinned: true, width: 350, - maxWidth: LAYOUT_RIGHT_MAX_PX, groups: [ { id: RIGHT_TOP_GROUP, panels: [panel("files"), panel("changes")] }, { id: RIGHT_BOTTOM_GROUP, panels: [panel("terminal-default")] }, diff --git a/apps/web/lib/state/dockview-layout-builders.ts b/apps/web/lib/state/dockview-layout-builders.ts index e394a513be..689c436a82 100644 --- a/apps/web/lib/state/dockview-layout-builders.ts +++ b/apps/web/lib/state/dockview-layout-builders.ts @@ -3,10 +3,10 @@ import { SIDEBAR_LOCK, SIDEBAR_GROUP, CENTER_GROUP, - LAYOUT_SIDEBAR_MAX_PX, - LAYOUT_RIGHT_MAX_PX, RIGHT_TOP_GROUP, RIGHT_BOTTOM_GROUP, + computePinnedMaxPx, + LAYOUT_PINNED_MIN_PX, resolveGroupIds, } from "./layout-manager"; import type { LayoutGroupIds } from "./layout-manager"; @@ -16,11 +16,12 @@ export { getRootSplitview } from "./layout-manager"; /** After fromJSON() restores a session layout, apply fixups and return group IDs. */ export function applyLayoutFixups(api: DockviewApi): LayoutGroupIds { + const cap = computePinnedMaxPx(); const sb = api.getPanel("sidebar"); if (sb) { sb.group.locked = SIDEBAR_LOCK; sb.group.header.hidden = false; - sb.group.api.setConstraints({ maximumWidth: LAYOUT_SIDEBAR_MAX_PX }); + sb.group.api.setConstraints({ maximumWidth: cap, minimumWidth: LAYOUT_PINNED_MIN_PX }); } const oldChanges = api.getPanel("diff-files"); @@ -34,7 +35,7 @@ export function applyLayoutFixups(api: DockviewApi): LayoutGroupIds { for (const gid of [RIGHT_TOP_GROUP, RIGHT_BOTTOM_GROUP]) { const group = api.groups.find((g) => g.id === gid); if (group) { - group.api.setConstraints({ maximumWidth: LAYOUT_RIGHT_MAX_PX }); + group.api.setConstraints({ maximumWidth: cap, minimumWidth: LAYOUT_PINNED_MIN_PX }); } } diff --git a/apps/web/lib/state/dockview-store.ts b/apps/web/lib/state/dockview-store.ts index 185d4fa92a..746d76beb2 100644 --- a/apps/web/lib/state/dockview-store.ts +++ b/apps/web/lib/state/dockview-store.ts @@ -53,8 +53,8 @@ export type { BuiltInPreset } from "./layout-manager"; export { LAYOUT_SIDEBAR_RATIO, LAYOUT_RIGHT_RATIO, - LAYOUT_SIDEBAR_MAX_PX, - LAYOUT_RIGHT_MAX_PX, + computePinnedMaxPx, + LAYOUT_PINNED_MIN_PX, } from "./layout-manager"; export { applyLayoutFixups } from "./dockview-layout-builders"; diff --git a/apps/web/lib/state/layout-manager/applier.ts b/apps/web/lib/state/layout-manager/applier.ts index 9023403bfa..3acfad6949 100644 --- a/apps/web/lib/state/layout-manager/applier.ts +++ b/apps/web/lib/state/layout-manager/applier.ts @@ -8,8 +8,8 @@ import { RIGHT_TOP_GROUP, RIGHT_BOTTOM_GROUP, TERMINAL_DEFAULT_ID, - LAYOUT_SIDEBAR_MAX_PX, } from "./constants"; +import { computePinnedMaxPx, LAYOUT_PINNED_MIN_PX } from "./caps"; export type LayoutGroupIds = { centerGroupId: string; @@ -71,23 +71,34 @@ export function applyLayout( api.fromJSON(serialized); - // Lock sidebar group and enforce max-width constraint. + // Lock sidebar group and enforce max/min-width constraints. // Constraints are not serialized with layouts, so we must reapply after fromJSON. + const runtimeCap = computePinnedMaxPx(); const sb = api.getPanel("sidebar"); if (sb) { sb.group.locked = SIDEBAR_LOCK; sb.group.header.hidden = false; - sb.group.api.setConstraints({ maximumWidth: LAYOUT_SIDEBAR_MAX_PX }); + sb.group.api.setConstraints({ + maximumWidth: runtimeCap, + minimumWidth: LAYOUT_PINNED_MIN_PX, + }); } - // Enforce max-width on other pinned columns (e.g. right panel group). + // Enforce constraints on other pinned columns (e.g. right panel group). + // Column.maxWidth (when set) wins over the runtime cap so presets like + // compact can pin the sidebar tight; otherwise we inherit the viewport- + // proportional cap so wide screens get a roomy right panel. for (const col of state.columns) { - if (col.id === "sidebar" || !col.pinned || !col.maxWidth) continue; + if (col.id === "sidebar" || !col.pinned) continue; + const cap = col.maxWidth ?? runtimeCap; for (const group of col.groups) { for (const p of group.panels) { const pnl = api.getPanel(p.id); if (pnl) { - pnl.group.api.setConstraints({ maximumWidth: col.maxWidth }); + pnl.group.api.setConstraints({ + maximumWidth: cap, + minimumWidth: LAYOUT_PINNED_MIN_PX, + }); break; } } diff --git a/apps/web/lib/state/layout-manager/caps.test.ts b/apps/web/lib/state/layout-manager/caps.test.ts new file mode 100644 index 0000000000..b06831281e --- /dev/null +++ b/apps/web/lib/state/layout-manager/caps.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect, afterEach, vi } from "vitest"; +import { computePinnedMaxPx, LAYOUT_PINNED_MIN_PX } from "./caps"; + +describe("computePinnedMaxPx", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("floors at 800px on narrow viewports", () => { + expect(computePinnedMaxPx(1024)).toBe(800); + expect(computePinnedMaxPx(100)).toBe(800); + }); + + it("scales to viewport * 0.7 above the floor", () => { + expect(computePinnedMaxPx(2000)).toBe(1400); + expect(computePinnedMaxPx(3000)).toBe(2100); + }); + + it("reads window.innerWidth when no argument passed", () => { + vi.stubGlobal("window", { innerWidth: 1800 } as Window); + expect(computePinnedMaxPx()).toBe(Math.round(1800 * 0.7)); + }); + + it("uses 1440 fallback when window is undefined", () => { + vi.stubGlobal("window", undefined); + expect(computePinnedMaxPx()).toBe(Math.round(1440 * 0.7)); + }); +}); + +describe("LAYOUT_PINNED_MIN_PX", () => { + it("keeps pinned panels usable", () => { + expect(LAYOUT_PINNED_MIN_PX).toBeGreaterThanOrEqual(150); + }); +}); diff --git a/apps/web/lib/state/layout-manager/caps.ts b/apps/web/lib/state/layout-manager/caps.ts new file mode 100644 index 0000000000..8122e774d4 --- /dev/null +++ b/apps/web/lib/state/layout-manager/caps.ts @@ -0,0 +1,27 @@ +/** + * Runtime caps for pinned columns (sidebar / right). + * + * The previous hard caps (350 sidebar, 450 right) were too strict on wide + * displays — users wanted to drag the right panel out to half the screen for + * file review or terminal work. Cap scales with viewport so wider screens get + * more room, while small screens still keep the center column usable. + */ + +const MIN_CAP_PX = 800; +const VIEWPORT_RATIO = 0.7; +const FALLBACK_VIEWPORT = 1440; + +/** + * Compute the maximum pixel width for a pinned column at the current viewport. + * Returns max(800, viewportWidth * 0.7). Falls back to 1440 when called in an + * SSR/non-browser context. + */ +export function computePinnedMaxPx(viewportWidth?: number): number { + const vw = + viewportWidth ?? (typeof window !== "undefined" ? window.innerWidth : FALLBACK_VIEWPORT); + return Math.max(MIN_CAP_PX, Math.round(vw * VIEWPORT_RATIO)); +} + +/** Minimum pixel width for any pinned column. Below this the panel becomes + * unusable (icons clipped, scrollbars stacked). */ +export const LAYOUT_PINNED_MIN_PX = 180; diff --git a/apps/web/lib/state/layout-manager/constants.ts b/apps/web/lib/state/layout-manager/constants.ts index 13179d8c29..a98d9c07d3 100644 --- a/apps/web/lib/state/layout-manager/constants.ts +++ b/apps/web/lib/state/layout-manager/constants.ts @@ -1,10 +1,9 @@ import type { LayoutPanel } from "./types"; -// Layout sizing constants (single source of truth) +// Layout sizing constants (single source of truth). +// Pinned column max width is computed at runtime — see ./caps.ts. export const LAYOUT_SIDEBAR_RATIO = 2.5 / 10; export const LAYOUT_RIGHT_RATIO = 2.5 / 10; -export const LAYOUT_SIDEBAR_MAX_PX = 350; -export const LAYOUT_RIGHT_MAX_PX = 450; // Well-known group/panel IDs export const SIDEBAR_GROUP = "group-sidebar"; diff --git a/apps/web/lib/state/layout-manager/index.ts b/apps/web/lib/state/layout-manager/index.ts index 944cf1ac95..16c41e9877 100644 --- a/apps/web/lib/state/layout-manager/index.ts +++ b/apps/web/lib/state/layout-manager/index.ts @@ -15,8 +15,6 @@ export type { export { LAYOUT_SIDEBAR_RATIO, LAYOUT_RIGHT_RATIO, - LAYOUT_SIDEBAR_MAX_PX, - LAYOUT_RIGHT_MAX_PX, SIDEBAR_GROUP, CENTER_GROUP, RIGHT_TOP_GROUP, @@ -28,6 +26,9 @@ export { panel, } from "./constants"; +// Runtime caps for pinned columns +export { computePinnedMaxPx, LAYOUT_PINNED_MIN_PX } from "./caps"; + // Presets export { defaultLayout, diff --git a/apps/web/lib/state/layout-manager/presets.test.ts b/apps/web/lib/state/layout-manager/presets.test.ts index b5215b4b72..5b90a35932 100644 --- a/apps/web/lib/state/layout-manager/presets.test.ts +++ b/apps/web/lib/state/layout-manager/presets.test.ts @@ -1,19 +1,22 @@ import { describe, expect, it } from "vitest"; import { compactLayout, defaultLayout, getPresetSidebarColumn } from "./presets"; +import { computePinnedMaxPx } from "./caps"; describe("layout presets", () => { it("keeps the compact workbench on Dockview while prioritizing the center panel", () => { const compact = compactLayout(); const compactSidebar = compact.columns.find((column) => column.id === "sidebar"); - const defaultSidebarMaxWidth = + // Default sidebar inherits the runtime cap (no per-column maxWidth); + // compact pins itself tighter. + const defaultSidebarCap = defaultLayout().columns.find((column) => column.id === "sidebar")?.maxWidth ?? - Number.POSITIVE_INFINITY; + computePinnedMaxPx(); expect(compact.columns.map((column) => column.id)).toEqual(["sidebar", "center"]); const compactSidebarWidth = compactSidebar?.width ?? Number.POSITIVE_INFINITY; const compactSidebarMaxWidth = compactSidebar?.maxWidth ?? Number.POSITIVE_INFINITY; - expect(compactSidebarWidth).toBeLessThan(defaultSidebarMaxWidth); - expect(compactSidebarMaxWidth).toBeLessThan(defaultSidebarMaxWidth); + expect(compactSidebarWidth).toBeLessThan(defaultSidebarCap); + expect(compactSidebarMaxWidth).toBeLessThan(defaultSidebarCap); expect(compact.columns.find((column) => column.id === "center")?.groups[0].panels[0].id).toBe( "chat", ); diff --git a/apps/web/lib/state/layout-manager/presets.ts b/apps/web/lib/state/layout-manager/presets.ts index afa4abfe3b..ba0072202a 100644 --- a/apps/web/lib/state/layout-manager/presets.ts +++ b/apps/web/lib/state/layout-manager/presets.ts @@ -1,7 +1,5 @@ import type { LayoutColumn, LayoutState } from "./types"; import { - LAYOUT_SIDEBAR_MAX_PX, - LAYOUT_RIGHT_MAX_PX, SIDEBAR_GROUP, CENTER_GROUP, RIGHT_TOP_GROUP, @@ -10,6 +8,8 @@ import { } from "./constants"; const COMPACT_SIDEBAR_WIDTH = 220; +// Compact preset intentionally caps the sidebar tight (small toolbar look), +// so it overrides the runtime cap rather than inheriting it. const COMPACT_SIDEBAR_MAX_PX = 260; export function defaultLayout(): LayoutState { @@ -18,7 +18,6 @@ export function defaultLayout(): LayoutState { { id: "sidebar", pinned: true, - maxWidth: LAYOUT_SIDEBAR_MAX_PX, groups: [{ id: SIDEBAR_GROUP, panels: [panel("sidebar")] }], }, { @@ -29,7 +28,6 @@ export function defaultLayout(): LayoutState { id: "right", pinned: true, width: 350, - maxWidth: LAYOUT_RIGHT_MAX_PX, groups: [ { id: RIGHT_TOP_GROUP, panels: [panel("files"), panel("changes")] }, { id: RIGHT_BOTTOM_GROUP, panels: [panel("terminal-default")] }, @@ -68,7 +66,6 @@ export function planLayout(): LayoutState { { id: "sidebar", pinned: true, - maxWidth: LAYOUT_SIDEBAR_MAX_PX, groups: [{ id: SIDEBAR_GROUP, panels: [panel("sidebar")] }], }, { @@ -89,7 +86,6 @@ export function previewLayout(): LayoutState { { id: "sidebar", pinned: true, - maxWidth: LAYOUT_SIDEBAR_MAX_PX, groups: [{ id: SIDEBAR_GROUP, panels: [panel("sidebar")] }], }, { @@ -110,7 +106,6 @@ export function vscodeLayout(): LayoutState { { id: "sidebar", pinned: true, - maxWidth: LAYOUT_SIDEBAR_MAX_PX, groups: [{ id: SIDEBAR_GROUP, panels: [panel("sidebar")] }], }, { @@ -119,7 +114,6 @@ export function vscodeLayout(): LayoutState { }, { id: "right", - maxWidth: LAYOUT_RIGHT_MAX_PX, groups: [{ panels: [panel("vscode")] }], }, ], diff --git a/apps/web/lib/state/layout-manager/serializer.ts b/apps/web/lib/state/layout-manager/serializer.ts index b68b2370a1..efb8ebcf35 100644 --- a/apps/web/lib/state/layout-manager/serializer.ts +++ b/apps/web/lib/state/layout-manager/serializer.ts @@ -1,13 +1,7 @@ import type { DockviewApi, SerializedDockview } from "dockview-react"; import type { LayoutState, LayoutColumn, LayoutGroup, LayoutPanel, LayoutNode } from "./types"; import { computeColumnWidths, computeGroupHeights } from "./sizing"; -import { - SIDEBAR_LOCK, - KNOWN_PANEL_IDS, - STRUCTURAL_COMPONENTS, - LAYOUT_SIDEBAR_MAX_PX, - LAYOUT_RIGHT_MAX_PX, -} from "./constants"; +import { SIDEBAR_LOCK, KNOWN_PANEL_IDS, STRUCTURAL_COMPONENTS } from "./constants"; // Dockview serialized grid node types (internal format) type SerializedLeafNode = { @@ -287,12 +281,7 @@ export function fromDockviewApi(api: DockviewApi): LayoutState { columns.push({ id: columnId, - ...(isPinned - ? { - pinned: true, - maxWidth: columnId === "right" ? LAYOUT_RIGHT_MAX_PX : LAYOUT_SIDEBAR_MAX_PX, - } - : {}), + ...(isPinned ? { pinned: true } : {}), width, groups: flatGroups, // Only store tree for columns with nested structure (branches) diff --git a/apps/web/lib/state/layout-manager/sizing.ts b/apps/web/lib/state/layout-manager/sizing.ts index 952704abdc..09942726df 100644 --- a/apps/web/lib/state/layout-manager/sizing.ts +++ b/apps/web/lib/state/layout-manager/sizing.ts @@ -1,23 +1,25 @@ import type { LayoutColumn, LayoutGroup } from "./types"; import { LAYOUT_SIDEBAR_RATIO } from "./constants"; +import { computePinnedMaxPx, LAYOUT_PINNED_MIN_PX } from "./caps"; /** * Get the effective pinned width for a column, * considering user overrides, maxWidth cap, and ratio-based default. + * + * The default cap is viewport-proportional (see ./caps.ts) — column.maxWidth + * still wins when explicitly set (e.g. compact preset narrowing the sidebar). */ export function getPinnedWidth( column: LayoutColumn, totalWidth: number, override?: number, ): number { + const max = column.maxWidth ?? computePinnedMaxPx(); + const min = column.minWidth ?? LAYOUT_PINNED_MIN_PX; if (override !== undefined) { - const max = column.maxWidth ?? Infinity; - const min = column.minWidth ?? 50; return Math.max(min, Math.min(override, max)); } const ratioWidth = Math.round(totalWidth * LAYOUT_SIDEBAR_RATIO); - const max = column.maxWidth ?? Infinity; - const min = column.minWidth ?? 50; return Math.max(min, Math.min(ratioWidth, max)); }