Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 22 additions & 4 deletions packages/editor/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ import { usePrintMode } from '@plannotator/ui/hooks/usePrintMode';
import { modKey } from '@plannotator/ui/utils/platform';
import { useResizablePanel } from '@plannotator/ui/hooks/useResizablePanel';
import { ResizeHandle } from '@plannotator/ui/components/ResizeHandle';
import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea';
import { ScrollViewportContext } from '@plannotator/ui/hooks/useScrollViewport';
import { useOverlayViewport } from '@plannotator/ui/hooks/useOverlayViewport';
import { MobileMenu } from '@plannotator/ui/components/MobileMenu';
import {
getPermissionModeSettings,
Expand Down Expand Up @@ -124,7 +127,15 @@ const App: React.FC = () => {
const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null);

const viewerRef = useRef<ViewerHandle>(null);
const containerRef = useRef<HTMLElement>(null);
// containerRef + scrollViewport both point at the OverlayScrollbars
// viewport element (the node that actually scrolls), not the <main>
// host. Consumers: useActiveSection (IntersectionObserver root) and
// everything reading ScrollViewportContext.
const {
ref: containerRef,
viewport: scrollViewport,
onViewportReady: handleViewportReady,
} = useOverlayViewport();

usePrintMode();

Expand Down Expand Up @@ -381,7 +392,7 @@ const App: React.FC = () => {

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

const { editorAnnotations, deleteEditorAnnotation } = useEditorAnnotations();
const { externalAnnotations, updateExternalAnnotation, deleteExternalAnnotation } = useExternalAnnotations<Annotation>({ enabled: isApiMode });
Expand Down Expand Up @@ -1499,6 +1510,7 @@ const App: React.FC = () => {
)}

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

{/* Document Area */}
<main data-print-region="document" ref={containerRef} className="flex-1 min-w-0 overflow-y-auto bg-grid">
<OverlayScrollArea
element="main"
className="flex-1 min-w-0 bg-grid"
data-print-region="document"
onViewportReady={handleViewportReady}
>
<ConfirmDialog
isOpen={!!draftBanner}
onClose={dismissDraft}
Expand Down Expand Up @@ -1662,7 +1679,7 @@ const App: React.FC = () => {
/>
</div>
</div>
</main>
</OverlayScrollArea>

{/* Resize Handle */}
{isPanelOpen && <ResizeHandle {...panelResize.handleProps} className="hidden md:block" side="right" />}
Expand All @@ -1689,6 +1706,7 @@ const App: React.FC = () => {
onOtherFileAnnotationsClick={handleFlashAnnotatedFiles}
/>
</div>
</ScrollViewportContext.Provider>

{/* Export Modal */}
<ExportModal
Expand Down
5 changes: 4 additions & 1 deletion packages/review-editor/components/AITab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { CopyButton } from './CopyButton';
import { PermissionCard } from './PermissionCard';
import { AIConfigBar } from './AIConfigBar';
import { submitHint } from '@plannotator/ui/utils/platform';
import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea';

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

return (
<div className="flex flex-col h-full">
<div ref={scrollRef} className="flex-1 overflow-y-auto p-2">
<OverlayScrollArea className="flex-1 min-h-0">
<div ref={scrollRef} className="p-2">
{isCreatingSession && messages.length === 0 && (
<div className="text-xs text-muted-foreground text-center py-4">
<span className="ai-streaming-cursor" /> Starting AI session...
Expand Down Expand Up @@ -261,6 +263,7 @@ export const AITab: React.FC<AITabProps> = ({
</div>
)}
</div>
</OverlayScrollArea>

{/* Config bar */}
<AIConfigBar
Expand Down
40 changes: 27 additions & 13 deletions packages/review-editor/components/DiffViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { storage } from '@plannotator/ui/utils/storage';
import { detectLanguage } from '../utils/detectLanguage';
import { useAnnotationToolbar } from '../hooks/useAnnotationToolbar';
import { useConfigValue } from '@plannotator/ui/config';
import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea';
import { useOverlayViewport } from '@plannotator/ui/hooks/useOverlayViewport';
import { getEnabledLabels } from './ConventionalLabelPicker';
import { FileHeader } from './FileHeader';
import { InlineAnnotation } from './InlineAnnotation';
Expand Down Expand Up @@ -198,7 +200,11 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
aiHistoryMessages = [],
}) => {
const { theme, colorTheme, resolvedMode } = useTheme();
const containerRef = useRef<HTMLDivElement>(null);
// containerRef must point at the actual scrolling element (the
// OverlayScrollbars viewport), not the OverlayScrollArea host. `viewport`
// is state so effects re-run once the library has mounted the viewport.
const { ref: containerRef, viewport, onViewportReady } =
useOverlayViewport<HTMLDivElement>();
const splitSurfaceRef = useRef<HTMLDivElement>(null);
const [fileCommentAnchor, setFileCommentAnchor] = useState<HTMLElement | null>(null);

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

const previousScrollFilePathRef = useRef(filePath);
useLayoutEffect(() => {
if (previousScrollFilePathRef.current !== filePath) {
// A new file should start from the top-left of the diff viewport.
containerRef.current?.scrollTo({ top: 0, left: 0, behavior: 'auto' });
previousScrollFilePathRef.current = filePath;
}
}, [filePath]);
if (previousScrollFilePathRef.current === filePath) return;
// A new file should start from the top-left of the diff viewport.
// Only advance the tracking ref once the scroll actually executed —
// otherwise a file switch landing before the OverlayScrollbars viewport
// has attached would leave the viewport stale on old content.
if (!containerRef.current) return;
containerRef.current.scrollTo({ top: 0, left: 0, behavior: 'auto' });
previousScrollFilePathRef.current = filePath;
}, [filePath, viewport]);

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

return () => clearTimeout(timeoutId);
}, [selectedAnnotationId]);
}, [selectedAnnotationId, viewport]);

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

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

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

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

