Five things Iris asked for, all about the transcript screen holding still around whoever is reading it. A tool call opened on its own stayed open when a second call in the same run turns it into a group. Watching a Bash call and having the session make another one used to shut the card being read and fold it behind "Called 2 tools" -- the reader lost their place because something else happened. The transition is noticed once, at the moment a run first becomes a group; after that the group's own toggle owns it, so shutting a group whose inner call is still expanded does not re-open it. The compaction clock is taken from the `compacting` status event's own timestamp rather than from this device noticing one, so it survives leaving the session and coming back -- it used to disappear, because the only thing that knew when the compaction started was a screen that had been disposed. The server timestamps every transcript line, so this is still a measurement; it is compared against the phone's wall clock, which is the same comparison a session's "last active" already makes. Session settings are a dialog over the session instead of a screen below it. Two controls did not warrant a page transition and a back stack, and the thing they change was hidden while they were on screen. Captions are gone -- each control is a labelled noun -- and "Notify me" is "Notifications" with a bell beside it (`md-bell`, added to the committed Nerd Fonts subset). Failures keep their words, since those are what a reader cannot work out by looking. Tool groups are rounded like every other card, their foot bar is the same height as their heading (both derived from the heading's own line height, so the pair cannot drift), and the calls inside are a connected stack: square where they face a neighbour, rounded on the outside, with a small gap so the join reads as a join. Scroll position is persistent on the device, per session, keyed by the row rather than by an index -- an index means nothing across a reopen, where the transcript is fetched newest-first. Reopening pages backwards until that row is loaded *and* has something older behind it, because the oldest loaded row is a half-row that grows when the page behind it arrives; anchoring into one landed a screen and a half out. The list draws nothing until the position lands, so there is no frame in which the transcript is somewhere other than where it was left. Two things found on the way. `snapshotFlow`'s first emission is the state before anybody has touched the list, and reading it as a scroll that had just ended at the newest end wiped every saved position on the way in. And backwards pages now ask for 800 events rather than 80: ai-app-2 measured a real transcript at 2,426 events for seven assistant messages, so a page of eighty is a fifth of one row and filling the lookahead took about thirty sequential round trips -- seconds of a list that will not move, over the tunnel. `/tools [n] [gap]` in the echo driver takes seconds between calls, which is what makes a run grow slowly enough for somebody to have opened one of its calls first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2116 lines
110 KiB
Kotlin
2116 lines
110 KiB
Kotlin
package com.example.aiapp
|
|
|
|
import android.os.SystemClock
|
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
import androidx.activity.result.PickVisualMediaRequest
|
|
import androidx.activity.result.contract.ActivityResultContracts
|
|
import androidx.compose.foundation.Image
|
|
import androidx.compose.foundation.layout.Arrangement
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.PaddingValues
|
|
import androidx.compose.foundation.layout.Row
|
|
import androidx.compose.foundation.layout.Spacer
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.layout.width
|
|
import androidx.compose.foundation.lazy.LazyColumn
|
|
import androidx.compose.foundation.lazy.items
|
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
import androidx.compose.foundation.shape.CircleShape
|
|
import androidx.compose.material3.AlertDialog
|
|
import androidx.compose.material3.Button
|
|
import androidx.compose.material3.Card
|
|
import androidx.compose.material3.CardDefaults
|
|
import androidx.compose.material3.CircularProgressIndicator
|
|
import androidx.compose.material3.DropdownMenu
|
|
import androidx.compose.material3.DropdownMenuItem
|
|
import androidx.compose.material3.LinearProgressIndicator
|
|
import androidx.compose.material3.LocalContentColor
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.OutlinedTextField
|
|
import androidx.compose.material3.Surface
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.TextButton
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.DisposableEffect
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.derivedStateOf
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableLongStateOf
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.rememberCoroutineScope
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.runtime.snapshotFlow
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.layout.onGloballyPositioned
|
|
import androidx.compose.ui.layout.onSizeChanged
|
|
import androidx.compose.ui.layout.positionInRoot
|
|
import androidx.compose.ui.platform.LocalContext
|
|
import androidx.compose.ui.semantics.contentDescription
|
|
import androidx.compose.ui.semantics.semantics
|
|
import androidx.compose.ui.text.style.TextOverflow
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.window.PopupProperties
|
|
import androidx.lifecycle.Lifecycle
|
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
|
import androidx.lifecycle.repeatOnLifecycle
|
|
import java.util.concurrent.atomic.AtomicLong
|
|
import java.util.concurrent.atomic.AtomicReference
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.awaitCancellation
|
|
import kotlinx.coroutines.delay
|
|
import kotlinx.coroutines.flow.drop
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.withContext
|
|
|
|
private const val RECONNECT_DELAY_MS = 1500L
|
|
|
|
/**
|
|
* How many rows to keep loaded past the oldest one on screen.
|
|
*
|
|
* Both the point at which history starts loading and how much of it a load has to produce before it
|
|
* stops. A cushion rather than a page count because a page is measured in events and this list is
|
|
* measured in rows, and the two are not close: a page of eighty events can be one message.
|
|
*
|
|
* Small enough that opening a long session still costs one page, large enough that a fling upwards
|
|
* lands on rows that are already there. Fewer, and reading back means waiting for the network at
|
|
* every screenful, which is what it did.
|
|
*/
|
|
private const val HISTORY_LOOKAHEAD = 8
|
|
|
|
/**
|
|
* How many events a backwards page asks for, which is ten times what the opening page takes.
|
|
*
|
|
* Because an event is not a row, and the ratio is nothing like one to one. Measured on a real
|
|
* transcript (2,426 events, 2026-08-30): the whole conversation is *seven* assistant messages, and
|
|
* the median run of consecutive text deltas that fold into one of them is four hundred. A page of
|
|
* eighty is therefore a fifth of a single row, and reaching [HISTORY_LOOKAHEAD] fresh rows took
|
|
* about thirty sequential round trips inside one collect -- a stutter on loopback, and four or five
|
|
* seconds of a list that will not move over the tunnel, which reads as history having run out.
|
|
*
|
|
* The opening page stays small: it is the one on the critical path of showing the screen at all,
|
|
* and it only has to fill a viewport.
|
|
*/
|
|
private const val HISTORY_PAGE = 800
|
|
|
|
/**
|
|
* The list key of the bubble holding what has been sent and not read yet.
|
|
*
|
|
* Named rather than written at the `item` that draws it, because a saved scroll position stores
|
|
* whatever key it was left on and this is one of the values that can be.
|
|
*/
|
|
private const val QUEUED_KEY = "queued"
|
|
|
|
/**
|
|
* Which row was asked to hold its top edge, and how tall it was when it last measured.
|
|
*
|
|
* Deliberately *not* snapshot state, and that is the point of the whole class. Both fields are
|
|
* written from the layout phase; a snapshot write there that composition reads would schedule
|
|
* another recomposition, and the correction has to land inside the frame that is already being laid
|
|
* out rather than in a later one. Nothing observes these, so nothing needs to.
|
|
*
|
|
* [key] is cleared by the resize it was set for, so it cannot be spent on an unrelated one.
|
|
*/
|
|
private class TopEdgeHold {
|
|
var key: Any? = null
|
|
}
|
|
|
|
/**
|
|
* Where a row is on screen, so a tap on it can be told which half it landed in.
|
|
*
|
|
* Not snapshot state, for the same reason as [TopEdgeHold]: written from layout, read from a click,
|
|
* and observed by nothing.
|
|
*/
|
|
private class RowBounds {
|
|
var top = 0f
|
|
var height = 0f
|
|
|
|
/** Above this is the row's top half, below it the bottom half. */
|
|
val middle
|
|
get() = top + height / 2
|
|
}
|
|
|
|
/** One row's height between layouts, so a change in it can be noticed. See [holdTopEdge]. */
|
|
private class LastHeight {
|
|
var value: Int? = null
|
|
}
|
|
|
|
/**
|
|
* Keeps this row's top edge where it is when the row changes height, if it was asked to.
|
|
*
|
|
* This runs in the *layout* phase, from the measurement that discovers the new height, and that is
|
|
* the whole reason it is a modifier rather than an effect. A correction posted to a coroutine
|
|
* arrives a frame or more after the layout it is correcting, so the wrong position is drawn once
|
|
* before the right one -- visible as a flick, and worse the faster the screen refreshes. Scrolling
|
|
* from here happens before anything is drawn, so there is no frame to see and nothing that depends
|
|
* on how quickly the correction is scheduled.
|
|
*
|
|
* [hold] is given the change in height. The row's bottom edge is held by the list, so a scroll of
|
|
* exactly that much is what leaves the top edge where it was.
|
|
*/
|
|
@Composable
|
|
private fun Modifier.holdTopEdge(key: Any, held: TopEdgeHold, hold: (Int) -> Unit): Modifier {
|
|
val last = remember { LastHeight() }
|
|
return onSizeChanged { size ->
|
|
val previous = last.value
|
|
last.value = size.height
|
|
// A first measurement has no previous height to have moved from, and a row that came
|
|
// back after being scrolled away is a first measurement again.
|
|
if (previous == null || previous == size.height || held.key != key) return@onSizeChanged
|
|
held.key = null
|
|
hold(size.height - previous)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* What the transcript renders: the event stream folded into displayable rows (see [foldEvent]). The
|
|
* stream is the only data source -- opening this screen replays from seq 0, and a reconnect resumes
|
|
* from the last seq seen, so there is no separate history fetch to drift from it.
|
|
*/
|
|
sealed class TranscriptItem {
|
|
/**
|
|
* The transcript sequence number this row started at, and its identity on screen.
|
|
*
|
|
* The list is drawn newest-first, so every new message is an insertion at index 0 and every
|
|
* page of history is an insertion at the far end. Without an identity that survives both, the
|
|
* list is addressed by position: whatever somebody had scrolled to keeps its index while the
|
|
* content underneath it slides, which reads as the view scrolling on its own.
|
|
*
|
|
* A seq is the right identity because it is what the transcript itself is ordered by, it never
|
|
* changes, and it is already carried by every event. A row built from several events -- a
|
|
* streaming message, a tool call and its result -- keeps the seq of the first, so it holds
|
|
* still while the rest of it arrives.
|
|
*/
|
|
abstract val seq: Long
|
|
|
|
data class UserMsg(
|
|
override val seq: Long,
|
|
val text: String,
|
|
/** Refs of what was attached, drawn inside the bubble. */
|
|
val images: List<String> = emptyList(),
|
|
) : TranscriptItem()
|
|
|
|
data class AssistantMsg(override val seq: Long, val text: String) : TranscriptItem()
|
|
|
|
data class ToolRun(
|
|
override val seq: Long,
|
|
val id: String,
|
|
/**
|
|
* The run of adjacent calls this one belongs to, named once when the call is folded in and
|
|
* never recomputed.
|
|
*
|
|
* Carried rather than derived because a run can gain members at *either* end -- a new call
|
|
* arriving beside it, or a page of history arriving in front of it -- so no function of its
|
|
* current members is stable. It is the first call's id at the moment the run started, which
|
|
* is a name rather than a description: [joinPages] hands it to older calls that turn out to
|
|
* belong to the same run, instead of renaming the run they joined.
|
|
*/
|
|
val runId: String,
|
|
val tool: String,
|
|
val input: String,
|
|
val output: String,
|
|
val done: Boolean,
|
|
/**
|
|
* The questions this call is waiting on, in the order they were asked.
|
|
*
|
|
* On the call's own row rather than beside it: an ask used to arrive as a second card
|
|
* repeating the input verbatim, so the reader saw the same command twice and had to work
|
|
* out that it was one event. The backend says which call a question is about, so this is a
|
|
* fact rather than a match on the input.
|
|
*
|
|
* A list because AskUserQuestion asks up to four at once, and they are one decision to make
|
|
* -- a permission is the case of exactly one, not a different shape.
|
|
*/
|
|
val asks: List<QuestionCard> = emptyList(),
|
|
/**
|
|
* Images this call's result carried, drawn under it.
|
|
*
|
|
* Beside it they had to be paired by position, and position is the thing a page boundary
|
|
* breaks -- a screenshot loaded on one page and its call on the next read as unrelated.
|
|
*/
|
|
val images: List<String> = emptyList(),
|
|
) : TranscriptItem()
|
|
|
|
data class QuestionCard(
|
|
override val seq: Long,
|
|
val id: String,
|
|
val prompt: String,
|
|
/** A few words naming what this is about, when the asker offered one. */
|
|
val header: String?,
|
|
val options: List<QuestionOption>,
|
|
/** Whether several options may be chosen at once. */
|
|
val multiSelect: Boolean,
|
|
/** What was chosen, once something was; empty until then. */
|
|
val answers: List<String>,
|
|
) : TranscriptItem()
|
|
|
|
data class ErrorMsg(override val seq: Long, val message: String) : TranscriptItem()
|
|
|
|
/** An image by server-side ref, fetched from the session's files route. */
|
|
data class ImageItem(override val seq: Long, val ref: String) : TranscriptItem()
|
|
|
|
/**
|
|
* A message another agent sent this session.
|
|
*
|
|
* Its own row rather than a [UserMsg]: see [PeerMessageRow] for why the voice matters.
|
|
*/
|
|
data class PeerNote(override val seq: Long, val from: String, val text: String) :
|
|
TranscriptItem()
|
|
|
|
/**
|
|
* A command the session ran on itself -- `/compact`, `/rename`.
|
|
*
|
|
* Kept in the transcript rather than only shown while it waits, because it explains what
|
|
* follows: a conversation that suddenly has half the context, or a session with a new name.
|
|
*/
|
|
data class CommandRow(override val seq: Long, val text: String) : TranscriptItem()
|
|
|
|
/** Placeholder row for events this build can't render (newer kinds). */
|
|
data class Note(override val seq: Long, val text: String) : TranscriptItem()
|
|
|
|
/**
|
|
* A clear that happened: everything above it left the session's context and stayed on screen.
|
|
*
|
|
* Carries only its position, because that is all it means.
|
|
*/
|
|
data class ClearedNote(override val seq: Long) : TranscriptItem()
|
|
|
|
/**
|
|
* A compaction that happened, and what it recovered.
|
|
*
|
|
* In the transcript rather than only in the status line, because the status is gone the moment
|
|
* it finishes and this is the part worth keeping: it is the explanation for a gap in the
|
|
* conversation, and for a minute or two in which the session was busy with nothing to show.
|
|
*
|
|
* The wire also says what triggered it, and this deliberately does not carry that: the row says
|
|
* the two sizes and nothing else (see [compactionSummary]), so keeping the trigger here would
|
|
* be a field nothing can read.
|
|
*/
|
|
data class CompactedNote(
|
|
override val seq: Long,
|
|
val preTokens: Long?,
|
|
val postTokens: Long?,
|
|
) : TranscriptItem()
|
|
}
|
|
|
|
/**
|
|
* The run a call joins: the one it lands next to, or a new one named after itself.
|
|
*
|
|
* Only ever consulted when the call is first folded in. That is what makes the name stable -- a run
|
|
* keeps whatever it was called when it started, however many calls arrive at either end of it
|
|
* afterwards.
|
|
*
|
|
* A question to the reader is in a run of its own, which is what puts it on the transcript as a row
|
|
* rather than inside a collapsed "Called 6 tools" card. Two things follow from being alone: it is
|
|
* always visible, since a run of one is drawn as itself rather than as a group; and the calls
|
|
* around it fall into a group before it and a group after it, so where the reader was asked
|
|
* something is legible in the shape of the transcript without opening anything. It ends the run
|
|
* before it as well as starting a fresh one after -- the moment somebody was asked is a boundary in
|
|
* the work, not a gap in the middle of one run.
|
|
*/
|
|
private fun runIdFor(items: List<TranscriptItem>, id: String, tool: String): String {
|
|
val previous = items.lastOrNull() as? TranscriptItem.ToolRun ?: return id
|
|
if (tool == ASK_USER_QUESTION || previous.tool == ASK_USER_QUESTION) return id
|
|
return previous.runId
|
|
}
|
|
|
|
/**
|
|
* Puts a page of older items in front of the ones already loaded, healing whatever the page
|
|
* boundary cut in two.
|
|
*
|
|
* Two things straddle a boundary: a tool call separated from its result, and a message separated
|
|
* from the rest of itself. Both were one thing before the transcript was cut into pages, and both
|
|
* have to be one thing again -- a reply drawn as two messages is the same defect as a call drawn
|
|
* twice, arriving from the same cause.
|
|
*
|
|
* A boundary lands wherever it lands, and roughly half the time that is between a call and its
|
|
* result. The newer page then holds a `ToolEnd` whose start it never saw, which [foldEvent] draws
|
|
* as a row of its own -- correctly, because a call that renders as nothing is indistinguishable
|
|
* from one that never happened. When the older page arrives it brings the real `ToolStart`, and
|
|
* concatenating the two lists left *both*: the same call twice, once as a proper card and once as a
|
|
* nameless placeholder. Visible as a run of four calls reporting "Called 5 tools", and worse than
|
|
* the miscount -- the extra row is at the join, so it also moves everything the reader was looking
|
|
* at.
|
|
*
|
|
* Merged by the call's own id rather than by position, because position is exactly what a page
|
|
* boundary destroys. The older row wins on what a start knows (the tool's name, its input) and the
|
|
* newer on what an end knows (the output, and whether it finished), which is the only way round
|
|
* that loses nothing.
|
|
*/
|
|
fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<TranscriptItem> {
|
|
val (older, newer) = healSplitMessage(earlier, later)
|
|
val startedEarlier =
|
|
older.filterIsInstance<TranscriptItem.ToolRun>().mapTo(mutableSetOf()) { it.id }
|
|
if (startedEarlier.isEmpty()) return older + newer
|
|
val endedLater =
|
|
newer
|
|
.filterIsInstance<TranscriptItem.ToolRun>()
|
|
.associateBy { it.id }
|
|
.filterKeys { it in startedEarlier }
|
|
if (endedLater.isEmpty()) return older + newer
|
|
val healed = older.map { row ->
|
|
val half = (row as? TranscriptItem.ToolRun)?.let { endedLater[it.id] }
|
|
if (row is TranscriptItem.ToolRun && half != null) {
|
|
row.copy(
|
|
output = half.output,
|
|
done = half.done,
|
|
// Kept from both halves: a question or an image can be attached to either,
|
|
// depending on which side of the boundary its event fell.
|
|
asks = row.asks + half.asks,
|
|
images = row.images + half.images,
|
|
)
|
|
} else {
|
|
row
|
|
}
|
|
}
|
|
val kept = newer.filterNot { it is TranscriptItem.ToolRun && it.id in endedLater }
|
|
return adoptRun(healed, kept) + kept
|
|
}
|
|
|
|
/**
|
|
* Rejoins a message the page boundary cut, and hands back the two pages to concatenate.
|
|
*
|
|
* [foldEvent] never leaves two assistant messages next to each other inside one page -- deltas
|
|
* accumulate into the message before them -- so two meeting at a join are always the two halves of
|
|
* one reply, and leaving them apart drew a single answer as two, with a paragraph break through the
|
|
* middle of a sentence.
|
|
*
|
|
* The newer half keeps its identity, for the reason [adoptRun] gives: it is the row already on
|
|
* screen, and renaming that is how the list loses its anchor. It grows by what the older half
|
|
* brings, which is safe here and nowhere else -- the join is at the oldest end of what is loaded,
|
|
* so the growth extends off the top of the screen, away from the row the list anchors to.
|
|
*/
|
|
private fun healSplitMessage(
|
|
earlier: List<TranscriptItem>,
|
|
later: List<TranscriptItem>,
|
|
): Pair<List<TranscriptItem>, List<TranscriptItem>> {
|
|
val head = earlier.lastOrNull()
|
|
val tail = later.firstOrNull()
|
|
if (head !is TranscriptItem.AssistantMsg || tail !is TranscriptItem.AssistantMsg) {
|
|
return earlier to later
|
|
}
|
|
return earlier.dropLast(1) to (listOf(tail.copy(text = head.text + tail.text)) + later.drop(1))
|
|
}
|
|
|
|
/**
|
|
* Hands the older calls at the join the name of the run they are joining.
|
|
*
|
|
* The two pages were folded separately, so a run split by the boundary came back as two runs with
|
|
* two names. Naming the joined run after the *older* half would be the obvious way round and is the
|
|
* wrong one: the newer half is the part already on screen, and renaming it is renaming the row the
|
|
* reader is looking at, which is how a list loses its anchor and steps under them. So the arriving
|
|
* calls take the name of the ones already there, and nothing visible changes identity.
|
|
*/
|
|
private fun adoptRun(
|
|
earlier: List<TranscriptItem>,
|
|
later: List<TranscriptItem>,
|
|
): List<TranscriptItem> {
|
|
val first = later.firstOrNull() as? TranscriptItem.ToolRun ?: return earlier
|
|
// A question is in a run of its own on both sides of the join, the same as it would be had
|
|
// the two pages been folded as one -- see `runIdFor`. Without this the heal would merge a
|
|
// group straight through the row the reader was asked something on.
|
|
if (first.tool == ASK_USER_QUESTION) return earlier
|
|
val joining = first.runId
|
|
val tail = earlier.takeLastWhile {
|
|
it is TranscriptItem.ToolRun && it.tool != ASK_USER_QUESTION
|
|
}
|
|
if (tail.isEmpty()) return earlier
|
|
return earlier.dropLast(tail.size) +
|
|
tail.map { (it as TranscriptItem.ToolRun).copy(runId = joining) }
|
|
}
|
|
|
|
fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem> =
|
|
when (val event = entry.event) {
|
|
is SessionEvent.UserMessage ->
|
|
items + TranscriptItem.UserMsg(entry.seq, event.text, event.images)
|
|
is SessionEvent.AssistantText -> {
|
|
// Deltas accumulate into the message they're streaming, which keeps the seq of the
|
|
// first of them: a row whose identity changed with every delta would be a new row on
|
|
// every frame, and the list would jump for the whole of a streamed answer.
|
|
val last = items.lastOrNull()
|
|
if (last is TranscriptItem.AssistantMsg) {
|
|
items.dropLast(1) + last.copy(text = last.text + event.delta)
|
|
} else {
|
|
items + TranscriptItem.AssistantMsg(entry.seq, event.delta)
|
|
}
|
|
}
|
|
is SessionEvent.ToolStart ->
|
|
items +
|
|
TranscriptItem.ToolRun(
|
|
entry.seq,
|
|
event.id,
|
|
runIdFor(items, event.id, event.tool),
|
|
event.tool,
|
|
event.input,
|
|
"",
|
|
done = false,
|
|
)
|
|
is SessionEvent.ToolUpdate -> updateTool(items, event.id) { it.copy(output = event.output) }
|
|
is SessionEvent.ToolEnd ->
|
|
// Created when its start is not here, rather than dropped. A
|
|
// fold that only ever *updates* loses the whole call when the
|
|
// start fell outside the loaded window, and a tool call that
|
|
// renders as nothing is indistinguishable from one that never
|
|
// happened. The name is unknown from an end alone; loading the
|
|
// page before this one replaces the row with the real thing.
|
|
if (items.any { it is TranscriptItem.ToolRun && it.id == event.id }) {
|
|
updateTool(items, event.id) { it.copy(output = event.output, done = true) }
|
|
} else {
|
|
items +
|
|
TranscriptItem.ToolRun(
|
|
entry.seq,
|
|
event.id,
|
|
// The name is not known from an end alone, so a call that was an ask
|
|
// cannot be recognised as one here; loading the page before this
|
|
// replaces the row with the real thing, which is when it splits out.
|
|
runIdFor(items, event.id, "tool"),
|
|
"tool",
|
|
"",
|
|
event.output,
|
|
done = true,
|
|
)
|
|
}
|
|
is SessionEvent.Question -> {
|
|
val card =
|
|
TranscriptItem.QuestionCard(
|
|
entry.seq,
|
|
event.id,
|
|
event.prompt,
|
|
event.header,
|
|
event.options,
|
|
event.multiSelect,
|
|
emptyList(),
|
|
)
|
|
// A question with no tool behind it -- AskUserQuestion, or an ask
|
|
// whose call fell outside the loaded window -- is a card of its
|
|
// own, which is what every question was before this.
|
|
if (
|
|
event.about != null &&
|
|
items.any { it is TranscriptItem.ToolRun && it.id == event.about }
|
|
) {
|
|
updateTool(items, event.about) { it.copy(asks = it.asks + card) }
|
|
} else {
|
|
items + card
|
|
}
|
|
}
|
|
is SessionEvent.Answered ->
|
|
// Resolved wherever it is drawn: a card of its own, or a tool
|
|
// row's ask. Missing the second left an Allow/Deny pair live on
|
|
// a question already answered from another device.
|
|
items.map {
|
|
when {
|
|
it is TranscriptItem.QuestionCard && it.id == event.id ->
|
|
it.copy(answers = event.answers)
|
|
it is TranscriptItem.ToolRun && it.asks.any { ask -> ask.id == event.id } ->
|
|
it.copy(
|
|
asks =
|
|
it.asks.map { ask ->
|
|
if (ask.id == event.id) ask.copy(answers = event.answers)
|
|
else ask
|
|
}
|
|
)
|
|
else -> it
|
|
}
|
|
}
|
|
is SessionEvent.PeerMessage ->
|
|
items + TranscriptItem.PeerNote(entry.seq, event.from, event.text)
|
|
is SessionEvent.CommandSent -> items + TranscriptItem.CommandRow(entry.seq, event.text)
|
|
// Screen-level state, not transcript rows -- see SessionScreen.
|
|
is SessionEvent.CommandQueued -> items
|
|
// No row of its own: a message that is still waiting is drawn as a pending bubble below
|
|
// the transcript, and becomes an ordinary one where the session read it.
|
|
is SessionEvent.MessageQueued -> items
|
|
is SessionEvent.Settings -> items
|
|
is SessionEvent.Status -> items
|
|
is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message)
|
|
is SessionEvent.Image ->
|
|
// Under the call that produced it when there is one, and a row of
|
|
// its own when there is not -- a person's own attachment belongs
|
|
// to no call, and neither does one whose call fell outside the
|
|
// loaded window.
|
|
if (
|
|
event.about != null &&
|
|
items.any { it is TranscriptItem.ToolRun && it.id == event.about }
|
|
) {
|
|
updateTool(items, event.about) { it.copy(images = it.images + event.ref) }
|
|
} else {
|
|
items + TranscriptItem.ImageItem(entry.seq, event.ref)
|
|
}
|
|
is SessionEvent.Cleared -> items + TranscriptItem.ClearedNote(entry.seq)
|
|
is SessionEvent.Compacted ->
|
|
items + TranscriptItem.CompactedNote(entry.seq, event.preTokens, event.postTokens)
|
|
is SessionEvent.Unknown -> items + TranscriptItem.Note(entry.seq, "[${event.type}]")
|
|
// Screen-level state, not transcript rows -- see SessionScreen.
|
|
is SessionEvent.UsageDelta -> items
|
|
}
|
|
|
|
private fun updateTool(
|
|
items: List<TranscriptItem>,
|
|
id: String,
|
|
change: (TranscriptItem.ToolRun) -> TranscriptItem.ToolRun,
|
|
): List<TranscriptItem> = items.map {
|
|
if (it is TranscriptItem.ToolRun && it.id == id) change(it) else it
|
|
}
|
|
|
|
/**
|
|
* Parses the replies among [rows], off whatever thread is drawing.
|
|
*
|
|
* Called where a page of transcript is folded rather than where a row is composed, which is the
|
|
* whole point: the work happens seconds before the reader reaches the rows it was done for. See
|
|
* [ParsedReplies].
|
|
*/
|
|
private suspend fun warm(replies: ParsedReplies, rows: List<TranscriptItem>) {
|
|
val texts = rows.filterIsInstance<TranscriptItem.AssistantMsg>().flatMap { markdownIn(it.text) }
|
|
if (texts.isEmpty()) return
|
|
withContext(Dispatchers.Default) { replies.warm(texts) }
|
|
}
|
|
|
|
@Composable
|
|
fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () -> Unit) {
|
|
val scope = rememberCoroutineScope()
|
|
val topEdgeHeld = remember { TopEdgeHold() }
|
|
var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
|
|
var status by remember { mutableStateOf(summary.status) }
|
|
// Seeded from the row this screen was opened from, so a conversation already under way says
|
|
// how much it is holding before any turn happens here. Null is "nobody has measured it",
|
|
// which is a different answer from an empty context and is drawn differently.
|
|
var contextTokens by remember(summary.id) { mutableStateOf(summary.contextTokens) }
|
|
// When the current compaction started and how long ago that is. The moment comes off the
|
|
// `compacting` status event itself -- the server timestamps every transcript line -- rather
|
|
// than off this device noticing one, which is what makes it survive leaving the session and
|
|
// reopening it. See `compactingLabel`: null is still the honest answer for a session whose
|
|
// status was never reported as compacting at all.
|
|
var compactingSince by remember { mutableStateOf<Double?>(null) }
|
|
var compactingFor by remember { mutableStateOf<Long?>(null) }
|
|
var streamError by remember { mutableStateOf<String?>(null) }
|
|
var actionError by remember { mutableStateOf<String?>(null) }
|
|
// Whether the composer's process button has a request out. What it does next is decided from
|
|
// the session's status, and the status only changes once the server has answered and the
|
|
// stream has carried it back -- so two presses in that gap are two requests, both decided
|
|
// against the state before either of them. The server refuses the second one, but a control
|
|
// that can be pressed while its own last press is still in flight is asking to be.
|
|
var processInFlight by remember { mutableStateOf(false) }
|
|
val context = LocalContext.current
|
|
// Seeded from what was left in the box last time and written back on every keystroke, so
|
|
// leaving the screen -- or the system reclaiming the app -- does not throw away a half-typed
|
|
// message. See `Drafts.kt` for why this one piece of state is the device's rather than the
|
|
// server's.
|
|
var input by remember(summary.id) { mutableStateOf(loadDraft(context, summary.id)) }
|
|
// A model the reader has chosen and not yet confirmed. See [ModelSwitchWarning]: switching
|
|
// makes the session re-read the whole conversation, which is worth asking about first.
|
|
var pendingModel by remember { mutableStateOf<String?>(null) }
|
|
var expandedTools by remember { mutableStateOf(setOf<String>()) }
|
|
// Which runs of adjacent tool calls are open. Keyed by the first call's
|
|
// id, so a group survives more calls arriving after it.
|
|
var expandedGroups by remember { mutableStateOf(setOf<String>()) }
|
|
// Runs that have already been drawn as a group, so the transition into one is noticed exactly
|
|
// once. See the effect below.
|
|
var everGrouped by remember { mutableStateOf(setOf<String>()) }
|
|
// Which messages from other agents are open, by the seq that identifies their row. Closed
|
|
// by default, which is the rule for anything new in this transcript: a screen that opens
|
|
// everything it can is one nobody can scan.
|
|
var expandedNotes by remember { mutableStateOf(setOf<Long>()) }
|
|
// Uploaded-but-not-yet-sent attachment ids; sent with the next message.
|
|
var pendingAttachments by remember { mutableStateOf(listOf<String>()) }
|
|
// What this session is set to now, seeded from the row that opened it and
|
|
// then owned here, because changing either is something this screen does.
|
|
// The name shown at the top. Held here rather than read from the row that opened this
|
|
// screen, because renaming is something this screen can do -- through the settings below it,
|
|
// or by typing the command -- and a header still showing the old name reads as a rename that
|
|
// did not take.
|
|
var title by remember(summary.id) { mutableStateOf(summary.title) }
|
|
var model by remember { mutableStateOf(summary.model) }
|
|
var permissionMode by remember { mutableStateOf(summary.permissionMode ?: "auto") }
|
|
// The models this provider actually offers, asked of the server rather
|
|
// than listed here: a hardcoded list is a claim about a machine.
|
|
var offeredModels by remember { mutableStateOf<List<String>>(emptyList()) }
|
|
val lifecycleOwner = LocalLifecycleOwner.current
|
|
// The resume cursor, written from the stream's IO thread.
|
|
val lastSeq = remember { AtomicLong(0) }
|
|
val activeStream = remember { AtomicReference<EventStream?>(null) }
|
|
// The oldest sequence number loaded, and whether there is more behind
|
|
// it. Paging backwards is what keeps opening a long session cheap: the
|
|
// screen starts with the end of the conversation and fetches earlier
|
|
// pages only when somebody scrolls to them.
|
|
var oldestSeq by remember { mutableLongStateOf(0L) }
|
|
// Where this session was last being read, from this device's own store. Read once, because
|
|
// it is the question "where did I leave off" and the answer stops being interesting the
|
|
// moment the list is on screen.
|
|
val savedAnchor = remember(summary.id) { loadScrollAnchor(context, summary.id) }
|
|
// Whether the list is still being put back where it was left. Nothing is drawn while it is:
|
|
// opening at the newest end and then travelling to the anchor is exactly the journey
|
|
// `reverseLayout` exists to remove, and this transcript is not allowed to move under a reader.
|
|
var restoring by remember(summary.id) { mutableStateOf(savedAnchor != null) }
|
|
// Remembered, and only ever written when a scroll settles -- so it records where the reader
|
|
// last left the list, and an insertion cannot change the answer. Reading the live position
|
|
// instead looks right and is subtly wrong: a keyed list moves its anchor to keep the reader's
|
|
// content still, so by the time the new item can be observed the view is already one item
|
|
// away from the newest and reports itself as scrolled back. The message then never followed,
|
|
// which was visible as a compaction whose progress bar sat just off the bottom of the screen
|
|
// while the button that started it said it was running.
|
|
//
|
|
// Seeded from whether there is a position to go back to, so the correction it drives does not
|
|
// pull the list to the newest end before the restore has put it anywhere.
|
|
var followTail 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() }
|
|
val listState = rememberLazyListState()
|
|
// Whether the newest message is on screen right now. The list is laid out from the bottom
|
|
// (see the LazyColumn below), so "newest" is index 0 and being there is being at the start of
|
|
// it. This is what the jump-to-newest button watches: it is about what the reader can see.
|
|
//
|
|
// It is also the gate on everything the list draws -- see [record].
|
|
val atNewest by remember {
|
|
derivedStateOf {
|
|
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
|
|
}
|
|
}
|
|
// 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.
|
|
val rows = remember(items) { groupToolRuns(items) }
|
|
|
|
/**
|
|
* Everything the transcript list draws, from one event.
|
|
*
|
|
* Separate from [apply] because it is the half that is allowed to wait. The list anchors on the
|
|
* leading edge of its first visible item, which in this upside-down layout is that item's
|
|
* *bottom* -- so a row that grows pushes everything already on screen upwards, and the view
|
|
* travels toward the newest end without anybody scrolling. Measured against a reply streamed in
|
|
* four hundred pieces: scrolling back a screen and then waiting six seconds ended at the very
|
|
* bottom, forty lines further on than where it was left.
|
|
*
|
|
* Insertions were never the problem -- the list is keyed, so a row arriving at either end
|
|
* leaves the anchor where it is, and reading back through history while a session works has
|
|
* always been still. What cannot be allowed is a row that is already there changing height, and
|
|
* the one guarantee that covers every way that happens -- a reply streaming, a tool's output
|
|
* arriving, a queued bubble appearing above the anchor -- is to change nothing at all while
|
|
* somebody is reading further back.
|
|
*/
|
|
fun record(entry: SeqEvent) {
|
|
// The oldest event this view holds, which is what paging backwards
|
|
// starts from. Maintained here rather than by each loader: the
|
|
// first page and a stream reset both begin an empty view, and one
|
|
// of them getting it wrong is a transcript that will not scroll up.
|
|
if (oldestSeq == 0L) {
|
|
oldestSeq = entry.seq
|
|
moreHistory = entry.seq > 1L
|
|
}
|
|
val event = entry.event
|
|
// The message coming back is the session saying it has
|
|
// read it, so the bubble held below the indicator becomes
|
|
// the row `foldEvent` is about to add.
|
|
// Waiting, then read. Matched by id: the same message sent twice is two
|
|
// bubbles, and clearing by text would take away whichever matched first.
|
|
if (event is SessionEvent.MessageQueued) {
|
|
queued = queued + QueuedMessage(event.id, event.text, event.images)
|
|
}
|
|
if (event is SessionEvent.UserMessage) {
|
|
queued = queued.filterNot { it.id == event.id }
|
|
}
|
|
// Waiting, then gone: a command leaves this list when the session takes it,
|
|
// and the row it becomes is added by `foldEvent` in the same pass.
|
|
if (event is SessionEvent.CommandQueued) {
|
|
waitingCommands = waitingCommands + (event.id to event.text)
|
|
}
|
|
if (event is SessionEvent.CommandSent) {
|
|
waitingCommands = waitingCommands.filterNot { it.first == event.id }
|
|
}
|
|
items = foldEvent(items, entry)
|
|
}
|
|
|
|
/**
|
|
* One event, at the moment it arrives.
|
|
*
|
|
* What it says about the *session* -- running or not, which model, how many tokens -- lands
|
|
* immediately, because none of that is drawn in the list and freezing it would trade a
|
|
* transcript that jumps for a status row that lies. What it adds to the transcript goes through
|
|
* [record], which waits for the reader to be at the newest end.
|
|
*/
|
|
fun apply(entry: SeqEvent) {
|
|
lastSeq.set(entry.seq)
|
|
// Before the rest, and for every event rather than only the usage ones: a compaction and
|
|
// a clear move this as much as a turn does, which is the whole reason it is a fold and
|
|
// not a running total. See `contextAfter`.
|
|
contextTokens = contextAfter(contextTokens, entry.event)
|
|
when (val event = entry.event) {
|
|
// Nothing further: what it carries was folded into the context above, and what a
|
|
// turn cost is not something the transcript draws.
|
|
is SessionEvent.UsageDelta -> {}
|
|
else -> {
|
|
// What the session says it is set to now, which is the only thing that
|
|
// says it: picking from either menu asks, and the answer comes back here.
|
|
if (event is SessionEvent.Settings) {
|
|
event.model?.let { model = it }
|
|
event.permissionMode?.let { permissionMode = it }
|
|
}
|
|
if (event is SessionEvent.Status) {
|
|
// The event's own timestamp, so a compaction that began before this screen
|
|
// opened is timed from when it actually began. Timing it from the moment we
|
|
// arrived would report the wait as shorter than it was, in exactly the case
|
|
// somebody is asking about -- a compaction worth asking about is a long one.
|
|
compactingSince =
|
|
when {
|
|
event.state != "compacting" -> null
|
|
status == "compacting" -> compactingSince
|
|
else -> entry.ts
|
|
}
|
|
status = event.state
|
|
}
|
|
// In order, always: one late event recorded ahead of the backlog would fold a
|
|
// streamed delta into whatever row happened to be last by then.
|
|
if (atNewest && held.isEmpty()) record(entry) else held = held + entry
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Changes a row's height while the end the reader touched stays where it is.
|
|
*
|
|
* The transcript is laid out from the bottom, so every row's *bottom* edge is what the list
|
|
* holds still and all growth goes upward. That is what a tap in a row's lower half already
|
|
* gets, so it needs nothing: shut a group from the bar at its foot and what follows it does not
|
|
* move, which is what the reader is looking at down there. A tap in the upper half is the other
|
|
* case -- left alone it sends the heading under the reader's finger up off the screen and fills
|
|
* the space above it, so the calls appear on the far side of the control that produced them --
|
|
* and that one asks the row to hold its top edge instead.
|
|
*
|
|
* Which half decides it, rather than which control was pressed, so that everything that opens
|
|
* behaves the same way whether or not it happens to have a control at each end. A group has two
|
|
* and its heading and foot bar land in the halves they are already in; a single call is one
|
|
* card, and tapping low on an open one shuts it downward exactly as the bar does.
|
|
*
|
|
* The correction itself belongs to the measurement -- see [holdTopEdge].
|
|
*/
|
|
fun toggleAnchored(key: Any, row: RowBounds, at: Float, toggle: () -> Unit) {
|
|
if (at < row.middle) topEdgeHeld.key = key
|
|
toggle()
|
|
}
|
|
|
|
/**
|
|
* How many list items sit above every transcript row -- one while something is waiting, none
|
|
* otherwise.
|
|
*
|
|
* Read both here and by the `item` that draws that bubble, so a restored position and the list
|
|
* cannot disagree about what is at which index. Anything else added above the rows later
|
|
* belongs in this count.
|
|
*/
|
|
fun itemsAboveRows() = if (queued.isNotEmpty() || waitingCommands.isNotEmpty()) 1 else 0
|
|
|
|
/**
|
|
* Where the row named [key] sits in the list, or null when it is not loaded.
|
|
*
|
|
* Computed from `items` rather than from `rows` for the reason [loadOlderPage] gives.
|
|
*/
|
|
/** Everything the list draws, in items rather than in rows. See [itemsAboveRows]. */
|
|
fun listItemCount() = groupToolRuns(items).size + itemsAboveRows()
|
|
|
|
fun indexOfKey(key: String): Int? {
|
|
// The bubble is not a row, and it is above all of them.
|
|
if (key == QUEUED_KEY) return if (itemsAboveRows() > 0) 0 else null
|
|
val row = groupToolRuns(items).asReversed().indexOfFirst { it.key.toString() == key }
|
|
return if (row < 0) null else row + itemsAboveRows()
|
|
}
|
|
|
|
/**
|
|
* 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(): Boolean {
|
|
val older =
|
|
withContext(Dispatchers.IO) {
|
|
fetchTranscript(settings, summary.id, before = oldestSeq, limit = HISTORY_PAGE)
|
|
}
|
|
if (older.isEmpty()) {
|
|
moreHistory = false
|
|
return false
|
|
}
|
|
oldestSeq = older.first().seq
|
|
moreHistory = oldestSeq > 1L
|
|
// Folded oldest-first into a list of their own, then put in front: `foldEvent` merges
|
|
// streaming text into the item before it, so replaying an older page through the live
|
|
// list would glue it onto the newest message rather than its own.
|
|
var earlier = listOf<TranscriptItem>()
|
|
older.forEach { entry ->
|
|
if (entry.event !is SessionEvent.UsageDelta) {
|
|
earlier = foldEvent(earlier, entry)
|
|
}
|
|
}
|
|
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) {
|
|
// Whether the list ended up where the reader left it. False covers every way it did not
|
|
// -- no saved position, a row that is no longer in the transcript, a page that never
|
|
// arrived -- and all of them mean the same thing to the list: this is the newest end now,
|
|
// so follow it.
|
|
var restored = false
|
|
try {
|
|
val page = withContext(Dispatchers.IO) { fetchTranscript(settings, summary.id) }
|
|
page.forEach { apply(it) }
|
|
warm(replies, items)
|
|
// Then back where reading stopped. An anchor deeper than the newest page is exactly
|
|
// the one worth restoring -- somebody who read to the bottom has no anchor at all --
|
|
// and the cost was already paid on the way down there.
|
|
savedAnchor?.let { anchor ->
|
|
// Pages until the 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. Any row that is not the oldest
|
|
// is final. Landing a screen and a half out was what this cost.
|
|
var index = indexOfKey(anchor.key)
|
|
while (moreHistory && (index == null || index >= listItemCount() - 1)) {
|
|
if (!loadOlderPage()) break
|
|
index = indexOfKey(anchor.key)
|
|
}
|
|
// Both writes before this coroutine yields, so the list's first measurement is
|
|
// the one with every row in it *and* the requested position -- the list is drawn
|
|
// where it was left rather than drawn and then moved. `requestScrollToItem` is
|
|
// the form that is applied during a layout pass; see [holdTopEdge].
|
|
restoring = false
|
|
// A null index is a row that is no longer in the transcript -- a reset stream, or
|
|
// a session cleared from elsewhere.
|
|
index?.let {
|
|
listState.requestScrollToItem(it, anchor.offset)
|
|
restored = true
|
|
}
|
|
}
|
|
} catch (e: ApiException) {
|
|
// Not fatal: the stream below still replays from zero, which is
|
|
// slow but complete. Saying so beats silently showing nothing.
|
|
streamError = e.message
|
|
}
|
|
if (!restored) followTail = true
|
|
// Whatever happened above, including a page that never arrived: an empty transcript is a
|
|
// state the screen can draw, and a permanently blank one is not.
|
|
restoring = false
|
|
ready = true
|
|
}
|
|
|
|
// Only while the screen is actually on screen. Android stops the
|
|
// activity when somebody switches away, and the socket dies with it --
|
|
// which arrived as "Lost the event stream (SocketTimeoutException)"
|
|
// waiting at the top on their return. Switching apps is a choice
|
|
// somebody made, not a fault to report, and reconnecting on a phone
|
|
// that has been backgrounded is work nobody is watching. Stopping the
|
|
// stream deliberately makes the drop a close rather than an error (see
|
|
// EventStream.close), and resuming reconnects from the same cursor.
|
|
LaunchedEffect(summary.id, ready, lifecycleOwner) {
|
|
if (!ready) return@LaunchedEffect
|
|
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
|
try {
|
|
while (true) {
|
|
val stream = EventStream(settings, summary.id)
|
|
activeStream.set(stream)
|
|
try {
|
|
withContext(Dispatchers.IO) {
|
|
stream.run(
|
|
after = lastSeq.get(),
|
|
// Connected, measured rather than inferred: this is what
|
|
// takes a failure off the screen, and nothing else does.
|
|
// Clearing on the first event instead meant an idle
|
|
// session kept displaying an error it had recovered from.
|
|
onOpen = { streamError = null },
|
|
onReset = {
|
|
// Too far behind to continue from: what is on
|
|
// screen is a stale prefix of a conversation
|
|
// that has moved on, and the window arriving
|
|
// next is not adjacent to it. Dropping the rows
|
|
// is what makes this the same as opening the
|
|
// screen -- `apply` refills them, and scrolling
|
|
// up pages the rest back in as it always does.
|
|
items = listOf()
|
|
replies.clear()
|
|
held = listOf()
|
|
oldestSeq = 0L
|
|
moreHistory = true
|
|
},
|
|
) { entry ->
|
|
apply(entry)
|
|
}
|
|
}
|
|
} catch (e: ApiException) {
|
|
streamError = e.message
|
|
} finally {
|
|
stream.close()
|
|
}
|
|
delay(RECONNECT_DELAY_MS)
|
|
}
|
|
} finally {
|
|
// Cancellation -- going below STARTED, or leaving the screen --
|
|
// cannot interrupt a blocking socket read. Closing is what
|
|
// unblocks it, and what marks the drop deliberate.
|
|
activeStream.getAndSet(null)?.close()
|
|
}
|
|
}
|
|
}
|
|
// The screen going away entirely, which the lifecycle scope above does
|
|
// not cover: a composable can leave the composition while the activity
|
|
// stays started.
|
|
DisposableEffect(summary.id) { onDispose { activeStream.get()?.close() } }
|
|
|
|
// Nothing gets announced about the session somebody is reading; see NotificationService.
|
|
// RESUMED rather than STARTED because "looking at it" means the foreground -- a session left
|
|
// on this screen behind another app is one whose notifications are still wanted, and STARTED
|
|
// covers that case too.
|
|
LaunchedEffect(summary.id, lifecycleOwner) {
|
|
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
|
NotificationService.showing(context, summary.id)
|
|
try {
|
|
awaitCancellation()
|
|
} finally {
|
|
NotificationService.stoppedShowing(summary.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Back at the newest end, so the backlog [apply] held can land. Everything at once rather
|
|
// than paced out: they are at the bottom, which is the one place the list is allowed to
|
|
// follow new content, and drip-feeding it would only make that following last longer.
|
|
LaunchedEffect(listState) {
|
|
snapshotFlow { atNewest && held.isNotEmpty() }
|
|
.collect { due ->
|
|
if (!due) return@collect
|
|
val backlog = held
|
|
held = listOf()
|
|
backlog.forEach { record(it) }
|
|
}
|
|
}
|
|
// Whether they *chose* to be at the newest end, which is a different question from being
|
|
// there and the one that decides whether an arriving message brings the view with it. Written
|
|
// where a scroll settles, below.
|
|
LaunchedEffect(listState) {
|
|
snapshotFlow { listState.isScrollInProgress }
|
|
// The value `snapshotFlow` emits on collection is the state of things before anybody
|
|
// has touched the list, and it is `false` -- which reads here as a scroll that has
|
|
// just ended at the newest end, and so as an instruction to forget where the reader
|
|
// was. That wiped every saved position on the way in, before the restore below could
|
|
// use it. Only the transitions after it are scrolls.
|
|
.drop(1)
|
|
.collect { scrolling ->
|
|
if (scrolling) return@collect
|
|
followTail = atNewest
|
|
// Written where the answer settles, for the same reason [followTail] is: mid-fling
|
|
// is not where anybody left off. Cleared at the newest end rather than recorded,
|
|
// because that is where a session with nothing to restore opens anyway -- so the
|
|
// ordinary case costs a `remove` and no page-back on the way in.
|
|
saveScrollAnchor(
|
|
context,
|
|
summary.id,
|
|
if (atNewest) null
|
|
else
|
|
listState.layoutInfo.visibleItemsInfo.firstOrNull()?.let { first ->
|
|
ScrollAnchor(
|
|
first.key.toString(),
|
|
listState.firstVisibleItemScrollOffset,
|
|
)
|
|
},
|
|
)
|
|
}
|
|
}
|
|
// A new item at the newest end shifts every index by one, so the view
|
|
// has to step back to 0 to stay put. One item, instantly -- not a
|
|
// journey through the transcript.
|
|
// Anything that changes how much room the list has, as well as a new
|
|
// item arriving. Typing is the case that gets missed: the field grows
|
|
// from one line to four and the keyboard opens under it, and neither
|
|
// is a new message, so watching the item count alone leaves the newest
|
|
// text drifting out of sight while somebody writes a reply to it.
|
|
//
|
|
// Counted in list items rather than in transcript rows, because the rows are not all of it:
|
|
// the working indicator and a queued message are items too, and they arrive at exactly the
|
|
// same end. Sibling to the paging trigger below, which is the same count read from the other
|
|
// end for the same reason.
|
|
//
|
|
// Never while a scroll is running, and that is a rule of its own rather than a refinement of
|
|
// the condition beside it: a list must not be moved out from under a hand that is moving it.
|
|
// The two disagree because [followTail] is deliberately a *remembered* answer, rewritten only
|
|
// when a scroll settles -- so for the whole of a fling it still reports the newest end, where
|
|
// the reader was when they threw it. A page of history landing during that fling is a change
|
|
// in the count, and the correction meant for an insertion at the newest end then fired for
|
|
// one at the oldest: the reader was thrown back to the bottom mid-flight. It could happen
|
|
// only once, which is what made it look arbitrary rather than mechanical -- the snap settles
|
|
// the scroll at the newest end, so the next fling gets far enough to settle away from it, and
|
|
// from then on [followTail] is false and nothing fires. Skipping the correction outright is
|
|
// right rather than merely safe: the count can only have grown at the newest end while the
|
|
// reader is already there, because [record] holds everything else until they come back.
|
|
LaunchedEffect(listState) {
|
|
snapshotFlow {
|
|
Pair(listState.layoutInfo.totalItemsCount, listState.layoutInfo.viewportSize.height)
|
|
}
|
|
.collect { (count, _) ->
|
|
if (followTail && !listState.isScrollInProgress && count > 0) {
|
|
listState.scrollToItem(0)
|
|
}
|
|
}
|
|
}
|
|
// Reaching the far end of what is loaded -- the oldest item, which in
|
|
// this layout is the last index -- fetches the page before it.
|
|
//
|
|
// Both numbers come from the list itself, and that is the point: an index into what is drawn
|
|
// can only be compared against how much is drawn. Three things already make that differ from
|
|
// the event count -- a run of adjacent tool calls is one row, and the queued bubble and the
|
|
// working indicator are rows with no event behind them at all -- so measuring the far end in
|
|
// events meant the threshold could not be reached, and a session with tool calls in it simply
|
|
// stopped scrolling back. Anything added to this list later is a fourth, and totalItemsCount
|
|
// already counts it.
|
|
LaunchedEffect(listState, rows.size, moreHistory) {
|
|
snapshotFlow {
|
|
val layout = listState.layoutInfo
|
|
Pair(layout.visibleItemsInfo.lastOrNull()?.index ?: 0, layout.totalItemsCount)
|
|
}
|
|
.collect { (last, total) ->
|
|
if (!moreHistory || loadingHistory || total == 0) return@collect
|
|
if (last < total - HISTORY_LOOKAHEAD) return@collect
|
|
loadingHistory = true
|
|
try {
|
|
// Pages until there are rows behind them again, not one page and stop.
|
|
//
|
|
// A page is eighty *events*, and eighty events are routinely one row: a
|
|
// reply arrives as hundreds of text deltas that fold into a single message.
|
|
// So a page that lands can leave the far end exactly where it was -- and
|
|
// since this is triggered by the far end moving, nothing asks for the next
|
|
// one. The list then only loads when somebody drags it again, a page at a
|
|
// time, which is what "it only loads when you touch the top" was.
|
|
// Counted from `items` rather than from `rows`, which is the
|
|
// composition's value and does not change under a running coroutine.
|
|
val start = groupToolRuns(items).size
|
|
var have = start
|
|
while (moreHistory && have - start < HISTORY_LOOKAHEAD && loadOlderPage()) {
|
|
have = groupToolRuns(items).size
|
|
}
|
|
} catch (_: ApiException) {
|
|
// Leave `moreHistory` alone: the next scroll asks again.
|
|
} finally {
|
|
loadingHistory = false
|
|
}
|
|
}
|
|
}
|
|
|
|
LaunchedEffect(summary.setupName, summary.provider) {
|
|
offeredModels =
|
|
try {
|
|
withContext(Dispatchers.IO) {
|
|
fetchSetups(settings)
|
|
.firstOrNull { it.name == summary.setupName }
|
|
?.providers
|
|
?.firstOrNull { it.name == summary.provider }
|
|
?.models
|
|
.orEmpty()
|
|
}
|
|
} catch (_: Exception) {
|
|
// Not worth reporting: the picker simply has nothing to
|
|
// offer, which is visible, and the session is unaffected.
|
|
emptyList()
|
|
}
|
|
}
|
|
|
|
fun act(onFailure: () -> Unit = {}, onDone: () -> Unit = {}, action: () -> Unit) {
|
|
scope.launch {
|
|
try {
|
|
withContext(Dispatchers.IO) { action() }
|
|
actionError = null
|
|
} catch (e: ApiException) {
|
|
actionError = e.message
|
|
onFailure()
|
|
} finally {
|
|
// Whatever happened, including the failure above: a caller that re-enables a
|
|
// control here must get it back on the path where the request was refused too,
|
|
// or the refusal is what disables the control permanently.
|
|
onDone()
|
|
}
|
|
}
|
|
}
|
|
|
|
fun send() {
|
|
val text = input.trim()
|
|
val attachments = pendingAttachments
|
|
if (text.isEmpty() && attachments.isEmpty()) return
|
|
// A command is not a message: it is an instruction to the session about itself, and one
|
|
// written into a running turn is read by the model instead. The server holds it until the
|
|
// turn ends and says so, which is where its waiting bubble comes from -- so nothing is
|
|
// held here, and there is no local guess to correct when the answer arrives.
|
|
if (text.startsWith("/") && attachments.isEmpty()) {
|
|
input = ""
|
|
saveDraft(context, summary.id, "")
|
|
// The one command with a visible effect outside the transcript, applied when the
|
|
// server has accepted it rather than when it was typed: the name is this app's own
|
|
// datum and changes at once, and only telling the session waits for a boundary.
|
|
val renamed =
|
|
text.removePrefix("/rename ").trim().takeIf {
|
|
text.startsWith("/rename ") && it.isNotEmpty()
|
|
}
|
|
act {
|
|
runCommand(settings, summary.id, text)
|
|
renamed?.let { title = it }
|
|
}
|
|
return
|
|
}
|
|
input = ""
|
|
saveDraft(context, summary.id, "")
|
|
pendingAttachments = emptyList()
|
|
// Nothing is added here. The server says what is waiting -- it emits `messageQueued`
|
|
// when it takes a message it cannot deliver yet -- and this screen draws that. Holding a
|
|
// local copy as well was the bug: the two agreed only until the app was restarted or the
|
|
// session left, and then the screen showed nothing pending while the queue was full.
|
|
act { sendMessage(settings, summary.id, text, attachments) }
|
|
}
|
|
|
|
// The system photo picker; the image uploads as soon as it's chosen,
|
|
// so Send only has ids to reference.
|
|
val pickImage =
|
|
rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
|
|
if (uri != null) {
|
|
scope.launch {
|
|
try {
|
|
val id =
|
|
withContext(Dispatchers.IO) {
|
|
// Shrunk to what this session's provider takes before it is
|
|
// uploaded, so a twelve-megapixel photo does not cross the tunnel
|
|
// to be rejected at the far end -- see `uploadPickedImage`.
|
|
uploadPickedImage(
|
|
context,
|
|
settings,
|
|
summary.id,
|
|
uri,
|
|
summary.maxImageEdge,
|
|
)
|
|
}
|
|
pendingAttachments = pendingAttachments + id
|
|
actionError = null
|
|
} catch (e: ApiException) {
|
|
actionError = e.message
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// One poll for this machine's limits, read by the two things that show them: the bar under
|
|
// the header, and the colour of the button that opens the dialog.
|
|
val usage = rememberSessionUsage(settings, summary.setup)
|
|
var usageOpen by remember { mutableStateOf(false) }
|
|
var settingsOpen by remember { mutableStateOf(false) }
|
|
|
|
Column(Modifier.fillMaxSize()) {
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
|
|
) {
|
|
GlyphButton(BACK_GLYPH, "Back", onBack)
|
|
Spacer(Modifier.width(8.dp))
|
|
Column(Modifier.weight(1f)) {
|
|
Text(title, style = MaterialTheme.typography.titleMedium)
|
|
// Machine first, then what runs on it -- the same order and the same wording
|
|
// everywhere this pair appears, so it reads as one fact rather than as two
|
|
// sentences with different grammar. The "on" that used to sit in the middle
|
|
// made it a phrase, which only works in one order and stops working the moment
|
|
// the pair is shown anywhere else.
|
|
//
|
|
// No model. The picker in the footer already shows what this session is set to,
|
|
// and showing it twice means two things to keep in step -- they disagreed for a
|
|
// moment on every model change, since one follows the request and the other the
|
|
// session's own answer.
|
|
Text(
|
|
"${summary.setupName} · ${summary.provider}",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
// Beside the provider it reports on, which is the line directly to its left.
|
|
//
|
|
// Its real home is this provider's settings, which do not exist yet; until they do,
|
|
// the session is the only place the provider is already named, so it is the only
|
|
// place the button can sit without inventing a scope for itself. What it shows is
|
|
// the paid service's own numbers, so a session on a provider with no such service
|
|
// gets an honest "unavailable" rather than a hidden button -- a control that comes
|
|
// and goes makes its absence the signal, and absence cannot say why.
|
|
// Coloured by the worst window behind it, so the row says whether the limits are
|
|
// worth opening before anybody opens them. Blue at every ordinary level and only
|
|
// yellow or red near a limit -- and the theme's plain control colour whenever there
|
|
// is no measurement, since blue is the low end of the scale here and would read as
|
|
// "checked, and fine" about a machine nobody could reach.
|
|
Row(horizontalArrangement = Arrangement.spacedBy(GLYPH_BUTTON_GAP)) {
|
|
GlyphButton(
|
|
USAGE_GLYPH,
|
|
"Usage",
|
|
{ usageOpen = true },
|
|
colour = usageGlyphColour(usage),
|
|
)
|
|
// 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),
|
|
)
|
|
}
|
|
|
|
// Laid out from the bottom, with the newest message at index 0.
|
|
//
|
|
// The obvious arrangement -- oldest first, then scroll to the end
|
|
// -- opens at the top and travels the whole transcript to get
|
|
// where it belongs. On an imported session that is nine hundred
|
|
// items measured before anything is readable, seen as the view
|
|
// visibly racing downward every time it opened.
|
|
//
|
|
// Anchoring at the bottom removes the journey rather than hiding
|
|
// it: the first frame is already the newest message, and older
|
|
// ones are composed only as somebody scrolls back to them, which
|
|
// is also what makes history cheap on a long conversation.
|
|
// Empty until a saved position has been put back -- see the opening effect. Held out of
|
|
// the list rather than drawn and scrolled, so there is no frame in which the transcript is
|
|
// somewhere other than where it was left.
|
|
val drawnRows = if (restoring) emptyList() else rows.asReversed()
|
|
Box(Modifier.weight(1f).fillMaxWidth()) {
|
|
LazyColumn(
|
|
state = listState,
|
|
reverseLayout = true,
|
|
modifier = Modifier.fillMaxSize(),
|
|
contentPadding = PaddingValues(16.dp),
|
|
// Bottom, and it has to be said: `reverseLayout` defaults the arrangement to
|
|
// `Bottom` on its own, but naming `spacedBy` replaces that default with
|
|
// `spacedBy`'s own, which is `Top`. The arrangement is what places the content
|
|
// when there is less of it than the viewport -- so a session whose loaded rows
|
|
// did not fill the screen drew them against the *top*, leaving a gap between the
|
|
// newest message and the box you type in, and no room to scroll the gap away.
|
|
// Opening the keyboard shrank the viewport enough for the content to overflow it
|
|
// and the list snapped down, which is what made it look like a scrolling fault
|
|
// rather than a placement one.
|
|
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.Bottom),
|
|
) {
|
|
// 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 (!restoring && itemsAboveRows() > 0) {
|
|
item(key = QUEUED_KEY) {
|
|
Column(horizontalAlignment = Alignment.End) {
|
|
waitingCommands.forEach { (_, text) ->
|
|
CommandBubble(text, waiting = true)
|
|
}
|
|
queued.forEach { waiting ->
|
|
UserBubble(
|
|
settings = settings,
|
|
sessionId = summary.id,
|
|
text = waiting.text,
|
|
images = waiting.images,
|
|
pending = true,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Reversed to match the layout, so index 0 is the newest and
|
|
// the reader still sees them in the order they happened.
|
|
// Grouped first: adjacent tool calls collapse into one row,
|
|
// which is a decision about this screen and not about the
|
|
// transcript the stream and paging share.
|
|
// Keyed, and this is what stops the list moving under whoever is reading
|
|
// it. Every new message is an insertion at index 0 here, so without a key the
|
|
// rows keep their positions and the content slides through them -- which looks
|
|
// exactly like the view scrolling by itself. The keys above matter for the same
|
|
// reason: the working indicator appearing and disappearing is another insertion
|
|
// at the same end. Paging older history is the opposite insertion and was
|
|
// already fine, and stays fine, because a key survives both.
|
|
items(drawnRows, key = { it.key }) { row ->
|
|
val bounds = remember { RowBounds() }
|
|
Box(
|
|
Modifier.onGloballyPositioned {
|
|
bounds.top = it.positionInRoot().y
|
|
bounds.height = it.size.height.toFloat()
|
|
}
|
|
.holdTopEdge(row.key, topEdgeHeld) { grew ->
|
|
// Requested rather than scrolled. Scrolling forces a remeasure,
|
|
// and forcing one from inside a measure throws; this is the form
|
|
// built to be asked for during layout and applied in that pass.
|
|
listState.requestScrollToItem(
|
|
listState.firstVisibleItemIndex,
|
|
listState.firstVisibleItemScrollOffset + grew,
|
|
)
|
|
}
|
|
) {
|
|
when (row) {
|
|
is TranscriptRow.Tools ->
|
|
ToolGroup(
|
|
group = row,
|
|
expanded = row.id in expandedGroups,
|
|
onToggle = { at ->
|
|
toggleAnchored(row.key, bounds, at) {
|
|
expandedGroups =
|
|
if (row.id in expandedGroups)
|
|
expandedGroups - row.id
|
|
else expandedGroups + row.id
|
|
}
|
|
},
|
|
isToolExpanded = { it in expandedTools },
|
|
// Anchored on the group, not the call: opening one call makes
|
|
// the whole group taller, and the heading the reader is under
|
|
// is the group's.
|
|
onToolToggle = { id, at ->
|
|
toggleAnchored(row.key, bounds, at) {
|
|
expandedTools =
|
|
if (id in expandedTools) expandedTools - id
|
|
else expandedTools + id
|
|
}
|
|
},
|
|
onAnswer = { questionId, answers ->
|
|
act {
|
|
answerQuestion(
|
|
settings,
|
|
summary.id,
|
|
questionId,
|
|
answers,
|
|
)
|
|
}
|
|
},
|
|
image = { ref -> SessionImage(settings, summary.id, ref) },
|
|
)
|
|
is TranscriptRow.Single ->
|
|
when (val item = row.item) {
|
|
is TranscriptItem.UserMsg ->
|
|
UserBubble(
|
|
settings = settings,
|
|
sessionId = summary.id,
|
|
text = item.text,
|
|
images = item.images,
|
|
)
|
|
is TranscriptItem.AssistantMsg ->
|
|
AssistantMessage(item.text, replies)
|
|
is TranscriptItem.ToolRun ->
|
|
ToolCard(
|
|
tool = item,
|
|
expanded = item.id in expandedTools,
|
|
onToggle = { at ->
|
|
toggleAnchored(row.key, bounds, at) {
|
|
expandedTools =
|
|
if (item.id in expandedTools)
|
|
expandedTools - item.id
|
|
else expandedTools + item.id
|
|
}
|
|
},
|
|
onAnswer = { questionId, answers ->
|
|
act {
|
|
answerQuestion(
|
|
settings,
|
|
summary.id,
|
|
questionId,
|
|
answers,
|
|
)
|
|
}
|
|
},
|
|
image = { ref ->
|
|
SessionImage(settings, summary.id, ref)
|
|
},
|
|
)
|
|
is TranscriptItem.QuestionCard ->
|
|
QuestionRow(item) { answers ->
|
|
act {
|
|
answerQuestion(
|
|
settings,
|
|
summary.id,
|
|
item.id,
|
|
answers,
|
|
)
|
|
}
|
|
}
|
|
is TranscriptItem.ErrorMsg ->
|
|
Text(
|
|
item.message,
|
|
color = MaterialTheme.colorScheme.error,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
)
|
|
is TranscriptItem.ImageItem ->
|
|
SessionImage(settings, summary.id, item.ref)
|
|
is TranscriptItem.Note ->
|
|
Text(
|
|
item.text,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
is TranscriptItem.CommandRow -> CommandBubble(item.text)
|
|
is TranscriptItem.ClearedNote -> ClearedRow()
|
|
is TranscriptItem.CompactedNote -> CompactedRow(item)
|
|
is TranscriptItem.PeerNote ->
|
|
PeerMessageRow(
|
|
item = item,
|
|
expanded = item.seq in expandedNotes,
|
|
replies = replies,
|
|
onToggle = { at ->
|
|
toggleAnchored(row.key, bounds, at) {
|
|
expandedNotes =
|
|
if (item.seq in expandedNotes)
|
|
expandedNotes - item.seq
|
|
else expandedNotes + item.seq
|
|
}
|
|
},
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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. The
|
|
// list is keyed and composes only what it lands on, so going straight there
|
|
// costs the same from anywhere.
|
|
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),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
pendingModel?.let { chosen ->
|
|
ModelSwitchWarning(
|
|
from = modelLabel(model),
|
|
to = modelLabel(chosen),
|
|
onDismiss = { pendingModel = null },
|
|
onConfirm = {
|
|
pendingModel = null
|
|
act { setSessionModel(settings, summary.id, chosen) }
|
|
},
|
|
)
|
|
}
|
|
|
|
SessionStatusRow(
|
|
status = status,
|
|
compactingFor = compactingFor,
|
|
contextTokens = contextTokens,
|
|
)
|
|
|
|
// Between the transcript and the box: above what is being typed, so the list does not
|
|
// cover the thing the command is about, and below everything that explains it.
|
|
CommandSuggestions(
|
|
commands = suggestedCommands(input),
|
|
onPick = { command -> input = command.typed() },
|
|
)
|
|
|
|
// Always enabled -- a send while the session is running becomes a
|
|
// steering message injected at the next tool boundary, which is
|
|
// the point of the whole app.
|
|
//
|
|
// The field gets a row of its own, above the buttons: sharing one
|
|
// put the full width behind three controls, so the thing being
|
|
// typed into was the narrowest thing on the row.
|
|
Column(Modifier.fillMaxWidth().padding(8.dp)) {
|
|
// Directly above the box they will be sent from, so what is attached is visible
|
|
// rather than counted: the "+2" on the button below said how many and never which.
|
|
PendingAttachments(
|
|
settings = settings,
|
|
sessionId = summary.id,
|
|
refs = pendingAttachments,
|
|
onRemove = { pendingAttachments = pendingAttachments - it },
|
|
)
|
|
OutlinedTextField(
|
|
value = input,
|
|
onValueChange = {
|
|
input = it
|
|
saveDraft(context, summary.id, it)
|
|
},
|
|
modifier = Modifier.fillMaxWidth(),
|
|
// No longer "(+image)": the images are on screen above this, and a placeholder
|
|
// saying so said it in words beside the thing itself.
|
|
placeholder = { Text("Message") },
|
|
maxLines = 4,
|
|
)
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
TextButton(
|
|
onClick = {
|
|
pickImage.launch(
|
|
PickVisualMediaRequest(
|
|
ActivityResultContracts.PickVisualMedia.ImageOnly
|
|
)
|
|
)
|
|
}
|
|
) {
|
|
// Just "+" now. The count was standing in for showing them.
|
|
Text("+")
|
|
}
|
|
// The settings share what is left after the actions have
|
|
// taken what they need. A Row hands out intrinsic widths in
|
|
// order and clips whatever runs past the edge, so with
|
|
// these laid out first the arrival of Stop pushed Send off
|
|
// the screen entirely -- the app's central control, gone at
|
|
// exactly the moment the app is most in use.
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.weight(1f),
|
|
) {
|
|
if (offeredModels.isNotEmpty()) {
|
|
PickerButton(
|
|
current = modelLabel(model),
|
|
// What the machine offers, plus the state a session is in when it
|
|
// has chosen none of them. The button has always been able to say
|
|
// "default"; until this the list could not, so leaving it was a
|
|
// one-way trip.
|
|
options = listOf(DEFAULT_MODEL) + offeredModels,
|
|
// Not set here. The button follows what the session reports it
|
|
// is set to, which arrives a moment later and is sometimes a
|
|
// different answer -- a name the CLI resolved, or no change at all
|
|
// on a provider whose model is fixed when it starts.
|
|
// Asked about first, unless there is nothing to lose by it --
|
|
// see [ModelSwitchWarning].
|
|
onPick = { chosen ->
|
|
if (modelLabel(chosen) == modelLabel(model) || items.isEmpty()) {
|
|
act { setSessionModel(settings, summary.id, chosen) }
|
|
} else {
|
|
pendingModel = chosen
|
|
}
|
|
},
|
|
)
|
|
}
|
|
PickerButton(
|
|
current = permissionMode,
|
|
options = PERMISSION_MODES,
|
|
onPick = { chosen ->
|
|
act { setSessionPermissionMode(settings, summary.id, chosen) }
|
|
},
|
|
)
|
|
}
|
|
// The same filled shape as the button beside it, not an outlined one: these are
|
|
// two things you can do about the session, and weighting one of them as secondary
|
|
// said they were a primary action and its qualifier. What separates them is the
|
|
// colour and the mark, which is what they mean.
|
|
//
|
|
// Always here, rather than arriving with the turn as it used to. A control that
|
|
// comes and goes makes its own presence the signal, and its absence could not say
|
|
// whether there was nothing to do; a button that is always in the same place also
|
|
// cannot push Send off the end of the row by turning up.
|
|
val process =
|
|
when {
|
|
running -> ProcessAction.Pause
|
|
status == "exited" -> ProcessAction.Start
|
|
else -> ProcessAction.Stop
|
|
}
|
|
Button(
|
|
onClick = {
|
|
processInFlight = true
|
|
act(onDone = { processInFlight = false }) {
|
|
process.perform(settings, summary.id)
|
|
}
|
|
},
|
|
enabled = !processInFlight,
|
|
colors = actionButtonColors(process.colour()),
|
|
) {
|
|
Glyph(
|
|
process.glyph,
|
|
colour = LocalContentColor.current,
|
|
modifier = Modifier.semantics { contentDescription = process.label },
|
|
)
|
|
}
|
|
Spacer(Modifier.width(8.dp))
|
|
// The paper plane, with a clock on it while a turn is in flight: sending then
|
|
// queues the message for the next tool boundary rather than starting a turn of
|
|
// its own, and the two have to be told apart at a glance. The label says the same
|
|
// thing to a screen reader, which has nothing else to read.
|
|
//
|
|
// Disabled while there is nothing to send, rather than pressable and silent:
|
|
// `send` has always returned early on an empty composer, so the button promised
|
|
// something it would not do, and the only feedback was the ripple. Disabled and
|
|
// not hidden, for the reason the button beside it is always here.
|
|
Button(
|
|
onClick = { send() },
|
|
enabled = input.isNotBlank() || pendingAttachments.isNotEmpty(),
|
|
colors = actionButtonColors(if (running) queueColor else sendColor),
|
|
) {
|
|
Glyph(
|
|
if (running) QUEUE_GLYPH else SEND_GLYPH,
|
|
colour = LocalContentColor.current,
|
|
modifier = Modifier.semantics { contentDescription = sendLabel(running) },
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (usageOpen) {
|
|
UsageDialog(settings = settings, onDismiss = { usageOpen = false })
|
|
}
|
|
if (settingsOpen) {
|
|
SessionSettingsDialog(
|
|
settings = settings,
|
|
sessionId = summary.id,
|
|
title = title,
|
|
// The header takes the new name at once and the dialog closes on it, because the
|
|
// rename has already been accepted by the server -- see [title], which is this app's
|
|
// own datum. The list behind this refetches on the way out of the session anyway.
|
|
onRenamed = {
|
|
title = it
|
|
settingsOpen = false
|
|
},
|
|
onDismiss = { settingsOpen = false },
|
|
)
|
|
}
|
|
}
|
|
|
|
/** What pressing Send does right now, said the same way to the eye and to a screen reader. */
|
|
private fun sendLabel(running: Boolean) = if (running) "Queue" else "Send"
|
|
|
|
/**
|
|
* What the composer's process button would do if it were pressed now.
|
|
*
|
|
* One value rather than four parallel conditions over the status, because the mark, the colour, the
|
|
* name a screen reader is given and the request that goes out are four halves of one decision. A
|
|
* button drawn as a pause that terminates the CLI is the worst bug available here, and separate
|
|
* branches over the same condition are how that happens -- these three each have to cover every
|
|
* case, and the compiler says so.
|
|
*/
|
|
private enum class ProcessAction(val glyph: String, val label: String) {
|
|
/** A turn is running: take it back, and leave the process holding the conversation. */
|
|
Pause(PAUSE_GLYPH, "Pause"),
|
|
/** Nothing is running, but the process behind the session is: end it. */
|
|
Stop(STOP_GLYPH, "Stop"),
|
|
/** The process is gone: start it again, on the conversation it left. */
|
|
Start(PLAY_GLYPH, "Start"),
|
|
}
|
|
|
|
@Composable
|
|
private fun ProcessAction.colour() =
|
|
when (this) {
|
|
ProcessAction.Pause -> pauseColor
|
|
ProcessAction.Stop -> stopColor
|
|
ProcessAction.Start -> startColor
|
|
}
|
|
|
|
private fun ProcessAction.perform(settings: ServerSettings, sessionId: String) =
|
|
when (this) {
|
|
ProcessAction.Pause -> interruptSession(settings, sessionId)
|
|
ProcessAction.Stop -> stopSession(settings, sessionId)
|
|
ProcessAction.Start -> startSession(settings, sessionId)
|
|
}
|
|
|
|
/**
|
|
* An inline transcript image, fetched (authenticated, pinned) from the session's files route. The
|
|
* bitmap is remembered per ref, so scrolling doesn't refetch.
|
|
*/
|
|
@Composable
|
|
private fun UserBubble(
|
|
settings: ServerSettings,
|
|
sessionId: String,
|
|
text: String,
|
|
images: List<String> = emptyList(),
|
|
pending: Boolean = false,
|
|
) {
|
|
Box(Modifier.fillMaxWidth()) {
|
|
Card(
|
|
// A message the session has not read yet is drawn quieter than
|
|
// one it has. The difference is in degree -- said, not yet
|
|
// heard -- which is what colour alone can carry; where it sits
|
|
// is what says the rest.
|
|
colors =
|
|
CardDefaults.cardColors(
|
|
containerColor =
|
|
if (pending) MaterialTheme.colorScheme.surfaceVariant
|
|
else MaterialTheme.colorScheme.primaryContainer
|
|
),
|
|
modifier = Modifier.align(Alignment.CenterEnd).padding(start = 48.dp),
|
|
) {
|
|
Column(Modifier.padding(12.dp)) {
|
|
// A message can be nothing but an attachment, and an empty line above a picture
|
|
// is a bubble with a gap in it for a sentence nobody wrote.
|
|
if (text.isNotEmpty()) {
|
|
Text(
|
|
text,
|
|
color =
|
|
if (pending) MaterialTheme.colorScheme.onSurfaceVariant
|
|
else MaterialTheme.colorScheme.onPrimaryContainer,
|
|
)
|
|
}
|
|
// Under the words: what somebody wrote is what the bubble is, and the picture is
|
|
// what they attached to it. It also keeps the first line of every bubble at the
|
|
// same place down the transcript, whether or not there is an image in it.
|
|
images.forEachIndexed { index, ref ->
|
|
if (index > 0 || text.isNotEmpty()) Spacer(Modifier.height(4.dp))
|
|
SessionImage(settings, sessionId, ref)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** A message the server has accepted and the session has not read yet. */
|
|
private data class QueuedMessage(val id: String, val text: String, val images: List<String>)
|
|
|
|
/**
|
|
* Collapsed by default: name plus a spinner while running, expandable to the input and output. The
|
|
* spinner-while-unfinished is exactly "ToolStart with no matching ToolEnd yet".
|
|
*/
|
|
|
|
/**
|
|
* Asked before switching model, because switching is not free and the cost is invisible.
|
|
*
|
|
* A model change drops the cached context: the next turn re-reads the entire conversation from the
|
|
* beginning and is charged for it. Measured on 2026-08-29 against a small session -- the turn
|
|
* before the switch read 30,771 tokens from cache and created 87; the turn after read **nothing**
|
|
* from cache and created 41,509. On a long conversation that is the whole of it, again.
|
|
*
|
|
* No number is offered here, deliberately. What it will cost depends on how long *this*
|
|
* conversation is, and this screen does not know that -- the running total beside it counts what
|
|
* has been spent, which is a different quantity. A figure worked out from it would be a guess in a
|
|
* measurement's clothes, and the reader could not tell which times it was right.
|
|
*
|
|
* The permission-mode picker beside it deliberately has no equivalent, which the same measurement
|
|
* decided: changing mode kept the cache (30,858 read, 75 created). Warning on both would teach the
|
|
* reader that these dialogs can be clicked through, which is what makes the one that matters stop
|
|
* working.
|
|
*/
|
|
@Composable
|
|
private fun ModelSwitchWarning(
|
|
from: String,
|
|
to: String,
|
|
onDismiss: () -> Unit,
|
|
onConfirm: () -> Unit,
|
|
) {
|
|
AlertDialog(
|
|
onDismissRequest = onDismiss,
|
|
title = { Text("Switch to $to?") },
|
|
text = {
|
|
Text(
|
|
"The session re-reads the whole conversation on its next turn: leaving $from " +
|
|
"drops the cached context, so that turn costs as much as the conversation " +
|
|
"is long. Nothing is lost -- it is read again, not forgotten."
|
|
)
|
|
},
|
|
confirmButton = { TextButton(onClick = onConfirm) { Text("Switch") } },
|
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Keep $from") } },
|
|
)
|
|
}
|
|
|
|
/**
|
|
* What the session is doing, and what the conversation has cost, on one line above the box.
|
|
*
|
|
* A row of its own because both of these are facts about the session rather than turns in it, and
|
|
* both were previously drawn over the transcript: the token total floated in its bottom corner,
|
|
* where a long message ran underneath it, and the working indicator was an item inside the list, so
|
|
* it scrolled away exactly when somebody reading back wanted to know whether anything was still
|
|
* happening. Here they are always in the same place, and the thing they report on -- the session
|
|
* you are about to type at -- is directly below.
|
|
*
|
|
* The row is drawn whether or not it has anything to say. An empty one costs a line; a row that
|
|
* came and went would move the text box under the reader's thumb every time a turn started, and
|
|
* would make its own presence the signal for a state it never names.
|
|
*
|
|
* The states are the session's own status words plus the total, and each looks different from the
|
|
* others: `exited` is here because a session whose process is gone cannot be typed at, and with the
|
|
* indicator gone from the list nothing else on this screen would say so.
|
|
*/
|
|
@Composable
|
|
private fun SessionStatusRow(
|
|
status: String,
|
|
/** Seconds since this device saw the compaction start; null if it did not see it. */
|
|
compactingFor: Long?,
|
|
/** Context the session is holding, or null where nothing has measured it. */
|
|
contextTokens: Long?,
|
|
modifier: Modifier = Modifier,
|
|
) {
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
|
|
) {
|
|
when (status) {
|
|
// A bar rather than the spinner an ordinary turn gets, and it takes the row's whole
|
|
// free width: nothing arrives in the transcript during a compaction, so this is the
|
|
// only thing on screen that is moving, and at a spinner's width that reads as a
|
|
// session that has hung.
|
|
//
|
|
// Indeterminate, which is a statement rather than an omission. The CLI says a
|
|
// compaction has begun and then says nothing at all until it has finished -- measured
|
|
// against 2.1.237 again on 2026-08-29, on a real 80,346-to-2,088-token compaction that
|
|
// took 23 seconds and produced not one line in between. So there is no fraction to
|
|
// fill, and a bar creeping along at the pace of the last one would be this screen
|
|
// inventing the part nobody sent it. Elapsed time is the only honest number here, and
|
|
// [compactingLabel] is where it is worded.
|
|
"compacting" -> {
|
|
Text(
|
|
compactingLabel(compactingFor),
|
|
style = MaterialTheme.typography.labelSmall,
|
|
// Stated beside the fill rather than inherited: a semantic colour has to carry
|
|
// its own contrast, since the surface under it will not change to rescue it.
|
|
color = commandColor,
|
|
)
|
|
LinearProgressIndicator(
|
|
color = commandColor,
|
|
trackColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
|
modifier = Modifier.weight(1f).padding(horizontal = 8.dp),
|
|
)
|
|
}
|
|
"running" -> {
|
|
CircularProgressIndicator(
|
|
// Smaller than the line beside it, so the row keeps the text's own height:
|
|
// a control taller than a line re-centres it and knocks it out of line with
|
|
// the total on the other end.
|
|
modifier = Modifier.width(12.dp).height(12.dp),
|
|
strokeWidth = 2.dp,
|
|
)
|
|
Text(
|
|
"working",
|
|
style = MaterialTheme.typography.labelSmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
modifier = Modifier.padding(start = 8.dp),
|
|
)
|
|
Spacer(Modifier.weight(1f))
|
|
}
|
|
// Every remaining state says which one it is, including the quiet one. The row used
|
|
// to name only `exited` and leave the rest blank, so a session sitting idle and one
|
|
// whose status nobody could read looked identical -- and a turn that had just been
|
|
// stopped showed nothing at all, which reads as the app having lost the session
|
|
// rather than as the stop having worked. The words are the session list's own, so
|
|
// one state is not called two things depending which screen you are on.
|
|
else ->
|
|
Text(
|
|
when (status) {
|
|
"idle" -> "idle"
|
|
"exited" -> "exited"
|
|
"awaitingInput" -> "your turn"
|
|
"unknown" -> "can't tell"
|
|
else -> status
|
|
},
|
|
style = MaterialTheme.typography.labelSmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
modifier = Modifier.weight(1f),
|
|
)
|
|
}
|
|
// How full the session is, which is the number a reader is asking about -- how much room
|
|
// is left before the next compaction -- rather than what has been spent getting here.
|
|
//
|
|
// "unknown" in words, and always drawn. A context nobody has measured is not an empty
|
|
// one, and the two used to share an appearance: a session that had just been cleared, one
|
|
// whose provider never reports usage, and one that has not run a turn all showed nothing
|
|
// at all, which reads as a conversation with room to spare. It is the same reason the
|
|
// status word beside it names the quiet state instead of leaving the row blank.
|
|
Text(
|
|
contextTokens?.let { "context ${tokens(it)}" } ?: "context unknown",
|
|
style = MaterialTheme.typography.labelSmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A question (or permission request -- same shape) inline in the transcript. Option buttons until
|
|
* answered; then the chosen answer, which the `answered` event also resolves on every other
|
|
* connected device.
|
|
*/
|
|
@Composable
|
|
private fun QuestionRow(
|
|
question: TranscriptItem.QuestionCard,
|
|
onAnswer: (List<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
|
|
|
|
/** The modes the CLI accepts, in the order they give up asking. */
|
|
private val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan")
|
|
|
|
/**
|
|
* A control that reads as its own value.
|
|
*
|
|
* The button *is* the current setting rather than a label beside one, so the row says what the
|
|
* session is set to without spending a second line on saying it.
|
|
*/
|
|
@Composable
|
|
private fun PickerButton(current: String, options: List<String>, onPick: (String) -> Unit) {
|
|
var open by remember { mutableStateOf(false) }
|
|
// 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)
|
|
},
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|