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
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);
}
Comment thread
cursor[bot] marked this conversation as resolved.

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");
};
}, []);
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>
);
}
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.
}) => {
Comment thread
cursor[bot] marked this conversation as resolved.
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