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(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(null) } var notifyError by remember { mutableStateOf(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(null) } var typedCwd by remember(sessionId) { mutableStateOf("") } var cwdError by remember { mutableStateOf(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") } }, ) }