Reopening a session downloaded the conversation again, every time, over the tunnel. It now draws from a copy of what the server has already sent and asks for one event to check that copy is still current. Per session, under cacheDir, the server's own event lines in chunks named for the range they cover -- so a coalesced page, whose lines do not say what they cover, still records it. Only the contiguous run ending at the newest chunk is served; a gap is closed by paging through it, bounded by `after` on /transcript so the page stops where the phone's copy starts and can therefore be kept. Nothing is derived and stored: rows are a rendering, and a cache of them would need throwing away on every change to the fold. Nothing here is load-bearing. Missing, evicted, damaged or unwritable all degrade to the cold open this screen did before, and the check before the stream resumes -- one request, one event -- is what stops a replaced or truncated file being spliced onto a copy of a different conversation. What that check cannot see, a line changed mid-file with the tail intact, is what Reload in session settings is for. Measured on the emulator against ui-sandbox, on a 505-event session: reopening it costs one request for one event, including scrolling the whole conversation back; a cold open is two requests and 100 events. A reset after falling 300 behind fetched the gap as four coalesced rows rather than re-fetching 104 events and discarding them. Every chunk was checked line by line against what the server says for the range its name claims, across the reset and the gap-fill. transcript-bench.sh, same viewport content and gestures, before and after: p50 16.9ms both, p90 25.6 -> 23.2ms, p99 33.5 -> 36.7ms, and the transcript's own draw accounting 0.33ms -> 0.32ms with place 0.31ms either way. Within the emulator's noise, which is what a cache must be: it changes what is fetched, not what is drawn. Building it also found that the server handed out the same transcript line two different ways. serde_json's default float parser is not correctly rounded, so a ts written as ...0757 came back from /transcript as ...0755 while the SSE stream sent the original -- invisible on screen, since a ts is drawn as a relative time, and visible here only because the cache compares a line it holds against the server's answer. Fixed with float_roundtrip, with a test that fails the moment it is dropped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
319 lines
15 KiB
Kotlin
319 lines
15 KiB
Kotlin
package com.example.aiapp
|
|
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.Row
|
|
import androidx.compose.foundation.layout.Spacer
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.foundation.layout.width
|
|
import androidx.compose.foundation.text.KeyboardActions
|
|
import androidx.compose.foundation.text.KeyboardOptions
|
|
import androidx.compose.material3.AlertDialog
|
|
import androidx.compose.material3.CircularProgressIndicator
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.OutlinedTextField
|
|
import androidx.compose.material3.Switch
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.TextButton
|
|
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.rememberCoroutineScope
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.text.input.ImeAction
|
|
import androidx.compose.ui.unit.dp
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.withContext
|
|
|
|
/**
|
|
* What can be changed about one session, as opposed to about this app.
|
|
*
|
|
* Over the session rather than a step down from it: everything here is about the conversation
|
|
* behind it, and a dialog keeps that conversation on screen while it is being adjusted. It was a
|
|
* screen of its own until 2026-08-30, which put a page transition and a back stack around two
|
|
* controls and hid the thing they act on.
|
|
*
|
|
* The model and the permission mode are deliberately still on the session's own bar, because those
|
|
* are changed *while* reading a turn -- "not this model, try that one" -- and a control belongs
|
|
* with the thing it acts on.
|
|
*
|
|
* Captions are for what a control costs rather than for what it is. Each control is a labelled noun
|
|
* with a switch or a field beside it, and a paragraph under every one of them made the dialog
|
|
* longer than the conversation it covers -- so Notifications has none, while Move and Reload do,
|
|
* because what those two take away is not visible from here. Failures get their words for the same
|
|
* reason: they are what the reader cannot work out by looking.
|
|
*/
|
|
@Composable
|
|
fun SessionSettingsDialog(
|
|
settings: ServerSettings,
|
|
sessionId: String,
|
|
/**
|
|
* What the session is called now, as the screen behind this knows it -- see the rename below.
|
|
*/
|
|
title: String,
|
|
onRenamed: (String) -> Unit,
|
|
/**
|
|
* What this phone is holding of the conversation, or null while that is being measured -- see
|
|
* the Reload row below, which is what would discard it.
|
|
*/
|
|
cachedBytes: Long?,
|
|
onReload: () -> Unit,
|
|
onDismiss: () -> Unit,
|
|
) {
|
|
val scope = rememberCoroutineScope()
|
|
var name by remember(sessionId) { mutableStateOf(title) }
|
|
var saving by remember { mutableStateOf(false) }
|
|
var error by remember { mutableStateOf<String?>(null) }
|
|
// Null until the server has been asked. The row this dialog was opened over is a snapshot of
|
|
// whenever the list was last fetched, so drawing the switch straight from it would show a
|
|
// position that may have been changed since -- from here or from another device -- with
|
|
// nothing to say so. Until the answer arrives the switch is disabled and a spinner sits beside
|
|
// it, which is what not knowing looks like: distinguishable from off, and from a refusal.
|
|
var notify by remember(sessionId) { mutableStateOf<Boolean?>(null) }
|
|
var notifyError by remember { mutableStateOf<String?>(null) }
|
|
// Where the session works. Null until the server has been asked, for the same reason the
|
|
// switch above is: the row this dialog opened over is a snapshot, and a path drawn from it
|
|
// could be one somebody changed from another device. An empty answer is a session that was
|
|
// never given a directory, which is not the same as one whose directory is unknown -- the
|
|
// field is only enabled once one of those two is settled.
|
|
var cwd by remember(sessionId) { mutableStateOf<String?>(null) }
|
|
var typedCwd by remember(sessionId) { mutableStateOf("") }
|
|
var cwdError by remember { mutableStateOf<String?>(null) }
|
|
var movingCwd by remember { mutableStateOf(false) }
|
|
|
|
LaunchedEffect(sessionId) {
|
|
try {
|
|
val fresh = withContext(Dispatchers.IO) { fetchSession(settings, sessionId) }
|
|
notify = fresh.notify
|
|
cwd = fresh.cwd.orEmpty()
|
|
typedCwd = fresh.cwd.orEmpty()
|
|
} catch (e: ApiException) {
|
|
// Left unknown rather than falling back to the stale row: the switch stays
|
|
// disabled, instead of offering a position nothing confirmed.
|
|
notifyError = e.message
|
|
notify = null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Moves the session, which ends the process that is in the old directory.
|
|
*
|
|
* Said plainly beside the field rather than confirmed in a second dialog: what it costs is a
|
|
* process, and a stopped session is a state this app already has a word and a button for.
|
|
*/
|
|
fun moveCwd() {
|
|
val chosen = typedCwd.trim()
|
|
if (movingCwd || chosen.isEmpty() || chosen == cwd) return
|
|
movingCwd = true
|
|
cwdError = null
|
|
scope.launch {
|
|
try {
|
|
withContext(Dispatchers.IO) { setSessionCwd(settings, sessionId, chosen) }
|
|
cwd = chosen
|
|
} catch (e: ApiException) {
|
|
// Where it happened: this field is the only thing on screen that knows a move was
|
|
// asked for, and the reason is usually the path itself.
|
|
cwdError = e.message
|
|
} finally {
|
|
movingCwd = false
|
|
}
|
|
}
|
|
}
|
|
|
|
// Moved optimistically so the switch answers the finger that moved it, and put back if the
|
|
// request is refused -- a switch that waits for a round trip reads as broken on a slow
|
|
// tunnel, and one that stays moved after a refusal lies.
|
|
fun setNotify(wanted: Boolean) {
|
|
val was = notify
|
|
notify = wanted
|
|
notifyError = null
|
|
scope.launch {
|
|
try {
|
|
withContext(Dispatchers.IO) { setSessionNotify(settings, sessionId, wanted) }
|
|
} catch (e: ApiException) {
|
|
notify = was
|
|
notifyError = e.message
|
|
}
|
|
}
|
|
}
|
|
|
|
// Nothing to do when the name has not changed, so the button says so rather than sending a
|
|
// request whose success would look exactly like the failure of having typed nothing.
|
|
val changed = name.trim().isNotEmpty() && name.trim() != title
|
|
|
|
fun save() {
|
|
if (!changed || saving) return
|
|
val chosen = name.trim()
|
|
saving = true
|
|
error = null
|
|
scope.launch {
|
|
try {
|
|
withContext(Dispatchers.IO) { renameSession(settings, sessionId, chosen) }
|
|
onRenamed(chosen)
|
|
} catch (e: ApiException) {
|
|
// Reported here, where it happened, because this dialog is the only place that
|
|
// knows a rename was attempted -- the session behind it shows nothing about it.
|
|
error = e.message
|
|
saving = false
|
|
}
|
|
}
|
|
}
|
|
|
|
AlertDialog(
|
|
onDismissRequest = onDismiss,
|
|
title = { Text("Session settings") },
|
|
text = {
|
|
Column {
|
|
OutlinedTextField(
|
|
value = name,
|
|
onValueChange = { name = it },
|
|
label = { Text("Name") },
|
|
singleLine = true,
|
|
enabled = !saving,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
// The keyboard's own action does what the button does: a one-field form
|
|
// where the return key does nothing is a form people press return at anyway.
|
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
|
keyboardActions = KeyboardActions(onDone = { save() }),
|
|
)
|
|
Spacer(Modifier.height(8.dp))
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
Glyph(BELL_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
|
|
Spacer(Modifier.width(8.dp))
|
|
Text("Notifications", modifier = Modifier.weight(1f))
|
|
if (notify == null && notifyError == null) {
|
|
CircularProgressIndicator(
|
|
modifier = Modifier.width(16.dp).height(16.dp),
|
|
strokeWidth = 2.dp,
|
|
)
|
|
Spacer(Modifier.width(8.dp))
|
|
}
|
|
Switch(
|
|
checked = notify == true,
|
|
onCheckedChange = { setNotify(it) },
|
|
enabled = notify != null,
|
|
)
|
|
}
|
|
// Beside the switch that failed, not with the rename's error: they are two
|
|
// requests and a reader has to be able to tell which one the server refused.
|
|
notifyError?.let {
|
|
Text(
|
|
it,
|
|
color = MaterialTheme.colorScheme.error,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
)
|
|
}
|
|
Spacer(Modifier.height(8.dp))
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
OutlinedTextField(
|
|
value = typedCwd,
|
|
onValueChange = { typedCwd = it },
|
|
label = { Text("Working directory") },
|
|
// What the field cannot say by being empty: a session that was never
|
|
// given one starts wherever its launcher does, and this names that
|
|
// rather than showing a path nobody chose.
|
|
placeholder = { Text("wherever the session was started") },
|
|
singleLine = true,
|
|
enabled = cwd != null && !movingCwd,
|
|
modifier = Modifier.weight(1f),
|
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
|
keyboardActions = KeyboardActions(onDone = { moveCwd() }),
|
|
)
|
|
TextButton(
|
|
onClick = { moveCwd() },
|
|
enabled =
|
|
cwd != null &&
|
|
!movingCwd &&
|
|
typedCwd.trim().isNotEmpty() &&
|
|
typedCwd.trim() != cwd,
|
|
) {
|
|
Text(if (movingCwd) "Moving..." else "Move")
|
|
}
|
|
}
|
|
// The whole of what pressing Move does, where it is about to be pressed. A
|
|
// directory is settled when the process is spawned, so there is no changing one
|
|
// under a running session -- it is ended, and the next thing said to the session
|
|
// starts it in the new place.
|
|
Text(
|
|
"Moving stops the session's process. It starts again in the new directory " +
|
|
"with the next message, or with Start.",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
cwdError?.let {
|
|
Text(
|
|
it,
|
|
color = MaterialTheme.colorScheme.error,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
)
|
|
}
|
|
Spacer(Modifier.height(8.dp))
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
Text("Transcript", modifier = Modifier.weight(1f))
|
|
// The size is what the button discards, and the unknown state is drawn
|
|
// rather than guessed: a spinner while the directory is being measured, and
|
|
// words when there is nothing there, because "nothing cached" and "0 B" read
|
|
// as different claims.
|
|
when {
|
|
cachedBytes == null ->
|
|
CircularProgressIndicator(
|
|
modifier = Modifier.width(16.dp).height(16.dp),
|
|
strokeWidth = 2.dp,
|
|
)
|
|
else ->
|
|
Text(
|
|
humanSize(cachedBytes)?.let { "$it cached" } ?: "nothing cached",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
Spacer(Modifier.width(12.dp))
|
|
// Enabled whether or not anything is cached: "what I see disagrees with the
|
|
// machine" is a state an empty cache can be in too, and a control that comes
|
|
// and goes makes its own presence the signal.
|
|
TextButton(onClick = onReload) { Text("Reload") }
|
|
}
|
|
// Captioned, unlike the controls above it, for the same reason Move is: what it
|
|
// costs is not visible, and neither is the case it exists for.
|
|
Text(
|
|
"Reload throws away this phone's copy and fetches the transcript from the " +
|
|
"server again. Use it when what is shown here disagrees with the file " +
|
|
"on the machine.",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
error?.let {
|
|
Spacer(Modifier.height(8.dp))
|
|
Text(
|
|
it,
|
|
color = MaterialTheme.colorScheme.error,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
)
|
|
}
|
|
}
|
|
},
|
|
// Disabled rather than absent while there is nothing to save: a button that comes and
|
|
// goes makes its own presence the signal, and its absence cannot say why.
|
|
confirmButton = {
|
|
TextButton(onClick = { save() }, enabled = changed && !saving) {
|
|
Text(if (saving) "Saving..." else "Save")
|
|
}
|
|
},
|
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Close") } },
|
|
)
|
|
}
|