Merge branch 'main' of git.arirex.me:iris/ai-app
This commit is contained in:
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)
|
||||
|
||||
Reference in new issue
Block a user