Hold an image's place, open it full screen, and fold a run of calls

Five changes to how a transcript reads.

**Images no longer move the page.** The row was as tall as whatever had
loaded, so it grew when the bytes arrived and pushed everything below it
-- and in a bottom-anchored list, an image loading above the viewport
moved the text under the reader's eyes. The height is now decided before
the fetch and never changes: four lines of the body style, measured from
the type so it stays four lines when the reader has scaled their fonts.
Nothing to see when loading finishes, which is the point.

**A small image is enlarged with nearest neighbour**, a large one shrunk
smoothly -- decided per image from its actual size rather than set once,
since blowing a 16px sprite up with interpolation turns it into a blur
of exactly the thing being looked at.

**Tapping one opens it full screen**, fitted so the whole image is
visible first, with two-finger zoom to 8x and pan once zoomed. A dialog
rather than a screen, so back returns to the transcript.

**A tool call is one line closed**: the tool's name and what the call is
for. The command is not on it, because a wrapped command turns one row
into four. Open, it shows the command, the rest of the input and the
output, with the timeout at the top right -- a limit on the call rather
than part of what it does, worth seeing beside the command it constrains.
A call waiting on permission is shown open regardless, since the command
is the thing being decided.

**Adjacent calls fold into "Called n tools"**, closed by default, and it
closes again from either end -- a long group's heading scrolls away while
its last call is still on screen, and the reader who wants it shut is
looking at the bottom. The calls keep their full width; what says they
belong together is the surface behind them, one cue rather than two
half-cues. Grouping happens at display time, not in the fold: the
transcript's own order is what paging and the stream depend on.

Echo gains `/tools [n]` so a run of calls can be produced without paying
for one.

Verified on the emulator: four calls folded and expanded, one opened
inside the group showing `timeout 5000` top right, a 16px checkerboard
enlarged with hard pixel edges beside a shrunk screenshot at the same
height, the screen byte-identical between one second and six after
opening, full screen fitted, and back returning to the same scroll
position. Pinch itself is the one thing not verified here -- `adb input`
cannot inject a two-finger gesture.

53 tests, clippy, rustfmt, Android lint and ktfmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-29 13:43:46 -04:00
1 parent 8fe13634cb
commit 011ed0d0e1
5 files changed
+559 -141

No files matched your search

