Stop paying a callback per row per frame, and drop the reply cap

Holding every row alive turned two per-row callbacks from a cost paid by
whatever was on screen into a cost paid by everything ever loaded, which
is why loading one more page of history made the whole transcript lag
and each page after made it worse. Both fire for every registered node
on every layout pass:

  - an `onGloballyPositioned` per row, kept so a tap could be told which
    half of the row it landed in
  - an `onGloballyPositioned` per clickable inside a row, for the same
    reason, so a tool group paid several

Neither is needed. One gesture detector on the whole list records where
a touch went down in the content's own coordinates, and the row's own
start is added up from heights when somebody actually taps -- so the
transcript keeps a height per row, which changes when the row does, in
place of a position per row, which is wrong the moment anything scrolls.
Positions for the saved-scroll anchor come from the same sum, and the
anchor is applied once per layout from the content rather than once per
row.

The reply cap is gone at Iris's request; she wasn't seeing it and does
not want it for now. It was doing measurable work -- p50 rises from 16ms
to 20ms on the emulator with it removed -- so it is worth knowing where
to look if long history feels heavy.

That 4ms is the only figure here I trust. This emulator's own noise
between identical repeats is larger than the difference the callback
change makes (identical builds measured 3.5% and 9.3% of frames over
budget), and its p50 of 16ms is already past a 120Hz budget, so it
cannot rank any of this the way the phone will. The callbacks are gone
because they are O(rows) per frame by construction and the list no
longer bounds how many rows there are -- not because a number here says
so.

Checked on the emulator against a real 1,200-event transcript: tapping a
collapsed row expands it with its top edge held exactly still, and a
scroll position comes back pixel-identical after leaving the session and
returning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-31 01:58:48 -04:00
1 parent ee1c493559
commit f22c96abe2
6 files changed
+149 -259

No files matched your search

@@ -46,9 +46,7 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
@@ -143,21 +141,6 @@ private class TopEdgeHold {
var key: Any? = null
}
/**
* Where a row is on screen, so a tap on it can be told which half it landed in.
*
* Not snapshot state, for the same reason as [TopEdgeHold]: written from layout, read from a click,
* and observed by nothing.
*/
private class RowBounds {
var top = 0f
var height = 0f
/** Above this is the row's top half, below it the bottom half. */
val middle
get() = top + height / 2
}
/** One row's height between layouts, so a change in it can be noticed. See [holdTopEdge]. */
private class LastHeight {
var value: Int? = null
@@ -644,10 +627,6 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// by default, which is the rule for anything new in this transcript: a screen that opens
// everything it can is one nobody can scan.
var expandedNotes by remember { mutableStateOf(setOf<Long>()) }
// Long replies the reader has asked to see the rest of, by the seq of the row. Held here
// rather than in the row so that scrolling away and back does not shut something they
// deliberately opened -- the same reason the sets above it are here.
var expandedReplies by remember { mutableStateOf(setOf<Long>()) }
// Uploaded-but-not-yet-sent attachment ids; sent with the next message.
var pendingAttachments by remember { mutableStateOf(listOf<String>()) }
// What this session is set to now, seeded from the row that opened it and
@@ -715,12 +694,6 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// What is actually drawn: the transcript with runs of adjacent tool
// calls folded into one row each.
val rows = remember(items) { groupToolRuns(items) }
// The reply drawn whole however long it is; see the transcript list below. Recomputed with
// `items` rather than tracked as it arrives, because "newest" moves: a reply that was the
// last one becomes history the moment the next turn starts, and a row that kept its
// exemption after that would be the one enormous row this exists to bound.
val newestReply =
remember(items) { items.filterIsInstance<TranscriptItem.AssistantMsg>().lastOrNull()?.seq }
/**
* Everything the transcript list draws, from one event.
@@ -834,8 +807,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
*
* The correction itself belongs to the measurement -- see [holdTopEdge].
*/
fun toggleAnchored(key: Any, row: RowBounds, at: Float, toggle: () -> Unit) {
if (at < row.middle) topEdgeHeld.key = key
fun toggleAnchored(row: TranscriptRow, toggle: () -> Unit) {
if (listState.tappedHigh(row.startSeq)) topEdgeHeld.key = row.key
toggle()
}
@@ -1422,21 +1395,14 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
}
},
) { row ->
val bounds = remember { RowBounds() }
Box(
Modifier.onGloballyPositioned {
bounds.top = it.positionInRoot().y
bounds.height = it.size.height.toFloat()
}
.holdTopEdge(row.key, topEdgeHeld) { grew -> listState.by(grew) }
) {
Box(Modifier.holdTopEdge(row.key, topEdgeHeld) { grew -> listState.by(grew) }) {
when (row) {
is TranscriptRow.Tools ->
ToolGroup(
group = row,
expanded = row.id in expandedGroups,
onToggle = { at ->
toggleAnchored(row.key, bounds, at) {
onToggle = {
toggleAnchored(row) {
expandedGroups =
if (row.id in expandedGroups)
expandedGroups - row.id
@@ -1447,8 +1413,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// Anchored on the group, not the call: opening one call makes
// the whole group taller, and the heading the reader is under
// is the group's.
onToolToggle = { id, at ->
toggleAnchored(row.key, bounds, at) {
onToolToggle = { id ->
toggleAnchored(row) {
expandedTools =
if (id in expandedTools) expandedTools - id
else expandedTools + id
@@ -1475,43 +1441,14 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
text = item.text,
images = item.images,
)
is TranscriptItem.AssistantMsg -> {
// The newest reply is never cut. It is the one being read
// as it arrives -- often still arriving -- and putting a
// "show the rest" under a turn somebody is waiting for
// hides the answer they are waiting for. Every reply
// behind it is history, and history is what this is for.
val shortened =
if (
item.seq == newestReply ||
item.seq in expandedReplies
)
null
else remember(item.text) { shortenedReply(item.text) }
if (shortened == null) {
AssistantMessage(item.text, replies)
} else {
CappedReply(
full = item.text,
shortened = shortened,
replies = replies,
onShowMore = {
// Anchored like every other row that changes
// height, so the edge the reader touched
// stays where it is.
toggleAnchored(row.key, bounds, bounds.top) {
expandedReplies = expandedReplies + item.seq
}
},
)
}
}
is TranscriptItem.AssistantMsg ->
AssistantMessage(item.text, replies)
is TranscriptItem.ToolRun ->
ToolCard(
tool = item,
expanded = item.id in expandedTools,
onToggle = { at ->
toggleAnchored(row.key, bounds, at) {
onToggle = {
toggleAnchored(row) {
expandedTools =
if (item.id in expandedTools)
expandedTools - item.id
@@ -1565,8 +1502,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
item = item,
expanded = item.seq in expandedNotes,
replies = replies,
onToggle = { at ->
toggleAnchored(row.key, bounds, at) {
onToggle = {
toggleAnchored(row) {
expandedNotes =
if (item.seq in expandedNotes)
expandedNotes - item.seq