2590 lines
140 KiB
Kotlin
2590 lines
140 KiB
Kotlin
package com.example.aiapp
|
|
|
|
import android.content.Context
|
|
import android.content.pm.ApplicationInfo
|
|
import android.net.Uri
|
|
import android.os.Build
|
|
import android.os.SystemClock
|
|
import android.util.Log
|
|
import android.widget.Toast
|
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
import androidx.activity.result.PickVisualMediaRequest
|
|
import androidx.activity.result.contract.ActivityResultContracts
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.clickable
|
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
|
import androidx.compose.foundation.layout.Row
|
|
import androidx.compose.foundation.layout.Spacer
|
|
import androidx.compose.foundation.layout.WindowInsets
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.foundation.layout.ime
|
|
import androidx.compose.foundation.layout.imePadding
|
|
import androidx.compose.foundation.layout.isImeVisible
|
|
import androidx.compose.foundation.layout.navigationBars
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.layout.size
|
|
import androidx.compose.foundation.layout.width
|
|
import androidx.compose.foundation.lazy.LazyListLayoutInfo
|
|
import androidx.compose.foundation.lazy.LazyListState
|
|
import androidx.compose.foundation.shape.CircleShape
|
|
import androidx.compose.foundation.text.selection.rememberSelectionState
|
|
import androidx.compose.material3.AlertDialog
|
|
import androidx.compose.material3.Button
|
|
import androidx.compose.material3.Card
|
|
import androidx.compose.material3.CardDefaults
|
|
import androidx.compose.material3.CircularProgressIndicator
|
|
import androidx.compose.material3.DropdownMenu
|
|
import androidx.compose.material3.DropdownMenuItem
|
|
import androidx.compose.material3.LinearProgressIndicator
|
|
import androidx.compose.material3.LocalContentColor
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.OutlinedTextField
|
|
import androidx.compose.material3.Surface
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.TextButton
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.DisposableEffect
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.derivedStateOf
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableIntStateOf
|
|
import androidx.compose.runtime.mutableLongStateOf
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.rememberCoroutineScope
|
|
import androidx.compose.runtime.rememberUpdatedState
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.runtime.snapshotFlow
|
|
import androidx.compose.runtime.snapshots.Snapshot
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.draw.drawWithContent
|
|
import androidx.compose.ui.graphics.graphicsLayer
|
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
|
import androidx.compose.ui.input.pointer.pointerInput
|
|
import androidx.compose.ui.layout.onSizeChanged
|
|
import androidx.compose.ui.platform.LocalContext
|
|
import androidx.compose.ui.platform.LocalDensity
|
|
import androidx.compose.ui.semantics.contentDescription
|
|
import androidx.compose.ui.semantics.semantics
|
|
import androidx.compose.ui.text.TextRange
|
|
import androidx.compose.ui.text.input.TextFieldValue
|
|
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 kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.awaitCancellation
|
|
import kotlinx.coroutines.cancelAndJoin
|
|
import kotlinx.coroutines.channels.Channel
|
|
import kotlinx.coroutines.delay
|
|
import kotlinx.coroutines.flow.drop
|
|
import kotlinx.coroutines.flow.first
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.withContext
|
|
|
|
/**
|
|
* How big the "still loading this conversation" spinner is: bigger than the ones inside a tool
|
|
* card, which report on one call among many, and smaller than a splash.
|
|
*/
|
|
private val LOADING_SPINNER = 48.dp
|
|
|
|
/** One callback from the blocking SSE reader, queued in wire order for the UI thread. */
|
|
private sealed interface TranscriptDelivery {
|
|
data object Opened : TranscriptDelivery
|
|
|
|
data object Reset : TranscriptDelivery
|
|
|
|
data class Event(val entry: SeqEvent) : TranscriptDelivery
|
|
}
|
|
|
|
/**
|
|
* How close, in screenfuls of estimated scroll, the reader may come to the end of loaded history
|
|
* before the next page is fetched.
|
|
*
|
|
* Multiplied by the viewport to give a number of *pixels*, which is the distance the question is
|
|
* actually about. A row is anything from one line to a page, so a count of rows is that distance
|
|
* only by accident: eight rows was the number once, and on a tool-heavy transcript that is less
|
|
* than one screen, so the reader ran out of loaded transcript on every swipe.
|
|
*
|
|
* Six, because the two ways to be wrong are not the same size: firing early costs a page nobody
|
|
* reads, firing late is a spinner under somebody's finger for a whole round trip over the tunnel.
|
|
*/
|
|
private const val HISTORY_SCREENS = 6
|
|
|
|
/**
|
|
* How many *rows* a backwards page asks for.
|
|
*
|
|
* Rows, not events, because the two are nothing alike: a reply is stored a token at a time, and on
|
|
* one real transcript (2,426 events) the whole conversation was seven assistant messages, the
|
|
* median folding four hundred deltas into one row. A page counted in events was a fifth of a single
|
|
* row, so reaching a screenful took dozens of sequential round trips.
|
|
*
|
|
* A few screens' worth, so one page clears the cushion below. A page that still falls short is
|
|
* followed by another in the background, nobody waiting on either.
|
|
*
|
|
* The opening page stays counted in events and small: it is on the critical path of showing the
|
|
* screen, and it is the newest window, where coalescing is unsafe for the live cursor anyway.
|
|
*/
|
|
private const val HISTORY_PAGE = 40
|
|
|
|
/**
|
|
* 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 in one request rather than
|
|
* walking there a page at a time. This bounds it anyway, because "exactly how far" is however far
|
|
* the reader had scrolled -- and a single response of arbitrary size is the shape a phone on a slow
|
|
* tunnel handles worst. At roughly 800 bytes an event, this is about three megabytes. Going past it
|
|
* costs another request rather than anything being missed.
|
|
*/
|
|
private const val RESTORE_PAGE_MAX = 4000
|
|
|
|
/**
|
|
* Events added past the anchor on a restore, so the anchor's row is never the oldest loaded one.
|
|
*
|
|
* The oldest loaded row is a half-row -- [joinPages] welds its other half on when the page behind
|
|
* it arrives, and it grows -- so a restore stopping exactly at the anchor would put the reader a
|
|
* screen out once that growth landed.
|
|
*/
|
|
private const val RESTORE_PAGE_CUSHION = 400
|
|
|
|
/** Every input that can make the history boundary need another request. */
|
|
private data class HistoryLoadSignal(
|
|
val layout: LazyListLayoutInfo,
|
|
val restoring: Boolean,
|
|
val moreHistory: Boolean,
|
|
val loading: Boolean,
|
|
val error: String?,
|
|
val oldestSeq: Long,
|
|
)
|
|
|
|
/**
|
|
* Which row was asked to hold its top edge, and how tall it was when it last measured.
|
|
*
|
|
* Deliberately *not* snapshot state, which is the point of the 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 already being laid out. [key] is
|
|
* cleared by the resize it was set for, so it cannot be spent on an unrelated one.
|
|
*/
|
|
private class TopEdgeHold {
|
|
var key: Any? = null
|
|
}
|
|
|
|
/** One row's height between layouts, so a change in it can be noticed. See [holdTopEdge]. */
|
|
private class LastHeight {
|
|
var value: Int? = null
|
|
}
|
|
|
|
/**
|
|
* Which row the last touch landed in, and whether it landed in the row's top half -- the end that
|
|
* row should hold when it changes height; see [holdTopEdge].
|
|
*
|
|
* One slot rather than a map, because only the touch about to toggle something matters:
|
|
* [toggleAnchored] reads it in the same gesture that wrote it. Written from a detector on each
|
|
* *visible* row, so it costs only rows on screen and runs on touch rather than per frame.
|
|
*/
|
|
private class LastTouch {
|
|
var key: Any? = null
|
|
var high = false
|
|
}
|
|
|
|
/**
|
|
* Keeps this row's top edge where it is when the row changes height, if it was asked to.
|
|
*
|
|
* This runs in the *layout* phase, from the measurement that discovers the new height, and that is
|
|
* why 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 first -- visible as a
|
|
* flick, and worse the faster the screen refreshes.
|
|
*
|
|
* [hold] is given the change in height. The row's bottom edge is held by the list, so a scroll of
|
|
* exactly that much leaves the top edge where it was.
|
|
*/
|
|
@Composable
|
|
private fun Modifier.holdTopEdge(key: Any, held: TopEdgeHold, hold: (Int) -> Unit): Modifier {
|
|
val last = remember { LastHeight() }
|
|
return onSizeChanged { size ->
|
|
val previous = last.value
|
|
last.value = size.height
|
|
// A first measurement has no previous height to have moved from, and a row that came back
|
|
// after being scrolled away is a first measurement again.
|
|
if (previous == null || previous == size.height || held.key != key) return@onSizeChanged
|
|
held.key = null
|
|
hold(size.height - previous)
|
|
}
|
|
}
|
|
|
|
// The transcript's data model -- TranscriptItem, foldEvent, joinPages, warm -- lives in
|
|
// TranscriptItems.kt: it is pure event folding with no screen in it.
|
|
//
|
|
// isImeVisible: see the comment beside `imeVisible` below.
|
|
@OptIn(ExperimentalLayoutApi::class)
|
|
@Composable
|
|
fun SessionScreen(
|
|
settings: ServerSettings,
|
|
summary: SessionSummary,
|
|
onBack: () -> Unit,
|
|
/** Opens the file explorer on this session's machine, starting where this session works. */
|
|
onFiles: (FilesTarget) -> Unit,
|
|
/** What another app shared in while this session is the one open; see [ShareRequest]. */
|
|
share: ShareRequest? = null,
|
|
/** Said once [share] has been attached here, so it is not attached again. */
|
|
onShareTaken: () -> Unit = {},
|
|
/**
|
|
* Draws this screen read-only, on a subagent's own transcript instead of the session's.
|
|
*
|
|
* A subagent has no process and no controls of its own -- see SUBAGENTS.md's "Phone" -- so
|
|
* every gate below keyed on this switches off the composer, the files button, the settings cog,
|
|
* the usage bar and notifications, while everything that draws a transcript (paging, cache,
|
|
* selection, images, the status row, stream reconnects) is reused unchanged, pointed at
|
|
* [address] instead of the session's own.
|
|
*/
|
|
subagent: SubagentSummary? = null,
|
|
) {
|
|
DebugStats.count("session screen recomposed")
|
|
val isSubagent = subagent != null
|
|
val address = TranscriptAddress(summary.id, subagent?.id)
|
|
val scope = rememberCoroutineScope()
|
|
val topEdgeHeld = remember { TopEdgeHold() }
|
|
var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
|
|
var status by remember { mutableStateOf(subagent?.status ?: summary.status) }
|
|
var backgroundTasks by
|
|
remember(address) { mutableIntStateOf(if (isSubagent) 0 else summary.backgroundTasks) }
|
|
// 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.
|
|
//
|
|
// A subagent has no context measurement of its own, so it always starts unmeasured rather than
|
|
// borrowing the parent session's figure -- see UI_RULES on not showing an inferred value as one
|
|
// that was measured.
|
|
var contextTokens by
|
|
remember(address) { mutableStateOf(if (isSubagent) null else summary.contextTokens) }
|
|
// When the current compaction started. 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.
|
|
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 that 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.
|
|
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 does not throw away a half-typed message. See `Drafts.kt`.
|
|
//
|
|
// A subagent has no box to type into, so it never touches a draft at all -- not this session's,
|
|
// which is what reading one keyed only by `summary.id` would do here.
|
|
var input by
|
|
remember(summary.id) {
|
|
mutableStateOf(if (isSubagent) atEnd("") else atEnd(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.
|
|
var pendingModel by remember { mutableStateOf<String?>(null) }
|
|
// What was last taken from the command suggestions, so the list closes behind it.
|
|
var picked by remember { mutableStateOf<String?>(null) }
|
|
// The transcript's selection, held here rather than inside [TranscriptList] because the rows
|
|
// have to ask whether anything is selected before they treat a tap as their own.
|
|
val selection = rememberSelectionState()
|
|
// Read here, at composition, rather than inside [expanding] at the moment of the click. The
|
|
// container clears the selection from the very press a card then reads as its own, a few
|
|
// milliseconds earlier and in the same event -- so a card asking the live state always hears
|
|
// "nothing is selected", and a tap meant to put a selection away also shut the tool call the
|
|
// words were in. This is whatever was true as of the last frame.
|
|
val selecting = selection.selectedTexts.isNotEmpty()
|
|
var expandedTools by remember { mutableStateOf(setOf<String>()) }
|
|
// Which runs of adjacent tool calls are open, by the group row's own key, so a group survives
|
|
// more calls arriving after it.
|
|
var expandedGroups by remember { mutableStateOf(setOf<String>()) }
|
|
// Calls already drawn inside a group, so being folded into one is noticed exactly once each.
|
|
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.
|
|
var expandedNotes by remember { mutableStateOf(setOf<Long>()) }
|
|
// Which memory notes are open, by the note's own text. Held here rather than in the card so a
|
|
// note opened and scrolled past is still open on the way back.
|
|
var openMemories by remember { mutableStateOf(setOf<String>()) }
|
|
// The image being looked at full screen, by ref. Here rather than in the row that drew the
|
|
// thumbnail: a row regrouped underneath the reader takes its whole subtree with it.
|
|
var fullImage by remember { mutableStateOf<String?>(null) }
|
|
// Uploaded-but-not-yet-sent attachment ids; sent with the next message.
|
|
var pendingAttachments by remember { mutableStateOf(listOf<String>()) }
|
|
// 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 -- 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) }
|
|
// The thinking level, held here for the same reason [title] is: the settings dialog can change
|
|
// it, and the row this screen was opened from is a snapshot taken before that. Read straight
|
|
// from the summary, a level set here was drawn as the old one again the next time the dialog
|
|
// was opened, and only came back right after a return to the list refetched the row.
|
|
var effort by remember(summary.id) { mutableStateOf(summary.effort) }
|
|
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()) }
|
|
var offeredPermissionModes 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) }
|
|
// Bumped to rebuild this screen from nothing -- what Reload in the settings dialog does. It
|
|
// keys everything describing one visit: the source, the opening effect, the stream, and the
|
|
// anchor. See TRANSCRIPT_CACHE.md's decision 8.
|
|
var epoch by remember(summary.id) { mutableIntStateOf(0) }
|
|
// This server's cached transcripts, and this session's half of them. The cache is per server
|
|
// because two servers can hold a session with the same id; the source is per visit because
|
|
// Reload throws away what it was reading from.
|
|
val cache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
|
|
val source =
|
|
remember(address, epoch) { TranscriptSource(settings, address, cache.session(address)) }
|
|
// Whether the cached tail has been shown to still be the server's own line. Nothing is resumed
|
|
// from a cached cursor until it has, and a probe that could not be made leaves this false for
|
|
// the stream loop to try again.
|
|
var probePassed by remember(address, epoch) { mutableStateOf(false) }
|
|
// Whether the opening effect is still settling that question. It draws the cached rows and
|
|
// lifts [ready] before the answer arrives, which is the point of the cache -- so the stream
|
|
// below waits for this rather than for `ready`, or it asks the same question twice.
|
|
var probing by remember(address, epoch) { mutableStateOf(true) }
|
|
// The oldest sequence number loaded, and whether there is more behind it. Paging backwards is
|
|
// what keeps opening a long session cheap.
|
|
var oldestSeq by remember { mutableLongStateOf(0L) }
|
|
// Where this transcript was last being read, from this device's own store, keyed by the address
|
|
// rather than the session id so a subagent's saved position cannot collide with its session's.
|
|
// Read once, because the answer stops being interesting the moment the list is on screen.
|
|
val savedAnchor = remember(address, epoch) { loadScrollAnchor(context, address.cachePath) }
|
|
// Whether the saved position is still being put back. Nothing is drawn while it is: opening at
|
|
// the newest end and then travelling to the anchor is exactly the journey a reader must never
|
|
// see.
|
|
var restoring by remember(address, epoch) { mutableStateOf(savedAnchor != null) }
|
|
// Messages not yet recorded as received by the provider. The local ones bridge Send to the
|
|
// server's first durable event (and hold a network failure); the rest are reconstructed from
|
|
// that event stream, so another device's queued message is drawn here too.
|
|
val pendingKey =
|
|
remember(settings, address) { "${settings.host}:${settings.port}/${address.cachePath}" }
|
|
var queued by remember(pendingKey) { mutableStateOf(loadPendingMessages(context, pendingKey)) }
|
|
|
|
/**
|
|
* Changes the visible outbox and keeps only its not-yet-durable part across a screen reopen.
|
|
*/
|
|
fun replaceQueued(messages: List<QueuedMessage>) {
|
|
queued = messages
|
|
savePendingMessages(context, pendingKey, messages)
|
|
}
|
|
// Commands the session has been asked to run and cannot yet, by the id that will resolve them.
|
|
// From the server, so a rename sent from another device is drawn waiting here too.
|
|
var waitingCommands by remember { mutableStateOf(listOf<Pair<String, String>>()) }
|
|
val running = sessionWorking(status)
|
|
var moreHistory by remember { mutableStateOf(true) }
|
|
var loadingHistory by remember { mutableStateOf(false) }
|
|
var historyError by remember(address, epoch) { mutableStateOf<String?>(null) }
|
|
var ready by remember { mutableStateOf(false) }
|
|
// The driver owns recognising provider prose; this screen responds only to its actionable
|
|
// event.
|
|
var loginOpen by remember(address) { mutableStateOf(false) }
|
|
// Replies parsed ahead of the rows that draw them; see [ParsedReplies].
|
|
val replies = remember(address) { ParsedReplies() }
|
|
// Keyed like everything else describing one transcript. `rememberLazyListState` saves through
|
|
// `rememberSaveable`, and this screen restores by its own anchor instead -- two restores would
|
|
// fight over the first frame.
|
|
val listState = remember(address) { LazyListState() }
|
|
// Whether the newest message is on screen right now. The list is reversed, so the newest end is
|
|
// the scrolling start: nothing behind you is exactly being at the bottom. Asked of the scroll
|
|
// state rather than of item indices, because a zero-height first item makes an index ambiguous.
|
|
// This is what the jump-to-newest button watches, and the gate on recording.
|
|
val atNewest by remember { derivedStateOf { !listState.canScrollBackward } }
|
|
// A decision made only by an actual scroll gesture. `canScrollBackward` also changes while a
|
|
// streaming row is laid out, and treating that displacement as intent freezes the rest of the
|
|
// reply off-screen while still advancing the SSE cursor.
|
|
var followingNewest by remember(address, epoch) { mutableStateOf(savedAnchor == null) }
|
|
// 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].
|
|
var held by remember { mutableStateOf(listOf<SeqEvent>()) }
|
|
// What is actually drawn: the transcript with runs of adjacent tool calls folded into one row.
|
|
val rows = remember(items) { groupToolRuns(items) }
|
|
// Bumped when a cold reply's parses become ready, so the flatten runs again and can split it.
|
|
var warmedTick by remember { mutableIntStateOf(0) }
|
|
val units =
|
|
remember(rows, expandedNotes, warmedTick) { transcriptUnits(rows, replies, expandedNotes) }
|
|
// The reply that just finished streaming is the one row whose parses nobody has made: pages
|
|
// warm before their fold lands, but nothing warms live deltas. Off the composing thread, then
|
|
// the tick re-flattens, so settling never costs a whole-message parse in a frame.
|
|
LaunchedEffect(rows) {
|
|
val cold = unwarmedReplies(rows, replies)
|
|
if (cold.isNotEmpty()) {
|
|
warm(replies, cold)
|
|
warmedTick++
|
|
}
|
|
}
|
|
// The same list, readable from effects launched before this composition: an effect's closure
|
|
// keeps the values of the composition that launched it.
|
|
val currentUnits by rememberUpdatedState(units)
|
|
val lastTouch = remember { LastTouch() }
|
|
|
|
/**
|
|
* Drops everything loaded, so the screen can be rebuilt from a window that is not adjacent to
|
|
* it.
|
|
*
|
|
* One function rather than a clearing written at each of the three places that need it -- a
|
|
* stream reset, a cached transcript the server turns out not to have, and Reload -- because
|
|
* what has to go is a property of "these rows are no longer continuous with what comes next".
|
|
* The two easy ones to leave out are [queued] and [waitingCommands]: both are folded from
|
|
* events, so a `messageQueued` whose resolving `userMessage` fell in the gap draws a bubble
|
|
* waiting for a message the session read long ago. [contextTokens] needs no clearing, because
|
|
* `UsageDelta.context` is absolute.
|
|
*
|
|
* The resume cursor is deliberately *not* cleared: a reset continues from where it was.
|
|
*/
|
|
fun dropLoadedTranscript() {
|
|
items = listOf()
|
|
replies.clear()
|
|
held = listOf()
|
|
oldestSeq = 0L
|
|
moreHistory = true
|
|
historyError = null
|
|
// A send the server could not accept is this phone's only copy. A reset replaces server
|
|
// state, not that local outbox, so dropping it here would eat the message a second time.
|
|
replaceQueued(queued.filter { it.local })
|
|
waitingCommands = listOf()
|
|
}
|
|
|
|
/**
|
|
* 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 waiting six seconds ended at the very
|
|
* bottom.
|
|
*
|
|
* Insertions were never the problem -- the list is keyed. What cannot be allowed is a row that
|
|
* is already there changing height, and the one guarantee covering every way that happens 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
|
|
// 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) {
|
|
replaceQueued(reconcileQueuedMessage(queued, event))
|
|
}
|
|
if (event is SessionEvent.UserMessage) {
|
|
replaceQueued(reconcileUserMessage(queued, event))
|
|
}
|
|
// Waiting, then taken back. From the server rather than from the tap, so every device drops
|
|
// the bubble and a reconnect does not put back one that was cancelled.
|
|
if (event is SessionEvent.MessageDropped) {
|
|
replaceQueued(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. See `contextAfter`.
|
|
contextTokens = contextAfter(contextTokens, entry.event)
|
|
when (val event = entry.event) {
|
|
// Nothing further: what it carries was folded into the context above.
|
|
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 -- and a compaction worth asking
|
|
// about is a long one.
|
|
compactingSince =
|
|
when {
|
|
event.state != "compacting" -> null
|
|
status == "compacting" -> compactingSince
|
|
else -> entry.ts
|
|
}
|
|
status = event.state
|
|
}
|
|
if (event is SessionEvent.BackgroundTasks && !isSubagent) {
|
|
backgroundTasks = event.count
|
|
}
|
|
if (!isSubagent) loginOpen = authenticationPromptAfter(loginOpen, event)
|
|
// 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.
|
|
// Read on the UI thread (the stream below marshals every frame here). This direct
|
|
// check closes the small window before the scroll observer records the gesture.
|
|
if (listState.isScrollInProgress && !atNewest) followingNewest = false
|
|
if (followingNewest && held.isEmpty()) record(entry) else held = held + entry
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A press on the transcript that would open or close something, and the one thing every such
|
|
* press has to check first.
|
|
*
|
|
* The transcript is one [SelectionContainer], so a reader who has selected some text puts that
|
|
* selection away by tapping -- and the tap that does it lands on whatever card the text is
|
|
* drawn in. Left alone, that card takes it as a press of its own: the reader clears a selection
|
|
* and the tool call under their finger collapses. So a press with a selection outstanding
|
|
* spends itself clearing it and does nothing else.
|
|
*
|
|
* Every open and close goes through here rather than each writing the check, since which card
|
|
* the finger lands on is not something the reader chose.
|
|
*/
|
|
fun expanding(toggle: () -> Unit) {
|
|
if (selecting) {
|
|
selection.clear()
|
|
return
|
|
}
|
|
toggle()
|
|
}
|
|
|
|
/**
|
|
* 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. 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 that one asks the row to hold its top edge instead.
|
|
*
|
|
* Which half decides it, rather than which control was pressed, so everything that opens
|
|
* behaves the same way whether or not it has a control at each end.
|
|
*
|
|
* The correction itself belongs to the measurement -- see [holdTopEdge].
|
|
*/
|
|
fun toggleAnchored(row: TranscriptRow, toggle: () -> Unit) = expanding {
|
|
if (lastTouch.key == row.key && lastTouch.high) topEdgeHeld.key = row.key
|
|
toggle()
|
|
}
|
|
|
|
/**
|
|
* Whether the row holding transcript position [seq] is loaded, with older history behind it.
|
|
*
|
|
* "Behind it" is the part easy to leave out. The oldest loaded row is a half-row -- [joinPages]
|
|
* welds the other half on 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, and the reader's place is
|
|
* inside whichever row now holds that seq.
|
|
*
|
|
* Computed from `items` rather than `rows` for the reason [loadOlderPage] gives.
|
|
*/
|
|
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.
|
|
*
|
|
* 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, coalesce: Boolean = true): Boolean {
|
|
// Nothing is loaded, so there is no "before" to ask about -- and asking anyway is not a
|
|
// harmless no-op: `before = 0` fetches the events before the first one, which is none, and
|
|
// an empty page is how this function is told it has reached the start of the conversation.
|
|
// It would latch `moreHistory` false and the session could never be paged back at all.
|
|
//
|
|
// The window it fires in is the first layout: `moreHistory` starts true, which puts the
|
|
// history spinner in the list and makes `visibleItemsInfo` non-empty before a single event
|
|
// has arrived. On a loopback server the opening page beat it; at `--delay 150`, which is
|
|
// what a phone over the tunnel costs, it won the race.
|
|
//
|
|
// Guarded here rather than at the two callers because it is a fact about the question.
|
|
val before = oldestSeq
|
|
if (before == 0L) return false
|
|
// The fetch *and* the fold, both off the thread that draws. Only the fetch used to be, and
|
|
// the fold is the expensive half: `foldEvent` returns a new list per event, so a page is
|
|
// that many copies of a growing list -- around three hundred thousand element copies, run
|
|
// on the main thread in the middle of the scroll that asked for it.
|
|
//
|
|
// `Dispatchers.IO` for both rather than a hop to `Default` between them: the two are one
|
|
// errand. Neither half touches anything the composition owns.
|
|
val page =
|
|
withContext(Dispatchers.IO) {
|
|
val older = source.page(before = before, limit = limit, coalesce = coalesce)
|
|
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
|
|
}
|
|
// A stream reset can replace the transcript while the page request is out. Its answer is a
|
|
// page of the view that no longer exists, so it must not decide anything about the new one.
|
|
if (oldestSeq != before) return false
|
|
if (page == null) {
|
|
moreHistory = false
|
|
return false
|
|
}
|
|
val (oldest, earlier) = page
|
|
// 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.
|
|
val joinedForWarm = 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.
|
|
warm(replies, joinedForWarm)
|
|
// `warm` suspends. A live event can be recorded while it parses, so joining the page before
|
|
// it and then assigning that snapshot afterward used to erase the event from this visit --
|
|
// the durable transcript still held it, which is why reopening made a sent message return.
|
|
// Read and write `items` together after the suspension instead.
|
|
if (oldestSeq != before) return false
|
|
oldestSeq = oldest
|
|
moreHistory = oldestSeq > 1L
|
|
items = joinPages(earlier, items)
|
|
return true
|
|
}
|
|
|
|
/** One guarded history request, shared by automatic paging and the explicit retry control. */
|
|
suspend fun requestOlderPage(): Boolean {
|
|
if (loadingHistory) return false
|
|
loadingHistory = true
|
|
historyError = null
|
|
return try {
|
|
loadOlderPage()
|
|
} catch (e: ApiException) {
|
|
historyError = e.message ?: "Unknown error"
|
|
false
|
|
} finally {
|
|
loadingHistory = false
|
|
}
|
|
}
|
|
|
|
// A call opened on its own stays open when it is folded into a group -- either because a second
|
|
// call in the same run arrived, or because something landed behind the last call and it
|
|
// rejoined the run it was standing outside of. 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".
|
|
//
|
|
// Per call rather than per group, because a group outlives the calls joining it: considered at
|
|
// the moment each call first lands inside one, and never again, so the reader who then shuts
|
|
// the group has shut it.
|
|
LaunchedEffect(rows) {
|
|
val fresh =
|
|
rows.filterIsInstance<TranscriptRow.Tools>().flatMap { group ->
|
|
group.calls.filter { it.id !in everGrouped }.map { group.key to it.id }
|
|
}
|
|
if (fresh.isEmpty()) return@LaunchedEffect
|
|
expandedGroups =
|
|
expandedGroups + fresh.filter { it.second in expandedTools }.map { it.first }
|
|
everGrouped = everGrouped + fresh.map { it.second }
|
|
}
|
|
|
|
// A compaction reports nothing about its own progress -- measured against the CLI, which says
|
|
// it has started and then 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. 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.
|
|
//
|
|
// The newest window first, before the stream opens, so the stream starts from where that window
|
|
// ended and carries live events only. The window comes from this phone's own copy when there is
|
|
// one, and then costs a single request to check that the server's transcript is still the one
|
|
// it came from. See TRANSCRIPT_CACHE.md.
|
|
LaunchedEffect(address, epoch) {
|
|
/**
|
|
* One opening window onto the screen, whichever side it came from.
|
|
*
|
|
* Warmed before the fold lands rather than after: flattening the rows into units splits
|
|
* every settled reply, and the flatten runs in the composition that first sees the rows.
|
|
*/
|
|
suspend fun open(page: List<SeqEvent>) {
|
|
withContext(Dispatchers.IO) {
|
|
var scratch = listOf<TranscriptItem>()
|
|
page.forEach { entry ->
|
|
if (entry.event !is SessionEvent.UsageDelta) {
|
|
scratch = foldEvent(scratch, entry)
|
|
}
|
|
}
|
|
warm(replies, scratch)
|
|
}
|
|
page.forEach { apply(it) }
|
|
}
|
|
|
|
try {
|
|
// This phone's own copy first, drawn before anything is asked of the server. What makes
|
|
// it safe to draw before it is checked is that a failed check replaces these rows, with
|
|
// the same appearance as a reset.
|
|
val cached = withContext(Dispatchers.IO) { source.cachedOpening() }
|
|
if (cached != null) {
|
|
open(cached)
|
|
// A replay is as old as the last visit; the row this screen was opened from was
|
|
// fetched moments ago. So the transcript comes from the cache and everything that
|
|
// is not the transcript comes from the summary -- otherwise a session that finished
|
|
// an hour ago opens saying "working" until the stream connects. A subagent's status
|
|
// comes from its own summary, never the parent session's: they are two different
|
|
// things running or not, and the parent's model and permission mode do not apply to
|
|
// it at all.
|
|
status = subagent?.status ?: summary.status
|
|
if (!isSubagent) {
|
|
model = summary.model
|
|
permissionMode = summary.permissionMode ?: "auto"
|
|
}
|
|
if (status != "compacting") compactingSince = null
|
|
// Nothing to put back, so these rows are the screen and the probe can return under
|
|
// them. A restore still has history to fetch and is gated below.
|
|
if (savedAnchor == null) ready = true
|
|
}
|
|
// The one thing a cached cursor has to be shown before the stream resumes from it.
|
|
val usable = cached != null && withContext(Dispatchers.IO) { source.probe() }
|
|
if (usable) probePassed = true
|
|
if (!usable) {
|
|
// Either there was nothing cached, or what was cached is not what the server has --
|
|
// the file was replaced or truncated under it. Same clearing as a reset, then an
|
|
// ordinary cold open.
|
|
if (cached != null) {
|
|
dropLoadedTranscript()
|
|
lastSeq.set(0)
|
|
}
|
|
open(withContext(Dispatchers.IO) { source.fetchOpening() })
|
|
// Refilled from the server, so the tail is the server's by construction.
|
|
probePassed = true
|
|
}
|
|
} catch (e: ApiException) {
|
|
// Not fatal: the stream below still replays from zero, which is slow but complete.
|
|
//
|
|
// It is also where a probe that could not be *made* lands -- a phone with no route to
|
|
// the server. Whatever was cached stays on screen and [probePassed] stays false, so the
|
|
// stream loop asks again before it resumes from that cursor.
|
|
streamError = e.message
|
|
} finally {
|
|
// However that went, the stream is free to take it from here.
|
|
probing = false
|
|
}
|
|
|
|
try {
|
|
// 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.
|
|
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 that grows when the page behind it arrives, so
|
|
// anchoring into one puts the reader where they were only until that lands.
|
|
//
|
|
// This terminates because `oldestSeq` walks strictly backwards and the anchor is a
|
|
// seq. 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.
|
|
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 overshoots into older history rather than stopping
|
|
// short.
|
|
//
|
|
// 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. Raw, not coalesced: this counts events back to a
|
|
// known seq, and a page measured in rows cannot be counted to one.
|
|
val behind = oldestSeq - anchor.seq
|
|
val loaded =
|
|
if (behind < 0) {
|
|
// The anchor's row is loaded but is the oldest half-row, which
|
|
// [anchorRow] refuses; what completes it is the row before it, and only
|
|
// a page counted in rows can promise to reach that. Counted in events
|
|
// the span is negative and was coerced to one: a request per delta, six
|
|
// hundred round trips for an anchor inside a 1,400-delta reply.
|
|
loadOlderPage()
|
|
} else {
|
|
loadOlderPage(
|
|
(behind + RESTORE_PAGE_CUSHION)
|
|
.coerceIn(1L, RESTORE_PAGE_MAX.toLong())
|
|
.toInt(),
|
|
coalesce = false,
|
|
)
|
|
}
|
|
if (!loaded) break
|
|
}
|
|
// Resolved to the row that *holds* the saved position rather than passed straight
|
|
// through, because the two are not always the same seq: the events behind a row
|
|
// regroup between the save and the reopen. Null is a row no longer in the
|
|
// transcript at all, and means there is nothing to put back.
|
|
anchorRow(anchor.seq)?.let { rowSeq ->
|
|
// The units are built by composition, and this coroutine has been loading rows
|
|
// the composition may not have seen -- so wait for the build that holds the
|
|
// anchor's row before turning it into an index. Guaranteed to arrive, because
|
|
// the units are a pure function of `items`. Nothing is drawn during the wait.
|
|
// One past the index, because item zero is the "below" slot.
|
|
val index =
|
|
snapshotFlow { unitIndexFor(currentUnits, rowSeq, anchor.unit) }
|
|
.first { it != null }!!
|
|
listState.scrollToItem(index + 1, anchor.offset)
|
|
}
|
|
}
|
|
} catch (e: ApiException) {
|
|
// A page of history that never arrived. The reader is left at the newest end rather
|
|
// than where they were, which is the state this screen opens in anyway.
|
|
streamError = e.message
|
|
}
|
|
// Whatever happened above: an empty transcript is a state the screen can draw, and a
|
|
// permanently blank one is not.
|
|
followingNewest = atNewest
|
|
restoring = false
|
|
ready = true
|
|
// The opening page is sized for time-to-first-frame, not for reading: it fills a viewport
|
|
// or two, so the first "still loading" boundary sat barely off-screen and the first upward
|
|
// scroll met it and waited a round trip. So the first full page goes right behind it, while
|
|
// the screen is already up. A restore skips this: it has just paged as deep as it needed.
|
|
if (savedAnchor == null && moreHistory && !loadingHistory) {
|
|
requestOlderPage()
|
|
}
|
|
// Last, and off this thread: this session is what must not be evicted, so it is marked as
|
|
// visited before the budget is applied, and both are a walk of the cache directory.
|
|
withContext(Dispatchers.IO) {
|
|
source.cache.touch()
|
|
cache.evictToBudget(keep = summary.id)
|
|
}
|
|
}
|
|
|
|
// 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" waiting
|
|
// at the top on their return. Switching apps is a choice somebody made, not a fault to report.
|
|
// Stopping the stream deliberately makes the drop a close rather than an error, and resuming
|
|
// reconnects from the same cursor.
|
|
LaunchedEffect(address, ready, epoch, lifecycleOwner) {
|
|
if (!ready) return@LaunchedEffect
|
|
// The opening effect draws cached rows and lifts `ready` *before* it has checked that the
|
|
// cursor under them is still the server's, so `ready` is no longer the whole gate. Without
|
|
// this the two run at once and race each other's answer -- two probes per warm open.
|
|
snapshotFlow { probing }.first { !it }
|
|
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
|
try {
|
|
while (true) {
|
|
try {
|
|
// A cached cursor whose probe never got an answer, because the server could
|
|
// not be reached when the screen opened. Resuming from an unchecked cursor
|
|
// is the one thing this must not do, so it is asked again here with the
|
|
// cached rows still on screen. False covers both answers that mean "open
|
|
// cold".
|
|
if (!probePassed) {
|
|
if (withContext(Dispatchers.IO) { source.probe() }) {
|
|
probePassed = true
|
|
} else {
|
|
dropLoadedTranscript()
|
|
lastSeq.set(0)
|
|
withContext(Dispatchers.IO) { source.fetchOpening() }
|
|
.forEach { apply(it) }
|
|
probePassed = true
|
|
}
|
|
}
|
|
// The socket is blocking, but Compose state belongs to this UI coroutine.
|
|
// Queue every callback -- including reset -- through one channel so none
|
|
// can race layout or overtake another while crossing threads.
|
|
val delivery = Channel<TranscriptDelivery>(Channel.UNLIMITED)
|
|
val follower =
|
|
launch(Dispatchers.IO) {
|
|
try {
|
|
source.follow(
|
|
after = lastSeq.get(),
|
|
onOpen = { delivery.trySend(TranscriptDelivery.Opened) },
|
|
onReset = { delivery.trySend(TranscriptDelivery.Reset) },
|
|
) { entry ->
|
|
delivery.trySend(TranscriptDelivery.Event(entry))
|
|
}
|
|
delivery.close()
|
|
} catch (error: Throwable) {
|
|
delivery.close(error)
|
|
}
|
|
}
|
|
try {
|
|
for (next in delivery) {
|
|
when (next) {
|
|
TranscriptDelivery.Opened -> streamError = null
|
|
TranscriptDelivery.Reset -> {
|
|
// The fresh window has no position in common with the rows
|
|
// just dropped, so it becomes the view even if the reader
|
|
// had been further back in the stale prefix.
|
|
dropLoadedTranscript()
|
|
followingNewest = true
|
|
}
|
|
is TranscriptDelivery.Event -> apply(next.entry)
|
|
}
|
|
}
|
|
} finally {
|
|
source.close()
|
|
follower.cancelAndJoin()
|
|
}
|
|
} catch (e: kotlinx.coroutines.CancellationException) {
|
|
// Leaving the screen or going below STARTED. Not a failure, and swallowing
|
|
// it would leave this loop reconnecting forever.
|
|
throw e
|
|
} catch (e: Exception) {
|
|
// Any failure, not only an [ApiException]: the stream reconnects from its
|
|
// cursor, so there is nothing a failure here can cost that is worth closing
|
|
// the app over.
|
|
streamError = e.message ?: e::class.simpleName
|
|
} finally {
|
|
source.close()
|
|
}
|
|
delay(RECONNECT_DELAY_MS)
|
|
}
|
|
} finally {
|
|
// Cancellation cannot interrupt a blocking socket read. Closing is what unblocks
|
|
// it, and what marks the drop deliberate.
|
|
source.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. Keyed on the epoch as well, so
|
|
// Reload's replacement source is the one a later disposal closes.
|
|
DisposableEffect(address, epoch) { onDispose { source.close() } }
|
|
|
|
// Nothing gets announced about the session somebody is reading; see NotificationService.
|
|
// RESUMED rather than STARTED because "looking at it" means the foreground.
|
|
//
|
|
// Not for a subagent: it has no notifications of its own, and it is not the session this would
|
|
// otherwise mark as being read.
|
|
if (!isSubagent) {
|
|
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.
|
|
LaunchedEffect(listState) {
|
|
snapshotFlow { Triple(listState.isScrollInProgress, atNewest, held.isNotEmpty()) }
|
|
.collect { (scrolling, newest, hasHeld) ->
|
|
if (scrolling && !newest) followingNewest = false
|
|
if (!newest) return@collect
|
|
followingNewest = true
|
|
if (!hasHeld) 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.
|
|
//
|
|
// The place is the first visible item -- in this reversed list, the one at the *bottom* of the
|
|
// viewport -- named by its row's seq and its unit within the row, which are the two things that
|
|
// survive a reopen. The index does not, and the key does not either; see [ScrollAnchor].
|
|
LaunchedEffect(listState) {
|
|
snapshotFlow {
|
|
if (listState.isScrollInProgress) null
|
|
else
|
|
Triple(
|
|
listState.firstVisibleItemIndex,
|
|
listState.firstVisibleItemScrollOffset,
|
|
listState.canScrollBackward,
|
|
)
|
|
}
|
|
// The value `snapshotFlow` emits on collection is where the list sits before anybody
|
|
// has touched it, which is not somewhere they left off. Taking it as one wiped every
|
|
// saved anchor on the way in -- before the restore above could use it.
|
|
.drop(1)
|
|
.collect { settled ->
|
|
if (settled == null || restoring) return@collect
|
|
val (index, offset, awayFromNewest) = settled
|
|
saveScrollAnchor(
|
|
context,
|
|
address.cachePath,
|
|
// Nothing to restore at the newest end, which is where a session with no anchor
|
|
// opens anyway. One *before* the index, because item zero is the "below" slot.
|
|
if (!awayFromNewest) null
|
|
else
|
|
currentUnits.getOrNull(index - 1)?.let {
|
|
ScrollAnchor(it.seq, offset, it.ordinal)
|
|
},
|
|
)
|
|
}
|
|
}
|
|
// Reaching within a few screens of the far end of what is loaded fetches the page before it.
|
|
//
|
|
// The question is pixels of scroll -- how far can the reader keep going before they run out --
|
|
// and a lazy list cannot answer it exactly, because it has never measured the items it has not
|
|
// composed. So the room ahead is added up from the real size of every unit the list *has* laid
|
|
// out, kept by key as units pass through the viewport, with the running average standing in for
|
|
// the ones it has never seen. It used to be the average of the units currently on screen, which
|
|
// is the worst possible sample: two tall blocks fill a viewport, multiply out over dozens of
|
|
// unseen one-line rows, and report screens of room when the end is one swipe away.
|
|
//
|
|
// There is no correction beside this one. Following the newest message is not an effect: the
|
|
// list is reversed, so an arriving message extends the end the viewport is pinned to.
|
|
val unitSizes = remember(address) { HashMap<Any, Int>() }
|
|
// Every state that can make another request useful is part of the collected value. In
|
|
// particular, the opening prefetch used to advance `oldestSeq` while `loadingHistory` was still
|
|
// true; the effect restarted, declined to overlap it, and never noticed when loading became
|
|
// false. A tool expansion happened to cause the next layout and unstick it. Collecting the
|
|
// loading transition itself makes a compact page immediately ask for its successor.
|
|
LaunchedEffect(listState) {
|
|
snapshotFlow {
|
|
HistoryLoadSignal(
|
|
listState.layoutInfo,
|
|
restoring,
|
|
moreHistory,
|
|
loadingHistory,
|
|
historyError,
|
|
oldestSeq,
|
|
)
|
|
}
|
|
.collect { signal ->
|
|
val info = signal.layout
|
|
val visible = info.visibleItemsInfo
|
|
if (visible.isEmpty()) return@collect
|
|
// Before the guards below, so sizes keep accumulating while a page is in flight and
|
|
// the next estimate starts better informed.
|
|
visible.forEach { unitSizes[it.key] = it.size }
|
|
if (
|
|
signal.restoring ||
|
|
!signal.moreHistory ||
|
|
signal.loading ||
|
|
signal.error != null ||
|
|
signal.oldestSeq == 0L
|
|
) {
|
|
return@collect
|
|
}
|
|
val viewport = info.viewportSize.height
|
|
if (viewport == 0) return@collect
|
|
val loaded = currentUnits
|
|
val average = unitSizes.values.sum() / unitSizes.size
|
|
// From the last visible lazy index: item zero is the "below" slot, so lazy index
|
|
// equals units index plus one -- and a visible spinner makes the range empty, which
|
|
// is room of zero.
|
|
var room = 0L
|
|
val cushion = viewport.toLong() * HISTORY_SCREENS
|
|
for (index in visible.last().index until loaded.size) {
|
|
room += unitSizes[loaded[index].key] ?: average
|
|
if (room >= cushion) return@collect
|
|
}
|
|
// One page, and then this fires again if it was not enough -- the estimate is
|
|
// re-made when loading returns to false, even when folding the page did not change
|
|
// the list's height at all. A failure leaves `moreHistory` alone but records an
|
|
// error, stopping this loop until the boundary's Try again control is pressed.
|
|
requestOlderPage()
|
|
}
|
|
}
|
|
|
|
// Only for the model picker, which a subagent does not have.
|
|
if (!isSubagent) {
|
|
LaunchedEffect(summary.machine, summary.provider) {
|
|
val provider = runCatching {
|
|
withContext(Dispatchers.IO) {
|
|
fetchMachines(settings)
|
|
.firstOrNull { it.id == summary.machine }
|
|
?.providers
|
|
?.firstOrNull { it.name == summary.provider }
|
|
}
|
|
}
|
|
.getOrNull()
|
|
offeredPermissionModes = provider?.permissionModes.orEmpty()
|
|
offeredModels =
|
|
provider
|
|
?.let {
|
|
runCatching {
|
|
withContext(Dispatchers.IO) {
|
|
fetchProviderModels(settings, summary.machine, summary.provider)
|
|
}
|
|
}
|
|
.getOrDefault(emptyList())
|
|
}
|
|
.orEmpty()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Asks the server to take back a message the session has not read yet.
|
|
*
|
|
* Nothing is removed here. The bubble goes on the `messageDropped` the server records, which is
|
|
* what makes the cancellation the session's own fact rather than this screen's opinion of it --
|
|
* a second device has to lose the bubble too, and this one has to still lose it after a
|
|
* reconnect.
|
|
*
|
|
* The refusal is kept on the message it was about rather than in [actionError]: the error row
|
|
* lives under the header, and a bubble at the foot of the transcript is the thing that was
|
|
* pressed. It is the ordinary answer here -- a Claude session writes a steer into the CLI the
|
|
* moment it arrives, so what is on screen as "waiting" is waiting to be *read*.
|
|
*/
|
|
fun takeBack(messageId: String) {
|
|
scope.launch {
|
|
val refusal =
|
|
try {
|
|
withContext(Dispatchers.IO) { unqueueMessage(settings, summary.id, messageId) }
|
|
null
|
|
} catch (e: ApiException) {
|
|
e.message ?: "this message could not be taken back"
|
|
}
|
|
replaceQueued(queued.map { if (it.id == messageId) it.copy(refusal = refusal) else it })
|
|
}
|
|
}
|
|
|
|
/** Opens one image full screen, from whichever row drew it; see [SessionImageViewer]. */
|
|
fun openImage(ref: String) {
|
|
fullImage = ref
|
|
}
|
|
|
|
/** Opens or closes one memory note, wherever it is drawn; see [MemoryNote]. */
|
|
fun toggleMemory(text: String) = expanding {
|
|
openMemories = if (text in openMemories) openMemories - text else openMemories + text
|
|
}
|
|
|
|
/**
|
|
* Opens or closes one peer message, from whichever of its pieces was pressed.
|
|
*
|
|
* By seq rather than by unit, because an open message is several units and all of them shut it.
|
|
*
|
|
* No [toggleAnchored] here, and that is the difference between growing a row and adding items:
|
|
* the list is keyed, so it holds the item it is anchored on wherever the new ones land.
|
|
*/
|
|
fun togglePeer(seq: Long) = expanding {
|
|
expandedNotes = if (seq in expandedNotes) expandedNotes - seq else expandedNotes + seq
|
|
}
|
|
|
|
fun act(onDone: () -> Unit = {}, action: () -> Unit) {
|
|
scope.launch {
|
|
try {
|
|
withContext(Dispatchers.IO) { action() }
|
|
actionError = null
|
|
} catch (e: ApiException) {
|
|
actionError = e.message
|
|
} finally {
|
|
// Whatever happened, including the failure above: a caller that re-enables a
|
|
// control here must get it back on the path where the request was refused too.
|
|
onDone()
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sends every answer a question card handed over, and says when the last of them has settled.
|
|
*
|
|
* All of them in one go because a card asks its questions together and the tool is waiting on
|
|
* all of them; the completion is what turns the card's spinner back into a button.
|
|
*/
|
|
fun answerAll(answers: List<QuestionAnswer>, onSettled: () -> Unit) {
|
|
act(onDone = onSettled) {
|
|
answers.forEach { answerQuestion(settings, summary.id, it.questionId, it.answers) }
|
|
}
|
|
}
|
|
|
|
fun send() {
|
|
val text = input.text.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.
|
|
if (text.startsWith("/") && attachments.isEmpty()) {
|
|
input = atEnd("")
|
|
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 = atEnd("")
|
|
saveDraft(context, summary.id, "")
|
|
pendingAttachments = emptyList()
|
|
// Clearing the field makes one promise: its contents are either waiting in this quiet
|
|
// bubble or recorded as a user message. The server's `messageQueued` replaces this local
|
|
// bridge when it arrives; an immediate `userMessage` removes it. Until one does, even a
|
|
// network failure leaves the words visible with the failure attached to them.
|
|
val pending = localPendingMessage(text, attachments)
|
|
replaceQueued(queued + pending)
|
|
scope.launch {
|
|
try {
|
|
withContext(Dispatchers.IO) { sendMessage(settings, summary.id, text, attachments) }
|
|
replaceQueued(markPendingAccepted(queued, pending.id))
|
|
actionError = null
|
|
} catch (e: ApiException) {
|
|
replaceQueued(
|
|
markPendingFailure(queued, pending.id, e.message ?: "message not sent")
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// One path for everything attached, however it arrived: the photo picker, the file chooser or
|
|
// another app's share sheet. It uploads as soon as it is chosen, so Send only has ids.
|
|
fun attach(uri: Uri) {
|
|
scope.launch {
|
|
try {
|
|
val id =
|
|
withContext(Dispatchers.IO) {
|
|
// An image is 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; a file goes whole.
|
|
uploadPicked(context, settings, summary.id, uri, summary.maxImageEdge)
|
|
}
|
|
pendingAttachments = pendingAttachments + id
|
|
actionError = null
|
|
} catch (e: ApiException) {
|
|
actionError = e.message
|
|
}
|
|
}
|
|
}
|
|
val pickImage =
|
|
rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
|
|
uri?.let(::attach)
|
|
}
|
|
val pickFile =
|
|
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
|
uri?.let(::attach)
|
|
}
|
|
// What another app shared in, attached the moment this screen has it. Taken off the request
|
|
// first, so a recomposition cannot attach it a second time.
|
|
LaunchedEffect(share) {
|
|
val incoming = share ?: return@LaunchedEffect
|
|
onShareTaken()
|
|
incoming.uris.forEach(::attach)
|
|
incoming.text?.let { shared ->
|
|
input = atEnd(if (input.text.isBlank()) shared else input.text + "\n" + shared)
|
|
saveDraft(context, summary.id, input.text)
|
|
}
|
|
}
|
|
|
|
// One poll for the machines' limits, read by everything on this screen that reports them.
|
|
// Nothing meters a subagent -- it has no account of its own -- so it never starts this poll.
|
|
val usageFeed = if (isSubagent) null else rememberUsageFeed(settings)
|
|
val usage = usageFeed?.forSession(summary) ?: SessionUsage.NotMetered
|
|
RecordFrames()
|
|
var usageOpen by remember { mutableStateOf(false) }
|
|
var settingsOpen by remember { mutableStateOf(false) }
|
|
|
|
// The composer floats over the bottom of the screen instead of sitting under the transcript in
|
|
// one column, and the keyboard moves it by a layer translation rather than by relayout. With
|
|
// everything in one column under a root imePadding, every frame of the keyboard animation re-
|
|
// measured, re-placed and re-recorded the entire screen -- ~7.6ms of main-thread work per frame
|
|
// across ~34 frames per open on the emulator, and 82% late frames on the Pixel while the
|
|
// transcript itself cost 0.25ms.
|
|
var composerHeight by remember { mutableIntStateOf(0) }
|
|
val imeInsets = WindowInsets.ime
|
|
val navInsets = WindowInsets.navigationBars
|
|
// Ground truth for whether the keyboard is up, independent of `imeInsets` -- which is what
|
|
// rescues this from a real fault rather than merely reading the same thing twice. `imeInsets`
|
|
// is driven by the animation as it interpolates and is dispatched every frame; `isImeVisible`
|
|
// is dispatched once, from the platform's own start/end of the transition, over a different
|
|
// path.
|
|
//
|
|
// Reported from a phone: closing the keyboard on purpose, while a reply was streaming, left the
|
|
// composer floating above the bottom of the screen for the rest of the session. The likely
|
|
// cause is the animation callback that carries `imeInsets` back to zero being interrupted mid-
|
|
// flight -- a streaming reply invalidates the view every frame, which is exactly the condition
|
|
// known to starve a running `WindowInsetsAnimationCallback` of its `onEnd` -- and the stale
|
|
// partway value it leaves behind has nothing left to correct it. `isImeVisible` is not
|
|
// interpolated, so there is nothing for a dropped frame to interrupt.
|
|
val imeVisible = WindowInsets.isImeVisible
|
|
|
|
// What this session is costing to draw, copied out to somewhere it can be read.
|
|
//
|
|
// Written here rather than beside the control that runs it, because everything it measures is
|
|
// this composable's own state and a control in a dialog cannot reach it. The control is a row
|
|
// in [SessionSettingsDialog], where the session's other about-the-session controls are. It
|
|
// copies rather than opens, because what it produces is a message to whoever is looking at the
|
|
// code.
|
|
//
|
|
// Whatever presses this, it is found by its **name**: `ui-trace`'s tap-by-label action resolves
|
|
// "Session settings" and then "Copy render timings" from what is on screen at that moment, so
|
|
// the bench scripts keep working when this moves again. They pressed it at a hand-measured
|
|
// coordinate until 2026-09-03, and anything that moved the header made that tap land on
|
|
// whatever now sat there -- reporting a number that was never measured.
|
|
val copyRenderReport = {
|
|
val report =
|
|
debugReport(
|
|
device =
|
|
"device: ${Build.MODEL} (${Build.MANUFACTURER})," +
|
|
" Android ${Build.VERSION.RELEASE}\n" +
|
|
// A debuggable build runs Compose at a fraction of release speed, so a
|
|
// report that did not say which it came from was read as the app's own
|
|
// cost.
|
|
"build: ${if (debuggable(context)) "debug" else "release"}",
|
|
transcript =
|
|
listOf(
|
|
" ${items.size} events, ${rows.size} rows, ${units.size} units loaded",
|
|
" viewport ${listState.layoutInfo.viewportSize.height}px," +
|
|
" ${listState.layoutInfo.visibleItemsInfo.size} units visible",
|
|
visibleUnits(units, listState.layoutInfo.visibleItemsInfo, UNITS_START),
|
|
" ${expandedTools.size} tool calls and ${expandedGroups.size} groups open",
|
|
),
|
|
frames = FrameStats.lines(context.refreshHz()),
|
|
accounting =
|
|
FrameStats.drawPhase().let { (nanos, count) -> drawAccounting(nanos, count) },
|
|
crash = lastCrash(context),
|
|
)
|
|
context.copyToClipboard("ai-app render report", report)
|
|
// Also to the log, so a session driving the app over adb can read the same report the
|
|
// button copies. The clipboard is not reachable from a shell.
|
|
Log.i("ai-app", report)
|
|
// Only once it is somewhere it can be read from, so a copy that never happened does not
|
|
// throw the stack away with it.
|
|
clearCrash(context)
|
|
// Emptied by the copy, so pressing it twice measures two separate stretches of scrolling.
|
|
FrameStats.reset()
|
|
DebugStats.reset()
|
|
Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT).show()
|
|
}
|
|
Box(Modifier.fillMaxSize()) {
|
|
Column(Modifier.fillMaxSize()) {
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
|
) {
|
|
GlyphButton(BACK_GLYPH, "Back", onBack)
|
|
// A ring's worth, which is what the arrow already keeps on its other three sides.
|
|
Spacer(Modifier.width(GLYPH_BUTTON_MARGIN))
|
|
Column(Modifier.weight(1f)) {
|
|
// A subagent's own title, with the session's beneath it in a smaller style --
|
|
// the header says whose conversation this is as well as what it is. Otherwise
|
|
// just the session's title, as before.
|
|
if (subagent != null) {
|
|
Text(subagent.title, style = MaterialTheme.typography.titleMedium)
|
|
Text(
|
|
title,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
} else {
|
|
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
|
|
// two sentences with different grammar.
|
|
//
|
|
// 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.
|
|
Text(
|
|
"${summary.machineName} · ${summary.provider}",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
}
|
|
// None of this is a subagent's: it has no files of its own to browse, no settings,
|
|
// and nothing meters it -- see SUBAGENTS.md's "Phone".
|
|
//
|
|
// 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. 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. 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.
|
|
//
|
|
// Usage, files, settings -- widest scope first, narrowing to the right, so the cog
|
|
// stays at the end where every other screen keeps it. Asked for in this order by
|
|
// Iris on 2026-09-03.
|
|
if (!isSubagent) {
|
|
Row {
|
|
GlyphButton(
|
|
USAGE_GLYPH,
|
|
"Usage",
|
|
{ usageOpen = true },
|
|
colour = usageGlyphColour(usage),
|
|
)
|
|
// The machine's files, which is where the answer to "what did it actually
|
|
// change" is. It opens *over* this screen rather than replacing it.
|
|
GlyphButton(
|
|
FOLDER_GLYPH,
|
|
"Files",
|
|
onClick = {
|
|
onFiles(
|
|
// Where this session works, and the machine's own home when it
|
|
// was never given a directory -- resolved there rather than
|
|
// guessed at here, since this app does not know that home.
|
|
summary.filesTarget()
|
|
)
|
|
},
|
|
)
|
|
// What it opens is about this session, so it sits at the end of the
|
|
// session's own row. A cog and not a word because 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. Nothing meters a subagent.
|
|
if (!isSubagent) {
|
|
SessionUsageBar(usage)
|
|
}
|
|
|
|
(streamError ?: actionError)?.let { message ->
|
|
Text(
|
|
message,
|
|
color = MaterialTheme.colorScheme.error,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
|
)
|
|
}
|
|
|
|
// The transcript, reversed: item zero is the newest message and sits at the bottom, so
|
|
// the first frame of a session is already the right one; see [TranscriptList].
|
|
//
|
|
// Drawn only once there is nothing left to put back. Held out of the drawing rather
|
|
// than out of the composition, so the restore's scroll is applied against a list that
|
|
// is fully built.
|
|
val settled = !restoring
|
|
Box(
|
|
Modifier.weight(1f)
|
|
.fillMaxWidth()
|
|
// The room the floating composer needs, measured off it below -- reserving it
|
|
// here is what lets the composer be an overlay without covering the newest
|
|
// message. This modifier is the whole of what the keyboard re-measures: the
|
|
// box's own size never changes, so nothing above it is touched.
|
|
.padding(bottom = with(LocalDensity.current) { composerHeight.toDp() })
|
|
// The keyboard's room, and only while the platform says there is a keyboard --
|
|
// dropping the modifier is what coerces the stuck-open animated value to zero.
|
|
// It has to stay a *modifier* rather than a padding computed here: `imePadding`
|
|
// reads the inset in the layout phase, so a keyboard frame re-measures this box
|
|
// and nothing else, while reading `imeInsets` in this composable body
|
|
// subscribes the whole of `SessionScreen` to a value that changes every frame
|
|
// -- 16 full recompositions per keyboard open against one, and the transcript's
|
|
// position behind a recomposition while the composer's stayed a draw-phase
|
|
// read.
|
|
.then(if (imeVisible) Modifier.imePadding() else Modifier)
|
|
) {
|
|
Box(Modifier.fillMaxSize()) {
|
|
TranscriptList(
|
|
units = units,
|
|
state = listState,
|
|
moreHistory = moreHistory,
|
|
historyError = historyError,
|
|
// A button means "make the request", not merely "let the estimator decide
|
|
// again". Its measurements can be stale after the tall error row is
|
|
// replaced by the spinner, and that used to make a press do nothing.
|
|
onRetryHistory = { scope.launch { requestOlderPage() } },
|
|
selection = selection,
|
|
modifier =
|
|
Modifier.fillMaxSize().drawWithContent { if (settled) drawContent() },
|
|
below = {
|
|
// The last thing in the transcript, because that is where they are in
|
|
// the session's reading of events: after everything it has taken in,
|
|
// and not yet taken in themselves. What the session is *doing* about
|
|
// them is a line below, in [SessionStatusRow].
|
|
if (queued.isNotEmpty() || waitingCommands.isNotEmpty()) {
|
|
// The gap the arrangement no longer provides: this item sits flush
|
|
// against the newest message otherwise.
|
|
Column(
|
|
Modifier.padding(top = TRANSCRIPT_SPACING),
|
|
horizontalAlignment = Alignment.End,
|
|
) {
|
|
waitingCommands.forEach { (_, text) ->
|
|
CommandBubble(text, waiting = true)
|
|
}
|
|
queued.forEach { waiting ->
|
|
UserBubble(
|
|
settings = settings,
|
|
sessionId = summary.id,
|
|
text = waiting.text,
|
|
attachments = waiting.attachments,
|
|
onOpenImage = ::openImage,
|
|
pending = true,
|
|
refusal = waiting.refusal,
|
|
// The bubble goes away on the `messageDropped` this
|
|
// produces, not here: the server knows whether the
|
|
// message was still its to take back, and the other
|
|
// devices have to be told by the same event.
|
|
onTakeBack =
|
|
if (waiting.local) null
|
|
else ({ takeBack(waiting.id) }),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
},
|
|
) { unit ->
|
|
when (unit) {
|
|
is TranscriptUnit.Block -> MarkdownPiece(unit.text, unit.piece, replies)
|
|
is TranscriptUnit.PeerHead ->
|
|
PeerHeadRow(
|
|
unit.item,
|
|
unit.open,
|
|
onToggle = { togglePeer(unit.item.seq) },
|
|
)
|
|
is TranscriptUnit.PeerBlock ->
|
|
PeerBlockRow(unit, replies, onToggle = { togglePeer(unit.seq) })
|
|
is TranscriptUnit.UserChunk ->
|
|
UserChunkRow(unit, settings, summary.id, ::openImage)
|
|
is TranscriptUnit.Memory ->
|
|
MemoryNote(
|
|
unit.part,
|
|
replies,
|
|
unit.part.text in openMemories,
|
|
) {
|
|
toggleMemory(unit.part.text)
|
|
}
|
|
is TranscriptUnit.Whole -> {
|
|
val row = unit.row
|
|
Box(
|
|
Modifier.holdTopEdge(row.key, topEdgeHeld) { grew ->
|
|
// A *request*, not a raw scroll delta: this runs inside
|
|
// the measure pass that discovered the new height, and
|
|
// a raw delta forces a synchronous remeasure from
|
|
// within measure, which is fatal. The request is
|
|
// applied by the same frame's next remeasure, so the
|
|
// correction still lands before anything is drawn.
|
|
// Reads unobserved, or this row's measure would inherit
|
|
// the scroll position as a dependency and remeasure on
|
|
// every frame.
|
|
Snapshot.withoutReadObservation {
|
|
listState.requestScrollToItem(
|
|
listState.firstVisibleItemIndex,
|
|
(listState.firstVisibleItemScrollOffset + grew)
|
|
.coerceAtLeast(0),
|
|
)
|
|
}
|
|
}
|
|
// Which half of this row the touch landed in, for
|
|
// [toggleAnchored]. On the initial pass and consuming
|
|
// nothing, so every control inside still gets the gesture;
|
|
// only visible rows have one, which is what makes a
|
|
// detector per row affordable.
|
|
.pointerInput(row.key) {
|
|
awaitEachGesture {
|
|
val down =
|
|
awaitFirstDown(
|
|
requireUnconsumed = false,
|
|
pass = PointerEventPass.Initial,
|
|
)
|
|
lastTouch.key = row.key
|
|
lastTouch.high = down.position.y < size.height / 2f
|
|
}
|
|
}
|
|
) {
|
|
when (row) {
|
|
is TranscriptRow.Tools ->
|
|
ToolGroup(
|
|
group = row,
|
|
expanded = row.key in expandedGroups,
|
|
onToggle = {
|
|
toggleAnchored(row) {
|
|
expandedGroups =
|
|
if (row.key in expandedGroups)
|
|
expandedGroups - row.key
|
|
else expandedGroups + row.key
|
|
}
|
|
},
|
|
isToolExpanded = { it in expandedTools },
|
|
// Anchored on the group, not the call: opening one
|
|
// call makes the whole group taller, and the
|
|
// heading the reader is under is the group's.
|
|
onToolToggle = { id ->
|
|
toggleAnchored(row) {
|
|
expandedTools =
|
|
if (id in expandedTools)
|
|
expandedTools - id
|
|
else expandedTools + id
|
|
}
|
|
},
|
|
onAnswer = ::answerAll,
|
|
image = { ref ->
|
|
SessionImage(
|
|
settings,
|
|
summary.id,
|
|
ref,
|
|
::openImage,
|
|
)
|
|
},
|
|
)
|
|
is TranscriptRow.Single ->
|
|
when (val item = row.item) {
|
|
is TranscriptItem.UserMsg ->
|
|
UserBubble(
|
|
settings = settings,
|
|
sessionId = summary.id,
|
|
text = item.text,
|
|
attachments = item.attachments,
|
|
onOpenImage = ::openImage,
|
|
)
|
|
is TranscriptItem.AssistantMsg ->
|
|
// A whole assistant row is only ever the reply
|
|
// still arriving -- every settled reply is
|
|
// flattened into block units instead. Live is
|
|
// what earns its blocks a layer each while
|
|
// deltas land.
|
|
AssistantMessage(
|
|
item.text,
|
|
replies,
|
|
openNotes = openMemories,
|
|
onToggleNote = ::toggleMemory,
|
|
live = true,
|
|
)
|
|
is TranscriptItem.ToolRun ->
|
|
ToolCard(
|
|
tool = item,
|
|
expanded = item.id in expandedTools,
|
|
onToggle = {
|
|
toggleAnchored(row) {
|
|
expandedTools =
|
|
if (item.id in expandedTools)
|
|
expandedTools - item.id
|
|
else expandedTools + item.id
|
|
}
|
|
},
|
|
onAnswer = ::answerAll,
|
|
image = { ref ->
|
|
SessionImage(
|
|
settings,
|
|
summary.id,
|
|
ref,
|
|
::openImage,
|
|
)
|
|
},
|
|
)
|
|
is TranscriptItem.QuestionCard ->
|
|
QuestionRow(item, ::answerAll)
|
|
is TranscriptItem.ErrorMsg ->
|
|
Text(
|
|
item.message,
|
|
color = MaterialTheme.colorScheme.error,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
)
|
|
is TranscriptItem.ImageItem ->
|
|
SessionImage(
|
|
settings,
|
|
summary.id,
|
|
item.ref,
|
|
::openImage,
|
|
)
|
|
is TranscriptItem.Note ->
|
|
Text(
|
|
item.text,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color =
|
|
MaterialTheme.colorScheme
|
|
.onSurfaceVariant,
|
|
)
|
|
is TranscriptItem.CommandRow ->
|
|
CommandBubble(item.text)
|
|
is TranscriptItem.ClearedNote -> ClearedRow()
|
|
is TranscriptItem.CompactedNote ->
|
|
CompactedRow(item)
|
|
is TranscriptItem.LimitNote -> LimitRow(item)
|
|
is TranscriptItem.TurnBreak -> TurnBreakRow()
|
|
// Never reached: a peer message is flattened into
|
|
// its own units. Here because a `when` over the
|
|
// item kinds has to stay exhaustive.
|
|
is TranscriptItem.PeerNote ->
|
|
PeerHeadRow(item, open = false, onToggle = {})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// In the middle of the transcript rather than at either end, because it is standing
|
|
// in for all of the rows. `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 puts the position back.
|
|
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.
|
|
//
|
|
// Down, and the same chevron a tool group collapses with: the list is built upside
|
|
// down internally, but nobody reading it knows that. The name is carried in the
|
|
// description, since an arrow alone says nothing to a screen reader.
|
|
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: the newest end is where the content
|
|
// hangs from, so being at it is the whole of following it. That is what
|
|
// this press used to forget, landing the reader at the bottom with new
|
|
// messages not bringing the view with them.
|
|
onClick = { scope.launch { listState.scrollToItem(0) } },
|
|
shape = CircleShape,
|
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
|
modifier =
|
|
Modifier.align(Alignment.BottomCenter)
|
|
.padding(bottom = 12.dp)
|
|
.semantics { contentDescription = "Jump to latest" },
|
|
) {
|
|
Chevron(
|
|
Pointing.Down,
|
|
colour = MaterialTheme.colorScheme.onSurface,
|
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Everything from here down floats: bottom-aligned over the transcript, moved up with the
|
|
// keyboard by a translation on its own layer. The translation is read inside the
|
|
// graphicsLayer block, so a keyboard frame invalidates layer properties only. Its height is
|
|
// reported to the transcript box above, which reserves that much room; the opaque
|
|
// background covers the one frame between this growing and that reservation catching up.
|
|
Column(
|
|
Modifier.align(Alignment.BottomCenter)
|
|
.fillMaxWidth()
|
|
.onSizeChanged { composerHeight = it.height }
|
|
.graphicsLayer {
|
|
// See `imeVisible` above: a callback interrupted mid-close leaves this stuck
|
|
// reading a stale height, and without the guard the composer floats above the
|
|
// bottom of the screen for good.
|
|
translationY =
|
|
if (imeVisible) {
|
|
-(imeInsets.getBottom(this) - navInsets.getBottom(this))
|
|
.coerceAtLeast(0)
|
|
.toFloat()
|
|
} else {
|
|
0f
|
|
}
|
|
}
|
|
.background(MaterialTheme.colorScheme.background)
|
|
) {
|
|
pendingModel?.let { chosen ->
|
|
ModelSwitchWarning(
|
|
from = modelLabel(model),
|
|
to = modelLabel(chosen),
|
|
onDismiss = { pendingModel = null },
|
|
onConfirm = {
|
|
pendingModel = null
|
|
act { setSessionModel(settings, summary.id, chosen) }
|
|
},
|
|
)
|
|
}
|
|
|
|
// Kept for a subagent -- see SUBAGENTS.md's "Phone" -- with the wording that turns
|
|
// "exited" into "finished" for one, since it has no process to leave running or stop.
|
|
SessionStatusRow(
|
|
status = status,
|
|
compactingFor = compactingFor,
|
|
contextTokens = contextTokens,
|
|
backgroundTasks = backgroundTasks,
|
|
subagent = isSubagent,
|
|
)
|
|
|
|
// Everything from here down is the composer: a subagent cannot be messaged, so none of
|
|
// it applies -- see SUBAGENTS.md's "Phone".
|
|
if (!isSubagent) {
|
|
// Between the transcript and the box: above what is being typed, so the list does
|
|
// not cover the thing the command is about, and below everything that explains it.
|
|
CommandSuggestions(
|
|
// Nothing to suggest about a suggestion that was just taken. `/compact` is a
|
|
// whole command *and* a prefix of itself, so picking it left the list standing
|
|
// there with the one row already chosen. Held by what was picked rather than by
|
|
// a flag, so typing anything else brings the list back without a second thing
|
|
// to
|
|
// reset.
|
|
commands =
|
|
if (input.text == picked) emptyList() else suggestedCommands(input.text),
|
|
onPick = { command ->
|
|
// At the end of what was inserted, which is where the reader carries on
|
|
// typing: a command with an argument is put in the box half-written, and a
|
|
// cursor left at the front makes the next keystroke the first character of
|
|
// "/rename".
|
|
input = atEnd(command.typed())
|
|
picked = command.typed()
|
|
},
|
|
)
|
|
|
|
// Always enabled -- a send while the session is running becomes a steering message
|
|
// injected at the next tool boundary, which is the point of the whole app.
|
|
//
|
|
// The field gets a row of its own, above the buttons: sharing one put the full
|
|
// width
|
|
// behind three controls, so the thing being typed into was the narrowest 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.text)
|
|
},
|
|
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(),
|
|
) {
|
|
// Photo or file, asked here rather than by two buttons: the row is full,
|
|
// and
|
|
// attaching is one action whichever picker answers it.
|
|
var attaching by remember { mutableStateOf(false) }
|
|
Box {
|
|
// Just "+". The count it used to carry was standing in for showing
|
|
// them.
|
|
BubbleButton(onClick = { attaching = true }) { Text("+") }
|
|
DropdownMenu(
|
|
expanded = attaching,
|
|
onDismissRequest = { attaching = false },
|
|
// See PickerButton: without this the menu opens a status bar's
|
|
// height away from the button in an edge-to-edge activity.
|
|
properties = PopupProperties(clippingEnabled = false),
|
|
shape = BubbleMenuShape,
|
|
) {
|
|
DropdownMenuItem(
|
|
text = { Text("Photo") },
|
|
onClick = {
|
|
attaching = false
|
|
pickImage.launch(
|
|
PickVisualMediaRequest(
|
|
ActivityResultContracts.PickVisualMedia.ImageOnly
|
|
)
|
|
)
|
|
},
|
|
)
|
|
DropdownMenuItem(
|
|
text = { Text("File") },
|
|
onClick = {
|
|
attaching = false
|
|
pickFile.launch(arrayOf("*/*"))
|
|
},
|
|
)
|
|
}
|
|
}
|
|
// 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 the
|
|
// moment it 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. Asked about first,
|
|
// unless there is nothing to lose by it -- see
|
|
// [ModelSwitchWarning].
|
|
onPick = { chosen ->
|
|
if (
|
|
modelLabel(chosen) == modelLabel(model) ||
|
|
!worthWarningAbout(status, contextTokens, items)
|
|
) {
|
|
act { setSessionModel(settings, summary.id, chosen) }
|
|
} else {
|
|
pendingModel = chosen
|
|
}
|
|
},
|
|
)
|
|
}
|
|
if (offeredPermissionModes.isNotEmpty()) {
|
|
PickerButton(
|
|
current = permissionMode,
|
|
options = offeredPermissionModes,
|
|
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 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 a button
|
|
// 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.
|
|
//
|
|
// 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. Disabled and not hidden, for the
|
|
// reason above.
|
|
Button(
|
|
onClick = { send() },
|
|
enabled = input.text.isNotBlank() || pendingAttachments.isNotEmpty(),
|
|
colors = actionButtonColors(if (running) queueColor else sendColor),
|
|
) {
|
|
Glyph(
|
|
if (running) QUEUE_GLYPH else SEND_GLYPH,
|
|
colour = LocalContentColor.current,
|
|
modifier =
|
|
Modifier.semantics { contentDescription = sendLabel(running) },
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Beside the other two dialogs, and outside the list for the same reason as them: what is open
|
|
// is the screen's business rather than any row's. See [SessionImageViewer].
|
|
fullImage?.let { ref -> SessionImageViewer(settings, summary.id, ref) { fullImage = null } }
|
|
if (usageOpen) {
|
|
usageFeed?.let {
|
|
UsageDialog(
|
|
settings = settings,
|
|
feed = it,
|
|
session = summary,
|
|
onDismiss = { usageOpen = false },
|
|
)
|
|
}
|
|
}
|
|
if (loginOpen) {
|
|
ProviderLoginDialog(
|
|
settings = settings,
|
|
machineId = summary.machine,
|
|
machineName = summary.machineName,
|
|
provider = summary.provider,
|
|
onDismiss = { loginOpen = false },
|
|
onSignedIn = {
|
|
loginOpen = false
|
|
usageFeed?.refresh?.invoke()
|
|
},
|
|
)
|
|
}
|
|
if (settingsOpen) {
|
|
// Measured when the dialog opens rather than kept up to date: what the reader is being told
|
|
// is what pressing the button now would discard, and null until the walk of the directory
|
|
// returns is what not knowing looks like.
|
|
var cachedBytes by remember(summary.id, epoch) { mutableStateOf<Long?>(null) }
|
|
LaunchedEffect(summary.id, epoch) {
|
|
cachedBytes = withContext(Dispatchers.IO) { source.cache.bytes() }
|
|
}
|
|
SessionSettingsDialog(
|
|
settings = settings,
|
|
sessionId = summary.id,
|
|
title = title,
|
|
effort = effort.takeIf { summary.takesEffort },
|
|
takesEffort = summary.takesEffort,
|
|
onEffortChanged = { effort = it },
|
|
cachedBytes = cachedBytes,
|
|
// The purge finishes before the epoch moves, because the relaunched opening effect
|
|
// reads the same directory and would otherwise draw what is about to be deleted. The
|
|
// epoch is what makes the rest a cold open.
|
|
onReload = {
|
|
settingsOpen = false
|
|
scope.launch {
|
|
withContext(Dispatchers.IO) { source.cache.purge() }
|
|
dropLoadedTranscript()
|
|
lastSeq.set(0)
|
|
ready = false
|
|
epoch++
|
|
}
|
|
},
|
|
// 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.
|
|
onRenamed = {
|
|
title = it
|
|
settingsOpen = false
|
|
},
|
|
onDismiss = { settingsOpen = false },
|
|
onCopyRenderReport = copyRenderReport,
|
|
)
|
|
}
|
|
}
|
|
|
|
/** 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.
|
|
*/
|
|
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)
|
|
}
|
|
|
|
/**
|
|
* One slice of a long user message, on the same bubble the first slice starts.
|
|
*
|
|
* Full width, unlike the wrapping bubble: slices have to share a width to read as one card, and a
|
|
* message long enough to be sliced has lines that wrap anyway -- see [USER_SPLIT_CHARS].
|
|
*/
|
|
@Composable
|
|
private fun UserChunkRow(
|
|
unit: TranscriptUnit.UserChunk,
|
|
settings: ServerSettings,
|
|
sessionId: String,
|
|
onOpenImage: (String) -> Unit,
|
|
) {
|
|
Column(
|
|
Modifier.padding(start = 48.dp)
|
|
.cardPiece(
|
|
top = unit.first,
|
|
bottom = unit.last,
|
|
fill = MaterialTheme.colorScheme.primaryContainer,
|
|
)
|
|
) {
|
|
Text(unit.text, color = MaterialTheme.colorScheme.onPrimaryContainer)
|
|
// The same arrangement [UserBubble] gives them: under the words, on the last slice because
|
|
// that is the bubble's bottom.
|
|
unit.attachments.forEachIndexed { index, ref ->
|
|
if (index > 0 || unit.text.isNotEmpty()) Spacer(Modifier.height(4.dp))
|
|
Attachment(settings, sessionId, ref, onOpenImage)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A message the person holding the phone sent, in a bubble at their end of the conversation.
|
|
*
|
|
* [pending] has not yet been recorded as received by the provider -- drawn quieter, because
|
|
* "pressed Send" and "heard" are different claims and the transcript must not merge them.
|
|
*
|
|
* A server-accepted pending bubble is tappable: [onTakeBack] asks the server to drop the message
|
|
* before the session reads it. A local one is not, because its request may still be in flight.
|
|
* [refusal] is either that take-back refusal or the send's network failure. It is drawn here rather
|
|
* than with the screen's other errors because this is the message the failure belongs to.
|
|
*
|
|
* A settled message longer than [USER_SPLIT_CHARS] is drawn as [UserChunkRow] slices instead -- one
|
|
* `Text` holding a pasted log is a hundred-thousand-pixel layout in the frame the row scrolls into.
|
|
*/
|
|
@Composable
|
|
private fun UserBubble(
|
|
settings: ServerSettings,
|
|
sessionId: String,
|
|
text: String,
|
|
attachments: List<String> = emptyList(),
|
|
onOpenImage: (String) -> Unit,
|
|
pending: Boolean = false,
|
|
refusal: String? = null,
|
|
onTakeBack: (() -> Unit)? = null,
|
|
) {
|
|
Box(Modifier.fillMaxWidth()) {
|
|
Card(
|
|
// A message the session has not read yet is drawn quieter than one it has. The
|
|
// difference is in degree -- said, not yet heard -- which is what colour alone can
|
|
// carry.
|
|
colors =
|
|
CardDefaults.cardColors(
|
|
containerColor =
|
|
if (pending) MaterialTheme.colorScheme.surfaceVariant
|
|
else MaterialTheme.colorScheme.primaryContainer
|
|
),
|
|
modifier =
|
|
Modifier.align(Alignment.CenterEnd)
|
|
.padding(start = 48.dp)
|
|
.then(
|
|
if (onTakeBack == null) Modifier
|
|
else
|
|
Modifier.clickable(onClick = onTakeBack).semantics {
|
|
// The bubble is its own control and its own label; without this the
|
|
// only thing to read is the message, which does not say what
|
|
// pressing it does.
|
|
contentDescription = "Waiting to be read; tap to take it back"
|
|
}
|
|
),
|
|
) {
|
|
Column(Modifier.padding(12.dp)) {
|
|
// A message can be nothing but an attachment, and an empty line above a picture is
|
|
// a bubble with a gap in it for a sentence nobody wrote.
|
|
if (text.isNotEmpty()) {
|
|
Text(
|
|
text,
|
|
color =
|
|
if (pending) MaterialTheme.colorScheme.onSurfaceVariant
|
|
else MaterialTheme.colorScheme.onPrimaryContainer,
|
|
)
|
|
}
|
|
// Under the words: what somebody wrote is what the bubble is, and the picture is
|
|
// what they attached to it. It also keeps the first line of every bubble at the
|
|
// same place down the transcript.
|
|
attachments.forEachIndexed { index, ref ->
|
|
if (index > 0 || text.isNotEmpty()) Spacer(Modifier.height(4.dp))
|
|
Attachment(settings, sessionId, ref, onOpenImage)
|
|
}
|
|
refusal?.let {
|
|
Spacer(Modifier.height(6.dp))
|
|
Text(
|
|
it,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.error,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A message the server has accepted and the session has not read yet.
|
|
*
|
|
* [refusal] is why taking it back did not work, kept per message rather than on the screen: two
|
|
* bubbles can be waiting at once, and an error above them both would not say which.
|
|
*/
|
|
/**
|
|
* Whether a model switch has anything to warn about -- see [ModelSwitchWarning].
|
|
*
|
|
* What the warning is about is a *cache* being dropped, so the question is whether there is one.
|
|
* Two answers say there is not, and both used to produce the dialog anyway: a session whose process
|
|
* has exited has nothing running to hold a cache, and a session reporting zero context is holding
|
|
* nothing.
|
|
*
|
|
* Where the figure is *unknown* rather than zero the fallback is whether anything has been said
|
|
* **since the last clear**. Unknown is not nothing, and treating it as nothing would drop the
|
|
* warning on exactly the sessions -- an import, a fresh reattach -- where nobody has measured yet.
|
|
* But a clear is the one case that makes the whole loaded transcript stop counting: it leaves the
|
|
* conversation on screen and takes it out of the session's context, and the server reports the
|
|
* context as unmeasured afterwards rather than as zero. So the reading that used the whole list
|
|
* warned about dropping a cache the clear had already dropped.
|
|
*
|
|
* With no clear anywhere in what is loaded this is the old reading exactly, which is the
|
|
* conservative answer for a clear further back than the loaded window.
|
|
*/
|
|
private fun worthWarningAbout(
|
|
status: String,
|
|
contextTokens: Long?,
|
|
items: List<TranscriptItem>,
|
|
): Boolean =
|
|
when {
|
|
status == "exited" -> false
|
|
contextTokens != null -> contextTokens > 0
|
|
else -> items.asReversed().takeWhile { it !is TranscriptItem.ClearedNote }.isNotEmpty()
|
|
}
|
|
|
|
/**
|
|
* Asked before switching model, because switching is not free and the cost is invisible.
|
|
*
|
|
* A model change drops the cached context: the next turn re-reads the entire conversation 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.
|
|
*
|
|
* No number is offered here, deliberately. What it will cost depends on how long *this*
|
|
* conversation is, and this screen does not know that -- a figure worked out from what has been
|
|
* spent would be a guess in a measurement's clothes.
|
|
*
|
|
* 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.
|
|
*/
|
|
@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 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.
|
|
*
|
|
* 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.
|
|
*
|
|
* `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?,
|
|
/** Provider-reported live background work; zero is deliberately not drawn. */
|
|
backgroundTasks: Int,
|
|
modifier: Modifier = Modifier,
|
|
/**
|
|
* Whether this row is for a subagent rather than a session, which changes only one word:
|
|
* "exited" reads as "finished" there too, the same as the subagent list's own card -- a
|
|
* subagent's process was always its parent's, so "exited" would read as a fault rather than the
|
|
* ordinary way one of these ends.
|
|
*/
|
|
subagent: Boolean = false,
|
|
) {
|
|
DebugStats.count("status row recomposed")
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
|
|
) {
|
|
when (status) {
|
|
// A bar rather than the spinner an ordinary turn gets, and it takes the row's whole
|
|
// free width: nothing arrives in the transcript during a compaction, so this is the
|
|
// only thing on screen that is moving, and at a spinner's width that reads as a session
|
|
// that has hung.
|
|
//
|
|
// Indeterminate, which is a statement rather than an omission. The CLI says a
|
|
// compaction has begun and then nothing at all until it has finished -- measured 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.
|
|
"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,
|
|
)
|
|
if (backgroundTasks > 0) {
|
|
Text(
|
|
backgroundTaskLabel(backgroundTasks),
|
|
style = MaterialTheme.typography.labelSmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
modifier = Modifier.padding(start = 8.dp),
|
|
)
|
|
}
|
|
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),
|
|
)
|
|
if (backgroundTasks > 0) {
|
|
Text(
|
|
backgroundTaskLabel(backgroundTasks),
|
|
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. The words and the colour are `sessionStatusWord`'s and
|
|
// `sessionStatusColour`'s, shared with the session list so one state is not called two
|
|
// things -- or drawn two colours -- depending which screen you are on.
|
|
else -> {
|
|
Text(
|
|
sessionStatusWord(status, subagent),
|
|
style = MaterialTheme.typography.labelSmall,
|
|
color = sessionStatusColour(status),
|
|
)
|
|
if (backgroundTasks > 0) {
|
|
Text(
|
|
backgroundTaskLabel(backgroundTasks),
|
|
style = MaterialTheme.typography.labelSmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
modifier = Modifier.padding(start = 8.dp),
|
|
)
|
|
}
|
|
Spacer(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 just 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.
|
|
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<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
|
) {
|
|
Card(Modifier.fillMaxWidth()) {
|
|
Column(Modifier.padding(12.dp)) {
|
|
// The same body the questions on a tool call get, down to the submit button: one
|
|
// question is the same thing whether or not something asked it, and two renderings of
|
|
// it would be two places for an answer to go missing.
|
|
AskUserQuestionBody(listOf(question), onAnswer)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* [text] in the message box, with the cursor after it.
|
|
*
|
|
* Everything that puts words in the box without the reader typing them goes through here: a
|
|
* restored draft, a share arriving from another app, a slash command taken from the suggestions.
|
|
* All three used to leave the cursor at whatever offset it happened to hold -- which for a box that
|
|
* has never been focused is the very start, so picking `/rename` and typing put the name in front.
|
|
*/
|
|
private fun atEnd(text: String) = TextFieldValue(text, TextRange(text.length))
|
|
|
|
/**
|
|
* 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 swallow a deliberate reopen.
|
|
*/
|
|
private const val ONE_TAP_MS = 250L
|
|
|
|
/**
|
|
* A control that reads as its own value.
|
|
*
|
|
* The button *is* the current setting rather than a label beside one, so the row says what the
|
|
* session is set to without spending a second line on saying it.
|
|
*/
|
|
@Composable
|
|
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), so the press that dismisses it is also delivered to the window
|
|
// underneath -- which 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.
|
|
var closedAt by remember { mutableLongStateOf(0L) }
|
|
Box {
|
|
BubbleButton(
|
|
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 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 as 142px, the status bar's height exactly, clear of the button that opened
|
|
// it. What this gives up is that the keyboard stops counting as an edge, so with the IME up
|
|
// the menu opens downwards over it. That is the lesser fault, and correcting it would mean
|
|
// supplying a position provider this menu takes no parameter for.
|
|
DropdownMenu(
|
|
expanded = open,
|
|
onDismissRequest = {
|
|
open = false
|
|
closedAt = SystemClock.uptimeMillis()
|
|
},
|
|
properties = PopupProperties(focusable = false, clippingEnabled = false),
|
|
shape = BubbleMenuShape,
|
|
) {
|
|
options.forEach { option ->
|
|
DropdownMenuItem(
|
|
text = { Text(option) },
|
|
onClick = {
|
|
open = false
|
|
if (option != current) onPick(option)
|
|
},
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun debuggable(context: Context) =
|
|
context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
|