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
+336 -48

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,
+53 -8
View File
@@ -99,7 +99,19 @@ const activeKey = ref(stored(ACTIVE_STORE_KEY) ?? DEFAULT_KEY)
watch(activeKey, (key) => persist(ACTIVE_STORE_KEY, key))
const transcript = useTranscript()
const { items: transcriptItems, live: liveTurn } = transcript
const {
items: transcriptItems,
live: liveTurn,
loadingOlder,
prependCount,
} = transcript
// Whether the record still holds turns above what the mirror shows.
// The stream opens on the newest turn and the reader walks back page
// by page (ChatPanel asks when the scroller nears its top, or when the
// transcript is too short to fill the screen), so a session that ran
// for weeks costs nothing up front and still reaches its first message.
const olderAvailable = ref(true)
// Attachment ids this page already tried to fetch; a failed fetch is
// not retried until the stream resets and clears the set.
@@ -529,20 +541,27 @@ let stream: { close: () => void } | null = null
function resubscribe(): void {
stream?.close()
transcript.reset()
olderAvailable.value = true
attemptedAttachments.clear()
notices.value = []
streamStatus.value = 'connecting'
stream = claw.subscribeEvents(activeKey.value, {
onEvent: (event) => transcript.applyEvent(event),
onEvent: (event, seq) => transcript.applyEvent(event, seq),
// The record lost what this mirror holds (a wiped or reset
// database): drop it, and the tail the claw sends next rebuilds it.
onReset: () => {
transcript.reset()
olderAvailable.value = true
attemptedAttachments.clear()
scannedItems = 0
},
onStatus: (status) => {
streamStatus.value = status
// A retry reopens the stream with the claw's replay of the
// recorded turns: drop the mirror and let it rebuild, or the
// replay would double everything already on screen. The
// attempted-attachment set goes too — a fetch that failed during
// the outage must retry against the recovered stream.
// A retry resumes from the last seq this mirror holds, so the
// claw sends the gap and nothing else: the mirror stays as it
// is. Only the attachment set is cleared — a fetch that failed
// during the outage should retry against the recovered stream.
if (status === 'reconnecting') {
transcript.reset()
attemptedAttachments.clear()
scannedItems = 0
}
@@ -552,6 +571,28 @@ function resubscribe(): void {
})
}
/** Fetches one page of turns older than the mirror's first and stacks
* it above. Whole turns only, so the page never duplicates or skips:
* the daemon ends it exactly where the mirror begins. */
async function loadOlder(): Promise<void> {
const before = transcript.firstSeq.value
if (before === null || !olderAvailable.value || transcript.loadingOlder.value) return
transcript.loadingOlder.value = true
const key = activeKey.value
try {
const page = await claw.history(key, before)
// The reader may have switched sessions while the page was in
// flight; a page belongs to the mirror it was asked for.
if (key !== activeKey.value) return
transcript.prependOlder(page.events)
olderAvailable.value = page.more
} catch (err) {
noteApiError(err)
} finally {
transcript.loadingOlder.value = false
}
}
watch(activeKey, resubscribe)
let healthTimer: ReturnType<typeof setInterval> | undefined
@@ -641,8 +682,12 @@ onUnmounted(() => {
:session="activeSession"
:active-backend="providersState?.active ?? null"
:stream-status="streamStatus"
:loading-older="loadingOlder"
:older-available="olderAvailable"
:prepend-count="prependCount"
:identity="identity"
:avatar-url="identityAvatarUrl"
@older="loadOlder"
@send="send"
@open-providers="go({ view: 'settings', section: 'providers' })"
@consent-answer="answerConsent"
+72
View File
@@ -28,6 +28,13 @@ const props = defineProps<{
session: ClawSession | null
activeBackend: { provider: string; model: string } | null
streamStatus: StreamStatus
/** An older page is in flight: the stream area shows it, so a slow
* claw reads as loading rather than as the top of the transcript. */
loadingOlder?: boolean
/** The record still holds turns above what the mirror shows. */
olderAvailable?: boolean
/** Bumped whenever a page was folded in above the mirror. */
prependCount?: number
identity?: { name: string | null; mark: string | null; initials: string | null } | null
avatarUrl?: string | null
}>()
@@ -47,6 +54,7 @@ const emit = defineEmits<{
consentAnswer: [id: string, answer: ConsentAnswer]
consentDeny: [id: string]
downloadAttachment: [id: string, name: string]
older: []
}>()
const draft = ref('')
@@ -68,12 +76,66 @@ const scroller = ref<HTMLElement | null>(null)
/** Pin to the bottom only while the reader is already there. */
const pinned = ref(true)
/** How near the top counts as asking for the turn above. */
const OLDER_THRESHOLD = 160
/** The scroll state captured when a page was asked for, so the reader
* keeps looking at the same message once it lands above them: the
* content grew over the viewport, and the position must move down by
* that growth. */
let olderAnchor: { top: number; height: number } | null = null
/** Asks for the page above when the reader nears the top, or when the
* transcript is too short to fill the screen — an auto-walk that stops
* the moment the record runs out (`olderAvailable`) or a page lands. */
function requestOlder(): void {
if (props.loadingOlder || props.olderAvailable === false) return
const el = scroller.value
if (!el) return
const atTop = el.scrollTop < OLDER_THRESHOLD
const unfilled = el.scrollHeight - el.clientHeight < 8
if (!atTop && !unfilled) return
olderAnchor = { top: el.scrollTop, height: el.scrollHeight }
emit('older')
}
function onScroll(): void {
const el = scroller.value
if (!el) return
pinned.value = el.scrollHeight - el.scrollTop - el.clientHeight < 48
// Scrolling back into the history asks for the next page up; the
// anchor captured here is what keeps the reader's place when it
// lands.
if (el.scrollTop < OLDER_THRESHOLD) requestOlder()
}
/** A page landed above the viewport: hold the reader's place. */
watch(
() => props.prependCount,
async () => {
const el = scroller.value
const anchor = olderAnchor
olderAnchor = null
if (!el || !anchor) return
await nextTick()
el.scrollTop = anchor.top + (el.scrollHeight - anchor.height)
},
)
/** A transcript shorter than the screen has nothing to scroll, so no
* scroll event will ever ask for the page above: ask once the first
* turn has rendered, and keep asking as pages land until the screen
* fills or the record ends. */
watch(
() => [props.items.length, props.loadingOlder] as const,
async () => {
await nextTick()
const el = scroller.value
if (!el || props.items.length === 0) return
if (el.scrollHeight - el.clientHeight < 8) requestOlder()
},
)
// Thinking grows inside the trailing entry without changing the
// entries length, so its total length joins the watch to keep the
// stream pinned at the bottom.
@@ -371,6 +433,8 @@ function streamTone(status: StreamStatus): 'ok' | 'warn' | 'error' {
nothing streamed yet; messages post to the claw and replies arrive here live
</p>
<p v-if="loadingOlder" class="older-hint">loading earlier turns</p>
<template v-for="item in items" :key="item.id">
<div v-if="item.kind === 'sent'" class="msg">
<div class="avatar user" aria-hidden="true">·</div>
@@ -618,6 +682,14 @@ function streamTone(status: StreamStatus): 'ok' | 'warn' | 'error' {
padding: 24px;
}
.older-hint {
color: var(--text3);
font-family: var(--mono, monospace);
font-size: 12px;
margin: 0 0 16px;
text-align: center;
}
.msg {
display: flex;
gap: 12px;
+111 -22
View File
@@ -303,8 +303,16 @@ export async function clawHealth(): Promise<boolean> {
/** Handlers for a turn-event stream. */
export interface StreamHandlers {
/** One turn event, in stream order. */
onEvent: (event: TurnEvent) => void
/** One turn event, in stream order, with the record's seq for it — the
* cursor a reconnect names. Null only for a frame whose `id:` was
* missing or unparseable. */
onEvent: (event: TurnEvent, seq: number | null) => void
/**
* The cursor named events the record no longer holds (a wiped or
* reset daemon). The mirror must go: the tail that follows this
* notice rebuilds it.
*/
onReset?: () => void
/** Connection lifecycle; `reconnecting` follows a drop, with backoff. */
onStatus?: (status: StreamStatus) => void
/**
@@ -315,6 +323,15 @@ export interface StreamHandlers {
onNotice?: (notice: string) => void
}
/** One page of a session's older turns, as `GET /v1/sessions/{key}/
* history` answers it: whole turns, oldest first, each event with the
* seq the record gave it. `more` is false once the page reached the
* session's first event. */
export interface HistoryPage {
events: { seq: number; event: TurnEvent }[]
more: boolean
}
export interface ClawApi {
/** `GET /v1/sessions` — every dispatch key with its status. */
sessions(): Promise<ClawSession[]>
@@ -373,12 +390,33 @@ export interface ClawApi {
/**
* Stream one session's turns. Resolves with a handle once the fetch
* is started; the claw keeps the stream open forever, and a drop is
* retried with backoff until `close()` is called. Every (re)connection
* opens with the session's recorded turns replayed first, then the
* live feed — so a consumer resets its mirror on reconnect and lets
* the stream rebuild it.
* retried with backoff until `close()` is called.
*
* Every frame names the record's seq, so a reconnect resumes from the
* last one delivered and replays only the gap. A first open with no
* `since` names nothing, and the claw sends the newest turn plus the
* live feed; older turns come from `history` as the reader scrolls
* up. A `reset` comment means the cursor named events the record no
* longer holds: the mirror is dropped and the tail that follows
* rebuilds it.
*/
subscribeEvents(key: string, handlers: StreamHandlers): { close: () => void }
subscribeEvents(
key: string,
handlers: StreamHandlers,
options?: StreamOptions,
): { close: () => void }
/**
* `GET /v1/sessions/{key}/history` — one page of whole turns ending
* at `before` (absent: the newest). Pages stack: the next call passes
* the seq of the oldest event held, and stops when `more` is false.
*/
history(key: string, before?: number | null, limit?: number): Promise<HistoryPage>
}
/** How `subscribeEvents` opens: `since` is the seq of the last event the
* caller already holds, or null for "nothing, give me the tail". */
export interface StreamOptions {
since?: number | null
}
/** Build the client bound to one bearer token (null = unguarded claw). */
@@ -443,6 +481,29 @@ export function useClaw(token: Ref<string | null>): ClawApi {
})
}
async function history(
key: string,
before?: number | null,
limit?: number,
): Promise<HistoryPage> {
const params = new URLSearchParams()
if (before !== undefined && before !== null) params.set('before', String(before))
if (limit !== undefined) params.set('limit', String(limit))
const query = params.size ? `?${params}` : ''
const body = (await get(
`/v1/sessions/${encodeURIComponent(key)}/history${query}`,
token.value,
)) as { events?: { seq?: unknown; event?: unknown }[]; more?: unknown }
// The wire shape is trusted only as far as it parses: an event the
// parser does not know is dropped, and its seq with it — the page
// still renders what it could read.
const events = (body.events ?? []).flatMap((entry) => {
const event = parseTurnEvent(JSON.stringify(entry.event))
return event && typeof entry.seq === 'number' ? [{ seq: entry.seq, event }] : []
})
return { events, more: body.more === true }
}
async function providers(): Promise<ProvidersState> {
return (await get('/v1/providers', token.value)) as ProvidersState
}
@@ -536,9 +597,17 @@ export function useClaw(token: Ref<string | null>): ClawApi {
return url
}
function subscribeEvents(key: string, handlers: StreamHandlers): { close: () => void } {
function subscribeEvents(
key: string,
handlers: StreamHandlers,
options?: StreamOptions,
): { close: () => void } {
let closed = false
let attempt = 0
/** The seq of the last event this stream delivered: what a retry
* names as `since`, so a reconnect replays the gap and nothing else.
* Null until a frame arrives, or after a reset drops the mirror. */
let cursor: number | null = options?.since ?? null
/** The attempt in flight; `close` aborts it mid-read. */
let current: AbortController | null = null
/** Set when the watchdog, not the server, cut the attempt — the
@@ -571,8 +640,9 @@ export function useClaw(token: Ref<string | null>): ClawApi {
timedOut = false
rearmSilence()
try {
const resume = cursor === null ? '' : `?since=${cursor}`
const response = await fetch(
`${base()}/v1/sessions/${encodeURIComponent(key)}/events`,
`${base()}/v1/sessions/${encodeURIComponent(key)}/events${resume}`,
{
headers: {
Accept: 'text/event-stream',
@@ -588,16 +658,31 @@ export function useClaw(token: Ref<string | null>): ClawApi {
status('open')
await readFrames(
response.body,
(data) => {
(data, id) => {
rearmSilence()
const event = parseTurnEvent(data)
if (event) handlers.onEvent(event)
if (!event) return
const seq = id !== null && /^\d+$/.test(id) ? Number(id) : null
if (seq !== null) cursor = seq
handlers.onEvent(event, seq)
},
(comment) => {
// Keep-alives come through here too; each one proves the
// path alive, and only the hub's dropped-events notice is
// worth raising.
// path alive.
rearmSilence()
// The record lost what this mirror holds: drop the cursor
// so the next open asks for the tail, and let the consumer
// drop its items before the tail that already followed
// this notice folds in.
if (comment.startsWith('reset')) {
cursor = null
handlers.onReset?.()
return
}
// A skipped run of events is not a hole in the cursor:
// the next frame's id names the seq after it, and a drop
// before then reopens at the old cursor and replays what
// was skipped.
if (comment.startsWith('lagged')) {
handlers.onNotice?.(`stream lagged — ${comment.replace(/^lagged;\s*/, '')}`)
}
@@ -640,6 +725,7 @@ export function useClaw(token: Ref<string | null>): ClawApi {
resolveConsent,
denyConsent,
sendMessage,
history,
providers,
setActive,
models,
@@ -659,13 +745,13 @@ export function useClaw(token: Ref<string | null>): ClawApi {
}
/** One parsed SSE frame: the joined `data:` payload, comments excluded. */
type FrameHandler = (data: string) => void
type FrameHandler = (data: string, id: string | null) => void
/**
* Reads an SSE body into frames. Frames split on a blank line; `data:`
* lines carry payload (joined with newlines) and `:`-prefixed lines are
* comments (the claw's keep-alive and its lag notice). `event:`/`id:`
* lines do not occur on this surface.
* lines carry payload (joined with newlines), `id:` names the event's
* seq, and `:`-prefixed lines are comments (the claw's keep-alive, its
* lag notice, and its reset). `event:` lines do not occur here.
*/
async function readFrames(
body: ReadableStream<Uint8Array>,
@@ -676,17 +762,20 @@ async function readFrames(
const decoder = new TextDecoder()
let buffer = ''
const step = (frame: string) => {
const data = frame
.split('\n')
const lines = frame.split('\n')
const data = lines
.filter((line) => line.startsWith('data:'))
.map((line) => line.replace(/^data: ?/, ''))
.join('\n')
if (data) {
onFrame(data)
const id = lines
.find((line) => line.startsWith('id:'))
?.slice(3)
.trim()
onFrame(data, id ?? null)
return
}
const comment = frame
.split('\n')
const comment = lines
.filter((line) => line.startsWith(':'))
.map((line) => line.slice(1).trimStart())
.join('\n')
+69 -8
View File
@@ -173,6 +173,19 @@ export function useTranscript() {
const live = ref<TurnBlock | null>(null)
let nextId = 1
/** The record's seq for the oldest event on the mirror, when one is
* known — the cursor an older page ends at. Null until an event with
* a seq lands, and again after a reset. */
const firstSeq = ref<number | null>(null)
/** True while an older page is being fetched and folded in, so a
* scroll that keeps firing at the top does not stack the same page
* twice. */
const loadingOlder = ref(false)
/** Bumped by every page folded in above the mirror. The panel watches
* it to hold the reader's place: the content above the viewport grew,
* so the scroll position moves down by exactly that much. */
const prependCount = ref(0)
/** Locally sent messages the stream has not echoed yet: `addSent`
* pushes here, the `user_message` handler pops the match. Text plus
* image count is the whole signature the stream carries, and that is
@@ -197,8 +210,17 @@ export function useTranscript() {
return { kind: 'attachment', attachment: { ...entry.attachment } }
}
/** Folds one stream event into the mirror. */
function applyEvent(event: TurnEvent): void {
/** True while a page of older turns is folding in: the pending-echo
* match is for live echoes, and a replayed question from weeks ago
* that happens to read the same as one on screen is its own row. */
let replaying = false
/** Folds one stream event into the mirror; `seq` is the record's
* number for it, which puts it at the mirror's top when nothing else
* is there yet. The stream keeps its own resume cursor; this one is
* the walk-back anchor. */
function applyEvent(event: TurnEvent, seq: number | null = null): void {
if (seq !== null && firstSeq.value === null) firstSeq.value = seq
switch (event.type) {
case 'user_message': {
// The replayed question. A live echo of a message this surface
@@ -207,8 +229,11 @@ export function useTranscript() {
// previews and all. Anything else (a replay, another surface's
// message, a steered echo after other events) renders as its
// own row.
const mine = pendingSent.findIndex(
(pending) => pending.text === event.text && pending.images === event.images.length,
const mine = replaying
? -1
: pendingSent.findIndex(
(pending) =>
pending.text === event.text && pending.images === event.images.length,
)
if (mine !== -1) {
pendingSent.splice(mine, 1)
@@ -419,20 +444,56 @@ export function useTranscript() {
return items.value.some((item) => item.kind === 'attachment' && item.attachment.id === id)
}
/** Session switched, or the stream reopened: the mirror is per-key,
* and the replay that follows rebuilds it either way. The pending
* echoes go too — a replay carries every message, optimistic rows
* included. */
/** Reads one page of older turns (whole turns, oldest first) and
* stacks it above what the mirror holds.
*
* The page folds from empty with the same reducer the stream uses, so
* a replayed consent or attachment card folds exactly as it would
* have live, and then the two lists join: no page is ever half
* applied. A page can end mid-turn if the record's first turn is
* longer than a page; that open tail is dropped, and the next page
* (which walks back from this one's first seq) brings it again with
* the turn's beginning above it. */
function prependOlder(events: { seq: number; event: TurnEvent }[]): void {
const page = events[0]
if (!page) return
const current = items.value
const currentLive = live.value
replaying = true
items.value = []
live.value = null
try {
for (const { event } of events) applyEvent(event)
} finally {
replaying = false
}
items.value = [...items.value, ...current]
live.value = currentLive
// The page sits above everything the mirror held, so it owns the
// first cursor.
firstSeq.value = page.seq
prependCount.value += 1
}
/** Session switched, or the record reset under the mirror: everything
* the mirror holds goes, cursors included. The replay that follows
* rebuilds it. The pending echoes go too — the tail carries every
* message, optimistic rows included. */
function reset(): void {
items.value = []
live.value = null
pendingSent.length = 0
firstSeq.value = null
}
return {
items,
live,
firstSeq,
loadingOlder,
prependCount,
applyEvent,
prependOlder,
addSent,
removeSent,
resolveConsent,
+6 -2
View File
@@ -14,7 +14,11 @@ import { proxyRequest } from 'h3'
export default defineEventHandler((event) => {
if (!import.meta.dev) return
const { pathname } = getRequestURL(event)
// The query string rides along: `since` on the event stream and
// `before`/`limit` on the history pages are the whole protocol, and a
// proxy that dropped them would quietly serve the tail to every
// request.
const { pathname, search } = getRequestURL(event)
if (pathname !== '/healthz' && !pathname.startsWith('/v1/')) return
return proxyRequest(event, useRuntimeConfig(event).clawUpstream + pathname)
return proxyRequest(event, useRuntimeConfig(event).clawUpstream + pathname + search)
})