From 4e66acbbb48b9dce579a1da7a40e9bedf4d01751 Mon Sep 17 00:00:00 2001 From: Aari Date: Wed, 29 Jul 2026 11:51:51 +0800 Subject: [PATCH] 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. --- frontend/AGENTS.md | 2 +- .../components/workspace/chats/chat-box.tsx | 29 +++++++++++++- .../tests/e2e/artifact-panel-resize.spec.ts | 39 +++++++++++++++++-- 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 1afb15965..af2c7266d 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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 diff --git a/frontend/src/components/workspace/chats/chat-box.tsx b/frontend/src/components/workspace/chats/chat-box.tsx index b8398c892..b0142ae80 100644 --- a/frontend/src/components/workspace/chats/chat-box.tsx +++ b/frontend/src/components/workspace/chats/chat-box.tsx @@ -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" >