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

+