Skip to content

feat(genui): the backend defines the widget, and its buttons route - #10

Merged
AimTune merged 2 commits into
mainfrom
feat/genui-component-events
Jul 30, 2026
Merged

feat(genui): the backend defines the widget, and its buttons route#10
AimTune merged 2 commits into
mainfrom
feat/genui-component-events

Conversation

@AimTune

@AimTune AimTune commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Two halves of PROTOCOL.md §10.

Server-defined components. 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. The catalog is versioned by a sha256 the client caches and hands back in hello, so an unchanged catalog costs one tiny frame.

What its buttons do. Until now a genui_event did exactly 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 routing model

The attribute picks the addressee:

markup scope who receives it
component-event="rate_delivery" "component" the node parked on onEvent waiting for that name — nothing else
mekik-event="track_order" "graph" the app's onGenUiEvent, which may start a turn
data-event="…" absent component route first, then graph (the original form, still supported)

Resolved 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, and the first thing to check when a button looks dead.

Two new author-facing pieces

onEvent / Shuttle.OnEventapprove for widgets. The run parks, but a button on a component already on screen answers it:

deliveryCard(ctx, props, { id: "card-1" });                 // mount it first
const rating = await mekik.onEvent<{ stars: number }>(ctx, "rate_delivery");

An ordinary ilmek interrupt, so it inherits everything a pause already gives you — checkpointed, survives a restart, re-announced in welcome.pending. Its frame carries data.event so 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 / OnGenUiEventinput 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

  • withTurn / WithTurnAsync extracted, so text, resume and a component-driven turn take the local lock and cross-node lease through one path and can't drift apart on the locking rules.
  • The .NET example self-test never ran in CI while its TypeScript twin did. It's in the probe step now, so the routing is covered on both sides.
  • Conformance scenarios 15 and 16, with 16 added to the "ports tend to break this" list — scope precedence is exactly the kind of rule that can be silently wrong.
  • Both server-components examples now demonstrate all three routes end to end, including a second pause the card's own Rate button answers. The settled beat is journaled because of it — without that, the rating's replay pass would re-emit states the client already rendered.

Verified

TypeScript 111 tests + every example self-test (pnpm check)
.NET 76 tests + every example and probe
Docs site builds under onBrokenAnchors: "throw"
Parity both server-components self-tests emit identical frame sequences

Note for the reviewer

The client half lives in AimTune/chativa — component-event / mekik-event have 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.

.vscode debugger config is deliberately not in here — it's #9, off main, touching disjoint files.

🤖 Generated with Claude Code

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>
@AimTune
AimTune merged commit d8501e6 into main Jul 30, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant