fix(frontend): keep panel open after reversed drag (#4566)

* fix(frontend): keep panel open after reversed drag

* test(frontend): model reversed panel drag cumulatively
This commit is contained in:
Aari 2026-07-29 21:07:00 +08:00 committed by GitHub
parent 4582a41347
commit 3c5e3d9de5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 48 additions and 15 deletions

View File

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

View File

@ -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<{
<ResizablePanelGroup
id={`${resizableIdBase}-group`}
orientation="horizontal"
onLayoutChanged={handlePanelGroupLayoutChanged}
className={cn(
"[container-type:inline-size] size-full min-h-0",
// The sized flex item is the library's own `[data-panel]` element, not

View File

@ -44,7 +44,7 @@ async function panelWidth(panel: Locator): Promise<number> {
return box?.width ?? 0;
}
async function dragPanel(handle: Locator, distance: number): Promise<void> {
async function dragPanel(handle: Locator, ...deltas: 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();
@ -58,7 +58,11 @@ async function dragPanel(handle: Locator, distance: number): Promise<void> {
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,
}) => {