From df98f1c803a3d1c8d1999f71dd87aa12d3cdcf67 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 20 Oct 2026 12:21:45 -0700 Subject: [PATCH] feat(app): hover pill on resize handles and a wider sidebar range (#57343) --- packages/app/e2e/regression/tabs.spec.ts | 16 ++++++-- packages/app/src/session/screen-layout.ts | 3 +- packages/app/src/session/screen.tsx | 1 + .../src/session/session-panel-width.test.ts | 21 ++++++---- .../app/src/session/session-panel-width.ts | 29 +++++++++---- packages/app/src/shell/shell.tsx | 6 +-- packages/app/src/shell/state/layout.tsx | 2 + packages/ui/src/components/resize-handle.css | 41 ++++++++++++------- packages/ui/src/components/resize-handle.tsx | 5 +++ 9 files changed, 84 insertions(+), 40 deletions(-) diff --git a/packages/app/e2e/regression/tabs.spec.ts b/packages/app/e2e/regression/tabs.spec.ts index 351816ae655..b4b01792016 100644 --- a/packages/app/e2e/regression/tabs.spec.ts +++ b/packages/app/e2e/regression/tabs.spec.ts @@ -423,10 +423,7 @@ test("vertical tabs resize, scroll, show shortcut hints, and navigate", async ({ const handle = sidebar.locator('[data-component="resize-handle"]') - for (const [offset, width] of [ - [-80, "180px"], - [-200, "140px"], - ] as const) { + const resize = async (offset: number, width: string) => { const box = await handle.boundingBox() if (!box) throw new Error("vertical tab resize handle has no bounding box") @@ -437,6 +434,9 @@ test("vertical tabs resize, scroll, show shortcut hints, and navigate", async ({ await expect(sidebar).toHaveCSS("width", width) } + await resize(-80, "180px") + await resize(-40, "140px") + for (const name of ["Home", "New session"]) { const label = sidebar.getByRole("button", { name, exact: true }).getByText(name, { exact: true }) await expect(label).toBeVisible() @@ -445,6 +445,14 @@ test("vertical tabs resize, scroll, show shortcut hints, and navigate", async ({ .toBeLessThanOrEqual(1) } + await resize(-200, "120px") + const homeLabel = sidebar.getByRole("button", { name: "Home", exact: true }).getByText("Home", { exact: true }) + await expect + .poll(() => homeLabel.evaluate((element) => element.scrollWidth - element.clientWidth)) + .toBeLessThanOrEqual(1) + await resize(800, "640px") + await resize(-380, "260px") + const scroll = sidebar.locator('[data-slot="vertical-tabs-scroll"]') await scroll.evaluate((element) => element.scrollTo(0, element.scrollHeight)) await expect(tabB).toBeInViewport({ ratio: 1 }) diff --git a/packages/app/src/session/screen-layout.ts b/packages/app/src/session/screen-layout.ts index d8d07b16549..9394e220ebd 100644 --- a/packages/app/src/session/screen-layout.ts +++ b/packages/app/src/session/screen-layout.ts @@ -45,6 +45,7 @@ export function createSessionScreenLayout( width: view().session.width(), available: available(), split: splitSide(), + resized: view().session.resized(), }), ) @@ -61,7 +62,7 @@ export function createSessionScreenLayout( if (width === undefined) return 1000 - return sessionPanelWidthMax({ available: width, split: splitSide() }) + return sessionPanelWidthMax({ available: width, split: splitSide(), resized: true }) }) const sideOpen = createMemo(() => tabsOpen() || fileTreeOpen()) diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index 28a114174e8..fa8f71efefb 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -474,6 +474,7 @@ function SessionScreenContent(props: { >
screen.size.start()}> { - expect(clampSessionPanelWidth({ width, available, split })).toBe(expected) + ["lets the chat panel take everything beyond the review pane minimum", 3440, 3440, false, false, 2960], + ["holds the chat panel minimum when there is no room for both", 1600, 700, true, false, 450], + ["never drops below the chat panel minimum on small windows", 1600, 0, false, false, 450], + ["skips clamping before the layout is measured", 1600, undefined, false, false, 1600], + ["lets a dragged width leave the side pane its hard minimum, split diffs included", 1600, 1700, true, true, 1380], + ["keeps a dragged width below the default minimum", 320, 700, true, true, 320], + ["never drops a dragged width below the hard chat minimum", 1600, 0, false, true, 320], +])("%s", (_name, width, available, split, resized, expected) => { + expect(clampSessionPanelWidth({ width, available, split, resized })).toBe(expected) }) diff --git a/packages/app/src/session/session-panel-width.ts b/packages/app/src/session/session-panel-width.ts index 3cf0efe77d2..e66fa9d1aea 100644 --- a/packages/app/src/session/session-panel-width.ts +++ b/packages/app/src/session/session-panel-width.ts @@ -2,22 +2,35 @@ // leaves behind. Instead of capping the chat panel at a fraction of the window // (which forces the review pane to grow with the monitor), reserve a fixed // minimum for the review pane and let the chat panel take everything else. -export const SESSION_PANEL_WIDTH_MIN = 450 +// Until the user drags the divider, the default width keeps roomier minimums +// (wider still for split diffs); a drag can take either side down to the hard +// minimum. +export const SESSION_PANEL_WIDTH_MIN = 320 -const REVIEW_PANE_WIDTH_MIN = 480 +const SIDE_PANE_WIDTH_MIN = 320 -const REVIEW_PANE_WIDTH_MIN_SPLIT = 800 +const SESSION_PANEL_WIDTH_FIT = 450 -export function sessionPanelWidthMax(input: { available: number; split: boolean }) { - const pane = input.split ? REVIEW_PANE_WIDTH_MIN_SPLIT : REVIEW_PANE_WIDTH_MIN +const REVIEW_PANE_WIDTH_FIT = 480 - return Math.max(SESSION_PANEL_WIDTH_MIN, input.available - pane) +const REVIEW_PANE_WIDTH_FIT_SPLIT = 800 + +export function sessionPanelWidthMax(input: { available: number; split: boolean; resized: boolean }) { + if (input.resized) return Math.max(SESSION_PANEL_WIDTH_MIN, input.available - SIDE_PANE_WIDTH_MIN) + const pane = input.split ? REVIEW_PANE_WIDTH_FIT_SPLIT : REVIEW_PANE_WIDTH_FIT + + return Math.max(SESSION_PANEL_WIDTH_FIT, input.available - pane) } // `available` is undefined until the layout row is first measured; render the // stored width untouched until then to avoid a first-frame snap. -export function clampSessionPanelWidth(input: { width: number; available: number | undefined; split: boolean }) { +export function clampSessionPanelWidth(input: { + width: number + available: number | undefined + split: boolean + resized: boolean +}) { if (input.available === undefined) return input.width - return Math.min(input.width, sessionPanelWidthMax({ available: input.available, split: input.split })) + return Math.min(input.width, sessionPanelWidthMax({ ...input, available: input.available })) } diff --git a/packages/app/src/shell/shell.tsx b/packages/app/src/shell/shell.tsx index d6c8eb1c133..7cd186add94 100644 --- a/packages/app/src/shell/shell.tsx +++ b/packages/app/src/shell/shell.tsx @@ -93,11 +93,11 @@ export default function Layout(props: ParentProps) {
setState("tabsWidth", width)} /> diff --git a/packages/app/src/shell/state/layout.tsx b/packages/app/src/shell/state/layout.tsx index ad761f85d9e..ff93e197328 100644 --- a/packages/app/src/shell/state/layout.tsx +++ b/packages/app/src/shell/state/layout.tsx @@ -576,6 +576,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, session: { width: createMemo(() => regions.sessionWidth() ?? DEFAULT_SESSION_WIDTH), + /** The user has dragged the divider, so the width is theirs rather than the default. */ + resized: createMemo(() => regions.sessionWidth() !== undefined), resize: regions.setSessionWidth, }, } diff --git a/packages/ui/src/components/resize-handle.css b/packages/ui/src/components/resize-handle.css index 5d8b90b3290..1a8cbbbfcb5 100644 --- a/packages/ui/src/components/resize-handle.css +++ b/packages/ui/src/components/resize-handle.css @@ -2,16 +2,35 @@ position: absolute; z-index: 10; + /* A pill centred on the handle. It waits a moment before showing on hover so crossing a boundary stays quiet. */ &::after { content: ""; position: absolute; + top: 50%; + left: 50%; + translate: -50% -50%; + border-radius: 9999px; + background-color: var(--v2-icon-icon-muted); opacity: 0; - transition: opacity 0.15s ease-in-out; + pointer-events: none; + transition: + opacity 110ms cubic-bezier(0.4, 0, 1, 1), + background-color 150ms cubic-bezier(0.2, 0.8, 0.2, 1); } - &:hover::after, - &:active::after { + &:hover::after { opacity: 1; + transition: + opacity 150ms cubic-bezier(0.2, 0.8, 0.2, 1) 200ms, + background-color 150ms cubic-bezier(0.2, 0.8, 0.2, 1); + } + + &[data-dragging]::after { + opacity: 1; + background-color: var(--v2-icon-icon-base); + transition: + opacity 150ms cubic-bezier(0.2, 0.8, 0.2, 1), + background-color 150ms cubic-bezier(0.2, 0.8, 0.2, 1); } &:where([data-direction="horizontal"]) { @@ -28,10 +47,8 @@ } &::after { - width: 3px; - inset-block: 0; - inset-inline-start: 50%; - transform: translateX(-50%); + width: 4px; + height: 40px; } &:dir(rtl) { @@ -40,10 +57,6 @@ &[data-edge="start"] { transform: translateX(50%); } - - &::after { - transform: translateX(50%); - } } } @@ -61,10 +74,8 @@ } &::after { - height: 3px; - inset-inline: 0; - inset-block-start: 50%; - transform: translateY(-50%); + width: 40px; + height: 4px; } } } diff --git a/packages/ui/src/components/resize-handle.tsx b/packages/ui/src/components/resize-handle.tsx index f63f83af7e7..c8c828557b6 100644 --- a/packages/ui/src/components/resize-handle.tsx +++ b/packages/ui/src/components/resize-handle.tsx @@ -31,6 +31,7 @@ export function ResizeHandle(props: ResizeHandleProps) { const handleMouseDown = (e: MouseEvent) => { if (e.detail > 1) return e.preventDefault() + const handle = e.currentTarget instanceof HTMLElement ? e.currentTarget : undefined const edge = local.edge ?? (local.direction === "vertical" ? "start" : "end") const start = local.direction === "horizontal" ? e.clientX : e.clientY @@ -51,6 +52,8 @@ export function ResizeHandle(props: ResizeHandleProps) { document.body.style.userSelect = "none" document.body.style.overflow = "hidden" + document.body.style.cursor = local.direction === "horizontal" ? "col-resize" : "row-resize" + handle?.setAttribute("data-dragging", "") const onMouseMove = (moveEvent: MouseEvent) => { const pos = local.direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY @@ -78,6 +81,8 @@ export function ResizeHandle(props: ResizeHandleProps) { const onMouseUp = () => { document.body.style.userSelect = "" document.body.style.overflow = "" + document.body.style.cursor = "" + handle?.removeAttribute("data-dragging") document.removeEventListener("mousemove", onMouseMove) document.removeEventListener("mouseup", onMouseUp)