diff --git a/docs/registry.html b/docs/registry.html index b6aa964570..53ccb820e5 100644 --- a/docs/registry.html +++ b/docs/registry.html @@ -675,19 +675,20 @@

x-word

x-work

- Where this widget can show a transient local line for - leaf status --on: content appends it to a - block prose widget; conversation places it at the start of - the widget's declared x-conversation box and therefore inherits that - box's own predicate. + Where this widget can admit leaf status --on without a + pending reader move: content names a block prose seat; + conversation names the widget's declared x-conversation box + and therefore inherits that box's own predicate.

Full contract

- An optional when narrows the instances that have the seat - under their replayed attributes. The declaration is permission for - runtime chrome, not a consequence of x-content: prose may itself be a - holder gesture or sit in a hidden panel. + An optional when narrows the instances that may admit + work under their replayed attributes. Once admitted, a page-widget + claim appears at the page edge through its Target Button even if a + later layer changes x-work. The declaration is permission for runtime + chrome, not a consequence of x-content: prose may itself be a holder + gesture or sit in a hidden panel.

diff --git a/examples/ship-review.jsonl b/examples/ship-review.jsonl index f9a6546a01..76b5932896 100644 --- a/examples/ship-review.jsonl +++ b/examples/ship-review.jsonl @@ -3,4 +3,7 @@ {"kind": "reply", "author": "user", "parent": "1c8f52d6", "revision": 1, "text": "Fine for the first patch — I would take the feature over holding it for someone else's release. What I don't want is this sitting open in a month because the day it shipped it stopped being anyone's problem. What happens if their release comes and goes without the fix in it?", "id": "3d90b7c2", "ts": "2026-08-15T09:41:12-07:00"} {"kind": "reply", "author": "claude", "agent": "Claude", "parent": "1c8f52d6", "revision": 1, "text": "Then the workaround is what ships, and it keeps working: a delay stays a delay whether they fix it or not. What I don't want is for that to become the answer by inaction, which is the shape you're describing. Tick what I should carry into the patch and press Done when the list is right — none of it starts before the ship.", "markup": "\n

If their release comes and goes, what should I have ready?

\n \n Go back to them monthly until there is a date or a no\n Clear the stall ourselves, so nobody has to background the app\n Say what the spinner is, where the reader is looking at it\n \n
", "id": "c904d6a2", "ts": "2026-08-15T09:58:30-07:00"} {"kind": "action", "author": "user", "revision": 1, "widget": "off-slip", "action": "choose", "detail": {"options": ["off-slip-chase", "off-slip-say"]}, "generated": [], "id": "5e2a71b4", "ts": "2026-08-15T10:07:55-07:00"} +{"kind": "pickup", "author": "page", "events": ["5e2a71b4"], "id": "d8a6f2c1", "ts": "2026-08-15T10:07:56-07:00"} +{"kind": "action", "author": "user", "revision": 1, "widget": "off-workaround-review", "action": "choose", "detail": {"options": ["off-workaround-approve"]}, "generated": [], "id": "b9e3a841", "ts": "2026-08-15T10:08:10-07:00"} +{"kind": "pickup", "author": "page", "events": ["b9e3a841"], "id": "a4c29d10", "ts": "2026-08-15T10:08:11-07:00"} {"kind": "comment", "author": "claude", "agent": "Claude", "revision": 1, "text": "The vendor release is the only schedule we do not control. Should this task stay blocked until their fix is in our build, even if the workaround ships first?", "anchor": {"section": "off-t-vendor"}, "id": "64df0b27", "ts": "2026-08-15T10:12:08-07:00"} diff --git a/skills/leaf/CLAUDE.md b/skills/leaf/CLAUDE.md index b9a45c398a..fd6db75913 100644 --- a/skills/leaf/CLAUDE.md +++ b/skills/leaf/CLAUDE.md @@ -129,7 +129,7 @@ surfaces; `runtime/conversation/thread-card.js` owns retained panel thread cards, their quote state, and their reply, resolve, and reopen controls; `runtime/conversation/thread-list.js` owns retained panel list reconciliation; -`runtime/conversation/work-lines.js` owns live claim seats; and +`runtime/conversation/acknowledgments.js` owns growing acknowledgment receipts and live claim seats; and `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 @@ -161,12 +161,12 @@ Each mutable fact has one writer: | proof of what the DOM currently represents | `committedProjection` | `stageOutboxAction` and `reconcileState` | | anchor paint | thread and composer anchor records | `paintAnchors` | | where each thread's passage lands | this version's resolution of its anchor | `paintAnchors` writes `placed` | -| local agent work | the typed, log-projected claims in `status.work` | `paintWorkLines` paints every subject seat without becoming another store | +| reader acknowledgment and local agent work | the canonical acknowledgment projection plus typed claims in `status.work` | `paintAcknowledgments` paints conversation-local fallbacks; the living margin maps page subjects onto their existing Target Button without becoming another store | | composer visibility | `composerOpen` and `fabAnchor` | `showComposer` and `showFab` | | panel visibility | `panelOpen` | `setPanel` | | the narrowing on the thread list | the reader's find words and waiting-on-you press | `renarrow` and `widen` | | how much of the thread list's top a pinned heading covers | the tallest `.lf-pinned` box as rendered, while the panel is open | `paintHeadRoom` writes `--lf-head-room`, called by `renderThreads` and by a `ResizeObserver` on the list | -| the thread list's viewport position through reflow | the live reference card in the open panel | `renderThreads` and the held `paintWorkLines` call preserve it through reconciliation, provisional work, and resolution folds | +| the thread list's viewport position through reflow | the live reference card in the open panel | `renderThreads` and the held `paintAcknowledgments` call preserve it through reconciliation, provisional work, and resolution folds | | where the thread holding the focus stands in the list | the band the list declares landable through `scroll-padding` | `threadsBox`'s `focusin`, and its press through `pointerdown`/`pointerup`; `stepThread` for a key press that moves no focus, `landIn` for the box it puts the reader in, `placeThreadEdge` for an explicit edge placement, and `revealThread` for a deliberate centring | | tray visibility | `trayUp` | `showTray` writes reader gestures; `restoreTrays` loads saved intent and `restoreTray` paints it at presentation | | region width the reader drew | the reader's store, per edge | `drawnEdge`'s `set` and `restore` | @@ -706,7 +706,7 @@ The extension keys describe general behavior: | `x-decision` | the complete reading and arrival region around one nested decision source | | `x-awaits` | the condition, explicit answer verbs, and optional nested roll-up for a decision | | `x-conversation` | the condition under which the widget owns a conversation seat, and whether its root requires a version response | -| `x-work` | the content or conversation seat in which local agent work may appear, with an optional condition | +| `x-work` | admits local agent work without a pending reader move, through a content or conversation seat and optional condition; an admitted page-widget claim then appears at the page edge through its Target Button | | `x-exhibit` | this occurrence is evidence, not an actionable live widget | | `x-wide` | whether width follows a box or a drawing | @@ -1348,6 +1348,16 @@ disclosure Button. `…` appears only when the cluster has secondary controls, information beside a direct action, or temporary communication choices. A target with one unambiguous control therefore gets one Button, not a row of variants. +An unsettled reader action reuses that same Button rather than growing a status row +inside authored content. Its information face advances from **Sent** or **Waiting for +pickup** to **Picked up**, then to **Active** only when a typed local claim exists; an +action's standing outcome supplies the same retained target cluster throughout. A +thread's existing Thread Button remains the page-edge route to the exact receipt in +the full conversation; an **Active** claim joins that cluster under `…`. A standalone +page-widget claim gets an **Active** Button directly. When no page edge exists—inside +the full thread panel or a widget frozen into conversation chrome—the compact +`.lf-receipt` remains the local fallback. + Content modules contribute through `registerMarginItem`; they own their verbs and events, never placement or control styling. Every press in a contribution is built with `marginAction(control, {glyph, label, behavior, tone, collapse})`. That is the @@ -2380,16 +2390,18 @@ help, inspection paint, legend, and address layer. The page and panel are separate scroll regions. Opening or closing one calls its state setter, updates the persisted intent, and schedules the shared layout and key paint. -`.lf-work-line` is transient runtime chrome that may also stand inside a page -widget. `paintWorkLines` is its one writer. A thread subject paints in the panel -and every inline conversation seat; a page-widget subject paints only in the -content or conversation seat its active `x-work` declaration names. A content -seat is block prose, but block prose alone grants no seat. The line wears `lf-ui` -and `data-lf-gen`: -it is an account of the widget, not authored words of the widget, so selection -and diff readings skip it. Reconcile widget state first and paint work afterward, +`.lf-receipt` is transient runtime chrome for a subject with no page-edge Button. +`paintAcknowledgments` is its one writer. An unsettled reader message paints after +that exact message in the full thread panel, and an event-backed widget frozen into +conversation chrome paints beneath its owner. Inline page conversations and page +widgets use their target's existing margin cluster instead; an explicit page-widget +claim is the cluster's **Active** reading. The fallback receipt wears `lf-ui` and +`data-lf-gen`: it is an account of the conversation, not authored words, so selection +and diff readings skip it. Reconcile widget state first and paint receipts afterward, because a module may rebuild the subtree that seats it. Keep surviving nodes -across state applications so an unchanged claim is not re-announced. +across state applications so an unchanged phase is not re-announced. Its live +state span changes only with semantic phase or detail; the separate age clock +may repaint on a heartbeat without entering the live region. The thread list reconciles nodes rather than rebuilding them. `setChildren` preserves existing message, reply, and textarea nodes when the same event still @@ -2402,7 +2414,7 @@ Tests pin the thread's box rather than a particular scroll offset. card under the pointer while the pointer is in the list, then the card containing focus, then the topmost visible card. It records later visible cards before the mutation and refreshes their baselines after each correction, so a live successor can -take over if the first leaves or becomes hidden. The held `paintWorkLines` call covers +take over if the first leaves or becomes hidden. The held `paintAcknowledgments` call covers claim-only mutations too. The list follows changes in the card's content position, keeping reflow out from under the pointer without fighting an intentional scroll. Browser scroll anchoring is disabled only for the life of a hold so those two diff --git a/skills/leaf/assets/leaf.js b/skills/leaf/assets/leaf.js index 56d47d8405..052880bef2 100644 --- a/skills/leaf/assets/leaf.js +++ b/skills/leaf/assets/leaf.js @@ -280,6 +280,7 @@ import { createPresence, observeServerNow, quietSince, + waitingForPickupSince, } from "./runtime/presence.js"; import { createPointer } from "./runtime/pointer.js"; import { createReactions, paintReactionStanding } from "./runtime/reactions.js"; @@ -1116,7 +1117,7 @@ commentsEdge.over.addEventListener("change", () => { // distinction for a Threads panel restored or opened during startup; its General // composer stays usable while the log-derived list says what it is waiting for. -// The threads the panel last reconciled. A work line repaints on the heartbeat's clock and +// The threads the panel last reconciled. A receipt repaints on the heartbeat's clock and // not only on the log's, because its age is half of what it says and a claim nobody // renews is exactly the one whose age has stopped moving. Keeping the last fold is what // makes that cheap: buildThreads walks the log and the page, and a second walk every two @@ -3051,8 +3052,8 @@ function awaitsReader(...args) { function setChildren(...args) { return conversationRuntime.setChildren(...args); } -function paintWorkLines(...args) { - return conversationRuntime.paintWorkLines(...args); +function paintAcknowledgments(...args) { + return conversationRuntime.paintAcknowledgments(...args); } function widen(...args) { return conversationRuntime.widen(...args); @@ -3211,7 +3212,7 @@ const runtimeProjection = createProjection(runtime, { pageShifted, paintAnchors, paintKeys, - paintWorkLines, + paintAcknowledgments, post, projectedParent, quoteFrom, @@ -3329,6 +3330,7 @@ conversationRuntime = createConversation({ post, PRESS, quietSince, + waitingForPickupSince, reachScrollers, reachedForWords, reactDone: () => setReact(false), @@ -3426,6 +3428,7 @@ const { ITEM, NOTE } = anchorRuntime; livingMargin = createLivingMargin({ anchorLabel, + acknowledgments: () => runtime.browser?.acknowledgments ?? [], announce, approveBtn, blockAt, @@ -3435,7 +3438,9 @@ livingMargin = createLivingMargin({ comparisonChanges, compact: commentsEdge.over, closestAcross, + currentRevision: () => runtime.currentRevision, designIsOn: () => designOn, + droppedAt, el, elementById, focused, @@ -3449,6 +3454,7 @@ livingMargin = createLivingMargin({ panelIsOpen: chromeLayout.panelIsOpen, paintKeys, placedAt, + quietSince, renderMarginThread: conversationRuntime.renderMarginThread, scrollBehavior, scrollToElement, @@ -3460,6 +3466,7 @@ livingMargin = createLivingMargin({ toggleBtn, updateSequence, versionBtn, + waitingForPickupSince, }); viewRuntime = createViewContinuity({ @@ -3527,7 +3534,7 @@ stateApplication = createStateApplication({ observeServerNow, paintAnchors, paintApproval, - paintWorkLines, + paintAcknowledgments, panelIsOpen, presented, reconcileState, diff --git a/skills/leaf/assets/registry.json b/skills/leaf/assets/registry.json index ea0e6669ac..7cb5bf8779 100644 --- a/skills/leaf/assets/registry.json +++ b/skills/leaf/assets/registry.json @@ -72,7 +72,7 @@ "contracts": {} }, "$events": { - "description": "The event contract this layer's runtime and CLI speak. Each kind carries a closed `record` schema for the complete event read from the log. A `browser` schema opts that kind into POST /api/event and adds the browser writer's assertions; a record contract alone opens no browser door. `page init` checks the existing log against the incoming record schemas before it re-vendors, so a recorded event never becomes unreadable. Kind contracts replace whole when layers merge: a half-old, half-new schema is no layer's statement. A `comment` or `reply` carrying `token` instead of `text` is a reaction — one word from $reactions, aimed where the comment's anchor or the reply's parent points, or with neither at the page whole. It starts as a mark rather than a conversation: no thread opens until someone replies to it, `resolve` on it is the floor after which it stops painting, and while nothing has answered it the reader takes it back with an ordinary `undo` naming it.", + "description": "The event contract this layer's runtime and CLI speak. Each kind carries a closed `record` schema for the complete event read from the log. A `browser` schema opts that kind into POST /api/event and adds the browser writer's assertions; a record contract alone opens no browser door. `page init` checks the existing log against the incoming record schemas before it re-vendors, so a recorded event never becomes unreadable. Kind contracts replace whole when layers merge: a half-old, half-new schema is no layer's statement. A `pickup` is the page-owned durable transport fact that named reader events reached their next consumer; it is never a claim that work has begun. A `comment` or `reply` carrying `token` instead of `text` is a reaction — one word from $reactions, aimed where the comment's anchor or the reply's parent points, or with neither at the page whole. It starts as a mark rather than a conversation: no thread opens until someone replies to it, `resolve` on it is the floor after which it stops painting, and while nothing has answered it the reader takes it back with an ordinary `undo` naming it.", "kinds": { "comment": { "record": { @@ -304,6 +304,26 @@ "additionalProperties": false } }, + "pickup": { + "record": { + "type": "object", + "properties": { + "id": {"type": "string", "minLength": 1}, + "ts": {"type": "string", "minLength": 1}, + "author": {"const": "page"}, + "kind": {"const": "pickup"}, + "seq": {"type": "integer", "minimum": 1}, + "events": { + "type": "array", + "items": {"type": "string", "minLength": 1}, + "minItems": 1, + "uniqueItems": true + } + }, + "required": ["id", "ts", "author", "kind", "seq", "events"], + "additionalProperties": false + } + }, "undo": { "record": { "type": "object", @@ -423,7 +443,7 @@ "x-wide": "The width model, for evidence set to its content rather than to the measure prose is read at: `box` (the widget lays its content out into whatever width it is given, a board's columns, so it stands at the one width the whole vocabulary shares) or `drawing` (the widget renders one thing drawn at a size of its own, a diagram's graph, so its box is the clip around it — the room the page has, with the drawing as near the column's axis as the claimed margins allow, and scrolling where even the room is short). A drawing declares no `x-says`: that box is laid out as a row holding what was drawn, so a word the layer writes into it would stand beside the drawing rather than over it, and the registry refuses the pair. Nothing is authored for it and no page asks: the theme grows the element out of the column into whichever margins are free. A margin holding something of the page's gives nothing where it holds it, and only there: an exhibit level with a note drops below it and then takes the whole width; one level with a suggestion's controls keeps its place and declines their side, a positioned row being nothing an exhibit can drop below; and one that nothing reaches keeps its place and grows both ways.", "x-withdrawn-as": "On a holder with retirable slots: the outcome an undecided instance stands as when a later version takes it back unanswered — a withdrawn suggestion leaves the page where a `reject` would — which is what lets that version drop the ids inside the slots the outcome retires. Hedged, because there the author asserts a state the user never gave: only whole, and never while a thread is anchored in it.", "x-word": "What x-word declares: this tag's own word for what it is comes from its module, which answers `lfWord()` on the element, rather than from the tag's name. Almost no widget declares it, and none needs to while one tag is one kind of thing: a name away from the page is then the tag with its prefix dropped. It is wrong where a tag holds several kinds. Every shape of lf-suggestion came out as \"suggestion\", so a row on the decisions tray named a deletion by the words it proposed to remove and read exactly like the insertion above it. The word is asked for wherever the page names an element away from itself — a tray row, a comment's anchor label — and an element that has not upgraded yet, or answers nothing, falls back to the tag.", - "x-work": "Where this widget can show a transient local line for `leaf status --on`: `content` appends it to a block prose widget; `conversation` places it at the start of the widget's declared x-conversation box and therefore inherits that box's own predicate. An optional `when` narrows the instances that have the seat under their replayed attributes. The declaration is permission for runtime chrome, not a consequence of x-content: prose may itself be a holder gesture or sit in a hidden panel." + "x-work": "Where this widget can admit `leaf status --on` without a pending reader move: `content` names a block prose seat; `conversation` names the widget's declared x-conversation box and therefore inherits that box's own predicate. An optional `when` narrows the instances that may admit work under their replayed attributes. Once admitted, a page-widget claim appears at the page edge through its Target Button even if a later layer changes x-work. The declaration is permission for runtime chrome, not a consequence of x-content: prose may itself be a holder gesture or sit in a hidden panel." }, "$state": { "facet": "A stable HTML-name identifying one independently standing fact within this tag. Together with the owning widget instance and unit it is the fold coordinate. Verbs on the same facet share one unit and an identical record form (or are all recordless); facet names are semantic and local to the tag, not a closed vocabulary.", @@ -441,7 +461,7 @@ "$settlement": { "description": "The typed `settles` relation on a version note ends provisional agent facts. Each target is `{kind, id}` so independently allocated id namespaces cannot collide and adding a kind does not add another parallel note field. The relation is explicit and durable: chronology alone ends nothing, and a later note need not repeat a settlement.", "report": "The id is a report event. Stamping derives these targets from reported state the source absorbs or explicitly overrules.", - "work": "The id is a page-widget work subject. `version stamp --completes` accepts only standing work claimed before that revision and writes this target; it also licenses the revision to remove the subject's local seat.", + "work": "The id is a page-widget work subject. `version stamp --completes` accepts only standing work claimed before that revision and writes this target; it also licenses the revision to remove the subject's page target.", "why-restated-is-separate": "`restated` is not a settlement. It retracts a reader decision because authored words or state invalidated what the decision rested on, while `settles` completes provisional information supplied by the agent. Conflating them would make ending work look like taking back the reader's action." }, "$awaits": { diff --git a/skills/leaf/assets/runtime/chrome-layout.js b/skills/leaf/assets/runtime/chrome-layout.js index 7cd7e70494..275175b25f 100644 --- a/skills/leaf/assets/runtime/chrome-layout.js +++ b/skills/leaf/assets/runtime/chrome-layout.js @@ -71,7 +71,7 @@ export function createChromeLayout({ // distinction for a Threads panel restored or opened during startup; its General // composer stays usable while the log-derived list says what it is waiting for. - // The threads the panel last reconciled. A work line repaints on the heartbeat's clock and + // The threads the panel last reconciled. A receipt repaints on the heartbeat's clock and // not only on the log's, because its age is half of what it says and a claim nobody // renews is exactly the one whose age has stopped moving. Keeping the last fold is what // makes that cheap: buildThreads walks the log and the page, and a second walk every two diff --git a/skills/leaf/assets/runtime/conversation/acknowledgments.js b/skills/leaf/assets/runtime/conversation/acknowledgments.js new file mode 100644 index 0000000000..509736ac69 --- /dev/null +++ b/skills/leaf/assets/runtime/conversation/acknowledgments.js @@ -0,0 +1,127 @@ +/* Interaction-scoped acknowledgment receipts and explicit work claims. */ +export function createAcknowledgments(dependencies) { + const { + ago, + claimState, + droppedAt, + el, + elementById, + inChrome, + pageQueryAll, + quietSince, + runtime, + threads, + threadsBox, + waitingForPickupSince, + } = dependencies; + + const phaseText = (receipt) => { + if (receipt.phase === "active") + return receipt.detail ? `● Active — ${receipt.detail}` : "● Active"; + if (receipt.phase === "picked_up") return "✓ Picked up"; + return waitingForPickupSince(receipt.ts) ? "○ Waiting for pickup" : "✓ Sent"; + }; + + // One retained node follows one reader move through every semantic phase. Only a + // phase/detail change touches its live region; the heartbeat updates the separate + // clock without making a screen reader repeat the state every two seconds. + function paintReceipt(host, receipt, before, wanted) { + if (!host) return; + let line = [...host.children].find( + (child) => child.matches(".lf-receipt") && child.dataset.receiptId === receipt.id, + ); + const created = !line; + if (!line) { + line = el("div", "lf-receipt lf-ui"); + line.dataset.lfGen = "1"; + line.dataset.receiptId = receipt.id; + const state = el("span", "lf-receipt-state"); + state.setAttribute("role", "status"); + state.setAttribute("aria-live", "polite"); + state.setAttribute("aria-atomic", "true"); + line.append(state, el("time")); + } + const next = before ?? null; + if (line.parentElement !== host || line.nextSibling !== next) + host.insertBefore(line, next); + wanted.add(line); + + const state = line.querySelector(":scope > .lf-receipt-state"); + const previous = line.dataset.phase; + const semantic = phaseText(receipt); + if (state.textContent !== semantic) state.textContent = semantic; + if (!created && previous && previous !== `${receipt.phase}:${semantic}`) { + line.classList.remove("lf-receipt-change"); + void line.offsetWidth; + line.classList.add("lf-receipt-change"); + } + line.dataset.phase = `${receipt.phase}:${semantic}`; + line.classList.toggle("is-active", receipt.phase === "active"); + + let quiet = line.querySelector(":scope > .lf-receipt-quiet"); + const turnClosed = + receipt.session && receipt.session === claimState().claimingSession + ? claimState().agentTurnClosed + : null; + const isQuiet = + receipt.phase === "active" && + (quietSince(receipt.ts) || droppedAt(receipt.ts, turnClosed)); + if (isQuiet && !quiet) { + quiet = el("span", "lf-receipt-quiet", "quiet"); + line.insertBefore(quiet, line.lastElementChild); + } else if (!isQuiet) quiet?.remove(); + + const time = line.querySelector(":scope > time"); + const age = ago(receipt.ts); + if (time.textContent !== age) time.textContent = age; + } + + function paintAcknowledgments() { + const wanted = new Set(); + const receipts = runtime.browser?.acknowledgments ?? []; + for (const projected of receipts) { + if (projected.phase === "active" && !claimState().claimsHeld && !projected.event) + continue; + const receipt = + projected.phase === "active" && !claimState().claimsHeld + ? { + ...projected, + phase: projected.fallback_phase, + ts: projected.fallback_ts, + detail: null, + } + : projected; + const { kind, id } = receipt.target; + if (kind === "thread") { + const thread = threads().find((candidate) => candidate.root.id === id); + if (!thread || thread.resolved) continue; + const complete = threadsBox.querySelector(`.lf-thread[data-id="${id}"]`); + const completeSource = receipt.event + ? complete?.querySelector(`:scope > .lf-msg[data-mid="${receipt.event}"]`) + : null; + paintReceipt( + complete, + receipt, + completeSource?.nextSibling ?? + complete?.querySelector(":scope > .lf-compose"), + wanted, + ); + continue; + } + if (kind !== "widget") continue; + if (receipt.revision > runtime.currentRevision) continue; + const owner = elementById(id); + // Frozen widgets sent in a message have no page edge of their own, so their + // event-backed receipt remains local to the conversation. A page widget uses + // its existing Target Button instead of growing another row inside authored + // content; standalone claims in chrome remain unsupported claim subjects. + if (owner && receipt.event && inChrome(owner)) + paintReceipt(owner, receipt, null, wanted); + } + for (const line of pageQueryAll(".lf-receipt")) + if (!wanted.has(line)) line.remove(); + document.dispatchEvent(new CustomEvent("lf-acknowledgments")); + } + + return { paintAcknowledgments }; +} diff --git a/skills/leaf/assets/runtime/conversation/inline.js b/skills/leaf/assets/runtime/conversation/inline.js index e006e381d0..cc59a762fb 100644 --- a/skills/leaf/assets/runtime/conversation/inline.js +++ b/skills/leaf/assets/runtime/conversation/inline.js @@ -97,10 +97,19 @@ export function createInlineConversations({ wireReply(t, input, send); } } - const work = thread.querySelector(":scope > .lf-work-line"); + const receipts = [...thread.querySelectorAll(":scope > .lf-receipt")]; + const after = new Map( + receipts.map((receipt) => [receipt.dataset.receiptId, receipt]), + ); + const placed = new Set(); + const messageRows = messages.flatMap((message) => { + const receipt = after.get(message.dataset.event); + if (receipt) placed.add(receipt); + return receipt ? [message, receipt] : [message]; + }); setChildren(thread, [ - ...messages, - ...(work ? [work] : []), + ...messageRows, + ...receipts.filter((receipt) => !placed.has(receipt)), ...(tail ? [tail] : []), ]); return thread; @@ -121,9 +130,9 @@ export function createInlineConversations({ const first = host.lfFirstMessage; const hold = registry[owner.localName]?.["x-conversation"]?.hold; const pending = hold || loadDraft("say:" + owner.id) !== null ? first : null; - const work = host.querySelector(":scope > .lf-work-line"); + const receipts = [...host.querySelectorAll(":scope > .lf-receipt")]; setChildren(host, [ - ...(work ? [work] : []), + ...receipts, ...owned.map((thread) => conversationThreadNode(host, thread)), ...(pending ? [pending] : []), ]); diff --git a/skills/leaf/assets/runtime/conversation/narrowing.js b/skills/leaf/assets/runtime/conversation/narrowing.js index 54377d0093..12b9912037 100644 --- a/skills/leaf/assets/runtime/conversation/narrowing.js +++ b/skills/leaf/assets/runtime/conversation/narrowing.js @@ -6,7 +6,7 @@ export function createConversationNarrowing(dependencies) { el, findInput, needsBtn, - paintWorkLines, + paintAcknowledgments, panelTitle, renderThreads, runtime, @@ -83,7 +83,7 @@ export function createConversationNarrowing(dependencies) { function renarrow() { if (runtime.statePhase !== "ready") return; renderThreads(threads()); - paintWorkLines(); + paintAcknowledgments(); // A new set of results starts at its own beginning. Keeping the old offset lands the // reader in the middle of a shorter list, or past the end of it, over a change they // made a keystroke at a time. diff --git a/skills/leaf/assets/runtime/conversation/reconcile.js b/skills/leaf/assets/runtime/conversation/reconcile.js index d95147de52..2002d3ba6c 100644 --- a/skills/leaf/assets/runtime/conversation/reconcile.js +++ b/skills/leaf/assets/runtime/conversation/reconcile.js @@ -9,7 +9,7 @@ import { createConversationReactionStrips } from "./reaction-strips.js"; import { createReplies } from "./replies.js"; import { createThreadCards } from "./thread-card.js"; import { createConversationThreadList } from "./thread-list.js"; -import { createWorkLines } from "./work-lines.js"; +import { createAcknowledgments } from "./acknowledgments.js"; /* Conversation state and panel reconciliation. */ export function createConversation(dependencies) { @@ -39,7 +39,6 @@ export function createConversation(dependencies) { layerPart, loadDraft, markDeclared, - matchesWhen, mirrorDraft, motion, needsBtn, @@ -79,6 +78,7 @@ export function createConversation(dependencies) { threadsBox, toggleBtn, updateSequence, + waitingForPickupSince, visualPartLabel, wireInput, withdraw, @@ -180,26 +180,25 @@ export function createConversation(dependencies) { placedAt, sectionOf, }); - const { paintWorkLines: paintWorkLinesUnheld } = createWorkLines({ - agentName, + const { paintAcknowledgments: paintAcknowledgmentsUnheld } = createAcknowledgments({ ago, claimState, droppedAt, el, elementById, inChrome, - matchesWhen, pageQueryAll, quietSince, - registry, runtime, threads: () => threadList, threadsBox, - updateSequence, + waitingForPickupSince, }); - function paintWorkLines(...args) { - if (!threadListRuntime) return paintWorkLinesUnheld(...args); - return threadListRuntime.holdScrollPosition(() => paintWorkLinesUnheld(...args)); + function paintAcknowledgments(...args) { + if (!threadListRuntime) return paintAcknowledgmentsUnheld(...args); + return threadListRuntime.holdScrollPosition(() => + paintAcknowledgmentsUnheld(...args), + ); } const narrowing = createConversationNarrowing({ @@ -208,7 +207,7 @@ export function createConversation(dependencies) { el, findInput, needsBtn, - paintWorkLines, + paintAcknowledgments, panelTitle, renderThreads, runtime, @@ -353,7 +352,7 @@ export function createConversation(dependencies) { renderThreads(threads); renderConversations(threadList); paintPageStrip(threads); - paintWorkLines(); + paintAcknowledgments(); } return { @@ -366,7 +365,7 @@ export function createConversation(dependencies) { narrowed, awaitsReader, setChildren, - paintWorkLines, + paintAcknowledgments, widen, paintThreadQuotes: cards.paintThreadQuotes, renderMarginThread, diff --git a/skills/leaf/assets/runtime/conversation/thread-card.js b/skills/leaf/assets/runtime/conversation/thread-card.js index bbf8692283..8cb2682e5f 100644 --- a/skills/leaf/assets/runtime/conversation/thread-card.js +++ b/skills/leaf/assets/runtime/conversation/thread-card.js @@ -39,7 +39,7 @@ export function createThreadCards(dependencies) { if (existing && existingResolved === Boolean(t.resolved)) { const compose = existing.querySelector(":scope > .lf-compose"); const tail = - existing.querySelector(":scope > .lf-work-line") ?? + existing.querySelector(":scope > .lf-receipt") ?? compose ?? existing.querySelector(":scope > .lf-thread-actions"); for (const m of turns(t)) { diff --git a/skills/leaf/assets/runtime/conversation/work-lines.js b/skills/leaf/assets/runtime/conversation/work-lines.js deleted file mode 100644 index 046c3c9e09..0000000000 --- a/skills/leaf/assets/runtime/conversation/work-lines.js +++ /dev/null @@ -1,135 +0,0 @@ -/* Live work claims rendered at their thread or widget-owned seats. */ -export function createWorkLines(dependencies) { - const { - agentName, - ago, - claimState, - droppedAt, - el, - elementById, - inChrome, - matchesWhen, - pageQueryAll, - quietSince, - registry, - runtime, - threads, - threadsBox, - updateSequence, - } = dependencies; - - // One writer for every local work line. Which box it stands in is the subject's — a - // thread's complete or inline seat, a widget's declared conversation, or a prose - // widget itself — while the sentence, silence word, and clock are identical. Lines are - // kept across polls so an unchanged claim is not announced again every two seconds. - function paintWorkLine(host, update, before, wanted) { - if (!host) return; - const { kind, id } = update.target; - let line = [...host.children].find( - (child) => - child.matches(".lf-work-line") && - child.dataset.subjectKind === kind && - child.dataset.subjectId === id, - ); - if (!line) { - line = el("div", "lf-work-line lf-ui"); - line.dataset.lfGen = "1"; - line.dataset.subjectKind = kind; - line.dataset.subjectId = id; - line.append(el("span"), el("time")); - } - const next = before ?? null; - if (line.parentElement !== host || line.nextSibling !== next) - host.insertBefore(line, next); - wanted.add(line); - const what = line.firstElementChild; - const when = line.lastElementChild; - // Written only on change, like the message clocks beside it: an unchanged poll must - // not hand the reader's screen reader the same sentence every two seconds. - const said = `${update.agent || agentName()} is on this — ${update.text}`; - if (what.textContent !== said) what.textContent = said; - // A claim of work nobody has renewed, said in a word. The banner cannot answer for - // this seat: every `leaf status … --on` write refreshes the page's own line, so one - // delegate still reporting keeps the banner green while another's claim ages here — - // the fleet's dead-row failure one level down, and the reason the roster says this - // in words rather than leaving it to a tint. Both of the banner's own questions, - // asked here by the same two predicates: gone unrenewed too long, or left behind by - // a turn that ended. A local line is written by the command that writes the claim, so a - // seat answering either question differently would have the page arguing with - // itself about one silence. `ago` is still rendered whole beside the word rather - // than reworded to absorb it. The cell is added and removed rather than hidden, - // because a hidden one still reads out in the thread's text. - let cold = line.querySelector(":scope > .lf-work-quiet"); - const turnClosed = - update.session && update.session === claimState().claimingSession - ? claimState().agentTurnClosed - : null; - if (quietSince(update.ts) || droppedAt(update.ts, turnClosed)) { - if (!cold) line.insertBefore(el("span", "lf-work-quiet", "quiet"), when); - } else cold?.remove(); - const age = ago(update.ts); - if (when.textContent !== age) when.textContent = age; - } - - function widgetWorkSeat(owner) { - const work = registry[owner.localName]?.["x-work"]; - if (!work || !matchesWhen(owner, work.when)) return null; - if (work.seat === "content") return { host: owner, before: null }; - const conversation = registry[owner.localName]["x-conversation"]; - if (!matchesWhen(owner, conversation.when)) return null; - const host = [...owner.children].find( - (child) => - child.matches(".lf-conversation[data-lf-conversation]") && - child.dataset.lfConversation === owner.id, - ); - if (!host) return null; - const before = [...host.children].find((child) => !child.matches(".lf-work-line")); - return { host, before: before ?? null }; - } - - // Every local seat for every typed subject. The merged x-work declaration decided at - // the CLI boundary whether a widget could safely carry one and tells this reading which - // of the two general seats to use. Core still knows no content-widget tag name. A claim - // created on a later version cannot leak backward into a pinned historical page. - function paintWorkLines() { - const wanted = new Set(); - const claims = claimState().claimsHeld - ? updateSequence().filter( - (update) => update.source === "claim" && update.disposition === "effective", - ) - : []; - for (const update of claims) { - const { kind, id } = update.target; - if (kind === "thread") { - const thread = threads().find((candidate) => candidate.root.id === id); - if (!thread || thread.resolved) continue; - const complete = threadsBox.querySelector(`.lf-thread[data-id="${id}"]`); - paintWorkLine( - complete, - update, - complete?.querySelector(":scope > .lf-compose"), - wanted, - ); - for (const inline of document.querySelectorAll( - `.lf-conversation-thread[data-thread="${id}"]`, - )) - paintWorkLine( - inline, - update, - inline.querySelector(":scope > .lf-say"), - wanted, - ); - continue; - } - if (kind !== "widget" || update.revision > runtime.currentRevision) continue; - const owner = elementById(id); - if (!owner || inChrome(owner)) continue; - const seat = widgetWorkSeat(owner); - if (seat) paintWorkLine(seat.host, update, seat.before, wanted); - } - for (const line of pageQueryAll(".lf-work-line")) - if (!wanted.has(line)) line.remove(); - } - - return { paintWorkLines }; -} diff --git a/skills/leaf/assets/runtime/living-margin.js b/skills/leaf/assets/runtime/living-margin.js index e25a6f8d34..53ab27ff7c 100644 --- a/skills/leaf/assets/runtime/living-margin.js +++ b/skills/leaf/assets/runtime/living-margin.js @@ -14,7 +14,10 @@ const KINDS = { comment: { label: "Thread", symbol: "💬", priority: 1 }, decision: { label: "Ask", symbol: "?", priority: 2 }, outcome: { label: "Outcome", symbol: "✓", priority: 3 }, - activity: { label: "Agent activity", symbol: "↻", priority: 4 }, + sent: { label: "Sent", symbol: "✓", priority: 3 }, + pickup: { label: "Picked up", symbol: "✓", priority: 3 }, + waiting: { label: "Waiting for pickup", symbol: "○", priority: 3 }, + activity: { label: "Active", symbol: "●", priority: 4 }, }; // Content modules contribute what their target offers; this projection decides where @@ -180,6 +183,7 @@ function comesBefore(left, right) { export function createLivingMargin(dependencies) { const { anchorLabel, + acknowledgments, announce, approveBtn, blockAt, @@ -189,7 +193,9 @@ export function createLivingMargin(dependencies) { comparisonChanges, compact, closestAcross, + currentRevision, designIsOn, + droppedAt, el, elementById, focused, @@ -203,6 +209,7 @@ export function createLivingMargin(dependencies) { panelIsOpen, paintKeys, placedAt, + quietSince, renderMarginThread, scrollBehavior, scrollToElement, @@ -214,6 +221,7 @@ export function createLivingMargin(dependencies) { toggleBtn, updateSequence, versionBtn, + waitingForPickupSince, } = dependencies; const nav = el("nav", "lf-ui lf-living-margin"); @@ -418,7 +426,10 @@ export function createLivingMargin(dependencies) { function markerFace(entry) { const kinds = kindsIn(entry, { markerOnly: true }); const choice = primaryReading(entry); - const face = KINDS[choice?.kind] ?? KINDS.action; + const face = + (choice?.items.length === 1 && choice.items[0].acknowledgmentFace) || + KINDS[choice?.kind] || + KINDS.action; const faceCount = choice?.items.length ?? 0; return { kinds, @@ -517,8 +528,51 @@ export function createLivingMargin(dependencies) { group.items.push(item); } + function visibleAcknowledgments() { + const visible = []; + for (const projected of acknowledgments()) { + if (projected.revision > currentRevision()) continue; + if (projected.phase !== "active" || claimState().claimsHeld) { + visible.push(projected); + continue; + } + if (!projected.event) continue; + visible.push({ + ...projected, + phase: projected.fallback_phase, + ts: projected.fallback_ts, + detail: null, + }); + } + return visible; + } + + function acknowledgmentFace(receipt) { + if (receipt.phase === "active") { + const turnClosed = + receipt.session && receipt.session === claimState().claimingSession + ? claimState().agentTurnClosed + : null; + const quiet = quietSince(receipt.ts) || droppedAt(receipt.ts, turnClosed); + return { + kind: "activity", + text: ["Active", receipt.detail, quiet ? "quiet" : null] + .filter(Boolean) + .join(" · "), + }; + } + if (receipt.phase === "picked_up") return { kind: "pickup", text: "Picked up" }; + if (waitingForPickupSince(receipt.ts)) + return { kind: "waiting", text: "Waiting for pickup" }; + return { kind: "sent", text: "Sent" }; + } + function collectEntries() { const groups = new Map(); + const receiptByCoordinate = new Map(); + for (const receipt of visibleAcknowledgments()) { + receiptByCoordinate.set(JSON.stringify(receipt.coordinate), receipt); + } for (const thread of threads()) { if (thread.resolved || !thread.root.anchor) continue; const id = thread.root.id; @@ -549,6 +603,7 @@ export function createLivingMargin(dependencies) { } const projection = stateProjection(); + const activityAlreadyShown = new Set(); for (const [coordinate, entry] of projection.desired) { if (entry.e.kind !== "action") continue; const target = elementById(entry.unit) ?? elementById(entry.e.widget); @@ -556,11 +611,16 @@ export function createLivingMargin(dependencies) { const account = [itemWord(target), humanized(entry.e.action), itemSays(target)] .filter(Boolean) .join(" · "); + const receipt = receiptByCoordinate.get(coordinate); + const face = receipt ? acknowledgmentFace(receipt) : null; + if (face?.kind === "activity") + activityAlreadyShown.add(`widget:${receipt.target.id}`); add(groups, target, { kind: "outcome", - id: `outcome:${coordinate}`, - text: trimmed(account), - activate: () => revealTarget(target, `Outcome: ${account}`), + id: receipt ? `acknowledgment:${receipt.id}` : `outcome:${coordinate}`, + text: trimmed(face ? `${face.text} · ${account}` : account), + ...(face ? { acknowledgmentFace: KINDS[face.kind] } : {}), + activate: () => revealTarget(target, `${face?.text ?? "Outcome"}: ${account}`), }); } @@ -578,15 +638,30 @@ export function createLivingMargin(dependencies) { if (claimState().claimsHeld) for (const update of updateSequence()) { if (update.source !== "claim" || update.disposition !== "effective") continue; + if (update.revision > currentRevision()) continue; + if (activityAlreadyShown.has(`${update.target.kind}:${update.target.id}`)) + continue; const target = update.target.kind === "thread" ? placedAt(update.target.id) : elementById(update.target.id); - const account = `${update.agent || "Agent"} · ${update.text || humanized(update.action)}`; + const turnClosed = + update.session && update.session === claimState().claimingSession + ? claimState().agentTurnClosed + : null; + const quiet = quietSince(update.ts) || droppedAt(update.ts, turnClosed); + const account = [ + update.agent || "Agent", + update.text || humanized(update.action), + quiet ? "quiet" : null, + ] + .filter(Boolean) + .join(" · "); add(groups, target, { kind: "activity", id: `activity:${update.id}`, text: trimmed(account), + acknowledgmentFace: KINDS.activity, activate: () => revealTarget(target, account), }); } @@ -712,9 +787,11 @@ export function createLivingMargin(dependencies) { function markerName(entry, index, anchored) { const choice = primaryReading(entry); - const face = KINDS[choice?.kind] ?? KINDS.action; + const face = markerFace(entry).face; const count = choice?.items.length ?? 0; const reading = `${face.label}${count > 1 ? `s (${count})` : ""}`; + const subject = + count === 1 && choice.items[0].acknowledgmentFace ? choice.text : entry.title; const main = document.querySelector("main"); const position = entry.target && main?.scrollHeight @@ -725,7 +802,7 @@ export function createLivingMargin(dependencies) { 100, ) : null; - return `${reading}, ${index + 1} of ${anchored}, ${entry.title}${position == null ? "" : `, ${Math.max(0, Math.min(100, position))} percent down`}`; + return `${reading}, ${index + 1} of ${anchored}, ${subject}${position == null ? "" : `, ${Math.max(0, Math.min(100, position))} percent down`}`; } function availableRows() { @@ -909,6 +986,7 @@ export function createLivingMargin(dependencies) { function paintMarker(row, entry, index, anchored, primary) { const { kinds: markerKinds, face, label, count: markerCount } = markerFace(entry); + const choice = primaryReading(entry); row.lfEntry = entry; row.hidden = markerKinds.length === 0 || Boolean(primary); row.dataset.lfKinds = markerKinds.map(({ kind }) => kind).join(" "); @@ -919,7 +997,10 @@ export function createLivingMargin(dependencies) { collapse: "always", }); row.setAttribute("aria-label", markerName(entry, index, anchored)); - row.title = `${face.label}${markerCount > 1 ? `s (${markerCount})` : ""}`; + row.title = + markerCount === 1 && choice.items[0].acknowledgmentFace + ? choice.text + : `${face.label}${markerCount > 1 ? `s (${markerCount})` : ""}`; syncThreadRelation(row, markerNeedsPreview(entry)); row.removeAttribute("aria-pressed"); if (markerCount > 1) { @@ -1658,6 +1739,11 @@ export function createLivingMargin(dependencies) { paintKeys(); }); document.addEventListener("lf-actions", render); + // TODO(2026-08-31): Reconcile this provisional acknowledgment-to-Button adapter + // with the in-flight Target Button implementation before their combined changes + // land. The canonical acknowledgment projection and page-edge placement remain the + // contract; only this presentation seam should follow the final Button API. + document.addEventListener("lf-acknowledgments", render); document.addEventListener("lf-answered", render); document.addEventListener("lf-comparison", render); offerListeners.add(render); diff --git a/skills/leaf/assets/runtime/presence.js b/skills/leaf/assets/runtime/presence.js index 55c7502219..869500058a 100644 --- a/skills/leaf/assets/runtime/presence.js +++ b/skills/leaf/assets/runtime/presence.js @@ -16,8 +16,11 @@ export const ago = (ts) => { }; const WORKING_GRACE_MS = 15 * 60 * 1000; +const PICKUP_RECEIPT_GRACE_MS = 2 * 60 * 1000; +const TURN_RENEWAL_GRACE_MS = 2 * 60 * 1000; export const quietSince = (ts, grace = WORKING_GRACE_MS) => Boolean(ts) && serverNow() - new Date(ts).getTime() > grace; +export const waitingForPickupSince = (ts) => quietSince(ts, PICKUP_RECEIPT_GRACE_MS); export function createPresence() { // ---------- presence ---------- @@ -42,7 +45,6 @@ export function createPresence() { // the poll declares it instead (`unattended`), and it is judged ahead of the rest because // it is not a state the evidence below could reach — there is no claim to weigh, no // lifetime to look for, and nothing coming that would change the answer. - const HANDOFF_GRACE_MS = 2 * 60 * 1000; // How long a claim of work may go unrefreshed before the page stops taking its word for // it. Exported, because the banner is not the only thing that judges one: a page running // a fleet says the same sentence per row, and a second threshold spelled in a widget @@ -57,7 +59,6 @@ export function createPresence() { // the turn in the same second, and the delegate's first note is a minute or so behind // that. Shorter than the grace by an order of magnitude, because it is measured from // an observed event rather than from the absence of one. - const PICKUP_GRACE_MS = 2 * 60 * 1000; // The second question the page asks of a claim, beside how long it has gone // unrenewed: did the turn behind it end with nothing picking it up. This one has an // answer the moment it becomes true, because the Stop hook watches the ending rather @@ -70,7 +71,7 @@ export function createPresence() { const droppedAt = (ts, turnClosed) => Boolean(turnClosed) && Date.parse(ts) <= Date.parse(turnClosed) && - quietSince(turnClosed, PICKUP_GRACE_MS); + quietSince(turnClosed, TURN_RENEWAL_GRACE_MS); // Which claim each kind reads out, and so whose detail it may speak. The question // sits here rather than at each seat, for the reason `kind` does: two seats answering // it separately is two answers to what the page may say it is waiting for. A kind @@ -94,14 +95,9 @@ export function createPresence() { // seat. function presented(state) { const { status, listening, session_alive, unattended, turn_closed } = state; - // How long the claim has gone unrefreshed. The rope is short for the status - // `leaf wait` writes as it prints a batch, because the agent writes its own - // `leaf status` after acknowledgement — that mark outliving minutes is a dropped - // pickup, not a long turn. - const aged = quietSince( - status.ts, - status.handoff ? HANDOFF_GRACE_MS : WORKING_GRACE_MS, - ); + // How long the agent's own work claim has gone unrefreshed. Delivery pickup is + // projected per interaction and never changes this page-wide status. + const aged = quietSince(status.ts); // The same silence reached by evidence instead of by a clock. A claim is written by // a model's turn, and when that turn ends nothing runs — so the page could only ever // find an abandoned claim by waiting out the grace, saying "Claude is working" over diff --git a/skills/leaf/assets/runtime/projection.js b/skills/leaf/assets/runtime/projection.js index 3a4a73a6ab..91e48452c8 100644 --- a/skills/leaf/assets/runtime/projection.js +++ b/skills/leaf/assets/runtime/projection.js @@ -31,7 +31,7 @@ export function createProjection(runtime, dependencies) { pageShifted, paintAnchors, paintKeys, - paintWorkLines, + paintAcknowledgments, post, projectedParent, quoteFrom, @@ -751,7 +751,7 @@ export function createProjection(runtime, dependencies) { const authoredOnly = runtime.lastEventSeq < 0 && eventSeq === 0; if (!complete && !authoredOnly) return false; reconcileState(); - paintWorkLines(); + paintAcknowledgments(); return true; } diff --git a/skills/leaf/assets/runtime/state-application.js b/skills/leaf/assets/runtime/state-application.js index a1cb1acd64..bc70a60cd3 100644 --- a/skills/leaf/assets/runtime/state-application.js +++ b/skills/leaf/assets/runtime/state-application.js @@ -27,7 +27,7 @@ export function createStateApplication(dependencies) { observeServerNow, paintAnchors, paintApproval, - paintWorkLines, + paintAcknowledgments, panelIsOpen, presented, reconcileState, @@ -260,7 +260,7 @@ export function createStateApplication(dependencies) { // Outside the log-growth block: a work claim lands and ages without changing an // event this tab holds. After widget reconciliation because a module may rebuild // its authored subtree; the local line is the transient overlay that follows it. - paintWorkLines(); + paintAcknowledgments(); if (activation) { restoreView(activation.view); paintAnchors(); diff --git a/skills/leaf/assets/runtime/state-feed.js b/skills/leaf/assets/runtime/state-feed.js index 65f91d3629..5939510c93 100644 --- a/skills/leaf/assets/runtime/state-feed.js +++ b/skills/leaf/assets/runtime/state-feed.js @@ -119,7 +119,7 @@ export function createStateFeed({ // // A poll that brought nothing still re-applied the state the page already held, and the // runtime has a lot hanging off that — a clock ageing toward a threshold, a claim going - // quiet, a work line, an activation a live editor deferred and nothing else will ask + // quiet, a receipt, an activation a live editor deferred and nothing else will ask // about again. None of those needed the network; they rode it because a request was the // only thing that happened regularly, which made a round trip the page's clock. // diff --git a/skills/leaf/assets/runtime/widget-elements.js b/skills/leaf/assets/runtime/widget-elements.js index 25d1e6f13f..8f3bb0d52c 100644 --- a/skills/leaf/assets/runtime/widget-elements.js +++ b/skills/leaf/assets/runtime/widget-elements.js @@ -199,11 +199,11 @@ export function worksInside(node, container) { // `contains` counts an element as containing itself, so the container is ruled out by // name — the question is what stands between the two, and a container that is itself // a thing to work would otherwise answer with itself and never take a gesture again. - // A local work line is runtime apparatus too. It may deliberately sit in one of + // A local receipt is runtime apparatus too. It may deliberately sit in one of // this container's declared parts, where the part is otherwise the gesture target; // reading or selecting the status must not cast that gesture on its way through. const inner = node.closest( - [...held, WORKS, "[data-lf-offer]", ".lf-work-line"].join(","), + [...held, WORKS, "[data-lf-offer]", ".lf-receipt"].join(","), ); return inner && inner !== container && container.contains(inner) ? inner : null; } diff --git a/skills/leaf/assets/theme.css b/skills/leaf/assets/theme.css index 6e3ba01bfe..7a6c66764c 100644 --- a/skills/leaf/assets/theme.css +++ b/skills/leaf/assets/theme.css @@ -1060,22 +1060,28 @@ ol.steps > li::before { content: counter(step); position: absolute; left: 0; .lf-conversation-body > :last-child { margin-bottom: 0; } .lf-conversation-open { margin-top: var(--sp-2); } .lf-conversation-resolved { margin-top: var(--sp-2); color: var(--ok); text-align: right; } -/* What the agent says it is doing about one local subject (`leaf status --on`). - Thread panels, inline conversations, and prose widgets are seats for the same - transient claim, so its rule is global rather than duplicated between runtime and - page styles. Provisional news wears the report channel's amber dashed edge rather - than message ink. The detail wraps; the age and the explicit silence word do not. +/* Where a subject has no page edge of its own, one receipt grows beneath the reader + move it acknowledges: Sent, Picked up, then Active when an explicit work claim names + that subject. This is the panel-local and frozen-conversation fallback; page targets + present the same projection through their existing margin Button. The state wraps; + the age and the explicit silence word do not. The shadow markers carry the same rule into any declared tree an authored subject is staged into; they remain ordinary comments in the document stylesheet. */ /* lf-shadow:start */ -.lf-work-line { display: flex; gap: 6px; align-items: flex-end; clear: both; +.lf-receipt { display: flex; gap: 6px; align-items: flex-end; clear: both; width: 100%; box-sizing: border-box; grid-column: 1 / -1; - margin: var(--sp-2) 0 0; padding: 2px 8px; font-size: var(--t-6); color: var(--muted); - border-left: 3px dashed color-mix(in srgb, var(--warn) 55%, transparent); } -.lf-work-line span { flex: 1; min-width: 0; overflow-wrap: anywhere; } -.lf-work-line .lf-work-quiet { flex: none; color: var(--warn-ink); + margin: var(--sp-2) 0 0; padding: 3px 8px; font-size: var(--t-6); color: var(--muted); + border: 1px solid var(--border-2); border-radius: 6px; + background: color-mix(in srgb, var(--field) 72%, transparent); } +.lf-receipt-state { flex: 1; min-width: 0; overflow-wrap: anywhere; } +.lf-receipt.is-active .lf-receipt-state { color: var(--accent); } +.lf-receipt .lf-receipt-quiet { flex: none; color: var(--warn-ink); background: var(--warn-tint); border-radius: 3px; padding: 0 4px; white-space: nowrap; } -.lf-work-line time { color: var(--muted-2); white-space: nowrap; } +.lf-receipt time { color: var(--muted-2); white-space: nowrap; } +.lf-receipt-change { animation: lf-receipt-change 360ms ease-out; } +@keyframes lf-receipt-change { + from { background: color-mix(in srgb, var(--accent) 16%, var(--field)); } +} /* The runtime's mark on a static box whose computed overflow scrolls (reachScrollers, * for what escaped such a box and why the mark is read from the composed page rather * than declared here beside each overflow), so no rule in this file or a package's @@ -1458,6 +1464,7 @@ html.lf-versioning::view-transition-new(root) { * of a box's style — the render gate's included — was told the value the * property was leaving rather than the one on screen. */ @media (prefers-reduced-motion: reduce) { + .lf-receipt-change { animation: none; } *, *::before, *::after { animation-duration: .01ms !important; transition: none !important; diff --git a/skills/leaf/references/conversation-loop.md b/skills/leaf/references/conversation-loop.md index 691425aef9..d68d711940 100644 --- a/skills/leaf/references/conversation-loop.md +++ b/skills/leaf/references/conversation-loop.md @@ -25,10 +25,10 @@ leaf status working "reading the reconnect traces" --on leaf status working "checking the rollout" --on ``` -The line stands beside its subject, so a question or card in hand reads -differently from one nobody has looked at even when no conversation exists. A -thread claim ends with your next reply there. A widget claim survives unrelated -revisions; when a stamped version completes that work, say so on the stamp: +The Target Button stands at the page edge beside its subject, so a question or card +in hand reads differently from one nobody has looked at even when no conversation +exists. A thread claim ends with your next reply there. A widget claim survives +unrelated revisions; when a stamped version completes that work, say so on the stamp: ```bash leaf version stamp --text "…" --completes @@ -36,17 +36,34 @@ leaf version stamp --text "…" --completes Repeat `--completes` when the version completes more than one active widget claim. Stamping accepts only widget ids with standing work. `status --on` -refuses a widget whose active registry declaration provides no `x-work` seat; -use the page-wide detail for work with no safe local line. The local line is the -banner's own claim at a second seat, and one status command writes both. +refuses a widget with neither an unsettled action receipt nor an active `x-work` +seat; use the page-wide detail when neither admits a safe local claim. Once admitted, +the page-edge Target Button carries the claim even if a later layer changes `x-work`. +The **Active** Button is the banner's own claim at a second seat, and one status +command writes both. + +Reader moves acknowledge themselves before an explicit work claim exists. The +same Target Button beside an action or widget subject progresses through: + +- **Sent** once the server durably appends the move; +- **Waiting for pickup** if transport has not accepted it within the short grace; +- **Picked up** once a direct wait flushes it or Codex's durable queue accepts it; +- **Active** only after `status … --on` supplies real work detail. + +Pickup is automatic and never changes the page-wide status. The Button cluster keeps +one identity as its phase changes, then returns to the standing outcome or disappears +when a reply or authored version answers the move. In the full thread panel and inside +conversation-frozen widgets, where no page edge is available, the same projection uses +a compact local row; the page's Thread Button opens that exact conversation rather than +duplicating its transport phase. Requests keep their own terminal receipt lifecycle. A `working` claim is believed while the turn that wrote it is open. The page is told when that turn ends, so a claim nothing has renewed within a couple of minutes of the ending stops being believed, and the banner reports the silence instead of the work; a claim nobody renews at all ages out after about a -quarter of an hour. Each local line carries the same reading at its own subject, -so one delegate still reporting keeps the banner green while another's line -goes quiet beside the work it names. +quarter of an hour. Each **Active** Button or local fallback carries the same reading +at its own subject, so one delegate still reporting keeps the banner green while +another's acknowledgment goes quiet beside the work it names. Renewing it is therefore part of the work and goes with it: a delegate outlives the turn that started it, and no part of this session can write the claim once diff --git a/skills/leaf/references/internals/events.md b/skills/leaf/references/internals/events.md index e66860dda0..e9dbbad684 100644 --- a/skills/leaf/references/internals/events.md +++ b/skills/leaf/references/internals/events.md @@ -24,7 +24,9 @@ action, typed detail, and revision are validated against the holder's `x-request` declaration and exact direct-child offers; when that declaration has `decision: true`, the ready lifecycle is a reader decision, acceptance hands it to the host, and a failed receipt reopens it), receipt (agent; exactly -one terminal `succeeded` or `failed` outcome naming a prior request), note +one terminal `succeeded` or `failed` outcome naming a prior request), pickup +(page; a durable, record-only transport acknowledgment naming the reader event +ids accepted by their next consumer; it is idempotent and makes no work claim), note (agent; one stamped checkpoint's public `version`, exact `revision`, and changelog, carrying `restated`: the element ids whose decisions that revision took back, and `settles`: the report or work targets the stamp answered), error (the page's own runtime reporting a diff --git a/skills/leaf/references/internals/page-storage.md b/skills/leaf/references/internals/page-storage.md index 6b75040caa..6cb93ccd29 100644 --- a/skills/leaf/references/internals/page-storage.md +++ b/skills/leaf/references/internals/page-storage.md @@ -63,11 +63,9 @@ A page directory holds: "work" holds typed, sequence-bounded claims on comment threads or page widgets. At the state boundary these private records become canonical claim updates, which - their local seats show beside the page-wide line - (`leaf status … --on`); - when `leaf wait` prints for a non-working page, it - writes working with "handoff": true until the agent's - own `leaf status` lands + their local receipts show beside the page-wide banner + (`leaf status … --on`). Delivery pickup never writes + this file; it is a page-owned event in comments.jsonl waiter.lock bare-shell `leaf wait` lease, held open and locked for the command's life. A host session holds one lease at sessions/.wait instead, because one wait watches all @@ -79,7 +77,8 @@ A page directory holds: studied and left cursor.json seq of the last user event acknowledged after the complete batch reached its next durable consumer — written by - `leaf ack` + `leaf ack`; a page-owned pickup event separately names + the exact reader events accepted by that consumer preview.json optional safe metadata written only by the repository's live example preview: example, checkout name, start time, and optional commit/dirty state. The server projects these named diff --git a/skills/leaf/references/internals/session-lifetime.md b/skills/leaf/references/internals/session-lifetime.md index 1a8fc0eab3..c30e437dbd 100644 --- a/skills/leaf/references/internals/session-lifetime.md +++ b/skills/leaf/references/internals/session-lifetime.md @@ -1,4 +1,4 @@ -# Session lifetime and work claims +# Session lifetime, pickup, and work claims status.json is a claim, and a claim never expires on its own: an agent that stopped watching renders exactly like one that is watching and has nothing to @@ -6,22 +6,33 @@ say, so a comment can sit unread with the page still reading "Claude is working". The directory therefore also carries what it can prove — a lease only a live `leaf wait` can hold, the acknowledgement cursor, and whether the owning session's lifetime stands — and `/api/state` ships those beside the claim, so the -banner can say when -the claim has outlived its evidence. When a wait prints for a -non-working page, it marks the status it writes "handoff", which dates that -claim: after acknowledgement the agent writes its own `leaf status`, so a -handoff mark that survives means a dropped pickup rather than a long turn, and -the banner gives it a much shorter rope. Wait output that lands while the agent -is already working leaves the existing claim untouched; there is no pickup gap -to date. +banner can say when the claim has outlived its evidence. + +Delivery acceptance is a different fact from work. Once a direct wait has +flushed its batch, or Codex's durable queue has accepted it, the carrier appends +one page-owned `pickup` event naming the reader event ids it delivered. Retries +name nothing new, so pickup is idempotent. It never rewrites `status.json` and +therefore cannot claim the agent has begun work, replace an existing claim, or +make one interaction borrow another's page-wide status. + +The browser projects one acknowledgment on each unsettled reader move. On the page it +reuses the subject's existing Target Button; the full thread panel and widgets frozen +into conversation chrome use a compact local row because they have no page edge. +Append is **Sent**; a `pickup` naming that move is **Picked up**; a later explicit +`status … --on` claim on the same subject is **Active**. Sent becomes **Waiting for +pickup** after the short pickup grace, without inventing a new log state. A reply, +resolution, or authored state that honors the move settles the acknowledgment. A +later version note settles a page action whose verb has no authored record form; a +note already standing when the move arrives cannot answer it. The append-only log +remains the authority for every durable phase. So a claim of work has to be renewed, and the command that makes one renews it. `--on` names the comment thread the work is about, so one check-in moves both -the page's line and the note the reader sees under their own words in the -thread panel, where it stands until the agent's next word in that thread. That -is how a claim crosses a turn boundary the session cannot write across: nothing -in a session touches status.json while its turn is over, so work handed to a -delegate is renewed from the delegate's own hands or not at all. +the page's banner and its Target Button, plus the local receipt the reader sees +under their own words in the full thread panel. They stand until the agent's next +word in that thread. That is how a claim crosses a turn boundary the session cannot +write across: nothing in a session touches status.json while its turn is over, so +work handed to a delegate is renewed from the delegate's own hands or not at all. Where nothing answers for the claim at all, the banner drops the claim rather than repeating it. A claimant whose lifetime has ended settles the question @@ -77,7 +88,8 @@ stable Leaf delivery id and exact pointer prompt across queue acceptance before cursor acknowledgement. An uncertain queue command is retried with that same pointer. The resulting delivery is at least once; a repeated turn recognizes the delivery id and applies the page-and-sequence retry rule. Once a queue -command succeeds, the adapter advances the page cursor and removes the intent. +command succeeds, the adapter records pickup, advances the page cursor, and +removes the intent. The payload stays as conservative recovery state because queue acceptance does not prove that a later turn read it. diff --git a/skills/leaf/scripts/CLAUDE.md b/skills/leaf/scripts/CLAUDE.md index 6491b4178e..1d75922bf4 100644 --- a/skills/leaf/scripts/CLAUDE.md +++ b/skills/leaf/scripts/CLAUDE.md @@ -21,7 +21,8 @@ The main owners are: - `events` and `projection`: standing event and durable state folds; - `thread_context` and `conversation`: thread identity, frozen markup, bounded delivery context, and conversation writes; -- `work`: transient work claims and widget work seats; +- `acknowledgments` and `work`: growing delivery receipts, transient work + claims, and widget work seats; - `decisions`: declaration-driven page and thread decision projections; - `requests`: declared request seats, their canonical lifecycle, and the terminal host receipts that close one; diff --git a/skills/leaf/scripts/leaf/acknowledgments.py b/skills/leaf/scripts/leaf/acknowledgments.py new file mode 100644 index 0000000000..e0f735d997 --- /dev/null +++ b/skills/leaf/scripts/leaf/acknowledgments.py @@ -0,0 +1,164 @@ +"""Interaction-scoped acknowledgment lifecycle projection.""" + +from .events import spoken_turns +from .projection import NO_RECORD, canonical_updates, folded_facet, markup_facet + + +def page_action_unsettled( + coordinate: tuple, + source: dict, + spec: dict, + parser, + spk: dict, + registry: dict, + events: list, +) -> bool: + """Whether authored markup still owes one standing page action an answer.""" + _widget, unit, _facet = coordinate + if source["author"] != "user" or unit not in parser.by_id: + return False + authored = markup_facet(unit, spec, parser.by_id, spk, registry) + folded = folded_facet(source, spec) + if authored is NO_RECORD: + return not any( + event["kind"] == "note" and event["seq"] > source["seq"] for event in events + ) + return authored != folded + + +def canonical_acknowledgments( + events: list, + claims: list, + threads: dict, + projection, + parser, + spk: dict, + conversation, + registry: dict, +) -> list[dict]: + """The unsettled reader moves and the strongest evidence held for each. + + Acknowledgment is one interaction-scoped projection over the document and + log: append means Sent, a page-owned pickup record means Picked up, and a + matching effective work claim means Active. Replies and authored state + settle the source move, so the row disappears instead of becoming a second + outcome surface. + """ + pickups = {} + for event in events: + if event["kind"] != "pickup": + continue + for event_id in event["events"]: + pickups.setdefault(event_id, event) + + effective_claims = { + (update["target"]["kind"], update["target"]["id"]): update + for update in canonical_updates(None, claims, threads, events) + if update["disposition"] == "effective" + } + used_claims = set() + + def receipt(source: dict, target: dict, coordinate: list[str]) -> dict: + claim = effective_claims.get((target["kind"], target["id"])) + pickup = pickups.get(source["id"]) + if claim and claim["log_floor"] >= source["seq"]: + phase, evidence = "active", claim + used_claims.add(claim["id"]) + elif pickup: + phase, evidence = "picked_up", pickup + else: + phase, evidence = "sent", source + return { + "id": source["id"], + "event": source["id"], + "seq": source["seq"], + "revision": source.get("revision"), + "target": target, + "coordinate": coordinate, + "phase": phase, + "ts": evidence["ts"], + "fallback_phase": "picked_up" if pickup else "sent", + "fallback_ts": pickup["ts"] if pickup else source["ts"], + "detail": claim["text"] if phase == "active" else None, + "agent": claim.get("agent") if phase == "active" else None, + "session": claim.get("session") if phase == "active" else None, + } + + acknowledgments = [] + for thread_id, thread in threads.items(): + turns = spoken_turns(thread) + if thread["resolved"] or not turns or turns[-1]["author"] != "user": + continue + source = turns[-1] + acknowledgments.append( + receipt( + source, + {"kind": "thread", "id": thread_id}, + ["thread", thread_id], + ) + ) + + # A page action stays unsettled only while the authored document still lags + # its standing record. A recordless verb has no markup form to compare, so a + # later version note in the log is the document's answer to that move. + for coordinate, (source, spec) in projection.actions.items(): + widget, unit, facet = coordinate + if not page_action_unsettled( + coordinate, source, spec, parser, spk, registry, events + ): + continue + acknowledgments.append( + receipt( + source, + {"kind": "widget", "id": widget}, + [widget, unit, facet], + ) + ) + + # Frozen widget actions are answered by the next agent turn in their + # conversation. They have no later authored document to absorb them into. + for coordinate, (source, _spec) in conversation.projection.actions.items(): + if source["author"] != "user": + continue + thread_id = conversation.thread_by_widget.get(source["widget"]) + thread = threads.get(thread_id) + if not thread or thread["resolved"]: + continue + if any( + message["kind"] == "reply" + and message["author"] == "claude" + and message["seq"] > source["seq"] + for message in thread["msgs"] + ): + continue + acknowledgments.append( + receipt( + source, + {"kind": "widget", "id": source["widget"]}, + list(coordinate), + ) + ) + + # Keep an explicit claim visible even when there was no preceding reader + # gesture to grow from. This preserves the useful part of `status --on` + # without inventing pickup evidence. + for claim in effective_claims.values(): + if claim["id"] in used_claims: + continue + target = claim["target"] + acknowledgments.append( + { + "id": f"claim:{claim['id']}", + "event": None, + "seq": claim["log_floor"], + "revision": claim.get("revision"), + "target": target, + "coordinate": [target["kind"], target["id"]], + "phase": "active", + "ts": claim["ts"], + "detail": claim["text"], + "agent": claim.get("agent"), + "session": claim.get("session"), + } + ) + return sorted(acknowledgments, key=lambda item: (item["seq"], item["id"])) diff --git a/skills/leaf/scripts/leaf/cli.py b/skills/leaf/scripts/leaf/cli.py index c612ab6103..2fe28c2034 100644 --- a/skills/leaf/scripts/leaf/cli.py +++ b/skills/leaf/scripts/leaf/cli.py @@ -301,7 +301,7 @@ def stamp(dir: str, text: str, completes: tuple[str, ...]) -> None: Checks the exact source first, then records it as the next public version. Repeat --completes for each active widget work claim this version completes. A widget claim otherwise survives unrelated versions, and a version cannot - silently remove its local seat. + silently remove its page target. """ cmd_stamp(resolve_dir(dir), text, completes) diff --git a/skills/leaf/scripts/leaf/codex.py b/skills/leaf/scripts/leaf/codex.py index cdbb47b8a0..8d172edcfc 100644 --- a/skills/leaf/scripts/leaf/codex.py +++ b/skills/leaf/scripts/leaf/codex.py @@ -18,7 +18,7 @@ from .leases import adapter_is_live, adapter_lease_path, take_waiter_lease from .server import running_server from .service import PageTransaction, restore_page_claim, take_page_claim -from .session import Watch, acknowledge, batch_jsonl, read_watch_pass +from .session import Watch, acknowledge, batch_jsonl, read_watch_pass, record_pickup QUEUE_TIMEOUT = 20 START_TIMEOUT = 20 @@ -162,7 +162,7 @@ def finish_intent(identity: dict, intent: dict) -> None: try: with PageTransaction(page_dir) as page: delivered = { - event["seq"]: event["id"] + event["seq"]: event for event in page.events if intent["first_seq"] <= event["seq"] <= intent["last_seq"] } @@ -174,17 +174,10 @@ def finish_intent(identity: dict, intent: dict) -> None: ) ) if all( - delivered.get(seq) == event_id for seq, event_id in expected.items() + delivered.get(seq, {}).get("id") == event_id + for seq, event_id in expected.items() ): - if page.owned_by(identity): - count = len(intent["event_ids"]) - status = page.status - if status["state"] != "working": - page.set_status( - "working", - f"picking up {count} update{'s' if count != 1 else ''}", - handoff=True, - ) + record_pickup(page, [delivered[seq] for seq in expected]) acknowledge(page, intent["last_seq"]) except FileNotFoundError: pass diff --git a/skills/leaf/scripts/leaf/publishing.py b/skills/leaf/scripts/leaf/publishing.py index 34ff00ff04..42c7f34444 100644 --- a/skills/leaf/scripts/leaf/publishing.py +++ b/skills/leaf/scripts/leaf/publishing.py @@ -16,7 +16,7 @@ from leaf.revisioning import activate_source from leaf.service import PageTransaction from leaf.validation.admission import read_text_arg -from leaf.work import standing_work_claims, widget_work_without_seats +from leaf.work import standing_work_claims, widget_work_without_targets def _stamp_activation(page_dir: Path, events: list): @@ -71,7 +71,7 @@ def _completed_work( f"revision r{revision} is not later than the active widget work claim for " + ", ".join(repr(widget) for widget in not_later) ) - unseated = widget_work_without_seats( + untargeted = widget_work_without_targets( checked.html, parser, projection, @@ -80,10 +80,10 @@ def _completed_work( registry, completed, ) - if unseated: - widgets = ", ".join(repr(widget) for widget in unseated) + if untargeted: + widgets = ", ".join(repr(widget) for widget in untargeted) sys.exit( - "refusing to stamp index.html: it would remove the local seat " + "refusing to stamp index.html: it would remove the local target " f"for active work on {widgets}; pass --completes for each widget " "this version completes" ) diff --git a/skills/leaf/scripts/leaf/render-checks/reachability.js b/skills/leaf/scripts/leaf/render-checks/reachability.js index 1a6f58da74..4f606bf52c 100644 --- a/skills/leaf/scripts/leaf/render-checks/reachability.js +++ b/skills/leaf/scripts/leaf/render-checks/reachability.js @@ -249,10 +249,10 @@ export function unreachableWords() { const chrome = el.closest(".lf-ui"); if (!chrome || !widget(chrome)) continue; if (speaks(el) || el.closest(CONTROL)) continue; - // A local work line is runtime chrome about its owning widget, not authored + // A local receipt is runtime chrome about its owning widget, not authored // words of that widget. Its subject is the anchor; the provisional sentence // deliberately is not another passage the reader can thread. - if (el.closest(".lf-work-line")) continue; + if (el.closest(".lf-receipt")) continue; // .lf-quiet is words for a reader listening, clipped to nothing: not on // screen, so there is nothing here the eye can see and the pointer can't // reach — the failure this check exists for. [hidden] is the other half of diff --git a/skills/leaf/scripts/leaf/render-checks/standalone.js b/skills/leaf/scripts/leaf/render-checks/standalone.js index e5382b2427..60c8d0bf83 100644 --- a/skills/leaf/scripts/leaf/render-checks/standalone.js +++ b/skills/leaf/scripts/leaf/render-checks/standalone.js @@ -82,7 +82,7 @@ export function coveredWords({ // is named here: this marks the medium, and the widgets answer for themselves. export function bake() { document.documentElement.classList.add("lf-copy"); - // A work line is runtime chrome even where its declared seat is in the page rather + // A receipt is runtime chrome even where its seat is in the page rather // than under .lf-chrome. Remove it from the document and every open shadow root // before those roots are serialized below: a file has no agent behind the claim, // so preserving the rendered sentence would turn provisional news into a lie. @@ -91,7 +91,7 @@ export function bake() { for (const element of root.querySelectorAll("*")) if (element.shadowRoot) roots.push(element.shadowRoot); for (const root of roots) - root.querySelectorAll(".lf-work-line").forEach((el) => el.remove()); + root.querySelectorAll(".lf-receipt").forEach((el) => el.remove()); document.querySelectorAll("script, .lf-chrome").forEach((el) => el.remove()); // A measurement of this window is not a fact about the reader's. The live page states // each drawn edge's width inline on the root, and an inline value outranks every rule diff --git a/skills/leaf/scripts/leaf/served_state/browser.py b/skills/leaf/scripts/leaf/served_state/browser.py index 31050d6a8a..e4628b891b 100644 --- a/skills/leaf/scripts/leaf/served_state/browser.py +++ b/skills/leaf/scripts/leaf/served_state/browser.py @@ -2,6 +2,7 @@ from pathlib import Path +from ..acknowledgments import canonical_acknowledgments from ..events import build_threads from ..files import list_revisions, revision_path from ..passages import enclosing_of @@ -34,9 +35,10 @@ def browser_state( ) active_within = enclosing_of(active_spk) threads = build_threads(events, active_within) - conversation, conversation_projection = _browser_conversation( + conversation, conversation_reading = _browser_conversation( events, registry, threads ) + conversation_projection = conversation_reading.projection views = {} for revision in sorted(view_revisions): @@ -98,6 +100,16 @@ def browser_state( "basis": {"through_seq": through_seq}, "views": views, "conversation": conversation, + "acknowledgments": canonical_acknowledgments( + events, + claims, + threads, + active_projection, + active_parser, + active_spk, + conversation_reading, + registry, + ), "receipts": [event for event in events if event.get("attempt")], "version_notes": { str(event["version"]): event["text"] diff --git a/skills/leaf/scripts/leaf/served_state/conversation.py b/skills/leaf/scripts/leaf/served_state/conversation.py index 6363611224..0d080316d0 100644 --- a/skills/leaf/scripts/leaf/served_state/conversation.py +++ b/skills/leaf/scripts/leaf/served_state/conversation.py @@ -8,7 +8,7 @@ seat_root, spoken_turns, ) -from ..projection import StateProjection, frozen_thread_reading +from ..projection import FrozenThreadReading, frozen_thread_reading from ..requests import request_lifecycles_for, request_phases from .wire import _browser_projection @@ -55,7 +55,7 @@ def _thread_awaits_reader( def _browser_conversation( events: list, registry: dict, threads: dict -) -> tuple[dict, StateProjection]: +) -> tuple[dict, FrozenThreadReading]: settled = {identity for identity, thread in threads.items() if thread["resolved"]} reading = frozen_thread_reading(events, registry) requests = request_lifecycles_for( @@ -103,5 +103,5 @@ def _browser_conversation( "threads": rendered_threads, "done": [event for event in events if event["kind"] == "done"], }, - reading.projection, + reading, ) diff --git a/skills/leaf/scripts/leaf/service.py b/skills/leaf/scripts/leaf/service.py index a5f6b3c96d..8c9c8593ec 100644 --- a/skills/leaf/scripts/leaf/service.py +++ b/skills/leaf/scripts/leaf/service.py @@ -152,7 +152,6 @@ def set_status( state: str, detail: str, *, - handoff: bool = False, work: dict | None = None, ) -> None: """Write the page claim and any typed local claim it renews. @@ -164,14 +163,12 @@ def set_status( what keeps `working` believed across a turn boundary the session itself cannot write across. - Standing work carries across every other status write, so a handoff's - "picking up 2 updates" does not silently drop what a helper is holding. + Standing work carries across every other status write, so a page-wide + status update does not silently drop what a helper is holding. A new claim replaces the old claim on its semantic subject; `idle` clears them all with the leaf. """ status = {"state": state, "detail": detail, "ts": now_iso()} - if handoff: - status["handoff"] = True claims = [] if state == "idle" else list(self.status.get("work", [])) if work: identity = message_identity() diff --git a/skills/leaf/scripts/leaf/session.py b/skills/leaf/scripts/leaf/session.py index 13501bbd91..70fde7d7a0 100644 --- a/skills/leaf/scripts/leaf/session.py +++ b/skills/leaf/scripts/leaf/session.py @@ -32,7 +32,6 @@ def cmd_status( page_dir: Path, state: str, detail: str, - handoff: bool = False, on: str | None = None, ) -> None: with PageTransaction(page_dir) as page: @@ -45,9 +44,11 @@ def cmd_status( if state != "working": sys.exit("--on says what you are working on; use it with `working`") if not detail: - sys.exit("--on needs a detail; a work line with no words says nothing") + sys.exit( + "--on needs a detail; an Active receipt with no words says nothing" + ) work = work_subject(page_dir, page.events, on) - page.set_status(state, detail, handoff=handoff, work=work) + page.set_status(state, detail, work=work) class PageTick(NamedTuple): @@ -68,7 +69,7 @@ class Watch: A tick yields while the page's log lock remains held. The caller decides how to deliver the batch before asking for the next tick, so claim transfer, - SessionEnd, event arrival, delivery, and the handoff status have one order. + SessionEnd, event arrival, delivery, and pickup recording have one order. A revival releases that transaction before waiting for the service transition, then rereads under a new transaction; no delivery snapshot crosses that unlocked interval. @@ -248,23 +249,38 @@ def batch_jsonl(reading: PageTick) -> str: return "\n".join(lines) -def mark_handoff(reading: PageTick) -> None: - """Claim a batch after its next consumer has accepted it.""" - if reading.status["state"] != "working": - # Flip before the agent handles the batch: the handoff gap between this - # delivery and pickup must not show "waiting". - count = len(reading.batch) - reading.transaction.set_status( - "working", - f"picking up {count} update{'s' if count != 1 else ''}", - handoff=True, - ) +def record_pickup(page: PageTransaction, events: list[dict]) -> dict | None: + """Durably record which reader moves reached their next consumer. + + Pickup is transport evidence, not a claim that the agent has started work. + Naming event ids makes retries idempotent and lets one receipt follow the + exact move the reader made even when a page was already working elsewhere. + """ + wanted = [event["id"] for event in events if event.get("author") == "user"] + picked = { + event_id + for event in page.events + if event["kind"] == "pickup" + for event_id in event["events"] + } + fresh = list( + dict.fromkeys(event_id for event_id in wanted if event_id not in picked) + ) + if not fresh: + return None + return page.append_event( + { + "kind": "pickup", + "author": "page", + "events": fresh, + } + ) def _deliver_batch(reading: PageTick) -> None: - """Write one page's complete batch and claim its direct handoff.""" + """Write one page's complete batch and record its direct pickup.""" print(batch_jsonl(reading), flush=True) - mark_handoff(reading) + record_pickup(reading.transaction, reading.batch) def read_watch_pass( diff --git a/skills/leaf/scripts/leaf/vendoring.py b/skills/leaf/scripts/leaf/vendoring.py index 012731b828..175d101671 100644 --- a/skills/leaf/scripts/leaf/vendoring.py +++ b/skills/leaf/scripts/leaf/vendoring.py @@ -37,7 +37,7 @@ from .schema import DATA_FILE, LAYER_PLACEHOLDER, PACKAGE_DIRS, PACKAGE_FILES from .service import PageTransaction, claim_path from .validation.compatibility import vocabulary_gaps -from .work import widget_work_without_seats +from .work import widget_work_without_targets def cmd_init(page_dir: Path, selected: tuple[str, ...] | None = None) -> None: @@ -218,7 +218,7 @@ def _refuse_data_contract_drift( ) -def _refuse_unseated_work(page_dir: Path, events: list[dict], incoming: dict) -> None: +def _refuse_untargeted_work(page_dir: Path, events: list[dict], incoming: dict) -> None: try: revision = latest_revision(page_dir) except SystemExit: @@ -227,7 +227,7 @@ def _refuse_unseated_work(page_dir: Path, events: list[dict], incoming: dict) -> return html = revision_path(page_dir, revision).read_text(encoding="utf-8") projection, parser, _spk = page_projection(html, events, incoming, revision) - unseated = widget_work_without_seats( + untargeted = widget_work_without_targets( html, parser, projection, @@ -235,10 +235,10 @@ def _refuse_unseated_work(page_dir: Path, events: list[dict], incoming: dict) -> read_json(page_dir / "status.json") or {}, incoming, ) - if unseated: + if untargeted: sys.exit( - "the incoming layer would remove the local seat for active widget work on " - + ", ".join(repr(widget) for widget in unseated) + "the incoming layer would remove the local target for active widget work on " + + ", ".join(repr(widget) for widget in untargeted) + "; stamp a later version with --completes for that work before " "re-vendoring" ) @@ -249,7 +249,7 @@ def _validate_page_transition( ) -> None: _refuse_vocabulary_drift(page_dir, events, incoming) _refuse_data_contract_drift(page_dir, events, incoming) - _refuse_unseated_work(page_dir, events, incoming) + _refuse_untargeted_work(page_dir, events, incoming) def _stamp_layer( diff --git a/skills/leaf/scripts/leaf/work.py b/skills/leaf/scripts/leaf/work.py index 32e24e5bb7..3724f95018 100644 --- a/skills/leaf/scripts/leaf/work.py +++ b/skills/leaf/scripts/leaf/work.py @@ -3,6 +3,7 @@ import sys from pathlib import Path +from .acknowledgments import page_action_unsettled from .decisions import asking, quoted_in, replayed_attrs from .events import build_threads, note_settlements from .files import latest_revision, revision_path @@ -79,7 +80,7 @@ def widget_work_seat( return work["seat"] -def widget_work_without_seats( +def widget_work_without_targets( html: str, parser, projection: "StateProjection", @@ -88,7 +89,7 @@ def widget_work_without_seats( registry: dict, ignored=(), ) -> list[str]: - """Standing widget work the given document and vocabulary cannot show locally.""" + """Standing widget work with no live page target for its Target Button.""" ignored = set(ignored) decided = retirement_outcomes(projection.actions, registry) passages = page_passages( @@ -107,7 +108,6 @@ def widget_work_without_seats( and widget not in passages.retired and widget not in passages.gone and not quoted_in(rec, registry) - and widget_work_seat(rec, projection, registry) ): missing.append(widget) return sorted(missing) @@ -170,10 +170,26 @@ def work_subject(page_dir: Path, events: list, target: str) -> dict: sys.exit(f"{target} is not visible under the page's standing outcomes") if quoted_in(widget, registry): sys.exit(f"{target} is quoted exhibit content, not a live page widget") - if not widget_work_seat(widget, widget_projection, registry): + has_receipt = any( + event["widget"] == target + and page_action_unsettled( + coordinate, + event, + spec, + parser, + spk, + registry, + events, + ) + for coordinate, (event, spec) in widget_projection.actions.items() + ) + if ( + not widget_work_seat(widget, widget_projection, registry) + and not has_receipt + ): sys.exit( f"{target} has no local work seat; its widget declaration " - "does not provide one in this state" + "does not provide one in this state and it has no unsettled action receipt" ) return { "subject": {"kind": "widget", "id": target}, diff --git a/tests/test_interact_session.py b/tests/test_interact_session.py index dc626e64ad..e46f1fadc5 100644 --- a/tests/test_interact_session.py +++ b/tests/test_interact_session.py @@ -66,6 +66,13 @@ from leaf.served_state import page as served_page +def last_deliverable_seq(page_dir: Path) -> int: + """The last event a wait can print, excluding page-owned pickup records.""" + return service_model.unacknowledged(events_model.read_events(page_dir), 0)[-1][ + "seq" + ] + + def fake_codex_cli(tmp_path: Path) -> tuple[Path, Path]: """A process-boundary `codex queue` implementation for adapter tests.""" program = tmp_path / "fake-codex" @@ -102,19 +109,20 @@ def fake_codex_cli(tmp_path: Path) -> tuple[Path, Path]: return program, log -def test_a_work_line_says_which_thread_the_agent_is_on(page_dir, capsys, monkeypatch): - """`leaf status --on` writes one claim at two seats: the page's line, which the - banner reads, and a line on the thread the work is about, which the reader sees +def test_an_active_receipt_says_which_thread_the_agent_is_on( + page_dir, capsys, monkeypatch +): + """`leaf status --on` writes one claim at two seats: the page's banner, which + reads it, and a receipt on the thread the work is about, which the reader sees under their own words. One command writes both because they are one sentence — a delegate that reports its thread is the agent checking in, and the shared timestamp is what keeps a `working` claim believed across a turn boundary the session that made the claim can no longer write across. - The line carries across every later write but its own. That is the case it exists - for: a wait handing over mid-delegation writes "picking up 1 update" over the - page's line, and a line dropped there would take the reader's only sign that their - question is in hand with it. `idle` is the end of the agent's side, and clears them - with the leaf.""" + The claim carries across every later status write but its own settlement. Pickup + is recorded separately in the event log, so transport acceptance neither replaces + this work nor invents a page-wide working claim. `idle` is the end of the agent's + side, and clears explicit work with the leaf.""" events_model.append_event( page_dir, {"kind": "comment", "id": "c1", "author": "user", "text": "why?"} ) @@ -173,8 +181,8 @@ def test_a_work_line_says_which_thread_the_agent_is_on(page_dir, capsys, monkeyp assert (waiting["state"], waiting["detail"]) == ("waiting", "look at v2") assert waiting["work"][0]["detail"] == "reading the traces" - # Nor does the handoff a wait writes on its way out, which is the whole point of - # carrying them: the pickup interrupts the delegate rather than replacing it. + # Nor does a pickup replace the claim: it is a durable transport fact about the + # exact reader events and leaves the page-wide status alone. serving(page_dir, 1) events_model.append_event( page_dir, {"kind": "comment", "id": "c2", "author": "user", "text": "and this?"} @@ -182,8 +190,11 @@ def test_a_work_line_says_which_thread_the_agent_is_on(page_dir, capsys, monkeyp assert session_model.cmd_wait(page_dir) == 0 capsys.readouterr() handed = files_model.read_json(page_dir / "status.json") - assert (handed["state"], handed["handoff"]) == ("working", True) + assert (handed["state"], handed["detail"]) == ("waiting", "look at v2") assert handed["work"][0]["detail"] == "reading the traces" + pickup = events_model.read_events(page_dir)[-1] + assert pickup["kind"] == "pickup" + assert pickup["events"] == ["c1", "c2"] session_model.cmd_status(page_dir, "idle", "") assert "work" not in files_model.read_json(page_dir / "status.json") @@ -194,7 +205,7 @@ def test_a_working_claim_can_name_a_widget_until_a_version_completes_it(page_dir the CLI boundary and stored with the distinction intact. Widget work ends only when a later published version explicitly says it completes that widget work: an unrelated version cannot silently cancel a local claim, while a version cannot - remove the claim's only seat without naming the work it completed.""" + remove the claim's page target without naming the work it completed.""" work_page = PAGE.replace( '', '\n' @@ -269,9 +280,9 @@ def test_a_working_claim_can_name_a_widget_until_a_version_completes_it(page_dir assert renewed_claim["id"] != claim["id"] assert renewed_claim["disposition"] == "effective" - # Replacing the prose widget with a data widget would keep the anchor id but remove - # the local chrome seat. Publication refuses that silent loss until this version - # names the work it answers. + # Replacing the prose widget with a data widget removes its x-work seat, but the + # page-edge Target Button remains attached to the same live subject. The claim + # therefore survives this unrelated version too. without_seat = re.sub( r'.*?', '
' @@ -282,29 +293,44 @@ def test_a_working_claim_can_name_a_widget_until_a_version_completes_it(page_dir flags=re.DOTALL, ) (page_dir / "versions" / "v4.html").write_text(without_seat) - dropped = stamp(page_dir, 4, "Removed") + changed = stamp(page_dir, 4, "Changed presentation") + assert changed.exit_code == 0, changed.output + + # Removing the subject itself would remove the Target Button. Publication still + # refuses that silent loss until the version names the work it answers. + without_target = re.sub( + r'.*?', + "", + without_seat, + count=1, + flags=re.DOTALL, + ) + (page_dir / "versions" / "v5.html").write_text(without_target) + dropped = stamp(page_dir, 5, "Removed") assert dropped.exit_code == 1 assert ( - "would remove the local seat for active work on 'rollout-card'" + "would remove the local target for active work on 'rollout-card'" in dropped.output ) - finished = stamp(page_dir, 4, "Removed", completes=("rollout-card",)) + finished = stamp(page_dir, 5, "Removed", completes=("rollout-card",)) assert finished.exit_code == 0, finished.output # Naming no active widget claim is an unearned settlement, not inert metadata. - (page_dir / "versions" / "v5.html").write_text( - without_seat.replace("t", "t · v5") + (page_dir / "versions" / "v6.html").write_text( + without_target.replace("t", "t · v6") ) - unearned = stamp(page_dir, 5, "Again", completes=("rollout-card",)) + unearned = stamp(page_dir, 6, "Again", completes=("rollout-card",)) assert unearned.exit_code == 1 assert "no active widget work claim" in unearned.output -def test_revendoring_cannot_remove_an_active_widget_work_seat(page_dir): - """A layer transition changes the same declaration the status door trusted. - Re-vendoring must not leave the page-wide claim standing while silently removing - its local half; the active claim is settled by a version note, not by new assets.""" +def test_revendoring_can_change_x_work_while_the_target_button_holds_a_claim(page_dir): + """x-work admits an initial claim; it is not the claim's only later seat. + + Re-vendoring can remove that declaration while the live widget remains, because + the page-edge Target Button continues to present the already-admitted work. + """ work_page = PAGE.replace( '', '\n' @@ -317,8 +343,6 @@ def test_revendoring_cannot_remove_an_active_widget_work_seat(page_dir): page_dir, "working", "checking the rollout", "--on", "rollout-card" ) assert claimed.exit_code == 0, claimed.output - before = (page_dir / "registry.json").read_bytes() - card = json.loads((schema_model.DEFAULT_PACKAGE / "registry.json").read_text())[ "lf-card" ] @@ -329,10 +353,72 @@ def test_revendoring_cannot_remove_an_active_widget_work_seat(page_dir): result = CliRunner().invoke(cli_model.cli, ["page", "init", str(page_dir)]) - assert result.exit_code == 1 - assert "incoming layer would remove the local seat" in result.output - assert "'rollout-card'" in result.output - assert (page_dir / "registry.json").read_bytes() == before + assert result.exit_code == 0, result.output + registry = json.loads((page_dir / "registry.json").read_text()) + assert "x-work" not in registry["lf-card"] + claim = next( + update + for update in state_json(page_dir)["updates"] + if update["source"] == "claim" + ) + assert claim["target"] == {"kind": "widget", "id": "rollout-card"} + assert claim["disposition"] == "effective" + + +def test_a_recordless_receipt_from_a_stale_revision_waits_for_a_later_note(page_dir): + """A completion move can arrive from a live revision after another is active. + + A version note older than the move cannot answer it. Its receipt can still admit + an explicit claim without x-work; only the next note settles the move, while the + claim itself remains at the widget's Target Button until an explicit --completes + note answers that separate work lifecycle. + """ + work_page = PAGE.replace( + "", '', 1 + ) + (page_dir / "versions" / "v1.html").write_text(work_page) + publish(page_dir) + (page_dir / "versions" / "v2.html").write_text( + work_page.replace("t", "t · v2") + ) + advanced = stamp(page_dir, 2, "Checked the surrounding plan") + assert advanced.exit_code == 0, advanced.output + + # The reader still has r1 open after r2 became active. That move is new work, + # not something the earlier r2 note could already have answered. + answer = events_model.append_event( + page_dir, + { + "kind": "action", + "author": "user", + "revision": 1, + "widget": "plan-choice", + "action": "answer", + "detail": {}, + }, + ) + acknowledgments = page_state(page_dir)["browser"]["acknowledgments"] + assert any(receipt["event"] == answer["id"] for receipt in acknowledgments) + + claimed = _status( + page_dir, "working", "checking the completed choice", "--on", "plan-choice" + ) + assert claimed.exit_code == 0, claimed.output + (page_dir / "versions" / "v3.html").write_text( + work_page.replace("t", "t · v3") + ) + answered = stamp(page_dir, 3, "Answered the completed choice") + assert answered.exit_code == 0, answered.output + + acknowledgments = page_state(page_dir)["browser"]["acknowledgments"] + assert not any(receipt["event"] == answer["id"] for receipt in acknowledgments) + claim = next( + update + for update in state_json(page_dir)["updates"] + if update["source"] == "claim" + ) + assert claim["target"] == {"kind": "widget", "id": "plan-choice"} + assert claim["disposition"] == "effective" @pytest.mark.parametrize("target", ["effort", "flag-first"]) @@ -460,23 +546,31 @@ def test_wait_prints_unacknowledged_user_events_and_flips_status(page_dir, capsy events_model.append_event( page_dir, {"kind": "comment", "id": "c2", "author": "user", "text": "later"} ) - session_model.cmd_ack(page_dir, 2) - session_model.cmd_ack(page_dir, 2) # retries are harmless - assert files_model.read_json(page_dir / "cursor.json")["seq"] == 2 + delivered_through = shown[-1]["seq"] + session_model.cmd_ack(page_dir, delivered_through) + session_model.cmd_ack(page_dir, delivered_through) # retries are harmless + assert files_model.read_json(page_dir / "cursor.json")["seq"] == delivered_through assert page_state(page_dir)["pending"] == 1 assert session_model.cmd_wait(page_dir) == 0 - assert [ - json.loads(line)["id"] for line in capsys.readouterr().out.splitlines()[1:] - ] == ["c2"] - session_model.cmd_ack(page_dir, 3) + [later] = [json.loads(line) for line in capsys.readouterr().out.splitlines()[1:]] + assert later["id"] == "c2" + session_model.cmd_ack(page_dir, later["seq"]) assert page_state(page_dir)["pending"] == 0 - # The wait status is marked a handoff, which dates the claim: the agent's own - # `leaf status` clears the mark, so the mark surviving is a pickup that never landed. + # Pickup is exact log evidence and does not rewrite the agent's page-wide claim. status = files_model.read_json(page_dir / "status.json") - assert (status["state"], status["handoff"]) == ("working", True) + assert status["state"] == "waiting" + pickups = [e for e in events_model.read_events(page_dir) if e["kind"] == "pickup"] + assert [e["events"] for e in pickups] == [["c1", shown[1]["id"]], ["c2"]] + stored = [ + json.loads(line) + for line in (page_dir / "comments.jsonl").read_text().splitlines() + ] + assert all("seq" not in event for event in stored if event["kind"] == "pickup") session_model.cmd_status(page_dir, "working", "revising the plan") - assert "handoff" not in files_model.read_json(page_dir / "status.json") + assert ( + files_model.read_json(page_dir / "status.json")["detail"] == "revising the plan" + ) # A worker's report wakes the watcher like a user event — it is the # orchestrator's to fold into a version — but the reader's banner count @@ -522,6 +616,8 @@ def test_wait_repeats_a_stable_transport_neutral_batch_until_ack(page_dir): retry = CliRunner().invoke(cli_model.cli, ["wait", str(page_dir)]) assert retry.exit_code == 0, retry.output assert json.loads(retry.output.splitlines()[0]) == header + pickups = [e for e in events_model.read_events(page_dir) if e["kind"] == "pickup"] + assert len(pickups) == 1 and pickups[0]["events"] == ["c1"] # If wait output was lost or truncated, retrieving it again before ack can # include a newer event. The old event keeps the same page-and-seq identity @@ -535,7 +631,7 @@ def test_wait_repeats_a_stable_transport_neutral_batch_until_ack(page_dir): json.loads(line) for line in grown.output.strip().splitlines() ] assert grown_header == header - assert [event["seq"] for event in grown_events] == [1, 2] + assert [event["seq"] for event in grown_events] == [1, 3] def test_a_delivered_reply_carries_the_conversation_it_lands_in(page_dir, capsys): @@ -595,7 +691,7 @@ def test_a_delivered_reply_carries_the_conversation_it_lands_in(page_dir, capsys # A comment that opens a thread states its own anchor on its own line, so # there is nothing behind it to carry. - session_model.cmd_ack(page_dir, events_model.read_events(page_dir)[-1]["seq"]) + session_model.cmd_ack(page_dir, last_deliverable_seq(page_dir)) events_model.append_event( page_dir, {"kind": "comment", "author": "user", "text": "separately — the rollout"}, @@ -606,7 +702,7 @@ def test_a_delivered_reply_carries_the_conversation_it_lands_in(page_dir, capsys # The reader closing a thread from the panel posts a resolve, whose only # pointer at the conversation is the message it names. - session_model.cmd_ack(page_dir, events_model.read_events(page_dir)[-1]["seq"]) + session_model.cmd_ack(page_dir, last_deliverable_seq(page_dir)) events_model.append_event( page_dir, {"kind": "resolve", "author": "user", "parent": followed["id"]} ) @@ -685,7 +781,7 @@ def test_a_delivered_gesture_on_a_sent_widget_carries_its_conversation( # Standing, so a later delivery in this thread carries what the reader # settled. Without it the agent meets the question with no answer under it # and replies reopening a list they have already ticked. - session_model.cmd_ack(page_dir, events_model.read_events(page_dir)[-1]["seq"]) + session_model.cmd_ack(page_dir, last_deliverable_seq(page_dir)) events_model.append_event( page_dir, { @@ -704,7 +800,7 @@ def test_a_delivered_gesture_on_a_sent_widget_carries_its_conversation( # Taken back, and the conversation stops carrying it — the log keeps the # gesture, and no reading of the log stands on it. - session_model.cmd_ack(page_dir, events_model.read_events(page_dir)[-1]["seq"]) + session_model.cmd_ack(page_dir, last_deliverable_seq(page_dir)) events_model.append_event( page_dir, {"kind": "undo", "author": "user", "undoes": chose["id"]} ) @@ -937,7 +1033,7 @@ def test_an_undo_of_a_page_decision_carries_the_thread_it_reopens(page_dir, caps the same reading the accept did.""" _settling_page(page_dir) accepted = events_model.append_event(page_dir, dict(SETTLING_ACCEPT)) - session_model.cmd_ack(page_dir, events_model.read_events(page_dir)[-1]["seq"]) + session_model.cmd_ack(page_dir, last_deliverable_seq(page_dir)) capsys.readouterr() events_model.append_event( page_dir, {"kind": "undo", "author": "user", "undoes": accepted["id"]} @@ -957,7 +1053,7 @@ def test_exact_thread_history_and_wait_share_indirect_resolution_events( directly. Exact history and live delivery use one Leaf-owned membership join.""" _settling_page(page_dir) accepted = events_model.append_event(page_dir, dict(SETTLING_ACCEPT)) - session_model.cmd_ack(page_dir, events_model.read_events(page_dir)[-1]["seq"]) + session_model.cmd_ack(page_dir, last_deliverable_seq(page_dir)) capsys.readouterr() rejected = events_model.append_event( @@ -1148,7 +1244,7 @@ def test_the_envelope_stops_growing_with_the_conversation(page_dir, capsys): assert session_model.cmd_wait(page_dir) == 0 header, *_rest = capsys.readouterr().out.strip().splitlines() headers.append(len(header)) - session_model.cmd_ack(page_dir, events_model.read_events(page_dir)[-1]["seq"]) + session_model.cmd_ack(page_dir, last_deliverable_seq(page_dir)) capsys.readouterr() # Flat, not merely slower: twenty further exchanges add only the few @@ -1215,7 +1311,7 @@ def test_the_bound_keeps_the_message_a_carried_gesture_needs(page_dir, capsys): "text": f"turn {turn}", }, )["id"] - session_model.cmd_ack(page_dir, events_model.read_events(page_dir)[-1]["seq"]) + session_model.cmd_ack(page_dir, last_deliverable_seq(page_dir)) capsys.readouterr() events_model.append_event( page_dir, @@ -1782,9 +1878,10 @@ def test_one_wait_watches_every_page_the_session_holds( lines = capsys.readouterr().out.strip().splitlines() assert json.loads(lines[0]) == {"page": str(second), "threads": []} assert [json.loads(line)["text"] for line in lines[1:]] == ["hi"] - # The page that spoke picked up the handoff status; the other is untouched. - assert files_model.read_json(second / "status.json")["state"] == "working" + # The page that spoke records exact pickup; neither page's status is rewritten. + assert files_model.read_json(second / "status.json")["state"] == "waiting" assert files_model.read_json(page_dir / "status.json")["state"] == "waiting" + assert events_model.read_events(second)[-1]["kind"] == "pickup" # Idling one leaf leaves the watch to the others; idling the last ends it. session_model.cmd_ack(second, 1) @@ -1979,6 +2076,7 @@ def test_codex_receipt_advances_after_page_ownership_transfers(page_dir): "event_ids": [delivered["id"]], } + codex_model.finish_intent({"id": "original", "host": "codex"}, intent) codex_model.finish_intent({"id": "original", "host": "codex"}, intent) assert files_model.read_json(page_dir / "cursor.json") == {"seq": delivered["seq"]} @@ -1988,7 +2086,12 @@ def test_codex_receipt_advances_after_page_ownership_transfers(page_dir): "waiting", "successor is listening", ) - assert "handoff" not in status + pickups = [ + event + for event in events_model.read_events(page_dir) + if event["kind"] == "pickup" + ] + assert len(pickups) == 1 and pickups[0]["events"] == [delivered["id"]] def test_codex_restart_finishes_an_accepted_intent_without_queueing_again( @@ -3090,7 +3193,7 @@ def test_an_acknowledged_comment_nobody_answered_holds_the_turn(claimed, capsys) asked = events_model.append_event( claimed, {"kind": "comment", "author": "user", "text": "why B?"} ) - session_model.cmd_ack(claimed, events_model.read_events(claimed)[-1]["seq"]) + session_model.cmd_ack(claimed, last_deliverable_seq(claimed)) hooks_model.cmd_hook({"hook_event_name": "Stop", "session_id": "s1"}) answer = json.loads(capsys.readouterr().out) @@ -3134,7 +3237,7 @@ def test_an_acknowledged_comment_nobody_answered_holds_the_turn(claimed, capsys) hooks_model.cmd_hook({"hook_event_name": "Stop", "session_id": "s1"}) assert capsys.readouterr().out == "" - session_model.cmd_ack(claimed, events_model.read_events(claimed)[-1]["seq"]) + session_model.cmd_ack(claimed, last_deliverable_seq(claimed)) hooks_model.cmd_hook({"hook_event_name": "Stop", "session_id": "s1"}) assert asked["id"] in json.loads(capsys.readouterr().out)["reason"] conversation_model.cmd_reply( @@ -3148,7 +3251,7 @@ def test_an_acknowledged_comment_nobody_answered_holds_the_turn(claimed, capsys) moot = events_model.append_event( claimed, {"kind": "comment", "author": "user", "text": "and C?"} ) - session_model.cmd_ack(claimed, events_model.read_events(claimed)[-1]["seq"]) + session_model.cmd_ack(claimed, last_deliverable_seq(claimed)) hooks_model.cmd_hook({"hook_event_name": "Stop", "session_id": "s1"}) assert moot["id"] in json.loads(capsys.readouterr().out)["reason"] conversation_model.cmd_resolve(claimed, moot["id"]) @@ -3170,7 +3273,7 @@ def test_an_acknowledged_comment_nobody_answered_holds_the_turn(claimed, capsys) claimed, {"kind": "reply", "author": "user", "parent": ask["id"], "text": "sqlite"}, ) - session_model.cmd_ack(claimed, events_model.read_events(claimed)[-1]["seq"]) + session_model.cmd_ack(claimed, last_deliverable_seq(claimed)) hooks_model.cmd_hook({"hook_event_name": "Stop", "session_id": "s1"}) assert ask["id"] in json.loads(capsys.readouterr().out)["reason"] conversation_model.cmd_reply(claimed, ask["id"], "sqlite it is", None) @@ -3212,7 +3315,7 @@ def test_a_clarification_thread_carries_a_version_response_while_the_reader_owns "text": "Add the camera first.", }, ) - session_model.cmd_ack(claimed, events_model.read_events(claimed)[-1]["seq"]) + session_model.cmd_ack(claimed, last_deliverable_seq(claimed)) hooks_model.cmd_hook({"hook_event_name": "Stop", "session_id": "s1"}) assert proposal["id"] in json.loads(capsys.readouterr().out)["reason"] @@ -3243,7 +3346,7 @@ def test_a_clarification_thread_carries_a_version_response_while_the_reader_owns "text": "Yes.", }, ) - session_model.cmd_ack(claimed, events_model.read_events(claimed)[-1]["seq"]) + session_model.cmd_ack(claimed, last_deliverable_seq(claimed)) hooks_model.cmd_hook({"hook_event_name": "Stop", "session_id": "s1"}) reason = json.loads(capsys.readouterr().out)["reason"] assert proposal["id"] in reason @@ -3278,7 +3381,7 @@ def test_the_guard_survives_a_page_vendored_before_the_layer_moved(claimed, caps asked = events_model.append_event( claimed, {"kind": "comment", "author": "user", "text": "why B?"} ) - session_model.cmd_ack(claimed, events_model.read_events(claimed)[-1]["seq"]) + session_model.cmd_ack(claimed, last_deliverable_seq(claimed)) hooks_model.cmd_hook({"hook_event_name": "Stop", "session_id": "s1"}) answer = json.loads(capsys.readouterr().out) assert answer["decision"] == "block" @@ -4100,7 +4203,7 @@ def test_a_reaction_holds_no_turn_as_an_unanswered_decision(claimed, capsys): claimed, {"kind": "reply", "author": "user", "parent": answer["id"], "token": "ok"}, ) - session_model.cmd_ack(claimed, events_model.read_events(claimed)[-1]["seq"]) + session_model.cmd_ack(claimed, last_deliverable_seq(claimed)) hooks_model.cmd_hook({"hook_event_name": "Stop", "session_id": "s1"}) assert capsys.readouterr().out == "" @@ -4110,6 +4213,6 @@ def test_a_reaction_holds_no_turn_as_an_unanswered_decision(claimed, capsys): claimed, {"kind": "reply", "author": "user", "parent": answer["id"], "text": "but C?"}, ) - session_model.cmd_ack(claimed, events_model.read_events(claimed)[-1]["seq"]) + session_model.cmd_ack(claimed, last_deliverable_seq(claimed)) hooks_model.cmd_hook({"hook_event_name": "Stop", "session_id": "s1"}) assert json.loads(capsys.readouterr().out)["decision"] == "block" diff --git a/tests/test_render_conversations.py b/tests/test_render_conversations.py index 7c6d9a59a2..8cfd16f409 100644 --- a/tests/test_render_conversations.py +++ b/tests/test_render_conversations.py @@ -241,9 +241,7 @@ def test_a_work_claim_cannot_move_a_later_control_under_the_pointer(browser, ser ) assert claimed.exit_code == 0, claimed.output told(page) - expect(page.locator(f'.lf-thread[data-id="{source}"] .lf-work-line')).to_have_count( - 1 - ) + expect(page.locator(f'.lf-thread[data-id="{source}"] .lf-receipt')).to_have_count(1) after = target_card.evaluate("el => el.getBoundingClientRect().top") assert after == pytest.approx(before, abs=1), ( f"the work claim moved the later card from {before:.1f}px to {after:.1f}px" @@ -1294,7 +1292,7 @@ def test_a_render_arriving_mid_fold_keeps_the_place_the_fold_is_holding(browser, the motion starts, and a hold that reads it again mid-fold pins a card above the fold while everything past it — the successor the reader was aiming at among it — goes on moving. The arrival here is another thread's reply, which is one of several: - the two-second heartbeat repaints the work lines under the same hold, and so does a + the two-second heartbeat repaints the receipts under the same hold, and so does a narrowing. """ page, errors = open_page( @@ -1422,9 +1420,10 @@ def test_a_fold_hands_off_without_reapplying_movement_already_held(browser, serv "}", [source, successor], ) - assert setup == {"sourceAbove": True, "successorVisible": True}, ( - f"the fold is not above every visible fallback: {setup}" - ) + assert setup == { + "sourceAbove": True, + "successorVisible": True, + }, f"the fold is not above every visible fallback: {setup}" resolved_box = resolved_card.bounding_box() page.mouse.move( resolved_box["x"] + resolved_box["width"] / 2, diff --git a/tests/test_render_export.py b/tests/test_render_export.py index 660a401dad..0f90f50592 100644 --- a/tests/test_render_export.py +++ b/tests/test_render_export.py @@ -271,7 +271,7 @@ def test_a_gloss_keeps_its_explanation_in_static_media(browser, serve, tmp_path) def test_an_export_drops_a_live_widget_work_claim(browser, serve, tmp_path): - """A local work line is live runtime chrome even though its seat is in the page. + """A local receipt is live runtime chrome even though its seat is in the page. A standalone copy has no agent behind it, so preserving the rendered sentence would turn a provisional claim into a frozen lie.""" work_page = leaf_page( @@ -303,7 +303,7 @@ def test_an_export_drops_a_live_widget_work_claim(browser, serve, tmp_path): errors = watched(page) page.goto(out.as_uri(), wait_until="load") - expect(page.locator(".lf-work-line")).to_have_count(0) + expect(page.locator(".lf-receipt")).to_have_count(0) expect(page.locator("#rollout-card")).not_to_contain_text("checking the shard") assert errors == [] page.close() diff --git a/tests/test_render_options.py b/tests/test_render_options.py index d60da68b3d..d6ac4b9ae9 100644 --- a/tests/test_render_options.py +++ b/tests/test_render_options.py @@ -10,6 +10,8 @@ from leaf import cli as cli_model from leaf import event_log as events_model from leaf import schema as schema_model +from leaf import service as service_model +from leaf import session as session_model from leaf.render_gate import version as render_gate_model from playwright.sync_api import expect from render_support import ( @@ -1352,6 +1354,78 @@ def test_a_pick_states_the_whole_set(browser, serve): page.close() +def test_a_widget_move_reuses_one_target_button_until_the_page_honors_it( + browser, serve +): + """A widget needs no x-work declaration to acknowledge the reader's move. + + The owner's existing page-edge Button keeps its DOM identity while durable + transport acceptance advances Sent to Picked up and a real claim makes it Active. + Once authored markup records the choice, the Button returns to Outcome. + """ + url = serve(DECISION_PAGE) + page, errors = open_page(browser, live_url(url)) + d = serve.page_dir + + page.locator("#job-mounts").click() + round_trip(page) + action = next( + event + for event in reversed(sent_events(d)) + if event.get("widget") == "jobs" and event.get("action") == "choose" + ) + logged_action = next( + event for event in events_model.read_events(d) if event["id"] == action["id"] + ) + receipt = page.locator('[data-lf-margin-for="jobs"] > .lf-margin-marker') + expect(receipt).to_have_attribute("data-lf-kinds", "outcome") + expect(receipt.locator(".lf-margin-action-glyph")).to_have_text("✓") + expect(receipt).to_have_attribute("aria-label", re.compile(r"^Sent, ")) + expect(page.locator("#jobs > .lf-receipt")).to_have_count(0) + receipt.evaluate("node => { node.dataset.identityProbe = 'kept' }") + + with service_model.PageTransaction(d) as transaction: + session_model.record_pickup(transaction, [logged_action]) + session_model.cmd_ack(d, logged_action["seq"]) + told(page) + expect(receipt).to_have_attribute("data-lf-kinds", "outcome") + expect(receipt).to_have_attribute("aria-label", re.compile(r"^Picked up, ")) + expect(receipt).to_have_attribute("data-identity-probe", "kept") + + active = CliRunner().invoke( + cli_model.cli, + ["status", str(d), "working", "checking the mounts", "--on", "jobs"], + ) + assert active.exit_code == 0, active.output + told(page) + expect(receipt).to_have_attribute("data-lf-kinds", "outcome") + expect(receipt.locator(".lf-margin-action-glyph")).to_have_text("●") + expect(receipt).to_have_attribute("aria-label", re.compile("checking the mounts")) + expect(receipt).to_have_attribute("data-identity-probe", "kept") + + # The receipt admitted this claim without an x-work declaration. Its page-edge + # Target Button is still a local seat, so an unrelated revision cannot wedge the + # authoring loop merely because the widget has no content or conversation seat. + unrelated = DECISION_PAGE.replace( + '

Three jobs

', '

Three jobs, checked

' + ) + stamp_page(d, unrelated, "Checked the surrounding plan") + wait_for_revision(page, 2) + expect(receipt.locator(".lf-margin-action-glyph")).to_have_text("●") + expect(receipt).to_have_attribute("aria-label", re.compile("checking the mounts")) + expect(receipt).to_have_attribute("data-identity-probe", "kept") + + honored = DECISION_PAGE.replace( + ' .lf-work-line") - expect(card_line).to_have_text( - re.compile(r"^Claude is on this — checking the shard\s*just now$") + card_button = page.locator( + '[data-lf-margin-for="card-migration"] > .lf-margin-marker' ) - expect(card_line).to_be_visible() - card_words, card_age = card_line.locator( - ":scope > span, :scope > time" - ).evaluate_all("els => els.map(el => el.getBoundingClientRect().bottom)") - assert abs(card_words - card_age) <= 1, ( - "the age splits a wrapped card sentence instead of following its last line" + expect(card_button).to_have_attribute("data-lf-kinds", "activity") + expect(card_button.locator(".lf-margin-action-glyph")).to_have_text("●") + expect(card_button).to_have_attribute( + "aria-label", re.compile("checking the shard") ) + card_button.click() + expect(page.locator(".lf-live")).to_contain_text("checking the shard") expect(page.locator(".lf-thread")).to_have_count(0) - expect(page.locator(".lf-panel .lf-work-line")).to_have_count(0) - expect(card_line).to_have_class(re.compile(r"\blf-ui\b")) - assert card_line.get_attribute("data-lf-gen") == "1" + expect(page.locator(".lf-panel .lf-receipt")).to_have_count(0) + expect(page.locator("#card-migration > .lf-receipt")).to_have_count(0) + expect(card_button).to_have_class(re.compile(r"\blf-margin-action\b")) # An unrelated version leaves the card coordinate standing. stamp_page(d, work_page, "Elsewhere") wait_for_revision(page, 2) - expect(card_line).to_have_count(1) + expect(card_button).to_have_count(1) # A new claim belongs to v2 and does not appear in a pinned v1 page. claim("card-migration", "checking the fallback") - expect(card_line).to_contain_text("checking the fallback") + expect(card_button).to_have_attribute( + "aria-label", re.compile("checking the fallback") + ) pinned, pinned_errors = open_page(browser, url, pin=True) - expect(pinned.locator("#card-migration > .lf-work-line")).to_have_count(0) + expect( + pinned.locator('[data-lf-margin-for="card-migration"] > .lf-margin-marker') + ).to_have_count(0) assert pinned_errors == [] pinned.close() @@ -1536,14 +1613,14 @@ def claim(subject, detail): completes=("card-migration",), ) wait_for_revision(page, 3) - expect(page.locator(".lf-work-line")).to_have_count(0) + expect(page.locator(".lf-receipt")).to_have_count(0) assert errors == [] page.close() def test_local_work_chrome_does_not_take_its_holder_gesture(browser, serve, tmp_path): """A customization may deliberately give a container member a content seat. - The runtime's generated line is still apparatus rather than that member's own + The runtime's generated Button is still apparatus rather than that member's own gesture: clicking status about an option must not choose the option.""" option = json.loads((schema_model.DEFAULT_PACKAGE / "registry.json").read_text())[ "lf-option" @@ -1568,13 +1645,13 @@ def test_local_work_chrome_does_not_take_its_holder_gesture(browser, serve, tmp_ assert result.exit_code == 0, result.output told(page) - work_line = page.locator("#job-mounts > .lf-work-line") - expect(work_line).to_be_visible() - work_line.click() + work_button = page.locator('[data-lf-margin-for="job-mounts"] > .lf-margin-marker') + expect(work_button).to_have_attribute("data-lf-kinds", "activity") + work_button.click() # A press that does nothing states no fact to wait on, so the control is the same # gesture where it is supposed to work: pick the neighbouring option and let its - # send settle. One outbox in gesture order means a pick the work line had taken + # send settle. One outbox in gesture order means a pick the receipt had taken # would already stand ahead of this one, so the whole log can be read once. page.locator("#job-heater").click() round_trip(page) @@ -1612,14 +1689,14 @@ def test_settled_widget_work_leaves_a_declared_shadow_tree(browser, serve): ) assert claimed.exit_code == 0, claimed.output told(page) - work_line = page.locator("#shadow-card > .lf-work-line") - expect(work_line).to_have_count(1) + work_button = page.locator('[data-lf-margin-for="shadow-card"] > .lf-margin-marker') + expect(work_button).to_have_attribute("data-lf-kinds", "activity") page.evaluate( """() => document.getElementById('patch').shadowRoot.append( document.getElementById('shadow-card'))""" ) - expect(work_line).to_have_count(1) + expect(work_button).to_have_count(1) settled = stamp_page( d, @@ -1629,12 +1706,12 @@ def test_settled_widget_work_leaves_a_declared_shadow_tree(browser, serve): ) assert settled["version"] == 2 told(page) - expect(work_line).to_have_count(0) + expect(work_button).to_have_count(0) assert errors == [] page.close() -def test_widget_work_keeps_its_style_in_a_declared_shadow_tree(browser, serve): +def test_widget_work_keeps_its_button_style_in_a_declared_shadow_tree(browser, serve): """The same declaration-backed seat may be staged into an x-shadow widget; crossing that supported boundary must not turn the shared local line back into an unstyled block.""" @@ -1659,15 +1736,16 @@ def test_widget_work_keeps_its_style_in_a_declared_shadow_tree(browser, serve): ) assert result.exit_code == 0, result.output told(page) - work_line = page.locator("#shadow-card > .lf-work-line") - expect(work_line).to_have_css("display", "flex") + work_button = page.locator('[data-lf-margin-for="shadow-card"] > .lf-margin-marker') + expect(work_button).to_have_css("display", "flex") page.evaluate( """() => document.getElementById('patch').shadowRoot.append( document.getElementById('shadow-card'))""" ) - expect(work_line).to_have_css("display", "flex") - expect(work_line).to_have_css("border-left-style", "dashed") + expect(work_button).to_have_css("display", "flex") + expect(work_button).to_have_css("border-radius", "999px") + expect(work_button).to_have_css("min-height", "30px") assert errors == [] page.close() @@ -1900,7 +1978,13 @@ def test_a_specimen_holds_a_wide_exhibit_inside_the_column(browser, serve): can fail.""" page, errors = open_page(browser, serve(SPECIMEN_PAGE)) assert errors == [] + page.evaluate( + """() => document.addEventListener('lf-margin-layout', () => { + window.lfMarginLayoutWidth = innerWidth; + })""" + ) resized(page, 380, 900) + page.wait_for_function("() => window.lfMarginLayoutWidth === 380") wide = page.evaluate( "() => [document.documentElement.scrollWidth," " document.documentElement.clientWidth," @@ -2004,6 +2088,14 @@ def test_a_specimen_in_a_reply_is_quoted_there_too(browser, serve): assert [(e["widget"], e["detail"]) for e in actions] == [ ("rp-live", {"options": ["rp-stage"]}) ] + receipt = page.locator( + f'#rp-live > .lf-receipt[data-receipt-id="{actions[0]["id"]}"]' + ) + expect(receipt).to_contain_text("✓ Sent") + with service_model.PageTransaction(d) as transaction: + session_model.record_pickup(transaction, actions) + told(page) + expect(receipt).to_contain_text("✓ Picked up") assert page.locator("#rp-quoted lf-option[chosen]").count() == 0 page.close() diff --git a/tests/test_render_startup.py b/tests/test_render_startup.py index eec910d5d7..4b0a50c4df 100644 --- a/tests/test_render_startup.py +++ b/tests/test_render_startup.py @@ -20,6 +20,7 @@ from leaf import http as http_model from leaf import render_checks as render_checks_model from leaf import service as service_model +from leaf import session as session_model from playwright.sync_api import TimeoutError as PlaywrightTimeout from playwright.sync_api import expect from render_support import ( @@ -1911,7 +1912,6 @@ def declare( detail="", *, agent="Claude", - handoff=False, quiet_for=0, turn_ended=None, session_pid=None, @@ -1926,8 +1926,6 @@ def declare( "detail": detail, "ts": ts.isoformat(timespec="seconds"), } - if handoff: - status["handoff"] = True if claimed: record_claim( d, @@ -2026,14 +2024,6 @@ def declare( "Claude isn't watching right now. 1 update waiting. It picks them up next turn." ) - # The failure the whole mechanism exists for: `leaf wait` printed, set this - # status, and Claude never came back. The handoff mark is what dates it. - declare("working", "picking up 1 update", handoff=True, quiet_for=20 * 60) - expect(text).to_have_text( - "Claude last checked in 20m ago. 1 update waiting. Nudge it in the terminal." - ) - expect(dot).to_have_class(re.compile(r"\baway\b")) - # With nobody listening the same ending carries the remedy, because the reader's # next word has nowhere to land until a session picks the page up again. declare("working", "running the migration", quiet_for=6 * 60, turn_ended=5 * 60) @@ -2120,7 +2110,7 @@ def claim(detail): def test_a_thread_says_what_the_agent_is_doing_about_it( browser, serve, tmp_path, dead_pid ): - """The banner says what the agent is doing; a work line says which of the reader's + """The banner says what the agent is doing; a receipt says which of the reader's questions it is doing it about. Both are one claim written by one command (`leaf status … --on`), which is what makes a delegate's check-in keep the page's line true as well as its own thread's. @@ -2140,8 +2130,21 @@ def test_a_thread_says_what_the_agent_is_doing_about_it( held, other = comments[0]["id"], comments[1]["id"] page.keyboard.press("c") expect(page.locator(".lf-panel")).to_be_visible() - work_line = page.locator(".lf-work-line") - expect(work_line).to_have_count(0) + receipts = page.locator(".lf-receipt") + held_receipt = page.locator(f'.lf-thread[data-id="{held}"] .lf-receipt') + other_receipt = page.locator(f'.lf-thread[data-id="{other}"] .lf-receipt') + expect(receipts).to_have_count(2) + expect(held_receipt).to_contain_text("✓ Sent") + held_receipt.evaluate("node => { node.dataset.identityProbe = 'kept' }") + + # Durable transport acceptance advances the exact same row in place. It does + # not claim that work has started and does not disturb another reader move. + with service_model.PageTransaction(d) as transaction: + session_model.record_pickup(transaction, [comments[0]]) + told(page) + expect(held_receipt).to_contain_text("✓ Picked up") + expect(held_receipt).to_have_attribute("data-identity-probe", "kept") + expect(other_receipt).to_contain_text("✓ Sent") def status(*args): assert ( @@ -2152,26 +2155,26 @@ def status(*args): status("working", "reading the reconnect traces", "--on", held) # One line, on the thread it names: a mark that stood on every open thread would # say only that the agent is busy, which the banner above already says. - expect(work_line).to_have_count(1) - expect(work_line).to_have_text( - re.compile(r"^Claude is on this — reading the reconnect traces\s*just now$") - ) - expect(page.locator(f'.lf-thread[data-id="{held}"] .lf-work-line')).to_have_count(1) - expect(page.locator(f'.lf-thread[data-id="{other}"] .lf-work-line')).to_have_count( - 0 - ) + expect(receipts).to_have_count(2) + expect(held_receipt).to_have_text( + re.compile(r"^● Active — reading the reconnect traces\s*just now$") + ) + expect(held_receipt).to_have_attribute("data-identity-probe", "kept") + expect(held_receipt).to_have_count(1) + expect(other_receipt).to_have_count(1) + expect(other_receipt).to_contain_text("✓ Sent") # Under the words that asked and above the box that answers, so it reads in the # thread's own order: what you said, what has been said back, what is being done. assert page.evaluate( f"""() => {{ const thread = document.querySelector('.lf-thread[data-id="{held}"]'); const kids = [...thread.children]; - return kids.findIndex((el) => el.matches('.lf-work-line')) + return kids.findIndex((el) => el.matches('.lf-receipt')) > kids.findLastIndex((el) => el.matches('.lf-msg.user')) - && kids.findIndex((el) => el.matches('.lf-work-line')) + && kids.findIndex((el) => el.matches('.lf-receipt')) < kids.findIndex((el) => el.matches('.lf-compose')); }}""" - ), "the work line is not between the thread's last message and its reply box" + ), "the receipt is not between the thread's last message and its reply box" # A later claim about the page as a whole is not an answer to the thread, so the # line stands: the two seats are one claim, and only one of them has been rewritten. @@ -2179,8 +2182,8 @@ def status(*args): expect(page.locator(".lf-status-text")).to_have_text( re.compile(r"^Claude is working — drafting v2") ) - expect(work_line).to_have_count(1) - expect(work_line).to_contain_text("reading the reconnect traces") + expect(held_receipt).to_have_count(1) + expect(held_receipt).to_contain_text("reading the reconnect traces") # The answer is what ends it. events_model.append_event( @@ -2197,19 +2200,22 @@ def status(*args): expect(page.locator(f'.lf-thread[data-id="{held}"] .lf-msg.claude')).to_have_count( 1 ) - expect(work_line).to_have_count(0) + expect(held_receipt).to_have_count(0) + expect(receipts).to_have_count(1) # And a claim the agent renews after answering stands again: its line is on the thread # a second time, which is a fact about now rather than about what was said. status("working", "re-running it against the rolling deploy", "--on", held) - expect(work_line).to_have_count(1) + expect(held_receipt).to_have_count(1) + expect(receipts).to_have_count(2) # A conversation the reader has closed asks nothing and shows nothing, for the same # reason its reply box is gone. events_model.append_event(d, {"kind": "resolve", "author": "user", "parent": held}) told(page) expect(page.locator(".lf-details summary")).to_have_text("Resolved (1)") - expect(work_line).to_have_count(0) + expect(held_receipt).to_have_count(0) + expect(receipts).to_have_count(1) # Reopening restores a claim that no reply answered. The local line still goes # with the page claim it is part of: once nothing holds the page, it cannot keep @@ -2218,13 +2224,40 @@ def status(*args): d, {"kind": "unresolve", "author": "user", "parent": held} ) told(page) - expect(work_line).to_have_count(1) + expect(held_receipt).to_have_count(1) + expect(receipts).to_have_count(2) record_claim(d, id="s", pid=dead_pid) told(page) expect(page.locator(".lf-status-text")).to_have_text( re.compile(r"^No session holds this page\.") ) - expect(work_line).to_have_count(0) + expect(held_receipt).to_have_count(0) + expect(receipts).to_have_count(1) + assert errors == [] + page.close() + + +def test_an_unpicked_move_says_it_is_waiting_after_the_short_grace(browser, serve): + """Silence changes the wording, not the durable phase or the interaction seat.""" + url = serve(LONG_PAGE) + d = serve.page_dir + old = (datetime.now().astimezone() - timedelta(minutes=3)).isoformat( + timespec="seconds" + ) + comment = events_model.append_event( + d, + { + "kind": "comment", + "author": "user", + "revision": 1, + "text": "Did this reach anyone?", + "ts": old, + }, + ) + page, errors = open_page(browser, url) + page.keyboard.press("c") + receipt = page.locator(f'.lf-thread[data-id="{comment["id"]}"] .lf-receipt') + expect(receipt).to_contain_text("○ Waiting for pickup") assert errors == [] page.close() @@ -2249,7 +2282,7 @@ def test_a_work_line_says_when_its_claim_has_gone_quiet(browser, serve, tmp_path held = next(e for e in events_model.read_events(d) if e["kind"] == "comment")["id"] page.keyboard.press("c") expect(page.locator(".lf-panel")).to_be_visible() - work_line = page.locator(".lf-work-line") + work_line = page.locator(".lf-receipt") def claim(claim_ts, session="s"): """A page claim made now, carrying local work last renewed whenever.""" diff --git a/tests/test_smoke.py b/tests/test_smoke.py index ca3aaf7db3..5d7dea70ce 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -21,9 +21,12 @@ def test_ship_review_asks_are_directly_answerable(browser, serve): example = ROOT / "examples" / "ship-review.html" page, errors = open_page(browser, serve(example)) - expect(page.locator(".lf-decisions")).to_have_text("Asks (2)") + expect(page.locator(".lf-decisions")).to_have_text("Asks (1)") expect(page.locator("#off-workaround-review .lf-pick")).to_have_count(2) expect(page.locator("#off-workaround-review .lf-pick").first).to_be_visible() + expect(page.locator("#off-workaround-approve .lf-pick")).to_have_attribute( + "aria-checked", "true" + ) assert errors == [] page.close()