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.
*
* Named by the *row* rather than by an index, because an index means nothing across a reopen: the
* transcript is fetched newest-first and a session that has said anything since has renumbered
* every position. The row's own key survives all of it -- it is the same value the list is keyed
* by, which is what already stops the view moving when history pages in.
* Named by a **sequence number** -- see [TranscriptRow.startSeq] -- rather than by an index or by
* the row key the list draws with. An index means nothing across a reopen, since the transcript is
* fetched newest-first and a session that has said anything since has renumbered every position.
* 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
* pair rather than a key: a reader stopped halfway down a long tool output is put back halfway down
* it.
* pair rather than a bare seq: a reader stopped halfway down a long tool output is put back halfway
* 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
@@ -29,10 +32,9 @@ fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? {
val stored =
context.getSharedPreferences(ANCHORS, Context.MODE_PRIVATE).getString(sessionId, null)
?: return null
// Offset first so the split is unambiguous: a row key is an arbitrary string and may contain
// anything, where the offset is digits.
val offset = stored.substringBefore(':').toIntOrNull() ?: return null
return ScrollAnchor(stored.substringAfter(':'), offset)
val seq = stored.substringBefore(':').toLongOrNull() ?: return null
val offset = stored.substringAfter(':').toIntOrNull() ?: return null
return ScrollAnchor(seq, offset)
}
/**
@@ -46,6 +48,6 @@ fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? {
fun saveScrollAnchor(context: Context, sessionId: String, anchor: ScrollAnchor?) {
context.getSharedPreferences(ANCHORS, Context.MODE_PRIVATE).edit {
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
/**
* 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.
*
@@ -844,19 +836,37 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
*/
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]. */
fun listItemCount() = groupToolRuns(items).size + itemsAboveRows()
fun indexOfKey(key: String): Int? {
// The bubble is not a row, and it is above all of them.
if (key == QUEUED_KEY) return if (itemsAboveRows() > 0) 0 else null
val row = groupToolRuns(items).asReversed().indexOfFirst { it.key.toString() == key }
return if (row < 0) null else row + itemsAboveRows()
/**
* The row drawn at list index [index], or null when that item is not a row.
*
* 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.
*
* 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 --
// and the cost was already paid on the way down there.
savedAnchor?.let { anchor ->
// Pages until the row is loaded and has something older behind it. The oldest
// loaded row is a half-row: `joinPages` welds the other half onto it when the
// page behind it arrives, and it grows -- so anchoring into one puts the reader
// where they were only until the next page lands. Any row that is not the oldest
// is final. Landing a screen and a half out was what this cost.
var index = indexOfKey(anchor.key)
// Pages until the anchor's row is loaded and has something older behind it. The
// oldest loaded row is a half-row: `joinPages` welds the other half onto it when
// the page behind it arrives, and it grows -- so anchoring into one puts the
// reader where they were only until the next page lands, which landed a screen
// and a half out. Any row that is not the oldest is final.
//
// 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)) {
if (!loadOlderPage()) break
index = indexOfKey(anchor.key)
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
@@ -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
// there and the one that decides whether an arriving message brings the view with it. Written
// where a scroll settles, below.
// 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 at the newest end, and so as an instruction to forget where the reader
// was. That wiped every saved position on the way in, before the restore below could
// use it. Only the transitions after it are scrolls.
// 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) return@collect
followTail = atNewest
// Written where the answer settles, for the same reason [followTail] is: mid-fling
// 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
// ordinary case costs a `remove` and no page-back on the way in.
.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
// *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(
context,
summary.id,
if (atNewest) null
else
listState.layoutInfo.visibleItemsInfo.firstOrNull()?.let { first ->
ScrollAnchor(
first.key.toString(),
listState.firstVisibleItemScrollOffset,
)
},
// 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) },
)
}
}
@@ -1426,7 +1461,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// themselves. What the session is *doing* about them is a
// line below, in [SessionStatusRow].
if (!restoring && itemsAboveRows() > 0) {
item(key = QUEUED_KEY) {
item(key = "queued") {
Column(horizontalAlignment = Alignment.End) {
waitingCommands.forEach { (_, text) ->
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
// list is keyed and composes only what it lands on, so going straight there
// 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,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier =
@@ -61,9 +61,25 @@ sealed class TranscriptRow {
*/
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() {
override val key: Any
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. */
@@ -74,6 +90,9 @@ sealed class TranscriptRow {
override val key: Any
get() = id
override val startSeq: Long
get() = calls.first().seq
}
}