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.
This commit is contained in:
iris committed 2026-08-29 22:16:02 -04:00
1 parent 76ba24993c
commit ba71c798f5
2 files changed
+110 -3

No files matched your search

@@ -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)
}
}
@@ -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<Long?>(null) }
var streamError by remember { mutableStateOf<String?>(null) }
var actionError by remember { mutableStateOf<String?>(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<String?>(null) }
var expandedTools by remember { mutableStateOf(setOf<String>()) }
// 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<List<String>>(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 ->
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.
*