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
+192
-65
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 ->
|
||||
SessionScreen(
|
||||
settings = current,
|
||||
summary = here.summary,
|
||||
onBack = goToMain,
|
||||
onSettings = { screen = Screen.SessionSettings(here.summary) },
|
||||
)
|
||||
// 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,
|
||||
|
||||
Reference in new issue
Block a user