Keep the reader's place: in a group, in a compaction, and in the list

Five things Iris asked for, all about the transcript screen holding still
around whoever is reading it.

A tool call opened on its own stayed open when a second call in the same
run turns it into a group. Watching a Bash call and having the session
make another one used to shut the card being read and fold it behind
"Called 2 tools" -- the reader lost their place because something else
happened. The transition is noticed once, at the moment a run first
becomes a group; after that the group's own toggle owns it, so shutting a
group whose inner call is still expanded does not re-open it.

The compaction clock is taken from the `compacting` status event's own
timestamp rather than from this device noticing one, so it survives
leaving the session and coming back -- it used to disappear, because the
only thing that knew when the compaction started was a screen that had
been disposed. The server timestamps every transcript line, so this is
still a measurement; it is compared against the phone's wall clock, which
is the same comparison a session's "last active" already makes.

Session settings are a dialog over the session instead of a screen below
it. Two controls did not warrant a page transition and a back stack, and
the thing they change was hidden while they were on screen. Captions are
gone -- each control is a labelled noun -- and "Notify me" is
"Notifications" with a bell beside it (`md-bell`, added to the committed
Nerd Fonts subset). Failures keep their words, since those are what a
reader cannot work out by looking.

Tool groups are rounded like every other card, their foot bar is the same
height as their heading (both derived from the heading's own line height,
so the pair cannot drift), and the calls inside are a connected stack:
square where they face a neighbour, rounded on the outside, with a small
gap so the join reads as a join.

Scroll position is persistent on the device, per session, keyed by the
row rather than by an index -- an index means nothing across a reopen,
where the transcript is fetched newest-first. Reopening pages backwards
until that row is loaded *and* has something older behind it, because the
oldest loaded row is a half-row that grows when the page behind it
arrives; anchoring into one landed a screen and a half out. The list
draws nothing until the position lands, so there is no frame in which the
transcript is somewhere other than where it was left.

Two things found on the way. `snapshotFlow`'s first emission is the state
before anybody has touched the list, and reading it as a scroll that had
just ended at the newest end wiped every saved position on the way in.
And backwards pages now ask for 800 events rather than 80: ai-app-2
measured a real transcript at 2,426 events for seven assistant messages,
so a page of eighty is a fifth of one row and filling the lookahead took
about thirty sequential round trips -- seconds of a list that will not
move, over the tunnel.

`/tools [n] [gap]` in the echo driver takes seconds between calls, which
is what makes a run grow slowly enough for somebody to have opened one of
its calls first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-30 19:06:11 -04:00
1 parent 8b0e654733
commit 61d2c78afe
10 files changed
+626 -323

No files matched your search

