A gear at the end of the session's own bar opens what can be changed about that session; the name is the first thing there. Compact is gone from that bar -- `/compact` typed into the message box is the CLI's own way to ask and it already worked, so the button was a second way to say one thing. Echo takes the typed word too now, since it is the rig the compaction display is checked against and losing the button would have taken that with it. The name is this server's, not a driver's: it is what the list shows, it exists before any process does, and every provider has one. So it is settled in the config and the driver is *told* -- which is the opposite of the model and the permission mode, and the difference is written down at `Driver::set_title`. A driver whose process has no notion of a name does nothing and says nothing, because there is no failure to report. Claude Code has one, so the name reaches it: `--name` for a session we create, and `/rename` afterwards, which is a local command rather than a control request -- `set_session_name` is not a subtype it knows, which I established by asking it. A resumed session is deliberately not renamed at launch: an import already has a name, quite possibly one the person typing in it chose, and taking that would be helping itself to something the app was only shown. Verified end to end rather than argued: renaming from the phone put "Session renamed to: paging and scroll" in the CLI's own session file, and the session now lists under that name to other agents. The gear is drawn rather than set in a font, for the reason Chevron gives. It was a sun on the first attempt -- thin teeth standing clear of a thin hub -- which no amount of reading the diff would have shown.
1090 lines
53 KiB
Kotlin
1090 lines
53 KiB
Kotlin
package com.example.aiapp
|
|
|
|
import android.os.SystemClock
|
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
import androidx.activity.result.PickVisualMediaRequest
|
|
import androidx.activity.result.contract.ActivityResultContracts
|
|
import androidx.compose.foundation.Image
|
|
import androidx.compose.foundation.layout.Arrangement
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.PaddingValues
|
|
import androidx.compose.foundation.layout.Row
|
|
import androidx.compose.foundation.layout.Spacer
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.layout.width
|
|
import androidx.compose.foundation.lazy.LazyColumn
|
|
import androidx.compose.foundation.lazy.items
|
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
import androidx.compose.foundation.shape.CircleShape
|
|
import androidx.compose.material3.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.IconButton
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.OutlinedButton
|
|
import androidx.compose.material3.OutlinedTextField
|
|
import androidx.compose.material3.Surface
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.TextButton
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.DisposableEffect
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.derivedStateOf
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableLongStateOf
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.rememberCoroutineScope
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.runtime.snapshotFlow
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.platform.LocalContext
|
|
import androidx.compose.ui.semantics.contentDescription
|
|
import androidx.compose.ui.semantics.semantics
|
|
import androidx.compose.ui.text.style.TextOverflow
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.window.PopupProperties
|
|
import androidx.lifecycle.Lifecycle
|
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
|
import androidx.lifecycle.repeatOnLifecycle
|
|
import java.util.concurrent.atomic.AtomicLong
|
|
import java.util.concurrent.atomic.AtomicReference
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.delay
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.withContext
|
|
|
|
private const val RECONNECT_DELAY_MS = 1500L
|
|
|
|
/**
|
|
* What the transcript renders: the event stream folded into displayable rows (see [foldEvent]). The
|
|
* stream is the only data source -- opening this screen replays from seq 0, and a reconnect resumes
|
|
* from the last seq seen, so there is no separate history fetch to drift from it.
|
|
*/
|
|
sealed class TranscriptItem {
|
|
/**
|
|
* The transcript sequence number this row started at, and its identity on screen.
|
|
*
|
|
* The list is drawn newest-first, so every new message is an insertion at index 0 and every
|
|
* page of history is an insertion at the far end. Without an identity that survives both, the
|
|
* list is addressed by position: whatever somebody had scrolled to keeps its index while the
|
|
* content underneath it slides, which reads as the view scrolling on its own.
|
|
*
|
|
* A seq is the right identity because it is what the transcript itself is ordered by, it never
|
|
* changes, and it is already carried by every event. A row built from several events -- a
|
|
* streaming message, a tool call and its result -- keeps the seq of the first, so it holds
|
|
* still while the rest of it arrives.
|
|
*/
|
|
abstract val seq: Long
|
|
|
|
data class UserMsg(override val seq: Long, val text: String) : TranscriptItem()
|
|
|
|
data class AssistantMsg(override val seq: Long, val text: String) : TranscriptItem()
|
|
|
|
data class ToolRun(
|
|
override val seq: Long,
|
|
val id: String,
|
|
val tool: String,
|
|
val input: String,
|
|
val output: String,
|
|
val done: Boolean,
|
|
/**
|
|
* The permission ask for this call, when there is one.
|
|
*
|
|
* On the call's own row rather than beside it: the ask used to arrive as a second card
|
|
* repeating the input verbatim, so the reader saw the same command twice and had to work
|
|
* out that it was one event. The backend says which call a permission is about, so this is
|
|
* a fact rather than a match on the input.
|
|
*/
|
|
val ask: QuestionCard? = null,
|
|
/**
|
|
* Images this call's result carried, drawn under it.
|
|
*
|
|
* Beside it they had to be paired by position, and position is the thing a page boundary
|
|
* breaks -- a screenshot loaded on one page and its call on the next read as unrelated.
|
|
*/
|
|
val images: List<String> = emptyList(),
|
|
) : TranscriptItem()
|
|
|
|
data class QuestionCard(
|
|
override val seq: Long,
|
|
val id: String,
|
|
val prompt: String,
|
|
val options: List<String>,
|
|
val answer: String?,
|
|
) : TranscriptItem()
|
|
|
|
data class ErrorMsg(override val seq: Long, val message: String) : TranscriptItem()
|
|
|
|
/** An image by server-side ref, fetched from the session's files route. */
|
|
data class ImageItem(override val seq: Long, val ref: String) : TranscriptItem()
|
|
|
|
/**
|
|
* A message another agent sent this session.
|
|
*
|
|
* Its own row rather than a [UserMsg]: see [PeerMessageRow] for why the voice matters.
|
|
*/
|
|
data class PeerNote(override val seq: Long, val from: String, val text: String) :
|
|
TranscriptItem()
|
|
|
|
/** Placeholder row for events this build can't render (newer kinds). */
|
|
data class Note(override val seq: Long, val text: String) : TranscriptItem()
|
|
|
|
/**
|
|
* A compaction that happened, and what it recovered.
|
|
*
|
|
* In the transcript rather than only in the status line, because the status is gone the moment
|
|
* it finishes and this is the part worth keeping: it is the explanation for a gap in the
|
|
* conversation, and for a minute or two in which the session was busy with nothing to show.
|
|
*/
|
|
data class CompactedNote(
|
|
override val seq: Long,
|
|
val preTokens: Long?,
|
|
val postTokens: Long?,
|
|
val trigger: String?,
|
|
) : TranscriptItem()
|
|
}
|
|
|
|
fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem> =
|
|
when (val event = entry.event) {
|
|
is SessionEvent.UserMessage -> items + TranscriptItem.UserMsg(entry.seq, event.text)
|
|
is SessionEvent.AssistantText -> {
|
|
// Deltas accumulate into the message they're streaming, which keeps the seq of the
|
|
// first of them: a row whose identity changed with every delta would be a new row on
|
|
// every frame, and the list would jump for the whole of a streamed answer.
|
|
val last = items.lastOrNull()
|
|
if (last is TranscriptItem.AssistantMsg) {
|
|
items.dropLast(1) + last.copy(text = last.text + event.delta)
|
|
} else {
|
|
items + TranscriptItem.AssistantMsg(entry.seq, event.delta)
|
|
}
|
|
}
|
|
is SessionEvent.ToolStart ->
|
|
items +
|
|
TranscriptItem.ToolRun(
|
|
entry.seq,
|
|
event.id,
|
|
event.tool,
|
|
event.input,
|
|
"",
|
|
done = false,
|
|
)
|
|
is SessionEvent.ToolUpdate -> updateTool(items, event.id) { it.copy(output = event.output) }
|
|
is SessionEvent.ToolEnd ->
|
|
// Created when its start is not here, rather than dropped. A
|
|
// fold that only ever *updates* loses the whole call when the
|
|
// start fell outside the loaded window, and a tool call that
|
|
// renders as nothing is indistinguishable from one that never
|
|
// happened. The name is unknown from an end alone; loading the
|
|
// page before this one replaces the row with the real thing.
|
|
if (items.any { it is TranscriptItem.ToolRun && it.id == event.id }) {
|
|
updateTool(items, event.id) { it.copy(output = event.output, done = true) }
|
|
} else {
|
|
items +
|
|
TranscriptItem.ToolRun(
|
|
entry.seq,
|
|
event.id,
|
|
"tool",
|
|
"",
|
|
event.output,
|
|
done = true,
|
|
)
|
|
}
|
|
is SessionEvent.Question -> {
|
|
val card =
|
|
TranscriptItem.QuestionCard(
|
|
entry.seq,
|
|
event.id,
|
|
event.prompt,
|
|
event.options,
|
|
null,
|
|
)
|
|
// A question with no tool behind it -- AskUserQuestion, or an ask
|
|
// whose call fell outside the loaded window -- is a card of its
|
|
// own, which is what every question was before this.
|
|
if (
|
|
event.about != null &&
|
|
items.any { it is TranscriptItem.ToolRun && it.id == event.about }
|
|
) {
|
|
updateTool(items, event.about) { it.copy(ask = card) }
|
|
} else {
|
|
items + card
|
|
}
|
|
}
|
|
is SessionEvent.Answered ->
|
|
// Resolved wherever it is drawn: a card of its own, or a tool
|
|
// row's ask. Missing the second left an Allow/Deny pair live on
|
|
// a question already answered from another device.
|
|
items.map {
|
|
when {
|
|
it is TranscriptItem.QuestionCard && it.id == event.id ->
|
|
it.copy(answer = event.answer)
|
|
it is TranscriptItem.ToolRun && it.ask?.id == event.id ->
|
|
it.copy(ask = it.ask.copy(answer = event.answer))
|
|
else -> it
|
|
}
|
|
}
|
|
is SessionEvent.PeerMessage ->
|
|
items + TranscriptItem.PeerNote(entry.seq, event.from, event.text)
|
|
// Screen-level state, not transcript rows -- see SessionScreen.
|
|
is SessionEvent.Settings -> items
|
|
is SessionEvent.Status -> items
|
|
is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message)
|
|
is SessionEvent.Image ->
|
|
// Under the call that produced it when there is one, and a row of
|
|
// its own when there is not -- a person's own attachment belongs
|
|
// to no call, and neither does one whose call fell outside the
|
|
// loaded window.
|
|
if (
|
|
event.about != null &&
|
|
items.any { it is TranscriptItem.ToolRun && it.id == event.about }
|
|
) {
|
|
updateTool(items, event.about) { it.copy(images = it.images + event.ref) }
|
|
} else {
|
|
items + TranscriptItem.ImageItem(entry.seq, event.ref)
|
|
}
|
|
is SessionEvent.Compacted ->
|
|
items +
|
|
TranscriptItem.CompactedNote(
|
|
entry.seq,
|
|
event.preTokens,
|
|
event.postTokens,
|
|
event.trigger,
|
|
)
|
|
is SessionEvent.Unknown -> items + TranscriptItem.Note(entry.seq, "[${event.type}]")
|
|
// Screen-level state, not transcript rows -- see SessionScreen.
|
|
is SessionEvent.UsageDelta -> items
|
|
}
|
|
|
|
private fun updateTool(
|
|
items: List<TranscriptItem>,
|
|
id: String,
|
|
change: (TranscriptItem.ToolRun) -> TranscriptItem.ToolRun,
|
|
): List<TranscriptItem> = items.map {
|
|
if (it is TranscriptItem.ToolRun && it.id == id) change(it) else it
|
|
}
|
|
|
|
@Composable
|
|
fun SessionScreen(
|
|
settings: ServerSettings,
|
|
summary: SessionSummary,
|
|
onBack: () -> Unit,
|
|
onUsage: () -> Unit,
|
|
onSettings: () -> Unit,
|
|
) {
|
|
val scope = rememberCoroutineScope()
|
|
var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
|
|
var status by remember { mutableStateOf(summary.status) }
|
|
var totalTokens by remember { mutableLongStateOf(0L) }
|
|
// When this screen saw the current compaction start, on this device's own clock, and how long
|
|
// ago that is. See `compactingLabel`: null is the honest answer whenever the start was not
|
|
// witnessed here, which is what opening a session that is already compacting looks like.
|
|
var compactingSince by remember { mutableStateOf<Long?>(null) }
|
|
var compactingFor by remember { mutableStateOf<Long?>(null) }
|
|
var streamError by remember { mutableStateOf<String?>(null) }
|
|
var actionError by remember { mutableStateOf<String?>(null) }
|
|
var input by remember { mutableStateOf("") }
|
|
var expandedTools by remember { mutableStateOf(setOf<String>()) }
|
|
// Which runs of adjacent tool calls are open. Keyed by the first call's
|
|
// id, so a group survives more calls arriving after it.
|
|
var expandedGroups by remember { mutableStateOf(setOf<String>()) }
|
|
// Which messages from other agents are open, by the seq that identifies their row. Closed
|
|
// by default, which is the rule for anything new in this transcript: a screen that opens
|
|
// everything it can is one nobody can scan.
|
|
var expandedNotes by remember { mutableStateOf(setOf<Long>()) }
|
|
// Uploaded-but-not-yet-sent attachment ids; sent with the next message.
|
|
var pendingAttachments by remember { mutableStateOf(listOf<String>()) }
|
|
// What this session is set to now, seeded from the row that opened it and
|
|
// then owned here, because changing either is something this screen does.
|
|
var model by remember { mutableStateOf(summary.model) }
|
|
var permissionMode by remember { mutableStateOf(summary.permissionMode ?: "auto") }
|
|
// The models this provider actually offers, asked of the server rather
|
|
// than listed here: a hardcoded list is a claim about a machine.
|
|
var offeredModels by remember { mutableStateOf<List<String>>(emptyList()) }
|
|
val context = LocalContext.current
|
|
val lifecycleOwner = LocalLifecycleOwner.current
|
|
// The resume cursor, written from the stream's IO thread.
|
|
val lastSeq = remember { AtomicLong(0) }
|
|
val activeStream = remember { AtomicReference<EventStream?>(null) }
|
|
// The oldest sequence number loaded, and whether there is more behind
|
|
// it. Paging backwards is what keeps opening a long session cheap: the
|
|
// screen starts with the end of the conversation and fetches earlier
|
|
// pages only when somebody scrolls to them.
|
|
var oldestSeq by remember { mutableLongStateOf(0L) }
|
|
// Every transcript event loaded, in order, beside the rows they folded
|
|
// into. See `apply`.
|
|
var loaded by remember { mutableStateOf(listOf<SessionEvent>()) }
|
|
// Sent, but not yet read by the session -- which is when the backend
|
|
// records it and it comes back as a row. Until then it is drawn below
|
|
// the working indicator, because that is where it is in the session's
|
|
// reading of events: after everything taken in, not yet taken in
|
|
// itself.
|
|
var queued by remember { mutableStateOf(listOf<String>()) }
|
|
val running = status == "running" || status == "compacting"
|
|
var moreHistory by remember { mutableStateOf(true) }
|
|
var loadingHistory by remember { mutableStateOf(false) }
|
|
var ready by remember { mutableStateOf(false) }
|
|
val listState = rememberLazyListState()
|
|
// What is actually drawn: the transcript with runs of adjacent tool
|
|
// calls folded into one row each.
|
|
val rows = remember(items) { groupToolRuns(items) }
|
|
|
|
fun apply(entry: SeqEvent) {
|
|
lastSeq.set(entry.seq)
|
|
// 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
|
|
}
|
|
when (val event = entry.event) {
|
|
is SessionEvent.UsageDelta -> totalTokens += event.tokens
|
|
else -> {
|
|
// What the session says it is set to now, which is the only thing that
|
|
// says it: picking from either menu asks, and the answer comes back here.
|
|
if (event is SessionEvent.Settings) {
|
|
event.model?.let { model = it }
|
|
event.permissionMode?.let { permissionMode = it }
|
|
}
|
|
if (event is SessionEvent.Status) {
|
|
// Started here, or nowhere. `ready` is what separates the live stream from
|
|
// the page of history the screen opens with, and a compaction found in that
|
|
// page began before anybody here was watching -- timing it from now would
|
|
// report the moment we arrived as the moment it started.
|
|
compactingSince =
|
|
when {
|
|
event.state != "compacting" -> null
|
|
status == "compacting" -> compactingSince
|
|
ready -> SystemClock.elapsedRealtime()
|
|
else -> null
|
|
}
|
|
status = event.state
|
|
}
|
|
// The message coming back is the session saying it has
|
|
// read it, so the bubble held below the indicator becomes
|
|
// the row `foldEvent` is about to add. Matched by text --
|
|
// all that distinguishes one message from an identical
|
|
// earlier one -- and only the first match, so two
|
|
// identical messages wait twice.
|
|
if (event is SessionEvent.UserMessage) queued = queued - event.text
|
|
// Kept as well as folded. Folding is one-way -- a tool's
|
|
// start and end become one row -- so a page arriving in
|
|
// front of what is already here cannot be stitched on
|
|
// without the events themselves.
|
|
loaded = loaded + event
|
|
items = foldEvent(items, entry)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A compaction reports nothing about its own progress -- measured against the CLI, which
|
|
// says it has started, and then says nothing at all until it is done. So what this counts is
|
|
// the one thing anybody here can measure: how long it has been going. A bar filling up would
|
|
// be this screen inventing the part the CLI does not send.
|
|
LaunchedEffect(compactingSince) {
|
|
val since = compactingSince
|
|
if (since == null) {
|
|
compactingFor = null
|
|
return@LaunchedEffect
|
|
}
|
|
while (true) {
|
|
compactingFor = (SystemClock.elapsedRealtime() - since) / 1000
|
|
delay(1000)
|
|
}
|
|
}
|
|
|
|
// The stream lifecycle: connect, follow, and on any drop reconnect
|
|
// from the cursor -- so a flaky link (or a backend restart) costs
|
|
// nothing but the gap's latency.
|
|
// The newest page first, in one request, before the stream opens. The
|
|
// stream then starts from where that page ended, so it carries live
|
|
// events only -- which is what it is good at.
|
|
LaunchedEffect(summary.id) {
|
|
try {
|
|
val page = withContext(Dispatchers.IO) { fetchTranscript(settings, summary.id) }
|
|
page.forEach { apply(it) }
|
|
} catch (e: ApiException) {
|
|
// Not fatal: the stream below still replays from zero, which is
|
|
// slow but complete. Saying so beats silently showing nothing.
|
|
streamError = e.message
|
|
}
|
|
ready = true
|
|
}
|
|
|
|
// Only while the screen is actually on screen. Android stops the
|
|
// activity when somebody switches away, and the socket dies with it --
|
|
// which arrived as "Lost the event stream (SocketTimeoutException)"
|
|
// waiting at the top on their return. Switching apps is a choice
|
|
// somebody made, not a fault to report, and reconnecting on a phone
|
|
// that has been backgrounded is work nobody is watching. Stopping the
|
|
// stream deliberately makes the drop a close rather than an error (see
|
|
// EventStream.close), and resuming reconnects from the same cursor.
|
|
LaunchedEffect(summary.id, ready, lifecycleOwner) {
|
|
if (!ready) return@LaunchedEffect
|
|
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
|
try {
|
|
while (true) {
|
|
val stream = EventStream(settings, summary.id)
|
|
activeStream.set(stream)
|
|
try {
|
|
withContext(Dispatchers.IO) {
|
|
stream.run(
|
|
after = lastSeq.get(),
|
|
// Connected, measured rather than inferred: this is what
|
|
// takes a failure off the screen, and nothing else does.
|
|
// Clearing on the first event instead meant an idle
|
|
// session kept displaying an error it had recovered from.
|
|
onOpen = { streamError = null },
|
|
onReset = {
|
|
// Too far behind to continue from: what is on
|
|
// screen is a stale prefix of a conversation
|
|
// that has moved on, and the window arriving
|
|
// next is not adjacent to it. Dropping the rows
|
|
// is what makes this the same as opening the
|
|
// screen -- `apply` refills them, and scrolling
|
|
// up pages the rest back in as it always does.
|
|
items = listOf()
|
|
loaded = listOf()
|
|
oldestSeq = 0L
|
|
moreHistory = true
|
|
},
|
|
) { entry ->
|
|
apply(entry)
|
|
}
|
|
}
|
|
} catch (e: ApiException) {
|
|
streamError = e.message
|
|
} finally {
|
|
stream.close()
|
|
}
|
|
delay(RECONNECT_DELAY_MS)
|
|
}
|
|
} finally {
|
|
// Cancellation -- going below STARTED, or leaving the screen --
|
|
// cannot interrupt a blocking socket read. Closing is what
|
|
// unblocks it, and what marks the drop deliberate.
|
|
activeStream.getAndSet(null)?.close()
|
|
}
|
|
}
|
|
}
|
|
// The screen going away entirely, which the lifecycle scope above does
|
|
// not cover: a composable can leave the composition while the activity
|
|
// stays started.
|
|
DisposableEffect(summary.id) { onDispose { activeStream.get()?.close() } }
|
|
|
|
// Whether the newest message is on screen right now. The list is laid out from the bottom
|
|
// (see the LazyColumn below), so "newest" is index 0 and being there is being at the start of
|
|
// it. This is what the jump-to-newest button watches: it is about what the reader can see.
|
|
val atNewest by remember {
|
|
derivedStateOf {
|
|
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
|
|
}
|
|
}
|
|
// Whether they *chose* to be there, which is a different question and the one that decides
|
|
// whether an arriving message brings the view with it.
|
|
//
|
|
// Remembered, and only ever written when a scroll settles -- so it records where the reader
|
|
// last left the list, and an insertion cannot change the answer. Reading the live position
|
|
// instead looks right and is subtly wrong: a keyed list moves its anchor to keep the reader's
|
|
// content still, so by the time the new item can be observed the view is already one item
|
|
// away from the newest and reports itself as scrolled back. The message then never followed,
|
|
// which was visible as a compaction whose progress bar sat just off the bottom of the screen
|
|
// while the button that started it said it was running.
|
|
var followTail by remember { mutableStateOf(true) }
|
|
LaunchedEffect(listState) {
|
|
snapshotFlow { listState.isScrollInProgress }
|
|
.collect { scrolling -> if (!scrolling) followTail = atNewest }
|
|
}
|
|
// A new item at the newest end shifts every index by one, so the view
|
|
// has to step back to 0 to stay put. One item, instantly -- not a
|
|
// journey through the transcript.
|
|
// Anything that changes how much room the list has, as well as a new
|
|
// item arriving. Typing is the case that gets missed: the field grows
|
|
// from one line to four and the keyboard opens under it, and neither
|
|
// is a new message, so watching the item count alone leaves the newest
|
|
// text drifting out of sight while somebody writes a reply to it.
|
|
//
|
|
// Counted in list items rather than in transcript rows, because the rows are not all of it:
|
|
// the working indicator and a queued message are items too, and they arrive at exactly the
|
|
// same end. Sibling to the paging trigger below, which is the same count read from the other
|
|
// end for the same reason.
|
|
LaunchedEffect(listState) {
|
|
snapshotFlow {
|
|
Pair(listState.layoutInfo.totalItemsCount, listState.layoutInfo.viewportSize.height)
|
|
}
|
|
.collect { (count, _) -> if (followTail && count > 0) listState.scrollToItem(0) }
|
|
}
|
|
// Reaching the far end of what is loaded -- the oldest item, which in
|
|
// this layout is the last index -- fetches the page before it.
|
|
//
|
|
// Both numbers come from the list itself, and that is the point: an index into what is drawn
|
|
// can only be compared against how much is drawn. Three things already make that differ from
|
|
// the event count -- a run of adjacent tool calls is one row, and the queued bubble and the
|
|
// working indicator are rows with no event behind them at all -- so measuring the far end in
|
|
// events meant the threshold could not be reached, and a session with tool calls in it simply
|
|
// stopped scrolling back. Anything added to this list later is a fourth, and totalItemsCount
|
|
// already counts it.
|
|
LaunchedEffect(listState, rows.size, moreHistory) {
|
|
snapshotFlow {
|
|
val layout = listState.layoutInfo
|
|
Pair(layout.visibleItemsInfo.lastOrNull()?.index ?: 0, layout.totalItemsCount)
|
|
}
|
|
.collect { (last, total) ->
|
|
if (!moreHistory || loadingHistory || total == 0) return@collect
|
|
if (last < total - 3) return@collect
|
|
loadingHistory = true
|
|
try {
|
|
val older =
|
|
withContext(Dispatchers.IO) {
|
|
fetchTranscript(settings, summary.id, before = oldestSeq)
|
|
}
|
|
if (older.isEmpty()) {
|
|
moreHistory = false
|
|
} else {
|
|
oldestSeq = older.first().seq
|
|
moreHistory = oldestSeq > 1L
|
|
// Folded oldest-first into a list of their own, then
|
|
// put in front: `foldEvent` merges streaming text
|
|
// into the item before it, so replaying an older page
|
|
// through the live list would glue it onto the newest
|
|
// message rather than its own.
|
|
var earlier = listOf<TranscriptItem>()
|
|
older.forEach { entry ->
|
|
if (entry.event !is SessionEvent.UsageDelta) {
|
|
earlier = foldEvent(earlier, entry)
|
|
}
|
|
}
|
|
items = earlier + items
|
|
}
|
|
} catch (_: ApiException) {
|
|
// Leave `moreHistory` alone: the next scroll asks again.
|
|
} finally {
|
|
loadingHistory = false
|
|
}
|
|
}
|
|
}
|
|
|
|
LaunchedEffect(summary.setupName, summary.provider) {
|
|
offeredModels =
|
|
try {
|
|
withContext(Dispatchers.IO) {
|
|
fetchSetups(settings)
|
|
.firstOrNull { it.name == summary.setupName }
|
|
?.providers
|
|
?.firstOrNull { it.name == summary.provider }
|
|
?.models
|
|
.orEmpty()
|
|
}
|
|
} catch (_: Exception) {
|
|
// Not worth reporting: the picker simply has nothing to
|
|
// offer, which is visible, and the session is unaffected.
|
|
emptyList()
|
|
}
|
|
}
|
|
|
|
fun act(onFailure: () -> Unit = {}, action: () -> Unit) {
|
|
scope.launch {
|
|
try {
|
|
withContext(Dispatchers.IO) { action() }
|
|
actionError = null
|
|
} catch (e: ApiException) {
|
|
actionError = e.message
|
|
onFailure()
|
|
}
|
|
}
|
|
}
|
|
|
|
fun send() {
|
|
val text = input.trim()
|
|
val attachments = pendingAttachments
|
|
if (text.isEmpty() && attachments.isEmpty()) return
|
|
input = ""
|
|
pendingAttachments = emptyList()
|
|
if (running && text.isNotEmpty()) queued = queued + text
|
|
// A held message leaves this list exactly two ways: the session
|
|
// reads it, which comes back as a UserMessage (see `apply`), or the
|
|
// send itself failed and there is nothing to wait for. Clearing the
|
|
// whole list when a turn ended was neither -- the server holds a
|
|
// queue of its own and takes one message per turn, so ending a turn
|
|
// is precisely when the *rest* are still waiting. It wiped them off
|
|
// the screen while they were on their way, which reads as messages
|
|
// two and three having been dropped.
|
|
act(onFailure = { queued = queued - text }) {
|
|
sendMessage(settings, summary.id, text, attachments)
|
|
}
|
|
}
|
|
|
|
// The system photo picker; the image uploads as soon as it's chosen,
|
|
// so Send only has ids to reference.
|
|
val pickImage =
|
|
rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
|
|
if (uri != null) {
|
|
scope.launch {
|
|
try {
|
|
val id =
|
|
withContext(Dispatchers.IO) {
|
|
val bytes =
|
|
context.contentResolver.openInputStream(uri)?.use {
|
|
it.readBytes()
|
|
} ?: throw ApiException("couldn't read the picked image")
|
|
val mime = context.contentResolver.getType(uri) ?: "image/jpeg"
|
|
uploadAttachment(settings, summary.id, bytes, mime)
|
|
}
|
|
pendingAttachments = pendingAttachments + id
|
|
actionError = null
|
|
} catch (e: ApiException) {
|
|
actionError = e.message
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Column(Modifier.fillMaxSize()) {
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
|
|
) {
|
|
TextButton(onClick = onBack) { Text("Back") }
|
|
Column(Modifier.weight(1f)) {
|
|
Text(summary.title, style = MaterialTheme.typography.titleMedium)
|
|
Text(
|
|
listOfNotNull(
|
|
summary.provider,
|
|
"on ${summary.setupName}",
|
|
// What the session says it is set to now, which is the same fact
|
|
// the picker below shows and has to be the same answer.
|
|
model?.let { modelLabel(it) },
|
|
if (totalTokens > 0) "$totalTokens tok" else null,
|
|
)
|
|
.joinToString(" · "),
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
// Beside the provider it reports on, which is the line directly to its left.
|
|
//
|
|
// Its real home is this provider's settings, which do not exist yet; until they do,
|
|
// the session is the only place the provider is already named, so it is the only
|
|
// place the button can sit without inventing a scope for itself. What it shows is
|
|
// the paid service's own numbers, so a session on a provider with no such service
|
|
// gets an honest "unavailable" rather than a hidden button -- a control that comes
|
|
// and goes makes its absence the signal, and absence cannot say why.
|
|
TextButton(onClick = onUsage) { Text("Usage") }
|
|
// A step down from this session, so it sits at the end of the session's own row.
|
|
// The name is the whole of what it holds today, which is why it is a gear and not a
|
|
// word: there will be more, and a bar of words has nowhere to put it.
|
|
IconButton(
|
|
onClick = onSettings,
|
|
modifier = Modifier.semantics { contentDescription = "Session settings" },
|
|
) {
|
|
Gear()
|
|
}
|
|
}
|
|
|
|
(streamError ?: actionError)?.let { message ->
|
|
Text(
|
|
message,
|
|
color = MaterialTheme.colorScheme.error,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
|
)
|
|
}
|
|
|
|
// Laid out from the bottom, with the newest message at index 0.
|
|
//
|
|
// The obvious arrangement -- oldest first, then scroll to the end
|
|
// -- opens at the top and travels the whole transcript to get
|
|
// where it belongs. On an imported session that is nine hundred
|
|
// items measured before anything is readable, seen as the view
|
|
// visibly racing downward every time it opened.
|
|
//
|
|
// Anchoring at the bottom removes the journey rather than hiding
|
|
// it: the first frame is already the newest message, and older
|
|
// ones are composed only as somebody scrolls back to them, which
|
|
// is also what makes history cheap on a long conversation.
|
|
Box(Modifier.weight(1f).fillMaxWidth()) {
|
|
LazyColumn(
|
|
state = listState,
|
|
reverseLayout = true,
|
|
modifier = Modifier.fillMaxSize(),
|
|
contentPadding = PaddingValues(16.dp),
|
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
) {
|
|
// Below the working indicator, 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.
|
|
if (queued.isNotEmpty()) {
|
|
item(key = "queued") {
|
|
Column(horizontalAlignment = Alignment.End) {
|
|
queued.forEach { text -> UserBubble(text, pending = true) }
|
|
}
|
|
}
|
|
}
|
|
// Where the next thing will appear: at the end of what has
|
|
// happened, which in this layout is the top of the list.
|
|
// In the corner it was a label about the session; here it
|
|
// is a place, and the eye is already there because that is
|
|
// where the newest message is.
|
|
if (running) {
|
|
item(key = "indicator") {
|
|
// Two shapes for two kinds of busy: an ordinary turn is a spinner beside
|
|
// a word, because the answer it is producing appears directly below it,
|
|
// and a compaction takes the whole row because nothing else will.
|
|
if (status == "compacting") {
|
|
CompactingRow(compactingFor)
|
|
} else {
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
CircularProgressIndicator(
|
|
modifier = Modifier.width(14.dp).height(14.dp),
|
|
strokeWidth = 2.dp,
|
|
)
|
|
Spacer(Modifier.width(8.dp))
|
|
Text(
|
|
"working",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
} else if (status == "exited") {
|
|
// Still said somewhere: a session whose process is gone
|
|
// cannot be typed at, and with the corner label removed
|
|
// nothing else on this screen would mention it.
|
|
item(key = "exited") {
|
|
Text(
|
|
"exited",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
}
|
|
// Reversed to match the layout, so index 0 is the newest and
|
|
// the reader still sees them in the order they happened.
|
|
// Grouped first: adjacent tool calls collapse into one row,
|
|
// which is a decision about this screen and not about the
|
|
// transcript the stream and paging share.
|
|
// Keyed, and this is what stops the list moving under whoever is reading
|
|
// it. Every new message is an insertion at index 0 here, so without a key the
|
|
// rows keep their positions and the content slides through them -- which looks
|
|
// exactly like the view scrolling by itself. The keys above matter for the same
|
|
// reason: the working indicator appearing and disappearing is another insertion
|
|
// at the same end. Paging older history is the opposite insertion and was
|
|
// already fine, and stays fine, because a key survives both.
|
|
items(rows.asReversed(), key = { it.seq }) { row ->
|
|
when (row) {
|
|
is TranscriptRow.Tools ->
|
|
ToolGroup(
|
|
group = row,
|
|
expanded = row.id in expandedGroups,
|
|
onToggle = {
|
|
expandedGroups =
|
|
if (row.id in expandedGroups) expandedGroups - row.id
|
|
else expandedGroups + row.id
|
|
},
|
|
isToolExpanded = { it in expandedTools },
|
|
onToolToggle = { id ->
|
|
expandedTools =
|
|
if (id in expandedTools) expandedTools - id
|
|
else expandedTools + id
|
|
},
|
|
onAnswer = { call, answer ->
|
|
call.ask?.let { ask ->
|
|
act { answerQuestion(settings, summary.id, ask.id, answer) }
|
|
}
|
|
},
|
|
image = { ref -> SessionImage(settings, summary.id, ref) },
|
|
)
|
|
is TranscriptRow.Single ->
|
|
when (val item = row.item) {
|
|
is TranscriptItem.UserMsg -> UserBubble(item.text)
|
|
is TranscriptItem.AssistantMsg -> AssistantMessage(item.text)
|
|
is TranscriptItem.ToolRun ->
|
|
ToolCard(
|
|
tool = item,
|
|
expanded = item.id in expandedTools,
|
|
onToggle = {
|
|
expandedTools =
|
|
if (item.id in expandedTools)
|
|
expandedTools - item.id
|
|
else expandedTools + item.id
|
|
},
|
|
onAnswer = { answer ->
|
|
item.ask?.let { ask ->
|
|
act {
|
|
answerQuestion(
|
|
settings,
|
|
summary.id,
|
|
ask.id,
|
|
answer,
|
|
)
|
|
}
|
|
}
|
|
},
|
|
image = { ref -> SessionImage(settings, summary.id, ref) },
|
|
)
|
|
is TranscriptItem.QuestionCard ->
|
|
QuestionRow(item) { answer ->
|
|
act {
|
|
answerQuestion(settings, summary.id, item.id, answer)
|
|
}
|
|
}
|
|
is TranscriptItem.ErrorMsg ->
|
|
Text(
|
|
item.message,
|
|
color = MaterialTheme.colorScheme.error,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
)
|
|
is TranscriptItem.ImageItem ->
|
|
SessionImage(settings, summary.id, item.ref)
|
|
is TranscriptItem.Note ->
|
|
Text(
|
|
item.text,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
is TranscriptItem.CompactedNote -> CompactedRow(item)
|
|
is TranscriptItem.PeerNote ->
|
|
PeerMessageRow(
|
|
item = item,
|
|
expanded = item.seq in expandedNotes,
|
|
onToggle = {
|
|
expandedNotes =
|
|
if (item.seq in expandedNotes)
|
|
expandedNotes - item.seq
|
|
else expandedNotes + item.seq
|
|
},
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Only while the newest message is off-screen. Reading back
|
|
// through a conversation is a place to be, not a state to be
|
|
// rescued from, so this waits to be wanted.
|
|
//
|
|
// Down, and the same chevron a tool group collapses with: the
|
|
// list is built upside down internally, but nobody reading it
|
|
// knows that -- on screen the newest message is at the bottom,
|
|
// which is where this goes. The name is carried in the
|
|
// description, since an arrow alone says nothing to a screen
|
|
// reader and nothing to whoever finds this in six months.
|
|
if (!atNewest) {
|
|
Surface(
|
|
onClick = { scope.launch { listState.animateScrollToItem(0) } },
|
|
shape = CircleShape,
|
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
|
modifier =
|
|
Modifier.align(Alignment.BottomCenter).padding(bottom = 12.dp).semantics {
|
|
contentDescription = "Jump to latest"
|
|
},
|
|
) {
|
|
Chevron(
|
|
pointingUp = false,
|
|
colour = MaterialTheme.colorScheme.onSurface,
|
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Always enabled -- a send while the session is running becomes a
|
|
// steering message injected at the next tool boundary, which is
|
|
// the point of the whole app.
|
|
//
|
|
// The field gets a row of its own, above the buttons: sharing one
|
|
// put the full width behind three controls, so the thing being
|
|
// typed into was the narrowest thing on the row.
|
|
Column(Modifier.fillMaxWidth().padding(8.dp)) {
|
|
OutlinedTextField(
|
|
value = input,
|
|
onValueChange = { input = it },
|
|
modifier = Modifier.fillMaxWidth(),
|
|
placeholder = {
|
|
Text(if (pendingAttachments.isEmpty()) "Message" else "Message (+image)")
|
|
},
|
|
maxLines = 4,
|
|
)
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
TextButton(
|
|
onClick = {
|
|
pickImage.launch(
|
|
PickVisualMediaRequest(
|
|
ActivityResultContracts.PickVisualMedia.ImageOnly
|
|
)
|
|
)
|
|
}
|
|
) {
|
|
Text(if (pendingAttachments.isEmpty()) "+" else "+${pendingAttachments.size}")
|
|
}
|
|
// The settings share what is left after the actions have
|
|
// taken what they need. A Row hands out intrinsic widths in
|
|
// order and clips whatever runs past the edge, so with
|
|
// these laid out first the arrival of Stop pushed Send off
|
|
// the screen entirely -- the app's central control, gone at
|
|
// exactly the moment the app is most in use.
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.weight(1f),
|
|
) {
|
|
if (offeredModels.isNotEmpty()) {
|
|
PickerButton(
|
|
current = modelLabel(model),
|
|
options = offeredModels,
|
|
// Not set here. The button follows what the session reports it
|
|
// is set to, which arrives a moment later and is sometimes a
|
|
// different answer -- a name the CLI resolved, or no change at all
|
|
// on a provider whose model is fixed when it starts.
|
|
onPick = { chosen ->
|
|
act { setSessionModel(settings, summary.id, chosen) }
|
|
},
|
|
)
|
|
}
|
|
PickerButton(
|
|
current = permissionMode,
|
|
options = PERMISSION_MODES,
|
|
onPick = { chosen ->
|
|
act { setSessionPermissionMode(settings, summary.id, chosen) }
|
|
},
|
|
)
|
|
}
|
|
if (running) {
|
|
OutlinedButton(onClick = { act { interruptSession(settings, summary.id) } }) {
|
|
Text("Stop")
|
|
}
|
|
Spacer(Modifier.width(8.dp))
|
|
}
|
|
// "Queue" while a turn is in flight, because that is what
|
|
// sending then does: the message is injected at the next
|
|
// tool boundary rather than starting a turn of its own.
|
|
// Naming it Send there would promise something immediate
|
|
// and describe something that waits.
|
|
Button(onClick = { send() }) { Text(if (running) "Queue" else "Send") }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* An inline transcript image, fetched (authenticated, pinned) from the session's files route. The
|
|
* bitmap is remembered per ref, so scrolling doesn't refetch.
|
|
*/
|
|
@Composable
|
|
private fun UserBubble(text: String, pending: Boolean = false) {
|
|
Box(Modifier.fillMaxWidth()) {
|
|
Card(
|
|
// A message the session has not read yet is drawn quieter than
|
|
// one it has. The difference is in degree -- said, not yet
|
|
// heard -- which is what colour alone can carry; where it sits
|
|
// is what says the rest.
|
|
colors =
|
|
CardDefaults.cardColors(
|
|
containerColor =
|
|
if (pending) MaterialTheme.colorScheme.surfaceVariant
|
|
else MaterialTheme.colorScheme.primaryContainer
|
|
),
|
|
modifier = Modifier.align(Alignment.CenterEnd).padding(start = 48.dp),
|
|
) {
|
|
Text(
|
|
text,
|
|
modifier = Modifier.padding(12.dp),
|
|
color =
|
|
if (pending) MaterialTheme.colorScheme.onSurfaceVariant
|
|
else MaterialTheme.colorScheme.onPrimaryContainer,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Collapsed by default: name plus a spinner while running, expandable to the input and output. The
|
|
* spinner-while-unfinished is exactly "ToolStart with no matching ToolEnd yet".
|
|
*/
|
|
|
|
/**
|
|
* 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: (String) -> Unit) {
|
|
Card(Modifier.fillMaxWidth()) {
|
|
Column(Modifier.padding(12.dp)) {
|
|
Text(question.prompt, style = MaterialTheme.typography.bodyLarge)
|
|
Spacer(Modifier.height(8.dp))
|
|
if (question.answer != null) {
|
|
Text(
|
|
"Answered: ${question.answer}",
|
|
style = MaterialTheme.typography.labelLarge,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
} else {
|
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
question.options.forEach { option ->
|
|
OutlinedButton(onClick = { onAnswer(option) }) { Text(option) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** The modes the CLI accepts, in the order they give up asking. */
|
|
private val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan")
|
|
|
|
/**
|
|
* A control that reads as its own value.
|
|
*
|
|
* The button *is* the current setting rather than a label beside one, so the row says what the
|
|
* session is set to without spending a second line on saying it.
|
|
*/
|
|
@Composable
|
|
private fun PickerButton(current: String, options: List<String>, onPick: (String) -> Unit) {
|
|
var open by remember { mutableStateOf(false) }
|
|
Box {
|
|
TextButton(onClick = { 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,
|
|
)
|
|
}
|
|
// Not focusable, so opening it does not take focus from the message
|
|
// field and dismiss the keyboard. Changing the model mid-sentence
|
|
// is an aside, not a departure from what you were typing.
|
|
DropdownMenu(
|
|
expanded = open,
|
|
onDismissRequest = { open = false },
|
|
properties = PopupProperties(focusable = false),
|
|
) {
|
|
options.forEach { option ->
|
|
DropdownMenuItem(
|
|
text = { Text(option) },
|
|
onClick = {
|
|
open = false
|
|
if (option != current) onPick(option)
|
|
},
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|