Let a session be renamed, under the same name everywhere

A gear at the end of the session's own bar opens what can be changed
about that session; the name is the first thing there. Compact is gone
from that bar -- `/compact` typed into the message box is the CLI's own
way to ask and it already worked, so the button was a second way to say
one thing. Echo takes the typed word too now, since it is the rig the
compaction display is checked against and losing the button would have
taken that with it.

The name is this server's, not a driver's: it is what the list shows, it
exists before any process does, and every provider has one. So it is
settled in the config and the driver is *told* -- which is the opposite
of the model and the permission mode, and the difference is written down
at `Driver::set_title`. A driver whose process has no notion of a name
does nothing and says nothing, because there is no failure to report.

Claude Code has one, so the name reaches it: `--name` for a session we
create, and `/rename` afterwards, which is a local command rather than a
control request -- `set_session_name` is not a subtype it knows, which I
established by asking it. A resumed session is deliberately not renamed
at launch: an import already has a name, quite possibly one the person
typing in it chose, and taking that would be helping itself to something
the app was only shown.

Verified end to end rather than argued: renaming from the phone put
"Session renamed to: paging and scroll" in the CLI's own session file,
and the session now lists under that name to other agents.

The gear is drawn rather than set in a font, for the reason Chevron
gives. It was a sun on the first attempt -- thin teeth standing clear of
a thin hub -- which no amount of reading the diff would have shown.
This commit is contained in:
iris committed 2026-08-29 16:06:15 -04:00
1 parent 1629e0911e
commit d3fff3d229
11 files changed
+395 -12

No files matched your search

