A backgrounded command in a session's panel was a terminal glyph beside its words. The glyph said "command" and so did the monospace face, which is the same fact twice -- so the mark goes and the command is drawn the way every other verbatim thing in this app is: highlighted, monospace, on the raw surface. The glyph stays for the kinds whose description is prose, and for a command a provider never named. Tapping one landed the tool card against the bottom edge of the screen, since a reversed list anchors an item by its bottom -- so a tall card arrived at its last line. A card that fits the viewport is centred now, and one that does not has its top put at the top, which is where reading it starts. Only for a journey the reader asked for: a restore still puts them back exactly where they stopped. The panel's own close chevron is gone -- the drag, the scrim and Back all close it -- and the count is drawn even when it is zero, so "nothing is running" and "nobody has asked yet" stop looking identical. There is then nothing to expand, so that heading carries no chevron either. Checked on the emulator against the sandbox: the card centred in a mid-transcript tap, sat at the newest end where the list clamps, and the zero heading drew with no control.
245 lines
11 KiB
Kotlin
245 lines
11 KiB
Kotlin
package com.example.aiapp
|
|
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.clickable
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.Row
|
|
import androidx.compose.foundation.layout.Spacer
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.foundation.layout.heightIn
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.layout.width
|
|
import androidx.compose.foundation.lazy.LazyListScope
|
|
import androidx.compose.material3.CircularProgressIndicator
|
|
import androidx.compose.material3.LocalContentColor
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.OutlinedCard
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.TextButton
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.draw.clip
|
|
import androidx.compose.ui.semantics.contentDescription
|
|
import androidx.compose.ui.semantics.semantics
|
|
import androidx.compose.ui.text.font.FontFamily
|
|
import androidx.compose.ui.text.style.TextOverflow
|
|
import androidx.compose.ui.unit.dp
|
|
|
|
/**
|
|
* The background work a session has going, above its subagents in the panel [SidePanels] slides
|
|
* over it from the right.
|
|
*
|
|
* Collapsed to its one-line count by default, the way everything else this app adds to a screen
|
|
* arrives: what a reader came to the panel for is the subagents, and a run of cards about work
|
|
* nobody asked after would push them off it. Expanding pushes them down instead of covering them,
|
|
* so the two are read together.
|
|
*
|
|
* The count is drawn even when it is zero, in the same words. A section that appeared only once
|
|
* something was running made its own presence the answer, and no heading at all draws "nothing is
|
|
* running" and "nobody has asked yet" identically. There is then nothing to expand, so the heading
|
|
* carries no chevron either: it is a statement rather than a control.
|
|
*/
|
|
fun LazyListScope.backgroundTaskSection(
|
|
count: Int,
|
|
tasks: LoadState<List<BackgroundTaskSummary>?>,
|
|
expanded: Boolean,
|
|
onToggle: () -> Unit,
|
|
onRetry: () -> Unit,
|
|
onOpenCall: (CallSite) -> Unit,
|
|
) {
|
|
item(key = "background-heading") {
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier =
|
|
Modifier.fillMaxWidth()
|
|
.heightIn(min = 48.dp)
|
|
.then(if (count == 0) Modifier else Modifier.clickable(onClick = onToggle)),
|
|
) {
|
|
Text(
|
|
"${backgroundTaskLabel(count)} running",
|
|
style = MaterialTheme.typography.titleMedium,
|
|
modifier = Modifier.weight(1f),
|
|
)
|
|
if (count > 0) Chevron(if (expanded) Pointing.Up else Pointing.Down)
|
|
}
|
|
}
|
|
// The count as well as the switch: [tasks] is the last answer anybody got, so a section left
|
|
// expanded as the work finished would draw cards for tasks that have ended.
|
|
if (count == 0 || !expanded) return
|
|
when (tasks) {
|
|
is LoadState.Loading ->
|
|
item(key = "background-loading") {
|
|
CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp))
|
|
}
|
|
is LoadState.Error ->
|
|
item(key = "background-error") {
|
|
Column {
|
|
Text(
|
|
tasks.message,
|
|
color = MaterialTheme.colorScheme.error,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
)
|
|
TextButton(onClick = onRetry) { Text("Try again") }
|
|
}
|
|
}
|
|
// Null is the provider declining to say, which a session whose process has gone answers.
|
|
// Said in words: the count above came from somewhere, and an empty space under it would
|
|
// read as the tasks having finished rather than as nobody being left to ask.
|
|
is LoadState.Loaded ->
|
|
when (val rows = tasks.value) {
|
|
null ->
|
|
item(key = "background-unknown") {
|
|
Text(
|
|
"This session isn't saying what these are.",
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
)
|
|
}
|
|
else ->
|
|
uniqueItems(rows, key = { "background-${it.id}" }) { task ->
|
|
BackgroundTaskCard(
|
|
task,
|
|
onOpen = task.call?.let { call -> { onOpenCall(call) } },
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* One background task: what it is doing, drawn as one line that says what kind it is by how it
|
|
* looks.
|
|
*
|
|
* The kind used to be a second line under the words, which on a list of backgrounded commands was
|
|
* "background command" repeated down the panel -- and for a provider that names a task by a process
|
|
* id it was the *whole* card, so every row said the same two words. A mark carries the same
|
|
* difference in a width the text does not have to make room for, and it is the [Glyph]'s
|
|
* description that keeps the words for anybody who cannot see it.
|
|
*
|
|
* A command needs no mark: drawn the way every other verbatim thing here is -- highlighted,
|
|
* monospace, on [rawSurface] -- it says "this is a command" in the same appearance the tool card it
|
|
* came from uses, and a mark beside that would be the same fact twice.
|
|
*
|
|
* [onOpen] is where the call that started this is in the transcript, for the readers who tap it:
|
|
* null where the provider never said which call it was, or where that call is no longer in the
|
|
* transcript, and the card is then a statement rather than a control. The chevron is what says
|
|
* which of the two this is, since a card that quietly does nothing when pressed is worse than one
|
|
* that never invited the press.
|
|
*/
|
|
@Composable
|
|
private fun BackgroundTaskCard(task: BackgroundTaskSummary, onOpen: (() -> Unit)?) {
|
|
val look = backgroundTaskLook(task.kind)
|
|
// Null where a provider named the task by a process id and nothing resolved a command out of
|
|
// it: there is no code to draw, so the row takes the mark and the words instead.
|
|
val command = task.description?.takeIf { look.code }
|
|
OutlinedCard(Modifier.fillMaxWidth()) {
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier =
|
|
Modifier.fillMaxWidth()
|
|
.then(
|
|
if (onOpen == null) Modifier
|
|
else
|
|
Modifier.clickable(
|
|
onClickLabel = "Show where this started",
|
|
onClick = onOpen,
|
|
)
|
|
)
|
|
.padding(horizontal = 12.dp, vertical = 10.dp),
|
|
) {
|
|
if (command == null) {
|
|
Glyph(
|
|
look.glyph,
|
|
colour = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
modifier = Modifier.semantics { contentDescription = look.words },
|
|
)
|
|
Spacer(Modifier.width(10.dp))
|
|
// The kind stands in as the words where the provider gave no description, rather
|
|
// than the id it named the task by: Codex reports a process number, which says
|
|
// nothing to the person reading and would look like a name somebody chose.
|
|
Text(
|
|
task.description ?: look.words,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
color =
|
|
if (task.description == null) MaterialTheme.colorScheme.onSurfaceVariant
|
|
else LocalContentColor.current,
|
|
maxLines = 2,
|
|
overflow = TextOverflow.Ellipsis,
|
|
modifier = Modifier.weight(1f),
|
|
)
|
|
} else {
|
|
// Cut at its tail: what identifies a command is the program at its head, and the
|
|
// long ones are exactly the ones being read closely.
|
|
Text(
|
|
// Not cached: one command line lexes in microseconds -- the cache exists for a
|
|
// fence with two hundred lines in it.
|
|
remember(command) { highlight(command, Language.SHELL) },
|
|
style =
|
|
MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
|
maxLines = 2,
|
|
overflow = TextOverflow.Ellipsis,
|
|
modifier =
|
|
Modifier.weight(1f)
|
|
// Smaller than the card's own radius, for the reason [RawBlock] rounds
|
|
// its corners that way: this sits inside one.
|
|
.clip(MaterialTheme.shapes.extraSmall)
|
|
.background(rawSurface)
|
|
.padding(horizontal = 6.dp, vertical = 4.dp)
|
|
// The fill says "command" to everybody else; this says it to a reader
|
|
// who cannot see the fill.
|
|
.semantics { contentDescription = "${look.words} $command" },
|
|
)
|
|
}
|
|
if (onOpen != null) {
|
|
Spacer(Modifier.width(8.dp))
|
|
Chevron(Pointing.Right)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* How one kind of background task is drawn: see [backgroundTaskLook].
|
|
*
|
|
* [code] is the kind whose description is verbatim text rather than prose, which is drawn as code
|
|
* and takes no [glyph]; the glyph is still what a task of that kind falls back to when nothing said
|
|
* what it ran.
|
|
*/
|
|
private data class TaskLook(val glyph: String, val words: String, val code: Boolean)
|
|
|
|
/**
|
|
* Everything a [BackgroundTaskSummary.kind] decides, answered by one `when`.
|
|
*
|
|
* One rather than three, which is the rule this screen already learned once with the status word
|
|
* and its colour: three `when`s over one set is two of them waiting to miss a member.
|
|
*
|
|
* A kind this build has not heard of takes the question mark and is named by what every one of them
|
|
* has in common. The nearest word or mark we do know -- a robot, a terminal -- would be this screen
|
|
* deciding what the server meant by a word it invented after this build shipped.
|
|
*/
|
|
private fun backgroundTaskLook(kind: String) =
|
|
when (kind) {
|
|
// A command is drawn in the face a command is drawn in everywhere else here.
|
|
"command" -> TaskLook(COMMAND_GLYPH, "background command", code = true)
|
|
"agent" -> TaskLook(AGENT_GLYPH, "subagent", code = false)
|
|
"workflow" -> TaskLook(WORKFLOW_GLYPH, "workflow", code = false)
|
|
else -> TaskLook(UNKNOWN_GLYPH, "background task", code = false)
|
|
}
|
|
|
|
/**
|
|
* The heading over one group in the panel, so neither list is a run of cards with no name.
|
|
*
|
|
* The same band as the background section's own heading row above, rather than a gap chosen to look
|
|
* right here: what separates a heading from the cards above it is that both headings sit in a row
|
|
* of one height.
|
|
*/
|
|
@Composable
|
|
fun PanelSectionHeading(text: String) {
|
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.heightIn(min = 48.dp)) {
|
|
Text(text, style = MaterialTheme.typography.titleMedium)
|
|
}
|
|
}
|