refactor(site): replace DiffRightPanel with generic tabbed RightPanel (#22633)

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 |
This commit is contained in:
Kyle Carberry
2026-03-04 19:37:17 -05:00
committed by GitHub
parent 28d99e8afb
commit 6afcc7b904
5 changed files with 389 additions and 129 deletions
+30 -7
View File
@@ -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<boolean | null>(
null,
);
const visualExpanded = dragVisualExpanded ?? isRightPanelExpanded;
const [pendingEditMessageId, setPendingEditMessageId] = useState<
number | null
>(null);
@@ -929,11 +937,16 @@ const AgentDetail: FC = () => {
return (
<div
className={cn(
"flex min-h-0 min-w-0 flex-1",
shouldShowDiffPanel && "flex-col xl:flex-row",
"relative flex min-h-0 min-w-0 flex-1",
shouldShowDiffPanel && !visualExpanded && "flex-col xl:flex-row",
)}
>
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
<div
className={cn(
"relative flex min-h-0 min-w-0 flex-1 flex-col",
visualExpanded && "hidden",
)}
>
<div className="relative z-10 shrink-0 overflow-visible">
<AgentDetailTopBar
chatTitle={chatTitle}
@@ -1024,9 +1037,19 @@ const AgentDetail: FC = () => {
/>
</div>
</div>
<DiffRightPanel isOpen={shouldShowDiffPanel}>
<FilesChangedPanel chatId={agentId} />
</DiffRightPanel>
<RightPanel
isOpen={shouldShowDiffPanel}
isExpanded={isRightPanelExpanded}
onToggleExpanded={() => setIsRightPanelExpanded((prev) => !prev)}
onClose={() => setShowDiffPanel(false)}
chatTitle={chatTitle}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
onVisualExpandedChange={setDragVisualExpanded}
tabContent={{
git: <FilesChangedPanel chatId={agentId} />,
}}
/>{" "}
</div>
);
};
@@ -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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<div
data-testid="agents-detail-right-panel"
style={
isOpen
? ({ "--panel-width": `${width}px` } as React.CSSProperties)
: undefined
}
className={cn(
"relative min-h-0 min-w-0 border-t border-border-default bg-surface-primary",
isOpen
? "flex h-[42dvh] min-h-[260px] max-h-[56dvh] flex-col xl:h-auto xl:max-h-none xl:w-[var(--panel-width)] xl:min-w-[360px] xl:max-w-[960px] xl:border-l xl:border-t-0"
: "hidden",
)}
>
{/* Drag handle (xl+ only, on the left edge of the panel) */}
<div
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
className="absolute top-0 left-0 z-20 hidden h-full w-1 cursor-col-resize select-none transition-colors hover:bg-content-link xl:block"
/>
{children}
</div>
);
};
@@ -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);
@@ -104,7 +104,7 @@ export const FilesChangedPanel: FC<FilesChangedPanelProps> = ({ chatId }) => {
if (diffContentsQuery.isLoading || diffStatusQuery.isLoading) {
return (
<div className="flex h-full min-w-0 flex-col overflow-hidden border-0 border-l border-solid bg-surface-primary">
<div className="flex h-full min-w-0 flex-col overflow-hidden border-0 border-solid">
<div className="flex items-center gap-2 border-0 border-b border-solid px-4 py-3">
<Skeleton className="h-4 w-4 rounded" />
<Skeleton className="h-4 w-28" />
@@ -132,7 +132,7 @@ export const FilesChangedPanel: FC<FilesChangedPanelProps> = ({ chatId }) => {
}
return (
<div className="flex h-full min-w-0 flex-col overflow-hidden border-0 border-l border-solid bg-surface-primary">
<div className="flex h-full min-w-0 flex-col overflow-hidden border-0 border-solid">
{/* Header */}
<div className="flex items-center justify-between gap-3 border-0 border-b border-solid px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
+356
View File
@@ -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<string, ReactNode>;
/** 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<React.SetStateAction<number>>;
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<div
data-testid="agents-right-panel"
style={
visualOpen && !visualExpanded
? ({ "--panel-width": `${width}px` } as React.CSSProperties)
: undefined
}
className={cn(
visualExpanded
? "absolute inset-0 z-30 flex flex-col"
: cn(
"relative min-h-0 min-w-0",
visualOpen
? "flex h-[42dvh] min-h-[260px] max-h-[56dvh] flex-col xl:h-auto xl:max-h-none xl:w-[var(--panel-width)] xl:min-w-[360px] xl:max-w-[70vw] xl:border-0 xl:border-l xl:border-solid xl:border-border-default"
: "hidden",
),
)}
>
{/* Drag handle (xl+ only, on the left edge of the panel) */}
<div
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
className={cn(
"absolute top-0 left-0 z-20 hidden h-full w-1 cursor-col-resize select-none transition-colors hover:bg-content-link xl:block",
visualExpanded && "-left-1",
)}
/>{" "}
<div className="flex min-h-0 flex-1 flex-col">
{/* Tabbed header */}
<div className="flex shrink-0 items-center gap-2 px-3 py-1">
{/* Left side: sidebar toggle (expanded + collapsed only) + tabs */}
<div className="flex items-center">
{visualExpanded &&
isSidebarCollapsed &&
onToggleSidebarCollapsed && (
<Button
variant="subtle"
size="icon"
onClick={onToggleSidebarCollapsed}
aria-label="Expand sidebar"
className="mr-1 h-7 w-7 min-w-0 shrink-0"
>
<PanelLeftIcon />
</Button>
)}
{TABS.map((tab) => (
<Button
key={tab.id}
variant={activeTab === tab.id ? "outline" : "subtle"}
size="lg"
onClick={() => setActiveTab(tab.id)}
className={cn(
"min-w-0 h-6 px-3",
activeTab === tab.id && "bg-surface-secondary",
)}
>
{tab.label}
</Button>
))}
</div>{" "}
{/* Center: chat title */}
<div className="min-w-0 flex-1 text-center">
{visualExpanded && chatTitle && (
<span className="truncate text-sm text-content-primary">
{chatTitle}
</span>
)}
</div>
{/* Right side: expand/contract button */}
<Button
variant="subtle"
size="icon"
onClick={onToggleExpanded}
aria-label={visualExpanded ? "Collapse panel" : "Expand panel"}
className="h-7 w-7 text-content-secondary hover:text-content-primary"
>
{visualExpanded ? <MinimizeIcon /> : <MaximizeIcon />}
</Button>
</div>
<div className="min-h-0 flex-1 overflow-hidden bg-surface-secondary/45">
{tabContent[activeTab]}
</div>
</div>
</div>
);
};