feat(web/claw): open on the newest turn, walk back, and resume from the seq

A reload of a weeks-old session replayed the whole record before the
first pixel, and every reconnect reset the mirror and replayed it
again. The stream now names each frame's seq and the client keeps it,
so a retry sends since=<seq> and receives only the gap, and the mirror
is left alone: its items are still true.

A first open with no cursor gets the tail, cut to a turn boundary,
which paints the latest conversation at once. The turns above it come
from the history route, one whole turn per page, fetched when the
reader nears the top or when the transcript is too short to fill the
screen; a page folds through the same reducer as the live stream and
joins above, and the scroll position moves down by the page's height so
the reader keeps their place. A reset comment (the record no longer
holds the cursor) drops the mirror; the tail that follows rebuilds it.

The dev proxy rebuilt the upstream URL from the pathname alone, so
since and before never reached the daemon under nuxt dev: every resume
was a full replay and every history page was the tail. It forwards the
query string now.
This commit is contained in:
vhaudiquet committed 2026-11-08 18:01:30 +01:00
1 parent 744ba8b06c
commit ed79c0cf1b
6 files changed
+337 -49

No files matched your search

+25 -8
View File
@@ -59,17 +59,34 @@ 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.
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:` frames by hand
(`composables/useClaw.ts`). Drops retry with backoff; the hub's
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
@@ -83,8 +100,8 @@ Two consequences worth knowing before changing this code:
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.
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,