Fix composer toolbar collapse flicker on tab switch (#2937)

Retained tabs stay mounted under display:none while inactive, which
reports a zero-width onLayout. ControlledAgentControls fed that
straight into its density resolver, so a hidden tab's toolbar
collapsed to its tightest density and painted one frame collapsed
before re-expanding when the tab became visible again.

Extract the zero-width guard that useContainerWidthBelow already had
into a shared readMeasuredWidth helper, and use it in the composer
toolbar's own layout handler so a display:none measurement can never
drive its density.

Also bump the chat outline rail's MIN_PANEL_WIDTH breakpoint (818 ->
918) so the rail disappears earlier under a narrower panel, and widen
the chat-outline e2e spec's viewport so its panel keeps healthy margin
over the new threshold.
This commit is contained in:
Mohamed Boudra
2026-08-06 18:37:38 +08:00
committed by GitHub
parent f2054cc701
commit 2298e9efdf
7 changed files with 179 additions and 6 deletions
@@ -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", () => {
@@ -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();
}
});
});
@@ -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<void> {
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<ToolbarFrame[]> {
return page.evaluate(() => {
window.__composerToolbarStop?.();
return window.__composerToolbarFrames ?? [];
});
}
export async function expectNoCollapsedComposerToolbarFrame(page: Page): Promise<void> {
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([]);
}