diff --git a/CLAUDE.md b/CLAUDE.md index 88cebb5532..6e63f0a48a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,9 +55,11 @@ the decision rests on. Use `restated` when a rewrite invalidates one. An `undo` event names the gesture withdrawn; it never deletes or invents a counter-event. Actions and reports share the registry-declared coordinate of owner widget, -fold unit, and facet. Python and JavaScript derive winners, retractions, and -settlement from the same declarations. Page-widget state is bounded by document -version; widgets frozen into thread markup use the conversation window. +fold unit, and facet. Python derives winners, retractions, settlement, asks, +threads, and updates in one transaction-consistent browser view. JavaScript +resolves that view onto live DOM nodes and overlays only unresolved local +gestures. Page-widget state is bounded by document version; widgets frozen into +thread markup use the conversation window. The page directory is the durable record and deployment unit. `index.html` is mutable author source; revisions and stamped versions are immutable. The event diff --git a/docs/session.js b/docs/session.js index 2be4d60ca8..8b0605ba67 100644 --- a/docs/session.js +++ b/docs/session.js @@ -2,14 +2,15 @@ * * A page directory is files plus a process. The files a static host serves perfectly — * the vendored layer sits at this site's root, and every example under /examples is the - * file in the tree — and the process answers three paths: GET /api/state, which hands - * the page the log and who is behind it, POST /api/event, which appends, and GET - * /api/news, a stream on which the page hears that the log has moved. So that is what - * this is: those three paths, answered in the tab. + * file in the tree — and the process answers four paths: GET /api/state, which hands + * the page the log and who is behind it, GET /api/view, which projects one exact read, + * POST /api/event, which appends, and GET /api/news, a stream on which the page hears + * that the log has moved. So that is what this is: those four paths, answered in the + * tab. * * Which makes the pages on this site live rather than pictures of live ones. Every * control is the shipped runtime's own — the banner and its counts, the comment panel, - * the quote marks in the margin, a board that takes a drag and holds it across a reload — + * the quote marks in the margin, and a board that takes a drag for this tab's visit — * because the runtime is loaded unmodified beside this file and cannot tell the * difference. What it can't have is the other half of the loop: no agent reads this log, * so a comment is recorded and answered by nobody. @@ -29,11 +30,6 @@ * second copy of that door would be a second thing to keep in step with the first. */ -// The page these events belong to, which is the document's path with the version file -// taken off — so a page published with two versions keeps one log across both, as a -// served page does. Per tab, and per page: a reader who opens three examples is three -// readers, and closing the tab is how a demo is put back. -const KEY = `leaf-demo:${location.pathname.replace(/\/versions\/v[1-9]\d*\.html$/, "")}`; // Which version this document is, read the way the runtime reads it. const VERSION = Number(location.pathname.match(/\/versions\/v([1-9]\d*)\.html$/)?.[1]); const REVISION = 1; @@ -42,9 +38,8 @@ revisionMarker.name = "lf-revision"; revisionMarker.content = String(REVISION); revisionMarker.dataset.lfRuntime = ""; document.head.append(revisionMarker); -const LAYER = await fetch("/registry.json") - .then((response) => response.json()) - .then((registry) => registry.$layer.generation); +const REGISTRY = await fetch("/registry.json").then((response) => response.json()); +const LAYER = REGISTRY.$layer.generation; const DATA = await fetch("../data.json") .then((response) => (response.ok ? response.json() : { revision: 0, sources: {} })) .catch(() => ({ revision: 0, sources: {} })); @@ -69,18 +64,6 @@ const ANSWER = `This is the demo answering, not an agent. The page stands on a s It went into the log all the same, with your quote attached — which is exactly what an agent picks up and answers. [Run one from a checkout](/examples.html#run) and a real reply lands right here.`; -// The log is this array. Web storage is where it is mirrored so a reload finds it, and -// a browser with storage switched off costs the reader their reload rather than the -// comment they just wrote — which is what reading the store back on every poll would -// have cost them, silently, at the one moment the page is proving it heard them. -const kept = (() => { - try { - return JSON.parse(sessionStorage.getItem(KEY)); - } catch { - return null; - } -})(); - // What the page opens on, for an example that ships a thread beside it: the log the // build laid in the page directory, which a served page would hand over on the first // poll. Awaited at the top of the module, so it is in hand before the runtime beside @@ -88,23 +71,21 @@ const kept = (() => { // empty panel and then a full one, over a reader already reading it. A page with no // thread to open on has no file here, and the 404 is that answer. // -// Only where the tab is holding nothing. A reader who came back to this tab has a log -// that already grew from this seed, and re-reading it would put the thread back under -// whatever they did to it. -const events = - kept ?? - (await fetch("../comments.jsonl") - .then((res) => (res.ok ? res.text() : "")) - .catch(() => "") - .then((text) => - text - .split("\n") - .filter((line) => line.trim()) - // seq is the line number, as `read_events` numbers a log it reads: the file - // holds none, and an event without one is invisible to the runtime's check for - // what a poll has brought that the last one hadn't. - .map((line, i) => ({ ...JSON.parse(line), seq: i + 1 })), - )); +// A reload deliberately starts again from this seed. Without the Python server there is +// no durable authority to replay; browser storage would turn this illustrative session +// into a second implementation of Leaf's persistence and version rules. +const events = await fetch("../comments.jsonl") + .then((res) => (res.ok ? res.text() : "")) + .catch(() => "") + .then((text) => + text + .split("\n") + .filter((line) => line.trim()) + // seq is the line number, as `read_events` numbers a log it reads: the file + // holds none, and an event without one is invisible to the runtime's check for + // what a poll has brought that the last one hadn't. + .map((line, i) => ({ ...JSON.parse(line), seq: i + 1 })), + ); // The streams open on this tab's log, told directly when it moves. A served page's // stream finds that out by looking at files; this log is in memory, and `append` is @@ -123,15 +104,266 @@ function append(event, author, agent) { ts: new Date().toISOString(), seq: events.length + 1, }); - try { - sessionStorage.setItem(KEY, JSON.stringify(events)); - } catch { - /* a demo that cannot remember still takes the next comment */ - } for (const ear of ears) ear.speak(); return events.at(-1); } +// This static host cannot run the canonical Python projector. Its session is therefore +// deliberately a small, ephemeral exhibit: enough structure for the shipped runtime to +// render this tab's gestures, with no claim that the result is a durable Leaf reading. +// Real pages never enter this path; their `browser` object is produced under the page +// transaction in the server. +const coordinateOf = (event) => { + const widget = document.getElementById(event.widget); + const channel = event.kind === "action" ? "x-state" : "x-report"; + const spec = widget && REGISTRY[widget.localName]?.[channel]?.[event.action]; + if (!spec) return null; + const unit = spec.unit === "widget" ? event.widget : event.detail[spec.unit]; + return typeof unit === "string" ? [event.widget, unit, spec.facet] : null; +}; + +const DEMO_COLLAPSE = + /[\t\n\v\f\r \u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]+/g; +const valueOf = (event) => { + const widget = document.getElementById(event.widget); + const channel = event.kind === "action" ? "x-state" : "x-report"; + const record = + widget && REGISTRY[widget.localName]?.[channel]?.[event.action]?.record; + if (!record) return null; + const value = event.detail[record.value]; + if (record.kind === "body") return String(value).replace(DEMO_COLLAPSE, " ").trim(); + if (record.kind === "attribute") return [...value].sort(); + return value ?? null; +}; + +function demoProjection(without = null) { + const withdrawn = new Set( + events.filter((event) => event.undoes).map((event) => event.undoes), + ); + const entries = []; + const actions = new Map(); + const reports = new Map(); + for (const event of events) { + if (!["action", "report"].includes(event.kind)) continue; + const coordinate = coordinateOf(event); + if (!coordinate) continue; + const key = JSON.stringify(coordinate); + entries.push({ + event, + coordinate, + value: valueOf(event), + scope: "document", + restated: [], + }); + if (event.kind === "action") { + if (!withdrawn.has(event.id) && event.id !== without) actions.set(key, event.id); + } else { + const standing = reports.get(key) ?? []; + standing.push(event.id); + reports.set(key, standing); + } + } + const desired = new Map( + [...reports].map(([key, standing]) => [key, standing.at(-1)]), + ); + for (const [key, id] of actions) desired.set(key, id); + return { + entries, + actions: [...actions.values()], + reports: [...reports.values()].flat(), + desired: [...desired.values()], + }; +} + +function demoThreads() { + const withdrawn = new Set( + events.filter((event) => event.undoes).map((event) => event.undoes), + ); + const threads = new Map(); + const threadFor = new Map(); + const messages = new Map(); + for (const event of events) { + if (withdrawn.has(event.id)) continue; + if (event.kind === "comment") { + const message = { ...event }; + const thread = { root: message, msgs: [message], resolved: null }; + messages.set(message.id, message); + threads.set(message.id, thread); + threadFor.set(message.id, thread); + } else if (event.kind === "reply") { + let thread = threadFor.get(event.parent); + if (!thread) { + thread = { root: { ...event }, msgs: [], resolved: null }; + threads.set(event.parent, thread); + threadFor.set(event.parent, thread); + } + const message = { ...event }; + messages.set(message.id, message); + thread.msgs.push(message); + threadFor.set(message.id, thread); + } else if (event.kind === "edit") { + const message = messages.get(event.message); + if (message) { + message.text = event.text; + message.edited = { id: event.id, seq: event.seq, ts: event.ts }; + } + } else if (event.kind === "resolve") { + const thread = threadFor.get(event.parent); + if (thread) thread.resolved = event; + } else if (event.kind === "unresolve") { + const thread = threadFor.get(event.parent); + if (thread) thread.resolved = null; + } else if (event.kind === "action" && event.detail?.resolves) { + const thread = threads.get(event.detail.resolves); + if (thread) thread.resolved = event; + } + } + return [...threads.values()].map((thread) => { + const spoken = thread.msgs.filter((message) => !message.token); + const last = spoken.at(-1); + return { + ...thread, + awaits_agent: Boolean(!thread.resolved && last && last.author !== "claude"), + awaits_reader: Boolean( + !thread.resolved && + last?.author === "claude" && + (last.kind !== "reply" || last.awaits), + ), + bare_reaction: Boolean(thread.root.token && spoken.length === 0), + seat: + !thread.root.about && + thread.root.anchor && + Object.keys(thread.root.anchor).length === 1 + ? (thread.root.anchor.section ?? null) + : null, + }; + }); +} + +const matchesWhen = (element, when = {}) => + Object.entries(when).every(([attribute, values]) => { + const present = element.hasAttribute(attribute); + const value = element.getAttribute(attribute); + return values.some((candidate) => + typeof candidate === "boolean" ? candidate === present : candidate === value, + ); + }); + +function demoAsks(projection, threads) { + const standing = new Set(projection.actions); + const withAgent = new Set( + threads + .filter((thread) => thread.awaits_agent) + .map((thread) => thread.seat) + .filter(Boolean), + ); + const values = {}; + const descriptors = []; + for (const [tag, entry] of Object.entries(REGISTRY)) { + if (tag.startsWith("$") || !entry?.["x-awaits"]) continue; + for (const element of document.querySelectorAll(tag)) { + if (!element.id || !matchesWhen(element, entry["x-awaits"].when)) continue; + const answered = (entry["x-awaits"].answers ?? []).some((verb) => { + const spec = entry["x-state"]?.[verb]; + const coordinate = spec && [element.id, element.id, spec.facet]; + return projection.entries.some( + (candidate) => + candidate.event.widget === element.id && + candidate.event.action === verb && + standing.has(candidate.event.id) && + JSON.stringify(candidate.coordinate) === JSON.stringify(coordinate), + ); + }); + const awaiting = !answered; + values[element.id] = awaiting; + if (!awaiting || withAgent.has(element.id)) continue; + let surface = element; + for (let parent = element.parentElement; parent; parent = parent.parentElement) { + if (REGISTRY[parent.localName]?.["x-ask"]) { + surface = parent; + break; + } + } + descriptors.push({ id: surface.id, tag: surface.localName, thread: null }); + } + } + return { descriptors, values }; +} + +function demoBrowser() { + const projection = demoProjection(); + const threads = demoThreads(); + const asks = demoAsks(projection, threads); + const throughSeq = events.at(-1)?.seq ?? 0; + const coverage = events + .filter((event) => ["action", "report", "undo"].includes(event.kind)) + .map((event) => { + const target = + event.kind === "undo" + ? events.find((candidate) => candidate.id === event.undoes) + : event; + return { event, coordinate: target ? coordinateOf(target) : null }; + }); + const undo = [...events] + .reverse() + .filter( + (event) => + event.author === "user" && + ["action", "resolve", "unresolve"].includes(event.kind) && + !events.some((candidate) => candidate.undoes === event.id), + ) + .map((event) => ({ + event, + ...(coordinateOf(event) && { coordinate: coordinateOf(event) }), + ...(event.kind === "action" && { + restores_desired: (() => { + const coordinate = JSON.stringify(coordinateOf(event)); + const projection = demoProjection(event.id); + const desired = new Set(projection.desired); + return projection.entries.some( + (entry) => + desired.has(entry.event.id) && + JSON.stringify(entry.coordinate) === coordinate, + ); + })(), + }), + })); + return { + basis: { through_seq: throughSeq }, + views: { + [REVISION]: { + basis: { revision: REVISION, through_seq: throughSeq }, + document: { + revision: REVISION, + projection, + asks: { + reader: asks.descriptors, + unanswered: asks.descriptors, + awaiting: asks.values, + unanswered_awaiting: asks.values, + }, + }, + updates: [], + undo, + coverage, + published_at: null, + }, + }, + conversation: { + projection: { entries: [], actions: [], reports: [], desired: [] }, + asks: { reader: [], unanswered: [], awaiting: {} }, + threads, + done: events.filter((event) => event.kind === "done"), + }, + receipts: events.filter((event) => event.attempt), + version_notes: Object.fromEntries( + events + .filter((event) => event.kind === "note") + .map((event) => [event.version, event.text]), + ), + }; +} + // `full_state`'s answer, field for field. The shape is the runtime's contract with // whatever is behind the page, so it is answered whole rather than pared down to what // this version of the runtime happens to read — a field left out is a banner seat that @@ -189,6 +421,7 @@ const state = () => ({ // full-state field as a live host and therefore exercises the same package modules. data: DATA, events, + browser: demoBrowser(), // The reading a served page stamps on its state and names on its stream, so a tab // can tell whether the stream is telling it something it already holds. The log is // the whole of what moves here, so its length is the reading. @@ -210,11 +443,21 @@ window.fetch = (input, init) => { input instanceof Request ? input.url : String(input), location.href, ); - // Only this page's two doors. Everything else — the theme, the registry, the widget + // Only this page's three request doors. Everything else — the theme, the registry, the widget // modules, another version's markup — is a file the host serves, and the runtime // reaches for it exactly as it would anywhere else. if (url.origin !== location.origin) return realFetch(input, init); if (url.pathname === "/api/state") return Promise.resolve(json(state())); + if (url.pathname === "/api/view") { + const browser = demoBrowser(); + const revision = Number(url.searchParams.get("revision")); + const throughSeq = Number(url.searchParams.get("through_seq")); + if (revision !== REVISION || throughSeq !== browser.basis.through_seq) + return Promise.resolve( + json({ error: "the static exhibit holds no such projection" }, 400), + ); + return Promise.resolve(json({ browser })); + } if (url.pathname === "/api/event") { const event = JSON.parse(init.body); // The execution record the door keeps per attempt, which is the whole of what a @@ -251,7 +494,7 @@ window.fetch = (input, init) => { return realFetch(input, init); }; -// The third door. A served page holds GET /api/news open and hears the page's reading +// The fourth door. A served page holds GET /api/news open and hears the page's reading // named each time it moves, then asks for state. Nothing here looks at a file: `append` // speaks to every open stream, and what it says is the reading `state` carries. No // `error` is ever dispatched, there being no server to lose, and no `alive`: the diff --git a/plugins/leaf/skills/leaf/CLAUDE.md b/plugins/leaf/skills/leaf/CLAUDE.md index c64d5c6315..0cf8fc6b94 100644 --- a/plugins/leaf/skills/leaf/CLAUDE.md +++ b/plugins/leaf/skills/leaf/CLAUDE.md @@ -89,7 +89,7 @@ dynamic widget imports, and initial settlement; `runtime/geometry.js` owns the shared readings of visible boxes and clipping; `runtime/navigation.js` owns reader travel and scroller selection; `runtime/anchors.js` owns anchor resolution, paint, and anchor-specific travel; -`runtime/conversation/model.js` owns the thread fold; +`runtime/conversation/model.js` adapts server-projected threads to browser callers; `runtime/conversation/messages.js` owns message rendering; `runtime/conversation/replies.js` owns reply drafts, mirrored send state, and delivery; `runtime/conversation/inline.js` owns conversation seats rendered into the page; @@ -100,7 +100,8 @@ dynamic widget imports, and initial settlement; `runtime/conversation/reconcile.js` composes panel reconciliation; `runtime/projection/authored.js` owns captured authored state and restore statements; `runtime/projection/data.js` owns keyed runtime-data DOM -reconciliation; `runtime/projection/fold.js` owns canonical action and report state; +reconciliation; `runtime/projection/fold.js` adapts canonical action and report state +to live DOM nodes and the local outbox; `runtime/projection.js` owns projection reconciliation and undo. The entry module composes their mutually dependent callbacks. @@ -290,11 +291,11 @@ accepted event because its rendering failed. A recorded action may be optimistic because its gesture has already changed the DOM. Drag and edit are examples. `stageOutboxAction` gives that local value the -same semantic coordinate as logged state and commits it on the exact widget and -unit nodes that carry it. `stateProjection` overlays all surviving recorded -outbox actions after logged winners in `outboxOrder`. Until a complete read -accounts for an attempt, its local winner outranks any older log winner on the -same coordinate. +same semantic coordinate as the server view and commits it on the exact widget +and unit nodes that carry it. The browser projection adapter overlays all +surviving recorded outbox actions after authoritative winners in `outboxOrder`. +Until a complete read accounts for an attempt, its local winner outranks any +older log winner on the same coordinate. A press whose result has not changed the DOM waits for the log. Recordless settlements and completion presses do not enter the optimistic overlay. The @@ -319,11 +320,11 @@ represents the DOM. Delivery may continue while that correction waits for a live drag or editor to finish. `accountOutbox` runs only after `receiveState` has installed and rendered a -complete state. It links accepted events to entries, resolves readers waiting on +complete state. It links receipt events to entries, resolves readers waiting on those events, removes non-action entries whose delivery is complete, and calls -`releaseProjectedOutbox` for actions. Never remove an action merely because a -POST returned 200 or because an attempt appears in an event array that failed -partway through rendering. +`releaseProjectedOutbox` for actions. Never remove an action merely because a POST +returned 200 or because an attempt appears in a receipt list that failed partway +through rendering. `unaccountedGesture` is true while undo is in flight, the outbox is nonempty, or a widget is visibly dragging. Navigation and undo both consult it. Navigating @@ -355,7 +356,7 @@ therefore survives its response. The DOM is a projection of three ordered inputs: 1. the authored state captured from this version; -2. standing actions and reports in the authoritative log window; +2. standing actions and reports in the server's transaction-consistent browser view; 3. surviving optimistic recorded actions in the outbox. The semantic coordinate is @@ -374,16 +375,24 @@ projections. `x-awaits.answers` says which actions actually close the request; orthogonal actions do not, and neither does a conversation standing in the widget's declared `x-conversation` seat — that takes the request off the reader's list without answering it, which is why this gate reads the projection with no seats in -it. `x-awaits.rollup` derives a nested request from direct -interventions and child roll-ups, using the same reducer in the browser and file -projection. - -`stateProjection(upto)` is the pure derived view. It classifies every action and -report, applies version and retraction windows, drops withdrawn actions and -answered reports, folds the last action for each coordinate, retains report -winners, overlays unresolved local records, and gives a reader action precedence -over a provisional report on the same coordinate. Winners on independent -coordinates compose in event order through `compareProjected`. +it. `x-awaits.rollup` derives a nested request from direct interventions and child +roll-ups in Python; the browser receives the resulting ids and awaiting values. + +Python's `state_projection` is the durable derived view. Under the same page +transaction as `/api/state`, `browser_state` serializes its classified events and +winners, asks, conversations, updates, undo candidates, receipts, and coverage at +one `through_seq`. A normal response projects the revision the tab shows and the +active revision it may install next. A version comparison requests its older base +from `/api/view` at the exact `through_seq` already applied to the live DOM, so every +view used together has the same sequence basis without every state read parsing all +historical revisions. Page coordinates use that revision's document window; +conversation coordinates use the unbounded frozen-markup window. + +The browser's `stateProjection` is a DOM adapter. It resolves those declared +coordinates back to current widget modules and overlays unresolved local records. +It does not derive retractions, settlements, thread structure, asks, updates, or +undo eligibility from raw events. Winners on independent coordinates still +compose in event order through `compareProjected`. The two durable channels share the coordinate model but retain their meaning: @@ -397,8 +406,8 @@ The two durable channels share the coordinate model but retain their meaning: `stateProjection` is uncached because registry declarations resolve through the live DOM. Thread construction and recordless restoration can replace node identities. Its result has four views: `actions`, `reports`, `classified`, and -`desired`. Add a consumer to one of these views instead of building another fold -over raw `events`. +`desired`. Add a browser consumer to one of these views or extend the Python wire +view instead of building another fold over raw `events`. `committedProjection` is not a second state authority. It is a checkpoint of what node identities and semantic winner the DOM currently represents. Each @@ -409,11 +418,11 @@ its authored baseline stands. `projectionCommitted` compares the desired coordinate with that checkpoint. Terminal events count as committed because this version has no applicable state -to paint. `projectionCoverage` converts coordinate commits back to event -coverage for `data-lf-applied`: superseded actions and answered reports are -covered when the coordinate that represents them is committed, and an undo is -covered when its target's coordinate has moved to the prior winner or authored -baseline. +to paint. The server supplies coverage records and `projectionCoverage` checks +their coordinates against DOM commits for `data-lf-applied`: superseded actions +and answered reports are covered when the coordinate that represents them is +committed, and an undo is covered when its target's coordinate has moved to the +prior winner or authored baseline. ### Reconciliation @@ -521,9 +530,9 @@ documents written after this version. A widget instantiated inside frozen thread markup is in chrome and reads the whole action sequence because the conversation, not a page version, owns it. -Threads also read the whole log. `retractionFloors(Infinity)` keeps a -conversation current on a pinned page even when the document projection remains -historical. Registry-declared `x-conversation` seats show an exact-section +The server projects threads from the whole log, so a conversation stays current +on a pinned page even when the document projection remains historical. +Registry-declared `x-conversation` seats show an exact-section textual view while the owner exists in the current document; the Comments panel keeps the complete thread and its interactive replies. A root declared with `response: {kind: version, verb: }` keeps that exact-section view @@ -532,8 +541,9 @@ response. Dropping the owner drops only the inline seat. `restated` and answered-report relations persist through version notes. The note records the version floor for each affected id or report event; silence in a -later version does not revive retracted state. `retractedIds` uses containment, -not a global id lookup, when deciding which detailed parts an action rests on. +later version does not revive retracted state. Python's projection uses +containment, not a global id lookup, when deciding which detailed parts an action +rests on. ### Event sequences for modules @@ -575,12 +585,12 @@ field, verb, or widget name. Claims use their required detail as `detail.text` and `text`. The state boundary performs this normalization once, before downstream code sees private status storage. -`sequence` is the action traversal. It applies widget, optional verb, version -window, and liveness in one place, then returns structured clones so modules -cannot mutate the private event list. `watchActions` and `watchUpdates` subscribe -the two public readings to `lf-actions` and invoke the callback immediately. The -same rendering function therefore handles a module connected before the first -state and one constructed by a later thread reconcile. +`actionSequence` traverses the classified events in the installed server view, +then returns structured clones so modules cannot mutate the reading. +`updateSequence` filters the server-normalized update feed. `watchActions` and +`watchUpdates` subscribe the two public readings to `lf-actions` and invoke the +callback immediately. The same rendering function therefore handles a module +connected before the first state and one constructed by a later thread reconcile. `lf-actions` fires after a complete state has reconciled, including a read whose event list did not grow and the heartbeat's re-application of the state the page @@ -599,7 +609,7 @@ assertion is as old as its revision. `actionStands` answers whether one accepted action is still the reader's winner for its semantic coordinate. It treats a newly accepted event as standing when the tab has not yet installed an event list containing its id, then asks -`stateProjection` once authoritative history contains it. Modules use this +the installed projection once an authoritative receipt contains it. Modules use this after a send whose visible choreography depends on whether the accepted action survived later events. diff --git a/plugins/leaf/skills/leaf/assets/leaf.js b/plugins/leaf/skills/leaf/assets/leaf.js index 0bed865c7f..04986c2c69 100644 --- a/plugins/leaf/skills/leaf/assets/leaf.js +++ b/plugins/leaf/skills/leaf/assets/leaf.js @@ -1580,7 +1580,7 @@ mirrorDraft(generalInput, syncGeneral, "general"); let approving = false; function paintApproval() { - const approved = runtime.events.some( + const approved = (runtime.browser?.conversation?.done ?? []).some( (e) => e.kind === "done" && e.revision === runtime.currentRevision && @@ -1967,27 +1967,16 @@ function allButTheReference(binding) { // page stands down under them — and each declares what it keeps, which is how the // reference's own key goes on working while every other one is suspended. -const { askEntry, isAwaiting, projectedParent, threadMarkupAwaiting, unansweredAsks } = - createAskModel({ - authoredParentOf: (node) => authoredParents.get(node), - awaitsAgent, - buildThreads, - closestAcross: (...args) => closestAcross(...args), - elementById: (...args) => elementById(...args), - inChrome: (node) => inChrome(node), - matchesProjectedWhen: (...args) => matchesProjectedWhen(...args), - matchesWhen, - pagePresented, - projectedFacet: (...args) => projectedFacet(...args), - quoted, - registry, - runtime, - seatRoot, - settledAway: (...args) => settledAway(...args), - stateCoordinate: (...args) => stateCoordinate(...args), - stateProjection: (...args) => stateProjection(...args), - tagsDeclaring, - }); +const { askEntry, isAwaiting, projectedParent, unansweredAsks } = createAskModel({ + authoredParentOf: (node) => authoredParents.get(node), + closestAcross: (...args) => closestAcross(...args), + elementById: (...args) => elementById(...args), + pagePresented, + registry, + runtime, + stateProjection: (...args) => stateProjection(...args), + tagsDeclaring, +}); const { ASK_CONTROL, @@ -3064,12 +3053,12 @@ const { chooserLabel: () => labelOf(CHOOSER), domFacet: (...args) => domFacet(...args), elementById: (...args) => elementById(...args), - foldedFacet: (...args) => foldedFacet(...args), inChrome: (...args) => inChrome(...args), quoted, + projectionFromView: (...args) => projectionFromView(...args), + sameLayer, showToast, stateCoordinate: (...args) => stateCoordinate(...args), - stateProjection: (...args) => stateProjection(...args), stateSpecs: (...args) => stateSpecs(...args), textBlockSelector: () => TEXT_BLOCK, versionBtn, @@ -3367,9 +3356,6 @@ const runtimeProjection = createProjection(runtime, { projectedParent, quoteFrom, reachScrollers, - // Whether a reaction still paints, off the conversation's last fold — built after this - // module, so asked lazily. - reactionStanding: (e) => conversationRuntime.reactionStanding(e), rememberPassageParts, removeOutbox, renderQuiet, @@ -3393,11 +3379,11 @@ const { committedProjection, coordinateProjectionCommitted, domFacet, - foldedFacet, markSettled, matchesProjectedWhen, paintPending, projectedFacet, + projectionFromView, projectionCommitted, rebuild, reconcileKnownState, @@ -3406,13 +3392,10 @@ const { rememberAuthoredMarkup, resetAuthoredPage, requirementMatches, - retractedIds, - retractionFloors, stageOutboxAction, stateCoordinate, stateProjection, stateSpecs, - takenBack, undoable, unitOf, withdraw, @@ -3494,8 +3477,6 @@ conversationRuntime = createConversation({ renderQuiet, renderSaid, reportPageError, - retractedIds, - retractionFloors, runtime, saveDraft, scrollToElement, @@ -3504,10 +3485,8 @@ conversationRuntime = createConversation({ sendDraft, setPanel, settling, - takenBack, tellDraft, threadsBox, - threadMarkupAwaiting, toggleBtn, updateSequence, visualPartLabel, @@ -3518,10 +3497,8 @@ conversationRuntime = createConversation({ updateRuntime = createUpdates(runtime, { closestAcross, coordinateProjectionCommitted, - inChrome, projectionCommitted, stateProjection, - threadList: () => conversationRuntime.threadList, }); anchorRuntime = createAnchors({ diff --git a/plugins/leaf/skills/leaf/assets/runtime/asks/model.js b/plugins/leaf/skills/leaf/assets/runtime/asks/model.js index 0e252a8ed6..15b4193a9d 100644 --- a/plugins/leaf/skills/leaf/assets/runtime/asks/model.js +++ b/plugins/leaf/skills/leaf/assets/runtime/asks/model.js @@ -3,55 +3,26 @@ export const answeredContext = (...args) => publishedAskModel.answeredContext(.. export const askSource = (...args) => publishedAskModel.askSource(...args); export const openAsks = (...args) => publishedAskModel.openAsks(...args); +/* Server-projected ask state, resolved onto the browser's live DOM. */ export function createAskModel({ authoredParentOf, - awaitsAgent, - buildThreads, closestAcross, elementById, - inChrome, - matchesProjectedWhen, - matchesWhen, pagePresented, - projectedFacet, - quoted, registry, runtime, - seatRoot, - settledAway, - stateCoordinate, stateProjection, tagsDeclaring, }) { - // ---------- the ask, collected ---------- - // An ask is a standing request to the reader: a question with no pick on it, a change - // nobody has decided, a piece of work the page says is waiting on them. Which widgets - // can source one is the registry's answer (x-awaits); x-ask may give that source a - // broader reading and arrival surface. Nothing out here names a tag — - // the banner's count, the n/p walk, and the "?" overlay's row are three readings of this - // one list, so what the banner counts and what the key steps to cannot disagree. The - // count used to be a query for `lf-suggestion:not([data-lf-state])`, which was - // perfect for suggestions and silently blind to every other thing a page asks. - // - // Both halves of "unanswered" were already written down. Asking is the entry's own - // condition over the element's attributes: a group takes picks only with `choose` and - // stops asking once it is `settled`, a task waits only at `review` or `blocked`. And - // answered is the state of one of x-awaits' explicit answer verbs. An attribute record - // lets authored markup honor a pick and lets clearing it reopen the ask; another named - // verb answers through its surviving fold entry. Verbs not named there are orthogonal - // state, so moving a deadline cannot silently answer the decision it postpones. const askEntry = (el) => registry[el.tagName.toLowerCase()]?.["x-awaits"]; - // A request may begin before the widget that records its answer. x-ask gives that - // complete reading one authored region: the heading, context and evidence above the - // control travel with it, while the nested x-awaits widget remains the state owner. - // Both directions are structural and declaration-driven, so a custom region and a - // custom request join without core naming either tag. `version check` holds each region - // to one nested source, which makes askSource's answer unambiguous. + const askTags = () => tagsDeclaring((entry) => entry["x-awaits"]); const askSurfaceTags = () => tagsDeclaring((entry) => entry["x-ask"]); + function askSurface(el) { const tags = askSurfaceTags(); return (tags.length && closestAcross(el, tags.join(","))) || el; } + function askSource(el) { if (askEntry(el)) return el; const tags = askTags(); @@ -62,27 +33,9 @@ export function createAskModel({ ) ?? el ); } - // Every declared attribute holding one of the values that ask — a flag's two values - // being its presence and its absence, since it carries none of its own. - function answeredAsk(el, projection) { - const entry = registry[el.tagName.toLowerCase()]; - const verbs = entry["x-awaits"].answers ?? []; - // The fold holds one entry per facet and unit, so a recordless verb is - // answered only by an entry that is actually its own — a `choose` surviving in - // the selection facet says nothing about `answer`'s completion facet, and a - // cleared pick must ask again. - return verbs.some((verb) => { - const spec = entry["x-state"][verb]; - return ["attribute", "value"].includes(spec.record?.kind) - ? ![null, ""].includes(projectedFacet(el, spec, projection.actions)) - : projection.actions.get(stateCoordinate(el.id, el.id, spec))?.e.action === - verb; - }); - } - const askTags = () => tagsDeclaring((entry) => entry["x-awaits"]); - function askContext(projection = stateProjection(runtime.currentRevision)) { - const positionedParents = new Map(); + function positionedParents(projection) { + const parents = new Map(); for (const { unit, e, spec } of projection.desired.values()) { if (spec.record?.kind !== "position") continue; const parent = elementById(e.detail[spec.record.value]); @@ -96,258 +49,52 @@ export function createAskModel({ holder && (registry[moved.localName]?.["x-parent"] ?? []).includes(holder.localName) ) - positionedParents.set(unit, parent); + parents.set(unit, parent); } - const threads = buildThreads(); - return { - projection, - positionedParents, - settled: new Set( - threads.filter((thread) => thread.resolved).map((thread) => thread.root.id), - ), - // The widgets whose own seat holds a conversation now waiting on the agent. What - // makes this the reader's-list reading, and what `answeredContext` takes back out. - seatsWithAgent: new Set( - threads - .filter((thread) => awaitsAgent(thread)) - .map((thread) => seatRoot(thread)) - .filter(Boolean), - ), - }; - } - - // The same reducer asked the other question: whether a request is answered, rather than - // whether it is the reader's to deal with. `seatsWithAgent` is the entire difference - // between the two contexts, so emptying it is the entire difference here. Every reading - // built on either context rests on that being true of the whole shape: a second member - // derived from those same conversations would pass through this spread untouched and - // reach both callers unemptied, so derive one only by widening this function to empty it - // too. Stated here rather than by a caller writing the spread inline, so the two answers - // cannot drift apart. - // - // Three callers ask it. An action's `requires`: a conversation does not answer a - // question the widget holds no state for, and refusing a pick over the reader's own - // remark would refuse them the answer they were asked for. The version-response resolve - // gate asks the same projection on the file side. And `unansweredAsks`, for where the - // reader is standing: the ring and `c`'s destination say what they are working, not what - // they owe. - function answeredContext(projection) { - return { ...askContext(projection), seatsWithAgent: new Set() }; - } - - function askExists(el, context) { - if (quoted(el) || settledAway(el)) return false; - const thread = closestAcross(el, ".lf-thread, .lf-going"); - return !thread || !context.settled.has(thread.dataset.id); - } - - function projectedParent(el, context) { - return ( - (el.id && context.positionedParents.get(el.id)) ?? - authoredParentOf(el) ?? - el.parentElement - ); - } - - function nearestRollup(el, context) { - for ( - let node = projectedParent(el, context); - node; - node = projectedParent(node, context) - ) - if (askEntry(node)?.rollup) return node; - return null; - } - - function projectedContains(ancestor, el, context) { - for (let node = el; node; node = projectedParent(node, context)) - if (node === ancestor) return true; - return false; - } - - function locallyAsks(el, context) { - return ( - askExists(el, context) && - matchesProjectedWhen(el, askEntry(el).when, context.projection) - ); + return parents; } - // A conversation standing in this widget's own seat, with the agent. Declaration-driven - // at both ends: a widget with no x-conversation offers no seat, and one whose attributes - // miss the predicate has none placed on this instance either — so an element anchor - // written onto some other widget by `leaf comment` reaches nothing here. - // `conversationBox` asks the same question of the same declaration when it places the - // box, so the cell the reader can see and the request this takes off their list are the - // same one. It reads the live attributes because that is what the placement read; the - // registry refuses a record-written attribute in this predicate, which is what keeps the - // two from drifting as the log replays. - // - // Whose thread it is does not enter into it. The agent may open one in the seat too, and - // once the reader has answered there the question is with the agent either way — which is - // the only thing this is claiming. - function seatWithAgent(el, context) { - const declaration = registry[el.localName]?.["x-conversation"]; - return Boolean( - declaration && - matchesWhen(el, declaration.when) && - context.seatsWithAgent.has(el.id), - ); - } + const awaitingValues = (answered) => ({ + ...(answered + ? runtime.view?.document?.asks?.unanswered_awaiting + : runtime.view?.document?.asks?.awaiting), + ...(runtime.browser?.conversation?.asks?.awaiting ?? {}), + }); - // Whether a request is still one the reader has to deal with. Two things take it off - // their hands, and only one of them is an answer. - // - // A state verb answers it outright. A conversation standing in the widget's own seat does - // not — the group holds no pick and its controls still offer one — but while that - // conversation is with the agent the request is not the reader's to act on, and saying it - // is asked them a second time for what they had just written, in a box the page itself - // put under the question. That was the panel and the banner telling one fact two ways. - // - // So this is the reader's-list reading, and it is what the banner, the asks tray and the - // `n`/`p` walk want. Three readings want the other one — whether the request is answered - // at all — and all say so by asking with no seats in their context. An action's - // `requires` is one: a pick refused because the reader had remarked on the question would - // be refusing them the very answer they were asked for. The file-side version-response - // resolve gate is another. Where the reader is standing is the third (`unansweredAsks`), - // because standing in a question is what the reader is working and not what they owe. - // - // An ordinary reply or resolve hands the conversation back. A version-response - // conversation takes no reply. Authored state in a later version must answer its - // originating request; if it was already answered, that state must instead change the - // declared answer record. A log action cannot stand in for the revision. A seat answer - // that held for good would let a clarifying question retire a decision nobody made, - // invisibly to both sides, which is what the log's own defaults exist to refuse. - // - // Frozen thread markup seats no conversation of its own — `conversationBox` declines a - // widget standing inside a thread, whose reply box is already that seat — so this reaches - // only the page's widgets, which is exactly where there is a box to answer in. - function asksTheReader(el, context) { - return inChrome(el) - ? !answeredThreadAsk(el, context.projection) - : !answeredAsk(el, context.projection) && !seatWithAgent(el, context); + function context(answered = false) { + const projection = stateProjection(); + return { + awaiting: awaitingValues(answered), + positionedParents: positionedParents(projection), + projection, + }; } - // The ordinary case is one local request. A roll-up is the same request projected - // through a nested plan: a non-requesting node stops the walk; direct interventions - // take precedence; otherwise child roll-ups recurse; a leaf - // that matches its condition waits. Every relation is discovered from x-awaits, so - // a custom goal and a custom intervention join without a tag branch. - function isAwaiting(el, context, candidates = null) { - if (!askExists(el, context)) return false; - if (!matchesProjectedWhen(el, askEntry(el).when, context.projection)) return false; - const entry = askEntry(el); - if (!entry.rollup) return asksTheReader(el, context); - - const tags = askTags(); - const universe = - candidates ?? (tags.length ? [...document.querySelectorAll(tags.join(","))] : []); - const direct = tags.length - ? universe.filter( - (candidate) => candidate !== el && nearestRollup(candidate, context) === el, - ) - : []; - const interventions = direct.filter( - (candidate) => !askEntry(candidate).rollup && locallyAsks(candidate, context), - ); - if (interventions.length) - return interventions.some((candidate) => - isAwaiting(candidate, context, candidates), - ); - const children = direct.filter((candidate) => askEntry(candidate).rollup); - if (children.length) - return children.some((candidate) => isAwaiting(candidate, context, candidates)); - return asksTheReader(el, context); - } + const answeredContext = () => context(true); + const isAwaiting = (el, reading) => Boolean(reading.awaiting[el.id]); + const projectedParent = (el, reading) => + (el.id && reading.positionedParents.get(el.id)) ?? + authoredParentOf(el) ?? + el.parentElement; - // A reply may carry the request in its validated widget markup instead of in prose. - // Return one reading per supplied message body: null where it carries no x-awaits - // declaration, otherwise the declaration-driven standing value. The conversation - // reconciler connects every body under the panel before calling, so the shared action - // projection can find its widget ids and distinguish an open request from one the - // reader has already answered. One context serves the whole batch; folding the complete - // log again for every open thread would make a panel reconcile quadratic in its history. - function threadMarkupAwaiting(roots) { - const tags = askTags(); - if (!tags.length) return roots.map(() => null); - const groups = roots.map((root) => [...root.querySelectorAll(tags.join(","))]); - if (!groups.some((candidates) => candidates.length)) return roots.map(() => null); - const context = askContext(); - return groups.map((candidates) => - candidates.length - ? candidates.some((candidate) => isAwaiting(candidate, context, candidates)) - : null, - ); - } - - // The reader's list: the requests still theirs to deal with, which is what the banner, - // the asks tray and the `n`/`p` walk follow. The panel's count is a different fact — - // threads open, not answers owed. - function openAsks() { - return asksIn(askContext); - } - // The same list read for the other question, the one `answeredContext` states: which - // requests nothing has answered, rather than which are the reader's to deal with. A - // widget whose seat holds a conversation with the agent is on this list and off the - // other, its controls live and its answer unmade. `standingIn` is its reader: where the - // reader is standing is not what the reader owes. - function unansweredAsks() { - return asksIn(answeredContext); - } - // What either question comes to on the page, the context saying which — the shape the - // rest of this file already takes, `askExists`, `asksTheReader` and `isAwaiting` all - // taking one, and `openAsks` the one member that built its own. - // - // In document order, because that is the order the page asks them in and the order the - // reader walks — the chrome container sits after the page's blocks, so a thread's - // question queues behind the page's own. Quoted material asks nothing (an exhibited - // decision is a mention). A widget in a thread asks like one on the page: a question is a - // request to the reader wherever it stands. - // - // The context arrives as the reader of one rather than one already built: both are - // uncached full passes over the log, and the two guards below exist to skip exactly that - // work. An argument is evaluated before the call, so a built context paid for both folds - // on every early return — once per painted frame through the whole pre-presentation - // window, where the answer is `[]` either way. - function asksIn(readContext) { - // Before the first replay, the DOM carries authored initial state while the log may - // already answer it. This list drives both pixels and actions, so an empty list is the - // only honest answer until the presentation boundary says replay is complete. + function asks(kind) { if (!pagePresented()) return []; - const tags = askTags(); - if (!tags.length) return []; - const context = readContext(); - const open = [...document.querySelectorAll(tags.join(","))].filter((el) => - isAwaiting(el, context), - ); - // A roll-up delegates its visible request to the open intervention or child that - // made it true. Keep the actionable leaf in the banner and keyboard walk, not the - // same request repeated at each ancestor. - const visible = open.filter( - (el) => - !askEntry(el).rollup || - !open.some( - (candidate) => candidate !== el && projectedContains(el, candidate, context), - ), - ); - // The source decides whether the request stands; the surface is what the reader is - // asked to take in. A set keeps a malformed duplicate from inflating the chrome while - // the authored boundary still reports the ambiguity at version check. - return [...new Set(visible.map(askSurface))]; - } - // A thread ask has no version or restatement, but undo still withdraws an action. - // `x-awaits.until` therefore reads the same standing action projection as the DOM: - // a posted answer closes the ask, and taking it back opens the ask again. - function answeredThreadAsk(el, projection) { - const entry = registry[el.tagName.toLowerCase()]; - if (!Object.keys(entry["x-state"] ?? {}).length) return true; - const until = entry["x-awaits"].until; - if (until && matchesProjectedWhen(el, until.when, projection)) - return [...projection.actions.values()].some( - ({ e }) => e.widget === el.id && e.action === until.verb, - ); - return answeredAsk(el, projection); + const documentAsks = runtime.view?.document?.asks?.[kind] ?? []; + const conversationAsks = runtime.browser?.conversation?.asks?.[kind] ?? []; + const elements = [...documentAsks, ...conversationAsks] + .map((ask) => elementById(ask.id)) + .filter(Boolean); + return [...new Set(elements)].sort((left, right) => { + if (left === right) return 0; + return left.compareDocumentPosition(right) & Node.DOCUMENT_POSITION_FOLLOWING + ? -1 + : 1; + }); } + const openAsks = () => asks("reader"); + const unansweredAsks = () => asks("unanswered"); + const model = { answeredContext, askEntry, @@ -355,7 +102,6 @@ export function createAskModel({ isAwaiting, openAsks, projectedParent, - threadMarkupAwaiting, unansweredAsks, }; publishedAskModel = model; diff --git a/plugins/leaf/skills/leaf/assets/runtime/context.js b/plugins/leaf/skills/leaf/assets/runtime/context.js index a4dfc11411..e8467d0848 100644 --- a/plugins/leaf/skills/leaf/assets/runtime/context.js +++ b/plugins/leaf/skills/leaf/assets/runtime/context.js @@ -6,6 +6,7 @@ export const runtime = { active: null, agent: "Claude", + browser: null, currentLabel: null, currentRevision: null, currentStamp: null, @@ -19,6 +20,7 @@ export const runtime = { statePhase: "waiting", undoing: false, versions: [], + view: null, }; export const agentName = () => runtime.agent; diff --git a/plugins/leaf/skills/leaf/assets/runtime/conversation/model.js b/plugins/leaf/skills/leaf/assets/runtime/conversation/model.js index 1a7c64fc3a..cae70ee14a 100644 --- a/plugins/leaf/skills/leaf/assets/runtime/conversation/model.js +++ b/plugins/leaf/skills/leaf/assets/runtime/conversation/model.js @@ -1,154 +1,20 @@ -/* The conversation log's version-independent thread fold. */ +/* Conversation structure and turn-taking projected by the server. */ import { sameAnchor } from "../anchors.js"; -export function createThreadModel(dependencies) { - const { - elementById, - markupAwaiting, - registry, - retractedIds, - retractionFloors, - runtime, - takenBack, - } = dependencies; - - // A reaction is a message carrying a token in place of words ($events): a mark on its - // target rather than a turn in the conversation. `spoken` is a thread's turns; one with - // none is a bare reaction — paint on the page, no card in the panel — until somebody - // replies to it, from which point it is a conversation whose root happens to be a mark. - // interact.py reads the log by the same three names (events.py), so the panel and - // `page state` cannot come to list different threads. - const isReaction = (m) => Boolean(m.token); - const spoken = (t) => t.msgs.filter((m) => !isReaction(m)); - // What a card shows: the turns, and the root whatever it is — a thread that grew out - // of a reaction opens on the mark that started it, which is what the agent answered. - const turns = (t) => t.msgs.filter((m) => m === t.root || !isReaction(m)); - const bareReaction = (t) => isReaction(t.root) && !spoken(t).length; - const conversational = (t) => !bareReaction(t); +export function createThreadModel({ registry, runtime }) { + const isReaction = (message) => Boolean(message.token); + const spoken = (thread) => thread.msgs.filter((message) => !isReaction(message)); + const turns = (thread) => + thread.msgs.filter( + (message) => message.id === thread.root.id || !isReaction(message), + ); + const bareReaction = (thread) => thread.bare_reaction; + const conversational = (thread) => !bareReaction(thread); const tokenEntry = (name) => registry.$reactions.tokens[name]; - // ---------- threads ---------- - // The fold this module last produced, which is what the reaction readings below - // answer from: they are asked on every key-line paint and on every press of the bar, - // and folding the whole log again for each would be a second answer to a question - // the panel has just answered. let lastThreads = []; function buildThreads() { - const threads = new Map(); - const threadFor = new Map(); - // The whole log, not this version's window: a conversation is not version-scoped, so - // the panel shows the same threads whichever version is pinned and a retraction - // settles a thread's state from wherever it was declared. interact.py's callers pass - // upto=None for the same reason. Replay windows to currentRevision instead, and on any version - // but the newest the two are meant to disagree — the rule binds both sites, so it is - // stated once in the skill's CLAUDE.md, under "A pinned version scopes the document, - // never the conversation". - const floors = retractionFloors(Infinity); - const withdrawn = takenBack(); - // widget id -> its last action the log still lets stand: not one the reader took - // back, not one a version retracted under it. The widget is what an ask is - // (x-awaits), so what answers one is that widget's own last word; and it is the - // only key the log carries by itself, which is why the page projection cannot be - // borrowed for this: it drops an action whose widget the page no longer holds, and the - // version that honors a decision retires the widget that made it, precisely when the - // thread it settled most needs to stay settled. x-state holds a verb declaring - // `resolves` to a widget-absolute unit so the two keys are the same one. - const answers = new Map(); - const settlingActions = new Set(); - for (const e of runtime.events) - if ( - e.kind === "action" && - !withdrawn.has(e.id) && - !retractedIds(e, floors, elementById(e.widget)).length - ) { - answers.set(e.widget, e); - if (e.detail.resolves) settlingActions.add(e.id); - } - const messages = new Map(); - for (const e of runtime.events) { - // A gesture the reader took back settles nothing, whichever way it settled: the - // log holds it and no reading of the log stands on it. The same sentence - // interact.py's build_threads reads, because it is the same reading. - if (withdrawn.has(e.id)) continue; - if (e.kind === "comment") { - // `resolved` is the event that currently closes the thread, or null. Either - // side can close one, so a flag beside a second field naming who would be two - // readings of one fact; the event answers both and carries its own author. - const message = { ...e }; - messages.set(e.id, message); - const thread = { root: message, msgs: [message], resolved: null }; - threads.set(e.id, thread); - threadFor.set(e.id, thread); - continue; - } - // The widget's standing answer closes the thread it names. The answer snapshots the thread - // it was made in, because the honoring version retires the wrapper that held the - // mapping and one atomic event cannot half-arrive the way a second POST could. - // The log is the only place that pairing survives. - // - // Read off the detail rather than the verb, because the naming is the - // mechanism's and the verb is a member's: `accept` stood here once, which was - // exactly right for the one widget that says that word and silently nothing - // for the next widget whose answer closes the question it was asked in. That - // is the failure the widget list's norm names — it arrives as a feature - // nobody wired up rather than as an error. A verb carries only the detail - // keys its entry declares (additionalProperties: false), so a `resolves` is - // one on purpose, and an answer that settles no thread carries none. - // - // Standing is every way the log currently holds an answer: not retracted by a - // version that rewrote what the decision rested on (`restated`), not taken back - // by the reader (the skip above), and not superseded by a later action on the - // same widget. Without that last one, a - // reject after an accept left the reader's question filed away as answered by - // the fix they had just turned down, while the fold reported the suggestion - // rejected: the log held one thing, the panel showed another, and nothing on - // either side said so. - // - // Folded at the answer's own place in the walk rather than after it: a resolve - // pressed between two decisions remains the last current word on the thread, - // while every surviving settlement keeps its causal position. - if (e.kind === "action") { - const answered = threads.get(e.detail.resolves); - if (answered && settlingActions.has(e.id)) { - if (answers.get(e.widget) === e) answered.resolved = e; - } - continue; - } - if (e.kind === "edit") { - const message = messages.get(e.message); - if (message) { - message.text = e.text; - message.edited = { id: e.id, seq: e.seq, ts: e.ts }; - } - continue; - } - // A reply whose message the log lost opens the thread that message would have - // opened, under the id it was known by — the same answer interact.py's - // build_threads gives, because it is the same reading. The log is read line by - // line and a torn one is skipped, so a reply can outlive the message above it; - // throwing here took the whole panel down over one lost line. - if (e.kind === "reply") { - let thread = threadFor.get(e.parent); - if (!thread) { - thread = { root: e, msgs: [], resolved: null }; - threads.set(e.parent, thread); - threadFor.set(e.parent, thread); - } - const message = { ...e }; - messages.set(e.id, message); - thread.msgs.push(message); - threadFor.set(e.id, thread); - } else if (e.kind === "resolve") { - // A resolve names a message rather than opening one, so a conversation the log - // lost whole has nothing for it to close. - const thread = threadFor.get(e.parent); - if (thread) thread.resolved = e; - } else if (e.kind === "unresolve") { - const thread = threadFor.get(e.parent); - if (thread) thread.resolved = null; - } - } - lastThreads = [...threads.values()]; + lastThreads = runtime.browser?.conversation?.threads ?? []; return lastThreads; } @@ -158,86 +24,21 @@ export function createThreadModel(dependencies) { const reactionsOn = (anchor) => lastThreads .filter( - (t) => bareReaction(t) && !t.resolved && sameAnchor(t.root.anchor, anchor), + (thread) => + bareReaction(thread) && + !thread.resolved && + sameAnchor(thread.root.anchor, anchor), ) - .map((t) => t.root); - // Whether a reaction still paints, and so can still be taken back: in an unresolved - // thread, and answered by no turn — the same three the door refuses (`undo_error`). - const reactionStanding = (e) => { - const thread = lastThreads.find((t) => t.msgs.some((m) => m.id === e.id)); - return ( - Boolean(thread) && - !thread.resolved && - !thread.msgs.some((m) => m.parent === e.id && !isReaction(m)) - ); - }; - - // ---------- whose turn a thread is ---------- - // A resolved thread waits on nobody. An agent comment opens a question; a reply leaves - // one only when its prose flag says so or its own x-awaits markup still asks. Anyone - // else speaking last hands the thread to the agent. Neither reading is the other's - // negation, and both have to say so. - // - // Not the agent, rather than the reader: `author` is an open string on every message - // contract, and the two written today are `user` and `claude`. A line from anywhere else - // reads as owed an answer, which is the direction to err in — an unanswered word is - // invisible to everyone, while one answer too many costs a reply. interact.py's - // `awaits_agent` is the same sentence for the same reason. - // - // Turns, not marks: a reaction on a message is not the reader speaking. Structured - // reply asks use the same x-awaits projection as the asks board rather than a parallel - // event flag. The one declared exception runs the other way — a token whose entry - // says `settles`, standing on the agent's latest message, is the reader saying "seen, - // go on" and takes the thread out of the waiting list without a second event. Take the - // ok back and the wait comes back, this being a reading of the log rather than a state - // anything wrote; core reads the flag and never the token's name. - // - // `markupAwaiting` is the conversation reconciler's reading of those reply bodies. It - // populates the map for every open last reply immediately before filtering or painting - // the list; public callers use `awaitsReader` only after that render stage. Calling this - // on a newly built fold before `renderThreads` would not yet have the structural half. - const awaitsReader = (t) => { - if (t.resolved) return false; - const last = spoken(t).at(-1); - if (last?.author !== "claude") return false; - if (last.kind === "reply") { - const structural = markupAwaiting(last); - if (structural === false || (structural === null && !last.awaits)) return false; - } - return !t.msgs.some( - (m) => - isReaction(m) && - m.author === "user" && - m.parent === last.id && - tokenEntry(m.token)?.settles, - ); - }; - const awaitsAgent = (t) => - !t.resolved && spoken(t).length > 0 && spoken(t).at(-1).author !== "claude"; - - // The widget whose seat a root stands in: an element anchor naming that widget and - // carrying nothing else. `renderConversations` collects the seat's own view from this, - // and the ask projection asks it of the same anchor — so the conversation the reader - // can see standing in the cell is the one that takes the request off their list, and - // the two surfaces cannot come to disagree about which conversation is the widget's. - // - // A reply whose root the log lost is its own root and carries no anchor, so it seats - // nowhere. That is the honest answer: no cell on the page shows it either. - const seatRoot = (t) => - !t.root.about && t.root.anchor && Object.keys(t.root.anchor).length === 1 - ? (t.root.anchor.section ?? null) - : null; - + .map((thread) => thread.root); return { - awaitsAgent, - awaitsReader, + awaitsAgent: (thread) => thread.awaits_agent, + awaitsReader: (thread) => thread.awaits_reader, bareReaction, buildThreads, conversational, isReaction, - reactionStanding, reactionsOn, - seatRoot, + seatRoot: (thread) => thread.seat, spoken, tokenEntry, turns, diff --git a/plugins/leaf/skills/leaf/assets/runtime/conversation/reconcile.js b/plugins/leaf/skills/leaf/assets/runtime/conversation/reconcile.js index 27f6f9db05..377baef4ca 100644 --- a/plugins/leaf/skills/leaf/assets/runtime/conversation/reconcile.js +++ b/plugins/leaf/skills/leaf/assets/runtime/conversation/reconcile.js @@ -62,8 +62,6 @@ export function createConversation(dependencies) { renderQuiet, renderSaid, reportPageError, - retractedIds, - retractionFloors, runtime, saveDraft, scrollToElement, @@ -73,10 +71,8 @@ export function createConversation(dependencies) { sendReaction, setPanel, settling, - takenBack, tellDraft, threadsBox, - threadMarkupAwaiting, toggleBtn, updateSequence, visualPartLabel, @@ -84,7 +80,6 @@ export function createConversation(dependencies) { withdraw, } = dependencies; let threadList = []; - const markupAwaiting = new Map(); const { awaitsAgent, awaitsReader, @@ -92,20 +87,14 @@ export function createConversation(dependencies) { buildThreads, conversational, isReaction, - reactionStanding, reactionsOn, spoken, seatRoot, tokenEntry, turns, } = createThreadModel({ - elementById, registry, - retractedIds, - retractionFloors, runtime, - takenBack, - markupAwaiting: (message) => markupAwaiting.get(message.id) ?? null, }); const { anchorLabel, @@ -702,45 +691,6 @@ export function createConversation(dependencies) { const grow = panelIsOpen() && Boolean(threadsBox.querySelector(":scope > .lf-thread")); - // A structured reply asks through its x-awaits widgets. Project the last reply bodies - // before applying the panel's narrowing, so a new question cannot be excluded before - // its markup has had a chance to admit the thread. Stage only the cached bodies, never - // complete hidden thread cards: building a card wires its reply box to document-level - // draft and flight listeners, and a filtered card has no node the next reconcile can - // reuse. Each body keeps skeletal thread/message ancestry while connected: widget - // setup may read its message timestamp or card geometry, and upgrades only once before - // the cache moves it into the lasting card. Connection lets the shared action fold - // find widget ids and distinguish an open request from one the reader already answered. - // The stage leaves synchronously, before a frame can expose it. - const markupReplies = []; - for (const t of open) { - const last = spoken(t).at(-1); - if (last?.kind !== "reply") continue; - if (!last.markup) { - markupAwaiting.set(last.id, null); - continue; - } - markupReplies.push({ reply: last, thread: t }); - } - const bodies = markupReplies.map(({ reply }) => msgBody(reply)); - const stage = el("div", "lf-thread-stage"); - bodies.forEach((body, index) => { - if (body.isConnected) return; - const { reply, thread } = markupReplies[index]; - const skeleton = el("div", "lf-thread"); - skeleton.dataset.id = thread.root.id; - skeleton.append(msgNode(reply)); - stage.append(skeleton); - }); - if (stage.childElementCount) { - threadsBox.append(stage); - captureAuthoredFacets(stage); - } - const readings = threadMarkupAwaiting(bodies); - markupReplies.forEach(({ reply }, index) => - markupAwaiting.set(reply.id, readings[index]), - ); - stage.remove(); // Where the reader's own narrowing applies, and the only place it does: the page's // marks, the inline conversation seats and the banner's count are readings of the log // and go on saying what the log says. What the panel shows is the panel's business. @@ -775,9 +725,8 @@ export function createConversation(dependencies) { } wanted.push(node); } - for (const e of runtime.events) { - if (e.kind === "done") wanted.push(systemNode(e, `✓ Approved ${ago(e.ts)}`)); - } + for (const e of runtime.browser?.conversation?.done ?? []) + wanted.push(systemNode(e, `✓ Approved ${ago(e.ts)}`)); if (resolved.length) { if (!resolvedBox) { resolvedBox = el("details", "lf-details"); @@ -1096,7 +1045,6 @@ export function createConversation(dependencies) { bareReaction, paintStanding, reactionsOn, - reactionStanding, loadMarked, anchorLabel, openThreads, diff --git a/plugins/leaf/skills/leaf/assets/runtime/drafts.js b/plugins/leaf/skills/leaf/assets/runtime/drafts.js index 03f1e842cb..9a9f8a36ee 100644 --- a/plugins/leaf/skills/leaf/assets/runtime/drafts.js +++ b/plugins/leaf/skills/leaf/assets/runtime/drafts.js @@ -62,7 +62,7 @@ const parseDraftRecord = (value) => { } }; const attemptAccepted = (attempt) => - runtime.events.some((event) => event.attempt === attempt); + (runtime.browser?.receipts ?? []).some((event) => event.attempt === attempt); const writeDraftRecord = (ctx, record) => draftStore.set(DRAFT + ctx, JSON.stringify(record)); const rawDraftRecord = (ctx) => { diff --git a/plugins/leaf/skills/leaf/assets/runtime/layer-client.js b/plugins/leaf/skills/leaf/assets/runtime/layer-client.js index 24a6829e0c..e2fe48576e 100644 --- a/plugins/leaf/skills/leaf/assets/runtime/layer-client.js +++ b/plugins/leaf/skills/leaf/assets/runtime/layer-client.js @@ -24,6 +24,9 @@ export function createLayerClient({ currentRevision, layerGeneration }) { headers: { "Content-Type": "application/json", "Leaf-Layer": layerGeneration, + ...(currentRevision() && { + "Leaf-View-Revision": String(currentRevision()), + }), }, body: JSON.stringify(event), }); diff --git a/plugins/leaf/skills/leaf/assets/runtime/outbox.js b/plugins/leaf/skills/leaf/assets/runtime/outbox.js index c632f78292..142bdd8d7b 100644 --- a/plugins/leaf/skills/leaf/assets/runtime/outbox.js +++ b/plugins/leaf/skills/leaf/assets/runtime/outbox.js @@ -82,16 +82,18 @@ export function createOutbox(runtime, dependencies) { // only statement this tab knows, so its caller may paint while the outbox keeps replay // and undo held for a complete read. function actionStands(event) { - if (!runtime.events.some((candidate) => candidate.id === event.id)) return true; + if ( + !(runtime.browser?.receipts ?? []).some((candidate) => candidate.id === event.id) + ) + return true; const el = elementById(event.widget); const spec = el && registry[el.localName]?.["x-state"]?.[event.action]; if (!el || !spec) return false; const unit = unitOf(event, spec); if (typeof unit !== "string") return false; return ( - stateProjection(runtime.currentRevision).actions.get( - stateCoordinate(event.widget, unit, spec), - )?.e.id === event.id + stateProjection().actions.get(stateCoordinate(event.widget, unit, spec))?.e.id === + event.id ); } @@ -177,7 +179,7 @@ export function createOutbox(runtime, dependencies) { continue; } const answer = decoded.answer; - const acceptedEvent = answer?.state?.events?.find( + const acceptedEvent = answer?.state?.browser?.receipts?.find( (candidate) => candidate.attempt === event.attempt, ); if (res.ok && answer?.ok === true && acceptedEvent) { diff --git a/plugins/leaf/skills/leaf/assets/runtime/projection.js b/plugins/leaf/skills/leaf/assets/runtime/projection.js index 7ac4cb4ebb..4f3b68f7bb 100644 --- a/plugins/leaf/skills/leaf/assets/runtime/projection.js +++ b/plugins/leaf/skills/leaf/assets/runtime/projection.js @@ -36,7 +36,6 @@ export function createProjection(runtime, dependencies) { projectedParent, quoteFrom, reachScrollers, - reactionStanding, rememberPassageParts, removeOutbox, renderQuiet, @@ -179,36 +178,24 @@ export function createProjection(runtime, dependencies) { const { compareProjected, foldedFacet, - retractedIds, - retractionFloors, + projectionFromView, standingState, stateProjection, - takenBack, } = createProjectionFold(runtime, { COLLAPSE, - containsAcross, domFacet, elementById, - inChrome, outbox, - stateCoordinate, - unitOf, }); // One canonical current reading for action admission as for replay: the latest desired // action/report at an owner-unit-facet coordinate, falling back to the version's // captured authored facet. A gesture may already have changed the live DOM before it // calls sendAction, so eligibility never reads that mutable rendering. - function projectedFacet( - widget, - spec, - winners = stateProjection(runtime.currentRevision).desired, - ) { + function projectedFacet(widget, spec, winners = stateProjection().desired) { const coordinate = stateCoordinate(widget.id, widget.id, spec); const winner = winners.get(coordinate); - return winner - ? foldedFacet(winner.e, winner.spec.record) - : authoredFacets.get(coordinate); + return winner ? winner.value : authoredFacets.get(coordinate); } // x-awaits conditions normally name authored configuration attributes (choose, @@ -284,6 +271,7 @@ export function createProjection(runtime, dependencies) { coordinate, localOrder: entry.order, e: { ...e, id: entry.localId }, + value: foldedFacet(e, spec.record), }; entry.projection = projection; committedProjection.set(coordinate, { @@ -314,7 +302,8 @@ export function createProjection(runtime, dependencies) { // Whether removing one action leaves the reconciler a state it can paint. The actual // transition belongs to reconciliation; this is only the keyboard offer, bounded to // the version where the gesture was made. - function canUndoAction(e) { + function canUndoAction(candidate) { + const e = candidate.event; const el = elementById(e.widget); if (!el || !el.applyAction) return false; const spec = registry[el.tagName.toLowerCase()]?.["x-state"]?.[e.action]; @@ -323,7 +312,7 @@ export function createProjection(runtime, dependencies) { const unit = unitOf(e, spec); const coordinate = stateCoordinate(e.widget, unit, spec); return ( - stateProjection(runtime.currentRevision, e.id).desired.has(coordinate) || + candidate.restores_desired || authoredDetails.has(coordinate) || authoredMarkup.has(e.widget) ); @@ -336,20 +325,15 @@ export function createProjection(runtime, dependencies) { // `leaf resolve` is not theirs to undo — and never an undo itself, which is what makes // repeated presses a walk backwards instead of a toggle. function undoable() { - const withdrawn = takenBack(); - for (let i = runtime.events.length - 1; i >= 0; i--) { - const e = runtime.events[i]; - if (e.author !== "user" || e.kind === "undo" || withdrawn.has(e.id)) continue; + for (const candidate of runtime.view?.undo ?? []) { + const e = candidate.event; if (e.kind === "resolve" || e.kind === "unresolve") return e; // A reaction, while it is still a mark: a message with words in it is said rather // than unsaid, and a token someone has answered is a conversation now — the // reader's move is in the thread the answer opened. One standing on a resolved // thread paints nothing, so there is nothing to offer the press. The server // refuses the same three (undo_error), this being the offer and that the door. - if (e.token) { - if (reactionStanding(e)) return e; - continue; - } + if (e.token) return e; // On the version it was made against: a later version may have been written // around the decision, and a press that paints nothing is not one to offer. What // *hearing* such an undo owes is reconciliation's, and is not the same answer. @@ -357,7 +341,7 @@ export function createProjection(runtime, dependencies) { if ( widget && (inChrome(widget) || e.revision === runtime.currentRevision) && - canUndoAction(e) + canUndoAction(candidate) ) return e; } @@ -492,7 +476,7 @@ export function createProjection(runtime, dependencies) { // complete read has committed the coordinate that now represents them; rejected // entries leave only after their optimistic token no longer represents the DOM. function releaseProjectedOutbox() { - const projection = stateProjection(runtime.currentRevision); + const projection = stateProjection(); let removed = false; for (const entry of [...outbox]) { if (!entry.answered || entry.event.kind !== "action") continue; @@ -512,14 +496,15 @@ export function createProjection(runtime, dependencies) { // to a prior winner or the authored baseline. function projectionCoverage(projection) { let covered = 0; - for (const e of runtime.events) { - if (e.kind === "action" || e.kind === "report") { - if (projectionCommitted(projection, e)) covered += 1; - } else if (e.kind === "undo") { - const target = projection.classified.get(e.undoes); - if (!target || target.terminal || projectionCommitted(projection, target.e)) - covered += 1; + for (const record of runtime.view?.coverage ?? []) { + if (record.coordinate === null) { + covered += 1; + continue; } + const e = record.event; + const target = projection.classified.get(e.kind === "undo" ? e.undoes : e.id); + if (!target || target.terminal || projectionCommitted(projection, target.e)) + covered += 1; } return covered; } @@ -576,7 +561,7 @@ export function createProjection(runtime, dependencies) { // A baseline no action detail can state replaces a subtree: a recordless verb, or // an optional authored scalar absent before its first action. for (;;) { - projection = stateProjection(runtime.currentRevision); + projection = stateProjection(); for (const entry of projection.classified.values()) for (const id of entry.restated ?? []) elementById(id)?.setAttribute(PAGE_PAINT_ATTRIBUTE.restated, "1"); @@ -747,7 +732,7 @@ export function createProjection(runtime, dependencies) { } renderQuiet(document.body); paintPending(); - projection = stateProjection(runtime.currentRevision); + projection = stateProjection(); document.body.setAttribute( PAGE_PAINT_ATTRIBUTE.applied, String(projectionCoverage(projection)), @@ -762,9 +747,9 @@ export function createProjection(runtime, dependencies) { // while completing the read itself; every asynchronous wake-up must use either the // authored-only starting point or an event list whose whole state already committed. function reconcileKnownState() { - const eventSeq = runtime.events.at(-1)?.seq ?? 0; + const eventSeq = runtime.browser?.basis?.through_seq ?? 0; const complete = runtime.statePhase === "ready" && eventSeq <= runtime.lastEventSeq; - const authoredOnly = runtime.lastEventSeq < 0 && runtime.events.length === 0; + const authoredOnly = runtime.lastEventSeq < 0 && eventSeq === 0; if (!complete && !authoredOnly) return false; reconcileState(); paintWorkLines(); @@ -780,13 +765,11 @@ export function createProjection(runtime, dependencies) { function paintPending() { for (const attr of [PAGE_PAINT_ATTRIBUTE.pending, PAGE_PAINT_ATTRIBUTE.reported]) for (const el of pageQueryAll(`[${attr}]`)) el.removeAttribute(attr); - const projection = stateProjection(runtime.currentRevision); - for (const [coordinate, { unit, e, spec }] of projection.desired) { + const projection = stateProjection(); + for (const [coordinate, { unit, e, spec, value }] of projection.desired) { const el = elementById(unit); if (!el || inChrome(el)) continue; - const behind = spec.record - ? foldedFacet(e, spec.record) !== authoredFacets.get(coordinate) - : true; + const behind = spec.record ? value !== authoredFacets.get(coordinate) : true; if (!behind) continue; // The channels keep separate marks so provisional worker news never wears // the reader's color. The desired projection chooses which channel owns a @@ -810,11 +793,11 @@ export function createProjection(runtime, dependencies) { committedProjection, coordinateProjectionCommitted, domFacet, - foldedFacet, markSettled, matchesProjectedWhen, paintPending, projectedFacet, + projectionFromView, projectionCommitted, rebuild, reconcileKnownState, @@ -823,15 +806,12 @@ export function createProjection(runtime, dependencies) { rememberAuthoredMarkup, resetAuthoredPage, requirementMatches, - retractedIds, - retractionFloors, shallowSigs, stageOutboxAction, standingState, stateCoordinate, stateProjection, stateSpecs, - takenBack, undoLast, undoable, unitOf, diff --git a/plugins/leaf/skills/leaf/assets/runtime/projection/authored.js b/plugins/leaf/skills/leaf/assets/runtime/projection/authored.js index 003d66b99f..868fb9ad09 100644 --- a/plugins/leaf/skills/leaf/assets/runtime/projection/authored.js +++ b/plugins/leaf/skills/leaf/assets/runtime/projection/authored.js @@ -51,9 +51,9 @@ export function createAuthoredProjection(dependencies) { // both readings collapse to the sorted ids, and comparing them stays a !==. // // The id-bearing ones only, because an id is how a member of that set is named - // everywhere else: in the action detail the fold reads back (foldedFacet sorts - // the ids the log carries) and in interact.py's reading of the same page, which - // can see none but those. One marked element without an id contributed an empty + // everywhere else: in the canonical value the server projects (and the local + // outbox adapter sorts before that round trip) and in interact.py's reading of the + // same page, which can see none but those. One marked element without an id contributed an empty // string that sorted to the front of the join, so a set the two sides agreed on // came out with a leading space on this one. function domFacet(el, record) { diff --git a/plugins/leaf/skills/leaf/assets/runtime/projection/fold.js b/plugins/leaf/skills/leaf/assets/runtime/projection/fold.js index 2953727ad2..d56cb2572d 100644 --- a/plugins/leaf/skills/leaf/assets/runtime/projection/fold.js +++ b/plugins/leaf/skills/leaf/assets/runtime/projection/fold.js @@ -1,94 +1,8 @@ -/* Canonical action and report state folded from the event log and local outbox. */ +/* The server's durable projection, adapted to live DOM nodes plus the local outbox. */ export function createProjectionFold(runtime, dependencies) { - const { - COLLAPSE, - containsAcross, - domFacet, - elementById, - inChrome, - outbox, - stateCoordinate, - unitOf, - } = dependencies; + const { COLLAPSE, domFacet, elementById, outbox } = dependencies; const { registry } = runtime; - // What an action rests on: the widget that sent it, and the parts of that widget - // its detail names — a `move` rests on its card as much as on the board. Either - // can be taken back, which is what lets a rewritten card drop its own moves while - // the rest of the board stays where the user put it. Containment is the test, - // not "the page has an element by that id", so a literal detail value can't - // collide with an unrelated element that happens to be called the same thing. - function restsOn(e, widget) { - // Every detail field but `resolves`, which the registry reserves for the comment - // thread the action answers. A conversation is not on the page to be contained, so - // the only thing the test above could find under that key is an element inside the - // widget spelled the same — the one collision containment does not rule out. - // interact.py's action_rests_on reads past it for the same reason. - // flatMap, because a detail field may name several elements at once (a group's - // set of picks) and each of them is something the action rests on. - const parts = Object.entries(e.detail) - .filter(([field]) => field !== "resolves") - .flatMap(([, named]) => named) - .map((v) => (typeof v === "string" ? elementById(v) : null)) - .filter((el) => el && widget && containsAcross(widget, el)) - .map((el) => el.id); - return [e.widget, ...parts]; - } - // Which of those a later version took back. One spelling of the rule, because three - // readings ask it — replay, the fold, and the thread list — and a decision standing in - // one of them and retracted in another is the drift `restated` exists to prevent. The - // ids rather than a boolean, since replay says so on the page (data-lf-restated) and - // the other two only count them. - // - // A widget the page no longer holds answers for itself alone, which is what a version - // honoring a decision leaves behind: the wrapper is retired, so there is nothing to ask - // about containment and nothing that should read as a retraction — retirement is the - // decision being carried out, not taken back. That is also the answer interact.py gives - // without trying, reading a version file where the same element is simply absent. - function retractedIds(e, floors, widget) { - return restsOn(e, widget).filter((id) => (floors.get(id) ?? 0) > e.revision); - } - // Retractions: a version that rewrote the words or state under a decision says - // so with `restated`, and stamping records it on the note that released it. - // Reading it from the log rather than from the markup is what makes it last — - // the version *after* the rewrite declares nothing, and its silence would - // otherwise hand the user's retracted state straight back. - // Retractions and settlements are separate durable relations carried by the same - // version note. `restated` retracts reader decisions; typed `settles` targets end - // provisional agent facts without overloading a field name or an id namespace. - // Memoized on the log's identity and the relation/window query: `events` has one writer, - // which replaces the array wholesale (poll), so a cached answer cannot be stale and - // every consumer shares the same filter-and-max fold. - const noteFloorsMemo = new WeakMap(); - function noteFloors(relation, upto, idsOf) { - let byQuery = noteFloorsMemo.get(runtime.events); - if (!byQuery) noteFloorsMemo.set(runtime.events, (byQuery = new Map())); - const query = `${relation}:${upto}`; - if (byQuery.has(query)) return byQuery.get(query); - const floors = new Map(); - for (const e of runtime.events) - if (e.kind === "note" && e.revision <= upto) - for (const id of idsOf(e)) - floors.set(id, Math.max(floors.get(id) ?? 0, e.revision)); - byQuery.set(query, floors); - return floors; - } - const retractionFloors = (upto) => - noteFloors("retraction", upto, (e) => e.restated ?? []); - // A report's end: the ids the notes in the window answered, absorbed or - // overruled — the agent channel's mirror of retractionFloors, read from the - // log for the same reason (the version after the answer declares nothing, and - // its silence must not hand the report back). - const settledReports = (upto) => - noteFloors("settlement:report", upto, (e) => - (e.settles ?? []) - .filter((target) => target.kind === "report") - .map((target) => target.id), - ); - - // The state the folded action left, from the detail field the record declares, - // collapsed the way the DOM reading collapses — its words where it is words, - // its sorted ids where it is a set. function foldedFacet(e, record) { const value = e.detail[record.value]; if (record.kind === "body") @@ -108,125 +22,91 @@ export function createProjectionFold(runtime, dependencies) { return a.localOrder - b.localOrder; } - const takenBack = () => - new Set(runtime.events.filter((e) => e.undoes).map((e) => e.undoes)); + const coordinateKey = (coordinate) => JSON.stringify(coordinate); + const domValue = (value, record) => + record?.kind === "attribute" ? value.join(" ") : value; - // Both durable channels projected in one pass. Actions holds the last surviving - // reader action per coordinate. Reports keeps every live report because stamping - // answers all of them there. Desired gives the reader's action precedence over - // provisional agent news on the same fact. - // The projection is deliberately pure and uncached: its declarations resolve through - // the live DOM, which panel construction and a recordless rebuild can replace. - function stateProjection(upto, without = null) { - const floors = retractionFloors(upto); - const withdrawn = takenBack(); - const settled = settledReports(upto); + function projectionFromView(view, conversation = runtime.browser?.conversation) { const actions = new Map(); const reports = new Map(); const classified = new Map(); - for (const e of runtime.events) { - if (e.kind !== "action" && e.kind !== "report") continue; - const el = elementById(e.widget); - if (!el) { - classified.set(e.id, { e, terminal: true }); - continue; - } - const chrome = inChrome(el); - // Reply widgets live in frozen log markup and therefore see the whole action - // sequence. Reports belong to versions, as do actions on page widgets. - if ( - e.kind === "report" ? chrome || e.revision > upto : !chrome && e.revision > upto - ) { - classified.set(e.id, { e, terminal: true }); - continue; - } - const channel = e.kind === "action" ? "x-state" : "x-report"; - const spec = registry[el.tagName.toLowerCase()]?.[channel]?.[e.action]; - if (!spec) { - classified.set(e.id, { e, terminal: true }); - continue; + const desired = new Map(); + const byId = new Map(); + const projections = [view?.document?.projection, conversation?.projection].filter( + Boolean, + ); + + for (const projection of projections) { + for (const wire of projection.entries ?? []) { + const e = wire.event; + const coordinate = coordinateKey(wire.coordinate); + const widget = elementById(e.widget); + const channel = e.kind === "action" ? "x-state" : "x-report"; + const spec = widget && registry[widget.localName]?.[channel]?.[e.action]; + const entry = spec + ? { + coordinate, + e, + restated: wire.restated ?? [], + scope: wire.scope, + spec, + unit: wire.coordinate[1], + value: domValue(wire.value, spec.record), + } + : { coordinate, e, scope: wire.scope, terminal: true }; + classified.set(e.id, entry); + byId.set(e.id, entry); } - const unit = unitOf(e, spec); - if (typeof unit !== "string") { - classified.set(e.id, { e, terminal: true }); - continue; + } + + for (const projection of projections) { + for (const id of projection.actions ?? []) { + const entry = byId.get(id); + if (entry && !entry.terminal) actions.set(entry.coordinate, entry); } - const coordinate = stateCoordinate(e.widget, unit, spec); - const entry = { unit, e, spec, coordinate }; - classified.set(e.id, entry); - if (e.kind === "action") { - if (e.id === without || withdrawn.has(e.id)) continue; - const restated = chrome ? [] : retractedIds(e, floors, el); - entry.restated = restated; - if (restated.length) continue; - actions.set(coordinate, entry); - } else if (!settled.has(e.id)) { - const standing = reports.get(coordinate) ?? []; + for (const id of projection.reports ?? []) { + const entry = byId.get(id); + if (!entry || entry.terminal) continue; + const standing = reports.get(entry.coordinate) ?? []; standing.push(entry); - reports.set(coordinate, standing); + reports.set(entry.coordinate, standing); + } + for (const id of projection.desired ?? []) { + const entry = byId.get(id); + if (entry && !entry.terminal) desired.set(entry.coordinate, entry); } } - // A retained accepted attempt already appears above at its true log position. Every - // other surviving recorded action is newer local state, in the order the one outbox - // will deliver it. Rejections are tombstones, not winners. + + // Invalid, retired, and out-of-window records are still complete readings. They do + // not name a DOM coordinate, but readiness must account for them once rather than + // waiting forever for a node the canonical projector says cannot receive them. + for (const record of view?.coverage ?? []) { + const e = record.event; + if (e.kind === "undo" || classified.has(e.id)) continue; + classified.set(e.id, { e, terminal: record.coordinate === null }); + } + + return { actions, reports, classified, desired }; + } + + function stateProjection() { + const projection = projectionFromView(runtime.view); const loggedAttempts = new Set( - runtime.events.map((e) => e.attempt).filter(Boolean), + (runtime.browser?.receipts ?? []).map((e) => e.attempt).filter(Boolean), ); + // A gesture already painted by its widget remains the newest local statement until + // one complete server snapshot both contains its receipt and has been reconciled. for (const out of outbox) { const entry = out.projection; if (!entry || out.rejected || loggedAttempts.has(out.event.attempt)) continue; - actions.set(entry.coordinate, entry); + projection.actions.set(entry.coordinate, entry); + projection.desired.set(entry.coordinate, entry); } - const desired = new Map( - [...reports].map(([coordinate, standing]) => [coordinate, standing.at(-1)]), - ); - for (const [coordinate, entry] of actions) desired.set(coordinate, entry); - return { - actions, - reports, - classified, - desired, - }; + return projection; } - // What this page's folds hold, handed out so the one premise underneath them can - // be tested from outside: every applyAction is absolute, and neither fold is a - // fold if one isn't. `version check --render` applies each of these a second - // time and asks what moved (RELATIVE_REPLAYS, in interact.py) — the page has - // already replayed them, so a widget stating the whole value has nothing to do - // and one stepping from what it reads moves again. - // - // Both channels, because both fold the same way: a report states an absolute - // value exactly as an action does. The widget rather than the unit, because - // applyAction is the widget's method and the detail is what names the part. - // - // In the log's own order, which is the whole of what makes re-applying them a - // no-op. An absolute applyAction states its unit whole and says nothing about - // any other, so where two units share an ordered container the page is the - // *sequence's* result rather than any one action's: two cards dragged to the - // head of one column leave it holding the second above the first, and replaying - // the first alone lifts it back over the second. Neither implementation moved; - // the reading did. A fold is keyed by coordinate and a Map keeps each key where it - // first appeared, so the surviving events have to be put back in `seq` order - // rather than taken as the fold hands them over. - // - // The widget and the facet are both read at the call rather than held, because - // an application earlier in the batch is free to have replaced the element a - // later one names. A unit the current version dropped has no facet at all — - // its widget survived it. - // - // Every action this page holds, the panel's included. A widget an agent sent folds the - // way a widget on the page does, and the poll replays its standing action over the state - // that action already produced, exactly as it does for the page's — so the premise binds - // it and the gate that tests the premise has to see it. A filter on `inChrome` stood here - // and meant the one verb only a message can carry (`answer`, the Done press on a question - // asked in a reply) was the one verb whose absoluteness nothing ever checked. - // - // Actions and not reports, though both channels come through here: a report has to be - // answerable by a version and thread markup is frozen, so every door refuses one on a - // widget an agent sent and the projection above marks any that reached the log terminal. const standingState = () => { - const projection = stateProjection(runtime.currentRevision); + const projection = stateProjection(); return [...projection.desired] .sort(([, a], [, b]) => compareProjected(a, b)) .map(([_coordinate, { unit, e, spec }]) => ({ @@ -248,10 +128,8 @@ export function createProjectionFold(runtime, dependencies) { return { compareProjected, foldedFacet, - retractedIds, - retractionFloors, + projectionFromView, standingState, stateProjection, - takenBack, }; } diff --git a/plugins/leaf/skills/leaf/assets/runtime/state-application.js b/plugins/leaf/skills/leaf/assets/runtime/state-application.js index 0ac3ee2722..960aff85ee 100644 --- a/plugins/leaf/skills/leaf/assets/runtime/state-application.js +++ b/plugins/leaf/skills/leaf/assets/runtime/state-application.js @@ -74,7 +74,10 @@ export function createStateApplication(dependencies) { if (dataChanged) notifyDataSubscribers(); }; const nextEvents = state.events; - const eventSeq = nextEvents.at(-1)?.seq ?? 0; + const nextBrowser = state.browser; + const eventSeq = nextBrowser?.basis?.through_seq; + if (!Number.isInteger(eventSeq) || eventSeq < 0) + throw new TypeError("state browser must name its log sequence"); // An answer taken before the one the page holds is judged as a stale sequence is // (takenBefore). Before the sequence gate because a stale answer may carry the same // sequence as the newer one and differ in everything the sequence does not order — @@ -169,6 +172,7 @@ export function createStateApplication(dependencies) { !versionMenuIsOpen() && targetRevision === state.active.revision; const priorEvents = runtime.events; + const priorBrowser = runtime.browser; const priorStatePhase = runtime.statePhase; const priorLastEventSeq = runtime.lastEventSeq; const priorReading = runtime.reading; @@ -178,15 +182,24 @@ export function createStateApplication(dependencies) { const priorCurrentLabel = runtime.currentLabel; const priorCurrentRevision = runtime.currentRevision; const priorCurrentStamp = runtime.currentStamp; + const priorView = runtime.view; let restoreClaimState = () => {}; const apply = async () => { runtime.events = nextEvents; + runtime.browser = nextBrowser; let activation = null; runtime.statePhase = "ready"; if (willActivate) { clearForcedActivation(); activation = await activateRevision(incoming, state.active); } + runtime.view = nextBrowser.views?.[String(runtime.currentRevision)] ?? null; + if ( + !runtime.view || + runtime.view.basis?.through_seq !== eventSeq || + runtime.view.basis?.revision !== runtime.currentRevision + ) + throw new TypeError("state browser has no matching revision view"); settleAcceptedDrafts(); runtime.agent = state.agent || "Claude"; restoreClaimState = replaceClaimState({ @@ -204,8 +217,11 @@ export function createStateApplication(dependencies) { renderPanel(); // Sign-off is a fact in the log, not a click this tab happens to remember, so a // reload (or the other tab) shows it too. - const agentReplies = runtime.events.filter( - (e) => e.author === "claude" && e.kind === "reply", + const agentReplies = (runtime.browser.conversation?.threads ?? []).flatMap( + (thread) => + thread.msgs.filter( + (message) => message.author === "claude" && message.kind === "reply", + ), ); if (agentMsgCount >= 0 && agentReplies.length > agentMsgCount && !panelIsOpen()) showToast( @@ -250,7 +266,7 @@ export function createStateApplication(dependencies) { // every surviving optimistic action, then releases the entries whose attempts the // read contained. A same-widget event later in this state can therefore never be // skipped under the hold and exposed only after the hold disappears. - accountOutbox(nextEvents); + accountOutbox(nextBrowser.receipts ?? []); // Sequence consumers render after replay, so their history and the widget's // standing body describe the same poll. This also fires when the event list did // not grow: applyAction may have deferred while a user was typing, then become @@ -294,6 +310,7 @@ export function createStateApplication(dependencies) { // reading so focus, panel, and undo cannot consume a log tail the page never // adopted. The next poll retries the candidate from the same complete boundary. runtime.events = priorEvents; + runtime.browser = priorBrowser; runtime.statePhase = priorStatePhase; runtime.lastEventSeq = priorLastEventSeq; runtime.reading = priorReading; @@ -306,6 +323,7 @@ export function createStateApplication(dependencies) { runtime.currentLabel = priorCurrentLabel; runtime.currentRevision = priorCurrentRevision; runtime.currentStamp = priorCurrentStamp; + runtime.view = priorView; stateSignoff(getSignoffDeclared()); restoreClaimState(); if (willActivate) location.reload(); diff --git a/plugins/leaf/skills/leaf/assets/runtime/state-feed.js b/plugins/leaf/skills/leaf/assets/runtime/state-feed.js index 4064c62d72..4ef637f844 100644 --- a/plugins/leaf/skills/leaf/assets/runtime/state-feed.js +++ b/plugins/leaf/skills/leaf/assets/runtime/state-feed.js @@ -26,7 +26,12 @@ export function createStateFeed({ async function readState() { let res; try { - res = await fetch("/api/state"); + const revision = runtime.currentRevision; + res = await fetch("/api/state", { + headers: Number.isInteger(revision) + ? { "Leaf-View-Revision": String(revision) } + : {}, + }); } catch { // Network absence is a completed answer: there is no log to replay, so the offline // authored page is honest. A successful but malformed response is different — let diff --git a/plugins/leaf/skills/leaf/assets/runtime/updates.js b/plugins/leaf/skills/leaf/assets/runtime/updates.js index 24f49305e1..f338fca10c 100644 --- a/plugins/leaf/skills/leaf/assets/runtime/updates.js +++ b/plugins/leaf/skills/leaf/assets/runtime/updates.js @@ -1,4 +1,4 @@ -/* Canonical action, work-claim, and report feeds exposed to widgets and chrome. */ +/* Canonical action and update feeds exposed to widgets and chrome. */ let publishedActionSequence; let publishedPublishedAt; let publishedSaidAt; @@ -20,15 +20,10 @@ export function createUpdates(runtime, dependencies) { const { closestAcross, coordinateProjectionCommitted, - inChrome, projectionCommitted, stateProjection, - threadList, } = dependencies; - // A state answer supplies claim records with the page-level evidence that says whether - // anything still stands behind them. Keep that accepted snapshot together so the feed - // and its work lines cannot read different applications. let claimState = Object.freeze({ sources: Object.freeze([]), claimsHeld: false, @@ -51,29 +46,21 @@ export function createUpdates(runtime, dependencies) { claimingSession: claimState.claimingSession, }); - // The fold answers where reader state stands; this answers how it got there. A widget - // receives its own absolute actions in log order, bounded by the version being viewed. - // A reply widget lives in frozen chrome and therefore sees the whole sequence. Returning - // fresh copies keeps the private event store private. - function sequence(widget, verb, live) { - return runtime.events + const actionSequence = (widget, action) => { + const projection = stateProjection(); + return [...projection.classified.values()] .filter( - (event) => - event.kind === "action" && - event.widget === widget.id && - (!verb || event.action === verb) && - (inChrome(widget) || event.revision <= runtime.currentRevision) && - live(event), + (entry) => + !entry.terminal && + entry.e.kind === "action" && + entry.e.widget === widget.id && + (!action || entry.e.action === action) && + projectionCommitted(projection, entry.e), ) - .map((event) => structuredClone(event)); - } - - const actionSequence = (widget, action) => { - const projection = stateProjection(runtime.currentRevision); - return sequence(widget, action, (e) => projectionCommitted(projection, e)); + .sort((left, right) => left.e.seq - right.e.seq) + .map((entry) => structuredClone(entry.e)); }; - // Thread and widget ids may have the same spelling, so every target names its kind. function updateTarget(target) { if (target === null) return null; if (target instanceof Element) { @@ -89,108 +76,21 @@ export function createUpdates(runtime, dependencies) { "update target must be a widget element or {kind: 'widget' | 'thread', id}", ); } - const updateTargetKey = (target) => + const targetKey = (target) => target ? JSON.stringify([target.kind, target.id]) : null; - const compareText = (left, right) => (left < right ? -1 : left > right ? 1 : 0); - // A claim follows its log floor and precedes the next event. - const updatePosition = (update) => - update.source === "report" ? [update.seq, 0] : [update.log_floor, 1]; - - // Normalize both sources after stateProjection has classified the durable channel. - function updatesFromProjection(target, projection) { - const key = updateTargetKey(updateTarget(target)); - const reportStanding = new Set( - [...projection.reports.values()].flat().map((entry) => entry.e.id), - ); - const reportEffective = new Set( - [...projection.desired.values()] - .filter((entry) => entry.e.kind === "report") - .map((entry) => entry.e.id), - ); - const updates = []; - for (const entry of projection.classified.values()) { - const event = entry.e; - if (event.kind !== "report" || entry.terminal) continue; - const entryTarget = { kind: "widget", id: event.widget }; - if (key !== null && updateTargetKey(entryTarget) !== key) continue; - const field = entry.spec.update; - updates.push({ - id: event.id, - target: entryTarget, - source: "report", - action: event.action, - detail: event.detail, - text: field ? event.detail[field] : null, - ts: event.ts, - revision: event.revision, - seq: event.seq, - agent: event.agent ?? null, - session: event.session ?? null, - disposition: reportEffective.has(event.id) - ? "effective" - : reportStanding.has(event.id) - ? "standing" - : "settled", - }); - } - const threads = new Map(threadList().map((thread) => [thread.root.id, thread])); - for (const source of claimState.sources) { - if (key !== null && updateTargetKey(source.target) !== key) continue; - let effective = false; - if (source.target.kind === "thread") { - const thread = threads.get(source.target.id); - effective = Boolean( - thread && - !thread.resolved && - !thread.msgs.some( - (message) => - message.kind === "reply" && - message.author === "claude" && - message.seq > source.log_floor, - ), - ); - } else if (source.target.kind === "widget") { - effective = !runtime.events.some( - (event) => - event.kind === "note" && - event.seq > source.log_floor && - (event.settles ?? []).some( - (settlement) => - settlement.kind === "work" && settlement.id === source.target.id, - ), - ); - } - updates.push({ - ...source, - disposition: effective ? "effective" : "settled", - }); - } - return updates - .sort((left, right) => { - const leftPosition = updatePosition(left); - const rightPosition = updatePosition(right); - return ( - leftPosition[0] - rightPosition[0] || - leftPosition[1] - rightPosition[1] || - Date.parse(left.ts) - Date.parse(right.ts) || - compareText(left.source, right.source) || - compareText(updateTargetKey(left.target), updateTargetKey(right.target)) || - compareText(left.id, right.id) - ); - }) + const updateSequence = (target = null) => { + const key = targetKey(updateTarget(target)); + return (runtime.view?.updates ?? []) + .filter((update) => key === null || targetKey(update.target) === key) .map((update) => structuredClone(update)); - } - - const updateSequence = (target = null) => - updatesFromProjection(target, stateProjection(runtime.currentRevision)); + }; - // Do not narrate a report while its coordinate still paints the prior winner. - function reportUpdatesCommitted(projection, target) { - const key = updateTargetKey(updateTarget(target)); + function reportsCommitted(projection, target) { + const key = targetKey(updateTarget(target)); const coordinates = new Map(); for (const entry of projection.classified.values()) { if (entry.terminal || entry.e.kind !== "report") continue; - const entryKey = updateTargetKey({ kind: "widget", id: entry.e.widget }); + const entryKey = targetKey({ kind: "widget", id: entry.e.widget }); if (key === null || key === entryKey) coordinates.set(entry.coordinate, entry); } return [...coordinates.values()].every((entry) => @@ -198,37 +98,11 @@ export function createUpdates(runtime, dependencies) { ); } - // When the revision being read became active, or when its public stamp landed — the - // floor under any statement about how fresh what the page says is. A row nobody has - // reported on is not a row of unknown age: its words are exactly this old. - // - // Without the floor, silence renders as nothing at all, which is the one direction a - // freshness line must never fail in. A fleet whose workers all died at six in the - // evening, on a page updated at six, shows five rows claiming work and no elapsed - // line anywhere at eight the next morning — a dead fleet reading healthy, which is the - // claim-nobody-revises failure the banner's own judgment exists to answer, reintroduced - // one section below the banner. - const publishedAt = () => { - let ts = - runtime.active?.revision === runtime.currentRevision - ? runtime.active.activated_at - : null; - for (const e of runtime.events) - if (e.kind === "note" && e.revision === runtime.currentRevision) ts = e.ts; - return ts; - }; - - // When the words around an element were said. A page's own content was said when its - // revision became active; a widget an agent put in a message was said when the message - // was, which is a later moment and often a much later one. Anything measuring "how long - // since we heard anything" has to take the second where it stands in one, or a roster - // carried in a reply certifies its workers against an activation from before the reply - // existed — the freshness line answering for a page nobody was asking about. + const publishedAt = () => runtime.view?.published_at ?? null; const saidAt = (el) => closestAcross(el, ".lf-msg")?.querySelector(":scope > .lf-msg-head > time") ?.dateTime || publishedAt(); - // Run immediately and after every reconciliation, including polls with no new events. const watch = (owner, callback) => { const update = () => { if (!owner.isConnected) { @@ -244,14 +118,13 @@ export function createUpdates(runtime, dependencies) { const watchActions = (widget, action, callback) => watch(widget, () => callback(actionSequence(widget, action))); - const watchUpdates = (target, callback) => watch(target instanceof Element ? target : document.body, () => { - const projection = stateProjection(runtime.currentRevision); - if (!reportUpdatesCommitted(projection, target)) return; - callback(updatesFromProjection(target, projection)); + const projection = stateProjection(); + if (reportsCommitted(projection, target)) callback(updateSequence(target)); }); - + // Full history is intentionally raw: it is the one public escape hatch whose contract + // is the append-only log itself rather than a semantic reading of that log. const watchHistory = (owner, callback) => watch(owner, () => callback(runtime.events.map((event) => structuredClone(event)))); diff --git a/plugins/leaf/skills/leaf/assets/runtime/version-diff.js b/plugins/leaf/skills/leaf/assets/runtime/version-diff.js index 8a8591c5f7..a58f998a9f 100644 --- a/plugins/leaf/skills/leaf/assets/runtime/version-diff.js +++ b/plugins/leaf/skills/leaf/assets/runtime/version-diff.js @@ -6,12 +6,12 @@ export function createVersionDiff({ chooserLabel, domFacet, elementById, - foldedFacet, inChrome, quoted, + projectionFromView, + sameLayer, showToast, stateCoordinate, - stateProjection, stateSpecs, textBlockSelector, versionBtn, @@ -115,7 +115,20 @@ export function createVersionDiff({ if (!res.ok) throw new Error(`couldn't load ${baseName}`); return new DOMParser().parseFromString(await res.text(), "text/html"); } - function applyDiff(doc, baseVersion) { + async function baseReading(baseRevision, throughSeq) { + const params = new URLSearchParams({ + revision: String(baseRevision), + through_seq: String(throughSeq), + }); + const res = await fetch(`/api/view?${params}`); + if (!res.ok) throw new Error(`couldn't project revision r${baseRevision}`); + const generation = res.headers.get("Leaf-Layer"); + if (generation && !sameLayer(generation)) return null; + const answer = await res.json(); + if (!answer.browser) throw new Error(`revision r${baseRevision} has no projection`); + return answer.browser; + } + function applyDiff(doc, baseVersion, baseReading) { // Multiset membership rather than an alignment: an unchanged block that // merely moved stays unmarked; a changed or new one has no base twin. const base = new Map(); @@ -140,7 +153,9 @@ export function createVersionDiff({ )?.revision; if (baseRevision == null) throw new Error(`version v${baseVersion} has no revision`); - const baseProjection = stateProjection(baseRevision); + const baseView = baseReading?.views?.[String(baseRevision)]; + if (!baseView) throw new Error(`revision r${baseRevision} has no projection`); + const baseProjection = projectionFromView(baseView, baseReading.conversation); for (const { tag, spec } of stateSpecs()) { if (!spec.record || spec.record.kind === "body") continue; for (const widget of document.body.querySelectorAll(tag)) { @@ -159,9 +174,7 @@ export function createVersionDiff({ // means an unrelated fact on this unit never enters the choice. const coordinate = stateCoordinate(widget.id, el.id, spec); const writer = baseProjection.desired.get(coordinate); - const before = writer - ? foldedFacet(writer.e, spec.record) - : domFacet(baseEl, spec.record); + const before = writer ? writer.value : domFacet(baseEl, spec.record); const now = domFacet(el, spec.record); if (before === now) continue; // The element the change reads on: the option now picked, or the moved @@ -245,16 +258,35 @@ export function createVersionDiff({ // many times the reader arrives there. async function showComparison(base) { const mine = ++diffRequest; + const baseRevision = runtime.versions.find( + (candidate) => candidate.version === base, + )?.revision; + if (baseRevision == null) { + showToast(`Couldn't load v${base}`); + return; + } + const documentRequest = baseDocument(base); let doc; + let reading; try { - doc = await baseDocument(base); + while (mine === diffRequest) { + const throughSeq = runtime.view?.basis?.through_seq; + if (!Number.isInteger(throughSeq)) + throw new Error("the current reading has no log sequence"); + [doc, reading] = await Promise.all([ + documentRequest, + baseReading(baseRevision, throughSeq), + ]); + if (reading === null || mine !== diffRequest) return; + if (runtime.view?.basis?.through_seq === throughSeq) break; + } } catch { showToast(`Couldn't load v${base}`); return; } if (mine !== diffRequest) return; if (diffOn) setDiff(false); // the old base's marks, before the new base's land - const n = applyDiff(doc, base); + const n = applyDiff(doc, base, reading); setDiff(true, base); showToast( n diff --git a/plugins/leaf/skills/leaf/assets/runtime/version-navigation.js b/plugins/leaf/skills/leaf/assets/runtime/version-navigation.js index cab107d69e..599c3025b2 100644 --- a/plugins/leaf/skills/leaf/assets/runtime/version-navigation.js +++ b/plugins/leaf/skills/leaf/assets/runtime/version-navigation.js @@ -279,8 +279,7 @@ export function createVersionNavigation({ versionsWalkable = walkable; paintKeys(); } - const notes = {}; - for (const e of runtime.events) if (e.kind === "note") notes[e.version] = e.text; + const notes = runtime.browser?.version_notes ?? {}; const key = JSON.stringify([ state.active, state.versions, diff --git a/plugins/leaf/skills/leaf/references/page-authoring.md b/plugins/leaf/skills/leaf/references/page-authoring.md index 970f7ce098..4cb746d7ef 100644 --- a/plugins/leaf/skills/leaf/references/page-authoring.md +++ b/plugins/leaf/skills/leaf/references/page-authoring.md @@ -140,9 +140,9 @@ suggestion, and a suggestion does not propose a widget's state. ## Honoring reader state -The event log outranks authored markup. The browser replays every standing action -onto later revisions, but the source must eventually record the decision so the -page reads correctly without the log: +The event log outranks authored markup. The server projects every standing action +and the browser replays that view onto later revisions, but the source must +eventually record the decision so the page reads correctly without the log: - Mark every picked option `chosen`. - Carry an option a reader wrote in the group's last cell into the group as an diff --git a/plugins/leaf/skills/leaf/scripts/leaf/agent_state.py b/plugins/leaf/skills/leaf/scripts/leaf/agent_state.py index 9477989d74..15ac2e7550 100644 --- a/plugins/leaf/skills/leaf/scripts/leaf/agent_state.py +++ b/plugins/leaf/skills/leaf/scripts/leaf/agent_state.py @@ -248,12 +248,10 @@ def _apply_thread_state(state: dict, events: list, registry: dict) -> None: def _write_page_state( page_dir: Path, events: list, source_error: str | None = None ) -> None: - """Where the page stands, as one JSON object — the agent-side twin of the - browser's /api/state, folded rather than raw. /api/state ships the log and - lets the runtime replay it; a session picking a page up owes the same - reading, and doing it in-head over `leaf events` is how a standing decision - gets missed. So this prints the readings the runtime derives, from the same - constructions it derives them with: the active revision's elements, the + """Where the page stands, as one JSON object — the agent-facing projection + beside the browser projection in /api/state. A session picking a page up needs + the same reading; doing it in-head over `leaf events` is how a standing decision + gets missed. So this prints the active revision's elements, the projection of the user's standing state and the reports standing on the agent channel, where the record lags either (`record_lag_entries`), authored measurements whose live source has run again (`measurement_lag_entries`), the diff --git a/plugins/leaf/skills/leaf/scripts/leaf/asks.py b/plugins/leaf/skills/leaf/scripts/leaf/asks.py index fb0c312ca8..7b50bdebb2 100644 --- a/plugins/leaf/skills/leaf/scripts/leaf/asks.py +++ b/plugins/leaf/skills/leaf/scripts/leaf/asks.py @@ -342,8 +342,7 @@ def page_ask_projection( An ordinary x-awaits instance is its local condition minus an explicit answer. A roll-up projects the same fact through a nested plan: a false local condition stops; direct ordinary interventions take precedence; - otherwise child roll-ups recurse; a matching leaf waits. The browser implements - this reducer over the DOM and the same standing fold. + otherwise child roll-ups recurse; a matching leaf waits. `with_agent` chooses which question this answers, and is the whole of the difference between the two. Given `seats_with_agent`, it is the reader's list: a @@ -395,7 +394,11 @@ def page_awaiting_values(html, parser, projection, spk, registry: dict) -> dict: def thread_ask_projection( - events: list, registry: dict, settled: set + events: list, + registry: dict, + settled: set, + *, + prepared: tuple | None = None, ) -> tuple[list, dict]: """Asks standing in thread markup — the runtime's `answeredThreadAsk` read from the log. A fragment is frozen: no version answers it and no `restated` @@ -409,9 +412,14 @@ def thread_ask_projection( agent asked and then withdrew by resolving stays on the banner's count for the life of the page, and the walk that steps to it lands in a shut disclosure.""" - structure = thread_structure(events) - records, byid, spk = [], {}, {} - thread_of = thread_roots(events) + if prepared is None: + structure = thread_structure(events) + projection = None + records, byid, spk = [], {}, {} + thread_of = thread_roots(events) + else: + projection, byid, spk, thread_of, structure = prepared + records = [] for e in events: if e["kind"] not in ("comment", "reply"): continue @@ -419,10 +427,12 @@ def thread_ask_projection( if not markup or thread_of[e["id"]] in settled: continue frag = structure.fragments[e["id"]] - byid.update(frag.by_id) - spk.update(spoken(markup, registry)) + if prepared is None: + byid.update(frag.by_id) + spk.update(spoken(markup, registry)) records.extend((thread_of[e["id"]], rec) for rec in frag.lf_elements) - projection = state_projection(events, byid, spk, registry, None, {}) + if projection is None: + projection = state_projection(events, byid, spk, registry, None, {}) asks, values = page_ask_projection( [rec for _thread, rec in records], diff --git a/plugins/leaf/skills/leaf/scripts/leaf/events.py b/plugins/leaf/skills/leaf/scripts/leaf/events.py index 0f8badde08..4ed03bed59 100644 --- a/plugins/leaf/skills/leaf/scripts/leaf/events.py +++ b/plugins/leaf/skills/leaf/scripts/leaf/events.py @@ -327,13 +327,11 @@ def report_settlements(events: list, upto=None) -> dict: def action_rests_on(event: dict, within: dict) -> list: - """The runtime's restsOn, read the same way here: the sending widget plus - every detail id it contains, `resolves` aside. This is the one key space for + """The sending widget plus every detail id it contains, `resolves` aside. + This is the one key space for liveness — fold survival, retraction floors, and the earning of `restated` - all go through it, in both runtimes — while `action_subjects` stays the - words gate's finer, subject-keyed view of the same containment. Two views, - one containment test; a third keying would fork the JS/Python twin a third - way. + all go through it, while `action_subjects` stays the words gate's finer, + subject-keyed view of the same containment. `within` is where each id sits, and nothing else about the page. Liveness never asks what an element says, and the difference is not tidiness: words diff --git a/plugins/leaf/skills/leaf/scripts/leaf/http.py b/plugins/leaf/skills/leaf/scripts/leaf/http.py index b0bfe44809..3614a71290 100644 --- a/plugins/leaf/skills/leaf/scripts/leaf/http.py +++ b/plugins/leaf/skills/leaf/scripts/leaf/http.py @@ -13,6 +13,7 @@ from .event_endpoint import EventEndpoint, event_rejection from .event_log import read_events from .files import ( + active_descriptor, latest_revision, list_revisions, list_versions, @@ -98,17 +99,31 @@ def versions_live(self, events): if version <= self.preview_upto ] - def _page_state(self, events: list, source_error: str | None = None) -> dict: + def _page_state( + self, + events: list, + source_error: str | None = None, + view_revision: int | None = None, + ) -> dict: """The page's own state from a caller's transaction-consistent log.""" - state = served_state.full_state( + active_override = None + source_overrides = None + if self.preview_source is not None: + active_override = self.preview_source["active"] + source_overrides = { + active_override["revision"]: self.preview_source["data"].decode("utf-8") + } + return served_state.full_state( self.page_dir, events, layer=self.layer, source_error=source_error, + view_revision=view_revision, + active_override=active_override, + source_overrides=source_overrides, ) - return state - def page_state(self) -> dict: + def page_state(self, view_revision: int | None = None) -> dict: """The current reading used by GET and accepted POST responses. A claim's ``log_floor`` is meaningful only beside the same log snapshot it @@ -129,11 +144,12 @@ def page_state(self) -> dict: if self.preview_source is None: activation = activate_source(self.page_dir, page.events) reading = served_state.page_reading(self.page_dir) - state = self._page_state(page.events, activation.error) + state = self._page_state( + page.events, activation.error, view_revision=view_revision + ) else: reading = served_state.page_reading(self.page_dir) - state = self._page_state(page.events) - state["active"] = self.preview_source["active"] + state = self._page_state(page.events, view_revision=view_revision) # Every URL in `others` carries the machine key (`host_key`), so the list # reaches neighbouring pages without creating another authorization path. # Scan neighbours after releasing this page's lease: they are independent @@ -148,6 +164,69 @@ def page_state(self) -> dict: ) return state + def page_browser_view(self, view_revision: int, through_seq: int) -> dict: + """One revision projected at an exact already-observed log boundary.""" + with PageTransaction(self.page_dir) as page: + if self.preview_source is None: + activate_source(self.page_dir, page.events) + try: + active = active_descriptor(self.page_dir, page.events) + except SystemExit as error: + raise ValueError(str(error)) from error + source_overrides = None + else: + active = self.preview_source["active"] + source_overrides = { + active["revision"]: self.preview_source["data"].decode("utf-8") + } + latest_seq = page.events[-1]["seq"] if page.events else 0 + if through_seq > latest_seq: + raise ValueError( + f"view sequence {through_seq} is newer than log sequence {latest_seq}" + ) + events = [event for event in page.events if event["seq"] <= through_seq] + projected = served_state.project_browser_state( + self.page_dir, + events, + view_revision, + active, + served_state.presence(self.page_dir, events)["claims"], + source_overrides, + include_active_view=False, + ) + if projected is None: + raise ValueError("page registry cannot be projected") + return projected + + def requested_view_revision(self, *, header: bool = False) -> int | None: + raw = ( + self.headers.get("Leaf-View-Revision") + if header + else parse_qs(urlsplit(self.path).query).get("revision", [None])[-1] + or self.headers.get("Leaf-View-Revision") + ) + if raw in (None, ""): + return None + try: + revision = int(raw) + except (TypeError, ValueError) as error: + raise ValueError("view revision must be a positive integer") from error + if revision < 1: + raise ValueError("view revision must be a positive integer") + return revision + + def requested_view_sequence(self) -> int: + raw = parse_qs(urlsplit(self.path).query).get("through_seq", [None])[-1] + if raw in (None, ""): + raise ValueError("view sequence is required") + try: + sequence = int(raw) + except (TypeError, ValueError) as error: + raise ValueError("view sequence must be a non-negative integer") from error + if sequence < 0: + raise ValueError("view sequence must be a non-negative integer") + return sequence + def log_message(self, *args): pass @@ -440,7 +519,25 @@ def _get(self): if path == "/api/state": # Versions pass through the handler's own view, so a preview state # agrees with the version it serves. - self._json(self.page_state()) + try: + revision = self.requested_view_revision() + state = self.page_state(revision) + except ValueError as error: + self._json({"error": str(error)}, 400) + return + self._json(state) + return + if path == "/api/view": + try: + revision = self.requested_view_revision() + if revision is None: + raise ValueError("view revision is required") + sequence = self.requested_view_sequence() + browser = self.page_browser_view(revision, sequence) + except ValueError as error: + self._json({"error": str(error)}, 400) + return + self._json({"browser": browser}) return # Browsers ask for this unprompted, and go on asking where nothing in the # markup names an icon — the runtime's link is written as the chrome is built, @@ -472,7 +569,19 @@ def _post(self): if self.posted_error: self._refuse(self.posted_error) return - status, answer = self.event_endpoint.accept(self.posted, self.page_state) + try: + view_revision = self.requested_view_revision(header=True) + except ValueError as error: + self._refuse(str(error)) + return + if view_revision is not None and view_revision not in list_revisions( + self.page_dir + ): + self._refuse(f"unknown view revision r{view_revision}") + return + status, answer = self.event_endpoint.accept( + self.posted, lambda: self.page_state(view_revision) + ) self._json(answer, status) diff --git a/plugins/leaf/skills/leaf/scripts/leaf/render_gate/models.py b/plugins/leaf/skills/leaf/scripts/leaf/render_gate/models.py index 79a2f34010..8e7ede80cd 100644 --- a/plugins/leaf/skills/leaf/scripts/leaf/render_gate/models.py +++ b/plugins/leaf/skills/leaf/scripts/leaf/render_gate/models.py @@ -19,6 +19,7 @@ class _SchemeContext: replayed: bool unsettled: list + @dataclass(frozen=True, slots=True) class _SchemeReadings: failsoft: list diff --git a/plugins/leaf/skills/leaf/scripts/leaf/render_gate/preview.py b/plugins/leaf/skills/leaf/scripts/leaf/render_gate/preview.py index 9b6c244da4..676c0a3455 100644 --- a/plugins/leaf/skills/leaf/scripts/leaf/render_gate/preview.py +++ b/plugins/leaf/skills/leaf/scripts/leaf/render_gate/preview.py @@ -49,6 +49,7 @@ def preview_server( finally: httpd.shutdown() + @contextlib.contextmanager def preview_source_server( page_dir: Path, diff --git a/plugins/leaf/skills/leaf/scripts/leaf/render_gate/scheme.py b/plugins/leaf/skills/leaf/scripts/leaf/render_gate/scheme.py index 5b78e5e1e1..ed914d6961 100644 --- a/plugins/leaf/skills/leaf/scripts/leaf/render_gate/scheme.py +++ b/plugins/leaf/skills/leaf/scripts/leaf/render_gate/scheme.py @@ -30,11 +30,13 @@ def served(page, url: str, path: str, timeout_ms: int | None = None): timeout_ms = SERVED_TIMEOUT_MS if timeout_ms is None else timeout_ms return page.request.get(urljoin(url, path), timeout=timeout_ms) + def previous_stamp(revision: int, versions: list[dict]) -> dict | None: """The newest stamped revision before ``revision``, if one exists.""" earlier = [version for version in versions if version["revision"] < revision] return max(earlier, key=lambda version: version["revision"]) if earlier else None + def rendered_revision(url: str, state: dict) -> int: """Resolve the immutable revision shown at ``url`` from the public state.""" name = Path(urlsplit(url).path).name @@ -54,9 +56,11 @@ def rendered_revision(url: str, state: dict) -> int: def resize_observer_error(text: str) -> bool: return text.startswith(RESIZE_OBSERVER_ERROR) + def recurring_resize_observer_error(unit: str) -> str: return f"{RESIZE_OBSERVER_ERROR} notice recurred on the confirming {unit}" + def _render_scheme(browser, url, scheme, served_timeout_ms, opened_pages): """Read and report the browser gate for one color scheme.""" from playwright.sync_api import Error as PlaywrightError diff --git a/plugins/leaf/skills/leaf/scripts/leaf/render_gate/version.py b/plugins/leaf/skills/leaf/scripts/leaf/render_gate/version.py index 9d1a722b59..30feff33a0 100644 --- a/plugins/leaf/skills/leaf/scripts/leaf/render_gate/version.py +++ b/plugins/leaf/skills/leaf/scripts/leaf/render_gate/version.py @@ -62,6 +62,7 @@ def _render_version_attempt( light_complete and dark_complete, ) + def render_version(browser, url: str, served_timeout_ms: int | None = None) -> list: """Read a version, confirming a complete attempt that reports a ResizeObserver loop notice. diff --git a/plugins/leaf/skills/leaf/scripts/leaf/served_state.py b/plugins/leaf/skills/leaf/scripts/leaf/served_state.py index fc53121c47..843b40dd6b 100644 --- a/plugins/leaf/skills/leaf/scripts/leaf/served_state.py +++ b/plugins/leaf/skills/leaf/scripts/leaf/served_state.py @@ -5,8 +5,23 @@ import time from pathlib import Path +from .asks import page_ask_projection, thread_ask_projection from .data import read_data from .event_log import now_iso, read_cursor, read_events +from .events import ( + action_rests_on, + action_retracted, + awaits_agent, + bare_reaction, + build_threads, + is_reaction, + retractions, + seat_root, + seats_with_agent, + spoken_turns, + taken_back, + undo_error, +) from .files import ( STAGED, active_descriptor, @@ -14,10 +29,20 @@ latest_revision, list_revisions, read_json, + revision_path, version_descriptors, ) from .host import state_home -from .registry import layer_generation +from .passages import enclosing_of, page_passages, spoken +from .projection import ( + StateProjection, + canonical_updates, + decisions, + folded_facet, + page_projection, + state_projection, +) +from .registry import RegistryError, layer_generation, load_registry from .server import running_server from .service import ( claim_is_active, @@ -29,6 +54,7 @@ wait_is_live, ) from .structure import parse_revision +from .thread_context import thread_roots, thread_structure def other_leaves(page_dir: Path) -> list: @@ -151,17 +177,421 @@ def presence(page_dir: Path, events: list) -> dict: } +def _browser_projection( + projection: StateProjection, + *, + scope: str, + within: dict, + floors: dict, +) -> dict: + """Serialize one declared projection without making the wire its authority.""" + entries = [] + for coordinate, (event, spec) in projection.classified.values(): + restated = [] + if event["kind"] == "action": + restated = [ + identity + for identity in action_rests_on(event, within) + if floors.get(identity, 0) > event["revision"] + ] + entries.append( + { + "event": event, + "coordinate": list(coordinate), + "value": folded_facet(event, spec) if spec.get("record") else None, + "scope": scope, + "restated": restated, + } + ) + return { + "entries": entries, + "actions": [event["id"] for event, _spec in projection.actions.values()], + "reports": [ + event["id"] + for standing in projection.reports.values() + for event, _spec in standing + ], + "desired": [event["id"] for event, _spec in projection.desired.values()], + } + + +def _thread_projection(events: list, registry: dict): + structure = thread_structure(events) + roots = thread_roots(events) + byid, spk = {}, {} + for event in events: + if markup := event.get("markup"): + fragment = structure.fragments[event["id"]] + byid.update(fragment.by_id) + spk.update(spoken(markup, registry)) + projection = state_projection(events, byid, spk, registry, None, floors={}) + return projection, byid, spk, roots, structure + + +def _thread_awaits_reader( + thread: dict, + registry: dict, + awaiting: dict[str, bool], + structure, +) -> bool: + if thread["resolved"]: + return False + turns = spoken_turns(thread) + if not turns or turns[-1]["author"] != "claude": + return False + last = turns[-1] + if last["kind"] == "reply": + fragment = structure.fragments.get(last["id"]) + asks = [ + rec["attrs"].get("id") + for rec in (fragment.lf_elements if fragment else []) + if (registry.get(rec["tag"]) or {}).get("x-awaits") is not None + ] + structural = ( + any(awaiting.get(identity, False) for identity in asks) if asks else None + ) + if structural is False or (structural is None and not last.get("awaits")): + return False + tokens = registry.get("$reactions", {}).get("tokens", {}) + return not any( + is_reaction(message) + and message["author"] == "user" + and message.get("parent") == last["id"] + and (tokens.get(message["token"]) or {}).get("settles") + for message in thread["msgs"] + ) + + +def _browser_conversation( + events: list, registry: dict, threads: dict +) -> tuple[dict, StateProjection]: + settled = {identity for identity, thread in threads.items() if thread["resolved"]} + prepared = _thread_projection(events, registry) + projection, _byid, _spk, _roots, structure = prepared + asks, awaiting = thread_ask_projection(events, registry, settled, prepared=prepared) + rendered_threads = [ + { + **thread, + "awaits_agent": awaits_agent(thread), + "awaits_reader": _thread_awaits_reader( + thread, registry, awaiting, structure + ), + "bare_reaction": bare_reaction(thread), + "seat": seat_root(thread), + } + for thread in threads.values() + ] + return ( + { + "projection": _browser_projection( + projection, scope="conversation", within={}, floors={} + ), + "asks": {"reader": asks, "unanswered": asks, "awaiting": awaiting}, + "threads": rendered_threads, + "done": [event for event in events if event["kind"] == "done"], + }, + projection, + ) + + +def _browser_document( + html: str, + events: list, + registry: dict, + revision: int, + threads: dict, + *, + prepared: tuple | None = None, +) -> tuple[dict, StateProjection, dict, dict]: + projection, parser, spk = prepared or page_projection( + html, events, registry, revision + ) + passages = page_passages(html, registry, decisions(projection.actions, registry)) + dropped = set(passages.retired) | set(passages.gone) + reader_asks, reader_awaiting = page_ask_projection( + parser, + projection, + parser.by_id, + spk, + registry, + dropped, + seats_with_agent(threads), + ) + unanswered_asks, unanswered_awaiting = page_ask_projection( + parser, + projection, + parser.by_id, + spk, + registry, + dropped, + set(), + ) + within = enclosing_of(spk) + floors = retractions(events, revision) + return ( + { + "revision": revision, + "projection": _browser_projection( + projection, + scope="document", + within=within, + floors=floors, + ), + "asks": { + "reader": reader_asks, + "unanswered": unanswered_asks, + "awaiting": reader_awaiting, + "unanswered_awaiting": unanswered_awaiting, + }, + }, + projection, + within, + floors, + ) + + +def _restores_desired( + event: dict, + coordinate: tuple, + projection: StateProjection, + withdrawn: set, + within: dict, + floors: dict, +) -> bool: + """Whether withdrawing one action exposes another durable value there.""" + desired = projection.desired.get(coordinate) + if desired and desired[0]["id"] != event["id"]: + return True + if projection.reports.get(coordinate): + return True + return any( + candidate_coordinate == coordinate + and candidate["kind"] == "action" + and candidate["id"] != event["id"] + and candidate["id"] not in withdrawn + and not action_retracted(candidate, floors, within) + for candidate_coordinate, (candidate, _spec) in projection.classified.values() + ) + + +def _browser_undo_candidates( + events: list, + within: dict, + document_within: dict, + document_floors: dict, + document_projection: StateProjection, + conversation_projection: StateProjection, +) -> list[dict]: + classified = { + **document_projection.classified, + **conversation_projection.classified, + } + candidates = [] + withdrawn = taken_back(events) + for event in reversed(events): + if ( + event.get("author") != "user" + or event["kind"] == "undo" + or event["id"] in withdrawn + ): + continue + if undo_error({"undoes": event["id"]}, events, within): + continue + item = {"event": event} + if event["kind"] == "action" and event["id"] in classified: + coordinate, _entry = classified[event["id"]] + item["coordinate"] = list(coordinate) + if event["id"] in document_projection.classified: + projection = document_projection + action_within = document_within + floors = document_floors + else: + projection = conversation_projection + action_within = {} + floors = {} + item["restores_desired"] = _restores_desired( + event, + coordinate, + projection, + withdrawn, + action_within, + floors, + ) + candidates.append(item) + return candidates + + +def browser_state( + documents: dict[int, str], + events: list, + registry: dict, + active_revision: int, + claims: list, + active: dict, + view_revisions: set[int], +) -> dict: + """The browser's derived reading of one transaction-consistent page snapshot. + + Documents and the append-only log remain the authorities. This object is an + ephemeral transport projection, keyed by the exact log sequence and revisions + from which it was read. + """ + through_seq = events[-1]["seq"] if events else 0 + active_html = documents[active_revision] + active_projection, active_parser, active_spk = page_projection( + active_html, events, registry, active_revision + ) + active_within = enclosing_of(active_spk) + threads = build_threads(events, active_within) + conversation, conversation_projection = _browser_conversation( + events, registry, threads + ) + + views = {} + for revision in sorted(view_revisions): + html = documents[revision] + document, projection, within, floors = _browser_document( + html, + events, + registry, + revision, + threads, + prepared=(active_projection, active_parser, active_spk) + if revision == active_revision + else None, + ) + classified = { + **projection.classified, + **conversation_projection.classified, + } + coverage = [] + for event in events: + if event["kind"] in {"action", "report"}: + classified_entry = classified.get(event["id"]) + elif event["kind"] == "undo": + classified_entry = classified.get(event["undoes"]) + else: + continue + coverage.append( + { + "event": event, + "coordinate": ( + list(classified_entry[0]) if classified_entry else None + ), + } + ) + published_at = next( + ( + event["ts"] + for event in reversed(events) + if event["kind"] == "note" and event["revision"] == revision + ), + active.get("activated_at") if active_revision == revision else None, + ) + views[str(revision)] = { + "basis": {"revision": revision, "through_seq": through_seq}, + "document": document, + "updates": canonical_updates(projection, claims, threads, events), + "undo": _browser_undo_candidates( + events, + active_within, + within, + floors, + projection, + conversation_projection, + ), + "coverage": coverage, + "published_at": published_at, + } + return { + "basis": {"through_seq": through_seq}, + "views": views, + "conversation": conversation, + "receipts": [event for event in events if event.get("attempt")], + "version_notes": { + str(event["version"]): event["text"] + for event in events + if event["kind"] == "note" + }, + } + + +def project_browser_state( + page_dir: Path, + events: list, + view_revision: int | None, + active: dict | None, + claims: list, + source_overrides: dict[int, str] | None = None, + *, + include_active_view: bool = True, +) -> dict | None: + """Project only the documents one browser reading can consume. + + A normal state needs the revision the tab is showing and the active revision it + may activate next. Older comparison bases are projected on demand at the tab's + exact log boundary, rather than making every state poll parse every immutable + revision the page has ever had. + """ + if active is None: + return None + active_revision = active["revision"] + requested_revision = view_revision or active_revision + revisions = set(list_revisions(page_dir)) | set(source_overrides or {}) + if requested_revision not in revisions: + raise ValueError(f"unknown view revision r{requested_revision}") + wanted = {requested_revision, active_revision} + documents = {} + for revision in sorted(wanted): + if source_overrides and revision in source_overrides: + documents[revision] = source_overrides[revision] + else: + documents[revision] = revision_path(page_dir, revision).read_text( + encoding="utf-8" + ) + try: + registry = load_registry(page_dir) + except RegistryError: + return None + if registry is None: + return None + return browser_state( + documents, + events, + registry, + active_revision, + claims, + active, + wanted if include_active_view else {requested_revision}, + ) + + def full_state( page_dir: Path, events: list, _versions: list | None = None, layer: str | None = None, source_error: str | None = None, + view_revision: int | None = None, + active_override: dict | None = None, + source_overrides: dict[int, str] | None = None, ) -> dict: - try: - active = active_descriptor(page_dir, events) - except SystemExit: - active = None + if active_override is not None: + active = active_override + else: + try: + active = active_descriptor(page_dir, events) + except SystemExit: + active = None + present = presence(page_dir, events) + browser = project_browser_state( + page_dir, + events, + view_revision, + active, + present["claims"], + source_overrides, + ) return { "layer": layer or layer_generation(page_dir), # The clock every timestamp below was written by. A seat dating one reads @@ -184,7 +614,8 @@ def full_state( "versions": version_descriptors(page_dir, events), "source_error": source_error, "data": read_data(page_dir), - **presence(page_dir, events), + **present, + "browser": browser, # As logged: a message's text is Markdown the page's vendored runtime renders, # and its markup is the fragment the CLI gate validated. The wire adds nothing, # so the only vocabulary a page's frozen layer has to keep speaking is the diff --git a/tests/CLAUDE.md b/tests/CLAUDE.md index 697f5d6d4c..3ba11b9674 100644 --- a/tests/CLAUDE.md +++ b/tests/CLAUDE.md @@ -272,6 +272,10 @@ are not two tabs for a single reader unless they share a browser context. `Browser.new_page` creates an independent context; `one_reader` supplies one context for the tests whose subject is shared tab state. +The static product-site session is the deliberate exception for semantic page state: +it has no Python authority, so its illustrative gestures last for the current load and +reset on reload. Do not make browser storage a durable event log for that exhibit. + For complete, valid browser fixtures, use `leaf_page(title, body, head="")`. It supplies the same language, charset, CSP, theme, module, and main-content shell to every specimen. Keep raw documents only when source structure is the subject: lint diff --git a/tests/test_interact_server.py b/tests/test_interact_server.py index 9e31127c14..c2da8aad0f 100644 --- a/tests/test_interact_server.py +++ b/tests/test_interact_server.py @@ -701,6 +701,245 @@ def test_an_accepted_event_response_is_state_through_that_event(server, page_dir assert answer["state"]["events"][-1]["attempt"] == sent["attempt"] +def test_browser_state_is_the_same_snapshot_as_an_accepted_action(server, page_dir): + """The browser receives a reading, not a log it must interpret again. + + Its receipt, semantic coordinate, winner, undo offer, and readiness coverage all + name the event accepted by this same POST response and the same sequence boundary. + """ + version = page_dir / "versions" / "v1.html" + version.write_text( + version.read_text().replace( + "", + '' + 'Now' + 'Later' + "", + ) + ) + publish(page_dir) + sent = { + "kind": "action", + "revision": 1, + "widget": "delivery", + "action": "choose", + "detail": {"options": ["delivery-now"]}, + "attempt": "attempt-browser-view-1", + } + + status, body = fetch(f"{server}/api/event", data=json.dumps(sent).encode()) + + assert status == 200, body + state = json.loads(body)["state"] + accepted = state["events"][-1] + browser = state["browser"] + view = browser["views"]["1"] + [entry] = view["document"]["projection"]["entries"] + assert browser["basis"] == {"through_seq": accepted["seq"]} + assert view["basis"] == {"revision": 1, "through_seq": accepted["seq"]} + assert browser["receipts"][-1]["id"] == accepted["id"] + assert entry["event"]["id"] == accepted["id"] + assert entry["coordinate"] == ["delivery", "delivery", "selection"] + assert entry["value"] == ["delivery-now"] + assert view["document"]["projection"]["actions"] == [accepted["id"]] + assert view["undo"][0]["event"]["id"] == accepted["id"] + assert view["undo"][0]["restores_desired"] is False + assert view["coverage"] == [ + {"event": accepted, "coordinate": ["delivery", "delivery", "selection"]} + ] + + +def test_undo_candidate_names_the_prior_durable_winner(server, page_dir): + """The DOM need not re-fold the log to know a prior action will reappear.""" + version = page_dir / "versions" / "v1.html" + version.write_text( + version.read_text().replace( + "", + '' + 'Now' + 'Later' + "", + ) + ) + publish(page_dir) + + for option, attempt in ( + ("delivery-now", "attempt-prior-winner-1"), + ("delivery-later", "attempt-prior-winner-2"), + ): + status, body = fetch( + f"{server}/api/event", + data=json.dumps( + { + "kind": "action", + "revision": 1, + "widget": "delivery", + "action": "choose", + "detail": {"options": [option]}, + "attempt": attempt, + } + ).encode(), + ) + assert status == 200, body + + latest = json.loads(body)["state"]["browser"]["views"]["1"]["undo"][0] + assert latest["event"]["detail"] == {"options": ["delivery-later"]} + assert latest["restores_desired"] is True + + +def test_undo_offer_keeps_the_doors_active_page_containment(monkeypatch): + """A pinned view paints its own projection but admits undo against live threads.""" + active_within = {"settled-on-live-page": ("live",)} + view_within = {"settled-on-pinned-view": ("pinned",)} + seen = [] + + def record_undo_read(_event, _events, within): + seen.append(within) + + monkeypatch.setattr(served_state_model, "undo_error", record_undo_read) + empty = served_state_model.StateProjection({}, {}, {}, {}, {}) + event = {"id": "reader-resolve", "kind": "resolve", "author": "user"} + + candidates = served_state_model._browser_undo_candidates( + [event], active_within, view_within, {}, empty, empty + ) + + assert [candidate["event"] for candidate in candidates] == [event] + assert seen == [active_within] + + +def test_a_comparison_view_explains_an_unpublished_page(server, page_dir): + for path in (page_dir / "revisions").glob("*.html"): + path.unlink() + (page_dir / "index.html").unlink() + + status, body = fetch(f"{server}/api/view?revision=1&through_seq=0") + + assert status == 400 + assert json.loads(body) == { + "error": f"no active revision; write {page_dir / 'index.html'} first" + } + + +def test_state_refuses_a_view_revision_the_page_does_not_have(server, page_dir): + publish(page_dir) + + status, body = fetch(f"{server}/api/state?revision=999") + + assert status == 400 + assert json.loads(body) == {"error": "unknown view revision r999"} + + +def test_an_event_with_an_unknown_view_revision_is_not_appended(server, page_dir): + publish(page_dir) + before = event_model.read_events(page_dir) + layer = json.loads(fetch(f"{server}/api/state")[1])["layer"] + address = urllib.parse.urlsplit(server) + connection = http.client.HTTPConnection(address.hostname, address.port) + event = { + "kind": "comment", + "revision": 1, + "text": "This request cannot receive a view for its claimed revision.", + "attempt": "attempt-unknown-view", + } + + connection.request( + "POST", + f"/api/event?t={TOKEN}", + body=json.dumps(event), + headers={ + "Content-Type": "application/json", + "Leaf-Layer": layer, + "Leaf-View-Revision": "999", + }, + ) + response = connection.getresponse() + body = json.loads(response.read()) + connection.close() + + assert response.status == 400 + assert body["error"] == "unknown view revision r999" + assert body["final"] is True + assert event_model.read_events(page_dir) == before + + +def test_a_comparison_view_uses_the_requested_log_boundary(server, page_dir): + """A version diff compares with the state the live DOM has actually applied. + + Later writes may land while its immutable base document is loading. The on-demand + view therefore names the already-observed sequence and projects no event beyond it. + """ + version = page_dir / "versions" / "v1.html" + version.write_text( + version.read_text().replace( + "", + '' + 'Now' + 'Later' + "", + ) + ) + publish(page_dir) + + def choose(option, attempt): + status, body = fetch( + f"{server}/api/event", + data=json.dumps( + { + "kind": "action", + "revision": 1, + "widget": "delivery", + "action": "choose", + "detail": {"options": [option]}, + "attempt": attempt, + } + ).encode(), + ) + assert status == 200, body + return json.loads(body)["state"]["events"][-1] + + first = choose("delivery-now", "attempt-comparison-first") + choose("delivery-later", "attempt-comparison-later") + + status, body = fetch(f"{server}/api/view?revision=1&through_seq={first['seq']}") + + assert status == 200, body + browser = json.loads(body)["browser"] + assert set(browser["views"]) == {"1"} + view = browser["views"]["1"] + assert browser["basis"] == {"through_seq": first["seq"]} + assert view["basis"] == {"revision": 1, "through_seq": first["seq"]} + assert view["document"]["projection"]["desired"] == [first["id"]] + + +def test_a_comparison_view_refuses_a_future_log_boundary(server, page_dir): + publish(page_dir) + latest = event_model.read_events(page_dir)[-1]["seq"] + + status, body = fetch(f"{server}/api/view?revision=1&through_seq={latest + 1}") + + assert status == 400 + assert json.loads(body) == { + "error": f"view sequence {latest + 1} is newer than log sequence {latest}" + } + + +def test_state_projects_only_the_shown_and_next_revisions(server, page_dir): + publish(page_dir, 1) + for version in (2, 3): + (page_dir / "versions" / f"v{version}.html").write_text( + PAGE.replace("t", f"v{version}") + ) + publish(page_dir, version) + + status, body = fetch(f"{server}/api/state?revision=1") + + assert status == 200, body + state = json.loads(body) + assert state["active"]["revision"] == 3 + assert set(state["browser"]["views"]) == {"1", "3"} + + def test_an_accepted_retry_releases_the_page_before_scanning_neighbours( server, page_dir, monkeypatch ): @@ -723,10 +962,10 @@ def test_an_accepted_retry_releases_the_page_before_scanning_neighbours( scanned = threading.Event() original = http_model.Handler._page_state - def own_state(handler, events, source_error=None): + def own_state(handler, events, source_error=None, view_revision=None): assert service_model.lock_is_held(page_dir / "comments.jsonl") own_state_read.set() - return original(handler, events, source_error) + return original(handler, events, source_error, view_revision) def neighbours(directory): assert directory == page_dir diff --git a/tests/test_render_navigation.py b/tests/test_render_navigation.py index 2409d44679..91c5994c08 100644 --- a/tests/test_render_navigation.py +++ b/tests/test_render_navigation.py @@ -2872,6 +2872,7 @@ def test_a_label_press_keeps_the_controls_keyboard_standing(browser, serve): # It reads the same logical focus while the press moves toward the label's control. page.evaluate("() => getSelection().removeAllRanges()") page.locator(".lf-comments").click() + panel_settled(page) thread = page.locator(".lf-threads > .lf-thread") thread.focus() thread_standing = key_line(page) diff --git a/tests/test_render_projection.py b/tests/test_render_projection.py index 689149974e..53e72cd8a7 100644 --- a/tests/test_render_projection.py +++ b/tests/test_render_projection.py @@ -740,6 +740,71 @@ def test_a_workers_report_paints_live_and_ends_at_the_version_that_answers_it( page.close() +def test_a_comparison_retries_when_the_live_projection_advances(browser, serve): + """The immutable file and its state must describe the DOM in one reading. + + Hold the first projected base after the server has answered it, advance the open + page with a report, and then deliver that stale base. The comparison asks again at + the new sequence before painting; otherwise it marks the task as changed even though + the same report stands on both the base and current documents. + """ + url = serve(REPORT_PAGE) + d = serve.page_dir + stamp_page( + d, + REPORT_PAGE.replace("", '

