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
@@ -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
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user