diff --git a/packages/app/e2e/browser/chat-outline.spec.ts b/packages/app/e2e/browser/chat-outline.spec.ts index 71ffbdbaf..758d2b7b8 100644 --- a/packages/app/e2e/browser/chat-outline.spec.ts +++ b/packages/app/e2e/browser/chat-outline.spec.ts @@ -34,7 +34,10 @@ import { type LongTimelineAgent, } from "../support/helpers/timeline-pagination"; -const WIDE_VIEWPORT = { width: 1280, height: 900 }; +// Wide enough that the timeline panel clears the rail's MIN_PANEL_WIDTH with room +// to spare. At 1280 the panel measures 960, which sits too close to the threshold +// for chrome-width changes elsewhere to stay out of these tests. +const WIDE_VIEWPORT = { width: 1440, height: 900 }; const LOADED_TURNS = 16; test.describe("desktop chat outline", () => { diff --git a/packages/app/e2e/browser/composer-control-density.spec.ts b/packages/app/e2e/browser/composer-control-density.spec.ts new file mode 100644 index 000000000..95eb658cd --- /dev/null +++ b/packages/app/e2e/browser/composer-control-density.spec.ts @@ -0,0 +1,93 @@ +import { test, expect } from "../support/fixtures"; +import { openWorkspaceWithAgents } from "../support/helpers/archive-tab"; +import { + expectNoCollapsedComposerToolbarFrame, + recordComposerToolbarFrames, +} from "../support/helpers/composer-control-density"; +import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; + +const SETTLE_MS = 1_000; + +async function seedSettledMockAgent(workspace: SeededWorkspace, title: string) { + const agent = await workspace.client.createAgent({ + provider: "mock", + model: "ten-second-stream", + modeId: "load-test", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title, + }); + await workspace.client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "idle", + 30_000, + ); + return { id: agent.id, title, cwd: workspace.repoPath, workspaceId: workspace.workspaceId }; +} + +function visibleAgentTab(page: import("@playwright/test").Page, agentId: string) { + return page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).first(); +} + +test.describe("Composer control density across tab switches", () => { + test.describe.configure({ timeout: 180_000 }); + + test("switching between agent tabs never paints a collapsed composer toolbar", async ({ + page, + }) => { + const workspace = await seedWorkspace({ repoPrefix: "composer-density-agents-" }); + try { + const first = await seedSettledMockAgent(workspace, "First chat"); + const second = await seedSettledMockAgent(workspace, "Second chat"); + await openWorkspaceWithAgents(page, [first, second]); + + await recordComposerToolbarFrames(page); + await visibleAgentTab(page, first.id).click(); + await page.waitForTimeout(SETTLE_MS); + await visibleAgentTab(page, second.id).click(); + await page.waitForTimeout(SETTLE_MS); + + await expectNoCollapsedComposerToolbarFrame(page); + } finally { + await workspace.cleanup(); + } + }); + + test("switching between draft tabs never paints a collapsed composer toolbar", async ({ + page, + }) => { + const workspace = await seedWorkspace({ repoPrefix: "composer-density-drafts-" }); + try { + await page.addInitScript(() => { + localStorage.setItem( + "@paseo:create-agent-preferences", + JSON.stringify({ + provider: "mock", + providerPreferences: { mock: { mode: "load-test" } }, + }), + ); + }); + await gotoWorkspace(page, workspace.workspaceId); + await clickNewChat(page); + + const draftTabs = page + .locator('[data-testid^="workspace-tab-draft"]') + .filter({ visible: true }); + await expect(draftTabs).toHaveCount(2, { timeout: 30_000 }); + await expect( + page.locator('[data-testid="mode-control"]').filter({ visible: true }).first(), + ).toBeVisible({ timeout: 30_000 }); + + await recordComposerToolbarFrames(page); + await draftTabs.nth(0).click(); + await page.waitForTimeout(SETTLE_MS); + await draftTabs.nth(1).click(); + await page.waitForTimeout(SETTLE_MS); + + await expectNoCollapsedComposerToolbarFrame(page); + } finally { + await workspace.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/support/helpers/composer-control-density.ts b/packages/app/e2e/support/helpers/composer-control-density.ts new file mode 100644 index 000000000..ff5e53b25 --- /dev/null +++ b/packages/app/e2e/support/helpers/composer-control-density.ts @@ -0,0 +1,55 @@ +import { expect, type Page } from "@playwright/test"; + +/** + * The composer toolbar collapses its controls by measured width. A collapsed + * frame is observable from the DOM: the mode trigger drops its label and + * shrinks to the icon-only box, so a visible trigger with no text is a + * collapsed paint. + */ +interface ToolbarFrame { + atMs: number; + labels: string[]; +} + +declare global { + interface Window { + __composerToolbarFrames?: ToolbarFrame[]; + __composerToolbarStop?: () => void; + } +} + +/** Records every painted frame's visible mode-trigger labels until stopped. */ +export async function recordComposerToolbarFrames(page: Page): Promise { + await page.evaluate(() => { + const frames: ToolbarFrame[] = []; + window.__composerToolbarFrames = frames; + const start = performance.now(); + let running = true; + const tick = () => { + if (!running) return; + const labels = Array.from(document.querySelectorAll('[data-testid="mode-control"]')) + .filter((node) => node.getClientRects().length > 0) + .map((node) => (node.textContent ?? "").trim()); + frames.push({ atMs: Math.round(performance.now() - start), labels }); + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + window.__composerToolbarStop = () => { + running = false; + }; + }); +} + +async function readComposerToolbarFrames(page: Page): Promise { + return page.evaluate(() => { + window.__composerToolbarStop?.(); + return window.__composerToolbarFrames ?? []; + }); +} + +export async function expectNoCollapsedComposerToolbarFrame(page: Page): Promise { + const frames = await readComposerToolbarFrames(page); + expect(frames.length).toBeGreaterThan(0); + const collapsed = frames.filter((frame) => frame.labels.some((label) => label === "")); + expect(collapsed, `collapsed toolbar frames: ${JSON.stringify(collapsed)}`).toEqual([]); +} diff --git a/packages/app/src/agent-stream/chat-outline/rail.web.tsx b/packages/app/src/agent-stream/chat-outline/rail.web.tsx index 99c7298bc..e72d2be0f 100644 --- a/packages/app/src/agent-stream/chat-outline/rail.web.tsx +++ b/packages/app/src/agent-stream/chat-outline/rail.web.tsx @@ -12,7 +12,7 @@ import type { ChatOutlineRailProps } from "./rail"; // magnifying a slot must not move the box the pointer is resting on. See docs/hover.md. const RAIL_WIDTH = 36; const SLOT_HEIGHT = 8; -const MIN_PANEL_WIDTH = 818; +const MIN_PANEL_WIDTH = 918; const RESTING_PILL_HEIGHT = 2; const MAGNIFIED_PILL_HEIGHT = 4; const RESTING_PILL_WIDTH = 10; diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index 3b2a3e07b..642f08046 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -64,6 +64,7 @@ import { resolveAgentModelSelection, } from "@/composer/agent-controls/utils"; import { useIsCompactFormFactor } from "@/constants/layout"; +import { readMeasuredWidth } from "@/hooks/use-container-width"; import { useToast } from "@/contexts/toast-context"; import { toErrorMessage } from "@/utils/error-messages"; import { showProviderNoticeToast } from "@/utils/provider-notice-toast"; @@ -520,7 +521,8 @@ function ControlledAgentControls({ const handleLayout = useCallback( (event: LayoutChangeEvent) => { - const availableWidth = event.nativeEvent.layout.width; + const availableWidth = readMeasuredWidth(event); + if (availableWidth === null) return; availableWidthRef.current = availableWidth; updateDensityForWidth(availableWidth); }, diff --git a/packages/app/src/hooks/use-container-width.test.ts b/packages/app/src/hooks/use-container-width.test.ts index e4df58f9d..ca1827210 100644 --- a/packages/app/src/hooks/use-container-width.test.ts +++ b/packages/app/src/hooks/use-container-width.test.ts @@ -3,7 +3,7 @@ import { act, renderHook } from "@testing-library/react"; import type { LayoutChangeEvent } from "react-native"; import { describe, expect, it } from "vitest"; -import { useContainerWidthBelow } from "./use-container-width"; +import { readMeasuredWidth, useContainerWidthBelow } from "./use-container-width"; function layoutEvent(width: number): LayoutChangeEvent { return { @@ -18,6 +18,16 @@ function layoutEvent(width: number): LayoutChangeEvent { } as LayoutChangeEvent; } +describe("readMeasuredWidth", () => { + it("returns the width of a rendered container", () => { + expect(readMeasuredWidth(layoutEvent(650))).toBe(650); + }); + + it("returns null for the zero width a hidden retained panel reports", () => { + expect(readMeasuredWidth(layoutEvent(0))).toBeNull(); + }); +}); + describe("useContainerWidthBelow", () => { it("does not re-render for width changes that stay in the same threshold bucket", () => { let renderCount = 0; diff --git a/packages/app/src/hooks/use-container-width.ts b/packages/app/src/hooks/use-container-width.ts index a23554167..cec08ddae 100644 --- a/packages/app/src/hooks/use-container-width.ts +++ b/packages/app/src/hooks/use-container-width.ts @@ -1,6 +1,16 @@ import { useCallback, useState } from "react"; import type { LayoutChangeEvent } from "react-native"; +/** + * Reads a real width out of a layout event. Retained tabs stay mounted under + * `display: none`, so an inactive panel emits width 0 — that is "not rendered", + * not "very narrow", and must never drive a width-based collapse. + */ +export function readMeasuredWidth(event: LayoutChangeEvent): number | null { + const width = event.nativeEvent.layout.width; + return width > 0 ? width : null; +} + /** * Tracks the width of a container via onLayout. */ @@ -31,8 +41,8 @@ export function useContainerWidthBelow( return { onLayout: useCallback( (e: LayoutChangeEvent) => { - const width = e.nativeEvent.layout.width; - if (width <= 0) { + const width = readMeasuredWidth(e); + if (width === null) { return; } const nextIsBelow = width < threshold;