@@ -1,11 +1,9 @@
package com.example.aiapp
import android.graphics.BitmapFactory
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -45,8 +43,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -191,6 +187,9 @@ fun SessionScreen(
var actionError by remember { mutableStateOf<String?>(null) }
var input by remember { mutableStateOf("") }
var expandedTools by remember { mutableStateOf(setOf<String>()) }
// Which runs of adjacent tool calls are open. Keyed by the first call's
// id, so a group survives more calls arriving after it.
var expandedGroups by remember { mutableStateOf(setOf<String>()) }
// Uploaded-but-not-yet-sent attachment ids; sent with the next message.
var pendingAttachments by remember { mutableStateOf(listOf<String>()) }
// What this session is set to now, seeded from the row that opened it and
@@ -224,6 +223,9 @@ fun SessionScreen(
var loadingHistory by remember { mutableStateOf(false) }
var ready by remember { mutableStateOf(false) }
val listState = rememberLazyListState()
// What is actually drawn: the transcript with runs of adjacent tool
// calls folded into one row each.
val rows = remember(items) { groupToolRuns(items) }
fun apply(entry: SeqEvent) {
lastSeq.set(entry.seq)
@@ -578,42 +580,80 @@ fun SessionScreen(
}
// Reversed to match the layout, so index 0 is the newest and
// the reader still sees them in the order they happened.
items(items.asReversed()) { item ->
when (item) {
is TranscriptItem.UserMsg -> UserBubble(item.text)
is TranscriptItem.AssistantMsg -> AssistantMessage(item.text)
is TranscriptItem.ToolRun ->
ToolCard(
tool = item,
expanded = item.id in expandedTools,
onAnswer = { answer ->
item.ask?.let { ask ->
// Grouped first: adjacent tool calls collapse into one row,
// which is a decision about this screen and not about the
// transcript the stream and paging share.
items(rows.asReversed()) { row ->
when (row) {
is TranscriptRow.Tools ->
ToolGroup(
group = row,
expanded = row.id in expandedGroups,
onToggle = {
expandedGroups =
if (row.id in expandedGroups) expandedGroups - row.id
else expandedGroups + row.id
},
isToolExpanded = { it in expandedTools },
onToolToggle = { id ->
expandedTools =
if (id in expandedTools) expandedTools - id
else expandedTools + id
},
onAnswer = { call, answer ->
call.ask?.let { ask ->
act { answerQuestion(settings, summary.id, ask.id, answer) }
}
},
onToggle = {
expandedTools =
if (item.id in expandedTools) expandedTools - item.id
else expandedTools + item.id
},
)
is TranscriptItem.QuestionCard ->
QuestionRow(item) { answer ->
act { answerQuestion(settings, summary.id, item.id, answer) }
is TranscriptRow.Single ->
when (val item = row.item) {
is TranscriptItem.UserMsg -> UserBubble(item.text)
is TranscriptItem.AssistantMsg -> AssistantMessage(item.text)
is TranscriptItem.ToolRun ->
ToolCard(
tool = item,
expanded = item.id in expandedTools,
onToggle = {
expandedTools =
if (item.id in expandedTools)
expandedTools - item.id
else expandedTools + item.id
},
onAnswer = { answer ->
item.ask?.let { ask ->
act {
answerQuestion(
settings,
summary.id,
ask.id,
answer,
)
}
}
},
)
is TranscriptItem.QuestionCard ->
QuestionRow(item) { answer ->
act {
answerQuestion(settings, summary.id, item.id, answer)
}
}
is TranscriptItem.ErrorMsg ->
Text(
item.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
is TranscriptItem.ImageItem ->
SessionImage(settings, summary.id, item.ref)
is TranscriptItem.Note ->
Text(
item.text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
is TranscriptItem.ErrorMsg ->
Text(
item.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
is TranscriptItem.ImageItem -> SessionImage(settings, summary.id, item.ref)
is TranscriptItem.Note ->
Text(
item.text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -716,35 +756,6 @@ fun SessionScreen(
* An inline transcript image, fetched (authenticated, pinned) from the session's files route. The
* bitmap is remembered per ref, so scrolling doesn't refetch.
*/
@Composable
private fun SessionImage(settings: ServerSettings, sessionId: String, ref: String) {
var bitmap by remember(ref) { mutableStateOf<ImageBitmap?>(null) }
var failed by remember(ref) { mutableStateOf(false) }
LaunchedEffect(ref) {
try {
val bytes = withContext(Dispatchers.IO) { fetchSessionFile(settings, sessionId, ref) }
bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
failed = bitmap == null
} catch (_: ApiException) {
failed = true
}
}
when (val image = bitmap) {
null ->
Text(
if (failed) "[image $ref unavailable]" else "[loading image…]",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
else ->
Image(
bitmap = image,
contentDescription = "session image",
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
private fun UserBubble(text: String, pending: Boolean = false) {
Box(Modifier.fillMaxWidth()) {
@@ -776,71 +787,6 @@ private fun UserBubble(text: String, pending: Boolean = false) {
* Collapsed by default: name plus a spinner while running, expandable to the input and output. The
* spinner-while-unfinished is exactly "ToolStart with no matching ToolEnd yet".
*/
@Composable
private fun ToolCard(
tool: TranscriptItem.ToolRun,
expanded: Boolean,
onToggle: () -> Unit,
onAnswer: (String) -> Unit,
) {
Card(Modifier.fillMaxWidth().clickable(onClick = onToggle)) {
Column(Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
tool.tool,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
)
if (!tool.done) {
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
strokeWidth = 2.dp,
)
}
}
// Always, not only when expanded: what a call is doing is the
// command, and a row saying "Bash" says nothing a reader can act
// on -- least of all when it is asking for permission to run it.
ToolInputView(tool.tool, tool.input, Modifier.padding(top = 4.dp))
tool.ask?.let { ask -> PermissionAsk(ask, onAnswer) }
if (expanded && tool.output.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text("Output", style = MaterialTheme.typography.labelSmall)
Text(tool.output, style = MaterialTheme.typography.bodySmall)
}
}
}
}
/**
* The permission ask on the call it is about.
*
* Only the question is shown, not the prompt's second half: the backend sends the tool's input
* along with it so the ask can stand alone, and here it does not have to -- the card above is
* already showing exactly that.
*/
@Composable
private fun PermissionAsk(ask: TranscriptItem.QuestionCard, onAnswer: (String) -> Unit) {
Spacer(Modifier.height(8.dp))
Text(
ask.prompt.substringBefore('\n'),
style = MaterialTheme.typography.bodyMedium,
color = awaitingColor,
)
if (ask.answer != null) {
Text(
"Answered: ${ask.answer}",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ask.options.forEach { option ->
OutlinedButton(onClick = { onAnswer(option) }) { Text(option) }
}
}
}
}
/**
* A question (or permission request -- same shape) inline in the transcript. Option buttons until