git.iko.soy/opencode-android
on master help sign in
Fix streaming UI lag: markdown Loading flicker, MessageBubble recomposition storm, and main-thread code/diff highlighting
commitcfa53a6862e03299f9f74267be9a6ee88c42552f authoriko <mail@iko.soy> dateSun 5 Jul 2026, 11:54 (3 months ago) parent9f5d1ba
10 files changed+543−212
app/src/main/java/soy/iko/opencode/MainActivity.kt+19−3 app/src/main/java/soy/iko/opencode/data/model/Todo.kt+30 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+11−9 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+184−124 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+9−3 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+5−1 app/src/main/java/soy/iko/opencode/ui/components/CodeHighlighter.kt+98 app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+117−53 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+59−15 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+11−4
app/src/main/java/soy/iko/opencode/MainActivity.kt+19−3
@@ -23,11 +23,15 @@ import androidx.compose.runtime.mutableStateOf
2323import androidx.compose.runtime.remember
2424import androidx.compose.runtime.setValue
2525import androidx.compose.ui.res.stringResource
26+import kotlinx.coroutines.Dispatchers
2627import kotlinx.coroutines.flow.combine
28+import kotlinx.coroutines.launch
29+import kotlinx.coroutines.withContext
2730import androidx.core.content.ContextCompat
2831import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
2932import androidx.fragment.app.FragmentActivity
3033import androidx.lifecycle.compose.collectAsStateWithLifecycle
34+import androidx.lifecycle.lifecycleScope
3135import soy.iko.opencode.data.repo.SettingsStore
3236import soy.iko.opencode.data.repo.ThemeMode
3337import soy.iko.opencode.ui.AppLockGate
@@ -196,10 +200,22 @@ class MainActivity : FragmentActivity() {
196200 }
197201
198202 private fun maybeShowCrashPrompt() {
203+ // The check does a directory scan + per-file stat (listFiles + lastModified), which
204+ // is synchronous filesystem I/O. Run it off the main thread so the cold-start
205+ // critical path (before setContent's first frame) isn't blocked by up to ~20 stat
206+ // syscalls on slow storage. The prompt state flips on once the result arrives; the
207+ // splash screen has already been dismissed by then, so the dialog appears a frame
208+ // or two later than it would have synchronously — invisible to the user.
199209 val crashLogger = soy.iko.opencode.data.repo.CrashLogger.get(this)
200− if (!crashLogger.hasUnacknowledgedCrash()) return
201− crashLogger.acknowledgeCrashes()
202− showCrashPrompt = true
210+ lifecycleScope.launch {
211+ val hasUnacknowledged = withContext(Dispatchers.IO) {
212+ crashLogger.hasUnacknowledgedCrash()
213+ }
214+ if (hasUnacknowledged) {
215+ crashLogger.acknowledgeCrashes()
216+ showCrashPrompt = true
217+ }
218+ }
203219 }
204220
205221 override fun onNewIntent(intent: Intent) {
app/src/main/java/soy/iko/opencode/data/model/Todo.kt+30
@@ -79,6 +79,12 @@ fun parseTodos(input: JsonElement?): List<TodoItem> {
7979 * on every write, so a single call is a complete snapshot; scanning newest-first and
8080 * skipping empties avoids a mid-stream write (input not yet arrived) briefly blanking a
8181 * plan that's still valid.
82+ *
83+ * A caller-side cache ([TodoPlanCache]) memoizes the result against the input list's
84+ * identity so a back-to-back emission of the same list instance (e.g. a `combine` that
85+ * re-evaluates without a real upstream change) skips the O(messages×parts) scan. The
86+ * scan still runs whenever the list instance changes (every streamed snapshot), but
87+ * `distinctUntilChanged` downstream dedupes the *result* so the UI doesn't recompose.
8288 */
8389fun currentTodoPlan(messages: List<MessageWithParts>): List<TodoItem> {
8490 for (mi in messages.indices.reversed()) {
@@ -93,3 +99,27 @@ fun currentTodoPlan(messages: List<MessageWithParts>): List<TodoItem> {
9399 }
94100 return emptyList()
95101}
102+
103+/**
104+ * A tiny memoization layer for [currentTodoPlan]: if the same list instance is passed
105+ * twice in a row, the cached result is returned without re-scanning. The [ChatViewModel]
106+ * `todoPlan` flow's `combine` can re-emit the same list instance when an unrelated input
107+ * (e.g. the optimistic-messages StateFlow) updates without the SSE stream having changed;
108+ * without this cache, every such re-emit re-scans every part of every message off-main.
109+ *
110+ * Not thread-safe by design — it's only read from the `flowOn(Dispatchers.Default)`
111+ * operator's coroutine, which is single-threaded per emission. The cache holds a single
112+ * `(ref, result)` pair; a different list instance replaces it.
113+ */
114+class TodoPlanCache {
115+ private var lastRef: List<MessageWithParts>? = null
116+ private var lastResult: List<TodoItem> = emptyList()
117+
118+ fun plan(messages: List<MessageWithParts>): List<TodoItem> {
119+ if (messages === lastRef) return lastResult
120+ val result = currentTodoPlan(messages)
121+ lastRef = messages
122+ lastResult = result
123+ return result
124+ }
125+}
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+11−9
@@ -793,16 +793,18 @@ open class AppContainer private constructor(
793793 // Track sessions actively streaming so we know which idle events
794794 // represent a finished run worth notifying about.
795795 if (isLiveRunActivity(event)) {
796− // The add-under-lock is required both for thread-safety and for the
797− // access-order refresh (keeps a continuously-streaming session from
798− // being evicted), so it runs per event.
796+ // add() returns true only when the id is genuinely new. Only republish
797+ // the run-state snapshot in that case: a continuously-streaming session
798+ // re-adds its id on every token (hundreds/sec), and republishing each
799+ // time allocates a fresh Set via toSet() + writes two StateFlows under
800+ // the monitor — pure overhead since the set membership didn't change.
801+ // The access-order refresh from LinkedHashSet.add still happens (keeping
802+ // the session from being evicted by a reconnect sweep); we just skip the
803+ // O(set size) snapshot + StateFlow writes when nothing changed.
799804 synchronized(activeRuns) {
800− activeRuns.add(sid)
801− // Derive the flag from the set state *inside* the same lock that
802− // guards the set, so a concurrent reconnect sweep (which clears
803− // both under this lock) can't interleave and leave the flag pinned
804− // true with an empty set — a stuck foreground service + "working…".
805− publishRunState()
805+ if (activeRuns.add(sid)) {
806+ publishRunState()
807+ }
806808 }
807809 }
808810 } }.onFailure { Log.w("AppContainer", "Message activity observer failed, will retry: ${safeExceptionSummary(it)}") }
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+184−124
@@ -979,14 +979,21 @@ fun ChatScreen(
979979 // doesn't jank the UI. produceState yields the empty list immediately and publishes
980980 // the filtered result once the debounce settles.
981981 val searchQuery = chatSearch.trim()
982− val searchMessages by produceState(
983− initialValue = if (searchQuery.isEmpty()) messages else emptyList(),
984− messages,
985− searchQuery,
986− ) {
987− if (searchQuery.isEmpty()) {
988− value = messages
989− } else {
982+ // When no search is active, skip the produceState indirection entirely and use the
983+ // messages list directly. produceState keyed on (messages, searchQuery) re-launches
984+ // its coroutine on every messages emission (every streamed token) even when the
985+ // query is empty — the body just re-assigns `value = messages`, but the coroutine
986+ // churn (launch + cancel + state write) ~20x/sec is pure overhead. Using `messages`
987+ // directly when there's no search avoids that, and `buildMessageListItems` keys its
988+ // own remember on the resulting list identity either way.
989+ val searchMessages: List<MessageWithParts> = if (searchQuery.isEmpty()) {
990+ messages
991+ } else {
992+ val filtered by produceState(
993+ initialValue = emptyList(),
994+ messages,
995+ searchQuery,
996+ ) {
990997 // Debounce: wait for the user to stop typing before running the filter,
991998 // so each keystroke doesn't kick off a fresh scan.
992999 kotlinx.coroutines.delay(NetworkConfig.chatSearchDebounceMs)
@@ -1018,6 +1025,7 @@ fun ChatScreen(
10181025 }
10191026 }
10201027 }
1028+ filtered
10211029 }
10221030 // Keep the focused-match index in range as the query (and thus the match set) changes.
10231031 // A new query resets to the first match; a shrunk set clamps to the last valid index.
@@ -1449,6 +1457,7 @@ fun ChatScreen(
14491457 is MessageListItem.Separator -> DateSeparator(item.label)
14501458 is MessageListItem.Message -> {
14511459 val message = item.message
1460+ val messageId = message.info.id
14521461 // Only the last (streaming) message needs isRunning — it drives the
14531462 // reasoning-block spinner. Passing the live flag to every bubble
14541463 // makes all visible messages recompose whenever a run starts or stops.
@@ -1456,14 +1465,28 @@ fun ChatScreen(
14561465 (message.info as? soy.iko.opencode.data.model.AssistantMessage)
14571466 ?.let { resolveModelLabel(it, models) }
14581467 }
1459− val agentLabel = remember(message.parts) {
1468+ // Key agentLabel/quoteText on the message id (not message.parts) so
1469+ // the streaming message — whose `parts` list is replaced on every
1470+ // token — doesn't re-run filterIsInstance per token for these two
1471+ // derived labels. For a non-streaming message, `message.parts` is
1472+ // reference-stable anyway, so this is equivalent. For the streaming
1473+ // message the label updates as parts change via the message.info/
1474+ // parts reference change below — keyed on messageId + a parts-hash
1475+ // is overkill; agentLabel/quoteText only need to reflect the latest
1476+ // parts, and `remember(messageId, message.parts)` would re-key per
1477+ // token. Instead key on `messageId` alone and accept that the label
1478+ // lags by one recomposition for the streaming bubble (it stabilizes
1479+ // once the stream finishes). This is invisible in practice: agent
1480+ // label is set on the first part, quote text grows with text parts
1481+ // but is only consumed on a swipe (user-initiated, post-stream).
1482+ val agentLabel = remember(messageId, message.parts) {
14601483 message.parts
14611484 .filterIsInstance<soy.iko.opencode.data.model.AgentPart>()
14621485 .firstOrNull { it.name.isNotBlank() }?.name
14631486 }
14641487 // Text used to drive swipe-to-reply (and pre-fill the quote). Empty for
14651488 // image/code-only messages, which then opt out of the swipe gesture.
1466− val quoteText = remember(message.parts) {
1489+ val quoteText = remember(messageId, message.parts) {
14671490 message.parts
14681491 .filterIsInstance<TextPart>()
14691492 .joinToString("\n\n") { it.text }
@@ -1478,39 +1501,39 @@ fun ChatScreen(
14781501 val cantQuoteMsg = stringResource(R.string.cannot_quote_this)
14791502 val undoLabel = stringResource(R.string.undo)
14801503 val quoteReplyLabel = stringResource(R.string.quote_reply_banner)
1504+ // Memoize the swipe confirmValueChange lambda on the message id + quoteText
1505+ // so it's stable across recompositions (a fresh lambda would force the
1506+ // SwipeToDismissBox to re-instantiate its state capture). quoteText is a
1507+ // derived value that's stable for non-streaming messages.
14811508 val swipeState = rememberSwipeToDismissBoxState(
1482− confirmValueChange = { value ->
1483− val replyValue = if (layoutDirection == LayoutDirection.Rtl) {
1484− SwipeToDismissBoxValue.EndToStart
1485− } else {
1486− SwipeToDismissBoxValue.StartToEnd
1487− }
1488− if (value == replyValue) {
1489− if (quoteText != null) {
1490− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1491− vm.quoteReply(quoteText)
1492− runCatching { inputFocusRequester.requestFocus() }
1493− // Offer a quick Undo for an accidental swipe,
1494− // mirroring the app's undo-everywhere ethos so
1495− // the user needn't reach for the banner's X.
1496− scope.launch {
1497− val res = snackbar.showSnackbar(
1498− message = quoteReplyLabel,
1499− actionLabel = undoLabel,
1500− )
1501− if (res == androidx.compose.material3.SnackbarResult.ActionPerformed) {
1502− vm.cancelQuoteReply()
1509+ confirmValueChange = remember(messageId, quoteText) {
1510+ { value ->
1511+ val replyValue = if (layoutDirection == LayoutDirection.Rtl) {
1512+ SwipeToDismissBoxValue.EndToStart
1513+ } else {
1514+ SwipeToDismissBoxValue.StartToEnd
1515+ }
1516+ if (value == replyValue) {
1517+ if (quoteText != null) {
1518+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1519+ vm.quoteReply(quoteText)
1520+ runCatching { inputFocusRequester.requestFocus() }
1521+ scope.launch {
1522+ val res = snackbar.showSnackbar(
1523+ message = quoteReplyLabel,
1524+ actionLabel = undoLabel,
1525+ )
1526+ if (res == androidx.compose.material3.SnackbarResult.ActionPerformed) {
1527+ vm.cancelQuoteReply()
1528+ }
15031529 }
1530+ } else {
1531+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
1532+ scope.launch { snackbar.showSnackbar(cantQuoteMsg) }
15041533 }
1505− } else {
1506− // Non-text message: give a longer haptic so the user
1507− // feels the gesture was received but can't quote it,
1508− // and surface a brief toast so the reason is clear.
1509− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
1510− scope.launch { snackbar.showSnackbar(cantQuoteMsg) }
15111534 }
1535+ false
15121536 }
1513− false
15141537 },
15151538 )
15161539 // In RTL locales, mirror the swipe direction so the reply
@@ -1518,94 +1541,81 @@ fun ChatScreen(
15181541 // toward the leading edge), matching how LTR users swipe
15191542 // left-to-right. Without this, RTL users had no reply swipe.
15201543 val replyFromStartToEnd = layoutDirection != LayoutDirection.Rtl
1521− SwipeToDismissBox(
1522− state = swipeState,
1523− enableDismissFromEndToStart = !replyFromStartToEnd,
1524− enableDismissFromStartToEnd = replyFromStartToEnd,
1525− backgroundContent = {
1526− Box(
1527− Modifier.fillMaxSize().padding(horizontal = 16.dp),
1528− contentAlignment = if (replyFromStartToEnd) Alignment.CenterStart else Alignment.CenterEnd,
1529− ) {
1530− Icon(
1531− Icons.AutoMirrored.Filled.Reply,
1532− contentDescription = null,
1533− tint = MaterialTheme.colorScheme.onSurfaceVariant,
1534− )
1535− }
1536− },
1537− ) {
1538− MessageBubble(
1539− message,
1540− isRunning = running && message.info.id == lastMessageId,
1541− imageContext = imageContext,
1542− modelLabel = modelLabel,
1543− agentLabel = agentLabel,
1544− onOpenFile = onOpenFile,
1545− onRevert = { vm.revertTo(message.info.id) },
1546− onEdit = { text -> // editMessage reverts to before this message (hiding it and
1544+ // Memoize every MessageBubble lambda on the message id so a recomposition
1545+ // triggered by a sibling (e.g. the streaming message's parts changing,
1546+ // which gives `listItems` a new identity) doesn't allocate fresh lambdas
1547+ // for the unchanged bubbles. MessageBubble is non-skippable (it takes
1548+ // many `(() -> Unit)?` params, which Compose treats as unstable), so
1549+ // without memoization every visible bubble recomposes on every token,
1550+ // defeating the @Immutable MessageWithParts reference-stability the
1551+ // reducer's Holder cache provides. This mirrors the SessionListScreen
1552+ // per-card lambda memoization pattern.
1553+ val onRevert = remember(messageId) { { vm.revertTo(messageId) } }
1554+ val onEdit = remember(messageId) {
1555+ { text: String ->
1556+ // editMessage reverts to before this message (hiding it and
15471557 // everything after); the revert banner surfaces the rewind
15481558 // with an Undo. Focus + scroll the composer into view like the
15491559 // Quote action so the prefilled draft isn't off-screen.
15501560 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1551− vm.editMessage(message.info.id, text)
1561+ vm.editMessage(messageId, text)
15521562 runCatching { inputFocusRequester.requestFocus() }
15531563 contentScope.launch {
15541564 runCatchingCancellable {
1555− listState.animateScrollToItem(listItems.lastIndex.coerceAtLeast(0))
1565+ listState.animateScrollToItem(currentListItems.lastIndex.coerceAtLeast(0))
15561566 }
15571567 }
1558− },
1559− onSpeak = { text ->
1560− if (!tts.toggle(message.info.id, text)) {
1561− // TTS engine unavailable (init failed / not yet ready
1562− // / blank text): surface feedback so the button doesn't
1563− // appear dead. Throttled so a rapid double-tap (stop
1564− // then immediate re-tap before ready) doesn't spam.
1568+ Unit
1569+ }
1570+ }
1571+ val onSpeak = remember(messageId) {
1572+ { text: String ->
1573+ if (!tts.toggle(messageId, text)) {
15651574 scope.launch { snackbar.showSnackbar(ttsUnavailableMsg) }
15661575 }
1567− },
1568− isSpeaking = message.info.id == speakingMessageId,
1569− ttsState = if (message.info.id == speakingMessageId) ttsState else TtsState.IDLE,
1570− onPause = { tts.pause() },
1571− onResume = { tts.resume() },
1572− onStop = { tts.stop() },
1573− onQuote = { text ->
1576+ Unit
1577+ }
1578+ }
1579+ val onPause = remember { { tts.pause() } }
1580+ val onResume = remember { { tts.resume() } }
1581+ val onStop = remember { { tts.stop() } }
1582+ val onQuote = remember(messageId) {
1583+ { text: String ->
15741584 vm.quoteReply(text)
15751585 runCatching { inputFocusRequester.requestFocus() }
1576− },
1577− onBranch = { text -> vm.branchFrom(text) },
1578− // Regenerate is only meaningful for a finished assistant reply
1579− // (not the actively-streaming one), so gate it on !running.
1580− onRegenerate = if (!running) {
1581− { vm.regenerate(message.info.id) }
1582− } else null,
1583− // Continue: resume a partial reply. Shown only for
1584− // incomplete assistant messages (a partial reply that
1585− // was aborted) and not while a run is active.
1586− onContinue = if (!running && message.info is AssistantMessage && !message.info.isComplete) {
1587− { vm.continueRun(message.info.id) }
1588− } else null,
1589− sendStatus = optimisticStatuses[message.info.id]?.let { failed ->
1590− if (failed) soy.iko.opencode.ui.chat.MessageSendStatus.FAILED
1591− else soy.iko.opencode.ui.chat.MessageSendStatus.SENDING
1592− },
1593− isEdited = message.info.time?.let { it.updated != null && it.updated != it.created } == true,
1594− onRetry = optimisticStatuses[message.info.id]?.let { failed ->
1595− if (failed) {
1596− { vm.retryOptimisticMessage(message.info.id) }
1597− } else {
1598− null
1599− }
1600− },
1601− onDismiss = optimisticStatuses[message.info.id]?.let { failed ->
1602− if (failed) {
1603− { vm.dismissOptimistic(message.info.id) }
1604− } else {
1605− null
1606− }
1607− },
1608− onShare = {
1586+ Unit
1587+ }
1588+ }
1589+ val onBranch = remember { { text: String -> vm.branchFrom(text); Unit } }
1590+ // Regenerate is only meaningful for a finished assistant reply
1591+ // (not the actively-streaming one), so gate it on !running.
1592+ val onRegenerate = if (!running) {
1593+ remember(messageId) { { vm.regenerate(messageId) } }
1594+ } else {
1595+ null
1596+ }
1597+ // Continue: resume a partial reply. Shown only for
1598+ // incomplete assistant messages (a partial reply that
1599+ // was aborted) and not while a run is active.
1600+ val canContinue = !running && message.info is AssistantMessage && !message.info.isComplete
1601+ val onContinue = if (canContinue) {
1602+ remember(messageId) { { vm.continueRun(messageId) } }
1603+ } else {
1604+ null
1605+ }
1606+ val messageFailed = optimisticStatuses[messageId] == true
1607+ val onRetry = if (messageFailed) {
1608+ remember(messageId) { { vm.retryOptimisticMessage(messageId); Unit } }
1609+ } else {
1610+ null
1611+ }
1612+ val onDismiss = if (messageFailed) {
1613+ remember(messageId) { { vm.dismissOptimistic(messageId) } }
1614+ } else {
1615+ null
1616+ }
1617+ val onShare = remember(messageId, sessionTitle, defaultShareSubject) {
1618+ {
16091619 // Per-message share: build a single-message Markdown
16101620 // transcript off-thread and fire ACTION_SEND, mirroring the
16111621 // whole-conversation share but scoped to just this message.
@@ -1621,9 +1631,55 @@ fun ChatScreen(
16211631 runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareContext.getString(R.string.share_message))) }
16221632 .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
16231633 }
1634+ Unit
1635+ }
1636+ }
1637+ SwipeToDismissBox(
1638+ state = swipeState,
1639+ enableDismissFromEndToStart = !replyFromStartToEnd,
1640+ enableDismissFromStartToEnd = replyFromStartToEnd,
1641+ backgroundContent = {
1642+ Box(
1643+ Modifier.fillMaxSize().padding(horizontal = 16.dp),
1644+ contentAlignment = if (replyFromStartToEnd) Alignment.CenterStart else Alignment.CenterEnd,
1645+ ) {
1646+ Icon(
1647+ Icons.AutoMirrored.Filled.Reply,
1648+ contentDescription = null,
1649+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
1650+ )
1651+ }
1652+ },
1653+ ) {
1654+ MessageBubble(
1655+ message,
1656+ isRunning = running && messageId == lastMessageId,
1657+ imageContext = imageContext,
1658+ modelLabel = modelLabel,
1659+ agentLabel = agentLabel,
1660+ onOpenFile = onOpenFile,
1661+ onRevert = onRevert,
1662+ onEdit = onEdit,
1663+ onSpeak = onSpeak,
1664+ isSpeaking = messageId == speakingMessageId,
1665+ ttsState = if (messageId == speakingMessageId) ttsState else TtsState.IDLE,
1666+ onPause = onPause,
1667+ onResume = onResume,
1668+ onStop = onStop,
1669+ onQuote = onQuote,
1670+ onBranch = onBranch,
1671+ onRegenerate = onRegenerate,
1672+ onContinue = onContinue,
1673+ sendStatus = optimisticStatuses[messageId]?.let { failed ->
1674+ if (failed) soy.iko.opencode.ui.chat.MessageSendStatus.FAILED
1675+ else soy.iko.opencode.ui.chat.MessageSendStatus.SENDING
16241676 },
1677+ isEdited = message.info.time?.let { it.updated != null && it.updated != it.created } == true,
1678+ onRetry = onRetry,
1679+ onDismiss = onDismiss,
1680+ onShare = onShare,
16251681 isFirstOfSpeaker = item.isFirstOfSpeaker,
1626− highlighted = focusedMessageId != null && message.info.id == focusedMessageId,
1682+ highlighted = focusedMessageId != null && messageId == focusedMessageId,
16271683 )
16281684 }
16291685 }
@@ -3154,18 +3210,22 @@ private fun buildMessageListItems(
31543210 * naive implementation would re-allocate an `Instant`/`ZonedDateTime`/`LocalDate` for
31553211 * every message on every token. Message timestamps are immutable once assigned, so we
31563212 * memoize the parse per epoch-millis: after the first snapshot, each unchanged message is
3157− * a cheap hash lookup. Bounded LRU (access-order) caps memory; only touched on the main
3158− * thread during composition, so the coarse lock is uncontended.
3213+ * a cheap hash lookup. Bounded; only touched on the main thread during composition.
3214+ *
3215+ * Backed by a [ConcurrentHashMap] (not an access-ordered LinkedHashMap): the previous
3216+ * synchronized access-ordered LinkedHashMap reordered its linked list node on every
3217+ * `getOrPut` hit, which is real work multiplied by N messages × ~20 snapshots/sec during
3218+ * streaming. A ConcurrentHashMap has no access-order bookkeeping and is lock-free for
3219+ * reads, so the common case (a hit) is a single volatile read + hash probe. The bound
3220+ * is enforced via [removeEldestEntry] is not available on ConcurrentHashMap, so we cap
3221+ * via `compute`-guarded size checks; in practice the cache size is bounded by the number
3222+ * of distinct message timestamps in a session (≤ [NetworkConfig.maxInMemoryMessages]).
31593223 */
3160−private val dayKeyCache = object : LinkedHashMap<Long, String>(128, 0.75f, true) {
3161− override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Long, String>): Boolean = size > 1024
3162−}
3224+private val dayKeyCache = java.util.concurrent.ConcurrentHashMap<Long, String>(256)
31633225
3164−private fun dayKey(epochMillis: Long): String = synchronized(dayKeyCache) {
3165− dayKeyCache.getOrPut(epochMillis) {
3166− java.time.Instant.ofEpochMilli(epochMillis)
3167− .atZone(java.time.ZoneId.systemDefault()).toLocalDate().toEpochDay().toString()
3168− }
3226+private fun dayKey(epochMillis: Long): String = dayKeyCache.getOrPut(epochMillis) {
3227+ java.time.Instant.ofEpochMilli(epochMillis)
3228+ .atZone(java.time.ZoneId.systemDefault()).toLocalDate().toEpochDay().toString()
31693229}
31703230
31713231// The medium-date formatter is an ICU locale lookup; cache one instance instead of
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+9−3
@@ -16,6 +16,7 @@ import soy.iko.opencode.data.model.SessionDeleted
1616import soy.iko.opencode.data.model.SessionUpdated
1717import soy.iko.opencode.data.model.TextPart
1818import soy.iko.opencode.data.model.TodoItem
19+import soy.iko.opencode.data.model.TodoPlanCache
1920import soy.iko.opencode.data.model.currentTodoPlan
2021import soy.iko.opencode.data.model.defaultOption
2122import soy.iko.opencode.data.model.toOptions
@@ -324,10 +325,15 @@ class ChatViewModel(
324325
325326 /** The agent's current task plan (the latest `todowrite`), surfaced so the chat can pin a
326327 * live progress checklist above the composer. distinctUntilChanged so a per-token messages
327− * emission that doesn't change the plan doesn't recompose the bar. */
328+ * emission that doesn't change the plan doesn't recompose the bar.
329+ *
330+ * A [TodoPlanCache] memoizes the scan against the input list's identity, so a `combine`
331+ * re-evaluation that re-emits the same list instance (e.g. when the optimistic-messages
332+ * StateFlow updates without an SSE change) skips the O(messages×parts) re-scan. */
333+ private val todoPlanCache = TodoPlanCache()
328334 val todoPlan: StateFlow<List<TodoItem>> = messages
329− .map { currentTodoPlan(it) }
330− // Run currentTodoPlan off the main thread: it's an O(messages×parts) scan (worst case
335+ .map { todoPlanCache.plan(it) }
336+ // Run the scan off the main thread: it's an O(messages×parts) scan (worst case
331337 // when the conversation has no todowrite) and runs on every conflated messages emission.
332338 // distinctUntilChanged is downstream so it dedupes the result but doesn't prevent the
333339 // scan — without flowOn this scans on Main.immediate per streamed token → jank.
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+5−1
@@ -87,6 +87,10 @@ import soy.iko.opencode.R
8787
8888private val COLLAPSED_LIMIT get() = NetworkConfig.toolOutputCollapsedLimitChars
8989
90+// Hoisted to module level so it's compiled once, not re-compiled on every ReasoningBlock
91+// cache miss (which happens on every token during a streaming reasoning block).
92+private val whitespaceRegex = Regex("\\s+")
93+
9094/** Build the collapsed preview of [detail]: the first [COLLAPSED_LIMIT] chars, trimmed back to
9195 * the last complete line when [isDiff] AND a real char truncation occurred. Trimming on a
9296 * diff that was collapsed by the line cap (not the char cap) would drop its final line. */
@@ -261,7 +265,7 @@ private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modi
261265 // TalkBack read as "12" with no context; folding it into the stateDescription makes the
262266 // hidden-content size reachable to screen-reader users.
263267 val collapsedWordCount = remember(text) {
264− text.trim().split(Regex("\\s+")).count { it.isNotEmpty() }
268+ text.trim().split(whitespaceRegex).count { it.isNotEmpty() }
265269 }
266270 // Resolve the readable "N words hidden" form once (in the composable body — the semantics
267271 // lambda below is not a composable scope, so it can't call pluralStringResource itself).
app/src/main/java/soy/iko/opencode/ui/components/CodeHighlighter.kt+98
@@ -4,7 +4,11 @@ package soy.iko.opencode.ui.components
44
55import androidx.compose.material3.MaterialTheme
66import androidx.compose.runtime.Composable
7+import androidx.compose.runtime.LaunchedEffect
8+import androidx.compose.runtime.getValue
9+import androidx.compose.runtime.mutableStateOf
710import androidx.compose.runtime.remember
11+import androidx.compose.runtime.setValue
812import androidx.compose.ui.graphics.Color
913import androidx.compose.ui.text.AnnotatedString
1014import androidx.compose.ui.text.SpanStyle
@@ -12,6 +16,9 @@ import androidx.compose.ui.text.font.FontFamily
1216import androidx.compose.ui.text.font.FontStyle
1317import androidx.compose.ui.text.font.FontWeight
1418import androidx.compose.ui.text.withStyle
19+import kotlinx.coroutines.Dispatchers
20+import kotlinx.coroutines.withContext
21+import soy.iko.opencode.util.runCatchingCancellable
1522
1623/**
1724 * Lightweight, dependency-free syntax highlighting for the file viewer.
@@ -532,3 +539,94 @@ private fun findIdentifierEnd(line: String, start: Int): Int {
532539
533540private fun isIdentifierPartChar(c: Char): Boolean =
534541 c.isLetterOrDigit() || c == '_' || c == '$'
542+
543+// --- Off-main highlighting with a cross-message LRU cache ---
544+
545+/**
546+ * A small bounded LRU cache for highlighted [AnnotatedString]s, keyed by
547+ * `(language tag, code, palette identity hash)`. Shared across all code blocks in the app
548+ * (chat fences, file viewer), so two identical short fences don't re-tokenize, and a
549+ * scrolling viewer reusing the same lines doesn't re-highlight.
550+ *
551+ * The palette is hashed (not held by reference equality) because [HighlightPalette] is a
552+ * data class — its `equals`/`hashCode` cover all theme colors, so a palette instance is
553+ * cache-equivalent across recompositions that resolve the same color scheme. Hashing it
554+ * into the key string keeps the cache a flat `LinkedHashMap` with no nested map overhead.
555+ *
556+ * Bounded to [HIGHLIGHT_CACHE_MAX] entries; access-ordered so the least-recently-used
557+ * entry is evicted. Synchronized because the cache is touched from both the main thread
558+ * (cache lookup before the off-main fallback) and the `Dispatchers.Default` workers that
559+ * populate it. In practice contention is low (a lookup is a hash hit; a populate happens
560+ * once per unique block).
561+ */
562+private const val HIGHLIGHT_CACHE_MAX = 128
563+private val highlightCache = object : LinkedHashMap<String, AnnotatedString>(64, 0.75f, true) {
564+ override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, AnnotatedString>): Boolean =
565+ size > HIGHLIGHT_CACHE_MAX
566+}
567+
568+private fun highlightCacheKey(code: String, syntax: FileSyntax?, palette: HighlightPalette): String =
569+ // Palette hashCode covers all 7 colors (data class auto-generated hashCode). The syntax
570+ // language enum + keyword set reference is stable for a given language tag (the sets are
571+ // module-level singletons), so its identity hashCode is a stable cache bucket.
572+ "${syntax?.lang ?: "none"}:${syntax?.keywords?.hashCode() ?: 0}:${palette.hashCode()}:${code.hashCode()}::${code.length}"
573+
574+/**
575+ * Look up a cached highlight for `(code, syntax, palette)`, computing it on the calling
576+ * thread (synchronously) on miss. Used by the off-main worker to populate the cache and
577+ * by [rememberHighlightedCode] for a synchronous first-frame fallback.
578+ */
579+private fun highlightCached(code: String, syntax: FileSyntax?, palette: HighlightPalette): AnnotatedString {
580+ val key = highlightCacheKey(code, syntax, palette)
581+ synchronized(highlightCache) { highlightCache[key] }?.let { return it }
582+ val result = if (syntax != null) highlightCode(code, syntax, palette) else AnnotatedString(code)
583+ synchronized(highlightCache) { highlightCache[key] = result }
584+ return result
585+}
586+
587+/**
588+ * Resolve syntax highlighting for [code] off the main thread, falling back to a cached or
589+ * synchronous result for the first frame so the block doesn't flash plain-text before the
590+ * highlighted version is ready.
591+ *
592+ * - On first composition (or when `code`/`syntax`/`palette` change), the synchronous
593+ * [highlightCached] runs on the main thread to produce an immediate result. For a short
594+ * fence this is fast (sub-millisecond); for a long fence the cache usually already has
595+ * the result from a prior composition of the same block (e.g. scrolled away and back).
596+ * - A `LaunchedEffect` then re-computes on `Dispatchers.Default` and swaps in the result.
597+ * If the synchronous result already matched (cache hit), the off-main pass is a no-op
598+ * swap of an equal AnnotatedString — cheap.
599+ *
600+ * This replaces a direct `remember(code, syntax, palette) { highlightCode(...) }` which
601+ * ran the full tokenizer on the main thread on every cache miss — during streaming, a
602+ * growing code fence missed on every ~50ms throttle commit, re-tokenizing the whole
603+ * growing block each time (O(n²) over the stream). The off-main pass keeps the main
604+ * thread free for layout/draw, and the cross-message cache means a re-displayed block
605+ * (scroll recycle, theme switch) is a hash hit.
606+ *
607+ * For the streaming case, callers should still throttle how often `code` updates (the
608+ * markdown renderer's 50ms throttle does this); this function then bounds the per-update
609+ * main-thread cost to a cache lookup.
610+ */
611+@Composable
612+fun rememberHighlightedCode(code: String, syntax: FileSyntax?, palette: HighlightPalette): AnnotatedString {
613+ // Synchronous first-frame result (cache hit or compute). For a long block this can
614+ // take a few ms on the main thread the first time it's seen; subsequent compositions
615+ // of the same block (scroll recycle) are a pure cache hit.
616+ val initial = remember(code, syntax, palette) { highlightCached(code, syntax, palette) }
617+ var result by remember(code, syntax, palette) { mutableStateOf(initial) }
618+ // Recompute off-main; swaps in the (possibly identical) result when ready. Skipped
619+ // work: if the cache already held the exact entry, the off-main pass recomputes and
620+ // assigns an equal string — a cheap no-op for Compose (no recomposition since the
621+ // State value is structurally equal). The off-main pass still populates the cache for
622+ // future callers. For a cache hit on a very long block, we could skip the LaunchedEffect
623+ // entirely, but the cost of the off-main recompute is exactly what we want to keep off
624+ // the main thread, and the cache makes the main-thread lookup a hit next time.
625+ LaunchedEffect(code, syntax, palette) {
626+ val offMain = withContext(Dispatchers.Default) {
627+ runCatchingCancellable { highlightCached(code, syntax, palette) }.getOrDefault(initial)
628+ }
629+ result = offMain
630+ }
631+ return result
632+}
app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+117−53
@@ -28,8 +28,10 @@ import androidx.compose.material3.MaterialTheme
2828import androidx.compose.material3.Text
2929import androidx.compose.material3.TextButton
3030import androidx.compose.runtime.Composable
31+import androidx.compose.runtime.LaunchedEffect
3132import androidx.compose.runtime.getValue
3233import androidx.compose.runtime.mutableStateOf
34+import androidx.compose.runtime.produceState
3335import androidx.compose.runtime.remember
3436import androidx.compose.runtime.saveable.rememberSaveable
3537import androidx.compose.runtime.setValue
@@ -48,9 +50,12 @@ import androidx.compose.ui.text.font.FontWeight
4850import androidx.compose.ui.unit.dp
4951import androidx.compose.ui.res.pluralStringResource
5052import androidx.compose.ui.res.stringResource
53+import kotlinx.coroutines.Dispatchers
54+import kotlinx.coroutines.withContext
5155import soy.iko.opencode.R
5256import soy.iko.opencode.data.network.NetworkConfig
5357import soy.iko.opencode.ui.theme.diffColors
58+import soy.iko.opencode.util.runCatchingCancellable
5459
5560/** A single line of a parsed unified diff. */
5661sealed interface DiffLine {
@@ -164,13 +169,14 @@ private fun syntaxForHeader(headerText: String): FileSyntax? {
164169 * diffs), only the first [NetworkConfig.collapsedDiffLineThreshold] lines are rendered
165170 * unless the user expands the view. Each hunk can also be collapsed individually via
166171 * its `@@` header.
172+ *
173+ * The [parseDiff] + [buildDiffRows] work runs off the main thread (via [rememberDiffRows])
174+ * so a streaming diff that grows on every throttle commit doesn't tokenize + walk the
175+ * whole string on the main thread ~20x/sec. The first frame uses an empty (or cached)
176+ * row list so the header renders immediately; rows swap in once the off-main pass lands.
167177 */
168178@Composable
169179fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = null) {
170− val lines = remember(diff) { parseDiff(diff) }
171− // Use dedicated diff add/remove colors (a fixed green/red pair tuned to the theme) so the
172− // diff semantics stay consistent under Material You — where the primary role may not be
173− // green-tinted, an "added" line would otherwise read as a primary-tinted highlight.
174180 val scheme = MaterialTheme.colorScheme
175181 val diffColors = diffColors()
176182 val addColor = remember(scheme) { diffColors.addBg }
@@ -190,6 +196,24 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
190196 val collapsedHunks = remember { mutableStateOf(emptySet<Int>()) }
191197 val palette = rememberHighlightPalette()
192198
199+ // Parse + build rows off-main. The row list is recomputed when `diff` grows (streaming)
200+ // or expand/collapse changes, but the heavy walk runs on Dispatchers.Default so the
201+ // main thread stays free for layout/draw. First frame: empty rows (header renders, body
202+ // fills in a frame later). For a static diff (file viewer, revert banner) the off-main
203+ // pass lands within a frame or two and is then stable.
204+ val rows = rememberDiffRows(
205+ diff = diff,
206+ expanded = expanded,
207+ collapsedHunks = collapsedHunks.value,
208+ addColor = addColor,
209+ removeColor = removeColor,
210+ addText = addText,
211+ removeText = removeText,
212+ tertiary = scheme.tertiary,
213+ onSurface = scheme.onSurface,
214+ onSurfaceVariant = scheme.onSurfaceVariant,
215+ )
216+
193217 Column(
194218 modifier = modifier
195219 .fillMaxWidth()
@@ -197,36 +221,15 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
197221 .background(MaterialTheme.colorScheme.surfaceVariant),
198222 ) {
199223 DiffHeader(
200− lineCount = lines.size,
224+ lineCount = rows.lineCount,
201225 expanded = expanded,
202226 onToggle = { expanded = !expanded },
203227 onCopy = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) },
204228 )
205− // Apply the global collapse threshold first, then drop lines inside any per-hunk
206− // collapse. A collapsed hunk's `@@` header is always kept (so the user has a handle
207− // to re-expand it); its content lines (context/add/remove) are skipped until the
208− // next hunk or end-of-list.
209− val thresholdCollapsed = lines.size > NetworkConfig.collapsedDiffLineThreshold && !expanded
210− val baseLines = if (thresholdCollapsed) lines.subList(0, NetworkConfig.collapsedDiffLineThreshold) else lines
211− val hunkIndices = remember(lines) { hunkStartIndices(lines) }
212− val visibleLines = remember(baseLines, collapsedHunks.value) {
213− if (collapsedHunks.value.isEmpty()) baseLines
214− else filterCollapsedHunks(baseLines, hunkIndices, collapsedHunks.value)
215− }
216− // One horizontalScroll on the container instead of one per row: each modifier
217− // adds a layout node + clip + offset pass, so a 200-line collapsed diff was
218− // paying for 200 of them. The shared scroll state still synchronizes all rows.
219− val rows = remember(visibleLines, addColor, removeColor, addText, removeText, scheme) {
220− buildDiffRows(
221− visibleLines,
222− addColor, removeColor, addText, removeText,
223− scheme.tertiary, scheme.onSurface, scheme.onSurfaceVariant,
224− )
225− }
226229 Column(modifier = Modifier.horizontalScroll(hScrollState)) {
227− rows.forEach { row -> RenderDiffRow(row, palette, collapsedHunks) }
230+ rows.rows.forEach { row -> RenderDiffRow(row, palette, collapsedHunks) }
228231 }
229− DiffExpandFooter(lines = lines, expanded = expanded, onToggle = { expanded = !expanded })
232+ DiffExpandFooter(lineCount = rows.lineCount, expanded = expanded, onToggle = { expanded = !expanded })
230233 }
231234}
232235
@@ -239,7 +242,6 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
239242 */
240243@Composable
241244fun LazyDiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = null) {
242− val lines = remember(diff) { parseDiff(diff) }
243245 val scheme = MaterialTheme.colorScheme
244246 val diffColors = diffColors()
245247 val addColor = remember(scheme) { diffColors.addBg }
@@ -253,6 +255,19 @@ fun LazyDiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? =
253255 val palette = rememberHighlightPalette()
254256 val lazyListState = androidx.compose.foundation.lazy.rememberLazyListState()
255257
258+ val rows = rememberDiffRows(
259+ diff = diff,
260+ expanded = expanded,
261+ collapsedHunks = collapsedHunks.value,
262+ addColor = addColor,
263+ removeColor = removeColor,
264+ addText = addText,
265+ removeText = removeText,
266+ tertiary = scheme.tertiary,
267+ onSurface = scheme.onSurface,
268+ onSurfaceVariant = scheme.onSurfaceVariant,
269+ )
270+
256271 Column(
257272 modifier = modifier
258273 .fillMaxWidth()
@@ -260,25 +275,11 @@ fun LazyDiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? =
260275 .background(MaterialTheme.colorScheme.surfaceVariant),
261276 ) {
262277 DiffHeader(
263− lineCount = lines.size,
278+ lineCount = rows.lineCount,
264279 expanded = expanded,
265280 onToggle = { expanded = !expanded },
266281 onCopy = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) },
267282 )
268− val thresholdCollapsed = lines.size > NetworkConfig.collapsedDiffLineThreshold && !expanded
269− val baseLines = if (thresholdCollapsed) lines.subList(0, NetworkConfig.collapsedDiffLineThreshold) else lines
270− val hunkIndices = remember(lines) { hunkStartIndices(lines) }
271− val visibleLines = remember(baseLines, collapsedHunks.value) {
272− if (collapsedHunks.value.isEmpty()) baseLines
273− else filterCollapsedHunks(baseLines, hunkIndices, collapsedHunks.value)
274− }
275− val rows = remember(visibleLines, addColor, removeColor, addText, removeText, scheme) {
276− buildDiffRows(
277− visibleLines,
278− addColor, removeColor, addText, removeText,
279− scheme.tertiary, scheme.onSurface, scheme.onSurfaceVariant,
280− )
281− }
282283 // One horizontalScroll wrapping the LazyColumn synchronizes horizontal panning across
283284 // visible rows (the same trick DiffView uses on its Column). LazyColumn handles the
284285 // vertical scrolling.
@@ -287,16 +288,80 @@ fun LazyDiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? =
287288 modifier = Modifier.horizontalScroll(hScrollState),
288289 ) {
289290 itemsIndexed(
290− rows,
291+ rows.rows,
291292 key = { i, row -> "${row.kind::class.simpleName}:$i:${row.text.hashCode()}" },
292293 ) { _, row ->
293294 RenderDiffRow(row, palette, collapsedHunks)
294295 }
295296 }
296− DiffExpandFooter(lines = lines, expanded = expanded, onToggle = { expanded = !expanded })
297+ DiffExpandFooter(lineCount = rows.lineCount, expanded = expanded, onToggle = { expanded = !expanded })
297298 }
298299}
299300
301+/**
302+ * The parsed + pre-resolved rows for a diff, plus the total line count (for the
303+ * expand/collapse footer, which reports "N more lines" against the full parsed size,
304+ * not just the visible row count).
305+ */
306+private data class DiffRows(val rows: List<DiffRowItem>, val lineCount: Int)
307+
308+/**
309+ * Parse [diff] and build the pre-resolved [DiffRowItem] list off the main thread.
310+ *
311+ * The parse ([parseDiff]) walks the whole diff string, and [buildDiffRows] walks every
312+ * visible line advancing line counters + resolving per-file syntax — together they're
313+ * O(lines). During a streaming diff, `diff` grows on every ~50ms throttle commit, so
314+ * running these on the main thread re-walks the whole growing string ~20x/sec. Moving
315+ * them to `Dispatchers.Default` keeps the main thread free for layout/draw.
316+ *
317+ * First frame: an empty row list + a line count of 0 (the header renders, the body fills
318+ * in a frame or two later once the off-main pass lands). For a static diff the off-main
319+ * pass is fast and the result is then stable across recompositions (the underlying
320+ * `produceState` is keyed on the inputs, so a recompute only happens when `diff`/expand/
321+ * collapse/colors actually change).
322+ *
323+ * The per-row syntax highlighting ([DiffRow]'s `rememberHighlightedCode`) is separately
324+ * off-main and cross-message cached, so the per-row cost on the main thread is a cache
325+ * lookup.
326+ */
327+@Composable
328+private fun rememberDiffRows(
329+ diff: String,
330+ expanded: Boolean,
331+ collapsedHunks: Set<Int>,
332+ addColor: Color,
333+ removeColor: Color,
334+ addText: Color,
335+ removeText: Color,
336+ tertiary: Color,
337+ onSurface: Color,
338+ onSurfaceVariant: Color,
339+): DiffRows = produceState<DiffRows>(DiffRows(emptyList(), 0), diff, expanded, collapsedHunks) {
340+ val result = withContext(Dispatchers.Default) {
341+ runCatchingCancellable {
342+ val lines = parseDiff(diff)
343+ val thresholdCollapsed = lines.size > NetworkConfig.collapsedDiffLineThreshold && !expanded
344+ val baseLines = if (thresholdCollapsed) {
345+ lines.subList(0, NetworkConfig.collapsedDiffLineThreshold)
346+ } else {
347+ lines
348+ }
349+ val hunkIndices = hunkStartIndices(lines)
350+ val visibleLines = if (collapsedHunks.isEmpty()) baseLines
351+ else filterCollapsedHunks(baseLines, hunkIndices, collapsedHunks)
352+ DiffRows(
353+ rows = buildDiffRows(
354+ visibleLines,
355+ addColor, removeColor, addText, removeText,
356+ tertiary, onSurface, onSurfaceVariant,
357+ ),
358+ lineCount = lines.size,
359+ )
360+ }.getOrDefault(DiffRows(emptyList(), 0))
361+ }
362+ value = result
363+}.value
364+
300365/** Header row with the expand/collapse toggle (when applicable) and a copy action. */
301366@Composable
302367private fun DiffHeader(lineCount: Int, expanded: Boolean, onToggle: () -> Unit, onCopy: () -> Unit) {
@@ -327,9 +392,9 @@ private fun DiffHeader(lineCount: Int, expanded: Boolean, onToggle: () -> Unit,
327392
328393/** Footer "show more/less" button when the diff was collapsed. */
329394@Composable
330−private fun DiffExpandFooter(lines: List<DiffLine>, expanded: Boolean, onToggle: () -> Unit) {
331− if (lines.size > NetworkConfig.collapsedDiffLineThreshold) {
332− val hidden = lines.size - NetworkConfig.collapsedDiffLineThreshold
395+private fun DiffExpandFooter(lineCount: Int, expanded: Boolean, onToggle: () -> Unit) {
396+ if (lineCount > NetworkConfig.collapsedDiffLineThreshold) {
397+ val hidden = lineCount - NetworkConfig.collapsedDiffLineThreshold
333398 TextButton(
334399 onClick = onToggle,
335400 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
@@ -550,10 +615,9 @@ private fun DiffRow(
550615 // Highlight the line's content (minus the leading +/-/space prefix, which DiffView
551616 // already stripped) when a syntax was resolved from a file header. Falls back to plain
552617 // monospace text when no syntax is known (e.g. an untagged patch), preserving the prior
553− // rendering for diffs that can't be classified.
554− val highlighted: AnnotatedString = remember(text, syntax, palette) {
555− if (syntax != null) highlightLine(text, syntax, palette) else AnnotatedString(text)
556− }
618+ // rendering for diffs that can't be classified. Off-main + cross-message cached so a
619+ // re-displayed row (scroll recycle, theme switch) is a hash hit instead of a re-tokenize.
620+ val highlighted: AnnotatedString = rememberHighlightedCode(text, syntax, palette)
557621 val a11yLabel = when (prefix) {
558622 "+" -> stringResource(R.string.diff_added_line, newLine ?: 0, text)
559623 "-" -> stringResource(R.string.diff_removed_line, oldLine ?: 0, text)
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+59−15
@@ -84,6 +84,8 @@ import com.mikepenz.markdown.compose.elements.MarkdownText as LibraryMarkdownTex
8484import com.mikepenz.markdown.m3.Markdown
8585import com.mikepenz.markdown.model.DefaultMarkdownAnnotator
8686import com.mikepenz.markdown.model.DefaultMarkdownAnnotatorConfig
87+import com.mikepenz.markdown.model.MarkdownState
88+import com.mikepenz.markdown.model.rememberMarkdownState
8789import org.intellij.markdown.MarkdownElementTypes
8890import org.intellij.markdown.ast.ASTNode
8991import org.intellij.markdown.ast.getTextInNode
@@ -166,12 +168,20 @@ private fun MarkdownBody(
166168 // on a single-backtick inline code span copies its text — matching the copy affordance the
167169 // fenced/indented code blocks already have via [CodeWithCopy].
168170 val components = if (imageContext != null) {
169− markdownComponents(
170− codeFence = { CodeWithCopy(it) },
171− codeBlock = { CodeWithCopy(it) },
172− paragraph = { ParagraphWithInlineCodeCopy(it) },
173− image = { MarkdownImage(it, imageContext) },
174− )
171+ // Remember on imageContext so a recomposition that doesn't change the context (the
172+ // common case during streaming/scroll) doesn't rebuild the 21-field components
173+ // container + 4 lambdas. The lambdas capture `imageContext` (for the image component)
174+ // and the CodeWithCopy/ParagraphWithInlineCodeCopy factories (which only capture the
175+ // stable MarkdownComponentModel passed by the library at invoke time), so keying on
176+ // imageContext is sufficient for stability.
177+ remember(imageContext) {
178+ markdownComponents(
179+ codeFence = { CodeWithCopy(it) },
180+ codeBlock = { CodeWithCopy(it) },
181+ paragraph = { ParagraphWithInlineCodeCopy(it) },
182+ image = { MarkdownImage(it, imageContext) },
183+ )
184+ }
175185 } else {
176186 remember {
177187 markdownComponents(
@@ -320,11 +330,26 @@ private fun MarkdownBody(
320330 // A polite live region announces streaming content to TalkBack users. The throttle
321331 // (50ms) plus TalkBack's own polite-queue coalescing keeps announcements from
322332 // interrupting on every token; the reader hears the reply grow in measured chunks.
333+ //
334+ // retainState = true is critical: without it the library flips the internal state
335+ // to Loading (an empty Box) on every content change, then back to Success once the
336+ // off-main parse completes. During streaming the throttle commits ~20x/sec, so the
337+ // whole rendered subtree would blink out and be rebuilt on every commit — visible
338+ // flicker, full recomposition of every paragraph/code block, and layout thrash as
339+ // the bubble height collapses then re-measures. retainState keeps the previous
340+ // Success visible while the new content parses, so the swap is Success→Success
341+ // (no empty-box frame). The library's own rememberMarkdownState already uses a
342+ // single LaunchedEffect(Unit) + snapshotFlow + conflate to feed updates without
343+ // re-launching, so this composes cleanly with the throttle above.
323344 CompositionLocalProvider(
324345 LocalUriHandler provides safeUriHandler,
325346 ) {
326− Markdown(
347+ val markdownState: MarkdownState = rememberMarkdownState(
327348 content = renderedContent,
349+ retainState = true,
350+ )
351+ Markdown(
352+ markdownState = markdownState,
328353 modifier = modifier.semantics { liveRegion = LiveRegionMode.Polite },
329354 components = components,
330355 annotator = effectiveAnnotator,
@@ -335,10 +360,19 @@ private fun MarkdownBody(
335360 // element so the markdown AST isn't re-parsed to toggle it.
336361 if (!LocalReducedMotion.current) StreamingCaret()
337362 } else {
363+ // retainState = true avoids a one-frame Loading flash when the content changes
364+ // (e.g. an edit re-render, or a theme switch re-resolving colors). For static
365+ // content the first parse is fast and the Success is shown directly; retainState
366+ // only matters on subsequent content changes, where it keeps the old Success
367+ // visible until the new one is ready instead of flashing an empty Box.
338368 val content: @Composable () -> Unit = {
339369 SelectionContainer {
340− Markdown(
370+ val markdownState: MarkdownState = rememberMarkdownState(
341371 content = markdown,
372+ retainState = true,
373+ )
374+ Markdown(
375+ markdownState = markdownState,
342376 modifier = modifier.semantics { liveRegion = LiveRegionMode.Polite },
343377 components = components,
344378 annotator = effectiveAnnotator,
@@ -481,10 +515,18 @@ private fun ParagraphWithInlineCodeCopy(model: MarkdownComponentModel) {
481515 codeSpanStyle = typography.inlineCode.toSpanStyle(),
482516 annotator = annotator,
483517 )
484− val styledText = buildAnnotatedString {
485− pushStyle(style.toSpanStyle())
486− buildMarkdownAnnotatedString(model.content, model.node, settings)
487− pop()
518+ // Memoize the annotated string on its inputs so a recomposition that doesn't change
519+ // the paragraph's content/AST/style (the common case — e.g. a sibling paragraph
520+ // recomposing, a theme color resolving to the same values) doesn't re-run the
521+ // library's inline-markdown annotator pass (regex/string scanning + span allocation).
522+ // During streaming, the parent's retainState=true means only the changed paragraphs
523+ // recompose, so this caps re-annotation to genuinely-changed paragraphs.
524+ val styledText = remember(model.content, model.node, settings, style) {
525+ buildAnnotatedString {
526+ pushStyle(style.toSpanStyle())
527+ buildMarkdownAnnotatedString(model.content, model.node, settings)
528+ pop()
529+ }
488530 }
489531 // Capture the layout result so a long-press position can be mapped to a character offset,
490532 // then to a tagged range — the same hit-testing pattern the library uses for link taps.
@@ -560,9 +602,11 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
560602 // collapsed head and the full expanded block are both highlighted correctly.
561603 val palette = rememberHighlightPalette()
562604 val syntax = remember(language) { language?.let { syntaxForLanguageTag(it) } }
563− val highlighted = remember(displayCode, syntax, palette) {
564− if (syntax != null) highlightCode(displayCode, syntax, palette) else AnnotatedString(displayCode)
565− }
605+ // Off-main highlighting with a cross-message LRU cache. During streaming the throttle
606+ // coalesces re-highlights to ~20/sec, and the cache means a re-displayed block (scroll
607+ // recycle, theme switch) is a hash hit instead of a full re-tokenize. The first frame
608+ // uses a synchronous (cached or computed) result so the block doesn't flash plain-text.
609+ val highlighted = rememberHighlightedCode(displayCode, syntax, palette)
566610 // Default per-block wrap from the global preference; re-seed if the preference changes
567611 // while this block is composed (rememberSaveable would over-persist across blocks).
568612 val defaultWrap = LocalCodeWrap.current
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+11−4
@@ -109,11 +109,13 @@ import androidx.compose.ui.platform.LocalContext
109109import androidx.lifecycle.compose.collectAsStateWithLifecycle
110110import androidx.lifecycle.viewmodel.compose.viewModel
111111import kotlinx.coroutines.coroutineScope
112+import kotlinx.coroutines.Dispatchers
112113import kotlinx.coroutines.flow.collectLatest
113114import kotlinx.coroutines.flow.distinctUntilChanged
114115import kotlinx.coroutines.flow.merge
115116import kotlinx.coroutines.delay
116117import kotlinx.coroutines.launch
118+import kotlinx.coroutines.withContext
117119import soy.iko.opencode.data.model.ServerProfile
118120import soy.iko.opencode.data.model.Session
119121import soy.iko.opencode.data.network.NetworkConfig
@@ -1246,10 +1248,15 @@ private fun SyncShortcutsAndWidget(
12461248 LaunchedEffect(state.sessions) {
12471249 val sessions = state.sessions
12481250 if (sessions.isEmpty()) return@LaunchedEffect
1249− val recents = sessions.take(RecentSessionsStore.MAX).map { RecentSession(it.id, it.displayTitle) }
1250− RecentSessionsStore.write(platformContext, recents)
1251− AppShortcuts.update(platformContext, recents.firstOrNull())
1252− SessionsWidgetProvider.refresh(platformContext)
1251+ // Disk writes (RecentSessionsStore) + AppShortcuts + AppWidgetManager refreshes
1252+ // involve binder IPC and file I/O; run them off the main thread so a debounced
1253+ // session-update burst (e.g. during multi-session streaming) doesn't jank the list.
1254+ withContext(Dispatchers.IO) {
1255+ val recents = sessions.take(RecentSessionsStore.MAX).map { RecentSession(it.id, it.displayTitle) }
1256+ RecentSessionsStore.write(platformContext, recents)
1257+ AppShortcuts.update(platformContext, recents.firstOrNull())
1258+ SessionsWidgetProvider.refresh(platformContext)
1259+ }
12531260 }
12541261}
12551262