/** * How to reach what the agent changed while you were reading somewhere else. * * A mark is held back until it is on screen, which answers the question of * whether it was worth showing but not the question of how to find it. These * are the other half: one chip per edge, counting only what is still unread in * that direction, and going to the nearest one when clicked — which reveals it * the ordinary way, by putting it in front of the reader. * * The list behind them shows every live change, seen or not. That is the only * place a removal can be read: the hole in the prose can say that something * was here, but the block itself is gone and cannot be asked what it was. */ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; import { MotionDisclosureIcon } from "./disclosure-motion"; import type { ChangeStore, Entry, Snapshot } from "./changes"; /** Past this the number stops being informative and starts being noise. */ const MANY = 9; export function useChanges(store: ChangeStore): Snapshot { return useSyncExternalStore(store.subscribe, store.snapshot, store.snapshot); } function count(value: number): string { return value > MANY ? `${MANY}+` : String(value); } function label(entry: Entry): string { switch (entry.kind) { case "added": return "Written"; case "moved": return "Moved"; case "removed": return entry.blocks.length > 1 ? `Removed ${entry.blocks.length} blocks` : "Removed"; } } function describe(entry: Entry): string { let first = entry.blocks[0]; if (!first) return ""; let text = first.preview.trim(); // A block with no words of its own — a divider, an image, a component with // nothing written in it — is named by what it is instead. return text || first.type; } function List({ entries }: { entries: Entry[] }) { if (entries.length === 0) { return

Nothing recent.

; } return ( ); } function Chip( { entries, motionImmediately, onGo, side, waiting }: { entries: Entry[]; motionImmediately?: () => boolean; onGo: () => void; side: "above" | "below"; waiting: number; }, ) { let [open, setOpen] = useState(false); let [iconMotionOwner, setIconMotionOwner] = useState<"immediate" | "pointer">(); let box = useRef(null); // Once every change in this direction is read, the list has nothing left // to show. Adjusting state during render (rather than in an Effect that // fires after the fact) closes it in the same commit, with no extra pass // where a stale, now-empty list is still open. let [openedForWaiting, setOpenedForWaiting] = useState(waiting); if (waiting !== openedForWaiting) { setOpenedForWaiting(waiting); if (waiting === 0) { setIconMotionOwner(undefined); setOpen(false); } } // Closing on an outside click rather than on blur: the list is inside the // same box as the button, so blur fires on the way to clicking it. useEffect(() => { if (!open) return; let close = (event: MouseEvent) => { if (!box.current?.contains(event.target as Node)) { setIconMotionOwner("pointer"); setOpen(false); } }; document.addEventListener("mousedown", close); return () => document.removeEventListener("mousedown", close); }, [open]); if (waiting === 0) return null; return (
{open && }
); } export function PlanChanges( { motionImmediately, store }: { motionImmediately?: () => boolean; store: ChangeStore }, ) { let { above, below, entries } = useChanges(store); let goUp = useCallback(() => store.reveal("above"), [store]); let goDown = useCallback(() => store.reveal("below"), [store]); return ( <> ); }