Use case
Today every termchart surface is one-way: the agent draws, the human reads. Many of the
most useful "companion screen" surfaces are two-way — the canonical one being a checklist
the agent and the human both act on:
- The agent posts a task/deploy/PR checklist and ticks items off as it completes them (live).
- The human ticks/unticks items in the UI themselves (approve a step, mark something done,
hand-check a list the agent prepared) — and the agent sees those changes and reacts.
Other surfaces of the same shape (out of scope for v1, but the same machinery): a single-choice
radio ("which option should I implement?"), an approve/reject button pair, a small form. The
unifying request: mutable surfaces whose state can change from either side and stays in sync.
Why this is new (not just another renderer)
The viewer is currently a display: the only client→server channel from the dashboard is clear
(everything else — push/patch/status/focus — is the agent writing, token-gated). There
is no path for a user interaction in the UI to flow back into stored state, and no way for the
agent to learn that the human changed something. This feature adds that inverse direction.
push / patch (token-gated) interact (NEW, URL-gated, bounded)
agent ───────────────────────────► server ◄─────────────────────────── user (UI)
▲ │ state (durable) │
└────────── pull / list ◄──────────┘ ──── SSE update ──┴────► every viewer
(agent reads user's edits) (live to all screens)
Proposed design
1. Data model — interactive state lives in the stored spec
Add an interactive Checklist component to the component whitelist
(packages/viewer/src/client/renderers/component-resolver.tsx). Its per-item checked booleans
are part of the stored component content, so they are durable (GCS write-through) and readable
by the agent via the existing termchart pull — no separate state store. Shape:
{ "type": "Checklist", "props": {
"id": "deploy",
"items": [
{ "id": "build", "label": "Build image", "checked": true },
{ "id": "deploy", "label": "Deploy to prod", "checked": false },
{ "id": "smoke", "label": "Smoke test", "checked": false }
],
"editable": true
}}
editable: false ⇒ display-only (agent-driven progress the user can't change). id namespaces
the widget within the spec so interactions address a stable path.
2. User → server — a new bounded, URL-gated interact endpoint
POST /w/<wsid>/interact { project, agent, ref: "deploy/smoke", value: true }
- Applies a single whitelisted mutation to the stored spec: flip the
checked boolean of the
addressed item. Persists (persistSave) and broadcasts an SSE update to every viewer — reusing
the exact path patch already uses (server.ts:252–284).
- URL-gated, not token-gated (same rationale as the new
clear): the human is acting on their
own screen, not injecting content. But strictly bounded — the security boundary below.
- Mirrors the existing
clear client helper in viewer.ts (the now-tokenless clearRequest).
3. Agent → server — extend the patch vocabulary
Add a setChecked op to component-patch.ts (alongside setProps/setChildren):
{ op: "setChecked", id: "deploy", item: "smoke", value: true }. So the agent ticks items via the
existing token-gated termchart patch — same broadcast, no new agent surface. (setProps could do
it, but a typed op keeps lint/validation tight and reads cleanly in the recipe.)
4. Agent read-back
v1: the agent calls termchart pull --project … --agent … and sees current checked states
(already works once the state is in the spec). Optionally (v2) the interact endpoint also emits a
lightweight status-style toast ("✓ user checked Smoke test") so a watching screen shows the human's
action, and/or a GET /interactions?since=<ts> poll so an agent can wait on human input without
diffing specs.
5. Rendering & sync
Checklist renders Mantine Checkboxes; on user toggle it does an optimistic local update
and fires interact.
- The SSE
update echo reconciles all clients. Avoid the feedback loop: tag the broadcast with an
origin id (or have the originating client ignore the next echo for that ref), so the actor's own
optimistic state isn't clobbered/flickered.
- Respect
prefers-reduced-motion; keep the checkbox theme-aware via the existing CSS vars.
Security boundary (the crux of making interact ungated)
interact must be impossible to abuse as a content-injection channel:
- Only mutates interactive leaf fields (
checked booleans; later: radio value, within an
enumerated set) on nodes the spec marked interactive — never children, never arbitrary props,
never new nodes.
ref must resolve to an existing interactive item in the stored spec, else 400/404 (no implicit
creation).
- Reuses
validateContent/validateComponentTree after applying, and the existing 1 MB body cap +
bounded depth. Add a small per-ref value whitelist (boolean / enumerated).
- Persistence stays write-through;
hydratedForWrite guard applies (refuse on backend failure,
like patch) so a transient GCS error can't clobber.
Phased implementation plan
- Renderer + data model (display-first). Add
Checklist to the whitelist; render checked
state; editable:false only. Unit test (resolves, reflects checked) + an e2e that pushes a
checklist and asserts the boxes render. No interactivity yet — pure additive, safe to ship.
- Agent-side toggling. Add
setChecked to component-patch.ts (+ lint + the patch handler
already broadcasts). CLI: termchart patch … --check deploy/smoke (or ops JSON). TDD on the patch
op; e2e that a patched check updates live.
- User-side
interact endpoint (the new direction). Add the bounded, URL-gated handler +
validate; wire the Checklist editable:true to fire it with optimistic update + SSE
reconciliation + origin-echo guard. Server tests (bounded mutation, rejects non-interactive ref,
rejects content injection, persists, broadcasts). Browser e2e: user clicks a box → state flips,
persists, second client sees it, pull shows it.
- Agent awareness (optional v2).
interact emits a status toast and/or a since-poll so an
agent can wait on human input. Decide based on real use.
- Recipe + docs. A
diagram-recipes example (a deploy/PR checklist), the component detail file,
and the diagram-remote command note. (Shipping the recipe to users needs a plugin marketplace
version bump, not just a viewer deploy — per the release-channels convention.)
Open questions
- Echo/conflict policy: origin-id-ignore vs last-write-wins vs per-item version? (Lean origin-id
- LWW for v1 — checklists are low-contention.)
- Should
interact be fully ungated, or gated by an optional per-widget lock? (Default ungated;
add editable:false for agent-only progress bars.)
- Generalize the endpoint now or after? A generic
interact {ref, value} already covers radios
/selects/forms later; v1 only renders checklists but the channel can be type-agnostic from day one.
Related
Use case
Today every termchart surface is one-way: the agent draws, the human reads. Many of the
most useful "companion screen" surfaces are two-way — the canonical one being a checklist
the agent and the human both act on:
hand-check a list the agent prepared) — and the agent sees those changes and reacts.
Other surfaces of the same shape (out of scope for v1, but the same machinery): a single-choice
radio ("which option should I implement?"), an approve/reject button pair, a small form. The
unifying request: mutable surfaces whose state can change from either side and stays in sync.
Why this is new (not just another renderer)
The viewer is currently a display: the only client→server channel from the dashboard is
clear(everything else —
push/patch/status/focus— is the agent writing, token-gated). Thereis no path for a user interaction in the UI to flow back into stored state, and no way for the
agent to learn that the human changed something. This feature adds that inverse direction.
Proposed design
1. Data model — interactive state lives in the stored spec
Add an interactive
Checklistcomponent to the component whitelist(
packages/viewer/src/client/renderers/component-resolver.tsx). Its per-itemcheckedbooleansare part of the stored
componentcontent, so they are durable (GCS write-through) and readableby the agent via the existing
termchart pull— no separate state store. Shape:{ "type": "Checklist", "props": { "id": "deploy", "items": [ { "id": "build", "label": "Build image", "checked": true }, { "id": "deploy", "label": "Deploy to prod", "checked": false }, { "id": "smoke", "label": "Smoke test", "checked": false } ], "editable": true }}editable: false⇒ display-only (agent-driven progress the user can't change).idnamespacesthe widget within the spec so interactions address a stable path.
2. User → server — a new bounded, URL-gated
interactendpointPOST /w/<wsid>/interact { project, agent, ref: "deploy/smoke", value: true }checkedboolean of theaddressed item. Persists (
persistSave) and broadcasts an SSEupdateto every viewer — reusingthe exact path
patchalready uses (server.ts:252–284).clear): the human is acting on theirown screen, not injecting content. But strictly bounded — the security boundary below.
clearclient helper inviewer.ts(the now-tokenlessclearRequest).3. Agent → server — extend the patch vocabulary
Add a
setCheckedop tocomponent-patch.ts(alongsidesetProps/setChildren):{ op: "setChecked", id: "deploy", item: "smoke", value: true }. So the agent ticks items via theexisting token-gated
termchart patch— same broadcast, no new agent surface. (setPropscould doit, but a typed op keeps lint/validation tight and reads cleanly in the recipe.)
4. Agent read-back
v1: the agent calls
termchart pull --project … --agent …and sees currentcheckedstates(already works once the state is in the spec). Optionally (v2) the
interactendpoint also emits alightweight
status-style toast ("✓ user checked Smoke test") so a watching screen shows the human'saction, and/or a
GET /interactions?since=<ts>poll so an agent can wait on human input withoutdiffing specs.
5. Rendering & sync
Checklistrenders MantineCheckboxes; on user toggle it does an optimistic local updateand fires
interact.updateecho reconciles all clients. Avoid the feedback loop: tag the broadcast with anorigin id (or have the originating client ignore the next echo for that ref), so the actor's own
optimistic state isn't clobbered/flickered.
prefers-reduced-motion; keep the checkbox theme-aware via the existing CSS vars.Security boundary (the crux of making
interactungated)interactmust be impossible to abuse as a content-injection channel:checkedbooleans; later: radio value, within anenumerated set) on nodes the spec marked interactive — never
children, never arbitrary props,never new nodes.
refmust resolve to an existing interactive item in the stored spec, else 400/404 (no implicitcreation).
validateContent/validateComponentTreeafter applying, and the existing 1 MB body cap +bounded depth. Add a small per-ref value whitelist (boolean / enumerated).
hydratedForWriteguard applies (refuse on backend failure,like patch) so a transient GCS error can't clobber.
Phased implementation plan
Checklistto the whitelist; render checkedstate;
editable:falseonly. Unit test (resolves, reflectschecked) + an e2e that pushes achecklist and asserts the boxes render. No interactivity yet — pure additive, safe to ship.
setCheckedtocomponent-patch.ts(+ lint + the patch handleralready broadcasts). CLI:
termchart patch … --check deploy/smoke(or ops JSON). TDD on the patchop; e2e that a patched check updates live.
interactendpoint (the new direction). Add the bounded, URL-gated handler +validate; wire theChecklisteditable:trueto fire it with optimistic update + SSEreconciliation + origin-echo guard. Server tests (bounded mutation, rejects non-interactive ref,
rejects content injection, persists, broadcasts). Browser e2e: user clicks a box → state flips,
persists, second client sees it,
pullshows it.interactemits a status toast and/or asince-poll so anagent can wait on human input. Decide based on real use.
diagram-recipesexample (a deploy/PR checklist), the component detail file,and the
diagram-remotecommand note. (Shipping the recipe to users needs a plugin marketplaceversion bump, not just a viewer deploy — per the release-channels convention.)
Open questions
interactbe fully ungated, or gated by an optional per-widgetlock? (Default ungated;add
editable:falsefor agent-only progress bars.)interact {ref, value}already covers radios/selects/forms later; v1 only renders checklists but the channel can be type-agnostic from day one.
Related
packages/viewer/src/component-patch.ts, serverpatchhandler (server.ts~252–284), SSE
updatebroadcast.GET /state+GET /list→termchart pull/list.clear(feat(viewer): clear scopes without the push token + theme-aware confirm modal #102).