From ed79c0cf1bcc73fb90de4a88012d7bedc7325503 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Sun, 8 Nov 2026 00:21:02 +0100 Subject: [PATCH] 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= 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. --- web/claw/README.md | 33 ++++-- web/claw/app/app.vue | 61 ++++++++-- web/claw/app/components/ChatPanel.vue | 72 ++++++++++++ web/claw/app/composables/useClaw.ts | 133 ++++++++++++++++++---- web/claw/app/composables/useTranscript.ts | 79 +++++++++++-- web/claw/server/middleware/claw-proxy.ts | 8 +- 6 files changed, 337 insertions(+), 49 deletions(-) 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

+