@@ -55,6 +55,7 @@ import androidx.compose.material.icons.filled.Description
5555import androidx.compose.material.icons.filled.Close
5656import androidx.compose.material.icons.filled.Edit
5757import androidx.compose.material.icons.filled.Expand
58+import androidx.compose.material.icons.filled.FormatQuote
5859import androidx.compose.material.icons.filled.ExpandLess
5960import androidx.compose.material.icons.filled.ExpandMore
6061import androidx.compose.material.icons.filled.Search
@@ -190,6 +191,9 @@ import soy.iko.opencode.ui.components.toImageContext
190191import soy.iko.opencode.ui.vmFactory
191192import soy.iko.opencode.util.runCatchingCancellable
192193
194+// Optional message id to scroll to and briefly highlight on first load (from global search).
195+// Consumed once: after the message is scrolled into view and highlighted, the focus is
196+// cleared so subsequent recompositions don't re-trigger the scroll.
193197@OptIn(ExperimentalMaterial3Api::class)
194198@Composable
195199fun ChatScreen(
@@ -198,6 +202,7 @@ fun ChatScreen(
198202 onBack: () -> Unit,
199203 onOpenFile: ((String) -> Unit)? = null,
200204 onOpenSession: ((String) -> Unit)? = null,
205+ focusMessageId: String? = null,
201206) {
202207 val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
203208 val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
@@ -224,8 +229,10 @@ fun ChatScreen(
224229 val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
225230 val sessionDeleted by vm.sessionDeleted.collectAsStateWithLifecycle()
226231 val draft by vm.draft.collectAsStateWithLifecycle()
232+ val pendingQuote by vm.pendingQuote.collectAsStateWithLifecycle()
227233 val attachments by vm.attachments.collectAsStateWithLifecycle()
228234 val reverted by vm.reverted.collectAsStateWithLifecycle()
235+ val editing by vm.editing.collectAsStateWithLifecycle()
229236 val revertDiff by vm.revertDiff.collectAsStateWithLifecycle()
230237 val shareUrl by vm.shareUrl.collectAsStateWithLifecycle()
231238 val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
@@ -855,6 +862,7 @@ fun ChatScreen(
855862 ) {
856863 RevertBanner(
857864 diff = revertDiff,
865+ isEditing = editing,
858866 onUndo = { vm.unrevert() },
859867 )
860868 }
@@ -870,6 +878,13 @@ fun ChatScreen(
870878 onDiscard = { vm.discardAllQueued() },
871879 )
872880 }
881+ AnimatedVisibility(
882+ visible = pendingQuote != null,
883+ enter = bannerMotion.enter,
884+ exit = bannerMotion.exit,
885+ ) {
886+ pendingQuote?.let { QuoteReplyBanner(it, onCancel = { vm.cancelQuoteReply() }) }
887+ }
873888 ChatInputBar(
874889 value = draft,
875890 onValueChange = vm::updateDraft,
@@ -1059,6 +1074,23 @@ fun ChatScreen(
10591074 if (index >= 0) runCatchingCancellable { listState.animateScrollToItem(index) }
10601075 }
10611076
1077+ // Global-search deep link: scroll to and briefly highlight the matched message. The
1078+ // focus id is threaded through the chat route from GlobalSearchScreen. The highlight
1079+ // clears itself after a short delay so the user can re-read the surrounding context
1080+ // without a persistent marker. Runs once per focusMessageId (cleared on consume).
1081+ var focusedMessageId by remember(focusMessageId) { mutableStateOf(focusMessageId) }
1082+ LaunchedEffect(focusedMessageId, listItems) {
1083+ val focus = focusedMessageId ?: return@LaunchedEffect
1084+ if (listItems.isEmpty()) return@LaunchedEffect
1085+ val index = listItems.indexOfFirst {
1086+ it is MessageListItem.Message && it.message.info.id == focus
1087+ }
1088+ if (index < 0) return@LaunchedEffect
1089+ runCatchingCancellable { listState.animateScrollToItem(index) }
1090+ kotlinx.coroutines.delay(2500)
1091+ focusedMessageId = null
1092+ }
1093+
10621094 // Scroll to bottom when the IME opens and the user was already pinned to bottom, so the
10631095 // latest reply stays visible as the composer rises. Without this the IME can obscure the
10641096 // last message even though the user was reading it a moment ago. WindowInsets.ime is a
@@ -1406,8 +1438,7 @@ fun ChatScreen(
14061438 agentLabel = agentLabel,
14071439 onOpenFile = onOpenFile,
14081440 onRevert = { vm.revertTo(message.info.id) },
1409− onEdit = { text ->
1410− // editMessage reverts to before this message (hiding it and
1441+ onEdit = { text -> // editMessage reverts to before this message (hiding it and
14111442 // everything after); the revert banner surfaces the rewind
14121443 // with an Undo. Focus + scroll the composer into view like the
14131444 // Quote action so the prefilled draft isn't off-screen.
@@ -1486,6 +1517,8 @@ fun ChatScreen(
14861517 .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
14871518 }
14881519 },
1520+ isFirstOfSpeaker = item.isFirstOfSpeaker,
1521+ highlighted = focusedMessageId != null && message.info.id == focusedMessageId,
14891522 )
14901523 }
14911524 }
@@ -2561,7 +2594,7 @@ private fun EmptyConversation(
25612594 * server included a diff of the revert, a collapsible "Show what changed" affordance reveals
25622595 * it inline via [DiffView] so the user can see what was rolled back before deciding to undo. */
25632596@Composable
2564−private fun RevertBanner(diff: String?, onUndo: () -> Unit) {
2597+private fun RevertBanner(diff: String?, isEditing: Boolean, onUndo: () -> Unit) {
25652598 val haptics = LocalHapticFeedback.current
25662599 var showDiff by rememberSaveable { mutableStateOf(false) }
25672600 val expandMotion = rememberVisibilityTransitions()
@@ -2578,13 +2611,13 @@ private fun RevertBanner(diff: String?, onUndo: () -> Unit) {
25782611 ) {
25792612 Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
25802613 Icon(
2581− Icons.Filled.Restore,
2614+ if (isEditing) Icons.Filled.Edit else Icons.Filled.Restore,
25822615 contentDescription = null,
25832616 tint = MaterialTheme.colorScheme.onTertiaryContainer,
25842617 modifier = Modifier.size(18.dp),
25852618 )
25862619 Text(
2587− stringResource(R.string.reverted_banner),
2620+ stringResource(if (isEditing) R.string.editing_banner else R.string.reverted_banner),
25882621 style = MaterialTheme.typography.bodySmall,
25892622 color = MaterialTheme.colorScheme.onTertiaryContainer,
25902623 modifier = Modifier.padding(start = 8.dp),
@@ -2643,6 +2676,57 @@ private fun RevertBanner(diff: String?, onUndo: () -> Unit) {
26432676 }
26442677}
26452678
2679+/** A dismissible preview card shown above the composer when the user quote-replied to a
2680+ * message. Displays the quoted text (truncated) with a Cancel affordance so the user can
2681+ * abort the quote without digging into the draft to strip the `>`-prefixed lines. Mirrors
2682+ * the Discord/Slack quote-preview pattern. */
2683+@Composable
2684+private fun QuoteReplyBanner(quotedText: String, onCancel: () -> Unit) {
2685+ val haptics = LocalHapticFeedback.current
2686+ Surface(
2687+ color = MaterialTheme.colorScheme.secondaryContainer,
2688+ tonalElevation = 2.dp,
2689+ modifier = Modifier.fillMaxWidth(),
2690+ ) {
2691+ Row(
2692+ modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
2693+ verticalAlignment = Alignment.Top,
2694+ ) {
2695+ Icon(
2696+ Icons.Filled.FormatQuote,
2697+ contentDescription = null,
2698+ tint = MaterialTheme.colorScheme.onSecondaryContainer,
2699+ modifier = Modifier.size(18.dp).padding(top = 2.dp),
2700+ )
2701+ Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) {
2702+ Text(
2703+ stringResource(R.string.quote_reply_banner),
2704+ style = MaterialTheme.typography.labelSmall,
2705+ color = MaterialTheme.colorScheme.onSecondaryContainer,
2706+ )
2707+ Text(
2708+ quotedText,
2709+ style = MaterialTheme.typography.bodySmall,
2710+ color = MaterialTheme.colorScheme.onSecondaryContainer,
2711+ maxLines = 2,
2712+ overflow = TextOverflow.Ellipsis,
2713+ )
2714+ }
2715+ IconButton(onClick = {
2716+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2717+ onCancel()
2718+ }) {
2719+ Icon(
2720+ Icons.Filled.Close,
2721+ contentDescription = stringResource(R.string.cancel_quote_reply),
2722+ tint = MaterialTheme.colorScheme.onSecondaryContainer,
2723+ modifier = Modifier.size(18.dp),
2724+ )
2725+ }
2726+ }
2727+ }
2728+}
2729+
26462730/** A banner shown while messages composed offline are queued in the outbox, with a
26472731 * "Send now" nudge and a discard. Shows a spinner while the queue is flushing. */
26482732@Composable
@@ -2818,7 +2902,15 @@ private sealed interface MessageListItem {
28182902 val key: Any
28192903 val contentType: Any
28202904
2821− data class Message(val message: MessageWithParts, override val key: Any) : MessageListItem {
2905+ data class Message(
2906+ val message: MessageWithParts,
2907+ override val key: Any,
2908+ // Whether this is the first message in a consecutive run of the same speaker (role).
2909+ // Used to suppress the assistant avatar/header on follow-up messages in the same run,
2910+ // reducing visual noise in tool-heavy conversations where many assistant messages
2911+ // appear back-to-back. A separator or a role change resets the flag.
2912+ val isFirstOfSpeaker: Boolean,
2913+ ) : MessageListItem {
28222914 override val contentType: Any get() = message.info::class
28232915 }
28242916
@@ -2859,6 +2951,11 @@ private fun buildMessageListItems(
28592951 var sepOrdinal = 0
28602952 var emptyIdOrdinal = 0
28612953 var first = true
2954+ // Track the previous message's role to detect consecutive same-speaker runs, so the
2955+ // avatar/header can be suppressed on follow-up messages in the same run. A null means
2956+ // "no previous message or a separator just interrupted the run", both of which make the
2957+ // next message the first of its group.
2958+ var prevRoleClass: kotlin.reflect.KClass<out soy.iko.opencode.data.model.MessageInfo>? = null
28622959 for (message in messages) {
28632960 val ts = message.info.time?.created ?: message.info.time?.updated ?: message.info.time?.completed
28642961 val dayKey = ts?.let { dayKey(it) } ?: ""
@@ -2869,6 +2966,9 @@ private fun buildMessageListItems(
28692966 // message would see a "" -> "<today>" transition and emit a duplicate "Today" header.
28702967 if (first || (dayKey.isNotEmpty() && dayKey != lastDayKey)) {
28712968 result.add(MessageListItem.Separator(dayLabel(dayKey, ts ?: 0L, today, todayLabel, yesterdayLabel, dateFmt), sepOrdinal++))
2969+ // A date separator visually breaks a speaker run, so the next message is treated
2970+ // as the first of its group (even if the same role continues across the day break).
2971+ prevRoleClass = null
28722972 }
28732973 if (dayKey.isNotEmpty()) lastDayKey = dayKey
28742974 first = false
@@ -2882,7 +2982,10 @@ private fun buildMessageListItems(
28822982 while (!seenKeys.add("$key#$suffix")) suffix++
28832983 key = "$key#$suffix"
28842984 }
2885− result.add(MessageListItem.Message(message, key))
2985+ val roleClass = message.info::class
2986+ val isFirstOfSpeaker = prevRoleClass == null || roleClass != prevRoleClass
2987+ result.add(MessageListItem.Message(message, key, isFirstOfSpeaker))
2988+ prevRoleClass = roleClass
28862989 }
28872990 return result
28882991}