The other half of EXPLORER.md: a folder button on the session header opens the machine's filesystem, starting where the session works. It draws **over** the session in the same `Box`, so the session under it stays composed -- its event stream keeps flowing, its draft and scroll position stay where they were, and coming back from a file costs nothing. Back steps one level inside it (editor, viewer, directory, parent) and only closes from where it opened; the platform gesture, the button and the swipe all go through the one function, so they cannot mean different things. The viewer is a `LazyColumn` of lines rather than one `Text`, because text layout is linear in the text and a twenty-thousand-line file in a single `Text` measures all of it to draw a screenful. Lines do not wrap and share one horizontal scroll, so a logical line is a visual line and the gutter cannot come to number the wrong text; the gutter's width is measured from the digit count of the line count in the style it is drawn in. The editor is a `BasicTextField` with a `VisualTransformation` carrying the scanner's spans, which is the one Compose API that colours a field's own text rather than replacing the field. `fileLanguage` reads the same table `fenceLanguage` does, so a language added for fences is a language added for files. A file that changed on the machine while it was open here refuses to be overwritten and asks, with what each of the three answers costs. That is the ordinary case, not the exotic one: an agent editing the file somebody is reading is what this whole feature is for. The speedometer moves off the header into the session settings dialog, where the session's other about-the-session controls are, and the folder takes a place between the usage chart and the cog -- widest scope to narrowest, cog at the end, as Iris asked. Both benchmark scripts move onto `ui-trace`'s new tap-by-label action in the same change, so the render report is never unavailable and never pressed at a coordinate that has stopped meaning anything; `app/bench-lib.sh` is what they share, and `grep -n "tap [0-9]" app/*.sh` is the check. Exercised on the emulator against the sandbox's new fixture tree, with a screenshot or a ui-trace for each: the listing (dotfiles, directories first, a symlink to a directory sorted with them, a name with a tab in it), a highlighted file, binary, too big, a permission error, editing and saving, the 409 and its Overwrite, back with unsaved edits, creating a name that exists, creating one that does not and landing in the editor, an empty directory, and `..` above the directory the session opened in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
292 lines
13 KiB
Kotlin
292 lines
13 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" -- and a control belongs
|
|
* with the thing it acts on.
|
|
*
|
|
* Nothing here is captioned. Each control is a labelled noun with a switch or a field beside it,
|
|
* and a paragraph under every one of them made the dialog longer than the conversation it covers.
|
|
* Failures still get their words: those are what the reader cannot work out by looking.
|
|
*/
|
|
@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,
|
|
onDismiss: () -> Unit,
|
|
/**
|
|
* Copies what this session costs to draw. Built by the session screen, because everything it
|
|
* measures is that screen's own state -- see `copyRenderReport` there.
|
|
*/
|
|
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 -- from here or from another device -- with
|
|
// nothing to say so. Until the answer arrives the switch is disabled and a spinner sits beside
|
|
// it, which is what not knowing looks like: distinguishable from off, and from a refusal.
|
|
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: the row this dialog opened over is a snapshot, and a path drawn from it
|
|
// could be one somebody changed from another device. 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 two 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 -- the session behind it shows nothing about it.
|
|
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 there is no changing one
|
|
// under a running session -- 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,
|
|
)
|
|
}
|
|
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") } },
|
|
)
|
|
}
|