Say it over the app when the app is what somebody is looking at
The notification stream now has three places to land instead of two, decided in one function. Nothing at all for the session on screen, as before. A banner over the app while the app is up. Android's drawer otherwise. Never two of them for one moment: a drawer filling up behind an app that showed you each one is a drawer nobody reads. The banners queue, one per session replacing that session's own -- the rule the drawer already followed, and for the same reason. Each can be tapped, which opens the session by the same path a tapped notification takes; pushed off either side; or left alone, in which case the bar across its foot retires it. The bar and the retiring are one value rather than a bar beside a timer, so a banner cannot outlive the countdown drawn under it. They clear when the app goes away, since a claim that a session wants somebody *now* does not survive an absence -- and the drawer has the job back by then. Which of the three applies needs no flag anybody keeps level. The session on screen is registered by the one composable that draws one, and "the app is up" is the queue being collected, which happens exactly while it is. Also: tapping a model or permission button while its own menu is open now closes it. A non-focusable popup does not swallow the press that dismisses it, so the same finger was reopening what it had just closed -- measured at 3ms between the two, which is what the guard is sized against. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e0eaa4b3f8
commit
be47beb0ff
6 files changed
+296
-11
No files matched your search
@@ -78,6 +78,11 @@ repo is in PLAN.md's "Backend layout" section.
|
||||
the REST + SSE clients; `Events.kt` the event model mirror;
|
||||
`ServerConfig.kt` settings + Keystore-sealed token; screens in
|
||||
`SessionListScreen/SessionScreen/SpawnScreen/SettingsScreen`.
|
||||
`Notifications.kt` is the foreground service holding the notification
|
||||
stream and the one place that decides where a notification is said --
|
||||
nothing for the session on screen, a `SessionAlerts` banner while the app
|
||||
is up, Android's drawer otherwise, never two of them. See PLAN.md's
|
||||
"Notifications: two places, never both".
|
||||
**Icons are Nerd Fonts glyphs from a committed subset**, not vector assets
|
||||
and not ordinary Unicode — `NerdIcons.kt` declares each codepoint and
|
||||
`app/build-icon-font.sh` subsets the font. The two lists have to agree: a
|
||||
|
||||
@@ -640,6 +640,37 @@ connectivity/lifecycle. The app keeps no persistent transcript store — the
|
||||
backend's transcript is the source of truth; the app caches only for the
|
||||
screen it's showing.
|
||||
|
||||
### Notifications: two places, never both (decided 2026-08-30)
|
||||
|
||||
The backend's `GET /notifications` is one SSE stream of attention-wanting
|
||||
moments, and the app decides where each one is said. Three outcomes, in one
|
||||
place (`NotificationService.show`):
|
||||
|
||||
- **Nothing at all** if the session is the one on screen. The transcript in
|
||||
front of the reader is already saying it.
|
||||
- **A banner over the app** if the app is up — `SessionAlerts`, queued, one
|
||||
per session replacing that session's own, dismissable by a push in either
|
||||
direction, and otherwise retiring itself when the bar across its foot runs
|
||||
out. Tapping one opens the session, through the same path a tapped
|
||||
notification uses.
|
||||
- **A row in Android's drawer** otherwise, which is what the foreground
|
||||
service exists for.
|
||||
|
||||
Never two of them for one moment. A notification that has already been shown
|
||||
in the app is not something to also find in the shade afterwards, and a
|
||||
drawer that fills up behind an app that showed you each one is a drawer
|
||||
nobody reads.
|
||||
|
||||
Which of the three applies is answered without a flag anybody has to keep
|
||||
level: the session on screen is registered by the one composable that draws
|
||||
one, and "the app is up" *is* the banner queue being collected, since it
|
||||
collects only while it is on screen.
|
||||
|
||||
The alternative considered and rejected was giving the app its own
|
||||
connection to `/notifications` while it is in front. That is a second stream
|
||||
per device saying the same thing, and it puts the "which of these two shows
|
||||
it" decision in two processes' worth of code instead of one function.
|
||||
|
||||
### Deferred polish
|
||||
|
||||
Noticed and deliberately not fixed yet, so they are not re-found from
|
||||
|
||||
@@ -212,4 +212,11 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
|
||||
onBack = goToMain,
|
||||
)
|
||||
}
|
||||
|
||||
// Last, so it draws over the screen above rather than under it: these are stacked in the Box
|
||||
// the activity puts around this, and that Box paints in the order it was given. A session
|
||||
// wanting attention is not a fact about the page somebody happens to be on, so it is not the
|
||||
// page's job to leave room for it. Tapping one is the same act as tapping a notification, so
|
||||
// it goes through the same `open`, failure dialog included.
|
||||
SessionAlerts(onOpen = { request -> scope.launch { open(request) } })
|
||||
}
|
||||
@@ -20,6 +20,9 @@ import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import kotlin.concurrent.thread
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
@@ -139,6 +142,10 @@ class NotificationService : Service() {
|
||||
// them is already saying it, and a sound over the top of it would be this app announcing
|
||||
// what the screen is showing.
|
||||
if (isOnScreen(notification.sessionId)) return
|
||||
// The app is up: it says this itself, as a banner over whatever screen they are on. See
|
||||
// [forTheScreen]. Never both -- one thing happened, and a drawer filling up behind an
|
||||
// app that already showed you each one is a drawer nobody reads.
|
||||
if (handOver(notification)) return
|
||||
val manager = NotificationManagerCompat.from(this)
|
||||
// Two different noes, and both are answers rather than faults: the runtime permission
|
||||
// refused, and notifications switched off for the app in Android's own settings. Neither
|
||||
@@ -160,15 +167,7 @@ class NotificationService : Service() {
|
||||
val built =
|
||||
NotificationCompat.Builder(this, ALERT_CHANNEL)
|
||||
.setContentTitle(notification.title)
|
||||
.setContentText(
|
||||
when (notification.kind) {
|
||||
// What the reader has to do, not what the session
|
||||
// did: "awaitingInput" is the wire's word and says
|
||||
// nothing to somebody reading a lock screen.
|
||||
"awaitingInput" -> "Waiting for you"
|
||||
else -> "Finished"
|
||||
}
|
||||
)
|
||||
.setContentText(attentionLine(notification.kind))
|
||||
.setSmallIcon(android.R.drawable.stat_notify_chat)
|
||||
.setContentIntent(open)
|
||||
.setAutoCancel(true)
|
||||
@@ -262,6 +261,23 @@ class NotificationService : Service() {
|
||||
|
||||
private fun isOnScreen(sessionId: String) = onScreen == sessionId
|
||||
|
||||
/**
|
||||
* The way a notification reaches the app instead of Android's drawer.
|
||||
*
|
||||
* Whether there is an app to reach is the subscriber count rather than a flag of its own:
|
||||
* [SessionAlerts] collects this exactly while it is on screen, so there is nothing that
|
||||
* could be left saying the app is up after it has gone. `tryEmit` neither suspends nor
|
||||
* blocks the thread reading the stream, and the buffer is there so a handful of sessions
|
||||
* finishing together all land rather than the last one winning.
|
||||
*/
|
||||
private val toApp = MutableSharedFlow<SessionNotification>(extraBufferCapacity = 8)
|
||||
|
||||
/** Everything meant for the screen rather than the drawer; see [toApp]. */
|
||||
val forTheScreen: SharedFlow<SessionNotification> = toApp.asSharedFlow()
|
||||
|
||||
private fun handOver(notification: SessionNotification) =
|
||||
toApp.subscriptionCount.value > 0 && toApp.tryEmit(notification)
|
||||
|
||||
/** Somebody is looking at [sessionId]; nothing is posted about it until they stop. */
|
||||
fun showing(context: Context, sessionId: String) {
|
||||
onScreen = sessionId
|
||||
@@ -319,6 +335,20 @@ data class SessionNotification(
|
||||
val at: Double,
|
||||
)
|
||||
|
||||
/**
|
||||
* What a notification asks of the reader, in the words they see.
|
||||
*
|
||||
* What they have to do, not what the session did: "awaitingInput" is the wire's word and says
|
||||
* nothing to somebody reading a lock screen. One function because the same fact is now shown in two
|
||||
* places -- Android's drawer and the app's own banner -- and two mappings of one word drift. The
|
||||
* banner colours the line as well, which is its own decision and stays with the drawing.
|
||||
*/
|
||||
fun attentionLine(kind: String): String =
|
||||
when (kind) {
|
||||
"awaitingInput" -> "Waiting for you"
|
||||
else -> "Finished"
|
||||
}
|
||||
|
||||
fun parseNotification(json: String): SessionNotification {
|
||||
val body = JSONObject(json)
|
||||
return SessionNotification(
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SwipeToDismissBox
|
||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||
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.mutableStateListOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
|
||||
/**
|
||||
* A session wanting attention, said over the app rather than through Android's drawer.
|
||||
*
|
||||
* Two places can carry the same fact and only one of them is right at a time. A row in the shade is
|
||||
* for somebody looking at something else: it makes a sound, it waits however long it has to, and
|
||||
* acting on it means leaving whatever they were doing. Somebody with this app open needs none of
|
||||
* that -- they are already here, and what a tap on the notification would have done is what a tap
|
||||
* on this does. So while these are on screen the stream is delivered here instead, which is
|
||||
* arranged by the collection below and nothing else; see `NotificationService.forTheScreen`.
|
||||
*
|
||||
* A banner can go three ways, and each is somebody deciding something different: tapped, which
|
||||
* opens the session; pushed off either side; or left alone, in which case it goes by itself when
|
||||
* the bar across its foot runs out.
|
||||
*/
|
||||
@Composable
|
||||
fun SessionAlerts(onOpen: (SessionOpenRequest) -> Unit, modifier: Modifier = Modifier) {
|
||||
val queue = remember { mutableStateListOf<SessionAlert>() }
|
||||
// What tells two notifications about one session apart, and what a replaced banner gets a new
|
||||
// one of so its timer starts again rather than inheriting the remains of the last one's.
|
||||
var arrivals by remember { mutableIntStateOf(0) }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
LaunchedEffect(lifecycleOwner) {
|
||||
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
try {
|
||||
NotificationService.forTheScreen.collect { notification ->
|
||||
arrivals++
|
||||
val alert = SessionAlert(notification, arrivals)
|
||||
// One banner per session, replacing that session's own -- the same rule the
|
||||
// drawer follows, and for the same reason: a session that finished and then
|
||||
// asked a question is one thing to know about, the question. It keeps its
|
||||
// place in the queue rather than moving to the end, because the reader may
|
||||
// already be reaching for it.
|
||||
val already = queue.indexOfFirst {
|
||||
it.notification.sessionId == notification.sessionId
|
||||
}
|
||||
if (already >= 0) queue[already] = alert else queue.add(alert)
|
||||
}
|
||||
} finally {
|
||||
// Leaving the app hands the job back to the drawer, so nothing arriving while it
|
||||
// is away is lost. What would be lost is the truth of what is already up: these
|
||||
// say a session wants somebody *now*, and one still sitting here on a return
|
||||
// several minutes later is a claim nobody checked. Frozen, too -- Compose stops
|
||||
// the clock with the window, so the timer that was going to retire it has been
|
||||
// standing still the whole time.
|
||||
queue.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
// Oldest at the top, so a new one appears below the ones already being read instead of
|
||||
// shoving them down the screen mid-reach.
|
||||
Column(modifier.fillMaxWidth().padding(8.dp)) {
|
||||
queue.forEach { alert ->
|
||||
key(alert.arrival) {
|
||||
AlertBanner(
|
||||
alert = alert,
|
||||
onOpen = {
|
||||
queue.remove(alert)
|
||||
onOpen(SessionOpenRequest(alert.notification.sessionId, alert.arrival))
|
||||
},
|
||||
onGone = { queue.remove(alert) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One notification queued for the screen, with the arrival that tells it from its predecessor. */
|
||||
private data class SessionAlert(val notification: SessionNotification, val arrival: Int)
|
||||
|
||||
/**
|
||||
* One banner: what wants attention, and how long this has left to say so.
|
||||
*
|
||||
* The bar and the going away are one value rather than a bar beside a timer, because two of them
|
||||
* would be two accounts of the same countdown and only one can be the one that fires. What is drawn
|
||||
* is therefore the thing that decides, which is the only arrangement where a bar that has emptied
|
||||
* cannot be sitting under a banner that is still there.
|
||||
*/
|
||||
@Composable
|
||||
private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> Unit) {
|
||||
val swipe = rememberSwipeToDismissBoxState()
|
||||
val life = remember { Animatable(1f) }
|
||||
LaunchedEffect(Unit) {
|
||||
life.animateTo(0f, animationSpec = tween(ALERT_LIFE_MS, easing = LinearEasing))
|
||||
onGone()
|
||||
}
|
||||
// Settled is "still where it started"; anything else is a push that carried far enough for the
|
||||
// gesture to commit, which the platform decides rather than this screen.
|
||||
LaunchedEffect(swipe.currentValue) {
|
||||
if (swipe.currentValue != SwipeToDismissBoxValue.Settled) onGone()
|
||||
}
|
||||
SwipeToDismissBox(
|
||||
state = swipe,
|
||||
// Nothing behind it. Pushing one of these away means the same thing whichever way it went,
|
||||
// so a coloured ground with an icon would be drawing a distinction that isn't there.
|
||||
backgroundContent = {},
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
) {
|
||||
Card(
|
||||
onClick = onOpen,
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
),
|
||||
// Outlined, because the step it needs to make is not one this palette can make with a
|
||||
// tint: the card under a banner on the session list is the same surface, so a banner
|
||||
// relying on colour alone reads as one more row that happens to be in the way. The
|
||||
// border is the one cue, and the elevation beside it is the platform's shadow rather
|
||||
// than a second tint -- Material draws no tonal overlay over a container stated here.
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp),
|
||||
) {
|
||||
Column(Modifier.padding(start = 12.dp, end = 12.dp, top = 12.dp, bottom = 10.dp)) {
|
||||
Text(
|
||||
alert.notification.title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
// One line, cut at the tail: a session is identified by the start of its
|
||||
// name, and a banner that grew with the name would move the one below it.
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
attentionLine(alert.notification.kind),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
// The list's own colour for a session waiting on a person, so the banner and
|
||||
// the row behind it are saying one thing rather than two.
|
||||
color =
|
||||
if (alert.notification.kind == "awaitingInput") awaitingColor
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
LinearProgressIndicator(
|
||||
progress = { life.value },
|
||||
// Blue because it is reporting how much of something is left rather than passing
|
||||
// judgement on it -- the reason `progressColor` exists. Stated beside the track,
|
||||
// which is the card's own colour so that the spent part reads as empty rather
|
||||
// than as a second bar.
|
||||
color = progressColor,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
drawStopIndicator = {},
|
||||
gapSize = 0.dp,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a banner stays if nobody touches it.
|
||||
*
|
||||
* Long enough to read a session name and a line, short enough that a stack of them clears itself
|
||||
* while somebody is still on the screen that produced them. The bar makes the number visible, so
|
||||
* this is a duration the reader can watch rather than one they have to learn.
|
||||
*/
|
||||
private const val ALERT_LIFE_MS = 6_000
|
||||
@@ -1619,6 +1619,16 @@ private fun QuestionRow(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How long after a menu closes a press on its own button still counts as the press that closed it.
|
||||
*
|
||||
* Sized to one tap, because one tap is all it has to span -- [PickerButton] explains the pair of
|
||||
* events it separates. Deliberately not the platform's long-press timeout, which is the longest a
|
||||
* tap can legally be: half a second of ignoring the button would start swallowing a deliberate
|
||||
* reopen, and a press held that long to close a menu is not worth protecting at that price.
|
||||
*/
|
||||
private const val ONE_TAP_MS = 250L
|
||||
|
||||
/** The modes the CLI accepts, in the order they give up asking. */
|
||||
private val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan")
|
||||
|
||||
@@ -1631,8 +1641,21 @@ private val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPe
|
||||
@Composable
|
||||
private fun PickerButton(current: String, options: List<String>, onPick: (String) -> Unit) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
// When an outside touch last closed the menu.
|
||||
//
|
||||
// Pressing this button while its own menu is open is such a touch. The menu is deliberately
|
||||
// not focusable (see below), which means the press that dismisses it is also delivered to the
|
||||
// window underneath -- and what it lands on there is this button. The dismissal arrives with
|
||||
// the press and the click with the release, measured 3ms apart on the emulator, so a button
|
||||
// that simply opened on every click would reopen what the same finger had just closed, and
|
||||
// the menu could only be put away by tapping somewhere else. So the moment is remembered, and
|
||||
// a click that follows it within one tap is read as the second half of that tap rather than
|
||||
// as a new one.
|
||||
var closedAt by remember { mutableLongStateOf(0L) }
|
||||
Box {
|
||||
TextButton(onClick = { open = true }) {
|
||||
TextButton(
|
||||
onClick = { if (SystemClock.uptimeMillis() - closedAt > ONE_TAP_MS) open = true }
|
||||
) {
|
||||
// One line, truncated rather than wrapped: this sits in a row
|
||||
// whose height is the buttons beside it, and a second line
|
||||
// would move them.
|
||||
@@ -1664,7 +1687,10 @@ private fun PickerButton(current: String, options: List<String>, onPick: (String
|
||||
// this menu takes no parameter for.
|
||||
DropdownMenu(
|
||||
expanded = open,
|
||||
onDismissRequest = { open = false },
|
||||
onDismissRequest = {
|
||||
open = false
|
||||
closedAt = SystemClock.uptimeMillis()
|
||||
},
|
||||
properties = PopupProperties(focusable = false, clippingEnabled = false),
|
||||
) {
|
||||
options.forEach { option ->
|
||||
|
||||
Reference in new issue
Block a user