<div ref={containerRef} className={`flex-1 overflow-auto relative ${isDraggingSplit ? 'select-none' : ''}`} onMouseMove={toolbar.handleMouseMove}>
<OverlayScrollArea
className={`flex-1 min-h-0 relative ${isDraggingSplit ? 'select-none' : ''}`}
overflowX="scroll"
onViewportReady={onViewportReady}
onMouseMove={toolbar.handleMouseMove}
>
<div className="p-4">
<div ref={splitSurfaceRef} className="relative min-w-0" style={splitGridStyle}>
{isSplitLayout && diffOverflow !== 'wrap' && (
Expand Down Expand Up @@ -664,7 +678,7 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
onClose={() => setFileCommentAnchor(null)}
/>
)}
</div>
</OverlayScrollArea>
</div>
);
};
5 changes: 4 additions & 1 deletion packages/review-editor/components/FileTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { buildFileTree, getAncestorPaths, getAllFolderPaths } from '../utils/bui
import { FileTreeNodeItem } from './FileTreeNode';
import { getReviewSearchSideLabel, type ReviewSearchFileGroup, type ReviewSearchMatch } from '../utils/reviewSearch';
import type { DiffFile } from '../types';
import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea';

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

{/* File tree or search results */}
<div className="flex-1 overflow-y-auto px-1 py-1">
<OverlayScrollArea className="flex-1 min-h-0">
<div className="px-1 py-1">
{searchQuery.trim() ? (
isSearchPending ? (
<div className="py-6 text-center text-xs text-muted-foreground/50">
Expand Down Expand Up @@ -393,6 +395,7 @@ export const FileTree: React.FC<FileTreeProps> = ({
))
)}
</div>
</OverlayScrollArea>

{/* Footer */}
<div className="px-2 py-1.5 border-t border-border/50 text-xs text-muted-foreground">
Expand Down
37 changes: 24 additions & 13 deletions packages/review-editor/components/LiveLogViewer.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import React, { useRef, useEffect, useMemo, useCallback } from 'react';
import React, { useEffect, useMemo, useRef } from 'react';
import { CopyButton } from './CopyButton';
import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea';
import { useOverlayViewport } from '@plannotator/ui/hooks/useOverlayViewport';

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

const handleScroll = useCallback(() => {
const el = containerRef.current;
if (!el) return;
isAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
}, []);
// Track whether the user is within 40px of the bottom. Attach directly
// to the OverlayScrollbars viewport because React's onScroll doesn't
// bubble across the library's wrapper layers.
useEffect(() => {
if (!viewport) return;
const handleScroll = () => {
isAtBottomRef.current =
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 40;
};
viewport.addEventListener('scroll', handleScroll, { passive: true });
return () => viewport.removeEventListener('scroll', handleScroll);
}, [viewport]);

// Auto-scroll on new content if user is at bottom
useEffect(() => {
if (isAtBottomRef.current && containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, [content]);
}, [content, viewport]);

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

return (
<div className={`group relative flex-1 min-h-0 ${className ?? ''}`}>
<div
ref={containerRef}
onScroll={handleScroll}
className="h-full overflow-y-auto rounded bg-muted/30 p-3"
<OverlayScrollArea
className="h-full rounded bg-muted/30"
onViewportReady={onViewportReady}
>
<div className="p-3">
{!content && isLive ? (
<span className="text-xs text-muted-foreground/50 animate-pulse">
Waiting for output...
Expand All @@ -73,7 +83,8 @@ export const LiveLogViewer: React.FC<LiveLogViewerProps> = ({
)}
</pre>
)}
</div>
</div>
</OverlayScrollArea>
{content && (
<div className="absolute top-2 right-2">
<CopyButton text={content} variant="inline" />
Expand Down
5 changes: 4 additions & 1 deletion packages/review-editor/components/PRCommentsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { PRContext, PRComment, PRReview, PRReviewThread } from '@plannotato
import { MarkdownBody } from './PRSummaryTab';
import { CopyButton } from './CopyButton';
import { DiffHunkPreview } from './DiffHunkPreview';
import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea';

// ---------------------------------------------------------------------------
// Types
Expand Down Expand Up @@ -309,7 +310,8 @@ export const PRCommentsTab: React.FC<PRCommentsTabProps> = React.memo(({ context
</div>

{/* ── Timeline ── */}
<div className="flex-1 overflow-y-auto px-8 py-4">
<OverlayScrollArea className="flex-1 min-h-0">
<div className="px-8 py-4">
<div className="space-y-3 max-w-2xl">
{displayTimeline.length === 0 ? (
<div className="text-center py-8">
Expand Down Expand Up @@ -396,6 +398,7 @@ export const PRCommentsTab: React.FC<PRCommentsTabProps> = React.memo(({ context
)}
</div>
</div>
</OverlayScrollArea>
</div>
);
});
Expand Down
5 changes: 3 additions & 2 deletions packages/review-editor/components/ReviewSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { SparklesIcon } from './SparklesIcon';
import { ReviewAgentsIcon } from '@plannotator/ui/components/ReviewAgentsIcon';
import { AgentsTab } from '@plannotator/ui/components/AgentsTab';
import type { PRMetadata } from '@plannotator/shared/pr-provider';
import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea';
import type { AIChatEntry } from '../hooks/useAIChat';
import type { AgentJobInfo, AgentCapabilities } from '@plannotator/ui/types';
import type { DiffFile } from '../types';
Expand Down Expand Up @@ -282,7 +283,7 @@ export const ReviewSidebar: React.FC<ReviewSidebarProps> = /* React.memo */({
</div>

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

</div>
</OverlayScrollArea>

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