diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 912ff17f1d..cbf47dc849 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -11,6 +11,7 @@ import { createMemo, createEffect, createComputed, + createSignal, on, onMount, type ParentProps, @@ -69,6 +70,11 @@ import { createTimelineModel } from "@/pages/session/timeline/model" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" import { useSessionLayout } from "@/pages/session/session-layout" import { syncSessionModel } from "@/pages/session/session-model-helpers" +import { + clampSessionPanelWidth, + SESSION_PANEL_WIDTH_MIN, + sessionPanelWidthMax, +} from "@/pages/session/session-panel-width" import { SessionSidePanel } from "@/pages/session/session-side-panel" import { sessionPanelLayout } from "@/pages/session/session-panel-layout" import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2" @@ -442,9 +448,39 @@ export default function Page() { newSessionDesign() ? desktopV2ReviewOpen() || desktopTerminalOpen() : desktopReviewOpen(), ) const desktopSidePanelOpen = createMemo(() => desktopSessionResizeOpen() || desktopFileTreeOpen()) + let panelRow: HTMLDivElement | undefined + const [panelRowWidth, setPanelRowWidth] = createSignal() + createResizeObserver( + () => panelRow, + ({ width }) => setPanelRowWidth(width), + ) + const splitReview = createMemo( + () => (newSessionDesign() ? desktopV2ReviewOpen() : desktopReviewOpen()) && layout.review.diffStyle() === "split", + ) + // The observer reports the content-box width, which already excludes the row + // padding; only the flex gap between the panels remains to subtract. + const sessionPanelAvailable = createMemo(() => { + const width = panelRowWidth() + if (width === undefined) return undefined + return width - (settings.general.newLayoutDesigns() ? 8 : 0) + }) + const sessionPanelMax = createMemo(() => { + const available = sessionPanelAvailable() + if (available === undefined) return 1000 + return sessionPanelWidthMax({ available, split: splitReview() }) + }) + // Clamp at render time so window or sidebar resizes squeeze the chat panel + // instead of the review pane, without overwriting the persisted width. + const sessionPanelResizedWidth = createMemo(() => + clampSessionPanelWidth({ + width: layout.session.width(), + available: sessionPanelAvailable(), + split: splitReview(), + }), + ) const sessionPanelWidth = createMemo(() => { if (!desktopSidePanelOpen()) return "100%" - if (desktopSessionResizeOpen()) return `${layout.session.width()}px` + if (desktopSessionResizeOpen()) return `${sessionPanelResizedWidth()}px` return `calc(100% - ${layout.fileTree.width()}px)` }) const centered = createMemo(() => isDesktop() && !desktopReviewOpen()) @@ -2148,6 +2184,7 @@ export default function Page() {
{ size.touch() layout.session.resize(width) diff --git a/packages/app/src/pages/session/session-panel-width.test.ts b/packages/app/src/pages/session/session-panel-width.test.ts new file mode 100644 index 0000000000..3e2623775f --- /dev/null +++ b/packages/app/src/pages/session/session-panel-width.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test" +import { + clampSessionPanelWidth, + REVIEW_PANE_WIDTH_MIN, + REVIEW_PANE_WIDTH_MIN_SPLIT, + SESSION_PANEL_WIDTH_MIN, + sessionPanelWidthMax, +} from "./session-panel-width" + +describe("sessionPanelWidthMax", () => { + test("reserves the unified review pane minimum", () => { + expect(sessionPanelWidthMax({ available: 1700, split: false })).toBe(1700 - REVIEW_PANE_WIDTH_MIN) + }) + + test("reserves a larger minimum for split diffs", () => { + expect(sessionPanelWidthMax({ available: 1700, split: true })).toBe(1700 - REVIEW_PANE_WIDTH_MIN_SPLIT) + expect(REVIEW_PANE_WIDTH_MIN_SPLIT).toBeGreaterThan(REVIEW_PANE_WIDTH_MIN) + }) + + test("lets the chat panel take everything beyond the review pane minimum", () => { + // Regression: the old cap was 45% of the window, forcing the review pane + // to at least 55% of the window regardless of content. + const available = 3440 + expect(sessionPanelWidthMax({ available, split: false })).toBeGreaterThan(available * 0.45) + }) + + test("never drops below the chat panel minimum on small windows", () => { + expect(sessionPanelWidthMax({ available: 600, split: true })).toBe(SESSION_PANEL_WIDTH_MIN) + expect(sessionPanelWidthMax({ available: 0, split: false })).toBe(SESSION_PANEL_WIDTH_MIN) + }) +}) + +describe("clampSessionPanelWidth", () => { + test("keeps widths already within the limit", () => { + expect(clampSessionPanelWidth({ width: 800, available: 1700, split: false })).toBe(800) + }) + + test("forces the width down when the window shrinks", () => { + expect(clampSessionPanelWidth({ width: 1600, available: 1700, split: false })).toBe(1700 - REVIEW_PANE_WIDTH_MIN) + expect(clampSessionPanelWidth({ width: 1600, available: 1700, split: true })).toBe( + 1700 - REVIEW_PANE_WIDTH_MIN_SPLIT, + ) + }) + + test("holds the chat panel minimum when there is no room for both", () => { + expect(clampSessionPanelWidth({ width: 1600, available: 700, split: true })).toBe(SESSION_PANEL_WIDTH_MIN) + }) + + test("skips clamping before the layout is measured", () => { + expect(clampSessionPanelWidth({ width: 1600, available: undefined, split: false })).toBe(1600) + }) +}) diff --git a/packages/app/src/pages/session/session-panel-width.ts b/packages/app/src/pages/session/session-panel-width.ts new file mode 100644 index 0000000000..d04646b483 --- /dev/null +++ b/packages/app/src/pages/session/session-panel-width.ts @@ -0,0 +1,19 @@ +// The review pane has no width of its own: it takes whatever the chat panel +// 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 +export const REVIEW_PANE_WIDTH_MIN = 480 +export const REVIEW_PANE_WIDTH_MIN_SPLIT = 800 + +export function sessionPanelWidthMax(input: { available: number; split: boolean }) { + const pane = input.split ? REVIEW_PANE_WIDTH_MIN_SPLIT : REVIEW_PANE_WIDTH_MIN + return Math.max(SESSION_PANEL_WIDTH_MIN, 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 }) { + if (input.available === undefined) return input.width + return Math.min(input.width, sessionPanelWidthMax({ available: input.available, split: input.split })) +}