deer-flow/frontend/tests/e2e/artifact-panel-resize.spec.ts
Aari 55c2153080
fix(frontend): restore resizing for the artifacts and sidecar panels (#4469)
* fix(frontend): restore resizing for the artifacts and sidecar panels

#3934 replaced the right panel's ResizablePanelGroup with a fixed CSS grid to
animate open/close, which removed the drag handle; #4187 then reintroduced a
resizable group for the browser panel only. The artifacts and sidecar panels
have had no way to resize since, while the browser divider still drags.

All three right panels now share one panel group, so there is no per-panel-kind
layout fork. Open/close goes through the side panel's collapse()/resize() so the
width still animates, and a dragged width survives closing and reopening.

Three library-specific constraints, each found by a failing test:

- the size transition is applied from the group as
  [&>[data-panel]]:transition-[flex-grow], because <ResizablePanel className>
  lands on an inner wrapper while the element the library sizes is its own
  [data-panel] div;
- reopening uses resize(remembered) rather than expand(), which falls back to
  minSize until the library has recorded a size, and the remembered width is
  read before collapse() because the closing animation reports shrinking sizes;
- during the animation the content is held at its final width in cqw and
  clipped, as the previous grid layout did — letting it reflow every frame makes
  the message list re-run its scroll-to-bottom and re-wraps the sidecar
  composer.

Fixes #4465

* fix(frontend): remove unreachable panel max size
2026-07-26 21:20:36 +08:00

193 lines
5.7 KiB
TypeScript

import { type Locator, type Page, expect, test } from "@playwright/test";
import { mockLangGraphAPI } from "./utils/mock-api";
const ARTIFACT_PATH = "/artifact-fixtures/report.html";
const THREAD_ID = "00000000-0000-0000-0000-000000003125";
function writeFileMessages() {
return [
{
type: "human",
id: "msg-human-artifact",
content: [{ type: "text", text: "Create a report artifact" }],
},
{
type: "ai",
id: "msg-ai-write-artifact",
content: "",
tool_calls: [
{
id: "write-file-artifact",
name: "write_file",
args: {
description: "Writing report artifact",
path: ARTIFACT_PATH,
content:
"<!doctype html><html><body><h1>Report draft</h1></body></html>",
},
},
],
},
{
type: "tool",
id: "msg-tool-write-artifact",
name: "write_file",
tool_call_id: "write-file-artifact",
content: "OK",
},
];
}
async function panelWidth(panel: Locator): Promise<number> {
const box = await panel.boundingBox();
return box?.width ?? 0;
}
/** Drag the divider left by `distance` px, widening the right panel. */
async function widenPanel(handle: Locator, distance: number): Promise<void> {
// hover() waits for a stable bounding box: the open animation moves the
// 1px-wide divider, so coordinates read any earlier miss it entirely.
await handle.hover();
await expect(handle).toHaveAttribute("data-separator", "hover");
const box = await handle.boundingBox();
expect(box).not.toBeNull();
const y = box!.y + box!.height / 2;
const x = box!.x + box!.width / 2;
const mouse = handle.page().mouse;
await mouse.down();
await expect(handle).toHaveAttribute("data-separator", "active");
await mouse.move(x - distance, y, { steps: 10 });
await mouse.up();
}
async function openArtifact(page: Page): Promise<void> {
await expect(page.getByText(ARTIFACT_PATH)).toBeVisible({ timeout: 15_000 });
await page.getByText(ARTIFACT_PATH).click();
}
test.describe("Artifacts panel resize", () => {
test.beforeEach(async ({ page }) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: THREAD_ID,
title: "Artifact panel resize",
messages: writeFileMessages(),
},
],
});
await page.goto(`/workspace/chats/${THREAD_ID}`);
});
test("the divider resizes the artifacts panel", async ({ page }) => {
await openArtifact(page);
const artifactsPanel = page.locator("#artifacts");
await expect(artifactsPanel).toBeVisible();
const handle = page.locator('[data-slot="resizable-handle"]');
await expect(handle).toBeVisible();
await handle.hover();
const widthBefore = await panelWidth(artifactsPanel);
expect(widthBefore).toBeGreaterThan(0);
await widenPanel(handle, 200);
await expect
.poll(async () => panelWidth(artifactsPanel))
.toBeGreaterThan(widthBefore + 100);
});
test("a dragged width is kept when the panel is reopened", async ({
page,
}) => {
await openArtifact(page);
const artifactsPanel = page.locator("#artifacts");
await expect(artifactsPanel).toBeVisible();
const handle = page.locator('[data-slot="resizable-handle"]');
await handle.hover();
const widthBefore = await panelWidth(artifactsPanel);
await widenPanel(handle, 200);
await expect
.poll(async () => panelWidth(artifactsPanel))
.toBeGreaterThan(widthBefore + 100);
const widthAfterDrag = await panelWidth(artifactsPanel);
await artifactsPanel
.getByRole("button", { name: /close/i })
.first()
.click();
await expect(artifactsPanel).toBeHidden();
await openArtifact(page);
await expect(artifactsPanel).toBeVisible();
await expect
.poll(async () => panelWidth(artifactsPanel))
.toBeGreaterThan(widthAfterDrag - 20);
});
test("opening animates the width, dragging does not", async ({ page }) => {
await expect(page.getByText(ARTIFACT_PATH)).toBeVisible({
timeout: 15_000,
});
// Listen for the transition instead of sampling widths: `transitionrun`
// fires when the transition is created, so a loaded machine dropping
// frames cannot turn a real animation into a missed one.
await page.evaluate(() => {
const store = window as unknown as { __transitions?: string[] };
store.__transitions = [];
document.addEventListener(
"transitionrun",
(event) => {
store.__transitions?.push(event.propertyName);
},
true,
);
});
await page.getByText(ARTIFACT_PATH).click();
await expect
.poll(async () =>
page.evaluate(
() =>
(window as unknown as { __transitions: string[] }).__transitions,
),
)
.toContain("flex-grow");
const artifactsPanel = page.locator("#artifacts");
const handle = page.locator('[data-slot="resizable-handle"]');
await expect(artifactsPanel).toBeVisible();
await handle.hover();
const transitionDuringDrag = await (async () => {
const box = await handle.boundingBox();
const mouse = page.mouse;
await mouse.down();
await mouse.move(box!.x - 120, box!.y + box!.height / 2, { steps: 5 });
const transition = await page.evaluate(() => {
// `[data-panel]` is the element the library sizes; the child that
// `className` lands on is not the flex item.
const panel = document
.querySelector("#artifacts")
?.closest("[data-panel]");
return panel
? window.getComputedStyle(panel).transitionProperty
: "missing";
});
await mouse.up();
return transition;
})();
expect(transitionDuringDrag).not.toContain("flex-grow");
});
});