Files
ai-app/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt
T
iris 28902ac834 Read the keyboard's inset in the layout phase again, not in composition
Reported: opening the keyboard lags more than it used to, and the scroll
area lags behind the rest of the UI vertically until the keyboard is fully
up.

Both come from the shape of the previous commit's fix rather than from what
it was fixing. Coercing the stuck-open animated inset to zero is right, but
it was written as a bottom padding computed in `SessionScreen`'s body --
`padding(bottom = ... + imeInsets.getBottom(this).toDp())` -- and reading
the inset there subscribes the whole composable to a value the platform
rewrites every frame of the keyboard's animation. That is exactly what the
comment above the box says the arrangement exists to avoid: the transcript
box was meant to be the whole of what a keyboard frame re-measures, with
nothing recomposed at all.

Measured on the emulator with the debug button's counters, over one
keyboard open on an idle session: `session screen recomposed` 16 before,
1 after -- the one being `isImeVisible` flipping, which is the recomposition
the guard actually needs. The per-frame layout work either side is
unchanged (17 measures of the transcript, ~0.6ms each), because that is the
work the keyboard is supposed to cost.

The second symptom is the same cause seen from the other end. The composer
is moved by a `graphicsLayer` block, which re-reads the inset in the draw
phase of the frame it changed; the transcript's padding was reading it in
composition, so the two only stayed together while that recomposition kept
landing inside the frame. `imePadding` reads it in the layout phase of the
same frame, which is where it was before and where the composer can be
followed from by construction.

`isImeVisible` still does the correcting -- the modifier is dropped rather
than the inset zeroed, which is the same coercion by a different route, so
a callback starved of its `onEnd` still cannot leave the composer floating.

Verified by tracking the two against each other frame by frame, from a
screen recording rather than from uiautomator, whose bounds do not update
per frame for a layer translation: the purple outline of the message field
and the last message bubble both move -820px over the ~150ms the keyboard
takes, and are within the 2px measurement floor of each other on every one
of the ten frames in between. Format, compile and lint are clean.
2026-09-01 00:51:15 -04:00

2208 lines
125 KiB
Kotlin

