Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2166,10 +2166,6 @@ export default function ChatView(props: ChatViewProps) {
[activeThread],
);
const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;
const previewPanelInlineSize = usePreviewPanelInlineSize(undefined, {
containerWidth: workspaceLayoutWidth ?? undefined,
widthStorageKey: `t3code:preview-panel-width:${activeThreadKey}`,
});
const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null);
const timelineThreadError =
serverRuntime?.status === "failed" &&
Expand Down Expand Up @@ -2284,6 +2280,12 @@ export default function ChatView(props: ChatViewProps) {
const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet;
const rightPanelMaximized =
canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey;
const previewPanelInlineSize = usePreviewPanelInlineSize(undefined, {
containerWidth: workspaceLayoutWidth ?? undefined,
enabled: !shouldUsePlanSidebarSheet && !rightPanelMaximized,
open: rightPanelOpen,
widthStorageKey: `t3code:preview-panel-width:${activeThreadKey}`,
});
const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet;
const [threadPanelPresentation, setThreadPanelPresentation] =
useState<ThreadPanelPresentation>("inline");
Expand Down
28 changes: 12 additions & 16 deletions apps/web/src/components/preview/PreviewPanelShell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,37 +3,33 @@ import { describe, expect, it } from "vite-plus/test";
import { getPreviewPanelMaxWidth } from "./PreviewPanelShell";

describe("getPreviewPanelMaxWidth", () => {
it("allows the panel to use 70% of an ultra-wide viewport without a pixel ceiling", () => {
expect(getPreviewPanelMaxWidth(6_000)).toBe(4_200);
});

it("rounds fractional CSS pixels down", () => {
expect(getPreviewPanelMaxWidth(2_001)).toBe(1_400);
it("reserves the sibling column minimum beside an open app sidebar", () => {
// Fullscreen 14" MacBook: viewport 1512, sidebar ~256 → row of 1256.
expect(getPreviewPanelMaxWidth(1_256)).toBe(896);
});

it("reserves the sibling column minimum when the flex row is known", () => {
// Fullscreen 14" MacBook: viewport 1512, sidebar ~256 → row of 1256.
// The 70% fraction (1058) would leave the chat column only ~198px;
// the container clamp caps the panel at 1256 − 360 instead.
expect(getPreviewPanelMaxWidth(1_512, 1_256)).toBe(896);
it("reserves the same sibling minimum when the app sidebar is closed", () => {
// The row is the whole 1800px viewport; the chat still keeps only 360px,
// not 30% of the viewport.
expect(getPreviewPanelMaxWidth(1_800)).toBe(1_440);
});

it("keeps the fraction cap when the row is wide enough for both columns", () => {
expect(getPreviewPanelMaxWidth(3_000, 2_900)).toBe(2_100);
it("has no pixel ceiling on ultra-wide rows", () => {
expect(getPreviewPanelMaxWidth(6_000)).toBe(5_640);
});

it("rounds fractional row widths down", () => {
expect(getPreviewPanelMaxWidth(1_512, 1_256.6)).toBe(896);
expect(getPreviewPanelMaxWidth(1_256.6)).toBe(896);
});

it("never drops below the panel minimum when the row cannot fit both columns", () => {
// ~1000px window with an expanded sidebar → row of 700. The sibling
// reservation (700 − 360 = 340) would undercut the panel's own 360
// minimum and invert the resize clamp, so the floor wins.
expect(getPreviewPanelMaxWidth(1_000, 700)).toBe(360);
expect(getPreviewPanelMaxWidth(700)).toBe(360);
});

it("stays at the panel minimum even when the row is narrower than the reservation", () => {
expect(getPreviewPanelMaxWidth(1_512, 300)).toBe(360);
expect(getPreviewPanelMaxWidth(300)).toBe(360);
});
});
1 change: 1 addition & 0 deletions apps/web/src/components/preview/PreviewPanelShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ function ResizablePreviewPanelShell(props: PreviewPanelShellProps) {
const hostRef = useRef<HTMLDivElement | null>(null);
const inlineSize = usePreviewPanelInlineSize(hostRef, {
enabled: props.mode === "inline" && !props.maximized,
open: props.open ?? true,
widthStorageKey: props.widthStorageKey,
defaultWidth: props.defaultWidth,
});
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/hooks/useElementWidth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useLayoutEffect, useState } from "react";
import { flushSync } from "react-dom";

export function useElementWidth<T extends HTMLElement>() {
const [element, setElement] = useState<T | null>(null);
Expand All @@ -14,8 +15,11 @@ export function useElementWidth<T extends HTMLElement>() {
update(element.getBoundingClientRect().width);
if (typeof ResizeObserver === "undefined") return;

// Flush in the observer's pre-paint slot: the preview panel absorbs row
// changes (the app sidebar animating its width), and a width that landed a
// frame late would wobble the chat column beside it.
const observer = new ResizeObserver(([entry]) => {
if (entry) update(entry.contentRect.width);
if (entry) flushSync(() => update(entry.contentRect.width));
});
observer.observe(element);
return () => observer.disconnect();
Expand Down
63 changes: 63 additions & 0 deletions apps/web/src/hooks/usePreviewPanelInlineSize.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { act, useLayoutEffect } from "react";
import { create, type ReactTestRenderer } from "react-test-renderer";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

import {
type PreviewPanelInlineSize,
usePreviewPanelInlineSize,
} from "./usePreviewPanelInlineSize";

let renderer: ReactTestRenderer;
let result: PreviewPanelInlineSize;

function Panel(props: { containerWidth: number; open?: boolean; enabled?: boolean }) {
const size = usePreviewPanelInlineSize(undefined, props);
useLayoutEffect(() => {
result = size;
});
return null;
}

beforeEach(async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
const events = new EventTarget();
vi.stubGlobal("window", {
addEventListener: events.addEventListener.bind(events),
removeEventListener: events.removeEventListener.bind(events),
localStorage: { getItem: () => null, setItem: () => {} },
});
await act(() => {
renderer = create(<Panel containerWidth={1_256} />);
});
});

afterEach(async () => {
await act(() => renderer.unmount());
vi.unstubAllGlobals();
});

describe("usePreviewPanelInlineSize", () => {
it("keeps the chat column width when the caller's row widens", async () => {
expect(result.width).toBe(540);
// App sidebar collapses: the 256px it frees goes to the panel.
await act(() => renderer.update(<Panel containerWidth={1_512} />));
expect(result.width).toBe(796);
});

it("has no viewport cap, so wide rows still go to the panel", async () => {
await act(() => renderer.update(<Panel containerWidth={6_000} />));
expect(result.width).toBe(5_284);
await act(() => renderer.update(<Panel containerWidth={1_256} />));
expect(result.width).toBe(540);
});

it.each([
["closed", { open: false }],
["maximized", { enabled: false }],
])("does not track the row while %s", async (_state, props) => {
await act(() => renderer.update(<Panel containerWidth={1_256} {...props} />));
await act(() => renderer.update(<Panel containerWidth={1_512} {...props} />));
await act(() => renderer.update(<Panel containerWidth={1_512} />));
expect(result.width).toBe(540);
});
});
96 changes: 46 additions & 50 deletions apps/web/src/hooks/usePreviewPanelInlineSize.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type RefObject, useEffect, useLayoutEffect, useState } from "react";
import { type RefObject, useLayoutEffect, useState } from "react";
import { flushSync } from "react-dom";

import { type ResizableWidthHandlers, useResizableWidth } from "./useResizableWidth";

Expand All @@ -10,70 +11,59 @@ export interface PreviewPanelInlineSize {
const PREVIEW_PANEL_WIDTH_STORAGE_KEY = "t3code:preview-panel-width";
const PREVIEW_PANEL_MIN_WIDTH = 360;
/**
* Upper bound as a fraction of the viewport; only binds on wide screens.
* On narrow windows the container clamp below is what preserves the
* sibling column's usable width.
*/
const PREVIEW_PANEL_MAX_WIDTH_FRACTION = 0.7;
/**
* Floor for the column beside the panel: below this the chat column's
* composer overflows, so the panel yields instead.
* Width reserved for the sibling column (chat, pull-request list) sharing the
* panel's flex row; below this the chat column's composer overflows. This is
* the only upper bound on the panel, so the chat keeps the same minimum
* whether or not the app sidebar is open. A cap based on the viewport would
* ignore the sidebar and bind only once it collapses.
*/
const SIBLING_COLUMN_MIN_WIDTH = 360;
const PREVIEW_PANEL_DEFAULT_WIDTH = 540;

export function usePreviewPanelInlineSize(
hostRef?: RefObject<HTMLElement | null>,
options: {
/** False in modes that never apply the inline width (sheet, maximized). */
readonly enabled?: boolean | undefined;
/** A closed panel leaves the whole row to its sibling, so it stops tracking the row. */
readonly open?: boolean | undefined;
readonly widthStorageKey?: string | undefined;
readonly defaultWidth?: number | undefined;
/** Use the caller's existing row measurement instead of observing the panel's parent. */
readonly containerWidth?: number | undefined;
} = {},
): PreviewPanelInlineSize {
const maxWidth = useViewportClampedMaxWidth(
hostRef,
options.enabled ?? true,
options.containerWidth,
);
const enabled = options.enabled ?? true;
const measuredRowWidth = useRowWidth(hostRef, enabled);
const rowWidth = options.containerWidth ?? measuredRowWidth;
return useResizableWidth({
storageKey: options.widthStorageKey ?? PREVIEW_PANEL_WIDTH_STORAGE_KEY,
defaultWidth: options.defaultWidth ?? PREVIEW_PANEL_DEFAULT_WIDTH,
minWidth: PREVIEW_PANEL_MIN_WIDTH,
maxWidth,
// Unmeasured only before the first layout effect or in modes that never
// apply the width; the viewport is an upper bound on the row.
maxWidth: getPreviewPanelMaxWidth(
rowWidth ?? (typeof window === "undefined" ? 1280 : window.innerWidth),
),
// An open inline panel absorbs row changes (window resize, app sidebar
// toggle) so its sibling keeps its width. Closed or maximized panels stop
// tracking and restart from a fresh baseline.
rowWidth: enabled && (options.open ?? true) ? rowWidth : undefined,
edge: "left",
});
}

/**
* Keep the resizable panel's upper bound in sync with the window AND the row
* it sits in. Resize-aware so dragging the OS window narrower (or expanding
* the app sidebar) re-clamps the stored width on the next render.
* Track the flex-row width the panel shares with its sibling column. The row
* is observed rather than the panel itself because the panel competes with
* its sibling for row space. Measurement only runs when `enabled`; modes
* without a resize handle never apply the resulting width.
*/
function useViewportClampedMaxWidth(
function useRowWidth(
hostRef: RefObject<HTMLElement | null> | undefined,
enabled: boolean,
containerWidth?: number,
): number {
const [vw, setVw] = useState(() => (typeof window === "undefined" ? 1280 : window.innerWidth));
const [measuredContainerWidth, setContainerWidth] = useState<number | undefined>(undefined);
useEffect(() => {
if (typeof window === "undefined") return;
let frame = 0;
const onResize = () => {
if (frame !== 0) return;
frame = window.requestAnimationFrame(() => {
frame = 0;
setVw(window.innerWidth);
});
};
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
if (frame !== 0) window.cancelAnimationFrame(frame);
};
}, []);
): number | undefined {
const [rowWidth, setRowWidth] = useState<number | undefined>(undefined);
useLayoutEffect(() => {
if (!enabled) return;
const parent = hostRef?.current?.parentElement;
Expand All @@ -83,25 +73,31 @@ function useViewportClampedMaxWidth(
// would flash over-wide on every mount). clientWidth is integral, so
// sub-pixel resize deltas bail out of re-rendering.
const measure = () => {
setContainerWidth(parent.clientWidth);
setRowWidth(parent.clientWidth);
};
measure();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(measure);
observer.observe(parent);
// Flush in the observer's pre-paint slot: the app sidebar animates its
// width, and a panel that caught up a frame late would wobble the chat.
const observer =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(() => {
flushSync(measure);
});
observer?.observe(parent);
return () => {
observer.disconnect();
observer?.disconnect();
// Tracking restarts from a fresh baseline, not a width from before.
setRowWidth(undefined);
};
}, [hostRef, enabled]);
return getPreviewPanelMaxWidth(vw, containerWidth ?? measuredContainerWidth);
return rowWidth;
}
export function getPreviewPanelMaxWidth(viewportWidth: number, containerWidth?: number): number {
const fractionCap = Math.floor(viewportWidth * PREVIEW_PANEL_MAX_WIDTH_FRACTION);
const containerCap =
containerWidth === undefined ? Infinity : Math.floor(containerWidth) - SIBLING_COLUMN_MIN_WIDTH;

export function getPreviewPanelMaxWidth(rowWidth: number): number {
// Never below the panel's own minimum: when the row cannot fit both
// columns' minimums the sibling yields, and useResizableWidth's clamp must
// not see max < min (it would resolve the inversion to min and, via
// drag-end persistence, overwrite the user's stored width).
return Math.max(PREVIEW_PANEL_MIN_WIDTH, Math.min(fractionCap, containerCap));
return Math.max(PREVIEW_PANEL_MIN_WIDTH, Math.floor(rowWidth) - SIBLING_COLUMN_MIN_WIDTH);
}
62 changes: 62 additions & 0 deletions apps/web/src/hooks/useResizableWidth.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,20 @@ function Panel({
edge = "left",
maxWidth = 800,
storageKey = "test-panel-width",
rowWidth,
}: {
edge?: "left" | "right";
maxWidth?: number;
storageKey?: string;
rowWidth?: number | undefined;
}) {
const resize = useResizableWidth({
storageKey,
defaultWidth: 400,
minWidth: 200,
maxWidth,
edge,
rowWidth,
});
useLayoutEffect(() => {
result = resize;
Expand Down Expand Up @@ -241,3 +244,62 @@ describe("panel width storage changes", () => {
expect(result.width).toBe(400);
});
});

describe("panel width row tracking", () => {
it("absorbs row changes so the sibling column keeps its width", async () => {
await act(() => renderer.update(<Panel rowWidth={1000} />));
expect(result.width).toBe(400);
// App sidebar collapses: the row widens and the panel takes all of it.
await act(() => renderer.update(<Panel rowWidth={1256} />));
expect(result.width).toBe(656);
await act(() => renderer.update(<Panel rowWidth={1000} />));
expect(result.width).toBe(400);
expect(setItem).not.toHaveBeenCalled();
});

it("restores the same split after a row change hits the panel bounds", async () => {
await act(() => renderer.update(<Panel rowWidth={1000} />));
await act(() => renderer.update(<Panel rowWidth={700} />));
expect(result.width).toBe(200);
await act(() => renderer.update(<Panel rowWidth={1000} />));
expect(result.width).toBe(400);
});

it("starts from a fresh baseline after tracking stops", async () => {
await act(() => renderer.update(<Panel rowWidth={1000} />));
await act(() => renderer.update(<Panel rowWidth={undefined} />));
await act(() => renderer.update(<Panel rowWidth={1256} />));
expect(result.width).toBe(400);
});

it("drags from the shifted width and saves what the user released", async () => {
await act(() => renderer.update(<Panel rowWidth={1000} />));
await act(() => renderer.update(<Panel rowWidth={1100} />));
await act(() => {
result.handlers.onPointerDown(pointer());
result.handlers.onPointerUp(pointer(50));
});
expect(result.width).toBe(550);
expect(setItem).toHaveBeenCalledExactlyOnceWith("test-panel-width", "550");
await act(() => renderer.update(<Panel rowWidth={1000} />));
expect(result.width).toBe(450);
});

it("keeps a row shift that lands mid-drag", async () => {
await act(() => renderer.update(<Panel rowWidth={1000} />));
await act(() => {
result.handlers.onPointerDown(pointer());
result.handlers.onPointerMove(pointer(50));
});
await act(() => frame?.(0));
expect(result.width).toBe(450);
// App sidebar collapses while the handle is held.
await act(() => renderer.update(<Panel rowWidth={1100} />));
expect(result.width).toBe(550);
await act(() => result.handlers.onPointerMove(pointer(25)));
await act(() => frame?.(0));
expect(result.width).toBe(575);
await act(() => result.handlers.onPointerUp(pointer(25)));
expect(setItem).toHaveBeenCalledExactlyOnceWith("test-panel-width", "575");
});
});
Loading
Loading