Files
ai-app/app/androidApp/src/main/kotlin/com/example/aiapp/SessionImage.kt
T
irisandClaude Opus 5 011ed0d0e1 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>
2026-08-29 13:43:46 -04:00

170 lines
7.2 KiB
Kotlin

package com.example.aiapp
import android.graphics.BitmapFactory
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* An image in the transcript: a fixed-height thumbnail that opens full screen.
*
* The height is decided before the bytes arrive and never changes. An image row that grew when it
* finished loading pushed everything below it, so a transcript being read scrolled itself while
* somebody was looking at it -- and in a bottom-anchored list, images loading above the viewport
* moved the text under the reader's eyes. Reserving the final height makes loading invisible, which
* is what it should be.
*
* Four lines of body text, so a screenshot reads as an attachment beside the conversation rather
* than as a page of its own. Full size is one tap away.
*/
@Composable
fun SessionImage(settings: ServerSettings, sessionId: String, ref: String) {
var bitmap by remember(ref) { mutableStateOf<ImageBitmap?>(null) }
var failed by remember(ref) { mutableStateOf(false) }
var full 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
}
}
val height = thumbnailHeight()
val heightPx = with(LocalDensity.current) { height.roundToPx() }
Box(Modifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) {
when (val image = bitmap) {
null ->
Text(
// Two states, not one: an image still arriving and an image that will never
// arrive look nothing alike to a reader who can do something about the second.
if (failed) "[image $ref unavailable]" else "[loading image…]",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
else ->
Image(
bitmap = image,
contentDescription = "Attached image, tap to view full screen",
contentScale = ContentScale.Fit,
filterQuality = enlargingFilter(image.height, heightPx),
modifier = Modifier.fillMaxSize().clickable { full = true },
alignment = Alignment.CenterStart,
)
}
}
if (full) bitmap?.let { image -> ImageViewer(image) { full = false } }
}
/**
* Four lines of the body style the transcript is set in.
*
* Measured from the type rather than written as a dp, so it stays four lines when the text size
* changes -- including when the reader has scaled fonts up, which is exactly when a hardcoded
* height would be wrong.
*/
@Composable
private fun thumbnailHeight(): Dp {
val line = MaterialTheme.typography.bodyLarge.lineHeight
val density = LocalDensity.current
return remember(line, density) {
with(density) { if (line.isSpecified) (line * 4).toDp() else 96.dp }
}
}
/**
* Nearest neighbour when the image is being enlarged, smooth when it is being shrunk.
*
* A small image blown up with interpolation turns into a blur that hides what it is -- the same
* image with hard pixel edges stays readable. Shrinking wants the opposite, so this is a decision
* per image rather than a preference set once.
*/
private fun enlargingFilter(sourceHeight: Int, drawnHeight: Int): FilterQuality =
if (sourceHeight < drawnHeight) FilterQuality.None else FilterQuality.High
/**
* The image on its own, as large as it fits, with pinch to zoom.
*
* A dialog rather than a screen, so the platform's back gesture returns to the transcript instead
* of leaving the app. It opens fitted -- the whole image visible, which is the thing a reader wants
* first -- and zoom is theirs from there.
*/
@Composable
private fun ImageViewer(image: ImageBitmap, onClose: () -> Unit) {
Dialog(
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
var scale by remember { mutableFloatStateOf(1f) }
var offsetX by remember { mutableFloatStateOf(0f) }
var offsetY by remember { mutableFloatStateOf(0f) }
Box(
Modifier.fillMaxSize()
.background(Color.Black)
.clickable(onClick = onClose)
.pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, _ ->
// Floor of 1 so the image cannot be pinched smaller than fitted, which is
// already the whole of it; a ceiling so it cannot be lost off-screen.
scale = (scale * zoom).coerceIn(1f, 8f)
if (scale > 1f) {
offsetX += pan.x
offsetY += pan.y
} else {
offsetX = 0f
offsetY = 0f
}
}
},
contentAlignment = Alignment.Center,
) {
Image(
bitmap = image,
contentDescription = "Attached image",
contentScale = ContentScale.Fit,
// Zoomed in, the reader is looking at pixels on purpose.
filterQuality = FilterQuality.None,
modifier =
Modifier.fillMaxSize().graphicsLayer {
scaleX = scale
scaleY = scale
translationX = offsetX
translationY = offsetY
},
)
}
}
}