Keep visited transcripts on the phone

Reopening a session downloaded the conversation again, every time, over
the tunnel. It now draws from a copy of what the server has already sent
and asks for one event to check that copy is still current.

Per session, under cacheDir, the server's own event lines in chunks named
for the range they cover -- so a coalesced page, whose lines do not say
what they cover, still records it. Only the contiguous run ending at the
newest chunk is served; a gap is closed by paging through it, bounded by
`after` on /transcript so the page stops where the phone's copy starts
and can therefore be kept. Nothing is derived and stored: rows are a
rendering, and a cache of them would need throwing away on every change
to the fold.

Nothing here is load-bearing. Missing, evicted, damaged or unwritable all
degrade to the cold open this screen did before, and the check before the
stream resumes -- one request, one event -- is what stops a replaced or
truncated file being spliced onto a copy of a different conversation.
What that check cannot see, a line changed mid-file with the tail intact,
is what Reload in session settings is for.

Measured on the emulator against ui-sandbox, on a 505-event session:
reopening it costs one request for one event, including scrolling the
whole conversation back; a cold open is two requests and 100 events. A
reset after falling 300 behind fetched the gap as four coalesced rows
rather than re-fetching 104 events and discarding them. Every chunk was
checked line by line against what the server says for the range its name
claims, across the reset and the gap-fill.

transcript-bench.sh, same viewport content and gestures, before and
after: p50 16.9ms both, p90 25.6 -> 23.2ms, p99 33.5 -> 36.7ms, and the
transcript's own draw accounting 0.33ms -> 0.32ms with place 0.31ms
either way. Within the emulator's noise, which is what a cache must be:
it changes what is fetched, not what is drawn.

Building it also found that the server handed out the same transcript
line two different ways. serde_json's default float parser is not
correctly rounded, so a ts written as ...0757 came back from /transcript
as ...0755 while the SSE stream sent the original -- invisible on screen,
since a ts is drawn as a relative time, and visible here only because the
cache compares a line it holds against the server's answer. Fixed with
float_roundtrip, with a test that fails the moment it is dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-04 15:00:25 -04:00
1 parent 8881a40919
commit a802522039
17 files changed
+2140 -74

No files matched your search

+48
View File
@@ -829,6 +829,54 @@ machine belongs in `~/.claude/TOOLCHAIN.md` (toolchain versions) or
every page. Both are off it now. The shape to watch for is a
`withContext` that wraps the *fetch* and leaves the work done with the
result outside it.
- **The phone keeps the transcripts it has been sent, and the design is
`TRANSCRIPT_CACHE.md`** -- read that before touching `TranscriptCache.kt`,
`TranscriptSource.kt`, or the opening and stream effects in
`SessionScreen.kt`. What the day-to-day work needs to know:
`<cacheDir>/transcripts/v1/<host>_<port>/<sessionId>/` holds the server's
own event lines in chunks named for the range they cover
(`<first>-<end>.rows.jsonl`, `.raw.jsonl`, and one `<first>-open.raw.jsonl`
the live stream appends to), and only the contiguous run ending at the
newest chunk is ever served. Measured on the emulator 2026-09-04 against
the sandbox: reopening a 500-event session costs **one request for one
event** -- the probe -- and scrolling the whole conversation back costs
nothing more. A cold open of the same session is two pages, 100 events.
Four things are easy to undo by accident.
**The probe is not optional**: before a stream is resumed from a cached
cursor, `GET /transcript?before=<cursor+1>&limit=1` has to come back as the
line the cache holds, or the cache is thrown away and the open is cold. It
is what stops a replaced or truncated file being spliced onto this phone's
copy of a different conversation, with no seam to see.
**A page fetched for the gap passes `after`** (the transcript route's own
parameter, added for this), so it stops where the phone's copy starts. A
page that overlaps a chunk cannot be stored -- a coalesced event has no
clean cut inside its delta run -- so without the bound the first scroll
back after a reset throws away everything behind it.
**Nothing here is load-bearing.** Every read has a network path beside it
giving the same answer, and a missing, evicted, damaged or unwritable cache
degrades to a cold open. Keep it that way: a cache that can blank the
screen is worse than no cache.
**Reload, in session settings, is the answer to what the probe cannot
see** -- a line changed in the middle of the file with the tail intact.
It purges and rebuilds the screen as a cold open, putting the reader back
where they were.
Exercise all of it with `./ui-sandbox.sh` and
`RUST_LOG=ai_server=debug`, which logs every page with its `before`,
`after` and what came back; `adb shell run-as com.example.aiapp ls
cache/transcripts/v1/*/<id>` shows whether the chunk names are adjacent,
which is the one thing the screen cannot tell you.
- **The server used to hand out the same transcript line two different
ways.** `serde_json`'s default float parser is not correctly rounded, so a
`ts` of `1788546972.6030757` in the transcript came back from
`/transcript` as `...0755` while the SSE stream, serializing the same
struct, sent the original. Nothing on screen could show it -- a `ts` is
drawn as a relative time -- and what found it was the phone's cache
comparing a line it held against the server's own answer, which turned an
invisible last-bit difference into a cache silently thrown away and a
transcript downloaded again. The `float_roundtrip` feature in
`server/Cargo.toml` is the fix and
`a_line_read_back_is_the_line_that_was_written` is what keeps it; that test
fails within a second of the feature being dropped.
- **ZXing only looks for a dark code on a light ground.** The enrollment
QR is block characters in the terminal's foreground colour, so a
dark-themed terminal renders it as a negative and the in-app scanner