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