Merge branch 'main' of git.arirex.me:iris/ai-app

This commit is contained in:
iris committed 2026-08-30 13:25:00 -04:00
commit 1ed6e29bc6
14 files changed
+637 -180

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
@@ -1478,30 +1478,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(
@@ -1522,6 +1536,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.