From e9751c829505e51abedaee990dcccd583739acd8 Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Tue, 30 Jun 2026 16:52:19 -0400 Subject: [PATCH] feat(desktop): let thread pane expand on ultrawide monitors The thread (auxiliary) panel width was hard-capped at 720px via AUXILIARY_PANEL_MAX_WIDTH_PX, so on ultrawide monitors the pane could not be dragged wider than 720px despite ample horizontal space. Make the upper clamp viewport-relative: the panel may now grow to viewportWidth - AUXILIARY_PANEL_MIN_WIDTH_PX, always reserving the main pane's minimum. The static 720px constant becomes a floor so narrow viewports keep their existing behavior, and AuxiliaryPanelShell's existing calc(100% - MIN) render clamp remains the runtime safety net. The clamp logic moves into auxiliaryPanelLayout as pure, viewport- parameterized helpers (clampAuxiliaryPanelWidth / getAuxiliaryPanelMaxWidth) with unit coverage, plus an e2e spec that drags the thread pane far past 720px on a 3440px viewport. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- desktop/playwright.config.ts | 1 + .../src/shared/hooks/useThreadPanelWidth.ts | 20 ++- .../src/shared/layout/AuxiliaryPanel/index.ts | 2 + .../layout/auxiliaryPanelLayout.test.mjs | 37 ++++++ .../src/shared/layout/auxiliaryPanelLayout.ts | 26 ++++ .../tests/e2e/threadpane-ultrawide.spec.ts | 121 ++++++++++++++++++ 6 files changed, 201 insertions(+), 6 deletions(-) create mode 100644 desktop/src/shared/layout/auxiliaryPanelLayout.test.mjs create mode 100644 desktop/tests/e2e/threadpane-ultrawide.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 0c827cc95f..ecf46c1b56 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -51,6 +51,7 @@ export default defineConfig({ "**/home-collapsed-top-chrome.spec.ts", "**/thread-unread.spec.ts", "**/thread-reply-anchor-roleplay.spec.ts", + "**/threadpane-ultrawide.spec.ts", "**/animated-avatar.spec.ts", "**/reminders.spec.ts", "**/virtualization.spec.ts", diff --git a/desktop/src/shared/hooks/useThreadPanelWidth.ts b/desktop/src/shared/hooks/useThreadPanelWidth.ts index 1e734f6799..18e36d7f9f 100644 --- a/desktop/src/shared/hooks/useThreadPanelWidth.ts +++ b/desktop/src/shared/hooks/useThreadPanelWidth.ts @@ -2,17 +2,25 @@ import * as React from "react"; import { AUXILIARY_PANEL_DEFAULT_WIDTH_PX, - AUXILIARY_PANEL_MAX_WIDTH_PX, - AUXILIARY_PANEL_MIN_WIDTH_PX, + clampAuxiliaryPanelWidth, } from "@/shared/layout/AuxiliaryPanel"; const THREAD_PANEL_WIDTH_SESSION_KEY = "buzz.desktop.thread-panel-width"; +function getViewportWidth(): number { + return typeof window === "undefined" ? 0 : window.innerWidth; +} + +/** + * Clamp the stored panel width for the current viewport. + * + * The upper bound grows with the viewport (see {@link clampAuxiliaryPanelWidth}) so + * the pane can expand on ultrawide displays. `AuxiliaryPanelShell` additionally + * clamps the rendered width to `calc(100% - MIN)` at paint time, so a stored width + * larger than the current viewport never collapses the main pane. + */ function clampThreadPanelWidth(width: number): number { - return Math.max( - AUXILIARY_PANEL_MIN_WIDTH_PX, - Math.min(AUXILIARY_PANEL_MAX_WIDTH_PX, width), - ); + return clampAuxiliaryPanelWidth(width, getViewportWidth()); } function getInitialThreadPanelWidth(): number { diff --git a/desktop/src/shared/layout/AuxiliaryPanel/index.ts b/desktop/src/shared/layout/AuxiliaryPanel/index.ts index 5f108b91ff..72724302d2 100644 --- a/desktop/src/shared/layout/AuxiliaryPanel/index.ts +++ b/desktop/src/shared/layout/AuxiliaryPanel/index.ts @@ -25,4 +25,6 @@ export { AUXILIARY_PANEL_MAX_WIDTH_PX, AUXILIARY_PANEL_MIN_WIDTH_PX, AUXILIARY_PANEL_SINGLE_COLUMN_BREAKPOINT_PX, + clampAuxiliaryPanelWidth, + getAuxiliaryPanelMaxWidth, } from "@/shared/layout/auxiliaryPanelLayout"; diff --git a/desktop/src/shared/layout/auxiliaryPanelLayout.test.mjs b/desktop/src/shared/layout/auxiliaryPanelLayout.test.mjs new file mode 100644 index 0000000000..08313f70aa --- /dev/null +++ b/desktop/src/shared/layout/auxiliaryPanelLayout.test.mjs @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + AUXILIARY_PANEL_MAX_WIDTH_PX, + AUXILIARY_PANEL_MIN_WIDTH_PX, + clampAuxiliaryPanelWidth, + getAuxiliaryPanelMaxWidth, +} from "./auxiliaryPanelLayout.ts"; + +test("max width falls back to the static cap on narrow viewports", () => { + // On viewports where `viewportWidth - MIN` is below the static cap, the floor wins. + assert.equal(getAuxiliaryPanelMaxWidth(900), AUXILIARY_PANEL_MAX_WIDTH_PX); + assert.equal(getAuxiliaryPanelMaxWidth(0), AUXILIARY_PANEL_MAX_WIDTH_PX); +}); + +test("max width grows with the viewport, reserving the main pane", () => { + assert.equal( + getAuxiliaryPanelMaxWidth(3440), + 3440 - AUXILIARY_PANEL_MIN_WIDTH_PX, + ); +}); + +test("clamp keeps width within [min, viewport-aware max]", () => { + // Below the floor clamps up to the min. + assert.equal( + clampAuxiliaryPanelWidth(100, 3440), + AUXILIARY_PANEL_MIN_WIDTH_PX, + ); + // A wide drag is allowed on an ultrawide viewport. + assert.equal(clampAuxiliaryPanelWidth(2000, 3440), 2000); + // The same wide value is clamped down on a small viewport. + assert.equal( + clampAuxiliaryPanelWidth(2000, 900), + AUXILIARY_PANEL_MAX_WIDTH_PX, + ); +}); diff --git a/desktop/src/shared/layout/auxiliaryPanelLayout.ts b/desktop/src/shared/layout/auxiliaryPanelLayout.ts index b267f589eb..6c574562b2 100644 --- a/desktop/src/shared/layout/auxiliaryPanelLayout.ts +++ b/desktop/src/shared/layout/auxiliaryPanelLayout.ts @@ -3,3 +3,29 @@ export const AUXILIARY_PANEL_MIN_WIDTH_PX = 300; export const AUXILIARY_PANEL_SINGLE_COLUMN_BREAKPOINT_PX = AUXILIARY_PANEL_MIN_WIDTH_PX * 2; export const AUXILIARY_PANEL_MAX_WIDTH_PX = 720; + +/** + * Upper bound for the auxiliary panel width clamp, given the current viewport width. + * + * On ultrawide displays the static {@link AUXILIARY_PANEL_MAX_WIDTH_PX} is too small, + * so the panel is allowed to grow with the viewport while always reserving at least + * {@link AUXILIARY_PANEL_MIN_WIDTH_PX} for the main pane. The static cap acts as a + * floor, so narrow viewports keep their existing behavior. + */ +export function getAuxiliaryPanelMaxWidth(viewportWidth: number): number { + return Math.max( + AUXILIARY_PANEL_MAX_WIDTH_PX, + viewportWidth - AUXILIARY_PANEL_MIN_WIDTH_PX, + ); +} + +/** Clamp a stored panel width into the allowed range for the current viewport. */ +export function clampAuxiliaryPanelWidth( + width: number, + viewportWidth: number, +): number { + return Math.max( + AUXILIARY_PANEL_MIN_WIDTH_PX, + Math.min(getAuxiliaryPanelMaxWidth(viewportWidth), width), + ); +} diff --git a/desktop/tests/e2e/threadpane-ultrawide.spec.ts b/desktop/tests/e2e/threadpane-ultrawide.spec.ts new file mode 100644 index 0000000000..7057701624 --- /dev/null +++ b/desktop/tests/e2e/threadpane-ultrawide.spec.ts @@ -0,0 +1,121 @@ +import { expect, test } from "@playwright/test"; + +import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge"; + +// Ultrawide viewport: 3440px is a common 21:9 monitor width. +const ULTRAWIDE = { width: 3440, height: 1440 }; + +async function waitForMockLiveSubscription( + page: import("@playwright/test").Page, + channelName: string, +) { + await expect + .poll(async () => + page.evaluate( + ({ ch }) => + ( + window as Window & { + __BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: { + channelName: string; + }) => boolean; + } + ).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ channelName: ch }) ?? + false, + { ch: channelName }, + ), + ) + .toBe(true); +} + +async function emitMockReply( + page: import("@playwright/test").Page, + channelName: string, + content: string, + parentEventId: string, +) { + await page.evaluate( + ({ ch, msg, parent, pubkey }) => + ( + window as Window & { + __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: { + channelName: string; + content: string; + parentEventId?: string | null; + pubkey?: string; + createdAt?: number; + }) => unknown; + } + ).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: ch, + content: msg, + parentEventId: parent, + pubkey, + createdAt: Math.floor(Date.now() / 1000) - 10, + }), + { + ch: channelName, + msg: content, + parent: parentEventId, + pubkey: TEST_IDENTITIES.alice.pubkey, + }, + ); +} + +async function openThread(page: import("@playwright/test").Page) { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockLiveSubscription(page, "general"); + + // Seed a reply so a thread summary row appears, then open it. + await emitMockReply( + page, + "general", + "Reply to welcome", + "mock-general-welcome", + ); + const threadSummary = page.getByTestId("message-thread-summary").first(); + await expect(threadSummary).toBeVisible(); + await threadSummary.click(); + await expect(page.getByTestId("message-thread-panel")).toBeVisible(); +} + +test.describe("thread pane on ultrawide monitors", () => { + test("expands well past the legacy 720px cap", async ({ page }) => { + await page.setViewportSize(ULTRAWIDE); + await installMockBridge(page); + await openThread(page); + + const pane = page.getByTestId("message-thread-panel"); + const handle = page.getByTestId("right-auxiliary-pane-resize-handle"); + + const beforeBox = await pane.boundingBox(); + if (!beforeBox) throw new Error("thread panel not laid out"); + // The panel opens at its default narrow width, far from the viewport edge. + expect(beforeBox.width).toBeLessThan(720); + await page.screenshot({ + path: "test-results/threadpane-ultrawide-before.png", + }); + + // Drag the resize handle far to the left to widen the right-hand pane. + const handleBox = await handle.boundingBox(); + if (!handleBox) throw new Error("resize handle not laid out"); + const startX = handleBox.x + handleBox.width / 2; + const startY = handleBox.y + handleBox.height / 2; + await page.mouse.move(startX, startY); + await page.mouse.down(); + // Move left in steps so pointermove fires repeatedly. + for (let x = startX; x >= 600; x -= 120) { + await page.mouse.move(x, startY); + } + await page.mouse.up(); + + const afterBox = await pane.boundingBox(); + if (!afterBox) throw new Error("thread panel not laid out after resize"); + // The pane is now far wider than the old 720px hard cap. + expect(afterBox.width).toBeGreaterThan(1200); + await page.screenshot({ + path: "test-results/threadpane-ultrawide-after.png", + }); + }); +});