Files
rhema-claw/web/claw/app/composables/useTranscript.ts
T
vhaudiquet ed79c0cf1b 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.
2026-11-08 18:01:30 +01:00

505 lines
19 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The client-side mirror of a claw session's turns. The daemon owns the
// conversation — this composable only accumulates what the event stream
// delivers, and the stream now opens with the session's recorded turns
// replayed before the live feed. So a reload (and every reconnect)
// rebuilds the transcript from the replay alone: the consumer resets the
// mirror when the stream reopens and folds what arrives, never the other
// way around.
import { ref } from 'vue'
import type { AttachmentMedia, ConsentKind, OutboundImage, TurnEvent } from './useClaw'
/** One tool call inside a turn: started, then completed by its result. */
export interface ToolRow {
name: string
/** null until the result lands. */
isError: boolean | null
summary: string
/** The process exit code a command-like result carries. */
code: number | null
/** The command line a bash result carries. */
command: string | null
}
/** One stream-ordered thing inside a turn: a tool call, a piece of the
* thinking trace (adjacent deltas merged into one block), the agent's
* ask (a consent card), or a handover (an attachment). The turn renders
* its entries in stream order, so a card sits where the event that
* raised it happened — an ask appears at its tool call, not at the
* turn's top, and thinking sits between the calls it precedes. */
export type TurnEntry =
| { kind: 'tool'; row: ToolRow }
| { kind: 'thinking'; text: string }
| { kind: 'consent'; consent: ConsentCard }
| { kind: 'attachment'; attachment: AttachmentCard }
/** One turn as the UI renders it: the stream-ordered entries and the
* streamed reply. */
export interface TurnBlock {
reply: string
entries: TurnEntry[]
/** Set by `turn_done`: `handled`, `failed`, or whatever the claw reports. */
outcome: string | null
}
/** One thing the agent asked its human, as the card renders it. */
export interface ConsentCard {
id: string
kind: ConsentKind
summary: string
detail: string
/** null while the human has not answered. */
granted: boolean | null
}
/** One file the agent handed over, as the card renders it. The bytes
* arrive as an object URL on request — the route is bearer-guarded. */
export interface AttachmentCard {
id: string
name: string
media: AttachmentMedia
size: number
caption: string | null
/** The object URL once fetched; null until then. */
url: string | null
}
/** One image a user message carried, as the transcript renders it. A
* locally sent row holds the browser's own object URL — no fetch, no
* id; a replayed row holds the attachment id the claw stored and gets
* its bytes bearer-guarded, like an attachment card. */
export interface SentImage {
/** The attachment id behind a replayed row; null for a local one. */
id: string | null
media_type: string
/** The object URL once resolved: local at stage time, fetched for a replay. */
url: string | null
/** The file's name when the browser knew one; a replay has only an id. */
name: string | null
}
/** One image staged in the composer, as the send path hands it up: the
* wire payload plus the local bits the optimistic row needs and the
* claw never sees. */
export interface StagedImage extends OutboundImage {
/** The local preview URL the optimistic row renders. */
url: string
/** The file's name, as the chip and the sent row label it. */
name: string
}
/** A transcript line: a message the local user sent, or one streamed turn. */
export type TranscriptItem =
| { kind: 'sent'; id: number; text: string; images: SentImage[] }
| { kind: 'turn'; id: number; turn: TurnBlock }
| { kind: 'consent'; id: number; consent: ConsentCard }
| { kind: 'attachment'; id: number; attachment: AttachmentCard }
// --- embedded pastes --------------------------------------------------
// The wire form a long paste travels in: MARK, payload, MARK — one
// logical unit inside otherwise plain text (rhema-core's paste module
// is the definition; these are its client-side shape).
/** The object-replacement character: opens and closes one embedded
* paste. Two marks always bracket a payload; a lone mark is literal. */
export const PASTE_MARK = ''
/** A paste at or under this many lines joins the input inline; longer
* ones become chips (the TUI's PASTE_INLINE_LINES). */
export const PASTE_INLINE_LINES = 3
/** Text that travels beside or inside marks: carriage returns to
* newlines, stray marks dropped — the scan must stay unambiguous
* (paste::normalize). */
export function normalizePaste(text: string): string {
return text.replaceAll('\r\n', '\n').replaceAll('\r', '\n').replaceAll(PASTE_MARK, '')
}
/** Rust `str::lines`: \n-split, and a trailing newline ends the last
* line instead of opening an empty one. */
export function lineCount(text: string): number {
if (text === '') return 0
const lines = text.split('\n')
if (lines[lines.length - 1] === '') lines.pop()
return lines.length
}
/** A chip's label: `▣1 5L` — the anchor's one-based id and the
* payload's line count (the TUI's chip_label). */
export function chipLabel(paste: string, index: number): string {
return `▣${index + 1} ${lineCount(paste)}L`
}
/** Appends each paste as one MARK-payload-MARK embed, in order — the
* session's record of the prompt (paste::embed; a web draft holds no
* anchors, so every payload lands at the end). */
export function embedPastes(draft: string, pastes: string[]): string {
return (
draft +
pastes.map((paste) => `${PASTE_MARK}${normalizePaste(paste)}${PASTE_MARK}`).join('')
)
}
/** One scanned piece of embedded-form text. */
export type PastePart = { kind: 'text'; text: string } | { kind: 'paste'; text: string }
/** Splits embedded-form text into its parts, left to right. A mark
* with no partner is literal text; malformed input never throws
* (paste::parts). */
export function pasteParts(text: string): PastePart[] {
const parts: PastePart[] = []
let rest = text
for (;;) {
const start = rest.indexOf(PASTE_MARK)
if (start === -1) break
const after = rest.slice(start + PASTE_MARK.length)
const end = after.indexOf(PASTE_MARK)
if (end === -1) {
parts.push({ kind: 'text', text: rest.slice(0, start + PASTE_MARK.length) })
rest = after
continue
}
if (start > 0) parts.push({ kind: 'text', text: rest.slice(0, start) })
parts.push({ kind: 'paste', text: after.slice(0, end) })
rest = after.slice(end + PASTE_MARK.length)
}
if (rest !== '') parts.push({ kind: 'text', text: rest })
return parts
}
export function useTranscript() {
const items = ref<TranscriptItem[]>([])
// A deep ref on purpose: deltas mutate fields in place (reply
// appends, tool rows completing) and each must re-render.
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
* enough — the echo dedupes against what this surface sent, not
* against whichever row happens to sit last: a steered turn's end
* marker can land between the optimistic row and its echo. `itemId`
* links an entry to its row, so a send that failed to post can take
* both the row and its pending echo back out. */
const pendingSent: { text: string; images: number; itemId: number }[] = []
function ensureLive(): TurnBlock {
if (!live.value) live.value = { reply: '', entries: [], outcome: null }
return live.value
}
/** A detached copy of one entry: the record of a finished turn must
* not alias the live slot's objects. */
function cloneEntry(entry: TurnEntry): TurnEntry {
if (entry.kind === 'tool') return { kind: 'tool', row: { ...entry.row } }
if (entry.kind === 'thinking') return { kind: 'thinking', text: entry.text }
if (entry.kind === 'consent') return { kind: 'consent', consent: { ...entry.consent } }
return { kind: 'attachment', attachment: { ...entry.attachment } }
}
/** 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
// just sent arrives after the optimistic `sent` row: the match
// pops the pending entry and the row already stands in for it,
// previews and all. Anything else (a replay, another surface's
// message, a steered echo after other events) renders as its
// own row.
const mine = replaying
? -1
: pendingSent.findIndex(
(pending) =>
pending.text === event.text && pending.images === event.images.length,
)
if (mine !== -1) {
pendingSent.splice(mine, 1)
return
}
items.value.push({
kind: 'sent',
id: nextId++,
text: event.text,
images: event.images.map((image) => ({ ...image, url: null, name: null })),
})
break
}
case 'thinking_delta': {
// Thinking joins the entries at its stream position: a delta
// extends the trailing block, a gap (a tool call, a reply)
// opens the next one.
const entries = ensureLive().entries
const last = entries[entries.length - 1]
if (last?.kind === 'thinking') last.text += event.text
else entries.push({ kind: 'thinking', text: event.text })
break
}
case 'text_delta':
ensureLive().reply += event.text
break
case 'tool_start':
ensureLive().entries.push({
kind: 'tool',
row: { name: event.name, isError: null, summary: '', code: null, command: null },
})
break
case 'tool_result': {
const entries = ensureLive().entries
// Results pair with the most recent still-running call of the
// same name; a result with no start still shows, never drops.
const pending = [...entries]
.reverse()
.find((entry) => entry.kind === 'tool' && entry.row.name === event.name && entry.row.isError === null)
if (pending && pending.kind === 'tool') {
pending.row.isError = event.is_error
pending.row.summary = event.summary
pending.row.code = event.code
pending.row.command = event.command
} else {
entries.push({
kind: 'tool',
row: {
name: event.name,
isError: event.is_error,
summary: event.summary,
code: event.code,
command: event.command,
},
})
}
break
}
case 'consent_request':
// One card per consent id, wherever the turn stands: mid-turn
// the card joins the entries at its tool call; a card with no
// turn around it (a defensive shape — asks fire mid-turn)
// falls back to a top-level row.
if (!findConsent(event.id)) {
const card: ConsentCard = {
id: event.id,
kind: event.kind,
summary: event.summary,
detail: event.detail,
granted: null,
}
if (live.value) {
live.value.entries.push({ kind: 'consent', consent: card })
} else {
items.value.push({ kind: 'consent', id: nextId++, consent: card })
}
}
break
case 'consent_resolved':
resolveConsent(event.id, event.granted)
break
case 'attachment':
// One card per attachment id; the same dual placement as an
// ask — mid-turn it rides the entries.
if (!findAttachment(event.id)) {
const card: AttachmentCard = {
id: event.id,
name: event.name,
media: event.media,
size: event.size,
caption: event.caption,
url: null,
}
if (live.value) {
live.value.entries.push({ kind: 'attachment', attachment: card })
} else {
items.value.push({ kind: 'attachment', id: nextId++, attachment: card })
}
}
break
case 'turn_done': {
const turn = ensureLive()
turn.outcome = event.outcome
// The clean end marker wins for the reply: deltas may have been
// dropped (best-effort stream), turn_done carries the final text.
if (event.reply !== null) turn.reply = event.reply
// A detached copy: the live slot resets and the record is final.
items.value.push({
kind: 'turn',
id: nextId++,
turn: { ...turn, entries: turn.entries.map(cloneEntry) },
})
live.value = null
break
}
}
}
/** A locally sent message, shown immediately; the reply streams after.
* Its images render from the staged previews — the claw stores them
* and the stream's echo dedupes against this row. Returns the row's
* id, so a failed POST can retract it. */
function addSent(text: string, images: SentImage[] = []): number {
const id = nextId++
pendingSent.push({ text, images: images.length, itemId: id })
items.value.push({ kind: 'sent', id, text, images })
return id
}
/** Retracts a locally sent row whose POST failed: the message never
* reached the claw, so neither the reply nor the echo will ever come.
* (If it did reach the claw and only the answer was lost, the
* pending echo goes with the row — the stream's `user_message` then
* renders a fresh one, with the stored attachment ids.) */
function removeSent(id: number): void {
const index = items.value.findIndex((item) => item.kind === 'sent' && item.id === id)
if (index !== -1) items.value.splice(index, 1)
const pending = pendingSent.findIndex((entry) => entry.itemId === id)
if (pending !== -1) pendingSent.splice(pending, 1)
}
/** A consent card resolved: flipped in place, or ignored when no
* card carries the id. The in-flight turn's entries hold the cards a
* running turn asked for; finished turns keep their own copies. */
function resolveConsent(id: string, granted: boolean): void {
if (live.value) {
for (const entry of live.value.entries) {
if (entry.kind === 'consent' && entry.consent.id === id) {
entry.consent.granted = granted
return
}
}
}
for (const item of items.value) {
if (item.kind === 'consent' && item.consent.id === id) {
item.consent.granted = granted
return
}
}
}
/** An attachment's object URL landed; stored on its card. The useClaw
* cache keeps a reconnect's replay from refetching the bytes. */
function setAttachmentUrl(id: string, url: string): void {
if (live.value) {
for (const entry of live.value.entries) {
if (entry.kind === 'attachment' && entry.attachment.id === id) {
entry.attachment.url = url
return
}
}
}
for (const item of items.value) {
if (item.kind === 'attachment' && item.attachment.id === id) {
item.attachment.url = url
return
}
}
}
/** A replayed sent image's object URL landed; stored on its row. The
* useClaw cache keeps a reconnect's replay from refetching the bytes.
* A user message is always a top-level row — it never rides a live
* turn — so the scan stays over `items`. */
function setSentImageUrl(id: string, url: string): void {
for (const item of items.value) {
if (item.kind !== 'sent') continue
const image = item.images.find((candidate) => candidate.id === id)
if (image) {
image.url = url
return
}
}
}
/** Whether a card for this consent id is already on the mirror — a
* replayed request never stacks a second card beside the first. */
function findConsent(id: string): boolean {
if (live.value?.entries.some((e) => e.kind === 'consent' && e.consent.id === id)) return true
return items.value.some((item) => item.kind === 'consent' && item.consent.id === id)
}
/** Whether a card for this attachment id is already on the mirror. */
function findAttachment(id: string): boolean {
if (live.value?.entries.some((e) => e.kind === 'attachment' && e.attachment.id === id)) {
return true
}
return items.value.some((item) => item.kind === 'attachment' && item.attachment.id === id)
}
/** 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,
setAttachmentUrl,
setSentImageUrl,
reset,
}
}