feat(app): hover pill on resize handles and a wider sidebar range (#57343)

This commit is contained in:
Kit Langton
2026-10-20 12:21:45 -07:00
committed by GitHub
Unverified
parent 05947ee285
commit df98f1c803
9 changed files with 84 additions and 40 deletions
+12 -4
View File
@@ -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 })
+2 -1
View File
@@ -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())
+1
View File
@@ -474,6 +474,7 @@ function SessionScreenContent(props: {
>
<div onPointerDown={() => screen.size.start()}>
<ResizeHandle
class="-top-1"
direction="vertical"
edge="start"
size={session.layout.view().bottom.height()}
@@ -2,14 +2,17 @@ import { expect, test } from "bun:test"
import { clampSessionPanelWidth } from "./session-panel-width"
test.each([
["keeps widths already within the limit", 800, 1700, false, 800],
["reserves the unified review pane minimum", 1600, 1700, false, 1220],
["reserves a larger minimum for split diffs", 1600, 1700, true, 900],
["keeps widths already within the limit", 800, 1700, false, false, 800],
["reserves the unified review pane minimum", 1600, 1700, false, false, 1220],
["reserves a larger minimum for split diffs", 1600, 1700, true, false, 900],
// Regression: the old cap was 45% of the window, forcing the review pane to at least 55%.
["lets the chat panel take everything beyond the review pane minimum", 3440, 3440, false, 2960],
["holds the chat panel minimum when there is no room for both", 1600, 700, true, 450],
["never drops below the chat panel minimum on small windows", 1600, 0, false, 450],
["skips clamping before the layout is measured", 1600, undefined, false, 1600],
])("%s", (_name, width, available, split, expected) => {
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)
})
@@ -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 }))
}
+3 -3
View File
@@ -93,11 +93,11 @@ export default function Layout(props: ParentProps) {
</div>
<Show when={preferences.appearance.sidebarOpen()}>
<ResizeHandle
class="-end-2"
class="-end-1"
direction="horizontal"
size={state.tabsWidth}
min={140}
max={520}
min={120}
max={640}
onResize={(width) => setState("tabsWidth", width)}
/>
</Show>
+2
View File
@@ -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,
},
}
+26 -15
View File
@@ -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;
}
}
}
@@ -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)