diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 1963acc3bc..275b03118c 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -328,11 +328,17 @@ const overrides = new Map([ // new model-clear test. Load-bearing chimera fix. // keyring-dev-isolation: run_boot_migrations wires agent-key migration. ["src-tauri/src/migration.rs", 1436], + // Bounded overlay-scrollbar host wiring adds 13 lines to ChannelPane after + // focused-thread mode brought the upstream file to the default limit. The + // geometry and interaction logic remain extracted in the shared hook. + ["src/features/channels/ui/ChannelPane.tsx", 1013], // onMarkRead + isUnread prop threading (mirrors the onMarkUnread prop // already here) for the single-toggle mark-read/unread menu item — a small // overage from load-bearing per-message plumbing, not generic debt growth. + // +2 for the bounded overlay-scrollbar host wiring; shared geometry and + // interaction logic remain extracted outside this already-large panel. // Approved override; still queued to split with the rest of this list. - ["src/features/messages/ui/MessageThreadPanel.tsx", 1006], + ["src/features/messages/ui/MessageThreadPanel.tsx", 1008], // AgentConfigPanel footer fold into ProfileFieldGroup for the config-bridge // panel — a small overage from load-bearing UI plumbing, not generic debt // growth. Approved override; still queued to split with the rest of this list. diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index e14fcddfa3..2d8d0831e9 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -66,6 +66,7 @@ import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; import { channelChrome } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; +import { OverlayScrollbar } from "@/shared/ui/OverlayScrollbar"; export const ChannelPane = React.memo(function ChannelPane({ activeChannel, agentPubkeys, @@ -164,6 +165,12 @@ export const ChannelPane = React.memo(function ChannelPane({ const timelineScrollRef = React.useRef(null); const messageTimelineRef = React.useRef(null); const composerWrapperRef = React.useRef(null); + const [activeTimelineScrollElement, setActiveTimelineScrollElement] = + React.useState(null); + const activeTimelineScrollRef = React.useMemo( + () => ({ current: activeTimelineScrollElement }), + [activeTimelineScrollElement], + ); const completedWelcomeBannerChannelIdsRef = React.useRef(new Set()); const welcomeComposerDismissTimerRef = React.useRef(null); const welcomeComposerHideTimerRef = React.useRef(null); @@ -682,6 +689,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onEdit={onEdit} onMarkUnread={onMarkUnread} onMarkRead={onMarkRead} + onActiveScrollContainerChange={setActiveTimelineScrollElement} onReply={activeChannel?.archivedAt ? undefined : onOpenThread} channelName={activeChannel?.name} channelType={activeChannel?.channelType ?? null} @@ -702,6 +710,11 @@ export const ChannelPane = React.memo(function ChannelPane({ } threadUnreadCounts={threadUnreadCounts} /> + {isNonMemberView ? (
- {threadScrollRegion} +
+ {threadScrollRegion} + +
); } diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index b66c97619e..44ac9f73a7 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -67,6 +67,8 @@ type MessageTimelineProps = { /** Optional external ref to the scroll container — used by the parent to * observe scroll position or adjust padding dynamically. */ scrollContainerRef?: React.RefObject; + /** Reports the element that currently owns timeline scrolling. */ + onActiveScrollContainerChange?: (element: HTMLDivElement | null) => void; /** True when the timeline has the composer overlay below it. */ hasComposerOverlay?: boolean; isFetchingOlder?: boolean; @@ -177,6 +179,7 @@ const MessageTimelineBase = React.forwardRef< onMarkUnread, onMarkRead, onReply, + onActiveScrollContainerChange, channelName, channelType, isSendingVideoReviewComment = false, @@ -215,6 +218,9 @@ const MessageTimelineBase = React.forwardRef< }), [scrollContainerRef, virtualizerScrollParent], ); + React.useLayoutEffect(() => { + onActiveScrollContainerChange?.(activeScrollContainerRef.current); + }, [activeScrollContainerRef, onActiveScrollContainerChange]); // Gate the heavy timeline render (each row runs a synchronous // react-markdown parse) behind React concurrency. `useDeferredValue` lets the @@ -690,6 +696,7 @@ const MessageTimelineBase = React.forwardRef< (!useTimelineVirtualizer || !showMessageList) && cn( "overflow-y-auto overflow-x-hidden overscroll-contain px-2 pt-1", + "buzz-content-scrollbar", hasComposerOverlay ? "pb-[var(--composer-overlay-height,6rem)]" : "pb-4", diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 5d3cf8fd5d..306a1eb656 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -699,7 +699,7 @@ function VirtualizedTimelineRows({ { + for (const releaseOrder of ["first-first", "second-first"]) { + const style = { userSelect: "text" }; + const releaseFirstDrag = acquireBodySelectionLock(style); + const releaseSecondDrag = acquireBodySelectionLock(style); + + assert.equal(style.userSelect, "none"); + + if (releaseOrder === "first-first") { + releaseFirstDrag(); + assert.equal(style.userSelect, "none"); + releaseSecondDrag(); + } else { + releaseSecondDrag(); + assert.equal(style.userSelect, "none"); + releaseFirstDrag(); + } + + assert.equal(style.userSelect, "text"); + } +}); + +test("selection lock release is idempotent across finish and cleanup", () => { + const style = { userSelect: "contain" }; + const releaseDrag = acquireBodySelectionLock(style); + + releaseDrag(); + releaseDrag(); + + assert.equal(style.userSelect, "contain"); + + const releaseNextDrag = acquireBodySelectionLock(style); + assert.equal(style.userSelect, "none"); + releaseNextDrag(); + assert.equal(style.userSelect, "contain"); +}); + +test("bounds the thumb above the composer at maximum scroll", () => { + for (const bottomInset of [64, 96, 180]) { + const clientHeight = 600; + const scrollHeight = 3_000; + const geometry = calculateOverlayScrollbarGeometry({ + bottomInset, + clientHeight, + scrollHeight, + scrollTop: scrollHeight - clientHeight, + }); + + assert.ok(geometry); + assert.equal(geometry.trackHeight, clientHeight - bottomInset); + assert.equal( + geometry.thumbOffset + geometry.thumbHeight, + geometry.trackHeight, + ); + } +}); + +test("uses a minimum grab target without exceeding the bounded track", () => { + const geometry = calculateOverlayScrollbarGeometry({ + bottomInset: 120, + clientHeight: 400, + scrollHeight: 40_000, + scrollTop: 20_000, + }); + + assert.ok(geometry); + assert.equal(geometry.thumbHeight, 24); + assert.ok(geometry.thumbOffset >= 0); + assert.ok( + geometry.thumbOffset + geometry.thumbHeight <= geometry.trackHeight, + ); +}); + +test("clamps overscrolled positions into the available thumb travel", () => { + const beforeStart = calculateOverlayScrollbarGeometry({ + bottomInset: 100, + clientHeight: 500, + scrollHeight: 2_000, + scrollTop: -50, + }); + const afterEnd = calculateOverlayScrollbarGeometry({ + bottomInset: 100, + clientHeight: 500, + scrollHeight: 2_000, + scrollTop: 2_000, + }); + + assert.ok(beforeStart); + assert.ok(afterEnd); + assert.equal(beforeStart.thumbOffset, 0); + assert.equal(afterEnd.thumbOffset, afterEnd.maxThumbOffset); +}); + +test("hides when content does not overflow or the visible track is too small", () => { + assert.equal( + calculateOverlayScrollbarGeometry({ + bottomInset: 100, + clientHeight: 500, + scrollHeight: 500, + scrollTop: 0, + }), + null, + ); + assert.equal( + calculateOverlayScrollbarGeometry({ + bottomInset: 480, + clientHeight: 500, + scrollHeight: 2_000, + scrollTop: 0, + }), + null, + ); +}); + +test("maps a full bounded-thumb drag across the full scroll range", () => { + const geometry = calculateOverlayScrollbarGeometry({ + bottomInset: 120, + clientHeight: 600, + scrollHeight: 3_000, + scrollTop: 0, + }); + + assert.ok(geometry); + assert.equal( + calculateScrollTopFromThumbDrag({ + deltaY: geometry.maxThumbOffset, + dragStartScrollTop: 0, + maxThumbOffset: geometry.maxThumbOffset, + scrollRange: geometry.scrollRange, + }), + geometry.scrollRange, + ); +}); + +test("clamps thumb drags at both ends of the scroll range", () => { + assert.equal( + calculateScrollTopFromThumbDrag({ + deltaY: -1_000, + dragStartScrollTop: 300, + maxThumbOffset: 200, + scrollRange: 1_500, + }), + 0, + ); + assert.equal( + calculateScrollTopFromThumbDrag({ + deltaY: 1_000, + dragStartScrollTop: 300, + maxThumbOffset: 200, + scrollRange: 1_500, + }), + 1_500, + ); +}); diff --git a/desktop/src/shared/hooks/useOverlayScrollbar.ts b/desktop/src/shared/hooks/useOverlayScrollbar.ts new file mode 100644 index 0000000000..c66e634363 --- /dev/null +++ b/desktop/src/shared/hooks/useOverlayScrollbar.ts @@ -0,0 +1,284 @@ +import * as React from "react"; + +const MIN_THUMB_HEIGHT = 24; +const IDLE_FADE_DELAY_MS = 700; + +type UserSelectStyle = { + userSelect: string; +}; + +let bodySelectionLockCount = 0; +let bodySelectionLockStyle: UserSelectStyle | null = null; +let bodySelectionPreviousValue = ""; + +/** + * Disables body text selection until every overlapping scrollbar drag releases + * its lock. The returned release function is safe to call more than once. + */ +export function acquireBodySelectionLock( + style: UserSelectStyle = document.body.style, +): () => void { + if (bodySelectionLockCount === 0) { + bodySelectionLockStyle = style; + bodySelectionPreviousValue = style.userSelect; + style.userSelect = "none"; + } + bodySelectionLockCount += 1; + + let isReleased = false; + return () => { + if (isReleased) return; + isReleased = true; + bodySelectionLockCount -= 1; + + if (bodySelectionLockCount === 0) { + if (bodySelectionLockStyle) { + bodySelectionLockStyle.userSelect = bodySelectionPreviousValue; + } + bodySelectionLockStyle = null; + bodySelectionPreviousValue = ""; + } + }; +} + +export type OverlayScrollbarGeometry = { + maxThumbOffset: number; + scrollRange: number; + thumbHeight: number; + thumbOffset: number; + trackHeight: number; +}; + +type OverlayScrollbarGeometryInput = { + bottomInset: number; + clientHeight: number; + scrollHeight: number; + scrollTop: number; +}; + +type OverlayScrollbarDragInput = { + deltaY: number; + dragStartScrollTop: number; + maxThumbOffset: number; + scrollRange: number; +}; + +export function calculateOverlayScrollbarGeometry({ + bottomInset, + clientHeight, + scrollHeight, + scrollTop, +}: OverlayScrollbarGeometryInput): OverlayScrollbarGeometry | null { + const scrollRange = scrollHeight - clientHeight; + const trackHeight = clientHeight - Math.max(0, bottomInset); + if (scrollRange <= 0 || trackHeight <= MIN_THUMB_HEIGHT) { + return null; + } + + const thumbHeight = Math.min( + trackHeight, + Math.max(MIN_THUMB_HEIGHT, (clientHeight / scrollHeight) * trackHeight), + ); + const maxThumbOffset = trackHeight - thumbHeight; + const clampedScrollTop = Math.min(Math.max(scrollTop, 0), scrollRange); + const thumbOffset = (clampedScrollTop / scrollRange) * maxThumbOffset; + + return { + maxThumbOffset, + scrollRange, + thumbHeight, + thumbOffset, + trackHeight, + }; +} + +export function calculateScrollTopFromThumbDrag({ + deltaY, + dragStartScrollTop, + maxThumbOffset, + scrollRange, +}: OverlayScrollbarDragInput): number { + if (maxThumbOffset <= 0 || scrollRange <= 0) { + return Math.min(Math.max(dragStartScrollTop, 0), Math.max(scrollRange, 0)); + } + + const scrollDelta = (deltaY / maxThumbOffset) * scrollRange; + return Math.min(Math.max(dragStartScrollTop + scrollDelta, 0), scrollRange); +} + +type UseOverlayScrollbarOptions = { + composerRef: React.RefObject; + resetKey?: unknown; + scrollRef: React.RefObject; + thumbRef: React.RefObject; +}; + +export function useOverlayScrollbar({ + composerRef, + resetKey, + scrollRef, + thumbRef, +}: UseOverlayScrollbarOptions) { + React.useEffect(() => { + void resetKey; + const scrollElement = scrollRef.current; + const thumbElement = thumbRef.current; + if (!scrollElement || !thumbElement) return; + + let fadeTimer: ReturnType | null = null; + let isDragging = false; + let isHovering = false; + let dragPointerId: number | null = null; + let dragStartY = 0; + let dragStartScrollTop = 0; + let releaseBodySelectionLock: (() => void) | null = null; + + const clearFadeTimer = () => { + if (fadeTimer !== null) { + globalThis.clearTimeout(fadeTimer); + fadeTimer = null; + } + }; + + const hideThumb = () => { + clearFadeTimer(); + thumbElement.style.opacity = "0"; + thumbElement.style.pointerEvents = "none"; + }; + + const updateGeometry = (): OverlayScrollbarGeometry | null => { + const geometry = calculateOverlayScrollbarGeometry({ + bottomInset: composerRef.current?.getBoundingClientRect().height ?? 0, + clientHeight: scrollElement.clientHeight, + scrollHeight: scrollElement.scrollHeight, + scrollTop: scrollElement.scrollTop, + }); + if (!geometry) { + hideThumb(); + return null; + } + + thumbElement.style.height = `${geometry.thumbHeight}px`; + thumbElement.style.transform = `translateY(${geometry.thumbOffset}px)`; + thumbElement.style.pointerEvents = "auto"; + return geometry; + }; + + const scheduleFade = () => { + clearFadeTimer(); + if (isDragging || isHovering) return; + fadeTimer = globalThis.setTimeout(() => { + thumbElement.style.opacity = "0"; + fadeTimer = null; + }, IDLE_FADE_DELAY_MS); + }; + + const showThumb = () => { + if (!updateGeometry()) return; + thumbElement.style.opacity = "1"; + scheduleFade(); + }; + + const restoreSelection = () => { + releaseBodySelectionLock?.(); + releaseBodySelectionLock = null; + }; + + const finishDrag = (pointerId: number) => { + if (!isDragging || pointerId !== dragPointerId) return; + isDragging = false; + dragPointerId = null; + if (thumbElement.hasPointerCapture(pointerId)) { + thumbElement.releasePointerCapture(pointerId); + } + restoreSelection(); + scheduleFade(); + }; + + const handleScroll = () => showThumb(); + const handlePointerEnter = () => { + isHovering = true; + clearFadeTimer(); + showThumb(); + }; + const handlePointerLeave = () => { + isHovering = false; + scheduleFade(); + }; + const handlePointerDown = (event: PointerEvent) => { + if (isDragging || event.button !== 0 || !updateGeometry()) return; + event.preventDefault(); + event.stopPropagation(); + isDragging = true; + dragPointerId = event.pointerId; + dragStartY = event.clientY; + dragStartScrollTop = scrollElement.scrollTop; + releaseBodySelectionLock = acquireBodySelectionLock(); + thumbElement.setPointerCapture(event.pointerId); + clearFadeTimer(); + thumbElement.style.opacity = "1"; + }; + const handlePointerMove = (event: PointerEvent) => { + if (!isDragging || event.pointerId !== dragPointerId) return; + const geometry = updateGeometry(); + if (!geometry || geometry.maxThumbOffset <= 0) return; + scrollElement.scrollTop = calculateScrollTopFromThumbDrag({ + deltaY: event.clientY - dragStartY, + dragStartScrollTop, + maxThumbOffset: geometry.maxThumbOffset, + scrollRange: geometry.scrollRange, + }); + thumbElement.style.opacity = "1"; + }; + const handlePointerUp = (event: PointerEvent) => + finishDrag(event.pointerId); + const handleLostPointerCapture = (event: PointerEvent) => + finishDrag(event.pointerId); + + scrollElement.addEventListener("scroll", handleScroll, { passive: true }); + // Match the sidebar scrollbar: entering anywhere in the scrollable pane + // reveals the thumb, so a short thumb does not need to be found while it + // is transparent. + scrollElement.addEventListener("pointerenter", handlePointerEnter); + scrollElement.addEventListener("pointerleave", handlePointerLeave); + thumbElement.addEventListener("pointerenter", handlePointerEnter); + thumbElement.addEventListener("pointerleave", handlePointerLeave); + thumbElement.addEventListener("pointerdown", handlePointerDown); + thumbElement.addEventListener("pointermove", handlePointerMove); + thumbElement.addEventListener("pointerup", handlePointerUp); + thumbElement.addEventListener("pointercancel", handlePointerUp); + thumbElement.addEventListener( + "lostpointercapture", + handleLostPointerCapture, + ); + + const resizeObserver = new ResizeObserver(showThumb); + resizeObserver.observe(scrollElement); + // Virtua owns measurement of its resizing inner content. Observing that + // same node here disrupts its prepend-anchor delivery order. + if (composerRef.current) { + resizeObserver.observe(composerRef.current); + } + + showThumb(); + + return () => { + clearFadeTimer(); + resizeObserver.disconnect(); + scrollElement.removeEventListener("scroll", handleScroll); + scrollElement.removeEventListener("pointerenter", handlePointerEnter); + scrollElement.removeEventListener("pointerleave", handlePointerLeave); + thumbElement.removeEventListener("pointerenter", handlePointerEnter); + thumbElement.removeEventListener("pointerleave", handlePointerLeave); + thumbElement.removeEventListener("pointerdown", handlePointerDown); + thumbElement.removeEventListener("pointermove", handlePointerMove); + thumbElement.removeEventListener("pointerup", handlePointerUp); + thumbElement.removeEventListener("pointercancel", handlePointerUp); + thumbElement.removeEventListener( + "lostpointercapture", + handleLostPointerCapture, + ); + restoreSelection(); + }; + }, [composerRef, resetKey, scrollRef, thumbRef]); +} diff --git a/desktop/src/shared/ui/OverlayScrollbar.tsx b/desktop/src/shared/ui/OverlayScrollbar.tsx new file mode 100644 index 0000000000..a10a31b561 --- /dev/null +++ b/desktop/src/shared/ui/OverlayScrollbar.tsx @@ -0,0 +1,27 @@ +import * as React from "react"; + +import { useOverlayScrollbar } from "@/shared/hooks/useOverlayScrollbar"; + +type OverlayScrollbarProps = { + composerRef: React.RefObject; + resetKey?: unknown; + scrollRef: React.RefObject; +}; + +export function OverlayScrollbar({ + composerRef, + resetKey, + scrollRef, +}: OverlayScrollbarProps) { + const thumbRef = React.useRef(null); + useOverlayScrollbar({ composerRef, resetKey, scrollRef, thumbRef }); + + return ( +