@@ -26,7 +26,9 @@ import androidx.compose.runtime.LaunchedEffect
2626import androidx.compose.runtime.getValue
2727import androidx.compose.runtime.mutableStateOf
2828import androidx.compose.runtime.remember
29+import androidx.compose.runtime.rememberUpdatedState
2930import androidx.compose.runtime.setValue
31+import androidx.compose.runtime.snapshotFlow
3032import androidx.compose.ui.Alignment
3133import androidx.compose.ui.Modifier
3234import androidx.compose.ui.draw.clip
@@ -35,6 +37,7 @@ import androidx.compose.ui.semantics.Role
3537import androidx.compose.ui.text.TextStyle
3638import androidx.compose.ui.text.font.FontFamily
3739import androidx.compose.ui.unit.dp
40+import kotlinx.coroutines.flow.conflate
3841import kotlinx.coroutines.delay
3942import com.mikepenz.markdown.compose.components.MarkdownComponentModel
4043import com.mikepenz.markdown.compose.components.markdownComponents
@@ -55,6 +58,14 @@ import soy.iko.opencode.data.network.NetworkConfig
5558 * whenever the content string changes). To avoid O(n²) work during long responses, the
5659 * rendered content is throttled — the latest [markdown] is committed to the renderer at
5760 * most once per frame (~16ms), so a burst of tokens coalesces into a single re-parse.
61+ *
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.
5869 */
5970@OptIn(ExperimentalFoundationApi::class)
6071@Composable
@@ -64,21 +75,29 @@ fun MarkdownText(
6475 style: TextStyle = MaterialTheme.typography.bodyLarge,
6576) {
6677 val context = LocalContext.current
67− // Throttle the rendered content so a rapid burst of streaming tokens coalesces
68− // into a single markdown re-parse per frame instead of one per token. The delay
69− // is only applied when the content is growing incrementally (streaming) — a
70− // content switch or initial render proceeds immediately.
78+ // Bridge the markdown parameter into snapshot state so snapshotFlow can observe it.
79+ val markdownState = rememberUpdatedState(markdown)
7180 // Keyed to a content-prefix so switching to a different message resets
7281 // immediately instead of showing the old message for one frame.
7382 var renderedContent by remember(markdown.take(32)) { mutableStateOf(markdown) }
74− LaunchedEffect(markdown) {
75− if (renderedContent.isNotEmpty() &&
76− markdown.startsWith(renderedContent) &&
77− markdown != renderedContent
78− ) {
79− delay(NetworkConfig.streamingThrottleMs)
80− }
81− renderedContent = 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.
87+ LaunchedEffect(markdown.take(32)) {
88+ snapshotFlow { markdownState.value }
89+ .conflate()
90+ .collect { md ->
91+ // Only throttle when the content is growing incrementally (streaming):
92+ // a content switch or initial render proceeds immediately.
93+ if (renderedContent.isNotEmpty() &&
94+ md.startsWith(renderedContent) &&
95+ md != renderedContent
96+ ) {
97+ delay(NetworkConfig.streamingThrottleMs)
98+ }
99+ renderedContent = md
100+ }
82101 }
83102 Markdown(
84103 content = renderedContent,
@@ -101,7 +120,13 @@ fun MarkdownText(
101120@Composable
102121private fun CodeWithCopy(model: MarkdownComponentModel) {
103122 val context = LocalContext.current
104− val code = androidx.compose.runtime.remember(model) { extractCode(model.content, model.node) }
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) {
128+ extractCode(model.content, node)
129+ }
105130 val codeStyle = model.typography.code.copy(fontFamily = FontFamily.Monospace)
106131 Box(
107132 modifier = Modifier
@@ -143,10 +168,18 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
143168 * Extract the raw code text from a markdown code node, stripping fence markers
144169 * (```/~~~ and the language tag) so only the code content is copied.
145170 */
146−private fun extractCode(content: String, node: ASTNode): String {
147− val raw = node.getTextInNode(content).toString()
148− val isOpenFence = node.type == MarkdownElementTypes.CODE_FENCE
149− if (!isOpenFence) return raw.trimIndent()
171+private fun extractCode(content: String, node: ASTNode): String =
172+ extractCodeText(node.getTextInNode(content).toString(), node.type == MarkdownElementTypes.CODE_FENCE)
173+
174+/**
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.
177+ *
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
180+ */
181+internal fun extractCodeText(raw: String, isFenced: Boolean): String {
182+ if (!isFenced) return raw.trimIndent()
150183 val lines = raw.lines()
151184 val body = lines.drop(1).toMutableList()
152185 if (body.isNotEmpty() && (body.last().startsWith("```") || body.last().startsWith("~~~"))) {