mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 08:54:42 +00:00
fix(app): prevent composer height flash after settings (#3943)
Retained workspace panels collapse to zero width while Settings is visible. Treat that geometry as an absent surface so hidden measurements cannot overwrite the last valid composer height before reveal.
This commit is contained in:
@@ -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`.
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
@@ -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<HTMLElement>('[data-testid="message-input-root"]');
|
||||
const input = root?.querySelector<HTMLTextAreaElement>("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<ComposerFrame[]> {
|
||||
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,
|
||||
}) => {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user