feat(genui): the backend defines the widget, and its buttons route - #10
Merged
Conversation
Server-defined components (PROTOCOL.md §10): markup, styles and prop defaults travel once in a `genui_components` frame, the client registers each as a custom element, and a plain `ui` chunk mounts it from then on. Adding a widget becomes a server deploy — no client build. The catalog is versioned by a sha256 the client caches and hands back in `hello`, so an unchanged catalog costs one tiny frame. The other half is what a button on one of those widgets *does*. Until now a `genui_event` only did one thing — a `submit` naming an open interrupt was coerced to a resume — and everything else was accepted and silently dropped. A `data-event` button was decorative. The attribute now picks the addressee, and that is the whole routing model: component-event="<name>" scope "component" the node parked on onEvent, only mekik-event="<name>" scope "graph" the app's handler, may start a turn data-event="<name>" no scope try the component route, then graph The engine resolves in that order, with the §4.4 shortcut ahead of both: a payload `id` naming an open interrupt is a direct address, so it outranks scope. A frame matching none of the three is accepted and dropped — the right cost for a decorative button. Two new author-facing pieces: - `onEvent` / `Shuttle.OnEvent` — approve for widgets. The run parks, but a button on a component already on screen answers it. An ordinary ilmek interrupt, so it is checkpointed, survives a restart, and re-announces in `welcome.pending`; its frame carries `data.event` so a client knows not to render Approve/Cancel chips. The pause the node holds is the binding, so nothing carries an interrupt id. - `onGenUiEvent` / `OnGenUiEvent` — input for components. A mapper, not a place to do work: return a graph input update to run a turn on the interaction, or nothing to ignore it. The turn obeys §5 (busy, interrupted) and writes no `text` frame on the user's behalf, because a click is not an utterance. Also here: the `withTurn` / `WithTurnAsync` extraction, so `text`, `resume` and a component-driven turn take the local lock and the cross-node lease through one path and cannot drift apart on the locking rules. The .NET example self-test never ran in CI while its TypeScript twin did; it is in the probe step now, so the routing is covered on both sides. Conformance scenarios 15 and 16 pin the behaviour for future ports, and 16 joins the "ports tend to break this" list — scope precedence is exactly the kind of rule that can be silently wrong. Verified: TS 111 tests + every example self-test (`pnpm check`), .NET 76 tests + every example and probe, and the docs site builds under `onBrokenAnchors: throw`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…cs site The feature landed in PROTOCOL.md, README.md and docs/GENUI.md but never reached the docusaurus tree, so the published docs described a protocol without `genui_components` in it — the one place a reader looks first. - protocol/frames.md: the frame's table row, a transient-frame detail block with a worked example, and `hello.componentsHash` (the ETag that keeps the catalog from travelling twice). - authoring/generative-ui.md: "Components the server defines" — both authoring forms, the template subset, the hash handshake, and the two rules that decide whether an in-place update is visible at all (pace it; journal the emissions that precede a pause, or the resume walks the client back through states it already rendered). - examples.md: the new example in both languages, and a note that it is the one demo needing no client-side registration. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two halves of PROTOCOL.md §10.
Server-defined components. Markup, styles and prop defaults travel once in a
genui_componentsframe; the client registers each as a custom element and a plainuichunk mounts it from then on. Adding a widget becomes a server deploy. The catalog is versioned by a sha256 the client caches and hands back inhello, so an unchanged catalog costs one tiny frame.What its buttons do. Until now a
genui_eventdid exactly one thing — asubmitnaming an open interrupt was coerced to a resume — and everything else was accepted and silently dropped. Adata-eventbutton was decorative.The routing model
The attribute picks the addressee:
scopecomponent-event="rate_delivery""component"onEventwaiting for that name — nothing elsemekik-event="track_order""graph"onGenUiEvent, which may start a turndata-event="…"Resolved in that order, with the §4.4 shortcut ahead of both: a payload
idnaming an open interrupt is a direct address, so it outranksscope. A frame matching none of the three is accepted and dropped — the right cost for a decorative button, and the first thing to check when a button looks dead.Two new author-facing pieces
onEvent/Shuttle.OnEvent—approvefor widgets. The run parks, but a button on a component already on screen answers it:An ordinary ilmek interrupt, so it inherits everything a pause already gives you — checkpointed, survives a restart, re-announced in
welcome.pending. Its frame carriesdata.eventso a client knows to wait for the widget instead of rendering Approve/Cancel. The pause the node holds is the binding, so nothing carries an interrupt id.onGenUiEvent/OnGenUiEvent—inputfor components. A mapper, not a place to do work: return a graph input update to run a turn on the interaction, or nothing to ignore it. The turn obeys §5 (busy,interrupted) and writes notextframe on the user's behalf, because a click is not an utterance.Also here
withTurn/WithTurnAsyncextracted, sotext,resumeand a component-driven turn take the local lock and cross-node lease through one path and can't drift apart on the locking rules.server-componentsexamples now demonstrate all three routes end to end, including a second pause the card's own Rate button answers. Thesettledbeat is journaled because of it — without that, the rating's replay pass would re-emit states the client already rendered.Verified
pnpm check)onBrokenAnchors: "throw"server-componentsself-tests emit identical frame sequencesNote for the reviewer
The client half lives in AimTune/chativa —
component-event/mekik-eventhave to be bound there for the new attributes to fire. That PR is up alongside this one and I checked the two agree on the wire by feeding chativa's frame factory output straight into a real mekik app..vscodedebugger config is deliberately not in here — it's #9, offmain, touching disjoint files.🤖 Generated with Claude Code