fix(frontend): sync panel state when a drag collapses the side panel (#4556)

The shared right panel is collapsible with collapsedSize="0%", so dragging
the divider past minSize makes the library collapse it to zero without
going through the state that owns the panel. The panel disappears while
that state still reads open, leaving the divider draggable but inert and
the panel's trigger needing two clicks to bring it back.

Mirror a zero-width resize back into the owning state so a drag-collapse
closes the panel the same way its trigger does, and keep recording
non-zero widths there as the size to reopen at.
This commit is contained in:
Aari 2026-07-29 11:51:51 +08:00 committed by GitHub
parent 4e44938551
commit 4e66acbbb4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 64 additions and 6 deletions

View File

@ -114,7 +114,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.
- `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.
## Code Style

View File

@ -1,7 +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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type PanelSize, usePanelRef } from "react-resizable-panels";
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
@ -150,6 +150,30 @@ 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}%`;
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.
if (activeRightPanel === "sidecar") {
sidecar?.close();
} else if (activeRightPanel === "browser") {
browserView?.close();
} else if (activeRightPanel === "artifacts") {
setArtifactsOpen(false);
}
},
[activeRightPanel, browserView, setArtifactsOpen, sidecar],
);
useEffect(() => {
if (rightPanelOpenRef.current === rightPanelOpen) {
return;
@ -360,6 +384,7 @@ const ChatBox: React.FC<{
collapsedSize="0%"
defaultSize={initialRightPanelSize}
minSize="20%"
onResize={handleSidePanelResize}
className="min-h-0 min-w-0"
>
<aside

View File

@ -44,8 +44,7 @@ async function panelWidth(panel: Locator): Promise<number> {
return box?.width ?? 0;
}
/** Drag the divider left by `distance` px, widening the right panel. */
async function widenPanel(handle: Locator, distance: number): Promise<void> {
async function dragPanel(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();
@ -59,10 +58,20 @@ async function widenPanel(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 });
await mouse.move(x + distance, y, { steps: 10 });
await mouse.up();
}
/** Drag the divider left by `distance` px, widening the right panel. */
async function widenPanel(handle: Locator, distance: number): Promise<void> {
await dragPanel(handle, -distance);
}
/** Drag the divider right by `distance` px, narrowing the right panel. */
async function narrowPanel(handle: Locator, distance: number): Promise<void> {
await dragPanel(handle, distance);
}
async function openArtifact(page: Page): Promise<void> {
await expect(page.getByText(ARTIFACT_PATH)).toBeVisible({ timeout: 15_000 });
await page.getByText(ARTIFACT_PATH).click();
@ -102,6 +111,30 @@ test.describe("Artifacts panel resize", () => {
.toBeGreaterThan(widthBefore + 100);
});
test("drag-collapse closes the panel and it can be reopened", async ({
page,
}) => {
await openArtifact(page);
const artifactsPanel = page.locator("#artifacts");
const group = page.locator('[data-slot="resizable-panel-group"]');
const handle = page.locator('[data-slot="resizable-handle"]');
await expect(artifactsPanel).toBeVisible();
await narrowPanel(handle, 500);
await expect(artifactsPanel).toBeHidden();
await expect(handle).toHaveAttribute("data-separator", "disabled");
await openArtifact(page);
await expect(artifactsPanel).toBeVisible();
const groupWidth = await panelWidth(group);
await expect
.poll(async () => panelWidth(artifactsPanel))
.toBeGreaterThan(groupWidth * 0.19);
});
test("a dragged width is kept when the panel is reopened", async ({
page,
}) => {