A new prose line.

'), + "added prose", + ) + page, errors = open_page(browser, url.replace("v1.html", "v2.html")) + held = [] + requests = [] + + def hold_first_view(route): + requests.append(route.request.url) + if not held: + held.append([route, None, False]) + else: + route.continue_() + + page.route("**/api/view*", hold_first_view) + try: + page.locator(".lf-version").click() + with page.expect_request("**/api/view*"): + page.locator('.lf-version-diff[data-lf-version="1"]').click() + page.wait_for_timeout(0) # let the request event enter its route callback + assert held, "the first comparison view was not held" + held[0][1] = held[0][0].fetch() + + events_model.append_event( + d, + { + "kind": "report", + "author": "claude", + "revision": 1, + "widget": "t-parser", + "action": "status", + "detail": {"status": "done"}, + }, + ) + told(page) + expect(page.locator("#t-parser")).to_have_attribute("status", "done") + + with page.expect_request("**/api/view*"): + held[0][0].fulfill(response=held[0][1]) + held[0][2] = True + page.wait_for_timeout(0) + assert len(requests) >= 2, "the stale comparison view was not retried" + expect(page.locator(".lf-version")).to_have_text("Δ v2 ▾") + expect(page.locator("#new-copy")).to_have_class(re.compile(r"lf-ins-block")) + expect(page.locator("#t-parser")).not_to_have_class(re.compile(r"lf-ins-block")) + finally: + if held and not held[0][2]: + held[0][0].fulfill(response=held[0][1]) + page.unroute("**/api/view*") + assert errors == [] + page.close() + + def test_a_rosters_row_says_when_the_log_last_heard_from_that_worker(browser, serve): """The half of a roster no version can write down. A standing report states what each worker is doing; only the log knows when it last said so, and a page that keeps diff --git a/tests/test_render_startup.py b/tests/test_render_startup.py index 8164e77c24..503f5bac3a 100644 --- a/tests/test_render_startup.py +++ b/tests/test_render_startup.py @@ -2244,9 +2244,9 @@ def test_an_export_carries_runtime_data_as_a_labelled_snapshot( ) native_page_state = http_model.Handler.page_state - def delayed_page_state(handler): + def delayed_page_state(handler, view_revision=None): time.sleep(0.5) - return native_page_state(handler) + return native_page_state(handler, view_revision) monkeypatch.setattr(http_model.Handler, "page_state", delayed_page_state) out = tmp_path / "data-copy.html" @@ -2321,6 +2321,7 @@ def test_new_data_in_a_stale_event_response_is_still_accepted(browser, serve): unrelated comment arriving first. """ page, errors = open_page(browser, data_projection_page(serve)) + older = page.evaluate("async () => await (await fetch('/api/state')).json()") events_model.append_event( serve.page_dir, { @@ -2344,7 +2345,8 @@ def older_events_with_new_data(route): if state["data"]["revision"] < 2: route.fulfill(status=response.status, json=state) return - state["events"] = state["events"][:-1] + state["events"] = older["events"] + state["browser"] = older["browser"] crossed.append(True) route.fulfill(status=response.status, json=state) diff --git a/tests/test_site.py b/tests/test_site.py index 8132a347e6..b44f26dcfd 100644 --- a/tests/test_site.py +++ b/tests/test_site.py @@ -429,9 +429,12 @@ def test_a_comment_lands_in_the_thread_with_its_quote(site, hosted, browser): page.close() -def test_a_decision_holds_across_a_reload(site, hosted, browser): - """The log is the state here as much as anywhere: a pick is an action, and what puts - it back on the next load is replay rather than anything the markup remembers.""" +def test_a_static_demo_decision_resets_on_reload(site, hosted, browser): + """The static site offers a live tab, not a second durable Leaf implementation. + + A gesture paints and is projected for the visit, while reload starts again from the + checked-in example. Durable replay belongs to a served page's Python authority. + """ page, errors = open_page(browser, example_url(hosted, "design-decision")) try: chosen = ( @@ -445,10 +448,32 @@ def test_a_decision_holds_across_a_reload(site, hosted, browser): page.reload(wait_until="load") page.wait_for_function(BOTH_STAMPS) - expect(page.locator("#session-options")).to_have_attribute( - "data-lf-pending", "1" + expect(page.locator("#session-options[data-lf-pending]")).to_have_count(0) + assert "opt-jwt" not in page.evaluate(chosen), ( + "the static exhibit persisted a decision without a durable authority" + ) + assert not errors, errors[:3] + finally: + page.close() + + +def test_the_static_demo_answers_the_exact_projection_path(site, hosted, browser): + page, errors = open_page(browser, example_url(hosted, "design-decision")) + try: + answer = page.evaluate( + """async () => { + const state = await fetch('/api/state').then(response => response.json()); + const revision = state.active.revision; + const through = state.browser.basis.through_seq; + const response = await fetch( + `/api/view?revision=${revision}&through_seq=${through}`, + ); + return {status: response.status, through, body: await response.json()}; + }""" ) - assert "opt-jwt" in page.evaluate(chosen), "the reload lost the reader's pick" + assert answer["status"] == 200 + assert answer["body"]["browser"]["basis"] == {"through_seq": answer["through"]} + assert set(answer["body"]["browser"]["views"]) == {"1"} assert not errors, errors[:3] finally: page.close()