diff --git a/docs/coding-standards.md b/docs/coding-standards.md index ec77d103b8..9d932b32dc 100644 --- a/docs/coding-standards.md +++ b/docs/coding-standards.md @@ -98,6 +98,14 @@ For testing rules, see [testing.md](testing.md). - Use stable ids for `key`, never array index for reorderable/filterable lists. - Context for stable values (theme, auth). Store with selectors for state that changes. +### Retained panel measurements + +Inactive retained panels use `display: none`, so DOM and layout APIs report zero width and height. A non-positive measurement means the surface is not rendered; it is never a valid compact layout. + +- Suspend geometry-derived updates while either measured dimension is non-positive. Preserve the last valid state until the surface reports positive geometry again. +- Enforce the rule at the measurement boundary shared by every trigger. Guarding only an `onLayout` or `ResizeObserver` callback still lets effects, content changes, and imperative remeasurement publish hidden geometry. +- Test the hide-and-reveal path through a real retained surface. Settled assertions do not catch stale geometry painted during the first returned frames. + ## Naming - Names describe meaning, not mechanics. `submitForm` over `handleOnClickButtonSubmit`. `running` over `filteredArrayOfRunningAgents`. diff --git a/docs/expo-router.md b/docs/expo-router.md index 2f586fd801..3287d6a7d4 100644 --- a/docs/expo-router.md +++ b/docs/expo-router.md @@ -67,7 +67,9 @@ that decision; if no host route is mounted yet, it falls back to ordinary route navigation. Those hidden entries are not harmless: composer floating panels can measure -against the wrong deck and disappear offscreen. +against the wrong deck and disappear offscreen. Follow the +[retained panel measurement rules](coding-standards.md#retained-panel-measurements) +for components that intentionally remain mounted while hidden. Hidden host routes may keep their local params while an app-wide route is foregrounded. Active-workspace observers must prefer the current pathname and diff --git a/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts b/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts index f1abdd0906..5b52c7025d 100644 --- a/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts +++ b/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts @@ -10,6 +10,58 @@ async function pressSettingsToggleShortcut(page: import("@playwright/test").Page await page.keyboard.press(`${modifier}+Comma`); } +interface ComposerFrame { + at: number; + visible: boolean; + rootHeight: number; + inputHeight: number; + inputWidth: number; + inputStyleHeight: string; + inputStyleMaxHeight: string; +} + +async function startComposerFrameCapture(page: import("@playwright/test").Page) { + await page.evaluate(() => { + const capture = { frames: [] as ComposerFrame[], stopped: false }; + Object.assign(window, { __composerFrameCapture: capture }); + + const sample = () => { + if (capture.stopped) return; + const root = document.querySelector('[data-testid="message-input-root"]'); + const input = root?.querySelector("textarea"); + if (root && input) { + const style = getComputedStyle(input); + capture.frames.push({ + at: performance.now(), + visible: root.offsetParent !== null, + rootHeight: root.getBoundingClientRect().height, + inputHeight: input.getBoundingClientRect().height, + inputWidth: input.clientWidth, + inputStyleHeight: style.height, + inputStyleMaxHeight: style.maxHeight, + }); + } + requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + }); +} + +async function stopComposerFrameCapture( + page: import("@playwright/test").Page, +): Promise { + return page.evaluate(() => { + const capture = ( + window as typeof window & { + __composerFrameCapture?: { frames: ComposerFrame[]; stopped: boolean }; + } + ).__composerFrameCapture; + if (!capture) throw new Error("Composer frame capture was not installed"); + capture.stopped = true; + return capture.frames; + }); +} + async function expectSendBehavior( page: import("@playwright/test").Page, expected: "interrupt" | "queue" | "steer", @@ -107,6 +159,65 @@ test.describe("Settings toggle tab regression", () => { } }); + test("returning from settings does not flash the composer at maximum height", async ({ + page, + }) => { + const workspace = await seedWorkspace({ repoPrefix: "settings-composer-height-" }); + + try { + const firstAgent = await createIdleAgent(workspace.client, { + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: `settings-composer-height-a-${Date.now()}`, + }); + const secondAgent = await createIdleAgent(workspace.client, { + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: `settings-composer-height-b-${Date.now()}`, + }); + await openWorkspaceWithAgents(page, [firstAgent, secondAgent]); + await waitForTabBar(page); + + const input = page.getByRole("textbox", { name: "Message agent..." }); + await input.fill("Keep this short draft in the composer"); + const root = page.getByTestId("message-input-root").filter({ visible: true }).first(); + const baselineHeight = (await root.boundingBox())?.height; + expect(baselineHeight).toBeDefined(); + + await startComposerFrameCapture(page); + await pressSettingsToggleShortcut(page); + await expect(page).toHaveURL(/\/settings\/general$/); + await pressSettingsToggleShortcut(page); + await expect(page).not.toHaveURL(/\/settings(\/|$)/); + await expect(input).toHaveValue("Keep this short draft in the composer"); + await page.waitForTimeout(250); + + const frames = await stopComposerFrameCapture(page); + const visibleFrames = frames.filter((frame) => frame.visible); + const tallestIndex = visibleFrames.reduce( + (index, frame, candidateIndex) => + frame.rootHeight > visibleFrames[index].rootHeight ? candidateIndex : index, + 0, + ); + const tallestFrame = visibleFrames[tallestIndex]; + const tallestFrameIndex = frames.findIndex((frame) => frame.at === tallestFrame.at); + expect( + tallestFrame.rootHeight, + JSON.stringify( + { + baselineHeight, + tallestFrame, + nearbyFrames: frames.slice(Math.max(0, tallestFrameIndex - 3), tallestFrameIndex + 4), + }, + null, + 2, + ), + ).toBeLessThanOrEqual((baselineHeight ?? 0) + 5); + } finally { + await workspace.cleanup(); + } + }); + test("refresh after navigating between agent routes keeps the latest agent focused", async ({ page, }) => { diff --git a/packages/app/src/composer/input/height.web.ts b/packages/app/src/composer/input/height.web.ts index 2dd5982d4b..4e3bef363d 100644 --- a/packages/app/src/composer/input/height.web.ts +++ b/packages/app/src/composer/input/height.web.ts @@ -56,12 +56,14 @@ export function useComposerHeight({ const mirror = mirrorRef.current; const source = textareaRef.current; if (!mirror || !source || typeof window === "undefined") return; + const sourceWidth = source.clientWidth; + if (sourceWidth <= 0) return; const computedStyle = window.getComputedStyle(source); for (const property of COPIED_STYLES) { mirror.style[property] = computedStyle[property]; } - mirror.style.width = `${source.clientWidth}px`; + mirror.style.width = `${sourceWidth}px`; mirror.value = text.endsWith("\n") ? `${text} ` : text; setBoundedHeight(mirror.scrollHeight); },