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

This commit is contained in:
iris committed 2026-08-30 12:41:47 -04:00
commit a4ec8cfbd8
4 files changed
+206 -20

No files matched your search

+15
View File
@@ -671,6 +671,21 @@ dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21). Screens:
output; a spinner while `ToolStart` has no matching `ToolEnd`).
- Question cards inline: option buttons for AskUserQuestion, allow/deny for
permissions, free-text where allowed.
- Expanding a row keeps still **the end nearest the tap**: touch a row's
upper half and its top edge holds, so it opens downwards; touch its
lower half and the bottom edge holds, as the list does by default.
Which half, rather than which control, so that everything that opens
behaves alike whether or not it has a control at each end — a group's
heading and foot bar simply fall in the halves they already occupy. The transcript is laid out from the
bottom, so a row's bottom edge is anchored for free and the top one
has to be arranged. The correction lives in the *layout* phase
(`Modifier.holdTopEdge`): the measurement that discovers the row's new
height asks the list to shift by that much, via
`requestScrollToItem`, before anything is drawn. Doing it from an
effect instead means the wrong position is drawn once first, which
reads as a flick and gets worse the faster the screen refreshes
(2026-08-30, asked for after groups opened upwards and sent their own
heading off the top of the screen).
- Input bar: text, attach (camera/gallery/file), send — **always enabled**;
mid-run sends become steering messages.
- Top bar: model chip (tap to change), stop button while running, token
@@ -1,6 +1,5 @@
package com.example.aiapp
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -32,10 +31,10 @@ import androidx.compose.ui.unit.dp
fun PeerMessageRow(
item: TranscriptItem.PeerNote,
expanded: Boolean,
onToggle: () -> Unit,
onToggle: (Float) -> Unit,
modifier: Modifier = Modifier,
) {
Card(modifier.fillMaxWidth().clickable(onClick = onToggle)) {
Card(modifier.fillMaxWidth().clickableAt(onToggle)) {
Column(Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Message from ${item.from}", style = MaterialTheme.typography.titleSmall)
@@ -47,6 +47,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
@@ -79,6 +82,67 @@ private const val RECONNECT_DELAY_MS = 1500L
*/
private const val HISTORY_LOOKAHEAD = 8
/**
* Which row was asked to hold its top edge, and how tall it was when it last measured.
*
* Deliberately *not* snapshot state, and that is the point of the whole class. Both fields are
* written from the layout phase; a snapshot write there that composition reads would schedule
* another recomposition, and the correction has to land inside the frame that is already being laid
* out rather than in a later one. Nothing observes these, so nothing needs to.
*
* [key] is cleared by the resize it was set for, so it cannot be spent on an unrelated one.
*/
private class TopEdgeHold {
var key: Any? = null
}
/**
* Where a row is on screen, so a tap on it can be told which half it landed in.
*
* Not snapshot state, for the same reason as [TopEdgeHold]: written from layout, read from a click,
* and observed by nothing.
*/
private class RowBounds {
var top = 0f
var height = 0f
/** Above this is the row's top half, below it the bottom half. */
val middle
get() = top + height / 2
}
/** One row's height between layouts, so a change in it can be noticed. See [holdTopEdge]. */
private class LastHeight {
var value: Int? = null
}
/**
* Keeps this row's top edge where it is when the row changes height, if it was asked to.
*
* This runs in the *layout* phase, from the measurement that discovers the new height, and that is
* the whole reason it is a modifier rather than an effect. A correction posted to a coroutine
* arrives a frame or more after the layout it is correcting, so the wrong position is drawn once
* before the right one -- visible as a flick, and worse the faster the screen refreshes. Scrolling
* from here happens before anything is drawn, so there is no frame to see and nothing that depends
* on how quickly the correction is scheduled.
*
* [hold] is given the change in height. The row's bottom edge is held by the list, so a scroll of
* exactly that much is what leaves the top edge where it was.
*/
@Composable
private fun Modifier.holdTopEdge(key: Any, held: TopEdgeHold, hold: (Int) -> Unit): Modifier {
val last = remember { LastHeight() }
return onSizeChanged { size ->
val previous = last.value
last.value = size.height
// A first measurement has no previous height to have moved from, and a row that came
// back after being scrolled away is a first measurement again.
if (previous == null || previous == size.height || held.key != key) return@onSizeChanged
held.key = null
hold(size.height - previous)
}
}
/**
* What the transcript renders: the event stream folded into displayable rows (see [foldEvent]). The
* stream is the only data source -- opening this screen replays from seq 0, and a reconnect resumes
@@ -477,6 +541,7 @@ fun SessionScreen(
onSettings: () -> Unit,
) {
val scope = rememberCoroutineScope()
val topEdgeHeld = remember { TopEdgeHold() }
var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
var status by remember { mutableStateOf(summary.status) }
// Seeded from the row this screen was opened from, so a conversation already under way says
@@ -669,6 +734,29 @@ fun SessionScreen(
}
}
/**
* Changes a row's height while the end the reader touched stays where it is.
*
* The transcript is laid out from the bottom, so every row's *bottom* edge is what the list
* holds still and all growth goes upward. That is what a tap in a row's lower half already
* gets, so it needs nothing: shut a group from the bar at its foot and what follows it does not
* move, which is what the reader is looking at down there. A tap in the upper half is the other
* case -- left alone it sends the heading under the reader's finger up off the screen and fills
* the space above it, so the calls appear on the far side of the control that produced them --
* and that one asks the row to hold its top edge instead.
*
* Which half decides it, rather than which control was pressed, so that everything that opens
* behaves the same way whether or not it happens to have a control at each end. A group has two
* and its heading and foot bar land in the halves they are already in; a single call is one
* card, and tapping low on an open one shuts it downward exactly as the bar does.
*
* The correction itself belongs to the measurement -- see [holdTopEdge].
*/
fun toggleAnchored(key: Any, row: RowBounds, at: Float, toggle: () -> Unit) {
if (at < row.middle) topEdgeHeld.key = key
toggle()
}
// A compaction reports nothing about its own progress -- measured against the CLI, which
// says it has started, and then says nothing at all until it is done. So what this counts is
// the one thing anybody here can measure: how long it has been going. A bar filling up would
@@ -1120,25 +1208,54 @@ fun SessionScreen(
// at the same end. Paging older history is the opposite insertion and was
// already fine, and stays fine, because a key survives both.
items(rows.asReversed(), key = { it.key }) { row ->
val bounds = remember { RowBounds() }
Box(
Modifier.onGloballyPositioned {
bounds.top = it.positionInRoot().y
bounds.height = it.size.height.toFloat()
}
.holdTopEdge(row.key, topEdgeHeld) { grew ->
// Requested rather than scrolled. Scrolling forces a remeasure,
// and forcing one from inside a measure throws; this is the form
// built to be asked for during layout and applied in that pass.
listState.requestScrollToItem(
listState.firstVisibleItemIndex,
listState.firstVisibleItemScrollOffset + grew,
)
}
) {
when (row) {
is TranscriptRow.Tools ->
ToolGroup(
group = row,
expanded = row.id in expandedGroups,
onToggle = {
onToggle = { at ->
toggleAnchored(row.key, bounds, at) {
expandedGroups =
if (row.id in expandedGroups) expandedGroups - row.id
if (row.id in expandedGroups)
expandedGroups - row.id
else expandedGroups + row.id
}
},
isToolExpanded = { it in expandedTools },
onToolToggle = { id ->
// Anchored on the group, not the call: opening one call makes
// the whole group taller, and the heading the reader is under
// is the group's.
onToolToggle = { id, at ->
toggleAnchored(row.key, bounds, at) {
expandedTools =
if (id in expandedTools) expandedTools - id
else expandedTools + id
}
},
onAnswer = { questionId, answers ->
act {
answerQuestion(settings, summary.id, questionId, answers)
answerQuestion(
settings,
summary.id,
questionId,
answers,
)
}
},
image = { ref -> SessionImage(settings, summary.id, ref) },
@@ -1157,11 +1274,13 @@ fun SessionScreen(
ToolCard(
tool = item,
expanded = item.id in expandedTools,
onToggle = {
onToggle = { at ->
toggleAnchored(row.key, bounds, at) {
expandedTools =
if (item.id in expandedTools)
expandedTools - item.id
else expandedTools + item.id
}
},
onAnswer = { questionId, answers ->
act {
@@ -1173,12 +1292,19 @@ fun SessionScreen(
)
}
},
image = { ref -> SessionImage(settings, summary.id, ref) },
image = { ref ->
SessionImage(settings, summary.id, ref)
},
)
is TranscriptItem.QuestionCard ->
QuestionRow(item) { answers ->
act {
answerQuestion(settings, summary.id, item.id, answers)
answerQuestion(
settings,
summary.id,
item.id,
answers,
)
}
}
is TranscriptItem.ErrorMsg ->
@@ -1202,17 +1328,20 @@ fun SessionScreen(
PeerMessageRow(
item = item,
expanded = item.seq in expandedNotes,
onToggle = {
onToggle = { at ->
toggleAnchored(row.key, bounds, at) {
expandedNotes =
if (item.seq in expandedNotes)
expandedNotes - item.seq
else expandedNotes + item.seq
}
},
)
}
}
}
}
}
// Only while the newest message is off-screen. Reading back
// through a conversation is a place to be, not a state to be
@@ -2,6 +2,8 @@ package com.example.aiapp
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -18,6 +20,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
@@ -99,6 +105,38 @@ fun groupToolRuns(items: List<TranscriptItem>): List<TranscriptRow> {
return rows
}
/** Where a click went down, and what it went down on. See [clickableAt]. */
private class TapPoint {
var coords: LayoutCoordinates? = null
var y = 0f
}
/**
* Clickable, and tells the click where on the screen the finger went down.
*
* A row holds still the end nearest the tap when it changes height, so the toggle has to say where
* it was touched. It cannot say which *end* it was: a group is one row with a control at each end
* and a call in the middle, and only the row knows where its own ends are. So this reports a
* position in root coordinates and leaves the meaning to whoever owns the row.
*
* Built on `clickable` rather than replacing it, because `clickable` is what draws the ripple and
* what puts a click action in front of assistive technology. The position is read on the initial
* pass and nothing is consumed, so the click still happens exactly as it would have.
*/
@Composable
fun Modifier.clickableAt(onClick: (Float) -> Unit): Modifier {
val tap = remember { TapPoint() }
return onGloballyPositioned { tap.coords = it }
.pointerInput(Unit) {
awaitEachGesture {
val down =
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
tap.y = tap.coords?.localToRoot(down.position)?.y ?: 0f
}
}
.clickable { onClick(tap.y) }
}
/**
* Several calls under one heading, closed until somebody asks.
*
@@ -113,14 +151,17 @@ fun groupToolRuns(items: List<TranscriptItem>): List<TranscriptRow> {
fun ToolGroup(
group: TranscriptRow.Tools,
expanded: Boolean,
onToggle: () -> Unit,
/**
* Told where it was pressed, because this row has a control at each end -- see [clickableAt].
*/
onToggle: (Float) -> Unit,
isToolExpanded: (String) -> Boolean,
onToolToggle: (String) -> Unit,
onToolToggle: (String, Float) -> Unit,
onAnswer: (questionId: String, answers: List<String>) -> Unit,
image: @Composable (String) -> Unit,
) {
if (!expanded) {
Card(Modifier.fillMaxWidth().clickable(onClick = onToggle)) {
Card(Modifier.fillMaxWidth().clickableAt(onToggle)) {
Text(
"Called ${group.calls.size} tools",
style = MaterialTheme.typography.titleSmall,
@@ -133,28 +174,30 @@ fun ToolGroup(
Text(
"Called ${group.calls.size} tools",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(12.dp),
modifier = Modifier.fillMaxWidth().clickableAt(onToggle).padding(12.dp),
)
group.calls.forEach { call ->
ToolCard(
tool = call,
expanded = isToolExpanded(call.id),
onToggle = { onToolToggle(call.id) },
onToggle = { at -> onToolToggle(call.id, at) },
onAnswer = onAnswer,
image = image,
)
}
// 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)
}
}
/** The bottom half of a group's toggle: an arrow back up to its heading. */
@Composable
private fun CollapseBar(onToggle: () -> Unit) {
private fun CollapseBar(onToggle: (Float) -> Unit) {
val colour = MaterialTheme.colorScheme.onSurfaceVariant
Row(
Modifier.fillMaxWidth()
.clickable(onClick = onToggle)
.clickableAt(onToggle)
.semantics { contentDescription = "Collapse these tool calls" }
.padding(vertical = 10.dp),
horizontalArrangement = Arrangement.Center,
@@ -181,14 +224,14 @@ private fun CollapseBar(onToggle: () -> Unit) {
fun ToolCard(
tool: TranscriptItem.ToolRun,
expanded: Boolean,
onToggle: () -> Unit,
onToggle: (Float) -> Unit,
onAnswer: (questionId: String, answers: List<String>) -> Unit,
image: @Composable (String) -> Unit = {},
) {
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().clickable(onClick = onToggle)) {
Card(Modifier.fillMaxWidth().clickableAt(onToggle)) {
Column(Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(tool.tool, style = MaterialTheme.typography.titleSmall)