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

+70 -9
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,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,