Files
kosmos b3bb026fab fix(web): the session inventory lists only the keys the chat can open
The sidebar grouped everything that was not `interactive:` under
"other keys", minus the phone's status keys. That included the coding
jobs' `job:<instance>#<id>` keys and forge keys — and those are dead
ends: the message, event and history routes answer only for the
resident prefixes (`is_resident_key`), so opening one 400s and the row
sits there looking busy with nothing inside. A finished job's key was
worse than useless while its dispatch spun: `reacquire`, forever.

`isResidentKey` names the rule once, beside `isInteractive` —
`interactive:`, `task:`, `calendar:` — and the inventory filters on it.
That leaves the phone's keys out as before, keeps scheduled tasks
(their turns have real transcripts), and stops offering keys the chat
surface cannot address. The shell's `nextActiveKey` follows the same
rule: after a delete the chat moves to the next key it can open, or to
none. The full list stays on the wire — the API's inventory is the
dispatcher's, not the client's, and the delete route still answers for
any key with a record.

Consequence worth naming: a `job:` or forge key has no row now, so it
cannot be deleted from the sidebar either.
2026-11-11 23:52:54 +00: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 on the newest turn before the live feed — a reload paints the latest conversation at once instead of replaying a session that may have run for weeks, and the turns above it come from GET /v1/sessions/{key}/history, one whole turn per page, as the reader scrolls up (or right away, while the transcript is shorter than the screen). So the transcript survives the browser, not just the claw.
  • Every frame names its seq, and a reconnect names it back. The frame's id: is the record's seq for the event, and useClaw().subscribeEvents keeps the last one it delivered: a retry sends ?since=<seq> and receives exactly the gap, so a dropped connection costs nothing on screen and nothing on the wire. The mirror is not reset on reconnect for the same reason — its items are still true. A reset; comment means the cursor named events the record no longer holds (a wiped database): the mirror drops, and the tail that follows rebuilds it.
  • 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:/id: frames by hand (composables/useClaw.ts). Drops retry with backoff; the hub's lagged comments surface as notices.
  • The dev proxy forwards the query string. server/middleware/ claw-proxy.ts rebuilds the upstream URL from the request's pathname, and since/before are the whole history protocol: dropping them silently turns every resume into a full replay and every history page into the tail.

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; nothing persists client-side, so a reload rebuilds the cards from the replay.
  • 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 inventory lists only the resident prefixes (interactive:, task:, calendar:) — the ones the message, event and history routes answer for. The phone's status keys (phone:…), the coding jobs' job:<instance>#<id> keys and forge keys stay out, and the full list stays on the wire.
  • Deleting a session is a decision, not a click. The row's delete button only names the key; the shell's dialog spells out that the turn history goes with it, and DELETE /v1/sessions/{key} runs on confirm — so a key created again under the same name starts empty. A 409 (a turn holds the key) and a 404 (already gone) land in the error banner like every other refusal, and the chat moves to the first remaining session, the stream's watcher following the key so nothing keeps streaming a session that no longer exists.

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