Skip to content

Commit 57495ec

Browse files
authored
feat: Zed-style overlay scrollbars (#509)
* feat(ui): Zed-style overlay scrollbars for plan mode Wide, translucent, full-length overlay scrollbars replacing the 6px WebKit rail that users couldn't reliably grab. Click the track to page-animate toward the click, drag the thumb, no layout shift, Firefox parity. Wraps plan-mode scroll containers (main viewer, annotation panel, sidebar, settings, export modal) in a new <OverlayScrollArea> component backed by overlayscrollbars-react. The library handles pointer capture, click-to-jump, hover reveal, auto-hide, RTL, touch, momentum, and cross-browser consistency. ClickScrollPlugin registered explicitly so `clickScroll: true` actually pages (otherwise it silently no-ops). Plumbing: - New ScrollViewportContext + useScrollViewport() hook so descendants (TableOfContents, PinpointOverlay, Viewer sticky observer) can reach the real scrolling element instead of document.querySelector('main'), which no longer returns the scroll node after wrapping. - New useOverlayViewport() hook — canonical ref+state+callback pattern bridging the library viewport into components that need both imperative access and effect re-runs when the viewport mounts. - useActiveSection gains an optional scrollElement arg so its IntersectionObserver root re-attaches when the viewport becomes available (ref mutations don't retrigger effects). Theme: - New .os-theme-plannotator tokens sourced from existing theme CSS variables (translucent muted-foreground for handle, transparent track at rest). 10px at rest, 14px on hover. Color + width transitions only — deliberately does not reintroduce transform/opacity in global transitions, preserving the abc952f scroll-jank fix. - Firefox `scrollbar-width: thin` + `scrollbar-color` fallback for unwrapped surfaces (micro-scrollers, future dockview). - print.css hides .os-scrollbar during print. ResizeHandle (#354 regression guard): - `side="right"` touch area retuned from `-right-3 left-0` to `-right-3 left-3` to clear the 14px hover scrollbar. Load-bearing comment added explicitly warning against simplification because #354 has already regressed twice. Intentionally left native: max-h-24 inline scrollers (EditorAnnotationCard, AgentsTab, ThemeTab), dropdown menus — a 14px overlay scrollbar would dominate those UI elements. Fixes #354 Follow-up to #359, #465 (both fixed #354, which kept regressing) Preserves #253 bidirectional annotation scroll Preserves #452 file-switch reset (plan mode portion) For provenance purposes, this commit was AI assisted. * feat(review): overlay scrollbars for file tree, sidebar, and PR panels Wrap the trivial code-review scroll containers in <OverlayScrollArea>: FileTree, ReviewSidebar content area, AITab chat history, PRCommentsTab timeline, and the ReviewPRSummary / ReviewPRChecks dock panels. None of these components read scrollTop / scrollHeight / scrollLeft directly — all descendant queries use containerRef.current.querySelector and all scroll-to-target calls use element.scrollIntoView, which walks up to the nearest scrollable ancestor (now the library viewport). No plumbing changes required. AITab was originally scoped to Commit B but an audit of its scroll effects (jump-to-question + auto-scroll-to-bottom) confirmed it only uses descendant scrollIntoView, so it ships here. DiffViewer and LiveLogViewer follow in a separate commit — they programmatically read/write scrollTop and need explicit viewport plumbing via useOverlayViewport. For provenance purposes, this commit was AI assisted. * feat(review): overlay scrollbars for diff viewer and live logs Wrap DiffViewer's main scroll container and LiveLogViewer's log pane in <OverlayScrollArea>, plumbing containerRef through useOverlayViewport so imperative scroll reads/writes and IntersectionObserver roots land on the real library viewport, not the OverlayScrollArea host. DiffViewer: - `previousScrollFilePathRef` guard added: the file-switch reset (#452) now only advances the tracking ref once the scroll actually executed, closing a race where a file switch landing before the library viewport attached would leave the ref stale while the scrollTo silently no-oped. - `viewport` added to every effect dep that reads containerRef.current (file-switch reset, annotation scroll, search-highlight apply + swap, scroll-to-match) so they re-run when the viewport mounts. Without this they'd silently no-op on first paint. - Split-view sync via @pierre/diffs unaffected — its ScrollSyncManager attaches scroll listeners to its own internal codeDeletions / codeAdditions elements inside the content, not the outer scroller. LiveLogViewer: - React onScroll replaced with a native addEventListener('scroll', ...) inside a useEffect keyed on `viewport` because React's onScroll doesn't bubble across the library's wrapper layers. - Follow-tail heuristic (`scrollHeight - scrollTop - clientHeight < 40`) and the auto-scroll-to-bottom assignment both preserved verbatim — only the ref target changed from the raw div to the library viewport. Preserves #452 file-switch reset Preserves #253 bidirectional annotation scroll (diff side) For provenance purposes, this commit was AI assisted. * fix(ui): address PR #509 review — viewport delivery and resize handle Two P1 bugs from the review, plus four correctness/cleanup items. P1: OverlayScrollbars viewport was never delivered to consumers. handleOsRef was reading osInstance() synchronously from the React ref callback, but `defer: true` queues the library's initialization for a later frame — at ref-callback time, the internal instance ref is still null. With a stable useCallback the ref never re-fires, so onViewportReady was never called with a real viewport. Every consumer of useOverlayViewport/useScrollViewport stayed permanently null: useActiveSection, TableOfContents.handleNavigate, Viewer sticky detection, PinpointOverlay, DiffViewer file-switch reset, LiveLogViewer follow-tail — all silently no-ops. Fix: deliver the viewport via the library's own `events.initialized` and `events.destroyed` callbacks, which fire exactly when elements are ready and when they're torn down. `getViewport()` prefers the tracked viewport ref over the imperative osInstance() path so late callers still work. P1: right-side ResizeHandle touch area was 0px wide. Touch area is an absolute-positioned child of a w-0 parent, so actual width = parent - left - right. With side='right' I'd set `left-3 -right-3`, which evaluates to `0 - 12 - (-12) = 0`. The annotation panel resize handle in plan mode and both right handles in code review had no draggable region. The visible 4px track has no event handlers, so the only affordance was cursor-style feedback — drag did nothing. Fix: revert to `left-0 -right-3` (12px wide, entirely right of the boundary, no left encroachment into the adjacent scrollbar zone — which was the original correct value before this branch). Rewrote the comment to explain the geometry trap instead of protecting the value that broke it. Additional fixes: - OverlayScrollArea: prefers-reduced-motion now reactive via useSyncExternalStore — OS toggle mid-session propagates to mounted instances instead of staying frozen at mount-time snapshot. - OverlayScrollArea: `ref as never` replaced with a narrow cast to `React.RefCallback<OverlayScrollbarsComponentRef<'div'>>` so future signature changes get type feedback. - PinpointOverlay: window resize listener moved above the scroll viewport guard so it attaches unconditionally. Scroll listener still requires the viewport (correct). Old code always registered resize on window; new code was accidentally skipping it when viewport was null. - TableOfContents: `className || default` changed to `className ?? default`. An explicit empty string from a caller (SidebarContainer passing className="" now that it wraps us in an OverlayScrollArea) should mean "no container styling", not "use the default". The old || operator treated "" as falsy and applied the default, leaving dead overflow-y-auto and unintended backdrop-blur on the nav. - useOverlayViewport: removed redundant double cast and `?? null` no-op in the ref setter. For provenance purposes, this commit was AI assisted. * fix(ui): print clipping with overlay scrollbar wrappers When <main> is wrapped in OverlayScrollArea, the library adds its own attribute-selector CSS rules: `[data-overlayscrollbars~="host"]` gets `overflow: hidden !important` and `[data-overlayscrollbars-viewport]` gets `overflow-x/y: hidden` (or scroll) with fixed viewport heights. These beat our existing `main { overflow: visible !important }` print override on specificity — attribute selectors outrank tag selectors even when both use `!important`. Result: long plans printed only the currently-visible viewport instead of flowing across pages. Fix: add a print-scoped override that targets the library's attribute selectors directly, setting overflow:visible, height:auto, max-height:none, and display:block to defeat both the overflow clip and the flex layout the library applies to host/padding wrappers. Verified by printing a multi-page plan in the dev server. For provenance purposes, this commit was AI assisted. * fix(ui): persistent overlay scrollbar, remove dead reduced-motion rule Switch autoHide from 'leave' to 'never'. The previous behavior faded the scrollbar 800ms after the pointer left the scroll host, which felt broken in a common interaction pattern: click a TOC entry (pointer in the sidebar) → trackpad-scroll (pointer still in sidebar) → 800ms idle → scrollbar disappears. User had to hover the right edge to bring it back on every interaction. Persistent visibility matches the pattern used by every editor-class technical app (VS Code, Zed, JetBrains, Xcode, Sublime) where the scrollbar is both a position indicator and a targeting surface for click-to-jump. Overlay scrollbars cost zero layout space, so "always visible" has no downside. Also removes the dead `.os-theme-plannotator .os-scrollbar` selector from the reduced-motion block. The library applies the theme class directly to the .os-scrollbar element itself (verified in the library runtime source, not just CSS), so a descendant selector with a space matches nothing. The track and handle selectors in the same block work correctly (they really are descendants) and are preserved. The component's own prefers-reduced-motion hook and helpers are now unused (autoHide is unconditionally 'never') and removed. Reduced motion is still honored for the hover color + width transitions on track/handle via the CSS @media (prefers-reduced-motion: reduce) block in theme.css, which the browser evaluates independently. For provenance purposes, this commit was AI assisted. * docs(ui): update OverlayScrollArea jsdoc to match persistent-scrollbar behavior The component header still described the old autoHide:'leave' behavior and implied the reduced-motion branch was in the component itself. Neither is true after cf137bf. Rewrite the jsdoc to describe the current behavior: always visible, no fade, reduced-motion handled via a CSS media query in theme.css rather than a React branch. For provenance purposes, this commit was AI assisted. * fix(ui): recompute scrollbar on content resize (pierre/diffs expand-lines) When pierre/diffs expanded context lines on a file whose diff previously fit inside the viewport, the scrollbar stayed hidden until the user manually scrolled or dragged the split-ratio handle to force a layout recalculation. Root cause: OverlayScrollbars' internal content observer doesn't see the mutation because pierre/diffs renders inside a shadow DOM, and MutationObserver does not pierce shadow DOM by default. The library's own host-level size observer doesn't help either — the host (our <main> / flex-1 container) has a fixed layout size that doesn't change when content grows. Fix: track the OverlayScrollbars instance in state and attach a ResizeObserver to the viewport's first element child in a useEffect. When the content's layout box grows — which happens even when the growth originates inside a shadow tree, because layout size propagates from shadow content to the shadow host — the observer fires and calls `instance.update(true)` to recompute scrollbar visibility. The call is debounced through requestAnimationFrame so the browser commits the new layout before OverlayScrollbars reads dimensions. Verified manually in the compiled binary against PR #509 itself: opening a small file, clicking pierre's expand-lines control, now reveals the scrollbar immediately. No regression observed in normal scroll / click-to-jump / file-switch / annotation-click paths. For provenance purposes, this commit was AI assisted. * docs(ui): document ResizeObserver content-resize mechanism Add the content-resize auto-recompute behavior to OverlayScrollArea's jsdoc header so the "what does this component do" summary is complete. The inline comment on the effect already explains the mechanism; this just surfaces it at the top. For provenance purposes, this commit was AI assisted.
1 parent 1e8a60b commit 57495ec

25 files changed

Lines changed: 535 additions & 73 deletions

bun.lock

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/editor/App.tsx

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ import { usePrintMode } from '@plannotator/ui/hooks/usePrintMode';
3737
import { modKey } from '@plannotator/ui/utils/platform';
3838
import { useResizablePanel } from '@plannotator/ui/hooks/useResizablePanel';
3939
import { ResizeHandle } from '@plannotator/ui/components/ResizeHandle';
40+
import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea';
41+
import { ScrollViewportContext } from '@plannotator/ui/hooks/useScrollViewport';
42+
import { useOverlayViewport } from '@plannotator/ui/hooks/useOverlayViewport';
4043
import { MobileMenu } from '@plannotator/ui/components/MobileMenu';
4144
import {
4245
getPermissionModeSettings,
@@ -126,7 +129,15 @@ const App: React.FC = () => {
126129
const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null);
127130

128131
const viewerRef = useRef<ViewerHandle>(null);
129-
const containerRef = useRef<HTMLElement>(null);
132+
// containerRef + scrollViewport both point at the OverlayScrollbars
133+
// viewport element (the node that actually scrolls), not the <main>
134+
// host. Consumers: useActiveSection (IntersectionObserver root) and
135+
// everything reading ScrollViewportContext.
136+
const {
137+
ref: containerRef,
138+
viewport: scrollViewport,
139+
onViewportReady: handleViewportReady,
140+
} = useOverlayViewport();
130141

131142
usePrintMode();
132143

@@ -383,7 +394,7 @@ const App: React.FC = () => {
383394

384395
// Track active section for TOC highlighting
385396
const headingCount = useMemo(() => blocks.filter(b => b.type === 'heading').length, [blocks]);
386-
const activeSection = useActiveSection(containerRef, headingCount);
397+
const activeSection = useActiveSection(containerRef, headingCount, scrollViewport);
387398

388399
const { editorAnnotations, deleteEditorAnnotation } = useEditorAnnotations();
389400
const { externalAnnotations, updateExternalAnnotation, deleteExternalAnnotation } = useExternalAnnotations<Annotation>({ enabled: isApiMode });
@@ -1514,6 +1525,7 @@ const App: React.FC = () => {
15141525
)}
15151526

15161527
{/* Main Content */}
1528+
<ScrollViewportContext.Provider value={scrollViewport}>
15171529
<div data-print-region="content" className={`flex-1 flex overflow-hidden relative z-0 ${isResizing ? 'select-none' : ''}`}>
15181530
{/* Tater sprites — inside content wrapper so z-0 stacking context applies */}
15191531
{taterMode && <TaterSpriteRunning />}
@@ -1586,7 +1598,12 @@ const App: React.FC = () => {
15861598
)}
15871599

15881600
{/* Document Area */}
1589-
<main data-print-region="document" ref={containerRef} className="flex-1 min-w-0 overflow-y-auto bg-grid">
1601+
<OverlayScrollArea
1602+
element="main"
1603+
className="flex-1 min-w-0 bg-grid"
1604+
data-print-region="document"
1605+
onViewportReady={handleViewportReady}
1606+
>
15901607
<ConfirmDialog
15911608
isOpen={!!draftBanner}
15921609
onClose={dismissDraft}
@@ -1699,7 +1716,7 @@ const App: React.FC = () => {
16991716
/>
17001717
</div>
17011718
</div>
1702-
</main>
1719+
</OverlayScrollArea>
17031720

17041721
{/* Resize Handle */}
17051722
{isPanelOpen && <ResizeHandle {...panelResize.handleProps} className="hidden md:block" side="right" />}
@@ -1726,6 +1743,7 @@ const App: React.FC = () => {
17261743
onOtherFileAnnotationsClick={handleFlashAnnotatedFiles}
17271744
/>
17281745
</div>
1746+
</ScrollViewportContext.Provider>
17291747

17301748
{/* Export Modal */}
17311749
<ExportModal

packages/review-editor/components/AITab.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { CopyButton } from './CopyButton';
99
import { PermissionCard } from './PermissionCard';
1010
import { AIConfigBar } from './AIConfigBar';
1111
import { submitHint } from '@plannotator/ui/utils/platform';
12+
import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea';
1213

1314
interface AIProviderInfo {
1415
id: string;
@@ -188,7 +189,8 @@ export const AITab: React.FC<AITabProps> = ({
188189

189190
return (
190191
<div className="flex flex-col h-full">
191-
<div ref={scrollRef} className="flex-1 overflow-y-auto p-2">
192+
<OverlayScrollArea className="flex-1 min-h-0">
193+
<div ref={scrollRef} className="p-2">
192194
{isCreatingSession && messages.length === 0 && (
193195
<div className="text-xs text-muted-foreground text-center py-4">
194196
<span className="ai-streaming-cursor" /> Starting AI session...
@@ -261,6 +263,7 @@ export const AITab: React.FC<AITabProps> = ({
261263
</div>
262264
)}
263265
</div>
266+
</OverlayScrollArea>
264267

265268
{/* Config bar */}
266269
<AIConfigBar

packages/review-editor/components/DiffViewer.tsx

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { storage } from '@plannotator/ui/utils/storage';
99
import { detectLanguage } from '../utils/detectLanguage';
1010
import { useAnnotationToolbar } from '../hooks/useAnnotationToolbar';
1111
import { useConfigValue } from '@plannotator/ui/config';
12+
import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea';
13+
import { useOverlayViewport } from '@plannotator/ui/hooks/useOverlayViewport';
1214
import { getEnabledLabels } from './ConventionalLabelPicker';
1315
import { FileHeader } from './FileHeader';
1416
import { InlineAnnotation } from './InlineAnnotation';
@@ -198,7 +200,11 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
198200
aiHistoryMessages = [],
199201
}) => {
200202
const { theme, colorTheme, resolvedMode } = useTheme();
201-
const containerRef = useRef<HTMLDivElement>(null);
203+
// containerRef must point at the actual scrolling element (the
204+
// OverlayScrollbars viewport), not the OverlayScrollArea host. `viewport`
205+
// is state so effects re-run once the library has mounted the viewport.
206+
const { ref: containerRef, viewport, onViewportReady } =
207+
useOverlayViewport<HTMLDivElement>();
202208
const splitSurfaceRef = useRef<HTMLDivElement>(null);
203209
const [fileCommentAnchor, setFileCommentAnchor] = useState<HTMLElement | null>(null);
204210

@@ -298,12 +304,15 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
298304

299305
const previousScrollFilePathRef = useRef(filePath);
300306
useLayoutEffect(() => {
301-
if (previousScrollFilePathRef.current !== filePath) {
302-
// A new file should start from the top-left of the diff viewport.
303-
containerRef.current?.scrollTo({ top: 0, left: 0, behavior: 'auto' });
304-
previousScrollFilePathRef.current = filePath;
305-
}
306-
}, [filePath]);
307+
if (previousScrollFilePathRef.current === filePath) return;
308+
// A new file should start from the top-left of the diff viewport.
309+
// Only advance the tracking ref once the scroll actually executed —
310+
// otherwise a file switch landing before the OverlayScrollbars viewport
311+
// has attached would leave the viewport stale on old content.
312+
if (!containerRef.current) return;
313+
containerRef.current.scrollTo({ top: 0, left: 0, behavior: 'auto' });
314+
previousScrollFilePathRef.current = filePath;
315+
}, [filePath, viewport]);
307316

308317
// Clear pending selection when file changes
309318
const prevFilePathRef = useRef(filePath);
@@ -328,7 +337,7 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
328337
}, 100);
329338

330339
return () => clearTimeout(timeoutId);
331-
}, [selectedAnnotationId]);
340+
}, [selectedAnnotationId, viewport]);
332341

