Skip to content

Feature: mutable/interactive surfaces — checklists toggled by agent or user (bidirectional state) #105

Description

@ivanmkc

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions