diff --git a/web/claw/README.md b/web/claw/README.md
index 0988bb0..5afacfd 100644
--- a/web/claw/README.md
+++ b/web/claw/README.md
@@ -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=` 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,
diff --git a/web/claw/app/app.vue b/web/claw/app/app.vue
index f12c6b4..cef0b93 100644
--- a/web/claw/app/app.vue
+++ b/web/claw/app/app.vue
@@ -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 {
+ 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 | 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"
diff --git a/web/claw/app/components/ChatPanel.vue b/web/claw/app/components/ChatPanel.vue
index 025883d..924ff0b 100644
--- a/web/claw/app/components/ChatPanel.vue
+++ b/web/claw/app/components/ChatPanel.vue
@@ -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(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
+ loading earlier turns
+
·
@@ -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;
diff --git a/web/claw/app/composables/useClaw.ts b/web/claw/app/composables/useClaw.ts
index 0db419c..d1ddad9 100644
--- a/web/claw/app/composables/useClaw.ts
+++ b/web/claw/app/composables/useClaw.ts
@@ -303,8 +303,16 @@ export async function clawHealth(): Promise
{
/** 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
@@ -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
+}
+
+/** 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): ClawApi {
})
}
+ async function history(
+ key: string,
+ before?: number | null,
+ limit?: number,
+ ): Promise {
+ 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 {
return (await get('/v1/providers', token.value)) as ProvidersState
}
@@ -536,9 +597,17 @@ export function useClaw(token: Ref): 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): 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): 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): ClawApi {
resolveConsent,
denyConsent,
sendMessage,
+ history,
providers,
setActive,
models,
@@ -659,13 +745,13 @@ export function useClaw(token: Ref): 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,
@@ -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')
diff --git a/web/claw/app/composables/useTranscript.ts b/web/claw/app/composables/useTranscript.ts
index 987c456..0e5b47f 100644
--- a/web/claw/app/composables/useTranscript.ts
+++ b/web/claw/app/composables/useTranscript.ts
@@ -173,6 +173,19 @@ export function useTranscript() {
const live = ref(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(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,9 +229,12 @@ 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)
return
@@ -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,
diff --git a/web/claw/server/middleware/claw-proxy.ts b/web/claw/server/middleware/claw-proxy.ts
index e2465c5..cd9a7a1 100644
--- a/web/claw/server/middleware/claw-proxy.ts
+++ b/web/claw/server/middleware/claw-proxy.ts
@@ -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)
})