Hold the transcript still under a scroll, and parse replies before drawing
Both faults needed a real conversation to see, so `app/debug-transcript.sh`
now puts one on the emulator: it copies a Claude Code transcript into /tmp,
gives an ai-server a HOME of its own so the import can only see the copy, and
enrols the app against it. The transcript itself never enters this repository
-- those files hold whatever was said, read and written in a session. Beside
it, `ai-server --delay MS` holds every response back, because a phone's
requests take tens to hundreds of milliseconds over the tunnel and several
faults live entirely in what the app does while one is outstanding.
**Scrolling up threw the reader back to the newest end, once.** `followTail`
is deliberately a remembered answer, rewritten only when a scroll settles, so
for the whole of a fling it still reports the newest end -- where the reader
was when they threw it. A page of history landing during that fling is a
change in the item count, and the correction written for an insertion at the
newest end fired for one at the oldest. Captured on the emulator:
scrolling=true atNewest=false followTail=true
history: START last=20 total=28
history: page of 80 events -> rows now 36
countChanged count=36 followTail=true scrolling=true
>>> scrollToItem(0) SNAP
It could happen only once, which is what made it look arbitrary rather than
mechanical: the snap settles the scroll at the newest end, so the next fling
gets far enough to settle away from it, and from then on `followTail` is
false. So the list is no longer moved while a scroll is running, which is a
rule of its own rather than a refinement of that condition -- and skipping
the correction outright is right rather than merely safe, because the count
can only grow at the newest end while the reader is already there, `record`
holding everything else until they come back.
**A page of history stalled the frame it appeared in.** Parsing is the
expensive half of drawing a reply and costs in proportion to what was
written: against this transcript one message took 51ms and several took
10-25ms, where the synthetic replies this was tuned on took 4.6ms. So each
page's replies are parsed on a background thread as the page arrives --
after the join, since a boundary falling through a reply leaves a message
made of both halves whose text has existed for no time at all, and warming
the page alone warmed the two halves and missed the one thing drawn. A row
with no answer waiting still parses inline: a row measured at nothing before
it is measured at its real height collapses the transcript above it. Misses
are not stored, so a reply still streaming cannot fill the map with copies
of itself on the way to being finished.
Measured over the same twelve flings: 13.5ms average per composed reply
before, 7us after, the remaining parse being one message at session open.
Verified with ui-trace at 1kHz: with a page landing mid-drag the suppression
fires and the row the reader is on moves monotonically down, 266 -> 1063,
with no step backwards; at rest 0 of 65 elements move. 86 server tests pass,
ktfmt/lint/clippy clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1ed6e29bc6
commit
fd616361eb
7 files changed
+323
-18
No files matched your search
@@ -533,6 +533,19 @@ private fun updateTool(
|
||||
if (it is TranscriptItem.ToolRun && it.id == id) change(it) else it
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the replies among [rows], off whatever thread is drawing.
|
||||
*
|
||||
* Called where a page of transcript is folded rather than where a row is composed, which is the
|
||||
* whole point: the work happens seconds before the reader reaches the rows it was done for. See
|
||||
* [ParsedReplies].
|
||||
*/
|
||||
private suspend fun warm(replies: ParsedReplies, rows: List<TranscriptItem>) {
|
||||
val texts = rows.filterIsInstance<TranscriptItem.AssistantMsg>().flatMap { markdownIn(it.text) }
|
||||
if (texts.isEmpty()) return
|
||||
withContext(Dispatchers.Default) { replies.warm(texts) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SessionScreen(
|
||||
settings: ServerSettings,
|
||||
@@ -613,6 +626,9 @@ fun SessionScreen(
|
||||
var moreHistory by remember { mutableStateOf(true) }
|
||||
var loadingHistory by remember { mutableStateOf(false) }
|
||||
var ready by remember { mutableStateOf(false) }
|
||||
// Replies parsed ahead of the rows that draw them; see [ParsedReplies]. Per session, because
|
||||
// it describes that session's rows and nothing else.
|
||||
val replies = remember(summary.id) { ParsedReplies() }
|
||||
val listState = rememberLazyListState()
|
||||
// Whether the newest message is on screen right now. The list is laid out from the bottom
|
||||
// (see the LazyColumn below), so "newest" is index 0 and being there is being at the start of
|
||||
@@ -775,6 +791,7 @@ fun SessionScreen(
|
||||
try {
|
||||
val page = withContext(Dispatchers.IO) { fetchTranscript(settings, summary.id) }
|
||||
page.forEach { apply(it) }
|
||||
warm(replies, items)
|
||||
} catch (e: ApiException) {
|
||||
// Not fatal: the stream below still replays from zero, which is
|
||||
// slow but complete. Saying so beats silently showing nothing.
|
||||
@@ -816,6 +833,7 @@ fun SessionScreen(
|
||||
// screen -- `apply` refills them, and scrolling
|
||||
// up pages the rest back in as it always does.
|
||||
items = listOf()
|
||||
replies.clear()
|
||||
held = listOf()
|
||||
oldestSeq = 0L
|
||||
moreHistory = true
|
||||
@@ -899,11 +917,28 @@ fun SessionScreen(
|
||||
// the working indicator and a queued message are items too, and they arrive at exactly the
|
||||
// same end. Sibling to the paging trigger below, which is the same count read from the other
|
||||
// end for the same reason.
|
||||
//
|
||||
// Never while a scroll is running, and that is a rule of its own rather than a refinement of
|
||||
// the condition beside it: a list must not be moved out from under a hand that is moving it.
|
||||
// The two disagree because [followTail] is deliberately a *remembered* answer, rewritten only
|
||||
// when a scroll settles -- so for the whole of a fling it still reports the newest end, where
|
||||
// the reader was when they threw it. A page of history landing during that fling is a change
|
||||
// in the count, and the correction meant for an insertion at the newest end then fired for
|
||||
// one at the oldest: the reader was thrown back to the bottom mid-flight. It could happen
|
||||
// only once, which is what made it look arbitrary rather than mechanical -- the snap settles
|
||||
// the scroll at the newest end, so the next fling gets far enough to settle away from it, and
|
||||
// from then on [followTail] is false and nothing fires. Skipping the correction outright is
|
||||
// right rather than merely safe: the count can only have grown at the newest end while the
|
||||
// reader is already there, because [record] holds everything else until they come back.
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow {
|
||||
Pair(listState.layoutInfo.totalItemsCount, listState.layoutInfo.viewportSize.height)
|
||||
}
|
||||
.collect { (count, _) -> if (followTail && count > 0) listState.scrollToItem(0) }
|
||||
.collect { (count, _) ->
|
||||
if (followTail && !listState.isScrollInProgress && count > 0) {
|
||||
listState.scrollToItem(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reaching the far end of what is loaded -- the oldest item, which in
|
||||
// this layout is the last index -- fetches the page before it.
|
||||
@@ -959,7 +994,14 @@ fun SessionScreen(
|
||||
earlier = foldEvent(earlier, entry)
|
||||
}
|
||||
}
|
||||
items = joinPages(earlier, items)
|
||||
val joined = joinPages(earlier, items)
|
||||
// After the join rather than on the page alone: a boundary that fell
|
||||
// through a reply leaves `joinPages` holding a message made of both
|
||||
// halves, and that text has existed for no time at all. Warming the page
|
||||
// by itself warmed the two halves and missed the one thing drawn --
|
||||
// which showed up as a single 22ms parse surviving every page.
|
||||
warm(replies, joined)
|
||||
items = joined
|
||||
have = groupToolRuns(items).size
|
||||
}
|
||||
} catch (_: ApiException) {
|
||||
@@ -1260,7 +1302,8 @@ fun SessionScreen(
|
||||
text = item.text,
|
||||
images = item.images,
|
||||
)
|
||||
is TranscriptItem.AssistantMsg -> AssistantMessage(item.text)
|
||||
is TranscriptItem.AssistantMsg ->
|
||||
AssistantMessage(item.text, replies)
|
||||
is TranscriptItem.ToolRun ->
|
||||
ToolCard(
|
||||
tool = item,
|
||||
@@ -1319,6 +1362,7 @@ fun SessionScreen(
|
||||
PeerMessageRow(
|
||||
item = item,
|
||||
expanded = item.seq in expandedNotes,
|
||||
replies = replies,
|
||||
onToggle = { at ->
|
||||
toggleAnchored(row.key, bounds, at) {
|
||||
expandedNotes =
|
||||
|
||||
Reference in new issue
Block a user