Files
ai-app/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt
T
iris b0629f77ca Shrink a photo to what the provider takes, and put it in its own bubble
Sending an image was broken in the way that is hardest to see from the
phone: a camera photo is twelve megapixels and several megabytes, the Claude
API resizes anything past 1568px on its long edge before looking at it and
refuses far larger outright, so the picture was uploaded whole over the
tunnel to be thrown away or rejected at the other end.

Shrunk on the phone, to a limit the server states. Which number it is comes
from the provider's *kind* -- `DriverKind::max_image_edge`, reported on the
session row -- because that is where a provider's requirements are known,
and a phone carrying its own copy of them would be a second place to update
when one changes. `None` where nothing cares, rather than a large number:
"no limit" and "a limit that happens to be big" are different answers and
only one of them stays true. Doing it before the upload rather than after is
the point -- the expensive part on a phone is the tunnel, not the decode --
and an image already inside the limit is uploaded byte for byte rather than
being round-tripped through JPEG for nothing.

EXIF orientation is applied while scaling. The camera writes which way up
the picture is into a tag rather than into the pixels, and re-encoding drops
it, so a portrait photo would have arrived at the model on its side with
nothing anywhere saying so.

**What is attached is now visible before it is sent**, in a row directly
above the box it will be sent from: the count on the "+" button said how
many and never which, so the only way to find out what you had picked was to
send it. It scrolls sideways rather than shrinking, and tapping one takes it
back off -- an image picked by mistake could otherwise only be dealt with by
sending it. The tile is outlined as well as filled, because most of what
gets attached here is a screenshot of a dark app and a cropped one is
near-black: without an edge the only thing on screen saying an image was
attached was the cross drawn on top of nothing.

**And the picture is inside the bubble that sent it.** Attachments used to
be their own `Image` events emitted just before the message, which drew
somebody's screenshot as a row floating above the bubble and left the phone
deciding from adjacency alone which message an image belonged to -- a thing
the sender knew and could simply say. `UserMessage`, `MessageQueued` and
`MessageTaken` carry the refs now, so a waiting message keeps its picture
for as long as the turn runs, and a replay puts it back in the same place.

Verified on a real claude-cli session rather than an echo one, since the
limit only exists for that kind: a 3000x4000 image arrived as 1176x1568
JPEG -- long edge exactly the limit, aspect ratio intact -- and haiku
answered "AI Sessions displays idle Photo", which is what the picture was.
No error, and the transcript records the message with `images` on it.
2026-08-30 00:47:16 -04:00

