Merge branch 'main' of git.arirex.me:iris/ai-app

This commit is contained in:
iris committed 2026-08-31 15:41:23 -04:00
commit 21d22f89c2
20 files changed
+803 -606

No files matched your search

+4 -3
View File
@@ -205,9 +205,10 @@ first if a remote spawn ever mangles an argument.
`./run-android.sh` to build, install, and launch on the emulator. `./run-android.sh` to build, install, and launch on the emulator.
- **Android Lint is not optional and is not run by a build.** It found a - **Android Lint is not optional and is not run by a build.** It found a
crash that had been shipping: `java.time` on a minSdk-24 app with crash that had been shipping: `java.time` on a minSdk-24 app with
desugaring off. It is clean now apart from Compose 1.11.1 having a 1.12.0 desugaring off — and later a permission check that silently dropped every
available; keep it that way, and suppress with `tools:ignore` plus a notification on Android 12 and below. It is fully clean as of 2026-08-31;
written reason rather than by lowering the bar. keep it that way, and suppress with `tools:ignore` plus a written reason
rather than by lowering the bar.
- **The APK pins the CA of the machine that builds it**, read at build time - **The APK pins the CA of the machine that builds it**, read at build time
from `$XDG_CONFIG_HOME/ai-app/certs/ca.pem` (`AI_APP_CA` overrides) and from `$XDG_CONFIG_HOME/ai-app/certs/ca.pem` (`AI_APP_CA` overrides) and
generated into a constant. So the server must have started once on that generated into a constant. So the server must have started once on that
+1
View File
@@ -146,4 +146,5 @@ dependencies {
implementation(libs.zxing.embedded) implementation(libs.zxing.embedded)
implementation(libs.markdown.renderer) implementation(libs.markdown.renderer)
implementation(libs.highlights) implementation(libs.highlights)
implementation(libs.androidx.exifinterface)
} }
@@ -636,6 +636,16 @@ fun setSessionModel(settings: ServerSettings, sessionId: String, model: String)
) {} ) {}
} }
/**
* The permission modes the Claude CLI accepts, in the order they give up asking. "manual" asks for
* everything (each ask arrives on the phone as a question card); the others are the CLI's own
* escalating levels of autonomy.
*
* One list for every screen that offers them -- spawn, import, and the session's own picker --
* because three copies had already drifted: the import screen was missing "plan".
*/
val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan")
/** Switches how much a running session asks before acting, also in place. */ /** Switches how much a running session asks before acting, also in place. */
fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode: String) { fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode: String) {
requestFromServer( requestFromServer(
@@ -1,6 +1,8 @@
package com.example.aiapp package com.example.aiapp
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -93,6 +95,7 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
if (current == null) { if (current == null) {
// Not enrolled yet: settings is the only usable screen. The QR // Not enrolled yet: settings is the only usable screen. The QR
// path lands in MainActivity and recomposes from the top. // path lands in MainActivity and recomposes from the top.
Box(Modifier.imePadding()) {
SettingsScreen( SettingsScreen(
existing = null, existing = null,
onSaved = { saved -> onSaved = { saved ->
@@ -101,6 +104,7 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
}, },
onBack = null, onBack = null,
) )
}
return return
} }
@@ -148,8 +152,13 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
) )
} }
// Every screen but the session takes the keyboard as bottom padding here. The session
// screen deliberately does not: resizing a whole screen on every frame of the keyboard
// animation is the cost that made it lag, so it moves only its composer and transcript --
// see the layout note in SessionScreen.
when (val here = screen) { when (val here = screen) {
is Screen.Main -> is Screen.Main ->
Box(Modifier.imePadding()) {
MainScreen( MainScreen(
settings = current, settings = current,
reloadToken = reloadToken, reloadToken = reloadToken,
@@ -161,6 +170,7 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
}, },
onSettings = { screen = Screen.Settings }, onSettings = { screen = Screen.Settings },
) )
}
is Screen.Session -> is Screen.Session ->
// Keyed on the id, because a different session is a different screen rather than this // Keyed on the id, because a different session is a different screen rather than this
// one showing other rows. SessionScreen remembers a transcript, an open event stream, a // one showing other rows. SessionScreen remembers a transcript, an open event stream, a
@@ -172,6 +182,7 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
SessionScreen(settings = current, summary = here.summary, onBack = goToMain) SessionScreen(settings = current, summary = here.summary, onBack = goToMain)
} }
is Screen.Spawn -> is Screen.Spawn ->
Box(Modifier.imePadding()) {
SpawnScreen( SpawnScreen(
settings = current, settings = current,
onSpawned = { spawned -> onSpawned = { spawned ->
@@ -180,7 +191,9 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
}, },
onBack = goToMain, onBack = goToMain,
) )
}
is Screen.Settings -> is Screen.Settings ->
Box(Modifier.imePadding()) {
SettingsScreen( SettingsScreen(
existing = current, existing = current,
onSaved = { saved -> onSaved = { saved ->
@@ -190,6 +203,7 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
onBack = goToMain, onBack = goToMain,
) )
} }
}
// Last, so it draws over the screen above rather than under it: these are stacked in the Box // Last, so it draws over the screen above rather than under it: these are stacked in the Box
// the activity puts around this, and that Box paints in the order it was given. A session // the activity puts around this, and that Box paints in the order it was given. A session
@@ -4,8 +4,8 @@ import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.Matrix import android.graphics.Matrix
import android.media.ExifInterface
import android.net.Uri import android.net.Uri
import androidx.exifinterface.media.ExifInterface
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import kotlin.math.max import kotlin.math.max
@@ -4,6 +4,12 @@ import java.io.IOException
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
/**
* The frame name the server uses to say a cursor was too far behind to continue from. Must match
* `send_backlog` in the backend's routes.rs.
*/
private const val RESET_EVENT = "reset"
/** /**
* The SSE half of the API: one long-lived GET per open session screen, replaying the transcript * The SSE half of the API: one long-lived GET per open session screen, replaying the transcript
* after a cursor and then following it live. * after a cursor and then following it live.
@@ -13,12 +19,6 @@ import java.net.URL
* of throwing, so a deliberate close doesn't surface as a connection error. The caller owns * of throwing, so a deliberate close doesn't surface as a connection error. The caller owns
* reconnecting (with the last seq it saw as the new cursor) -- see SessionScreen. * reconnecting (with the last seq it saw as the new cursor) -- see SessionScreen.
*/ */
/**
* The frame name the server uses to say a cursor was too far behind to continue from. Must match
* `send_backlog` in the backend's routes.rs.
*/
private const val RESET_EVENT = "reset"
class EventStream(private val settings: ServerSettings, private val sessionId: String) { class EventStream(private val settings: ServerSettings, private val sessionId: String) {
@Volatile private var connection: HttpURLConnection? = null @Volatile private var connection: HttpURLConnection? = null
@Volatile private var closed = false @Volatile private var closed = false
@@ -26,6 +26,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -128,7 +129,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
} else { } else {
ChipGroup( ChipGroup(
label = "Permissions", label = "Permissions",
options = listOf("manual", "acceptEdits", "auto", "bypassPermissions"), options = PERMISSION_MODES,
selected = permissionMode, selected = permissionMode,
onSelect = { permissionMode = it }, onSelect = { permissionMode = it },
) )
@@ -199,7 +200,17 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
deleteImportable(settings, setup.id, session.id) deleteImportable(settings, setup.id, session.id)
} }
loadSessions(setup) // Only this row, and only what changed -- the same rule as the
// session list's delete. Refetching instead put every other row
// back through a loading spinner to report a change that was
// never in doubt.
val loaded = sessions
if (loaded is LoadState.Loaded) {
sessions =
LoadState.Loaded(
loaded.value.filterNot { it.id == session.id }
)
}
} catch (err: Exception) { } catch (err: Exception) {
failure = err.message ?: "Couldn't delete that session" failure = err.message ?: "Couldn't delete that session"
} }
@@ -273,16 +284,17 @@ private fun ImportableList(
// it is one long value with no natural break, where the // it is one long value with no natural break, where the
// lines below it are short enough to wrap readably. // lines below it are short enough to wrap readably.
// Cut at the head, because a path is identified by its // Cut at the head, because a path is identified by its
// tail and these all share a long prefix. // tail and these all share a long prefix. By the row's
// real width rather than a character count, which was
// one guess for every font size and screen.
session.cwd session.cwd
.takeIf { it.isNotEmpty() } .takeIf { it.isNotEmpty() }
?.let { cwd -> ?.let { cwd ->
Text( Text(
if (cwd.length > PATH_CHARS) cwd,
"" + cwd.takeLast(PATH_CHARS)
else cwd,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.StartEllipsis,
color = color =
MaterialTheme.colorScheme.onSurfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -333,9 +345,6 @@ private fun humanSize(bytes: Long): String? =
else -> "$bytes B" else -> "$bytes B"
} }
/** How much of a path a row shows before cutting its front off. */
private const val PATH_CHARS = 40
/** What this session is: the measurements, in the order they are worth knowing. */ /** What this session is: the measurements, in the order they are worth knowing. */
private fun statsOf(session: Importable, importing: String?): String = private fun statsOf(session: Importable, importing: String?): String =
listOfNotNull( listOfNotNull(
@@ -11,7 +11,6 @@ import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -21,7 +20,9 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.layout.layout
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -91,14 +92,52 @@ class MainActivity : ComponentActivity() {
Surface(modifier = Modifier.fillMaxSize()) { Surface(modifier = Modifier.fillMaxSize()) {
Box( Box(
modifier = modifier =
Modifier.fillMaxSize() // Timed like the transcript times itself, and for the same reason:
// the frame's draw phase is where Compose's measurement lands, and
// a report saying "draw is high" cannot otherwise say whether the
// cost is the transcript or the chrome around it. The keyboard is
// the case that made it matter -- every frame of the IME animation
// relays out and re-records this whole box.
Modifier.layout { measurable, constraints ->
val started = System.nanoTime()
val placeable = measurable.measure(constraints)
DebugStats.record(
"measure: the app root",
System.nanoTime() - started,
)
layout(placeable.width, placeable.height) {
val placing = System.nanoTime()
placeable.place(0, 0)
DebugStats.record(
"place: the app root",
System.nanoTime() - placing,
)
}
}
.drawWithContent {
val started = System.nanoTime()
drawContent()
DebugStats.record(
"record: the app root",
System.nanoTime() - started,
)
}
.fillMaxSize()
.statusBarsPadding() .statusBarsPadding()
// The gesture strip at the bottom of most // The gesture strip at the bottom of most
// phones. Without it the send row sits under // phones. Without it the send row sits under
// the swipe area, where a tap is as likely to // the swipe area, where a tap is as likely to
// navigate away as to press a button. // navigate away as to press a button.
//
// No imePadding here, deliberately: applied at the root it
// resizes this whole box on every frame of the keyboard
// animation, which re-measures, re-places and re-records every
// screen's entire tree per frame -- measured above as most of
// the frame budget. Each screen takes the keyboard itself
// (AppRoot wraps the ordinary ones; the session screen moves
// only its composer and transcript), so the per-frame cost is
// scoped to what actually moves.
.navigationBarsPadding() .navigationBarsPadding()
.imePadding()
) { ) {
AppRoot(settingsVersion, openRequest) AppRoot(settingsVersion, openRequest)
} }
@@ -54,26 +54,19 @@ fun AssistantMessage(
* *
* A reply carrying no notes is drawn from the message as it arrived rather than from the trimmed * A reply carrying no notes is drawn from the message as it arrived rather than from the trimmed
* prose part made while looking for them -- inspecting a message must not change it. That belongs * prose part made while looking for them -- inspecting a message must not change it. That belongs
* here rather than at the two places that need the answer, because [markdownIn] has to name the * here rather than at the places that need the answer, because [warm] has to name the same strings
* same strings this draws: a string warmed under a key no row ever looks up is a miss that nothing * the rows draw: a string warmed under a key no row ever looks up is a miss that nothing reports,
* reports, and the row pays the parse in the frame it appears, which is the cost being removed. * and the row pays the parse in the frame it appears, which is the cost being removed.
* *
* Public because [transcriptUnits] flattens settled replies into the same parts; go through * Public because [transcriptUnits] flattens settled replies into the same parts; go through
* [ParsedReplies.partsOf] on any path that runs per fold, so the scan happens once per message. * [ParsedReplies.partsOf] on any path that runs per fold or per page, so the scan happens once per
* message.
*/ */
fun messageParts(text: String): List<MessagePart> { fun messageParts(text: String): List<MessagePart> {
val parts = splitMemoryNotes(text) val parts = splitMemoryNotes(text)
return if (parts.singleOrNull() is MessagePart.Prose) listOf(MessagePart.Prose(text)) else parts return if (parts.singleOrNull() is MessagePart.Prose) listOf(MessagePart.Prose(text)) else parts
} }
/**
* Every string a reply will be drawn from, for [ParsedReplies.warm] to make ready.
*
* A string warmed under a key no row ever looks up is a miss that nothing reports, so this has to
* name what the rows actually draw rather than what the message contains.
*/
fun markdownIn(text: String): List<String> = messageParts(text).map { it.text }
@Composable @Composable
fun MemoryNote(note: MessagePart.Remembered, replies: ParsedReplies) { fun MemoryNote(note: MessagePart.Remembered, replies: ParsedReplies) {
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
@@ -151,7 +151,13 @@ class NotificationService : Service() {
// refused, and notifications switched off for the app in Android's own settings. Neither // refused, and notifications switched off for the app in Android's own settings. Neither
// is reported anywhere -- the person said no, and saying it back to them through the // is reported anywhere -- the person said no, and saying it back to them through the
// channel they closed is not available anyway. // channel they closed is not available anyway.
//
// The permission only exists from Android 13. Asking an older version about it gets
// "denied" for a name it does not know, which read as the person having said no -- so
// every notification on Android 12 and below was silently dropped. Before 13 the
// switch in Android's own settings, checked below, is the whole of the answer.
val allowed = val allowed =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED PackageManager.PERMISSION_GRANTED
if (!allowed || !manager.areNotificationsEnabled()) { if (!allowed || !manager.areNotificationsEnabled()) {
@@ -1,6 +1,5 @@
package com.example.aiapp package com.example.aiapp
import android.graphics.BitmapFactory
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
@@ -17,23 +16,14 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** /**
* What is about to be sent, directly above the box it will be sent from. * What is about to be sent, directly above the box it will be sent from.
@@ -78,17 +68,7 @@ private fun PendingThumbnail(
ref: String, ref: String,
onRemove: () -> Unit, onRemove: () -> Unit,
) { ) {
var bitmap by remember(ref) { mutableStateOf<ImageBitmap?>(null) } val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref)
var failed by remember(ref) { mutableStateOf(false) }
LaunchedEffect(ref) {
try {
val bytes = withContext(Dispatchers.IO) { fetchSessionFile(settings, sessionId, ref) }
bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
failed = bitmap == null
} catch (_: ApiException) {
failed = true
}
}
val shape = RoundedCornerShape(8.dp) val shape = RoundedCornerShape(8.dp)
Box( Box(
Modifier.size(THUMBNAIL) Modifier.size(THUMBNAIL)
@@ -3,13 +3,9 @@ package com.example.aiapp
import java.time.Duration import java.time.Duration
import java.time.OffsetDateTime import java.time.OffsetDateTime
/** // How long is left in a usage window. Shared by the session bar and the usage screen: the
* How long is left in a usage window. // arithmetic is the same in both and only the sentence around it differs, so everything here
* // returns the span or the state on its own and leaves the wording to the caller.
* Shared by the session bar and the usage screen: the arithmetic is the same in both and only the
* sentence around it differs, so this returns the span on its own and leaves the wording to the
* caller.
*/
/** "1d 4h", "3h 12m", "12m" -- the span alone, with no leading or trailing words. */ /** "1d 4h", "3h 12m", "12m" -- the span alone, with no leading or trailing words. */
fun formatSpan(until: Duration): String = fun formatSpan(until: Duration): String =
@@ -36,6 +36,38 @@ import androidx.compose.ui.window.DialogProperties
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
/**
* One image from the session's files route: the bitmap once it arrives, and whether it never will.
*
* [failed] exists because the two empty states differ in kind -- still coming and never coming --
* and a reader can act on the second; each caller supplies its own words for them.
*/
data class SessionBitmap(val bitmap: ImageBitmap?, val failed: Boolean)
/**
* Fetches (authenticated, pinned) and decodes one transcript image, remembered per ref so scrolling
* does not refetch.
*
* Shared by the transcript's images and the composer's pending attachments, because the fetch, the
* decode and the two-state answer are one block of logic that had been written twice.
*/
@Composable
fun rememberSessionBitmap(settings: ServerSettings, sessionId: String, ref: String): SessionBitmap {
var state by remember(ref) { mutableStateOf(SessionBitmap(null, failed = false)) }
LaunchedEffect(ref) {
state =
try {
val bytes =
withContext(Dispatchers.IO) { fetchSessionFile(settings, sessionId, ref) }
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
SessionBitmap(decoded, failed = decoded == null)
} catch (_: ApiException) {
SessionBitmap(null, failed = true)
}
}
return state
}
/** /**
* An image in the transcript: a fixed-height thumbnail that opens full screen. * An image in the transcript: a fixed-height thumbnail that opens full screen.
* *
@@ -50,18 +82,8 @@ import kotlinx.coroutines.withContext
*/ */
@Composable @Composable
fun SessionImage(settings: ServerSettings, sessionId: String, ref: String) { fun SessionImage(settings: ServerSettings, sessionId: String, ref: String) {
var bitmap by remember(ref) { mutableStateOf<ImageBitmap?>(null) } val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref)
var failed by remember(ref) { mutableStateOf(false) }
var full by remember(ref) { mutableStateOf(false) } var full by remember(ref) { mutableStateOf(false) }
LaunchedEffect(ref) {
try {
val bytes = withContext(Dispatchers.IO) { fetchSessionFile(settings, sessionId, ref) }
bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
failed = bitmap == null
} catch (_: ApiException) {
failed = true
}
}
val height = thumbnailHeight() val height = thumbnailHeight()
val heightPx = with(LocalDensity.current) { height.roundToPx() } val heightPx = with(LocalDensity.current) { height.roundToPx() }
Box(Modifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) { Box(Modifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) {
@@ -35,9 +35,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
// Status colors, keyed by the wire strings in Events.kt. Light theme only,
// as in dev-updater.
/** /**
* The sessions tab: sessions awaiting an answer sort to the top, which is the "your turn" inbox. * The sessions tab: sessions awaiting an answer sort to the top, which is the "your turn" inbox.
* *
@@ -7,16 +7,20 @@ import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@@ -38,10 +42,10 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -53,10 +57,12 @@ import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent 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.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -87,41 +93,46 @@ private const val RECONNECT_DELAY_MS = 1500L
private val LOADING_SPINNER = 48.dp private val LOADING_SPINNER = 48.dp
/** /**
* How much history to keep loaded past the oldest row on screen, counted in screenfuls. * How close, in screenfuls of estimated scroll, the reader may come to the end of loaded history
* before the next page is fetched.
* *
* Both the point at which history starts loading and how much of it a load has to produce before it * Multiplied by the viewport to give a number of *pixels* of scroll, which is the distance the
* stops. Multiplied by the viewport to give a number of *pixels* of scroll, which is the distance * question is actually about: how far the reader can keep going before they run out. A row is
* the question is actually about: how far the reader can keep going before they run out. A row is
* anything from one line to a page, so a count of rows is that distance only by accident. Eight * anything from one line to a page, so a count of rows is that distance only by accident. Eight
* rows was the number, and on a tool-heavy transcript eight rows is less than one screen: the * rows was the number once, and on a tool-heavy transcript eight rows is less than one screen: the
* reader reached the end of what was loaded on *every* swipe and waited a round trip standing * reader reached the end of what was loaded on *every* swipe and waited a round trip standing
* there, which is a list running out of transcript rather than a slow frame. Counting screenfuls of * there, which is a list running out of transcript rather than a slow frame.
* rows fixed the size of the mistake without fixing its kind; pixels are the unit itself.
* *
* Three, so a fling lands on rows that are already there and the page after them is on its way. The * Six, because the two ways to be wrong are not the same size: firing early costs a page fetched
* cost of being generous is a page fetched that nobody reads; the cost of being mean is a list that * that nobody reads, firing late is a spinner under somebody's finger for a whole round trip over
* stops under a finger, and those are not the same size. * the tunnel -- and the distance a hard fling covers while that fetch is in flight is several
* * screens on its own. The estimate this multiplies is built from measured unit sizes, so a bigger
* Counted at the server rather than inferred from the screen, which is the only measurement here * cushion no longer amplifies a bad guess the way it would have when the guess came from whatever
* that does not depend on how the emulator renders: against a 24,000-event transcript at `--delay * happened to be on screen.
* 120`, ten swipes asked for **ten** pages before this and **three** after.
*/ */
private const val HISTORY_SCREENS = 3 private const val HISTORY_SCREENS = 6
/** /**
* How many events a backwards page asks for, which is ten times what the opening page takes. * How many events a backwards page asks for, which is five times what the opening page takes.
* *
* Because an event is not a row, and the ratio is nothing like one to one. Measured on a real * The floor is that an event is not a row, and the ratio is nothing like one to one. Measured on a
* transcript (2,426 events, 2026-08-30): the whole conversation is *seven* assistant messages, and * real transcript (2,426 events, 2026-08-30): the whole conversation is *seven* assistant messages,
* the median run of consecutive text deltas that fold into one of them is four hundred. A page of * and the median run of consecutive text deltas that fold into one of them is four hundred. A page
* eighty is therefore a fifth of a single row, and reaching a screenful of fresh rows took about * of eighty is therefore a fifth of a single row, and reaching a screenful of fresh rows took about
* thirty sequential round trips inside one collect -- a stutter on loopback, and four or five * thirty sequential round trips inside one collect. Below this number a page can add no visible
* seconds of a list that will not move over the tunnel, which reads as history having run out. * room at all, and the fetch chain degenerates into those round trips again.
*
* At the floor rather than above it, because pages are fetched in the background before the reader
* arrives -- the cushion decides how deep loading runs, and a page that was not enough is followed
* by another without anybody waiting on either. What a *smaller* page buys is hiding: it crosses
* the tunnel in half the time and lands in a smaller frame spike, so the case where the reader
* outruns an in-flight fetch is rarer and cheaper. This was 800 when the reader was the one
* standing at the boundary and each round trip had to be amortized as far as it would go.
* *
* The opening page stays small: it is the one on the critical path of showing the screen at all, * The opening page stays small: it is the one on the critical path of showing the screen at all,
* and it only has to fill a viewport. * and it only has to fill a viewport.
*/ */
private const val HISTORY_PAGE = 800 private const val HISTORY_PAGE = 400
/** /**
* The most events one request of a restore may ask for. * The most events one request of a restore may ask for.
@@ -197,433 +208,13 @@ private fun Modifier.holdTopEdge(key: Any, held: TopEdgeHold, hold: (Int) -> Uni
} }
} }
/** // The transcript's data model -- TranscriptItem, foldEvent, joinPages, warm -- lives in
* What the transcript renders: the event stream folded into displayable rows (see [foldEvent]). The // TranscriptItems.kt: it is pure event folding with no screen in it, and the two halves changed
* stream is the only data source -- opening this screen replays from seq 0, and a reconnect resumes // for unrelated reasons while they shared this file.
* from the last seq seen, so there is no separate history fetch to drift from it.
*/
@Immutable
sealed class TranscriptItem {
/**
* The transcript sequence number this row started at, and its identity on screen.
*
* The list is drawn newest-first, so every new message is an insertion at index 0 and every
* page of history is an insertion at the far end. Without an identity that survives both, the
* list is addressed by position: whatever somebody had scrolled to keeps its index while the
* content underneath it slides, which reads as the view scrolling on its own.
*
* A seq is the right identity because it is what the transcript itself is ordered by, it never
* changes, and it is already carried by every event. A row built from several events -- a
* streaming message, a tool call and its result -- keeps the seq of the first, so it holds
* still while the rest of it arrives.
*/
abstract val seq: Long
data class UserMsg(
override val seq: Long,
val text: String,
/** Refs of what was attached, drawn inside the bubble. */
val images: List<String> = emptyList(),
) : TranscriptItem()
data class AssistantMsg(override val seq: Long, val text: String) : TranscriptItem()
data class ToolRun(
override val seq: Long,
val id: String,
/**
* The run of adjacent calls this one belongs to, named once when the call is folded in and
* never recomputed.
*
* Carried rather than derived because a run can gain members at *either* end -- a new call
* arriving beside it, or a page of history arriving in front of it -- so no function of its
* current members is stable. It is the first call's id at the moment the run started, which
* is a name rather than a description: [joinPages] hands it to older calls that turn out to
* belong to the same run, instead of renaming the run they joined.
*/
val runId: String,
val tool: String,
val input: String,
val output: String,
val done: Boolean,
/**
* The questions this call is waiting on, in the order they were asked.
*
* On the call's own row rather than beside it: an ask used to arrive as a second card
* repeating the input verbatim, so the reader saw the same command twice and had to work
* out that it was one event. The backend says which call a question is about, so this is a
* fact rather than a match on the input.
*
* A list because AskUserQuestion asks up to four at once, and they are one decision to make
* -- a permission is the case of exactly one, not a different shape.
*/
val asks: List<QuestionCard> = emptyList(),
/**
* Images this call's result carried, drawn under it.
*
* Beside it they had to be paired by position, and position is the thing a page boundary
* breaks -- a screenshot loaded on one page and its call on the next read as unrelated.
*/
val images: List<String> = emptyList(),
) : TranscriptItem()
data class QuestionCard(
override val seq: Long,
val id: String,
val prompt: String,
/** A few words naming what this is about, when the asker offered one. */
val header: String?,
val options: List<QuestionOption>,
/** Whether several options may be chosen at once. */
val multiSelect: Boolean,
/** What was chosen, once something was; empty until then. */
val answers: List<String>,
) : TranscriptItem()
data class ErrorMsg(override val seq: Long, val message: String) : TranscriptItem()
/** An image by server-side ref, fetched from the session's files route. */
data class ImageItem(override val seq: Long, val ref: String) : TranscriptItem()
/**
* A message another agent sent this session.
*
* Its own row rather than a [UserMsg]: see [PeerMessageRow] for why the voice matters.
*/
data class PeerNote(override val seq: Long, val from: String, val text: String) :
TranscriptItem()
/**
* A command the session ran on itself -- `/compact`, `/rename`.
*
* Kept in the transcript rather than only shown while it waits, because it explains what
* follows: a conversation that suddenly has half the context, or a session with a new name.
*/
data class CommandRow(override val seq: Long, val text: String) : TranscriptItem()
/** Placeholder row for events this build can't render (newer kinds). */
data class Note(override val seq: Long, val text: String) : TranscriptItem()
/**
* A clear that happened: everything above it left the session's context and stayed on screen.
*
* Carries only its position, because that is all it means.
*/
data class ClearedNote(override val seq: Long) : TranscriptItem()
/**
* A compaction that happened, and what it recovered.
*
* In the transcript rather than only in the status line, because the status is gone the moment
* it finishes and this is the part worth keeping: it is the explanation for a gap in the
* conversation, and for a minute or two in which the session was busy with nothing to show.
*
* The wire also says what triggered it, and this deliberately does not carry that: the row says
* the two sizes and nothing else (see [compactionSummary]), so keeping the trigger here would
* be a field nothing can read.
*/
data class CompactedNote(
override val seq: Long,
val preTokens: Long?,
val postTokens: Long?,
) : TranscriptItem()
}
/**
* The run a call joins: the one it lands next to, or a new one named after itself.
*
* Only ever consulted when the call is first folded in. That is what makes the name stable -- a run
* keeps whatever it was called when it started, however many calls arrive at either end of it
* afterwards.
*
* A question to the reader is in a run of its own, which is what puts it on the transcript as a row
* rather than inside a collapsed "Called 6 tools" card. Two things follow from being alone: it is
* always visible, since a run of one is drawn as itself rather than as a group; and the calls
* around it fall into a group before it and a group after it, so where the reader was asked
* something is legible in the shape of the transcript without opening anything. It ends the run
* before it as well as starting a fresh one after -- the moment somebody was asked is a boundary in
* the work, not a gap in the middle of one run.
*/
private fun runIdFor(items: List<TranscriptItem>, id: String, tool: String): String {
val previous = items.lastOrNull() as? TranscriptItem.ToolRun ?: return id
if (tool == ASK_USER_QUESTION || previous.tool == ASK_USER_QUESTION) return id
return previous.runId
}
/**
* Puts a page of older items in front of the ones already loaded, healing whatever the page
* boundary cut in two.
*
* Two things straddle a boundary: a tool call separated from its result, and a message separated
* from the rest of itself. Both were one thing before the transcript was cut into pages, and both
* have to be one thing again -- a reply drawn as two messages is the same defect as a call drawn
* twice, arriving from the same cause.
*
* A boundary lands wherever it lands, and roughly half the time that is between a call and its
* result. The newer page then holds a `ToolEnd` whose start it never saw, which [foldEvent] draws
* as a row of its own -- correctly, because a call that renders as nothing is indistinguishable
* from one that never happened. When the older page arrives it brings the real `ToolStart`, and
* concatenating the two lists left *both*: the same call twice, once as a proper card and once as a
* nameless placeholder. Visible as a run of four calls reporting "Called 5 tools", and worse than
* the miscount -- the extra row is at the join, so it also moves everything the reader was looking
* at.
*
* Merged by the call's own id rather than by position, because position is exactly what a page
* boundary destroys. The older row wins on what a start knows (the tool's name, its input) and the
* newer on what an end knows (the output, and whether it finished), which is the only way round
* that loses nothing.
*/
fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<TranscriptItem> {
val (older, newer) = healSplitMessage(earlier, later)
val startedEarlier =
older.filterIsInstance<TranscriptItem.ToolRun>().mapTo(mutableSetOf()) { it.id }
if (startedEarlier.isEmpty()) return older + newer
val endedLater =
newer
.filterIsInstance<TranscriptItem.ToolRun>()
.associateBy { it.id }
.filterKeys { it in startedEarlier }
if (endedLater.isEmpty()) return older + newer
val healed = older.map { row ->
val half = (row as? TranscriptItem.ToolRun)?.let { endedLater[it.id] }
if (row is TranscriptItem.ToolRun && half != null) {
row.copy(
output = half.output,
done = half.done,
// Kept from both halves: a question or an image can be attached to either,
// depending on which side of the boundary its event fell.
asks = row.asks + half.asks,
images = row.images + half.images,
)
} else {
row
}
}
val kept = newer.filterNot { it is TranscriptItem.ToolRun && it.id in endedLater }
return adoptRun(healed, kept) + kept
}
/**
* Rejoins a message the page boundary cut, and hands back the two pages to concatenate.
*
* [foldEvent] never leaves two assistant messages next to each other inside one page -- deltas
* accumulate into the message before them -- so two meeting at a join are always the two halves of
* one reply, and leaving them apart drew a single answer as two, with a paragraph break through the
* middle of a sentence.
*
* The newer half keeps its identity, for the reason [adoptRun] gives: it is the row already on
* screen, and renaming that is how the list loses its anchor. It grows by what the older half
* brings, which is safe here and nowhere else -- the join is at the oldest end of what is loaded,
* so the growth extends off the top of the screen, away from the row the list anchors to.
*/
private fun healSplitMessage(
earlier: List<TranscriptItem>,
later: List<TranscriptItem>,
): Pair<List<TranscriptItem>, List<TranscriptItem>> {
val head = earlier.lastOrNull()
val tail = later.firstOrNull()
if (head !is TranscriptItem.AssistantMsg || tail !is TranscriptItem.AssistantMsg) {
return earlier to later
}
return earlier.dropLast(1) to (listOf(tail.copy(text = head.text + tail.text)) + later.drop(1))
}
/**
* Hands the older calls at the join the name of the run they are joining.
*
* The two pages were folded separately, so a run split by the boundary came back as two runs with
* two names. Naming the joined run after the *older* half would be the obvious way round and is the
* wrong one: the newer half is the part already on screen, and renaming it is renaming the row the
* reader is looking at, which is how a list loses its anchor and steps under them. So the arriving
* calls take the name of the ones already there, and nothing visible changes identity.
*/
private fun adoptRun(
earlier: List<TranscriptItem>,
later: List<TranscriptItem>,
): List<TranscriptItem> {
val first = later.firstOrNull() as? TranscriptItem.ToolRun ?: return earlier
// A question is in a run of its own on both sides of the join, the same as it would be had
// the two pages been folded as one -- see `runIdFor`. Without this the heal would merge a
// group straight through the row the reader was asked something on.
if (first.tool == ASK_USER_QUESTION) return earlier
val joining = first.runId
val tail = earlier.takeLastWhile {
it is TranscriptItem.ToolRun && it.tool != ASK_USER_QUESTION
}
if (tail.isEmpty()) return earlier
return earlier.dropLast(tail.size) +
tail.map { (it as TranscriptItem.ToolRun).copy(runId = joining) }
}
fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem> =
when (val event = entry.event) {
is SessionEvent.UserMessage ->
items + TranscriptItem.UserMsg(entry.seq, event.text, event.images)
is SessionEvent.AssistantText -> {
// Deltas accumulate into the message they're streaming, which keeps the seq of the
// first of them: a row whose identity changed with every delta would be a new row on
// every frame, and the list would jump for the whole of a streamed answer.
val last = items.lastOrNull()
if (last is TranscriptItem.AssistantMsg) {
items.dropLast(1) + last.copy(text = last.text + event.delta)
} else {
items + TranscriptItem.AssistantMsg(entry.seq, event.delta)
}
}
is SessionEvent.ToolStart ->
items +
TranscriptItem.ToolRun(
entry.seq,
event.id,
runIdFor(items, event.id, event.tool),
event.tool,
event.input,
"",
done = false,
)
is SessionEvent.ToolUpdate -> updateTool(items, event.id) { it.copy(output = event.output) }
is SessionEvent.ToolEnd ->
// Created when its start is not here, rather than dropped. A
// fold that only ever *updates* loses the whole call when the
// start fell outside the loaded window, and a tool call that
// renders as nothing is indistinguishable from one that never
// happened. The name is unknown from an end alone; loading the
// page before this one replaces the row with the real thing.
if (items.any { it is TranscriptItem.ToolRun && it.id == event.id }) {
updateTool(items, event.id) { it.copy(output = event.output, done = true) }
} else {
items +
TranscriptItem.ToolRun(
entry.seq,
event.id,
// The name is not known from an end alone, so a call that was an ask
// cannot be recognised as one here; loading the page before this
// replaces the row with the real thing, which is when it splits out.
runIdFor(items, event.id, "tool"),
"tool",
"",
event.output,
done = true,
)
}
is SessionEvent.Question -> {
val card =
TranscriptItem.QuestionCard(
entry.seq,
event.id,
event.prompt,
event.header,
event.options,
event.multiSelect,
emptyList(),
)
// A question with no tool behind it -- AskUserQuestion, or an ask
// whose call fell outside the loaded window -- is a card of its
// own, which is what every question was before this.
if (
event.about != null &&
items.any { it is TranscriptItem.ToolRun && it.id == event.about }
) {
updateTool(items, event.about) { it.copy(asks = it.asks + card) }
} else {
items + card
}
}
is SessionEvent.Answered ->
// Resolved wherever it is drawn: a card of its own, or a tool
// row's ask. Missing the second left an Allow/Deny pair live on
// a question already answered from another device.
items.map {
when {
it is TranscriptItem.QuestionCard && it.id == event.id ->
it.copy(answers = event.answers)
it is TranscriptItem.ToolRun && it.asks.any { ask -> ask.id == event.id } ->
it.copy(
asks =
it.asks.map { ask ->
if (ask.id == event.id) ask.copy(answers = event.answers)
else ask
}
)
else -> it
}
}
is SessionEvent.PeerMessage ->
items + TranscriptItem.PeerNote(entry.seq, event.from, event.text)
is SessionEvent.CommandSent -> items + TranscriptItem.CommandRow(entry.seq, event.text)
// Screen-level state, not transcript rows -- see SessionScreen.
is SessionEvent.CommandQueued -> items
// No row of its own: a message that is still waiting is drawn as a pending bubble below
// the transcript, and becomes an ordinary one where the session read it.
is SessionEvent.MessageQueued -> items
is SessionEvent.Settings -> items
is SessionEvent.Status -> items
is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message)
is SessionEvent.Image ->
// Under the call that produced it when there is one, and a row of
// its own when there is not -- a person's own attachment belongs
// to no call, and neither does one whose call fell outside the
// loaded window.
if (
event.about != null &&
items.any { it is TranscriptItem.ToolRun && it.id == event.about }
) {
updateTool(items, event.about) { it.copy(images = it.images + event.ref) }
} else {
items + TranscriptItem.ImageItem(entry.seq, event.ref)
}
is SessionEvent.Cleared -> items + TranscriptItem.ClearedNote(entry.seq)
is SessionEvent.Compacted ->
items + TranscriptItem.CompactedNote(entry.seq, event.preTokens, event.postTokens)
is SessionEvent.Unknown -> items + TranscriptItem.Note(entry.seq, "[${event.type}]")
// Screen-level state, not transcript rows -- see SessionScreen.
is SessionEvent.UsageDelta -> items
}
private fun updateTool(
items: List<TranscriptItem>,
id: String,
change: (TranscriptItem.ToolRun) -> TranscriptItem.ToolRun,
): List<TranscriptItem> = items.map {
if (it is TranscriptItem.ToolRun && it.id == id) change(it) else it
}
/**
* Where markdown is parsed ahead of being drawn: two threads, never all of them.
*
* The default dispatcher sizes itself to the machine, which is right for work somebody is waiting
* on and wrong for work nobody is. A page of history is hundreds of parses arriving at once, and
* taking every core for them leaves the thread that draws the frame queueing behind one -- measured
* on a Pixel 9 Pro XL as 21ms of `waited` at the 90th percentile, which is the frame failing to
* *start* rather than taking too long once it had.
*/
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
private val parsingThreads = Dispatchers.Default.limitedParallelism(2)
/**
* Parses the replies among [rows], off whatever thread is drawing.
*
* Called where a page of transcript is folded rather than where a row is composed, which is the
* whole point: the work happens seconds before the reader reaches the rows it was done for. See
* [ParsedReplies].
*/
private suspend fun warm(replies: ParsedReplies, rows: List<TranscriptItem>) {
// Including the search for what to parse, which is not the cheap half it looks like:
// [markdownIn] splits every assistant message looking for memory notes, and this is handed
// the *whole* loaded transcript on every page, so the scan grows with the conversation while
// the work it finds stays one page's worth. Off the calling thread it is nobody's frame.
withContext(parsingThreads) {
val texts =
rows
.filterIsInstance<TranscriptItem.AssistantMsg>()
.flatMap { markdownIn(it.text) }
.flatMap { replies.blocksOf(it) }
if (texts.isNotEmpty()) replies.warm(texts)
}
}
@Composable @Composable
fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () -> Unit) { fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () -> Unit) {
DebugStats.count("session screen recomposed")
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val topEdgeHeld = remember { TopEdgeHold() } val topEdgeHeld = remember { TopEdgeHold() }
var items by remember { mutableStateOf(listOf<TranscriptItem>()) } var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
@@ -1069,6 +660,22 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// state the screen can draw, and a permanently blank one is not. // state the screen can draw, and a permanently blank one is not.
restoring = false restoring = false
ready = true 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. The same reasoning that keeps the
// opening page off the critical path puts the first full page right behind it, while
// the screen is already up. A restore skips this: it has just paged as deep as the
// anchor needed.
if (savedAnchor == null && moreHistory && !loadingHistory) {
loadingHistory = true
try {
loadOlderPage()
} catch (_: ApiException) {
// The next scroll asks again.
} finally {
loadingHistory = false
}
}
} }
// Only while the screen is actually on screen. Android stops the // Only while the screen is actually on screen. Android stops the
@@ -1205,37 +812,46 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
) )
} }
} }
// Reaching the far end of what is loaded fetches the page before it. // 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 // 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 // -- and a lazy list cannot answer it exactly, because it has never measured the items it
// has not composed. So the room ahead is *estimated*: the units past the last visible one, // has not composed. So the room ahead is added up from the real size of every unit the
// at the typical size of the units that are on screen. A unit is at most a block of a reply, // list *has* laid out, kept by key as units pass through the viewport, with the running
// which is what makes the estimate usable where a count of rows was not -- a row is anything // average standing in for the ones it has never seen. It used to be the average of the
// from one line to twenty-five screens, a block is roughly a paragraph. Being wrong is // units currently on screen, and the units on screen are the worst possible sample: two
// cheap and one-sided in effect: too low fetches a page early, too high is corrected a few // tall blocks fill a viewport, multiply out over dozens of unseen one-line rows, and
// frames later as the real sizes scroll in, and the spinner item stands at the edge for // report screens of room when the end is one swipe away -- so the reader met the spinner
// whatever slips through. // at every boundary, which is exactly what the cushion exists to prevent.
// //
// There is no correction beside this one. Following the newest message is not an effect: // 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, // the list is reversed, so an arriving message extends the end the viewport is pinned to,
// and a page of history lands past every visible index and moves nothing. // and a page of history lands past every visible index and moves nothing.
val unitSizes = remember(summary.id) { HashMap<Any, Int>() }
LaunchedEffect(listState, moreHistory) { LaunchedEffect(listState, moreHistory) {
snapshotFlow { snapshotFlow { listState.layoutInfo }
val info = listState.layoutInfo .collect { info ->
val visible = info.visibleItemsInfo val visible = info.visibleItemsInfo
if (visible.isEmpty()) null if (visible.isEmpty()) return@collect
else // Before the guards below, so sizes keep accumulating while a page is in
Triple( // flight and the next estimate starts better informed.
info.totalItemsCount - 1 - visible.last().index, visible.forEach { unitSizes[it.key] = it.size }
visible.sumOf { it.size } / visible.size, if (restoring || !moreHistory || loadingHistory) return@collect
info.viewportSize.height, 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 -- starting the walk at `last().index`
// begins one unit past the last visible 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
} }
.collect { measured ->
val (ahead, typical, viewport) = measured ?: return@collect
if (restoring || !moreHistory || loadingHistory || viewport == 0) return@collect
if (ahead.toLong() * typical >= viewport.toLong() * HISTORY_SCREENS) return@collect
loadingHistory = true loadingHistory = true
try { try {
// One page, and then this fires again if it was not enough -- the estimate // One page, and then this fires again if it was not enough -- the estimate
@@ -1269,14 +885,13 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
} }
} }
fun act(onFailure: () -> Unit = {}, onDone: () -> Unit = {}, action: () -> Unit) { fun act(onDone: () -> Unit = {}, action: () -> Unit) {
scope.launch { scope.launch {
try { try {
withContext(Dispatchers.IO) { action() } withContext(Dispatchers.IO) { action() }
actionError = null actionError = null
} catch (e: ApiException) { } catch (e: ApiException) {
actionError = e.message actionError = e.message
onFailure()
} finally { } finally {
// Whatever happened, including the failure above: a caller that re-enables a // 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, // control here must get it back on the path where the request was refused too,
@@ -1356,6 +971,19 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
var usageOpen by remember { mutableStateOf(false) } var usageOpen by remember { mutableStateOf(false) }
var settingsOpen 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 -- measured on the
// emulator at ~7.6ms of main-thread work per frame across ~34 frames per open, and on the
// Pixel as 82% late frames while the transcript itself cost 0.25ms. Scoped this way, a
// keyboard frame costs one layer transform for the composer and one re-measure of the
// transcript box, whose children skip measurement (width unchanged) and whose rows are
// already layers.
var composerHeight by remember { mutableIntStateOf(0) }
val imeInsets = WindowInsets.ime
val navInsets = WindowInsets.navigationBars
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -1400,7 +1028,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// Left of the numbers about the *conversation*, because it is the same kind of // Left of the numbers about the *conversation*, because it is the same kind of
// thing about the *app*: what this session is costing to draw. It copies rather // thing about the *app*: what this session is costing to draw. It copies rather
// than opens, because what it produces is for somewhere else -- a message to // than opens, because what it produces is for somewhere else -- a message to
// whoever is looking at the code -- and a screenful of timings read on the phone // whoever is looking at the code -- and a screenful of timings read on the
// phone
// is a screenful nobody can act on. // is a screenful nobody can act on.
GlyphButton( GlyphButton(
SPEED_GLYPH, SPEED_GLYPH,
@@ -1432,17 +1061,20 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
context.copyToClipboard("ai-app render report", report) context.copyToClipboard("ai-app render report", report)
// Also to the log, so a session driving the app over adb can read the // 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 // same report the button copies. The clipboard is not reachable from a
// shell, and a counter nobody can check from here is a counter that only // shell, and a counter nobody can check from here is a counter that
// only
// gets checked by asking Iris to press a button and paste. // gets checked by asking Iris to press a button and paste.
Log.i("ai-app", report) Log.i("ai-app", report)
// Only once it is somewhere it can be read from, so a copy that never // Only once it is somewhere it can be read from, so a copy that never
// happened does not throw the stack away with it. // happened does not throw the stack away with it.
clearCrash(context) clearCrash(context)
// Emptied by the copy, so pressing it twice measures two separate stretches // Emptied by the copy, so pressing it twice measures two separate
// stretches
// of scrolling rather than one and then the same one again. // of scrolling rather than one and then the same one again.
frames.reset() frames.reset()
DebugStats.reset() DebugStats.reset()
Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT)
.show()
}, },
) )
GlyphButton( GlyphButton(
@@ -1452,7 +1084,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
colour = usageGlyphColour(usage), colour = usageGlyphColour(usage),
) )
// What it opens is about this session, so it sits at the end of the session's // What it opens is about this session, so it sits at the end of the session's
// own row. The name is the whole of what it holds today, which is why it is a cog // own row. The name is the whole of what it holds today, which is why it is a
// cog
// and not a word: there will be more, and a bar of words has nowhere to put it. // and not a word: there will be more, and a bar of words has nowhere to put it.
GlyphButton(SETTINGS_GLYPH, "Session settings", { settingsOpen = true }) GlyphButton(SETTINGS_GLYPH, "Session settings", { settingsOpen = true })
} }
@@ -1481,7 +1114,17 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// is fully built, and there is no frame in which the transcript is somewhere other // is fully built, and there is no frame in which the transcript is somewhere other
// than where it was left. // than where it was left.
val settled = !restoring val settled = !restoring
Box(Modifier.weight(1f).fillMaxWidth()) { 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 -- and then the keyboard's, per frame of its animation. 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() })
.imePadding()
) {
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
TranscriptList( TranscriptList(
units = units, units = units,
@@ -1490,8 +1133,10 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
modifier = modifier =
Modifier.fillMaxSize().drawWithContent { if (settled) drawContent() }, Modifier.fillMaxSize().drawWithContent { if (settled) drawContent() },
below = { below = {
// The last thing in the transcript, because that is where they are in the // The last thing in the transcript, because that is where they are in
// session's reading of events: after everything it has taken in, and not // 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 // yet taken in themselves. What the session is *doing* about them is a
// line below, in [SessionStatusRow]. // line below, in [SessionStatusRow].
if (queued.isNotEmpty() || waitingCommands.isNotEmpty()) { if (queued.isNotEmpty() || waitingCommands.isNotEmpty()) {
@@ -1525,7 +1170,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
Box( Box(
Modifier.holdTopEdge(row.key, topEdgeHeld) { grew -> Modifier.holdTopEdge(row.key, topEdgeHeld) { grew ->
// A *request*, not a raw scroll delta: this runs inside // A *request*, not a raw scroll delta: this runs inside
// the measure pass that discovered the new height, and a // the measure pass that discovered the new height, and
// a
// raw delta forces a synchronous remeasure from within // raw delta forces a synchronous remeasure from within
// measure, which is fatal // measure, which is fatal
// ("performMeasureAndLayout called during measure"). // ("performMeasureAndLayout called during measure").
@@ -1545,11 +1191,14 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
} }
// Which half of this row the touch landed in, for // Which half of this row the touch landed in, for
// [toggleAnchored]. // [toggleAnchored].
// On the initial pass and consuming nothing, so every control // On the initial pass and consuming nothing, so every
// control
// inside // inside
// still gets the gesture exactly as it would have; only visible // still gets the gesture exactly as it would have; only
// visible
// rows // rows
// have one, which is what makes a detector per row affordable. // have one, which is what makes a detector per row
// affordable.
.pointerInput(row.key) { .pointerInput(row.key) {
awaitEachGesture { awaitEachGesture {
val down = val down =
@@ -1576,15 +1225,18 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
} }
}, },
isToolExpanded = { it in expandedTools }, isToolExpanded = { it in expandedTools },
// Anchored on the group, not the call: opening one call // Anchored on the group, not the call: opening one
// call
// makes // makes
// the whole group taller, and the heading the reader is // the whole group taller, and the heading the
// reader is
// under // under
// is the group's. // is the group's.
onToolToggle = { id -> onToolToggle = { id ->
toggleAnchored(row) { toggleAnchored(row) {
expandedTools = expandedTools =
if (id in expandedTools) expandedTools - id if (id in expandedTools)
expandedTools - id
else expandedTools + id else expandedTools + id
} }
}, },
@@ -1614,12 +1266,19 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
is TranscriptItem.AssistantMsg -> is TranscriptItem.AssistantMsg ->
// A whole assistant row is only ever the reply // A whole assistant row is only ever the reply
// still // still
// arriving -- every settled reply is flattened into // arriving -- every settled reply is flattened
// block units instead; see [transcriptUnits]. Live // into
// block units instead; see [transcriptUnits].
// Live
// is // is
// what earns its blocks a layer each while deltas // what earns its blocks a layer each while
// deltas
// land. // land.
AssistantMessage(item.text, replies, live = true) AssistantMessage(
item.text,
replies,
live = true,
)
is TranscriptItem.ToolRun -> is TranscriptItem.ToolRun ->
ToolCard( ToolCard(
tool = item, tool = item,
@@ -1670,11 +1329,14 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
item.text, item.text,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = color =
MaterialTheme.colorScheme.onSurfaceVariant, MaterialTheme.colorScheme
.onSurfaceVariant,
) )
is TranscriptItem.CommandRow -> CommandBubble(item.text) is TranscriptItem.CommandRow ->
CommandBubble(item.text)
is TranscriptItem.ClearedNote -> ClearedRow() is TranscriptItem.ClearedNote -> ClearedRow()
is TranscriptItem.CompactedNote -> CompactedRow(item) is TranscriptItem.CompactedNote ->
CompactedRow(item)
is TranscriptItem.PeerNote -> is TranscriptItem.PeerNote ->
PeerMessageRow( PeerMessageRow(
item = item, item = item,
@@ -1705,12 +1367,17 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// //
// In the middle of the transcript rather than at either end, because it is not // In the middle of the transcript rather than at either end, because it is not
// reporting on the newest message or the oldest; it is standing in for all of them. // reporting on the newest message or the oldest; it is standing in for all of them.
// `settled` and not `restoring` alone, so the spinner covers the whole wait: fetching // `settled` and not `restoring` alone, so the spinner covers the whole wait:
// the history a saved position needs, and then the frames between those rows arriving // fetching
// and the layout that measures them putting the position back. They are the two halves // the history a saved position needs, and then the frames between those rows
// arriving
// and the layout that measures them putting the position back. They are the two
// halves
// of the same wait and the transcript is not drawn for either. // of the same wait and the transcript is not drawn for either.
if (!ready || !settled) { if (!ready || !settled) {
CircularProgressIndicator(Modifier.align(Alignment.Center).size(LOADING_SPINNER)) CircularProgressIndicator(
Modifier.align(Alignment.Center).size(LOADING_SPINNER)
)
} }
// Only while the newest message is off-screen. Reading back // Only while the newest message is off-screen. Reading back
@@ -1730,7 +1397,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// whose cost grows with how much there is to skip, which is backwards. // whose cost grows with how much there is to skip, which is backwards.
// //
// Arriving there is all this has to do now. The newest end is where the // Arriving there is all this has to do now. The newest end is where the
// content hangs from, so being at it is the whole of following it, and there // content hangs from, so being at it is the whole of following it, and
// there
// is no separate flag to set -- which is what this press used to forget, // is no separate flag to set -- which is what this press used to forget,
// landing the reader at the bottom with new messages not bringing the view // landing the reader at the bottom with new messages not bringing the view
// with them. // with them.
@@ -1738,9 +1406,9 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
shape = CircleShape, shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceContainerHigh, color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = modifier =
Modifier.align(Alignment.BottomCenter).padding(bottom = 12.dp).semantics { Modifier.align(Alignment.BottomCenter)
contentDescription = "Jump to latest" .padding(bottom = 12.dp)
}, .semantics { contentDescription = "Jump to latest" },
) { ) {
Chevron( Chevron(
pointingUp = false, pointingUp = false,
@@ -1750,7 +1418,27 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
} }
} }
} }
}
// 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 -- no
// measure, no recomposition, no re-recording of anything. Its height is reported to the
// transcript box above, which reserves that much room; the opaque background covers the
// one frame between this growing (a suggestion row, a second draft line) and that
// reservation catching up.
Column(
Modifier.align(Alignment.BottomCenter)
.fillMaxWidth()
.onSizeChanged { composerHeight = it.height }
.graphicsLayer {
translationY =
-(imeInsets.getBottom(this) - navInsets.getBottom(this))
.coerceAtLeast(0)
.toFloat()
}
.background(MaterialTheme.colorScheme.background)
) {
pendingModel?.let { chosen -> pendingModel?.let { chosen ->
ModelSwitchWarning( ModelSwitchWarning(
from = modelLabel(model), from = modelLabel(model),
@@ -1845,7 +1533,9 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// Asked about first, unless there is nothing to lose by it -- // Asked about first, unless there is nothing to lose by it --
// see [ModelSwitchWarning]. // see [ModelSwitchWarning].
onPick = { chosen -> onPick = { chosen ->
if (modelLabel(chosen) == modelLabel(model) || items.isEmpty()) { if (
modelLabel(chosen) == modelLabel(model) || items.isEmpty()
) {
act { setSessionModel(settings, summary.id, chosen) } act { setSessionModel(settings, summary.id, chosen) }
} else { } else {
pendingModel = chosen pendingModel = chosen
@@ -1862,13 +1552,16 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
) )
} }
// The same filled shape as the button beside it, not an outlined one: these are // The same filled shape as the button beside it, not an outlined one: these are
// two things you can do about the session, and weighting one of them as secondary // two things you can do about the session, and weighting one of them as
// secondary
// said they were a primary action and its qualifier. What separates them is the // said they were a primary action and its qualifier. What separates them is the
// colour and the mark, which is what they mean. // colour and the mark, which is what they mean.
// //
// Always here, rather than arriving with the turn as it used to. A control that // Always here, rather than arriving with the turn as it used to. A control that
// comes and goes makes its own presence the signal, and its absence could not say // comes and goes makes its own presence the signal, and its absence could not
// whether there was nothing to do; a button that is always in the same place also // say
// whether there was nothing to do; a button that is always in the same place
// also
// cannot push Send off the end of the row by turning up. // cannot push Send off the end of the row by turning up.
val process = val process =
when { when {
@@ -1895,7 +1588,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
// The paper plane, with a clock on it while a turn is in flight: sending then // 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 // 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 // its own, and the two have to be told apart at a glance. The label says the
// same
// thing to a screen reader, which has nothing else to read. // thing to a screen reader, which has nothing else to read.
// //
// Disabled while there is nothing to send, rather than pressable and silent: // Disabled while there is nothing to send, rather than pressable and silent:
@@ -1910,12 +1604,14 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
Glyph( Glyph(
if (running) QUEUE_GLYPH else SEND_GLYPH, if (running) QUEUE_GLYPH else SEND_GLYPH,
colour = LocalContentColor.current, colour = LocalContentColor.current,
modifier = Modifier.semantics { contentDescription = sendLabel(running) }, modifier =
Modifier.semantics { contentDescription = sendLabel(running) },
) )
} }
} }
} }
} }
}
if (usageOpen) { if (usageOpen) {
UsageDialog(settings = settings, onDismiss = { usageOpen = false }) UsageDialog(settings = settings, onDismiss = { usageOpen = false })
@@ -1974,8 +1670,10 @@ private fun ProcessAction.perform(settings: ServerSettings, sessionId: String) =
} }
/** /**
* An inline transcript image, fetched (authenticated, pinned) from the session's files route. The * A message the person holding the phone sent, in a bubble at their end of the conversation.
* bitmap is remembered per ref, so scrolling doesn't refetch. *
* [pending] is one the server has taken and the session has not read yet -- drawn quieter, because
* "said" and "heard" are different claims and the transcript must not merge them.
*/ */
@Composable @Composable
private fun UserBubble( private fun UserBubble(
@@ -2025,11 +1723,6 @@ private fun UserBubble(
/** A message the server has accepted and the session has not read yet. */ /** A message the server has accepted and the session has not read yet. */
private data class QueuedMessage(val id: String, val text: String, val images: List<String>) private data class QueuedMessage(val id: String, val text: String, val images: List<String>)
/**
* Collapsed by default: name plus a spinner while running, expandable to the input and output. The
* spinner-while-unfinished is exactly "ToolStart with no matching ToolEnd yet".
*/
/** /**
* Asked before switching model, because switching is not free and the cost is invisible. * Asked before switching model, because switching is not free and the cost is invisible.
* *
@@ -2097,6 +1790,7 @@ private fun SessionStatusRow(
contextTokens: Long?, contextTokens: Long?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
DebugStats.count("status row recomposed")
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
@@ -2209,9 +1903,6 @@ private fun QuestionRow(
*/ */
private const val ONE_TAP_MS = 250L private const val ONE_TAP_MS = 250L
/** The modes the CLI accepts, in the order they give up asking. */
private val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan")
/** /**
* A control that reads as its own value. * A control that reads as its own value.
* *
@@ -116,6 +116,7 @@ fun usageGlyphColour(usage: SessionUsage): Color =
*/ */
@Composable @Composable
fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) { fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
DebugStats.count("usage bar recomposed")
// The countdown moves even when the numbers do not, so it is driven by a clock of its own // The countdown moves even when the numbers do not, so it is driven by a clock of its own
// rather than recomputed at draw time: a percentage that comes back unchanged is an equal // rather than recomputed at draw time: a percentage that comes back unchanged is an equal
// value, Compose skips the recomposition, and a "left" that only ticked when the quota // value, Compose skips the recomposition, and a "left" that only ticked when the quota
@@ -33,13 +33,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
// Claude Code 2.x permission modes. "manual" asks for everything (each ask
// arrives on the phone as a question card); the others are the CLI's own
// escalating levels of autonomy.
private val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan")
/** Runs on the backend machine itself -- the "no host" case. */
/** /**
* The spawn screen: what to run, where to run it, and the per-kind fields. * The spawn screen: what to run, where to run it, and the per-kind fields.
* *
@@ -33,13 +33,12 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
/** /**
* A run of consecutive tool calls, or anything else, in the order they will be drawn. * One row as the transcript draws it: a run of consecutive tool calls, or anything else.
* *
* Grouping is decided here rather than when events are folded, because it is a display decision: * Grouping is decided here rather than when events are folded, because it is a display decision:
* the transcript's own order is what paging and the event stream depend on, and one screen's idea * the transcript's own order is what paging and the event stream depend on, and one screen's idea
* of "these belong together" must not reach back into it. * of "these belong together" must not reach back into it.
*/ *
/**
* Immutable, and said so, because Compose cannot tell. * Immutable, and said so, because Compose cannot tell.
* *
* A row is a value: it is rebuilt from the transcript rather than edited, and two rows describing * A row is a value: it is rebuilt from the transcript rather than edited, and two rows describing
@@ -0,0 +1,439 @@
package com.example.aiapp
import androidx.compose.runtime.Immutable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* What the transcript renders: the event stream folded into displayable rows (see [foldEvent]). The
* stream is the only data source -- opening a session 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.
*/
@Immutable
sealed class TranscriptItem {
/**
* The transcript sequence number this row started at, and its identity on screen.
*
* The list is drawn newest-first, so every new message is an insertion at index 0 and every
* page of history is an insertion at the far end. Without an identity that survives both, the
* list is addressed by position: whatever somebody had scrolled to keeps its index while the
* content underneath it slides, which reads as the view scrolling on its own.
*
* A seq is the right identity because it is what the transcript itself is ordered by, it never
* changes, and it is already carried by every event. A row built from several events -- a
* streaming message, a tool call and its result -- keeps the seq of the first, so it holds
* still while the rest of it arrives.
*/
abstract val seq: Long
data class UserMsg(
override val seq: Long,
val text: String,
/** Refs of what was attached, drawn inside the bubble. */
val images: List<String> = emptyList(),
) : TranscriptItem()
data class AssistantMsg(override val seq: Long, val text: String) : TranscriptItem()
data class ToolRun(
override val seq: Long,
val id: String,
/**
* The run of adjacent calls this one belongs to, named once when the call is folded in and
* never recomputed.
*
* Carried rather than derived because a run can gain members at *either* end -- a new call
* arriving beside it, or a page of history arriving in front of it -- so no function of its
* current members is stable. It is the first call's id at the moment the run started, which
* is a name rather than a description: [joinPages] hands it to older calls that turn out to
* belong to the same run, instead of renaming the run they joined.
*/
val runId: String,
val tool: String,
val input: String,
val output: String,
val done: Boolean,
/**
* The questions this call is waiting on, in the order they were asked.
*
* On the call's own row rather than beside it: an ask used to arrive as a second card
* repeating the input verbatim, so the reader saw the same command twice and had to work
* out that it was one event. The backend says which call a question is about, so this is a
* fact rather than a match on the input.
*
* A list because AskUserQuestion asks up to four at once, and they are one decision to make
* -- a permission is the case of exactly one, not a different shape.
*/
val asks: List<QuestionCard> = emptyList(),
/**
* Images this call's result carried, drawn under it.
*
* Beside it they had to be paired by position, and position is the thing a page boundary
* breaks -- a screenshot loaded on one page and its call on the next read as unrelated.
*/
val images: List<String> = emptyList(),
) : TranscriptItem()
data class QuestionCard(
override val seq: Long,
val id: String,
val prompt: String,
/** A few words naming what this is about, when the asker offered one. */
val header: String?,
val options: List<QuestionOption>,
/** Whether several options may be chosen at once. */
val multiSelect: Boolean,
/** What was chosen, once something was; empty until then. */
val answers: List<String>,
) : TranscriptItem()
data class ErrorMsg(override val seq: Long, val message: String) : TranscriptItem()
/** An image by server-side ref, fetched from the session's files route. */
data class ImageItem(override val seq: Long, val ref: String) : TranscriptItem()
/**
* A message another agent sent this session.
*
* Its own row rather than a [UserMsg]: see [PeerMessageRow] for why the voice matters.
*/
data class PeerNote(override val seq: Long, val from: String, val text: String) :
TranscriptItem()
/**
* A command the session ran on itself -- `/compact`, `/rename`.
*
* Kept in the transcript rather than only shown while it waits, because it explains what
* follows: a conversation that suddenly has half the context, or a session with a new name.
*/
data class CommandRow(override val seq: Long, val text: String) : TranscriptItem()
/** Placeholder row for events this build can't render (newer kinds). */
data class Note(override val seq: Long, val text: String) : TranscriptItem()
/**
* A clear that happened: everything above it left the session's context and stayed on screen.
*
* Carries only its position, because that is all it means.
*/
data class ClearedNote(override val seq: Long) : TranscriptItem()
/**
* A compaction that happened, and what it recovered.
*
* In the transcript rather than only in the status line, because the status is gone the moment
* it finishes and this is the part worth keeping: it is the explanation for a gap in the
* conversation, and for a minute or two in which the session was busy with nothing to show.
*
* The wire also says what triggered it, and this deliberately does not carry that: the row says
* the two sizes and nothing else (see [compactionSummary]), so keeping the trigger here would
* be a field nothing can read.
*/
data class CompactedNote(
override val seq: Long,
val preTokens: Long?,
val postTokens: Long?,
) : TranscriptItem()
}
/**
* The run a call joins: the one it lands next to, or a new one named after itself.
*
* Only ever consulted when the call is first folded in. That is what makes the name stable -- a run
* keeps whatever it was called when it started, however many calls arrive at either end of it
* afterwards.
*
* A question to the reader is in a run of its own, which is what puts it on the transcript as a row
* rather than inside a collapsed "Called 6 tools" card. Two things follow from being alone: it is
* always visible, since a run of one is drawn as itself rather than as a group; and the calls
* around it fall into a group before it and a group after it, so where the reader was asked
* something is legible in the shape of the transcript without opening anything. It ends the run
* before it as well as starting a fresh one after -- the moment somebody was asked is a boundary in
* the work, not a gap in the middle of one run.
*/
private fun runIdFor(items: List<TranscriptItem>, id: String, tool: String): String {
val previous = items.lastOrNull() as? TranscriptItem.ToolRun ?: return id
if (tool == ASK_USER_QUESTION || previous.tool == ASK_USER_QUESTION) return id
return previous.runId
}
/**
* Puts a page of older items in front of the ones already loaded, healing whatever the page
* boundary cut in two.
*
* Two things straddle a boundary: a tool call separated from its result, and a message separated
* from the rest of itself. Both were one thing before the transcript was cut into pages, and both
* have to be one thing again -- a reply drawn as two messages is the same defect as a call drawn
* twice, arriving from the same cause.
*
* A boundary lands wherever it lands, and roughly half the time that is between a call and its
* result. The newer page then holds a `ToolEnd` whose start it never saw, which [foldEvent] draws
* as a row of its own -- correctly, because a call that renders as nothing is indistinguishable
* from one that never happened. When the older page arrives it brings the real `ToolStart`, and
* concatenating the two lists left *both*: the same call twice, once as a proper card and once as a
* nameless placeholder. Visible as a run of four calls reporting "Called 5 tools", and worse than
* the miscount -- the extra row is at the join, so it also moves everything the reader was looking
* at.
*
* Merged by the call's own id rather than by position, because position is exactly what a page
* boundary destroys. The older row wins on what a start knows (the tool's name, its input) and the
* newer on what an end knows (the output, and whether it finished), which is the only way round
* that loses nothing.
*/
fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<TranscriptItem> {
val (older, newer) = healSplitMessage(earlier, later)
val startedEarlier =
older.filterIsInstance<TranscriptItem.ToolRun>().mapTo(mutableSetOf()) { it.id }
if (startedEarlier.isEmpty()) return older + newer
val endedLater =
newer
.filterIsInstance<TranscriptItem.ToolRun>()
.associateBy { it.id }
.filterKeys { it in startedEarlier }
if (endedLater.isEmpty()) return older + newer
val healed = older.map { row ->
val half = (row as? TranscriptItem.ToolRun)?.let { endedLater[it.id] }
if (row is TranscriptItem.ToolRun && half != null) {
row.copy(
output = half.output,
done = half.done,
// Kept from both halves: a question or an image can be attached to either,
// depending on which side of the boundary its event fell.
asks = row.asks + half.asks,
images = row.images + half.images,
)
} else {
row
}
}
val kept = newer.filterNot { it is TranscriptItem.ToolRun && it.id in endedLater }
return adoptRun(healed, kept) + kept
}
/**
* Rejoins a message the page boundary cut, and hands back the two pages to concatenate.
*
* [foldEvent] never leaves two assistant messages next to each other inside one page -- deltas
* accumulate into the message before them -- so two meeting at a join are always the two halves of
* one reply, and leaving them apart drew a single answer as two, with a paragraph break through the
* middle of a sentence.
*
* The newer half keeps its identity, for the reason [adoptRun] gives: it is the row already on
* screen, and renaming that is how the list loses its anchor. It grows by what the older half
* brings, which is safe here and nowhere else -- the join is at the oldest end of what is loaded,
* so the growth extends off the top of the screen, away from the row the list anchors to.
*/
private fun healSplitMessage(
earlier: List<TranscriptItem>,
later: List<TranscriptItem>,
): Pair<List<TranscriptItem>, List<TranscriptItem>> {
val head = earlier.lastOrNull()
val tail = later.firstOrNull()
if (head !is TranscriptItem.AssistantMsg || tail !is TranscriptItem.AssistantMsg) {
return earlier to later
}
return earlier.dropLast(1) to (listOf(tail.copy(text = head.text + tail.text)) + later.drop(1))
}
/**
* Hands the older calls at the join the name of the run they are joining.
*
* The two pages were folded separately, so a run split by the boundary came back as two runs with
* two names. Naming the joined run after the *older* half would be the obvious way round and is the
* wrong one: the newer half is the part already on screen, and renaming it is renaming the row the
* reader is looking at, which is how a list loses its anchor and steps under them. So the arriving
* calls take the name of the ones already there, and nothing visible changes identity.
*/
private fun adoptRun(
earlier: List<TranscriptItem>,
later: List<TranscriptItem>,
): List<TranscriptItem> {
val first = later.firstOrNull() as? TranscriptItem.ToolRun ?: return earlier
// A question is in a run of its own on both sides of the join, the same as it would be had
// the two pages been folded as one -- see `runIdFor`. Without this the heal would merge a
// group straight through the row the reader was asked something on.
if (first.tool == ASK_USER_QUESTION) return earlier
val joining = first.runId
val tail = earlier.takeLastWhile {
it is TranscriptItem.ToolRun && it.tool != ASK_USER_QUESTION
}
if (tail.isEmpty()) return earlier
return earlier.dropLast(tail.size) +
tail.map { (it as TranscriptItem.ToolRun).copy(runId = joining) }
}
fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem> =
when (val event = entry.event) {
is SessionEvent.UserMessage ->
items + TranscriptItem.UserMsg(entry.seq, event.text, event.images)
is SessionEvent.AssistantText -> {
// Deltas accumulate into the message they're streaming, which keeps the seq of the
// first of them: a row whose identity changed with every delta would be a new row on
// every frame, and the list would jump for the whole of a streamed answer.
val last = items.lastOrNull()
if (last is TranscriptItem.AssistantMsg) {
items.dropLast(1) + last.copy(text = last.text + event.delta)
} else {
items + TranscriptItem.AssistantMsg(entry.seq, event.delta)
}
}
is SessionEvent.ToolStart ->
items +
TranscriptItem.ToolRun(
entry.seq,
event.id,
runIdFor(items, event.id, event.tool),
event.tool,
event.input,
"",
done = false,
)
is SessionEvent.ToolUpdate -> updateTool(items, event.id) { it.copy(output = event.output) }
is SessionEvent.ToolEnd ->
// Created when its start is not here, rather than dropped. A
// fold that only ever *updates* loses the whole call when the
// start fell outside the loaded window, and a tool call that
// renders as nothing is indistinguishable from one that never
// happened. The name is unknown from an end alone; loading the
// page before this one replaces the row with the real thing.
if (items.any { it is TranscriptItem.ToolRun && it.id == event.id }) {
updateTool(items, event.id) { it.copy(output = event.output, done = true) }
} else {
items +
TranscriptItem.ToolRun(
entry.seq,
event.id,
// The name is not known from an end alone, so a call that was an ask
// cannot be recognised as one here; loading the page before this
// replaces the row with the real thing, which is when it splits out.
runIdFor(items, event.id, "tool"),
"tool",
"",
event.output,
done = true,
)
}
is SessionEvent.Question -> {
val card =
TranscriptItem.QuestionCard(
entry.seq,
event.id,
event.prompt,
event.header,
event.options,
event.multiSelect,
emptyList(),
)
// A question with no tool behind it -- AskUserQuestion, or an ask
// whose call fell outside the loaded window -- is a card of its
// own, which is what every question was before this.
if (
event.about != null &&
items.any { it is TranscriptItem.ToolRun && it.id == event.about }
) {
updateTool(items, event.about) { it.copy(asks = it.asks + card) }
} else {
items + card
}
}
is SessionEvent.Answered ->
// Resolved wherever it is drawn: a card of its own, or a tool
// row's ask. Missing the second left an Allow/Deny pair live on
// a question already answered from another device.
items.map {
when {
it is TranscriptItem.QuestionCard && it.id == event.id ->
it.copy(answers = event.answers)
it is TranscriptItem.ToolRun && it.asks.any { ask -> ask.id == event.id } ->
it.copy(
asks =
it.asks.map { ask ->
if (ask.id == event.id) ask.copy(answers = event.answers)
else ask
}
)
else -> it
}
}
is SessionEvent.PeerMessage ->
items + TranscriptItem.PeerNote(entry.seq, event.from, event.text)
is SessionEvent.CommandSent -> items + TranscriptItem.CommandRow(entry.seq, event.text)
// Screen-level state, not transcript rows -- see SessionScreen.
is SessionEvent.CommandQueued -> items
// No row of its own: a message that is still waiting is drawn as a pending bubble below
// the transcript, and becomes an ordinary one where the session read it.
is SessionEvent.MessageQueued -> items
is SessionEvent.Settings -> items
is SessionEvent.Status -> items
is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message)
is SessionEvent.Image ->
// Under the call that produced it when there is one, and a row of
// its own when there is not -- a person's own attachment belongs
// to no call, and neither does one whose call fell outside the
// loaded window.
if (
event.about != null &&
items.any { it is TranscriptItem.ToolRun && it.id == event.about }
) {
updateTool(items, event.about) { it.copy(images = it.images + event.ref) }
} else {
items + TranscriptItem.ImageItem(entry.seq, event.ref)
}
is SessionEvent.Cleared -> items + TranscriptItem.ClearedNote(entry.seq)
is SessionEvent.Compacted ->
items + TranscriptItem.CompactedNote(entry.seq, event.preTokens, event.postTokens)
is SessionEvent.Unknown -> items + TranscriptItem.Note(entry.seq, "[${event.type}]")
// Screen-level state, not transcript rows -- see SessionScreen.
is SessionEvent.UsageDelta -> items
}
private fun updateTool(
items: List<TranscriptItem>,
id: String,
change: (TranscriptItem.ToolRun) -> TranscriptItem.ToolRun,
): List<TranscriptItem> = items.map {
if (it is TranscriptItem.ToolRun && it.id == id) change(it) else it
}
/**
* Where markdown is parsed ahead of being drawn: two threads, never all of them.
*
* The default dispatcher sizes itself to the machine, which is right for work somebody is waiting
* on and wrong for work nobody is. A page of history is hundreds of parses arriving at once, and
* taking every core for them leaves the thread that draws the frame queueing behind one -- measured
* on a Pixel 9 Pro XL as 21ms of `waited` at the 90th percentile, which is the frame failing to
* *start* rather than taking too long once it had.
*/
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
private val parsingThreads = Dispatchers.Default.limitedParallelism(2)
/**
* Parses the replies among [rows], off whatever thread is drawing.
*
* Called where a page of transcript is folded rather than where a row is composed, which is the
* whole point: the work happens seconds before the reader reaches the rows it was done for. See
* [ParsedReplies].
*
* What is warmed mirrors what the rows draw, unit by unit -- prose split into its blocks, a memory
* note whole -- because a string warmed under a key no row ever looks up is a miss that nothing
* reports; see [transcriptUnits], which is the flatten this has to agree with. It reads the same
* [ParsedReplies.partsOf] and [ParsedReplies.blocksOf] caches the flatten does, so a message is
* scanned once however many pages hand it back through here, while the whole loaded transcript
* crosses this on every page.
*/
suspend fun warm(replies: ParsedReplies, rows: List<TranscriptItem>) {
withContext(parsingThreads) {
val texts =
rows
.filterIsInstance<TranscriptItem.AssistantMsg>()
.flatMap { replies.partsOf(it.text) }
.flatMap { part ->
when (part) {
is MessagePart.Prose -> replies.blocksOf(part.text)
// Drawn as one MarkdownText, so its whole text is the key looked up.
is MessagePart.Remembered -> listOf(part.text)
}
}
if (texts.isNotEmpty()) replies.warm(texts)
}
}
+8 -2
View File
@@ -28,8 +28,13 @@ zxing-embedded = "4.3.0"
markdown-renderer = "0.45.0" markdown-renderer = "0.45.0"
# Syntax highlighting for a tool call's input. Same reasoning as the markdown # Syntax highlighting for a tool call's input. Same reasoning as the markdown
# renderer: a language's lexical rules are somebody else's specification. # renderer: a language's lexical rules are somebody else's specification.
# Latest stable, checked 2026-08-29 against Maven Central. # Latest stable, checked 2026-08-31 against Maven Central.
highlights = "1.0.0" highlights = "1.1.0"
# The support ExifInterface rather than android.media's, which lint warns off:
# the framework one is missing formats and the fixes for parsing hostile
# images, and images here arrive from outside the phone. Latest stable,
# checked 2026-08-31 against Google Maven.
androidx-exifinterface = "1.4.2"
# Declared rather than inherited for the same reason as core-ktx: SessionScreen # Declared rather than inherited for the same reason as core-ktx: SessionScreen
# now calls repeatOnLifecycle/LocalLifecycleOwner directly, to hold the event # now calls repeatOnLifecycle/LocalLifecycleOwner directly, to hold the event
# stream open only while the screen is on screen. Latest stable, checked # stream open only while the screen is on screen. Latest stable, checked
@@ -59,6 +64,7 @@ desugar-jdk-libs = { module = "com.android.tools:desugar_jdk_libs", version.ref
# theme, so the app's Catppuccin scheme is what it draws with. # theme, so the app's Catppuccin scheme is what it draws with.
markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "markdown-renderer" } markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "markdown-renderer" }
highlights = { module = "dev.snipme:highlights", version.ref = "highlights" } highlights = { module = "dev.snipme:highlights", version.ref = "highlights" }
androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "androidx-exifinterface" }
# Declared directly rather than through the plugin's `compose.*` accessors, # Declared directly rather than through the plugin's `compose.*` accessors,
# which are deprecated as of CMP 1.11. # which are deprecated as of CMP 1.11.
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-multiplatform" } compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-multiplatform" }