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?>, 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) } }