Compare commits

...

1 Commits

Author SHA1 Message Date
Danielle Maywood 10c5189e31 fix(site): improve git panel drag performance on agents page
Dragging the right panel with a large diff was slow due to several
compounding re-render issues:

1. Every pointermove event fired multiple React state updates
   (setWidth, setDragSnap, onVisualExpandedChange) with no
   throttling, causing re-renders faster than the display refresh
   rate. Now wrapped in requestAnimationFrame.

2. onVisualExpandedChange fired on every pointer move even when
   the value hadn't changed, causing the entire AgentDetailView
   (including chat timeline) to re-render. Now only fires when
   the visual expanded state actually changes.

3. In FilesChangedPanel, getFileOptions(fileName) created a new
   options object with new closure references on every render for
   every file. The @pierre/diffs library's shallow-equality check
   (areOptionsEqual) saw new references and force-re-rendered every
   visible FileDiff's Shadow DOM content. Now uses a useMemo Map
   so each file gets a stable options reference.

4. ResizeObserver callbacks in DiffViewer and FilesChangedPanel
   called setContainerWidth synchronously, causing a secondary
   React re-render on every frame during drag. Now debounced
   with requestAnimationFrame.

5. LazyFileDiff was not memoized, so parent re-renders cascaded
   through to every visible diff. Now wrapped in React.memo in
   both DiffViewer and FilesChangedPanel.
