From 3c5e3d9de570bd6b55e82979bd832ed1c5de8aea Mon Sep 17 00:00:00 2001 From: Aari Date: Wed, 29 Jul 2026 21:07:00 +0800 Subject: [PATCH] fix(frontend): keep panel open after reversed drag (#4566) * fix(frontend): keep panel open after reversed drag * test(frontend): model reversed panel drag cumulatively --- frontend/AGENTS.md | 2 +- .../components/workspace/chats/chat-box.tsx | 35 ++++++++++++------- .../tests/e2e/artifact-panel-resize.spec.ts | 26 ++++++++++++-- 3 files changed, 48 insertions(+), 15 deletions(-) diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 6cecb4cba..c0bc87efc 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -116,7 +116,7 @@ Edit-and-rerun is deliberately latest-turn-only. `core/messages/utils.ts::getLat - `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. Because the panel is `collapsible`, the library can also collapse it to `0%` on its own when a drag crosses `minSize`, without going through the state that owns it — so `onResize` must mirror that back into `sidecar` / `browserView` / `artifactsOpen`, otherwise the state still reads open and the trigger needs two clicks to bring the panel back. +- `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. Because the panel is `collapsible`, the library can also collapse it to `0%` on its own when a drag crosses `minSize`, without going through the state that owns it. `onResize` records the last positive size while the pointer moves, but the owning `sidecar` / `browserView` / `artifactsOpen` state must only mirror a final `0%` layout from `onLayoutChanged`, after pointer release; closing on the first `0%` resize frame breaks a continuous drag that reaches the edge and then reverses before release. ## Code Style diff --git a/frontend/src/components/workspace/chats/chat-box.tsx b/frontend/src/components/workspace/chats/chat-box.tsx index b0142ae80..077603f5f 100644 --- a/frontend/src/components/workspace/chats/chat-box.tsx +++ b/frontend/src/components/workspace/chats/chat-box.tsx @@ -1,7 +1,11 @@ import { FilesIcon, XIcon } from "lucide-react"; import { usePathname } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { type PanelSize, usePanelRef } from "react-resizable-panels"; +import { + type Layout, + type PanelSize, + usePanelRef, +} from "react-resizable-panels"; import { ConversationEmptyState } from "@/components/ai-elements/conversation"; import { Button } from "@/components/ui/button"; @@ -150,19 +154,25 @@ const ChatBox: React.FC<{ rightPanelOpen ? RIGHT_PANEL_DEFAULT_SIZE : "0%", ); - const handleSidePanelResize = useCallback( - (size: PanelSize) => { - if (!rightPanelOpenRef.current) { - return; - } - if (size.asPercentage > 0) { - openSizeRef.current = `${size.asPercentage}%`; + const handleSidePanelResize = useCallback((size: PanelSize) => { + if (!rightPanelOpenRef.current || size.asPercentage <= 0) { + return; + } + openSizeRef.current = `${size.asPercentage}%`; + }, []); + + const handlePanelGroupLayoutChanged = useCallback( + (layout: Layout) => { + if ( + !rightPanelOpenRef.current || + layout[`${resizableIdBase}-side`] !== 0 + ) { return; } - // Dragging a collapsible panel below its minimum snaps it to 0% without - // updating the state that owns the panel. Treat that as a normal close so - // its trigger can reopen it at the last non-zero size. + // Finalize a drag-collapse only after the pointer is released. Closing + // from onResize at the first 0% frame would break a continuous gesture + // that reaches the edge and then reverses before release. if (activeRightPanel === "sidecar") { sidecar?.close(); } else if (activeRightPanel === "browser") { @@ -171,7 +181,7 @@ const ChatBox: React.FC<{ setArtifactsOpen(false); } }, - [activeRightPanel, browserView, setArtifactsOpen, sidecar], + [activeRightPanel, browserView, resizableIdBase, setArtifactsOpen, sidecar], ); useEffect(() => { @@ -350,6 +360,7 @@ const ChatBox: React.FC<{ { return box?.width ?? 0; } -async function dragPanel(handle: Locator, distance: number): Promise { +async function dragPanel(handle: Locator, ...deltas: 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(); @@ -58,7 +58,11 @@ async function dragPanel(handle: Locator, distance: number): Promise { const mouse = handle.page().mouse; await mouse.down(); await expect(handle).toHaveAttribute("data-separator", "active"); - await mouse.move(x + distance, y, { steps: 10 }); + let currentX = x; + for (const delta of deltas) { + currentX += delta; + await mouse.move(currentX, y, { steps: 10 }); + } await mouse.up(); } @@ -135,6 +139,24 @@ test.describe("Artifacts panel resize", () => { .toBeGreaterThan(groupWidth * 0.19); }); + test("reversing a collapse drag before release keeps the panel open", async ({ + page, + }) => { + await openArtifact(page); + + const artifactsPanel = page.locator("#artifacts"); + const handle = page.locator('[data-slot="resizable-handle"]'); + await expect(artifactsPanel.getByText("report.html")).toBeVisible(); + + // Cross the collapse threshold, then reverse the same drag before the + // pointer is released. The final non-zero layout should remain open. + await dragPanel(handle, 500, -500); + + await expect(artifactsPanel).toHaveAttribute("aria-hidden", "false"); + await expect(artifactsPanel.getByText("report.html")).toBeVisible(); + await expect(handle).not.toHaveAttribute("data-separator", "disabled"); + }); + test("a dragged width is kept when the panel is reopened", async ({ page, }) => {