Keep the reader's place: in a group, in a compaction, and in the list
Five things Iris asked for, all about the transcript screen holding still around whoever is reading it. A tool call opened on its own stayed open when a second call in the same run turns it into a group. Watching a Bash call and having the session make another one used to shut the card being read and fold it behind "Called 2 tools" -- the reader lost their place because something else happened. The transition is noticed once, at the moment a run first becomes a group; after that the group's own toggle owns it, so shutting a group whose inner call is still expanded does not re-open it. The compaction clock is taken from the `compacting` status event's own timestamp rather than from this device noticing one, so it survives leaving the session and coming back -- it used to disappear, because the only thing that knew when the compaction started was a screen that had been disposed. The server timestamps every transcript line, so this is still a measurement; it is compared against the phone's wall clock, which is the same comparison a session's "last active" already makes. Session settings are a dialog over the session instead of a screen below it. Two controls did not warrant a page transition and a back stack, and the thing they change was hidden while they were on screen. Captions are gone -- each control is a labelled noun -- and "Notify me" is "Notifications" with a bell beside it (`md-bell`, added to the committed Nerd Fonts subset). Failures keep their words, since those are what a reader cannot work out by looking. Tool groups are rounded like every other card, their foot bar is the same height as their heading (both derived from the heading's own line height, so the pair cannot drift), and the calls inside are a connected stack: square where they face a neighbour, rounded on the outside, with a small gap so the join reads as a join. Scroll position is persistent on the device, per session, keyed by the row rather than by an index -- an index means nothing across a reopen, where the transcript is fetched newest-first. Reopening pages backwards until that row is loaded *and* has something older behind it, because the oldest loaded row is a half-row that grows when the page behind it arrives; anchoring into one landed a screen and a half out. The list draws nothing until the position lands, so there is no frame in which the transcript is somewhere other than where it was left. Two things found on the way. `snapshotFlow`'s first emission is the state before anybody has touched the list, and reading it as a scroll that had just ended at the newest end wiped every saved position on the way in. And backwards pages now ask for 800 events rather than 80: ai-app-2 measured a real transcript at 2,426 events for seven assistant messages, so a page of eighty is a fifth of one row and filling the lookahead took about thirty sequential round trips -- seconds of a list that will not move, over the tunnel. `/tools [n] [gap]` in the echo driver takes seconds between calls, which is what makes a run grow slowly enough for somebody to have opened one of its calls first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8b0e654733
commit
61d2c78afe
10 files changed
+626
-323
No files matched your search
@@ -12,7 +12,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CornerBasedShape
|
||||
import androidx.compose.foundation.shape.CornerSize
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -20,13 +23,17 @@ 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.graphics.Shape
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
@@ -140,12 +147,21 @@ fun Modifier.clickableAt(onClick: (Float) -> Unit): Modifier {
|
||||
/**
|
||||
* Several calls under one heading, closed until somebody asks.
|
||||
*
|
||||
* The calls keep their own full width -- no indent, no inset -- because they are the same rows they
|
||||
* would be on their own, and stepping them in would say they are something lesser. What says they
|
||||
* belong together is the surface behind them, which is the one cue rather than two half-cues.
|
||||
* What says the calls belong together is the surface behind them, which is the one cue rather than
|
||||
* two half-cues -- rounded to the same corner every other card in the app has, so a group reads as
|
||||
* one object rather than as a square patch behind round things. The calls sit on it inset by
|
||||
* [GROUP_INSET], which is the container's own padding rather than an indent: they are the same rows
|
||||
* they would be on their own, and a rounded corner drawn hard against a rounded corner reads as a
|
||||
* notch.
|
||||
*
|
||||
* Inside, the calls are a connected stack. Facing corners are square and the outer ones are not, so
|
||||
* the run reads as one thing broken into its parts; [GROUP_GAP] keeps the parts legible without
|
||||
* separating them. See [connectedShape].
|
||||
*
|
||||
* It closes from either end. A long group's header scrolls off while its last call is still on
|
||||
* screen, and the reader who wants it shut is looking at the bottom, not hunting for the top.
|
||||
* screen, and the reader who wants it shut is looking at the bottom, not hunting for the top. The
|
||||
* bar at the foot is the same height as the heading at the top, so the surface the calls sit on is
|
||||
* as thick below them as above.
|
||||
*/
|
||||
@Composable
|
||||
fun ToolGroup(
|
||||
@@ -160,52 +176,116 @@ fun ToolGroup(
|
||||
onAnswer: (questionId: String, answers: List<String>) -> Unit,
|
||||
image: @Composable (String) -> Unit,
|
||||
) {
|
||||
val heading = "Called ${group.calls.size} tools"
|
||||
if (!expanded) {
|
||||
Card(Modifier.fillMaxWidth().clickableAt(onToggle)) {
|
||||
Text(
|
||||
"Called ${group.calls.size} tools",
|
||||
heading,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
modifier = Modifier.padding(GROUP_INSET_LARGE),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
Column(Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceContainerLow)) {
|
||||
Text(
|
||||
"Called ${group.calls.size} tools",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.fillMaxWidth().clickableAt(onToggle).padding(12.dp),
|
||||
)
|
||||
group.calls.forEach { call ->
|
||||
ToolCard(
|
||||
tool = call,
|
||||
expanded = isToolExpanded(call.id),
|
||||
onToggle = { at -> onToolToggle(call.id, at) },
|
||||
onAnswer = onAnswer,
|
||||
image = image,
|
||||
Column(
|
||||
Modifier.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerLow)
|
||||
) {
|
||||
val barHeight = groupBarHeight()
|
||||
Row(
|
||||
Modifier.fillMaxWidth().height(barHeight).clickableAt(onToggle),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
heading,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(horizontal = GROUP_INSET_LARGE),
|
||||
)
|
||||
}
|
||||
Column(
|
||||
Modifier.padding(horizontal = GROUP_INSET),
|
||||
verticalArrangement = Arrangement.spacedBy(GROUP_GAP),
|
||||
) {
|
||||
group.calls.forEachIndexed { index, call ->
|
||||
ToolCard(
|
||||
tool = call,
|
||||
expanded = isToolExpanded(call.id),
|
||||
onToggle = { at -> onToolToggle(call.id, at) },
|
||||
onAnswer = onAnswer,
|
||||
image = image,
|
||||
shape = connectedShape(index, group.calls.size),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Shutting it from here anchors the other end: the reader is at the bottom of a long
|
||||
// group, and what they are looking at is what follows it.
|
||||
CollapseBar(onToggle)
|
||||
CollapseBar(barHeight, onToggle)
|
||||
}
|
||||
}
|
||||
|
||||
/** The bottom half of a group's toggle: an arrow back up to its heading. */
|
||||
/**
|
||||
* The height of a group's heading, and so of the bar at its foot.
|
||||
*
|
||||
* Derived from the type the heading is set in rather than written down, because the two have to
|
||||
* match and a pair of numbers chosen to look equal stops being equal the moment either the style or
|
||||
* the density changes. Taking the line height also means the heading cannot be clipped by it.
|
||||
*/
|
||||
@Composable
|
||||
private fun CollapseBar(onToggle: (Float) -> Unit) {
|
||||
private fun groupBarHeight(): Dp {
|
||||
val line = MaterialTheme.typography.titleSmall.lineHeight
|
||||
return with(LocalDensity.current) { line.toDp() } + GROUP_INSET_LARGE * 2
|
||||
}
|
||||
|
||||
/**
|
||||
* The bottom half of a group's toggle: an arrow back up to its heading.
|
||||
*
|
||||
* Given the heading's height rather than padded to something that looks close, so the surface the
|
||||
* calls sit on is the same thickness at both ends. See [groupBarHeight].
|
||||
*/
|
||||
@Composable
|
||||
private fun CollapseBar(height: Dp, onToggle: (Float) -> Unit) {
|
||||
val colour = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
Row(
|
||||
Modifier.fillMaxWidth()
|
||||
.clickableAt(onToggle)
|
||||
.semantics { contentDescription = "Collapse these tool calls" }
|
||||
.padding(vertical = 10.dp),
|
||||
Modifier.fillMaxWidth().height(height).clickableAt(onToggle).semantics {
|
||||
contentDescription = "Collapse these tool calls"
|
||||
},
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Chevron(pointingUp = true, colour = colour)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape of one card in a stack of [count]: square where it faces a neighbour, rounded where it
|
||||
* does not.
|
||||
*
|
||||
* Written once and given an index rather than branched at each end, because a stack has three cases
|
||||
* that are one rule -- and the middle one is the case a hand-written first/last pair gets wrong
|
||||
* when a run turns out to have three calls in it.
|
||||
*/
|
||||
@Composable
|
||||
private fun connectedShape(index: Int, count: Int): CornerBasedShape {
|
||||
val shape = MaterialTheme.shapes.medium
|
||||
val square = CornerSize(0.dp)
|
||||
return shape.copy(
|
||||
topStart = if (index == 0) shape.topStart else square,
|
||||
topEnd = if (index == 0) shape.topEnd else square,
|
||||
bottomStart = if (index == count - 1) shape.bottomStart else square,
|
||||
bottomEnd = if (index == count - 1) shape.bottomEnd else square,
|
||||
)
|
||||
}
|
||||
|
||||
/** The padding inside a card, and so the height a bar of one line of text comes to. */
|
||||
private val GROUP_INSET_LARGE = 12.dp
|
||||
|
||||
/** How far the stack of calls is held off the edge of the surface it sits on. */
|
||||
private val GROUP_INSET = 4.dp
|
||||
|
||||
/** Enough to read the join as a join rather than as one tall card. */
|
||||
private val GROUP_GAP = 2.dp
|
||||
|
||||
/**
|
||||
* One tool call.
|
||||
*
|
||||
@@ -227,12 +307,14 @@ fun ToolCard(
|
||||
onToggle: (Float) -> Unit,
|
||||
onAnswer: (questionId: String, answers: List<String>) -> Unit,
|
||||
image: @Composable (String) -> Unit = {},
|
||||
/** Square where this card faces another in a group; see [connectedShape]. */
|
||||
shape: Shape = CardDefaults.shape,
|
||||
) {
|
||||
val parsed = remember(tool.tool, tool.input) { parseToolInput(tool.tool, tool.input) }
|
||||
val deciding = tool.asks.any { it.answers.isEmpty() }
|
||||
val open = expanded || deciding
|
||||
Card(Modifier.fillMaxWidth().clickableAt(onToggle)) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Card(Modifier.fillMaxWidth().clickableAt(onToggle), shape = shape) {
|
||||
Column(Modifier.padding(GROUP_INSET_LARGE)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(tool.tool, style = MaterialTheme.typography.titleSmall)
|
||||
if (open) {
|
||||
|
||||
Reference in new issue
Block a user