2026-03-13 13:17:10 +00:00
4 changed files with 329 additions and 166 deletions
+57 -28
View File
@@ -2,7 +2,7 @@ import type { ChatDiffStatusResponse } from "api/api";
import type * as TypesGen from "api/typesGenerated";
import type { ModelSelectorOption } from "components/ai-elements";
import { ArchiveIcon } from "lucide-react";
import { type FC, type RefObject, useMemo, useState } from "react";
import { type FC, type RefObject, useCallback, useMemo, useState } from "react";
import type { UrlTransform } from "streamdown";
import { cn } from "utils/cn";
import { pageTitle } from "utils/page";
@@ -198,6 +198,56 @@ export const AgentDetailView: FC<AgentDetailViewProps> = ({
</title>
);
const handleToggleRightPanel = useCallback(
() => setIsRightPanelExpanded((prev) => !prev),
[],
);
const handleClosePanel = useCallback(
() => onSetShowSidebarPanel(false),
[onSetShowSidebarPanel],
);
const handleToggleSidebar = useCallback(
() => onSetShowSidebarPanel((prev) => !prev),
[onSetShowSidebarPanel],
);
const prTab = useMemo(
() => (prNumber && agentId ? { prNumber, chatId: agentId } : undefined),
[prNumber, agentId],
);
const gitPanelContent = useMemo(
() => (
<GitPanel
prTab={prTab}
repositories={gitWatcher.repositories}
onRefresh={gitWatcher.refresh}
onCommit={handleCommit}
isExpanded={visualExpanded}
remoteDiffStats={diffStatusData}
localDiffStats={localDiffStats}
chatInputRef={editing.chatInputRef}
/>
),
[
prTab,
gitWatcher.repositories,
gitWatcher.refresh,
handleCommit,
visualExpanded,
diffStatusData,
localDiffStats,
editing.chatInputRef,
],
);
const sidebarTabs = useMemo(
() => [{ id: "git", label: "Git", content: gitPanelContent }],
[gitPanelContent],
);
const shouldShowSidebar = showSidebarPanel;
return (
@@ -222,7 +272,7 @@ export const AgentDetailView: FC<AgentDetailViewProps> = ({
onOpenParentChat={(chatId) => onNavigateToChat(chatId)}
panel={{
showSidebarPanel,
onToggleSidebar: () => onSetShowSidebarPanel((prev) => !prev),
onToggleSidebar: handleToggleSidebar,
}}
workspace={{
canOpenEditors,
@@ -308,38 +358,17 @@ export const AgentDetailView: FC<AgentDetailViewProps> = ({
<RightPanel
isOpen={shouldShowSidebar}
isExpanded={isRightPanelExpanded}
onToggleExpanded={() => setIsRightPanelExpanded((prev) => !prev)}
onClose={() => onSetShowSidebarPanel(false)}
onToggleExpanded={handleToggleRightPanel}
onClose={handleClosePanel}
onVisualExpandedChange={setDragVisualExpanded}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
>
<SidebarTabView
tabs={[
{
id: "git",
label: "Git",
content: (
<GitPanel
prTab={
prNumber && agentId
? { prNumber, chatId: agentId }
: undefined
}
repositories={gitWatcher.repositories}
onRefresh={gitWatcher.refresh}
onCommit={handleCommit}
isExpanded={visualExpanded}
remoteDiffStats={diffStatusData}
localDiffStats={localDiffStats}
chatInputRef={editing.chatInputRef}
/>
),
},
]}
onClose={() => onSetShowSidebarPanel(false)}
tabs={sidebarTabs}
onClose={handleClosePanel}
isExpanded={visualExpanded}
onToggleExpanded={() => setIsRightPanelExpanded((prev) => !prev)}
onToggleExpanded={handleToggleRightPanel}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
chatTitle={chatTitle}
+22 -15
View File
@@ -14,6 +14,7 @@ import {
type ComponentProps,
type FC,
type ReactNode,
memo,
useCallback,
useEffect,
useMemo,
@@ -304,16 +305,13 @@ const FileTreeNodeView: FC<{
* FileDiff that the user has already scrolled past, which avoids
* layout shifts and repeated highlighting work.
*/
const LazyFileDiff: FC<{
const LazyFileDiff = memo<{
fileDiff: FileDiffMetadata;
options: ComponentProps<typeof FileDiff>["options"];
}> = ({ fileDiff, options }) => {
const placeholderRef = useRef<HTMLDivElement>(null);
}>(({ fileDiff, options }) => {
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = placeholderRef.current;
if (!el || visible) {
const placeholderRef = useCallback((el: HTMLDivElement | null) => {
if (!el) {
return;
}
const observer = new IntersectionObserver(
@@ -329,8 +327,7 @@ const LazyFileDiff: FC<{
{ rootMargin: "100% 0px" },
);
observer.observe(el);
return () => observer.disconnect();
}, [visible]);
}, []);
if (!visible) {
return (
@@ -350,7 +347,7 @@ const LazyFileDiff: FC<{
return (
<FileDiff fileDiff={fileDiff} options={options} style={DIFFS_FONT_STYLE} />
);
};
});
// -------------------------------------------------------------------
// Main component
@@ -424,7 +421,12 @@ export const DiffViewer: FC<DiffViewerProps> = ({
// whether to show the file tree sidebar without a prop from the
// parent.
// ---------------------------------------------------------------
const [containerWidth, setContainerWidth] = useState(0);
// Track whether the container is wide enough for the file tree
// sidebar. We only store the boolean threshold result so the
// ResizeObserver doesn't trigger React re-renders on every
// pixel of panel resize — only when the visibility actually
// needs to change.
const [isWideEnoughForTree, setIsWideEnoughForTree] = useState(false);
const roRef = useRef<ResizeObserver | null>(null);
const containerRef = useCallback((el: HTMLDivElement | null) => {
if (roRef.current) {
@@ -434,17 +436,18 @@ export const DiffViewer: FC<DiffViewerProps> = ({
if (!el) {
return;
}
setContainerWidth(el.getBoundingClientRect().width);
setIsWideEnoughForTree(
el.getBoundingClientRect().width >= FILE_TREE_THRESHOLD,
);
const ro = new ResizeObserver(([entry]) => {
setContainerWidth(entry.contentRect.width);
setIsWideEnoughForTree(entry.contentRect.width >= FILE_TREE_THRESHOLD);
});
ro.observe(el);
roRef.current = ro;
}, []);
const showTree =
(isExpanded || containerWidth >= FILE_TREE_THRESHOLD) &&
sortedFiles.length > 0;
(isExpanded || isWideEnoughForTree) && sortedFiles.length > 0;
// ---------------------------------------------------------------
// Refs for each file diff wrapper so we can scroll-to and track
@@ -631,6 +634,10 @@ export const DiffViewer: FC<DiffViewerProps> = ({
<div
key={fileDiff.name}
ref={(el) => setFileRef(fileDiff.name, el)}
style={{
contentVisibility: "auto",
containIntrinsicSize: `auto ${estimateDiffHeight(fileDiff)}px`,
}}
>
<LazyFileDiff fileDiff={fileDiff} options={fileOptions} />
</div>
+140 -112
View File
@@ -25,6 +25,7 @@ import {
type ComponentProps,
type FC,
type ReactNode,
memo,
useCallback,
useEffect,
useMemo,
@@ -68,6 +69,12 @@ const STICKY_HEADER_CSS = [
type DiffStyle = "unified" | "split";
const DIFF_STYLE_KEY = "agents.diff-view-style";
/**
* Shared empty array used for files without active annotations so
* that every render returns the same reference.
*/
const EMPTY_ANNOTATIONS: DiffLineAnnotation<string>[] = [];
/**
* Walk the parsed hunks for a file and collect code lines that fall
* within `startLine..endLine` on the given side. For "additions"
@@ -426,6 +433,7 @@ export const FilesChangedPanel: FC<FilesChangedPanelProps> = ({
}) => {
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
const [diffStyle, setDiffStyle] = useState<DiffStyle>(loadDiffStyle);
const handleSetDiffStyle = useCallback((style: DiffStyle) => {
setDiffStyle(style);
@@ -450,60 +458,6 @@ export const FilesChangedPanel: FC<FilesChangedPanelProps> = ({
};
}, [isDark, diffStyle]);
// Returns per-file diff options that include a line-number click
// handler scoped to the given file name.
const getFileOptions = useCallback(
(fileName: string) => ({
...diffOptions,
overflow: "wrap" as const,
enableLineSelection: true,
enableHoverUtility: true,
onLineNumberClick(props: {
lineNumber: number;
annotationSide: "additions" | "deletions";
}) {
setActiveCommentBox({
fileName,
startLine: props.lineNumber,
endLine: props.lineNumber,
side: props.annotationSide,
});
},
onLineSelected(
range: {
start: number;
end: number;
side?: "additions" | "deletions";
} | null,
) {
if (!range || range.start === range.end) return;
const side = range.side ?? "additions";
setActiveCommentBox({
fileName,
startLine: Math.min(range.start, range.end),
endLine: Math.max(range.start, range.end),
side,
});
},
}),
[diffOptions],
);
const getAnnotationsForFile = useCallback(
(fileName: string): DiffLineAnnotation<string>[] => {
if (activeCommentBox && activeCommentBox.fileName === fileName) {
return [
{
side: activeCommentBox.side,
lineNumber: activeCommentBox.startLine,
metadata: "active-input",
},
];
}
return [];
},
[activeCommentBox],
);
const handleCancelComment = useCallback(() => {
setActiveCommentBox(null);
}, []);
@@ -602,6 +556,72 @@ export const FilesChangedPanel: FC<FilesChangedPanelProps> = ({
);
}, [fileTree, parsedFiles]);
// Pre-build a stable options object for every file so that the
// diff viewer's shallow-equality check (areOptionsEqual) does
// not force expensive Shadow DOM re-renders on every React
// render cycle.
const fileOptionsMap = useMemo(() => {
const map = new Map<string, ComponentProps<typeof FileDiff>["options"]>();
for (const file of sortedFiles) {
const fileName = file.name;
map.set(fileName, {
...diffOptions,
overflow: "wrap" as const,
enableLineSelection: true,
enableHoverUtility: true,
onLineNumberClick(props: {
lineNumber: number;
annotationSide: "additions" | "deletions";
}) {
setActiveCommentBox({
fileName,
startLine: props.lineNumber,
endLine: props.lineNumber,
side: props.annotationSide,
});
},
onLineSelected(
range: {
start: number;
end: number;
side?: "additions" | "deletions";
} | null,
) {
if (!range || range.start === range.end) return;
const side = range.side ?? "additions";
setActiveCommentBox({
fileName,
startLine: Math.min(range.start, range.end),
endLine: Math.max(range.start, range.end),
side,
});
},
});
}
return map;
}, [diffOptions, sortedFiles]);
// Pre-build annotation arrays for every file. Files without an
// active comment box share the module-level EMPTY_ANNOTATIONS
// constant so their reference stays stable across renders.
const annotationsMap = useMemo(() => {
const map = new Map<string, DiffLineAnnotation<string>[]>();
for (const file of sortedFiles) {
if (activeCommentBox && activeCommentBox.fileName === file.name) {
map.set(file.name, [
{
side: activeCommentBox.side,
lineNumber: activeCommentBox.startLine,
metadata: "active-input",
},
]);
} else {
map.set(file.name, EMPTY_ANNOTATIONS);
}
}
return map;
}, [activeCommentBox, sortedFiles]);
const pullRequestUrl = diffStatusQuery.data?.url;
const parsedPr = pullRequestUrl ? parsePullRequestUrl(pullRequestUrl) : null;
@@ -610,7 +630,12 @@ export const FilesChangedPanel: FC<FilesChangedPanelProps> = ({
// whether to show the file tree sidebar without a prop from the
// parent.
// ---------------------------------------------------------------
const [containerWidth, setContainerWidth] = useState(0);
// Track whether the container is wide enough for the file tree
// sidebar. We only store the boolean threshold result so the
// ResizeObserver doesn't trigger React re-renders on every
// pixel of panel resize — only when the visibility actually
// needs to change.
const [isWideEnoughForTree, setIsWideEnoughForTree] = useState(false);
const roRef = useRef<ResizeObserver | null>(null);
const containerRef = useCallback((el: HTMLDivElement | null) => {
if (roRef.current) {
@@ -620,17 +645,18 @@ export const FilesChangedPanel: FC<FilesChangedPanelProps> = ({
if (!el) {
return;
}
setContainerWidth(el.getBoundingClientRect().width);
setIsWideEnoughForTree(
el.getBoundingClientRect().width >= FILE_TREE_THRESHOLD,
);
const ro = new ResizeObserver(([entry]) => {
setContainerWidth(entry.contentRect.width);
setIsWideEnoughForTree(entry.contentRect.width >= FILE_TREE_THRESHOLD);
});
ro.observe(el);
roRef.current = ro;
}, []);
const showTree =
(isExpanded || containerWidth >= FILE_TREE_THRESHOLD) &&
sortedFiles.length > 0;
(isExpanded || isWideEnoughForTree) && sortedFiles.length > 0;
// ---------------------------------------------------------------
// Refs for each file diff wrapper so we can scroll-to and track
@@ -884,11 +910,15 @@ export const FilesChangedPanel: FC<FilesChangedPanelProps> = ({
<div
key={fileDiff.name}
ref={(el) => setFileRef(fileDiff.name, el)}
style={{
contentVisibility: "auto",
containIntrinsicSize: `auto ${estimateDiffHeight(fileDiff)}px`,
}}
>
<LazyFileDiff
fileDiff={fileDiff}
options={getFileOptions(fileDiff.name)}
lineAnnotations={getAnnotationsForFile(fileDiff.name)}
options={fileOptionsMap.get(fileDiff.name)}
lineAnnotations={annotationsMap.get(fileDiff.name)}
renderAnnotation={renderAnnotation}
/>
</div>
@@ -931,63 +961,61 @@ function estimateDiffHeight(fileDiff: FileDiffMetadata): number {
* FileDiff that the user has already scrolled past, which avoids
* layout shifts and repeated highlighting work.
*/
const LazyFileDiff: FC<{
const LazyFileDiff = memo<{
fileDiff: FileDiffMetadata;
options: ComponentProps<typeof FileDiff>["options"];
lineAnnotations?: DiffLineAnnotation<string>[];
renderAnnotation?: (annotation: DiffLineAnnotation<string>) => ReactNode;
}> = ({
fileDiff,
options,
lineAnnotations,
renderAnnotation: renderAnnotationProp,
}) => {
const placeholderRef = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
}>(
({
fileDiff,
options,
lineAnnotations,
renderAnnotation: renderAnnotationProp,
}) => {
const [visible, setVisible] = useState(false);
const placeholderRef = useCallback((el: HTMLDivElement | null) => {
if (!el) {
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
// Pre-load files that are within one viewport-height of
// the visible area so they are ready before the user
// scrolls to them.
{ rootMargin: "100% 0px" },
);
observer.observe(el);
}, []);
useEffect(() => {
const el = placeholderRef.current;
if (!el || visible) {
return;
if (!visible) {
return (
<div
ref={placeholderRef}
style={{ height: estimateDiffHeight(fileDiff) }}
className="p-4 space-y-2"
>
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-3/4" />
</div>
);
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
// Pre-load files that are within one viewport-height of
// the visible area so they are ready before the user
// scrolls to them.
{ rootMargin: "100% 0px" },
);
observer.observe(el);
return () => observer.disconnect();
}, [visible]);
if (!visible) {
return (
<div
ref={placeholderRef}
style={{ height: estimateDiffHeight(fileDiff) }}
className="p-4 space-y-2"
>
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-3/4" />
</div>
<FileDiff
fileDiff={fileDiff}
options={options}
style={DIFFS_FONT_STYLE}
lineAnnotations={lineAnnotations}
renderAnnotation={renderAnnotationProp}
/>
);
}
return (
<FileDiff
fileDiff={fileDiff}
options={options}
style={DIFFS_FONT_STYLE}
lineAnnotations={lineAnnotations}
renderAnnotation={renderAnnotationProp}
/>
);
};
},
);
+110 -11
View File
@@ -1,6 +1,7 @@
import {
type ReactNode,
type PointerEvent as ReactPointerEvent,
type RefObject,
useCallback,
useEffect,
useRef,
@@ -56,10 +57,13 @@ interface RightPanelProps {
* refs, pointer handlers, snap state, and visual state
* derivation.
*/
function useResizableDrag({
isExpanded,
width,
setWidth,
panelRef,
contentRef,
isOpen,
onSnapCommit,
onVisualExpandedChange,
@@ -69,6 +73,8 @@ function useResizableDrag({
isExpanded: boolean;
width: number;
setWidth: React.Dispatch<React.SetStateAction<number>>;
panelRef: RefObject<HTMLDivElement | null>;
contentRef: RefObject<HTMLDivElement | null>;
isOpen: boolean;
onSnapCommit: (snap: "normal" | "expanded" | "closed") => void;
onVisualExpandedChange?: (visualExpanded: boolean | null) => void;
@@ -79,27 +85,70 @@ function useResizableDrag({
const startX = useRef(0);
const startWidth = useRef(0);
const sidebarCollapsedByDrag = useRef(false);
// Live width tracked via ref during drag so we can update the
// DOM directly without triggering React re-renders on every
// pointer-move. Committed to React state on pointer-up.
const liveWidthRef = useRef(width);
liveWidthRef.current = width;
// 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);
// Mirror of dragSnap used to avoid redundant setState calls
// (same string) and to read the latest snap in handlePointerUp
// without a stale closure.
const dragSnapRef = useRef<"normal" | "expanded" | "closed" | null>(null);
// Pending animation frame id so we can cancel stale frames.
const rafId = useRef<number | null>(null);
// Track the last visual-expanded value we reported so we only
// notify the parent when the value actually changes.
const lastVisualExpanded = useRef<boolean | undefined>(undefined);
const handlePointerDown = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
e.preventDefault();
isDragging.current = true;
setDragSnap(null);
sidebarCollapsedByDrag.current = false;
lastVisualExpanded.current = false;
// Pre-set the snap zone to "normal" so the first
// pointer-move doesn't trigger a wasted React
// re-render (the derived visualExpanded and
// visualOpen values are identical for null vs
// "normal" when the panel is not expanded).
dragSnapRef.current = "normal";
if (isExpanded) {
setDragSnap("normal");
}
startX.current = e.clientX;
startWidth.current = isExpanded
? ((e.target as HTMLElement).closest(
"[data-testid='agents-right-panel']",
)?.parentElement?.clientWidth ?? getMaxWidth())
: width;
: liveWidthRef.current;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
// Freeze content width so the expensive diff content doesn't
// reflow on every pointer-move. The panel edge moves but the
// inner content stays fixed. A single reflow happens on
// pointer-up when we release the constraints.
const content = contentRef.current;
if (content) {
const w = `${content.offsetWidth}px`;
content.style.minWidth = w;
content.style.maxWidth = w;
}
if (panelRef.current) {
panelRef.current.style.overflow = "hidden";
}
},
[width, isExpanded],
[isExpanded, panelRef, contentRef],
);
const handlePointerMove = useCallback(
@@ -129,25 +178,48 @@ function useResizableDrag({
}
let nextSnap: "normal" | "expanded" | "closed";
let clampedWidth: number | null = null;
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)));
clampedWidth = Math.min(maxWidth, Math.max(MIN_WIDTH, raw));
}
// Update the panel width directly on the DOM element so
// we avoid a React re-render on every pointer-move. The
// width is committed to React state once on pointer-up.
if (clampedWidth !== null) {
liveWidthRef.current = clampedWidth;
panelRef.current?.style.setProperty(
"--panel-width",
`${clampedWidth}px`,
);
} // Only trigger React re-renders when the snap zone
// changes (normal ↔ expanded ↔ closed). Within the
// "normal" zone every pixel of movement is handled
// purely via the DOM mutation above.
if (nextSnap !== dragSnapRef.current) {
dragSnapRef.current = nextSnap;
setDragSnap(nextSnap);
}
setDragSnap(nextSnap);
// Notify parent of the live visual expanded state so
// sibling content reacts during the drag.
// sibling content reacts during the drag, but only
// when the value actually changes to avoid unnecessary
// parent re-renders.
const nextVisualExpanded =
nextSnap === "expanded" ||
(nextSnap !== "normal" && nextSnap !== "closed" && isExpanded);
onVisualExpandedChange?.(nextVisualExpanded);
if (nextVisualExpanded !== lastVisualExpanded.current) {
lastVisualExpanded.current = nextVisualExpanded;
onVisualExpandedChange?.(nextVisualExpanded);
}
},
[
setWidth,
panelRef,
isExpanded,
onVisualExpandedChange,
isSidebarCollapsed,
@@ -160,11 +232,31 @@ function useResizableDrag({
if (!isDragging.current) {
return;
}
const snap = dragSnap;
// Cancel any outstanding animation frame from the drag.
if (rafId.current !== null) {
cancelAnimationFrame(rafId.current);
rafId.current = null;
}
const snap = dragSnapRef.current;
isDragging.current = false;
dragSnapRef.current = null;
setDragSnap(null);
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
// Unfreeze content so it reflows once to the final width.
const content = contentRef.current;
if (content) {
content.style.minWidth = "";
content.style.maxWidth = "";
}
if (panelRef.current) {
panelRef.current.style.overflow = "";
}
// Commit the live width to React state so it persists
// and is available for subsequent non-drag renders.
setWidth(liveWidthRef.current);
// Clear the drag override so parent falls back to its
// own committed expanded state.
onVisualExpandedChange?.(null);
@@ -173,7 +265,7 @@ function useResizableDrag({
onSnapCommit(snap);
}
},
[dragSnap, onSnapCommit, onVisualExpandedChange],
[onSnapCommit, onVisualExpandedChange, setWidth, panelRef, contentRef],
);
// Derive visual state: during a drag the snap overrides the
@@ -205,6 +297,8 @@ export const RightPanel = ({
children,
}: RightPanelProps) => {
const [width, setWidth] = useState(loadPersistedWidth);
const panelRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
// Clamp width when the viewport shrinks so the panel
// doesn't overflow and take over the whole page.
@@ -243,6 +337,8 @@ export const RightPanel = ({
isExpanded,
width,
setWidth,
panelRef,
contentRef,
isOpen,
onSnapCommit: handleSnapCommit,
onVisualExpandedChange,
@@ -258,6 +354,7 @@ export const RightPanel = ({
return (
<div
ref={panelRef}
data-testid="agents-right-panel"
style={
visualOpen && !visualExpanded
@@ -285,7 +382,9 @@ export const RightPanel = ({
visualExpanded && "-left-1",
)}
/>
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
<div ref={contentRef} className="flex min-h-0 flex-1 flex-col">
{children}
</div>
</div>
);
};