diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ModelName.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ModelName.kt index 0e3b3a2..c372d2d 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ModelName.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ModelName.kt @@ -1,5 +1,15 @@ package com.example.aiapp +/** + * What a session with no model of its own is called, in the button and in the list it opens. + * + * One constant rather than a literal in each place, because the two have to agree: a picker whose + * options cannot say every state its button can display is one you can leave and not get back to. + * It is also the Claude CLI's own word for "whatever is configured", so choosing it is a request + * the session can act on rather than a name this app made up. + */ +const val DEFAULT_MODEL = "default" + /** * A model's name as a person reads it. * @@ -17,7 +27,7 @@ package com.example.aiapp * is shown when there is room for it. */ fun modelLabel(model: String?): String { - val name = model?.takeIf { it.isNotBlank() } ?: return "default" + val name = model?.takeIf { it.isNotBlank() } ?: return DEFAULT_MODEL return name.removePrefix("claude-").replace(DATED_SUFFIX, "") } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Notifications.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Notifications.kt index eab3a42..60c2f1c 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Notifications.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Notifications.kt @@ -135,6 +135,10 @@ class NotificationService : Service() { * becomes something to clear rather than read. */ private fun show(notification: SessionNotification) { + // Nothing to tell somebody about the session they are reading. The transcript in front of + // 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 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 @@ -243,6 +247,35 @@ class NotificationService : Service() { ) } + /** + * The session somebody is looking at, or null when no screen is showing one. + * + * Process-wide state, which the rest of this app does without: Android constructs the + * service and the composition draws the screen, so the two have no common owner a value + * could be passed through. [showing] and [stoppedShowing] are the pair, both called from + * the one composable that shows a session. Clearing names the session rather than setting + * null outright, because moving from one session to another composes the new screen before + * the old one's coroutine is cancelled -- an unconditional clear would then throw away the + * new screen's claim and start notifying about what is on it. + */ + @Volatile private var onScreen: String? = null + + private fun isOnScreen(sessionId: String) = onScreen == sessionId + + /** Somebody is looking at [sessionId]; nothing is posted about it until they stop. */ + fun showing(context: Context, sessionId: String) { + onScreen = sessionId + // Whatever was posted about it before is about to be read, so it has nothing left + // to say -- and a row in the drawer for the conversation on screen is the same + // duplication this whole rule is about. + NotificationManagerCompat.from(context).cancel(sessionId, ALERT_ID) + } + + /** They have stopped, unless another screen has claimed it since. */ + fun stoppedShowing(sessionId: String) { + if (onScreen == sessionId) onScreen = null + } + private const val ALERT_CHANNEL = "sessions" private const val ONGOING_CHANNEL = "connection" private const val ONGOING_ID = 1 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 b3ae262..4148fd5 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -59,6 +59,7 @@ import androidx.lifecycle.repeatOnLifecycle import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -743,6 +744,21 @@ fun SessionScreen( // stays started. DisposableEffect(summary.id) { onDispose { activeStream.get()?.close() } } + // Nothing gets announced about the session somebody is reading; see NotificationService. + // RESUMED rather than STARTED because "looking at it" means the foreground -- a session left + // on this screen behind another app is one whose notifications are still wanted, and STARTED + // covers that case too. + LaunchedEffect(summary.id, lifecycleOwner) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { + NotificationService.showing(context, summary.id) + try { + awaitCancellation() + } finally { + NotificationService.stoppedShowing(summary.id) + } + } + } + // Back at the newest end, so the backlog [apply] held can land. Everything at once rather // than paced out: they are at the bottom, which is the one place the list is allowed to // follow new content, and drip-feeding it would only make that following last longer. @@ -1280,7 +1296,11 @@ fun SessionScreen( if (offeredModels.isNotEmpty()) { PickerButton( current = modelLabel(model), - options = offeredModels, + // What the machine offers, plus the state a session is in when it + // has chosen none of them. The button has always been able to say + // "default"; until this the list could not, so leaving it was a + // one-way trip. + options = listOf(DEFAULT_MODEL) + offeredModels, // Not set here. The button follows what the session reports it // is set to, which arrives a moment later and is sometimes a // different answer -- a name the CLI resolved, or no change at all @@ -1288,7 +1308,7 @@ fun SessionScreen( // Asked about first, unless there is nothing to lose by it -- // see [ModelSwitchWarning]. onPick = { chosen -> - if (chosen == model || items.isEmpty()) { + if (modelLabel(chosen) == modelLabel(model) || items.isEmpty()) { act { setSessionModel(settings, summary.id, chosen) } } else { pendingModel = chosen @@ -1593,13 +1613,29 @@ private fun PickerButton(current: String, options: List, onPick: (String overflow = TextOverflow.Ellipsis, ) } - // Not focusable, so opening it does not take focus from the message - // field and dismiss the keyboard. Changing the model mid-sentence - // is an aside, not a departure from what you were typing. + // Two departures from the defaults, both deliberate. + // + // Not focusable, so opening it does not take focus from the message field and dismiss the + // keyboard. Changing the model mid-sentence is an aside, not a departure from what you + // were typing. + // + // Not clipped, which is what puts the menu on the button instead of floating above it. + // Compose measures the anchor in *window* coordinates -- this app draws edge to edge, so + // that window is the whole screen -- but asks whether the menu fits inside the *visible* + // frame, which is the screen less the status and navigation bars. Two spaces, one + // comparison: sitting just above a button near the bottom then looks like an overflow, + // and the menu falls back to a fixed 48dp above the bottom of the visible frame. Measured + // on the emulator, that left the menu's foot 142px -- the status bar's height, exactly -- + // clear of the button that opened it. Turning clipping off makes both questions about the + // same window. What it gives up is that the keyboard stops counting as an edge: with the + // IME up the menu opens downwards over it rather than upwards over the transcript. That + // is the lesser fault -- it is still attached to the button that opened it, which is the + // whole complaint -- and correcting it would mean supplying a position provider, which + // this menu takes no parameter for. DropdownMenu( expanded = open, onDismissRequest = { open = false }, - properties = PopupProperties(focusable = false), + properties = PopupProperties(focusable = false, clippingEnabled = false), ) { options.forEach { option -> DropdownMenuItem(