@@ -26,7 +26,9 @@ import androidx.compose.runtime.LaunchedEffect
26import androidx.compose.runtime.getValue
26import androidx.compose.runtime.getValue
27import androidx.compose.runtime.mutableStateOf
27import androidx.compose.runtime.mutableStateOf
28import androidx.compose.runtime.remember
28import androidx.compose.runtime.remember
29+import androidx.compose.runtime.rememberUpdatedState
29import androidx.compose.runtime.setValue
30import androidx.compose.runtime.setValue
31+import androidx.compose.runtime.snapshotFlow
30import androidx.compose.ui.Alignment
32import androidx.compose.ui.Alignment
31import androidx.compose.ui.Modifier
33import androidx.compose.ui.Modifier
32import androidx.compose.ui.draw.clip
34import androidx.compose.ui.draw.clip
@@ -35,6 +37,7 @@ import androidx.compose.ui.semantics.Role
35import androidx.compose.ui.text.TextStyle
37import androidx.compose.ui.text.TextStyle
36import androidx.compose.ui.text.font.FontFamily
38import androidx.compose.ui.text.font.FontFamily
37import androidx.compose.ui.unit.dp
39import androidx.compose.ui.unit.dp
40+import kotlinx.coroutines.flow.conflate
38import kotlinx.coroutines.delay
41import kotlinx.coroutines.delay
39import com.mikepenz.markdown.compose.components.MarkdownComponentModel
42import com.mikepenz.markdown.compose.components.MarkdownComponentModel
40import com.mikepenz.markdown.compose.components.markdownComponents
43import com.mikepenz.markdown.compose.components.markdownComponents
@@ -55,6 +58,14 @@ import soy.iko.opencode.data.network.NetworkConfig
55 * whenever the content string changes). To avoid O(n²) work during long responses, the
58 * whenever the content string changes). To avoid O(n²) work during long responses, the
56 * rendered content is throttled — the latest [markdown] is committed to the renderer at
59 * rendered content is throttled — the latest [markdown] is committed to the renderer at
57 * most once per frame (~16ms), so a burst of tokens coalesces into a single re-parse.
60 * most once per frame (~16ms), so a burst of tokens coalesces into a single re-parse.
62+ * The throttle uses a single long-lived `LaunchedEffect(Unit)` that observes the markdown
63+ * parameter via `rememberUpdatedState` + `snapshotFlow` + `conflate` + `collect`. A keyed
64+ * effect (`LaunchedEffect(markdown)`) cancels and restarts on every token; if tokens arrive
65+ * faster than the throttle delay, the in-flight `delay` is cancelled before it completes,
66+ * starving the render and showing stale content until the stream pauses. The conflated
67+ * `collect` lets the delay run to completion, then picks up the most recent buffered value,
68+ * so rendering always progresses even under continuous fast streaming.
59@OptIn(ExperimentalFoundationApi::class)
70@OptIn(ExperimentalFoundationApi::class)
60@Composable
71@Composable
@@ -64,21 +75,29 @@ fun MarkdownText(
64 style: TextStyle = MaterialTheme.typography.bodyLarge,
75 style: TextStyle = MaterialTheme.typography.bodyLarge,
66 val context = LocalContext.current
77 val context = LocalContext.current
67− // Throttle the rendered content so a rapid burst of streaming tokens coalesces
78+ // Bridge the markdown parameter into snapshot state so snapshotFlow can observe it.
68− // into a single markdown re-parse per frame instead of one per token. The delay
79+ val markdownState = rememberUpdatedState(markdown)
69− // is only applied when the content is growing incrementally (streaming) — a
70− // content switch or initial render proceeds immediately.
71 // Keyed to a content-prefix so switching to a different message resets
80 // Keyed to a content-prefix so switching to a different message resets
72 // immediately instead of showing the old message for one frame.
81 // immediately instead of showing the old message for one frame.
73 var renderedContent by remember(markdown.take(32)) { mutableStateOf(markdown) }
82 var renderedContent by remember(markdown.take(32)) { mutableStateOf(markdown) }
83+ // conflate() coalesces a burst of token updates into a single emission so the
84+ // collector sees only the latest value after the previous delay completes. Plain
85+ // collect (not collectLatest) is critical: collectLatest would cancel the delay
86+ // on every new value, reintroducing the same starvation the keyed effect had.
74− LaunchedEffect(markdown) {
87+ LaunchedEffect(markdown.take(32)) {
88+ snapshotFlow { markdownState.value }
91+ // Only throttle when the content is growing incrementally (streaming):
92+ // a content switch or initial render proceeds immediately.
75− if (renderedContent.isNotEmpty() &&
93+ if (renderedContent.isNotEmpty() &&
76− markdown.startsWith(renderedContent) &&
94+ md.startsWith(renderedContent) &&
77− markdown != renderedContent
95+ md != renderedContent
79− delay(NetworkConfig.streamingThrottleMs)
97+ delay(NetworkConfig.streamingThrottleMs)
81− renderedContent = markdown
99+ renderedContent = md
83 Markdown(
102 Markdown(
84 content = renderedContent,
103 content = renderedContent,
@@ -101,7 +120,13 @@ fun MarkdownText(
101@Composable
120@Composable
102private fun CodeWithCopy(model: MarkdownComponentModel) {
121private fun CodeWithCopy(model: MarkdownComponentModel) {
103 val context = LocalContext.current
122 val context = LocalContext.current
123+ // Key on the content string + the AST node's offset range (stable ints) instead of
124+ // the model itself. MarkdownComponentModel is not @Immutable/@Stable (it holds an
125+ // ASTNode), so remembering on it re-executes extractCode on every recomposition.
126+ val node = model.node
127+ val code = remember(model.content, node.startOffset, node.endOffset) {
104− val code = androidx.compose.runtime.remember(model) { extractCode(model.content, model.node) }
128+ extractCode(model.content, node)
105 val codeStyle = model.typography.code.copy(fontFamily = FontFamily.Monospace)
130 val codeStyle = model.typography.code.copy(fontFamily = FontFamily.Monospace)
107 modifier = Modifier
132 modifier = Modifier
@@ -143,10 +168,18 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
143 * Extract the raw code text from a markdown code node, stripping fence markers
168 * Extract the raw code text from a markdown code node, stripping fence markers
144 * (```/~~~ and the language tag) so only the code content is copied.
169 * (```/~~~ and the language tag) so only the code content is copied.
146−private fun extractCode(content: String, node: ASTNode): String {
171+private fun extractCode(content: String, node: ASTNode): String =
147− val raw = node.getTextInNode(content).toString()
148− val isOpenFence = node.type == MarkdownElementTypes.CODE_FENCE
172+ extractCodeText(node.getTextInNode(content).toString(), node.type == MarkdownElementTypes.CODE_FENCE)
175+ * Strip fence markers (```/~~~ and the language tag) from a raw code node text so
176+ * only the code content is returned. Extracted as a pure function for testability.
178+ * @param raw the raw text of the code node (including fence markers for fenced blocks)
179+ * @param isFenced true for CODE_FENCE nodes, false for indented CODE_BLOCK nodes
181+internal fun extractCodeText(raw: String, isFenced: Boolean): String {
149− if (!isOpenFence) return raw.trimIndent()
182+ if (!isFenced) return raw.trimIndent()
150 val lines = raw.lines()
183 val lines = raw.lines()
151 val body = lines.drop(1).toMutableList()
184 val body = lines.drop(1).toMutableList()
152 if (body.isNotEmpty() && (body.last().startsWith("```") || body.last().startsWith("~~~"))) {
185 if (body.isNotEmpty() && (body.last().startsWith("```") || body.last().startsWith("~~~"))) {