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