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

This commit is contained in:
iris committed 2026-08-30 13:41:06 -04:00
commit 257f4c85c1
2 files changed
+36 -23

No files matched your search

@@ -2,6 +2,8 @@ package com.example.aiapp
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextLinkStyles
@@ -11,7 +13,10 @@ import androidx.compose.ui.unit.TextUnit
import com.mikepenz.markdown.m3.Markdown import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.m3.markdownColor import com.mikepenz.markdown.m3.markdownColor
import com.mikepenz.markdown.m3.markdownTypography import com.mikepenz.markdown.m3.markdownTypography
import com.mikepenz.markdown.model.State
import com.mikepenz.markdown.model.parseMarkdown import com.mikepenz.markdown.model.parseMarkdown
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** /**
* An assistant's reply, rendered as the markdown it is written in. * An assistant's reply, rendered as the markdown it is written in.
@@ -26,20 +31,7 @@ import com.mikepenz.markdown.model.parseMarkdown
@Composable @Composable
fun MarkdownText(text: String, modifier: Modifier = Modifier) { fun MarkdownText(text: String, modifier: Modifier = Modifier) {
val body = MaterialTheme.typography.bodyLarge val body = MaterialTheme.typography.bodyLarge
// Parsed here, in the composition, rather than by the overload that takes the text itself. val parsed = parsedMarkdown(text)
// 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( Markdown(
parsed, parsed,
colors = colors =
@@ -112,3 +104,33 @@ fun MarkdownText(text: String, modifier: Modifier = Modifier) {
modifier = modifier, modifier = modifier,
) )
} }
/**
* [text] parsed: on the composing thread the first time this row is drawn, and off it every time
* afterwards.
*
* The first parse has to be inline. The renderer's own asynchronous path draws an empty loading
* slot until its 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.
*
* Every parse *after* the first is a different case, and it is the one that was costing: a reply
* arrives as hundreds of deltas, each one re-parsing the whole message it has grown into. Measured
* against `/stream 200` on the emulator, that was fifty-eight parses and 78ms of main-thread work
* in three seconds, with single parses reaching 7ms -- most of a frame at 60Hz and more than one at
* 120. Those go to a background thread, and the row keeps drawing the parse it already has until
* the new one lands, so there is never a frame without a height. What is on screen is always a
* real prefix of the reply rather than a guess at it; it is simply one parse behind.
*/
@Composable
private fun parsedMarkdown(text: String): State {
// The text each parse came from, so the first composition's is not immediately repeated.
val parsed = remember { mutableStateOf(text to parseMarkdown(text)) }
LaunchedEffect(text) {
if (parsed.value.first == text) return@LaunchedEffect
parsed.value = text to withContext(Dispatchers.Default) { parseMarkdown(text) }
}
return parsed.value.second
}
@@ -601,9 +601,6 @@ fun SessionScreen(
// screen starts with the end of the conversation and fetches earlier // screen starts with the end of the conversation and fetches earlier
// pages only when somebody scrolls to them. // pages only when somebody scrolls to them.
var oldestSeq by remember { mutableLongStateOf(0L) } var oldestSeq by remember { mutableLongStateOf(0L) }
// Every transcript event loaded, in order, beside the rows they folded
// into. See `apply`.
var loaded by remember { mutableStateOf(listOf<SessionEvent>()) }
// Sent, but not yet read by the session -- which is when the backend // Sent, but not yet read by the session -- which is when the backend
// records it and it comes back as a row. Until then it is drawn below // records it and it comes back as a row. Until then it is drawn below
// the working indicator, because that is where it is in the session's // the working indicator, because that is where it is in the session's
@@ -686,11 +683,6 @@ fun SessionScreen(
if (event is SessionEvent.CommandSent) { if (event is SessionEvent.CommandSent) {
waitingCommands = waitingCommands.filterNot { it.first == event.id } 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) items = foldEvent(items, entry)
} }
@@ -830,7 +822,6 @@ fun SessionScreen(
// screen -- `apply` refills them, and scrolling // screen -- `apply` refills them, and scrolling
// up pages the rest back in as it always does. // up pages the rest back in as it always does.
items = listOf() items = listOf()
loaded = listOf()
held = listOf() held = listOf()
oldestSeq = 0L oldestSeq = 0L
moreHistory = true moreHistory = true