authorgravatar for 82711162+Lasdw6@users.noreply.github.comVividh Mahajan <82711162+Lasdw6@users.noreply.github.com> 2026-08-15 08:01:47-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-15 12:01:47+00:00
log06dd9993b6292e61de8890956a3ce3c50495a6eb
tree75a157322d2dc23ec4f92fe6902ec09e7cc6b0c4
parent143f713c79103b8039a7e83cc16124aadcffa424
signature Signed by PGP key B5690EEEBB952194

fix(web): reserve sibling column width when resizing the right panel (#6279)


2 files changed, 90 insertions(+), 10 deletions(-)

apps/web/src/components/preview/PreviewPanelShell.test.ts+26
...@@ -20,4 +20,30 @@ describe("getPreviewPanelMaxWidth", () => {...@@ -20,4 +20,30 @@ describe("getPreviewPanelMaxWidth", () => {
2020
21 expect(markup).toContain("max-w-full");21 expect(markup).toContain("max-w-full");
22 });22 });
23
24 it("reserves the sibling column minimum when the flex row is known", () => {
25 // Fullscreen 14" MacBook: viewport 1512, sidebar ~256 → row of 1256.
26 // The 70% fraction (1058) would leave the chat column only ~198px;
27 // the container clamp caps the panel at 1256 − 360 instead.
28 expect(getPreviewPanelMaxWidth(1_512, 1_256)).toBe(896);
29 });
30
31 it("keeps the fraction cap when the row is wide enough for both columns", () => {
32 expect(getPreviewPanelMaxWidth(3_000, 2_900)).toBe(2_100);
33 });
34
35 it("rounds fractional row widths down", () => {
36 expect(getPreviewPanelMaxWidth(1_512, 1_256.6)).toBe(896);
37 });
38
39 it("never drops below the panel minimum when the row cannot fit both columns", () => {
40 // ~1000px window with an expanded sidebar → row of 700. The sibling
41 // reservation (700 − 360 = 340) would undercut the panel's own 360
42 // minimum and invert the resize clamp, so the floor wins.
43 expect(getPreviewPanelMaxWidth(1_000, 700)).toBe(360);
44 });
45
46 it("stays at the panel minimum even when the row is narrower than the reservation", () => {
47 expect(getPreviewPanelMaxWidth(1_512, 300)).toBe(360);
48 });
23});49});
apps/web/src/components/preview/PreviewPanelShell.tsx+64-10
...@@ -1,4 +1,11 @@...@@ -1,4 +1,11 @@
1import { type ReactNode, useEffect, useState } from "react";1import {
2 type ReactNode,
3 type RefObject,
4 useEffect,
5 useLayoutEffect,
6 useRef,
7 useState,
8} from "react";
29
3import { isElectron } from "~/env";10import { isElectron } from "~/env";
4import { useResizableWidth } from "~/hooks/useResizableWidth";11import { useResizableWidth } from "~/hooks/useResizableWidth";
...@@ -10,12 +17,31 @@ export type PreviewPanelMode = "inline" | "sheet" | "sidebar" | "embedded";...@@ -10,12 +17,31 @@ export type PreviewPanelMode = "inline" | "sheet" | "sidebar" | "embedded";
1017
11const PREVIEW_PANEL_WIDTH_STORAGE_KEY = "t3code:preview-panel-width";18const PREVIEW_PANEL_WIDTH_STORAGE_KEY = "t3code:preview-panel-width";
12const PREVIEW_PANEL_MIN_WIDTH = 360;19const PREVIEW_PANEL_MIN_WIDTH = 360;
13/** Fraction of the viewport allowed, preserving the remaining space for chat. */20/**
21 * Upper bound as a fraction of the viewport; only binds on wide screens.
22 * On narrow windows the container clamp below is what preserves the
23 * sibling column's space.
24 */
14const PREVIEW_PANEL_MAX_WIDTH_FRACTION = 0.7;25const PREVIEW_PANEL_MAX_WIDTH_FRACTION = 0.7;
15const PREVIEW_PANEL_DEFAULT_WIDTH = 540;26const PREVIEW_PANEL_DEFAULT_WIDTH = 540;
27/**
28 * Width reserved for the sibling column (chat, pull-request list) sharing the
29 * panel's flex row. The viewport fraction alone is not enough: the app
30 * sidebar sits outside the row, so on narrow windows (any MacBook, even
31 * fullscreen) the remaining 30% of the viewport minus the sidebar left the
32 * sibling below its usable width and the composer overflowed.
33 */
34const SIBLING_COLUMN_MIN_WIDTH = 360;
1635
17export function getPreviewPanelMaxWidth(viewportWidth: number): number {36export function getPreviewPanelMaxWidth(viewportWidth: number, containerWidth?: number): number {
18 return Math.floor(viewportWidth * PREVIEW_PANEL_MAX_WIDTH_FRACTION);37 const fractionCap = Math.floor(viewportWidth * PREVIEW_PANEL_MAX_WIDTH_FRACTION);
38 const containerCap =
39 containerWidth === undefined ? Infinity : Math.floor(containerWidth) - SIBLING_COLUMN_MIN_WIDTH;
40 // Never below the panel's own minimum: when the row cannot fit both
41 // columns' minimums the sibling yields, and useResizableWidth's clamp
42 // must not see max < min (it would resolve the inversion to min and,
43 // via drag-end persistence, overwrite the user's stored width).
44 return Math.max(PREVIEW_PANEL_MIN_WIDTH, Math.min(fractionCap, containerCap));
19}45}
2046
21/**47/**
...@@ -39,7 +65,10 @@ export function PreviewPanelShell(props: {...@@ -39,7 +65,10 @@ export function PreviewPanelShell(props: {
39}) {65}) {
40 const useDragRegion = isElectron && props.mode !== "sheet" && props.mode !== "embedded";66 const useDragRegion = isElectron && props.mode !== "sheet" && props.mode !== "embedded";
41 const isInline = props.mode === "inline";67 const isInline = props.mode === "inline";
42 const maxWidth = useViewportClampedMaxWidth();68 const hostRef = useRef<HTMLDivElement | null>(null);
69 // Only inline non-maximized mode applies `width`/`maxWidth`; skip the
70 // container measurement (and its re-renders) everywhere else.
71 const maxWidth = useClampedMaxWidth(hostRef, isInline && !props.maximized);
43 const { width, handlers } = useResizableWidth({72 const { width, handlers } = useResizableWidth({
44 storageKey: props.widthStorageKey ?? PREVIEW_PANEL_WIDTH_STORAGE_KEY,73 storageKey: props.widthStorageKey ?? PREVIEW_PANEL_WIDTH_STORAGE_KEY,
45 defaultWidth: props.defaultWidth ?? PREVIEW_PANEL_DEFAULT_WIDTH,74 defaultWidth: props.defaultWidth ?? PREVIEW_PANEL_DEFAULT_WIDTH,
...@@ -50,6 +79,7 @@ export function PreviewPanelShell(props: {...@@ -50,6 +79,7 @@ export function PreviewPanelShell(props: {
5079
51 return (80 return (
52 <div81 <div
82 ref={hostRef}
53 className={cn(83 className={cn(
54 "relative flex h-full min-h-0 min-w-0 max-w-full flex-col self-stretch bg-background",84 "relative flex h-full min-h-0 min-w-0 max-w-full flex-col self-stretch bg-background",
55 isInline85 isInline
...@@ -70,12 +100,17 @@ export function PreviewPanelShell(props: {...@@ -70,12 +100,17 @@ export function PreviewPanelShell(props: {
70}100}
71101
72/**102/**
73 * Track viewport width to derive a sensible upper bound for the panel.103 * Track viewport and flex-row widths to derive an upper bound for the panel.
74 * Resize-aware so dragging the OS window narrower re-clamps the stored104 * Resize-aware so dragging the OS window narrower (or expanding the app
75 * width on the next render (the hook's clamp picks this up automatically).105 * sidebar) re-clamps the stored width on the next render (the hook's clamp
106 * picks this up automatically). The row is observed rather than the panel
107 * itself because the panel competes with its sibling column for row space.
108 * Row measurement only runs when `enabled`; modes without a resize handle
109 * never apply the resulting width, so they skip the observer entirely.
76 */110 */
77function useViewportClampedMaxWidth(): number {111function useClampedMaxWidth(hostRef: RefObject<HTMLDivElement | null>, enabled: boolean): number {
78 const [vw, setVw] = useState(() => (typeof window === "undefined" ? 1280 : window.innerWidth));112 const [vw, setVw] = useState(() => (typeof window === "undefined" ? 1280 : window.innerWidth));
113 const [containerWidth, setContainerWidth] = useState<number | undefined>(undefined);
79 useEffect(() => {114 useEffect(() => {
80 if (typeof window === "undefined") return;115 if (typeof window === "undefined") return;
81 let frame = 0;116 let frame = 0;
...@@ -93,5 +128,24 @@ function useViewportClampedMaxWidth(): number {...@@ -93,5 +128,24 @@ function useViewportClampedMaxWidth(): number {
93 if (frame !== 0) window.cancelAnimationFrame(frame);128 if (frame !== 0) window.cancelAnimationFrame(frame);
94 };129 };
95 }, []);130 }, []);
96 return getPreviewPanelMaxWidth(vw);131 useLayoutEffect(() => {
132 if (!enabled) return;
133 const parent = hostRef.current?.parentElement;
134 if (!parent) return;
135 // Measure before first paint: the persisted width must be clamped
136 // against the row on the initial render, not one observer tick later
137 // (the panel would flash over-wide on every mount). clientWidth is
138 // integral, so sub-pixel resize deltas bail out of re-rendering.
139 const measure = () => {
140 setContainerWidth(parent.clientWidth);
141 };
142 measure();
143 if (typeof ResizeObserver === "undefined") return;
144 const observer = new ResizeObserver(measure);
145 observer.observe(parent);
146 return () => {
147 observer.disconnect();
148 };
149 }, [hostRef, enabled]);
150 return getPreviewPanelMaxWidth(vw, containerWidth);
97}151}