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:
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.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.key
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
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
|
||||
@@ -40,15 +48,33 @@ private sealed class 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),
|
||||
* 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
|
||||
fun AppRoot(settingsVersion: Int) {
|
||||
fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) }
|
||||
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
|
||||
// returning to it refetches instead of showing a stale list.
|
||||
var reloadToken by remember { mutableIntStateOf(0) }
|
||||
@@ -95,6 +121,38 @@ fun AppRoot(settingsVersion: Int) {
|
||||
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) {
|
||||
is Screen.Main ->
|
||||
MainScreen(
|
||||
@@ -109,12 +167,20 @@ fun AppRoot(settingsVersion: Int) {
|
||||
onSettings = { screen = Screen.Settings },
|
||||
)
|
||||
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(
|
||||
settings = current,
|
||||
summary = here.summary,
|
||||
onBack = goToMain,
|
||||
onSettings = { screen = Screen.SessionSettings(here.summary) },
|
||||
)
|
||||
}
|
||||
is Screen.Spawn ->
|
||||
SpawnScreen(
|
||||
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
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
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 reader's answer to why the wait was worth it. When they are not, this says nothing about size
|
||||
* rather than filling in a plausible number, and when only the size before is known it says exactly
|
||||
* that much.
|
||||
*
|
||||
* `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.
|
||||
* The counts are the whole point -- "a million tokens became ten thousand" is the reader's answer
|
||||
* to why the wait was worth it -- and they are all this says, because a divider is read in passing.
|
||||
* When they were not reported this says only that a compaction happened, rather than filling in a
|
||||
* plausible number or explaining at length what was missing.
|
||||
*/
|
||||
fun compactionSummary(item: TranscriptItem.CompactedNote): String {
|
||||
val what = if (item.trigger == "auto") "Compacted automatically" else "Compacted"
|
||||
val pre = item.preTokens
|
||||
val post = item.postTokens
|
||||
return when {
|
||||
pre != null && post != null -> "$what -- ${tokens(pre)} to ${tokens(post)} tokens"
|
||||
pre != null -> "$what -- was ${tokens(pre)} tokens, new size not reported"
|
||||
else -> what
|
||||
return if (pre != null && post != null) {
|
||||
"Compacted • ${tokens(pre)} → ${tokens(post)} tok"
|
||||
} else {
|
||||
"Compacted"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,47 +1,55 @@
|
||||
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.padding
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* 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
|
||||
* one -- a compaction and a clear -- and they look the same on purpose: to a reader scrolling back,
|
||||
* both mean "the session no longer has what is above this", and the difference between summarised
|
||||
* and dropped is what the words say, not how they are drawn.
|
||||
* Centred between two rules, because it is a divider rather than something anybody said. Two things
|
||||
* produce one -- a compaction and a clear -- and they are drawn the same way on purpose: to a
|
||||
* reader scrolling back, both mean "the session no longer has what is above this", and which of the
|
||||
* 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
|
||||
* different kinds of thing.
|
||||
*/
|
||||
@Composable
|
||||
fun TranscriptDivider(text: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(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.
|
||||
*
|
||||
* It says the conversation is still here, because the reader can see that it is -- everything above
|
||||
* stays on screen and stays scrollable, and the only thing that changed is what the session will
|
||||
* send. Without that sentence the divider reads as a deletion, which is the one thing it is not.
|
||||
*
|
||||
* 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.
|
||||
* Red, and no counts: a clear takes the conversation out of what the session is given, and unlike a
|
||||
* compaction it summarises nothing and measures nothing, so there is nothing to report but the
|
||||
* fact. Everything above stays on screen and stays scrollable -- the reader can see that, which is
|
||||
* why this does not say it.
|
||||
*/
|
||||
@Composable
|
||||
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.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
@@ -28,6 +29,13 @@ class MainActivity : ComponentActivity() {
|
||||
// composition below re-reads the stored settings.
|
||||
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
|
||||
// before the activity reaches STARTED.
|
||||
private val requestLocalNetworkPermission =
|
||||
@@ -69,7 +77,7 @@ class MainActivity : ComponentActivity() {
|
||||
requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
|
||||
handleEnrollment(intent)
|
||||
handleIntent(intent)
|
||||
// After enrollment, so a first launch that arrives with a token
|
||||
// starts the service with something to connect to rather than
|
||||
// stopping it and waiting for the next launch.
|
||||
@@ -89,22 +97,36 @@ class MainActivity : ComponentActivity() {
|
||||
.navigationBarsPadding()
|
||||
.imePadding()
|
||||
) {
|
||||
AppRoot(settingsVersion)
|
||||
AppRoot(settingsVersion, openRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// launchMode="singleTop": an enrollment scan while the app is open
|
||||
// lands here rather than in a second activity instance.
|
||||
// launchMode="singleTop": an enrollment scan, or a notification tapped
|
||||
// while the app is open, lands here rather than in a second activity
|
||||
// instance.
|
||||
override fun onNewIntent(intent: 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 sessionId = notifiedSessionId(uri)
|
||||
if (sessionId != null) {
|
||||
opens++
|
||||
openRequest = SessionOpenRequest(sessionId, opens)
|
||||
return
|
||||
}
|
||||
val settings = parseEnrollmentUri(uri)
|
||||
if (settings == null) {
|
||||
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.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
@@ -149,9 +150,7 @@ class NotificationService : Service() {
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java)
|
||||
.setAction(Intent.ACTION_MAIN)
|
||||
.addCategory(Intent.CATEGORY_LAUNCHER),
|
||||
sessionIntent(this, notification.sessionId),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
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`. */
|
||||
data class SessionNotification(
|
||||
val sessionId: String,
|
||||
|
||||
@@ -184,13 +184,6 @@ sealed class TranscriptItem {
|
||||
/** Placeholder row for events this build can't render (newer kinds). */
|
||||
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.
|
||||
*
|
||||
@@ -198,11 +191,21 @@ sealed class 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(
|
||||
override val seq: Long,
|
||||
val preTokens: Long?,
|
||||
val postTokens: Long?,
|
||||
val trigger: String?,
|
||||
) : TranscriptItem()
|
||||
}
|
||||
|
||||
@@ -430,13 +433,7 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
||||
}
|
||||
is SessionEvent.Cleared -> items + TranscriptItem.ClearedNote(entry.seq)
|
||||
is SessionEvent.Compacted ->
|
||||
items +
|
||||
TranscriptItem.CompactedNote(
|
||||
entry.seq,
|
||||
event.preTokens,
|
||||
event.postTokens,
|
||||
event.trigger,
|
||||
)
|
||||
items + TranscriptItem.CompactedNote(entry.seq, event.preTokens, event.postTokens)
|
||||
is SessionEvent.Unknown -> items + TranscriptItem.Note(entry.seq, "[${event.type}]")
|
||||
// Screen-level state, not transcript rows -- see SessionScreen.
|
||||
is SessionEvent.UsageDelta -> items
|
||||
|
||||
@@ -123,6 +123,18 @@ val failedColor: Color
|
||||
val commandColor: Color
|
||||
@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. */
|
||||
val awaitingColor: Color
|
||||
@Composable get() = Mocha.Peach
|
||||
|
||||
Reference in new issue
Block a user