@@ -29,7 +29,9 @@ import kotlinx.coroutines.withContext
*
* Import, models and setups are not here any more. They are tabs inside [MainScreen] -- four views
* of the same backend, none of them a step down from another -- and what is left in this `when` is
* only what genuinely is a step down: one session, spawning one, and settings.
* only what genuinely is a step down: one session, spawning one, and settings. A session's own
* settings are not among them: they are a dialog over the session, which is where the thing they
* change is.
*/
private sealed class Screen {
data object Main : Screen()
@@ -38,13 +40,6 @@ private sealed class Screen {
data object Spawn : Screen()
/**
* What can be changed about one session. Carries the session back with it so Back returns where
* it came from, and carries it *out* renamed, so the session behind it shows the new name
* without waiting for a list refresh.
*/
data class SessionSettings(val from: SessionSummary) : Screen()
data object Settings : Screen()
}
@@ -174,12 +169,7 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
// row key. Only reachable since a notification can move straight from one session to
// another; every other way here passes through [Screen.Main], which disposes it anyway.
key(here.summary.id) {
SessionScreen(
settings = current,
summary = here.summary,
onBack = goToMain,
onSettings = { screen = Screen.SessionSettings(here.summary) },
)
SessionScreen(settings = current, summary = here.summary, onBack = goToMain)
}
is Screen.Spawn ->
SpawnScreen(
@@ -190,18 +180,6 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
},
onBack = goToMain,
)
is Screen.SessionSettings ->
SessionSettingsScreen(
settings = current,
session = here.from,
onRenamed = { renamed ->
// The list shows the name too, so it has to refetch; and the session
// returned to is the renamed one, not the one this was opened from.
reloadToken++
screen = Screen.Session(renamed)
},
onBack = { screen = Screen.Session(here.from) },
)
is Screen.Settings ->
SettingsScreen(
existing = current,
@@ -26,9 +26,9 @@ import androidx.compose.ui.unit.sp
* grounds that a system font may not have the glyph and whoever gets the empty box instead is never
* the person who wrote it. That objection is about *relying* on a system font, and it is exactly
* right: the answer is not to avoid glyphs but to ship them. The font here is
* `app/build-icon-font.sh`'s output -- five glyphs, 1.4 KB, subset out of the 3 MB symbols font and
* committed -- so the codepoints below are resolved by an asset in the APK and cannot come back as
* tofu. Adding one means adding its codepoint in *both* places; a codepoint here that the script
* `app/build-icon-font.sh`'s output -- eleven glyphs, 2.1 KB, subset out of the 3 MB symbols font
* and committed -- so the codepoints below are resolved by an asset in the APK and cannot come back
* as tofu. Adding one means adding its codepoint in *both* places; a codepoint here that the script
* did not subset is a glyph that silently isn't there.
*
* The subset is the font's **Mono** face, where every glyph is exactly one em wide and one em tall.
@@ -94,6 +94,9 @@ val CLOSE_GLYPH = glyph(0xF0156)
/** `md-arrow_left` -- back one level, to whatever this was opened from. */
val BACK_GLYPH = glyph(0xF004D)
/** `md-bell` -- the notifications this session is allowed to raise. */
val BELL_GLYPH = glyph(0xF009A)
/**
* `fa-line_chart` -- how much of the account's rate limits is gone.
*
@@ -0,0 +1,51 @@
package com.example.aiapp
import android.content.Context
import androidx.core.content.edit
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.
*
* [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.
*/
data class ScrollAnchor(val key: String, val offset: Int)
/**
* On this device rather than on the backend, which is where this app otherwise keeps state so every
* device sees it. Scroll position is the same exception a draft is: it is where the phone in
* somebody's hand is pointed, and having one device jump because another was scrolled would be a
* surprise rather than a convenience.
*/
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)
}
/**
* Records where [sessionId] is being read, or forgets it when [anchor] is null.
*
* The path out is reading to the newest end, which is what the caller passes null for: a session
* left at the bottom has nothing to restore and should open at the bottom, which is also the cheap
* case. A session *deleted* while it held an anchor leaves its key behind, for the reason and at
* the cost `Drafts.kt` describes.
*/
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}")
}
}
@@ -64,6 +64,7 @@ import java.util.concurrent.atomic.AtomicReference
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -82,6 +83,29 @@ private const val RECONNECT_DELAY_MS = 1500L
*/
private const val HISTORY_LOOKAHEAD = 8
/**
* How many events a backwards page asks for, which is ten times what the opening page takes.
*
* Because an event is not a row, and the ratio is nothing like one to one. Measured on a real
* transcript (2,426 events, 2026-08-30): the whole conversation is *seven* assistant messages, and
* the median run of consecutive text deltas that fold into one of them is four hundred. A page of
* eighty is therefore a fifth of a single row, and reaching [HISTORY_LOOKAHEAD] fresh rows took
* about thirty sequential round trips inside one collect -- a stutter on loopback, and four or five
* seconds of a list that will not move over the tunnel, which reads as history having run out.
*
* The opening page stays small: it is the one on the critical path of showing the screen at all,
* and it only has to fill a viewport.
*/
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.
*
@@ -547,12 +571,7 @@ private suspend fun warm(replies: ParsedReplies, rows: List<TranscriptItem>) {
}
@Composable
fun SessionScreen(
settings: ServerSettings,
summary: SessionSummary,
onBack: () -> Unit,
onSettings: () -> Unit,
) {
fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () -> Unit) {
val scope = rememberCoroutineScope()
val topEdgeHeld = remember { TopEdgeHold() }
var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
@@ -561,10 +580,12 @@ fun SessionScreen(
// how much it is holding before any turn happens here. Null is "nobody has measured it",
// which is a different answer from an empty context and is drawn differently.
var contextTokens by remember(summary.id) { mutableStateOf(summary.contextTokens) }
// When this screen saw the current compaction start, on this device's own clock, and how long
// ago that is. See `compactingLabel`: null is the honest answer whenever the start was not
// witnessed here, which is what opening a session that is already compacting looks like.
var compactingSince by remember { mutableStateOf<Long?>(null) }
// When the current compaction started and how long ago that is. The moment comes off the
// `compacting` status event itself -- the server timestamps every transcript line -- rather
// than off this device noticing one, which is what makes it survive leaving the session and
// reopening it. See `compactingLabel`: null is still the honest answer for a session whose
// status was never reported as compacting at all.
var compactingSince by remember { mutableStateOf<Double?>(null) }
var compactingFor by remember { mutableStateOf<Long?>(null) }
var streamError by remember { mutableStateOf<String?>(null) }
var actionError by remember { mutableStateOf<String?>(null) }
@@ -587,6 +608,9 @@ fun SessionScreen(
// Which runs of adjacent tool calls are open. Keyed by the first call's
// id, so a group survives more calls arriving after it.
var expandedGroups by remember { mutableStateOf(setOf<String>()) }
// Runs that have already been drawn as a group, so the transition into one is noticed exactly
// once. See the effect below.
var everGrouped by remember { mutableStateOf(setOf<String>()) }
// Which messages from other agents are open, by the seq that identifies their row. Closed
// by default, which is the rule for anything new in this transcript: a screen that opens
// everything it can is one nobody can scan.
@@ -614,6 +638,25 @@ fun SessionScreen(
// screen starts with the end of the conversation and fetches earlier
// pages only when somebody scrolls to them.
var oldestSeq by remember { mutableLongStateOf(0L) }
// Where this session was last being read, from this device's own store. Read once, because
// 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.
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
@@ -728,16 +771,15 @@ fun SessionScreen(
event.permissionMode?.let { permissionMode = it }
}
if (event is SessionEvent.Status) {
// Started here, or nowhere. `ready` is what separates the live stream from
// the page of history the screen opens with, and a compaction found in that
// page began before anybody here was watching -- timing it from now would
// report the moment we arrived as the moment it started.
// The event's own timestamp, so a compaction that began before this screen
// opened is timed from when it actually began. Timing it from the moment we
// arrived would report the wait as shorter than it was, in exactly the case
// somebody is asking about -- a compaction worth asking about is a long one.
compactingSince =
when {
event.state != "compacting" -> null
status == "compacting" -> compactingSince
ready -> SystemClock.elapsedRealtime()
else -> null
else -> entry.ts
}
status = event.state
}
@@ -771,6 +813,88 @@ fun SessionScreen(
toggle()
}
/**
* How many list items sit above every transcript row -- one while something is waiting, none
* otherwise.
*
* 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
/**
* 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()
}
/**
* One page of older events onto the front of what is loaded; false when there was none.
*
* Shared by the two things that page backwards -- somebody scrolling to the far end, and
* putting the list back where it was left -- because they want the same page for the same
* reason and a second copy of this would be a second answer to "what is loaded".
*
* Reads `items` rather than `rows`: this runs in a coroutine, and `rows` is the composition's
* value, which does not change under a running one.
*/
suspend fun loadOlderPage(): Boolean {
val older =
withContext(Dispatchers.IO) {
fetchTranscript(settings, summary.id, before = oldestSeq, limit = HISTORY_PAGE)
}
if (older.isEmpty()) {
moreHistory = false
return false
}
oldestSeq = older.first().seq
moreHistory = oldestSeq > 1L
// Folded oldest-first into a list of their own, then put in front: `foldEvent` merges
// streaming text into the item before it, so replaying an older page through the live
// list would glue it onto the newest message rather than its own.
var earlier = listOf<TranscriptItem>()
older.forEach { entry ->
if (entry.event !is SessionEvent.UsageDelta) {
earlier = foldEvent(earlier, entry)
}
}
val joined = joinPages(earlier, items)
// After the join rather than on the page alone: a boundary that fell through a reply
// leaves `joinPages` holding a message made of both halves, and that text has existed for
// no time at all. Warming the page by itself warmed the two halves and missed the one
// thing drawn -- which showed up as a single 22ms parse surviving every page.
warm(replies, joined)
items = joined
return true
}
// A call opened on its own stays open when a second call in the same run turns it into a
// group. Until this, watching a Bash call and having the session make another one shut the
// one being read and folded it behind "Called 2 tools" -- the reader lost what they were
// looking at because something else happened.
//
// Considered once per run, at the moment it first becomes a group, and never again: after
// that the group's own toggle owns it, and re-deriving this every time would re-open a group
// the reader had just shut while one of its calls was still expanded.
LaunchedEffect(rows) {
val fresh = rows.filterIsInstance<TranscriptRow.Tools>().filter { it.id !in everGrouped }
if (fresh.isEmpty()) return@LaunchedEffect
expandedGroups =
expandedGroups +
fresh.filter { group -> group.calls.any { it.id in expandedTools } }.map { it.id }
everGrouped = everGrouped + fresh.map { it.id }
}
// A compaction reports nothing about its own progress -- measured against the CLI, which
// says it has started, and then says nothing at all until it is done. So what this counts is
// the one thing anybody here can measure: how long it has been going. A bar filling up would
@@ -782,7 +906,11 @@ fun SessionScreen(
return@LaunchedEffect
}
while (true) {
compactingFor = (SystemClock.elapsedRealtime() - since) / 1000
// Against this device's wall clock, because `since` is the server's -- the same
// comparison `relativeTime` already makes for a session's last activity. Floored at
// zero so a phone running a little behind the backend counts up from nothing rather
// than reporting a compaction that has not started yet.
compactingFor = (System.currentTimeMillis() / 1000.0 - since).toLong().coerceAtLeast(0)
delay(1000)
}
}
@@ -794,15 +922,50 @@ fun SessionScreen(
// stream then starts from where that page ended, so it carries live
// events only -- which is what it is good at.
LaunchedEffect(summary.id) {
// Whether the list ended up where the reader left it. False covers every way it did not
// -- no saved position, a row that is no longer in the transcript, a page that never
// arrived -- and all of them mean the same thing to the list: this is the newest end now,
// so follow it.
var restored = false
try {
val page = withContext(Dispatchers.IO) { fetchTranscript(settings, summary.id) }
page.forEach { apply(it) }
warm(replies, items)
// Then back where reading stopped. An anchor deeper than the newest page is exactly
// 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)
while (moreHistory && (index == null || index >= listItemCount() - 1)) {
if (!loadOlderPage()) break
index = indexOfKey(anchor.key)
}
// 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].
restoring = false
// A null index is a row that is no longer in the transcript -- a reset stream, or
// a session cleared from elsewhere.
index?.let {
listState.requestScrollToItem(it, anchor.offset)
restored = true
}
}
} catch (e: ApiException) {
// Not fatal: the stream below still replays from zero, which is
// slow but complete. Saying so beats silently showing nothing.
streamError = e.message
}
if (!restored) followTail = true
// 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
ready = true
}
@@ -895,20 +1058,37 @@ fun SessionScreen(
backlog.forEach { record(it) }
}
}
// Whether they *chose* to be there, which is a different question and the one that decides
// whether an arriving message brings the view with it.
//
// 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.
var followTail by remember { mutableStateOf(true) }
// 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.
LaunchedEffect(listState) {
snapshotFlow { listState.isScrollInProgress }
.collect { scrolling -> if (!scrolling) followTail = atNewest }
// 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.
.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.
saveScrollAnchor(
context,
summary.id,
if (atNewest) null
else
listState.layoutInfo.visibleItemsInfo.firstOrNull()?.let { first ->
ScrollAnchor(
first.key.toString(),
listState.firstVisibleItemScrollOffset,
)
},
)
}
}
// 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
@@ -978,36 +1158,7 @@ fun SessionScreen(
// composition's value and does not change under a running coroutine.
val start = groupToolRuns(items).size
var have = start
while (moreHistory && have - start < HISTORY_LOOKAHEAD) {
val older =
withContext(Dispatchers.IO) {
fetchTranscript(settings, summary.id, before = oldestSeq)
}
if (older.isEmpty()) {
moreHistory = false
break
}
oldestSeq = older.first().seq
moreHistory = oldestSeq > 1L
// Folded oldest-first into a list of their own, then
// put in front: `foldEvent` merges streaming text
// into the item before it, so replaying an older page
// through the live list would glue it onto the newest
// message rather than its own.
var earlier = listOf<TranscriptItem>()
older.forEach { entry ->
if (entry.event !is SessionEvent.UsageDelta) {
earlier = foldEvent(earlier, entry)
}
}
val joined = joinPages(earlier, items)
// After the join rather than on the page alone: a boundary that fell
// through a reply leaves `joinPages` holding a message made of both
// halves, and that text has existed for no time at all. Warming the page
// by itself warmed the two halves and missed the one thing drawn --
// which showed up as a single 22ms parse surviving every page.
warm(replies, joined)
items = joined
while (moreHistory && have - start < HISTORY_LOOKAHEAD && loadOlderPage()) {
have = groupToolRuns(items).size
}
} catch (_: ApiException) {
@@ -1120,6 +1271,7 @@ fun SessionScreen(
// the header, and the colour of the button that opens the dialog.
val usage = rememberSessionUsage(settings, summary.setup)
var usageOpen by remember { mutableStateOf(false) }
var settingsOpen by remember { mutableStateOf(false) }
Column(Modifier.fillMaxSize()) {
Row(
@@ -1166,10 +1318,10 @@ fun SessionScreen(
{ usageOpen = true },
colour = usageGlyphColour(usage),
)
// A step down from this session, so it sits at the end of the session's own row.
// The name is the whole of what it holds today, which is why it is a cog and not
// a word: there will be more, and a bar of words has nowhere to put it.
GlyphButton(SETTINGS_GLYPH, "Session settings", onSettings)
// What it opens is about this session, so it sits at the end of the session's
// own row. The name is the whole of what it holds today, which is why it is a cog
// and not a word: there will be more, and a bar of words has nowhere to put it.
GlyphButton(SETTINGS_GLYPH, "Session settings", { settingsOpen = true })
}
}
@@ -1199,6 +1351,10 @@ fun SessionScreen(
// 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()
Box(Modifier.weight(1f).fillMaxWidth()) {
LazyColumn(
state = listState,
@@ -1221,8 +1377,8 @@ fun SessionScreen(
// 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()) {
item(key = "queued") {
if (!restoring && itemsAboveRows() > 0) {
item(key = QUEUED_KEY) {
Column(horizontalAlignment = Alignment.End) {
waitingCommands.forEach { (_, text) ->
CommandBubble(text, waiting = true)
@@ -1251,7 +1407,7 @@ fun SessionScreen(
// 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(rows.asReversed(), key = { it.key }) { row ->
items(drawnRows, key = { it.key }) { row ->
val bounds = remember { RowBounds() }
Box(
Modifier.onGloballyPositioned {
@@ -1592,6 +1748,21 @@ fun SessionScreen(
if (usageOpen) {
UsageDialog(settings = settings, onDismiss = { usageOpen = false })
}
if (settingsOpen) {
SessionSettingsDialog(
settings = settings,
sessionId = summary.id,
title = title,
// The header takes the new name at once and the dialog closes on it, because the
// rename has already been accepted by the server -- see [title], which is this app's
// own datum. The list behind this refetches on the way out of the session anyway.
onRenamed = {
title = it
settingsOpen = false
},
onDismiss = { settingsOpen = false },
)
}
}
/** What pressing Send does right now, said the same way to the eye and to a screen reader. */
@@ -0,0 +1,189 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* What can be changed about one session, as opposed to about this app.
*
* Over the session rather than a step down from it: everything here is about the conversation
* behind it, and a dialog keeps that conversation on screen while it is being adjusted. It was a
* screen of its own until 2026-08-30, which put a page transition and a back stack around two
* controls and hid the thing they act on.
*
* The model and the permission mode are deliberately still on the session's own bar, because those
* are changed *while* reading a turn -- "not this model, try that one" -- and a control belongs
* with the thing it acts on.
*
* Nothing here is captioned. Each control is a labelled noun with a switch or a field beside it,
* and a paragraph under every one of them made the dialog longer than the conversation it covers.
* Failures still get their words: those are what the reader cannot work out by looking.
*/
@Composable
fun SessionSettingsDialog(
settings: ServerSettings,
sessionId: String,
/**
* What the session is called now, as the screen behind this knows it -- see the rename below.
*/
title: String,
onRenamed: (String) -> Unit,
onDismiss: () -> Unit,
) {
val scope = rememberCoroutineScope()
var name by remember(sessionId) { mutableStateOf(title) }
var saving by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
// Null until the server has been asked. The row this dialog was opened over is a snapshot of
// whenever the list was last fetched, so drawing the switch straight from it would show a
// position that may have been changed since -- from here or from another device -- with
// nothing to say so. Until the answer arrives the switch is disabled and a spinner sits beside
// it, which is what not knowing looks like: distinguishable from off, and from a refusal.
var notify by remember(sessionId) { mutableStateOf<Boolean?>(null) }
var notifyError by remember { mutableStateOf<String?>(null) }
LaunchedEffect(sessionId) {
notify =
try {
withContext(Dispatchers.IO) { fetchSession(settings, sessionId).notify }
} catch (e: ApiException) {
// Left unknown rather than falling back to the stale row: the switch stays
// disabled, instead of offering a position nothing confirmed.
notifyError = e.message
null
}
}
// Moved optimistically so the switch answers the finger that moved it, and put back if the
// request is refused -- a switch that waits for a round trip reads as broken on a slow
// tunnel, and one that stays moved after a refusal lies.
fun setNotify(wanted: Boolean) {
val was = notify
notify = wanted
notifyError = null
scope.launch {
try {
withContext(Dispatchers.IO) { setSessionNotify(settings, sessionId, wanted) }
} catch (e: ApiException) {
notify = was
notifyError = e.message
}
}
}
// Nothing to do when the name has not changed, so the button says so rather than sending a
// request whose success would look exactly like the failure of having typed nothing.
val changed = name.trim().isNotEmpty() && name.trim() != title
fun save() {
if (!changed || saving) return
val chosen = name.trim()
saving = true
error = null
scope.launch {
try {
withContext(Dispatchers.IO) { renameSession(settings, sessionId, chosen) }
onRenamed(chosen)
} catch (e: ApiException) {
// Reported here, where it happened, because this dialog is the only place that
// knows a rename was attempted -- the session behind it shows nothing about it.
error = e.message
saving = false
}
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Session settings") },
text = {
Column {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
singleLine = true,
enabled = !saving,
modifier = Modifier.fillMaxWidth(),
// The keyboard's own action does what the button does: a one-field form
// where the return key does nothing is a form people press return at anyway.
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { save() }),
)
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Glyph(BELL_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
Spacer(Modifier.width(8.dp))
Text("Notifications", modifier = Modifier.weight(1f))
if (notify == null && notifyError == null) {
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
strokeWidth = 2.dp,
)
Spacer(Modifier.width(8.dp))
}
Switch(
checked = notify == true,
onCheckedChange = { setNotify(it) },
enabled = notify != null,
)
}
// Beside the switch that failed, not with the rename's error: they are two
// requests and a reader has to be able to tell which one the server refused.
notifyError?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
error?.let {
Spacer(Modifier.height(8.dp))
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
}
},
// Disabled rather than absent while there is nothing to save: a button that comes and
// goes makes its own presence the signal, and its absence cannot say why.
confirmButton = {
TextButton(onClick = { save() }, enabled = changed && !saving) {
Text(if (saving) "Saving..." else "Save")
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Close") } },
)
}
@@ -1,196 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* What can be changed about one session, as opposed to about this app.
*
* A step down from the session rather than a menu over it: the name is a text field with a keyboard
* in front of it, and that is more than belongs in a bar above a conversation. Back returns to the
* session it was opened from, which is the only thing back can mean here.
*
* The name is the one setting so far. The model and the permission mode are deliberately still on
* the session's own bar, because those are changed *while* reading a turn -- "not this model, try
* that one" -- and a control belongs with the thing it acts on.
*/
@Composable
fun SessionSettingsScreen(
settings: ServerSettings,
session: SessionSummary,
onRenamed: (SessionSummary) -> Unit,
onBack: () -> Unit,
) {
val scope = rememberCoroutineScope()
var name by remember(session.id) { mutableStateOf(session.title) }
var saving by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
// Null until the server has been asked. The row this screen was opened from is a snapshot of
// whenever the list was last fetched, so drawing the switch straight from it would show a
// position that may have been changed since -- from here or from another device -- with
// nothing to say so. Until the answer arrives the switch is disabled and the caption says it
// is being read, which is the one honest thing a two-position control can do about not
// knowing.
var notify by remember(session.id) { mutableStateOf<Boolean?>(null) }
var notifyError by remember { mutableStateOf<String?>(null) }
LaunchedEffect(session.id) {
notify =
try {
withContext(Dispatchers.IO) { fetchSession(settings, session.id).notify }
} catch (e: ApiException) {
// Left unknown rather than falling back to the stale row: the switch stays
// disabled and says why, instead of offering a position nothing confirmed.
notifyError = e.message
null
}
}
// Moved optimistically so the switch answers the finger that moved it, and put back if the
// request is refused -- a switch that waits for a round trip reads as broken on a slow
// tunnel, and one that stays moved after a refusal lies.
fun setNotify(wanted: Boolean) {
val was = notify
notify = wanted
notifyError = null
scope.launch {
try {
withContext(Dispatchers.IO) { setSessionNotify(settings, session.id, wanted) }
} catch (e: ApiException) {
notify = was
notifyError = e.message
}
}
}
// Nothing to do when the name has not changed, so the button says so rather than sending a
// request whose success would look exactly like the failure of having typed nothing.
val changed = name.trim().isNotEmpty() && name.trim() != session.title
fun save() {
if (!changed || saving) return
val chosen = name.trim()
saving = true
error = null
scope.launch {
try {
withContext(Dispatchers.IO) { renameSession(settings, session.id, chosen) }
onRenamed(session.copy(title = chosen))
} catch (e: ApiException) {
// Reported here, where it happened, because this screen is the only place that
// knows a rename was attempted -- the session behind it shows nothing about it.
error = e.message
saving = false
}
}
}
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
GlyphButton(BACK_GLYPH, "Back", onBack)
Spacer(Modifier.width(8.dp))
Text(
"Session settings",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f),
)
}
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
singleLine = true,
enabled = !saving,
modifier = Modifier.fillMaxWidth(),
// The keyboard's own action does what the button does: a one-field form where the
// return key does nothing is a form people press return at anyway.
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { save() }),
)
Text(
"Passed on to whatever is running this session, so Claude Code's own session " +
"picker and any agent listing sessions use the same name.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
Spacer(Modifier.height(24.dp))
// The switch gets its own row rather than sitting beside the label: a control is taller
// than a line of text, so putting one in a row with a label re-centres that label and
// knocks it out of line with everything above it.
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text("Notify me", modifier = Modifier.weight(1f))
Switch(
checked = notify == true,
onCheckedChange = { setNotify(it) },
enabled = notify != null,
)
}
Text(
if (notify == null && notifyError == null) "Reading the current setting..."
else
"A notification when this session asks you something or finishes a turn. Kept " +
"on the backend, so every device agrees about which sessions are worth " +
"interrupting you for.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
notifyError?.let {
Spacer(Modifier.height(4.dp))
// Beside the switch that failed, not with the rename's error: they are two requests
// and a reader has to be able to tell which one the server refused.
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(Modifier.height(24.dp))
// Disabled rather than absent while there is nothing to save: a button that comes and
// goes makes its own presence the signal, and its absence cannot say why.
Button(onClick = { save() }, enabled = changed && !saving) {
Text(if (saving) "Saving..." else "Save")
}
error?.let {
Spacer(Modifier.height(8.dp))
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
}
}
@@ -12,7 +12,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CornerBasedShape
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -20,13 +23,17 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
@@ -140,12 +147,21 @@ fun Modifier.clickableAt(onClick: (Float) -> Unit): Modifier {
/**
* Several calls under one heading, closed until somebody asks.
*
* The calls keep their own full width -- no indent, no inset -- because they are the same rows they
* would be on their own, and stepping them in would say they are something lesser. What says they
* belong together is the surface behind them, which is the one cue rather than two half-cues.
* What says the calls belong together is the surface behind them, which is the one cue rather than
* two half-cues -- rounded to the same corner every other card in the app has, so a group reads as
* one object rather than as a square patch behind round things. The calls sit on it inset by
* [GROUP_INSET], which is the container's own padding rather than an indent: they are the same rows
* they would be on their own, and a rounded corner drawn hard against a rounded corner reads as a
* notch.
*
* Inside, the calls are a connected stack. Facing corners are square and the outer ones are not, so
* the run reads as one thing broken into its parts; [GROUP_GAP] keeps the parts legible without
* separating them. See [connectedShape].
*
* It closes from either end. A long group's header scrolls off while its last call is still on
* screen, and the reader who wants it shut is looking at the bottom, not hunting for the top.
* screen, and the reader who wants it shut is looking at the bottom, not hunting for the top. The
* bar at the foot is the same height as the heading at the top, so the surface the calls sit on is
* as thick below them as above.
*/
@Composable
fun ToolGroup(
@@ -160,52 +176,116 @@ fun ToolGroup(
onAnswer: (questionId: String, answers: List<String>) -> Unit,
image: @Composable (String) -> Unit,
) {
val heading = "Called ${group.calls.size} tools"
if (!expanded) {
Card(Modifier.fillMaxWidth().clickableAt(onToggle)) {
Text(
"Called ${group.calls.size} tools",
heading,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(12.dp),
modifier = Modifier.padding(GROUP_INSET_LARGE),
)
}
return
}
Column(Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceContainerLow)) {
Text(
"Called ${group.calls.size} tools",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.fillMaxWidth().clickableAt(onToggle).padding(12.dp),
)
group.calls.forEach { call ->
ToolCard(
tool = call,
expanded = isToolExpanded(call.id),
onToggle = { at -> onToolToggle(call.id, at) },
onAnswer = onAnswer,
image = image,
Column(
Modifier.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceContainerLow)
) {
val barHeight = groupBarHeight()
Row(
Modifier.fillMaxWidth().height(barHeight).clickableAt(onToggle),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
heading,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(horizontal = GROUP_INSET_LARGE),
)
}
Column(
Modifier.padding(horizontal = GROUP_INSET),
verticalArrangement = Arrangement.spacedBy(GROUP_GAP),
) {
group.calls.forEachIndexed { index, call ->
ToolCard(
tool = call,
expanded = isToolExpanded(call.id),
onToggle = { at -> onToolToggle(call.id, at) },
onAnswer = onAnswer,
image = image,
shape = connectedShape(index, group.calls.size),
)
}
}
// Shutting it from here anchors the other end: the reader is at the bottom of a long
// group, and what they are looking at is what follows it.
CollapseBar(onToggle)
CollapseBar(barHeight, onToggle)
}
}
/** The bottom half of a group's toggle: an arrow back up to its heading. */
/**
* The height of a group's heading, and so of the bar at its foot.
*
* Derived from the type the heading is set in rather than written down, because the two have to
* match and a pair of numbers chosen to look equal stops being equal the moment either the style or
* the density changes. Taking the line height also means the heading cannot be clipped by it.
*/
@Composable
private fun CollapseBar(onToggle: (Float) -> Unit) {
private fun groupBarHeight(): Dp {
val line = MaterialTheme.typography.titleSmall.lineHeight
return with(LocalDensity.current) { line.toDp() } + GROUP_INSET_LARGE * 2
}
/**
* The bottom half of a group's toggle: an arrow back up to its heading.
*
* Given the heading's height rather than padded to something that looks close, so the surface the
* calls sit on is the same thickness at both ends. See [groupBarHeight].
*/
@Composable
private fun CollapseBar(height: Dp, onToggle: (Float) -> Unit) {
val colour = MaterialTheme.colorScheme.onSurfaceVariant
Row(
Modifier.fillMaxWidth()
.clickableAt(onToggle)
.semantics { contentDescription = "Collapse these tool calls" }
.padding(vertical = 10.dp),
Modifier.fillMaxWidth().height(height).clickableAt(onToggle).semantics {
contentDescription = "Collapse these tool calls"
},
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Chevron(pointingUp = true, colour = colour)
}
}
/**
* The shape of one card in a stack of [count]: square where it faces a neighbour, rounded where it
* does not.
*
* Written once and given an index rather than branched at each end, because a stack has three cases
* that are one rule -- and the middle one is the case a hand-written first/last pair gets wrong
* when a run turns out to have three calls in it.
*/
@Composable
private fun connectedShape(index: Int, count: Int): CornerBasedShape {
val shape = MaterialTheme.shapes.medium
val square = CornerSize(0.dp)
return shape.copy(
topStart = if (index == 0) shape.topStart else square,
topEnd = if (index == 0) shape.topEnd else square,
bottomStart = if (index == count - 1) shape.bottomStart else square,
bottomEnd = if (index == count - 1) shape.bottomEnd else square,
)
}
/** The padding inside a card, and so the height a bar of one line of text comes to. */
private val GROUP_INSET_LARGE = 12.dp
/** How far the stack of calls is held off the edge of the surface it sits on. */
private val GROUP_INSET = 4.dp
/** Enough to read the join as a join rather than as one tall card. */
private val GROUP_GAP = 2.dp
/**
* One tool call.
*
@@ -227,12 +307,14 @@ fun ToolCard(
onToggle: (Float) -> Unit,
onAnswer: (questionId: String, answers: List<String>) -> Unit,
image: @Composable (String) -> Unit = {},
/** Square where this card faces another in a group; see [connectedShape]. */
shape: Shape = CardDefaults.shape,
) {
val parsed = remember(tool.tool, tool.input) { parseToolInput(tool.tool, tool.input) }
val deciding = tool.asks.any { it.answers.isEmpty() }
val open = expanded || deciding
Card(Modifier.fillMaxWidth().clickableAt(onToggle)) {
Column(Modifier.padding(12.dp)) {
Card(Modifier.fillMaxWidth().clickableAt(onToggle), shape = shape) {
Column(Modifier.padding(GROUP_INSET_LARGE)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(tool.tool, style = MaterialTheme.typography.titleSmall)
if (open) {
Binary file not shown.