-
Notifications
You must be signed in to change notification settings - Fork 2.1k
Add resizable inline right panels #2512
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
3e32677
a7d7c20
fdb9ff5
f77acbc
385b32c
8a193e5
e03ecba
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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], | ||
| ); | ||
|
|
||
| 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> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 ( | ||
|
|
@@ -49,92 +42,17 @@ const LazyDiffPanel = (props: { mode: DiffPanelMode }) => { | |
| ); | ||
| }; | ||
|
|
||
| const DiffPanelInlineSidebar = (props: { | ||
| diffOpen: boolean; | ||
| onCloseDiff: () => void; | ||
| onOpenDiff: () => void; | ||
| renderDiffContent: boolean; | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| }) => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Inline diff panel loses "keep mounted" optimization on closeMedium Severity The refactored Additional Locations (1)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> | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| ); | ||
| }; | ||
|
|
||
|
|
@@ -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; | ||
|
|
@@ -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} | ||
|
|
@@ -249,13 +152,8 @@ function ChatThreadRouteView() { | |
| routeKind="server" | ||
| /> | ||
| </SidebarInset> | ||
| <DiffPanelInlineSidebar | ||
| diffOpen={diffOpen} | ||
| onCloseDiff={closeDiff} | ||
| onOpenDiff={openDiff} | ||
| renderDiffContent={shouldRenderDiffContent} | ||
| /> | ||
| </> | ||
| <DiffPanelInlineSidebar diffOpen={diffOpen} /> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
|
|
||


Uh oh!
There was an error while loading. Please reload this page.