@@ -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