@@ -55,6 +55,7 @@ import androidx.compose.material.icons.filled.Description
55import androidx.compose.material.icons.filled.Close
55import androidx.compose.material.icons.filled.Close
56import androidx.compose.material.icons.filled.Edit
56import androidx.compose.material.icons.filled.Edit
57import androidx.compose.material.icons.filled.Expand
57import androidx.compose.material.icons.filled.Expand
58+import androidx.compose.material.icons.filled.FormatQuote
58import androidx.compose.material.icons.filled.ExpandLess
59import androidx.compose.material.icons.filled.ExpandLess
59import androidx.compose.material.icons.filled.ExpandMore
60import androidx.compose.material.icons.filled.ExpandMore
60import androidx.compose.material.icons.filled.Search
61import androidx.compose.material.icons.filled.Search
@@ -190,6 +191,9 @@ import soy.iko.opencode.ui.components.toImageContext
190import soy.iko.opencode.ui.vmFactory
191import soy.iko.opencode.ui.vmFactory
191import soy.iko.opencode.util.runCatchingCancellable
192import soy.iko.opencode.util.runCatchingCancellable
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.
193@OptIn(ExperimentalMaterial3Api::class)
197@OptIn(ExperimentalMaterial3Api::class)
194@Composable
198@Composable
195fun ChatScreen(
199fun ChatScreen(
@@ -198,6 +202,7 @@ fun ChatScreen(
198 onBack: () -> Unit,
202 onBack: () -> Unit,
199 onOpenFile: ((String) -> Unit)? = null,
203 onOpenFile: ((String) -> Unit)? = null,
200 onOpenSession: ((String) -> Unit)? = null,
204 onOpenSession: ((String) -> Unit)? = null,
205+ focusMessageId: String? = null,
202 val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
207 val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
203 val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
208 val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
@@ -224,8 +229,10 @@ fun ChatScreen(
224 val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
229 val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
225 val sessionDeleted by vm.sessionDeleted.collectAsStateWithLifecycle()
230 val sessionDeleted by vm.sessionDeleted.collectAsStateWithLifecycle()
226 val draft by vm.draft.collectAsStateWithLifecycle()
231 val draft by vm.draft.collectAsStateWithLifecycle()
232+ val pendingQuote by vm.pendingQuote.collectAsStateWithLifecycle()
227 val attachments by vm.attachments.collectAsStateWithLifecycle()
233 val attachments by vm.attachments.collectAsStateWithLifecycle()
228 val reverted by vm.reverted.collectAsStateWithLifecycle()
234 val reverted by vm.reverted.collectAsStateWithLifecycle()
235+ val editing by vm.editing.collectAsStateWithLifecycle()
229 val revertDiff by vm.revertDiff.collectAsStateWithLifecycle()
236 val revertDiff by vm.revertDiff.collectAsStateWithLifecycle()
230 val shareUrl by vm.shareUrl.collectAsStateWithLifecycle()
237 val shareUrl by vm.shareUrl.collectAsStateWithLifecycle()
231 val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
238 val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
@@ -855,6 +862,7 @@ fun ChatScreen(
856 RevertBanner(
863 RevertBanner(
857 diff = revertDiff,
864 diff = revertDiff,
865+ isEditing = editing,
858 onUndo = { vm.unrevert() },
866 onUndo = { vm.unrevert() },
@@ -870,6 +878,13 @@ fun ChatScreen(
870 onDiscard = { vm.discardAllQueued() },
878 onDiscard = { vm.discardAllQueued() },
882+ visible = pendingQuote != null,
883+ enter = bannerMotion.enter,
884+ exit = bannerMotion.exit,
886+ pendingQuote?.let { QuoteReplyBanner(it, onCancel = { vm.cancelQuoteReply() }) }
873 ChatInputBar(
888 ChatInputBar(
874 value = draft,
889 value = draft,
875 onValueChange = vm::updateDraft,
890 onValueChange = vm::updateDraft,
@@ -1059,6 +1074,23 @@ fun ChatScreen(
1059 if (index >= 0) runCatchingCancellable { listState.animateScrollToItem(index) }
1074 if (index >= 0) runCatchingCancellable { listState.animateScrollToItem(index) }
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
1088+ if (index < 0) return@LaunchedEffect
1089+ runCatchingCancellable { listState.animateScrollToItem(index) }
1090+ kotlinx.coroutines.delay(2500)
1091+ focusedMessageId = null
1062 // Scroll to bottom when the IME opens and the user was already pinned to bottom, so the
1094 // Scroll to bottom when the IME opens and the user was already pinned to bottom, so the
1063 // latest reply stays visible as the composer rises. Without this the IME can obscure the
1095 // latest reply stays visible as the composer rises. Without this the IME can obscure the
1064 // last message even though the user was reading it a moment ago. WindowInsets.ime is a
1096 // last message even though the user was reading it a moment ago. WindowInsets.ime is a
@@ -1406,8 +1438,7 @@ fun ChatScreen(
1406 agentLabel = agentLabel,
1438 agentLabel = agentLabel,
1407 onOpenFile = onOpenFile,
1439 onOpenFile = onOpenFile,
1408 onRevert = { vm.revertTo(message.info.id) },
1440 onRevert = { vm.revertTo(message.info.id) },
1410− // editMessage reverts to before this message (hiding it and
1441+ onEdit = { text -> // editMessage reverts to before this message (hiding it and
1411 // everything after); the revert banner surfaces the rewind
1442 // everything after); the revert banner surfaces the rewind
1412 // with an Undo. Focus + scroll the composer into view like the
1443 // with an Undo. Focus + scroll the composer into view like the
1413 // Quote action so the prefilled draft isn't off-screen.
1444 // Quote action so the prefilled draft isn't off-screen.
@@ -1486,6 +1517,8 @@ fun ChatScreen(
1486 .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
1517 .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
1520+ isFirstOfSpeaker = item.isFirstOfSpeaker,
1521+ highlighted = focusedMessageId != null && message.info.id == focusedMessageId,
@@ -2561,7 +2594,7 @@ private fun EmptyConversation(
2561 * server included a diff of the revert, a collapsible "Show what changed" affordance reveals
2594 * server included a diff of the revert, a collapsible "Show what changed" affordance reveals
2562 * it inline via [DiffView] so the user can see what was rolled back before deciding to undo. */
2595 * it inline via [DiffView] so the user can see what was rolled back before deciding to undo. */
2563@Composable
2596@Composable
2564−private fun RevertBanner(diff: String?, onUndo: () -> Unit) {
2597+private fun RevertBanner(diff: String?, isEditing: Boolean, onUndo: () -> Unit) {
2565 val haptics = LocalHapticFeedback.current
2598 val haptics = LocalHapticFeedback.current
2566 var showDiff by rememberSaveable { mutableStateOf(false) }
2599 var showDiff by rememberSaveable { mutableStateOf(false) }
2567 val expandMotion = rememberVisibilityTransitions()
2600 val expandMotion = rememberVisibilityTransitions()
@@ -2578,13 +2611,13 @@ private fun RevertBanner(diff: String?, onUndo: () -> Unit) {
2579 Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
2612 Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
2581− Icons.Filled.Restore,
2614+ if (isEditing) Icons.Filled.Edit else Icons.Filled.Restore,
2582 contentDescription = null,
2615 contentDescription = null,
2583 tint = MaterialTheme.colorScheme.onTertiaryContainer,
2616 tint = MaterialTheme.colorScheme.onTertiaryContainer,
2584 modifier = Modifier.size(18.dp),
2617 modifier = Modifier.size(18.dp),
2587− stringResource(R.string.reverted_banner),
2620+ stringResource(if (isEditing) R.string.editing_banner else R.string.reverted_banner),
2588 style = MaterialTheme.typography.bodySmall,
2621 style = MaterialTheme.typography.bodySmall,
2589 color = MaterialTheme.colorScheme.onTertiaryContainer,
2622 color = MaterialTheme.colorScheme.onTertiaryContainer,
2590 modifier = Modifier.padding(start = 8.dp),
2623 modifier = Modifier.padding(start = 8.dp),
@@ -2643,6 +2676,57 @@ private fun RevertBanner(diff: String?, onUndo: () -> Unit) {
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. */
2684+private fun QuoteReplyBanner(quotedText: String, onCancel: () -> Unit) {
2685+ val haptics = LocalHapticFeedback.current
2687+ color = MaterialTheme.colorScheme.secondaryContainer,
2688+ tonalElevation = 2.dp,
2689+ modifier = Modifier.fillMaxWidth(),
2692+ modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
2693+ verticalAlignment = Alignment.Top,
2696+ Icons.Filled.FormatQuote,
2697+ contentDescription = null,
2698+ tint = MaterialTheme.colorScheme.onSecondaryContainer,
2699+ modifier = Modifier.size(18.dp).padding(top = 2.dp),
2701+ Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) {
2703+ stringResource(R.string.quote_reply_banner),
2704+ style = MaterialTheme.typography.labelSmall,
2705+ color = MaterialTheme.colorScheme.onSecondaryContainer,
2709+ style = MaterialTheme.typography.bodySmall,
2710+ color = MaterialTheme.colorScheme.onSecondaryContainer,
2712+ overflow = TextOverflow.Ellipsis,
2715+ IconButton(onClick = {
2716+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2720+ Icons.Filled.Close,
2721+ contentDescription = stringResource(R.string.cancel_quote_reply),
2722+ tint = MaterialTheme.colorScheme.onSecondaryContainer,
2723+ modifier = Modifier.size(18.dp),
2646/** A banner shown while messages composed offline are queued in the outbox, with a
2730/** A banner shown while messages composed offline are queued in the outbox, with a
2647 * "Send now" nudge and a discard. Shows a spinner while the queue is flushing. */
2731 * "Send now" nudge and a discard. Shows a spinner while the queue is flushing. */
2648@Composable
2732@Composable
@@ -2818,7 +2902,15 @@ private sealed interface MessageListItem {
2818 val key: Any
2902 val key: Any
2819 val contentType: Any
2903 val contentType: Any
2905+ data class Message(
2821− data class Message(val message: MessageWithParts, override val key: Any) : MessageListItem {
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 {
2822 override val contentType: Any get() = message.info::class
2914 override val contentType: Any get() = message.info::class
@@ -2859,6 +2951,11 @@ private fun buildMessageListItems(
2859 var sepOrdinal = 0
2951 var sepOrdinal = 0
2860 var emptyIdOrdinal = 0
2952 var emptyIdOrdinal = 0
2861 var first = true
2953 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
2862 for (message in messages) {
2959 for (message in messages) {
2863 val ts = message.info.time?.created ?: message.info.time?.updated ?: message.info.time?.completed
2960 val ts = message.info.time?.created ?: message.info.time?.updated ?: message.info.time?.completed
2864 val dayKey = ts?.let { dayKey(it) } ?: ""
2961 val dayKey = ts?.let { dayKey(it) } ?: ""
@@ -2869,6 +2966,9 @@ private fun buildMessageListItems(
2869 // message would see a "" -> "<today>" transition and emit a duplicate "Today" header.
2966 // message would see a "" -> "<today>" transition and emit a duplicate "Today" header.
2870 if (first || (dayKey.isNotEmpty() && dayKey != lastDayKey)) {
2967 if (first || (dayKey.isNotEmpty() && dayKey != lastDayKey)) {
2871 result.add(MessageListItem.Separator(dayLabel(dayKey, ts ?: 0L, today, todayLabel, yesterdayLabel, dateFmt), sepOrdinal++))
2968 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
2873 if (dayKey.isNotEmpty()) lastDayKey = dayKey
2973 if (dayKey.isNotEmpty()) lastDayKey = dayKey
2874 first = false
2974 first = false
@@ -2882,7 +2982,10 @@ private fun buildMessageListItems(
2882 while (!seenKeys.add("$key#$suffix")) suffix++
2982 while (!seenKeys.add("$key#$suffix")) suffix++
2883 key = "$key#$suffix"
2983 key = "$key#$suffix"
2985+ val roleClass = message.info::class
2986+ val isFirstOfSpeaker = prevRoleClass == null || roleClass != prevRoleClass
2885− result.add(MessageListItem.Message(message, key))
2987+ result.add(MessageListItem.Message(message, key, isFirstOfSpeaker))
2988+ prevRoleClass = roleClass
2887 return result
2990 return result