@@ -513,6 +513,23 @@ fun fetchTranscript(
}
}
/**
* Renames a session.
*
* The name is the backend's own -- it is what the list shows and it exists before any process does
* -- so this settles it rather than asking. Where the thing running the session has a name of its
* own, the backend passes it on, which is what makes a session the same session in Claude Code's
* picker and to any other agent that lists it.
*/
fun renameSession(settings: ServerSettings, sessionId: String, title: String) {
requestFromServer(
settings,
"/sessions/$sessionId/title",
method = "POST",
jsonBody = JSONObject().put("title", title).toString(),
) {}
}
/** Switches a running session's model; the CLI changes it in place. */
fun setSessionModel(settings: ServerSettings, sessionId: String, model: String) {
requestFromServer(
@@ -36,6 +36,13 @@ private sealed class Screen {
*/
data class Usage(val from: SessionSummary) : Screen()
/**
* What can be changed about one session. Carries the session back with it for the same reason
* [Screen.Usage] does, and carries it *out* renamed, so the session behind it shows the new
* name without waiting for a list refresh.
*/
data class SessionSettings(val from: SessionSummary) : Screen()
data object Models : Screen()
data object Setups : Screen()
@@ -116,6 +123,7 @@ fun AppRoot(settingsVersion: Int) {
summary = here.summary,
onBack = goToList,
onUsage = { screen = Screen.Usage(here.summary) },
onSettings = { screen = Screen.SessionSettings(here.summary) },
)
is Screen.Spawn ->
SpawnScreen(
@@ -142,6 +150,18 @@ fun AppRoot(settingsVersion: Int) {
// from that session, so stepping back is the one thing Back can mean here.
onBack = { screen = Screen.Session(here.from) },
)
is Screen.SessionSettings ->
SessionSettingsScreen(
settings = current,
session = here.from,
onRenamed = { renamed ->
// The list shows the name too, so it has to refetch; and the session
// returned to is the renamed one, not the one this was opened from.
reloadToken++
screen = Screen.Session(renamed)
},
onBack = { screen = Screen.Session(here.from) },
)
is Screen.Models -> ModelsScreen(settings = current, onBack = goToList)
is Screen.Setups -> SetupsScreen(settings = current, onBack = goToList)
is Screen.Settings ->
@@ -0,0 +1,59 @@
package com.example.aiapp
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.dp
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
/**
* A gear: settings for the thing it sits beside.
*
* Drawn rather than set in a font, for the reason [Chevron] gives -- an icon glyph is one a system
* font may not have, and whoever gets the empty box instead is never the person who wrote it. The
* app has no icon set otherwise, and one dependency for one gear is a poor trade.
*
* A thick ring with eight blunt teeth cut into its edge. The proportions are the whole of whether
* this reads as a gear: drawn first with thin teeth standing clear of a thin hub, it was a sun --
* unmistakably, and only once it was looked at on a screen. What separates the two shapes is that a
* gear's teeth are as heavy as its body and barely longer than they are wide, and that its centre
* is a hole rather than a dot.
*
* It draws no label, so every caller owes it a `contentDescription`: that is all assistive
* technology has, and it is also the answer to "what was that button for" six months from now.
*/
@Composable
fun Gear(modifier: Modifier = Modifier, colour: Color = MaterialTheme.colorScheme.primary) {
Canvas(modifier.size(20.dp)) {
val centre = Offset(size.width / 2, size.height / 2)
val tooth = 4.dp.toPx()
// The teeth end at the edge, so the body has to leave room for half a tooth's width
// where they meet the ring -- otherwise the widest part of the drawing is clipped.
val tip = size.minDimension / 2
val body = tip - tooth * 0.62f
drawCircle(colour, radius = body, centre, style = Stroke(3.dp.toPx()))
repeat(TEETH) { index ->
val angle = 2 * PI * index / TEETH
val direction = Offset(cos(angle).toFloat(), sin(angle).toFloat())
drawLine(
colour,
centre + direction * (body - 1.dp.toPx()),
centre + direction * tip,
strokeWidth = tooth,
// Square-ended, because a rounded tooth on a shape this small rounds away
// most of the tooth.
cap = StrokeCap.Butt,
)
}
}
}
private const val TEETH = 8
@@ -26,6 +26,7 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
@@ -277,6 +278,7 @@ fun SessionScreen(
summary: SessionSummary,
onBack: () -> Unit,
onUsage: () -> Unit,
onSettings: () -> Unit,
) {
val scope = rememberCoroutineScope()
var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
@@ -677,16 +679,16 @@ fun SessionScreen(
// the paid service's own numbers, so a session on a provider with no such service
// gets an honest "unavailable" rather than a hidden button -- a control that comes
// and goes makes its absence the signal, and absence cannot say why.
// Beside the token count it acts on, which is the line to its left: compacting is
// what that number is for. Disabled rather than hidden while one is already running,
// so the button still says the session can do this and why it cannot right now.
TextButton(
onClick = { act { compactSession(settings, summary.id) } },
enabled = status != "compacting" && status != "exited",
) {
Text("Compact")
}
TextButton(onClick = onUsage) { Text("Usage") }
// A step down from this session, so it sits at the end of the session's own row.
// The name is the whole of what it holds today, which is why it is a gear and not a
// word: there will be more, and a bar of words has nowhere to put it.
IconButton(
onClick = onSettings,
modifier = Modifier.semantics { contentDescription = "Session settings" },
) {
Gear()
}
}
(streamError ?: actionError)?.let { message ->
@@ -0,0 +1,123 @@
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
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.
*
* A step down from the session rather than a menu over it: the name is a text field with a keyboard
* in front of it, and that is more than belongs in a bar above a conversation. Back returns to the
* session it was opened from, which is the only thing back can mean here.
*
* The name is the one setting so far. 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.
*/
@Composable
fun SessionSettingsScreen(
settings: ServerSettings,
session: SessionSummary,
onRenamed: (SessionSummary) -> Unit,
onBack: () -> Unit,
) {
val scope = rememberCoroutineScope()
var name by remember(session.id) { mutableStateOf(session.title) }
var saving by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
// 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() != session.title
fun save() {
if (!changed || saving) return
val chosen = name.trim()
saving = true
error = null
scope.launch {
try {
withContext(Dispatchers.IO) { renameSession(settings, session.id, chosen) }
onRenamed(session.copy(title = chosen))
} catch (e: ApiException) {
// Reported here, where it happened, because this screen is the only place that
// knows a rename was attempted -- the session behind it shows nothing about it.
error = e.message
saving = false
}
}
}
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
"Session settings",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onBack) { Text("Back") }
}
Spacer(Modifier.height(16.dp))
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() }),
)
Text(
"Passed on to whatever is running this session, so Claude Code's own session " +
"picker and any agent listing sessions use the same name.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
Spacer(Modifier.height(16.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) {
Text(if (saving) "Saving..." else "Save")
}
error?.let {
Spacer(Modifier.height(8.dp))
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
}
}