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>
58 lines
2.4 KiB
Kotlin
58 lines
2.4 KiB
Kotlin
package com.example.aiapp
|
|
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.Row
|
|
import androidx.compose.foundation.layout.Spacer
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.layout.width
|
|
import androidx.compose.material3.Card
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.text.style.TextOverflow
|
|
import androidx.compose.ui.unit.dp
|
|
|
|
/**
|
|
* A message another agent sent this session, closed until somebody asks.
|
|
*
|
|
* Closed by default, like a tool call and for the same reason: these are long, there can be several
|
|
* in a row, and what a reader scanning the transcript needs from one is that it happened and who
|
|
* sent it. The first line comes with the heading because a name alone does not say which message
|
|
* this was.
|
|
*
|
|
* Drawn as its own kind rather than as the reader's own bubble. They did not say this, and a
|
|
* transcript that puts it in their voice is making a claim about who asked for the work that
|
|
* follows -- which is exactly the question a peer message is usually the answer to.
|
|
*/
|
|
@Composable
|
|
fun PeerMessageRow(
|
|
item: TranscriptItem.PeerNote,
|
|
expanded: Boolean,
|
|
onToggle: (Float) -> Unit,
|
|
replies: ParsedReplies,
|
|
modifier: Modifier = Modifier,
|
|
) {
|
|
Card(modifier.fillMaxWidth().clickableAt(onToggle)) {
|
|
Column(Modifier.padding(12.dp)) {
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
Text("Message from ${item.from}", style = MaterialTheme.typography.titleSmall)
|
|
if (!expanded) {
|
|
Spacer(Modifier.width(8.dp))
|
|
Text(
|
|
item.text.lineSequence().firstOrNull { it.isNotBlank() }.orEmpty(),
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
maxLines = 1,
|
|
// The head, not the tail: a message is identified by how it opens.
|
|
overflow = TextOverflow.Ellipsis,
|
|
)
|
|
}
|
|
}
|
|
if (expanded) MarkdownText(item.text, replies, Modifier.padding(top = 6.dp))
|
|
}
|
|
}
|
|
}
|