diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6b84aa11ca6..53f451afc89 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -181,6 +181,7 @@ import { } 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 { @@ -197,6 +198,7 @@ const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; +const PLAN_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_plan_sidebar_width_ratio"; type EnvironmentUnavailableState = { readonly environmentId: EnvironmentId; readonly label: string; @@ -3724,17 +3726,19 @@ export default function ChatView(props: ChatViewProps) { {/* Plan sidebar */} {planSidebarOpen && !shouldUsePlanSidebarSheet ? ( - + + + ) : null} {/* end horizontal flex container */} diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index afd4bb2e0bc..d68e4c7c433 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -130,9 +130,7 @@ const PlanSidebar = memo(function PlanSidebar({
{/* Header */} diff --git a/apps/web/src/components/ResizableRightPanel.tsx b/apps/web/src/components/ResizableRightPanel.tsx new file mode 100644 index 00000000000..63668a5fbcf --- /dev/null +++ b/apps/web/src/components/ResizableRightPanel.tsx @@ -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; + } +} + +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(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) => { + 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) => { + 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) => { + const resizeState = resizeStateRef.current; + if (!resizeState || resizeState.pointerId !== event.pointerId) return; + stopResize(event.pointerId); + }, + [stopResize], + ); + + useEffect(() => { + const resizeOwner = resizeOwnerRef.current; + return () => { + const resizeState = resizeStateRef.current; + if (resizeState?.frameId !== null && resizeState?.frameId !== undefined) { + window.cancelAnimationFrame(resizeState.frameId); + } + clearBodyResizeStyles(resizeOwner); + }; + }, []); + + return ( +
+
+ {children} +
+ ); +} diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index 1877fee6f7d..e4240a287a9 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -11,25 +11,19 @@ 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"; +import { cn } from "~/lib/utils"; 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 ( @@ -49,92 +43,17 @@ const LazyDiffPanel = (props: { mode: DiffPanelMode }) => { ); }; -const DiffPanelInlineSidebar = (props: { - diffOpen: boolean; - onCloseDiff: () => void; - onOpenDiff: () => void; - renderDiffContent: boolean; -}) => { - 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("[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( - "[data-chat-composer-footer='true']", - ); - const composerRightActions = composerForm.querySelector( - "[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; renderDiffContent: boolean }) => { + const { diffOpen, renderDiffContent } = props; + if (!renderDiffContent) return null; return ( - - - {renderDiffContent ? : null} - - - + + ); }; @@ -199,21 +118,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; @@ -239,7 +143,7 @@ function ChatThreadRouteView() { if (!shouldUseDiffSheet) { return ( - <> +
- - + +
); }