/**
* 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 (
{entries.map(entry => (
{label(entry)}{describe(entry)}
))}
);
}
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 (
<>
>
);
}