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:
1 parent
744ba8b06c
commit
ed79c0cf1b
6 files changed
+337
-49
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user