Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
49d3439ec4
22 files changed
+1125
-61
No files matched your search
@@ -10,6 +10,14 @@
|
||||
dev-updater's manifest for the full story. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||
|
||||
<!-- Telling somebody a session wants them. POST_NOTIFICATIONS is a
|
||||
runtime permission from Android 13; the foreground-service pair
|
||||
below is what lets the connection outlive the app being closed,
|
||||
which is the entire point (see Notifications.kt). -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
|
||||
<!-- tools:ignore MissingApplicationIcon: there is no icon yet, and
|
||||
that is a decision rather than an oversight. An app with no icon
|
||||
of its own is obvious to anyone who opens a launcher, so the
|
||||
@@ -49,6 +57,23 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- specialUse rather than dataSync, which is the type this looks
|
||||
like: Android 15 caps dataSync at six hours a day, and a
|
||||
connection that stops listening after six hours is one that
|
||||
misses the overnight run it exists for. The subtype below is
|
||||
the reason string that type requires. -->
|
||||
<service
|
||||
android:name=".NotificationService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Holds one connection to the user's own backend so a session
|
||||
that needs an answer can be reported while the app is closed. There is no
|
||||
push service: the backend is reachable only over the user's WireGuard
|
||||
tunnel and never talks to a third party." />
|
||||
</service>
|
||||
|
||||
<!-- The scanner behind Settings' "Scan QR code". Declared here so
|
||||
it can drop the library CaptureActivity's landscape pin: the
|
||||
code being scanned is usually on a monitor in front of someone
|
||||
|
||||
@@ -141,6 +141,18 @@ data class SessionSummary(
|
||||
* Whether this continues a session the machine already had, which changes what deleting means.
|
||||
*/
|
||||
val imported: Boolean,
|
||||
/**
|
||||
* Whether this session announces itself when it wants attention.
|
||||
*
|
||||
* Reported rather than assumed, for the same reason [permissionMode] is: a switch that draws
|
||||
* itself from a default is one you can turn off while believing you are reading it. Defaults to
|
||||
* on when a backend is too old to say, which matches what that backend actually does.
|
||||
*/
|
||||
val notify: Boolean,
|
||||
/**
|
||||
* Every token this session has spent, as the server counts it -- see `SessionEvent.UsageDelta`.
|
||||
*/
|
||||
val totalTokens: Long,
|
||||
val status: String,
|
||||
val lastActivity: Double,
|
||||
)
|
||||
@@ -156,6 +168,8 @@ private fun parseSession(session: JSONObject) =
|
||||
model = session.optString("model").ifEmpty { null },
|
||||
permissionMode = session.optString("permissionMode").ifEmpty { null },
|
||||
imported = session.optBoolean("imported", false),
|
||||
notify = session.optBoolean("notify", true),
|
||||
totalTokens = session.optLong("totalTokens", 0),
|
||||
status = session.getString("status"),
|
||||
lastActivity = session.getDouble("lastActivity"),
|
||||
)
|
||||
@@ -163,6 +177,17 @@ private fun parseSession(session: JSONObject) =
|
||||
fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
|
||||
requestFromServer(settings, "/sessions") { it.jsonObjects(::parseSession) }
|
||||
|
||||
/**
|
||||
* One session as the server has it now.
|
||||
*
|
||||
* For screens whose controls are *set to* something rather than merely showing it. A screen opened
|
||||
* from a list row carries the row the list last fetched, which is a snapshot: fine for a title,
|
||||
* wrong for a switch, since a switch drawn from a stale row shows a position that may have been
|
||||
* changed since -- here or on another device -- and nothing on screen says which.
|
||||
*/
|
||||
fun fetchSession(settings: ServerSettings, sessionId: String): SessionSummary =
|
||||
requestFromServer(settings, "/sessions/$sessionId") { parseSession(it.jsonObject()) }
|
||||
|
||||
// What the server offers, so the spawn screen has no hardcoded lists: a
|
||||
// setup added to the server's config.ron appears here with no app rebuild.
|
||||
//
|
||||
@@ -588,6 +613,16 @@ fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode:
|
||||
) {}
|
||||
}
|
||||
|
||||
/** Turns this session's notifications on or off. Stored on the backend -- see `SessionConfig`. */
|
||||
fun setSessionNotify(settings: ServerSettings, sessionId: String, notify: Boolean) {
|
||||
requestFromServer(
|
||||
settings,
|
||||
"/sessions/$sessionId/notify",
|
||||
method = "POST",
|
||||
jsonBody = JSONObject().put("notify", notify).toString(),
|
||||
) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the session to run one of its own commands.
|
||||
*
|
||||
|
||||
@@ -102,7 +102,15 @@ sealed class SessionEvent {
|
||||
*/
|
||||
data class Settings(val model: String?, val permissionMode: String?) : SessionEvent()
|
||||
|
||||
data class UsageDelta(val tokens: Long) : SessionEvent()
|
||||
/**
|
||||
* What a turn cost, and what the session has cost in total.
|
||||
*
|
||||
* [total] is the server's running figure, carried on the event so a reader never adds up its
|
||||
* own: a phone opens a session on the newest page of the transcript, so a sum it computed would
|
||||
* be that page's share of the conversation wearing the whole conversation's label. Zero on
|
||||
* entries recorded before the backend sent it.
|
||||
*/
|
||||
data class UsageDelta(val tokens: Long, val total: Long) : SessionEvent()
|
||||
|
||||
/**
|
||||
* A compaction that finished, and how much context it recovered.
|
||||
@@ -200,7 +208,8 @@ fun parseSeqEvent(json: String): SeqEvent {
|
||||
model = body.optString("model").ifEmpty { null },
|
||||
permissionMode = body.optString("permissionMode").ifEmpty { null },
|
||||
)
|
||||
"usageDelta" -> SessionEvent.UsageDelta(body.getLong("tokens"))
|
||||
"usageDelta" ->
|
||||
SessionEvent.UsageDelta(body.getLong("tokens"), body.optLong("total", 0))
|
||||
"compacted" ->
|
||||
SessionEvent.Compacted(
|
||||
preTokens = if (body.has("preTokens")) body.getLong("preTokens") else null,
|
||||
|
||||
@@ -33,6 +33,16 @@ class MainActivity : ComponentActivity() {
|
||||
private val requestLocalNetworkPermission =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
|
||||
|
||||
/**
|
||||
* The service starts either way, and posts nothing if this is refused.
|
||||
*
|
||||
* Deliberately not gated on the answer: the permission can be granted later from Android's own
|
||||
* settings, and a service that only ever started at the moment it was granted would then stay
|
||||
* down until the app was launched again -- which is the case notifications exist to avoid.
|
||||
*/
|
||||
private val requestNotificationPermission =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
@@ -55,7 +65,15 @@ class MainActivity : ComponentActivity() {
|
||||
requestLocalNetworkPermission.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
|
||||
handleEnrollment(intent)
|
||||
// After enrollment, so a first launch that arrives with a token
|
||||
// starts the service with something to connect to rather than
|
||||
// stopping it and waiting for the next launch.
|
||||
NotificationService.sync(this)
|
||||
|
||||
setContent {
|
||||
MaterialTheme(colorScheme = AiAppColors) {
|
||||
@@ -94,6 +112,9 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
saveServerSettings(this, settings)
|
||||
settingsVersion++
|
||||
// Enrolling is the moment there is a backend to watch, and
|
||||
// re-enrolling elsewhere is the moment the old one stops being it.
|
||||
NotificationService.sync(this)
|
||||
Toast.makeText(this, "Enrolled with ${settings.baseUrl}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
@@ -59,21 +59,28 @@ fun MarkdownText(text: String, modifier: Modifier = Modifier) {
|
||||
bullet = body,
|
||||
list = body,
|
||||
table = body,
|
||||
// Code in a monospace face: a code block set in the body font stops looking like
|
||||
// code at all. The colour rides on the style here rather than in `markdownColor`,
|
||||
// which stopped carrying `codeText`/`inlineCodeText`/`linkText` when the renderer
|
||||
// moved them onto the typography.
|
||||
// Code in a monospace face, in the ordinary text colour. The face and the tinted
|
||||
// background are what say "this is code"; colour is not, and it used to be green
|
||||
// -- the palette's colour for a *literal*. A block of code is not a literal, it
|
||||
// is text that happens to be code, and painting all of it green said the whole
|
||||
// block was one. Where a literal really does appear inside code, the thing that
|
||||
// should colour it is a syntax highlighter looking at the code, which is exactly
|
||||
// what a tool call's input already gets from `catppuccinSyntax`.
|
||||
//
|
||||
// The colour rides on the style here rather than in `markdownColor`, which
|
||||
// stopped carrying `codeText`/`inlineCodeText`/`linkText` when the renderer moved
|
||||
// them onto the typography.
|
||||
code =
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = codeColor,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
inlineCode =
|
||||
body.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
// Unspecified so an inline span keeps the size of the line it sits in.
|
||||
fontSize = TextUnit.Unspecified,
|
||||
color = codeColor,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
textLink =
|
||||
TextLinkStyles(
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import kotlin.concurrent.thread
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Telling somebody a session wants them, when they are not looking at the app.
|
||||
*
|
||||
* This is a **foreground service**, which on Android is the only way to keep a connection open
|
||||
* while the app is closed -- there has been no such thing as a long-lived background service since
|
||||
* Android 8. It is what Syncthing does for the same reason. Discord is not a counter-example: it
|
||||
* gets a push from Google's servers, which would mean this backend talking to Google about
|
||||
* somebody's coding sessions, and the whole point of the tunnel is that it does not.
|
||||
*
|
||||
* The cost Android charges for it is a notification of its own that cannot be dismissed. That is
|
||||
* made as quiet as the platform allows: [ONGOING_CHANNEL] is `IMPORTANCE_MIN`, so it makes no
|
||||
* sound, shows no status-bar icon, and sits at the bottom of the shade -- the same arrangement
|
||||
* Syncthing's "hide the persistent notification" option produces. It is not hidden outright,
|
||||
* because it cannot be and because it should not be: it is the honest indicator that something is
|
||||
* holding a connection open.
|
||||
*/
|
||||
class NotificationService : Service() {
|
||||
@Volatile private var stream: HttpURLConnection? = null
|
||||
@Volatile private var stopping = false
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val settings = loadServerSettings(this)
|
||||
if (settings == null) {
|
||||
// Nothing to connect to. Stopping rather than idling: a service
|
||||
// holding no connection still costs the ongoing notification,
|
||||
// which would then be announcing work that is not happening.
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
// Through ServiceCompat so the type is stated once and ignored on
|
||||
// the versions that predate types, rather than branching here.
|
||||
ServiceCompat.startForeground(this, ONGOING_ID, ongoingNotification(), foregroundType())
|
||||
thread(isDaemon = true, name = "ai-app-notifications") { follow(settings) }
|
||||
// Restarted if Android kills it, which is the whole point: the
|
||||
// window this covers is exactly the one where nobody is watching.
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
stopping = true
|
||||
stream?.disconnect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows the backend's notification stream, reconnecting until stopped.
|
||||
*
|
||||
* A dropped connection is the ordinary case here rather than an error -- a phone changes
|
||||
* networks, the tunnel comes and goes, the backend restarts -- so it retries quietly and
|
||||
* forever. Nothing is shown when it cannot connect: a notification saying "I could not tell you
|
||||
* whether anything happened" on a phone in somebody's pocket is noise about a condition they
|
||||
* cannot act on, and the session list already says what is waiting when they next look.
|
||||
*/
|
||||
private fun follow(settings: ServerSettings) {
|
||||
while (!stopping) {
|
||||
try {
|
||||
readStream(settings)
|
||||
} catch (_: IOException) {
|
||||
// Deliberate: see above.
|
||||
}
|
||||
if (stopping) return
|
||||
try {
|
||||
Thread.sleep(RECONNECT_DELAY_MS)
|
||||
} catch (_: InterruptedException) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun readStream(settings: ServerSettings) {
|
||||
val connection =
|
||||
URL("${settings.baseUrl}/notifications").openConnection() as HttpURLConnection
|
||||
stream = connection
|
||||
try {
|
||||
connection.applyPinnedTls()
|
||||
connection.connectTimeout = CONNECT_TIMEOUT_MS
|
||||
// No read timeout, for the reason EventStream gives: between
|
||||
// notifications there is nothing to read, possibly for hours.
|
||||
connection.readTimeout = 0
|
||||
connection.setRequestProperty("Authorization", "Bearer ${settings.token}")
|
||||
connection.setRequestProperty("Accept", "text/event-stream")
|
||||
if (connection.responseCode != 200) {
|
||||
throw IOException("HTTP ${connection.responseCode} for the notification stream")
|
||||
}
|
||||
val reader = connection.inputStream.bufferedReader()
|
||||
val data = StringBuilder()
|
||||
while (!stopping) {
|
||||
val line = reader.readLine() ?: break
|
||||
when {
|
||||
line.isEmpty() -> {
|
||||
if (data.isNotEmpty()) show(parseNotification(data.toString()))
|
||||
data.clear()
|
||||
}
|
||||
line.startsWith("data:") -> data.append(line.removePrefix("data:").trim())
|
||||
else -> {} // comments (keep-alives) and ids: nothing to do
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
stream = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One notification per session, replacing that session's previous one.
|
||||
*
|
||||
* Keyed by session id rather than accumulating: two sessions wanting attention are two things
|
||||
* to know about, but one session that finished and then asked a question is one thing -- the
|
||||
* question. A stack of stale rows for the same conversation is how a notification drawer
|
||||
* becomes something to clear rather than read.
|
||||
*/
|
||||
private fun show(notification: SessionNotification) {
|
||||
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
|
||||
// is reported anywhere -- the person said no, and saying it back to them through the
|
||||
// channel they closed is not available anyway.
|
||||
val allowed =
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!allowed || !manager.areNotificationsEnabled()) {
|
||||
return
|
||||
}
|
||||
val open =
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java)
|
||||
.setAction(Intent.ACTION_MAIN)
|
||||
.addCategory(Intent.CATEGORY_LAUNCHER),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
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"
|
||||
}
|
||||
)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_chat)
|
||||
.setContentIntent(open)
|
||||
.setAutoCancel(true)
|
||||
.setWhen((notification.at * 1000).toLong())
|
||||
.setShowWhen(true)
|
||||
.build()
|
||||
manager.notify(notification.sessionId, ALERT_ID, built)
|
||||
}
|
||||
|
||||
/**
|
||||
* The type Android 14+ requires a foreground service to declare, and nothing before it.
|
||||
*
|
||||
* Named behind a version check rather than passed as a constant: the value is inlined at
|
||||
* compile time and would be handed to platforms that have no concept of it, which is exactly
|
||||
* the case lint's InlinedApi exists to catch. Zero is what ServiceCompat wants where types do
|
||||
* not apply.
|
||||
*/
|
||||
private fun foregroundType(): Int =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
private fun ongoingNotification(): Notification =
|
||||
NotificationCompat.Builder(this, ONGOING_CHANNEL)
|
||||
.setContentTitle("Watching for sessions that need you")
|
||||
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
||||
.setOngoing(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.build()
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Starts the service if there is a server to connect to, and stops it otherwise.
|
||||
*
|
||||
* Called on every launch rather than once: a service Android killed does not restart itself
|
||||
* if the process was replaced, and asking for one that is already running is free.
|
||||
*/
|
||||
fun sync(context: Context) {
|
||||
val intent = Intent(context, NotificationService::class.java)
|
||||
if (loadServerSettings(context) == null) {
|
||||
context.stopService(intent)
|
||||
return
|
||||
}
|
||||
createChannels(context)
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Two channels, because they are two different things to be told.
|
||||
*
|
||||
* The alerts are what somebody turned this on for, so they get the default importance and
|
||||
* whatever sound and heads-up display the person has chosen for the app. The ongoing one is
|
||||
* the platform's tax for staying connected, so it takes the lowest importance that exists.
|
||||
* Both are created before the service starts, since posting to a channel that does not
|
||||
* exist is silently dropped.
|
||||
*/
|
||||
private fun createChannels(context: Context) {
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannelCompat.Builder(
|
||||
ALERT_CHANNEL,
|
||||
NotificationManagerCompat.IMPORTANCE_DEFAULT,
|
||||
)
|
||||
.setName("Sessions needing attention")
|
||||
.build()
|
||||
)
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannelCompat.Builder(
|
||||
ONGOING_CHANNEL,
|
||||
NotificationManagerCompat.IMPORTANCE_MIN,
|
||||
)
|
||||
.setName("Staying connected")
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
private const val ALERT_CHANNEL = "sessions"
|
||||
private const val ONGOING_CHANNEL = "connection"
|
||||
private const val ONGOING_ID = 1
|
||||
/** Shared by every alert; the session id is the tag that separates them. */
|
||||
private const val ALERT_ID = 2
|
||||
private const val RECONNECT_DELAY_MS = 5_000L
|
||||
}
|
||||
}
|
||||
|
||||
/** One frame of `GET /notifications`. */
|
||||
data class SessionNotification(
|
||||
val sessionId: String,
|
||||
val title: String,
|
||||
/** The wire's word: "awaitingInput" or "finished". */
|
||||
val kind: String,
|
||||
val at: Double,
|
||||
)
|
||||
|
||||
fun parseNotification(json: String): SessionNotification {
|
||||
val body = JSONObject(json)
|
||||
return SessionNotification(
|
||||
sessionId = body.getString("sessionId"),
|
||||
title = body.getString("title"),
|
||||
kind = body.getString("kind"),
|
||||
at = body.optDouble("at", 0.0),
|
||||
)
|
||||
}
|
||||
@@ -263,11 +263,12 @@ private fun SessionCard(
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
// Provider, then where it runs -- "on <host>" rather
|
||||
// than a bare name, so a host isn't mistaken for a model.
|
||||
// Machine, then what runs on it, then what it is set to: the same order
|
||||
// and separator as the session screen's header and the usage dialog, so
|
||||
// one pair of facts is not written three ways.
|
||||
listOfNotNull(
|
||||
session.setupName,
|
||||
session.provider,
|
||||
"on ${session.setupName}",
|
||||
session.model?.let { modelLabel(it) },
|
||||
)
|
||||
.joinToString(" · "),
|
||||
|
||||
@@ -410,7 +410,10 @@ fun SessionScreen(
|
||||
val scope = rememberCoroutineScope()
|
||||
var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
|
||||
var status by remember { mutableStateOf(summary.status) }
|
||||
var totalTokens by remember { mutableLongStateOf(0L) }
|
||||
// Seeded from the row this screen was opened from, so a conversation that has spent
|
||||
// something says so before any turn happens here. Zero used to mean "nothing yet" and
|
||||
// "nothing in the page I loaded" at once, and the second one is most of the long sessions.
|
||||
var totalTokens by remember(summary.id) { mutableLongStateOf(summary.totalTokens) }
|
||||
// When this screen saw the current compaction start, on this device's own clock, and how long
|
||||
// ago that is. See `compactingLabel`: null is the honest answer whenever the start was not
|
||||
// witnessed here, which is what opening a session that is already compacting looks like.
|
||||
@@ -495,7 +498,13 @@ fun SessionScreen(
|
||||
moreHistory = entry.seq > 1L
|
||||
}
|
||||
when (val event = entry.event) {
|
||||
is SessionEvent.UsageDelta -> totalTokens += event.tokens
|
||||
// Taken, not accumulated: the server's running total is on the event, and adding
|
||||
// up the deltas this screen happened to receive counted one page of a conversation
|
||||
// and called it the whole. `max` because pages arrive in no guaranteed order and an
|
||||
// older event's total is a smaller true answer, never a correction downwards; it
|
||||
// also leaves the seeded figure alone for transcripts recorded before the backend
|
||||
// sent a total at all.
|
||||
is SessionEvent.UsageDelta -> totalTokens = maxOf(totalTokens, event.total)
|
||||
else -> {
|
||||
// What the session says it is set to now, which is the only thing that
|
||||
// says it: picking from either menu asks, and the answer comes back here.
|
||||
@@ -839,15 +848,18 @@ fun SessionScreen(
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||
// Machine first, then what runs on it -- the same order and the same wording
|
||||
// everywhere this pair appears, so it reads as one fact rather than as two
|
||||
// sentences with different grammar. The "on" that used to sit in the middle
|
||||
// made it a phrase, which only works in one order and stops working the moment
|
||||
// the pair is shown anywhere else.
|
||||
//
|
||||
// No model. The picker in the footer already shows what this session is set to,
|
||||
// and showing it twice means two things to keep in step -- they disagreed for a
|
||||
// moment on every model change, since one follows the request and the other the
|
||||
// session's own answer.
|
||||
Text(
|
||||
listOfNotNull(
|
||||
summary.provider,
|
||||
"on ${summary.setupName}",
|
||||
// What the session says it is set to now, which is the same fact
|
||||
// the picker below shows and has to be the same answer.
|
||||
model?.let { modelLabel(it) },
|
||||
)
|
||||
.joinToString(" · "),
|
||||
"${summary.setupName} · ${summary.provider}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -1344,16 +1356,25 @@ private fun SessionStatusRow(
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
else -> {
|
||||
if (status == "exited") {
|
||||
Text(
|
||||
"exited",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
// Every remaining state says which one it is, including the quiet one. The row used
|
||||
// to name only `exited` and leave the rest blank, so a session sitting idle and one
|
||||
// whose status nobody could read looked identical -- and a turn that had just been
|
||||
// stopped showed nothing at all, which reads as the app having lost the session
|
||||
// rather than as the stop having worked. The words are the session list's own, so
|
||||
// one state is not called two things depending which screen you are on.
|
||||
else ->
|
||||
Text(
|
||||
when (status) {
|
||||
"idle" -> "idle"
|
||||
"exited" -> "exited"
|
||||
"awaitingInput" -> "your turn"
|
||||
"unknown" -> "can't tell"
|
||||
else -> status
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
// Nothing rather than "0 tok" before anything has been spent: a total of zero is a fact
|
||||
// about a conversation that has not started, and it is the one reading nobody needs.
|
||||
|
||||
@@ -15,8 +15,10 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -52,6 +54,43 @@ fun SessionSettingsScreen(
|
||||
var name by remember(session.id) { mutableStateOf(session.title) }
|
||||
var saving by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
// Null until the server has been asked. The row this screen was opened from is a snapshot of
|
||||
// whenever the list was last fetched, so drawing the switch straight from it would show a
|
||||
// position that may have been changed since -- from here or from another device -- with
|
||||
// nothing to say so. Until the answer arrives the switch is disabled and the caption says it
|
||||
// is being read, which is the one honest thing a two-position control can do about not
|
||||
// knowing.
|
||||
var notify by remember(session.id) { mutableStateOf<Boolean?>(null) }
|
||||
var notifyError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(session.id) {
|
||||
notify =
|
||||
try {
|
||||
withContext(Dispatchers.IO) { fetchSession(settings, session.id).notify }
|
||||
} catch (e: ApiException) {
|
||||
// Left unknown rather than falling back to the stale row: the switch stays
|
||||
// disabled and says why, instead of offering a position nothing confirmed.
|
||||
notifyError = e.message
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// Moved optimistically so the switch answers the finger that moved it, and put back if the
|
||||
// request is refused -- a switch that waits for a round trip reads as broken on a slow
|
||||
// tunnel, and one that stays moved after a refusal lies.
|
||||
fun setNotify(wanted: Boolean) {
|
||||
val was = notify
|
||||
notify = wanted
|
||||
notifyError = null
|
||||
scope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) { setSessionNotify(settings, session.id, wanted) }
|
||||
} catch (e: ApiException) {
|
||||
notify = was
|
||||
notifyError = e.message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing to do when the name has not changed, so the button says so rather than sending a
|
||||
// request whose success would look exactly like the failure of having typed nothing.
|
||||
@@ -106,7 +145,40 @@ fun SessionSettingsScreen(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
// The switch gets its own row rather than sitting beside the label: a control is taller
|
||||
// than a line of text, so putting one in a row with a label re-centres that label and
|
||||
// knocks it out of line with everything above it.
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Notify me", modifier = Modifier.weight(1f))
|
||||
Switch(
|
||||
checked = notify == true,
|
||||
onCheckedChange = { setNotify(it) },
|
||||
enabled = notify != null,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
if (notify == null && notifyError == null) "Reading the current setting..."
|
||||
else
|
||||
"A notification when this session asks you something or finishes a turn. Kept " +
|
||||
"on the backend, so every device agrees about which sessions are worth " +
|
||||
"interrupting you for.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
notifyError?.let {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
// Beside the switch that failed, not with the rename's error: they are two requests
|
||||
// and a reader has to be able to tell which one the server refused.
|
||||
Text(
|
||||
it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
// Disabled rather than absent while there is nothing to save: a button that comes and
|
||||
// goes makes its own presence the signal, and its absence cannot say why.
|
||||
Button(onClick = { save() }, enabled = changed && !saving) {
|
||||
|
||||
@@ -167,16 +167,7 @@ fun quotaColor(percent: Double): Color =
|
||||
private const val WARNING_PERCENT = 75.0
|
||||
|
||||
/** Close enough that the next turn may be the one that is refused. */
|
||||
private const val OVER_LIMIT_PERCENT = 95.0
|
||||
|
||||
/**
|
||||
* Code: a fenced block, an inline span, a tool's input.
|
||||
*
|
||||
* Green because on this palette it is what a literal is coloured as, and because code sits on
|
||||
* Surface 0 where the ordinary text colour would say nothing about it being code.
|
||||
*/
|
||||
val codeColor: Color
|
||||
@Composable get() = Mocha.Green
|
||||
private const val OVER_LIMIT_PERCENT = 90.0
|
||||
|
||||
/**
|
||||
* Catppuccin Mocha as a syntax theme, for the highlighter used on a tool call's input.
|
||||
|
||||
Reference in new issue
Block a user