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:
@@ -34,7 +34,10 @@ import {
|
|||||||
type LongTimelineAgent,
|
type LongTimelineAgent,
|
||||||
} from "../support/helpers/timeline-pagination";
|
} 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;
|
const LOADED_TURNS = 16;
|
||||||
|
|
||||||
test.describe("desktop chat outline", () => {
|
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([]);
|
||||||
|
}
|
||||||
@@ -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.
|
// magnifying a slot must not move the box the pointer is resting on. See docs/hover.md.
|
||||||
const RAIL_WIDTH = 36;
|
const RAIL_WIDTH = 36;
|
||||||
const SLOT_HEIGHT = 8;
|
const SLOT_HEIGHT = 8;
|
||||||
const MIN_PANEL_WIDTH = 818;
|
const MIN_PANEL_WIDTH = 918;
|
||||||
const RESTING_PILL_HEIGHT = 2;
|
const RESTING_PILL_HEIGHT = 2;
|
||||||
const MAGNIFIED_PILL_HEIGHT = 4;
|
const MAGNIFIED_PILL_HEIGHT = 4;
|
||||||
const RESTING_PILL_WIDTH = 10;
|
const RESTING_PILL_WIDTH = 10;
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ import {
|
|||||||
resolveAgentModelSelection,
|
resolveAgentModelSelection,
|
||||||
} from "@/composer/agent-controls/utils";
|
} from "@/composer/agent-controls/utils";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
|
import { readMeasuredWidth } from "@/hooks/use-container-width";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { toErrorMessage } from "@/utils/error-messages";
|
import { toErrorMessage } from "@/utils/error-messages";
|
||||||
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
|
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
|
||||||
@@ -520,7 +521,8 @@ function ControlledAgentControls({
|
|||||||
|
|
||||||
const handleLayout = useCallback(
|
const handleLayout = useCallback(
|
||||||
(event: LayoutChangeEvent) => {
|
(event: LayoutChangeEvent) => {
|
||||||
const availableWidth = event.nativeEvent.layout.width;
|
const availableWidth = readMeasuredWidth(event);
|
||||||
|
if (availableWidth === null) return;
|
||||||
availableWidthRef.current = availableWidth;
|
availableWidthRef.current = availableWidth;
|
||||||
updateDensityForWidth(availableWidth);
|
updateDensityForWidth(availableWidth);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { act, renderHook } from "@testing-library/react";
|
import { act, renderHook } from "@testing-library/react";
|
||||||
import type { LayoutChangeEvent } from "react-native";
|
import type { LayoutChangeEvent } from "react-native";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { useContainerWidthBelow } from "./use-container-width";
|
import { readMeasuredWidth, useContainerWidthBelow } from "./use-container-width";
|
||||||
|
|
||||||
function layoutEvent(width: number): LayoutChangeEvent {
|
function layoutEvent(width: number): LayoutChangeEvent {
|
||||||
return {
|
return {
|
||||||
@@ -18,6 +18,16 @@ function layoutEvent(width: number): LayoutChangeEvent {
|
|||||||
} as 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", () => {
|
describe("useContainerWidthBelow", () => {
|
||||||
it("does not re-render for width changes that stay in the same threshold bucket", () => {
|
it("does not re-render for width changes that stay in the same threshold bucket", () => {
|
||||||
let renderCount = 0;
|
let renderCount = 0;
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import type { LayoutChangeEvent } from "react-native";
|
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.
|
* Tracks the width of a container via onLayout.
|
||||||
*/
|
*/
|
||||||
@@ -31,8 +41,8 @@ export function useContainerWidthBelow(
|
|||||||
return {
|
return {
|
||||||
onLayout: useCallback(
|
onLayout: useCallback(
|
||||||
(e: LayoutChangeEvent) => {
|
(e: LayoutChangeEvent) => {
|
||||||
const width = e.nativeEvent.layout.width;
|
const width = readMeasuredWidth(e);
|
||||||
if (width <= 0) {
|
if (width === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nextIsBelow = width < threshold;
|
const nextIsBelow = width < threshold;
|
||||||
|
|||||||
Reference in New Issue
Block a user