@@ -979,14 +979,21 @@ fun ChatScreen(
979 // doesn't jank the UI. produceState yields the empty list immediately and publishes
979 // doesn't jank the UI. produceState yields the empty list immediately and publishes
980 // the filtered result once the debounce settles.
980 // the filtered result once the debounce settles.
981 val searchQuery = chatSearch.trim()
981 val searchQuery = chatSearch.trim()
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()) {
982− val searchMessages by produceState(
992+ val filtered by produceState(
983− initialValue = if (searchQuery.isEmpty()) messages else emptyList(),
993+ initialValue = emptyList(),
984− messages,
994+ messages,
985− searchQuery,
995+ searchQuery,
987− if (searchQuery.isEmpty()) {
990 // Debounce: wait for the user to stop typing before running the filter,
997 // Debounce: wait for the user to stop typing before running the filter,
991 // so each keystroke doesn't kick off a fresh scan.
998 // so each keystroke doesn't kick off a fresh scan.
992 kotlinx.coroutines.delay(NetworkConfig.chatSearchDebounceMs)
999 kotlinx.coroutines.delay(NetworkConfig.chatSearchDebounceMs)
@@ -1018,6 +1025,7 @@ fun ChatScreen(
1022 // Keep the focused-match index in range as the query (and thus the match set) changes.
1030 // Keep the focused-match index in range as the query (and thus the match set) changes.
1023 // A new query resets to the first match; a shrunk set clamps to the last valid index.
1031 // A new query resets to the first match; a shrunk set clamps to the last valid index.
@@ -1449,6 +1457,7 @@ fun ChatScreen(
1449 is MessageListItem.Separator -> DateSeparator(item.label)
1457 is MessageListItem.Separator -> DateSeparator(item.label)
1450 is MessageListItem.Message -> {
1458 is MessageListItem.Message -> {
1451 val message = item.message
1459 val message = item.message
1460+ val messageId = message.info.id
1452 // Only the last (streaming) message needs isRunning — it drives the
1461 // Only the last (streaming) message needs isRunning — it drives the
1453 // reasoning-block spinner. Passing the live flag to every bubble
1462 // reasoning-block spinner. Passing the live flag to every bubble
1454 // makes all visible messages recompose whenever a run starts or stops.
1463 // makes all visible messages recompose whenever a run starts or stops.
@@ -1456,14 +1465,28 @@ fun ChatScreen(
1456 (message.info as? soy.iko.opencode.data.model.AssistantMessage)
1465 (message.info as? soy.iko.opencode.data.model.AssistantMessage)
1457 ?.let { resolveModelLabel(it, models) }
1466 ?.let { resolveModelLabel(it, models) }
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).
1459− val agentLabel = remember(message.parts) {
1482+ val agentLabel = remember(messageId, message.parts) {
1460 message.parts
1483 message.parts
1461 .filterIsInstance<soy.iko.opencode.data.model.AgentPart>()
1484 .filterIsInstance<soy.iko.opencode.data.model.AgentPart>()
1462 .firstOrNull { it.name.isNotBlank() }?.name
1485 .firstOrNull { it.name.isNotBlank() }?.name
1464 // Text used to drive swipe-to-reply (and pre-fill the quote). Empty for
1487 // Text used to drive swipe-to-reply (and pre-fill the quote). Empty for
1465 // image/code-only messages, which then opt out of the swipe gesture.
1488 // 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) {
1467 message.parts
1490 message.parts
1468 .filterIsInstance<TextPart>()
1491 .filterIsInstance<TextPart>()
1469 .joinToString("\n\n") { it.text }
1492 .joinToString("\n\n") { it.text }
@@ -1478,39 +1501,39 @@ fun ChatScreen(
1478 val cantQuoteMsg = stringResource(R.string.cannot_quote_this)
1501 val cantQuoteMsg = stringResource(R.string.cannot_quote_this)
1479 val undoLabel = stringResource(R.string.undo)
1502 val undoLabel = stringResource(R.string.undo)
1480 val quoteReplyLabel = stringResource(R.string.quote_reply_banner)
1503 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.
1481 val swipeState = rememberSwipeToDismissBoxState(
1508 val swipeState = rememberSwipeToDismissBoxState(
1482− confirmValueChange = { value ->
1509+ confirmValueChange = remember(messageId, quoteText) {
1483− val replyValue = if (layoutDirection == LayoutDirection.Rtl) {
1511+ val replyValue = if (layoutDirection == LayoutDirection.Rtl) {
1484− SwipeToDismissBoxValue.EndToStart
1512+ SwipeToDismissBoxValue.EndToStart
1485− } else {
1513+ } else {
1486− SwipeToDismissBoxValue.StartToEnd
1514+ SwipeToDismissBoxValue.StartToEnd
1488− if (value == replyValue) {
1516+ if (value == replyValue) {
1489− if (quoteText != null) {
1517+ if (quoteText != null) {
1490− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1518+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1491− vm.quoteReply(quoteText)
1519+ vm.quoteReply(quoteText)
1492− runCatching { inputFocusRequester.requestFocus() }
1520+ 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 {
1521+ scope.launch {
1497− val res = snackbar.showSnackbar(
1522+ val res = snackbar.showSnackbar(
1498− message = quoteReplyLabel,
1523+ message = quoteReplyLabel,
1499− actionLabel = undoLabel,
1524+ actionLabel = undoLabel,
1501− if (res == androidx.compose.material3.SnackbarResult.ActionPerformed) {
1526+ if (res == androidx.compose.material3.SnackbarResult.ActionPerformed) {
1502− vm.cancelQuoteReply()
1527+ vm.cancelQuoteReply()
1531+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
1532+ scope.launch { snackbar.showSnackbar(cantQuoteMsg) }
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) }
1516 // In RTL locales, mirror the swipe direction so the reply
1539 // In RTL locales, mirror the swipe direction so the reply
@@ -1518,94 +1541,81 @@ fun ChatScreen(
1518 // toward the leading edge), matching how LTR users swipe
1541 // toward the leading edge), matching how LTR users swipe
1519 // left-to-right. Without this, RTL users had no reply swipe.
1542 // left-to-right. Without this, RTL users had no reply swipe.
1520 val replyFromStartToEnd = layoutDirection != LayoutDirection.Rtl
1543 val replyFromStartToEnd = layoutDirection != LayoutDirection.Rtl
1521− SwipeToDismissBox(
1544+ // Memoize every MessageBubble lambda on the message id so a recomposition
1522− state = swipeState,
1545+ // triggered by a sibling (e.g. the streaming message's parts changing,
1523− enableDismissFromEndToStart = !replyFromStartToEnd,
1546+ // which gives `listItems` a new identity) doesn't allocate fresh lambdas
1524− enableDismissFromStartToEnd = replyFromStartToEnd,
1547+ // for the unchanged bubbles. MessageBubble is non-skippable (it takes
1525− backgroundContent = {
1548+ // many `(() -> Unit)?` params, which Compose treats as unstable), so
1526− Box(
1549+ // without memoization every visible bubble recomposes on every token,
1527− Modifier.fillMaxSize().padding(horizontal = 16.dp),
1550+ // defeating the @Immutable MessageWithParts reference-stability the
1528− contentAlignment = if (replyFromStartToEnd) Alignment.CenterStart else Alignment.CenterEnd,
1551+ // reducer's Holder cache provides. This mirrors the SessionListScreen
1529− ) {
1552+ // per-card lambda memoization pattern.
1531− Icons.AutoMirrored.Filled.Reply,
1532− contentDescription = null,
1533− tint = MaterialTheme.colorScheme.onSurfaceVariant,
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) },
1553+ val onRevert = remember(messageId) { { vm.revertTo(messageId) } }
1554+ val onEdit = remember(messageId) {
1546− onEdit = { text -> // editMessage reverts to before this message (hiding it and
1556+ // editMessage reverts to before this message (hiding it and
1547 // everything after); the revert banner surfaces the rewind
1557 // everything after); the revert banner surfaces the rewind
1548 // with an Undo. Focus + scroll the composer into view like the
1558 // with an Undo. Focus + scroll the composer into view like the
1549 // Quote action so the prefilled draft isn't off-screen.
1559 // Quote action so the prefilled draft isn't off-screen.
1550 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1560 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1551− vm.editMessage(message.info.id, text)
1561+ vm.editMessage(messageId, text)
1552 runCatching { inputFocusRequester.requestFocus() }
1562 runCatching { inputFocusRequester.requestFocus() }
1553 contentScope.launch {
1563 contentScope.launch {
1554 runCatchingCancellable {
1564 runCatchingCancellable {
1555− listState.animateScrollToItem(listItems.lastIndex.coerceAtLeast(0))
1565+ listState.animateScrollToItem(currentListItems.lastIndex.coerceAtLeast(0))
1559− onSpeak = { text ->
1570+ }
1571+ val onSpeak = remember(messageId) {
1560− if (!tts.toggle(message.info.id, text)) {
1573+ if (!tts.toggle(messageId, 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.
1565 scope.launch { snackbar.showSnackbar(ttsUnavailableMsg) }
1574 scope.launch { snackbar.showSnackbar(ttsUnavailableMsg) }
1568− isSpeaking = message.info.id == speakingMessageId,
1578+ }
1569− ttsState = if (message.info.id == speakingMessageId) ttsState else TtsState.IDLE,
1570− onPause = { tts.pause() },
1579+ val onPause = remember { { tts.pause() } }
1571− onResume = { tts.resume() },
1580+ val onResume = remember { { tts.resume() } }
1572− onStop = { tts.stop() },
1581+ val onStop = remember { { tts.stop() } }
1573− onQuote = { text ->
1582+ val onQuote = remember(messageId) {
1574 vm.quoteReply(text)
1584 vm.quoteReply(text)
1575 runCatching { inputFocusRequester.requestFocus() }
1585 runCatching { inputFocusRequester.requestFocus() }
1577− onBranch = { text -> vm.branchFrom(text) },
1589+ val onBranch = remember { { text: String -> vm.branchFrom(text); Unit } }
1578− // Regenerate is only meaningful for a finished assistant reply
1590+ // Regenerate is only meaningful for a finished assistant reply
1579− // (not the actively-streaming one), so gate it on !running.
1591+ // (not the actively-streaming one), so gate it on !running.
1580− onRegenerate = if (!running) {
1592+ val onRegenerate = if (!running) {
1581− { vm.regenerate(message.info.id) }
1593+ remember(messageId) { { vm.regenerate(messageId) } }
1582− } else null,
1594+ } else {
1583− // Continue: resume a partial reply. Shown only for
1597+ // Continue: resume a partial reply. Shown only for
1584− // incomplete assistant messages (a partial reply that
1598+ // incomplete assistant messages (a partial reply that
1585− // was aborted) and not while a run is active.
1599+ // was aborted) and not while a run is active.
1586− onContinue = if (!running && message.info is AssistantMessage && !message.info.isComplete) {
1600+ val canContinue = !running && message.info is AssistantMessage && !message.info.isComplete
1601+ val onContinue = if (canContinue) {
1587− { vm.continueRun(message.info.id) }
1602+ remember(messageId) { { vm.continueRun(messageId) } }
1588− } else null,
1603+ } else {
1589− sendStatus = optimisticStatuses[message.info.id]?.let { failed ->
1604+ null
1590− if (failed) soy.iko.opencode.ui.chat.MessageSendStatus.FAILED
1605+ }
1591− else soy.iko.opencode.ui.chat.MessageSendStatus.SENDING
1606+ val messageFailed = optimisticStatuses[messageId] == true
1592− },
1607+ val onRetry = if (messageFailed) {
1593− isEdited = message.info.time?.let { it.updated != null && it.updated != it.created } == true,
1594− onRetry = optimisticStatuses[message.info.id]?.let { failed ->
1596− { vm.retryOptimisticMessage(message.info.id) }
1608+ remember(messageId) { { vm.retryOptimisticMessage(messageId); Unit } }
1597− } else {
1609+ } else {
1600− },
1612+ val onDismiss = if (messageFailed) {
1601− onDismiss = optimisticStatuses[message.info.id]?.let { failed ->
1603− { vm.dismissOptimistic(message.info.id) }
1613+ remember(messageId) { { vm.dismissOptimistic(messageId) } }
1604− } else {
1614+ } else {
1607− },
1617+ val onShare = remember(messageId, sessionTitle, defaultShareSubject) {
1609 // Per-message share: build a single-message Markdown
1619 // Per-message share: build a single-message Markdown
1610 // transcript off-thread and fire ACTION_SEND, mirroring the
1620 // transcript off-thread and fire ACTION_SEND, mirroring the
1611 // whole-conversation share but scoped to just this message.
1621 // whole-conversation share but scoped to just this message.
@@ -1621,9 +1631,55 @@ fun ChatScreen(
1621 runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareContext.getString(R.string.share_message))) }
1631 runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareContext.getString(R.string.share_message))) }
1622 .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
1632 .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
1638+ state = swipeState,
1639+ enableDismissFromEndToStart = !replyFromStartToEnd,
1640+ enableDismissFromStartToEnd = replyFromStartToEnd,
1641+ backgroundContent = {
1643+ Modifier.fillMaxSize().padding(horizontal = 16.dp),
1644+ contentAlignment = if (replyFromStartToEnd) Alignment.CenterStart else Alignment.CenterEnd,
1647+ Icons.AutoMirrored.Filled.Reply,
1648+ contentDescription = null,
1649+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
1656+ isRunning = running && messageId == lastMessageId,
1657+ imageContext = imageContext,
1658+ modelLabel = modelLabel,
1659+ agentLabel = agentLabel,
1660+ onOpenFile = onOpenFile,
1661+ onRevert = onRevert,
1664+ isSpeaking = messageId == speakingMessageId,
1665+ ttsState = if (messageId == speakingMessageId) ttsState else TtsState.IDLE,
1667+ onResume = onResume,
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
1677+ isEdited = message.info.time?.let { it.updated != null && it.updated != it.created } == true,
1679+ onDismiss = onDismiss,
1625 isFirstOfSpeaker = item.isFirstOfSpeaker,
1681 isFirstOfSpeaker = item.isFirstOfSpeaker,
1626− highlighted = focusedMessageId != null && message.info.id == focusedMessageId,
1682+ highlighted = focusedMessageId != null && messageId == focusedMessageId,
@@ -3154,18 +3210,22 @@ private fun buildMessageListItems(
3154 * naive implementation would re-allocate an `Instant`/`ZonedDateTime`/`LocalDate` for
3210 * naive implementation would re-allocate an `Instant`/`ZonedDateTime`/`LocalDate` for
3155 * every message on every token. Message timestamps are immutable once assigned, so we
3211 * every message on every token. Message timestamps are immutable once assigned, so we
3156 * memoize the parse per epoch-millis: after the first snapshot, each unchanged message is
3212 * 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
3213+ * a cheap hash lookup. Bounded; only touched on the main thread during composition.
3158− * thread during composition, so the coarse lock is uncontended.
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]).
3160−private val dayKeyCache = object : LinkedHashMap<Long, String>(128, 0.75f, true) {
3224+private val dayKeyCache = java.util.concurrent.ConcurrentHashMap<Long, String>(256)
3161− override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Long, String>): Boolean = size > 1024
3164−private fun dayKey(epochMillis: Long): String = synchronized(dayKeyCache) {
3165− dayKeyCache.getOrPut(epochMillis) {
3226+private fun dayKey(epochMillis: Long): String = dayKeyCache.getOrPut(epochMillis) {
3166− java.time.Instant.ofEpochMilli(epochMillis)
3227+ java.time.Instant.ofEpochMilli(epochMillis)
3167− .atZone(java.time.ZoneId.systemDefault()).toLocalDate().toEpochDay().toString()
3228+ .atZone(java.time.ZoneId.systemDefault()).toLocalDate().toEpochDay().toString()
3171// The medium-date formatter is an ICU locale lookup; cache one instance instead of
3231// The medium-date formatter is an ICU locale lookup; cache one instance instead of