Merge remote-tracking branch 'origin/main'

# Conflicts:
#	app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt
This commit is contained in:
iris committed 2026-08-30 00:49:34 -04:00
commit da65c1571f
3 files changed
+124 -38

No files matched your search

@@ -2,6 +2,7 @@ package com.example.aiapp
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.font.FontFamily
@@ -10,6 +11,7 @@ import androidx.compose.ui.unit.TextUnit
import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.m3.markdownColor
import com.mikepenz.markdown.m3.markdownTypography
import com.mikepenz.markdown.model.parseMarkdown
/**
* An assistant's reply, rendered as the markdown it is written in.
@@ -24,8 +26,22 @@ import com.mikepenz.markdown.m3.markdownTypography
@Composable
fun MarkdownText(text: String, modifier: Modifier = Modifier) {
val body = MaterialTheme.typography.bodyLarge
// Parsed here, in the composition, rather than by the overload that takes the text itself.
// That one parses in a coroutine and draws an empty loading slot until the result arrives --
// so a row is measured at nothing before it is measured at its real height, and the
// transcript above it collapses and springs back. Seen with five replies on screen at once,
// every one of them blank, the whole conversation shrunk to fit a single screen; a moment
// later it was all there again. That is the "skipping up and down" this list must never do,
// and no amount of scroll anchoring can survive a row that lies about its height first.
//
// The cost is the parse on the main thread, which is the trade being made deliberately: a
// few milliseconds of work at the moment a row is composed, against a layout that is wrong
// every time one is. If a very long reply ever makes that visible, the answer is the
// renderer's streaming state -- which parses incrementally -- and not going back to a
// placeholder with no height.
val parsed = remember(text) { parseMarkdown(text) }
Markdown(
content = text,
parsed,
colors =
markdownColor(
text = MaterialTheme.colorScheme.onSurface,
@@ -489,12 +489,41 @@ fun SessionScreen(
var loadingHistory by remember { mutableStateOf(false) }
var ready by remember { mutableStateOf(false) }
val listState = rememberLazyListState()
// Whether the newest message is on screen right now. The list is laid out from the bottom
// (see the LazyColumn below), so "newest" is index 0 and being there is being at the start of
// it. This is what the jump-to-newest button watches: it is about what the reader can see.
//
// It is also the gate on everything the list draws -- see [record].
val atNewest by remember {
derivedStateOf {
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
}
}
// Transcript events that arrived while somebody was reading further back, in the order they
// arrived, waiting for them to return to the newest end. See [record] for why.
var held by remember { mutableStateOf(listOf<SeqEvent>()) }
// What is actually drawn: the transcript with runs of adjacent tool
// calls folded into one row each.
val rows = remember(items) { groupToolRuns(items) }
fun apply(entry: SeqEvent) {
lastSeq.set(entry.seq)
/**
* Everything the transcript list draws, from one event.
*
* Separate from [apply] because it is the half that is allowed to wait. The list anchors on the
* leading edge of its first visible item, which in this upside-down layout is that item's
* *bottom* -- so a row that grows pushes everything already on screen upwards, and the view
* travels toward the newest end without anybody scrolling. Measured against a reply streamed in
* four hundred pieces: scrolling back a screen and then waiting six seconds ended at the very
* bottom, forty lines further on than where it was left.
*
* Insertions were never the problem -- the list is keyed, so a row arriving at either end
* leaves the anchor where it is, and reading back through history while a session works has
* always been still. What cannot be allowed is a row that is already there changing height, and
* the one guarantee that covers every way that happens -- a reply streaming, a tool's output
* arriving, a queued bubble appearing above the anchor -- is to change nothing at all while
* somebody is reading further back.
*/
fun record(entry: SeqEvent) {
// The oldest event this view holds, which is what paging backwards
// starts from. Maintained here rather than by each loader: the
// first page and a stream reset both begin an empty view, and one
@@ -503,6 +532,44 @@ fun SessionScreen(
oldestSeq = entry.seq
moreHistory = entry.seq > 1L
}
val event = entry.event
// The message coming back is the session saying it has
// read it, so the bubble held below the indicator becomes
// the row `foldEvent` is about to add.
// Waiting, then read. Matched by id: the same message sent twice is two
// bubbles, and clearing by text would take away whichever matched first.
if (event is SessionEvent.MessageQueued) {
queued = queued + QueuedMessage(event.id, event.text, event.images)
}
if (event is SessionEvent.UserMessage) {
queued = queued.filterNot { it.id == event.id }
}
// Waiting, then gone: a command leaves this list when the session takes it,
// and the row it becomes is added by `foldEvent` in the same pass.
if (event is SessionEvent.CommandQueued) {
waitingCommands = waitingCommands + (event.id to event.text)
}
if (event is SessionEvent.CommandSent) {
waitingCommands = waitingCommands.filterNot { it.first == event.id }
}
// Kept as well as folded. Folding is one-way -- a tool's
// start and end become one row -- so a page arriving in
// front of what is already here cannot be stitched on
// without the events themselves.
loaded = loaded + event
items = foldEvent(items, entry)
}
/**
* One event, at the moment it arrives.
*
* What it says about the *session* -- running or not, which model, how many tokens -- lands
* immediately, because none of that is drawn in the list and freezing it would trade a
* transcript that jumps for a status row that lies. What it adds to the transcript goes through
* [record], which waits for the reader to be at the newest end.
*/
fun apply(entry: SeqEvent) {
lastSeq.set(entry.seq)
when (val event = entry.event) {
// Taken, not accumulated: the server's running total is on the event, and adding
// up the deltas this screen happened to receive counted one page of a conversation
@@ -532,34 +599,9 @@ fun SessionScreen(
}
status = event.state
}
// The message coming back is the session saying it has
// read it, so the bubble held below the indicator becomes
// the row `foldEvent` is about to add. Matched by text --
// all that distinguishes one message from an identical
// earlier one -- and only the first match, so two
// identical messages wait twice.
// Waiting, then read. Matched by id: the same message sent twice is two
// bubbles, and clearing by text would take away whichever matched first.
if (event is SessionEvent.MessageQueued) {
queued = queued + QueuedMessage(event.id, event.text, event.images)
}
if (event is SessionEvent.UserMessage) {
queued = queued.filterNot { it.id == event.id }
}
// Waiting, then gone: a command leaves this list when the session takes it,
// and the row it becomes is added by `foldEvent` in the same pass.
if (event is SessionEvent.CommandQueued) {
waitingCommands = waitingCommands + (event.id to event.text)
}
if (event is SessionEvent.CommandSent) {
waitingCommands = waitingCommands.filterNot { it.first == event.id }
}
// Kept as well as folded. Folding is one-way -- a tool's
// start and end become one row -- so a page arriving in
// front of what is already here cannot be stitched on
// without the events themselves.
loaded = loaded + event
items = foldEvent(items, entry)
// In order, always: one late event recorded ahead of the backlog would fold a
// streamed delta into whatever row happened to be last by then.
if (atNewest && held.isEmpty()) record(entry) else held = held + entry
}
}
}
@@ -632,6 +674,7 @@ fun SessionScreen(
// up pages the rest back in as it always does.
items = listOf()
loaded = listOf()
held = listOf()
oldestSeq = 0L
moreHistory = true
},
@@ -659,13 +702,17 @@ fun SessionScreen(
// stays started.
DisposableEffect(summary.id) { onDispose { activeStream.get()?.close() } }
// Whether the newest message is on screen right now. The list is laid out from the bottom
// (see the LazyColumn below), so "newest" is index 0 and being there is being at the start of
// it. This is what the jump-to-newest button watches: it is about what the reader can see.
val atNewest by remember {
derivedStateOf {
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
}
// Back at the newest end, so the backlog [apply] held can land. Everything at once rather
// than paced out: they are at the bottom, which is the one place the list is allowed to
// follow new content, and drip-feeding it would only make that following last longer.
LaunchedEffect(listState) {
snapshotFlow { atNewest && held.isNotEmpty() }
.collect { due ->
if (!due) return@collect
val backlog = held
held = listOf()
backlog.forEach { record(it) }
}
}
// Whether they *chose* to be there, which is a different question and the one that decides
// whether an arriving message brings the view with it.