Skip to content
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 @@ -197,6 +198,7 @@
const EMPTY_PROVIDERS: ServerProvider[] = [];
const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];
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 @@ -1780,7 +1782,7 @@
);

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

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

View workflow job for this annotation

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

react-hooks(exhaustive-deps)

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

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

View workflow job for this annotation

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

react-hooks(exhaustive-deps)

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

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

View workflow job for this annotation

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

react-hooks(exhaustive-deps)

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

const command = resolveShortcutCommand(event, keybindings, {
Expand Down Expand Up @@ -3019,7 +3021,7 @@
};
});
promptRef.current = "";
composerRef.current?.resetCursorState({ cursor: 0 });

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

View workflow job for this annotation

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

react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
},
[activePendingProgress?.activeQuestion, activePendingUserInput],
);
Expand All @@ -3046,7 +3048,7 @@
),
},
}));
const snapshot = composerRef.current?.readSnapshot();

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

View workflow job for this annotation

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

react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
if (
snapshot?.value !== value ||
snapshot.cursor !== nextCursor ||
Expand Down Expand Up @@ -3109,7 +3111,7 @@
return;
}

const sendCtx = composerRef.current?.getSendContext();

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

View workflow job for this annotation

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

react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
if (!sendCtx) {
return;
}
Expand Down Expand Up @@ -3246,7 +3248,7 @@
return;
}

const sendCtx = composerRef.current?.getSendContext();

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

View workflow job for this annotation

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

react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
if (!sendCtx) {
return;
}
Expand Down Expand Up @@ -3724,17 +3726,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
174 changes: 174 additions & 0 deletions apps/web/src/components/ResizableRightPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import * as Schema from "effect/Schema";
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;
let bodyResizeStyleOwner: symbol | null = null;

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

function readStoredRatio(storageKey: string | undefined) {
if (!storageKey) return DEFAULT_RATIO;
try {
const storedRatio = getLocalStorageItem(storageKey, Schema.Finite);
return storedRatio === null ? DEFAULT_RATIO : clampRatio(storedRatio);
} catch (error) {
console.error("[LOCALSTORAGE] Error:", error);
return DEFAULT_RATIO;
}
}
Comment thread
cursor[bot] marked this conversation as resolved.

const applyBodyResizeStyles = (owner: symbol) => {
bodyResizeStyleOwner = owner;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
};

const clearBodyResizeStyles = (owner: symbol) => {
if (bodyResizeStyleOwner !== owner) return;
document.body.style.removeProperty("cursor");
document.body.style.removeProperty("user-select");
bodyResizeStyleOwner = null;
};

export function ResizableRightPanel({
children,
className,
storageKey,
}: {
children: ReactNode;
className?: string;
storageKey?: string;
}) {
const [widthRatio, setWidthRatio] = useState(() => readStoredRatio(storageKey));
const resizeOwnerRef = useRef(Symbol("ResizableRightPanel"));
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);
}
clearBodyResizeStyles(resizeOwnerRef.current);
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);
applyBodyResizeStyles(resizeOwnerRef.current);
}, []);

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(() => {
const resizeOwner = resizeOwnerRef.current;
return () => {
const resizeState = resizeStateRef.current;
if (resizeState?.frameId !== null && resizeState?.frameId !== undefined) {
window.cancelAnimationFrame(resizeState.frameId);
}
clearBodyResizeStyles(resizeOwner);
};
}, []);
Comment thread
cursor[bot] marked this conversation as resolved.

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>
);
}
Loading
Loading