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
+1322 -1125

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,14 +95,16 @@ 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.
SettingsScreen( Box(Modifier.imePadding()) {
existing = null, SettingsScreen(
onSaved = { saved -> existing = null,
settings = saved onSaved = { saved ->
screen = Screen.Main settings = saved
}, screen = Screen.Main
onBack = null, },
) onBack = null,
)
}
return return
} }
@@ -148,19 +152,25 @@ 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 ->
MainScreen( Box(Modifier.imePadding()) {
settings = current, MainScreen(
reloadToken = reloadToken, settings = current,
onOpen = { screen = Screen.Session(it) }, reloadToken = reloadToken,
onSpawn = { screen = Screen.Spawn }, onOpen = { screen = Screen.Session(it) },
onImported = { imported -> onSpawn = { screen = Screen.Spawn },
reloadToken++ onImported = { imported ->
screen = Screen.Session(imported) reloadToken++
}, screen = Screen.Session(imported)
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,23 +182,27 @@ 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 ->
SpawnScreen( Box(Modifier.imePadding()) {
settings = current, SpawnScreen(
onSpawned = { spawned -> settings = current,
reloadToken++ onSpawned = { spawned ->
screen = Screen.Session(spawned) reloadToken++
}, screen = Screen.Session(spawned)
onBack = goToMain, },
) onBack = goToMain,
)
}
is Screen.Settings -> is Screen.Settings ->
SettingsScreen( Box(Modifier.imePadding()) {
existing = current, SettingsScreen(
onSaved = { saved -> existing = current,
settings = saved onSaved = { saved ->
goToMain() settings = saved
}, goToMain()
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
@@ -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,9 +151,15 @@ 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 =
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
PackageManager.PERMISSION_GRANTED ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
if (!allowed || !manager.areNotificationsEnabled()) { if (!allowed || !manager.areNotificationsEnabled()) {
return return
} }
@@ -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.
* *
File diff suppressed because it is too large. Load diff
@@ -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" }