Merge branch 'main' of git.arirex.me:iris/ai-app

This commit is contained in:
iris committed 2026-08-31 01:51:18 -04:00
commit 5737c06654
4 files changed
+494 -261

No files matched your search

@@ -0,0 +1,101 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/**
* How much of a reply is drawn before the reader is offered the rest.
*
* A limit on the *source*, not on the height, and that is the whole point. Clipping a laid-out row
* to a height saves nothing: Compose measures the text and then throws the overflow away, so the
* line-breaking has already happened. Cutting the string before it is parsed is what stops the work
* from being done -- and it stops the parse too, which the height version could never reach.
*
* Four thousand characters is about two screenfuls of body text on a phone. Two rather than one so
* that a reply somewhat over the limit is not cut nearly in half, and so a reader who does not
* press anything still gets more than they can see at once.
*
* The measurement that made this worth having, from the ai-app-2 session on 2026-08-30: one message
* in a real transcript is over 14,000px tall -- seven screens -- and a single frame spent 59.6ms in
* `measureAndLayout` when it entered the viewport. That cost is proportional to the whole row
* however little of it is on screen, and it is paid again every time the row comes back.
*/
private const val REPLY_CAP_CHARS = 4000
/**
* How far past the cap a reply has to be before it is worth cutting.
*
* Without this a message of 4,001 characters loses one character and gains a button, which is worth
* nothing to anybody and costs a control that has to be read and decided about. The row that needs
* this treatment is several times the limit, not just over it.
*/
private const val REPLY_CAP_SLACK = 1000
/**
* The opening of [text] if it is long enough to be worth cutting, or null if it should be drawn
* whole.
*
* Cut at a line ending, because a markdown source cut mid-line is a different document: half a
* heading marker, a list item with no bullet, a link whose closing bracket is in the part that was
* dropped. A whole number of lines is the coarsest cut that cannot invent syntax.
*
* A fence left open by the cut is closed, which is the one case a line boundary does not save. An
* unterminated ``` swallows the rest of the reply into a code block, so the truncation would change
* how the part still on screen is *drawn* rather than only how much of it there is -- and a reader
* has no way to tell that from the reply genuinely having been code.
*/
fun shortenedReply(text: String, limit: Int = REPLY_CAP_CHARS): String? {
if (text.length <= limit + REPLY_CAP_SLACK) return null
val cut = text.lastIndexOf('\n', limit).let { if (it <= 0) limit else it }
val head = text.substring(0, cut)
// Fences are counted rather than matched: an opening and a closing one are the same token, so
// an odd number of them is an opening that never closed.
return if (head.split("\n").count { it.trimStart().startsWith("```") } % 2 == 1) "$head\n```"
else head
}
/**
* A reply drawn to [REPLY_CAP_CHARS], with the rest a press away.
*
* The control says how much is behind it rather than only "Show more", because the two answers a
* reader wants are different sizes of decision: another paragraph is worth opening while standing
* in a scroll, and another twenty screens is worth knowing about first.
*
* Expanded is remembered by the screen rather than by this row, so scrolling away and back does not
* shut something the reader deliberately opened -- see SessionScreen's other expansion sets.
*/
@Composable
fun CappedReply(
full: String,
shortened: String,
replies: ParsedReplies,
onShowMore: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier.fillMaxWidth()) {
AssistantMessage(shortened, replies)
TextButton(onClick = onShowMore) {
Text(
"Show the rest (${remaining(full.length - shortened.length)})",
style = MaterialTheme.typography.labelLarge,
)
}
}
}
/** What is left, in the units a reader thinks in rather than in characters. */
private fun remaining(chars: Int): String {
// Against the same figure the cap is written in, so the two cannot drift: a "screenful" here is
// whatever [REPLY_CAP_CHARS] is two of.
val screens = chars.toDouble() / (REPLY_CAP_CHARS / 2)
return when {
screens < 1.5 -> "about another screen"
screens < 20 -> "about ${Math.round(screens)} more screens"
else -> "more than 20 screens"
}
}
@@ -57,8 +57,18 @@ private fun partsOf(text: String): List<MessagePart> {
return if (parts.singleOrNull() is MessagePart.Prose) listOf(MessagePart.Prose(text)) else parts
}
/** Every string a reply will be drawn from, for [ParsedReplies.warm] to make ready. */
fun markdownIn(text: String): List<String> = partsOf(text).map { it.text }
/**
* Every string a reply will be drawn from, for [ParsedReplies.warm] to make ready.
*
* Both forms of a long one, because which gets drawn is not decided here: the newest reply is drawn
* whole and every other long one is drawn cut (see [shortenedReply]), and the reader can ask for
* the rest of any of them. Warming only one of the two would leave the other parsing on the thread
* that draws, in the frame the row appears -- and a string warmed under a key no row ever looks up
* is a miss that nothing reports. The extra parse is off the composing thread, which is the only
* place it would have cost anything.
*/
fun markdownIn(text: String): List<String> =
partsOf(text).flatMap { part -> listOfNotNull(part.text, shortenedReply(part.text)) }
@Composable
private fun MemoryNote(note: MessagePart.Remembered, replies: ParsedReplies) {
@@ -7,8 +7,8 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -17,9 +17,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
@@ -48,6 +45,7 @@ import androidx.compose.runtime.setValue
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
@@ -84,11 +82,13 @@ private val LOADING_SPINNER = 48.dp
* How much history to keep loaded past the oldest row on screen, counted in screenfuls.
*
* Both the point at which history starts loading and how much of it a load has to produce before it
* stops. Measured against what is on screen rather than written down as a number of rows, because a
* row is anything from one line to a page and a fixed count is therefore a distance only by
* accident. Eight rows was the number, and on a tool-heavy transcript eight rows is less than one
* screen: the reader reached the end of what was loaded on *every* swipe and waited a round trip
* standing there, which is a list running out of transcript rather than a slow frame.
* stops. Multiplied by the viewport to give a number of *pixels* of scroll, which is the distance
* the question is actually about: how far the reader can keep going before they run out. A row is
* anything from one line to a page, so a count of rows is that distance only by accident. Eight
* rows was the number, and on a tool-heavy transcript eight rows is less than one screen: the
* reader reached the end of what was loaded on *every* swipe and waited a round trip standing
* there, which is a list running out of transcript rather than a slow frame. Counting screenfuls of
* rows fixed the size of the mistake without fixing its kind; pixels are the unit itself.
*
* Three, so a fling lands on rows that are already there and the page after them is on its way. The
* cost of being generous is a page fetched that nobody reads; the cost of being mean is a list that
@@ -644,6 +644,10 @@ 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
@@ -671,21 +675,12 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// it is the question "where did I leave off" and the answer stops being interesting the
// moment the list is on screen.
val savedAnchor = remember(summary.id) { loadScrollAnchor(context, summary.id) }
// Whether the list is still being put back where it was left. Nothing is drawn while it is:
// opening at the newest end and then travelling to the anchor is exactly the journey
// `reverseLayout` exists to remove, and this transcript is not allowed to move under a reader.
// Whether the history the saved position needs is still being fetched. Nothing is drawn while
// it is: opening at the newest end and then travelling to the anchor is exactly the journey
// this layout exists to remove, and this transcript is not allowed to move under a reader.
// The other half of the wait is [TranscriptScroll.settling], which covers the frames between
// the rows arriving and the layout that measures them putting the position back.
var restoring by remember(summary.id) { mutableStateOf(savedAnchor != null) }
// Remembered, and only ever written when a scroll settles -- so it records where the reader
// last left the list, and an insertion cannot change the answer. Reading the live position
// instead looks right and is subtly wrong: a keyed list moves its anchor to keep the reader's
// content still, so by the time the new item can be observed the view is already one item
// away from the newest and reports itself as scrolled back. The message then never followed,
// which was visible as a compaction whose progress bar sat just off the bottom of the screen
// while the button that started it said it was running.
//
// Seeded from whether there is a position to go back to, so the correction it drives does not
// pull the list to the newest end before the restore has put it anywhere.
var followTail by remember(summary.id) { mutableStateOf(savedAnchor == null) }
// Sent, but not yet read by the session -- which is when the backend
// records it and it comes back as a row. Until then it is drawn below
// the working indicator, because that is where it is in the session's
@@ -707,23 +702,25 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// 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
// it. This is what the jump-to-newest button watches: it is about what the reader can see.
val listState = rememberTranscriptScroll(summary.id)
// Whether the newest message is on screen right now. The content hangs from its newest end
// (see [TranscriptScroll]), so being there is being at scroll position zero. This is what the
// jump-to-newest button watches: it is about what the reader can see.
//
// It is also the gate on everything the list draws -- see [record].
val atNewest by remember {
derivedStateOf {
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
}
}
val atNewest by remember { derivedStateOf { listState.atNewest } }
// Transcript events that arrived while somebody was reading further back, in the order they
// arrived, waiting for them to return to the newest end. See [record] for why.
var held by remember { mutableStateOf(listOf<SeqEvent>()) }
// 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.
@@ -843,46 +840,26 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
}
/**
* How many list items sit above every transcript row -- one while something is waiting, none
* otherwise.
* Whether the row holding transcript position [seq] is loaded, with older history behind it.
*
* Read both here and by the `item` that draws that bubble, so a restored position and the list
* cannot disagree about what is at which index. Anything else added above the rows later
* belongs in this count.
*/
fun itemsAboveRows() = if (queued.isNotEmpty() || waitingCommands.isNotEmpty()) 1 else 0
/** Everything the list draws, in items rather than in rows. See [itemsAboveRows]. */
fun listItemCount() = groupToolRuns(items).size + itemsAboveRows()
/**
* The row drawn at list index [index], or null when that item is not a row.
* "Behind it" is the part that is easy to leave out. The oldest loaded row is a half-row --
* [joinPages] welds the other half onto it when the page before it arrives, and it grows -- so
* putting the reader inside one leaves them where they were only until the next page lands,
* which was a screen and a half out. Any row that is not the oldest is final.
*
* Computed from `items` rather than from `rows` for the reason [loadOlderPage] gives, and this
* is the caller that makes it matter: the scroll listener below is started once and keyed on
* the list state, so a `rows` read inside it is the value from the *first* composition, which
* is empty. That saved a null anchor on every scroll -- indistinguishable from having been left
* at the newest end, so the position was silently never recorded at all.
*/
fun rowAt(index: Int): TranscriptRow? =
groupToolRuns(items).asReversed().getOrNull(index - itemsAboveRows())
/**
* Where the row holding transcript position [seq] sits in the list, or null when nothing loaded
* reaches back that far.
* The last row starting at or before [seq], rather than one starting exactly there: the events
* behind a row can be regrouped between the save and the reopen -- a run of calls folds
* differently when a page boundary moves, and two halves of a reply become one message -- and
* the reader's place is inside whichever row now holds that seq, not gone.
*
* Computed from `items` rather than from `rows`, as [rowAt] is and for the same reason.
* Computed from `items` rather than from `rows` for the reason [loadOlderPage] gives: `rows` is
* the composition's value and does not change under a running coroutine.
*/
fun indexOfSeq(seq: Long): Int? {
// The last row that starts at or before it, rather than one that starts exactly there:
// the events behind a row can be regrouped between the save and the reopen -- a run of
// calls folds differently when a page boundary moves, and two halves of a reply become
// one message -- and the reader's place is inside whichever row now holds that seq, not
// gone. Rows are oldest-first here, so that is the last match.
fun anchorRow(seq: Long): Long? {
val ordered = groupToolRuns(items)
val row = ordered.indexOfLast { it.startSeq <= seq }
if (row < 0) return null
return ordered.size - 1 - row + itemsAboveRows()
val at = ordered.indexOfLast { it.startSeq <= seq }
// Zero is the oldest loaded row, which is the half-row above; not found is -1.
return if (at > 0) ordered[at].startSeq else null
}
/**
@@ -1012,8 +989,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// that -- a tool run is renamed whenever the newest page starts somewhere new,
// so an anchor on one was never found and this paged to the first event of the
// conversation every time an active session was reopened.
var index = indexOfSeq(anchor.seq)
while (moreHistory && (index == null || index >= listItemCount() - 1)) {
while (moreHistory && anchorRow(anchor.seq) == null) {
// The whole span in one request rather than a page at a time. `read_window`
// counts *lines* and a transcript numbers them one per seq, so the distance
// back to the anchor is the number of events to ask for -- and were seqs ever
@@ -1030,17 +1006,25 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// 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
index = indexOfSeq(anchor.seq)
}
// Both writes before this coroutine yields, so the list's first measurement is
// the one with every row in it *and* the requested position -- the list is drawn
// where it was left rather than drawn and then moved. `requestScrollToItem` is
// the form that is applied during a layout pass; see [holdTopEdge].
// Both writes before this coroutine yields, so the layout that first measures
// these rows is also the one that puts the position back -- the transcript is
// drawn where it was left rather than drawn and then moved. The pixels do not
// exist until that measurement, which is why the anchor is handed to the layout
// rather than applied here; see [TranscriptScroll.pending].
restoring = false
// A null index is a row that is no longer in the transcript -- a reset stream, or
// 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
// behind a row regroup between the save and the reopen -- a run of calls folds
// differently when a page boundary moves, two halves of a reply become one
// message -- and the layout can only recognise a row by the seq it now starts
// at. Handing it the saved seq meant the row it named no longer existed, so the
// position was never applied and the transcript opened at the newest end.
//
// Null is a row that is no longer in the transcript at all -- a reset stream, or
// a session cleared from elsewhere.
index?.let {
listState.requestScrollToItem(it, anchor.offset)
anchorRow(anchor.seq)?.let { rowSeq ->
listState.restore(ScrollAnchor(rowSeq, anchor.offset))
restored = true
}
}
@@ -1049,7 +1033,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// slow but complete. Saying so beats silently showing nothing.
streamError = e.message
}
if (!restored) followTail = true
// Nothing to put back, so draw where the content already hangs: the newest end.
if (!restored) listState.giveUp()
// Whatever happened above, including a page that never arrived: an empty transcript is a
// state the screen can draw, and a permanently blank one is not.
restoring = false
@@ -1145,18 +1130,6 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
backlog.forEach { record(it) }
}
}
// Whether they *chose* to be at the newest end, which is a different question from being
// there and the one that decides whether an arriving message brings the view with it.
LaunchedEffect(listState) {
snapshotFlow { listState.isScrollInProgress }
// The value `snapshotFlow` emits on collection is the state of things before anybody
// has touched the list, and it is `false` -- which reads here as a scroll that has
// just ended, and so as an answer about where the reader chose to be. Only the
// transitions after it are scrolls.
.drop(1)
.collect { scrolling -> if (!scrolling) followTail = atNewest }
}
// Where the reader left off, written whenever the list settles somewhere new.
//
// Driven by the position rather than by the scroll flag, and that is the whole point: a
@@ -1165,115 +1138,63 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// that, and it left the old position recorded -- so the reader pressed the control that means
// "take me to the end", left, came back, and was put back where they had been.
//
// Deliberately not where [followTail] is decided, which has to stay on the settle: a keyed
// list moves its own anchor to keep content still when a row arrives, so the position reports
// itself as one item back for a frame every time a message lands. That frame is the whole
// reason [followTail] is a remembered answer, and reading it here instead would stop new
// messages being followed.
// There is no separate "did they choose to be here" flag any more, and there is nothing left
// for one to protect against. It existed because a keyed lazy list moved its own anchor when a
// row arrived, so for one frame the position reported itself scrolled back from the newest end
// when nobody had scrolled at all. This layout hangs from that end, so a row arriving does not
// move the position: zero still means the newest message, during the frame it lands and after.
LaunchedEffect(listState) {
snapshotFlow {
if (listState.isScrollInProgress) null
else listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset
}
// As above: where the list sits before anybody has touched it is not somewhere they
// left off, and taking it as one wiped every saved anchor on the way in -- before the
// restore below could use it.
snapshotFlow { if (listState.scroll.isScrollInProgress) null else listState.scroll.value }
// The value `snapshotFlow` emits on collection is where the list sits before anybody
// has touched it, which is not somewhere they left off. Taking it as one wiped every
// saved anchor on the way in -- before the restore above could use it.
.drop(1)
.collect { settled ->
if (settled == null) return@collect
if (settled == null || listState.settling) return@collect
saveScrollAnchor(
context,
summary.id,
// Nothing to restore at the newest end, which is where a session with no
// anchor opens anyway -- so the ordinary case costs a `remove` and no
// page-back on the way in. [followTail] covers the frame described above,
// where a row has just arrived and the position has not caught up yet;
// `rowAt` is null for the pending bubble, which sits above every row and is
// not one, and being on that is being at the newest end too.
if (followTail || atNewest) null
else rowAt(settled.first)?.let { ScrollAnchor(it.startSeq, settled.second) },
// page-back on the way in.
if (settled == 0) null else listState.anchor(),
)
}
}
// A new item at the newest end shifts every index by one, so the view
// has to step back to 0 to stay put. One item, instantly -- not a
// journey through the transcript.
// Anything that changes how much room the list has, as well as a new
// item arriving. Typing is the case that gets missed: the field grows
// from one line to four and the keyboard opens under it, and neither
// is a new message, so watching the item count alone leaves the newest
// text drifting out of sight while somebody writes a reply to it.
// Reaching the far end of what is loaded fetches the page before it.
//
// Counted in list items rather than in transcript rows, because the rows are not all of it:
// 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.
// Measured in pixels of scroll, which is the unit the question is actually about: how far can
// the reader keep going before they run out. Rows are the wrong unit for it and were the
// reason this went wrong twice -- a row is anything from one line to a screenful, so a cushion
// of "three rows" is a different amount of reading depending which three, and a page of eight
// hundred *events* can fold into almost no new rows at all when it is one streamed reply and a
// run of tool calls. Nothing then asked for the next page, and the transcript only loaded when
// somebody dragged it again.
//
// 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 && !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.
//
// Both numbers come from the list itself, and that is the point: an index into what is drawn
// can only be compared against how much is drawn. Three things already make that differ from
// the event count -- a run of adjacent tool calls is one row, and the queued bubble and the
// working indicator are rows with no event behind them at all -- so measuring the far end in
// events meant the threshold could not be reached, and a session with tool calls in it simply
// stopped scrolling back. Anything added to this list later is a fourth, and totalItemsCount
// already counts it.
LaunchedEffect(listState, rows.size, moreHistory) {
snapshotFlow {
val layout = listState.layoutInfo
// How many rows are on screen is the third thing this needs, and it is what turns a
// cushion measured in rows into one measured in screens; see [HISTORY_SCREENS].
Triple(
layout.visibleItemsInfo.lastOrNull()?.index ?: 0,
layout.totalItemsCount,
layout.visibleItemsInfo.size,
)
}
.collect { (last, total, onScreen) ->
if (!moreHistory || loadingHistory || total == 0) return@collect
val cushion = onScreen.coerceAtLeast(1) * HISTORY_SCREENS
if (last < total - cushion) return@collect
// There is no correction beside this one any more. Following the newest message used to be an
// effect here too, watching the item count and the viewport for a change and snapping back --
// and it had to be told not to fire during a scroll, because a page of history landing mid-
// fling looked exactly like a message arriving and threw the reader to the bottom. The content
// now hangs from the newest end, so an arriving message needs no correction and a page of
// history moves nothing; see [TranscriptScroll].
LaunchedEffect(listState, moreHistory) {
snapshotFlow { listState.roomAbove to listState.viewport }
.collect { (room, viewport) ->
if (!moreHistory || loadingHistory || viewport == 0) return@collect
val cushion = viewport * HISTORY_SCREENS
if (room >= cushion) return@collect
loadingHistory = true
try {
// Pages until there are rows behind them again, not one page and stop.
// One page, and then this fires again if it was not enough.
//
// A page is eight hundred *events*, and events are not rows: a reply arrives
// as hundreds of text deltas that fold into a single message, and a run of
// thirty-five tool calls is one row. So a page that lands can leave the far
// end almost exactly where it was -- and since this is triggered by the far
// end moving, nothing asks for the next one. The list then only loads when
// somebody drags it again, a page at a time, which is what "it only loads
// when you touch the top" was, and later what the wait at every swipe was.
// Counted from `items` rather than from `rows`, which is the
// composition's value and does not change under a running coroutine.
val start = groupToolRuns(items).size
var have = start
while (moreHistory && have - start < cushion && loadOlderPage()) {
have = groupToolRuns(items).size
}
// Deliberately not a loop: how much room a page bought is a fact about the
// *layout* it produced, and no layout has happened yet inside this coroutine
// -- so a loop would be re-reading the height from before the page it just
// fetched and would ask for the whole conversation. Letting the measurement
// answer means each page is checked against what it actually added, and a
// page that folds into almost no new rows is followed by another because the
// room genuinely did not grow.
loadOlderPage()
} catch (_: ApiException) {
// Leave `moreHistory` alone: the next scroll asks again.
} finally {
@@ -1454,46 +1375,38 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
)
}
// Laid out from the bottom, with the newest message at index 0.
// Every loaded row composed and kept, hanging from the newest message.
//
// The obvious arrangement -- oldest first, then scroll to the end
// -- opens at the top and travels the whole transcript to get
// where it belongs. On an imported session that is nine hundred
// items measured before anything is readable, seen as the view
// visibly racing downward every time it opened.
// The obvious arrangement -- oldest first, then scroll to the end -- opens at the top and
// travels the whole transcript to get where it belongs. On an imported session that was
// nine hundred rows measured before anything was readable, seen as the view visibly racing
// downward every time it opened. Scrolling the content in reverse removes the journey
// rather than hiding it: position zero *is* the newest message, so the first frame is
// already the right one and nothing has to be scrolled at all. See [TranscriptScroll].
//
// Anchoring at the bottom removes the journey rather than hiding
// it: the first frame is already the newest message, and older
// ones are composed only as somebody scrolls back to them, which
// is also what makes history cheap on a long conversation.
// Empty until a saved position has been put back -- see the opening effect. Held out of
// the list rather than drawn and scrolled, so there is no frame in which the transcript is
// somewhere other than where it was left.
val drawnRows = if (restoring) emptyList() else rows.asReversed()
// Drawn only once there is nothing left to put back, and measured throughout -- the
// heights are what a saved position is expressed in, so the rows have to be laid out
// before it can be applied. Held out of the drawing rather than out of the list, so there
// is no frame in which the transcript is somewhere other than where it was left.
val settled = !restoring && !listState.settling
Box(Modifier.weight(1f).fillMaxWidth()) {
LazyColumn(
BoxWithConstraints(Modifier.fillMaxSize()) {
TranscriptColumn(
rows = rows,
state = listState,
reverseLayout = true,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
// Bottom, and it has to be said: `reverseLayout` defaults the arrangement to
// `Bottom` on its own, but naming `spacedBy` replaces that default with
// `spacedBy`'s own, which is `Top`. The arrangement is what places the content
// when there is less of it than the viewport -- so a session whose loaded rows
// did not fill the screen drew them against the *top*, leaving a gap between the
// newest message and the box you type in, and no room to scroll the gap away.
// Opening the keyboard shrank the viewport enough for the content to overflow it
// and the list snapped down, which is what made it look like a scrolling fault
// rather than a placement one.
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.Bottom),
) {
// The last thing in the transcript, because that is where
// they are in the session's reading of events: after
// everything it has taken in, and not yet taken in
// themselves. What the session is *doing* about them is a
// A scrollable child is measured with no height bound, so the content cannot
// ask how tall the visible area is; this is the only place that knows.
viewportHeight = maxHeight,
contentPadding = TRANSCRIPT_PADDING,
spacing = TRANSCRIPT_SPACING,
modifier =
Modifier.fillMaxSize().drawWithContent { if (settled) drawContent() },
below = {
// The last thing in the transcript, because that is where they are in the
// session's reading of events: after everything it has taken in, and not
// yet taken in themselves. What the session is *doing* about them is a
// line below, in [SessionStatusRow].
if (!restoring && itemsAboveRows() > 0) {
item(key = "queued") {
if (queued.isNotEmpty() || waitingCommands.isNotEmpty()) {
Column(horizontalAlignment = Alignment.End) {
waitingCommands.forEach { (_, text) ->
CommandBubble(text, waiting = true)
@@ -1509,35 +1422,15 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
}
}
}
}
// Reversed to match the layout, so index 0 is the newest and
// the reader still sees them in the order they happened.
// Grouped first: adjacent tool calls collapse into one row,
// which is a decision about this screen and not about the
// transcript the stream and paging share.
// Keyed, and this is what stops the list moving under whoever is reading
// it. Every new message is an insertion at index 0 here, so without a key the
// rows keep their positions and the content slides through them -- which looks
// exactly like the view scrolling by itself. The keys above matter for the same
// reason: the working indicator appearing and disappearing is another insertion
// at the same end. Paging older history is the opposite insertion and was
// already fine, and stays fine, because a key survives both.
items(drawnRows, key = { it.key }) { row ->
},
) { row ->
val bounds = remember { RowBounds() }
Box(
Modifier.onGloballyPositioned {
bounds.top = it.positionInRoot().y
bounds.height = it.size.height.toFloat()
}
.holdTopEdge(row.key, topEdgeHeld) { grew ->
// Requested rather than scrolled. Scrolling forces a remeasure,
// and forcing one from inside a measure throws; this is the form
// built to be asked for during layout and applied in that pass.
listState.requestScrollToItem(
listState.firstVisibleItemIndex,
listState.firstVisibleItemScrollOffset + grew,
)
}
.holdTopEdge(row.key, topEdgeHeld) { grew -> listState.by(grew) }
) {
when (row) {
is TranscriptRow.Tools ->
@@ -1584,8 +1477,37 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
text = item.text,
images = item.images,
)
is TranscriptItem.AssistantMsg ->
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.ToolRun ->
ToolCard(
tool = item,
@@ -1668,7 +1590,11 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
//
// In the middle of the transcript rather than at either end, because it is not
// reporting on the newest message or the oldest; it is standing in for all of them.
if (!ready || restoring) {
// `settled` and not `restoring` alone, so the spinner covers the whole wait: fetching
// the history a saved position needs, and then the frames between those rows arriving
// and the layout that measures them putting the position back. They are the two halves
// of the same wait and the transcript is not drawn for either.
if (!ready || !settled) {
CircularProgressIndicator(Modifier.align(Alignment.Center).size(LOADING_SPINNER))
}
@@ -1686,22 +1612,14 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
Surface(
// Instantly. An animated scroll travels the whole transcript, so the
// further back somebody has read the longer this takes -- the one press
// whose cost grows with how much there is to skip, which is backwards. The
// list is keyed and composes only what it lands on, so going straight there
// costs the same from anywhere.
onClick = {
// Says what it means as well as doing it. Pressing this is the reader
// choosing the newest end, which is exactly what [followTail] records --
// and nothing else here would notice, because a snap moves the list
// inside one frame and the listener that decides [followTail] waits for a
// scroll to *end*. So this used to land at the bottom with following
// still switched off, and the next message did not bring the view with
// it: the reader pressed "take me to the latest" and then sat watching a
// conversation that had moved on without them. Sibling to the anchor
// above, which had the same hole and lost the saved position instead.
followTail = true
scope.launch { listState.scrollToItem(0) }
},
// whose cost grows with how much there is to skip, which is backwards.
//
// Arriving there is all this has to do now. The newest end is where the
// content hangs from, so being at it is the whole of following it, and there
// is no separate flag to set -- which is what this press used to forget,
// landing the reader at the bottom with new messages not bringing the view
// with them.
onClick = { scope.launch { listState.scroll.scrollTo(0) } },
shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier =
@@ -0,0 +1,204 @@
package com.example.aiapp
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* The transcript's scroll position, addressed the way the transcript itself is: a row, and how far
* that row has been scrolled past the top of the viewport.
*
* Pixels are the unit underneath because this list is a plain [Column] rather than a lazy one --
* every loaded row is composed, measured and kept, so there is a real height for every row whether
* or not it is on screen, and no index has to stand in for one. What that buys is that scrolling
* back over a message never rebuilds it: a Compose node that is still alive and whose constraints
* have not changed is not re-measured at all (`MeasurePassDelegate.remeasure` returns early), so
* the text keeps the layout it was given. A lazy list disposes a row the moment it leaves the
* viewport, and the markdown tree, the measured lines and the cached paragraph go with it -- which
* is the lag when scrolling back over history that has already been read.
*
* The content is laid out oldest-first and scrolled in reverse, which is what makes the two
* insertions this list actually gets free rather than corrected:
*
* - `reverseScrolling` places the content by its *end*, so `value == 0` is the newest message and
* growing the content at the far end -- a page of older history landing -- moves nothing on
* screen. `maxValue` grows and `value` is left alone.
* - A new message extends the same end the viewport is pinned to, so following it is not an effect
* that has to notice and correct: at `value == 0` the newest content is simply what is on screen.
* The keyboard opening is the same case from the other side -- the viewport shrinks, `maxValue`
* grows, and the newest message is still against the bottom.
*
* Both of those were scroll corrections in the lazy version, each with a comment explaining a way
* it had been seen to fire at the wrong moment.
*/
@Stable
class TranscriptScroll(internal val scroll: ScrollState) {
/**
* Where each row's top edge sits inside the content, by the seq that names it.
*
* Written from the layout pass as rows are placed, so it describes the layout that is on
* screen. Keyed on [TranscriptRow.startSeq] rather than on the row's display key for the reason
* the anchor is: a tool run is renamed when the newest page starts somewhere new, and a
* position recorded against the old name is never found again.
*/
private val tops = HashMap<Long, Int>()
/**
* A position waiting to be put back, applied by the layout that first places its row.
*
* Held here rather than applied by whoever loaded the row because the pixels do not exist yet
* at that point: a plain column has no height for a row until it has been measured. Applying it
* from the placement is what makes the restore frame-exact -- the transcript is drawn where it
* was left rather than drawn at the newest end and then moved, which is a journey the reader
* would see. [settling] is the same fact asked the other way, and gates drawing.
*/
var pending: ScrollAnchor? by mutableStateOf(null)
private set
/** Whether a saved position is still being put back, so nothing should be drawn yet. */
val settling: Boolean
get() = pending != null
internal fun placed(seq: Long, top: Int) {
tops[seq] = top
pending?.let { anchor ->
if (anchor.seq != seq) return@let
scroll.dispatchRawDelta(
(scroll.maxValue - top - anchor.offset - scroll.value).toFloat()
)
pending = null
}
}
/** Everything these described is gone -- a stream reset, or a different session. */
fun clear() = tops.clear()
/** Whether the newest message is on screen. See the class comment: the newest end is zero. */
val atNewest: Boolean
get() = scroll.value == 0
/**
* How much unread history sits above the viewport, in pixels.
*
* This is the paging question asked in the unit it is actually about. Counting rows could not
* answer it: a row is anything from one line to a screenful, so "three rows back" is a
* different amount of reading depending on which three, and the count that mattered -- how far
* somebody can scroll before running out -- was never what was measured.
*/
val roomAbove: Int
get() = scroll.maxValue - scroll.value
/** The height of the visible area, 0 until the first measurement. */
val viewport: Int
get() = scroll.viewportSize
/** Where the reader is now, or null before anything has been laid out. */
fun anchor(): ScrollAnchor? {
val top = scroll.maxValue - scroll.value
// The row covering the top of the viewport: the last one that starts at or above it.
val at = tops.entries.filter { it.value <= top }.maxByOrNull { it.value } ?: return null
return ScrollAnchor(at.key, top - at.value)
}
/**
* Asks for [anchor] to be put back by the layout that places its row; see [pending].
*
* The caller is responsible for the row being loaded. Nothing here can wait for one that never
* arrives, and a position held open for it would leave the transcript blank -- so a row that is
* not in the transcript any more is [giveUp]'s case, not this one.
*/
fun restore(anchor: ScrollAnchor) {
pending = anchor
}
/** Draw where we are instead: the anchored row is not in this transcript any more. */
fun giveUp() {
pending = null
}
/**
* Scrolls by [pixels] without animating, from wherever the caller is.
*
* Used to hold a row's top edge still when it changes height. Growth already goes upward
* because the content hangs from its end, so the bottom edge is held for free and this is the
* other case; see `holdTopEdge`.
*/
fun by(pixels: Int) {
scroll.dispatchRawDelta(pixels.toFloat())
}
}
@Composable
fun rememberTranscriptScroll(key: Any?): TranscriptScroll {
val scroll = rememberScrollState()
return remember(key) { TranscriptScroll(scroll) }
}
/**
* Every loaded row, composed and kept. See [TranscriptScroll] for why this is not a lazy list.
*
* [viewportHeight] is passed in rather than measured here because a scrollable child is measured
* with no height bound at all, so the content cannot ask how tall the visible area is. It is what
* holds a conversation shorter than the screen against the bottom, where the composer is, instead
* of leaving a gap under it that cannot be scrolled away.
*/
@Composable
fun TranscriptColumn(
rows: List<TranscriptRow>,
state: TranscriptScroll,
viewportHeight: Dp,
contentPadding: PaddingValues,
spacing: Dp,
modifier: Modifier = Modifier,
below: @Composable () -> Unit,
row: @Composable (TranscriptRow) -> Unit,
) {
Column(
modifier
.verticalScroll(state.scroll, reverseScrolling = true)
.padding(contentPadding)
.heightIn(min = viewportHeight)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(spacing, Alignment.Bottom),
) {
rows.forEach { item ->
// Keyed so that a row keeps its composition -- and so the state inside it, an open
// tool call or an expanded reply, stays with the row rather than with the position.
key(item.key) {
Column(
Modifier.fillMaxWidth().onPlaced {
state.placed(item.startSeq, it.positionInParent().y.toInt())
}
) {
row(item)
}
}
}
below()
}
}
/** The gap between rows, and the room around the whole conversation. */
val TRANSCRIPT_SPACING: Dp = 8.dp
val TRANSCRIPT_PADDING: PaddingValues = PaddingValues(16.dp)