Merge branch 'main' of git.arirex.me:iris/ai-app
This commit is contained in:
commit
21d22f89c2
20 files changed
+803
-606
No files matched your search
@@ -205,9 +205,10 @@ first if a remote spawn ever mangles an argument.
|
||||
`./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
|
||||
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
|
||||
available; keep it that way, and suppress with `tools:ignore` plus a
|
||||
written reason rather than by lowering the bar.
|
||||
desugaring off — and later a permission check that silently dropped every
|
||||
notification on Android 12 and below. It is fully clean as of 2026-08-31;
|
||||
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
|
||||
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
|
||||
|
||||
@@ -146,4 +146,5 @@ dependencies {
|
||||
implementation(libs.zxing.embedded)
|
||||
implementation(libs.markdown.renderer)
|
||||
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. */
|
||||
fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode: String) {
|
||||
requestFromServer(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.example.aiapp
|
||||
|
||||
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.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -93,6 +95,7 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
|
||||
if (current == null) {
|
||||
// Not enrolled yet: settings is the only usable screen. The QR
|
||||
// path lands in MainActivity and recomposes from the top.
|
||||
Box(Modifier.imePadding()) {
|
||||
SettingsScreen(
|
||||
existing = null,
|
||||
onSaved = { saved ->
|
||||
@@ -101,6 +104,7 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
|
||||
},
|
||||
onBack = null,
|
||||
)
|
||||
}
|
||||
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) {
|
||||
is Screen.Main ->
|
||||
Box(Modifier.imePadding()) {
|
||||
MainScreen(
|
||||
settings = current,
|
||||
reloadToken = reloadToken,
|
||||
@@ -161,6 +170,7 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
|
||||
},
|
||||
onSettings = { screen = Screen.Settings },
|
||||
)
|
||||
}
|
||||
is Screen.Session ->
|
||||
// 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
|
||||
@@ -172,6 +182,7 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
|
||||
SessionScreen(settings = current, summary = here.summary, onBack = goToMain)
|
||||
}
|
||||
is Screen.Spawn ->
|
||||
Box(Modifier.imePadding()) {
|
||||
SpawnScreen(
|
||||
settings = current,
|
||||
onSpawned = { spawned ->
|
||||
@@ -180,7 +191,9 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
|
||||
},
|
||||
onBack = goToMain,
|
||||
)
|
||||
}
|
||||
is Screen.Settings ->
|
||||
Box(Modifier.imePadding()) {
|
||||
SettingsScreen(
|
||||
existing = current,
|
||||
onSaved = { saved ->
|
||||
@@ -190,6 +203,7 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
|
||||
onBack = goToMain,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -4,8 +4,8 @@ import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Matrix
|
||||
import android.media.ExifInterface
|
||||
import android.net.Uri
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlin.math.max
|
||||
|
||||
|
||||
@@ -4,6 +4,12 @@ import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
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
|
||||
* 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
|
||||
* 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) {
|
||||
@Volatile private var connection: HttpURLConnection? = null
|
||||
@Volatile private var closed = false
|
||||
|
||||
@@ -26,6 +26,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -128,7 +129,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
} else {
|
||||
ChipGroup(
|
||||
label = "Permissions",
|
||||
options = listOf("manual", "acceptEdits", "auto", "bypassPermissions"),
|
||||
options = PERMISSION_MODES,
|
||||
selected = permissionMode,
|
||||
onSelect = { permissionMode = it },
|
||||
)
|
||||
@@ -199,7 +200,17 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
withContext(Dispatchers.IO) {
|
||||
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) {
|
||||
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
|
||||
// lines below it are short enough to wrap readably.
|
||||
// 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
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { cwd ->
|
||||
Text(
|
||||
if (cwd.length > PATH_CHARS)
|
||||
"…" + cwd.takeLast(PATH_CHARS)
|
||||
else cwd,
|
||||
cwd,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.StartEllipsis,
|
||||
color =
|
||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -333,9 +345,6 @@ private fun humanSize(bytes: Long): String? =
|
||||
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. */
|
||||
private fun statsOf(session: Importable, importing: String?): String =
|
||||
listOfNotNull(
|
||||
|
||||
@@ -11,7 +11,6 @@ import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -21,7 +20,9 @@ import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
@@ -91,14 +92,52 @@ class MainActivity : ComponentActivity() {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
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()
|
||||
// The gesture strip at the bottom of most
|
||||
// phones. Without it the send row sits under
|
||||
// the swipe area, where a tap is as likely to
|
||||
// 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()
|
||||
.imePadding()
|
||||
) {
|
||||
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
|
||||
* 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
|
||||
* same strings this draws: a string warmed under a key no row ever looks up is a miss that nothing
|
||||
* reports, and the row pays the parse in the frame it appears, which is the cost being removed.
|
||||
* here rather than at the places that need the answer, because [warm] has to name the same strings
|
||||
* the rows draw: a string warmed under a key no row ever looks up is a miss that nothing reports,
|
||||
* 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
|
||||
* [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> {
|
||||
val parts = splitMemoryNotes(text)
|
||||
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
|
||||
fun MemoryNote(note: MessagePart.Remembered, replies: ParsedReplies) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
|
||||
@@ -151,7 +151,13 @@ class NotificationService : Service() {
|
||||
// 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
|
||||
// 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 =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!allowed || !manager.areNotificationsEnabled()) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -17,23 +16,14 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
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.Modifier
|
||||
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.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.
|
||||
@@ -78,17 +68,7 @@ private fun PendingThumbnail(
|
||||
ref: String,
|
||||
onRemove: () -> Unit,
|
||||
) {
|
||||
var bitmap by remember(ref) { mutableStateOf<ImageBitmap?>(null) }
|
||||
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 (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref)
|
||||
val shape = RoundedCornerShape(8.dp)
|
||||
Box(
|
||||
Modifier.size(THUMBNAIL)
|
||||
|
||||
@@ -3,13 +3,9 @@ package com.example.aiapp
|
||||
import java.time.Duration
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
/**
|
||||
* How long is left in a usage window.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
// How long is left in a usage window. Shared by the session bar and the usage screen: the
|
||||
// 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.
|
||||
|
||||
/** "1d 4h", "3h 12m", "12m" -- the span alone, with no leading or trailing words. */
|
||||
fun formatSpan(until: Duration): String =
|
||||
|
||||
@@ -36,6 +36,38 @@ import androidx.compose.ui.window.DialogProperties
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
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.
|
||||
*
|
||||
@@ -50,18 +82,8 @@ import kotlinx.coroutines.withContext
|
||||
*/
|
||||
@Composable
|
||||
fun SessionImage(settings: ServerSettings, sessionId: String, ref: String) {
|
||||
var bitmap by remember(ref) { mutableStateOf<ImageBitmap?>(null) }
|
||||
var failed by remember(ref) { mutableStateOf(false) }
|
||||
val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref)
|
||||
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 heightPx = with(LocalDensity.current) { height.roundToPx() }
|
||||
Box(Modifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) {
|
||||
|
||||
@@ -35,9 +35,6 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -7,16 +7,20 @@ import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
@@ -38,10 +42,10 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -53,10 +57,12 @@ import androidx.compose.runtime.snapshots.Snapshot
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -87,41 +93,46 @@ private const val RECONNECT_DELAY_MS = 1500L
|
||||
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
|
||||
* stops. Multiplied by the viewport to give a number of *pixels* of scroll, which is the distance
|
||||
* the question is actually about: how far the reader can keep going before they run out. A row is
|
||||
* Multiplied by the viewport to give a number of *pixels* of scroll, which is the distance 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
|
||||
* 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
|
||||
* there, which is a list running out of transcript rather than a slow frame. Counting screenfuls of
|
||||
* rows fixed the size of the mistake without fixing its kind; pixels are the unit itself.
|
||||
* there, which is a list running out of transcript rather than a slow frame.
|
||||
*
|
||||
* Three, so a fling lands on rows that are already there and the page after them is on its way. The
|
||||
* cost of being generous is a page fetched that nobody reads; the cost of being mean is a list that
|
||||
* stops under a finger, and those are not the same size.
|
||||
*
|
||||
* Counted at the server rather than inferred from the screen, which is the only measurement here
|
||||
* that does not depend on how the emulator renders: against a 24,000-event transcript at `--delay
|
||||
* 120`, ten swipes asked for **ten** pages before this and **three** after.
|
||||
* Six, because the two ways to be wrong are not the same size: firing early costs a page fetched
|
||||
* that nobody reads, firing late is a spinner under somebody's finger for a whole round trip over
|
||||
* 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
|
||||
* cushion no longer amplifies a bad guess the way it would have when the guess came from whatever
|
||||
* happened to be on screen.
|
||||
*/
|
||||
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
|
||||
* transcript (2,426 events, 2026-08-30): the whole conversation is *seven* assistant messages, and
|
||||
* the median run of consecutive text deltas that fold into one of them is four hundred. A page 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
|
||||
* seconds of a list that will not move over the tunnel, which reads as history having run out.
|
||||
* The floor is that an event is not a row, and the ratio is nothing like one to one. Measured on a
|
||||
* real transcript (2,426 events, 2026-08-30): the whole conversation is *seven* assistant messages,
|
||||
* and the median run of consecutive text deltas that fold into one of them is four hundred. A page
|
||||
* 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. Below this number a page can add no visible
|
||||
* 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,
|
||||
* 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.
|
||||
@@ -197,433 +208,13 @@ private fun Modifier.holdTopEdge(key: Any, held: TopEdgeHold, hold: (Int) -> Uni
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the transcript renders: the event stream folded into displayable rows (see [foldEvent]). The
|
||||
* stream is the only data source -- opening this screen replays from seq 0, and a reconnect resumes
|
||||
* from the last seq seen, so there is no separate history fetch to drift from it.
|
||||
*/
|
||||
@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)
|
||||
}
|
||||
}
|
||||
// The transcript's data model -- TranscriptItem, foldEvent, joinPages, warm -- lives in
|
||||
// TranscriptItems.kt: it is pure event folding with no screen in it, and the two halves changed
|
||||
// for unrelated reasons while they shared this file.
|
||||
|
||||
@Composable
|
||||
fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () -> Unit) {
|
||||
DebugStats.count("session screen recomposed")
|
||||
val scope = rememberCoroutineScope()
|
||||
val topEdgeHeld = remember { TopEdgeHold() }
|
||||
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.
|
||||
restoring = false
|
||||
ready = true
|
||||
// The opening page is sized for time-to-first-frame, not for reading: it fills a
|
||||
// viewport or two, so the first "still loading" boundary sat barely off-screen and the
|
||||
// first upward scroll met it and waited a round trip. 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
|
||||
@@ -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
|
||||
// -- 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,
|
||||
// at the typical size of the units that are on screen. A unit is at most a block of a reply,
|
||||
// which is what makes the estimate usable where a count of rows was not -- a row is anything
|
||||
// from one line to twenty-five screens, a block is roughly a paragraph. Being wrong is
|
||||
// cheap and one-sided in effect: too low fetches a page early, too high is corrected a few
|
||||
// frames later as the real sizes scroll in, and the spinner item stands at the edge for
|
||||
// whatever slips through.
|
||||
// has not composed. So the room ahead is added up from the real size of every unit the
|
||||
// list *has* laid out, kept by key as units pass through the viewport, with the running
|
||||
// average standing in for the ones it has never seen. It used to be the average of the
|
||||
// units currently on screen, and the units on screen are the worst possible sample: two
|
||||
// tall blocks fill a viewport, multiply out over dozens of unseen one-line rows, and
|
||||
// report screens of room when the end is one swipe away -- so the reader met the spinner
|
||||
// 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:
|
||||
// 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.
|
||||
val unitSizes = remember(summary.id) { HashMap<Any, Int>() }
|
||||
LaunchedEffect(listState, moreHistory) {
|
||||
snapshotFlow {
|
||||
val info = listState.layoutInfo
|
||||
snapshotFlow { listState.layoutInfo }
|
||||
.collect { info ->
|
||||
val visible = info.visibleItemsInfo
|
||||
if (visible.isEmpty()) null
|
||||
else
|
||||
Triple(
|
||||
info.totalItemsCount - 1 - visible.last().index,
|
||||
visible.sumOf { it.size } / visible.size,
|
||||
info.viewportSize.height,
|
||||
)
|
||||
if (visible.isEmpty()) return@collect
|
||||
// Before the guards below, so sizes keep accumulating while a page is in
|
||||
// flight and the next estimate starts better informed.
|
||||
visible.forEach { unitSizes[it.key] = it.size }
|
||||
if (restoring || !moreHistory || loadingHistory) return@collect
|
||||
val viewport = info.viewportSize.height
|
||||
if (viewport == 0) return@collect
|
||||
val loaded = currentUnits
|
||||
val average = unitSizes.values.sum() / unitSizes.size
|
||||
// From the last visible lazy index: item zero is the "below" slot, so lazy
|
||||
// index equals units index plus one -- 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
|
||||
try {
|
||||
// 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 {
|
||||
try {
|
||||
withContext(Dispatchers.IO) { action() }
|
||||
actionError = null
|
||||
} catch (e: ApiException) {
|
||||
actionError = e.message
|
||||
onFailure()
|
||||
} finally {
|
||||
// Whatever happened, including the failure above: a caller that re-enables a
|
||||
// control here must get it back on the path where the request was refused too,
|
||||
@@ -1356,6 +971,19 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
var usageOpen by remember { mutableStateOf(false) }
|
||||
var settingsOpen by remember { mutableStateOf(false) }
|
||||
|
||||
// The composer floats over the bottom of the screen instead of sitting under the transcript
|
||||
// in one column, and the keyboard moves it by a layer translation rather than by relayout.
|
||||
// With everything in one column under a root imePadding, every frame of the keyboard
|
||||
// animation re-measured, re-placed and re-recorded the entire screen -- 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()) {
|
||||
Row(
|
||||
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
|
||||
// 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
|
||||
// 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.
|
||||
GlyphButton(
|
||||
SPEED_GLYPH,
|
||||
@@ -1432,17 +1061,20 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
context.copyToClipboard("ai-app render report", report)
|
||||
// Also to the log, so a session driving the app over adb can read the
|
||||
// same report the button copies. The clipboard is not reachable from a
|
||||
// shell, 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.
|
||||
Log.i("ai-app", report)
|
||||
// Only once it is somewhere it can be read from, so a copy that never
|
||||
// happened does not throw the stack away with it.
|
||||
clearCrash(context)
|
||||
// Emptied by the copy, so pressing it twice measures two separate stretches
|
||||
// Emptied by the copy, so pressing it twice measures two separate
|
||||
// stretches
|
||||
// of scrolling rather than one and then the same one again.
|
||||
frames.reset()
|
||||
DebugStats.reset()
|
||||
Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
},
|
||||
)
|
||||
GlyphButton(
|
||||
@@ -1452,7 +1084,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
colour = usageGlyphColour(usage),
|
||||
)
|
||||
// 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.
|
||||
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
|
||||
// than where it was left.
|
||||
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()) {
|
||||
TranscriptList(
|
||||
units = units,
|
||||
@@ -1490,8 +1133,10 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
modifier =
|
||||
Modifier.fillMaxSize().drawWithContent { if (settled) drawContent() },
|
||||
below = {
|
||||
// The last thing in the transcript, because that is where they are in the
|
||||
// session's reading of events: after everything it has taken in, and not
|
||||
// The last thing in the transcript, because that is where they are in
|
||||
// the
|
||||
// session's reading of events: after everything it has taken in, and
|
||||
// not
|
||||
// yet taken in themselves. What the session is *doing* about them is a
|
||||
// line below, in [SessionStatusRow].
|
||||
if (queued.isNotEmpty() || waitingCommands.isNotEmpty()) {
|
||||
@@ -1525,7 +1170,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
Box(
|
||||
Modifier.holdTopEdge(row.key, topEdgeHeld) { grew ->
|
||||
// A *request*, not a raw scroll delta: this runs inside
|
||||
// the measure pass that discovered the new height, and a
|
||||
// the measure pass that discovered the new height, and
|
||||
// a
|
||||
// raw delta forces a synchronous remeasure from within
|
||||
// measure, which is fatal
|
||||
// ("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
|
||||
// [toggleAnchored].
|
||||
// On the initial pass and consuming nothing, so every control
|
||||
// On the initial pass and consuming nothing, so every
|
||||
// control
|
||||
// inside
|
||||
// still gets the gesture exactly as it would have; only visible
|
||||
// still gets the gesture exactly as it would have; only
|
||||
// visible
|
||||
// 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) {
|
||||
awaitEachGesture {
|
||||
val down =
|
||||
@@ -1576,15 +1225,18 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
}
|
||||
},
|
||||
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
|
||||
// the whole group taller, and the heading the reader is
|
||||
// the whole group taller, and the heading the
|
||||
// reader is
|
||||
// under
|
||||
// is the group's.
|
||||
onToolToggle = { id ->
|
||||
toggleAnchored(row) {
|
||||
expandedTools =
|
||||
if (id in expandedTools) expandedTools - id
|
||||
if (id in expandedTools)
|
||||
expandedTools - id
|
||||
else expandedTools + id
|
||||
}
|
||||
},
|
||||
@@ -1614,12 +1266,19 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
is TranscriptItem.AssistantMsg ->
|
||||
// A whole assistant row is only ever the reply
|
||||
// still
|
||||
// arriving -- every settled reply is flattened into
|
||||
// block units instead; see [transcriptUnits]. Live
|
||||
// arriving -- every settled reply is flattened
|
||||
// into
|
||||
// block units instead; see [transcriptUnits].
|
||||
// Live
|
||||
// is
|
||||
// what earns its blocks a layer each while deltas
|
||||
// what earns its blocks a layer each while
|
||||
// deltas
|
||||
// land.
|
||||
AssistantMessage(item.text, replies, live = true)
|
||||
AssistantMessage(
|
||||
item.text,
|
||||
replies,
|
||||
live = true,
|
||||
)
|
||||
is TranscriptItem.ToolRun ->
|
||||
ToolCard(
|
||||
tool = item,
|
||||
@@ -1670,11 +1329,14 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
item.text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
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.CompactedNote -> CompactedRow(item)
|
||||
is TranscriptItem.CompactedNote ->
|
||||
CompactedRow(item)
|
||||
is TranscriptItem.PeerNote ->
|
||||
PeerMessageRow(
|
||||
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
|
||||
// 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
|
||||
// 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
|
||||
// `settled` and not `restoring` alone, so the spinner covers the whole wait:
|
||||
// fetching
|
||||
// the history a saved position needs, and then the frames between those rows
|
||||
// arriving
|
||||
// and the layout that measures them putting the position back. They are the two
|
||||
// halves
|
||||
// of the same wait and the transcript is not drawn for either.
|
||||
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
|
||||
@@ -1730,7 +1397,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
// 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
|
||||
// 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,
|
||||
// landing the reader at the bottom with new messages not bringing the view
|
||||
// with them.
|
||||
@@ -1738,9 +1406,9 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
modifier =
|
||||
Modifier.align(Alignment.BottomCenter).padding(bottom = 12.dp).semantics {
|
||||
contentDescription = "Jump to latest"
|
||||
},
|
||||
Modifier.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 12.dp)
|
||||
.semantics { contentDescription = "Jump to latest" },
|
||||
) {
|
||||
Chevron(
|
||||
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 ->
|
||||
ModelSwitchWarning(
|
||||
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 --
|
||||
// see [ModelSwitchWarning].
|
||||
onPick = { chosen ->
|
||||
if (modelLabel(chosen) == modelLabel(model) || items.isEmpty()) {
|
||||
if (
|
||||
modelLabel(chosen) == modelLabel(model) || items.isEmpty()
|
||||
) {
|
||||
act { setSessionModel(settings, summary.id, chosen) }
|
||||
} else {
|
||||
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
|
||||
// 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
|
||||
// colour and the mark, which is what they mean.
|
||||
//
|
||||
// Always here, rather than arriving with the turn as it used to. A control that
|
||||
// comes and goes makes its own presence the signal, and its absence could not say
|
||||
// whether there was nothing to do; a button that is always in the same place also
|
||||
// comes and goes makes its own presence the signal, and its absence could not
|
||||
// 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.
|
||||
val process =
|
||||
when {
|
||||
@@ -1895,7 +1588,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
Spacer(Modifier.width(8.dp))
|
||||
// The paper plane, with a clock on it while a turn is in flight: sending then
|
||||
// queues the message for the next tool boundary rather than starting a turn of
|
||||
// its own, and the two have to be told apart at a glance. The label says the same
|
||||
// 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.
|
||||
//
|
||||
// Disabled while there is nothing to send, rather than pressable and silent:
|
||||
@@ -1910,12 +1604,14 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
Glyph(
|
||||
if (running) QUEUE_GLYPH else SEND_GLYPH,
|
||||
colour = LocalContentColor.current,
|
||||
modifier = Modifier.semantics { contentDescription = sendLabel(running) },
|
||||
modifier =
|
||||
Modifier.semantics { contentDescription = sendLabel(running) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (usageOpen) {
|
||||
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
|
||||
* bitmap is remembered per ref, so scrolling doesn't refetch.
|
||||
* A message the person holding the phone sent, in a bubble at their end of the conversation.
|
||||
*
|
||||
* [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
|
||||
private fun UserBubble(
|
||||
@@ -2025,11 +1723,6 @@ private fun UserBubble(
|
||||
/** A message the server has accepted and the session has not read yet. */
|
||||
private data class QueuedMessage(val id: String, val text: String, val images: List<String>)
|
||||
|
||||
/**
|
||||
* Collapsed by default: name plus a spinner while running, expandable to the input and output. The
|
||||
* spinner-while-unfinished is exactly "ToolStart with no matching ToolEnd yet".
|
||||
*/
|
||||
|
||||
/**
|
||||
* Asked before switching model, because switching is not free and the cost is invisible.
|
||||
*
|
||||
@@ -2097,6 +1790,7 @@ private fun SessionStatusRow(
|
||||
contextTokens: Long?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
DebugStats.count("status row recomposed")
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
@@ -2209,9 +1903,6 @@ private fun QuestionRow(
|
||||
*/
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -116,6 +116,7 @@ fun usageGlyphColour(usage: SessionUsage): Color =
|
||||
*/
|
||||
@Composable
|
||||
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
|
||||
// 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
|
||||
|
||||
@@ -33,13 +33,6 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -33,13 +33,12 @@ 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:
|
||||
* 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.
|
||||
*/
|
||||
/**
|
||||
*
|
||||
* 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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,13 @@ zxing-embedded = "4.3.0"
|
||||
markdown-renderer = "0.45.0"
|
||||
# Syntax highlighting for a tool call's input. Same reasoning as the markdown
|
||||
# renderer: a language's lexical rules are somebody else's specification.
|
||||
# Latest stable, checked 2026-08-29 against Maven Central.
|
||||
highlights = "1.0.0"
|
||||
# Latest stable, checked 2026-08-31 against Maven Central.
|
||||
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
|
||||
# now calls repeatOnLifecycle/LocalLifecycleOwner directly, to hold the event
|
||||
# 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.
|
||||
markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "markdown-renderer" }
|
||||
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,
|
||||
# which are deprecated as of CMP 1.11.
|
||||
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-multiplatform" }
|
||||
|
||||
Reference in new issue
Block a user