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)))