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:
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
|
||||
|
||||
Reference in new issue
Block a user