333342
// Apply search highlights to diff lines (including inside shadow DOM).
334343
// The query is already debounced upstream (useReviewSearch), so this runs synchronously.
@@ -349,20 +358,20 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
349358
roots.forEach(root =>
350359
applySearchHighlights(root, query, matches, activeSearchMatchId)
351360
);
352-
}, [searchQuery, searchMatches, filePath, diffStyle, diffOverflow, diffIndicators, lineDiffType, disableLineNumbers, disableBackground, augmentedDiff]);
361+
}, [searchQuery, searchMatches, filePath, diffStyle, diffOverflow, diffIndicators, lineDiffType, disableLineNumbers, disableBackground, augmentedDiff, viewport]);
353362

354363
// Swap active search highlight instantly when stepping between matches.
355364
// This avoids a full rebuild just to change two elements' background color.
356365
useEffect(() => {
357366
if (!containerRef.current) return;
358367
swapActiveSearchHighlight(containerRef.current, activeSearchMatchId);
359-
}, [activeSearchMatchId]);
368+
}, [activeSearchMatchId, viewport]);
360369

361370
// Scroll to active search match (with retry for lazy-rendered content)
362371
useEffect(() => {
363372
if (!activeSearchMatch || !containerRef.current) return;
364373
return retryScrollToSearchMatch(containerRef.current, activeSearchMatch);
365-
}, [activeSearchMatch, filePath, diffStyle, diffOverflow, diffIndicators, lineDiffType, disableLineNumbers, disableBackground]);
374+
}, [activeSearchMatch, filePath, diffStyle, diffOverflow, diffIndicators, lineDiffType, disableLineNumbers, disableBackground, viewport]);
366375

