`POST /sessions/{id}/cwd`, behind a field in the session settings dialog. A
working directory is settled when the process is spawned -- the CLI is
launched with it as its cwd and there is no control request that changes one
-- so this records the new one and ends the process that is in the old one.
It does not start a replacement, and the field says so in a line beside it:
a session with no process starts on the next message or on Start, which is
this app's rule for that everywhere else, and "usually restarts" is a worse
control than "always stops".
The path is checked against the session's own machine and refused if it is
not there. The spawn path corrects instead of refusing, because it is
resuming a directory the *machine* recorded and that can be gone through
nobody's fault; a path somebody has just typed is different, and a mistyped
one accepted here would surface much later as a session that would not
start, with nothing pointing at the typo. The refusal names the machine and
the path, and is drawn under the field it is about.
Nothing of Claude Code's own is moved, and that is measured rather than
assumed: on CLI 2.1.237, `claude --resume <id>` finds a session from any
working directory -- an id that does not exist answers "No conversation
found with session ID", and a real one resumed from an unrelated directory
did not. So the conversation continues in the new place with nothing
relocated. Doing otherwise would mean reproducing a rule this app cannot
see the whole of; PLAN.md records what that rule is, for whoever tries.
Found while checking it: `SessionInfo.cwd` came from the snapshot a session
launched with, so a moved session went on reporting its *old* directory for
as long as its process lived -- a dialog showing a directory the next launch
would not use, with nothing saying so. It is read from the config where the
row is built now, the same way `setup_name` already was, and for the reason
already written above `setup_name`: only the manager holds the config, and
both of these change under a running session.
Checked end to end on the emulator against a session whose process really
does take a cwd: /proc said /tmp/cwd-a before and /tmp/cwd-b after, the
dialog showed the new path immediately rather than after a restart, and a
directory that is not there and a relative path were both refused with the
session left exactly as it was.
273 lines
12 KiB
Kotlin
273 lines
12 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,
|
|
) {
|
|
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,
|
|
)
|
|
}
|
|
}
|
|
},
|
|
// 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") } },
|
|
)
|
|
}
|