diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt index 59e9eae..8c066f6 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt @@ -282,7 +282,6 @@ fun AppRoot( summary = here.summary, active = active, backgroundTasks = backgroundTasks, - onClose = close, onOpenSubagent = { screen = here.copy(subagent = it) }, // Closed with it: what the reader asked to see is under this // panel, and a panel left open over the answer is the one diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/BackgroundTasks.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/BackgroundTasks.kt index 485acd9..ea20875 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/BackgroundTasks.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/BackgroundTasks.kt @@ -1,5 +1,6 @@ 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 @@ -17,8 +18,10 @@ 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 @@ -34,9 +37,10 @@ import androidx.compose.ui.unit.dp * nobody asked after would push them off it. Expanding pushes them down instead of covering them, * so the two are read together. * - * Nothing is drawn at all when the count is zero -- including when the provider never said, which - * is the same absence the status row draws. A permanently visible "0 bg tasks" would be a line - * about nothing on every session that has never backgrounded anything, which is most of them. + * 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, @@ -46,21 +50,25 @@ fun LazyListScope.backgroundTaskSection( onRetry: () -> Unit, onOpenCall: (CallSite) -> Unit, ) { - if (count == 0) return item(key = "background-heading") { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).clickable(onClick = onToggle), + 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), ) - Chevron(if (expanded) Pointing.Up else Pointing.Down) + if (count > 0) Chevron(if (expanded) Pointing.Up else Pointing.Down) } } - if (!expanded) return + // 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") { @@ -102,7 +110,8 @@ fun LazyListScope.backgroundTaskSection( } /** - * One background task: what it is doing, drawn as one line with its kind as the mark beside it. + * 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 @@ -110,6 +119,10 @@ fun LazyListScope.backgroundTaskSection( * 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 @@ -119,6 +132,9 @@ fun LazyListScope.backgroundTaskSection( @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, @@ -134,31 +150,49 @@ private fun BackgroundTaskCard(task: BackgroundTaskSummary, onOpen: (() -> Unit) ) .padding(horizontal = 12.dp, vertical = 10.dp), ) { - 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. - // - // 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( - task.description ?: look.words, - style = - if (look.mono) - MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace) - else MaterialTheme.typography.bodyMedium, - color = - if (task.description == null) MaterialTheme.colorScheme.onSurfaceVariant - else LocalContentColor.current, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) + 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) @@ -167,8 +201,14 @@ private fun BackgroundTaskCard(task: BackgroundTaskSummary, onOpen: (() -> Unit) } } -/** How one kind of background task is drawn: see [backgroundTaskLook]. */ -private data class TaskLook(val glyph: String, val words: String, val mono: Boolean) +/** + * 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`. @@ -183,10 +223,10 @@ private data class TaskLook(val glyph: String, val words: String, val mono: Bool 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", mono = true) - "agent" -> TaskLook(AGENT_GLYPH, "subagent", mono = false) - "workflow" -> TaskLook(WORKFLOW_GLYPH, "workflow", mono = false) - else -> TaskLook(UNKNOWN_GLYPH, "background task", mono = false) + "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) } /** diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index 044c5ee..6671a6b 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -817,6 +817,38 @@ fun SessionScreen( } } + /** + * Puts the row at [index] where it can be read, once something has scrolled to it. + * + * A reversed list anchors an item by its *bottom*, so a scroll to a tool card lands it against + * the bottom edge with the turn that follows it above -- and for a tall card that means + * arriving at its last line. What was asked for is the card: one that fits is centred, and one + * that does not has its top put at the top of the viewport, where reading it starts. + * + * Only for a journey the reader asked for. A restore puts somebody back exactly where they + * stopped, and exactly is the whole of what that means. + * + * The size is waited for rather than read: the row may not have been laid out yet in any + * composition this scroll has had. + */ + suspend fun frameRow(index: Int) { + val size = + snapshotFlow { + listState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index }?.size + } + .first { it != null }!! + val layout = listState.layoutInfo + // The room between the paddings, which is what a row is drawn in. Nothing to frame against + // while the list has no size at all, and the scroll already made is as good an answer. + val viewport = + layout.viewportSize.height - layout.beforeContentPadding - layout.afterContentPadding + if (viewport <= 0) return + // Negative, so the row's bottom sits that far *inside* the viewport rather than past its + // edge. The list clamps it where there is not enough behind the row to scroll. + val offset = if (size <= viewport) -(viewport - size) / 2 else size - viewport + listState.scrollToItem(index, offset) + } + /** * Puts the reader at [anchor], paging history back until the row holding it is loaded. * @@ -831,7 +863,8 @@ fun SessionScreen( * * [openCall] opens the tool call at the anchor, and the run it is drawn inside, on the way * past. That is what makes a tapped background task land on its own card with its output - * showing, rather than on a shut group the reader then has to find it in. + * showing, rather than on a shut group the reader then has to find it in -- and [frameRow] is + * what then puts the card where it can be read. */ suspend fun travelTo(anchor: ScrollAnchor, openCall: Boolean = false) { // Pages until the anchor's row is loaded and has something older behind it. The oldest @@ -909,6 +942,7 @@ fun SessionScreen( snapshotFlow { unitIndexFor(currentUnits, rowSeq, anchor.unit) } .first { it != null }!! listState.scrollToItem(index + 1, anchor.offset) + if (openCall) frameRow(index + 1) } } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SubagentPanel.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SubagentPanel.kt index 2ff0abe..081aafc 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SubagentPanel.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SubagentPanel.kt @@ -62,7 +62,6 @@ fun SubagentPanel( summary: SessionSummary, active: Boolean, backgroundTasks: Int, - onClose: () -> Unit, onOpenSubagent: (SubagentSummary) -> Unit, onOpenCall: (CallSite) -> Unit, ) { @@ -114,14 +113,6 @@ fun SubagentPanel( val ordered = (rows as? LoadState.Loaded)?.value?.let(::subagentOrder) Column(Modifier.fillMaxSize()) { - Row( - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), - ) { - MarkButton("Close panel", onClose) { Chevron(Pointing.Right) } - } - LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.weight(1f).padding(horizontal = 16.dp),