Compare commits
5 Commits
main
...
perf/diff-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
986d6a856d | ||
|
|
351ab6c7c7 | ||
|
|
d3d0ea3622 | ||
|
|
c0b71a1161 | ||
|
|
828c9e23f5 |
@@ -428,3 +428,85 @@ export const NotFoundSidebarCollapsed: Story = {
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stress-test story: large chat + huge diff
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate a unified diff string with the given number of changed
|
||||
* files, each containing `linesPerFile` added lines. Repeats a
|
||||
* simple pattern so the story code stays small.
|
||||
*/
|
||||
function generateHugeDiff(fileCount: number, linesPerFile: number): string {
|
||||
const chunks: string[] = [];
|
||||
for (let f = 0; f < fileCount; f++) {
|
||||
const path = `src/components/Module${f}/index.tsx`;
|
||||
chunks.push(
|
||||
`diff --git a/${path} b/${path}`,
|
||||
"index 0000000..1111111 100644",
|
||||
`--- a/${path}`,
|
||||
`+++ b/${path}`,
|
||||
`@@ -1,0 +1,${linesPerFile} @@`,
|
||||
);
|
||||
for (let l = 0; l < linesPerFile; l++) {
|
||||
chunks.push(
|
||||
`+// Line ${l + 1}: ${path} — filler to stress-test rendering performance`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return chunks.join("\n");
|
||||
}
|
||||
|
||||
const STRESS_FILE_COUNT = 50;
|
||||
const STRESS_LINES_PER_FILE = 200; // 50 × 200 = 10 000 changed lines
|
||||
const STRESS_TOTAL_ADDITIONS = STRESS_FILE_COUNT * STRESS_LINES_PER_FILE;
|
||||
|
||||
/** Build a long chat history with alternating user/assistant messages. */
|
||||
function generateLongChatHistory(messageCount: number): TypesGen.ChatMessage[] {
|
||||
const msgs: TypesGen.ChatMessage[] = [];
|
||||
for (let i = 0; i < messageCount; i++) {
|
||||
const role: TypesGen.ChatMessageRole = i % 2 === 0 ? "user" : "assistant";
|
||||
const text =
|
||||
role === "user"
|
||||
? `Request #${Math.floor(i / 2) + 1}: Please refactor the next batch of modules.`
|
||||
: `Done! I've updated modules ${Math.floor(i / 2) * 5}-${Math.floor(i / 2) * 5 + 4} with the new patterns. Here's a summary of the changes:\n\n- Extracted shared hooks\n- Replaced class components with functional components\n- Added proper TypeScript types\n- Cleaned up unused imports\n- Added unit tests for critical paths`;
|
||||
msgs.push(buildMessage(i + 1, role, text));
|
||||
}
|
||||
return msgs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stress-test story: 100 chat messages + 10 000-line diff with
|
||||
* the sidebar panel open. Use this to manually verify that
|
||||
* drag-resizing and scrolling feel smooth.
|
||||
*/
|
||||
export const StressLargeDiff: Story = {
|
||||
args: {
|
||||
store: buildStoreWithMessages(generateLongChatHistory(100)),
|
||||
showSidebarPanel: true,
|
||||
prNumber: 456,
|
||||
hasMoreMessages: false,
|
||||
isFetchingMoreMessages: false,
|
||||
onFetchMoreMessages: fn(),
|
||||
diffStatusData: {
|
||||
chat_id: AGENT_ID,
|
||||
url: "https://github.com/coder/coder/pull/456",
|
||||
pull_request_title: "refactor: massive module extraction",
|
||||
pull_request_state: "open",
|
||||
pull_request_draft: false,
|
||||
changes_requested: false,
|
||||
additions: STRESS_TOTAL_ADDITIONS,
|
||||
deletions: 0,
|
||||
changed_files: STRESS_FILE_COUNT,
|
||||
base_branch: "main",
|
||||
head_branch: "refactor/module-extraction",
|
||||
} satisfies ChatDiffStatus,
|
||||
},
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getChatDiffContents").mockResolvedValue({
|
||||
chat_id: AGENT_ID,
|
||||
diff: generateHugeDiff(STRESS_FILE_COUNT, STRESS_LINES_PER_FILE),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -727,6 +727,13 @@ export const DiffViewer: FC<DiffViewerProps> = ({
|
||||
<div
|
||||
key={fileDiff.name}
|
||||
ref={(el) => setFileRef(fileDiff.name, el)}
|
||||
style={{
|
||||
// Allow the browser to skip layout/paint for
|
||||
// off-screen file diffs, dramatically reducing
|
||||
// the work during panel resize or scroll.
|
||||
contentVisibility: "auto",
|
||||
containIntrinsicSize: `auto ${estimateDiffHeight(fileDiff)}px`,
|
||||
}}
|
||||
>
|
||||
<LazyFileDiff
|
||||
fileDiff={fileDiff}
|
||||
@@ -740,8 +747,6 @@ export const DiffViewer: FC<DiffViewerProps> = ({
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{/* Spacer so the last file can scroll fully to the top. */}
|
||||
<div className="h-[calc(100vh-100px)]" />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
@@ -55,6 +55,12 @@ interface RightPanelProps {
|
||||
* Encapsulates all drag/resize logic for the right panel:
|
||||
* refs, pointer handlers, snap state, and visual state
|
||||
* derivation.
|
||||
*
|
||||
* During a drag the panel width is applied directly to the DOM
|
||||
* via the panelRef, bypassing React state. This avoids
|
||||
* re-rendering the entire diff subtree on every pointermove.
|
||||
* React state is only committed on pointerup so localStorage
|
||||
* persistence and non-drag renders stay correct.
|
||||
*/
|
||||
function useResizableDrag({
|
||||
isExpanded,
|
||||
@@ -75,10 +81,23 @@ function useResizableDrag({
|
||||
isSidebarCollapsed?: boolean;
|
||||
onToggleSidebarCollapsed?: () => void;
|
||||
}) {
|
||||
const isDragging = useRef(false);
|
||||
const isDraggingRef = useRef(false);
|
||||
const startX = useRef(0);
|
||||
const startWidth = useRef(0);
|
||||
const sidebarCollapsedByDrag = useRef(false);
|
||||
|
||||
// Live width tracked in a ref so we can read/write it during
|
||||
// drag without triggering React re-renders. Synced from React
|
||||
// state when not dragging (e.g. after window-resize clamp).
|
||||
const liveWidthRef = useRef(width);
|
||||
if (!isDraggingRef.current) {
|
||||
liveWidthRef.current = width;
|
||||
}
|
||||
|
||||
// Ref to the panel DOM element for direct style mutations
|
||||
// during drag, avoiding React re-renders.
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 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<
|
||||
@@ -88,7 +107,7 @@ function useResizableDrag({
|
||||
const handlePointerDown = useCallback(
|
||||
(e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
isDragging.current = true;
|
||||
isDraggingRef.current = true;
|
||||
setDragSnap(null);
|
||||
sidebarCollapsedByDrag.current = false;
|
||||
startX.current = e.clientX;
|
||||
@@ -96,15 +115,15 @@ function useResizableDrag({
|
||||
? ((e.target as HTMLElement).closest(
|
||||
"[data-testid='agents-right-panel']",
|
||||
)?.parentElement?.clientWidth ?? getMaxWidth())
|
||||
: width;
|
||||
: liveWidthRef.current;
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
},
|
||||
[width, isExpanded],
|
||||
[isExpanded],
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!isDragging.current) {
|
||||
if (!isDraggingRef.current) {
|
||||
return;
|
||||
}
|
||||
const delta = startX.current - e.clientX;
|
||||
@@ -135,7 +154,12 @@ function useResizableDrag({
|
||||
nextSnap = "closed";
|
||||
} else {
|
||||
nextSnap = "normal";
|
||||
setWidth(Math.min(maxWidth, Math.max(MIN_WIDTH, raw)));
|
||||
const clamped = Math.min(maxWidth, Math.max(MIN_WIDTH, raw));
|
||||
liveWidthRef.current = clamped;
|
||||
// Mutate the DOM directly instead of calling
|
||||
// setWidth() — this skips React reconciliation
|
||||
// for the entire panel subtree on every frame.
|
||||
panelRef.current?.style.setProperty("--panel-width", `${clamped}px`);
|
||||
}
|
||||
setDragSnap(nextSnap);
|
||||
|
||||
@@ -147,7 +171,6 @@ function useResizableDrag({
|
||||
onVisualExpandedChange?.(nextVisualExpanded);
|
||||
},
|
||||
[
|
||||
setWidth,
|
||||
isExpanded,
|
||||
onVisualExpandedChange,
|
||||
isSidebarCollapsed,
|
||||
@@ -157,11 +180,11 @@ function useResizableDrag({
|
||||
|
||||
const handlePointerUp = useCallback(
|
||||
(e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!isDragging.current) {
|
||||
if (!isDraggingRef.current) {
|
||||
return;
|
||||
}
|
||||
const snap = dragSnap;
|
||||
isDragging.current = false;
|
||||
isDraggingRef.current = false;
|
||||
setDragSnap(null);
|
||||
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
|
||||
@@ -169,11 +192,15 @@ function useResizableDrag({
|
||||
// own committed expanded state.
|
||||
onVisualExpandedChange?.(null);
|
||||
|
||||
// Commit the final width to React state so it persists
|
||||
// to localStorage and is used for the next render.
|
||||
setWidth(liveWidthRef.current);
|
||||
|
||||
if (snap) {
|
||||
onSnapCommit(snap);
|
||||
}
|
||||
},
|
||||
[dragSnap, onSnapCommit, onVisualExpandedChange],
|
||||
[dragSnap, onSnapCommit, onVisualExpandedChange, setWidth],
|
||||
);
|
||||
|
||||
// Derive visual state: during a drag the snap overrides the
|
||||
@@ -185,9 +212,14 @@ function useResizableDrag({
|
||||
dragSnap !== "closed" &&
|
||||
(dragSnap === "expanded" || dragSnap === "normal" || isOpen);
|
||||
|
||||
// Dragging is active whenever a snap override is in effect.
|
||||
const isDragging = dragSnap !== null;
|
||||
|
||||
return {
|
||||
visualExpanded,
|
||||
visualOpen,
|
||||
isDragging,
|
||||
panelRef,
|
||||
handlePointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
@@ -236,6 +268,8 @@ export const RightPanel = ({
|
||||
const {
|
||||
visualExpanded,
|
||||
visualOpen,
|
||||
isDragging,
|
||||
panelRef,
|
||||
handlePointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
@@ -258,6 +292,7 @@ export const RightPanel = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
data-testid="agents-right-panel"
|
||||
style={
|
||||
visualOpen && !visualExpanded
|
||||
@@ -285,7 +320,18 @@ export const RightPanel = ({
|
||||
visualExpanded && "-left-1",
|
||||
)}
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
|
||||
<div
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
style={{
|
||||
// During drag-resize, disable pointer events on children
|
||||
// to skip expensive hit-testing on Shadow DOM diff
|
||||
// elements, and isolate this subtree from layout recalc.
|
||||
contain: "layout style paint",
|
||||
pointerEvents: isDragging ? "none" : undefined,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -325,6 +325,11 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
|
||||
effectiveTabId ? `${tabIdPrefix}-tab-${effectiveTabId}` : undefined
|
||||
}
|
||||
className="min-h-0 flex-1"
|
||||
style={{
|
||||
// Isolate this subtree so the browser doesn't need to
|
||||
// re-check external layout when the panel resizes.
|
||||
contain: "layout style paint",
|
||||
}}
|
||||
>
|
||||
{effectiveTabId === "desktop" && desktopChatId ? (
|
||||
<DesktopPanel
|
||||
|
||||
Reference in New Issue
Block a user