diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 988abfcb8..33ba03568 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -107,6 +107,7 @@ Tool-calling AI messages can contain user-visible text as well as `tool_calls`. - `src/components/workspace/messages/message-list.tsx` owns human-input card answered/latest/pending gating; entry pages only translate a submitted card response into `sendMessage` calls. - `src/components/workspace/browser-view/browser-view-panel.tsx` forwards each physical pointer click as one `click` input; do not also emit `down`/`up` for the same gesture because the remote Playwright click would run twice. - `src/core/threads/hooks.ts` owns pre-submit upload state and thread submission. +- `src/components/workspace/chats/chat-box.tsx` owns the desktop right-panel layout, and **all three** right panels (artifacts, sidecar, browser) share one `ResizablePanelGroup` — do not fork a non-resizable branch per panel kind, which is how the artifacts divider silently lost its drag handle (#4465). Open/close is `collapse()` / `resize()` on the side panel's imperative handle, not conditional rendering, so the width can animate. Three constraints hold that together: the size transition is applied from the group as `[&>[data-panel]]:transition-[flex-grow]` because the sized flex item is the library's own `[data-panel]` element rather than the child `className` lands on; it is applied only while an open/close is in flight, so a drag is not interpolated frame by frame; and during the animation the panel content is held at its final width in `cqw` and clipped, because a reflowing message list re-runs its scroll-to-bottom (pinned by `tests/e2e/sidecar-chat.spec.ts`'s no-animated-scroll test) and a re-wrapping composer changes which responsive labels it shows. ## Code Style diff --git a/frontend/src/components/workspace/chats/chat-box.tsx b/frontend/src/components/workspace/chats/chat-box.tsx index bd3963100..b8398c892 100644 --- a/frontend/src/components/workspace/chats/chat-box.tsx +++ b/frontend/src/components/workspace/chats/chat-box.tsx @@ -1,6 +1,7 @@ import { FilesIcon, XIcon } from "lucide-react"; import { usePathname } from "next/navigation"; import { useEffect, useMemo, useRef, useState } from "react"; +import { usePanelRef } from "react-resizable-panels"; import { ConversationEmptyState } from "@/components/ai-elements/conversation"; import { Button } from "@/components/ui/button"; @@ -30,6 +31,7 @@ import { useThread } from "../messages/context"; import { SidecarPanel, useMaybeSidecar } from "../sidecar"; const RIGHT_PANEL_ANIMATION_MS = 280; +const RIGHT_PANEL_DEFAULT_SIZE = "40%"; type RightPanelKind = "sidecar" | "artifacts" | "browser"; @@ -126,6 +128,67 @@ const ChatBox: React.FC<{ return pathname.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, ""); }, [pathname]); + const sidePanelRef = usePanelRef(); + // Width the panel reopens at: the last size the user dragged it to. + const openSizeRef = useRef(RIGHT_PANEL_DEFAULT_SIZE); + // While the panel width animates, the content is held at its final width and + // clipped instead of reflowing every frame — a reflowing message list keeps + // re-running its scroll-to-bottom (pinned by the sidecar scroll regression + // test), and re-wrapping text mid-animation looks unsettled. Expressed in + // `cqw` against the group so it tracks the final width even if the group + // itself resizes during the animation. + const [pinnedContentWidth, setPinnedContentWidth] = useState( + null, + ); + // Size transitions belong to open/close only: leaving them on during a drag + // would interpolate every pointer frame and make the handle feel laggy. + const [animatingRightPanel, setAnimatingRightPanel] = useState(false); + const rightPanelOpenRef = useRef(rightPanelOpen); + // Read once: `defaultSize` only applies on mount, the effect below owns every + // later open/close. + const [initialRightPanelSize] = useState(() => + rightPanelOpen ? RIGHT_PANEL_DEFAULT_SIZE : "0%", + ); + + useEffect(() => { + if (rightPanelOpenRef.current === rightPanelOpen) { + return; + } + rightPanelOpenRef.current = rightPanelOpen; + + setAnimatingRightPanel(true); + if (!rightPanelOpen) { + // Remember the width now: the closing animation reports shrinking sizes, + // so sampling those would reopen the panel a few pixels wide. + const size = sidePanelRef.current?.getSize().asPercentage ?? 0; + if (size > 0) { + openSizeRef.current = `${size}%`; + } + } + + const openPercentage = Number.parseFloat(openSizeRef.current); + setPinnedContentWidth( + Number.isFinite(openPercentage) ? `${openPercentage}cqw` : null, + ); + + // resize() rather than expand(): the library expands to `minSize` until it + // has recorded a size of its own, which would reopen narrower than before. + if (rightPanelOpen) { + sidePanelRef.current?.resize(openSizeRef.current); + } else { + sidePanelRef.current?.collapse(); + } + + const timeout = window.setTimeout(() => { + setAnimatingRightPanel(false); + setPinnedContentWidth(null); + }, RIGHT_PANEL_ANIMATION_MS); + + return () => { + window.clearTimeout(timeout); + }; + }, [rightPanelOpen, sidePanelRef]); + useEffect(() => { if (activeRightPanel) { setRenderedRightPanel(activeRightPanel); @@ -260,83 +323,69 @@ const ChatBox: React.FC<{ } return ( -
[data-panel]]:transition-[flex-grow] [&>[data-panel]]:duration-[280ms] [&>[data-panel]]:ease-out motion-reduce:[&>[data-panel]]:transition-none", )} > - {activeRightPanel === "browser" ? ( - +
+ {children} +
+ + + +
+ + + ); }; diff --git a/frontend/tests/e2e/artifact-panel-resize.spec.ts b/frontend/tests/e2e/artifact-panel-resize.spec.ts new file mode 100644 index 000000000..b821f6ced --- /dev/null +++ b/frontend/tests/e2e/artifact-panel-resize.spec.ts @@ -0,0 +1,192 @@ +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: + "

Report draft

", + }, + }, + ], + }, + { + type: "tool", + id: "msg-tool-write-artifact", + name: "write_file", + tool_call_id: "write-file-artifact", + content: "OK", + }, + ]; +} + +async function panelWidth(panel: Locator): Promise { + 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 { + // 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 { + 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"); + }); +});