Files
rhema-claw/web/claw
vhaudiquet 041240f706 feat(claw): a message into a live turn steers it
The resident dispatch's turn now runs under a steer select: the
messages route, after its ingest, signals a per-key interrupt registry,
the drop lands at the turn's current await (an in-flight shell command
dies with it), the dangling calls close, and the dispatch loops on the
same lease — the steered message runs as the next round, same session,
same stream. The interrupted turn closes on the stream with turn_done
carrying outcome "steered", and the shim's whole and streamed
answers both wait through steered end markers for the round that
actually replies.

The messages route answers 202 the moment the message is accepted; the
drain pass runs detached, so a turn's minutes never hold the request
open. The web composer leans on that: it never closes for a running
turn, the placeholder and the send button say that sending steers, a
steered outcome gets its own tone, and the sent-row echo dedup pops a
pending list instead of comparing the last item — a steered chain's
extra end marker can no longer double the row.
2026-11-08 18:00:39 +01:00
..

rhema claw web

A small, hand-rolled web client for the claw (rhema-claw): a live transcript of one session, the session inventory, and a settings area (model providers, about). Nuxt 4 + Vue 3 + TypeScript, no component library, no store. It is strictly one client of the claw's public /v1 HTTP surface.

Run it against a locally running claw

rhema-claw                 # the daemon, on 127.0.0.1:7799
cd web/claw
npm install
npm run dev                # http://localhost:3000

In dev, the server/middleware/claw-proxy.ts middleware forwards /v1 and /healthz to http://127.0.0.1:7799 (NUXT_CLAW_UPSTREAM overrides), so the browser sees a same-origin API and CORS never enters the picture. If the claw is not running, the page still loads: the down/401/stream banners tell you what is wrong.

Token

When the claw config sets token:, every /v1 route wants Authorization: Bearer <token>. Give the UI a token either way:

  • the environment: NUXT_PUBLIC_CLAW_TOKEN=... npm run dev (runtime config clawToken), or
  • the inline field in the 401 banner; it is stored in localStorage.

/healthz is open either way, which is how the header dot stays honest when the token is wrong.

Build

npm run build      # nuxt generate -> .output/public, fully static
npm run typecheck  # vue-tsc over the whole app

npm run build produces a static SPA in .output/public, the artifact an ingress serves. The ingress owns routing: same-origin /v1 and /healthz to the claw. runtimeConfig.public.clawBase can point the client at another base, but the claw sends no CORS headers, so cross-origin use needs a proxy, not a different origin.

The one-consumer principle

The claw's /v1 API is the contract (crates/rhema-claw/src/http.rs): sessions, messages, events, providers. This client uses only public routes, speaks the wire shapes the Rust side serializes, and adds nothing server-side. UI needs never justify private endpoints; they justify claw API changes, argued in the Rust tree.

Two consequences worth knowing before changing this code:

  • The daemon owns the conversation. POST .../messages is fire-and-forget (202); the reply streams back on GET /v1/sessions/{key}/events. The transcript here is a mirror of the stream, and the stream opens with the claw's replay of the session's recorded turns before the live feed — a reload and every reconnect reset the mirror and rebuild from the replay, so the transcript survives the browser, not just the claw.
  • SSE goes over fetch, not EventSource. The claw's auth reads only the Authorization: Bearer header (http.rs require_auth); it has no query-param fallback, and EventSource cannot set headers. So useClaw().subscribeEvents holds a GET with the auth header, reads the body as a ReadableStream, and parses data: frames by hand (composables/useClaw.ts). Drops retry with backoff; the hub's lagged comments surface as notices.

