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