From a0a02fc3398aaf1de35ac29e408336b909ce1650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Mon, 5 Oct 2026 15:41:26 +0200 Subject: [PATCH] fix(ui): preserve right panel width across minimize and restore Keep the selected right drawer width separate from transient viewport constraints. Native minimize resize events previously clamped and persisted the preference to the 200px minimum, so restoring the window could not recover the user's layout. Use the existing drawer layout to constrain display without changing the saved width. Divider gestures still update the preference; overlay transitions and responsive center-width limits remain unchanged in the shared Tauri/Electron UI. Add real Solid shell browser regressions for zero and small transient viewport measurements, ordinary shrink/restore, overlay transitions and subsequent user resizing. Both tests fail before the fix. UI typecheck and drawer layout unit tests pass; wider browser and native Tauri checks are recorded in the PR. --- .../components/instance/instance-shell2.tsx | 4 +- .../ui/tests/browser/drawer-width.test.ts | 100 ++++++++++++++++++ .../tests/browser/fixtures/header-windows.tsx | 3 + 3 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 packages/ui/tests/browser/drawer-width.test.ts diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 7ed6c86ee..5396ac8c9 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -301,8 +301,8 @@ const InstanceShell2: Component = (props) => { const handleResize = () => { const width = clampWidth(window.innerWidth * 0.3) setSessionSidebarWidth((current) => clampWidth(current || width)) - const fallbackRight = window.innerWidth * 0.35 - setRightDrawerWidth((current) => clampRightWidth(current || fallbackRight)) + // Keep the selected width across transient native minimize sizes. The + // drawer layout constrains display without overwriting the preference. measureDrawerHost() } diff --git a/packages/ui/tests/browser/drawer-width.test.ts b/packages/ui/tests/browser/drawer-width.test.ts new file mode 100644 index 000000000..c5b544994 --- /dev/null +++ b/packages/ui/tests/browser/drawer-width.test.ts @@ -0,0 +1,100 @@ +import assert from "node:assert/strict" +import { after, before, test } from "node:test" +import { fileURLToPath } from "node:url" +import { chromium, type Browser, type Page } from "playwright" +import { createServer, type ViteDevServer } from "vite" +import solid from "vite-plugin-solid" + +let server: ViteDevServer, browser: Browser, url: string +before(async () => { + server = await createServer({ + configFile: false, root: fileURLToPath(new URL("../..", import.meta.url)), logLevel: "error", + cacheDir: "node_modules/.vite-drawer-width", + plugins: [solid(), { name: "drawer-width-fixture", configureServer(s) { + s.middlewares.use("/fixture", async (_req, res) => { + res.setHeader("Content-Type", "text/html") + res.end(await s.transformIndexHtml("/fixture", '
')) + }) + } }], resolve: { dedupe: ["solid-js"] }, optimizeDeps: { exclude: ["lucide-solid"] }, + server: { host: "127.0.0.1", port: 0, hmr: false, watch: null }, + }) + await server.listen() + url = `http://127.0.0.1:${(server.httpServer!.address() as { port: number }).port}/fixture?drawerWidth=1` + browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined }) +}) +after(async () => { await browser?.close(); await server?.close() }) + +const key = "opencode-session-right-drawer-width-v1" +const savedWidth = (page: Page) => page.evaluate(key => Number((window as any).fixture.readLayout(key)), key) +const displayedWidth = async (page: Page) => (await page.locator(".session-right-panel").boundingBox())!.width + +async function chooseWidth(page: Page, width: number) { + const startWidth = await savedWidth(page) + const handle = (await page.locator(".session-resize-handle--right").boundingBox())! + const x = handle.x + handle.width / 2, y = handle.y + 160 + await page.mouse.move(x, y) + await page.mouse.down() + await page.mouse.move(x + startWidth - width, y, { steps: 16 }) + await page.mouse.up() + await page.waitForFunction(({ key, width }) => Number((window as any).fixture.readLayout(key)) === width, { key, width }, { timeout: 3000 }).catch(async error => { + throw new Error(`Drawer resize: desired=${width} saved=${await savedWidth(page)} displayed=${await displayedWidth(page)} start=${startWidth}`, { cause: error }) + }) + assert.ok(Math.abs(await displayedWidth(page) - width) <= 1) +} + +test("transient minimized viewport sizes do not overwrite the chosen drawer width", async () => { + const page = await browser.newPage({ viewport: { width: 1600, height: 900 } }) + const errors: string[] = [] + page.on("pageerror", error => errors.push(error.message)) + await page.route("**/api/**", route => route.fulfill({ json: {} })) + try { + await page.goto(url) + await page.waitForFunction(() => Boolean((window as any).fixture)) + await chooseWidth(page, 500) + for (const minimizedWidth of [0, 160, 320]) { + // Feed the native minimize symptom into the real shell's resize handler; + // this is a deterministic renderer regression, not a native host test. + await page.evaluate(width => { + Object.defineProperty(window, "innerWidth", { configurable: true, value: width }) + window.dispatchEvent(new Event("resize")) + }, minimizedWidth) + assert.equal(await savedWidth(page), 500, `saved width during transient ${minimizedWidth}px viewport`) + await page.evaluate(() => { + delete (window as any).innerWidth + window.dispatchEvent(new Event("resize")) + }) + assert.ok(Math.abs(await displayedWidth(page) - 500) <= 1) + assert.equal(await savedWidth(page), 500) + } + assert.deepEqual(errors, []) + } finally { await page.close() } +}) + +test("real viewport changes constrain only display and restoring space recovers the selected width", async () => { + const page = await browser.newPage({ viewport: { width: 1600, height: 900 } }) + const errors: string[] = [] + page.on("pageerror", error => errors.push(error.message)) + await page.route("**/api/**", route => route.fulfill({ json: {} })) + try { + await page.goto(url) + await page.waitForFunction(() => Boolean((window as any).fixture)) + await chooseWidth(page, 600) + await page.setViewportSize({ width: 1100, height: 900 }) + await page.waitForFunction(() => document.querySelector(".session-right-panel")!.getBoundingClientRect().width < 600) + assert.equal(await savedWidth(page), 600) + for (const width of [800, 390]) { + await page.setViewportSize({ width, height: 900 }) + await page.waitForFunction(() => !document.querySelector(".session-right-panel")) + assert.equal(await savedWidth(page), 600) + } + await page.setViewportSize({ width: 1600, height: 900 }) + await page.waitForFunction(() => Math.abs((document.querySelector(".session-right-panel")?.getBoundingClientRect().width ?? 0) - 600) <= 1) + assert.equal(await savedWidth(page), 600) + await chooseWidth(page, 450) + await page.setViewportSize({ width: 1100, height: 900 }) + await page.setViewportSize({ width: 1600, height: 900 }) + await page.waitForFunction(() => Math.abs((document.querySelector(".session-right-panel")?.getBoundingClientRect().width ?? 0) - 450) <= 1) + assert.equal(await savedWidth(page), 450, "explicit resizing still replaces the saved preference") + assert.deepEqual(errors, []) + } finally { await page.close() } +}) diff --git a/packages/ui/tests/browser/fixtures/header-windows.tsx b/packages/ui/tests/browser/fixtures/header-windows.tsx index b17799a84..9698e7375 100644 --- a/packages/ui/tests/browser/fixtures/header-windows.tsx +++ b/packages/ui/tests/browser/fixtures/header-windows.tsx @@ -16,11 +16,13 @@ import { focusInterruption } from "../../../src/stores/interruption-navigation" import { addInstance, addPendingForm, addPermissionToQueue, instances } from "../../../src/stores/instances" import { setSessions, setActiveSession, setActiveParentSession, clearActiveParentSession, setSessionPage, setProviders, setSessionStatus, activeSessionId, setSessionInfoByInstance } from "../../../src/stores/session-state" import { ensureWorktreesLoaded } from "../../../src/stores/worktrees" +import { initializeClientState, readClientLayoutValue } from "../../../src/stores/client-state" import "../../../src/index.css" // Optional, synchronous test-only marks; no observer is installed on native pages. const bootStage = (phase: string) => (window as any).__headerFixtureBoot?.mark(phase) bootStage("imports-complete") +if (new URLSearchParams(location.search).has("drawerWidth")) await initializeClientState() const id = "header-windows", sessionId = "session" let interrupts = 0 const session: any = { id: sessionId, instanceId: id, parentId: null, title: "Fixture conversation", location: { directory: "/repo" }, @@ -100,6 +102,7 @@ await updatePreferences({ locale: "en" }) bootStage("preferences-after") ;(window as any).fixture = { runtimeEnv, replies, setImmersive, setActive, + readLayout: readClientLayoutValue, addSession: (sid: string, parentId: string | null = null) => { fixtureSessions.set(sid, { ...session, id: sid, parentId, title: `Fixture ${sid}` }) setSessions(previous => new Map(previous).set(id, new Map(fixtureSessions)))