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. *