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

This commit is contained in:
iris committed 2026-08-30 19:50:07 -04:00
commit c5ecc63995
3 files changed
+127 -59

No files matched your search

@@ -8,16 +8,19 @@ private const val ANCHORS = "session-scroll"
/** /**
* Where a session's transcript was left, so reopening it lands where reading stopped. * Where a session's transcript was left, so reopening it lands where reading stopped.
* *
* Named by the *row* rather than by an index, because an index means nothing across a reopen: the * Named by a **sequence number** -- see [TranscriptRow.startSeq] -- rather than by an index or by
* transcript is fetched newest-first and a session that has said anything since has renumbered * the row key the list draws with. An index means nothing across a reopen, since the transcript is
* every position. The row's own key survives all of it -- it is the same value the list is keyed * fetched newest-first and a session that has said anything since has renumbered every position.
* by, which is what already stops the view moving when history pages in. * The row key looks stable and is not: a tool row is named after its run, `joinPages` gives a run
* the name of its newest half, and the newest half is whatever the newest page happened to start
* with -- so an active session renames its tool runs every time it is reopened, and an anchor
* naming one is never found. A seq is the server's own numbering, assigned once and never moved.
* *
* [offset] is how far into that row the viewport starts, in pixels, and is the reason this is a * [offset] is how far into that row the viewport starts, in pixels, and is the reason this is a
* pair rather than a key: a reader stopped halfway down a long tool output is put back halfway down * pair rather than a bare seq: a reader stopped halfway down a long tool output is put back halfway
* it. * down it.
*/ */
data class ScrollAnchor(val key: String, val offset: Int) data class ScrollAnchor(val seq: Long, val offset: Int)
/** /**
* On this device rather than on the backend, which is where this app otherwise keeps state so every * On this device rather than on the backend, which is where this app otherwise keeps state so every
@@ -29,10 +32,9 @@ fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? {
val stored = val stored =
context.getSharedPreferences(ANCHORS, Context.MODE_PRIVATE).getString(sessionId, null) context.getSharedPreferences(ANCHORS, Context.MODE_PRIVATE).getString(sessionId, null)
?: return null ?: return null
// Offset first so the split is unambiguous: a row key is an arbitrary string and may contain val seq = stored.substringBefore(':').toLongOrNull() ?: return null
// anything, where the offset is digits. val offset = stored.substringAfter(':').toIntOrNull() ?: return null
val offset = stored.substringBefore(':').toIntOrNull() ?: return null return ScrollAnchor(seq, offset)
return ScrollAnchor(stored.substringAfter(':'), offset)
} }
/** /**
@@ -46,6 +48,6 @@ fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? {
fun saveScrollAnchor(context: Context, sessionId: String, anchor: ScrollAnchor?) { fun saveScrollAnchor(context: Context, sessionId: String, anchor: ScrollAnchor?) {
context.getSharedPreferences(ANCHORS, Context.MODE_PRIVATE).edit { context.getSharedPreferences(ANCHORS, Context.MODE_PRIVATE).edit {
if (anchor == null) remove(sessionId) if (anchor == null) remove(sessionId)
else putString(sessionId, "${anchor.offset}:${anchor.key}") else putString(sessionId, "${anchor.seq}:${anchor.offset}")
} }
} }
@@ -113,14 +113,6 @@ private const val HISTORY_SCREENS = 3
*/ */
private const val HISTORY_PAGE = 800 private const val HISTORY_PAGE = 800
/**
* The list key of the bubble holding what has been sent and not read yet.
*
* Named rather than written at the `item` that draws it, because a saved scroll position stores
* whatever key it was left on and this is one of the values that can be.
*/
private const val QUEUED_KEY = "queued"
/** /**
* Which row was asked to hold its top edge, and how tall it was when it last measured. * Which row was asked to hold its top edge, and how tall it was when it last measured.
* *
@@ -844,19 +836,37 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
*/ */
fun itemsAboveRows() = if (queued.isNotEmpty() || waitingCommands.isNotEmpty()) 1 else 0 fun itemsAboveRows() = if (queued.isNotEmpty() || waitingCommands.isNotEmpty()) 1 else 0
/**
* Where the row named [key] sits in the list, or null when it is not loaded.
*
* Computed from `items` rather than from `rows` for the reason [loadOlderPage] gives.
*/
/** Everything the list draws, in items rather than in rows. See [itemsAboveRows]. */ /** Everything the list draws, in items rather than in rows. See [itemsAboveRows]. */
fun listItemCount() = groupToolRuns(items).size + itemsAboveRows() fun listItemCount() = groupToolRuns(items).size + itemsAboveRows()
fun indexOfKey(key: String): Int? { /**
// The bubble is not a row, and it is above all of them. * The row drawn at list index [index], or null when that item is not a row.
if (key == QUEUED_KEY) return if (itemsAboveRows() > 0) 0 else null *
val row = groupToolRuns(items).asReversed().indexOfFirst { it.key.toString() == key } * Computed from `items` rather than from `rows` for the reason [loadOlderPage] gives, and this
return if (row < 0) null else row + itemsAboveRows() * 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.
*
* Computed from `items` rather than from `rows`, as [rowAt] is and for the same reason.
*/
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.
val ordered = groupToolRuns(items)
val row = ordered.indexOfLast { it.startSeq <= seq }
if (row < 0) return null
return ordered.size - 1 - row + itemsAboveRows()
} }
/** /**
@@ -975,15 +985,22 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// the one worth restoring -- somebody who read to the bottom has no anchor at all -- // the one worth restoring -- somebody who read to the bottom has no anchor at all --
// and the cost was already paid on the way down there. // and the cost was already paid on the way down there.
savedAnchor?.let { anchor -> savedAnchor?.let { anchor ->
// Pages until the row is loaded and has something older behind it. The oldest // Pages until the anchor's row is loaded and has something older behind it. The
// loaded row is a half-row: `joinPages` welds the other half onto it when the // oldest loaded row is a half-row: `joinPages` welds the other half onto it when
// page behind it arrives, and it grows -- so anchoring into one puts the reader // the page behind it arrives, and it grows -- so anchoring into one puts the
// where they were only until the next page lands. Any row that is not the oldest // reader where they were only until the next page lands, which landed a screen
// is final. Landing a screen and a half out was what this cost. // and a half out. Any row that is not the oldest is final.
var index = indexOfKey(anchor.key) //
// This terminates because `oldestSeq` walks strictly backwards and the anchor is
// a seq: once the window reaches past it, some loaded row starts at or before it
// and [indexOfSeq] answers. Keying on the row's *name* instead could not promise
// 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 && (index == null || index >= listItemCount() - 1)) {
if (!loadOlderPage()) break if (!loadOlderPage()) break
index = indexOfKey(anchor.key) index = indexOfSeq(anchor.seq)
} }
// Both writes before this coroutine yields, so the list's first measurement is // 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 // the one with every row in it *and* the requested position -- the list is drawn
@@ -1099,34 +1116,52 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
} }
} }
// Whether they *chose* to be at the newest end, which is a different question from being // 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. Written // there and the one that decides whether an arriving message brings the view with it.
// where a scroll settles, below.
LaunchedEffect(listState) { LaunchedEffect(listState) {
snapshotFlow { listState.isScrollInProgress } snapshotFlow { listState.isScrollInProgress }
// The value `snapshotFlow` emits on collection is the state of things before anybody // 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 // has touched the list, and it is `false` -- which reads here as a scroll that has
// just ended at the newest end, and so as an instruction to forget where the reader // just ended, and so as an answer about where the reader chose to be. Only the
// was. That wiped every saved position on the way in, before the restore below could // transitions after it are scrolls.
// use it. Only the transitions after it are scrolls.
.drop(1) .drop(1)
.collect { scrolling -> .collect { scrolling -> if (!scrolling) followTail = atNewest }
if (scrolling) return@collect }
followTail = atNewest
// Written where the answer settles, for the same reason [followTail] is: mid-fling // Where the reader left off, written whenever the list settles somewhere new.
// is not where anybody left off. Cleared at the newest end rather than recorded, //
// because that is where a session with nothing to restore opens anyway -- so the // Driven by the position rather than by the scroll flag, and that is the whole point: a
// ordinary case costs a `remove` and no page-back on the way in. // *programmatic* scroll moves the list within one frame, so `isScrollInProgress` never
// observably changes and anything waiting for a settle never runs. Jump to latest is exactly
// 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.
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.
.drop(1)
.collect { settled ->
if (settled == null) return@collect
saveScrollAnchor( saveScrollAnchor(
context, context,
summary.id, summary.id,
if (atNewest) null // Nothing to restore at the newest end, which is where a session with no
else // anchor opens anyway -- so the ordinary case costs a `remove` and no
listState.layoutInfo.visibleItemsInfo.firstOrNull()?.let { first -> // page-back on the way in. [followTail] covers the frame described above,
ScrollAnchor( // where a row has just arrived and the position has not caught up yet;
first.key.toString(), // `rowAt` is null for the pending bubble, which sits above every row and is
listState.firstVisibleItemScrollOffset, // 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) },
) )
} }
} }
@@ -1426,7 +1461,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// themselves. What the session is *doing* about them is a // themselves. What the session is *doing* about them is a
// line below, in [SessionStatusRow]. // line below, in [SessionStatusRow].
if (!restoring && itemsAboveRows() > 0) { if (!restoring && itemsAboveRows() > 0) {
item(key = QUEUED_KEY) { item(key = "queued") {
Column(horizontalAlignment = Alignment.End) { Column(horizontalAlignment = Alignment.End) {
waitingCommands.forEach { (_, text) -> waitingCommands.forEach { (_, text) ->
CommandBubble(text, waiting = true) CommandBubble(text, waiting = true)
@@ -1622,7 +1657,19 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// whose cost grows with how much there is to skip, which is backwards. The // 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 // list is keyed and composes only what it lands on, so going straight there
// costs the same from anywhere. // costs the same from anywhere.
onClick = { scope.launch { listState.scrollToItem(0) } }, 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) }
},
shape = CircleShape, shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceContainerHigh, color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = modifier =
@@ -61,9 +61,25 @@ sealed class TranscriptRow {
*/ */
abstract val key: Any abstract val key: Any
/**
* Where this row starts in the transcript: the sequence number of the oldest event behind it.
*
* Separate from [key], and deliberately so. [key] is the list's identity and is a display
* decision -- a tool row is named after its run, and a run takes its name from whichever call
* was first when it was folded, which changes as pages arrive. A seq is the server's own
* numbering: it is assigned once, never moves, and means the same thing to every device. So
* anything that has to point at a place in the conversation and still find it later -- a saved
* scroll position is the one -- points with this, and anything that has to identify a row
* within one composition uses [key].
*/
abstract val startSeq: Long
data class Single(val item: TranscriptItem) : TranscriptRow() { data class Single(val item: TranscriptItem) : TranscriptRow() {
override val key: Any override val key: Any
get() = (item as? TranscriptItem.ToolRun)?.runId ?: item.seq get() = (item as? TranscriptItem.ToolRun)?.runId ?: item.seq
override val startSeq: Long
get() = item.seq
} }
/** Two or more calls with nothing between them; drawn as one collapsed card. */ /** Two or more calls with nothing between them; drawn as one collapsed card. */
@@ -74,6 +90,9 @@ sealed class TranscriptRow {
override val key: Any override val key: Any
get() = id get() = id
override val startSeq: Long
get() = calls.first().seq
} }
} }