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)