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
+279 -93

No files matched your search

@@ -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,96 +1208,137 @@ 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 ->
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 = { questionId, answers ->
act {
answerQuestion(settings, summary.id, questionId, answers)
}
},
image = { ref -> SessionImage(settings, summary.id, ref) },
)
is TranscriptRow.Single ->
when (val item = row.item) {
is TranscriptItem.UserMsg ->
UserBubble(
settings = settings,
sessionId = summary.id,
text = item.text,
images = item.images,
)
is TranscriptItem.AssistantMsg -> AssistantMessage(item.text)
is TranscriptItem.ToolRun ->
ToolCard(
tool = item,
expanded = item.id in expandedTools,
onToggle = {
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 = { at ->
toggleAnchored(row.key, bounds, at) {
expandedGroups =
if (row.id in expandedGroups)
expandedGroups - row.id
else expandedGroups + row.id
}
},
isToolExpanded = { it in expandedTools },
// 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 (item.id in expandedTools)
expandedTools - item.id
else expandedTools + item.id
},
onAnswer = { questionId, answers ->
if (id in expandedTools) expandedTools - id
else expandedTools + id
}
},
onAnswer = { questionId, answers ->
act {
answerQuestion(
settings,
summary.id,
questionId,
answers,
)
}
},
image = { ref -> SessionImage(settings, summary.id, ref) },
)
is TranscriptRow.Single ->
when (val item = row.item) {
is TranscriptItem.UserMsg ->
UserBubble(
settings = settings,
sessionId = summary.id,
text = item.text,
images = item.images,
)
is TranscriptItem.AssistantMsg -> AssistantMessage(item.text)
is TranscriptItem.ToolRun ->
ToolCard(
tool = item,
expanded = item.id in expandedTools,
onToggle = { at ->
toggleAnchored(row.key, bounds, at) {
expandedTools =
if (item.id in expandedTools)
expandedTools - item.id
else expandedTools + item.id
}
},
onAnswer = { questionId, answers ->
act {
answerQuestion(
settings,
summary.id,
questionId,
answers,
)
}
},
image = { ref ->
SessionImage(settings, summary.id, ref)
},
)
is TranscriptItem.QuestionCard ->
QuestionRow(item) { answers ->
act {
answerQuestion(
settings,
summary.id,
questionId,
item.id,
answers,
)
}
},
image = { ref -> SessionImage(settings, summary.id, ref) },
)
is TranscriptItem.QuestionCard ->
QuestionRow(item) { answers ->
act {
answerQuestion(settings, summary.id, item.id, answers)
}
}
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.CommandRow -> CommandBubble(item.text)
is TranscriptItem.ClearedNote -> ClearedRow()
is TranscriptItem.CompactedNote -> CompactedRow(item)
is TranscriptItem.PeerNote ->
PeerMessageRow(
item = item,
expanded = item.seq in expandedNotes,
onToggle = {
expandedNotes =
if (item.seq in expandedNotes)
expandedNotes - item.seq
else expandedNotes + item.seq
},
)
}
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.CommandRow -> CommandBubble(item.text)
is TranscriptItem.ClearedNote -> ClearedRow()
is TranscriptItem.CompactedNote -> CompactedRow(item)
is TranscriptItem.PeerNote ->
PeerMessageRow(
item = item,
expanded = item.seq in expandedNotes,
onToggle = { at ->
toggleAnchored(row.key, bounds, at) {
expandedNotes =
if (item.seq in expandedNotes)
expandedNotes - item.seq
else expandedNotes + item.seq
}
},
)
}
}
}
}
}
@@ -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)