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:
irisandClaude Opus 5 committed 2026-08-30 19:06:11 -04:00
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) {