Take every whole-message parse off the composing thread, and count the rest
Bryan's report after the last change read no worse in the numbers -- medians unchanged, worst stall halved -- but scrolling felt bumpier while messages loaded, and instrumenting the split paths found the feeling's likely source: work the counters never saw. The live reply's block split (BlockedMarkdown) recomputed on the composing thread at every delta -- a whole-message parse of the growing text, 815 of them and 2.9 seconds inside one streamed reply, a few milliseconds per delta on the thread that draws, plus a cache entry per partial text that nothing reads again. It now works the way parsedMarkdown already did one file over: first split inline so the row has its height, every later one off-thread, drawing one split behind, cached nowhere. Emulator, same streamed fixture: anim p90 7.3 -> 3.4ms, p99 9.3 -> 4.7ms. The settle moment had the same shape: nothing warms live deltas, so the just-finished reply's split parse ran inside the flatten, uncounted, in a frame. The flatten now splits a reply only when ParsedReplies.splitReady says warm() has made its parses; the session screen warms the one cold row off-thread and re-flattens (warmedTick), so the whole-to-blocks swap always composes against ready parses. Readiness is an explicit mark set by warm() rather than a peek into the blocks cache, because a message with memory notes is warmed as its parts -- inferred readiness left it unsplittable forever and re-warmed on every fold. Also: user slices shrink to ~1000 chars (about one viewport, so a slice composing mid-fling costs a few milliseconds, not sixty), and the split and flatten paths are all timed -- "units flattened", "markdown split into blocks", "message cut into parts", "user message cut into slices", "blocks split while streaming" -- so the next "it feels bumpier" report names its cause instead of hiding it in anim. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
333d2de92b
commit
356dee65f1
5 files changed
+113
-8
No files matched your search
@@ -164,6 +164,7 @@ fun transcriptUnits(
|
||||
replies: ParsedReplies,
|
||||
openNotes: Set<Long>,
|
||||
): List<TranscriptUnit> {
|
||||
val started = System.nanoTime()
|
||||
val units = ArrayList<TranscriptUnit>(rows.size)
|
||||
rows.forEachIndexed { index, row ->
|
||||
val rowGap = if (index == 0) 0.dp else TRANSCRIPT_SPACING
|
||||
@@ -204,7 +205,9 @@ fun transcriptUnits(
|
||||
)
|
||||
}
|
||||
} else if (
|
||||
item is TranscriptItem.AssistantMsg && (item.settled || index != rows.lastIndex)
|
||||
item is TranscriptItem.AssistantMsg &&
|
||||
splitWanted(item, index, rows.lastIndex) &&
|
||||
replies.splitReady(item.text)
|
||||
) {
|
||||
var ordinal = 0
|
||||
fun gap() = if (ordinal == 0) rowGap else BLOCK_SPACING
|
||||
@@ -227,9 +230,37 @@ fun transcriptUnits(
|
||||
}
|
||||
units.reverse()
|
||||
reportDuplicateKeys(units)
|
||||
// Timed because this runs per fold on the composing thread: "loading messages feels bumpy"
|
||||
// is this number growing, and it was invisible until it was written down.
|
||||
DebugStats.record("units flattened", System.nanoTime() - started)
|
||||
return units
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this reply should be drawn as blocks: settled, or anywhere but the newest row.
|
||||
*
|
||||
* Wanting is not being ready -- the flatten also asks [ParsedReplies.splitReady], and the two
|
||||
* questions are separate because they are answered by different things: this one by the fold, the
|
||||
* other by whether [warm] has run for the text. [unwarmedReplies] is the gap between them.
|
||||
*/
|
||||
private fun splitWanted(item: TranscriptItem.AssistantMsg, index: Int, lastIndex: Int) =
|
||||
item.settled || index != lastIndex
|
||||
|
||||
/**
|
||||
* The replies among [rows] that should draw as blocks but whose parses are not made yet.
|
||||
*
|
||||
* Normally empty: every page's rows are warmed before the fold lands. The one row that can be cold
|
||||
* is the reply that just finished streaming -- nothing warms live deltas, so at the moment its turn
|
||||
* ends its split would cost a whole-message parse on the composing thread. The session screen warms
|
||||
* what this returns off-thread and re-flattens, so the whole-to-blocks swap always composes against
|
||||
* ready parses.
|
||||
*/
|
||||
fun unwarmedReplies(rows: List<TranscriptRow>, replies: ParsedReplies): List<TranscriptItem> =
|
||||
rows.mapIndexedNotNull { index, row ->
|
||||
val item = (row as? TranscriptRow.Single)?.item as? TranscriptItem.AssistantMsg
|
||||
item?.takeIf { splitWanted(it, index, rows.lastIndex) && !replies.splitReady(it.text) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Above this many characters, a user message is drawn in slices rather than as one bubble.
|
||||
*
|
||||
@@ -240,8 +271,14 @@ fun transcriptUnits(
|
||||
*/
|
||||
const val USER_SPLIT_CHARS = 4000
|
||||
|
||||
/** Roughly how much text one slice holds -- bounded, like a markdown block, is the whole point. */
|
||||
private const val USER_CHUNK_CHARS = 2500
|
||||
/**
|
||||
* Roughly how much text one slice holds -- bounded, like a markdown block, is the whole point.
|
||||
*
|
||||
* About one viewport of wrapped text: a slice is composed whole in the frame it scrolls into, so
|
||||
* its size is a frame-budget decision, and one screenful keeps that to a few milliseconds on the
|
||||
* phone. Smaller buys nothing -- the seams are free -- but the units multiply.
|
||||
*/
|
||||
private const val USER_CHUNK_CHARS = 1000
|
||||
|
||||
/**
|
||||
* A long user message cut at line starts into slices of roughly [USER_CHUNK_CHARS].
|
||||
|
||||
Reference in new issue
Block a user