Files
ai-app/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt
T
iris 82401cd887 Select any of the transcript, and take a queued message back
Two things a reader could not do to what is on screen.

**Selection.** Nothing in the transcript was selectable at all, so a
command, a path or an error message could be read and not copied. One
`SelectionContainer` around the whole list rather than one per row: a
transcript is one body of text to a reader, and a selection has to be able
to run from a reply into the tool output under it. Per row it also could
not, and whatever was drawn without a container would have been silently
unselectable -- a state nothing on screen reports. Rows keep their tap
handlers; checked on the emulator that expanding a tool call, scrolling and
flinging are all unaffected, since a selection is a long press.

**Taking a message back.** A message sent into a running turn sits as a
bubble waiting to be read, and there was no way to change your mind: it is
tappable now, and the server answers `POST /sessions/{id}/unqueue`.

The answer has three states, and the middle one is the point. Claude's
driver writes a steer into the CLI's stdin the instant it arrives -- that
is what makes it reach the model at the next tool boundary rather than at
the end of the turn, and it was measured -- so the line is already gone and
`AlreadySent` is the only honest answer it can give. Holding the write
until a boundary would make the drop real and cost a steer one model call,
which is the latency the immediate write exists to remove; rejected on that
trade, with the reasoning in PLAN.md. The refusal is drawn on the bubble
that was pressed rather than in the error row under the header, a screen
away from it.

Where a driver really does hold its queue -- echo today -- the message goes
for good, and it goes as an `Event::MessageDropped` rather than as a return
value: every device watching the session loses the bubble, and a phone that
reconnects and replays the `messageQueued` does not put back one that was
cancelled with nothing left to resolve it.
2026-08-31 22:20:47 -04:00

2055 lines
115 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.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.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.
@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) }
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>()) }
// 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 {
// 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: ApiException) {
streamError = e.message
} 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 }
}
}
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
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() })
.imePadding()
) {
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,
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)
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)
},
)
is TranscriptRow.Single ->
when (val item = row.item) {
is TranscriptItem.UserMsg ->
UserBubble(
settings = settings,
sessionId = summary.id,
text = item.text,
images = item.images,
)
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,
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)
},
)
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)
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 {
translationY =
-(imeInsets.getBottom(this) - navInsets.getBottom(this))
.coerceAtLeast(0)
.toFloat()
}
.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(
commands = suggestedCommands(input),
onPick = { command -> input = 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) || items.isEmpty()
) {
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) },
)
}
}
}
}
}
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(),
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)
}
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,
)
/**
* 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)
},
)
}
}
}
}