package com.example.aiapp
import android.os.Build
import android.os.SystemClock
import android.util.Log
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
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.window.PopupProperties
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import java.util.concurrent.atomic.AtomicLong
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.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* How big the "still loading this conversation" spinner is.
*
* Bigger than the ones inside a tool card, which are 16dp and report on one call among many, and
* smaller than a splash: this one is standing in for the whole screen while there is nothing else
* on it, and it is the only thing to look at.
*/
private val LOADING_SPINNER = 48.dp
/**
* How close, in screenfuls of estimated scroll, the reader may come to the end of loaded history
* before the next page is fetched.
*
* 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 once, 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.
*
* Six, because the two ways to be wrong are not the same size: firing early costs a page fetched
* that nobody reads, firing late is a spinner under somebody's finger for a whole round trip over
* the tunnel -- and the distance a hard fling covers while that fetch is in flight is several
* screens on its own. The estimate this multiplies is built from measured unit sizes, so a bigger
* cushion no longer amplifies a bad guess the way it would have when the guess came from whatever
* happened to be on screen.
*/
private const val HISTORY_SCREENS = 6
/**
* How many events a backwards page asks for, which is five times what the opening page takes.
*
* The floor is that 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 a screenful of fresh rows took about
* thirty sequential round trips inside one collect. Below this number a page can add no visible
* room at all, and the fetch chain degenerates into those round trips again.
*
* At the floor rather than above it, because pages are fetched in the background before the reader
* arrives -- the cushion decides how deep loading runs, and a page that was not enough is followed
* by another without anybody waiting on either. What a *smaller* page buys is hiding: it crosses
* the tunnel in half the time and lands in a smaller frame spike, so the case where the reader
* outruns an in-flight fetch is rarer and cheaper. This was 800 when the reader was the one
* standing at the boundary and each round trip had to be amortized as far as it would go.
*
* 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 = 400
/**
* The most events one request of a restore may ask for.
*
* A restore knows exactly how far back it has to reach, so it asks for that in one request rather
* than walking there a page at a time. This bounds the request anyway, because "exactly how far" is
* however far the reader had scrolled and there is no bound on that -- and a single response of
* arbitrary size is the one shape a phone on a slow tunnel handles worst. At roughly 800 bytes an
* event, measured on a real transcript, this is about three megabytes.
*
* Going past it costs another request rather than anything being missed, so the number only trades
* round trips against response size.
*/
private const val RESTORE_PAGE_MAX = 4000
/**
* Which row was asked to hold its top edge, and how tall it was when it last measured.
*
* Deliberately *not* snapshot state, and that is the point of the whole class. Both fields are
* written from the layout phase; a snapshot write there that composition reads would schedule
* another recomposition, and the correction has to land inside the frame that is already being laid
* out rather than in a later one. Nothing observes these, so nothing needs to.
*
* [key] is cleared by the resize it was set for, so it cannot be spent on an unrelated one.
*/
private class TopEdgeHold {
var key: Any? = null
}
/** One row's height between layouts, so a change in it can be noticed. See [holdTopEdge]. */
private class LastHeight {
var value: Int? = null
}
/**
* Which row the last touch landed in, and whether it landed in the row's top half -- which is the
* end that row should hold when it changes height; see [holdTopEdge].
*
* One slot rather than a map, because only the touch that is about to toggle something matters:
* [toggleAnchored] reads it in the same gesture that wrote it. Written from a detector on each
* *visible* row -- the lazy list is what makes that affordable, since only rows on screen have one
* and it runs on touch, not per frame. Not snapshot state: nothing composes from it.
*/
private class LastTouch {
var key: Any? = null
var high = false
}
/**
* Keeps this row's top edge where it is when the row changes height, if it was asked to.
*
* This runs in the *layout* phase, from the measurement that discovers the new height, and that is
* the whole reason it is a modifier rather than an effect. A correction posted to a coroutine
* arrives a frame or more after the layout it is correcting, so the wrong position is drawn once
* before the right one -- visible as a flick, and worse the faster the screen refreshes. Scrolling
* from here happens before anything is drawn, so there is no frame to see and nothing that depends
* on how quickly the correction is scheduled.
*
* [hold] is given the change in height. The row's bottom edge is held by the list, so a scroll of
* exactly that much is what leaves the top edge where it was.
*/
@Composable
private fun Modifier.holdTopEdge(key: Any, held: TopEdgeHold, hold: (Int) -> Unit): Modifier {
val last = remember { LastHeight() }
return onSizeChanged { size ->
val previous = last.value
last.value = size.height
// A first measurement has no previous height to have moved from, and a row that came
// back after being scrolled away is a first measurement again.
if (previous == null || previous == size.height || held.key != key) return@onSizeChanged
held.key = null
hold(size.height - previous)
}
}
// The transcript's data model -- TranscriptItem, foldEvent, joinPages, warm -- lives in
// TranscriptItems.kt: it is pure event folding with no screen in it, and the two halves changed
// for unrelated reasons while they shared this file.
// isImeVisible: see the comment beside `imeVisible` below for why the keyboard's own
// self-correction needs it.
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () -> Unit) {
DebugStats.count("session screen recomposed")
val scope = rememberCoroutineScope()
val topEdgeHeld = remember { TopEdgeHold() }
var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
var status by remember { mutableStateOf(summary.status) }
// Seeded from the row this screen was opened from, so a conversation already under way says
// 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 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) }
// Whether the composer's process button has a request out. What it does next is decided from
// the session's status, and the status only changes once the server has answered and the
// stream has carried it back -- so two presses in that gap are two requests, both decided
// against the state before either of them. The server refuses the second one, but a control
// that can be pressed while its own last press is still in flight is asking to be.
var processInFlight by remember { mutableStateOf(false) }
val context = LocalContext.current
// Seeded from what was left in the box last time and written back on every keystroke, so
// leaving the screen -- or the system reclaiming the app -- does not throw away a half-typed
// message. See `Drafts.kt` for why this one piece of state is the device's rather than the
// server's.
var input by remember(summary.id) { mutableStateOf(loadDraft(context, summary.id)) }
// A model the reader has chosen and not yet confirmed. See [ModelSwitchWarning]: switching
// makes the session re-read the whole conversation, which is worth asking about first.
var pendingModel by remember { mutableStateOf<String?>(null) }
// What was last taken from the command suggestions, so the list closes behind it; see
// [CommandSuggestions] at its call site.
var picked by remember { mutableStateOf<String?>(null) }
var expandedTools by remember { mutableStateOf(setOf<String>()) }
// 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.
var expandedNotes by remember { mutableStateOf(setOf<Long>()) }
// Which memory notes are open, by the note's own text -- see [MemoryNote]. Closed by default
// like everything else new in this transcript, and held here rather than in the card so a
// note opened and scrolled past is still open on the way back.
var openMemories by remember { mutableStateOf(setOf<String>()) }
// The image being looked at full screen, by ref. Here rather than in the row that drew the
// thumbnail: a row regrouped underneath the reader takes its whole subtree with it, and the
// dialog with it -- see [SessionImageViewer].
var fullImage by remember { mutableStateOf<String?>(null) }
// 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
// then owned here, because changing either is something this screen does.
// The name shown at the top. Held here rather than read from the row that opened this
// screen, because renaming is something this screen can do -- through the settings below it,
// or by typing the command -- and a header still showing the old name reads as a rename that
// did not take.
var title by remember(summary.id) { mutableStateOf(summary.title) }
var model by remember { mutableStateOf(summary.model) }
var permissionMode by remember { mutableStateOf(summary.permissionMode ?: "auto") }
// The models this provider actually offers, asked of the server rather
// than listed here: a hardcoded list is a claim about a machine.
var offeredModels by remember { mutableStateOf<List<String>>(emptyList()) }
val lifecycleOwner = LocalLifecycleOwner.current
// The resume cursor, written from the stream's IO thread.
val lastSeq = remember { AtomicLong(0) }
val activeStream = remember { AtomicReference<EventStream?>(null) }
// The oldest sequence number loaded, and whether there is more behind
// it. Paging backwards is what keeps opening a long session cheap: the
// 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 saved position is still being put back -- the history it needs fetched, and the
// scroll applied. Nothing is drawn while it is: opening at the newest end and then travelling
// to the anchor is exactly the journey a reader must never see, and this transcript is not
// allowed to move under one.
var restoring 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
// reading of events: after everything taken in, not yet taken in
// itself.
// Messages the server has taken and the session has not read yet, by the id that will resolve
// them. From the event stream rather than from what this screen sent, so they are still here
// after leaving the session or restarting the app -- and so a message sent from another device
// is drawn waiting on this one too.
var queued by remember { mutableStateOf(listOf<QueuedMessage>()) }
// Commands the session has been asked to run and cannot yet, by the id that will resolve
// them. From the server rather than from this screen, so a rename sent from the settings
// screen -- or from another device -- is drawn waiting here too.
var waitingCommands by remember { mutableStateOf(listOf<Pair<String, String>>()) }
val running = status == "running" || status == "compacting"
var moreHistory by remember { mutableStateOf(true) }
var loadingHistory by remember { mutableStateOf(false) }
var ready by remember { mutableStateOf(false) }
// 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() }
// Keyed like everything else that describes one session's transcript. `rememberLazyListState`
// saves through `rememberSaveable`, and this screen restores by its own anchor instead --
// two restores would fight over the first frame.
val listState = remember(summary.id) { LazyListState() }
// Whether the newest message is on screen right now. The list is reversed, so the newest end
// is the scrolling start: nothing behind you is exactly being at the bottom. Asked of the
// scroll state rather than of item indices, because a zero-height first item (the empty
// "below" slot) makes an index ambiguous about where the viewport actually is.
//
// This is what the jump-to-newest button watches, and the gate on recording -- see [record].
val atNewest by remember { derivedStateOf { !listState.canScrollBackward } }
// 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, flattened into the list's units.
val rows = remember(items) { groupToolRuns(items) }
val units = remember(rows) { transcriptUnits(rows, replies) }
// The same list, readable from effects launched before this composition: an effect's closure
// keeps the values of the composition that launched it, and both the anchor saver and the
// restore need the units as they are *now*.
val currentUnits by rememberUpdatedState(units)
val lastTouch = remember { LastTouch() }
/**
* Everything the transcript list draws, from one event.
*
* Separate from [apply] because it is the half that is allowed to wait. The list anchors on the
* leading edge of its first visible item, which in this upside-down layout is that item's
* *bottom* -- so a row that grows pushes everything already on screen upwards, and the view
* travels toward the newest end without anybody scrolling. Measured against a reply streamed in
* four hundred pieces: scrolling back a screen and then waiting six seconds ended at the very
* bottom, forty lines further on than where it was left.
*
* Insertions were never the problem -- the list is keyed, so a row arriving at either end
* leaves the anchor where it is, and reading back through history while a session works has
* always been still. What cannot be allowed is a row that is already there changing height, and
* the one guarantee that covers every way that happens -- a reply streaming, a tool's output
* arriving, a queued bubble appearing above the anchor -- is to change nothing at all while
* somebody is reading further back.
*/
fun record(entry: SeqEvent) {
// The oldest event this view holds, which is what paging backwards
// starts from. Maintained here rather than by each loader: the
// first page and a stream reset both begin an empty view, and one
// of them getting it wrong is a transcript that will not scroll up.
if (oldestSeq == 0L) {
oldestSeq = entry.seq
moreHistory = entry.seq > 1L
}
val event = entry.event
// The message coming back is the session saying it has
// read it, so the bubble held below the indicator becomes
// the row `foldEvent` is about to add.
// Waiting, then read. Matched by id: the same message sent twice is two
// bubbles, and clearing by text would take away whichever matched first.
if (event is SessionEvent.MessageQueued) {
queued = queued + QueuedMessage(event.id, event.text, event.images)
}
if (event is SessionEvent.UserMessage) {
queued = queued.filterNot { it.id == event.id }
}
// Waiting, then taken back. From the server rather than from the tap, so every device
// drops the bubble and a reconnect does not put back one that was cancelled.
if (event is SessionEvent.MessageDropped) {
queued = queued.filterNot { it.id == event.id }
}
// Waiting, then gone: a command leaves this list when the session takes it,
// and the row it becomes is added by `foldEvent` in the same pass.
if (event is SessionEvent.CommandQueued) {
waitingCommands = waitingCommands + (event.id to event.text)
}
if (event is SessionEvent.CommandSent) {
waitingCommands = waitingCommands.filterNot { it.first == event.id }
}
items = foldEvent(items, entry)
}
/**
* One event, at the moment it arrives.
*
* What it says about the *session* -- running or not, which model, how many tokens -- lands
* immediately, because none of that is drawn in the list and freezing it would trade a
* transcript that jumps for a status row that lies. What it adds to the transcript goes through
* [record], which waits for the reader to be at the newest end.
*/
fun apply(entry: SeqEvent) {
lastSeq.set(entry.seq)
// Before the rest, and for every event rather than only the usage ones: a compaction and
// a clear move this as much as a turn does, which is the whole reason it is a fold and
// not a running total. See `contextAfter`.
contextTokens = contextAfter(contextTokens, entry.event)
when (val event = entry.event) {
// Nothing further: what it carries was folded into the context above, and what a
// turn cost is not something the transcript draws.
is SessionEvent.UsageDelta -> {}
else -> {
// What the session says it is set to now, which is the only thing that
// says it: picking from either menu asks, and the answer comes back here.
if (event is SessionEvent.Settings) {
event.model?.let { model = it }
event.permissionMode?.let { permissionMode = it }
}
if (event is SessionEvent.Status) {
// 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
else -> entry.ts
}
status = event.state
}
// In order, always: one late event recorded ahead of the backlog would fold a
// streamed delta into whatever row happened to be last by then.
if (atNewest && held.isEmpty()) record(entry) else held = held + entry
}
}
}
/**
* Changes a row's height while the end the reader touched stays where it is.
*
* The transcript is laid out from the bottom, so every row's *bottom* edge is what the list
* holds still and all growth goes upward. That is what a tap in a row's lower half already
* gets, so it needs nothing: shut a group from the bar at its foot and what follows it does not
* move, which is what the reader is looking at down there. A tap in the upper half is the other
* case -- left alone it sends the heading under the reader's finger up off the screen and fills
* the space above it, so the calls appear on the far side of the control that produced them --
* and that one asks the row to hold its top edge instead.
*
* Which half decides it, rather than which control was pressed, so that everything that opens
* behaves the same way whether or not it happens to have a control at each end. A group has two
* and its heading and foot bar land in the halves they are already in; a single call is one
* card, and tapping low on an open one shuts it downward exactly as the bar does.
*
* The correction itself belongs to the measurement -- see [holdTopEdge]. Which half was touched
* comes from the row's own detector ([LastTouch]), written by the gesture that is about to run
* [toggle].
*/
fun toggleAnchored(row: TranscriptRow, toggle: () -> Unit) {
if (lastTouch.key == row.key && lastTouch.high) topEdgeHeld.key = row.key
toggle()
}
/**
* Whether the row holding transcript position [seq] is loaded, with older history behind it.
*
* "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.
*
* 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` for the reason [loadOlderPage] gives: `rows` is
* the composition's value and does not change under a running coroutine.
*/
fun anchorRow(seq: Long): Long? {
val ordered = groupToolRuns(items)
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
}
/**
* 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(limit: Int = HISTORY_PAGE): Boolean {
// Nothing is loaded, so there is no "before" to ask about, and asking anyway is not a
// harmless no-op: `before = 0` fetches the events before the first one, which is none,
// and an empty page is how this function is told it has reached the start of the
// conversation -- so it would latch `moreHistory` false and the session could never be
// paged back at all.
//
// The window it fires in is the first layout. `moreHistory` starts true, which puts the
// history spinner in the list, which makes `visibleItemsInfo` non-empty before a single
// event has arrived -- and with no units loaded the room ahead adds up to zero, so the
// pager fetches. On a loopback server the opening page beat it and nothing was ever
// wrong; at `--delay 150`, which is what a phone over the tunnel actually costs, it won
// the race and the transcript stopped one page from its newest end with no spinner and
// nothing to say why.
//
// Guarded here rather than at the two callers because it is a fact about the question,
// not about who is asking: the post-open fetch reaches it too, on the path where the
// opening page failed and left `oldestSeq` unset.
if (oldestSeq == 0L) return false
// The fetch *and* the fold, both off the thread that draws. Only the fetch used to be,
// and the fold is the expensive half: `foldEvent` returns a new list per event, so a page
// of [HISTORY_PAGE] events is that many copies of a list growing to that length -- around
// three hundred thousand element copies for one page, run on the main thread in the
// middle of the scroll that asked for it. It was affordable at eighty events and is not
// at eight hundred, which is why the page that made scrolling back reach the top made it
// stutter to get there.
//
// `Dispatchers.IO` for both rather than a hop to `Default` between them: the two are one
// errand, and this way the page costs one context switch instead of three. Neither half
// touches anything the composition owns -- `older` and `earlier` are local, and the
// `items` read below happens back on the caller's thread, where the write does too.
val page =
withContext(Dispatchers.IO) {
val older = fetchTranscript(settings, summary.id, before = oldestSeq, limit = limit)
if (older.isEmpty()) return@withContext null
// 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)
}
}
older.first().seq to earlier
}
if (page == null) {
moreHistory = false
return false
}
val (oldest, earlier) = page
oldestSeq = oldest
moreHistory = oldestSeq > 1L
// Joined here rather than above, because it is the one step that reads what is already
// loaded: `items` must be read where it is written, and it is a single pass over the two
// lists against the page's quadratic fold.
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
// be this screen inventing the part the CLI does not send.
LaunchedEffect(compactingSince) {
val since = compactingSince
if (since == null) {
compactingFor = null
return@LaunchedEffect
}
while (true) {
// 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)
}
}
// The stream lifecycle: connect, follow, and on any drop reconnect
// from the cursor -- so a flaky link (or a backend restart) costs
// nothing but the gap's latency.
// The newest page first, in one request, before the stream opens. The
// stream then starts from where that page ended, so it carries live
// events only -- which is what it is good at.
LaunchedEffect(summary.id) {
try {
val page = withContext(Dispatchers.IO) { fetchTranscript(settings, summary.id) }
// Warmed before the fold lands rather than after: flattening the rows into units
// splits every settled reply ([transcriptUnits]), and the flatten runs in the
// composition that first sees the rows. Folded into a scratch list off this thread
// to find out what needs warming; the real fold below also maintains the queue and
// the cursor, so it cannot be reused here.
withContext(Dispatchers.IO) {
var scratch = listOf<TranscriptItem>()
page.forEach { entry ->
if (entry.event !is SessionEvent.UsageDelta) {
scratch = foldEvent(scratch, entry)
}
}
warm(replies, scratch)
}
page.forEach { apply(it) }
// 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 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.
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
// sparse, that difference is larger than the count, which overshoots into
// older history rather than stopping short. [HISTORY_PAGE] on top is the
// cushion that keeps the anchor's row off the oldest edge, where it would
// still grow.
//
// Capped, and the loop is what makes the cap safe: a span past it comes back
// in several requests instead of one, which is what this did for every
// restore until now -- thirteen sequential round trips to reopen a session
// somebody had read a little way back into, and a spinner for all of them.
// The bytes are the same either way, since every row between the anchor and
// 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
}
// 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. Null is a row that is no longer in the transcript at all -- a reset
// stream, or a session cleared from elsewhere -- and means there is nothing to
// put back: the list is already at the newest end, which is where it opens.
anchorRow(anchor.seq)?.let { rowSeq ->
// The units are built by composition, and this coroutine has been loading
// rows the composition may not have seen -- so wait for the build that
// holds the anchor's row before turning it into an index. Guaranteed to
// arrive, because the row is in `items` and the units are a pure function
// of it. Nothing is drawn during the wait: [restoring] gates drawing, and
// the scroll is applied before it is lifted, so there is no frame showing
// anywhere else. One past the index, because item zero is the "below" slot.
val index =
snapshotFlow { unitIndexFor(currentUnits, rowSeq, anchor.unit) }
.first { it != null }!!
listState.scrollToItem(index + 1, anchor.offset)
}
}
} 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
}
// 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
// The opening page is sized for time-to-first-frame, not for reading: it fills a
// viewport or two, so the first "still loading" boundary sat barely off-screen and the
// first upward scroll met it and waited a round trip. The same reasoning that keeps the
// opening page off the critical path puts the first full page right behind it, while
// the screen is already up. A restore skips this: it has just paged as deep as the
// anchor needed.
if (savedAnchor == null && moreHistory && !loadingHistory) {
loadingHistory = true
try {
loadOlderPage()
} catch (_: ApiException) {
// The next scroll asks again.
} finally {
loadingHistory = false
}
}
}
// Only while the screen is actually on screen. Android stops the
// activity when somebody switches away, and the socket dies with it --
// which arrived as "Lost the event stream (SocketTimeoutException)"
// waiting at the top on their return. Switching apps is a choice
// somebody made, not a fault to report, and reconnecting on a phone
// that has been backgrounded is work nobody is watching. Stopping the
// stream deliberately makes the drop a close rather than an error (see
// EventStream.close), and resuming reconnects from the same cursor.
LaunchedEffect(summary.id, ready, lifecycleOwner) {
if (!ready) return@LaunchedEffect
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
try {
while (true) {
val stream = EventStream(settings, summary.id)
activeStream.set(stream)
try {
withContext(Dispatchers.IO) {
stream.run(
after = lastSeq.get(),
// Connected, measured rather than inferred: this is what
// takes a failure off the screen, and nothing else does.
// Clearing on the first event instead meant an idle
// session kept displaying an error it had recovered from.
onOpen = { streamError = null },
onReset = {
// Too far behind to continue from: what is on
// screen is a stale prefix of a conversation
// that has moved on, and the window arriving
// next is not adjacent to it. Dropping the rows
// is what makes this the same as opening the
// screen -- `apply` refills them, and scrolling
// up pages the rest back in as it always does.
items = listOf()
replies.clear()
held = listOf()
oldestSeq = 0L
moreHistory = true
},
) { entry ->
apply(entry)
}
}
} catch (e: kotlinx.coroutines.CancellationException) {
// Leaving the screen or going below STARTED. Not a failure, and
// swallowing it would leave this loop reconnecting forever.
throw e
} catch (e: Exception) {
// Any failure, not only an [ApiException]: the stream reconnects from its
// cursor, so there is nothing a failure here can cost that is worth
// closing the app over. Reported on the screen either way.
streamError = e.message ?: e::class.simpleName
} finally {
stream.close()
}
delay(RECONNECT_DELAY_MS)
}
} finally {
// Cancellation -- going below STARTED, or leaving the screen --
// cannot interrupt a blocking socket read. Closing is what
// unblocks it, and what marks the drop deliberate.
activeStream.getAndSet(null)?.close()
}
}
}
// The screen going away entirely, which the lifecycle scope above does
// not cover: a composable can leave the composition while the activity
// stays started.
DisposableEffect(summary.id) { onDispose { activeStream.get()?.close() } }
// Nothing gets announced about the session somebody is reading; see NotificationService.
// RESUMED rather than STARTED because "looking at it" means the foreground -- a session left
// on this screen behind another app is one whose notifications are still wanted, and STARTED
// covers that case too.
LaunchedEffect(summary.id, lifecycleOwner) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
NotificationService.showing(context, summary.id)
try {
awaitCancellation()
} finally {
NotificationService.stoppedShowing(summary.id)
}
}
}
// Back at the newest end, so the backlog [apply] held can land. Everything at once rather
// than paced out: they are at the bottom, which is the one place the list is allowed to
// follow new content, and drip-feeding it would only make that following last longer.
LaunchedEffect(listState) {
snapshotFlow { atNewest && held.isNotEmpty() }
.collect { due ->
if (!due) return@collect
val backlog = held
held = listOf()
backlog.forEach { record(it) }
}
}
// 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.
//
// The place is the first visible item -- in this reversed list, the one at the *bottom* of
// the viewport -- named by its row's seq and its unit within the row, which are the two
// things that survive a reopen. The index does not (the transcript is fetched newest-first),
// and the key does not either (a tool run is renamed when the newest page starts somewhere
// new); see [ScrollAnchor].
LaunchedEffect(listState) {
snapshotFlow {
if (listState.isScrollInProgress) null
else
Triple(
listState.firstVisibleItemIndex,
listState.firstVisibleItemScrollOffset,
listState.canScrollBackward,
)
}
// 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 || restoring) return@collect
val (index, offset, awayFromNewest) = settled
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. One *before* the index, because item zero is
// the "below" slot; a viewport starting inside it is at the newest end.
if (!awayFromNewest) null
else
currentUnits.getOrNull(index - 1)?.let {
ScrollAnchor(it.seq, offset, it.ordinal)
},
)
}
}
// Reaching within a few screens of the far end of what is loaded fetches the page before
// it.
//
// The question is pixels of scroll -- how far can the reader keep going before they run out
// -- and a lazy list cannot answer it exactly, because it has never measured the items it
// has not composed. So the room ahead is added up from the real size of every unit the
// list *has* laid out, kept by key as units pass through the viewport, with the running
// average standing in for the ones it has never seen. It used to be the average of the
// units currently on screen, and the units on screen are the worst possible sample: two
// tall blocks fill a viewport, multiply out over dozens of unseen one-line rows, and
// report screens of room when the end is one swipe away -- so the reader met the spinner
// at every boundary, which is exactly what the cushion exists to prevent.
//
// There is no correction beside this one. Following the newest message is not an effect:
// the list is reversed, so an arriving message extends the end the viewport is pinned to,
// and a page of history lands past every visible index and moves nothing.
val unitSizes = remember(summary.id) { HashMap<Any, Int>() }
LaunchedEffect(listState, moreHistory) {
snapshotFlow { listState.layoutInfo }
.collect { info ->
val visible = info.visibleItemsInfo
if (visible.isEmpty()) return@collect
// Before the guards below, so sizes keep accumulating while a page is in
// flight and the next estimate starts better informed.
visible.forEach { unitSizes[it.key] = it.size }
if (restoring || !moreHistory || loadingHistory) return@collect
val viewport = info.viewportSize.height
if (viewport == 0) return@collect
val loaded = currentUnits
val average = unitSizes.values.sum() / unitSizes.size
// From the last visible lazy index: item zero is the "below" slot, so lazy
// index equals units index plus one -- starting the walk at `last().index`
// begins one unit past the last visible one, and a visible spinner makes the
// range empty, which is room of zero.
var room = 0L
val cushion = viewport.toLong() * HISTORY_SCREENS
for (index in visible.last().index until loaded.size) {
room += unitSizes[loaded[index].key] ?: average
if (room >= cushion) return@collect
}
loadingHistory = true
try {
// One page, and then this fires again if it was not enough -- the estimate
// is re-made from what the page actually added, so a page that folds into
// almost no new units is followed by another because the room genuinely
// did not grow.
loadOlderPage()
} catch (_: ApiException) {
// Leave `moreHistory` alone: the next scroll asks again.
} finally {
loadingHistory = false
}
}
}
LaunchedEffect(summary.setupName, summary.provider) {
offeredModels =
try {
withContext(Dispatchers.IO) {
fetchSetups(settings)
.firstOrNull { it.name == summary.setupName }
?.providers
?.firstOrNull { it.name == summary.provider }
?.models
.orEmpty()
}
} catch (_: Exception) {
// Not worth reporting: the picker simply has nothing to
// offer, which is visible, and the session is unaffected.
emptyList()
}
}
/**
* Asks the server to take back a message the session has not read yet.
*
* Nothing is removed here. The bubble goes on the `messageDropped` the server records, which is
* what makes the cancellation the session's own fact rather than this screen's opinion of it --
* a second device watching the same session has to lose the bubble too, and this one has to
* still lose it after a reconnect.
*
* The refusal is kept on the message it was about rather than in [actionError]: the error row
* lives under the header, and a bubble at the foot of the transcript is the thing that was
* pressed. It is the ordinary answer here rather than the exceptional one -- a Claude session
* writes a steer into the CLI the moment it arrives, so what is on screen as "waiting" is
* waiting to be *read*, not waiting to be sent.
*/
fun takeBack(messageId: String) {
scope.launch {
val refusal =
try {
withContext(Dispatchers.IO) { unqueueMessage(settings, summary.id, messageId) }
null
} catch (e: ApiException) {
e.message ?: "this message could not be taken back"
}
queued = queued.map { if (it.id == messageId) it.copy(refusal = refusal) else it }
}
}
/** Opens one image full screen, from whichever row drew it; see [SessionImageViewer]. */
fun openImage(ref: String) {
fullImage = ref
}
/** Opens or closes one memory note, wherever it is drawn; see [MemoryNote]. */
fun toggleMemory(text: String) {
openMemories = if (text in openMemories) openMemories - text else openMemories + text
}
fun act(onDone: () -> Unit = {}, action: () -> Unit) {
scope.launch {
try {
withContext(Dispatchers.IO) { action() }
actionError = null
} catch (e: ApiException) {
actionError = e.message
} finally {
// Whatever happened, including the failure above: a caller that re-enables a
// control here must get it back on the path where the request was refused too,
// or the refusal is what disables the control permanently.
onDone()
}
}
}
fun send() {
val text = input.trim()
val attachments = pendingAttachments
if (text.isEmpty() && attachments.isEmpty()) return
// A command is not a message: it is an instruction to the session about itself, and one
// written into a running turn is read by the model instead. The server holds it until the
// turn ends and says so, which is where its waiting bubble comes from -- so nothing is
// held here, and there is no local guess to correct when the answer arrives.
if (text.startsWith("/") && attachments.isEmpty()) {
input = ""
saveDraft(context, summary.id, "")
// The one command with a visible effect outside the transcript, applied when the
// server has accepted it rather than when it was typed: the name is this app's own
// datum and changes at once, and only telling the session waits for a boundary.
val renamed =
text.removePrefix("/rename ").trim().takeIf {
text.startsWith("/rename ") && it.isNotEmpty()
}
act {
runCommand(settings, summary.id, text)
renamed?.let { title = it }
}
return
}
input = ""
saveDraft(context, summary.id, "")
pendingAttachments = emptyList()
// Nothing is added here. The server says what is waiting -- it emits `messageQueued`
// when it takes a message it cannot deliver yet -- and this screen draws that. Holding a
// local copy as well was the bug: the two agreed only until the app was restarted or the
// session left, and then the screen showed nothing pending while the queue was full.
act { sendMessage(settings, summary.id, text, attachments) }
}
// The system photo picker; the image uploads as soon as it's chosen,
// so Send only has ids to reference.
val pickImage =
rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri != null) {
scope.launch {
try {
val id =
withContext(Dispatchers.IO) {
// Shrunk to what this session's provider takes before it is
// uploaded, so a twelve-megapixel photo does not cross the tunnel
// to be rejected at the far end -- see `uploadPickedImage`.
uploadPickedImage(
context,
settings,
summary.id,
uri,
summary.maxImageEdge,
)
}
pendingAttachments = pendingAttachments + id
actionError = null
} catch (e: ApiException) {
actionError = e.message
}
}
}
}
// One poll for this machine's limits, read by the two things that show them: the bar under
// the header, and the colour of the button that opens the dialog.
val usage = rememberSessionUsage(settings, summary.setup)
val frames = rememberFrameStats()
var usageOpen by remember { mutableStateOf(false) }
var settingsOpen by remember { mutableStateOf(false) }
// The composer floats over the bottom of the screen instead of sitting under the transcript
// in one column, and the keyboard moves it by a layer translation rather than by relayout.
// With everything in one column under a root imePadding, every frame of the keyboard
// animation re-measured, re-placed and re-recorded the entire screen -- measured on the
// emulator at ~7.6ms of main-thread work per frame across ~34 frames per open, and on the
// Pixel as 82% late frames while the transcript itself cost 0.25ms. Scoped this way, a
// keyboard frame costs one layer transform for the composer and one re-measure of the
// transcript box, whose children skip measurement (width unchanged) and whose rows are
// already layers.
var composerHeight by remember { mutableIntStateOf(0) }
val imeInsets = WindowInsets.ime
val navInsets = WindowInsets.navigationBars
// Ground truth for whether the keyboard is up, independent of `imeInsets` -- which is what
// rescues this from a real fault rather than merely reading the same thing twice. `imeInsets`
// is driven by the animation as it interpolates and is dispatched every frame; `isImeVisible`
// is dispatched once, from the platform's own start/end of the transition, over a different
// path (`onApplyWindowInsets` rather than the animation callback).
//
// Reported from a phone: closing the keyboard on purpose, while a reply was streaming, left
// the composer floating above the bottom of the screen for the rest of the session, with a
// bar of background colour showing under it and nothing that closed it. The likely cause is
// the animation callback that carries `imeInsets` back to zero being interrupted mid-flight --
// a streaming reply invalidates the view every frame, which is exactly the condition known to
// starve a running `WindowInsetsAnimationCallback` of its `onEnd` -- and once that happens the
// stale, partway value it leaves behind has nothing left to correct it: the keyboard is not
// going to move again on its own. `isImeVisible` does not share that failure mode (it is not
// interpolated, so there is nothing for a dropped frame to interrupt), so it is what both
// places below fall back to.
val imeVisible = WindowInsets.isImeVisible
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) {
GlyphButton(BACK_GLYPH, "Back", onBack)
// A ring's worth, which is what the arrow already keeps on its other three sides --
// the pair of glyph buttons at the far end of this row get theirs from each other.
Spacer(Modifier.width(GLYPH_BUTTON_MARGIN))
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleMedium)
// Machine first, then what runs on it -- the same order and the same wording
// everywhere this pair appears, so it reads as one fact rather than as two
// sentences with different grammar. The "on" that used to sit in the middle
// made it a phrase, which only works in one order and stops working the moment
// the pair is shown anywhere else.
//
// No model. The picker in the footer already shows what this session is set to,
// and showing it twice means two things to keep in step -- they disagreed for a
// moment on every model change, since one follows the request and the other the
// session's own answer.
Text(
"${summary.setupName} · ${summary.provider}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Beside the provider it reports on, which is the line directly to its left.
//
// Its real home is this provider's settings, which do not exist yet; until they do,
// the session is the only place the provider is already named, so it is the only
// place the button can sit without inventing a scope for itself. What it shows is
// the paid service's own numbers, so a session on a provider with no such service
// gets an honest "unavailable" rather than a hidden button -- a control that comes
// and goes makes its absence the signal, and absence cannot say why.
// Coloured by the worst window behind it, so the row says whether the limits are
// worth opening before anybody opens them. Blue at every ordinary level and only
// yellow or red near a limit -- and the theme's plain control colour whenever there
// is no measurement, since blue is the low end of the scale here and would read as
// "checked, and fine" about a machine nobody could reach.
Row {
// Left of the numbers about the *conversation*, because it is the same kind of
// thing about the *app*: what this session is costing to draw. It copies rather
// than opens, because what it produces is for somewhere else -- a message to
// whoever is looking at the code -- and a screenful of timings read on the
// phone
// is a screenful nobody can act on.
GlyphButton(
SPEED_GLYPH,
"Copy render timings",
onClick = {
val report =
debugReport(
device =
"device: ${Build.MODEL} (${Build.MANUFACTURER})," +
" Android ${Build.VERSION.RELEASE}",
transcript =
listOf(
" ${items.size} events, ${rows.size} rows," +
" ${units.size} units loaded",
" viewport" +
" ${listState.layoutInfo.viewportSize.height}px," +
" ${listState.layoutInfo.visibleItemsInfo.size}" +
" units visible",
" ${expandedTools.size} tool calls and" +
" ${expandedGroups.size} groups open",
),
frames = frames.lines(context.refreshHz()),
accounting =
frames.drawPhase().let { (nanos, count) ->
drawAccounting(nanos, count)
},
crash = lastCrash(context),
)
context.copyToClipboard("ai-app render report", report)
// Also to the log, so a session driving the app over adb can read the
// same report the button copies. The clipboard is not reachable from a
// shell, and a counter nobody can check from here is a counter that
// only
// gets checked by asking Iris to press a button and paste.
Log.i("ai-app", report)
// Only once it is somewhere it can be read from, so a copy that never
// happened does not throw the stack away with it.
clearCrash(context)
// Emptied by the copy, so pressing it twice measures two separate
// stretches
// of scrolling rather than one and then the same one again.
frames.reset()
DebugStats.reset()
Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT)
.show()
},
)
GlyphButton(
USAGE_GLYPH,
"Usage",
{ usageOpen = true },
colour = usageGlyphColour(usage),
)
// 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 })
}
}
// Under the header, above everything the session itself says: it is a fact about the
// machine rather than a turn in the conversation, and it is the number that decides
// whether to keep going -- which was a screen away from where that gets decided.
SessionUsageBar(usage)
(streamError ?: actionError)?.let { message ->
Text(
message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
// The transcript, reversed: item zero is the newest message and sits at the bottom, so
// the first frame of a session is already the right one, and following new content is
// where the list is rather than a correction it makes; see [TranscriptList].
//
// Drawn only once there is nothing left to put back. Held out of the drawing rather
// than out of the composition, so the restore's scroll is applied against a list that
// is fully built, and there is no frame in which the transcript is somewhere other
// than where it was left.
val settled = !restoring
Box(
Modifier.weight(1f)
.fillMaxWidth()
// The room the floating composer needs, measured off it below -- reserving it
// here is what lets the composer be an overlay without covering the newest
// message -- and then the keyboard's, per frame of its animation. This modifier
// is the whole of what the keyboard re-measures: the box's own size never
// changes, so nothing above it is touched.
.padding(bottom = with(LocalDensity.current) { composerHeight.toDp() })
// The keyboard's room, and only while the platform says there is a keyboard --
// dropping the modifier is what coerces the stuck-open animated value to zero,
// the same guard the composer's translation applies below. It has to stay a
// *modifier* rather than a padding computed here: `imePadding` reads the inset
// in the layout phase, so a keyboard frame re-measures this box and nothing
// else, while reading `imeInsets` in this composable body subscribes the whole
// of `SessionScreen` to a value that changes every frame of the animation.
// That cost 16 full recompositions of this screen per keyboard open, against
// one, and it made the transcript's position depend on a recomposition landing
// inside the frame that the inset changed -- which the composer's does not,
// since its translation is re-read in that frame's draw phase. When the
// recomposition misses, the transcript trails the composer up the screen.
.then(if (imeVisible) Modifier.imePadding() else Modifier)
) {
Box(Modifier.fillMaxSize()) {
TranscriptList(
units = units,
state = listState,
moreHistory = moreHistory,
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()) {
// The gap the arrangement no longer provides: this item sits flush
// against the newest message otherwise.
Column(
Modifier.padding(top = TRANSCRIPT_SPACING),
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,
onOpenImage = ::openImage,
pending = true,
refusal = waiting.refusal,
// The bubble goes away on the `messageDropped` this
// produces, not here: the server is what knows whether
// the message was still its to take back, and the
// other devices watching this session have to be told
// by the same event.
onTakeBack = { takeBack(waiting.id) },
)
}
}
}
},
) { unit ->
when (unit) {
is TranscriptUnit.Block -> MarkdownText(unit.text, replies)
is TranscriptUnit.Memory ->
MemoryNote(
unit.part,
replies,
unit.part.text in openMemories,
) {
toggleMemory(unit.part.text)
}
is TranscriptUnit.Whole -> {
val row = unit.row
Box(
Modifier.holdTopEdge(row.key, topEdgeHeld) { grew ->
// A *request*, not a raw scroll delta: this runs inside
// the measure pass that discovered the new height, and
// a
// raw delta forces a synchronous remeasure from within
// measure, which is fatal
// ("performMeasureAndLayout called during measure").
// The request is applied by the same frame's next
// remeasure, so the correction still lands before
// anything is drawn. Reads unobserved, or this row's
// measure would inherit the scroll position as a
// dependency and remeasure on every frame of every
// fling.
Snapshot.withoutReadObservation {
listState.requestScrollToItem(
listState.firstVisibleItemIndex,
(listState.firstVisibleItemScrollOffset + grew)
.coerceAtLeast(0),
)
}
}
// Which half of this row the touch landed in, for
// [toggleAnchored].
// On the initial pass and consuming nothing, so every
// control
// inside
// still gets the gesture exactly as it would have; only
// visible
// rows
// have one, which is what makes a detector per row
// affordable.
.pointerInput(row.key) {
awaitEachGesture {
val down =
awaitFirstDown(
requireUnconsumed = false,
pass = PointerEventPass.Initial,
)
lastTouch.key = row.key
lastTouch.high = down.position.y < size.height / 2f
}
}
) {
when (row) {
is TranscriptRow.Tools ->
ToolGroup(
group = row,
expanded = row.id in expandedGroups,
onToggle = {
toggleAnchored(row) {
expandedGroups =
if (row.id in expandedGroups)
expandedGroups - row.id
else expandedGroups + row.id
}
},
isToolExpanded = { it in expandedTools },
// Anchored on the group, not the call: opening one
// call
// makes
// the whole group taller, and the heading the
// reader is
// under
// is the group's.
onToolToggle = { id ->
toggleAnchored(row) {
expandedTools =
if (id in expandedTools)
expandedTools - id
else expandedTools + id
}
},
onAnswer = { questionId, answers ->
act {
answerQuestion(
settings,
summary.id,
questionId,
answers,
)
}
},
image = { ref ->
SessionImage(
settings,
summary.id,
ref,
::openImage,
)
},
)
is TranscriptRow.Single ->
when (val item = row.item) {
is TranscriptItem.UserMsg ->
UserBubble(
settings = settings,
sessionId = summary.id,
text = item.text,
images = item.images,
onOpenImage = ::openImage,
)
is TranscriptItem.AssistantMsg ->
// A whole assistant row is only ever the reply
// still
// arriving -- every settled reply is flattened
// into
// block units instead; see [transcriptUnits].
// Live
// is
// what earns its blocks a layer each while
// deltas
// land.
AssistantMessage(
item.text,
replies,
openNotes = openMemories,
onToggleNote = ::toggleMemory,
live = true,
)
is TranscriptItem.ToolRun ->
ToolCard(
tool = item,
expanded = item.id in expandedTools,
onToggle = {
toggleAnchored(row) {
expandedTools =
if (item.id in expandedTools)
expandedTools - item.id
else expandedTools + item.id
}
},
onAnswer = { questionId, answers ->
act {
answerQuestion(
settings,
summary.id,
questionId,
answers,
)
}
},
image = { ref ->
SessionImage(
settings,
summary.id,
ref,
::openImage,
)
},
)
is TranscriptItem.QuestionCard ->
QuestionRow(item) { answers ->
act {
answerQuestion(
settings,
summary.id,
item.id,
answers,
)
}
}
is TranscriptItem.ErrorMsg ->
Text(
item.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
is TranscriptItem.ImageItem ->
SessionImage(
settings,
summary.id,
item.ref,
::openImage,
)
is TranscriptItem.Note ->
Text(
item.text,
style = MaterialTheme.typography.bodySmall,
color =
MaterialTheme.colorScheme
.onSurfaceVariant,
)
is TranscriptItem.CommandRow ->
CommandBubble(item.text)
is TranscriptItem.ClearedNote -> ClearedRow()
is TranscriptItem.CompactedNote ->
CompactedRow(item)
is TranscriptItem.PeerNote ->
PeerMessageRow(
item = item,
expanded = item.seq in expandedNotes,
replies = replies,
onToggle = {
toggleAnchored(row) {
expandedNotes =
if (item.seq in expandedNotes)
expandedNotes - item.seq
else expandedNotes + item.seq
}
},
)
}
}
}
}
}
}
}
// Still finding out what this conversation is: the newest page has not arrived, or
// it has and the list is being put back where reading stopped. Both draw no rows at
// all, and a blank page is what this screen otherwise means by "there is nothing
// here" -- so the state that does not know needs its own appearance rather than
// sharing one with the empty answer.
//
// 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.
// `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)
)
}
// Only while the newest message is off-screen. Reading back
// through a conversation is a place to be, not a state to be
// rescued from, so this waits to be wanted.
//
// Down, and the same chevron a tool group collapses with: the
// list is built upside down internally, but nobody reading it
// knows that -- on screen the newest message is at the bottom,
// which is where this goes. The name is carried in the
// description, since an arrow alone says nothing to a screen
// reader and nothing to whoever finds this in six months.
if (!atNewest) {
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.
//
// 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.scrollToItem(0) } },
shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier =
Modifier.align(Alignment.BottomCenter)
.padding(bottom = 12.dp)
.semantics { contentDescription = "Jump to latest" },
) {
Chevron(
pointingUp = false,
colour = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
)
}
}
}
}
// Everything from here down floats: bottom-aligned over the transcript, moved up with
// the keyboard by a translation on its own layer. The translation is read inside the
// graphicsLayer block, so a keyboard frame invalidates layer properties only -- no
// measure, no recomposition, no re-recording of anything. Its height is reported to the
// transcript box above, which reserves that much room; the opaque background covers the
// one frame between this growing (a suggestion row, a second draft line) and that
// reservation catching up.
Column(
Modifier.align(Alignment.BottomCenter)
.fillMaxWidth()
.onSizeChanged { composerHeight = it.height }
.graphicsLayer {
// See `imeVisible` above: a callback interrupted mid-close leaves this stuck
// reading a stale height, and without the guard the composer floats above the
// bottom of the screen for good.
translationY =
if (imeVisible) {
-(imeInsets.getBottom(this) - navInsets.getBottom(this))
.coerceAtLeast(0)
.toFloat()
} else {
0f
}
}
.background(MaterialTheme.colorScheme.background)
) {
pendingModel?.let { chosen ->
ModelSwitchWarning(
from = modelLabel(model),
to = modelLabel(chosen),
onDismiss = { pendingModel = null },
onConfirm = {
pendingModel = null
act { setSessionModel(settings, summary.id, chosen) }
},
)
}
SessionStatusRow(
status = status,
compactingFor = compactingFor,
contextTokens = contextTokens,
)
// Between the transcript and the box: above what is being typed, so the list does not
// cover the thing the command is about, and below everything that explains it.
CommandSuggestions(
// Nothing to suggest about a suggestion that was just taken. `/compact` is a
// whole command *and* a prefix of itself, so picking it left the list standing
// there with the one row already chosen -- the reader has to dismiss a list that
// has nothing left to offer, in front of the box they are about to send from.
// Held by what was picked rather than by a flag, so typing anything else brings
// the list back without needing a second thing to reset.
commands = if (input == picked) emptyList() else suggestedCommands(input),
onPick = { command ->
input = command.typed()
picked = command.typed()
},
)
// Always enabled -- a send while the session is running becomes a
// steering message injected at the next tool boundary, which is
// the point of the whole app.
//
// The field gets a row of its own, above the buttons: sharing one
// put the full width behind three controls, so the thing being
// typed into was the narrowest thing on the row.
Column(Modifier.fillMaxWidth().padding(8.dp)) {
// Directly above the box they will be sent from, so what is attached is visible
// rather than counted: the "+2" on the button below said how many and never which.
PendingAttachments(
settings = settings,
sessionId = summary.id,
refs = pendingAttachments,
onRemove = { pendingAttachments = pendingAttachments - it },
)
OutlinedTextField(
value = input,
onValueChange = {
input = it
saveDraft(context, summary.id, it)
},
modifier = Modifier.fillMaxWidth(),
// No longer "(+image)": the images are on screen above this, and a placeholder
// saying so said it in words beside the thing itself.
placeholder = { Text("Message") },
maxLines = 4,
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
TextButton(
onClick = {
pickImage.launch(
PickVisualMediaRequest(
ActivityResultContracts.PickVisualMedia.ImageOnly
)
)
}
) {
// Just "+" now. The count was standing in for showing them.
Text("+")
}
// The settings share what is left after the actions have
// taken what they need. A Row hands out intrinsic widths in
// order and clips whatever runs past the edge, so with
// these laid out first the arrival of Stop pushed Send off
// the screen entirely -- the app's central control, gone at
// exactly the moment the app is most in use.
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f),
) {
if (offeredModels.isNotEmpty()) {
PickerButton(
current = modelLabel(model),
// What the machine offers, plus the state a session is in when it
// has chosen none of them. The button has always been able to say
// "default"; until this the list could not, so leaving it was a
// one-way trip.
options = listOf(DEFAULT_MODEL) + offeredModels,
// Not set here. The button follows what the session reports it
// is set to, which arrives a moment later and is sometimes a
// different answer -- a name the CLI resolved, or no change at all
// on a provider whose model is fixed when it starts.
// Asked about first, unless there is nothing to lose by it --
// see [ModelSwitchWarning].
onPick = { chosen ->
if (
modelLabel(chosen) == modelLabel(model) ||
!worthWarningAbout(status, contextTokens, items)
) {
act { setSessionModel(settings, summary.id, chosen) }
} else {
pendingModel = chosen
}
},
)
}
PickerButton(
current = permissionMode,
options = PERMISSION_MODES,
onPick = { chosen ->
act { setSessionPermissionMode(settings, summary.id, chosen) }
},
)
}
// The same filled shape as the button beside it, not an outlined one: these are
// two things you can do about the session, and weighting one of them as
// secondary
// said they were a primary action and its qualifier. What separates them is the
// colour and the mark, which is what they mean.
//
// Always here, rather than arriving with the turn as it used to. A control that
// comes and goes makes its own presence the signal, and its absence could not
// say
// whether there was nothing to do; a button that is always in the same place
// also
// cannot push Send off the end of the row by turning up.
val process =
when {
running -> ProcessAction.Pause
status == "exited" -> ProcessAction.Start
else -> ProcessAction.Stop
}
Button(
onClick = {
processInFlight = true
act(onDone = { processInFlight = false }) {
process.perform(settings, summary.id)
}
},
enabled = !processInFlight,
colors = actionButtonColors(process.colour()),
) {
Glyph(
process.glyph,
colour = LocalContentColor.current,
modifier = Modifier.semantics { contentDescription = process.label },
)
}
Spacer(Modifier.width(8.dp))
// The paper plane, with a clock on it while a turn is in flight: sending then
// queues the message for the next tool boundary rather than starting a turn of
// its own, and the two have to be told apart at a glance. The label says the
// same
// thing to a screen reader, which has nothing else to read.
//
// Disabled while there is nothing to send, rather than pressable and silent:
// `send` has always returned early on an empty composer, so the button promised
// something it would not do, and the only feedback was the ripple. Disabled and
// not hidden, for the reason the button beside it is always here.
Button(
onClick = { send() },
enabled = input.isNotBlank() || pendingAttachments.isNotEmpty(),
colors = actionButtonColors(if (running) queueColor else sendColor),
) {
Glyph(
if (running) QUEUE_GLYPH else SEND_GLYPH,
colour = LocalContentColor.current,
modifier =
Modifier.semantics { contentDescription = sendLabel(running) },
)
}
}
}
}
}
// Beside the other two dialogs, and outside the list for the same reason as them: what is
// open is the screen's business rather than any row's. See [SessionImageViewer].
fullImage?.let { ref -> SessionImageViewer(settings, summary.id, ref) { fullImage = null } }
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. */
private fun sendLabel(running: Boolean) = if (running) "Queue" else "Send"
/**
* What the composer's process button would do if it were pressed now.
*
* One value rather than four parallel conditions over the status, because the mark, the colour, the
* name a screen reader is given and the request that goes out are four halves of one decision. A
* button drawn as a pause that terminates the CLI is the worst bug available here, and separate
* branches over the same condition are how that happens -- these three each have to cover every
* case, and the compiler says so.
*/
private enum class ProcessAction(val glyph: String, val label: String) {
/** A turn is running: take it back, and leave the process holding the conversation. */
Pause(PAUSE_GLYPH, "Pause"),
/** Nothing is running, but the process behind the session is: end it. */
Stop(STOP_GLYPH, "Stop"),
/** The process is gone: start it again, on the conversation it left. */
Start(PLAY_GLYPH, "Start"),
}
@Composable
private fun ProcessAction.colour() =
when (this) {
ProcessAction.Pause -> pauseColor
ProcessAction.Stop -> stopColor
ProcessAction.Start -> startColor
}
private fun ProcessAction.perform(settings: ServerSettings, sessionId: String) =
when (this) {
ProcessAction.Pause -> interruptSession(settings, sessionId)
ProcessAction.Stop -> stopSession(settings, sessionId)
ProcessAction.Start -> startSession(settings, sessionId)
}
/**
* A message the person holding the phone sent, in a bubble at their end of the conversation.
*
* [pending] is one the server has taken and the session has not read yet -- drawn quieter, because
* "said" and "heard" are different claims and the transcript must not merge them.
*
* A pending bubble is tappable: [onTakeBack] asks the server to drop the message before the session
* reads it, and [refusal] is what came back when it would not. The refusal is drawn here rather
* than with the screen's other errors because this is where the reader pressed -- the error row is
* under the header, a screen away from the bubble they were looking at.
*/
@Composable
private fun UserBubble(
settings: ServerSettings,
sessionId: String,
text: String,
images: List<String> = emptyList(),
onOpenImage: (String) -> Unit,
pending: Boolean = false,
refusal: String? = null,
onTakeBack: (() -> Unit)? = null,
) {
Box(Modifier.fillMaxWidth()) {
Card(
// A message the session has not read yet is drawn quieter than
// one it has. The difference is in degree -- said, not yet
// heard -- which is what colour alone can carry; where it sits
// is what says the rest.
colors =
CardDefaults.cardColors(
containerColor =
if (pending) MaterialTheme.colorScheme.surfaceVariant
else MaterialTheme.colorScheme.primaryContainer
),
modifier =
Modifier.align(Alignment.CenterEnd)
.padding(start = 48.dp)
.then(
if (onTakeBack == null) Modifier
else
Modifier.clickable(onClick = onTakeBack).semantics {
// The bubble is its own control and its own label; without this
// the only thing to read is the message, which does not say what
// pressing it does.
contentDescription = "Waiting to be read; tap to take it back"
}
),
) {
Column(Modifier.padding(12.dp)) {
// A message can be nothing but an attachment, and an empty line above a picture
// is a bubble with a gap in it for a sentence nobody wrote.
if (text.isNotEmpty()) {
Text(
text,
color =
if (pending) MaterialTheme.colorScheme.onSurfaceVariant
else MaterialTheme.colorScheme.onPrimaryContainer,
)
}
// Under the words: what somebody wrote is what the bubble is, and the picture is
// what they attached to it. It also keeps the first line of every bubble at the
// same place down the transcript, whether or not there is an image in it.
images.forEachIndexed { index, ref ->
if (index > 0 || text.isNotEmpty()) Spacer(Modifier.height(4.dp))
SessionImage(settings, sessionId, ref, onOpenImage)
}
refusal?.let {
Spacer(Modifier.height(6.dp))
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
}
/**
* A message the server has accepted and the session has not read yet.
*
* [refusal] is why taking it back did not work, kept per message rather than on the screen: two
* bubbles can be waiting at once, and an error above them both would not say which one it was
* about.
*/
private data class QueuedMessage(
val id: String,
val text: String,
val images: List<String>,
val refusal: String? = null,
)
/**
* Whether a model switch has anything to warn about -- see [ModelSwitchWarning].
*
* What the warning is about is a *cache* being dropped, so the question is whether there is one.
* Two answers say there is not, and both used to produce the dialog anyway:
*
* A session whose process has exited has nothing running to hold a cache, so the next turn was
* always going to re-read the conversation -- the switch adds nothing to that bill. And a session
* reporting zero context is holding nothing, which is what `/clear` leaves behind.
*
* Where the figure is *unknown* rather than zero the fallback is what it always was: whether
* anything has been said at all. Unknown is not nothing, and treating it as nothing would drop the
* warning on exactly the sessions -- an import, a fresh reattach -- where nobody has measured yet
* and the conversation may be enormous.
*/
private fun worthWarningAbout(
status: String,
contextTokens: Long?,
items: List<TranscriptItem>,
): Boolean =
when {
status == "exited" -> false
contextTokens != null -> contextTokens > 0
else -> items.isNotEmpty()
}
/**
* Asked before switching model, because switching is not free and the cost is invisible.
*
* A model change drops the cached context: the next turn re-reads the entire conversation from the
* beginning and is charged for it. Measured on 2026-08-29 against a small session -- the turn
* before the switch read 30,771 tokens from cache and created 87; the turn after read **nothing**
* from cache and created 41,509. On a long conversation that is the whole of it, again.
*
* No number is offered here, deliberately. What it will cost depends on how long *this*
* conversation is, and this screen does not know that -- the running total beside it counts what
* has been spent, which is a different quantity. A figure worked out from it would be a guess in a
* measurement's clothes, and the reader could not tell which times it was right.
*
* The permission-mode picker beside it deliberately has no equivalent, which the same measurement
* decided: changing mode kept the cache (30,858 read, 75 created). Warning on both would teach the
* reader that these dialogs can be clicked through, which is what makes the one that matters stop
* working.
*/
@Composable
private fun ModelSwitchWarning(
from: String,
to: String,
onDismiss: () -> Unit,
onConfirm: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Switch to $to?") },
text = {
Text(
"The session re-reads the whole conversation on its next turn: leaving $from " +
"drops the cached context, so that turn costs as much as the conversation " +
"is long. Nothing is lost -- it is read again, not forgotten."
)
},
confirmButton = { TextButton(onClick = onConfirm) { Text("Switch") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Keep $from") } },
)
}
/**
* What the session is doing, and what the conversation has cost, on one line above the box.
*
* A row of its own because both of these are facts about the session rather than turns in it, and
* both were previously drawn over the transcript: the token total floated in its bottom corner,
* where a long message ran underneath it, and the working indicator was an item inside the list, so
* it scrolled away exactly when somebody reading back wanted to know whether anything was still
* happening. Here they are always in the same place, and the thing they report on -- the session
* you are about to type at -- is directly below.
*
* The row is drawn whether or not it has anything to say. An empty one costs a line; a row that
* came and went would move the text box under the reader's thumb every time a turn started, and
* would make its own presence the signal for a state it never names.
*
* The states are the session's own status words plus the total, and each looks different from the
* others: `exited` is here because a session whose process is gone cannot be typed at, and with the
* indicator gone from the list nothing else on this screen would say so.
*/
@Composable
private fun SessionStatusRow(
status: String,
/** Seconds since this device saw the compaction start; null if it did not see it. */
compactingFor: Long?,
/** Context the session is holding, or null where nothing has measured it. */
contextTokens: Long?,
modifier: Modifier = Modifier,
) {
DebugStats.count("status row recomposed")
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
) {
when (status) {
// A bar rather than the spinner an ordinary turn gets, and it takes the row's whole
// free width: nothing arrives in the transcript during a compaction, so this is the
// only thing on screen that is moving, and at a spinner's width that reads as a
// session that has hung.
//
// Indeterminate, which is a statement rather than an omission. The CLI says a
// compaction has begun and then says nothing at all until it has finished -- measured
// against 2.1.237 again on 2026-08-29, on a real 80,346-to-2,088-token compaction that
// took 23 seconds and produced not one line in between. So there is no fraction to
// fill, and a bar creeping along at the pace of the last one would be this screen
// inventing the part nobody sent it. Elapsed time is the only honest number here, and
// [compactingLabel] is where it is worded.
"compacting" -> {
Text(
compactingLabel(compactingFor),
style = MaterialTheme.typography.labelSmall,
// Stated beside the fill rather than inherited: a semantic colour has to carry
// its own contrast, since the surface under it will not change to rescue it.
color = commandColor,
)
LinearProgressIndicator(
color = commandColor,
trackColor = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = Modifier.weight(1f).padding(horizontal = 8.dp),
)
}
"running" -> {
CircularProgressIndicator(
// Smaller than the line beside it, so the row keeps the text's own height:
// a control taller than a line re-centres it and knocks it out of line with
// the total on the other end.
modifier = Modifier.width(12.dp).height(12.dp),
strokeWidth = 2.dp,
)
Text(
"working",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp),
)
Spacer(Modifier.weight(1f))
}
// Every remaining state says which one it is, including the quiet one. The row used
// to name only `exited` and leave the rest blank, so a session sitting idle and one
// whose status nobody could read looked identical -- and a turn that had just been
// stopped showed nothing at all, which reads as the app having lost the session
// rather than as the stop having worked. The words are the session list's own, so
// one state is not called two things depending which screen you are on.
else ->
Text(
when (status) {
"idle" -> "idle"
"exited" -> "exited"
"awaitingInput" -> "your turn"
"unknown" -> "can't tell"
else -> status
},
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
}
// How full the session is, which is the number a reader is asking about -- how much room
// is left before the next compaction -- rather than what has been spent getting here.
//
// "unknown" in words, and always drawn. A context nobody has measured is not an empty
// one, and the two used to share an appearance: a session that had just been cleared, one
// whose provider never reports usage, and one that has not run a turn all showed nothing
// at all, which reads as a conversation with room to spare. It is the same reason the
// status word beside it names the quiet state instead of leaving the row blank.
Text(
contextTokens?.let { "context ${tokens(it)}" } ?: "context unknown",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
/**
* A question (or permission request -- same shape) inline in the transcript. Option buttons until
* answered; then the chosen answer, which the `answered` event also resolves on every other
* connected device.
*/
@Composable
private fun QuestionRow(
question: TranscriptItem.QuestionCard,
onAnswer: (List<String>) -> Unit,
) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
// The same body the questions on a tool call get: one question is the same
// thing whether or not something else asked it.
AskedQuestion(question, onAnswer)
}
}
}
/**
* How long after a menu closes a press on its own button still counts as the press that closed it.
*
* Sized to one tap, because one tap is all it has to span -- [PickerButton] explains the pair of
* events it separates. Deliberately not the platform's long-press timeout, which is the longest a
* tap can legally be: half a second of ignoring the button would start swallowing a deliberate
* reopen, and a press held that long to close a menu is not worth protecting at that price.
*/
private const val ONE_TAP_MS = 250L
/**
* A control that reads as its own value.
*
* The button *is* the current setting rather than a label beside one, so the row says what the
* session is set to without spending a second line on saying it.
*/
@Composable
private fun PickerButton(current: String, options: List<String>, onPick: (String) -> Unit) {
var open by remember { mutableStateOf(false) }
// When an outside touch last closed the menu.
//
// Pressing this button while its own menu is open is such a touch. The menu is deliberately
// not focusable (see below), which means the press that dismisses it is also delivered to the
// window underneath -- and what it lands on there is this button. The dismissal arrives with
// the press and the click with the release, measured 3ms apart on the emulator, so a button
// that simply opened on every click would reopen what the same finger had just closed, and
// the menu could only be put away by tapping somewhere else. So the moment is remembered, and
// a click that follows it within one tap is read as the second half of that tap rather than
// as a new one.
var closedAt by remember { mutableLongStateOf(0L) }
Box {
TextButton(
onClick = { if (SystemClock.uptimeMillis() - closedAt > ONE_TAP_MS) open = true }
) {
// One line, truncated rather than wrapped: this sits in a row
// whose height is the buttons beside it, and a second line
// would move them.
Text(
current,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
// Two departures from the defaults, both deliberate.
//
// Not focusable, so opening it does not take focus from the message field and dismiss the
// keyboard. Changing the model mid-sentence is an aside, not a departure from what you
// were typing.
//
// Not clipped, which is what puts the menu on the button instead of floating above it.
// Compose measures the anchor in *window* coordinates -- this app draws edge to edge, so
// that window is the whole screen -- but asks whether the menu fits inside the *visible*
// frame, which is the screen less the status and navigation bars. Two spaces, one
// comparison: sitting just above a button near the bottom then looks like an overflow,
// and the menu falls back to a fixed 48dp above the bottom of the visible frame. Measured
// on the emulator, that left the menu's foot 142px -- the status bar's height, exactly --
// clear of the button that opened it. Turning clipping off makes both questions about the
// same window. What it gives up is that the keyboard stops counting as an edge: with the
// IME up the menu opens downwards over it rather than upwards over the transcript. That
// is the lesser fault -- it is still attached to the button that opened it, which is the
// whole complaint -- and correcting it would mean supplying a position provider, which
// this menu takes no parameter for.
DropdownMenu(
expanded = open,
onDismissRequest = {
open = false
closedAt = SystemClock.uptimeMillis()
},
properties = PopupProperties(focusable = false, clippingEnabled = false),
) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(option) },
onClick = {
open = false
if (option != current) onPick(option)
},
)
}
}
}
}