Leave the session you are reading alone, and put its menus on their buttons
Three things about the session screen. A notification is no longer posted about the session in front of you: the transcript is already saying it, and one that was posted before you opened it is cancelled, since a row in the drawer for the conversation on screen is the same duplication. Bound to RESUMED rather than STARTED, so a session left on this screen behind another app still reports. The model and permission menus opened 142px clear of the buttons that opened them -- the status bar's height, exactly. Compose measures the anchor in window coordinates, which for an edge-to-edge activity is the whole display, but asks whether the menu fits inside the visible frame, which is that less the system bars; sitting just above a control near the bottom then reads as an overflow and Material3 parks the menu near the bottom of the visible frame instead. Turning clipping off makes both questions about the same window. The model picker now offers "default". The button has always been able to say it -- that is what a session with no model of its own reads as -- but the list could not, so choosing any model was a one-way trip. It is the Claude CLI's own word for "whatever is configured", which its set_model accepts, so it is a request rather than a name invented here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
68704fce7c
commit
9d5a7cf602
3 files changed
+86
-7
No files matched your search
@@ -1,5 +1,15 @@
|
|||||||
package com.example.aiapp
|
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.
|
* 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.
|
* is shown when there is room for it.
|
||||||
*/
|
*/
|
||||||
fun modelLabel(model: String?): String {
|
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, "")
|
return name.removePrefix("claude-").replace(DATED_SUFFIX, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,10 @@ class NotificationService : Service() {
|
|||||||
* becomes something to clear rather than read.
|
* becomes something to clear rather than read.
|
||||||
*/
|
*/
|
||||||
private fun show(notification: SessionNotification) {
|
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)
|
val manager = NotificationManagerCompat.from(this)
|
||||||
// Two different noes, and both are answers rather than faults: the runtime permission
|
// 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
|
// 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 ALERT_CHANNEL = "sessions"
|
||||||
private const val ONGOING_CHANNEL = "connection"
|
private const val ONGOING_CHANNEL = "connection"
|
||||||
private const val ONGOING_ID = 1
|
private const val ONGOING_ID = 1
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import androidx.lifecycle.repeatOnLifecycle
|
|||||||
import java.util.concurrent.atomic.AtomicLong
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
import java.util.concurrent.atomic.AtomicReference
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.awaitCancellation
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -743,6 +744,21 @@ fun SessionScreen(
|
|||||||
// stays started.
|
// stays started.
|
||||||
DisposableEffect(summary.id) { onDispose { activeStream.get()?.close() } }
|
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
|
// 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
|
// 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.
|
// follow new content, and drip-feeding it would only make that following last longer.
|
||||||
@@ -1280,7 +1296,11 @@ fun SessionScreen(
|
|||||||
if (offeredModels.isNotEmpty()) {
|
if (offeredModels.isNotEmpty()) {
|
||||||
PickerButton(
|
PickerButton(
|
||||||
current = modelLabel(model),
|
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
|
// Not set here. The button follows what the session reports it
|
||||||
// is set to, which arrives a moment later and is sometimes a
|
// is set to, which arrives a moment later and is sometimes a
|
||||||
// different answer -- a name the CLI resolved, or no change at all
|
// 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 --
|
// Asked about first, unless there is nothing to lose by it --
|
||||||
// see [ModelSwitchWarning].
|
// see [ModelSwitchWarning].
|
||||||
onPick = { chosen ->
|
onPick = { chosen ->
|
||||||
if (chosen == model || items.isEmpty()) {
|
if (modelLabel(chosen) == modelLabel(model) || items.isEmpty()) {
|
||||||
act { setSessionModel(settings, summary.id, chosen) }
|
act { setSessionModel(settings, summary.id, chosen) }
|
||||||
} else {
|
} else {
|
||||||
pendingModel = chosen
|
pendingModel = chosen
|
||||||
@@ -1593,13 +1613,29 @@ private fun PickerButton(current: String, options: List<String>, onPick: (String
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Not focusable, so opening it does not take focus from the message
|
// Two departures from the defaults, both deliberate.
|
||||||
// field and dismiss the keyboard. Changing the model mid-sentence
|
//
|
||||||
// is an aside, not a departure from what you were typing.
|
// 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(
|
DropdownMenu(
|
||||||
expanded = open,
|
expanded = open,
|
||||||
onDismissRequest = { open = false },
|
onDismissRequest = { open = false },
|
||||||
properties = PopupProperties(focusable = false),
|
properties = PopupProperties(focusable = false, clippingEnabled = false),
|
||||||
) {
|
) {
|
||||||
options.forEach { option ->
|
options.forEach { option ->
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
|
|||||||
Reference in new issue
Block a user