package com.example.aiapp import android.os.SystemClock import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.PopupProperties import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.repeatOnLifecycle import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.delay import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import kotlinx.coroutines.withContext private const val RECONNECT_DELAY_MS = 1500L /** * How big the "still loading this conversation" spinner is. * * Bigger than the ones inside a tool card, which are 16dp and report on one call among many, and * smaller than a splash: this one is standing in for the whole screen while there is nothing else * on it, and it is the only thing to look at. */ private val LOADING_SPINNER = 48.dp /** * How much history to keep loaded past the oldest row on screen, counted in screenfuls. * * Both the point at which history starts loading and how much of it a load has to produce before it * stops. Multiplied by the viewport to give a number of *pixels* of scroll, which is the distance * the question is actually about: how far the reader can keep going before they run out. A row is * anything from one line to a page, so a count of rows is that distance only by accident. Eight * rows was the number, and on a tool-heavy transcript eight rows is less than one screen: the * reader reached the end of what was loaded on *every* swipe and waited a round trip standing * there, which is a list running out of transcript rather than a slow frame. Counting screenfuls of * rows fixed the size of the mistake without fixing its kind; pixels are the unit itself. * * Three, so a fling lands on rows that are already there and the page after them is on its way. The * cost of being generous is a page fetched that nobody reads; the cost of being mean is a list that * stops under a finger, and those are not the same size. * * Counted at the server rather than inferred from the screen, which is the only measurement here * that does not depend on how the emulator renders: against a 24,000-event transcript at `--delay * 120`, ten swipes asked for **ten** pages before this and **three** after. */ private const val HISTORY_SCREENS = 3 /** * How many events a backwards page asks for, which is ten times what the opening page takes. * * Because an event is not a row, and the ratio is nothing like one to one. Measured on a real * transcript (2,426 events, 2026-08-30): the whole conversation is *seven* assistant messages, and * the median run of consecutive text deltas that fold into one of them is four hundred. A page of * eighty is therefore a fifth of a single row, and reaching a screenful of fresh rows took about * thirty sequential round trips inside one collect -- a stutter on loopback, and four or five * seconds of a list that will not move over the tunnel, which reads as history having run out. * * The opening page stays small: it is the one on the critical path of showing the screen at all, * and it only has to fill a viewport. */ private const val HISTORY_PAGE = 800 /** * The most events one request of a restore may ask for. * * A restore knows exactly how far back it has to reach, so it asks for that in one request rather * than walking there a page at a time. This bounds the request anyway, because "exactly how far" is * however far the reader had scrolled and there is no bound on that -- and a single response of * arbitrary size is the one shape a phone on a slow tunnel handles worst. At roughly 800 bytes an * event, measured on a real transcript, this is about three megabytes. * * Going past it costs another request rather than anything being missed, so the number only trades * round trips against response size. */ private const val RESTORE_PAGE_MAX = 4000 /** * Which row was asked to hold its top edge, and how tall it was when it last measured. * * Deliberately *not* snapshot state, and that is the point of the whole class. Both fields are * written from the layout phase; a snapshot write there that composition reads would schedule * another recomposition, and the correction has to land inside the frame that is already being laid * out rather than in a later one. Nothing observes these, so nothing needs to. * * [key] is cleared by the resize it was set for, so it cannot be spent on an unrelated one. */ private class TopEdgeHold { var key: Any? = null } /** * Where a row is on screen, so a tap on it can be told which half it landed in. * * Not snapshot state, for the same reason as [TopEdgeHold]: written from layout, read from a click, * and observed by nothing. */ private class RowBounds { var top = 0f var height = 0f /** Above this is the row's top half, below it the bottom half. */ val middle get() = top + height / 2 } /** One row's height between layouts, so a change in it can be noticed. See [holdTopEdge]. */ private class LastHeight { var value: Int? = null } /** * Keeps this row's top edge where it is when the row changes height, if it was asked to. * * This runs in the *layout* phase, from the measurement that discovers the new height, and that is * the whole reason it is a modifier rather than an effect. A correction posted to a coroutine * arrives a frame or more after the layout it is correcting, so the wrong position is drawn once * before the right one -- visible as a flick, and worse the faster the screen refreshes. Scrolling * from here happens before anything is drawn, so there is no frame to see and nothing that depends * on how quickly the correction is scheduled. * * [hold] is given the change in height. The row's bottom edge is held by the list, so a scroll of * exactly that much is what leaves the top edge where it was. */ @Composable private fun Modifier.holdTopEdge(key: Any, held: TopEdgeHold, hold: (Int) -> Unit): Modifier { val last = remember { LastHeight() } return onSizeChanged { size -> val previous = last.value last.value = size.height // A first measurement has no previous height to have moved from, and a row that came // back after being scrolled away is a first measurement again. if (previous == null || previous == size.height || held.key != key) return@onSizeChanged held.key = null hold(size.height - previous) } } /** * What the transcript renders: the event stream folded into displayable rows (see [foldEvent]). The * stream is the only data source -- opening this screen replays from seq 0, and a reconnect resumes * from the last seq seen, so there is no separate history fetch to drift from it. */ sealed class TranscriptItem { /** * The transcript sequence number this row started at, and its identity on screen. * * The list is drawn newest-first, so every new message is an insertion at index 0 and every * page of history is an insertion at the far end. Without an identity that survives both, the * list is addressed by position: whatever somebody had scrolled to keeps its index while the * content underneath it slides, which reads as the view scrolling on its own. * * A seq is the right identity because it is what the transcript itself is ordered by, it never * changes, and it is already carried by every event. A row built from several events -- a * streaming message, a tool call and its result -- keeps the seq of the first, so it holds * still while the rest of it arrives. */ abstract val seq: Long data class UserMsg( override val seq: Long, val text: String, /** Refs of what was attached, drawn inside the bubble. */ val images: List = emptyList(), ) : TranscriptItem() data class AssistantMsg(override val seq: Long, val text: String) : TranscriptItem() data class ToolRun( override val seq: Long, val id: String, /** * The run of adjacent calls this one belongs to, named once when the call is folded in and * never recomputed. * * Carried rather than derived because a run can gain members at *either* end -- a new call * arriving beside it, or a page of history arriving in front of it -- so no function of its * current members is stable. It is the first call's id at the moment the run started, which * is a name rather than a description: [joinPages] hands it to older calls that turn out to * belong to the same run, instead of renaming the run they joined. */ val runId: String, val tool: String, val input: String, val output: String, val done: Boolean, /** * The questions this call is waiting on, in the order they were asked. * * On the call's own row rather than beside it: an ask used to arrive as a second card * repeating the input verbatim, so the reader saw the same command twice and had to work * out that it was one event. The backend says which call a question is about, so this is a * fact rather than a match on the input. * * A list because AskUserQuestion asks up to four at once, and they are one decision to make * -- a permission is the case of exactly one, not a different shape. */ val asks: List = emptyList(), /** * Images this call's result carried, drawn under it. * * Beside it they had to be paired by position, and position is the thing a page boundary * breaks -- a screenshot loaded on one page and its call on the next read as unrelated. */ val images: List = emptyList(), ) : TranscriptItem() data class QuestionCard( override val seq: Long, val id: String, val prompt: String, /** A few words naming what this is about, when the asker offered one. */ val header: String?, val options: List, /** Whether several options may be chosen at once. */ val multiSelect: Boolean, /** What was chosen, once something was; empty until then. */ val answers: List, ) : TranscriptItem() data class ErrorMsg(override val seq: Long, val message: String) : TranscriptItem() /** An image by server-side ref, fetched from the session's files route. */ data class ImageItem(override val seq: Long, val ref: String) : TranscriptItem() /** * A message another agent sent this session. * * Its own row rather than a [UserMsg]: see [PeerMessageRow] for why the voice matters. */ data class PeerNote(override val seq: Long, val from: String, val text: String) : TranscriptItem() /** * A command the session ran on itself -- `/compact`, `/rename`. * * Kept in the transcript rather than only shown while it waits, because it explains what * follows: a conversation that suddenly has half the context, or a session with a new name. */ data class CommandRow(override val seq: Long, val text: String) : TranscriptItem() /** Placeholder row for events this build can't render (newer kinds). */ data class Note(override val seq: Long, val text: String) : TranscriptItem() /** * A clear that happened: everything above it left the session's context and stayed on screen. * * Carries only its position, because that is all it means. */ data class ClearedNote(override val seq: Long) : TranscriptItem() /** * A compaction that happened, and what it recovered. * * In the transcript rather than only in the status line, because the status is gone the moment * it finishes and this is the part worth keeping: it is the explanation for a gap in the * conversation, and for a minute or two in which the session was busy with nothing to show. * * The wire also says what triggered it, and this deliberately does not carry that: the row says * the two sizes and nothing else (see [compactionSummary]), so keeping the trigger here would * be a field nothing can read. */ data class CompactedNote( override val seq: Long, val preTokens: Long?, val postTokens: Long?, ) : TranscriptItem() } /** * The run a call joins: the one it lands next to, or a new one named after itself. * * Only ever consulted when the call is first folded in. That is what makes the name stable -- a run * keeps whatever it was called when it started, however many calls arrive at either end of it * afterwards. * * A question to the reader is in a run of its own, which is what puts it on the transcript as a row * rather than inside a collapsed "Called 6 tools" card. Two things follow from being alone: it is * always visible, since a run of one is drawn as itself rather than as a group; and the calls * around it fall into a group before it and a group after it, so where the reader was asked * something is legible in the shape of the transcript without opening anything. It ends the run * before it as well as starting a fresh one after -- the moment somebody was asked is a boundary in * the work, not a gap in the middle of one run. */ private fun runIdFor(items: List, id: String, tool: String): String { val previous = items.lastOrNull() as? TranscriptItem.ToolRun ?: return id if (tool == ASK_USER_QUESTION || previous.tool == ASK_USER_QUESTION) return id return previous.runId } /** * Puts a page of older items in front of the ones already loaded, healing whatever the page * boundary cut in two. * * Two things straddle a boundary: a tool call separated from its result, and a message separated * from the rest of itself. Both were one thing before the transcript was cut into pages, and both * have to be one thing again -- a reply drawn as two messages is the same defect as a call drawn * twice, arriving from the same cause. * * A boundary lands wherever it lands, and roughly half the time that is between a call and its * result. The newer page then holds a `ToolEnd` whose start it never saw, which [foldEvent] draws * as a row of its own -- correctly, because a call that renders as nothing is indistinguishable * from one that never happened. When the older page arrives it brings the real `ToolStart`, and * concatenating the two lists left *both*: the same call twice, once as a proper card and once as a * nameless placeholder. Visible as a run of four calls reporting "Called 5 tools", and worse than * the miscount -- the extra row is at the join, so it also moves everything the reader was looking * at. * * Merged by the call's own id rather than by position, because position is exactly what a page * boundary destroys. The older row wins on what a start knows (the tool's name, its input) and the * newer on what an end knows (the output, and whether it finished), which is the only way round * that loses nothing. */ fun joinPages(earlier: List, later: List): List { val (older, newer) = healSplitMessage(earlier, later) val startedEarlier = older.filterIsInstance().mapTo(mutableSetOf()) { it.id } if (startedEarlier.isEmpty()) return older + newer val endedLater = newer .filterIsInstance() .associateBy { it.id } .filterKeys { it in startedEarlier } if (endedLater.isEmpty()) return older + newer val healed = older.map { row -> val half = (row as? TranscriptItem.ToolRun)?.let { endedLater[it.id] } if (row is TranscriptItem.ToolRun && half != null) { row.copy( output = half.output, done = half.done, // Kept from both halves: a question or an image can be attached to either, // depending on which side of the boundary its event fell. asks = row.asks + half.asks, images = row.images + half.images, ) } else { row } } val kept = newer.filterNot { it is TranscriptItem.ToolRun && it.id in endedLater } return adoptRun(healed, kept) + kept } /** * Rejoins a message the page boundary cut, and hands back the two pages to concatenate. * * [foldEvent] never leaves two assistant messages next to each other inside one page -- deltas * accumulate into the message before them -- so two meeting at a join are always the two halves of * one reply, and leaving them apart drew a single answer as two, with a paragraph break through the * middle of a sentence. * * The newer half keeps its identity, for the reason [adoptRun] gives: it is the row already on * screen, and renaming that is how the list loses its anchor. It grows by what the older half * brings, which is safe here and nowhere else -- the join is at the oldest end of what is loaded, * so the growth extends off the top of the screen, away from the row the list anchors to. */ private fun healSplitMessage( earlier: List, later: List, ): Pair, List> { val head = earlier.lastOrNull() val tail = later.firstOrNull() if (head !is TranscriptItem.AssistantMsg || tail !is TranscriptItem.AssistantMsg) { return earlier to later } return earlier.dropLast(1) to (listOf(tail.copy(text = head.text + tail.text)) + later.drop(1)) } /** * Hands the older calls at the join the name of the run they are joining. * * The two pages were folded separately, so a run split by the boundary came back as two runs with * two names. Naming the joined run after the *older* half would be the obvious way round and is the * wrong one: the newer half is the part already on screen, and renaming it is renaming the row the * reader is looking at, which is how a list loses its anchor and steps under them. So the arriving * calls take the name of the ones already there, and nothing visible changes identity. */ private fun adoptRun( earlier: List, later: List, ): List { val first = later.firstOrNull() as? TranscriptItem.ToolRun ?: return earlier // A question is in a run of its own on both sides of the join, the same as it would be had // the two pages been folded as one -- see `runIdFor`. Without this the heal would merge a // group straight through the row the reader was asked something on. if (first.tool == ASK_USER_QUESTION) return earlier val joining = first.runId val tail = earlier.takeLastWhile { it is TranscriptItem.ToolRun && it.tool != ASK_USER_QUESTION } if (tail.isEmpty()) return earlier return earlier.dropLast(tail.size) + tail.map { (it as TranscriptItem.ToolRun).copy(runId = joining) } } fun foldEvent(items: List, entry: SeqEvent): List = when (val event = entry.event) { is SessionEvent.UserMessage -> items + TranscriptItem.UserMsg(entry.seq, event.text, event.images) is SessionEvent.AssistantText -> { // Deltas accumulate into the message they're streaming, which keeps the seq of the // first of them: a row whose identity changed with every delta would be a new row on // every frame, and the list would jump for the whole of a streamed answer. val last = items.lastOrNull() if (last is TranscriptItem.AssistantMsg) { items.dropLast(1) + last.copy(text = last.text + event.delta) } else { items + TranscriptItem.AssistantMsg(entry.seq, event.delta) } } is SessionEvent.ToolStart -> items + TranscriptItem.ToolRun( entry.seq, event.id, runIdFor(items, event.id, event.tool), event.tool, event.input, "", done = false, ) is SessionEvent.ToolUpdate -> updateTool(items, event.id) { it.copy(output = event.output) } is SessionEvent.ToolEnd -> // Created when its start is not here, rather than dropped. A // fold that only ever *updates* loses the whole call when the // start fell outside the loaded window, and a tool call that // renders as nothing is indistinguishable from one that never // happened. The name is unknown from an end alone; loading the // page before this one replaces the row with the real thing. if (items.any { it is TranscriptItem.ToolRun && it.id == event.id }) { updateTool(items, event.id) { it.copy(output = event.output, done = true) } } else { items + TranscriptItem.ToolRun( entry.seq, event.id, // The name is not known from an end alone, so a call that was an ask // cannot be recognised as one here; loading the page before this // replaces the row with the real thing, which is when it splits out. runIdFor(items, event.id, "tool"), "tool", "", event.output, done = true, ) } is SessionEvent.Question -> { val card = TranscriptItem.QuestionCard( entry.seq, event.id, event.prompt, event.header, event.options, event.multiSelect, emptyList(), ) // A question with no tool behind it -- AskUserQuestion, or an ask // whose call fell outside the loaded window -- is a card of its // own, which is what every question was before this. if ( event.about != null && items.any { it is TranscriptItem.ToolRun && it.id == event.about } ) { updateTool(items, event.about) { it.copy(asks = it.asks + card) } } else { items + card } } is SessionEvent.Answered -> // Resolved wherever it is drawn: a card of its own, or a tool // row's ask. Missing the second left an Allow/Deny pair live on // a question already answered from another device. items.map { when { it is TranscriptItem.QuestionCard && it.id == event.id -> it.copy(answers = event.answers) it is TranscriptItem.ToolRun && it.asks.any { ask -> ask.id == event.id } -> it.copy( asks = it.asks.map { ask -> if (ask.id == event.id) ask.copy(answers = event.answers) else ask } ) else -> it } } is SessionEvent.PeerMessage -> items + TranscriptItem.PeerNote(entry.seq, event.from, event.text) is SessionEvent.CommandSent -> items + TranscriptItem.CommandRow(entry.seq, event.text) // Screen-level state, not transcript rows -- see SessionScreen. is SessionEvent.CommandQueued -> items // No row of its own: a message that is still waiting is drawn as a pending bubble below // the transcript, and becomes an ordinary one where the session read it. is SessionEvent.MessageQueued -> items is SessionEvent.Settings -> items is SessionEvent.Status -> items is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message) is SessionEvent.Image -> // Under the call that produced it when there is one, and a row of // its own when there is not -- a person's own attachment belongs // to no call, and neither does one whose call fell outside the // loaded window. if ( event.about != null && items.any { it is TranscriptItem.ToolRun && it.id == event.about } ) { updateTool(items, event.about) { it.copy(images = it.images + event.ref) } } else { items + TranscriptItem.ImageItem(entry.seq, event.ref) } is SessionEvent.Cleared -> items + TranscriptItem.ClearedNote(entry.seq) is SessionEvent.Compacted -> items + TranscriptItem.CompactedNote(entry.seq, event.preTokens, event.postTokens) is SessionEvent.Unknown -> items + TranscriptItem.Note(entry.seq, "[${event.type}]") // Screen-level state, not transcript rows -- see SessionScreen. is SessionEvent.UsageDelta -> items } private fun updateTool( items: List, id: String, change: (TranscriptItem.ToolRun) -> TranscriptItem.ToolRun, ): List = items.map { if (it is TranscriptItem.ToolRun && it.id == id) change(it) else it } /** * Parses the replies among [rows], off whatever thread is drawing. * * Called where a page of transcript is folded rather than where a row is composed, which is the * whole point: the work happens seconds before the reader reaches the rows it was done for. See * [ParsedReplies]. */ private suspend fun warm(replies: ParsedReplies, rows: List) { // Including the search for what to parse, which is not the cheap half it looks like: // [markdownIn] splits every assistant message looking for memory notes, and this is handed // the *whole* loaded transcript on every page, so the scan grows with the conversation while // the work it finds stays one page's worth. Off the calling thread it is nobody's frame. withContext(Dispatchers.Default) { val texts = rows.filterIsInstance().flatMap { markdownIn(it.text) } if (texts.isNotEmpty()) replies.warm(texts) } } @Composable fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () -> Unit) { val scope = rememberCoroutineScope() val topEdgeHeld = remember { TopEdgeHold() } var items by remember { mutableStateOf(listOf()) } var status by remember { mutableStateOf(summary.status) } // Seeded from the row this screen was opened from, so a conversation already under way says // how much it is holding before any turn happens here. Null is "nobody has measured it", // which is a different answer from an empty context and is drawn differently. var contextTokens by remember(summary.id) { mutableStateOf(summary.contextTokens) } // When the current compaction started and how long ago that is. The moment comes off the // `compacting` status event itself -- the server timestamps every transcript line -- rather // than off this device noticing one, which is what makes it survive leaving the session and // reopening it. See `compactingLabel`: null is still the honest answer for a session whose // status was never reported as compacting at all. var compactingSince by remember { mutableStateOf(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 the status only changes once the server has answered and the // stream has carried it back -- so two presses in that gap are two requests, both decided // against the state before either of them. The server refuses the second one, but a control // that can be pressed while its own last press is still in flight is asking to be. var processInFlight by remember { mutableStateOf(false) } val context = LocalContext.current // Seeded from what was left in the box last time and written back on every keystroke, so // leaving the screen -- or the system reclaiming the app -- does not throw away a half-typed // message. See `Drafts.kt` for why this one piece of state is the device's rather than the // server's. var input by remember(summary.id) { mutableStateOf(loadDraft(context, summary.id)) } // A model the reader has chosen and not yet confirmed. See [ModelSwitchWarning]: switching // makes the session re-read the whole conversation, which is worth asking about first. var pendingModel by remember { mutableStateOf(null) } 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 that have already been drawn as a group, so the transition into one is noticed exactly // once. See the effect below. var everGrouped by remember { mutableStateOf(setOf()) } // Which messages from other agents are open, by the seq that identifies their row. Closed // by default, which is the rule for anything new in this transcript: a screen that opens // everything it can is one nobody can scan. var expandedNotes by remember { mutableStateOf(setOf()) } // Long replies the reader has asked to see the rest of, by the seq of the row. Held here // rather than in the row so that scrolling away and back does not shut something they // deliberately opened -- the same reason the sets above it are here. var expandedReplies by remember { mutableStateOf(setOf()) } // Uploaded-but-not-yet-sent attachment ids; sent with the next message. var pendingAttachments by remember { mutableStateOf(listOf()) } // What this session is set to now, seeded from the row that opened it and // then owned here, because changing either is something this screen does. // The name shown at the top. Held here rather than read from the row that opened this // screen, because renaming is something this screen can do -- through the settings below it, // or by typing the command -- and a header still showing the old name reads as a rename that // did not take. var title by remember(summary.id) { mutableStateOf(summary.title) } var model by remember { mutableStateOf(summary.model) } var permissionMode by remember { mutableStateOf(summary.permissionMode ?: "auto") } // The models this provider actually offers, asked of the server rather // than listed here: a hardcoded list is a claim about a machine. var offeredModels by remember { mutableStateOf>(emptyList()) } val lifecycleOwner = LocalLifecycleOwner.current // The resume cursor, written from the stream's IO thread. val lastSeq = remember { AtomicLong(0) } val activeStream = remember { AtomicReference(null) } // The oldest sequence number loaded, and whether there is more behind // it. Paging backwards is what keeps opening a long session cheap: the // screen starts with the end of the conversation and fetches earlier // pages only when somebody scrolls to them. var oldestSeq by remember { mutableLongStateOf(0L) } // Where this session was last being read, from this device's own store. Read once, because // it is the question "where did I leave off" and the answer stops being interesting the // moment the list is on screen. val savedAnchor = remember(summary.id) { loadScrollAnchor(context, summary.id) } // Whether the history the saved position needs is still being fetched. Nothing is drawn while // it is: opening at the newest end and then travelling to the anchor is exactly the journey // this layout exists to remove, and this transcript is not allowed to move under a reader. // The other half of the wait is [TranscriptScroll.settling], which covers the frames between // the rows arriving and the layout that measures them putting the position back. var restoring by remember(summary.id) { mutableStateOf(savedAnchor != null) } // Sent, but not yet read by the session -- which is when the backend // records it and it comes back as a row. Until then it is drawn below // the working indicator, because that is where it is in the session's // reading of events: after everything taken in, not yet taken in // itself. // Messages the server has taken and the session has not read yet, by the id that will resolve // them. From the event stream rather than from what this screen sent, so they are still here // after leaving the session or restarting the app -- and so a message sent from another device // is drawn waiting on this one too. var queued by remember { mutableStateOf(listOf()) } // Commands the session has been asked to run and cannot yet, by the id that will resolve // them. From the server rather than from this screen, so a rename sent from the settings // screen -- or from another device -- is drawn waiting here too. var waitingCommands by remember { mutableStateOf(listOf>()) } val running = status == "running" || status == "compacting" var moreHistory by remember { mutableStateOf(true) } var loadingHistory by remember { mutableStateOf(false) } var ready by remember { mutableStateOf(false) } // Replies parsed ahead of the rows that draw them; see [ParsedReplies]. Per session, because // it describes that session's rows and nothing else. val replies = remember(summary.id) { ParsedReplies() } val listState = rememberTranscriptScroll(summary.id) // Whether the newest message is on screen right now. The content hangs from its newest end // (see [TranscriptScroll]), so being there is being at scroll position zero. This is what the // jump-to-newest button watches: it is about what the reader can see. // // It is also the gate on everything the list draws -- see [record]. val atNewest by remember { derivedStateOf { listState.atNewest } } // Transcript events that arrived while somebody was reading further back, in the order they // arrived, waiting for them to return to the newest end. See [record] for why. var held by remember { mutableStateOf(listOf()) } // What is actually drawn: the transcript with runs of adjacent tool // calls folded into one row each. val rows = remember(items) { groupToolRuns(items) } // The reply drawn whole however long it is; see the transcript list below. Recomputed with // `items` rather than tracked as it arrives, because "newest" moves: a reply that was the // last one becomes history the moment the next turn starts, and a row that kept its // exemption after that would be the one enormous row this exists to bound. val newestReply = remember(items) { items.filterIsInstance().lastOrNull()?.seq } /** * Everything the transcript list draws, from one event. * * Separate from [apply] because it is the half that is allowed to wait. The list anchors on the * leading edge of its first visible item, which in this upside-down layout is that item's * *bottom* -- so a row that grows pushes everything already on screen upwards, and the view * travels toward the newest end without anybody scrolling. Measured against a reply streamed in * four hundred pieces: scrolling back a screen and then waiting six seconds ended at the very * bottom, forty lines further on than where it was left. * * Insertions were never the problem -- the list is keyed, so a row arriving at either end * leaves the anchor where it is, and reading back through history while a session works has * always been still. What cannot be allowed is a row that is already there changing height, and * the one guarantee that covers every way that happens -- a reply streaming, a tool's output * arriving, a queued bubble appearing above the anchor -- is to change nothing at all while * somebody is reading further back. */ fun record(entry: SeqEvent) { // The oldest event this view holds, which is what paging backwards // starts from. Maintained here rather than by each loader: the // first page and a stream reset both begin an empty view, and one // of them getting it wrong is a transcript that will not scroll up. if (oldestSeq == 0L) { oldestSeq = entry.seq moreHistory = entry.seq > 1L } val event = entry.event // The message coming back is the session saying it has // read it, so the bubble held below the indicator becomes // the row `foldEvent` is about to add. // Waiting, then read. Matched by id: the same message sent twice is two // bubbles, and clearing by text would take away whichever matched first. if (event is SessionEvent.MessageQueued) { queued = queued + QueuedMessage(event.id, event.text, event.images) } if (event is SessionEvent.UserMessage) { queued = queued.filterNot { it.id == event.id } } // Waiting, then gone: a command leaves this list when the session takes it, // and the row it becomes is added by `foldEvent` in the same pass. if (event is SessionEvent.CommandQueued) { waitingCommands = waitingCommands + (event.id to event.text) } if (event is SessionEvent.CommandSent) { waitingCommands = waitingCommands.filterNot { it.first == event.id } } items = foldEvent(items, entry) } /** * One event, at the moment it arrives. * * What it says about the *session* -- running or not, which model, how many tokens -- lands * immediately, because none of that is drawn in the list and freezing it would trade a * transcript that jumps for a status row that lies. What it adds to the transcript goes through * [record], which waits for the reader to be at the newest end. */ fun apply(entry: SeqEvent) { lastSeq.set(entry.seq) // Before the rest, and for every event rather than only the usage ones: a compaction and // a clear move this as much as a turn does, which is the whole reason it is a fold and // not a running total. See `contextAfter`. contextTokens = contextAfter(contextTokens, entry.event) when (val event = entry.event) { // Nothing further: what it carries was folded into the context above, and what a // turn cost is not something the transcript draws. is SessionEvent.UsageDelta -> {} else -> { // What the session says it is set to now, which is the only thing that // says it: picking from either menu asks, and the answer comes back here. if (event is SessionEvent.Settings) { event.model?.let { model = it } event.permissionMode?.let { permissionMode = it } } if (event is SessionEvent.Status) { // The event's own timestamp, so a compaction that began before this screen // opened is timed from when it actually began. Timing it from the moment we // arrived would report the wait as shorter than it was, in exactly the case // somebody is asking about -- a compaction worth asking about is a long one. compactingSince = when { event.state != "compacting" -> null status == "compacting" -> compactingSince else -> entry.ts } status = event.state } // In order, always: one late event recorded ahead of the backlog would fold a // streamed delta into whatever row happened to be last by then. if (atNewest && held.isEmpty()) record(entry) else held = held + entry } } } /** * Changes a row's height while the end the reader touched stays where it is. * * The transcript is laid out from the bottom, so every row's *bottom* edge is what the list * holds still and all growth goes upward. That is what a tap in a row's lower half already * gets, so it needs nothing: shut a group from the bar at its foot and what follows it does not * move, which is what the reader is looking at down there. A tap in the upper half is the other * case -- left alone it sends the heading under the reader's finger up off the screen and fills * the space above it, so the calls appear on the far side of the control that produced them -- * and that one asks the row to hold its top edge instead. * * Which half decides it, rather than which control was pressed, so that everything that opens * behaves the same way whether or not it happens to have a control at each end. A group has two * and its heading and foot bar land in the halves they are already in; a single call is one * card, and tapping low on an open one shuts it downward exactly as the bar does. * * The correction itself belongs to the measurement -- see [holdTopEdge]. */ fun toggleAnchored(key: Any, row: RowBounds, at: Float, toggle: () -> Unit) { if (at < row.middle) topEdgeHeld.key = key toggle() } /** * Whether the row holding transcript position [seq] is loaded, with older history behind it. * * "Behind it" is the part that is easy to leave out. The oldest loaded row is a half-row -- * [joinPages] welds the other half onto it when the page before it arrives, and it grows -- so * putting the reader inside one leaves them where they were only until the next page lands, * which was a screen and a half out. Any row that is not the oldest is final. * * The last row starting at or before [seq], rather than one starting exactly there: the events * behind a row can be regrouped between the save and the reopen -- a run of calls folds * differently when a page boundary moves, and two halves of a reply become one message -- and * the reader's place is inside whichever row now holds that seq, not gone. * * Computed from `items` rather than from `rows` for the reason [loadOlderPage] gives: `rows` is * the composition's value and does not change under a running coroutine. */ fun anchorRow(seq: Long): Long? { val ordered = groupToolRuns(items) val at = ordered.indexOfLast { it.startSeq <= seq } // Zero is the oldest loaded row, which is the half-row above; not found is -1. return if (at > 0) ordered[at].startSeq else null } /** * One page of older events onto the front of what is loaded; false when there was none. * * Shared by the two things that page backwards -- somebody scrolling to the far end, and * putting the list back where it was left -- because they want the same page for the same * reason and a second copy of this would be a second answer to "what is loaded". * * Reads `items` rather than `rows`: this runs in a coroutine, and `rows` is the composition's * value, which does not change under a running one. */ suspend fun loadOlderPage(limit: Int = HISTORY_PAGE): Boolean { // The fetch *and* the fold, both off the thread that draws. Only the fetch used to be, // and the fold is the expensive half: `foldEvent` returns a new list per event, so a page // of [HISTORY_PAGE] events is that many copies of a list growing to that length -- around // three hundred thousand element copies for one page, run on the main thread in the // middle of the scroll that asked for it. It was affordable at eighty events and is not // at eight hundred, which is why the page that made scrolling back reach the top made it // stutter to get there. // // `Dispatchers.IO` for both rather than a hop to `Default` between them: the two are one // errand, and this way the page costs one context switch instead of three. Neither half // touches anything the composition owns -- `older` and `earlier` are local, and the // `items` read below happens back on the caller's thread, where the write does too. val page = withContext(Dispatchers.IO) { val older = fetchTranscript(settings, summary.id, before = oldestSeq, limit = limit) if (older.isEmpty()) return@withContext null // Folded oldest-first into a list of their own, then put in front: `foldEvent` // merges streaming text into the item before it, so replaying an older page // through the live list would glue it onto the newest message rather than its own. var earlier = listOf() older.forEach { entry -> if (entry.event !is SessionEvent.UsageDelta) { earlier = foldEvent(earlier, entry) } } older.first().seq to earlier } if (page == null) { moreHistory = false return false } val (oldest, earlier) = page oldestSeq = oldest moreHistory = oldestSeq > 1L // Joined here rather than above, because it is the one step that reads what is already // loaded: `items` must be read where it is written, and it is a single pass over the two // lists against the page's quadratic fold. val joined = joinPages(earlier, items) // After the join rather than on the page alone: a boundary that fell through a reply // leaves `joinPages` holding a message made of both halves, and that text has existed for // no time at all. Warming the page by itself warmed the two halves and missed the one // thing drawn -- which showed up as a single 22ms parse surviving every page. warm(replies, joined) items = joined return true } // A call opened on its own stays open when a second call in the same run turns it into a // group. Until this, watching a Bash call and having the session make another one shut the // one being read and folded it behind "Called 2 tools" -- the reader lost what they were // looking at because something else happened. // // Considered once per run, at the moment it first becomes a group, and never again: after // that the group's own toggle owns it, and re-deriving this every time would re-open a group // the reader had just shut while one of its calls was still expanded. LaunchedEffect(rows) { val fresh = rows.filterIsInstance().filter { it.id !in everGrouped } if (fresh.isEmpty()) return@LaunchedEffect expandedGroups = expandedGroups + fresh.filter { group -> group.calls.any { it.id in expandedTools } }.map { it.id } everGrouped = everGrouped + fresh.map { it.id } } // A compaction reports nothing about its own progress -- measured against the CLI, which // says it has started, and then says nothing at all until it is done. So what this counts is // the one thing anybody here can measure: how long it has been going. A bar filling up would // be this screen inventing the part the CLI does not send. LaunchedEffect(compactingSince) { val since = compactingSince if (since == null) { compactingFor = null return@LaunchedEffect } while (true) { // Against this device's wall clock, because `since` is the server's -- the same // comparison `relativeTime` already makes for a session's last activity. Floored at // zero so a phone running a little behind the backend counts up from nothing rather // than reporting a compaction that has not started yet. compactingFor = (System.currentTimeMillis() / 1000.0 - since).toLong().coerceAtLeast(0) delay(1000) } } // The stream lifecycle: connect, follow, and on any drop reconnect // from the cursor -- so a flaky link (or a backend restart) costs // nothing but the gap's latency. // The newest page first, in one request, before the stream opens. The // stream then starts from where that page ended, so it carries live // events only -- which is what it is good at. LaunchedEffect(summary.id) { // Whether the list ended up where the reader left it. False covers every way it did not // -- no saved position, a row that is no longer in the transcript, a page that never // arrived -- and all of them mean the same thing to the list: this is the newest end now, // so follow it. var restored = false try { val page = withContext(Dispatchers.IO) { fetchTranscript(settings, summary.id) } page.forEach { apply(it) } warm(replies, items) // Then back where reading stopped. An anchor deeper than the newest page is exactly // the one worth restoring -- somebody who read to the bottom has no anchor at all -- // and the cost was already paid on the way down there. savedAnchor?.let { anchor -> // Pages until the anchor's row is loaded and has something older behind it. The // oldest loaded row is a half-row: `joinPages` welds the other half onto it when // the page behind it arrives, and it grows -- so anchoring into one puts the // reader where they were only until the next page lands, which landed a screen // and a half out. Any row that is not the oldest is final. // // This terminates because `oldestSeq` walks strictly backwards and the anchor is // a seq: once the window reaches past it, some loaded row starts at or before it // and [indexOfSeq] answers. Keying on the row's *name* instead could not promise // that -- a tool run is renamed whenever the newest page starts somewhere new, // so an anchor on one was never found and this paged to the first event of the // conversation every time an active session was reopened. while (moreHistory && anchorRow(anchor.seq) == null) { // The whole span in one request rather than a page at a time. `read_window` // counts *lines* and a transcript numbers them one per seq, so the distance // back to the anchor is the number of events to ask for -- and were seqs ever // sparse, that difference is larger than the count, which overshoots into // older history rather than stopping short. [HISTORY_PAGE] on top is the // cushion that keeps the anchor's row off the oldest edge, where it would // still grow. // // Capped, and the loop is what makes the cap safe: a span past it comes back // in several requests instead of one, which is what this did for every // restore until now -- thirteen sequential round trips to reopen a session // somebody had read a little way back into, and a spinner for all of them. // The bytes are the same either way, since every row between the anchor and // the newest end has to be there for the list to be able to count to it. val span = oldestSeq - anchor.seq + HISTORY_PAGE if (!loadOlderPage(span.coerceIn(1L, RESTORE_PAGE_MAX.toLong()).toInt())) break } // Both writes before this coroutine yields, so the layout that first measures // these rows is also the one that puts the position back -- the transcript is // drawn where it was left rather than drawn and then moved. The pixels do not // exist until that measurement, which is why the anchor is handed to the layout // rather than applied here; see [TranscriptScroll.pending]. restoring = false // Resolved to the row that *holds* the saved position rather than passed // straight through, because the two are not always the same seq: the events // behind a row regroup between the save and the reopen -- a run of calls folds // differently when a page boundary moves, two halves of a reply become one // message -- and the layout can only recognise a row by the seq it now starts // at. Handing it the saved seq meant the row it named no longer existed, so the // position was never applied and the transcript opened at the newest end. // // Null is a row that is no longer in the transcript at all -- a reset stream, or // a session cleared from elsewhere. anchorRow(anchor.seq)?.let { rowSeq -> listState.restore(ScrollAnchor(rowSeq, anchor.offset)) restored = true } } } catch (e: ApiException) { // Not fatal: the stream below still replays from zero, which is // slow but complete. Saying so beats silently showing nothing. streamError = e.message } // Nothing to put back, so draw where the content already hangs: the newest end. if (!restored) listState.giveUp() // Whatever happened above, including a page that never arrived: an empty transcript is a // state the screen can draw, and a permanently blank one is not. restoring = false ready = true } // Only while the screen is actually on screen. Android stops the // activity when somebody switches away, and the socket dies with it -- // which arrived as "Lost the event stream (SocketTimeoutException)" // waiting at the top on their return. Switching apps is a choice // somebody made, not a fault to report, and reconnecting on a phone // that has been backgrounded is work nobody is watching. Stopping the // stream deliberately makes the drop a close rather than an error (see // EventStream.close), and resuming reconnects from the same cursor. LaunchedEffect(summary.id, ready, lifecycleOwner) { if (!ready) return@LaunchedEffect lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { try { while (true) { val stream = EventStream(settings, summary.id) activeStream.set(stream) try { withContext(Dispatchers.IO) { stream.run( after = lastSeq.get(), // Connected, measured rather than inferred: this is what // takes a failure off the screen, and nothing else does. // Clearing on the first event instead meant an idle // session kept displaying an error it had recovered from. onOpen = { streamError = null }, onReset = { // Too far behind to continue from: what is on // screen is a stale prefix of a conversation // that has moved on, and the window arriving // next is not adjacent to it. Dropping the rows // is what makes this the same as opening the // screen -- `apply` refills them, and scrolling // up pages the rest back in as it always does. items = listOf() replies.clear() held = listOf() oldestSeq = 0L moreHistory = true }, ) { entry -> apply(entry) } } } catch (e: ApiException) { streamError = e.message } finally { stream.close() } delay(RECONNECT_DELAY_MS) } } finally { // Cancellation -- going below STARTED, or leaving the screen -- // cannot interrupt a blocking socket read. Closing is what // unblocks it, and what marks the drop deliberate. activeStream.getAndSet(null)?.close() } } } // The screen going away entirely, which the lifecycle scope above does // not cover: a composable can leave the composition while the activity // stays started. DisposableEffect(summary.id) { onDispose { activeStream.get()?.close() } } // Nothing gets announced about the session somebody is reading; see NotificationService. // RESUMED rather than STARTED because "looking at it" means the foreground -- a session left // on this screen behind another app is one whose notifications are still wanted, and STARTED // covers that case too. LaunchedEffect(summary.id, lifecycleOwner) { lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { NotificationService.showing(context, summary.id) try { awaitCancellation() } finally { NotificationService.stoppedShowing(summary.id) } } } // Back at the newest end, so the backlog [apply] held can land. Everything at once rather // than paced out: they are at the bottom, which is the one place the list is allowed to // follow new content, and drip-feeding it would only make that following last longer. LaunchedEffect(listState) { snapshotFlow { atNewest && held.isNotEmpty() } .collect { due -> if (!due) return@collect val backlog = held held = listOf() backlog.forEach { record(it) } } } // Where the reader left off, written whenever the list settles somewhere new. // // Driven by the position rather than by the scroll flag, and that is the whole point: a // *programmatic* scroll moves the list within one frame, so `isScrollInProgress` never // observably changes and anything waiting for a settle never runs. Jump to latest is exactly // that, and it left the old position recorded -- so the reader pressed the control that means // "take me to the end", left, came back, and was put back where they had been. // // There is no separate "did they choose to be here" flag any more, and there is nothing left // for one to protect against. It existed because a keyed lazy list moved its own anchor when a // row arrived, so for one frame the position reported itself scrolled back from the newest end // when nobody had scrolled at all. This layout hangs from that end, so a row arriving does not // move the position: zero still means the newest message, during the frame it lands and after. LaunchedEffect(listState) { snapshotFlow { if (listState.scroll.isScrollInProgress) null else listState.scroll.value } // 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 || listState.settling) return@collect saveScrollAnchor( context, summary.id, // Nothing to restore at the newest end, which is where a session with no // anchor opens anyway -- so the ordinary case costs a `remove` and no // page-back on the way in. if (settled == 0) null else listState.anchor(), ) } } // Reaching the far end of what is loaded fetches the page before it. // // Measured in pixels of scroll, which is the unit the question is actually about: how far can // the reader keep going before they run out. Rows are the wrong unit for it and were the // reason this went wrong twice -- a row is anything from one line to a screenful, so a cushion // of "three rows" is a different amount of reading depending which three, and a page of eight // hundred *events* can fold into almost no new rows at all when it is one streamed reply and a // run of tool calls. Nothing then asked for the next page, and the transcript only loaded when // somebody dragged it again. // // There is no correction beside this one any more. Following the newest message used to be an // effect here too, watching the item count and the viewport for a change and snapping back -- // and it had to be told not to fire during a scroll, because a page of history landing mid- // fling looked exactly like a message arriving and threw the reader to the bottom. The content // now hangs from the newest end, so an arriving message needs no correction and a page of // history moves nothing; see [TranscriptScroll]. LaunchedEffect(listState, moreHistory) { snapshotFlow { listState.roomAbove to listState.viewport } .collect { (room, viewport) -> if (!moreHistory || loadingHistory || viewport == 0) return@collect val cushion = viewport * HISTORY_SCREENS if (room >= cushion) return@collect loadingHistory = true try { // One page, and then this fires again if it was not enough. // // Deliberately not a loop: how much room a page bought is a fact about the // *layout* it produced, and no layout has happened yet inside this coroutine // -- so a loop would be re-reading the height from before the page it just // fetched and would ask for the whole conversation. Letting the measurement // answer means each page is checked against what it actually added, and a // page that folds into almost no new rows is followed by another because the // room genuinely did not grow. loadOlderPage() } catch (_: ApiException) { // Leave `moreHistory` alone: the next scroll asks again. } finally { loadingHistory = false } } } LaunchedEffect(summary.setupName, summary.provider) { offeredModels = try { withContext(Dispatchers.IO) { fetchSetups(settings) .firstOrNull { it.name == summary.setupName } ?.providers ?.firstOrNull { it.name == summary.provider } ?.models .orEmpty() } } catch (_: Exception) { // Not worth reporting: the picker simply has nothing to // offer, which is visible, and the session is unaffected. emptyList() } } fun act(onFailure: () -> Unit = {}, onDone: () -> Unit = {}, action: () -> Unit) { scope.launch { try { withContext(Dispatchers.IO) { action() } actionError = null } catch (e: ApiException) { actionError = e.message onFailure() } finally { // Whatever happened, including the failure above: a caller that re-enables a // control here must get it back on the path where the request was refused too, // or the refusal is what disables the control permanently. onDone() } } } fun send() { val text = input.trim() val attachments = pendingAttachments if (text.isEmpty() && attachments.isEmpty()) return // A command is not a message: it is an instruction to the session about itself, and one // written into a running turn is read by the model instead. The server holds it until the // turn ends and says so, which is where its waiting bubble comes from -- so nothing is // held here, and there is no local guess to correct when the answer arrives. if (text.startsWith("/") && attachments.isEmpty()) { input = "" saveDraft(context, summary.id, "") // The one command with a visible effect outside the transcript, applied when the // server has accepted it rather than when it was typed: the name is this app's own // datum and changes at once, and only telling the session waits for a boundary. val renamed = text.removePrefix("/rename ").trim().takeIf { text.startsWith("/rename ") && it.isNotEmpty() } act { runCommand(settings, summary.id, text) renamed?.let { title = it } } return } input = "" saveDraft(context, summary.id, "") pendingAttachments = emptyList() // Nothing is added here. The server says what is waiting -- it emits `messageQueued` // when it takes a message it cannot deliver yet -- and this screen draws that. Holding a // local copy as well was the bug: the two agreed only until the app was restarted or the // session left, and then the screen showed nothing pending while the queue was full. act { sendMessage(settings, summary.id, text, attachments) } } // The system photo picker; the image uploads as soon as it's chosen, // so Send only has ids to reference. val pickImage = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> if (uri != null) { scope.launch { try { val id = withContext(Dispatchers.IO) { // Shrunk to what this session's provider takes before it is // uploaded, so a twelve-megapixel photo does not cross the tunnel // to be rejected at the far end -- see `uploadPickedImage`. uploadPickedImage( context, settings, summary.id, uri, summary.maxImageEdge, ) } pendingAttachments = pendingAttachments + id actionError = null } catch (e: ApiException) { actionError = e.message } } } } // One poll for this machine's limits, read by the two things that show them: the bar under // the header, and the colour of the button that opens the dialog. val usage = rememberSessionUsage(settings, summary.setup) var usageOpen by remember { mutableStateOf(false) } var settingsOpen by remember { mutableStateOf(false) } Column(Modifier.fillMaxSize()) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), ) { GlyphButton(BACK_GLYPH, "Back", onBack) // A ring's worth, which is what the arrow already keeps on its other three sides -- // the pair of glyph buttons at the far end of this row get theirs from each other. Spacer(Modifier.width(GLYPH_BUTTON_MARGIN)) Column(Modifier.weight(1f)) { Text(title, style = MaterialTheme.typography.titleMedium) // Machine first, then what runs on it -- the same order and the same wording // everywhere this pair appears, so it reads as one fact rather than as two // sentences with different grammar. The "on" that used to sit in the middle // made it a phrase, which only works in one order and stops working the moment // the pair is shown anywhere else. // // No model. The picker in the footer already shows what this session is set to, // and showing it twice means two things to keep in step -- they disagreed for a // moment on every model change, since one follows the request and the other the // session's own answer. Text( "${summary.setupName} ยท ${summary.provider}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } // Beside the provider it reports on, which is the line directly to its left. // // Its real home is this provider's settings, which do not exist yet; until they do, // the session is the only place the provider is already named, so it is the only // place the button can sit without inventing a scope for itself. What it shows is // the paid service's own numbers, so a session on a provider with no such service // gets an honest "unavailable" rather than a hidden button -- a control that comes // and goes makes its absence the signal, and absence cannot say why. // Coloured by the worst window behind it, so the row says whether the limits are // worth opening before anybody opens them. Blue at every ordinary level and only // yellow or red near a limit -- and the theme's plain control colour whenever there // is no measurement, since blue is the low end of the scale here and would read as // "checked, and fine" about a machine nobody could reach. Row { GlyphButton( USAGE_GLYPH, "Usage", { usageOpen = true }, colour = usageGlyphColour(usage), ) // What it opens is about this session, so it sits at the end of the session's // own row. The name is the whole of what it holds today, which is why it is a cog // and not a word: there will be more, and a bar of words has nowhere to put it. GlyphButton(SETTINGS_GLYPH, "Session settings", { settingsOpen = true }) } } // Under the header, above everything the session itself says: it is a fact about the // machine rather than a turn in the conversation, and it is the number that decides // whether to keep going -- which was a screen away from where that gets decided. SessionUsageBar(usage) (streamError ?: actionError)?.let { message -> Text( message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), ) } // Every loaded row composed and kept, hanging from the newest message. // // The obvious arrangement -- oldest first, then scroll to the end -- opens at the top and // travels the whole transcript to get where it belongs. On an imported session that was // nine hundred rows measured before anything was readable, seen as the view visibly racing // downward every time it opened. Scrolling the content in reverse removes the journey // rather than hiding it: position zero *is* the newest message, so the first frame is // already the right one and nothing has to be scrolled at all. See [TranscriptScroll]. // // Drawn only once there is nothing left to put back, and measured throughout -- the // heights are what a saved position is expressed in, so the rows have to be laid out // before it can be applied. Held out of the drawing rather than out of the list, so there // is no frame in which the transcript is somewhere other than where it was left. val settled = !restoring && !listState.settling Box(Modifier.weight(1f).fillMaxWidth()) { BoxWithConstraints(Modifier.fillMaxSize()) { TranscriptColumn( rows = rows, state = listState, // A scrollable child is measured with no height bound, so the content cannot // ask how tall the visible area is; this is the only place that knows. viewportHeight = maxHeight, contentPadding = TRANSCRIPT_PADDING, spacing = TRANSCRIPT_SPACING, 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()) { Column(horizontalAlignment = Alignment.End) { waitingCommands.forEach { (_, text) -> CommandBubble(text, waiting = true) } queued.forEach { waiting -> UserBubble( settings = settings, sessionId = summary.id, text = waiting.text, images = waiting.images, pending = true, ) } } } }, ) { row -> val bounds = remember { RowBounds() } Box( Modifier.onGloballyPositioned { bounds.top = it.positionInRoot().y bounds.height = it.size.height.toFloat() } .holdTopEdge(row.key, topEdgeHeld) { grew -> listState.by(grew) } ) { when (row) { is TranscriptRow.Tools -> ToolGroup( group = row, expanded = row.id in expandedGroups, onToggle = { at -> toggleAnchored(row.key, bounds, at) { 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, at -> toggleAnchored(row.key, bounds, at) { expandedTools = if (id in expandedTools) expandedTools - id else expandedTools + id } }, onAnswer = { questionId, answers -> act { answerQuestion( settings, summary.id, questionId, answers, ) } }, image = { ref -> SessionImage(settings, summary.id, ref) }, ) is TranscriptRow.Single -> when (val item = row.item) { is TranscriptItem.UserMsg -> UserBubble( settings = settings, sessionId = summary.id, text = item.text, images = item.images, ) is TranscriptItem.AssistantMsg -> { // The newest reply is never cut. It is the one being read // as it arrives -- often still arriving -- and putting a // "show the rest" under a turn somebody is waiting for // hides the answer they are waiting for. Every reply // behind it is history, and history is what this is for. val shortened = if ( item.seq == newestReply || item.seq in expandedReplies ) null else remember(item.text) { shortenedReply(item.text) } if (shortened == null) { AssistantMessage(item.text, replies) } else { CappedReply( full = item.text, shortened = shortened, replies = replies, onShowMore = { // Anchored like every other row that changes // height, so the edge the reader touched // stays where it is. toggleAnchored(row.key, bounds, bounds.top) { expandedReplies = expandedReplies + item.seq } }, ) } } is TranscriptItem.ToolRun -> ToolCard( tool = item, expanded = item.id in expandedTools, onToggle = { at -> toggleAnchored(row.key, bounds, at) { expandedTools = if (item.id in expandedTools) expandedTools - item.id else expandedTools + item.id } }, onAnswer = { questionId, answers -> act { answerQuestion( settings, summary.id, questionId, answers, ) } }, image = { ref -> SessionImage(settings, summary.id, ref) }, ) is TranscriptItem.QuestionCard -> QuestionRow(item) { answers -> act { answerQuestion( settings, summary.id, item.id, answers, ) } } is TranscriptItem.ErrorMsg -> Text( item.message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium, ) is TranscriptItem.ImageItem -> SessionImage(settings, summary.id, item.ref) is TranscriptItem.Note -> Text( item.text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) is TranscriptItem.CommandRow -> CommandBubble(item.text) is TranscriptItem.ClearedNote -> ClearedRow() is TranscriptItem.CompactedNote -> CompactedRow(item) is TranscriptItem.PeerNote -> PeerMessageRow( item = item, expanded = item.seq in expandedNotes, replies = replies, onToggle = { at -> toggleAnchored(row.key, bounds, at) { expandedNotes = if (item.seq in expandedNotes) expandedNotes - item.seq else expandedNotes + item.seq } }, ) } } } } } // Still finding out what this conversation is: the newest page has not arrived, or // it has and the list is being put back where reading stopped. Both draw no rows at // all, and a blank page is what this screen otherwise means by "there is nothing // here" -- so the state that does not know needs its own appearance rather than // sharing one with the empty answer. // // In the middle of the transcript rather than at either end, because it is not // reporting on the newest message or the oldest; it is standing in for all of them. // `settled` and not `restoring` alone, so the spinner covers the whole wait: fetching // the history a saved position needs, and then the frames between those rows arriving // and the layout that measures them putting the position back. They are the two halves // of the same wait and the transcript is not drawn for either. if (!ready || !settled) { CircularProgressIndicator(Modifier.align(Alignment.Center).size(LOADING_SPINNER)) } // Only while the newest message is off-screen. Reading back // through a conversation is a place to be, not a state to be // rescued from, so this waits to be wanted. // // Down, and the same chevron a tool group collapses with: the // list is built upside down internally, but nobody reading it // knows that -- on screen the newest message is at the bottom, // which is where this goes. The name is carried in the // description, since an arrow alone says nothing to a screen // reader and nothing to whoever finds this in six months. if (!atNewest) { Surface( // Instantly. An animated scroll travels the whole transcript, so the // further back somebody has read the longer this takes -- the one press // whose cost grows with how much there is to skip, which is backwards. // // Arriving there is all this has to do now. The newest end is where the // content hangs from, so being at it is the whole of following it, and there // is no separate flag to set -- which is what this press used to forget, // landing the reader at the bottom with new messages not bringing the view // with them. onClick = { scope.launch { listState.scroll.scrollTo(0) } }, shape = CircleShape, color = MaterialTheme.colorScheme.surfaceContainerHigh, modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 12.dp).semantics { contentDescription = "Jump to latest" }, ) { Chevron( pointingUp = false, colour = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), ) } } } pendingModel?.let { chosen -> ModelSwitchWarning( from = modelLabel(model), to = modelLabel(chosen), onDismiss = { pendingModel = null }, onConfirm = { pendingModel = null act { setSessionModel(settings, summary.id, chosen) } }, ) } SessionStatusRow( status = status, compactingFor = compactingFor, contextTokens = contextTokens, ) // Between the transcript and the box: above what is being typed, so the list does not // cover the thing the command is about, and below everything that explains it. CommandSuggestions( commands = suggestedCommands(input), onPick = { command -> input = command.typed() }, ) // Always enabled -- a send while the session is running becomes a // steering message injected at the next tool boundary, which is // the point of the whole app. // // The field gets a row of its own, above the buttons: sharing one // put the full width behind three controls, so the thing being // typed into was the narrowest thing on the row. Column(Modifier.fillMaxWidth().padding(8.dp)) { // Directly above the box they will be sent from, so what is attached is visible // rather than counted: the "+2" on the button below said how many and never which. PendingAttachments( settings = settings, sessionId = summary.id, refs = pendingAttachments, onRemove = { pendingAttachments = pendingAttachments - it }, ) OutlinedTextField( value = input, onValueChange = { input = it saveDraft(context, summary.id, it) }, modifier = Modifier.fillMaxWidth(), // No longer "(+image)": the images are on screen above this, and a placeholder // saying so said it in words beside the thing itself. placeholder = { Text("Message") }, maxLines = 4, ) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), ) { TextButton( onClick = { pickImage.launch( PickVisualMediaRequest( ActivityResultContracts.PickVisualMedia.ImageOnly ) ) } ) { // Just "+" now. The count was standing in for showing them. Text("+") } // The settings share what is left after the actions have // taken what they need. A Row hands out intrinsic widths in // order and clips whatever runs past the edge, so with // these laid out first the arrival of Stop pushed Send off // the screen entirely -- the app's central control, gone at // exactly the moment the app is most in use. Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f), ) { if (offeredModels.isNotEmpty()) { PickerButton( current = modelLabel(model), // What the machine offers, plus the state a session is in when it // has chosen none of them. The button has always been able to say // "default"; until this the list could not, so leaving it was a // one-way trip. options = listOf(DEFAULT_MODEL) + offeredModels, // Not set here. The button follows what the session reports it // is set to, which arrives a moment later and is sometimes a // different answer -- a name the CLI resolved, or no change at all // on a provider whose model is fixed when it starts. // Asked about first, unless there is nothing to lose by it -- // see [ModelSwitchWarning]. onPick = { chosen -> if (modelLabel(chosen) == modelLabel(model) || items.isEmpty()) { act { setSessionModel(settings, summary.id, chosen) } } else { pendingModel = chosen } }, ) } PickerButton( current = permissionMode, options = PERMISSION_MODES, onPick = { chosen -> act { setSessionPermissionMode(settings, summary.id, chosen) } }, ) } // The same filled shape as the button beside it, not an outlined one: these are // two things you can do about the session, and weighting one of them as secondary // said they were a primary action and its qualifier. What separates them is the // colour and the mark, which is what they mean. // // Always here, rather than arriving with the turn as it used to. A control that // comes and goes makes its own presence the signal, and its absence could not say // whether there was nothing to do; a button that is always in the same place also // cannot push Send off the end of the row by turning up. val process = when { running -> ProcessAction.Pause status == "exited" -> ProcessAction.Start else -> ProcessAction.Stop } Button( onClick = { processInFlight = true act(onDone = { processInFlight = false }) { process.perform(settings, summary.id) } }, enabled = !processInFlight, colors = actionButtonColors(process.colour()), ) { Glyph( process.glyph, colour = LocalContentColor.current, modifier = Modifier.semantics { contentDescription = process.label }, ) } Spacer(Modifier.width(8.dp)) // The paper plane, with a clock on it while a turn is in flight: sending then // queues the message for the next tool boundary rather than starting a turn of // its own, and the two have to be told apart at a glance. The label says the same // thing to a screen reader, which has nothing else to read. // // Disabled while there is nothing to send, rather than pressable and silent: // `send` has always returned early on an empty composer, so the button promised // something it would not do, and the only feedback was the ripple. Disabled and // not hidden, for the reason the button beside it is always here. Button( onClick = { send() }, enabled = input.isNotBlank() || pendingAttachments.isNotEmpty(), colors = actionButtonColors(if (running) queueColor else sendColor), ) { Glyph( if (running) QUEUE_GLYPH else SEND_GLYPH, colour = LocalContentColor.current, modifier = Modifier.semantics { contentDescription = sendLabel(running) }, ) } } } } if (usageOpen) { UsageDialog(settings = settings, onDismiss = { usageOpen = false }) } if (settingsOpen) { SessionSettingsDialog( settings = settings, sessionId = summary.id, title = title, // The header takes the new name at once and the dialog closes on it, because the // rename has already been accepted by the server -- see [title], which is this app's // own datum. The list behind this refetches on the way out of the session anyway. onRenamed = { title = it settingsOpen = false }, onDismiss = { settingsOpen = false }, ) } } /** What pressing Send does right now, said the same way to the eye and to a screen reader. */ private fun sendLabel(running: Boolean) = if (running) "Queue" else "Send" /** * What the composer's process button would do if it were pressed now. * * One value rather than four parallel conditions over the status, because the mark, the colour, the * name a screen reader is given and the request that goes out are four halves of one decision. A * button drawn as a pause that terminates the CLI is the worst bug available here, and separate * branches over the same condition are how that happens -- these three each have to cover every * case, and the compiler says so. */ private enum class ProcessAction(val glyph: String, val label: String) { /** A turn is running: take it back, and leave the process holding the conversation. */ Pause(PAUSE_GLYPH, "Pause"), /** Nothing is running, but the process behind the session is: end it. */ Stop(STOP_GLYPH, "Stop"), /** The process is gone: start it again, on the conversation it left. */ Start(PLAY_GLYPH, "Start"), } @Composable private fun ProcessAction.colour() = when (this) { ProcessAction.Pause -> pauseColor ProcessAction.Stop -> stopColor ProcessAction.Start -> startColor } private fun ProcessAction.perform(settings: ServerSettings, sessionId: String) = when (this) { ProcessAction.Pause -> interruptSession(settings, sessionId) ProcessAction.Stop -> stopSession(settings, sessionId) ProcessAction.Start -> startSession(settings, sessionId) } /** * An inline transcript image, fetched (authenticated, pinned) from the session's files route. The * bitmap is remembered per ref, so scrolling doesn't refetch. */ @Composable private fun UserBubble( settings: ServerSettings, sessionId: String, text: String, images: List = emptyList(), pending: Boolean = false, ) { Box(Modifier.fillMaxWidth()) { Card( // A message the session has not read yet is drawn quieter than // one it has. The difference is in degree -- said, not yet // heard -- which is what colour alone can carry; where it sits // is what says the rest. colors = CardDefaults.cardColors( containerColor = if (pending) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.primaryContainer ), modifier = Modifier.align(Alignment.CenterEnd).padding(start = 48.dp), ) { Column(Modifier.padding(12.dp)) { // A message can be nothing but an attachment, and an empty line above a picture // is a bubble with a gap in it for a sentence nobody wrote. if (text.isNotEmpty()) { Text( text, color = if (pending) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onPrimaryContainer, ) } // Under the words: what somebody wrote is what the bubble is, and the picture is // what they attached to it. It also keeps the first line of every bubble at the // same place down the transcript, whether or not there is an image in it. images.forEachIndexed { index, ref -> if (index > 0 || text.isNotEmpty()) Spacer(Modifier.height(4.dp)) SessionImage(settings, sessionId, ref) } } } } } /** A message the server has accepted and the session has not read yet. */ private data class QueuedMessage(val id: String, val text: String, val images: List) /** * Collapsed by default: name plus a spinner while running, expandable to the input and output. The * spinner-while-unfinished is exactly "ToolStart with no matching ToolEnd yet". */ /** * Asked before switching model, because switching is not free and the cost is invisible. * * A model change drops the cached context: the next turn re-reads the entire conversation from the * beginning and is charged for it. Measured on 2026-08-29 against a small session -- the turn * before the switch read 30,771 tokens from cache and created 87; the turn after read **nothing** * from cache and created 41,509. On a long conversation that is the whole of it, again. * * No number is offered here, deliberately. What it will cost depends on how long *this* * conversation is, and this screen does not know that -- the running total beside it counts what * has been spent, which is a different quantity. A figure worked out from it would be a guess in a * measurement's clothes, and the reader could not tell which times it was right. * * The permission-mode picker beside it deliberately has no equivalent, which the same measurement * decided: changing mode kept the cache (30,858 read, 75 created). Warning on both would teach the * reader that these dialogs can be clicked through, which is what makes the one that matters stop * working. */ @Composable private fun ModelSwitchWarning( from: String, to: String, onDismiss: () -> Unit, onConfirm: () -> Unit, ) { AlertDialog( onDismissRequest = onDismiss, title = { Text("Switch to $to?") }, text = { Text( "The session re-reads the whole conversation on its next turn: leaving $from " + "drops the cached context, so that turn costs as much as the conversation " + "is long. Nothing is lost -- it is read again, not forgotten." ) }, confirmButton = { TextButton(onClick = onConfirm) { Text("Switch") } }, dismissButton = { TextButton(onClick = onDismiss) { Text("Keep $from") } }, ) } /** * What the session is doing, and what the conversation has cost, on one line above the box. * * A row of its own because both of these are facts about the session rather than turns in it, and * both were previously drawn over the transcript: the token total floated in its bottom corner, * where a long message ran underneath it, and the working indicator was an item inside the list, so * it scrolled away exactly when somebody reading back wanted to know whether anything was still * happening. Here they are always in the same place, and the thing they report on -- the session * you are about to type at -- is directly below. * * The row is drawn whether or not it has anything to say. An empty one costs a line; a row that * came and went would move the text box under the reader's thumb every time a turn started, and * would make its own presence the signal for a state it never names. * * The states are the session's own status words plus the total, and each looks different from the * others: `exited` is here because a session whose process is gone cannot be typed at, and with the * indicator gone from the list nothing else on this screen would say so. */ @Composable private fun SessionStatusRow( status: String, /** Seconds since this device saw the compaction start; null if it did not see it. */ compactingFor: Long?, /** Context the session is holding, or null where nothing has measured it. */ contextTokens: Long?, modifier: Modifier = Modifier, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), ) { when (status) { // A bar rather than the spinner an ordinary turn gets, and it takes the row's whole // free width: nothing arrives in the transcript during a compaction, so this is the // only thing on screen that is moving, and at a spinner's width that reads as a // session that has hung. // // Indeterminate, which is a statement rather than an omission. The CLI says a // compaction has begun and then says nothing at all until it has finished -- measured // against 2.1.237 again on 2026-08-29, on a real 80,346-to-2,088-token compaction that // took 23 seconds and produced not one line in between. So there is no fraction to // fill, and a bar creeping along at the pace of the last one would be this screen // inventing the part nobody sent it. Elapsed time is the only honest number here, and // [compactingLabel] is where it is worded. "compacting" -> { Text( compactingLabel(compactingFor), style = MaterialTheme.typography.labelSmall, // Stated beside the fill rather than inherited: a semantic colour has to carry // its own contrast, since the surface under it will not change to rescue it. color = commandColor, ) LinearProgressIndicator( color = commandColor, trackColor = MaterialTheme.colorScheme.surfaceContainerHigh, modifier = Modifier.weight(1f).padding(horizontal = 8.dp), ) } "running" -> { CircularProgressIndicator( // Smaller than the line beside it, so the row keeps the text's own height: // a control taller than a line re-centres it and knocks it out of line with // the total on the other end. modifier = Modifier.width(12.dp).height(12.dp), strokeWidth = 2.dp, ) Text( "working", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 8.dp), ) Spacer(Modifier.weight(1f)) } // Every remaining state says which one it is, including the quiet one. The row used // to name only `exited` and leave the rest blank, so a session sitting idle and one // whose status nobody could read looked identical -- and a turn that had just been // stopped showed nothing at all, which reads as the app having lost the session // rather than as the stop having worked. The words are the session list's own, so // one state is not called two things depending which screen you are on. else -> Text( when (status) { "idle" -> "idle" "exited" -> "exited" "awaitingInput" -> "your turn" "unknown" -> "can't tell" else -> status }, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), ) } // How full the session is, which is the number a reader is asking about -- how much room // is left before the next compaction -- rather than what has been spent getting here. // // "unknown" in words, and always drawn. A context nobody has measured is not an empty // one, and the two used to share an appearance: a session that had just been cleared, one // whose provider never reports usage, and one that has not run a turn all showed nothing // at all, which reads as a conversation with room to spare. It is the same reason the // status word beside it names the quiet state instead of leaving the row blank. Text( contextTokens?.let { "context ${tokens(it)}" } ?: "context unknown", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } /** * A question (or permission request -- same shape) inline in the transcript. Option buttons until * answered; then the chosen answer, which the `answered` event also resolves on every other * connected device. */ @Composable private fun QuestionRow( question: TranscriptItem.QuestionCard, onAnswer: (List) -> Unit, ) { Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp)) { // The same body the questions on a tool call get: one question is the same // thing whether or not something else asked it. AskedQuestion(question, onAnswer) } } } /** * How long after a menu closes a press on its own button still counts as the press that closed it. * * Sized to one tap, because one tap is all it has to span -- [PickerButton] explains the pair of * events it separates. Deliberately not the platform's long-press timeout, which is the longest a * tap can legally be: half a second of ignoring the button would start swallowing a deliberate * reopen, and a press held that long to close a menu is not worth protecting at that price. */ private const val ONE_TAP_MS = 250L /** The modes the CLI accepts, in the order they give up asking. */ private val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan") /** * A control that reads as its own value. * * The button *is* the current setting rather than a label beside one, so the row says what the * session is set to without spending a second line on saying it. */ @Composable private fun PickerButton(current: String, options: List, onPick: (String) -> Unit) { var open by remember { mutableStateOf(false) } // When an outside touch last closed the menu. // // Pressing this button while its own menu is open is such a touch. The menu is deliberately // not focusable (see below), which means the press that dismisses it is also delivered to the // window underneath -- and what it lands on there is this button. The dismissal arrives with // the press and the click with the release, measured 3ms apart on the emulator, so a button // that simply opened on every click would reopen what the same finger had just closed, and // the menu could only be put away by tapping somewhere else. So the moment is remembered, and // a click that follows it within one tap is read as the second half of that tap rather than // as a new one. var closedAt by remember { mutableLongStateOf(0L) } Box { TextButton( onClick = { if (SystemClock.uptimeMillis() - closedAt > ONE_TAP_MS) open = true } ) { // One line, truncated rather than wrapped: this sits in a row // whose height is the buttons beside it, and a second line // would move them. Text( current, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } // Two departures from the defaults, both deliberate. // // Not focusable, so opening it does not take focus from the message field and dismiss the // keyboard. Changing the model mid-sentence is an aside, not a departure from what you // were typing. // // Not clipped, which is what puts the menu on the button instead of floating above it. // Compose measures the anchor in *window* coordinates -- this app draws edge to edge, so // that window is the whole screen -- but asks whether the menu fits inside the *visible* // frame, which is the screen less the status and navigation bars. Two spaces, one // comparison: sitting just above a button near the bottom then looks like an overflow, // and the menu falls back to a fixed 48dp above the bottom of the visible frame. Measured // on the emulator, that left the menu's foot 142px -- the status bar's height, exactly -- // clear of the button that opened it. Turning clipping off makes both questions about the // same window. What it gives up is that the keyboard stops counting as an edge: with the // IME up the menu opens downwards over it rather than upwards over the transcript. That // is the lesser fault -- it is still attached to the button that opened it, which is the // whole complaint -- and correcting it would mean supplying a position provider, which // this menu takes no parameter for. DropdownMenu( expanded = open, onDismissRequest = { open = false closedAt = SystemClock.uptimeMillis() }, properties = PopupProperties(focusable = false, clippingEnabled = false), ) { options.forEach { option -> DropdownMenuItem( text = { Text(option) }, onClick = { open = false if (option != current) onPick(option) }, ) } } } }