Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 15 additions & 11 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@
} from "~/rpc/serverState";
import { sanitizeThreadErrorMessage } from "~/rpc/transportError";
import { retainThreadDetailSubscription } from "../environments/runtime/service";
import { ResizableRightPanel } from "./ResizableRightPanel";
import { RightPanelSheet } from "./RightPanelSheet";
import { Button } from "./ui/button";
import {
Expand All @@ -196,6 +197,7 @@
const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = [];
const EMPTY_PROVIDERS: ServerProvider[] = [];
const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
const PLAN_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_plan_sidebar_width_ratio";
type EnvironmentUnavailableState = {
readonly environmentId: EnvironmentId;
readonly label: string;
Expand Down Expand Up @@ -1779,7 +1781,7 @@
);

const focusComposer = useCallback(() => {
composerRef.current?.focusAtEnd();

Check warning on line 1784 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint-plugin-react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
}, []);
const scheduleComposerFocus = useCallback(() => {
window.requestAnimationFrame(() => {
Expand All @@ -1787,7 +1789,7 @@
});
}, [focusComposer]);
const addTerminalContextToDraft = useCallback((selection: TerminalContextSelection) => {
composerRef.current?.addTerminalContext(selection);

Check warning on line 1792 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint-plugin-react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
}, []);
const setTerminalOpen = useCallback(
(open: boolean) => {
Expand Down Expand Up @@ -2466,7 +2468,7 @@
const shortcutContext = {
terminalFocus: isTerminalFocused(),
terminalOpen: Boolean(terminalState.terminalOpen),
modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false,

Check warning on line 2471 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint-plugin-react-hooks(exhaustive-deps)

React Hook useEffect has a missing dependency: 'composerRef.current'
};

const command = resolveShortcutCommand(event, keybindings, {
Expand Down Expand Up @@ -3722,17 +3724,19 @@

{/* Plan sidebar */}
{planSidebarOpen && !shouldUsePlanSidebarSheet ? (
<PlanSidebar
activePlan={activePlan}
activeProposedPlan={sidebarProposedPlan}
label={planSidebarLabel}
environmentId={environmentId}
markdownCwd={gitCwd ?? undefined}
workspaceRoot={activeWorkspaceRoot}
timestampFormat={timestampFormat}
mode="sidebar"
onClose={closePlanSidebar}
/>
<ResizableRightPanel storageKey={PLAN_INLINE_SIDEBAR_WIDTH_STORAGE_KEY}>
<PlanSidebar
activePlan={activePlan}
activeProposedPlan={sidebarProposedPlan}
label={planSidebarLabel}
environmentId={environmentId}
markdownCwd={gitCwd ?? undefined}
workspaceRoot={activeWorkspaceRoot}
timestampFormat={timestampFormat}
mode="sidebar"
onClose={closePlanSidebar}
/>
</ResizableRightPanel>
) : null}
</div>
{/* end horizontal flex container */}
Expand Down
4 changes: 1 addition & 3 deletions apps/web/src/components/PlanSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,7 @@ const PlanSidebar = memo(function PlanSidebar({
<div
className={cn(
"flex min-h-0 flex-col bg-card/50",
mode === "sidebar"
? "h-full w-[340px] shrink-0 border-l border-border/70"
: "h-full w-full",
mode === "sidebar" ? "h-full w-full border-l border-border/70" : "h-full w-full",
)}
>
{/* Header */}
Expand Down
156 changes: 156 additions & 0 deletions apps/web/src/components/ResizableRightPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { Schema } from "effect";
import {
type PointerEvent as ReactPointerEvent,
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react";

import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage";
import { cn } from "~/lib/utils";

const DEFAULT_RATIO = 0.4;
const MIN_RATIO = 0.3;
const MAX_RATIO = 0.8;

const clampRatio = (ratio: number) => Math.max(MIN_RATIO, Math.min(ratio, MAX_RATIO));

function readStoredRatio(storageKey: string | undefined) {
if (!storageKey) return DEFAULT_RATIO;
const storedRatio = getLocalStorageItem(storageKey, Schema.Finite);
return storedRatio === null ? DEFAULT_RATIO : clampRatio(storedRatio);
}

export function ResizableRightPanel({
children,
className,
storageKey,
}: {
children: ReactNode;
className?: string;
storageKey?: string;
}) {
const [widthRatio, setWidthRatio] = useState(() => readStoredRatio(storageKey));
const panelRef = useRef<HTMLDivElement | null>(null);
const widthRatioRef = useRef(widthRatio);
const resizeStateRef = useRef<{
frameId: number | null;
handle: HTMLDivElement;
panel: HTMLDivElement;
pointerId: number;
startWidth: number;
startX: number;
} | null>(null);

const commitWidthRatio = useCallback((ratio: number) => {
widthRatioRef.current = ratio;
setWidthRatio(ratio);
}, []);

const stopResize = useCallback(
(pointerId: number) => {
const resizeState = resizeStateRef.current;
if (!resizeState) return;
if (resizeState.frameId !== null) {
window.cancelAnimationFrame(resizeState.frameId);
}
if (resizeState.handle.hasPointerCapture(pointerId)) {
resizeState.handle.releasePointerCapture(pointerId);
}
document.body.style.removeProperty("cursor");
document.body.style.removeProperty("user-select");
resizeStateRef.current = null;
if (storageKey) {
setLocalStorageItem(storageKey, widthRatioRef.current, Schema.Finite);
}
},
[storageKey],
);

const handlePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
const panel = panelRef.current;
if (!panel) return;

event.preventDefault();
event.stopPropagation();
resizeStateRef.current = {
frameId: null,
handle: event.currentTarget,
panel,
pointerId: event.pointerId,
startWidth: panel.getBoundingClientRect().width,
startX: event.clientX,
};
event.currentTarget.setPointerCapture(event.pointerId);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
}, []);

const handlePointerMove = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
const resizeState = resizeStateRef.current;
if (!resizeState || resizeState.pointerId !== event.pointerId) return;

event.preventDefault();
if (resizeState.frameId !== null) return;

const clientX = event.clientX;
resizeState.frameId = window.requestAnimationFrame(() => {
const activeResizeState = resizeStateRef.current;
if (!activeResizeState) return;

activeResizeState.frameId = null;
const containerWidth = activeResizeState.panel.parentElement?.clientWidth ?? 0;
if (containerWidth <= 0) return;

const nextWidth = activeResizeState.startWidth + activeResizeState.startX - clientX;
commitWidthRatio(clampRatio(nextWidth / containerWidth));
});
},
[commitWidthRatio],
);

const handlePointerUp = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
const resizeState = resizeStateRef.current;
if (!resizeState || resizeState.pointerId !== event.pointerId) return;
stopResize(event.pointerId);
},
[stopResize],
);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

useEffect(() => {
return () => {
const resizeState = resizeStateRef.current;
if (resizeState?.frameId !== null && resizeState?.frameId !== undefined) {
window.cancelAnimationFrame(resizeState.frameId);
}
document.body.style.removeProperty("cursor");
document.body.style.removeProperty("user-select");
};
}, []);

return (
<div
className={cn("relative min-h-0 shrink-0", className)}
ref={panelRef}
style={{ width: `${widthRatio * 100}%` }}
>
<div
aria-label="Resize right panel"
className="absolute inset-y-0 left-0 z-20 w-4 -translate-x-1/2 cursor-col-resize touch-none after:absolute after:inset-y-0 after:left-1/2 after:w-px hover:after:bg-border"
onPointerCancel={handlePointerUp}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
role="separator"
tabIndex={-1}
title="Drag to resize right panel"
/>
{children}
</div>
);
}
132 changes: 15 additions & 117 deletions apps/web/src/routes/_chat.$environmentId.$threadId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,18 @@ import {
type DiffPanelMode,
} from "../components/DiffPanelShell";
import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore";
import {
type DiffRouteSearch,
parseDiffRouteSearch,
stripDiffSearchParams,
} from "../diffRouteSearch";
import { type DiffRouteSearch, parseDiffRouteSearch } from "../diffRouteSearch";
import { useMediaQuery } from "../hooks/useMediaQuery";
import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout";
import { selectEnvironmentState, selectThreadExistsByRef, useStore } from "../store";
import { createThreadSelectorByRef } from "../storeSelectors";
import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes";
import { ResizableRightPanel } from "../components/ResizableRightPanel";
import { RightPanelSheet } from "../components/RightPanelSheet";
import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar";
import { SidebarInset } from "~/components/ui/sidebar";

const DiffPanel = lazy(() => import("../components/DiffPanel"));
const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width";
const DIFF_INLINE_DEFAULT_WIDTH = "clamp(24rem,34vw,36rem)";
const DIFF_INLINE_SIDEBAR_MIN_WIDTH = 22 * 16;
const DIFF_INLINE_SIDEBAR_MAX_WIDTH = 256 * 16;
const COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX = 208;
const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width_ratio";

const DiffLoadingFallback = (props: { mode: DiffPanelMode }) => {
return (
Expand All @@ -49,92 +42,17 @@ const LazyDiffPanel = (props: { mode: DiffPanelMode }) => {
);
};

const DiffPanelInlineSidebar = (props: {
diffOpen: boolean;
onCloseDiff: () => void;
onOpenDiff: () => void;
renderDiffContent: boolean;
Comment thread
cursor[bot] marked this conversation as resolved.
}) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline diff panel loses "keep mounted" optimization on close

Medium Severity

The refactored DiffPanelInlineSidebar returns null when !diffOpen, fully unmounting the diff panel on every close. The old code used renderDiffContent (backed by shouldRenderDiffContent = diffOpen || hasOpenedDiff) to keep the content mounted even when the sidebar was visually hidden, preserving scroll position, view state, and the DiffWorkerPoolProvider worker pool. Now shouldRenderDiffContent is computed but unused in the inline path (it's only used in the sheet path at line 171), and toggling the diff panel repeatedly tears down and recreates the worker pool and component tree.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8a193e5. Configure here.

const { diffOpen, onCloseDiff, onOpenDiff, renderDiffContent } = props;
const onOpenChange = useCallback(
(open: boolean) => {
if (open) {
onOpenDiff();
return;
}
onCloseDiff();
},
[onCloseDiff, onOpenDiff],
);
const shouldAcceptInlineSidebarWidth = useCallback(
({ nextWidth, wrapper }: { nextWidth: number; wrapper: HTMLElement }) => {
const composerForm = document.querySelector<HTMLElement>("[data-chat-composer-form='true']");
if (!composerForm) return true;
const composerViewport = composerForm.parentElement;
if (!composerViewport) return true;
const previousSidebarWidth = wrapper.style.getPropertyValue("--sidebar-width");
wrapper.style.setProperty("--sidebar-width", `${nextWidth}px`);

const viewportStyle = window.getComputedStyle(composerViewport);
const viewportPaddingLeft = Number.parseFloat(viewportStyle.paddingLeft) || 0;
const viewportPaddingRight = Number.parseFloat(viewportStyle.paddingRight) || 0;
const viewportContentWidth = Math.max(
0,
composerViewport.clientWidth - viewportPaddingLeft - viewportPaddingRight,
);
const formRect = composerForm.getBoundingClientRect();
const composerFooter = composerForm.querySelector<HTMLElement>(
"[data-chat-composer-footer='true']",
);
const composerRightActions = composerForm.querySelector<HTMLElement>(
"[data-chat-composer-actions='right']",
);
const composerRightActionsWidth = composerRightActions?.getBoundingClientRect().width ?? 0;
const composerFooterGap = composerFooter
? Number.parseFloat(window.getComputedStyle(composerFooter).columnGap) ||
Number.parseFloat(window.getComputedStyle(composerFooter).gap) ||
0
: 0;
const minimumComposerWidth =
COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX + composerRightActionsWidth + composerFooterGap;
const hasComposerOverflow = composerForm.scrollWidth > composerForm.clientWidth + 0.5;
const overflowsViewport = formRect.width > viewportContentWidth + 0.5;
const violatesMinimumComposerWidth = composerForm.clientWidth + 0.5 < minimumComposerWidth;

if (previousSidebarWidth.length > 0) {
wrapper.style.setProperty("--sidebar-width", previousSidebarWidth);
} else {
wrapper.style.removeProperty("--sidebar-width");
}

return !hasComposerOverflow && !overflowsViewport && !violatesMinimumComposerWidth;
},
[],
);
const DiffPanelInlineSidebar = (props: { diffOpen: boolean }) => {
const { diffOpen } = props;
if (!diffOpen) return null;

return (
<SidebarProvider
defaultOpen={false}
open={diffOpen}
onOpenChange={onOpenChange}
className="w-auto min-h-0 flex-none bg-transparent"
style={{ "--sidebar-width": DIFF_INLINE_DEFAULT_WIDTH } as React.CSSProperties}
<ResizableRightPanel
className="border-l border-border bg-card text-foreground"
storageKey={DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY}
>
<Sidebar
side="right"
collapsible="offcanvas"
className="border-l border-border bg-card text-foreground"
resizable={{
maxWidth: DIFF_INLINE_SIDEBAR_MAX_WIDTH,
minWidth: DIFF_INLINE_SIDEBAR_MIN_WIDTH,
shouldAcceptWidth: shouldAcceptInlineSidebarWidth,
storageKey: DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY,
}}
>
{renderDiffContent ? <LazyDiffPanel mode="sidebar" /> : null}
<SidebarRail />
</Sidebar>
</SidebarProvider>
<LazyDiffPanel mode="sidebar" />
</ResizableRightPanel>
Comment thread
cursor[bot] marked this conversation as resolved.
);
};

Expand Down Expand Up @@ -199,21 +117,6 @@ function ChatThreadRouteView() {
search: { diff: undefined },
});
}, [navigate, threadRef]);
const openDiff = useCallback(() => {
if (!threadRef) {
return;
}
markDiffOpened();
void navigate({
to: "/$environmentId/$threadId",
params: buildThreadRouteParams(threadRef),
search: (previous) => {
const rest = stripDiffSearchParams(previous);
return { ...rest, diff: "1" };
},
});
}, [markDiffOpened, navigate, threadRef]);

useEffect(() => {
if (!threadRef || !bootstrapComplete) {
return;
Expand All @@ -239,7 +142,7 @@ function ChatThreadRouteView() {

if (!shouldUseDiffSheet) {
return (
<>
<div className="flex min-h-0 min-w-0 flex-1">
<SidebarInset className="h-svh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground md:h-dvh">
<ChatView
environmentId={threadRef.environmentId}
Expand All @@ -249,13 +152,8 @@ function ChatThreadRouteView() {
routeKind="server"
/>
</SidebarInset>
<DiffPanelInlineSidebar
diffOpen={diffOpen}
onCloseDiff={closeDiff}
onOpenDiff={openDiff}
renderDiffContent={shouldRenderDiffContent}
/>
</>
<DiffPanelInlineSidebar diffOpen={diffOpen} />
</div>
);
}

Expand Down
Loading