From 4b3fe010b2ae8b43207d550dd0bb4f0369e759de Mon Sep 17 00:00:00 2001 From: flamboh Date: Tue, 10 Nov 2026 03:57:35 +0000 Subject: [PATCH] fix(web): shrinking a column while scrolled right no longer runs away --- apps/web/src/layout/ColumnResizeHandle.tsx | 54 +++++++++---- apps/web/src/layout/SurfaceHost.tsx | 11 ++- apps/web/src/layout/layoutModel.test.ts | 93 ++++++++++++++++++++++ apps/web/src/layout/layoutModel.ts | 55 +++++++++++++ apps/web/src/layout/layoutStore.ts | 17 +++- 5 files changed, 207 insertions(+), 23 deletions(-) diff --git a/apps/web/src/layout/ColumnResizeHandle.tsx b/apps/web/src/layout/ColumnResizeHandle.tsx index c1fa91444b2b6..196ca9c2ff5e8 100644 --- a/apps/web/src/layout/ColumnResizeHandle.tsx +++ b/apps/web/src/layout/ColumnResizeHandle.tsx @@ -10,19 +10,19 @@ import { import { settleStripCamera } from "../motion/stripCamera"; import { - arrangeLayoutWorkspaces, LAYOUT_COLUMN_WIDTH_PRESETS, LAYOUT_MIN_COLUMN_WIDTH, LAYOUT_MIN_PANE_SIZE, LAYOUT_READABLE_COLUMN_PX, findLayoutPane, - resizeLayoutColumn, + resizeLayoutColumnEdge, resizeLayoutPane, type LayoutColumn, type LayoutPaneSlot, type LayoutState, type LayoutWorkspace, } from "./layoutModel"; +import { getLayoutBehavior } from "./layoutBehavior"; import { saveLayoutNow, useLayoutStore } from "./layoutStore"; /** Handles fill the gap between panes (the chrome gutter), centered on the boundary. */ @@ -94,8 +94,9 @@ type ActiveDrag = columnId: string; /** Where the column starts, in arranged widths from the strip's left edge. */ columnLeft: number; - /** The width its workspace is drawn against, in px: the unit column shares use. */ + /** The strip's visible width in px: the unit column shares use. */ unitWidth: number; + drawnUnitWidth: number; contentLeft: number; scrollLeft: number; bounds: { min: number; max: number }; @@ -137,13 +138,14 @@ export function LayoutResizeHandles({ stripWidth: number; unitWidth: number; scrollerRef: RefObject; - onPreview: (layout: LayoutState | null) => void; + onPreview: (preview: { layout: LayoutState; heldStripPx: number } | null) => void; }) { const [drag, setDrag] = useState(null); const dragRef = useRef(null); const baseLayoutRef = useRef(layout); const frameRef = useRef(null); const pointerRef = useRef({ x: 0, y: 0, scrollLeft: 0 }); + const heldStripPxRef = useRef(0); const cancelFrame = () => { if (frameRef.current === null) return; @@ -171,10 +173,13 @@ export function LayoutResizeHandles({ // release does, so the preview never jumps between fitting and scrolling. const applyDrag = (base: LayoutState, active: ActiveDrag): LayoutState => active.kind === "column" - ? arrangeLayoutWorkspaces( + ? resizeLayoutColumnEdge( base, - resizeLayoutColumn(base, active.columnId, active.value), + active.columnId, + active.value, + getLayoutBehavior().whenNoRoom, active.unitWidth, + active.drawnUnitWidth, ) : resizeLayoutPane(base, active.paneId, active.value); @@ -183,12 +188,16 @@ export function LayoutResizeHandles({ event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); baseLayoutRef.current = layout; + heldStripPxRef.current = scrollerRef.current?.scrollWidth ?? 0; pointerRef.current = { x: event.clientX, y: event.clientY, scrollLeft: scrollerRef.current?.scrollLeft ?? 0, }; updateDrag(active); + if (active.kind === "column") { + onPreview({ layout: applyDrag(layout, active), heldStripPx: heldStripPxRef.current }); + } }; const dragAtPointer = (active: ActiveDrag, event: PointerEvent): ActiveDrag => { @@ -222,7 +231,10 @@ export function LayoutResizeHandles({ const latest = dragRef.current; if (!latest) return; setDrag(latest); - onPreview(applyDrag(baseLayoutRef.current, latest)); + onPreview({ + layout: applyDrag(baseLayoutRef.current, latest), + heldStripPx: heldStripPxRef.current, + }); }); }; @@ -234,7 +246,12 @@ export function LayoutResizeHandles({ onPreview(null); const store = useLayoutStore.getState(); if (active.kind === "column") { - store.resizeColumnEdge(active.columnId, active.value, active.unitWidth); + store.resizeColumnEdge( + active.columnId, + active.value, + active.unitWidth, + active.drawnUnitWidth, + ); } else store.resizePane(active.paneId, active.value); saveLayoutNow(); }; @@ -260,6 +277,7 @@ export function LayoutResizeHandles({ settleStripCamera(); const rect = scroller.getBoundingClientRect(); return { + width: scroller.clientWidth, height: scroller.clientHeight, contentLeft: rect.left - scroller.scrollLeft, contentTop: rect.top, @@ -279,9 +297,9 @@ export function LayoutResizeHandles({ }; }); // No wider than the strip, so a column can always be wholly on screen. - const columnBounds = () => ({ - ...edgeBounds(MIN_COLUMN_PX, unitWidth, null, LAYOUT_MIN_COLUMN_WIDTH), - max: (scrollerRef.current?.clientWidth ?? unitWidth) / unitWidth, + const columnBounds = (unit: number) => ({ + ...edgeBounds(MIN_COLUMN_PX, unit, null, LAYOUT_MIN_COLUMN_WIDTH), + max: (scrollerRef.current?.clientWidth ?? unit) / unit, }); const paneBounds = (panes: LayoutColumn["panes"], index: number, heightPx: number) => edgeBounds( @@ -372,16 +390,18 @@ export function LayoutResizeHandles({ }} onPointerDown={(event) => { const metrics = measure(); - if (!metrics) return; + if (!metrics || metrics.width <= 0) return; + const scale = unitWidth / metrics.width; startDrag(event, { kind: "column", columnId: column.id, - columnLeft: thisColumnLeft, - unitWidth, + columnLeft: thisColumnLeft * scale, + unitWidth: metrics.width, + drawnUnitWidth: unitWidth, contentLeft: metrics.contentLeft, scrollLeft: scrollerRef.current?.scrollLeft ?? 0, - bounds: columnBounds(), - value: width, + bounds: columnBounds(metrics.width), + value: width * scale, snapped: null, }); }} @@ -395,7 +415,7 @@ export function LayoutResizeHandles({ const step = stepPreset(width, event.key === "ArrowRight" ? 1 : -1); useLayoutStore .getState() - .resizeColumnEdge(column.id, clampToBounds(step, columnBounds()), unitWidth); + .resizeColumnEdge(column.id, clampToBounds(step, columnBounds(unitWidth)), unitWidth); }} />, ); diff --git a/apps/web/src/layout/SurfaceHost.tsx b/apps/web/src/layout/SurfaceHost.tsx index ccf79e0db2ca7..83a8a205aad99 100644 --- a/apps/web/src/layout/SurfaceHost.tsx +++ b/apps/web/src/layout/SurfaceHost.tsx @@ -522,8 +522,8 @@ export function LayoutSurfaceHost({ pagePlane?: React.ReactNode; }) { // A resize drag previews here and commits to the store once on release. - const [preview, setPreview] = useState(null); - const shown = preview ?? layout; + const [preview, setPreview] = useState<{ layout: LayoutState; heldStripPx: number } | null>(null); + const shown = preview?.layout ?? layout; const workspace = selectFocusedWorkspace(shown); const narrow = useMediaQuery("max-md"); useSyncLayoutBehavior(); @@ -1402,6 +1402,13 @@ export function LayoutSurfaceHost({ /> ) : null} + {preview ? ( +
+ ) : null}
{pagePlane} diff --git a/apps/web/src/layout/layoutModel.test.ts b/apps/web/src/layout/layoutModel.test.ts index 34fae5250ceee..26f00763c99f6 100644 --- a/apps/web/src/layout/layoutModel.test.ts +++ b/apps/web/src/layout/layoutModel.test.ts @@ -11,7 +11,9 @@ import { openLayoutTab, placeLayoutTabs, replaceLayoutTabContent, + resizeLayoutColumnEdge, selectFocusedTab, + selectLayoutPaneSlots, showLayoutContent, type LayoutState, type LayoutTab, @@ -19,6 +21,7 @@ import { } from "./layoutModel"; import type { DraftId } from "../composerDraftStore"; import type { ThreadRouteTarget } from "../threadRoutes"; +import type { LayoutWhenNoRoom } from "./layoutBehavior"; const environmentId = EnvironmentId.make("local"); const threadRef = scopeThreadRef(environmentId, ThreadId.make("thread")); @@ -130,6 +133,26 @@ function shapeOf(state: LayoutState): Shape[] { ); } +const column = (id: string, width: number) => ({ + id, + width, + panes: [{ id: `${id}-pane`, tabs: [], activeTabId: null, size: 1 }], +}); + +function drawnRightEdges( + state: LayoutState, + viewportWidth: number, + whenNoRoom: LayoutWhenNoRoom = "shrink-then-scroll", +): number[] { + const { slots, stripWidth } = selectLayoutPaneSlots(state.workspaces[0]!, { + width: viewportWidth, + whenNoRoom, + }); + return slots.map((slot) => + Math.round((slot.rect.left + slot.rect.width) * stripWidth * viewportWidth), + ); +} + describe("showLayoutContent", () => { it("opens a thread in place of the ghost chat whose side panel has focus, not in the panel", () => { const withPanel = openLayoutTab(showLayoutContent(createLayoutState(), chat), diff, { @@ -345,3 +368,73 @@ describe("replaceLayoutTabContent", () => { expect(replaceLayoutTabContent(before, "closed-tab", chat)).toBe(before); }); }); + +describe("resizeLayoutColumnEdge", () => { + it("keeps the dragged edge under the pointer when the strip was arranged wider than the window", () => { + const viewportWidth = 1000; + const base: LayoutState = { + focusedWorkspaceId: "workspace", + workspaces: [ + { + id: "workspace", + name: null, + focusedPaneId: null, + cameraWidth: 1200, + columns: [column("chat", 0.5), column("terminal", 0.55)], + }, + ], + }; + const { unitWidth } = selectLayoutPaneSlots(base.workspaces[0]!, { + width: viewportWidth, + whenNoRoom: "shrink-then-scroll", + }); + expect(drawnRightEdges(base, viewportWidth)).toEqual([600, 1260]); + + const pointers = Array.from({ length: 41 }, (_, index) => 300 + index * 10); + const drawn = pointers.map((pointer) => + drawnRightEdges( + resizeLayoutColumnEdge( + base, + "chat", + pointer / viewportWidth, + "shrink-then-scroll", + viewportWidth, + unitWidth, + ), + viewportWidth, + ), + ); + expect(drawn).toEqual(pointers.map((pointer) => [pointer, pointer + 660])); + }); + + it("lands the dragged edge where it is released when columns always shrink to fit", () => { + const viewportWidth = 1000; + const base: LayoutState = { + focusedWorkspaceId: "workspace", + workspaces: [ + { + id: "workspace", + name: null, + focusedPaneId: null, + cameraWidth: viewportWidth, + columns: [column("chat", 0.4), column("diff", 0.4), column("terminal", 0.4)], + }, + ], + }; + const { unitWidth } = selectLayoutPaneSlots(base.workspaces[0]!, { + width: viewportWidth, + whenNoRoom: "always-shrink", + }); + expect(drawnRightEdges(base, viewportWidth, "always-shrink")).toEqual([333, 667, 1000]); + + const released = resizeLayoutColumnEdge( + base, + "diff", + (600 - 1000 / 3) / viewportWidth, + "always-shrink", + viewportWidth, + unitWidth, + ); + expect(drawnRightEdges(released, viewportWidth, "always-shrink")).toEqual([333, 600, 1000]); + }); +}); diff --git a/apps/web/src/layout/layoutModel.ts b/apps/web/src/layout/layoutModel.ts index fa939c63b3432..ac713a2f08375 100644 --- a/apps/web/src/layout/layoutModel.ts +++ b/apps/web/src/layout/layoutModel.ts @@ -1786,6 +1786,61 @@ export function resizeLayoutColumn( }); } +export function resizeLayoutColumnEdge( + state: LayoutState, + columnId: string, + width: number, + mode: LayoutWhenNoRoom, + cameraWidth: number, + drawnUnitWidth = cameraWidth, +): LayoutState { + const scale = drawnUnitWidth / cameraWidth; + const rebased = + scale === 1 + ? state + : mapWorkspaces(state, (workspace) => + workspace.columns.some((column) => column.id === columnId) + ? { + ...workspace, + columns: workspace.columns.map((column) => ({ + ...column, + width: column.width * scale, + })), + } + : workspace, + ); + const workspace = rebased.workspaces.find((candidate) => + candidate.columns.some((column) => column.id === columnId), + ); + const index = workspace?.columns.findIndex((column) => column.id === columnId) ?? -1; + const neighbor = workspace?.columns[index + 1]; + if (mode !== "always-shrink" || !workspace || !neighbor || !Number.isFinite(width)) { + return arrangeLayoutWorkspaces( + state, + resizeLayoutColumn(rebased, columnId, width), + cameraWidth, + ); + } + const pair = workspace.columns[index]!.width + neighbor.width; + const floor = Math.min(LAYOUT_MIN_COLUMN_WIDTH, pair / 2); + const resized = Math.min(pair - floor, Math.max(floor, width)); + const shared = mapWorkspaces(rebased, (candidate) => + candidate === workspace + ? { + ...candidate, + columns: candidate.columns.map((column, columnIndex) => + columnIndex === index + ? { ...column, width: resized } + : columnIndex === index + 1 + ? { ...column, width: pair - resized } + : column, + ), + } + : candidate, + ); + return arrangeLayoutWorkspaces(state, shared, cameraWidth); +} + /** Step to the next width preset, wrapping from 2/3 back to 1/3. A lone column keeps filling the window. */ export function cycleLayoutColumnWidth(state: LayoutState, columnId: string): LayoutState { const workspace = state.workspaces.find((candidate) => diff --git a/apps/web/src/layout/layoutStore.ts b/apps/web/src/layout/layoutStore.ts index e3133a07cc9dd..3be98443e5a1e 100644 --- a/apps/web/src/layout/layoutStore.ts +++ b/apps/web/src/layout/layoutStore.ts @@ -42,6 +42,7 @@ import { pullLayoutPane, replaceLayoutTabContent, resizeLayoutColumn, + resizeLayoutColumnEdge, resizeLayoutPane, type LayoutCompanionKind, type LayoutDirection, @@ -318,7 +319,12 @@ interface LayoutStoreState { closeTabsWhere: (predicate: (tab: LayoutTab) => boolean) => void; focusAdjacentTab: (delta: 1 | -1) => void; /** Resize a column measured in shares of `unitWidth`, and arrange its workspace there. */ - resizeColumnEdge: (columnId: string, width: number, unitWidth: number) => void; + resizeColumnEdge: ( + columnId: string, + width: number, + unitWidth: number, + drawnUnitWidth?: number, + ) => void; moveTab: (tabId: string, destination: LayoutTabDestination) => void; /** Move the tab showing this content to the destination, or open one there. */ placeContent: (content: LayoutTabContent, destination: LayoutTabDestination) => void; @@ -497,13 +503,16 @@ export const useLayoutStore = create()( closeTab: (tabId) => apply((layout) => closeLayoutTab(layout, tabId)), closeTabsWhere: (predicate) => apply((layout) => closeLayoutTabsWhere(layout, predicate)), focusAdjacentTab: (delta) => apply((layout) => focusAdjacentLayoutTab(layout, delta)), - resizeColumnEdge: (columnId, width, unitWidth) => + resizeColumnEdge: (columnId, width, unitWidth, drawnUnitWidth) => apply( (layout) => - arrangeLayoutWorkspaces( + resizeLayoutColumnEdge( layout, - resizeLayoutColumn(layout, columnId, width), + columnId, + width, + getLayoutBehavior().whenNoRoom, unitWidth, + drawnUnitWidth, ), true, ),