From 6afcc7b90456bca200841b2da93dfa84b2bb7490 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 4 Mar 2026 19:37:17 -0500 Subject: [PATCH] refactor(site): replace DiffRightPanel with generic tabbed RightPanel (#22633) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single-purpose DiffRightPanel with a generic RightPanel component that supports tabs, drag-resize, drag-to-snap, and drag-to-collapse-sidebar. ## Changes - **New `RightPanel.tsx`**: generic tabbed panel with: - Drag handle with pointer capture for smooth resizing - Snap thresholds: drag past max → expand, drag below min → close - Live sidebar collapse when dragging to the left viewport edge (and reverses if dragged back) - Persisted width via localStorage - `onVisualExpandedChange` callback so parent syncs sibling visibility during drag (not just on pointer-up) - **Deleted `DiffRightPanel.tsx`** - **Updated `AgentDetail.tsx`**: uses `RightPanel` with `tabContent` record, tracks `dragVisualExpanded` for live chat section hiding - **Updated `FilesChangedPanel.tsx`**: removed border/background (now handled by RightPanel wrapper) ## Drag behavior | Gesture | Effect | |---|---| | Drag left past 70vw + 80px | Snap to expanded (fullscreen within parent) | | Drag right below 360px - 80px | Snap to closed | | Drag to left viewport edge (<80px) | Collapse sidebar live | | Drag back from left edge | Uncollapse sidebar live | | Start expanded, drag right | Live resize back to normal | --- site/src/pages/AgentsPage/AgentDetail.tsx | 37 +- site/src/pages/AgentsPage/DiffRightPanel.tsx | 118 ------ .../AgentsPage/FilesChangedPanel.stories.tsx | 3 +- .../pages/AgentsPage/FilesChangedPanel.tsx | 4 +- site/src/pages/AgentsPage/RightPanel.tsx | 356 ++++++++++++++++++ 5 files changed, 389 insertions(+), 129 deletions(-) delete mode 100644 site/src/pages/AgentsPage/DiffRightPanel.tsx create mode 100644 site/src/pages/AgentsPage/RightPanel.tsx diff --git a/site/src/pages/AgentsPage/AgentDetail.tsx b/site/src/pages/AgentsPage/AgentDetail.tsx index dee325f428..eb47a16dbc 100644 --- a/site/src/pages/AgentsPage/AgentDetail.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.tsx @@ -64,7 +64,6 @@ import { buildStreamTools } from "./AgentDetail/streamState"; import { AgentDetailTopBar } from "./AgentDetail/TopBar"; import { useMessageWindow } from "./AgentDetail/useMessageWindow"; import type { AgentsOutletContext } from "./AgentsPage"; -import { DiffRightPanel } from "./DiffRightPanel"; import { FilesChangedPanel } from "./FilesChangedPanel"; import { getModelCatalogStatusMessage, @@ -72,6 +71,7 @@ import { getModelSelectorPlaceholder, hasConfiguredModelsInCatalog, } from "./modelOptions"; +import { RightPanel } from "./RightPanel"; const noopSetChatErrorReason: AgentsOutletContext["setChatErrorReason"] = () => {}; @@ -436,6 +436,14 @@ const AgentDetail: FC = () => { const queryClient = useQueryClient(); const [selectedModel, setSelectedModel] = useState(""); const [showDiffPanel, setShowDiffPanel] = useState(false); + const [isRightPanelExpanded, setIsRightPanelExpanded] = useState(false); + // Tracks the live visual expanded state during drag so sibling + // content hides/shows in real-time rather than on pointer-up. + // Null means "no drag override, use isRightPanelExpanded". + const [dragVisualExpanded, setDragVisualExpanded] = useState( + null, + ); + const visualExpanded = dragVisualExpanded ?? isRightPanelExpanded; const [pendingEditMessageId, setPendingEditMessageId] = useState< number | null >(null); @@ -929,11 +937,16 @@ const AgentDetail: FC = () => { return (
-
+
{ />
- - - + setIsRightPanelExpanded((prev) => !prev)} + onClose={() => setShowDiffPanel(false)} + chatTitle={chatTitle} + isSidebarCollapsed={isSidebarCollapsed} + onToggleSidebarCollapsed={onToggleSidebarCollapsed} + onVisualExpandedChange={setDragVisualExpanded} + tabContent={{ + git: , + }} + />{" "}
); }; diff --git a/site/src/pages/AgentsPage/DiffRightPanel.tsx b/site/src/pages/AgentsPage/DiffRightPanel.tsx deleted file mode 100644 index 8d9bfde9ca..0000000000 --- a/site/src/pages/AgentsPage/DiffRightPanel.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { - type ReactNode, - type PointerEvent as ReactPointerEvent, - useCallback, - useEffect, - useRef, - useState, -} from "react"; -import { cn } from "utils/cn"; - -const STORAGE_KEY = "agents.diff-panel-width"; -const MIN_WIDTH = 360; -const MAX_WIDTH = 960; -const DEFAULT_WIDTH = 480; - -function loadPersistedWidth(): number { - if (typeof window === "undefined") { - return DEFAULT_WIDTH; - } - const stored = localStorage.getItem(STORAGE_KEY); - if (!stored) { - return DEFAULT_WIDTH; - } - const parsed = Number.parseInt(stored, 10); - if (Number.isNaN(parsed) || parsed < MIN_WIDTH || parsed > MAX_WIDTH) { - return DEFAULT_WIDTH; - } - return parsed; -} - -interface DiffRightPanelProps { - isOpen: boolean; - children?: ReactNode; -} - -/** - * The right-side panel for the diff/files-changed view. When closed - * the panel is hidden via CSS and takes no layout space. On xl+ - * screens the panel is horizontally resizable via a drag handle. - */ -export const DiffRightPanel = ({ isOpen, children }: DiffRightPanelProps) => { - const [width, setWidth] = useState(loadPersistedWidth); - const isDragging = useRef(false); - const startX = useRef(0); - const startWidth = useRef(0); - - const handlePointerDown = useCallback( - (e: ReactPointerEvent) => { - e.preventDefault(); - isDragging.current = true; - startX.current = e.clientX; - startWidth.current = width; - (e.target as HTMLElement).setPointerCapture(e.pointerId); - }, - [width], - ); - - const handlePointerMove = useCallback( - (e: ReactPointerEvent) => { - if (!isDragging.current) { - return; - } - // Dragging left (negative delta) should make the panel wider - // since the handle is on the left edge. - const delta = startX.current - e.clientX; - const next = Math.min( - MAX_WIDTH, - Math.max(MIN_WIDTH, startWidth.current + delta), - ); - setWidth(next); - }, - [], - ); - - const handlePointerUp = useCallback( - (e: ReactPointerEvent) => { - if (!isDragging.current) { - return; - } - isDragging.current = false; - (e.target as HTMLElement).releasePointerCapture(e.pointerId); - }, - [], - ); - - // Persist width to localStorage when dragging ends. - useEffect(() => { - if (typeof window !== "undefined") { - localStorage.setItem(STORAGE_KEY, String(width)); - } - }, [width]); - - return ( -
- {/* Drag handle (xl+ only, on the left edge of the panel) */} -
- {children} -
- ); -}; diff --git a/site/src/pages/AgentsPage/FilesChangedPanel.stories.tsx b/site/src/pages/AgentsPage/FilesChangedPanel.stories.tsx index e428322fd1..1e9bcedcd8 100644 --- a/site/src/pages/AgentsPage/FilesChangedPanel.stories.tsx +++ b/site/src/pages/AgentsPage/FilesChangedPanel.stories.tsx @@ -160,13 +160,12 @@ function generateLargeDiff( delPct: 0.05, }, { - path: "site/src/pages/AgentsPage/DiffRightPanel.tsx", + path: "site/src/pages/AgentsPage/RightPanel.tsx", addPct: 0.05, delPct: 0.1, }, { path: "site/src/hooks/useDiffViewer.ts", addPct: 0.05, delPct: 0.05 }, ]; - const patches: string[] = []; for (const f of files) { const add = Math.round(totalAdditions * f.addPct); diff --git a/site/src/pages/AgentsPage/FilesChangedPanel.tsx b/site/src/pages/AgentsPage/FilesChangedPanel.tsx index 455fc88b40..bf945de827 100644 --- a/site/src/pages/AgentsPage/FilesChangedPanel.tsx +++ b/site/src/pages/AgentsPage/FilesChangedPanel.tsx @@ -104,7 +104,7 @@ export const FilesChangedPanel: FC = ({ chatId }) => { if (diffContentsQuery.isLoading || diffStatusQuery.isLoading) { return ( -
+
@@ -132,7 +132,7 @@ export const FilesChangedPanel: FC = ({ chatId }) => { } return ( -
+
{/* Header */}
diff --git a/site/src/pages/AgentsPage/RightPanel.tsx b/site/src/pages/AgentsPage/RightPanel.tsx new file mode 100644 index 0000000000..c1c92a8d1e --- /dev/null +++ b/site/src/pages/AgentsPage/RightPanel.tsx @@ -0,0 +1,356 @@ +import { Button } from "components/Button/Button"; +import { MaximizeIcon, MinimizeIcon, PanelLeftIcon } from "lucide-react"; +import { + type ReactNode, + type PointerEvent as ReactPointerEvent, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { cn } from "utils/cn"; + +const STORAGE_KEY = "agents.right-panel-width"; +const MIN_WIDTH = 360; +const MAX_WIDTH_RATIO = 0.7; +const DEFAULT_WIDTH = 480; + +const TABS = [{ id: "git", label: "Git" }]; + +const SNAP_THRESHOLD = 80; + +function getMaxWidth(): number { + if (typeof window === "undefined") { + return 960; + } + return Math.max(MIN_WIDTH, Math.floor(window.innerWidth * MAX_WIDTH_RATIO)); +} + +function loadPersistedWidth(): number { + if (typeof window === "undefined") { + return DEFAULT_WIDTH; + } + const stored = localStorage.getItem(STORAGE_KEY); + if (!stored) { + return DEFAULT_WIDTH; + } + const parsed = Number.parseInt(stored, 10); + if (Number.isNaN(parsed) || parsed < MIN_WIDTH || parsed > getMaxWidth()) { + return DEFAULT_WIDTH; + } + return parsed; +} + +interface RightPanelProps { + isOpen: boolean; + isExpanded: boolean; + onToggleExpanded: () => void; + onClose: () => void; + chatTitle?: string; + isSidebarCollapsed?: boolean; + onToggleSidebarCollapsed?: () => void; + tabContent: Record; + /** Fires during drag with the live visual expanded state, and + * null when the drag ends so the parent falls back to the + * committed isExpanded prop. */ + onVisualExpandedChange?: (visualExpanded: boolean | null) => void; +} + +/** + * Encapsulates all drag/resize logic for the right panel: + * refs, pointer handlers, snap state, sidebar collapse + * tracking, and visual state derivation. + */ +function useResizableDrag({ + isExpanded, + isSidebarCollapsed, + onToggleSidebarCollapsed, + width, + setWidth, + isOpen, + onSnapCommit, + onVisualExpandedChange, +}: { + isExpanded: boolean; + isSidebarCollapsed?: boolean; + onToggleSidebarCollapsed?: () => void; + width: number; + setWidth: React.Dispatch>; + isOpen: boolean; + onSnapCommit: (snap: "normal" | "expanded" | "closed") => void; + onVisualExpandedChange?: (visualExpanded: boolean | null) => void; +}) { + const isDragging = useRef(false); + const startX = useRef(0); + const startWidth = useRef(0); + // Track snap state during a drag. This is state (not a ref) so + // the panel visually updates as the user drags across thresholds. + const [dragSnap, setDragSnap] = useState< + "normal" | "expanded" | "closed" | null + >(null); + // Whether we collapsed the sidebar during this drag gesture. + // Used to reverse it if the user drags back. + const sidebarCollapsedByDrag = useRef(false); + + const handlePointerDown = useCallback( + (e: ReactPointerEvent) => { + e.preventDefault(); + isDragging.current = true; + sidebarCollapsedByDrag.current = false; + setDragSnap(null); + startX.current = e.clientX; + startWidth.current = isExpanded + ? ((e.target as HTMLElement).closest( + "[data-testid='agents-right-panel']", + )?.parentElement?.clientWidth ?? getMaxWidth()) + : width; + (e.target as HTMLElement).setPointerCapture(e.pointerId); + }, + [width, isExpanded], + ); + + const handlePointerMove = useCallback( + (e: ReactPointerEvent) => { + if (!isDragging.current) { + return; + } + const delta = startX.current - e.clientX; + const raw = startWidth.current + delta; + const maxWidth = getMaxWidth(); + + // Collapse/uncollapse the sidebar live when the pointer + // reaches the left edge of the viewport. + if (e.clientX < SNAP_THRESHOLD && !sidebarCollapsedByDrag.current) { + if (!isSidebarCollapsed && onToggleSidebarCollapsed) { + onToggleSidebarCollapsed(); + sidebarCollapsedByDrag.current = true; + } + } else if ( + e.clientX >= SNAP_THRESHOLD && + sidebarCollapsedByDrag.current + ) { + if (onToggleSidebarCollapsed) { + onToggleSidebarCollapsed(); + sidebarCollapsedByDrag.current = false; + } + } + + let nextSnap: "normal" | "expanded" | "closed"; + if (raw > maxWidth + SNAP_THRESHOLD) { + nextSnap = "expanded"; + } else if (raw < MIN_WIDTH - SNAP_THRESHOLD) { + nextSnap = "closed"; + } else { + nextSnap = "normal"; + setWidth(Math.min(maxWidth, Math.max(MIN_WIDTH, raw))); + } + setDragSnap(nextSnap); + + // Notify parent of the live visual expanded state so + // sibling content reacts during the drag. + const nextVisualExpanded = + nextSnap === "expanded" || + (nextSnap !== "normal" && nextSnap !== "closed" && isExpanded); + onVisualExpandedChange?.(nextVisualExpanded); + }, + [ + isSidebarCollapsed, + onToggleSidebarCollapsed, + setWidth, + isExpanded, + onVisualExpandedChange, + ], + ); + + const handlePointerUp = useCallback( + (e: ReactPointerEvent) => { + if (!isDragging.current) { + return; + } + const snap = dragSnap; + isDragging.current = false; + setDragSnap(null); + (e.target as HTMLElement).releasePointerCapture(e.pointerId); + + // Clear the drag override so parent falls back to its + // own committed expanded state. + onVisualExpandedChange?.(null); + + if (snap) { + onSnapCommit(snap); + } + }, + [dragSnap, onSnapCommit, onVisualExpandedChange], + ); + + // Derive visual state: during a drag the snap overrides the + // committed parent state so the panel reacts live. + const visualExpanded = + dragSnap === "expanded" || + (dragSnap !== "normal" && dragSnap !== "closed" && isExpanded); + const visualOpen = + dragSnap !== "closed" && + (dragSnap === "expanded" || dragSnap === "normal" || isOpen); + + return { + visualExpanded, + visualOpen, + handlePointerDown, + handlePointerMove, + handlePointerUp, + sidebarCollapsedByDrag, + }; +} + +export const RightPanel = ({ + isOpen, + isExpanded, + onToggleExpanded, + onClose, + chatTitle, + isSidebarCollapsed, + onToggleSidebarCollapsed, + tabContent, + onVisualExpandedChange, +}: RightPanelProps) => { + const [activeTab, setActiveTab] = useState("git"); + const [width, setWidth] = useState(loadPersistedWidth); + + // Clamp width when the viewport shrinks so the panel + // doesn't overflow and take over the whole page. + useEffect(() => { + const handleResize = () => { + setWidth((prev) => Math.min(prev, getMaxWidth())); + }; + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + const handleSnapCommit = useCallback( + (snap: "normal" | "expanded" | "closed") => { + if (snap === "expanded" && !isExpanded) { + onToggleExpanded(); + } else if (snap === "closed") { + setWidth(DEFAULT_WIDTH); + if (isExpanded) { + onToggleExpanded(); + } + onClose(); + } else if (snap === "normal" && isExpanded) { + onToggleExpanded(); + } + }, + [isExpanded, onToggleExpanded, onClose], + ); + + const { + visualExpanded, + visualOpen, + handlePointerDown, + handlePointerMove, + handlePointerUp, + } = useResizableDrag({ + isExpanded, + isSidebarCollapsed, + onToggleSidebarCollapsed, + width, + setWidth, + isOpen, + onSnapCommit: handleSnapCommit, + onVisualExpandedChange, + }); + + useEffect(() => { + if (typeof window !== "undefined") { + localStorage.setItem(STORAGE_KEY, String(width)); + } + }, [width]); + + return ( +
+ {/* Drag handle (xl+ only, on the left edge of the panel) */} + + ); +};