367376
// Map annotations to @pierre/diffs format
368377
const lineAnnotations = useMemo(() => {
@@ -574,7 +583,12 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
574583
onFileComment={setFileCommentAnchor}
575584
/>
576585

577-
<div ref={containerRef} className={`flex-1 overflow-auto relative ${isDraggingSplit ? 'select-none' : ''}`} onMouseMove={toolbar.handleMouseMove}>
586+
<OverlayScrollArea
587+
className={`flex-1 min-h-0 relative ${isDraggingSplit ? 'select-none' : ''}`}
588+
overflowX="scroll"
589+
onViewportReady={onViewportReady}
590+
onMouseMove={toolbar.handleMouseMove}
591+
>
578592
<div className="p-4">
579593
<div ref={splitSurfaceRef} className="relative min-w-0" style={splitGridStyle}>
580594
{isSplitLayout && diffOverflow !== 'wrap' && (
@@ -664,7 +678,7 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
664678
onClose={() => setFileCommentAnchor(null)}
665679
/>
666680
)}
667-
</div>
681+
</OverlayScrollArea>
668682
</div>
669683
);
670684
};

packages/review-editor/components/FileTree.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { buildFileTree, getAncestorPaths, getAllFolderPaths } from '../utils/bui
55
import { FileTreeNodeItem } from './FileTreeNode';
66
import { getReviewSearchSideLabel, type ReviewSearchFileGroup, type ReviewSearchMatch } from '../utils/reviewSearch';
77
import type { DiffFile } from '../types';
8+
import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea';
89