What the transcript does with the stream

  • Long pastes travel as chips. A paste over three lines (the TUI's PASTE_INLINE_LINES) never lands in the composer; it stages as a droppable chip (▣1 42L) and embeds into the sent text as U+FFFC-payload-U+FFFC — the same record rhema-core's paste::embed keeps — so the transcript renders each embed back as a chip in place of the payload. An unpaired mark is literal text.
  • The agent can ask. A consent_request event renders as an inline card: kind secret gets a password input plus submit, kind phone gets Approve/Deny, and both carry a deny link (DELETE /v1/consents/{id}). The card flips when the answer lands and again on the stream's consent_resolved echo; a reconnect's replay rebuilds the cards, so nothing persists client-side.
  • Attachments render inline. An attachment event becomes a card — media glyph, file name, B/KB/MB size, caption. Images fetch the bearer-guarded bytes (GET /v1/attachments/{id}, blob → object URL, like the identity images) and render capped at 240px; code and file cards download on click. Object URLs cache per id, so a replay never refetches.
  • Your images ride along. The composer's + — and an image on the clipboard — stage as removable chips (▣1 photo.png 1.2 MB), the claw's own budget enforced client-side (8 a message, 5 MiB decoded each; the draft stays required, the route 422s an empty text). The sent row renders the local previews at once; the stream's user_message echo carries the stored attachment ids and dedupes against that row, so a replayed message renders its own thumbnails through the same bearer-guarded fetch as an attachment card.
  • Sessions are created in place. The sidebar's + form posts POST /v1/sessions with a client-validated name ([A-Za-z0-9._-]{1,64}), switches the chat to the new key, and lets 400/409 land in the error banner. The phone client's status keys (phone:…) stay out of the inventory; the full list stays on the wire.

Layout

The shell is an icon rail plus two views, switched by a tiny hash router (#/chat, #/settings/agent/general, #/settings/agent/ accounts, #/settings/providers, #/settings/about), no vue-router. The chat view is the session inventory plus the live transcript; the settings view is a searchable nav plus pages. The agent's identity (name, mark, avatar) loads once and renders everywhere — rail, sidebar, chat, window title — through AgentAvatar, which owns the precedence: avatar image, then mark or initials. Settings pages stage edits into a local draft and apply them with an explicit Save (PUT /v1/providers/active answers the new pair only; the client carries the provider list forward). The providers page also manages the roster itself: the add form renders from the addable kinds the claw serves; each kind's fields carry {name, required}, required marking what the daemon refuses the login without (llama.cpp needs neither of its fields, it serves keyless on a default url); the form demands only those, marks the rest optional, and takes an optional instance name; unnamed, the id is the kind. Model options carry a ◉ vision mark when the live listing knows the model takes images (supports_vision: true); the key's absence is unknown, never a no, so it renders nothing. A login is PUT /v1/providers/{id}/credentials: apply immediately, credentials persist; roster rows are backend instances (id, the kind unless the login named it, plus the credential's source badge, env-backed kinds return on restart after a removal), and the remove button (DELETE /v1/providers/{id}) refuses the last backend claw-side.

app/
  app.vue                        the shell: rail, banners, view switch
  assets/css/main.css            the Jadeite tokens (matches the TUI's
                                 palette.rs DARK) + card/badge primitives
  components/
    AppRail.vue                  the icon rail: views, claw pulse
    AppBadge.vue                 the status/tag pill
    AppCallout.vue               the toned inline note (info/ok/warn/error)
    AppMarkdown.vue              agent replies as markdown (markdown-it,
                                 raw HTML escaped, styles in main.css .md)
    ClawBanner.vue               the one error/notice banner surface
    ChatPanel.vue                transcript: sent lines (paste chips),
                                 streamed turns as bash shell lines and
                                 glyph rows, consent + attachment
                                 cards; the growing composer; agent
                                 replies render through AppMarkdown
    SessionsSidebar.vue          conversable dispatch keys (phone status
                                 keys hidden), interactive first; the
                                 + form creates a session
    PageHeader.vue               title + subtitle + page actions
    SettingCard.vue              one titled card of rows
    SettingRow.vue               name + description + control
    SettingsNav.vue              searchable settings nav
    StatusDot.vue                one honest state dot
    settings/
      ProvidersPage.vue          the backend switch: draft → Save/Discard
      AboutPage.vue              identity, stack, the routes spoken
  composables/
    useClaw.ts                   the typed API client + fetch-based SSE
    useTranscript.ts             folds TurnEvents into renderable turn
                                 blocks; owns the paste-embed text form
server/
  middleware/claw-proxy.ts dev-only same-origin proxy to a running claw