Files
backnotprop__plannotator/packages/guide-viewer/GuideViewportManager.tsx
Michael Ramos 64062af9a1 feat: Portable Guided Reviews — export, share links, agent-authored guides, guides.show (#1324)
A Guided Review can now leave Plannotator: as a single self-contained HTML file that renders exactly like the in-app guide, as an encrypted-by-default share link on guides.show, or authored by any agent through the new guide CLI.

Highlights: packages/guide-viewer extracted from review-editor at the injection seam (read-only host, no third renderer); guides.show Worker with R2-backed share storage, per-IP rate limiting on creation, delete tokens hashed at rest, and 128-bit ids; portable exports pin the viewer by SRI hash with budget and manifest gates in PR CI and at deploy; two-runtime parity across Bun and Pi verified; v0.27.x saved guides load unchanged. Retention is indefinite by explicit decision, to revisit with the lean sharing refactor.

Decision record: adr/decisions/007-portable-guided-reviews-20260815.md
2026-08-16 12:17:13 -07:00

433 lines
15 KiB
TypeScript

import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
useSyncExternalStore,
type ReactNode,
} from 'react';
const MAX_MOUNTED_CODE_VIEWS = 8;
const INITIAL_MOUNTED_CODE_VIEWS = 2;
/**
* Guides with at most this many files mount every CodeView up front instead of
* running the outer window: the window exists to bound hundreds of shells, and
* for a small changeset the eviction/remount churn (and the blank shells it
* leaves while scrolling) costs more than the handful of CodeViews it saves.
*/
export const GUIDE_EAGER_MOUNT_MAX_FILES = 15;
const OUTER_OVERSCAN_PX = 1_200;
const FAST_SCROLL_VELOCITY_PX_PER_MS = 1.2;
const SCROLL_IDLE_MS = 120;
const FORCE_MOUNT_MS = 1_500;
interface RegisteredShell {
element: HTMLElement;
near: boolean;
}
type Subscriber = () => void;
/**
* The nearest ancestor that actually scrolls the guide, or null for the
* window. `body`/`html` are never returned: an overflow set on them propagates
* to the viewport (the element itself does not scroll — its scrollHeight equals
* its clientHeight), so observing it as an IntersectionObserver root would make
* every shell "near", anchor distances to the middle of the whole document,
* and hang the scroll listener on an element that never emits scroll events —
* the CodeViews would then only mount on hover (`requestMount`).
*/
function findScrollRoot(host: HTMLElement): HTMLElement | null {
let current = host.parentElement;
while (current) {
if (current === document.body || current === document.documentElement) return null;
const overflowY = getComputedStyle(current).overflowY;
if (overflowY === 'auto' || overflowY === 'scroll') return current;
current = current.parentElement;
}
return null;
}
function setEquals(left: ReadonlySet<string>, right: ReadonlySet<string>): boolean {
if (left.size !== right.size) return false;
for (const value of left) {
if (!right.has(value)) return false;
}
return true;
}
/**
* One outer-window coordinator for every file shell in a Guided Review.
*
* Pierre virtualizes lines INSIDE each mounted CodeView. This manager bounds
* how many one-file CodeViews exist at all, without making 250 shell components
* rerender on every scroll-window update: subscribers are keyed per file id.
*/
class GuideViewportManager {
private entries = new Map<string, RegisteredShell>();
private elementIds = new WeakMap<HTMLElement, string>();
private mounted = new Set<string>();
private pinned = new Set<string>();
private subscribers = new Map<string, Set<Subscriber>>();
private observer: IntersectionObserver | null = null;
private scrollRoot: HTMLElement | null = null;
private host: HTMLElement | null = null;
private reconcileRaf: number | null = null;
private idleTimer: ReturnType<typeof setTimeout> | null = null;
private forceTimer: ReturnType<typeof setTimeout> | null = null;
private forcedId: string | null = null;
private fastScrolling = false;
private lastScrollTop = 0;
private lastScrollAt = 0;
private eager: boolean;
constructor(eager = false) {
this.eager = eager;
}
/**
* Eager mode mounts every registered shell and keeps it mounted: no window,
* no observer, no scroll tracking. Flipping it at runtime (a refreshed diff
* crossing the file threshold) either mounts everything or re-arms the
* window machinery against the current host.
*/
setEager = (eager: boolean): void => {
if (eager === this.eager) return;
this.eager = eager;
if (eager) {
this.cleanObservers();
this.setMounted(new Set(this.entries.keys()));
return;
}
const host = this.host;
this.host = null;
this.attachHost(host);
};
attachHost = (host: HTMLElement | null): void => {
if (host === this.host) return;
this.cleanObservers();
this.host = host;
if (!host) return;
if (this.eager) {
this.setMounted(new Set(this.entries.keys()));
return;
}
this.scrollRoot = findScrollRoot(host);
this.lastScrollTop = this.readScrollTop();
this.lastScrollAt = performance.now();
// Distance-based reconciliation still works without IntersectionObserver,
// but only if scrolling continues to schedule it. Register this listener for
// both paths so older embedded browsers do not freeze the initial window.
const scrollTarget: EventTarget = this.scrollRoot ?? window;
scrollTarget.addEventListener('scroll', this.handleOuterScroll, { passive: true });
if (typeof IntersectionObserver === 'undefined') {
for (const entry of this.entries.values()) entry.near = true;
this.reconcile();
return;
}
this.observer = new IntersectionObserver(this.handleIntersections, {
root: this.scrollRoot,
rootMargin: `${OUTER_OVERSCAN_PX}px 0px`,
threshold: 0,
});
for (const entry of this.entries.values()) this.observer.observe(entry.element);
};
register = (id: string, element: HTMLElement | null): void => {
const previous = this.entries.get(id);
if (previous?.element === element) return;
if (previous) {
this.observer?.unobserve(previous.element);
this.elementIds.delete(previous.element);
}
if (!element) {
this.entries.delete(id);
this.pinned.delete(id);
// Ref detachment means the subscriber is unmounting too; remove the id
// without notifying that departing component. A future shell with the
// same id reads the fresh false snapshot on subscription.
this.mounted.delete(id);
this.scheduleReconcile();
return;
}
const near = typeof IntersectionObserver === 'undefined';
this.entries.set(id, { element, near });
this.elementIds.set(element, id);
if (this.eager) {
this.setMounted(new Set([...this.mounted, id]));
return;
}
this.observer?.observe(element);
// Avoid a blank first paint before IntersectionObserver's first delivery.
// Only the first couple of shells seed eagerly; the observer immediately
// replaces them with the real viewport window.
if (this.mounted.size < INITIAL_MOUNTED_CODE_VIEWS) {
this.setMounted(new Set([...this.mounted, id]));
}
if (near || this.forcedId === id) this.scheduleReconcile();
};
subscribe = (id: string, subscriber: Subscriber): (() => void) => {
let listeners = this.subscribers.get(id);
if (!listeners) {
listeners = new Set();
this.subscribers.set(id, listeners);
}
listeners.add(subscriber);
return () => {
listeners?.delete(subscriber);
if (listeners?.size === 0) this.subscribers.delete(id);
};
};
isMounted = (id: string): boolean => this.mounted.has(id);
setPinned = (id: string, pinned: boolean): void => {
if (pinned === this.pinned.has(id)) return;
if (this.eager) {
// Everything registered is already mounted; only track the pin so a later
// switch back to the window keeps the focused file alive.
if (pinned) this.pinned.add(id);
else this.pinned.delete(id);
return;
}
if (!pinned) {
this.pinned.delete(id);
this.scheduleReconcile();
return;
}
this.pinned.add(id);
const next = new Set(this.mounted);
next.add(id);
if (next.size > MAX_MOUNTED_CODE_VIEWS) {
const eviction = this.farthestMountedFrom(id, next);
if (eviction) next.delete(eviction);
}
this.setMounted(next);
};
requestMount = (id: string): void => {
if (this.eager) {
if (this.entries.has(id) && !this.mounted.has(id)) this.setMounted(new Set([...this.mounted, id]));
return;
}
this.forcedId = id;
const next = new Set(this.mounted);
next.add(id);
if (next.size > MAX_MOUNTED_CODE_VIEWS) {
const eviction = this.farthestMountedFrom(id, next);
if (eviction) next.delete(eviction);
}
this.setMounted(next);
if (this.forceTimer) clearTimeout(this.forceTimer);
this.forceTimer = setTimeout(() => {
if (this.forcedId === id) this.forcedId = null;
this.forceTimer = null;
this.reconcile();
}, FORCE_MOUNT_MS);
};
private handleIntersections = (changes: IntersectionObserverEntry[]): void => {
for (const change of changes) {
if (!(change.target instanceof HTMLElement)) continue;
const id = this.elementIds.get(change.target);
const entry = id ? this.entries.get(id) : undefined;
if (entry) entry.near = change.isIntersecting;
}
if (this.fastScrolling) {
this.scheduleIdleReconcile();
} else {
this.scheduleReconcile();
}
};
private handleOuterScroll = (): void => {
const now = performance.now();
const scrollTop = this.readScrollTop();
const elapsed = Math.max(1, now - this.lastScrollAt);
const velocity = Math.abs(scrollTop - this.lastScrollTop) / elapsed;
this.lastScrollTop = scrollTop;
this.lastScrollAt = now;
this.fastScrolling = velocity >= FAST_SCROLL_VELOCITY_PX_PER_MS;
if (this.fastScrolling) this.scheduleIdleReconcile();
else this.scheduleReconcile();
};
private scheduleIdleReconcile(): void {
if (this.idleTimer) clearTimeout(this.idleTimer);
this.idleTimer = setTimeout(() => {
this.idleTimer = null;
this.fastScrolling = false;
this.reconcile();
}, SCROLL_IDLE_MS);
}
private scheduleReconcile(): void {
if (this.reconcileRaf != null) return;
this.reconcileRaf = requestAnimationFrame(() => {
this.reconcileRaf = null;
if (!this.fastScrolling) this.reconcile();
});
}
private reconcile(): void {
if (this.eager) {
this.setMounted(new Set(this.entries.keys()));
return;
}
const rootRect = this.getRootRect();
const candidates = [...this.entries.entries()]
.filter(([id, entry]) => entry.near || id === this.forcedId || this.pinned.has(id))
.map(([id, entry]) => ({
id,
distance: this.distanceFromViewport(entry.element.getBoundingClientRect(), rootRect)
- (this.mounted.has(id) ? 80 : 0),
}))
.sort((left, right) => left.distance - right.distance);
// Before the first observer delivery, retain the tiny eager seed rather
// than flashing every CodeView off and back on.
if (candidates.length === 0 && this.mounted.size > 0) return;
const next = new Set<string>();
for (const id of this.pinned) {
if (next.size >= MAX_MOUNTED_CODE_VIEWS) break;
if (this.entries.has(id)) next.add(id);
}
if (this.forcedId && this.entries.has(this.forcedId) && next.size < MAX_MOUNTED_CODE_VIEWS) {
next.add(this.forcedId);
}
for (const candidate of candidates) {
if (next.size >= MAX_MOUNTED_CODE_VIEWS) break;
next.add(candidate.id);
}
this.setMounted(next);
}
private farthestMountedFrom(protectedId: string, ids: ReadonlySet<string>): string | null {
const rootRect = this.getRootRect();
let farthest: { id: string; distance: number } | null = null;
for (const id of ids) {
if (id === protectedId || this.pinned.has(id)) continue;
const entry = this.entries.get(id);
if (!entry) return id;
const distance = this.distanceFromViewport(entry.element.getBoundingClientRect(), rootRect);
if (!farthest || distance > farthest.distance) farthest = { id, distance };
}
return farthest?.id ?? null;
}
private setMounted(next: Set<string>): void {
if (setEquals(this.mounted, next)) return;
const changed = new Set<string>();
for (const id of this.mounted) {
if (!next.has(id)) changed.add(id);
}
for (const id of next) {
if (!this.mounted.has(id)) changed.add(id);
}
this.mounted = next;
for (const id of changed) this.notify(id);
}
private notify(id: string): void {
for (const subscriber of this.subscribers.get(id) ?? []) subscriber();
}
private getRootRect(): DOMRectReadOnly {
if (this.scrollRoot) return this.scrollRoot.getBoundingClientRect();
return new DOMRectReadOnly(0, 0, window.innerWidth, window.innerHeight);
}
private distanceFromViewport(rect: DOMRectReadOnly, root: DOMRectReadOnly): number {
if (rect.bottom < root.top) return root.top - rect.bottom;
if (rect.top > root.bottom) return rect.top - root.bottom;
const rectCenter = rect.top + rect.height / 2;
const rootCenter = root.top + root.height / 2;
return Math.abs(rectCenter - rootCenter) * 0.1;
}
private readScrollTop(): number {
return this.scrollRoot?.scrollTop ?? window.scrollY;
}
private cleanObservers(): void {
this.observer?.disconnect();
this.observer = null;
const scrollTarget: EventTarget = this.scrollRoot ?? window;
scrollTarget.removeEventListener('scroll', this.handleOuterScroll);
this.scrollRoot = null;
if (this.reconcileRaf != null) cancelAnimationFrame(this.reconcileRaf);
this.reconcileRaf = null;
if (this.idleTimer) clearTimeout(this.idleTimer);
this.idleTimer = null;
if (this.forceTimer) clearTimeout(this.forceTimer);
this.forceTimer = null;
this.forcedId = null;
this.fastScrolling = false;
}
}
const GuideViewportContext = createContext<GuideViewportManager | null>(null);
export function GuideViewportProvider({
children,
className,
eager = false,
}: {
children: ReactNode;
className?: string;
/** Mount every registered shell immediately and keep it mounted (small guides). */
eager?: boolean;
}) {
const [manager] = useState(() => new GuideViewportManager(eager));
useEffect(() => {
manager.setEager(eager);
}, [manager, eager]);
return (
<GuideViewportContext.Provider value={manager}>
<div ref={manager.attachHost} className={className}>
{children}
</div>
</GuideViewportContext.Provider>
);
}
export function useGuideFileWindow(id: string, pinned = false): {
mounted: boolean;
register: (element: HTMLElement | null) => void;
requestMount: () => void;
} {
const manager = useContext(GuideViewportContext);
if (!manager) throw new Error('useGuideFileWindow must be used within GuideViewportProvider');
const subscribe = useCallback((callback: Subscriber) => manager.subscribe(id, callback), [manager, id]);
const getSnapshot = useCallback(() => manager.isMounted(id), [manager, id]);
const mounted = useSyncExternalStore(subscribe, getSnapshot, () => false);
const register = useCallback((element: HTMLElement | null) => manager.register(id, element), [manager, id]);
const requestMount = useCallback(() => manager.requestMount(id), [manager, id]);
// The guide has one focus arbiter. Keeping that file pinned preserves any
// portaled annotation composer and its draft-owning CodeView during scrolling.
useEffect(() => {
manager.setPinned(id, pinned);
return () => manager.setPinned(id, false);
}, [manager, id, pinned]);
return { mounted, register, requestMount };
}
export const GUIDE_MAX_MOUNTED_CODE_VIEWS = MAX_MOUNTED_CODE_VIEWS;