Merge branch 'main' of git.arirex.me:iris/ai-app
This commit is contained in:
commit
3ecc550c1e
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
|
||||||
@@ -764,6 +765,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.
|
||||||
@@ -1314,7 +1330,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
|
||||||
@@ -1322,7 +1342,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
|
||||||
@@ -1632,13 +1652,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