Merge branch 'main' of git.arirex.me:iris/ai-app

The drag-right-to-go-back gesture was removed on the other side while the
file explorer was being built on this one, so the explorer's own use of it
goes with it: back inside the explorer is the arrow and the platform's own
gesture, which is what the rest of the app now has too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-04 00:00:48 -04:00
commit 9c4d33273b
8 files changed
+130 -109

No files matched your search

+4
View File
@@ -358,6 +358,10 @@ first if a remote spawn ever mangles an argument.
reply into the tool output under it -- and a container per row leaves reply into the tool output under it -- and a container per row leaves
whatever was drawn without one silently unselectable, which nothing on whatever was drawn without one silently unselectable, which nothing on
screen reports. Rows keep their tap handlers; selection is a long press. screen reports. Rows keep their tap handlers; selection is a long press.
**An inline code chip is drawn behind the text** rather than as the
renderer's span background, because a span background is part of the
text's own drawing and hid the selection under it -- see
`appendCodeChip` in `MarkdownLinks.kt` and TRANSCRIPT_RENDERING.md.
- **A session can be moved to another directory** from the settings dialog - **A session can be moved to another directory** from the settings dialog
(`POST /sessions/{id}/cwd`). It stops the process, because a working (`POST /sessions/{id}/cwd`). It stops the process, because a working
directory is settled at spawn; the next message starts it in the new one. directory is settled at spawn; the next message starts it in the new one.
+1 -1
View File
@@ -215,7 +215,7 @@ which highlighting is on -- see "Numbers to measure".
`FilesScreen` is composed **on top of** the session in the same `Box`, and `FilesScreen` is composed **on top of** the session in the same `Box`, and
the session stays composed under it: its event stream keeps flowing, its the session stays composed under it: its event stream keeps flowing, its
scroll position and draft stay where they were, and returning from a file scroll position and draft stay where they were, and returning from a file
costs nothing. Back -- the button, the platform gesture and `swipeBack` -- costs nothing. Back -- the button and the platform gesture --
clears `files` when it is set and goes to the list otherwise. Inside the clears `files` when it is set and goes to the list otherwise. Inside the
explorer the same back steps one level: editor → viewer (with the unsaved explorer the same back steps one level: editor → viewer (with the unsaved
question), viewer → listing, listing → parent directory it came from, and question), viewer → listing, listing → parent directory it came from, and
+20
View File
@@ -72,6 +72,26 @@ the inline builder draws nothing for a node type it does not know (a week
of blank headings). Tables go through `LinkedTable`/`LinkedTableRow` so of blank headings). Tables go through `LinkedTable`/`LinkedTableRow` so
cells get the same treatment. cells get the same treatment.
**An inline code chip is drawn behind the text, not as a span
background.** A `SpanStyle` background is part of the text's own drawing
and the text node draws the selection *under* the glyphs, so an opaque
chip hid the selection: selecting a sentence highlighted every word of it
except the ones in backticks, and there is no way to reorder that -- the
order is the node's. `appendCodeChip` therefore takes the code span from
the renderer's builder, keeps its style and its space of padding either
side but drops the background, and marks the range; `LinkedText` draws
those ranges in a `drawBehind`, which is under both the selection and the
glyphs -- the same place a fenced block's box already was, which is why
one of those always looked right. Geometry is one box per line, from the
bounding boxes of the run's first and last characters, taken as far as the
line's `visibleEnd`: `getPathForRange` is a *selection* shape and runs to
the right edge of every line but the last, which left a full-width empty
chip behind whenever the code wrapped, and `visibleEnd` is what makes the
chip and the selection rectangle stop in the same place. Measured against
the same build without it, streaming 60 paragraphs of three chips each:
measure 755ms against 776ms, record 327ms against 321ms, transcript draw
0.22ms in both -- noise.
**Text draws on the platform directly.** A paragraph without an image **Text draws on the platform directly.** A paragraph without an image
skips the renderer's `MarkdownText`, which charges every paragraph for the skips the renderer's `MarkdownText`, which charges every paragraph for the
possibility of inline images (placement callback, derived inline-content possibility of inline images (placement callback, derived inline-content
@@ -208,10 +208,10 @@ fun AppRoot(
// row key. Only reachable since a notification can move straight from one session to // row key. Only reachable since a notification can move straight from one session to
// another; every other way here passes through [Screen.Main], which disposes it anyway. // another; every other way here passes through [Screen.Main], which disposes it anyway.
key(here.summary.id) { key(here.summary.id) {
// The gesture goes on a box around the screen rather than inside it, so it is the // A Box so the explorer can be drawn *over* the session rather than instead of
// outermost thing in the tree and everything within has already had its chance at // it; the session stays composed underneath. No imePadding here, for the reason
// the drag. See [swipeBack]. No imePadding here, for the reason above. // above -- the explorer adds its own, since it has a text field.
Box(Modifier.swipeBack(goToMain)) { Box {
SessionScreen( SessionScreen(
settings = current, settings = current,
summary = here.summary, summary = here.summary,
@@ -220,9 +220,8 @@ fun AppRoot(
share = share, share = share,
onShareTaken = { share = null }, onShareTaken = { share = null },
) )
// Over the session, in the same Box, with the session still composed beneath // Its own back handler is registered after this screen's, so it is the one the
// it. Its own back handler is registered after this screen's, so it is the one // platform asks first, and it steps back inside itself before closing.
// the platform asks first, and it steps back inside itself before closing.
here.files?.let { target -> here.files?.let { target ->
FilesScreen( FilesScreen(
settings = current, settings = current,
@@ -233,7 +232,7 @@ fun AppRoot(
} }
} }
is Screen.Spawn -> is Screen.Spawn ->
Box(Modifier.imePadding().swipeBack(goToMain)) { Box(Modifier.imePadding()) {
SpawnScreen( SpawnScreen(
settings = current, settings = current,
onSpawned = { spawned -> onSpawned = { spawned ->
@@ -244,7 +243,7 @@ fun AppRoot(
) )
} }
is Screen.Settings -> is Screen.Settings ->
Box(Modifier.imePadding().swipeBack(goToMain)) { Box(Modifier.imePadding()) {
SettingsScreen( SettingsScreen(
existing = current, existing = current,
onSaved = { saved -> onSaved = { saved ->
@@ -76,8 +76,8 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
val listings = remember { mutableStateMapOf<String, LoadState<Listing>>() } val listings = remember { mutableStateMapOf<String, LoadState<Listing>>() }
var creating by remember { mutableStateOf(false) } var creating by remember { mutableStateOf(false) }
// Edit mode and whether anything has been typed live here rather than in the pane below, // Edit mode and whether anything has been typed live here rather than in the pane below,
// because they are what back has to know about -- and back arrives from three places (the // because they are what back has to know about -- and back arrives from two places, the arrow
// button, the platform gesture, the swipe) which must all mean the same thing. // and the platform's own gesture, which must mean the same thing.
var editing by remember { mutableStateOf(false) } var editing by remember { mutableStateOf(false) }
var dirty by remember { mutableStateOf(false) } var dirty by remember { mutableStateOf(false) }
var askUnsaved by remember { mutableStateOf(false) } var askUnsaved by remember { mutableStateOf(false) }
@@ -125,9 +125,6 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
// layout note), so the explorer adds its own -- otherwise the editor types under the // layout note), so the explorer adds its own -- otherwise the editor types under the
// keyboard. // keyboard.
.imePadding() .imePadding()
// Innermost wins, so this takes the gesture before the session's own swipe-to-list
// does, and the reader steps back through the explorer rather than out of it.
.swipeBack(::back)
) { ) {
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
when (val spot = here) { when (val spot = here) {
@@ -368,15 +368,10 @@ private fun MarkdownRoot(
// exactly a card's own fill, so a fenced block inside a tool call had no // exactly a card's own fill, so a fenced block inside a tool call had no
// background at all and one in a reply read as a step *up* out of the page. // background at all and one in a reply read as a step *up* out of the page.
codeBackground = rawSurface, codeBackground = rawSurface,
// The same colour, but let through. An inline span's background is part of the // The same colour. Not drawn by the renderer as a span background but by
// *text's* own drawing and the selection rectangle is drawn underneath it, so an // [LinkedText] behind the text, so a selection lands on top of it as it does on a
// opaque chip hides the selection completely: selecting a sentence highlighted // fenced block -- see `appendCodeChip`.
// every word of it except the ones in backticks, which is a difference in inlineCodeBackground = rawSurface,
// appearance the reader has no way to account for. Translucent, the selection
// shows through and the chip still reads as one step down from the page -- there
// is no way to draw it over the selection instead, since the order is the text
// node's.
inlineCodeBackground = rawSurface.copy(alpha = INLINE_CODE_ALPHA),
// The same tint a code block gets, rather than the renderer's 2%-alpha default: // The same tint a code block gets, rather than the renderer's 2%-alpha default:
// two adjacent tints that differ by a fiftieth read as one flat block on a phone, // two adjacent tints that differ by a fiftieth read as one flat block on a phone,
// so the table would have had a border-less grid and nothing saying where it began. // so the table would have had a border-less grid and nothing saying where it began.
@@ -719,12 +714,3 @@ class ParsedReplies {
ready.clear() ready.clear()
} }
} }
/**
* How much of the inline-code chip's fill is its own colour, the rest being whatever it sits on.
*
* High enough that the chip is still a clear step down from the page, low enough that a selection
* under it changes what the chip looks like. Both halves are the point: at 1.0 the chip was the
* only part of a selected sentence that did not look selected.
*/
private const val INLINE_CODE_ALPHA = 0.6f
@@ -9,7 +9,10 @@ import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.node.Ref import androidx.compose.ui.node.Ref
@@ -96,12 +99,23 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
val layout = remember { Ref<TextLayoutResult>() } val layout = remember { Ref<TextLayoutResult>() }
// The renderer's own rule for a style that names no colour: the theme's text colour. // The renderer's own rule for a style that names no colour: the theme's text colour.
val color = if (style.color.isSpecified) style.color else LocalMarkdownColors.current.text val color = if (style.color.isSpecified) style.color else LocalMarkdownColors.current.text
val chips = remember(text) { text.getStringAnnotations(CODE_CHIP, 0, text.length) }
val chipColor = LocalMarkdownColors.current.inlineCodeBackground
// Filled in by `onTextLayout`, which runs in the layout phase, so the draw of the same frame
// finds it set -- no state needed, and a relayout redraws the node anyway.
val chipFills = remember { Ref<List<Rect>>() }
val chipFill =
if (chips.isEmpty()) Modifier
else
Modifier.drawBehind {
chipFills.value?.forEach { drawRect(chipColor, it.topLeft, it.size) }
}
BasicText( BasicText(
text = text, text = text,
modifier = modifier =
// A tap here is either a link or the card's; see [LocalMarkdownTap] for why the // A tap here is either a link or the card's; see [LocalMarkdownTap] for why the
// second one has to be answered from inside the text rather than left to the card. // second one has to be answered from inside the text rather than left to the card.
modifier.pointerInput(text, onPlainTap) { modifier.then(chipFill).pointerInput(text, onPlainTap) {
awaitEachGesture { awaitEachGesture {
// Unconsumed is not required: something outside may already be tracking this // Unconsumed is not required: something outside may already be tracking this
// press, and it is still the press that may land on a link. // press, and it is still the press that may land on a link.
@@ -130,7 +144,10 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
}, },
style = style, style = style,
color = { color }, color = { color },
onTextLayout = { layout.value = it }, onTextLayout = {
layout.value = it
chipFills.value = chips.flatMap { chip -> it.chipRects(chip.start, chip.end) }
},
) )
} }
@@ -188,15 +205,83 @@ private fun AnnotatedString.linkAt(layout: TextLayoutResult?, position: Offset):
private const val LINK_URL = "url" private const val LINK_URL = "url"
/** /**
* The renderer's annotator settings with [appendPlainLink] answering for links. The annotator needs * Appends [node] as inline code -- the renderer's own span, padded by a space each side as it does,
* the settings to draw a link's label, and the settings hold the annotator, so the reference goes * but with no background of its own -- if it is a code span; false leaves anything else to the
* through a cell filled in once both exist. * renderer.
*
* The chip's fill is drawn by [LinkedText] from the layout instead, behind the text. A span's
* background is part of the text's own drawing, and the text node draws the selection first and the
* glyphs over it, so a chip painted as a span background covered the selection: selecting a
* sentence highlighted every word of it except the ones in backticks. Anything drawn by a modifier
* on the text is under both, which is where a fenced block's box already is and why one of those
* always looked right. The [CODE_CHIP] annotation is what says where the fill goes.
*/
private fun appendCodeChip(
builder: AnnotatedString.Builder,
content: String,
node: ASTNode,
settings: AnnotatorSettings,
): Boolean {
if (node.type != MarkdownElementTypes.CODE_SPAN) return false
builder.pushStringAnnotation(CODE_CHIP, "")
builder.pushStyle(settings.codeSpanStyle.copy(background = Color.Unspecified))
builder.append(' ')
// The backticks are the first and last children.
builder.buildMarkdownAnnotatedString(content, node.children.drop(1).dropLast(1), settings)
builder.append(' ')
builder.pop()
builder.pop()
return true
}
private const val CODE_CHIP = "code"
/**
* One box per line of the text [start] until [end] covers, in the layout's own coordinates.
*
* Not `getPathForRange`, which is the geometry of a *selection* and runs to the right edge of every
* line but the last, so a chip whose code wrapped left a full-width empty box behind on the line
* above. Each line is taken as far as `visibleEnd`, which is where that line's own trailing space
* stops being drawn: the same rule the selection rectangle obeys, so the two agree rather than the
* chip sticking a space out past the end of a selected line. It is also what leaves nothing behind
* when the only thing to reach a line is the space a chip is padded with.
*
* A run's extent is taken from the boxes of its first and last characters, which is exact while a
* line reads in one direction; mixed directions inside a code span would draw one box across the
* whole run rather than one per direction, and code spans are code.
*/
private fun TextLayoutResult.chipRects(start: Int, end: Int): List<Rect> {
val rects = mutableListOf<Rect>()
for (line in getLineForOffset(start)..getLineForOffset(end - 1)) {
val from = maxOf(start, getLineStart(line))
val to = minOf(end, getLineEnd(line, visibleEnd = true))
if (from >= to) continue
val head = getBoundingBox(from)
val tail = getBoundingBox(to - 1)
rects +=
Rect(
left = minOf(head.left, tail.left),
top = minOf(head.top, tail.top),
right = maxOf(head.right, tail.right),
bottom = maxOf(head.bottom, tail.bottom),
)
}
return rects
}
/**
* The renderer's annotator settings with [appendPlainLink] answering for links and [appendCodeChip]
* for inline code. The annotator needs the settings to draw a link's label, and the settings hold
* the annotator, so the reference goes through a cell filled in once both exist.
*/ */
@Composable @Composable
private fun plainLinkSettings(): AnnotatorSettings { private fun plainLinkSettings(): AnnotatorSettings {
val cell = remember { Ref<AnnotatorSettings>() } val cell = remember { Ref<AnnotatorSettings>() }
val annotator = remember { val annotator = remember {
markdownAnnotator { content, node -> appendPlainLink(this, content, node, cell.value!!) } markdownAnnotator { content, node ->
appendPlainLink(this, content, node, cell.value!!) ||
appendCodeChip(this, content, node, cell.value!!)
}
} }
return annotatorSettings(annotator = annotator).also { cell.value = it } return annotatorSettings(annotator = annotator).also { cell.value = it }
} }
@@ -1,70 +0,0 @@
package com.example.aiapp
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
/**
* Dragging the screen to the right to step back to the one behind it.
*
* The platform's own back gesture is a swipe from the very edge, and only from there; on a phone
* held in one hand the way back from a session is either that narrow strip or the arrow at the top
* left, which is the far corner from the thumb. This is the same movement from anywhere on the
* screen.
*
* **It loses every argument.** The gesture is a plain horizontal [draggable] on the outside of the
* screen, so anything inside that wants horizontal drags has already taken them by the time this
* would see them: pointer events reach the innermost node first, and a drag a child has consumed
* never crosses this modifier's touch slop. That is what keeps a wide code fence, a table scrolled
* sideways or a text selection working -- they are the components the reader meant, and this is
* only what is left over. Vertical drags are not its orientation, so the transcript scrolls
* untouched.
*
* The screen follows the finger rather than jumping at the end, because a gesture with no feedback
* cannot be aborted: the reader has to be able to see it starting and change their mind. Released
* short of [SWIPE_BACK_TRAVEL] it slides back and nothing happens. Right rather than left, and only
* right, since there is nothing forward of these screens to go to.
*/
@Composable
fun Modifier.swipeBack(onBack: () -> Unit): Modifier {
val offset = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
val travel = with(LocalDensity.current) { SWIPE_BACK_TRAVEL.toPx() }
return draggable(
state =
rememberDraggableState { delta ->
// Rightward only: a leftward drag stays at zero rather than lifting the
// screen off its left edge, which would look like a gesture that does
// something and does not.
scope.launch { offset.snapTo((offset.value + delta).coerceAtLeast(0f)) }
},
orientation = Orientation.Horizontal,
onDragStopped = {
if (offset.value >= travel) {
onBack()
// Straight back rather than animated: the screen this was moving is being
// replaced, and animating it home first would show the old one sliding back
// into place after the new one had arrived.
offset.snapTo(0f)
} else {
offset.animateTo(0f)
}
},
)
// Read inside the block, so following the finger is a draw-phase change and costs no
// recomposition of the screen being dragged.
.graphicsLayer { translationX = offset.value }
}
/** How far the screen has to be pulled for letting go to mean "back" rather than "never mind". */
private val SWIPE_BACK_TRAVEL: Dp = 96.dp