Open the session a notification is about, and say the two dividers plainly

Tapping a notification landed on whatever the app was last showing. It now
opens the session it named. The id rides in the intent's data rather than an
extra, because PendingIntent identity is Intent.filterEquals -- with an extra
every session's notification would share one PendingIntent and every tap would
open whichever session was notified last. MainActivity sorts the aiapp:// URI
by host, so enrollment and this are one entry point rather than two.

The notification carries only an id, so the session is fetched before there is
a screen; a fetch that fails says so and offers to try again, since somebody
deliberately tapped and an app that opens to the list explains nothing.

That made session-to-session navigation reachable for the first time, and it
crashed: SessionScreen remembers a transcript and an event stream, and without
a key Compose kept both across the change and merged two conversations into
duplicate list keys. Keyed on the session id.

The two transcript dividers now say only what they are, centred between two
rules: "Compacted <bullet> 128,402 -> 9,617 tok" in blue, and "Context cleared"
in red. The rules stay the ordinary divider colour -- they are framing, and the
words are what carries the meaning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-30 01:23:37 -04:00
1 parent 03a8d7d3d3
commit ea2da0896d
7 files changed
+186 -59

No files matched your search

@@ -2,18 +2,26 @@ package com.example.aiapp
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.wgapplink.localNetworkAllowed import com.example.wgapplink.localNetworkAllowed
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** /**
* One `when` rather than a navigation library: a handful of screens, with [Screen.Main] as the root * One `when` rather than a navigation library: a handful of screens, with [Screen.Main] as the root
@@ -40,15 +48,33 @@ private sealed class Screen {
data object Settings : Screen() data object Settings : Screen()
} }
/**
* A session a notification tap asked to open, before it is a screen.
*
* The notification names an id and nothing else, so opening it means fetching the session first.
* [serial] tells two taps on the same session's notification apart, since they are two requests and
* would otherwise compare equal -- see MainActivity, which counts them.
*/
data class SessionOpenRequest(val sessionId: String, val serial: Int)
/** A tap that could not be turned into a screen, kept with its request so Try again knows what. */
private data class FailedOpen(val request: SessionOpenRequest, val message: String)
/** /**
* [settingsVersion] bumps when enrollment lands via an `aiapp://` intent (see MainActivity), * [settingsVersion] bumps when enrollment lands via an `aiapp://` intent (see MainActivity),
* re-reading the stored settings -- a plain `remember` would keep serving the pre-enrollment null. * re-reading the stored settings -- a plain `remember` would keep serving the pre-enrollment null.
*
* [openRequest] is the session a notification tap asked for, likewise from MainActivity.
*/ */
@Composable @Composable
fun AppRoot(settingsVersion: Int) { fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope()
var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) } var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) }
var screen by remember { mutableStateOf<Screen>(Screen.Main) } var screen by remember { mutableStateOf<Screen>(Screen.Main) }
// A notification tap this could not follow, and why. Null both before one is asked for and
// after one succeeds, since success is a screen rather than a message.
var failedOpen by remember { mutableStateOf<FailedOpen?>(null) }
// Bumped whenever another screen changes something the list shows, so // Bumped whenever another screen changes something the list shows, so
// returning to it refetches instead of showing a stale list. // returning to it refetches instead of showing a stale list.
var reloadToken by remember { mutableIntStateOf(0) } var reloadToken by remember { mutableIntStateOf(0) }
@@ -95,6 +121,38 @@ fun AppRoot(settingsVersion: Int) {
BackHandler(onBack = goToMain) BackHandler(onBack = goToMain)
} }
// Turning a notification into the screen it points at. The id has to be resolved to a session
// first, because that is what SessionScreen is given -- and unlike a list row, which is a
// snapshot the list already fetched, there is nothing here to seed it from.
//
// A failure is reported rather than swallowed: somebody deliberately tapped a notification, so
// an app that opens to the session list with no explanation looks like the tap missed.
val open: suspend (SessionOpenRequest) -> Unit = { request ->
failedOpen = null
try {
val session = withContext(Dispatchers.IO) { fetchSession(current, request.sessionId) }
screen = Screen.Session(session)
} catch (e: ApiException) {
failedOpen = FailedOpen(request, e.message ?: "Unknown error")
}
}
LaunchedEffect(openRequest) { openRequest?.let { open(it) } }
val failed = failedOpen
if (failed != null) {
AlertDialog(
onDismissRequest = { failedOpen = null },
title = { Text("Couldn't open that session") },
text = { Text(failed.message) },
confirmButton = {
TextButton(onClick = { scope.launch { open(failed.request) } }) {
Text("Try again")
}
},
dismissButton = { TextButton(onClick = { failedOpen = null }) { Text("Cancel") } },
)
}
when (val here = screen) { when (val here = screen) {
is Screen.Main -> is Screen.Main ->
MainScreen( MainScreen(
@@ -109,12 +167,20 @@ fun AppRoot(settingsVersion: Int) {
onSettings = { screen = Screen.Settings }, onSettings = { screen = Screen.Settings },
) )
is Screen.Session -> 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
// draft and a scroll position, and without the key Compose keeps all of it across the
// change and merges two conversations -- which crashes the list on the first duplicate
// row key. Only reachable since a notification can move straight from one session to
// another; every other way here passes through [Screen.Main], which disposes it anyway.
key(here.summary.id) {
SessionScreen( SessionScreen(
settings = current, settings = current,
summary = here.summary, summary = here.summary,
onBack = goToMain, onBack = goToMain,
onSettings = { screen = Screen.SessionSettings(here.summary) }, onSettings = { screen = Screen.SessionSettings(here.summary) },
) )
}
is Screen.Spawn -> is Screen.Spawn ->
SpawnScreen( SpawnScreen(
settings = current, settings = current,
@@ -10,32 +10,30 @@ import androidx.compose.ui.Modifier
* now, and that is a fact about the conversation, not a turn in it. It has no collapsed form -- it * now, and that is a fact about the conversation, not a turn in it. It has no collapsed form -- it
* is already one line, and there is nothing behind it to open. Drawn by [TranscriptDivider], which * is already one line, and there is nothing behind it to open. Drawn by [TranscriptDivider], which
* a clear also uses, so the two marks cannot drift apart. * a clear also uses, so the two marks cannot drift apart.
*
* Blue is [commandColor]: the session acting on itself rather than working on what was asked of it,
* which is the same thing the status line says while the compaction runs.
*/ */
@Composable @Composable
fun CompactedRow(item: TranscriptItem.CompactedNote, modifier: Modifier = Modifier) { fun CompactedRow(item: TranscriptItem.CompactedNote, modifier: Modifier = Modifier) {
TranscriptDivider(compactionSummary(item), modifier) TranscriptDivider(compactionSummary(item), commandColor, modifier)
} }
/** /**
* What to say about a compaction, given what was measured about it. * What to say about a compaction: the two sizes, and nothing else.
* *
* The counts are the whole point when they are there -- "a million tokens became ten thousand" is * The counts are the whole point -- "a million tokens became ten thousand" is the reader's answer
* the reader's answer to why the wait was worth it. When they are not, this says nothing about size * to why the wait was worth it -- and they are all this says, because a divider is read in passing.
* rather than filling in a plausible number, and when only the size before is known it says exactly * When they were not reported this says only that a compaction happened, rather than filling in a
* that much. * plausible number or explaining at length what was missing.
*
* `auto` is named because it is the case the reader did not ask for, and so the one that explains a
* session going quiet on its own. Anything else -- including a trigger this build does not
* recognise -- makes no claim about who asked, which is the honest reading of not knowing.
*/ */
fun compactionSummary(item: TranscriptItem.CompactedNote): String { fun compactionSummary(item: TranscriptItem.CompactedNote): String {
val what = if (item.trigger == "auto") "Compacted automatically" else "Compacted"
val pre = item.preTokens val pre = item.preTokens
val post = item.postTokens val post = item.postTokens
return when { return if (pre != null && post != null) {
pre != null && post != null -> "$what -- ${tokens(pre)} to ${tokens(post)} tokens" "Compacted • ${tokens(pre)} ${tokens(post)} tok"
pre != null -> "$what -- was ${tokens(pre)} tokens, new size not reported" } else {
else -> what "Compacted"
} }
} }
@@ -1,47 +1,55 @@
package com.example.aiapp package com.example.aiapp
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
/** /**
* A line across the transcript saying what left the session's context. * A line across the transcript saying what left the session's context.
* *
* Centred and dim, because it is a divider rather than something anybody said. Two things produce * Centred between two rules, because it is a divider rather than something anybody said. Two things
* one -- a compaction and a clear -- and they look the same on purpose: to a reader scrolling back, * produce one -- a compaction and a clear -- and they are drawn the same way on purpose: to a
* both mean "the session no longer has what is above this", and the difference between summarised * reader scrolling back, both mean "the session no longer has what is above this", and which of the
* and dropped is what the words say, not how they are drawn. * two it was is said by the words and the colour.
*
* The rules stay the ordinary divider colour whatever [color] is. They are framing: the words are
* what carries the meaning, and colouring the lines too would make the framing look like it were
* carrying some of it.
* *
* Written once here rather than styled at each of them, so the two cannot drift into looking like * Written once here rather than styled at each of them, so the two cannot drift into looking like
* different kinds of thing. * different kinds of thing.
*/ */
@Composable @Composable
fun TranscriptDivider(text: String, modifier: Modifier = Modifier) { fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier) {
Text( Row(
text, verticalAlignment = Alignment.CenterVertically,
style = MaterialTheme.typography.bodySmall, horizontalArrangement = Arrangement.spacedBy(8.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = modifier.fillMaxWidth().padding(vertical = 8.dp), modifier = modifier.fillMaxWidth().padding(vertical = 8.dp),
) ) {
HorizontalDivider(Modifier.weight(1f))
Text(text, style = MaterialTheme.typography.bodySmall, color = color)
HorizontalDivider(Modifier.weight(1f))
}
} }
/** /**
* The mark a clear leaves. * The mark a clear leaves.
* *
* It says the conversation is still here, because the reader can see that it is -- everything above * Red, and no counts: a clear takes the conversation out of what the session is given, and unlike a
* stays on screen and stays scrollable, and the only thing that changed is what the session will * compaction it summarises nothing and measures nothing, so there is nothing to report but the
* send. Without that sentence the divider reads as a deletion, which is the one thing it is not. * fact. Everything above stays on screen and stays scrollable -- the reader can see that, which is
* * why this does not say it.
* No counts, unlike a compaction: nothing was measured and nothing was summarised, so there is
* nothing to report but the fact. A plausible-looking number here would be invented.
*/ */
@Composable @Composable
fun ClearedRow(modifier: Modifier = Modifier) { fun ClearedRow(modifier: Modifier = Modifier) {
TranscriptDivider("Cleared -- everything above stays here, and is no longer sent", modifier) TranscriptDivider("Context cleared", clearedColor, modifier)
} }
@@ -18,6 +18,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.luminance
@@ -28,6 +29,13 @@ class MainActivity : ComponentActivity() {
// composition below re-reads the stored settings. // composition below re-reads the stored settings.
private var settingsVersion by mutableIntStateOf(0) private var settingsVersion by mutableIntStateOf(0)
// The session a notification tap asked for, or null if nothing has. The
// serial is what makes a second tap on the same session's notification a
// second request: without it the two compare equal and the composition
// below has nothing to react to.
private var openRequest by mutableStateOf<SessionOpenRequest?>(null)
private var opens = 0
// Registered up front since permission launchers must be registered // Registered up front since permission launchers must be registered
// before the activity reaches STARTED. // before the activity reaches STARTED.
private val requestLocalNetworkPermission = private val requestLocalNetworkPermission =
@@ -69,7 +77,7 @@ class MainActivity : ComponentActivity() {
requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
} }
handleEnrollment(intent) handleIntent(intent)
// After enrollment, so a first launch that arrives with a token // After enrollment, so a first launch that arrives with a token
// starts the service with something to connect to rather than // starts the service with something to connect to rather than
// stopping it and waiting for the next launch. // stopping it and waiting for the next launch.
@@ -89,22 +97,36 @@ class MainActivity : ComponentActivity() {
.navigationBarsPadding() .navigationBarsPadding()
.imePadding() .imePadding()
) { ) {
AppRoot(settingsVersion) AppRoot(settingsVersion, openRequest)
} }
} }
} }
} }
} }
// launchMode="singleTop": an enrollment scan while the app is open // launchMode="singleTop": an enrollment scan, or a notification tapped
// lands here rather than in a second activity instance. // while the app is open, lands here rather than in a second activity
// instance.
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
handleEnrollment(intent) handleIntent(intent)
} }
private fun handleEnrollment(intent: Intent?) { /**
* The one place an incoming `aiapp://` URI is sorted into what it means.
*
* Two things arrive this way -- an enrollment code and a notification naming a session -- and
* they are told apart by the URI's host rather than by two entry points, so a third kind is a
* branch here rather than another intent to remember to handle.
*/
private fun handleIntent(intent: Intent?) {
val uri = intent?.data ?: return val uri = intent?.data ?: return
val sessionId = notifiedSessionId(uri)
if (sessionId != null) {
opens++
openRequest = SessionOpenRequest(sessionId, opens)
return
}
val settings = parseEnrollmentUri(uri) val settings = parseEnrollmentUri(uri)
if (settings == null) { if (settings == null) {
Toast.makeText(this, "Not a valid enrollment code", Toast.LENGTH_LONG).show() Toast.makeText(this, "Not a valid enrollment code", Toast.LENGTH_LONG).show()
@@ -8,6 +8,7 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationChannelCompat
@@ -149,9 +150,7 @@ class NotificationService : Service() {
PendingIntent.getActivity( PendingIntent.getActivity(
this, this,
0, 0,
Intent(this, MainActivity::class.java) sessionIntent(this, notification.sessionId),
.setAction(Intent.ACTION_MAIN)
.addCategory(Intent.CATEGORY_LAUNCHER),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
) )
val built = val built =
@@ -253,6 +252,31 @@ class NotificationService : Service() {
} }
} }
/**
* The intent that opens one session, and the id it carries back out.
*
* The two halves are written together so neither can be changed without the other, and the scheme
* is enrollment's `aiapp://` under a different host so that [MainActivity] has one thing to look at
* when an intent arrives rather than two.
*
* The id rides in the intent's **data** rather than in an extra, which is not a style choice:
* PendingIntent identity is `Intent.filterEquals`, and that compares the data while ignoring
* extras. Carried as an extra, every session's notification would update one shared PendingIntent
* and every tap would open whichever session was notified last.
*/
fun sessionIntent(context: Context, sessionId: String): Intent =
Intent(context, MainActivity::class.java)
.setAction(Intent.ACTION_VIEW)
.setData(
// Built rather than concatenated so an id needing escaping survives the round trip;
// lastPathSegment below decodes what appendPath encoded.
Uri.Builder().scheme("aiapp").authority("session").appendPath(sessionId).build()
)
/** The session [sessionIntent] named, or null for any other URI -- enrollment's included. */
fun notifiedSessionId(uri: Uri): String? =
if (uri.scheme == "aiapp" && uri.host == "session") uri.lastPathSegment else null
/** One frame of `GET /notifications`. */ /** One frame of `GET /notifications`. */
data class SessionNotification( data class SessionNotification(
val sessionId: String, val sessionId: String,
@@ -184,13 +184,6 @@ sealed class TranscriptItem {
/** Placeholder row for events this build can't render (newer kinds). */ /** Placeholder row for events this build can't render (newer kinds). */
data class Note(override val seq: Long, val text: String) : TranscriptItem() data class Note(override val seq: Long, val text: String) : 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.
*/
/** /**
* A clear that happened: everything above it left the session's context and stayed on screen. * A clear that happened: everything above it left the session's context and stayed on screen.
* *
@@ -198,11 +191,21 @@ sealed class TranscriptItem {
*/ */
data class ClearedNote(override val seq: Long) : TranscriptItem() 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( data class CompactedNote(
override val seq: Long, override val seq: Long,
val preTokens: Long?, val preTokens: Long?,
val postTokens: Long?, val postTokens: Long?,
val trigger: String?,
) : TranscriptItem() ) : TranscriptItem()
} }
@@ -430,13 +433,7 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
} }
is SessionEvent.Cleared -> items + TranscriptItem.ClearedNote(entry.seq) is SessionEvent.Cleared -> items + TranscriptItem.ClearedNote(entry.seq)
is SessionEvent.Compacted -> is SessionEvent.Compacted ->
items + items + TranscriptItem.CompactedNote(entry.seq, event.preTokens, event.postTokens)
TranscriptItem.CompactedNote(
entry.seq,
event.preTokens,
event.postTokens,
event.trigger,
)
is SessionEvent.Unknown -> items + TranscriptItem.Note(entry.seq, "[${event.type}]") is SessionEvent.Unknown -> items + TranscriptItem.Note(entry.seq, "[${event.type}]")
// Screen-level state, not transcript rows -- see SessionScreen. // Screen-level state, not transcript rows -- see SessionScreen.
is SessionEvent.UsageDelta -> items is SessionEvent.UsageDelta -> items
@@ -123,6 +123,18 @@ val failedColor: Color
val commandColor: Color val commandColor: Color
@Composable get() = Mocha.Blue @Composable get() = Mocha.Blue
/**
* A clear: the conversation taken out of what the session is given.
*
* Red because of what it does, not because anything went wrong -- somebody asked for this, and a
* deliberate choice is not a problem to report. It is the same red as [failedColor] and [stopColor]
* for a third reason, which is worth naming rather than collapsing: this is neither a fault nor a
* button, it is the mark left where something was taken away. The reader never has to tell the
* three apart, because no two of them can appear as the same kind of thing.
*/
val clearedColor: Color
@Composable get() = Mocha.Red
/** Waiting on a person: a question, a permission, a turn that is theirs. */ /** Waiting on a person: a question, a permission, a turn that is theirs. */
val awaitingColor: Color val awaitingColor: Color
@Composable get() = Mocha.Peach @Composable get() = Mocha.Peach