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
This commit is contained in:
Aari 2026-07-26 21:20:36 +08:00 committed by GitHub
parent 8145d66a33
commit 55c2153080
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 310 additions and 68 deletions

View File

@ -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

View File

@ -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<string | null>(
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 (
<div
id={`${resizableIdBase}-panels`}
<ResizablePanelGroup
id={`${resizableIdBase}-group`}
orientation="horizontal"
className={cn(
"[container-type:inline-size] size-full min-h-0",
activeRightPanel !== "browser" &&
"grid transition-[grid-template-columns] duration-[280ms] ease-out motion-reduce:transition-none",
activeRightPanel !== "browser" &&
(rightPanelOpen
? "grid-cols-[minmax(0,1fr)_1px_minmax(0,40%)]"
: "grid-cols-[minmax(0,1fr)_0px_0px]"),
// The sized flex item is the library's own `[data-panel]` element, not
// the child `className` lands on, so the open/close transition has to be
// addressed from here.
animatingRightPanel &&
"[&>[data-panel]]:transition-[flex-grow] [&>[data-panel]]:duration-[280ms] [&>[data-panel]]:ease-out motion-reduce:[&>[data-panel]]:transition-none",
)}
>
{activeRightPanel === "browser" ? (
<ResizablePanelGroup
id={`${resizableIdBase}-group`}
orientation="horizontal"
className="size-full min-h-0"
<ResizablePanel
id={`${resizableIdBase}-chat`}
minSize="30%"
className="relative min-h-0 min-w-0"
>
<div className="relative size-full min-h-0 min-w-0" id="chat">
{children}
</div>
</ResizablePanel>
<ResizableHandle
id={`${resizableIdBase}-separator`}
withHandle
disabled={!rightPanelOpen}
className={cn(
"opacity-33 transition-opacity duration-200 ease-out hover:opacity-100 motion-reduce:transition-none",
!rightPanelOpen && "pointer-events-none opacity-0",
)}
/>
<ResizablePanel
id={`${resizableIdBase}-side`}
panelRef={sidePanelRef}
collapsible
collapsedSize="0%"
defaultSize={initialRightPanelSize}
minSize="20%"
className="min-h-0 min-w-0"
>
<aside
aria-hidden={!rightPanelOpen}
className={cn(
"size-full min-h-0 min-w-0 overflow-hidden transition-opacity duration-[280ms] ease-out motion-reduce:transition-none",
rightPanelOpen ? "opacity-100" : "pointer-events-none opacity-0",
)}
id="artifacts"
>
<ResizablePanel
id={`${resizableIdBase}-chat`}
minSize="30%"
className="relative min-h-0 min-w-0"
>
<div className="relative size-full min-h-0 min-w-0" id="chat">
{children}
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
id={`${resizableIdBase}-side`}
defaultSize="40%"
minSize="20%"
maxSize="75%"
className="min-h-0 min-w-0"
>
<aside
className="size-full min-h-0 min-w-0 overflow-hidden p-0"
id="artifacts"
>
{rightPanelContent}
</aside>
</ResizablePanel>
</ResizablePanelGroup>
) : (
<>
<div className="relative min-h-0 min-w-0" id="chat">
{children}
</div>
<div
id={`${resizableIdBase}-separator`}
aria-hidden="true"
className={cn(
"bg-border opacity-33 transition-opacity duration-200 ease-out motion-reduce:transition-none",
!rightPanelOpen && "pointer-events-none opacity-0",
"ml-auto h-full",
renderedRightPanel === "artifacts" ? "p-4" : "p-0",
)}
/>
<aside
aria-hidden={!rightPanelOpen}
className={cn(
"min-h-0 min-w-0 overflow-hidden transition-opacity duration-[280ms] ease-out motion-reduce:transition-none",
!rightPanelOpen && "pointer-events-none opacity-0",
)}
id="artifacts"
style={
pinnedContentWidth === null
? undefined
: { width: pinnedContentWidth }
}
>
<div
className={cn(
"ml-auto h-full w-[40cqw] transition-opacity duration-[280ms] ease-out motion-reduce:transition-none",
renderedRightPanel === "sidecar" ? "p-0" : "p-4",
rightPanelOpen ? "opacity-100" : "opacity-0",
)}
>
{rightPanelContent}
</div>
</aside>
</>
)}
</div>
{rightPanelContent}
</div>
</aside>
</ResizablePanel>
</ResizablePanelGroup>
);
};

View File

@ -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:
"<!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");
});
});