Page history by rows, coalescing a reply's deltas server-side
The scroll-up freeze-then-skip was the history pager fighting the transcript's own storage. A streamed reply is stored one token per event -- hundreds of AssistantText events for one message -- but a page was counted in events, so on a delta-heavy conversation a page was a fraction of one row: the opening 80-event load was less than a screen, "scroll up a bit" hit the unloaded boundary at once, and each page the client did fetch cost a 400-event fold (hundreds of thousands of list copies) that landed as one jarring insertion. The server now joins each run of consecutive AssistantText deltas into the one event the client's fold makes of it, and counts a page's limit in these coalesced rows -- so a page is a page of the screen whatever the delta density. Measured against a 3,500-event / 100-row echo session on the emulator: a raw limit-20 page returns 20 tokens of one reply; the coalesced limit-20 returns five whole replies. Scrolling the whole thing showed waited p99 51.7ms -> 0.6ms and the worst whole-transcript measure 59ms -> ~0, with the client folding ~100 row-events instead of 3,500 token-events. No duplicate keys; the first reply still reconstructs whole from token zero, so healSplitMessage welds the raw newest window to the coalesced older pages exactly as before. Coalescing is opt-in per request (`?coalesce=true`) and applied only to older pages (`before` set): the newest window keeps real seqs because the live stream resumes from the newest seq the phone applied, and a coalesced newest event would hide the deltas after its first seq and replay them. The anchor-restore path also stays raw -- it counts events to reach a known seq, which a page measured in rows cannot do -- so HISTORY_PAGE is now rows while the restore span and its cushion stay in events. Also: ui-sandbox.sh gains a `keep` verb that restarts the server without wiping sessions, so a fixture that costs minutes to build (a long delta-heavy transcript) survives a server rebuild. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
356dee65f1
commit
63c0bb9e4a
6 files changed
+220
-29
No files matched your search
@@ -744,10 +744,17 @@ fun fetchTranscript(
|
||||
sessionId: String,
|
||||
before: Long? = null,
|
||||
limit: Int = 80,
|
||||
// Count [limit] in rows, not events, joining a reply's streamed deltas into one -- so a page
|
||||
// of a delta-heavy conversation is a page of the screen rather than a fraction of one message.
|
||||
// The scroll-back pager wants this; the anchor restore does not (it counts events to a known
|
||||
// seq). Ignored by the server for the newest window, where the live cursor needs real seqs.
|
||||
// See the server's `read_window`.
|
||||
coalesce: Boolean = false,
|
||||
): List<SeqEvent> {
|
||||
val query = buildString {
|
||||
append("?limit=").append(limit)
|
||||
if (before != null) append("&before=").append(before)
|
||||
if (coalesce) append("&coalesce=true")
|
||||
}
|
||||
return requestFromServer(settings, "/sessions/$sessionId/transcript$query") { connection ->
|
||||
val body = JSONArray(connection.inputStream.bufferedReader().readText())
|
||||
|
||||
@@ -114,26 +114,25 @@ private val LOADING_SPINNER = 48.dp
|
||||
private const val HISTORY_SCREENS = 6
|
||||
|
||||
/**
|
||||
* How many events a backwards page asks for, which is five times what the opening page takes.
|
||||
* How many *rows* a backwards page asks for.
|
||||
*
|
||||
* The floor is that an event is not a row, and the ratio is nothing like one to one. Measured on a
|
||||
* real transcript (2,426 events, 2026-08-30): the whole conversation is *seven* assistant messages,
|
||||
* and the median run of consecutive text deltas that fold into one of them is four hundred. A page
|
||||
* of eighty is therefore a fifth of a single row, and reaching a screenful of fresh rows took about
|
||||
* thirty sequential round trips inside one collect. Below this number a page can add no visible
|
||||
* room at all, and the fetch chain degenerates into those round trips again.
|
||||
* Rows, not events, because the two are nothing alike: a reply is stored a token at a time, and on
|
||||
* one real transcript (2,426 events, 2026-08-30) the whole conversation was seven assistant
|
||||
* messages, the median folding four hundred deltas into one row. A page counted in events was a
|
||||
* fifth of a single row, so reaching a screenful took dozens of sequential round trips and the
|
||||
* reader stood at the boundary through every one. The server now joins each delta run into the one
|
||||
* event the fold makes of it (`fetchTranscript(coalesce = true)`), so a page of rows is a page of
|
||||
* the screen whatever the delta density.
|
||||
*
|
||||
* At the floor rather than above it, because pages are fetched in the background before the reader
|
||||
* arrives -- the cushion decides how deep loading runs, and a page that was not enough is followed
|
||||
* by another without anybody waiting on either. What a *smaller* page buys is hiding: it crosses
|
||||
* the tunnel in half the time and lands in a smaller frame spike, so the case where the reader
|
||||
* outruns an in-flight fetch is rarer and cheaper. This was 800 when the reader was the one
|
||||
* standing at the boundary and each round trip had to be amortized as far as it would go.
|
||||
* A few screens' worth, so one page clears the cushion below and the reader reaches loaded content
|
||||
* without a fetch in the way. A page that still falls short is followed by another in the
|
||||
* background, nobody waiting on either.
|
||||
*
|
||||
* The opening page stays small: it is the one on the critical path of showing the screen at all,
|
||||
* and it only has to fill a viewport.
|
||||
* The opening page stays counted in events and small ([fetchTranscript]'s default): it is on the
|
||||
* critical path of showing the screen, only has to fill a viewport, and is the newest window, where
|
||||
* coalescing is unsafe for the live cursor anyway.
|
||||
*/
|
||||
private const val HISTORY_PAGE = 400
|
||||
private const val HISTORY_PAGE = 40
|
||||
|
||||
/**
|
||||
* The most events one request of a restore may ask for.
|
||||
@@ -149,6 +148,16 @@ private const val HISTORY_PAGE = 400
|
||||
*/
|
||||
private const val RESTORE_PAGE_MAX = 4000
|
||||
|
||||
/**
|
||||
* Events added past the anchor on a restore, so the anchor's row is never the oldest loaded one.
|
||||
*
|
||||
* The oldest loaded row is a half-row -- [joinPages] welds its other half on when the page behind
|
||||
* it arrives, and it grows -- so a restore that stopped exactly at the anchor would put the reader
|
||||
* a screen out once that growth landed. This is in events, like the rest of the restore span: that
|
||||
* path counts events to reach a known seq and does not coalesce.
|
||||
*/
|
||||
private const val RESTORE_PAGE_CUSHION = 400
|
||||
|
||||
/**
|
||||
* Which row was asked to hold its top edge, and how tall it was when it last measured.
|
||||
*
|
||||
@@ -523,7 +532,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
* Reads `items` rather than `rows`: this runs in a coroutine, and `rows` is the composition's
|
||||
* value, which does not change under a running one.
|
||||
*/
|
||||
suspend fun loadOlderPage(limit: Int = HISTORY_PAGE): Boolean {
|
||||
suspend fun loadOlderPage(limit: Int = HISTORY_PAGE, coalesce: Boolean = true): Boolean {
|
||||
// Nothing is loaded, so there is no "before" to ask about, and asking anyway is not a
|
||||
// harmless no-op: `before = 0` fetches the events before the first one, which is none,
|
||||
// and an empty page is how this function is told it has reached the start of the
|
||||
@@ -556,7 +565,14 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
// `items` read below happens back on the caller's thread, where the write does too.
|
||||
val page =
|
||||
withContext(Dispatchers.IO) {
|
||||
val older = fetchTranscript(settings, summary.id, before = oldestSeq, limit = limit)
|
||||
val older =
|
||||
fetchTranscript(
|
||||
settings,
|
||||
summary.id,
|
||||
before = oldestSeq,
|
||||
limit = limit,
|
||||
coalesce = coalesce,
|
||||
)
|
||||
if (older.isEmpty()) return@withContext null
|
||||
// Folded oldest-first into a list of their own, then put in front: `foldEvent`
|
||||
// merges streaming text into the item before it, so replaying an older page
|
||||
@@ -681,8 +697,17 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
// somebody had read a little way back into, and a spinner for all of them.
|
||||
// The bytes are the same either way, since every row between the anchor and
|
||||
// the newest end has to be there for the list to be able to count to it.
|
||||
val span = oldestSeq - anchor.seq + HISTORY_PAGE
|
||||
if (!loadOlderPage(span.coerceIn(1L, RESTORE_PAGE_MAX.toLong()).toInt())) break
|
||||
// Raw, not coalesced: this counts events back to a known seq, and a page
|
||||
// measured in rows cannot be counted to a seq. `RESTORE_PAGE_MAX` and the loop
|
||||
// bound it; see [loadOlderPage].
|
||||
val span = oldestSeq - anchor.seq + RESTORE_PAGE_CUSHION
|
||||
if (
|
||||
!loadOlderPage(
|
||||
span.coerceIn(1L, RESTORE_PAGE_MAX.toLong()).toInt(),
|
||||
coalesce = false,
|
||||
)
|
||||
)
|
||||
break
|
||||
}
|
||||
// Resolved to the row that *holds* the saved position rather than passed
|
||||
// straight through, because the two are not always the same seq: the events
|
||||
|
||||
+16
-2
@@ -127,8 +127,14 @@ print(json.dumps({"text": text}))' "$@" >"$ROOT/send.json"
|
||||
exit 0
|
||||
;;
|
||||
start) ;;
|
||||
# Restart the server but keep the sessions and enrolment already there, so a
|
||||
# fixture built over minutes (a long delta-heavy transcript, say) survives a
|
||||
# rebuild of the server binary. Plain `start` wipes them, which is right for
|
||||
# the list-screen fixtures but wrong when the session under test was expensive
|
||||
# to make.
|
||||
keep) KEEP=1 ;;
|
||||
*)
|
||||
echo "ui-sandbox.sh: unknown command '$1' (start, stop, api, spawn, send)" >&2
|
||||
echo "ui-sandbox.sh: unknown command '$1' (start, keep, stop, api, spawn, send)" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -152,9 +158,13 @@ if [ -f "$ROOT/config.ron" ]; then
|
||||
}
|
||||
END { printf "%s", entries }' h="$hash" "$ROOT/config.ron")
|
||||
fi
|
||||
rm -rf "$ROOT/home" "$ROOT/sessions" "$ROOT/config.ron"
|
||||
PROJECTS=$ROOT/home/.claude/projects/-home-bob-repos-sandbox
|
||||
if [ -z "${KEEP:-}" ]; then
|
||||
rm -rf "$ROOT/home" "$ROOT/sessions"
|
||||
fi
|
||||
rm -f "$ROOT/config.ron"
|
||||
mkdir -p "$PROJECTS" "$ROOT/sessions"
|
||||
if [ -z "${KEEP:-}" ]; then
|
||||
|
||||
# Eight of them, because the point of the screen is a list long enough that
|
||||
# picking rows one at a time is the annoyance being fixed. Ids are the same
|
||||
@@ -219,6 +229,7 @@ awk -v mb="$BIG_MB" 'BEGIN {
|
||||
}
|
||||
print "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"usage\":{\"input_tokens\":180000,\"output_tokens\":900}}}"
|
||||
}' > "$big"
|
||||
fi
|
||||
|
||||
cat >"$ROOT/config.ron" <<RON
|
||||
tokens: [
|
||||
@@ -296,6 +307,9 @@ sandbox: 9 invented Claude Code sessions under $PROJECTS (one of them ${BIG_MB}M
|
||||
./ui-sandbox.sh send SID text|@file a message into it
|
||||
./ui-sandbox.sh api /sessions/SID any authenticated request
|
||||
|
||||
keep sessions across a restart (e.g. after rebuilding the server):
|
||||
./ui-sandbox.sh keep
|
||||
|
||||
stop it:
|
||||
./ui-sandbox.sh stop
|
||||
INFO
|
||||
Reference in new issue
Block a user