From ba71c798f5af1bc863e6935133eb0febccbc8aa2 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sat, 29 Aug 2026 22:16:02 -0400 Subject: [PATCH] Keep what was typed, and ask before a switch that re-reads everything Two things about the box at the bottom of a session. **A half-typed message survived nothing.** It lived in `remember`, so leaving the screen threw it away, and so did the system reclaiming the app. `Drafts.kt` keeps it per session id and the box is seeded from it. On the device rather than the backend, which is where this app otherwise puts state so every device sees it: this is the contents of a text box on the phone somebody is holding, written on every keystroke, and half a sentence surfacing on another device would be a surprise rather than a convenience. What has been *sent* is the server's, and that is the part which has to outlive this phone. **Switching model quietly re-reads the whole conversation.** The picker did it on the tap, and the cost only showed up as the next turn being expensive. It now asks first, in words, with no number: what it will cost depends on how long this conversation is, and the screen does not know that -- the running total beside it counts what has been spent, which is a different quantity, and a figure derived from it would be a guess wearing a measurement's clothes. The picker beside it deliberately gets no dialog, and that is measured rather than assumed. Driving one session through both changes and reading the CLI's own usage: a warm turn read 30,771 tokens from cache and created 87; after a *permission mode* change it read 30,858 and created 75 -- still a hit; after a *model* change it read nothing at all and created 41,509. So the model picker is the whole of the set, and warning on both would teach that these dialogs can be clicked through, which is what stops the one that matters from working. Nothing is asked when there is nothing to lose either: choosing the model already set, or switching before the session has said anything, applies straight through. Checked on the emulator. A draft survived leaving the session and a force-stop; the dialog names both models and both buttons; declining left the model where it was; and the permission picker still applies on the tap with no dialog in the way. --- .../main/kotlin/com/example/aiapp/Drafts.kt | 36 +++++++++ .../kotlin/com/example/aiapp/SessionScreen.kt | 79 ++++++++++++++++++- 2 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Drafts.kt diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Drafts.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Drafts.kt new file mode 100644 index 0000000..7ba2635 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Drafts.kt @@ -0,0 +1,36 @@ +package com.example.aiapp + +import android.content.Context +import androidx.core.content.edit + +private const val DRAFTS = "session-drafts" + +/** + * A message typed into a session and not sent yet. + * + * On this device rather than on the backend, which is where this app otherwise keeps state so that + * every device sees it. A draft is the case that rule is not about: it is the contents of a text + * box on the phone somebody is holding, written on every keystroke, and half a sentence surfacing + * on another device would be a surprise rather than a convenience. What has been *sent* is the + * server's, and that is the part which has to outlive this phone. + * + * Kept per session id, because the thing being typed belongs to the conversation it is aimed at: + * one shared box would hand a message meant for one session to whichever was opened next. + */ +fun loadDraft(context: Context, sessionId: String): String = + context.getSharedPreferences(DRAFTS, Context.MODE_PRIVATE).getString(sessionId, "").orEmpty() + +/** + * Records [text] as the draft for [sessionId], or forgets it when there is nothing left to keep. + * + * The path out is emptying the box, which is what sending does -- so a sent message removes its own + * entry and nothing accumulates for a session in ordinary use. A session *deleted* while it held a + * draft does leave its key behind: pruning those means a pass over the live session list, which + * this file would otherwise have no reason to know about, and the residue is a few bytes per + * session ever abandoned mid-sentence. That is a trade rather than an oversight. + */ +fun saveDraft(context: Context, sessionId: String, text: String) { + context.getSharedPreferences(DRAFTS, Context.MODE_PRIVATE).edit { + if (text.isEmpty()) remove(sessionId) else putString(sessionId, text) + } +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index 50b8ef2..5933704 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -326,7 +327,15 @@ fun SessionScreen( var compactingFor by remember { mutableStateOf(null) } var streamError by remember { mutableStateOf(null) } var actionError by remember { mutableStateOf(null) } - var input by remember { mutableStateOf("") } + val context = LocalContext.current + // Seeded from what was left in the box last time and written back on every keystroke, so + // leaving the screen -- or the system reclaiming the app -- does not throw away a half-typed + // message. See `Drafts.kt` for why this one piece of state is the device's rather than the + // server's. + var input by remember(summary.id) { mutableStateOf(loadDraft(context, summary.id)) } + // A model the reader has chosen and not yet confirmed. See [ModelSwitchWarning]: switching + // makes the session re-read the whole conversation, which is worth asking about first. + var pendingModel by remember { mutableStateOf(null) } var expandedTools by remember { mutableStateOf(setOf()) } // Which runs of adjacent tool calls are open. Keyed by the first call's // id, so a group survives more calls arriving after it. @@ -349,7 +358,6 @@ fun SessionScreen( // The models this provider actually offers, asked of the server rather // than listed here: a hardcoded list is a claim about a machine. var offeredModels by remember { mutableStateOf>(emptyList()) } - val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current // The resume cursor, written from the stream's IO thread. val lastSeq = remember { AtomicLong(0) } @@ -666,6 +674,7 @@ fun SessionScreen( // held here, and there is no local guess to correct when the answer arrives. if (text.startsWith("/") && attachments.isEmpty()) { input = "" + saveDraft(context, summary.id, "") // The one command with a visible effect outside the transcript, applied when the // server has accepted it rather than when it was typed: the name is this app's own // datum and changes at once, and only telling the session waits for a boundary. @@ -680,6 +689,7 @@ fun SessionScreen( return } input = "" + saveDraft(context, summary.id, "") pendingAttachments = emptyList() if (running && text.isNotEmpty()) queued = queued + text // A held message leaves this list exactly two ways: the session @@ -940,6 +950,18 @@ fun SessionScreen( } } + pendingModel?.let { chosen -> + ModelSwitchWarning( + from = modelLabel(model), + to = modelLabel(chosen), + onDismiss = { pendingModel = null }, + onConfirm = { + pendingModel = null + act { setSessionModel(settings, summary.id, chosen) } + }, + ) + } + SessionStatusRow(status = status, compactingFor = compactingFor, totalTokens = totalTokens) // Between the transcript and the box: above what is being typed, so the list does not @@ -959,7 +981,10 @@ fun SessionScreen( Column(Modifier.fillMaxWidth().padding(8.dp)) { OutlinedTextField( value = input, - onValueChange = { input = it }, + onValueChange = { + input = it + saveDraft(context, summary.id, it) + }, modifier = Modifier.fillMaxWidth(), placeholder = { Text(if (pendingAttachments.isEmpty()) "Message" else "Message (+image)") @@ -999,8 +1024,14 @@ fun SessionScreen( // is set to, which arrives a moment later and is sometimes a // different answer -- a name the CLI resolved, or no change at all // on a provider whose model is fixed when it starts. + // Asked about first, unless there is nothing to lose by it -- + // see [ModelSwitchWarning]. onPick = { chosen -> - act { setSessionModel(settings, summary.id, chosen) } + if (chosen == model || items.isEmpty()) { + act { setSessionModel(settings, summary.id, chosen) } + } else { + pendingModel = chosen + } }, ) } @@ -1065,6 +1096,46 @@ private fun UserBubble(text: String, pending: Boolean = false) { * 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. + * + * A model change drops the cached context: the next turn re-reads the entire conversation from the + * beginning and is charged for it. Measured on 2026-08-29 against a small session -- the turn + * before the switch read 30,771 tokens from cache and created 87; the turn after read **nothing** + * from cache and created 41,509. On a long conversation that is the whole of it, again. + * + * No number is offered here, deliberately. What it will cost depends on how long *this* + * conversation is, and this screen does not know that -- the running total beside it counts what + * has been spent, which is a different quantity. A figure worked out from it would be a guess in a + * measurement's clothes, and the reader could not tell which times it was right. + * + * The permission-mode picker beside it deliberately has no equivalent, which the same measurement + * decided: changing mode kept the cache (30,858 read, 75 created). Warning on both would teach the + * reader that these dialogs can be clicked through, which is what makes the one that matters stop + * working. + */ +@Composable +private fun ModelSwitchWarning( + from: String, + to: String, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Switch to $to?") }, + text = { + Text( + "The session re-reads the whole conversation on its next turn: leaving $from " + + "drops the cached context, so that turn costs as much as the conversation " + + "is long. Nothing is lost -- it is read again, not forgotten." + ) + }, + confirmButton = { TextButton(onClick = onConfirm) { Text("Switch") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Keep $from") } }, + ) +} + /** * What the session is doing, and what the conversation has cost, on one line above the box. *