1717 lines
88 KiB
Kotlin
1717 lines
88 KiB
Kotlin
package com.example.aiapp
|
|
|
|
import android.os.SystemClock
|
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
import androidx.activity.result.PickVisualMediaRequest
|
|
import androidx.activity.result.contract.ActivityResultContracts
|
|
import androidx.compose.foundation.Image
|
|
import androidx.compose.foundation.layout.Arrangement
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.PaddingValues
|
|
import androidx.compose.foundation.layout.Row
|
|
import androidx.compose.foundation.layout.Spacer
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.layout.width
|
|
import androidx.compose.foundation.lazy.LazyColumn
|
|
import androidx.compose.foundation.lazy.items
|
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
import androidx.compose.foundation.shape.CircleShape
|
|
import androidx.compose.material3.AlertDialog
|
|
import androidx.compose.material3.Button
|
|
import androidx.compose.material3.Card
|
|
import androidx.compose.material3.CardDefaults
|
|
import androidx.compose.material3.CircularProgressIndicator
|
|
import androidx.compose.material3.DropdownMenu
|
|
import androidx.compose.material3.DropdownMenuItem
|
|
import androidx.compose.material3.LinearProgressIndicator
|
|
import androidx.compose.material3.LocalContentColor
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.OutlinedTextField
|
|
import androidx.compose.material3.Surface
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.TextButton
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.DisposableEffect
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.derivedStateOf
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableLongStateOf
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.rememberCoroutineScope
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.runtime.snapshotFlow
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.platform.LocalContext
|
|
import androidx.compose.ui.semantics.contentDescription
|
|
import androidx.compose.ui.semantics.semantics
|
|
import androidx.compose.ui.text.style.TextOverflow
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.window.PopupProperties
|
|
import androidx.lifecycle.Lifecycle
|
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
|
import androidx.lifecycle.repeatOnLifecycle
|
|
import java.util.concurrent.atomic.AtomicLong
|
|
import java.util.concurrent.atomic.AtomicReference
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.awaitCancellation
|
|
import kotlinx.coroutines.delay
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.withContext
|
|
|
|
private const val RECONNECT_DELAY_MS = 1500L
|
|
|
|
/**
|
|
* How many rows to keep loaded past the oldest one on screen.
|
|
*
|
|
* Both the point at which history starts loading and how much of it a load has to produce before it
|
|
* stops. A cushion rather than a page count because a page is measured in events and this list is
|
|
* measured in rows, and the two are not close: a page of eighty events can be one message.
|
|
*
|
|
* Small enough that opening a long session still costs one page, large enough that a fling upwards
|
|
* lands on rows that are already there. Fewer, and reading back means waiting for the network at
|
|
* every screenful, which is what it did.
|
|
*/
|
|
private const val HISTORY_LOOKAHEAD = 8
|
|
|
|
/**
|
|
* 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
|
|
}
|
|
|
|
@Composable
|
|
fun SessionScreen(
|
|
settings: ServerSettings,
|
|
summary: SessionSummary,
|
|
onBack: () -> Unit,
|
|
onSettings: () -> Unit,
|
|
) {
|
|
val scope = rememberCoroutineScope()
|
|
var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
|
|
var status by remember { mutableStateOf(summary.status) }
|
|
// Seeded from the row this screen was opened from, so a conversation 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 this screen saw the current compaction start, on this device's own clock, and how long
|
|
// ago that is. See `compactingLabel`: null is the honest answer whenever the start was not
|
|
// witnessed here, which is what opening a session that is already compacting looks like.
|
|
var compactingSince by remember { mutableStateOf<Long?>(null) }
|
|
var compactingFor by remember { mutableStateOf<Long?>(null) }
|
|
var streamError by remember { mutableStateOf<String?>(null) }
|
|
var actionError by remember { mutableStateOf<String?>(null) }
|
|
val context = LocalContext.current
|
|
// Seeded from what was left in the box last time and written back on every keystroke, so
|
|
// leaving the screen -- or the system reclaiming the app -- does not throw away a half-typed
|
|
// message. See `Drafts.kt` for why this one piece of state is the device's rather than the
|
|
// server's.
|
|
var input by remember(summary.id) { mutableStateOf(loadDraft(context, summary.id)) }
|
|
// A model the reader has chosen and not yet confirmed. See [ModelSwitchWarning]: switching
|
|
// makes the session re-read the whole conversation, which is worth asking about first.
|
|
var pendingModel by remember { mutableStateOf<String?>(null) }
|
|
var expandedTools by remember { mutableStateOf(setOf<String>()) }
|
|
// Which runs of adjacent tool calls are open. Keyed by the first call's
|
|
// id, so a group survives more calls arriving after it.
|
|
var expandedGroups by remember { mutableStateOf(setOf<String>()) }
|
|
// Which messages from other agents are open, by the seq that identifies their row. Closed
|
|
// by default, which is the rule for anything new in this transcript: a screen that opens
|
|
// everything it can is one nobody can scan.
|
|
var expandedNotes by remember { mutableStateOf(setOf<Long>()) }
|
|
// Uploaded-but-not-yet-sent attachment ids; sent with the next message.
|
|
var pendingAttachments by remember { mutableStateOf(listOf<String>()) }
|
|
// What this session is set to now, seeded from the row that opened it and
|
|
// then owned here, because changing either is something this screen does.
|
|
// The name shown at the top. Held here rather than read from the row that opened this
|
|
// screen, because renaming is something this screen can do -- through the settings below it,
|
|
// or by typing the command -- and a header still showing the old name reads as a rename that
|
|
// did not take.
|
|
var title by remember(summary.id) { mutableStateOf(summary.title) }
|
|
var model by remember { mutableStateOf(summary.model) }
|
|
var permissionMode by remember { mutableStateOf(summary.permissionMode ?: "auto") }
|
|
// The models this provider actually offers, asked of the server rather
|
|
// than listed here: a hardcoded list is a claim about a machine.
|
|
var offeredModels by remember { mutableStateOf<List<String>>(emptyList()) }
|
|
val lifecycleOwner = LocalLifecycleOwner.current
|
|
// The resume cursor, written from the stream's IO thread.
|
|
val lastSeq = remember { AtomicLong(0) }
|
|
val activeStream = remember { AtomicReference<EventStream?>(null) }
|
|
// The oldest sequence number loaded, and whether there is more behind
|
|
// it. Paging backwards is what keeps opening a long session cheap: the
|
|
// screen starts with the end of the conversation and fetches earlier
|
|
// pages only when somebody scrolls to them.
|
|
var oldestSeq by remember { mutableLongStateOf(0L) }
|
|
// Every transcript event loaded, in order, beside the rows they folded
|
|
// into. See `apply`.
|
|
var loaded by remember { mutableStateOf(listOf<SessionEvent>()) }
|
|
// Sent, but not yet read by the session -- which is when the backend
|
|
// records it and it comes back as a row. Until then it is drawn below
|
|
// the working indicator, because that is where it is in the session's
|
|
// reading of events: after everything taken in, not yet taken in
|
|
// itself.
|
|
// Messages the server has taken and the session has not read yet, by the id that will resolve
|
|
// them. From the event stream rather than from what this screen sent, so they are still here
|
|
// after leaving the session or restarting the app -- and so a message sent from another device
|
|
// is drawn waiting on this one too.
|
|
var queued by remember { mutableStateOf(listOf<QueuedMessage>()) }
|
|
// Commands the session has been asked to run and cannot yet, by the id that will resolve
|
|
// them. From the server rather than from this screen, so a rename sent from the settings
|
|
// screen -- or from another device -- is drawn waiting here too.
|
|
var waitingCommands by remember { mutableStateOf(listOf<Pair<String, String>>()) }
|
|
val running = status == "running" || status == "compacting"
|
|
var moreHistory by remember { mutableStateOf(true) }
|
|
var loadingHistory by remember { mutableStateOf(false) }
|
|
var ready by remember { mutableStateOf(false) }
|
|
val listState = rememberLazyListState()
|
|
// Whether the newest message is on screen right now. The list is laid out from the bottom
|
|
// (see the LazyColumn below), so "newest" is index 0 and being there is being at the start of
|
|
// it. This is what the jump-to-newest button watches: it is about what the reader can see.
|
|
//
|
|
// It is also the gate on everything the list draws -- see [record].
|
|
val atNewest by remember {
|
|
derivedStateOf {
|
|
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
|
|
}
|
|
}
|
|
// Transcript events that arrived while somebody was reading further back, in the order they
|
|
// arrived, waiting for them to return to the newest end. See [record] for why.
|
|
var held by remember { mutableStateOf(listOf<SeqEvent>()) }
|
|
// What is actually drawn: the transcript with runs of adjacent tool
|
|
// calls folded into one row each.
|
|
val rows = remember(items) { groupToolRuns(items) }
|
|
|
|
/**
|
|
* Everything the transcript list draws, from one event.
|
|
*
|
|
* Separate from [apply] because it is the half that is allowed to wait. The list anchors on the
|
|
* leading edge of its first visible item, which in this upside-down layout is that item's
|
|
* *bottom* -- so a row that grows pushes everything already on screen upwards, and the view
|
|
* travels toward the newest end without anybody scrolling. Measured against a reply streamed in
|
|
* four hundred pieces: scrolling back a screen and then waiting six seconds ended at the very
|
|
* bottom, forty lines further on than where it was left.
|
|
*
|
|
* Insertions were never the problem -- the list is keyed, so a row arriving at either end
|
|
* leaves the anchor where it is, and reading back through history while a session works has
|
|
* always been still. What cannot be allowed is a row that is already there changing height, and
|
|
* the one guarantee that covers every way that happens -- a reply streaming, a tool's output
|
|
* arriving, a queued bubble appearing above the anchor -- is to change nothing at all while
|
|
* somebody is reading further back.
|
|
*/
|
|
fun record(entry: SeqEvent) {
|
|
// The oldest event this view holds, which is what paging backwards
|
|
// starts from. Maintained here rather than by each loader: the
|
|
// first page and a stream reset both begin an empty view, and one
|
|
// of them getting it wrong is a transcript that will not scroll up.
|
|
if (oldestSeq == 0L) {
|
|
oldestSeq = entry.seq
|
|
moreHistory = entry.seq > 1L
|
|
}
|
|
val event = entry.event
|
|
// The message coming back is the session saying it has
|
|
// read it, so the bubble held below the indicator becomes
|
|
// the row `foldEvent` is about to add.
|
|
// Waiting, then read. Matched by id: the same message sent twice is two
|
|
// bubbles, and clearing by text would take away whichever matched first.
|
|
if (event is SessionEvent.MessageQueued) {
|
|
queued = queued + QueuedMessage(event.id, event.text, event.images)
|
|
}
|
|
if (event is SessionEvent.UserMessage) {
|
|
queued = queued.filterNot { it.id == event.id }
|
|
}
|
|
// Waiting, then gone: a command leaves this list when the session takes it,
|
|
// and the row it becomes is added by `foldEvent` in the same pass.
|
|
if (event is SessionEvent.CommandQueued) {
|
|
waitingCommands = waitingCommands + (event.id to event.text)
|
|
}
|
|
if (event is SessionEvent.CommandSent) {
|
|
waitingCommands = waitingCommands.filterNot { it.first == event.id }
|
|
}
|
|
// Kept as well as folded. Folding is one-way -- a tool's
|
|
// start and end become one row -- so a page arriving in
|
|
// front of what is already here cannot be stitched on
|
|
// without the events themselves.
|
|
loaded = loaded + event
|
|
items = foldEvent(items, entry)
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
// Started here, or nowhere. `ready` is what separates the live stream from
|
|
// the page of history the screen opens with, and a compaction found in that
|
|
// page began before anybody here was watching -- timing it from now would
|
|
// report the moment we arrived as the moment it started.
|
|
compactingSince =
|
|
when {
|
|
event.state != "compacting" -> null
|
|
status == "compacting" -> compactingSince
|
|
ready -> SystemClock.elapsedRealtime()
|
|
else -> null
|
|
}
|
|
status = event.state
|
|
}
|
|
// 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
|
|
}
|
|
}
|
|
}
|
|
|
|
// A compaction reports nothing about its own progress -- measured against the CLI, which
|
|
// says it has started, and then says nothing at all until it is done. So what this counts is
|
|
// the one thing anybody here can measure: how long it has been going. A bar filling up would
|
|
// be this screen inventing the part the CLI does not send.
|
|
LaunchedEffect(compactingSince) {
|
|
val since = compactingSince
|
|
if (since == null) {
|
|
compactingFor = null
|
|
return@LaunchedEffect
|
|
}
|
|
while (true) {
|
|
compactingFor = (SystemClock.elapsedRealtime() - since) / 1000
|
|
delay(1000)
|
|
}
|
|
}
|
|
|
|
// The stream lifecycle: connect, follow, and on any drop reconnect
|
|
// from the cursor -- so a flaky link (or a backend restart) costs
|
|
// nothing but the gap's latency.
|
|
// The newest page first, in one request, before the stream opens. The
|
|
// stream then starts from where that page ended, so it carries live
|
|
// events only -- which is what it is good at.
|
|
LaunchedEffect(summary.id) {
|
|
try {
|
|
val page = withContext(Dispatchers.IO) { fetchTranscript(settings, summary.id) }
|
|
page.forEach { apply(it) }
|
|
} catch (e: ApiException) {
|
|
// Not fatal: the stream below still replays from zero, which is
|
|
// slow but complete. Saying so beats silently showing nothing.
|
|
streamError = e.message
|
|
}
|
|
ready = true
|
|
}
|
|
|
|
// Only while the screen is actually on screen. Android stops the
|
|
// activity when somebody switches away, and the socket dies with it --
|
|
// which arrived as "Lost the event stream (SocketTimeoutException)"
|
|
// waiting at the top on their return. Switching apps is a choice
|
|
// somebody made, not a fault to report, and reconnecting on a phone
|
|
// that has been backgrounded is work nobody is watching. Stopping the
|
|
// stream deliberately makes the drop a close rather than an error (see
|
|
// EventStream.close), and resuming reconnects from the same cursor.
|
|
LaunchedEffect(summary.id, ready, lifecycleOwner) {
|
|
if (!ready) return@LaunchedEffect
|
|
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
|
try {
|
|
while (true) {
|
|
val stream = EventStream(settings, summary.id)
|
|
activeStream.set(stream)
|
|
try {
|
|
withContext(Dispatchers.IO) {
|
|
stream.run(
|
|
after = lastSeq.get(),
|
|
// Connected, measured rather than inferred: this is what
|
|
// takes a failure off the screen, and nothing else does.
|
|
// Clearing on the first event instead meant an idle
|
|
// session kept displaying an error it had recovered from.
|
|
onOpen = { streamError = null },
|
|
onReset = {
|
|
// Too far behind to continue from: what is on
|
|
// screen is a stale prefix of a conversation
|
|
// that has moved on, and the window arriving
|
|
// next is not adjacent to it. Dropping the rows
|
|
// is what makes this the same as opening the
|
|
// screen -- `apply` refills them, and scrolling
|
|
// up pages the rest back in as it always does.
|
|
items = listOf()
|
|
loaded = listOf()
|
|
held = listOf()
|
|
oldestSeq = 0L
|
|
moreHistory = true
|
|
},
|
|
) { entry ->
|
|
apply(entry)
|
|
}
|
|
}
|
|
} catch (e: ApiException) {
|
|
streamError = e.message
|
|
} finally {
|
|
stream.close()
|
|
}
|
|
delay(RECONNECT_DELAY_MS)
|
|
}
|
|
} finally {
|
|
// Cancellation -- going below STARTED, or leaving the screen --
|
|
// cannot interrupt a blocking socket read. Closing is what
|
|
// unblocks it, and what marks the drop deliberate.
|
|
activeStream.getAndSet(null)?.close()
|
|
}
|
|
}
|
|
}
|
|
// The screen going away entirely, which the lifecycle scope above does
|
|
// not cover: a composable can leave the composition while the activity
|
|
// stays started.
|
|
DisposableEffect(summary.id) { onDispose { activeStream.get()?.close() } }
|
|
|
|
// Nothing gets announced about the session somebody is reading; see NotificationService.
|
|
// RESUMED rather than STARTED because "looking at it" means the foreground -- a session left
|
|
// on this screen behind another app is one whose notifications are still wanted, and STARTED
|
|
// covers that case too.
|
|
LaunchedEffect(summary.id, lifecycleOwner) {
|
|
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
|
NotificationService.showing(context, summary.id)
|
|
try {
|
|
awaitCancellation()
|
|
} finally {
|
|
NotificationService.stoppedShowing(summary.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Back at the newest end, so the backlog [apply] held can land. Everything at once rather
|
|
// than paced out: they are at the bottom, which is the one place the list is allowed to
|
|
// follow new content, and drip-feeding it would only make that following last longer.
|
|
LaunchedEffect(listState) {
|
|
snapshotFlow { atNewest && held.isNotEmpty() }
|
|
.collect { due ->
|
|
if (!due) return@collect
|
|
val backlog = held
|
|
held = listOf()
|
|
backlog.forEach { record(it) }
|
|
}
|
|
}
|
|
// Whether they *chose* to be there, which is a different question and the one that decides
|
|
// whether an arriving message brings the view with it.
|
|
//
|
|
// Remembered, and only ever written when a scroll settles -- so it records where the reader
|
|
// last left the list, and an insertion cannot change the answer. Reading the live position
|
|
// instead looks right and is subtly wrong: a keyed list moves its anchor to keep the reader's
|
|
// content still, so by the time the new item can be observed the view is already one item
|
|
// away from the newest and reports itself as scrolled back. The message then never followed,
|
|
// which was visible as a compaction whose progress bar sat just off the bottom of the screen
|
|
// while the button that started it said it was running.
|
|
var followTail by remember { mutableStateOf(true) }
|
|
LaunchedEffect(listState) {
|
|
snapshotFlow { listState.isScrollInProgress }
|
|
.collect { scrolling -> if (!scrolling) followTail = atNewest }
|
|
}
|
|
// A new item at the newest end shifts every index by one, so the view
|
|
// has to step back to 0 to stay put. One item, instantly -- not a
|
|
// journey through the transcript.
|
|
// Anything that changes how much room the list has, as well as a new
|
|
// item arriving. Typing is the case that gets missed: the field grows
|
|
// from one line to four and the keyboard opens under it, and neither
|
|
// is a new message, so watching the item count alone leaves the newest
|
|
// text drifting out of sight while somebody writes a reply to it.
|
|
//
|
|
// Counted in list items rather than in transcript rows, because the rows are not all of it:
|
|
// the working indicator and a queued message are items too, and they arrive at exactly the
|
|
// same end. Sibling to the paging trigger below, which is the same count read from the other
|
|
// end for the same reason.
|
|
LaunchedEffect(listState) {
|
|
snapshotFlow {
|
|
Pair(listState.layoutInfo.totalItemsCount, listState.layoutInfo.viewportSize.height)
|
|
}
|
|
.collect { (count, _) -> if (followTail && count > 0) listState.scrollToItem(0) }
|
|
}
|
|
// Reaching the far end of what is loaded -- the oldest item, which in
|
|
// this layout is the last index -- fetches the page before it.
|
|
//
|
|
// Both numbers come from the list itself, and that is the point: an index into what is drawn
|
|
// can only be compared against how much is drawn. Three things already make that differ from
|
|
// the event count -- a run of adjacent tool calls is one row, and the queued bubble and the
|
|
// working indicator are rows with no event behind them at all -- so measuring the far end in
|
|
// events meant the threshold could not be reached, and a session with tool calls in it simply
|
|
// stopped scrolling back. Anything added to this list later is a fourth, and totalItemsCount
|
|
// already counts it.
|
|
LaunchedEffect(listState, rows.size, moreHistory) {
|
|
snapshotFlow {
|
|
val layout = listState.layoutInfo
|
|
Pair(layout.visibleItemsInfo.lastOrNull()?.index ?: 0, layout.totalItemsCount)
|
|
}
|
|
.collect { (last, total) ->
|
|
if (!moreHistory || loadingHistory || total == 0) return@collect
|
|
if (last < total - HISTORY_LOOKAHEAD) return@collect
|
|
loadingHistory = true
|
|
try {
|
|
// Pages until there are rows behind them again, not one page and stop.
|
|
//
|
|
// A page is eighty *events*, and eighty events are routinely one row: a
|
|
// reply arrives as hundreds of text deltas that fold into a single message.
|
|
// So a page that lands can leave the far end exactly where it was -- and
|
|
// since this is triggered by the far end moving, nothing asks for the next
|
|
// one. The list then only loads when somebody drags it again, a page at a
|
|
// time, which is what "it only loads when you touch the top" was.
|
|
// Counted from `items` rather than from `rows`, which is the
|
|
// composition's value and does not change under a running coroutine.
|
|
val start = groupToolRuns(items).size
|
|
var have = start
|
|
while (moreHistory && have - start < HISTORY_LOOKAHEAD) {
|
|
val older =
|
|
withContext(Dispatchers.IO) {
|
|
fetchTranscript(settings, summary.id, before = oldestSeq)
|
|
}
|
|
if (older.isEmpty()) {
|
|
moreHistory = false
|
|
break
|
|
}
|
|
oldestSeq = older.first().seq
|
|
moreHistory = oldestSeq > 1L
|
|
// Folded oldest-first into a list of their own, then
|
|
// put in front: `foldEvent` merges streaming text
|
|
// into the item before it, so replaying an older page
|
|
// through the live list would glue it onto the newest
|
|
// message rather than its own.
|
|
var earlier = listOf<TranscriptItem>()
|
|
older.forEach { entry ->
|
|
if (entry.event !is SessionEvent.UsageDelta) {
|
|
earlier = foldEvent(earlier, entry)
|
|
}
|
|
}
|
|
items = joinPages(earlier, items)
|
|
have = groupToolRuns(items).size
|
|
}
|
|
} catch (_: ApiException) {
|
|
// Leave `moreHistory` alone: the next scroll asks again.
|
|
} finally {
|
|
loadingHistory = false
|
|
}
|
|
}
|
|
}
|
|
|
|
LaunchedEffect(summary.setupName, summary.provider) {
|
|
offeredModels =
|
|
try {
|
|
withContext(Dispatchers.IO) {
|
|
fetchSetups(settings)
|
|
.firstOrNull { it.name == summary.setupName }
|
|
?.providers
|
|
?.firstOrNull { it.name == summary.provider }
|
|
?.models
|
|
.orEmpty()
|
|
}
|
|
} catch (_: Exception) {
|
|
// Not worth reporting: the picker simply has nothing to
|
|
// offer, which is visible, and the session is unaffected.
|
|
emptyList()
|
|
}
|
|
}
|
|
|
|
fun act(onFailure: () -> Unit = {}, action: () -> Unit) {
|
|
scope.launch {
|
|
try {
|
|
withContext(Dispatchers.IO) { action() }
|
|
actionError = null
|
|
} catch (e: ApiException) {
|
|
actionError = e.message
|
|
onFailure()
|
|
}
|
|
}
|
|
}
|
|
|
|
fun send() {
|
|
val text = input.trim()
|
|
val attachments = pendingAttachments
|
|
if (text.isEmpty() && attachments.isEmpty()) return
|
|
// A command is not a message: it is an instruction to the session about itself, and one
|
|
// written into a running turn is read by the model instead. The server holds it until the
|
|
// turn ends and says so, which is where its waiting bubble comes from -- so nothing is
|
|
// held here, and there is no local guess to correct when the answer arrives.
|
|
if (text.startsWith("/") && attachments.isEmpty()) {
|
|
input = ""
|
|
saveDraft(context, summary.id, "")
|
|
// The one command with a visible effect outside the transcript, applied when the
|
|
// server has accepted it rather than when it was typed: the name is this app's own
|
|
// datum and changes at once, and only telling the session waits for a boundary.
|
|
val renamed =
|
|
text.removePrefix("/rename ").trim().takeIf {
|
|
text.startsWith("/rename ") && it.isNotEmpty()
|
|
}
|
|
act {
|
|
runCommand(settings, summary.id, text)
|
|
renamed?.let { title = it }
|
|
}
|
|
return
|
|
}
|
|
input = ""
|
|
saveDraft(context, summary.id, "")
|
|
pendingAttachments = emptyList()
|
|
// Nothing is added here. The server says what is waiting -- it emits `messageQueued`
|
|
// when it takes a message it cannot deliver yet -- and this screen draws that. Holding a
|
|
// local copy as well was the bug: the two agreed only until the app was restarted or the
|
|
// session left, and then the screen showed nothing pending while the queue was full.
|
|
act { sendMessage(settings, summary.id, text, attachments) }
|
|
}
|
|
|
|
// The system photo picker; the image uploads as soon as it's chosen,
|
|
// so Send only has ids to reference.
|
|
val pickImage =
|
|
rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
|
|
if (uri != null) {
|
|
scope.launch {
|
|
try {
|
|
val id =
|
|
withContext(Dispatchers.IO) {
|
|
// Shrunk to what this session's provider takes before it is
|
|
// uploaded, so a twelve-megapixel photo does not cross the tunnel
|
|
// to be rejected at the far end -- see `uploadPickedImage`.
|
|
uploadPickedImage(
|
|
context,
|
|
settings,
|
|
summary.id,
|
|
uri,
|
|
summary.maxImageEdge,
|
|
)
|
|
}
|
|
pendingAttachments = pendingAttachments + id
|
|
actionError = null
|
|
} catch (e: ApiException) {
|
|
actionError = e.message
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// One poll for this machine's limits, read by the two things that show them: the bar under
|
|
// the header, and the colour of the button that opens the dialog.
|
|
val usage = rememberSessionUsage(settings, summary.setup)
|
|
var usageOpen by remember { mutableStateOf(false) }
|
|
|
|
Column(Modifier.fillMaxSize()) {
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
|
|
) {
|
|
GlyphButton(BACK_GLYPH, "Back", onBack)
|
|
Spacer(Modifier.width(8.dp))
|
|
Column(Modifier.weight(1f)) {
|
|
Text(title, style = MaterialTheme.typography.titleMedium)
|
|
// Machine first, then what runs on it -- the same order and the same wording
|
|
// everywhere this pair appears, so it reads as one fact rather than as two
|
|
// sentences with different grammar. The "on" that used to sit in the middle
|
|
// made it a phrase, which only works in one order and stops working the moment
|
|
// the pair is shown anywhere else.
|
|
//
|
|
// No model. The picker in the footer already shows what this session is set to,
|
|
// and showing it twice means two things to keep in step -- they disagreed for a
|
|
// moment on every model change, since one follows the request and the other the
|
|
// session's own answer.
|
|
Text(
|
|
"${summary.setupName} · ${summary.provider}",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
// Beside the provider it reports on, which is the line directly to its left.
|
|
//
|
|
// Its real home is this provider's settings, which do not exist yet; until they do,
|
|
// the session is the only place the provider is already named, so it is the only
|
|
// place the button can sit without inventing a scope for itself. What it shows is
|
|
// the paid service's own numbers, so a session on a provider with no such service
|
|
// gets an honest "unavailable" rather than a hidden button -- a control that comes
|
|
// and goes makes its absence the signal, and absence cannot say why.
|
|
// Coloured by the worst window behind it, so the row says whether the limits are
|
|
// worth opening before anybody opens them. Blue at every ordinary level and only
|
|
// yellow or red near a limit -- and the theme's plain control colour whenever there
|
|
// is no measurement, since blue is the low end of the scale here and would read as
|
|
// "checked, and fine" about a machine nobody could reach.
|
|
Row(horizontalArrangement = Arrangement.spacedBy(GLYPH_BUTTON_GAP)) {
|
|
GlyphButton(
|
|
USAGE_GLYPH,
|
|
"Usage",
|
|
{ usageOpen = true },
|
|
colour = usageGlyphColour(usage),
|
|
)
|
|
// A step down from this session, so it sits at the end of the session's own row.
|
|
// The name is the whole of what it holds today, which is why it is a cog and not
|
|
// a word: there will be more, and a bar of words has nowhere to put it.
|
|
GlyphButton(SETTINGS_GLYPH, "Session settings", onSettings)
|
|
}
|
|
}
|
|
|
|
// Under the header, above everything the session itself says: it is a fact about the
|
|
// machine rather than a turn in the conversation, and it is the number that decides
|
|
// whether to keep going -- which was a screen away from where that gets decided.
|
|
SessionUsageBar(usage)
|
|
|
|
(streamError ?: actionError)?.let { message ->
|
|
Text(
|
|
message,
|
|
color = MaterialTheme.colorScheme.error,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
|
)
|
|
}
|
|
|
|
// Laid out from the bottom, with the newest message at index 0.
|
|
//
|
|
// The obvious arrangement -- oldest first, then scroll to the end
|
|
// -- opens at the top and travels the whole transcript to get
|
|
// where it belongs. On an imported session that is nine hundred
|
|
// items measured before anything is readable, seen as the view
|
|
// visibly racing downward every time it opened.
|
|
//
|
|
// Anchoring at the bottom removes the journey rather than hiding
|
|
// it: the first frame is already the newest message, and older
|
|
// ones are composed only as somebody scrolls back to them, which
|
|
// is also what makes history cheap on a long conversation.
|
|
Box(Modifier.weight(1f).fillMaxWidth()) {
|
|
LazyColumn(
|
|
state = listState,
|
|
reverseLayout = true,
|
|
modifier = Modifier.fillMaxSize(),
|
|
contentPadding = PaddingValues(16.dp),
|
|
// Bottom, and it has to be said: `reverseLayout` defaults the arrangement to
|
|
// `Bottom` on its own, but naming `spacedBy` replaces that default with
|
|
// `spacedBy`'s own, which is `Top`. The arrangement is what places the content
|
|
// when there is less of it than the viewport -- so a session whose loaded rows
|
|
// did not fill the screen drew them against the *top*, leaving a gap between the
|
|
// newest message and the box you type in, and no room to scroll the gap away.
|
|
// Opening the keyboard shrank the viewport enough for the content to overflow it
|
|
// and the list snapped down, which is what made it look like a scrolling fault
|
|
// rather than a placement one.
|
|
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.Bottom),
|
|
) {
|
|
// The last thing in the transcript, because that is where
|
|
// they are in the session's reading of events: after
|
|
// everything it has taken in, and not yet taken in
|
|
// themselves. What the session is *doing* about them is a
|
|
// line below, in [SessionStatusRow].
|
|
if (queued.isNotEmpty() || waitingCommands.isNotEmpty()) {
|
|
item(key = "queued") {
|
|
Column(horizontalAlignment = Alignment.End) {
|
|
waitingCommands.forEach { (_, text) ->
|
|
CommandBubble(text, waiting = true)
|
|
}
|
|
queued.forEach { waiting ->
|
|
UserBubble(
|
|
settings = settings,
|
|
sessionId = summary.id,
|
|
text = waiting.text,
|
|
images = waiting.images,
|
|
pending = true,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Reversed to match the layout, so index 0 is the newest and
|
|
// the reader still sees them in the order they happened.
|
|
// Grouped first: adjacent tool calls collapse into one row,
|
|
// which is a decision about this screen and not about the
|
|
// transcript the stream and paging share.
|
|
// Keyed, and this is what stops the list moving under whoever is reading
|
|
// it. Every new message is an insertion at index 0 here, so without a key the
|
|
// rows keep their positions and the content slides through them -- which looks
|
|
// exactly like the view scrolling by itself. The keys above matter for the same
|
|
// reason: the working indicator appearing and disappearing is another insertion
|
|
// at the same end. Paging older history is the opposite insertion and was
|
|
// already fine, and stays fine, because a key survives both.
|
|
items(rows.asReversed(), key = { it.key }) { row ->
|
|
when (row) {
|
|
is TranscriptRow.Tools ->
|
|
ToolGroup(
|
|
group = row,
|
|
expanded = row.id in expandedGroups,
|
|
onToggle = {
|
|
expandedGroups =
|
|
if (row.id in expandedGroups) expandedGroups - row.id
|
|
else expandedGroups + row.id
|
|
},
|
|
isToolExpanded = { it in expandedTools },
|
|
onToolToggle = { id ->
|
|
expandedTools =
|
|
if (id in expandedTools) expandedTools - id
|
|
else expandedTools + id
|
|
},
|
|
onAnswer = { questionId, answers ->
|
|
act {
|
|
answerQuestion(settings, summary.id, questionId, answers)
|
|
}
|
|
},
|
|
image = { ref -> SessionImage(settings, summary.id, ref) },
|
|
)
|
|
is TranscriptRow.Single ->
|
|
when (val item = row.item) {
|
|
is TranscriptItem.UserMsg ->
|
|
UserBubble(
|
|
settings = settings,
|
|
sessionId = summary.id,
|
|
text = item.text,
|
|
images = item.images,
|
|
)
|
|
is TranscriptItem.AssistantMsg -> AssistantMessage(item.text)
|
|
is TranscriptItem.ToolRun ->
|
|
ToolCard(
|
|
tool = item,
|
|
expanded = item.id in expandedTools,
|
|
onToggle = {
|
|
expandedTools =
|
|
if (item.id in expandedTools)
|
|
expandedTools - item.id
|
|
else expandedTools + item.id
|
|
},
|
|
onAnswer = { questionId, answers ->
|
|
act {
|
|
answerQuestion(
|
|
settings,
|
|
summary.id,
|
|
questionId,
|
|
answers,
|
|
)
|
|
}
|
|
},
|
|
image = { ref -> SessionImage(settings, summary.id, ref) },
|
|
)
|
|
is TranscriptItem.QuestionCard ->
|
|
QuestionRow(item) { answers ->
|
|
act {
|
|
answerQuestion(settings, summary.id, item.id, answers)
|
|
}
|
|
}
|
|
is TranscriptItem.ErrorMsg ->
|
|
Text(
|
|
item.message,
|
|
color = MaterialTheme.colorScheme.error,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
)
|
|
is TranscriptItem.ImageItem ->
|
|
SessionImage(settings, summary.id, item.ref)
|
|
is TranscriptItem.Note ->
|
|
Text(
|
|
item.text,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
is TranscriptItem.CommandRow -> CommandBubble(item.text)
|
|
is TranscriptItem.ClearedNote -> ClearedRow()
|
|
is TranscriptItem.CompactedNote -> CompactedRow(item)
|
|
is TranscriptItem.PeerNote ->
|
|
PeerMessageRow(
|
|
item = item,
|
|
expanded = item.seq in expandedNotes,
|
|
onToggle = {
|
|
expandedNotes =
|
|
if (item.seq in expandedNotes)
|
|
expandedNotes - item.seq
|
|
else expandedNotes + item.seq
|
|
},
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Only while the newest message is off-screen. Reading back
|
|
// through a conversation is a place to be, not a state to be
|
|
// rescued from, so this waits to be wanted.
|
|
//
|
|
// Down, and the same chevron a tool group collapses with: the
|
|
// list is built upside down internally, but nobody reading it
|
|
// knows that -- on screen the newest message is at the bottom,
|
|
// which is where this goes. The name is carried in the
|
|
// description, since an arrow alone says nothing to a screen
|
|
// reader and nothing to whoever finds this in six months.
|
|
if (!atNewest) {
|
|
Surface(
|
|
// Instantly. An animated scroll travels the whole transcript, so the
|
|
// further back somebody has read the longer this takes -- the one press
|
|
// whose cost grows with how much there is to skip, which is backwards. The
|
|
// list is keyed and composes only what it lands on, so going straight there
|
|
// costs the same from anywhere.
|
|
onClick = { scope.launch { listState.scrollToItem(0) } },
|
|
shape = CircleShape,
|
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
|
modifier =
|
|
Modifier.align(Alignment.BottomCenter).padding(bottom = 12.dp).semantics {
|
|
contentDescription = "Jump to latest"
|
|
},
|
|
) {
|
|
Chevron(
|
|
pointingUp = false,
|
|
colour = MaterialTheme.colorScheme.onSurface,
|
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
pendingModel?.let { chosen ->
|
|
ModelSwitchWarning(
|
|
from = modelLabel(model),
|
|
to = modelLabel(chosen),
|
|
onDismiss = { pendingModel = null },
|
|
onConfirm = {
|
|
pendingModel = null
|
|
act { setSessionModel(settings, summary.id, chosen) }
|
|
},
|
|
)
|
|
}
|
|
|
|
SessionStatusRow(
|
|
status = status,
|
|
compactingFor = compactingFor,
|
|
contextTokens = contextTokens,
|
|
)
|
|
|
|
// Between the transcript and the box: above what is being typed, so the list does not
|
|
// cover the thing the command is about, and below everything that explains it.
|
|
CommandSuggestions(
|
|
commands = suggestedCommands(input),
|
|
onPick = { command -> input = command.typed() },
|
|
)
|
|
|
|
// Always enabled -- a send while the session is running becomes a
|
|
// steering message injected at the next tool boundary, which is
|
|
// the point of the whole app.
|
|
//
|
|
// The field gets a row of its own, above the buttons: sharing one
|
|
// put the full width behind three controls, so the thing being
|
|
// typed into was the narrowest thing on the row.
|
|
Column(Modifier.fillMaxWidth().padding(8.dp)) {
|
|
// Directly above the box they will be sent from, so what is attached is visible
|
|
// rather than counted: the "+2" on the button below said how many and never which.
|
|
PendingAttachments(
|
|
settings = settings,
|
|
sessionId = summary.id,
|
|
refs = pendingAttachments,
|
|
onRemove = { pendingAttachments = pendingAttachments - it },
|
|
)
|
|
OutlinedTextField(
|
|
value = input,
|
|
onValueChange = {
|
|
input = it
|
|
saveDraft(context, summary.id, it)
|
|
},
|
|
modifier = Modifier.fillMaxWidth(),
|
|
// No longer "(+image)": the images are on screen above this, and a placeholder
|
|
// saying so said it in words beside the thing itself.
|
|
placeholder = { Text("Message") },
|
|
maxLines = 4,
|
|
)
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
TextButton(
|
|
onClick = {
|
|
pickImage.launch(
|
|
PickVisualMediaRequest(
|
|
ActivityResultContracts.PickVisualMedia.ImageOnly
|
|
)
|
|
)
|
|
}
|
|
) {
|
|
// Just "+" now. The count was standing in for showing them.
|
|
Text("+")
|
|
}
|
|
// The settings share what is left after the actions have
|
|
// taken what they need. A Row hands out intrinsic widths in
|
|
// order and clips whatever runs past the edge, so with
|
|
// these laid out first the arrival of Stop pushed Send off
|
|
// the screen entirely -- the app's central control, gone at
|
|
// exactly the moment the app is most in use.
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.weight(1f),
|
|
) {
|
|
if (offeredModels.isNotEmpty()) {
|
|
PickerButton(
|
|
current = modelLabel(model),
|
|
// What the machine offers, plus the state a session is in when it
|
|
// has chosen none of them. The button has always been able to say
|
|
// "default"; until this the list could not, so leaving it was a
|
|
// one-way trip.
|
|
options = listOf(DEFAULT_MODEL) + offeredModels,
|
|
// Not set here. The button follows what the session reports it
|
|
// is set to, which arrives a moment later and is sometimes a
|
|
// different answer -- a name the CLI resolved, or no change at all
|
|
// on a provider whose model is fixed when it starts.
|
|
// Asked about first, unless there is nothing to lose by it --
|
|
// see [ModelSwitchWarning].
|
|
onPick = { chosen ->
|
|
if (modelLabel(chosen) == modelLabel(model) || items.isEmpty()) {
|
|
act { setSessionModel(settings, summary.id, chosen) }
|
|
} else {
|
|
pendingModel = chosen
|
|
}
|
|
},
|
|
)
|
|
}
|
|
PickerButton(
|
|
current = permissionMode,
|
|
options = PERMISSION_MODES,
|
|
onPick = { chosen ->
|
|
act { setSessionPermissionMode(settings, summary.id, chosen) }
|
|
},
|
|
)
|
|
}
|
|
if (running) {
|
|
// The same filled shape as the button beside it, not an outlined one: these
|
|
// are two things you can do about the turn that is running, and weighting one
|
|
// of them as secondary said they were a primary action and its qualifier.
|
|
// What separates them is the colour and the mark, which is what they mean.
|
|
Button(
|
|
onClick = { act { interruptSession(settings, summary.id) } },
|
|
colors = actionButtonColors(stopColor),
|
|
) {
|
|
// A filled square, which is what stop has looked like since tape decks.
|
|
Glyph(
|
|
STOP_GLYPH,
|
|
colour = LocalContentColor.current,
|
|
modifier = Modifier.semantics { contentDescription = "Stop" },
|
|
)
|
|
}
|
|
Spacer(Modifier.width(8.dp))
|
|
}
|
|
// The paper plane, with a clock on it while a turn is in flight: sending then
|
|
// queues the message for the next tool boundary rather than starting a turn of
|
|
// its own, and the two have to be told apart at a glance. The label says the same
|
|
// thing to a screen reader, which has nothing else to read.
|
|
Button(
|
|
onClick = { send() },
|
|
colors = actionButtonColors(if (running) queueColor else sendColor),
|
|
) {
|
|
Glyph(
|
|
if (running) QUEUE_GLYPH else SEND_GLYPH,
|
|
colour = LocalContentColor.current,
|
|
modifier = Modifier.semantics { contentDescription = sendLabel(running) },
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (usageOpen) {
|
|
UsageDialog(settings = settings, onDismiss = { usageOpen = false })
|
|
}
|
|
}
|
|
|
|
/** What pressing Send does right now, said the same way to the eye and to a screen reader. */
|
|
private fun sendLabel(running: Boolean) = if (running) "Queue" else "Send"
|
|
|
|
/**
|
|
* An inline transcript image, fetched (authenticated, pinned) from the session's files route. The
|
|
* bitmap is remembered per ref, so scrolling doesn't refetch.
|
|
*/
|
|
@Composable
|
|
private fun UserBubble(
|
|
settings: ServerSettings,
|
|
sessionId: String,
|
|
text: String,
|
|
images: List<String> = emptyList(),
|
|
pending: Boolean = false,
|
|
) {
|
|
Box(Modifier.fillMaxWidth()) {
|
|
Card(
|
|
// A message the session has not read yet is drawn quieter than
|
|
// one it has. The difference is in degree -- said, not yet
|
|
// heard -- which is what colour alone can carry; where it sits
|
|
// is what says the rest.
|
|
colors =
|
|
CardDefaults.cardColors(
|
|
containerColor =
|
|
if (pending) MaterialTheme.colorScheme.surfaceVariant
|
|
else MaterialTheme.colorScheme.primaryContainer
|
|
),
|
|
modifier = Modifier.align(Alignment.CenterEnd).padding(start = 48.dp),
|
|
) {
|
|
Column(Modifier.padding(12.dp)) {
|
|
// 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)
|
|
},
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|