Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10c5189e31 |
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user