package com.example.aiapp import android.content.Context import android.content.pm.ApplicationInfo import android.net.Uri 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.foundation.text.selection.rememberSelectionState 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.TextRange import androidx.compose.ui.text.input.TextFieldValue 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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.channels.Channel 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 report on one call among many, and smaller than a splash. */ private val LOADING_SPINNER = 48.dp /** One callback from the blocking SSE reader, queued in wire order for the UI thread. */ private sealed interface TranscriptDelivery { data object Opened : TranscriptDelivery data object Reset : TranscriptDelivery data class Event(val entry: SeqEvent) : TranscriptDelivery } /** * 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*, which is the distance the question is * actually about. 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 that is less * than one screen, so the reader ran out of loaded transcript on every swipe. * * Six, because the two ways to be wrong are not the same size: firing early costs a page nobody * reads, firing late is a spinner under somebody's finger for a whole round trip over the tunnel. */ private const val HISTORY_SCREENS = 6 /** * How many *rows* a backwards page asks for. * * Rows, not events, because the two are nothing alike: a reply is stored a token at a time, and on * one real transcript (2,426 events) the whole conversation was seven assistant messages, the * median folding four hundred deltas into one row. A page counted in events was a fifth of a single * row, so reaching a screenful took dozens of sequential round trips. * * A few screens' worth, so one page clears the cushion below. A page that still falls short is * followed by another in the background, nobody waiting on either. * * The opening page stays counted in events and small: it is on the critical path of showing the * screen, and it is the newest window, where coalescing is unsafe for the live cursor anyway. */ private const val HISTORY_PAGE = 40 /** * 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 in one request rather than * walking there a page at a time. This bounds it anyway, because "exactly how far" is however far * the reader had scrolled -- and a single response of arbitrary size is the shape a phone on a slow * tunnel handles worst. At roughly 800 bytes an event, this is about three megabytes. Going past it * costs another request rather than anything being missed. */ private const val RESTORE_PAGE_MAX = 4000 /** * Events added past the anchor on a restore, so the anchor's row is never the oldest loaded one. * * The oldest loaded row is a half-row -- [joinPages] welds its other half on when the page behind * it arrives, and it grows -- so a restore stopping exactly at the anchor would put the reader a * screen out once that growth landed. */ private const val RESTORE_PAGE_CUSHION = 400 /** * Which row was asked to hold its top edge, and how tall it was when it last measured. * * Deliberately *not* snapshot state, which is the point of the 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 already being laid out. [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 -- the end that * row should hold when it changes height; see [holdTopEdge]. * * One slot rather than a map, because only the touch about to toggle something matters: * [toggleAnchored] reads it in the same gesture that wrote it. Written from a detector on each * *visible* row, so it costs only rows on screen and runs on touch rather than per frame. */ 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 * why 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 first -- visible as a * flick, and worse the faster the screen refreshes. * * [hold] is given the change in height. The row's bottom edge is held by the list, so a scroll of * exactly that much 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. // // isImeVisible: see the comment beside `imeVisible` below. @OptIn(ExperimentalLayoutApi::class) @Composable fun SessionScreen( settings: ServerSettings, summary: SessionSummary, onBack: () -> Unit, /** Opens the file explorer on this session's machine, starting where this session works. */ onFiles: (FilesTarget) -> Unit, /** What another app shared in while this session is the one open; see [ShareRequest]. */ share: ShareRequest? = null, /** Said once [share] has been attached here, so it is not attached again. */ onShareTaken: () -> Unit = {}, /** * Draws this screen read-only, on a subagent's own transcript instead of the session's. * * A subagent has no process and no controls of its own -- see SUBAGENTS.md's "Phone" -- so * every gate below keyed on this switches off the composer, the files button, the settings cog, * the usage bar and notifications, while everything that draws a transcript (paging, cache, * selection, images, the status row, stream reconnects) is reused unchanged, pointed at * [address] instead of the session's own. */ subagent: SubagentSummary? = null, ) { DebugStats.count("session screen recomposed") val isSubagent = subagent != null val address = TranscriptAddress(summary.id, subagent?.id) val scope = rememberCoroutineScope() val topEdgeHeld = remember { TopEdgeHold() } var items by remember { mutableStateOf(listOf()) } var status by remember { mutableStateOf(subagent?.status ?: 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. // // A subagent has no context measurement of its own, so it always starts unmeasured rather than // borrowing the parent session's figure -- see UI_RULES on not showing an inferred value as one // that was measured. var contextTokens by remember(address) { mutableStateOf(if (isSubagent) null else summary.contextTokens) } // When the current compaction started. 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. var compactingSince by remember { mutableStateOf(null) } var compactingFor by remember { mutableStateOf(null) } var streamError by remember { mutableStateOf(null) } var actionError by remember { mutableStateOf(null) } // Whether the composer's process button has a request out. What it does next is decided from // the session's status, and that 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. 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 does not throw away a half-typed message. See `Drafts.kt`. // // A subagent has no box to type into, so it never touches a draft at all -- not this session's, // which is what reading one keyed only by `summary.id` would do here. var input by remember(summary.id) { mutableStateOf(if (isSubagent) atEnd("") else atEnd(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. var pendingModel by remember { mutableStateOf(null) } // What was last taken from the command suggestions, so the list closes behind it. var picked by remember { mutableStateOf(null) } // The transcript's selection, held here rather than inside [TranscriptList] because the rows // have to ask whether anything is selected before they treat a tap as their own. val selection = rememberSelectionState() // Read here, at composition, rather than inside [expanding] at the moment of the click. The // container clears the selection from the very press a card then reads as its own, a few // milliseconds earlier and in the same event -- so a card asking the live state always hears // "nothing is selected", and a tap meant to put a selection away also shut the tool call the // words were in. This is whatever was true as of the last frame. val selecting = selection.selectedTexts.isNotEmpty() var expandedTools by remember { mutableStateOf(setOf()) } // 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()) } // Runs already drawn as a group, so the transition into one is noticed exactly once. var everGrouped by remember { mutableStateOf(setOf()) } // 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. var expandedNotes by remember { mutableStateOf(setOf()) } // Which memory notes are open, by the note's own text. 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()) } // 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. var fullImage by remember { mutableStateOf(null) } // Uploaded-but-not-yet-sent attachment ids; sent with the next message. var pendingAttachments by remember { mutableStateOf(listOf()) } // 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 -- 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>(emptyList()) } var offeredPermissionModes by remember { mutableStateOf>(emptyList()) } val lifecycleOwner = LocalLifecycleOwner.current // The resume cursor, written from the stream's IO thread. val lastSeq = remember { AtomicLong(0) } // Bumped to rebuild this screen from nothing -- what Reload in the settings dialog does. It // keys everything describing one visit: the source, the opening effect, the stream, and the // anchor. See TRANSCRIPT_CACHE.md's decision 8. var epoch by remember(summary.id) { mutableIntStateOf(0) } // This server's cached transcripts, and this session's half of them. The cache is per server // because two servers can hold a session with the same id; the source is per visit because // Reload throws away what it was reading from. val cache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) } val source = remember(address, epoch) { TranscriptSource(settings, address, cache.session(address)) } // Whether the cached tail has been shown to still be the server's own line. Nothing is resumed // from a cached cursor until it has, and a probe that could not be made leaves this false for // the stream loop to try again. var probePassed by remember(address, epoch) { mutableStateOf(false) } // Whether the opening effect is still settling that question. It draws the cached rows and // lifts [ready] before the answer arrives, which is the point of the cache -- so the stream // below waits for this rather than for `ready`, or it asks the same question twice. var probing by remember(address, epoch) { mutableStateOf(true) } // The oldest sequence number loaded, and whether there is more behind it. Paging backwards is // what keeps opening a long session cheap. var oldestSeq by remember { mutableLongStateOf(0L) } // Where this transcript was last being read, from this device's own store, keyed by the address // rather than the session id so a subagent's saved position cannot collide with its session's. // Read once, because the answer stops being interesting the moment the list is on screen. val savedAnchor = remember(address, epoch) { loadScrollAnchor(context, address.cachePath) } // Whether the saved position is still being put back. 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. var restoring by remember(address, epoch) { mutableStateOf(savedAnchor != null) } // Messages not yet recorded as received by the provider. The local ones bridge Send to the // server's first durable event (and hold a network failure); the rest are reconstructed from // that event stream, so another device's queued message is drawn here too. val pendingKey = remember(settings, address) { "${settings.host}:${settings.port}/${address.cachePath}" } var queued by remember(pendingKey) { mutableStateOf(loadPendingMessages(context, pendingKey)) } /** * Changes the visible outbox and keeps only its not-yet-durable part across a screen reopen. */ fun replaceQueued(messages: List) { queued = messages savePendingMessages(context, pendingKey, messages) } // Commands the session has been asked to run and cannot yet, by the id that will resolve them. // From the server, so a rename sent from another device is drawn waiting here too. var waitingCommands by remember { mutableStateOf(listOf>()) } val running = sessionWorking(status) 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]. val replies = remember(address) { ParsedReplies() } // Keyed like everything else describing one 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(address) { 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 makes an index ambiguous. // This is what the jump-to-newest button watches, and the gate on recording. val atNewest by remember { derivedStateOf { !listState.canScrollBackward } } // A decision made only by an actual scroll gesture. `canScrollBackward` also changes while a // streaming row is laid out, and treating that displacement as intent freezes the rest of the // reply off-screen while still advancing the SSE cursor. var followingNewest by remember(address, epoch) { mutableStateOf(savedAnchor == null) } // 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]. var held by remember { mutableStateOf(listOf()) } // What is actually drawn: the transcript with runs of adjacent tool calls folded into one row. val rows = remember(items) { groupToolRuns(items) } // Bumped when a cold reply's parses become ready, so the flatten runs again and can split it. var warmedTick by remember { mutableIntStateOf(0) } val units = remember(rows, expandedNotes, warmedTick) { transcriptUnits(rows, replies, expandedNotes) } // The reply that just finished streaming is the one row whose parses nobody has made: pages // warm before their fold lands, but nothing warms live deltas. Off the composing thread, then // the tick re-flattens, so settling never costs a whole-message parse in a frame. LaunchedEffect(rows) { val cold = unwarmedReplies(rows, replies) if (cold.isNotEmpty()) { warm(replies, cold) warmedTick++ } } // The same list, readable from effects launched before this composition: an effect's closure // keeps the values of the composition that launched it. val currentUnits by rememberUpdatedState(units) val lastTouch = remember { LastTouch() } /** * Drops everything loaded, so the screen can be rebuilt from a window that is not adjacent to * it. * * One function rather than a clearing written at each of the three places that need it -- a * stream reset, a cached transcript the server turns out not to have, and Reload -- because * what has to go is a property of "these rows are no longer continuous with what comes next". * The two easy ones to leave out are [queued] and [waitingCommands]: both are folded from * events, so a `messageQueued` whose resolving `userMessage` fell in the gap draws a bubble * waiting for a message the session read long ago. [contextTokens] needs no clearing, because * `UsageDelta.context` is absolute. * * The resume cursor is deliberately *not* cleared: a reset continues from where it was. */ fun dropLoadedTranscript() { items = listOf() replies.clear() held = listOf() oldestSeq = 0L moreHistory = true // A send the server could not accept is this phone's only copy. A reset replaces server // state, not that local outbox, so dropping it here would eat the message a second time. replaceQueued(queued.filter { it.local }) waitingCommands = listOf() } /** * 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 waiting six seconds ended at the very * bottom. * * Insertions were never the problem -- the list is keyed. What cannot be allowed is a row that * is already there changing height, and the one guarantee covering every way that happens 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 // 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) { replaceQueued(reconcileQueuedMessage(queued, event)) } if (event is SessionEvent.UserMessage) { replaceQueued(reconcileUserMessage(queued, event)) } // 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) { replaceQueued(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. See `contextAfter`. contextTokens = contextAfter(contextTokens, entry.event) when (val event = entry.event) { // Nothing further: what it carries was folded into the context above. 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 -- and 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. // Read on the UI thread (the stream below marshals every frame here). This direct // check closes the small window before the scroll observer records the gesture. if (listState.isScrollInProgress && !atNewest) followingNewest = false if (followingNewest && held.isEmpty()) record(entry) else held = held + entry } } } /** * A press on the transcript that would open or close something, and the one thing every such * press has to check first. * * The transcript is one [SelectionContainer], so a reader who has selected some text puts that * selection away by tapping -- and the tap that does it lands on whatever card the text is * drawn in. Left alone, that card takes it as a press of its own: the reader clears a selection * and the tool call under their finger collapses. So a press with a selection outstanding * spends itself clearing it and does nothing else. * * Every open and close goes through here rather than each writing the check, since which card * the finger lands on is not something the reader chose. */ fun expanding(toggle: () -> Unit) { if (selecting) { selection.clear() return } toggle() } /** * 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. 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 that one asks the row to hold its top edge instead. * * Which half decides it, rather than which control was pressed, so everything that opens * behaves the same way whether or not it has a control at each end. * * The correction itself belongs to the measurement -- see [holdTopEdge]. */ fun toggleAnchored(row: TranscriptRow, toggle: () -> Unit) = expanding { 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 easy to leave out. The oldest loaded row is a half-row -- [joinPages] * welds the other half on 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, and the reader's place is * inside whichever row now holds that seq. * * Computed from `items` rather than `rows` for the reason [loadOlderPage] gives. */ 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. * * 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, coalesce: Boolean = true): 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. // 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 and makes `visibleItemsInfo` non-empty before a single event // has arrived. On a loopback server the opening page beat it; at `--delay 150`, which is // what a phone over the tunnel costs, it won the race. // // Guarded here rather than at the two callers because it is a fact about the question. val before = oldestSeq if (before == 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 is // that many copies of a growing list -- around three hundred thousand element copies, run // on the main thread in the middle of the scroll that asked for it. // // `Dispatchers.IO` for both rather than a hop to `Default` between them: the two are one // errand. Neither half touches anything the composition owns. val page = withContext(Dispatchers.IO) { val older = source.page(before = before, limit = limit, coalesce = coalesce) 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() older.forEach { entry -> if (entry.event !is SessionEvent.UsageDelta) { earlier = foldEvent(earlier, entry) } } older.first().seq to earlier } // A stream reset can replace the transcript while the page request is out. Its answer is a // page of the view that no longer exists, so it must not decide anything about the new one. if (oldestSeq != before) return false if (page == null) { moreHistory = false return false } val (oldest, earlier) = page // 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. val joinedForWarm = 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. warm(replies, joinedForWarm) // `warm` suspends. A live event can be recorded while it parses, so joining the page before // it and then assigning that snapshot afterward used to erase the event from this visit -- // the durable transcript still held it, which is why reopening made a sent message return. // Read and write `items` together after the suspension instead. if (oldestSeq != before) return false oldestSeq = oldest moreHistory = oldestSeq > 1L items = joinPages(earlier, items) 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". // // Considered once per run, at the moment it first becomes a group, and never again: after that // the group's own toggle owns it. LaunchedEffect(rows) { val fresh = rows.filterIsInstance().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 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. 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. // // The newest window first, before the stream opens, so the stream starts from where that window // ended and carries live events only. The window comes from this phone's own copy when there is // one, and then costs a single request to check that the server's transcript is still the one // it came from. See TRANSCRIPT_CACHE.md. LaunchedEffect(address, epoch) { /** * One opening window onto the screen, whichever side it came from. * * Warmed before the fold lands rather than after: flattening the rows into units splits * every settled reply, and the flatten runs in the composition that first sees the rows. */ suspend fun open(page: List) { withContext(Dispatchers.IO) { var scratch = listOf() page.forEach { entry -> if (entry.event !is SessionEvent.UsageDelta) { scratch = foldEvent(scratch, entry) } } warm(replies, scratch) } page.forEach { apply(it) } } try { // This phone's own copy first, drawn before anything is asked of the server. What makes // it safe to draw before it is checked is that a failed check replaces these rows, with // the same appearance as a reset. val cached = withContext(Dispatchers.IO) { source.cachedOpening() } if (cached != null) { open(cached) // A replay is as old as the last visit; the row this screen was opened from was // fetched moments ago. So the transcript comes from the cache and everything that // is not the transcript comes from the summary -- otherwise a session that finished // an hour ago opens saying "working" until the stream connects. A subagent's status // comes from its own summary, never the parent session's: they are two different // things running or not, and the parent's model and permission mode do not apply to // it at all. status = subagent?.status ?: summary.status if (!isSubagent) { model = summary.model permissionMode = summary.permissionMode ?: "auto" } if (status != "compacting") compactingSince = null // Nothing to put back, so these rows are the screen and the probe can return under // them. A restore still has history to fetch and is gated below. if (savedAnchor == null) ready = true } // The one thing a cached cursor has to be shown before the stream resumes from it. val usable = cached != null && withContext(Dispatchers.IO) { source.probe() } if (usable) probePassed = true if (!usable) { // Either there was nothing cached, or what was cached is not what the server has -- // the file was replaced or truncated under it. Same clearing as a reset, then an // ordinary cold open. if (cached != null) { dropLoadedTranscript() lastSeq.set(0) } open(withContext(Dispatchers.IO) { source.fetchOpening() }) // Refilled from the server, so the tail is the server's by construction. probePassed = true } } catch (e: ApiException) { // Not fatal: the stream below still replays from zero, which is slow but complete. // // It is also where a probe that could not be *made* lands -- a phone with no route to // the server. Whatever was cached stays on screen and [probePassed] stays false, so the // stream loop asks again before it resumes from that cursor. streamError = e.message } finally { // However that went, the stream is free to take it from here. probing = false } try { // 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. 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 that grows when the page behind it arrives, so // anchoring into one puts the reader where they were only until that lands. // // This terminates because `oldestSeq` walks strictly backwards and the anchor is a // seq. 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. 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 overshoots into older history rather than stopping // short. // // 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. Raw, not coalesced: this counts events back to a // known seq, and a page measured in rows cannot be counted to one. val behind = oldestSeq - anchor.seq val loaded = if (behind < 0) { // The anchor's row is loaded but is the oldest half-row, which // [anchorRow] refuses; what completes it is the row before it, and only // a page counted in rows can promise to reach that. Counted in events // the span is negative and was coerced to one: a request per delta, six // hundred round trips for an anchor inside a 1,400-delta reply. loadOlderPage() } else { loadOlderPage( (behind + RESTORE_PAGE_CUSHION) .coerceIn(1L, RESTORE_PAGE_MAX.toLong()) .toInt(), coalesce = false, ) } if (!loaded) 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. Null is a row no longer in the // transcript at all, and means there is nothing to put back. 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 units are a pure function of `items`. Nothing is drawn during the wait. // 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) { // A page of history that never arrived. The reader is left at the newest end rather // than where they were, which is the state this screen opens in anyway. streamError = e.message } // Whatever happened above: an empty transcript is a state the screen can draw, and a // permanently blank one is not. followingNewest = atNewest 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. So the first full page goes right behind it, while // the screen is already up. A restore skips this: it has just paged as deep as it needed. if (savedAnchor == null && moreHistory && !loadingHistory) { loadingHistory = true try { loadOlderPage() } catch (_: ApiException) { // The next scroll asks again. } finally { loadingHistory = false } } // Last, and off this thread: this session is what must not be evicted, so it is marked as // visited before the budget is applied, and both are a walk of the cache directory. withContext(Dispatchers.IO) { source.cache.touch() cache.evictToBudget(keep = summary.id) } } // 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" waiting // at the top on their return. Switching apps is a choice somebody made, not a fault to report. // Stopping the stream deliberately makes the drop a close rather than an error, and resuming // reconnects from the same cursor. LaunchedEffect(address, ready, epoch, lifecycleOwner) { if (!ready) return@LaunchedEffect // The opening effect draws cached rows and lifts `ready` *before* it has checked that the // cursor under them is still the server's, so `ready` is no longer the whole gate. Without // this the two run at once and race each other's answer -- two probes per warm open. snapshotFlow { probing }.first { !it } lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { try { while (true) { try { // A cached cursor whose probe never got an answer, because the server could // not be reached when the screen opened. Resuming from an unchecked cursor // is the one thing this must not do, so it is asked again here with the // cached rows still on screen. False covers both answers that mean "open // cold". if (!probePassed) { if (withContext(Dispatchers.IO) { source.probe() }) { probePassed = true } else { dropLoadedTranscript() lastSeq.set(0) withContext(Dispatchers.IO) { source.fetchOpening() } .forEach { apply(it) } probePassed = true } } // The socket is blocking, but Compose state belongs to this UI coroutine. // Queue every callback -- including reset -- through one channel so none // can race layout or overtake another while crossing threads. val delivery = Channel(Channel.UNLIMITED) val follower = launch(Dispatchers.IO) { try { source.follow( after = lastSeq.get(), onOpen = { delivery.trySend(TranscriptDelivery.Opened) }, onReset = { delivery.trySend(TranscriptDelivery.Reset) }, ) { entry -> delivery.trySend(TranscriptDelivery.Event(entry)) } delivery.close() } catch (error: Throwable) { delivery.close(error) } } try { for (next in delivery) { when (next) { TranscriptDelivery.Opened -> streamError = null TranscriptDelivery.Reset -> { // The fresh window has no position in common with the rows // just dropped, so it becomes the view even if the reader // had been further back in the stale prefix. dropLoadedTranscript() followingNewest = true } is TranscriptDelivery.Event -> apply(next.entry) } } } finally { source.close() follower.cancelAndJoin() } } 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. streamError = e.message ?: e::class.simpleName } finally { source.close() } delay(RECONNECT_DELAY_MS) } } finally { // Cancellation cannot interrupt a blocking socket read. Closing is what unblocks // it, and what marks the drop deliberate. source.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. Keyed on the epoch as well, so // Reload's replacement source is the one a later disposal closes. DisposableEffect(address, epoch) { onDispose { source.close() } } // Nothing gets announced about the session somebody is reading; see NotificationService. // RESUMED rather than STARTED because "looking at it" means the foreground. // // Not for a subagent: it has no notifications of its own, and it is not the session this would // otherwise mark as being read. if (!isSubagent) { 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. LaunchedEffect(listState) { snapshotFlow { Triple(listState.isScrollInProgress, atNewest, held.isNotEmpty()) } .collect { (scrolling, newest, hasHeld) -> if (scrolling && !newest) followingNewest = false if (!newest) return@collect followingNewest = true if (!hasHeld) 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. // // 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, and the key does not either; 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, address.cachePath, // Nothing to restore at the newest end, which is where a session with no anchor // opens anyway. One *before* the index, because item zero is the "below" slot. 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, which // is 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. // // 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. val unitSizes = remember(address) { HashMap() } 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 -- 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. loadOlderPage() } catch (_: ApiException) { // Leave `moreHistory` alone: the next scroll asks again. } finally { loadingHistory = false } } } // Only for the model picker, which a subagent does not have. if (!isSubagent) { LaunchedEffect(summary.setup, summary.provider) { val provider = runCatching { withContext(Dispatchers.IO) { fetchSetups(settings) .firstOrNull { it.id == summary.setup } ?.providers ?.firstOrNull { it.name == summary.provider } } } .getOrNull() offeredPermissionModes = provider?.permissionModes.orEmpty() offeredModels = provider ?.let { runCatching { withContext(Dispatchers.IO) { fetchProviderModels(settings, summary.setup, summary.provider) } } .getOrDefault(emptyList()) } .orEmpty() } } /** * 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 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 -- 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*. */ 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" } replaceQueued(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) = expanding { openMemories = if (text in openMemories) openMemories - text else openMemories + text } /** * Opens or closes one peer message, from whichever of its pieces was pressed. * * By seq rather than by unit, because an open message is several units and all of them shut it. * * No [toggleAnchored] here, and that is the difference between growing a row and adding items: * the list is keyed, so it holds the item it is anchored on wherever the new ones land. */ fun togglePeer(seq: Long) = expanding { expandedNotes = if (seq in expandedNotes) expandedNotes - seq else expandedNotes + seq } 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. onDone() } } } /** * Sends every answer a question card handed over, and says when the last of them has settled. * * All of them in one go because a card asks its questions together and the tool is waiting on * all of them; the completion is what turns the card's spinner back into a button. */ fun answerAll(answers: List, onSettled: () -> Unit) { act(onDone = onSettled) { answers.forEach { answerQuestion(settings, summary.id, it.questionId, it.answers) } } } fun send() { val text = input.text.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. if (text.startsWith("/") && attachments.isEmpty()) { input = atEnd("") 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 = atEnd("") saveDraft(context, summary.id, "") pendingAttachments = emptyList() // Clearing the field makes one promise: its contents are either waiting in this quiet // bubble or recorded as a user message. The server's `messageQueued` replaces this local // bridge when it arrives; an immediate `userMessage` removes it. Until one does, even a // network failure leaves the words visible with the failure attached to them. val pending = localPendingMessage(text, attachments) replaceQueued(queued + pending) scope.launch { try { withContext(Dispatchers.IO) { sendMessage(settings, summary.id, text, attachments) } replaceQueued(markPendingAccepted(queued, pending.id)) actionError = null } catch (e: ApiException) { replaceQueued( markPendingFailure(queued, pending.id, e.message ?: "message not sent") ) } } } // One path for everything attached, however it arrived: the photo picker, the file chooser or // another app's share sheet. It uploads as soon as it is chosen, so Send only has ids. fun attach(uri: Uri) { scope.launch { try { val id = withContext(Dispatchers.IO) { // An image is 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; a file goes whole. uploadPicked(context, settings, summary.id, uri, summary.maxImageEdge) } pendingAttachments = pendingAttachments + id actionError = null } catch (e: ApiException) { actionError = e.message } } } val pickImage = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> uri?.let(::attach) } val pickFile = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> uri?.let(::attach) } // What another app shared in, attached the moment this screen has it. Taken off the request // first, so a recomposition cannot attach it a second time. LaunchedEffect(share) { val incoming = share ?: return@LaunchedEffect onShareTaken() incoming.uris.forEach(::attach) incoming.text?.let { shared -> input = atEnd(if (input.text.isBlank()) shared else input.text + "\n" + shared) saveDraft(context, summary.id, input.text) } } // One poll for the machines' limits, read by everything on this screen that reports them. // Nothing meters a subagent -- it has no account of its own -- so it never starts this poll. val usageFeed = if (isSubagent) null else rememberUsageFeed(settings) val usage = usageFeed?.forSession(summary) ?: SessionUsage.NotMetered RecordFrames() 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 -- ~7.6ms of main-thread work per frame // across ~34 frames per open on the emulator, and 82% late frames on the Pixel while the // transcript itself cost 0.25ms. 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. // // 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. 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 the stale // partway value it leaves behind has nothing left to correct it. `isImeVisible` is not // interpolated, so there is nothing for a dropped frame to interrupt. val imeVisible = WindowInsets.isImeVisible // What this session is costing to draw, copied out to somewhere it can be read. // // Written here rather than beside the control that runs it, because everything it measures is // this composable's own state and a control in a dialog cannot reach it. The control is a row // in [SessionSettingsDialog], where the session's other about-the-session controls are. It // copies rather than opens, because what it produces is a message to whoever is looking at the // code. // // Whatever presses this, it is found by its **name**: `ui-trace`'s tap-by-label action resolves // "Session settings" and then "Copy render timings" from what is on screen at that moment, so // the bench scripts keep working when this moves again. They pressed it at a hand-measured // coordinate until 2026-09-03, and anything that moved the header made that tap land on // whatever now sat there -- reporting a number that was never measured. val copyRenderReport = { val report = debugReport( device = "device: ${Build.MODEL} (${Build.MANUFACTURER})," + " Android ${Build.VERSION.RELEASE}\n" + // A debuggable build runs Compose at a fraction of release speed, so a // report that did not say which it came from was read as the app's own // cost. "build: ${if (debuggable(context)) "debug" else "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", visibleUnits(units, listState.layoutInfo.visibleItemsInfo, UNITS_START), " ${expandedTools.size} tool calls and ${expandedGroups.size} groups open", ), frames = FrameStats.lines(context.refreshHz()), accounting = FrameStats.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. 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. FrameStats.reset() DebugStats.reset() Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT).show() } 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. Spacer(Modifier.width(GLYPH_BUTTON_MARGIN)) Column(Modifier.weight(1f)) { // A subagent's own title, with the session's beneath it in a smaller style -- // the header says whose conversation this is as well as what it is. Otherwise // just the session's title, as before. if (subagent != null) { Text(subagent.title, style = MaterialTheme.typography.titleMedium) Text( title, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { 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 // two sentences with different grammar. // // 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. Text( "${summary.setupName} ยท ${summary.provider}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } // None of this is a subagent's: it has no files of its own to browse, no settings, // and nothing meters it -- see SUBAGENTS.md's "Phone". // // 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. 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. 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. // // Usage, files, settings -- widest scope first, narrowing to the right, so the cog // stays at the end where every other screen keeps it. Asked for in this order by // Iris on 2026-09-03. if (!isSubagent) { Row { GlyphButton( USAGE_GLYPH, "Usage", { usageOpen = true }, colour = usageGlyphColour(usage), ) // The machine's files, which is where the answer to "what did it actually // change" is. It opens *over* this screen rather than replacing it. GlyphButton( FOLDER_GLYPH, "Files", onClick = { onFiles( // Where this session works, and the machine's own home when it // was never given a directory -- resolved there rather than // guessed at here, since this app does not know that home. summary.filesTarget() ) }, ) // What it opens is about this session, so it sits at the end of the // session's own row. A cog and not a word because 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. Nothing meters a subagent. if (!isSubagent) { 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; 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. 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. 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. // 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 // -- 16 full recompositions per keyboard open against one, and the transcript's // position behind a recomposition while the composer's stayed a draw-phase // read. .then(if (imeVisible) Modifier.imePadding() else Modifier) ) { Box(Modifier.fillMaxSize()) { TranscriptList( units = units, state = listState, moreHistory = moreHistory, selection = selection, 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, attachments = waiting.attachments, onOpenImage = ::openImage, pending = true, refusal = waiting.refusal, // The bubble goes away on the `messageDropped` this // produces, not here: the server knows whether the // message was still its to take back, and the other // devices have to be told by the same event. onTakeBack = if (waiting.local) null else ({ takeBack(waiting.id) }), ) } } } }, ) { unit -> when (unit) { is TranscriptUnit.Block -> MarkdownPiece(unit.text, unit.piece, replies) is TranscriptUnit.PeerHead -> PeerHeadRow( unit.item, unit.open, onToggle = { togglePeer(unit.item.seq) }, ) is TranscriptUnit.PeerBlock -> PeerBlockRow(unit, replies, onToggle = { togglePeer(unit.seq) }) is TranscriptUnit.UserChunk -> UserChunkRow(unit, settings, summary.id, ::openImage) 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. 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. 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; // 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 = ::answerAll, 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, attachments = item.attachments, 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. 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 = ::answerAll, image = { ref -> SessionImage( settings, summary.id, ref, ::openImage, ) }, ) is TranscriptItem.QuestionCard -> QuestionRow(item, ::answerAll) 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.LimitNote -> LimitRow(item) is TranscriptItem.TurnBreak -> TurnBreakRow() // Never reached: a peer message is flattened into // its own units. Here because a `when` over the // item kinds has to stay exhaustive. is TranscriptItem.PeerNote -> PeerHeadRow(item, open = false, onToggle = {}) } } } } } } } // 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. // // In the middle of the transcript rather than at either end, because it is standing // in for all of the rows. `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 puts the position back. 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. // // Down, and the same chevron a tool group collapses with: the list is built upside // down internally, but nobody reading it knows that. The name is carried in the // description, since an arrow alone says nothing to a screen reader. 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: the newest end is where the content // hangs from, so being at it is the whole of following it. That 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( Pointing.Down, 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. Its height is // reported to the transcript box above, which reserves that much room; the opaque // background covers the one frame between this growing 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) } }, ) } // Kept for a subagent -- see SUBAGENTS.md's "Phone" -- with the wording that turns // "exited" into "finished" for one, since it has no process to leave running or stop. SessionStatusRow( status = status, compactingFor = compactingFor, contextTokens = contextTokens, subagent = isSubagent, ) // Everything from here down is the composer: a subagent cannot be messaged, so none of // it applies -- see SUBAGENTS.md's "Phone". if (!isSubagent) { // 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. Held by what was picked rather than by // a flag, so typing anything else brings the list back without a second thing // to // reset. commands = if (input.text == picked) emptyList() else suggestedCommands(input.text), onPick = { command -> // At the end of what was inserted, which is where the reader carries on // typing: a command with an argument is put in the box half-written, and a // cursor left at the front makes the next keystroke the first character of // "/rename". input = atEnd(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 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.text) }, 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(), ) { // Photo or file, asked here rather than by two buttons: the row is full, // and // attaching is one action whichever picker answers it. var attaching by remember { mutableStateOf(false) } Box { // Just "+". The count it used to carry was standing in for showing // them. BubbleButton(onClick = { attaching = true }) { Text("+") } DropdownMenu( expanded = attaching, onDismissRequest = { attaching = false }, // See PickerButton: without this the menu opens a status bar's // height away from the button in an edge-to-edge activity. properties = PopupProperties(clippingEnabled = false), shape = BubbleMenuShape, ) { DropdownMenuItem( text = { Text("Photo") }, onClick = { attaching = false pickImage.launch( PickVisualMediaRequest( ActivityResultContracts.PickVisualMedia.ImageOnly ) ) }, ) DropdownMenuItem( text = { Text("File") }, onClick = { attaching = false pickFile.launch(arrayOf("*/*")) }, ) } } // 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 the // moment it 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. 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 } }, ) } if (offeredPermissionModes.isNotEmpty()) { PickerButton( current = permissionMode, options = offeredPermissionModes, 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 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 a button // 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. // // 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. Disabled and not hidden, for the // reason above. Button( onClick = { send() }, enabled = input.text.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) { usageFeed?.let { UsageDialog(feed = it, session = summary, onDismiss = { usageOpen = false }) } } if (settingsOpen) { // Measured when the dialog opens rather than kept up to date: what the reader is being told // is what pressing the button now would discard, and null until the walk of the directory // returns is what not knowing looks like. var cachedBytes by remember(summary.id, epoch) { mutableStateOf(null) } LaunchedEffect(summary.id, epoch) { cachedBytes = withContext(Dispatchers.IO) { source.cache.bytes() } } SessionSettingsDialog( settings = settings, sessionId = summary.id, title = title, effort = summary.effort.takeIf { summary.takesEffort }, takesEffort = summary.takesEffort, cachedBytes = cachedBytes, // The purge finishes before the epoch moves, because the relaunched opening effect // reads the same directory and would otherwise draw what is about to be deleted. The // epoch is what makes the rest a cold open. onReload = { settingsOpen = false scope.launch { withContext(Dispatchers.IO) { source.cache.purge() } dropLoadedTranscript() lastSeq.set(0) ready = false epoch++ } }, // 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. onRenamed = { title = it settingsOpen = false }, onDismiss = { settingsOpen = false }, onCopyRenderReport = copyRenderReport, ) } } /** 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. */ 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) } /** * One slice of a long user message, on the same bubble the first slice starts. * * Full width, unlike the wrapping bubble: slices have to share a width to read as one card, and a * message long enough to be sliced has lines that wrap anyway -- see [USER_SPLIT_CHARS]. */ @Composable private fun UserChunkRow( unit: TranscriptUnit.UserChunk, settings: ServerSettings, sessionId: String, onOpenImage: (String) -> Unit, ) { Column( Modifier.padding(start = 48.dp) .cardPiece( top = unit.first, bottom = unit.last, fill = MaterialTheme.colorScheme.primaryContainer, ) ) { Text(unit.text, color = MaterialTheme.colorScheme.onPrimaryContainer) // The same arrangement [UserBubble] gives them: under the words, on the last slice because // that is the bubble's bottom. unit.attachments.forEachIndexed { index, ref -> if (index > 0 || unit.text.isNotEmpty()) Spacer(Modifier.height(4.dp)) Attachment(settings, sessionId, ref, onOpenImage) } } } /** * A message the person holding the phone sent, in a bubble at their end of the conversation. * * [pending] has not yet been recorded as received by the provider -- drawn quieter, because * "pressed Send" and "heard" are different claims and the transcript must not merge them. * * A server-accepted pending bubble is tappable: [onTakeBack] asks the server to drop the message * before the session reads it. A local one is not, because its request may still be in flight. * [refusal] is either that take-back refusal or the send's network failure. It is drawn here rather * than with the screen's other errors because this is the message the failure belongs to. * * A settled message longer than [USER_SPLIT_CHARS] is drawn as [UserChunkRow] slices instead -- one * `Text` holding a pasted log is a hundred-thousand-pixel layout in the frame the row scrolls into. */ @Composable private fun UserBubble( settings: ServerSettings, sessionId: String, text: String, attachments: List = 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. 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. attachments.forEachIndexed { index, ref -> if (index > 0 || text.isNotEmpty()) Spacer(Modifier.height(4.dp)) Attachment(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. */ /** * 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, and a session reporting zero context is holding * nothing. * * Where the figure is *unknown* rather than zero the fallback is whether anything has been said * **since the last clear**. 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. * But a clear is the one case that makes the whole loaded transcript stop counting: it leaves the * conversation on screen and takes it out of the session's context, and the server reports the * context as unmeasured afterwards rather than as zero. So the reading that used the whole list * warned about dropping a cache the clear had already dropped. * * With no clear anywhere in what is loaded this is the old reading exactly, which is the * conservative answer for a clear further back than the loaded window. */ private fun worthWarningAbout( status: String, contextTokens: Long?, items: List, ): Boolean = when { status == "exited" -> false contextTokens != null -> contextTokens > 0 else -> items.asReversed().takeWhile { it !is TranscriptItem.ClearedNote }.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 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. * * No number is offered here, deliberately. What it will cost depends on how long *this* * conversation is, and this screen does not know that -- a figure worked out from what has been * spent would be a guess in a measurement's clothes. * * 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. */ @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 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. * * 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. * * `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, /** * Whether this row is for a subagent rather than a session, which changes only one word: * "exited" reads as "finished" there too, the same as the subagent list's own card -- a * subagent's process was always its parent's, so "exited" would read as a fault rather than the * ordinary way one of these ends. */ subagent: Boolean = false, ) { 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 nothing at all until it has finished -- measured 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. "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. The words and the colour are `sessionStatusWord`'s and // `sessionStatusColour`'s, shared with the session list so one state is not called two // things -- or drawn two colours -- depending which screen you are on. else -> Text( sessionStatusWord(status, subagent), style = MaterialTheme.typography.labelSmall, color = sessionStatusColour(status), 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 just 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. 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, onSettled: () -> Unit) -> Unit, ) { Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp)) { // The same body the questions on a tool call get, down to the submit button: one // question is the same thing whether or not something asked it, and two renderings of // it would be two places for an answer to go missing. AskUserQuestionBody(listOf(question), onAnswer) } } } /** * [text] in the message box, with the cursor after it. * * Everything that puts words in the box without the reader typing them goes through here: a * restored draft, a share arriving from another app, a slash command taken from the suggestions. * All three used to leave the cursor at whatever offset it happened to hold -- which for a box that * has never been focused is the very start, so picking `/rename` and typing put the name in front. */ private fun atEnd(text: String) = TextFieldValue(text, TextRange(text.length)) /** * 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 swallow a deliberate reopen. */ 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 fun PickerButton(current: String, options: List, 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), so the press that dismisses it is also delivered to the window // underneath -- which 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. var closedAt by remember { mutableLongStateOf(0L) } Box { BubbleButton( 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 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 as 142px, the status bar's height exactly, clear of the button that opened // it. What this gives up is that the keyboard stops counting as an edge, so with the IME up // the menu opens downwards over it. That is the lesser fault, and correcting it would mean // supplying a position provider this menu takes no parameter for. DropdownMenu( expanded = open, onDismissRequest = { open = false closedAt = SystemClock.uptimeMillis() }, properties = PopupProperties(focusable = false, clippingEnabled = false), shape = BubbleMenuShape, ) { options.forEach { option -> DropdownMenuItem( text = { Text(option) }, onClick = { open = false if (option != current) onPick(option) }, ) } } } } private fun debuggable(context: Context) = context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0