910
interface FileTreeProps {
1011
files: DiffFile[];
@@ -353,7 +354,8 @@ export const FileTree: React.FC<FileTreeProps> = ({
353354
)}
354355

355356
{/* File tree or search results */}
356-
<div className="flex-1 overflow-y-auto px-1 py-1">
357+
<OverlayScrollArea className="flex-1 min-h-0">
358+
<div className="px-1 py-1">
357359
{searchQuery.trim() ? (
358360
isSearchPending ? (
359361
<div className="py-6 text-center text-xs text-muted-foreground/50">
@@ -393,6 +395,7 @@ export const FileTree: React.FC<FileTreeProps> = ({
393395
))
394396
)}
395397
</div>
398+
</OverlayScrollArea>
396399

397400
{/* Footer */}
398401
<div className="px-2 py-1.5 border-t border-border/50 text-xs text-muted-foreground">

packages/review-editor/components/LiveLogViewer.tsx

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import React, { useRef, useEffect, useMemo, useCallback } from 'react';
1+
import React, { useEffect, useMemo, useRef } from 'react';
22
import { CopyButton } from './CopyButton';
3+
import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea';
4+
import { useOverlayViewport } from '@plannotator/ui/hooks/useOverlayViewport';
35

46
interface LiveLogViewerProps {
57
/** The full accumulated log text. */
@@ -24,21 +26,29 @@ export const LiveLogViewer: React.FC<LiveLogViewerProps> = ({
2426
maxRenderSize = 50_000,
2527
className,
2628
}) => {
27-
const containerRef = useRef<HTMLDivElement>(null);
29+
const { ref: containerRef, viewport, onViewportReady } =
30+
useOverlayViewport<HTMLDivElement>();
2831
const isAtBottomRef = useRef(true);
2932

30-
const handleScroll = useCallback(() => {
31-
const el = containerRef.current;
32-
if (!el) return;
33-
isAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
34-
}, []);
33+
// Track whether the user is within 40px of the bottom. Attach directly
34+
// to the OverlayScrollbars viewport because React's onScroll doesn't
35+
// bubble across the library's wrapper layers.
36+
useEffect(() => {
37+
if (!viewport) return;
38+
const handleScroll = () => {
39+
isAtBottomRef.current =
40+
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 40;
41+
};
42+
viewport.addEventListener('scroll', handleScroll, { passive: true });
43+
return () => viewport.removeEventListener('scroll', handleScroll);
44+
}, [viewport]);
3545

3646
// Auto-scroll on new content if user is at bottom
3747
useEffect(() => {
3848
if (isAtBottomRef.current && containerRef.current) {
3949
containerRef.current.scrollTop = containerRef.current.scrollHeight;
4050
}
41-
}, [content]);
51+
}, [content, viewport]);
4252

4353
const displayText = useMemo(() => {
4454
if (content.length <= maxRenderSize) return content;
@@ -56,11 +66,11 @@ export const LiveLogViewer: React.FC<LiveLogViewerProps> = ({
5666

5767
return (
5868
<div className={`group relative flex-1 min-h-0 ${className ?? ''}`}>
59-
<div
60-
ref={containerRef}
61-
onScroll={handleScroll}
62-
className="h-full overflow-y-auto rounded bg-muted/30 p-3"
69+
<OverlayScrollArea
70+
className="h-full rounded bg-muted/30"
71+
onViewportReady={onViewportReady}
6372
>
73+
<div className="p-3">
6474
{!content && isLive ? (
6575
<span className="text-xs text-muted-foreground/50 animate-pulse">
6676
Waiting for output...
@@ -73,7 +83,8 @@ export const LiveLogViewer: React.FC<LiveLogViewerProps> = ({
7383
)}
7484
</pre>
7585
)}
76-
</div>
86+
</div>
87+
</OverlayScrollArea>
7788
{content && (
7889
<div className="absolute top-2 right-2">
7990
<CopyButton text={content} variant="inline" />

packages/review-editor/components/PRCommentsTab.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { PRContext, PRComment, PRReview, PRReviewThread } from '@plannotato
33
import { MarkdownBody } from './PRSummaryTab';
44
import { CopyButton } from './CopyButton';
55
import { DiffHunkPreview } from './DiffHunkPreview';
6+
import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea';
67

78
// ---------------------------------------------------------------------------
89
// Types
@@ -309,7 +310,8 @@ export const PRCommentsTab: React.FC<PRCommentsTabProps> = React.memo(({ context
309310
</div>
310311

311312
{/* ── Timeline ── */}
312-
<div className="flex-1 overflow-y-auto px-8 py-4">
313+
<OverlayScrollArea className="flex-1 min-h-0">
314+
<div className="px-8 py-4">
313315
<div className="space-y-3 max-w-2xl">
314316
{displayTimeline.length === 0 ? (
315317
<div className="text-center py-8">
@@ -396,6 +398,7 @@ export const PRCommentsTab: React.FC<PRCommentsTabProps> = React.memo(({ context
396398
)}
397399
</div>
398400
</div>
401+
</OverlayScrollArea>
399402
</div>
400403
);
401404
});

packages/review-editor/components/ReviewSidebar.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { SparklesIcon } from './SparklesIcon';
1313
import { ReviewAgentsIcon } from '@plannotator/ui/components/ReviewAgentsIcon';
1414
import { AgentsTab } from '@plannotator/ui/components/AgentsTab';
1515
import type { PRMetadata } from '@plannotator/shared/pr-provider';
16+
import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea';
1617
import type { AIChatEntry } from '../hooks/useAIChat';
1718
import type { AgentJobInfo, AgentCapabilities } from '@plannotator/ui/types';
1819
import type { DiffFile } from '../types';
@@ -282,7 +283,7 @@ export const ReviewSidebar: React.FC<ReviewSidebarProps> = /* React.memo */({
282283
</div>
283284

284285
{/* Content */}
285-
<div className="flex-1 overflow-y-auto">
286+
<OverlayScrollArea className="flex-1 min-h-0">
286287
{/* Annotations tab */}
287288
{activeTab === 'annotations' && (
288289
<div className="p-2 space-y-1.5">
@@ -439,7 +440,7 @@ export const ReviewSidebar: React.FC<ReviewSidebarProps> = /* React.memo */({
439440
/>
440441
)}
441442

442-
</div>
443+
</OverlayScrollArea>
443444

444445
{/* Quick Copy Footer — annotations tab only */}
445446
{activeTab === 'annotations' && feedbackMarkdown && totalCount > 0 && (

packages/review-editor/dock/panels/ReviewPRChecksPanel.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { useEffect } from 'react';
22
import type { IDockviewPanelProps } from 'dockview-react';
33
import { useReviewState } from '../ReviewStateContext';
44
import { PRChecksTab } from '../../components/PRChecksTab';
5+
import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea';
56

67
/**
78
* Dock panel wrapper for PR Checks.
@@ -39,8 +40,8 @@ export const ReviewPRChecksPanel: React.FC<IDockviewPanelProps> = () => {
3940
if (!prContext) return null;
4041

4142
return (
42-
<div className="h-full overflow-y-auto">
43+
<OverlayScrollArea className="h-full">
4344
<PRChecksTab context={prContext} />
44-
</div>
45+
</OverlayScrollArea>
4546
);
4647
};

0 commit comments

Comments
 (0)