Files
rhema-claw/web/claw
vhaudiquet 94b82e2636 fix(web): the bugs a chat that never closes for a turn exposes
An investigation pass over the client, driven by steering's new shape —
a composer that is always live makes adjacent latent bugs visible.

The event stream now watches for silence: the claw keeps the
connection alive with a comment every fifteen seconds, and three
missed ones abort the fetch and reconnect, so a half-open TCP after a
laptop sleep no longer reads as 'live' forever. A reconnect clears the
attempted-attachment set with the transcript, or an image whose fetch
failed during the outage never retries against the recovered stream.
Saving a token reloads the identity and accounts the 401 froze: the
banner cleared, but the pages stayed empty until a browser reload.

A failed POST retracts its optimistic sent row (and its pending echo,
so a message the claw did take still renders from the stream's echo);
before, a send into an unreachable claw left a phantom row waiting for
a reply that could never come. Drafts are per-session now — the
composer's text belonged to no session, so a draft typed into one
session sent into whichever was selected at Enter. The providers
login form distinguishes a landed login (the roster carries its id)
from a failed one and no longer closes on an unrelated state refresh.

Two cost fixes that read as jank on long turns: the streamed reply's
markdown re-parsed the whole text per delta (quadratic) and now
re-renders on a short trailing debounce; the attachment-image watcher
walked the whole transcript on every streamed chunk and now scans only
the items it has not seen. The accounts page drops a stray Rust-ism
comment, and the avatar row's meta line admits webp like its accept
attribute always did.
2026-11-08 18:00:56 +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