1507 lines
75 KiB
Kotlin

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.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
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.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.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private const val RECONNECT_DELAY_MS = 1500L
/**
* 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<String> = 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<QuestionCard> = 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<String> = 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<QuestionOption>,
/** Whether several options may be chosen at once. */
val multiSelect: Boolean,
/** What was chosen, once something was; empty until then. */
val answers: List<String>,
) : 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 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.
*/
/**
* 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()
data class CompactedNote(
override val seq: Long,
val preTokens: Long?,
val postTokens: Long?,
val trigger: String?,
) : 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.
*/
private fun runIdFor(items: List<TranscriptItem>, id: String): String =
(items.lastOrNull() as? TranscriptItem.ToolRun)?.runId ?: id
/**
* Puts a page of older items in front of the ones already loaded, healing any tool call the page
* boundary cut in two.
*
* 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<TranscriptItem>, later: List<TranscriptItem>): List<TranscriptItem> {
val startedEarlier =
earlier.filterIsInstance<TranscriptItem.ToolRun>().mapTo(mutableSetOf()) { it.id }
if (startedEarlier.isEmpty()) return earlier + later
val endedLater =
later
.filterIsInstance<TranscriptItem.ToolRun>()
.associateBy { it.id }
.filterKeys { it in startedEarlier }
if (endedLater.isEmpty()) return earlier + later
val healed = earlier.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 = later.filterNot { it is TranscriptItem.ToolRun && it.id in endedLater }
return adoptRun(healed, kept) + kept
}
/**
* 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<TranscriptItem>,
later: List<TranscriptItem>,
): List<TranscriptItem> {
val joining = (later.firstOrNull() as? TranscriptItem.ToolRun)?.runId ?: return earlier
val tail = earlier.takeLastWhile { it is TranscriptItem.ToolRun }
if (tail.isEmpty()) return earlier
return earlier.dropLast(tail.size) +
tail.map { (it as TranscriptItem.ToolRun).copy(runId = joining) }
}
fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem> =
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.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,
runIdFor(items, event.id),
"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,
event.trigger,
)
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<TranscriptItem>,
id: String,
change: (TranscriptItem.ToolRun) -> TranscriptItem.ToolRun,
): List<TranscriptItem> = items.map {
if (it is TranscriptItem.ToolRun && it.id == id) change(it) else it
}
@Composable
fun SessionScreen(
settings: ServerSettings,
summary: SessionSummary,
onBack: () -> Unit,
onSettings: () -> Unit,
) {
val scope = rememberCoroutineScope()
var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
var status by remember { mutableStateOf(summary.status) }
// Seeded from the row this screen was opened from, so a conversation that has spent
// something says so before any turn happens here. Zero used to mean "nothing yet" and
// "nothing in the page I loaded" at once, and the second one is most of the long sessions.
var totalTokens by remember(summary.id) { mutableLongStateOf(summary.totalTokens) }
// When this screen saw the current compaction start, on this device's own clock, and how long
// ago that is. See `compactingLabel`: null is the honest answer whenever the start was not
// witnessed here, which is what opening a session that is already compacting looks like.
var compactingSince by remember { mutableStateOf<Long?>(null) }
var compactingFor by remember { mutableStateOf<Long?>(null) }
var streamError by remember { mutableStateOf<String?>(null) }
var actionError by remember { mutableStateOf<String?>(null) }
val context = LocalContext.current
// Seeded from what was left in the box last time and written back on every keystroke, so
// leaving the screen -- or the system reclaiming the app -- does not throw away a half-typed
// message. See `Drafts.kt` for why this one piece of state is the device's rather than the
// server's.
var input by remember(summary.id) { mutableStateOf(loadDraft(context, summary.id)) }
// A model the reader has chosen and not yet confirmed. See [ModelSwitchWarning]: switching
// makes the session re-read the whole conversation, which is worth asking about first.
var pendingModel by remember { mutableStateOf<String?>(null) }
var expandedTools by remember { mutableStateOf(setOf<String>()) }
// Which runs of adjacent tool calls are open. Keyed by the first call's
// id, so a group survives more calls arriving after it.
var expandedGroups by remember { mutableStateOf(setOf<String>()) }
// Which messages from other agents are open, by the seq that identifies their row. Closed
// by default, which is the rule for anything new in this transcript: a screen that opens
// everything it can is one nobody can scan.
var expandedNotes by remember { mutableStateOf(setOf<Long>()) }
// Uploaded-but-not-yet-sent attachment ids; sent with the next message.
var pendingAttachments by remember { mutableStateOf(listOf<String>()) }
// What this session is set to now, seeded from the row that opened it and
// then owned here, because changing either is something this screen does.
// The name shown at the top. Held here rather than read from the row that opened this
// screen, because renaming is something this screen can do -- through the settings below it,
// or by typing the command -- and a header still showing the old name reads as a rename that
// did not take.
var title by remember(summary.id) { mutableStateOf(summary.title) }
var model by remember { mutableStateOf(summary.model) }
var permissionMode by remember { mutableStateOf(summary.permissionMode ?: "auto") }
// The models this provider actually offers, asked of the server rather
// than listed here: a hardcoded list is a claim about a machine.
var offeredModels by remember { mutableStateOf<List<String>>(emptyList()) }
val lifecycleOwner = LocalLifecycleOwner.current
// The resume cursor, written from the stream's IO thread.
val lastSeq = remember { AtomicLong(0) }
val activeStream = remember { AtomicReference<EventStream?>(null) }
// The oldest sequence number loaded, and whether there is more behind
// it. Paging backwards is what keeps opening a long session cheap: the
// screen starts with the end of the conversation and fetches earlier
// pages only when somebody scrolls to them.
var oldestSeq by remember { mutableLongStateOf(0L) }
// Every transcript event loaded, in order, beside the rows they folded
// into. See `apply`.
var loaded by remember { mutableStateOf(listOf<SessionEvent>()) }
// Sent, but not yet read by the session -- which is when the backend
// records it and it comes back as a row. Until then it is drawn below
// the working indicator, because that is where it is in the session's
// reading of events: after everything taken in, not yet taken in
// itself.
// Messages the server has taken and the session has not read yet, by the id that will resolve
// them. From the event stream rather than from what this screen sent, so they are still here
// after leaving the session or restarting the app -- and so a message sent from another device
// is drawn waiting on this one too.
var queued by remember { mutableStateOf(listOf<QueuedMessage>()) }
// Commands the session has been asked to run and cannot yet, by the id that will resolve
// them. From the server rather than from this screen, so a rename sent from the settings
// screen -- or from another device -- is drawn waiting here too.
var waitingCommands by remember { mutableStateOf(listOf<Pair<String, String>>()) }
val running = status == "running" || status == "compacting"
var moreHistory by remember { mutableStateOf(true) }
var loadingHistory by remember { mutableStateOf(false) }
var ready by remember { mutableStateOf(false) }
val listState = rememberLazyListState()
// What is actually drawn: the transcript with runs of adjacent tool
// calls folded into one row each.
val rows = remember(items) { groupToolRuns(items) }
fun apply(entry: SeqEvent) {
lastSeq.set(entry.seq)
// 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
}
when (val event = entry.event) {
// Taken, not accumulated: the server's running total is on the event, and adding
// up the deltas this screen happened to receive counted one page of a conversation
// and called it the whole. `max` because pages arrive in no guaranteed order and an
// older event's total is a smaller true answer, never a correction downwards; it
// also leaves the seeded figure alone for transcripts recorded before the backend
// sent a total at all.
is SessionEvent.UsageDelta -> totalTokens = maxOf(totalTokens, event.total)
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) {
// Started here, or nowhere. `ready` is what separates the live stream from
// the page of history the screen opens with, and a compaction found in that
// page began before anybody here was watching -- timing it from now would
// report the moment we arrived as the moment it started.
compactingSince =
when {
event.state != "compacting" -> null
status == "compacting" -> compactingSince
ready -> SystemClock.elapsedRealtime()
else -> null
}
status = event.state
}
// 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. Matched by text --
// all that distinguishes one message from an identical
// earlier one -- and only the first match, so two
// identical messages wait twice.
// 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 }
}
// Kept as well as folded. Folding is one-way -- a tool's
// start and end become one row -- so a page arriving in
// front of what is already here cannot be stitched on
// without the events themselves.
loaded = loaded + event
items = foldEvent(items, entry)
}
}
}
// 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) {
compactingFor = (SystemClock.elapsedRealtime() - since) / 1000
delay(1000)
}
}
// The stream lifecycle: connect, follow, and on any drop reconnect
// from the cursor -- so a flaky link (or a backend restart) costs
// nothing but the gap's latency.
// The newest page first, in one request, before the stream opens. The
// stream then starts from where that page ended, so it carries live
// events only -- which is what it is good at.
LaunchedEffect(summary.id) {
try {
val page = withContext(Dispatchers.IO) { fetchTranscript(settings, summary.id) }
page.forEach { apply(it) }
} 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
}
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()
loaded = 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() } }
// Whether the newest message is on screen right now. The list is laid out from the bottom
// (see the LazyColumn below), so "newest" is index 0 and being there is being at the start of
// it. This is what the jump-to-newest button watches: it is about what the reader can see.
val atNewest by remember {
derivedStateOf {
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
}
}
// Whether they *chose* to be there, which is a different question and the one that decides
// whether an arriving message brings the view with it.
//
// Remembered, and only ever written when a scroll settles -- so it records where the reader
// last left the list, and an insertion cannot change the answer. Reading the live position
// instead looks right and is subtly wrong: a keyed list moves its anchor to keep the reader's
// content still, so by the time the new item can be observed the view is already one item
// away from the newest and reports itself as scrolled back. The message then never followed,
// which was visible as a compaction whose progress bar sat just off the bottom of the screen
// while the button that started it said it was running.
var followTail by remember { mutableStateOf(true) }
LaunchedEffect(listState) {
snapshotFlow { listState.isScrollInProgress }
.collect { scrolling -> if (!scrolling) followTail = atNewest }
}
// A new item at the newest end shifts every index by one, so the view
// has to step back to 0 to stay put. One item, instantly -- not a
// journey through the transcript.
// Anything that changes how much room the list has, as well as a new
// item arriving. Typing is the case that gets missed: the field grows
// from one line to four and the keyboard opens under it, and neither
// is a new message, so watching the item count alone leaves the newest
// text drifting out of sight while somebody writes a reply to it.
//
// Counted in list items rather than in transcript rows, because the rows are not all of it:
// the working indicator and a queued message are items too, and they arrive at exactly the
// same end. Sibling to the paging trigger below, which is the same count read from the other
// end for the same reason.
LaunchedEffect(listState) {
snapshotFlow {
Pair(listState.layoutInfo.totalItemsCount, listState.layoutInfo.viewportSize.height)
}
.collect { (count, _) -> if (followTail && count > 0) listState.scrollToItem(0) }
}
// Reaching the far end of what is loaded -- the oldest item, which in
// this layout is the last index -- fetches the page before it.
//
// Both numbers come from the list itself, and that is the point: an index into what is drawn
// can only be compared against how much is drawn. Three things already make that differ from
// the event count -- a run of adjacent tool calls is one row, and the queued bubble and the
// working indicator are rows with no event behind them at all -- so measuring the far end in
// events meant the threshold could not be reached, and a session with tool calls in it simply
// stopped scrolling back. Anything added to this list later is a fourth, and totalItemsCount
// already counts it.
LaunchedEffect(listState, rows.size, moreHistory) {
snapshotFlow {
val layout = listState.layoutInfo
Pair(layout.visibleItemsInfo.lastOrNull()?.index ?: 0, layout.totalItemsCount)
}
.collect { (last, total) ->
if (!moreHistory || loadingHistory || total == 0) return@collect
if (last < total - 3) return@collect
loadingHistory = true
try {
val older =
withContext(Dispatchers.IO) {
fetchTranscript(settings, summary.id, before = oldestSeq)
}
if (older.isEmpty()) {
moreHistory = false
} else {
oldestSeq = older.first().seq
moreHistory = oldestSeq > 1L
// Folded oldest-first into a list of their own, then
// put in front: `foldEvent` merges streaming text
// into the item before it, so replaying an older page
// through the live list would glue it onto the newest
// message rather than its own.
var earlier = listOf<TranscriptItem>()
older.forEach { entry ->
if (entry.event !is SessionEvent.UsageDelta) {
earlier = foldEvent(earlier, entry)
}
}
items = joinPages(earlier, items)
}
} 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 = {}, action: () -> Unit) {
scope.launch {
try {
withContext(Dispatchers.IO) { action() }
actionError = null
} catch (e: ApiException) {
actionError = e.message
onFailure()
}
}
}
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) }
Column(Modifier.fillMaxSize()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
) {
GlyphButton(BACK_GLYPH, "Back", onBack)
Spacer(Modifier.width(8.dp))
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(horizontalArrangement = Arrangement.spacedBy(GLYPH_BUTTON_GAP)) {
GlyphButton(
USAGE_GLYPH,
"Usage",
{ usageOpen = true },
colour = usageGlyphColour(usage),
)
// A step down from this session, so it sits at the end of the session's own row.
// The name is the whole of what it holds today, which is why it is a cog and not
// a word: there will be more, and a bar of words has nowhere to put it.
GlyphButton(SETTINGS_GLYPH, "Session settings", onSettings)
}
}
// 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),
)
}
// Laid out from the bottom, with the newest message at index 0.
//
// 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 is nine hundred
// items measured before anything is readable, seen as the view
// visibly racing downward every time it opened.
//
// Anchoring at the bottom removes the journey rather than hiding
// it: the first frame is already the newest message, and older
// ones are composed only as somebody scrolls back to them, which
// is also what makes history cheap on a long conversation.
Box(Modifier.weight(1f).fillMaxWidth()) {
LazyColumn(
state = listState,
reverseLayout = true,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// 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()) {
item(key = "queued") {
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,
)
}
}
}
}
// Reversed to match the layout, so index 0 is the newest and
// the reader still sees them in the order they happened.
// Grouped first: adjacent tool calls collapse into one row,
// which is a decision about this screen and not about the
// transcript the stream and paging share.
// Keyed, and this is what stops the list moving under whoever is reading
// it. Every new message is an insertion at index 0 here, so without a key the
// rows keep their positions and the content slides through them -- which looks
// exactly like the view scrolling by itself. The keys above matter for the same
// reason: the working indicator appearing and disappearing is another insertion
// at the same end. Paging older history is the opposite insertion and was
// already fine, and stays fine, because a key survives both.
items(rows.asReversed(), key = { it.key }) { row ->
when (row) {
is TranscriptRow.Tools ->
ToolGroup(
group = row,
expanded = row.id in expandedGroups,
onToggle = {
expandedGroups =
if (row.id in expandedGroups) expandedGroups - row.id
else expandedGroups + row.id
},
isToolExpanded = { it in expandedTools },
onToolToggle = { id ->
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 -> AssistantMessage(item.text)
is TranscriptItem.ToolRun ->
ToolCard(
tool = item,
expanded = item.id in expandedTools,
onToggle = {
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,
onToggle = {
expandedNotes =
if (item.seq in expandedNotes)
expandedNotes - item.seq
else expandedNotes + item.seq
},
)
}
}
}
}
// 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(
onClick = { scope.launch { listState.animateScrollToItem(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, totalTokens = totalTokens)
// 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),
options = 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 (chosen == 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) }
},
)
}
if (running) {
// The same filled shape as the button beside it, not an outlined one: these
// are two things you can do about the turn that is running, 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.
Button(
onClick = { act { interruptSession(settings, summary.id) } },
colors = actionButtonColors(stopColor),
) {
// A filled square, which is what stop has looked like since tape decks.
Glyph(
STOP_GLYPH,
colour = LocalContentColor.current,
modifier = Modifier.semantics { contentDescription = "Stop" },
)
}
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.
Button(
onClick = { send() },
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 })
}
}
/** 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"
/**
* 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<String> = 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)) {
// Above the words, in the order they were composed: the picture was attached
// before the sentence about it was typed, and it is what the sentence refers to.
images.forEach { ref ->
SessionImage(settings, sessionId, ref)
Spacer(Modifier.height(4.dp))
}
// A message can be nothing but an attachment, and an empty line under 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,
)
}
}
}
}
}
/** 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<String>)
/**
* 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?,
totalTokens: 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),
)
}
// Nothing rather than "0 tok" before anything has been spent: a total of zero is a fact
// about a conversation that has not started, and it is the one reading nobody needs.
if (totalTokens > 0) {
Text(
"$totalTokens tok",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/**
* A question (or permission request -- same shape) inline in the transcript. Option buttons until
* answered; then the chosen answer, which the `answered` event also resolves on every other
* connected device.
*/
@Composable
private fun QuestionRow(
question: TranscriptItem.QuestionCard,
onAnswer: (List<String>) -> Unit,
) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
// The same body the questions on a tool call get: one question is the same
// thing whether or not something else asked it.
AskedQuestion(question, onAnswer)
}
}
}
/** 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<String>, onPick: (String) -> Unit) {
var open by remember { mutableStateOf(false) }
Box {
TextButton(onClick = { 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,
)
}
// 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.
DropdownMenu(
expanded = open,
onDismissRequest = { open = false },
properties = PopupProperties(focusable = false),
) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(option) },
onClick = {
open = false
if (option != current) onPick(option)
},
)
}
}
}
}