Show a session's subagents as subcards, each with a read-only transcript

A subagent is a second transcript owned by a session, in the same event
model, with no process and no controls. The claude translator routes lines
carrying parent_tool_use_id to a per-subagent translator and transcript
under <session>/subagents/<tool_use_id>; three routes expose the list, a
transcript page and the SSE stream. Echo grows /subagent [n] as the rig.

On the phone a card with subagents ends in a chevron expander, collapsed by
default, opening to outlined subcards styled like dev-updater's components;
a subcard opens SessionScreen in read-only form, addressed through
TranscriptAddress so paging, cache and stream are shared.

Design in SUBAGENTS.md; choices awaiting review in DECISIONS.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-05 13:41:15 -04:00
1 parent eff5c8b0c0
commit 9fa09b0af1
21 files changed
+1953 -332

No files matched your search

@@ -216,16 +216,33 @@ fun SessionScreen(
share: ShareRequest? = null,
/** Said once [share] has been attached here, so it is not attached again. */
onShareTaken: () -> Unit = {},
/**
* Draws this screen read-only, on a subagent's own transcript instead of the session's.
*
* A subagent has no process and no controls of its own -- see SUBAGENTS.md's "Phone" -- so
* every gate below keyed on this switches off the composer, the files button, the settings cog,
* the usage bar and notifications, while everything that draws a transcript (paging, cache,
* selection, images, the status row, stream reconnects) is reused unchanged, pointed at
* [address] instead of the session's own.
*/
subagent: SubagentSummary? = null,
) {
DebugStats.count("session screen recomposed")
val isSubagent = subagent != null
val address = TranscriptAddress(summary.id, subagent?.id)
val scope = rememberCoroutineScope()
val topEdgeHeld = remember { TopEdgeHold() }
var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
var status by remember { mutableStateOf(summary.status) }
var status by remember { mutableStateOf(subagent?.status ?: summary.status) }
// Seeded from the row this screen was opened from, so a conversation already under way says how
// much it is holding before any turn happens here. Null is "nobody has measured it", which is a
// different answer from an empty context and is drawn differently.
var contextTokens by remember(summary.id) { mutableStateOf(summary.contextTokens) }
//
// A subagent has no context measurement of its own, so it always starts unmeasured rather than
// borrowing the parent session's figure -- see UI_RULES on not showing an inferred value as one
// that was measured.
var contextTokens by
remember(address) { mutableStateOf(if (isSubagent) null else summary.contextTokens) }
// When the current compaction started. The moment comes off the `compacting` status event
// itself -- the server timestamps every transcript line -- rather than off this device noticing
// one, which is what makes it survive leaving the session and reopening it.
@@ -241,7 +258,13 @@ fun SessionScreen(
val context = LocalContext.current
// Seeded from what was left in the box last time and written back on every keystroke, so
// leaving the screen does not throw away a half-typed message. See `Drafts.kt`.
var input by remember(summary.id) { mutableStateOf(atEnd(loadDraft(context, summary.id))) }
//
// A subagent has no box to type into, so it never touches a draft at all -- not this session's,
// which is what reading one keyed only by `summary.id` would do here.
var input by
remember(summary.id) {
mutableStateOf(if (isSubagent) atEnd("") else atEnd(loadDraft(context, summary.id)))
}
// A model the reader has chosen and not yet confirmed. See [ModelSwitchWarning]: switching
// makes the session re-read the whole conversation.
var pendingModel by remember { mutableStateOf<String?>(null) }
@@ -294,27 +317,26 @@ fun SessionScreen(
// Reload throws away what it was reading from.
val cache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
val source =
remember(summary.id, epoch) {
TranscriptSource(settings, summary.id, cache.session(summary.id))
}
remember(address, epoch) { TranscriptSource(settings, address, cache.session(address)) }
// Whether the cached tail has been shown to still be the server's own line. Nothing is resumed
// from a cached cursor until it has, and a probe that could not be made leaves this false for
// the stream loop to try again.
var probePassed by remember(summary.id, epoch) { mutableStateOf(false) }
var probePassed by remember(address, epoch) { mutableStateOf(false) }
// Whether the opening effect is still settling that question. It draws the cached rows and
// lifts [ready] before the answer arrives, which is the point of the cache -- so the stream
// below waits for this rather than for `ready`, or it asks the same question twice.
var probing by remember(summary.id, epoch) { mutableStateOf(true) }
var probing by remember(address, epoch) { mutableStateOf(true) }
// The oldest sequence number loaded, and whether there is more behind it. Paging backwards is
// what keeps opening a long session cheap.
var oldestSeq by remember { mutableLongStateOf(0L) }
// Where this session was last being read, from this device's own store. Read once, because the
// answer stops being interesting the moment the list is on screen.
val savedAnchor = remember(summary.id, epoch) { loadScrollAnchor(context, summary.id) }
// Where this transcript was last being read, from this device's own store, keyed by the address
// rather than the session id so a subagent's saved position cannot collide with its session's.
// Read once, because the answer stops being interesting the moment the list is on screen.
val savedAnchor = remember(address, epoch) { loadScrollAnchor(context, address.cachePath) }
// Whether the saved position is still being put back. Nothing is drawn while it is: opening at
// the newest end and then travelling to the anchor is exactly the journey a reader must never
// see.
var restoring by remember(summary.id, epoch) { mutableStateOf(savedAnchor != null) }
var restoring by remember(address, epoch) { mutableStateOf(savedAnchor != null) }
// Messages the server has taken and the session has not read yet, by the id that will resolve
// them. From the event stream rather than from what this screen sent, so they survive leaving
// the session -- and a message sent from another device is drawn waiting on this one too.
@@ -327,11 +349,11 @@ fun SessionScreen(
var loadingHistory by remember { mutableStateOf(false) }
var ready by remember { mutableStateOf(false) }
// Replies parsed ahead of the rows that draw them; see [ParsedReplies].
val replies = remember(summary.id) { ParsedReplies() }
// Keyed like everything else describing one session's transcript. `rememberLazyListState` saves
// through `rememberSaveable`, and this screen restores by its own anchor instead -- two
// restores would fight over the first frame.
val listState = remember(summary.id) { LazyListState() }
val replies = remember(address) { ParsedReplies() }
// Keyed like everything else describing one transcript. `rememberLazyListState` saves through
// `rememberSaveable`, and this screen restores by its own anchor instead -- two restores would
// fight over the first frame.
val listState = remember(address) { LazyListState() }
// Whether the newest message is on screen right now. The list is reversed, so the newest end is
// the scrolling start: nothing behind you is exactly being at the bottom. Asked of the scroll
// state rather than of item indices, because a zero-height first item makes an index ambiguous.
@@ -637,7 +659,7 @@ fun SessionScreen(
// ended and carries live events only. The window comes from this phone's own copy when there is
// one, and then costs a single request to check that the server's transcript is still the one
// it came from. See TRANSCRIPT_CACHE.md.
LaunchedEffect(summary.id, epoch) {
LaunchedEffect(address, epoch) {
/**
* One opening window onto the screen, whichever side it came from.
*
@@ -667,11 +689,16 @@ fun SessionScreen(
// A replay is as old as the last visit; the row this screen was opened from was
// fetched moments ago. So the transcript comes from the cache and everything that
// is not the transcript comes from the summary -- otherwise a session that finished
// an hour ago opens saying "working" until the stream connects.
status = summary.status
model = summary.model
permissionMode = summary.permissionMode ?: "auto"
if (summary.status != "compacting") compactingSince = null
// an hour ago opens saying "working" until the stream connects. A subagent's status
// comes from its own summary, never the parent session's: they are two different
// things running or not, and the parent's model and permission mode do not apply to
// it at all.
status = subagent?.status ?: summary.status
if (!isSubagent) {
model = summary.model
permissionMode = summary.permissionMode ?: "auto"
}
if (status != "compacting") compactingSince = null
// Nothing to put back, so these rows are the screen and the probe can return under
// them. A restore still has history to fetch and is gated below.
if (savedAnchor == null) ready = true
@@ -798,7 +825,7 @@ fun SessionScreen(
// at the top on their return. Switching apps is a choice somebody made, not a fault to report.
// Stopping the stream deliberately makes the drop a close rather than an error, and resuming
// reconnects from the same cursor.
LaunchedEffect(summary.id, ready, epoch, lifecycleOwner) {
LaunchedEffect(address, ready, epoch, lifecycleOwner) {
if (!ready) return@LaunchedEffect
// The opening effect draws cached rows and lifts `ready` *before* it has checked that the
// cursor under them is still the server's, so `ready` is no longer the whole gate. Without
@@ -868,17 +895,22 @@ fun SessionScreen(
// The screen going away entirely, which the lifecycle scope above does not cover: a composable
// can leave the composition while the activity stays started. Keyed on the epoch as well, so
// Reload's replacement source is the one a later disposal closes.
DisposableEffect(summary.id, epoch) { onDispose { source.close() } }
DisposableEffect(address, epoch) { onDispose { source.close() } }
// Nothing gets announced about the session somebody is reading; see NotificationService.
// RESUMED rather than STARTED because "looking at it" means the foreground.
LaunchedEffect(summary.id, lifecycleOwner) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
NotificationService.showing(context, summary.id)
try {
awaitCancellation()
} finally {
NotificationService.stoppedShowing(summary.id)
//
// Not for a subagent: it has no notifications of its own, and it is not the session this would
// otherwise mark as being read.
if (!isSubagent) {
LaunchedEffect(summary.id, lifecycleOwner) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
NotificationService.showing(context, summary.id)
try {
awaitCancellation()
} finally {
NotificationService.stoppedShowing(summary.id)
}
}
}
}
@@ -924,7 +956,7 @@ fun SessionScreen(
val (index, offset, awayFromNewest) = settled
saveScrollAnchor(
context,
summary.id,
address.cachePath,
// Nothing to restore at the newest end, which is where a session with no anchor
// opens anyway. One *before* the index, because item zero is the "below" slot.
if (!awayFromNewest) null
@@ -947,7 +979,7 @@ fun SessionScreen(
//
// There is no correction beside this one. Following the newest message is not an effect: the
// list is reversed, so an arriving message extends the end the viewport is pinned to.
val unitSizes = remember(summary.id) { HashMap<Any, Int>() }
val unitSizes = remember(address) { HashMap<Any, Int>() }
LaunchedEffect(listState, moreHistory) {
snapshotFlow { listState.layoutInfo }
.collect { info ->
@@ -983,21 +1015,25 @@ fun SessionScreen(
}
}
LaunchedEffect(summary.setupName, summary.provider) {
offeredModels =
try {
withContext(Dispatchers.IO) {
fetchSetups(settings)
.firstOrNull { it.name == summary.setupName }
?.providers
?.firstOrNull { it.name == summary.provider }
?.models
.orEmpty()
// Only for the model picker, which a subagent does not have.
if (!isSubagent) {
LaunchedEffect(summary.setupName, summary.provider) {
offeredModels =
try {
withContext(Dispatchers.IO) {
fetchSetups(settings)
.firstOrNull { it.name == summary.setupName }
?.providers
?.firstOrNull { it.name == summary.provider }
?.models
.orEmpty()
}
} catch (_: Exception) {
// Not worth reporting: the picker simply has nothing to offer, which is
// visible.
emptyList()
}
} catch (_: Exception) {
// Not worth reporting: the picker simply has nothing to offer, which is visible.
emptyList()
}
}
}
/**
@@ -1148,8 +1184,9 @@ fun SessionScreen(
}
// One poll for the machines' limits, read by everything on this screen that reports them.
val usageFeed = rememberUsageFeed(settings)
val usage = usageFeed.forSession(summary)
// Nothing meters a subagent -- it has no account of its own -- so it never starts this poll.
val usageFeed = if (isSubagent) null else rememberUsageFeed(settings)
val usage = usageFeed?.forSession(summary) ?: SessionUsage.NotMetered
RecordFrames()
var usageOpen by remember { mutableStateOf(false) }
var settingsOpen by remember { mutableStateOf(false) }
@@ -1236,20 +1273,35 @@ fun SessionScreen(
// A ring's worth, which is what the arrow already keeps on its other three sides.
Spacer(Modifier.width(GLYPH_BUTTON_MARGIN))
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleMedium)
// Machine first, then what runs on it -- the same order and the same wording
// everywhere this pair appears, so it reads as one fact rather than two
// sentences with different grammar.
//
// No model. The picker in the footer already shows what this session is set to,
// and showing it twice means two things to keep in step -- they disagreed for a
// moment on every model change.
Text(
"${summary.setupName} · ${summary.provider}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// A subagent's own title, with the session's beneath it in a smaller style --
// the header says whose conversation this is as well as what it is. Otherwise
// just the session's title, as before.
if (subagent != null) {
Text(subagent.title, style = MaterialTheme.typography.titleMedium)
Text(
title,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
Text(title, style = MaterialTheme.typography.titleMedium)
// Machine first, then what runs on it -- the same order and the same
// wording everywhere this pair appears, so it reads as one fact rather than
// two sentences with different grammar.
//
// No model. The picker in the footer already shows what this session is set
// to, and showing it twice means two things to keep in step -- they
// disagreed for a moment on every model change.
Text(
"${summary.setupName} · ${summary.provider}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// None of this is a subagent's: it has no files of its own to browse, no settings,
// and nothing meters it -- see SUBAGENTS.md's "Phone".
//
// Beside the provider it reports on, which is the line directly to its left. Its
// real home is this provider's settings, which do not exist yet. A session on a
// provider with no such service gets an honest "unavailable" rather than a hidden
@@ -1264,42 +1316,47 @@ fun SessionScreen(
// Usage, files, settings -- widest scope first, narrowing to the right, so the cog
// stays at the end where every other screen keeps it. Asked for in this order by
// Iris on 2026-09-03.
Row {
GlyphButton(
USAGE_GLYPH,
"Usage",
{ usageOpen = true },
colour = usageGlyphColour(usage),
)
// The machine's files, which is where the answer to "what did it actually
// change" is. It opens *over* this screen rather than replacing it.
GlyphButton(
FOLDER_GLYPH,
"Files",
onClick = {
onFiles(
FilesTarget(
setup = summary.setup,
setupName = summary.setupName,
// Where this session works, and the machine's own home when it
// was never given a directory -- resolved there rather than
// guessed at here, since this app does not know that home.
start = summary.cwd?.takeIf { it.isNotBlank() } ?: "~",
if (!isSubagent) {
Row {
GlyphButton(
USAGE_GLYPH,
"Usage",
{ usageOpen = true },
colour = usageGlyphColour(usage),
)
// The machine's files, which is where the answer to "what did it actually
// change" is. It opens *over* this screen rather than replacing it.
GlyphButton(
FOLDER_GLYPH,
"Files",
onClick = {
onFiles(
FilesTarget(
setup = summary.setup,
setupName = summary.setupName,
// Where this session works, and the machine's own home when
// it was never given a directory -- resolved there rather
// than guessed at here, since this app does not know that
// home.
start = summary.cwd?.takeIf { it.isNotBlank() } ?: "~",
)
)
)
},
)
// What it opens is about this session, so it sits at the end of the session's
// own row. A cog and not a word because there will be more, and a bar of words
// has nowhere to put it.
GlyphButton(SETTINGS_GLYPH, "Session settings", { settingsOpen = true })
},
)
// What it opens is about this session, so it sits at the end of the
// session's own row. A cog and not a word because there will be more, and a
// bar of words has nowhere to put it.
GlyphButton(SETTINGS_GLYPH, "Session settings", { settingsOpen = true })
}
}
}
// Under the header, above everything the session itself says: it is a fact about the
// machine rather than a turn in the conversation, and it is the number that decides
// whether to keep going.
SessionUsageBar(usage)
// whether to keep going. Nothing meters a subagent.
if (!isSubagent) {
SessionUsageBar(usage)
}
(streamError ?: actionError)?.let { message ->
Text(
@@ -1644,185 +1701,208 @@ fun SessionScreen(
)
}
// Kept for a subagent -- see SUBAGENTS.md's "Phone" -- with the wording that turns
// "exited" into "finished" for one, since it has no process to leave running or stop.
SessionStatusRow(
status = status,
compactingFor = compactingFor,
contextTokens = contextTokens,
subagent = isSubagent,
)
// Between the transcript and the box: above what is being typed, so the list does not
// cover the thing the command is about, and below everything that explains it.
CommandSuggestions(
// Nothing to suggest about a suggestion that was just taken. `/compact` is a whole
// command *and* a prefix of itself, so picking it left the list standing there with
// the one row already chosen. Held by what was picked rather than by a flag, so
// typing anything else brings the list back without a second thing to reset.
commands = if (input.text == picked) emptyList() else suggestedCommands(input.text),
onPick = { command ->
// At the end of what was inserted, which is where the reader carries on typing:
// a command with an argument is put in the box half-written, and a cursor left
// at the front makes the next keystroke the first character of "/rename".
input = atEnd(command.typed())
picked = command.typed()
},
)
// Always enabled -- a send while the session is running becomes a steering message
// injected at the next tool boundary, which is the point of the whole app.
//
// The field gets a row of its own, above the buttons: sharing one put the full width
// behind three controls, so the thing being typed into was the narrowest on the row.
Column(Modifier.fillMaxWidth().padding(8.dp)) {
// Directly above the box they will be sent from, so what is attached is visible
// rather than counted: the "+2" on the button below said how many and never which.
PendingAttachments(
settings = settings,
sessionId = summary.id,
refs = pendingAttachments,
onRemove = { pendingAttachments = pendingAttachments - it },
)
OutlinedTextField(
value = input,
onValueChange = {
input = it
saveDraft(context, summary.id, it.text)
// Everything from here down is the composer: a subagent cannot be messaged, so none of
// it applies -- see SUBAGENTS.md's "Phone".
if (!isSubagent) {
// Between the transcript and the box: above what is being typed, so the list does
// not cover the thing the command is about, and below everything that explains it.
CommandSuggestions(
// Nothing to suggest about a suggestion that was just taken. `/compact` is a
// whole command *and* a prefix of itself, so picking it left the list standing
// there with the one row already chosen. Held by what was picked rather than by
// a flag, so typing anything else brings the list back without a second thing
// to
// reset.
commands =
if (input.text == picked) emptyList() else suggestedCommands(input.text),
onPick = { command ->
// At the end of what was inserted, which is where the reader carries on
// typing: a command with an argument is put in the box half-written, and a
// cursor left at the front makes the next keystroke the first character of
// "/rename".
input = atEnd(command.typed())
picked = command.typed()
},
modifier = Modifier.fillMaxWidth(),
// No longer "(+image)": the images are on screen above this, and a placeholder
// saying so said it in words beside the thing itself.
placeholder = { Text("Message") },
maxLines = 4,
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
// Photo or file, asked here rather than by two buttons: the row is full, and
// attaching is one action whichever picker answers it.
var attaching by remember { mutableStateOf(false) }
Box {
// Just "+". The count it used to carry was standing in for showing them.
BubbleButton(onClick = { attaching = true }) { Text("+") }
DropdownMenu(
expanded = attaching,
onDismissRequest = { attaching = false },
// See PickerButton: without this the menu opens a status bar's height
// away from the button in an edge-to-edge activity.
properties = PopupProperties(clippingEnabled = false),
shape = BubbleMenuShape,
) {
DropdownMenuItem(
text = { Text("Photo") },
onClick = {
attaching = false
pickImage.launch(
PickVisualMediaRequest(
ActivityResultContracts.PickVisualMedia.ImageOnly
)
)
},
)
DropdownMenuItem(
text = { Text("File") },
onClick = {
attaching = false
pickFile.launch(arrayOf("*/*"))
},
)
}
}
// The settings share what is left after the actions have taken what they need.
// A Row hands out intrinsic widths in order and clips whatever runs past the
// edge, so with these laid out first the arrival of Stop pushed Send off the
// screen entirely -- the app's central control, gone at the moment it is most
// in use.
// Always enabled -- a send while the session is running becomes a steering message
// injected at the next tool boundary, which is the point of the whole app.
//
// The field gets a row of its own, above the buttons: sharing one put the full
// width
// behind three controls, so the thing being typed into was the narrowest on the
// row.
Column(Modifier.fillMaxWidth().padding(8.dp)) {
// Directly above the box they will be sent from, so what is attached is visible
// rather than counted: the "+2" on the button below said how many and never
// which.
PendingAttachments(
settings = settings,
sessionId = summary.id,
refs = pendingAttachments,
onRemove = { pendingAttachments = pendingAttachments - it },
)
OutlinedTextField(
value = input,
onValueChange = {
input = it
saveDraft(context, summary.id, it.text)
},
modifier = Modifier.fillMaxWidth(),
// No longer "(+image)": the images are on screen above this, and a
// placeholder saying so said it in words beside the thing itself.
placeholder = { Text("Message") },
maxLines = 4,
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f),
modifier = Modifier.fillMaxWidth(),
) {
if (offeredModels.isNotEmpty()) {
// Photo or file, asked here rather than by two buttons: the row is full,
// and
// attaching is one action whichever picker answers it.
var attaching by remember { mutableStateOf(false) }
Box {
// Just "+". The count it used to carry was standing in for showing
// them.
BubbleButton(onClick = { attaching = true }) { Text("+") }
DropdownMenu(
expanded = attaching,
onDismissRequest = { attaching = false },
// See PickerButton: without this the menu opens a status bar's
// height away from the button in an edge-to-edge activity.
properties = PopupProperties(clippingEnabled = false),
shape = BubbleMenuShape,
) {
DropdownMenuItem(
text = { Text("Photo") },
onClick = {
attaching = false
pickImage.launch(
PickVisualMediaRequest(
ActivityResultContracts.PickVisualMedia.ImageOnly
)
)
},
)
DropdownMenuItem(
text = { Text("File") },
onClick = {
attaching = false
pickFile.launch(arrayOf("*/*"))
},
)
}
}
// The settings share what is left after the actions have taken what they
// need. A Row hands out intrinsic widths in order and clips whatever runs
// past the edge, so with these laid out first the arrival of Stop pushed
// Send off the screen entirely -- the app's central control, gone at the
// moment it is most in use.
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f),
) {
if (offeredModels.isNotEmpty()) {
PickerButton(
current = modelLabel(model),
// What the machine offers, plus the state a session is in when
// it has chosen none of them. The button has always been able
// to
// say "default"; until this the list could not, so leaving it
// was a one-way trip.
options = listOf(DEFAULT_MODEL) + offeredModels,
// Not set here. The button follows what the session reports it
// is set to, which arrives a moment later and is sometimes a
// different answer -- a name the CLI resolved, or no change at
// all on a provider whose model is fixed. Asked about first,
// unless there is nothing to lose by it -- see
// [ModelSwitchWarning].
onPick = { chosen ->
if (
modelLabel(chosen) == modelLabel(model) ||
!worthWarningAbout(status, contextTokens, items)
) {
act { setSessionModel(settings, summary.id, chosen) }
} else {
pendingModel = chosen
}
},
)
}
PickerButton(
current = modelLabel(model),
// What the machine offers, plus the state a session is in when it
// has chosen none of them. The button has always been able to say
// "default"; until this the list could not, so leaving it was a
// one-way trip.
options = listOf(DEFAULT_MODEL) + offeredModels,
// Not set here. The button follows what the session reports it is
// set to, which arrives a moment later and is sometimes a different
// answer -- a name the CLI resolved, or no change at all on a
// provider whose model is fixed. Asked about first, unless there is
// nothing to lose by it -- see [ModelSwitchWarning].
current = permissionMode,
options = PERMISSION_MODES,
onPick = { chosen ->
if (
modelLabel(chosen) == modelLabel(model) ||
!worthWarningAbout(status, contextTokens, items)
) {
act { setSessionModel(settings, summary.id, chosen) }
} else {
pendingModel = chosen
}
act { setSessionPermissionMode(settings, summary.id, chosen) }
},
)
}
PickerButton(
current = permissionMode,
options = PERMISSION_MODES,
onPick = { chosen ->
act { setSessionPermissionMode(settings, summary.id, chosen) }
},
)
}
// 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 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 a button 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
}
Button(
onClick = {
processInFlight = true
act(onDone = { processInFlight = false }) {
process.perform(settings, summary.id)
// 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 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 a button
// 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
}
},
enabled = !processInFlight,
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.
//
// 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. Disabled and not hidden, for the reason above.
Button(
onClick = { send() },
enabled = input.text.isNotBlank() || pendingAttachments.isNotEmpty(),
colors = actionButtonColors(if (running) queueColor else sendColor),
) {
Glyph(
if (running) QUEUE_GLYPH else SEND_GLYPH,
colour = LocalContentColor.current,
modifier =
Modifier.semantics { contentDescription = sendLabel(running) },
)
Button(
onClick = {
processInFlight = true
act(onDone = { processInFlight = false }) {
process.perform(settings, summary.id)
}
},
enabled = !processInFlight,
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.
//
// 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. Disabled and not hidden, for the
// reason above.
Button(
onClick = { send() },
enabled = input.text.isNotBlank() || pendingAttachments.isNotEmpty(),
colors = actionButtonColors(if (running) queueColor else sendColor),
) {
Glyph(
if (running) QUEUE_GLYPH else SEND_GLYPH,
colour = LocalContentColor.current,
modifier =
Modifier.semantics { contentDescription = sendLabel(running) },
)
}
}
}
}
@@ -1833,7 +1913,7 @@ fun SessionScreen(
// is the screen's business rather than any row's. See [SessionImageViewer].
fullImage?.let { ref -> SessionImageViewer(settings, summary.id, ref) { fullImage = null } }
if (usageOpen) {
UsageDialog(feed = usageFeed, onDismiss = { usageOpen = false })
usageFeed?.let { UsageDialog(feed = it, onDismiss = { usageOpen = false }) }
}
if (settingsOpen) {
// Measured when the dialog opens rather than kept up to date: what the reader is being told
@@ -2125,6 +2205,13 @@ private fun SessionStatusRow(
/** Context the session is holding, or null where nothing has measured it. */
contextTokens: Long?,
modifier: Modifier = Modifier,
/**
* Whether this row is for a subagent rather than a session, which changes only one word:
* "exited" reads as "finished" there too, the same as the subagent list's own card -- a
* subagent's process was always its parent's, so "exited" would read as a fault rather than the
* ordinary way one of these ends.
*/
subagent: Boolean = false,
) {
DebugStats.count("status row recomposed")
Row(
@@ -2181,7 +2268,7 @@ private fun SessionStatusRow(
Text(
when (status) {
"idle" -> "idle"
"exited" -> "exited"
"exited" -> if (subagent) "finished" else "exited"
"awaitingInput" -> "your turn"
"unknown" -> "can't tell"
else -> status