The same pass the server had, on the Kotlin side: comments restating what the code says are gone, and the ones recording a measurement, a constraint or an incident are kept but cut to a few lines each. 6540 comment lines to 5674, and 920 lines off the app. Two doc comments had drifted onto the item above the one they describe -- `contextAfter`'s onto `sessionWorking` in Events.kt, and `UsageMonitor`'s equivalent on the server was fixed in the previous commit. Each is back on its own item, which is the only non-comment line this diff moves. The comments are reflowed to the column limit at their own indentation: several were written wide, and ktfmt re-wrapped them into lines holding a single orphan word. `/tmp` script, not kept -- ktfmt is idempotent over the result, which is the check. Left alone deliberately: this codebase's remaining comment density is high because the comments carry things the code cannot say -- what a null means, what a number was measured against, which bug a guard exists for. Of the 238 one-line doc comments in the app, five were pure restatement of the name and were removed; the rest each say something the signature does not. ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest pass; cargo test (127), clippy --all-targets and fmt still clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
332 lines
15 KiB
Kotlin
332 lines
15 KiB
Kotlin
package com.example.aiapp
|
|
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.Row
|
|
import androidx.compose.foundation.layout.Spacer
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.foundation.layout.width
|
|
import androidx.compose.foundation.text.KeyboardActions
|
|
import androidx.compose.foundation.text.KeyboardOptions
|
|
import androidx.compose.material3.AlertDialog
|
|
import androidx.compose.material3.CircularProgressIndicator
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.OutlinedTextField
|
|
import androidx.compose.material3.Switch
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.TextButton
|
|
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
|
|
import androidx.compose.runtime.rememberCoroutineScope
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.text.input.ImeAction
|
|
import androidx.compose.ui.unit.dp
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.withContext
|
|
|
|
/**
|
|
* What can be changed about one session, as opposed to about this app.
|
|
*
|
|
* Over the session rather than a step down from it: everything here is about the conversation
|
|
* behind it, and a dialog keeps that conversation on screen while it is being adjusted. It was a
|
|
* screen of its own until 2026-08-30, which put a page transition and a back stack around two
|
|
* controls and hid the thing they act on.
|
|
*
|
|
* The model and the permission mode are deliberately still on the session's own bar, because those
|
|
* are changed *while* reading a turn -- "not this model, try that one".
|
|
*
|
|
* Captions are for what a control costs rather than for what it is. A paragraph under every control
|
|
* made the dialog longer than the conversation it covers -- so Notifications has none, while Move
|
|
* and Reload do, because what those two take away is not visible from here.
|
|
*/
|
|
@Composable
|
|
fun SessionSettingsDialog(
|
|
settings: ServerSettings,
|
|
sessionId: String,
|
|
/**
|
|
* What the session is called now, as the screen behind this knows it -- see the rename below.
|
|
*/
|
|
title: String,
|
|
onRenamed: (String) -> Unit,
|
|
/**
|
|
* What this phone is holding of the conversation, or null while that is being measured -- see
|
|
* the Reload row below, which is what would discard it.
|
|
*/
|
|
cachedBytes: Long?,
|
|
onReload: () -> Unit,
|
|
onDismiss: () -> Unit,
|
|
/**
|
|
* Copies what this session costs to draw. Built by the session screen, because everything it
|
|
* measures is that screen's own state.
|
|
*/
|
|
onCopyRenderReport: () -> Unit,
|
|
) {
|
|
val scope = rememberCoroutineScope()
|
|
var name by remember(sessionId) { mutableStateOf(title) }
|
|
var saving by remember { mutableStateOf(false) }
|
|
var error by remember { mutableStateOf<String?>(null) }
|
|
// Null until the server has been asked. The row this dialog was opened over 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. Until the answer arrives the switch is disabled
|
|
// and a spinner sits beside it, which is what not knowing looks like.
|
|
var notify by remember(sessionId) { mutableStateOf<Boolean?>(null) }
|
|
var notifyError by remember { mutableStateOf<String?>(null) }
|
|
// Where the session works. Null until the server has been asked, for the same reason the switch
|
|
// above is. An empty answer is a session that was never given a directory, which is not the
|
|
// same as one whose directory is unknown -- the field is only enabled once one of those is
|
|
// settled.
|
|
var cwd by remember(sessionId) { mutableStateOf<String?>(null) }
|
|
var typedCwd by remember(sessionId) { mutableStateOf("") }
|
|
var cwdError by remember { mutableStateOf<String?>(null) }
|
|
var movingCwd by remember { mutableStateOf(false) }
|
|
|
|
LaunchedEffect(sessionId) {
|
|
try {
|
|
val fresh = withContext(Dispatchers.IO) { fetchSession(settings, sessionId) }
|
|
notify = fresh.notify
|
|
cwd = fresh.cwd.orEmpty()
|
|
typedCwd = fresh.cwd.orEmpty()
|
|
} catch (e: ApiException) {
|
|
// Left unknown rather than falling back to the stale row: the switch stays disabled,
|
|
// instead of offering a position nothing confirmed.
|
|
notifyError = e.message
|
|
notify = null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Moves the session, which ends the process that is in the old directory.
|
|
*
|
|
* Said plainly beside the field rather than confirmed in a second dialog: what it costs is a
|
|
* process, and a stopped session is a state this app already has a word and a button for.
|
|
*/
|
|
fun moveCwd() {
|
|
val chosen = typedCwd.trim()
|
|
if (movingCwd || chosen.isEmpty() || chosen == cwd) return
|
|
movingCwd = true
|
|
cwdError = null
|
|
scope.launch {
|
|
try {
|
|
withContext(Dispatchers.IO) { setSessionCwd(settings, sessionId, chosen) }
|
|
cwd = chosen
|
|
} catch (e: ApiException) {
|
|
// Where it happened: this field is the only thing on screen that knows a move was
|
|
// asked for, and the reason is usually the path itself.
|
|
cwdError = e.message
|
|
} finally {
|
|
movingCwd = false
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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, sessionId, 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.
|
|
val changed = name.trim().isNotEmpty() && name.trim() != title
|
|
|
|
fun save() {
|
|
if (!changed || saving) return
|
|
val chosen = name.trim()
|
|
saving = true
|
|
error = null
|
|
scope.launch {
|
|
try {
|
|
withContext(Dispatchers.IO) { renameSession(settings, sessionId, chosen) }
|
|
onRenamed(chosen)
|
|
} catch (e: ApiException) {
|
|
// Reported here, where it happened, because this dialog is the only place that
|
|
// knows a rename was attempted.
|
|
error = e.message
|
|
saving = false
|
|
}
|
|
}
|
|
}
|
|
|
|
AlertDialog(
|
|
onDismissRequest = onDismiss,
|
|
title = { Text("Session settings") },
|
|
text = {
|
|
Column {
|
|
OutlinedTextField(
|
|
value = name,
|
|
onValueChange = { name = it },
|
|
label = { Text("Name") },
|
|
singleLine = true,
|
|
enabled = !saving,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
// The keyboard's own action does what the button does: a one-field form where
|
|
// the return key does nothing is a form people press return at anyway.
|
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
|
keyboardActions = KeyboardActions(onDone = { save() }),
|
|
)
|
|
Spacer(Modifier.height(8.dp))
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
Glyph(BELL_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
|
|
Spacer(Modifier.width(8.dp))
|
|
Text("Notifications", modifier = Modifier.weight(1f))
|
|
if (notify == null && notifyError == null) {
|
|
CircularProgressIndicator(
|
|
modifier = Modifier.width(16.dp).height(16.dp),
|
|
strokeWidth = 2.dp,
|
|
)
|
|
Spacer(Modifier.width(8.dp))
|
|
}
|
|
Switch(
|
|
checked = notify == true,
|
|
onCheckedChange = { setNotify(it) },
|
|
enabled = notify != null,
|
|
)
|
|
}
|
|
// 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.
|
|
notifyError?.let {
|
|
Text(
|
|
it,
|
|
color = MaterialTheme.colorScheme.error,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
)
|
|
}
|
|
Spacer(Modifier.height(8.dp))
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
OutlinedTextField(
|
|
value = typedCwd,
|
|
onValueChange = { typedCwd = it },
|
|
label = { Text("Working directory") },
|
|
// What the field cannot say by being empty: a session that was never given
|
|
// one starts wherever its launcher does, and this names that rather than
|
|
// showing a path nobody chose.
|
|
placeholder = { Text("wherever the session was started") },
|
|
singleLine = true,
|
|
enabled = cwd != null && !movingCwd,
|
|
modifier = Modifier.weight(1f),
|
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
|
keyboardActions = KeyboardActions(onDone = { moveCwd() }),
|
|
)
|
|
TextButton(
|
|
onClick = { moveCwd() },
|
|
enabled =
|
|
cwd != null &&
|
|
!movingCwd &&
|
|
typedCwd.trim().isNotEmpty() &&
|
|
typedCwd.trim() != cwd,
|
|
) {
|
|
Text(if (movingCwd) "Moving..." else "Move")
|
|
}
|
|
}
|
|
// The whole of what pressing Move does, where it is about to be pressed. A
|
|
// directory is settled when the process is spawned, so it is ended and the next
|
|
// thing said to the session starts it in the new place.
|
|
Text(
|
|
"Moving stops the session's process. It starts again in the new directory " +
|
|
"with the next message, or with Start.",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
cwdError?.let {
|
|
Text(
|
|
it,
|
|
color = MaterialTheme.colorScheme.error,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
)
|
|
}
|
|
Spacer(Modifier.height(8.dp))
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
Text("Transcript", modifier = Modifier.weight(1f))
|
|
// The size is what the button discards, and the unknown state is drawn rather
|
|
// than guessed: a spinner while the directory is being measured, and words when
|
|
// there is nothing there, because "nothing cached" and "0 B" read as different
|
|
// claims.
|
|
when {
|
|
cachedBytes == null ->
|
|
CircularProgressIndicator(
|
|
modifier = Modifier.width(16.dp).height(16.dp),
|
|
strokeWidth = 2.dp,
|
|
)
|
|
else ->
|
|
Text(
|
|
humanSize(cachedBytes)?.let { "$it cached" } ?: "nothing cached",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
Spacer(Modifier.width(12.dp))
|
|
// Enabled whether or not anything is cached: "what I see disagrees with the
|
|
// machine" is a state an empty cache can be in too, and a control that comes
|
|
// and goes makes its own presence the signal.
|
|
TextButton(onClick = onReload) { Text("Reload") }
|
|
}
|
|
// Captioned, unlike the controls above it, for the same reason Move is: what it
|
|
// costs is not visible, and neither is the case it exists for.
|
|
Text(
|
|
"Reload throws away this phone's copy and fetches the transcript from the " +
|
|
"server again. Use it when what is shown here disagrees with the file " +
|
|
"on the machine.",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
error?.let {
|
|
Spacer(Modifier.height(8.dp))
|
|
Text(
|
|
it,
|
|
color = MaterialTheme.colorScheme.error,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
)
|
|
}
|
|
Spacer(Modifier.height(8.dp))
|
|
// About this session, which is what everything in here is -- and it was on the
|
|
// header until 2026-09-03, where the folder button now is. It copies rather than
|
|
// opening anything, so it says so and then says it happened: a row that looks like
|
|
// a control and gives no sign of having run is one people press twice.
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
Glyph(SPEED_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
|
|
Spacer(Modifier.width(8.dp))
|
|
Text("Render timings", modifier = Modifier.weight(1f))
|
|
TextButton(onClick = onCopyRenderReport) { Text("Copy") }
|
|
}
|
|
}
|
|
},
|
|
// 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.
|
|
confirmButton = {
|
|
TextButton(onClick = { save() }, enabled = changed && !saving) {
|
|
Text(if (saving) "Saving..." else "Save")
|
|
}
|
|
},
|
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Close") } },
|
|
)
|
|
}
|