Stop and start a session's process from the composer
The composer's second button now says what pressing it would do to the process behind the session, in one place that is always there: an orange pause while a turn is running (interrupt, the process stays), a red stop when it is not (end the process), a green play when it has exited (start it again on the same conversation). Send is disabled while there is nothing to send, rather than pressable and silent. Behind it, two routes. `stop` signals the recorded process and says nothing else -- the driver's own reader already reports a death correctly, and announcing it here would be a guess ahead of the measurement. `start` replaces the driver and nothing else, so the transcript, the pump and every open phone's stream stay where they were and there is still one writer of the transcript; it is refused unless the session is known to have exited, since starting on `Unknown` is the two-CLIs-on-one-conversation fault. That last rule found a bug in the launch path: a relaunched session took its status from the transcript, so one whose process had died before a backend restart reported `exited` while the launch had just started a new process -- which refuses every command and offers a phone the chance to start a second CLI on a live conversation. A launch that leaves a process running now says idle. The icon font moves to the Mono face, where every glyph is one em square, so two icon buttons are the same width without either being told one; the proportional advances ran 0.46 to 0.92 em and Send came out visibly wider than Stop. GLYPH_SIZE comes down to match, since a glyph that fills its em draws bigger at the same point size. Verified against a stand-in CLI on the emulator: idle -> stop -> exited -> start -> idle, a turn interrupted from the pause button, and both buttons measured at 171x105 device pixels. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
317ad29d85
commit
6154cb1949
13 files changed
+562
-79
No files matched your search
@@ -558,6 +558,22 @@ fun interruptSession(settings: ServerSettings, sessionId: String) {
|
||||
requestFromServer(settings, "/sessions/$sessionId/interrupt", method = "POST") {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the process behind a session, leaving the session and its transcript.
|
||||
*
|
||||
* Not a delete and not an interrupt: the conversation stays exactly where it is and [startSession]
|
||||
* picks it back up. The server reports what it could not do -- there was nothing running, or the
|
||||
* machine would not say whether there was -- rather than answering the same way either way.
|
||||
*/
|
||||
fun stopSession(settings: ServerSettings, sessionId: String) {
|
||||
requestFromServer(settings, "/sessions/$sessionId/stop", method = "POST") {}
|
||||
}
|
||||
|
||||
/** Starts the process again on the conversation it left. See [stopSession]. */
|
||||
fun startSession(settings: ServerSettings, sessionId: String) {
|
||||
requestFromServer(settings, "/sessions/$sessionId/start", method = "POST") {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a Claude Code session from the machine.
|
||||
*
|
||||
|
||||
@@ -31,6 +31,12 @@ import androidx.compose.ui.unit.sp
|
||||
* tofu. Adding one means adding its codepoint in *both* places; a codepoint here that the script
|
||||
* did not subset is a glyph that silently isn't there.
|
||||
*
|
||||
* The subset is the font's **Mono** face, where every glyph is exactly one em wide and one em tall.
|
||||
* That is what makes two icons the same size without either of them being given a size: the
|
||||
* proportional face's advances run from 0.46 em to 0.92 em, so a Send button and a Stop button side
|
||||
* by side came out visibly different widths, and matching them at the call site would have meant
|
||||
* one hardcoded measurement per pair. [GLYPH_SIZE] carries the cost.
|
||||
*
|
||||
* The same arrangement as dev-updater, down to the cog and the refresh arrow being the same two
|
||||
* Material Design codepoints. Those two must not drift: an icon that means "settings" in one app
|
||||
* and something else in the other is the failure this is worth preventing. The script is copied
|
||||
@@ -52,9 +58,27 @@ val REFRESH_GLYPH = glyph(0xF0450)
|
||||
/** `md-send` -- the filled paper plane: submit what is in the composer. */
|
||||
val SEND_GLYPH = glyph(0xF048A)
|
||||
|
||||
/** `md-stop` -- a filled square: interrupt the turn that is running. */
|
||||
/**
|
||||
* `md-stop` -- a filled square: end the process behind this session.
|
||||
*
|
||||
* The square is what stop has meant since tape decks, and it is spent here on the thing that
|
||||
* actually stops rather than on pausing. [PAUSE_GLYPH] is the turn; this is the session.
|
||||
*/
|
||||
val STOP_GLYPH = glyph(0xF04DB)
|
||||
|
||||
/**
|
||||
* `md-pause` -- two bars: take the running turn away and leave the session there.
|
||||
*
|
||||
* The pair with [STOP_GLYPH] and [PLAY_GLYPH] is the point: one button in the composer says what
|
||||
* pressing it now would do to the process, and the three marks are the three answers. An interrupt
|
||||
* ends a turn and nothing else -- the CLI is still there and still holds the conversation -- which
|
||||
* is a pause, not a stop, and drawing it as a square said otherwise.
|
||||
*/
|
||||
val PAUSE_GLYPH = glyph(0xF03E4)
|
||||
|
||||
/** `md-play` -- start the process again, on the conversation it left. See [PAUSE_GLYPH]. */
|
||||
val PLAY_GLYPH = glyph(0xF040A)
|
||||
|
||||
/**
|
||||
* `md-send_clock` -- the same paper plane with a clock on it: this message will wait its turn.
|
||||
*
|
||||
@@ -142,5 +166,13 @@ fun Glyph(
|
||||
Text(glyph, fontFamily = NerdIcons, fontSize = size, color = colour, modifier = modifier)
|
||||
}
|
||||
|
||||
/** The size an icon draws at beside a line of text. */
|
||||
private val GLYPH_SIZE = 20.sp
|
||||
/**
|
||||
* The size an icon draws at beside a line of text.
|
||||
*
|
||||
* 17 rather than the 20 it was while the font was the proportional face. A glyph there filled at
|
||||
* most 0.83 em of its point size and most filled a good deal less, so the number was standing in
|
||||
* for the headroom above the tallest one; in the Mono face every glyph fills its em exactly, and
|
||||
* keeping 20 would have made every icon in the app step up by a fifth for no reason anybody asked
|
||||
* for. This is what the largest of them already drew at.
|
||||
*/
|
||||
private val GLYPH_SIZE = 17.sp
|
||||
@@ -1358,30 +1358,44 @@ fun SessionScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
if (running) {
|
||||
// The same filled shape as the button beside it, not an outlined one: these
|
||||
// are two things you can do about the turn that is running, and weighting one
|
||||
// of them as secondary said they were a primary action and its qualifier.
|
||||
// What separates them is the colour and the mark, which is what they mean.
|
||||
Button(
|
||||
onClick = { act { interruptSession(settings, summary.id) } },
|
||||
colors = actionButtonColors(stopColor),
|
||||
) {
|
||||
// A filled square, which is what stop has looked like since tape decks.
|
||||
Glyph(
|
||||
STOP_GLYPH,
|
||||
colour = LocalContentColor.current,
|
||||
modifier = Modifier.semantics { contentDescription = "Stop" },
|
||||
)
|
||||
// The same filled shape as the button beside it, not an outlined one: these are
|
||||
// two things you can do about the session, and weighting one of them as secondary
|
||||
// said they were a primary action and its qualifier. What separates them is the
|
||||
// colour and the mark, which is what they mean.
|
||||
//
|
||||
// Always here, rather than arriving with the turn as it used to. A control that
|
||||
// comes and goes makes its own presence the signal, and its absence could not say
|
||||
// whether there was nothing to do; a button that is always in the same place also
|
||||
// cannot push Send off the end of the row by turning up.
|
||||
val process =
|
||||
when {
|
||||
running -> ProcessAction.Pause
|
||||
status == "exited" -> ProcessAction.Start
|
||||
else -> ProcessAction.Stop
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = { act { process.perform(settings, summary.id) } },
|
||||
colors = actionButtonColors(process.colour()),
|
||||
) {
|
||||
Glyph(
|
||||
process.glyph,
|
||||
colour = LocalContentColor.current,
|
||||
modifier = Modifier.semantics { contentDescription = process.label },
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
// The paper plane, with a clock on it while a turn is in flight: sending then
|
||||
// queues the message for the next tool boundary rather than starting a turn of
|
||||
// its own, and the two have to be told apart at a glance. The label says the same
|
||||
// thing to a screen reader, which has nothing else to read.
|
||||
//
|
||||
// Disabled while there is nothing to send, rather than pressable and silent:
|
||||
// `send` has always returned early on an empty composer, so the button promised
|
||||
// something it would not do, and the only feedback was the ripple. Disabled and
|
||||
// not hidden, for the reason the button beside it is always here.
|
||||
Button(
|
||||
onClick = { send() },
|
||||
enabled = input.isNotBlank() || pendingAttachments.isNotEmpty(),
|
||||
colors = actionButtonColors(if (running) queueColor else sendColor),
|
||||
) {
|
||||
Glyph(
|
||||
@@ -1402,6 +1416,39 @@ fun SessionScreen(
|
||||
/** What pressing Send does right now, said the same way to the eye and to a screen reader. */
|
||||
private fun sendLabel(running: Boolean) = if (running) "Queue" else "Send"
|
||||
|
||||
/**
|
||||
* What the composer's process button would do if it were pressed now.
|
||||
*
|
||||
* One value rather than four parallel conditions over the status, because the mark, the colour, the
|
||||
* name a screen reader is given and the request that goes out are four halves of one decision. A
|
||||
* button drawn as a pause that terminates the CLI is the worst bug available here, and separate
|
||||
* branches over the same condition are how that happens -- these three each have to cover every
|
||||
* case, and the compiler says so.
|
||||
*/
|
||||
private enum class ProcessAction(val glyph: String, val label: String) {
|
||||
/** A turn is running: take it back, and leave the process holding the conversation. */
|
||||
Pause(PAUSE_GLYPH, "Pause"),
|
||||
/** Nothing is running, but the process behind the session is: end it. */
|
||||
Stop(STOP_GLYPH, "Stop"),
|
||||
/** The process is gone: start it again, on the conversation it left. */
|
||||
Start(PLAY_GLYPH, "Start"),
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProcessAction.colour() =
|
||||
when (this) {
|
||||
ProcessAction.Pause -> pauseColor
|
||||
ProcessAction.Stop -> stopColor
|
||||
ProcessAction.Start -> startColor
|
||||
}
|
||||
|
||||
private fun ProcessAction.perform(settings: ServerSettings, sessionId: String) =
|
||||
when (this) {
|
||||
ProcessAction.Pause -> interruptSession(settings, sessionId)
|
||||
ProcessAction.Stop -> stopSession(settings, sessionId)
|
||||
ProcessAction.Start -> startSession(settings, sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* An inline transcript image, fetched (authenticated, pinned) from the session's files route. The
|
||||
* bitmap is remembered per ref, so scrolling doesn't refetch.
|
||||
|
||||
@@ -213,13 +213,14 @@ val overLimitColor: Color
|
||||
@Composable get() = MaterialTheme.colorScheme.error
|
||||
|
||||
/**
|
||||
* The composer's three buttons, coloured by what pressing one does rather than by where it sits.
|
||||
* The composer's buttons, coloured by what pressing one does rather than by where it sits.
|
||||
*
|
||||
* Green sends now, blue sends later, red takes the running turn away. The pair of greens and the
|
||||
* pair of reds elsewhere in this file are deliberate near-collisions worth naming: [runningColor]
|
||||
* is green because a session is working, and [failedColor] is red because one fell over -- those
|
||||
* are *states*, and these are *actions*. A reader never has to tell them apart, because nothing
|
||||
* here is a state and nothing there is pressable.
|
||||
* Green makes something happen now, blue makes it happen later, orange takes back what is in
|
||||
* flight, red ends the process. The near-collisions with the states above are deliberate and worth
|
||||
* naming rather than collapsing: [runningColor] is green because a session is working,
|
||||
* [failedColor] is red because one fell over, [awaitingColor] is the same orange because a session
|
||||
* is waiting on somebody -- those are *states*, and these are *actions*. A reader never has to tell
|
||||
* them apart, because nothing here is a state and nothing there is pressable.
|
||||
*/
|
||||
val sendColor: Color
|
||||
@Composable get() = Mocha.Green
|
||||
@@ -228,10 +229,30 @@ val sendColor: Color
|
||||
val queueColor: Color
|
||||
@Composable get() = Mocha.Blue
|
||||
|
||||
/** Interrupting the running turn -- the one button here that takes something away. */
|
||||
/**
|
||||
* Interrupting the running turn: the work stops and the session stays.
|
||||
*
|
||||
* Orange rather than red because of how much it takes: only what is in flight. The process is still
|
||||
* there holding the conversation, and the next message starts a turn as though nothing had
|
||||
* happened. Red is spent on [stopColor], which is the same button in the same place when what it
|
||||
* would end is the session's process.
|
||||
*/
|
||||
val pauseColor: Color
|
||||
@Composable get() = Mocha.Peach
|
||||
|
||||
/** Ending the session's process -- the one button here that takes something away. */
|
||||
val stopColor: Color
|
||||
@Composable get() = Mocha.Red
|
||||
|
||||
/**
|
||||
* Starting the process again, on the conversation it left.
|
||||
*
|
||||
* The same green as [sendColor] on purpose: both mean "this happens now", and they are never the
|
||||
* same button -- the process button only offers to start when there is nothing running to stop.
|
||||
*/
|
||||
val startColor: Color
|
||||
@Composable get() = Mocha.Green
|
||||
|
||||
/**
|
||||
* A filled button in one of the action colours above.
|
||||
*
|
||||
|
||||
Binary file not shown.
Reference in new issue
Block a user