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
+510 -277

No files matched your search

@@ -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,90 +1375,62 @@ 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(
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
// line below, in [SessionStatusRow].
if (!restoring && itemsAboveRows() > 0) {
item(key = "queued") {
Column(horizontalAlignment = Alignment.End) {
waitingCommands.forEach { (_, text) ->
CommandBubble(text, waiting = true)
}
queued.forEach { waiting ->
UserBubble(
settings = settings,
sessionId = summary.id,
text = waiting.text,
images = waiting.images,
pending = true,
)
BoxWithConstraints(Modifier.fillMaxSize()) {
TranscriptColumn(
rows = rows,
state = listState,
// 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 (queued.isNotEmpty() || waitingCommands.isNotEmpty()) {
Column(horizontalAlignment = Alignment.End) {
waitingCommands.forEach { (_, text) ->
CommandBubble(text, waiting = true)
}
queued.forEach { waiting ->
UserBubble(
settings = settings,
sessionId = summary.id,
text = waiting.text,
images = waiting.images,
pending = true,
)
}
}
}
}
}
// 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 ->
AssistantMessage(item.text, replies)
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 =