git.iko.soy/opencode-android
on master help sign in
Add 15 UI/UX improvements: bottom nav bar, message grouping, quote-reply preview, global-search jump-to-message, editing banner, animated connection banner, language fix, server test-connection/delete, and shared components
commit16c6bac5b0ab929601b2a9fcdeeda705b5aab5f4 authoriko <mail@iko.soy> dateSat 4 Jul 2026, 16:38 (3 months ago) parentd647ff2
19 files changed+773−80
app/detekt-baseline.xml+1−1 app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+12−8 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+36−9 app/src/main/java/soy/iko/opencode/ui/Routes.kt+10−1 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+110−7 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+36−1 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+28−3 app/src/main/java/soy/iko/opencode/ui/components/CommonStates.kt+170 app/src/main/java/soy/iko/opencode/ui/components/CompactNavBar.kt+97 app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+15−4 app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+2−2 app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchViewModel.kt+13−7 app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+86 app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+76 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+3−3 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+45−26 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+17−8 app/src/main/res/values-es/strings.xml+8 app/src/main/res/values/strings.xml+8
app/detekt-baseline.xml+1−1
@@ -6,7 +6,7 @@
66 <ID>ComplexCondition:PartRenderer.kt$part.isImage &amp;&amp; imageContext != null &amp;&amp; (part.source != null || !part.url.isNullOrBlank())</ID>
77 <ID>ComplexCondition:SessionListViewModel.kt$SessionListState$q.isEmpty() &amp;&amp; pinnedIds.isEmpty() &amp;&amp; noArchivedHidden &amp;&amp; directoryFilter == null</ID>
88 <ID>CyclomaticComplexMethod:AppContainer.kt$AppContainer$@OptIn(ExperimentalCoroutinesApi::class) private fun observeMessageActivity()</ID>
9− <ID>CyclomaticComplexMethod:ChatScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( container: AppContainer, sessionId: String, onBack: () -&gt; Unit, onOpenFile: ((String) -&gt; Unit)? = null, onOpenSession: ((String) -&gt; Unit)? = null, )</ID>
9+ <ID>CyclomaticComplexMethod:ChatScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( container: AppContainer, sessionId: String, onBack: () -&gt; Unit, onOpenFile: ((String) -&gt; Unit)? = null, onOpenSession: ((String) -&gt; Unit)? = null, focusMessageId: String? = null, )</ID>
1010 <ID>CyclomaticComplexMethod:ChatScreen.kt$@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable private fun ChatInputBar( value: String, onValueChange: (String) -&gt; Unit, running: Boolean, aborting: Boolean, enabled: Boolean, sendOnEnter: Boolean, onSend: () -&gt; Unit, onAbort: () -&gt; Unit, queuedFollowUp: String?, onQueueFollowUp: (String) -&gt; Unit, onCancelQueue: () -&gt; Unit, focusRequester: androidx.compose.ui.focus.FocusRequester, attachments: List&lt;PendingAttachment&gt;, staging: Boolean, stagingFileCount: Int, onRemoveAttachment: (String) -&gt; Unit, onPickPhoto: () -&gt; Unit, onPickFile: () -&gt; Unit, onCamera: () -&gt; Unit, onVoice: () -&gt; Unit, onPasteImage: (List&lt;Uri&gt;) -&gt; Unit, )</ID>
1111 <ID>CyclomaticComplexMethod:ChatViewModel.kt$ChatViewModel$private fun send(text: String, includeAttachments: Boolean, idempotencyKey: String? = null): Boolean</ID>
1212 <ID>CyclomaticComplexMethod:CodeHighlighter.kt$fun highlightLine(line: String, syntax: FileSyntax, palette: HighlightPalette, state: BlockCommentState): AnnotatedString</ID>
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+12−8
@@ -256,7 +256,7 @@ object NetworkConfig {
256256 /** Minimum number of sessions before the session list shows its search/filter field.
257257 * Matches the sibling screens' convention: a short list is faster to scan by eye than
258258 * type into, so the field earns its vertical space only once the list grows. */
259− const val sessionListSearchThreshold = 3
259+ const val sessionListSearchThreshold = 5
260260 /** Extra bottom content padding (dp) for the chat message list, reserving room for the
261261 * jump-to-latest FAB so it never floats over the last message. */
262262 const val chatListFabInsetDp = 80
@@ -309,11 +309,14 @@ object NetworkConfig {
309309 * so the layout doesn't jump when the load completes and the image's intrinsic size is known. */
310310 const val inlineImageMinHeightDp = 120
311311
312− /** Lines of a code fence rendered before collapsing to a head with a "show more" affordance. */
313− const val collapsedCodeLineThreshold = 200
312+ /** Lines of a code fence rendered before collapsing to a head with a "show more" affordance.
313+ * Capped low for mobile: a 200-line block rendered at once can jank a low-end phone, so the
314+ * initial render is bounded and a "show more" reveals the rest in place. */
315+ const val collapsedCodeLineThreshold = 80
314316
315− /** Lines of a unified diff rendered before collapsing to a head with a "show more" affordance. */
316− const val collapsedDiffLineThreshold = 200
317+ /** Lines of a unified diff rendered before collapsing to a head with a "show more" affordance.
318+ * Same mobile-performance rationale as [collapsedCodeLineThreshold]. */
319+ const val collapsedDiffLineThreshold = 80
317320
318321 /** Lines of tool output rendered before collapsing to a head with a "show more" affordance. */
319322 const val toolOutputCollapsedLimitChars = 4000
@@ -334,7 +337,7 @@ object NetworkConfig {
334337 const val imageViewerSwipeDismissThreshold = 200f
335338
336339 /** Blink period (ms) of the streaming caret at the tail of a live assistant reply. */
337− const val streamingCaretPeriodMs = 550
340+ const val streamingCaretPeriodMs = 500
338341
339342 /** Auto-reject a tool-permission prompt after this many ms if the user hasn't responded,
340343 * so a forgotten prompt doesn't block the run indefinitely. The foreground service keeps
@@ -376,8 +379,9 @@ object NetworkConfig {
376379
377380 // --- Motion tokens (shared across screens) ---
378381
379− /** Duration (ms) for horizontal slide push/pop nav transitions. */
380− const val motionSlideDurationMs = 220
382+ /** Duration (ms) for horizontal slide push/pop nav transitions. M3 spec recommends 200ms
383+ * for medium-duration container transitions. */
384+ const val motionSlideDurationMs = 200
381385 /** Duration (ms) for fade in/out nav and content transitions. */
382386 const val motionFadeDurationMs = 180
383387
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+36−9
@@ -52,6 +52,7 @@ import soy.iko.opencode.ui.server.ServerEditScreen
5252import soy.iko.opencode.ui.server.ServerListScreen
5353import soy.iko.opencode.ui.search.GlobalSearchScreen
5454import soy.iko.opencode.ui.components.LargeScreenNavRail
55+import soy.iko.opencode.ui.components.CompactNavBar
5556import soy.iko.opencode.ui.components.LocalChatTextScale
5657import soy.iko.opencode.ui.components.LocalCodeWrap
5758import soy.iko.opencode.ui.components.LocalReducedMotion
@@ -426,12 +427,16 @@ fun OpencodeApp(container: AppContainer) {
426427 }
427428
428429 composable(
429− route = "${Routes.CHAT}/{sessionId}",
430− arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
430+ route = "${Routes.CHAT}/{sessionId}?focus={focus}",
431+ arguments = listOf(
432+ navArgument("sessionId") { type = NavType.StringType },
433+ navArgument("focus") { type = NavType.StringType; nullable = true; defaultValue = null },
434+ ),
431435 ) { entry ->
432436 ChatScreen(
433437 container = container,
434438 sessionId = entry.arguments?.getString("sessionId").orEmpty(),
439+ focusMessageId = entry.arguments?.getString("focus"),
435440 onBack = { navController.popBackStack() },
436441 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
437442 onOpenSession = { id ->
@@ -518,19 +523,20 @@ fun OpencodeApp(container: AppContainer) {
518523 composable(Routes.SEARCH) {
519524 GlobalSearchScreen(
520525 container = container,
521− onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
526+ onOpenSession = { id, focusMessageId -> navController.navigate(Routes.chat(id, focusMessageId)) },
522527 onBack = { navController.popBackStack() },
523528 )
524529 }
525530 }
526531 }
527532
533+ // Collect unread/run state for nav badges (rail on large screens, bottom bar on
534+ // compact screens), so a user on Files/Settings sees pending activity at a glance.
535+ val unread by container.unread.collectAsStateWithLifecycle()
536+ val anyRunActive by container.anyRunActive.collectAsStateWithLifecycle()
537+ val totalUnread = remember(unread) { unread.values.sum() }
538+
528539 if (showNavRail) {
529− // Collect unread/run state for the nav rail's Sessions badge, so a user on
530− // Files/Settings sees pending activity at a glance.
531− val unread by container.unread.collectAsStateWithLifecycle()
532− val anyRunActive by container.anyRunActive.collectAsStateWithLifecycle()
533− val totalUnread = remember(unread) { unread.values.sum() }
534540 Row(modifier = Modifier.fillMaxSize()) {
535541 LargeScreenNavRail(
536542 currentRoute = currentRoute,
@@ -555,7 +561,28 @@ fun OpencodeApp(container: AppContainer) {
555561 Box(modifier = Modifier.weight(1f).fillMaxHeight()) { navHost() }
556562 }
557563 } else {
558− navHost()
564+ // Compact screens: a bottom NavigationBar surfaces the top-level destinations one
565+ // tap away (Sessions/Search/Files/Settings/Servers) instead of buried in each
566+ // screen's overflow menu. The navHost fills the space above the bar; the bar's
567+ // own height is accounted for by the NavigationBar's intrinsic measurement.
568+ androidx.compose.foundation.layout.Column(modifier = Modifier.fillMaxSize()) {
569+ Box(modifier = Modifier.weight(1f).fillMaxHeight()) { navHost() }
570+ CompactNavBar(
571+ currentRoute = currentRoute,
572+ connected = connection != null,
573+ unreadCount = totalUnread,
574+ runActive = anyRunActive,
575+ onNavigate = { route ->
576+ navController.navigate(route) {
577+ popUpTo(navController.graph.findStartDestination().id) {
578+ saveState = true
579+ }
580+ launchSingleTop = true
581+ restoreState = true
582+ }
583+ },
584+ )
585+ }
559586 }
560587 }
561588 }
app/src/main/java/soy/iko/opencode/ui/Routes.kt+10−1
@@ -19,7 +19,16 @@ object Routes {
1919 fun serverEditDuplicate(sourceId: String) = "$SERVER_EDIT?dup=${android.net.Uri.encode(sourceId)}"
2020
2121 const val CHAT = "chat"
22− fun chat(sessionId: String) = "$CHAT/${android.net.Uri.encode(sessionId)}"
22+ fun chat(sessionId: String, focusMessageId: String? = null): String {
23+ val base = "$CHAT/${android.net.Uri.encode(sessionId)}"
24+ // An optional focusMessageId scrolls the chat to (and briefly highlights) that message,
25+ // used by global search to jump directly to the matched message instead of the tail.
26+ return if (focusMessageId != null && focusMessageId.isNotBlank()) {
27+ "$base?focus=${android.net.Uri.encode(focusMessageId)}"
28+ } else {
29+ base
30+ }
31+ }
2332
2433 const val FILE_VIEW = "file_view"
2534 fun fileView(path: String, line: Int? = null): String {
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+110−7
@@ -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}
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+36−1
@@ -540,6 +540,13 @@ class ChatViewModel(
540540 private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
541541 val draft: StateFlow<String> = _draft.asStateFlow()
542542
543+ /** A pending quote-reply preview (the raw text the user quoted), shown as a dismissible
544+ * card above the composer so the user can see and cancel the quote before sending. Cleared
545+ * on send or via [cancelQuoteReply]. The actual `>` prefixed lines are still folded into
546+ * the draft for the server; this state is purely for the visual preview chip. */
547+ private val _pendingQuote = MutableStateFlow<String?>(null)
548+ val pendingQuote: StateFlow<String?> = _pendingQuote.asStateFlow()
549+
543550 /** Messages composed for this session while offline/disconnected, queued in the outbox and
544551 * awaiting an automatic flush on reconnect. Surfaced so the composer can show a "queued"
545552 * chip with a discard/send-now affordance. */
@@ -564,6 +571,12 @@ class ChatViewModel(
564571 private val _reverted = MutableStateFlow(false)
565572 val reverted: StateFlow<Boolean> = _reverted.asStateFlow()
566573
574+ /** True when the revert was triggered by an Edit action (vs a bare Revert). Drives the
575+ * banner's copy so it reads "Editing message" rather than "Reverted", making the composer's
576+ * prefilled text unambiguous. Cleared on unrevert and on send. */
577+ private val _editing = MutableStateFlow(false)
578+ val editing: StateFlow<Boolean> = _editing.asStateFlow()
579+
567580 /** The diff of the active revert checkpoint, when the server includes one. Shown in the
568581 * revert banner so the user can see what changed before deciding to undo. Null when the
569582 * session isn't reverted or the server didn't send a diff. */
@@ -1019,6 +1032,8 @@ class ChatViewModel(
10191032 if (_draft.value.trim() == trimmed) {
10201033 suppressDraftPersist.set(true)
10211034 _draft.value = ""
1035+ _pendingQuote.value = null
1036+ _editing.value = false
10221037 }
10231038 // Inject an optimistic user message so the outgoing prompt is visible immediately,
10241039 // before the server echoes it back via SSE. Removed by reconcileOptimistic once the
@@ -1343,6 +1358,7 @@ class ChatViewModel(
13431358 .onSuccess {
13441359 _reverted.value = it.isReverted
13451360 _revertDiff.value = it.revert?.diff?.takeIf { diff -> diff.isNotBlank() }
1361+ _editing.value = false
13461362 }
13471363 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
13481364 }
@@ -1401,11 +1417,14 @@ class ChatViewModel(
14011417 fun editMessage(messageId: String, text: String) {
14021418 revertTo(messageId)
14031419 _draft.value = text.take(NetworkConfig.maxDraftLengthChars)
1420+ _pendingQuote.value = null
1421+ _editing.value = true
14041422 }
14051423
14061424 /** Prefill the composer with [text] as a Markdown blockquote so the user can respond to a
14071425 * specific message inline. Appends to any existing draft (with a blank line) rather than
1408− * overwriting, so quoting doesn't discard text already being typed. */
1426+ * overwriting, so quoting doesn't discard text already being typed. Also stashes the raw
1427+ * quoted text in [pendingQuote] so the composer can show a dismissible preview card. */
14091428 fun quoteReply(text: String) {
14101429 val quoted = text.trim().lineSequence().joinToString("\n") { "> $it" }
14111430 if (quoted.isBlank()) return
@@ -1414,6 +1433,21 @@ class ChatViewModel(
14141433 // Cap like the composer's typed/pasted/dictated input so quoting onto a near-full draft
14151434 // can't push it past the limit (negative remaining counter + an over-cap prompt sent).
14161435 _draft.value = combined.take(NetworkConfig.maxDraftLengthChars)
1436+ _pendingQuote.value = text.trim().takeIf { it.isNotBlank() }
1437+ }
1438+
1439+ /** Remove the pending quote-reply preview and strip the `>`-prefixed blockquote lines from
1440+ * the draft so canceling the quote card actually removes the quoted text, not just the chip. */
1441+ fun cancelQuoteReply() {
1442+ val removed = _pendingQuote.value ?: return
1443+ _pendingQuote.value = null
1444+ val quotedBlock = removed.trim().lineSequence().joinToString("\n") { "> $it" }
1445+ _draft.value = _draft.value
1446+ .replace("\n\n$quotedBlock\n\n", "\n\n", ignoreCase = false)
1447+ .replace("$quotedBlock\n\n", "", ignoreCase = false)
1448+ .replace("\n\n$quotedBlock", "", ignoreCase = false)
1449+ .replace(quotedBlock, "", ignoreCase = false)
1450+ .trimStart()
14171451 }
14181452
14191453 /** Fork the conversation by creating a brand-new session seeded with [text] as its first
@@ -1448,6 +1482,7 @@ class ChatViewModel(
14481482 .onSuccess {
14491483 _reverted.value = it.isReverted
14501484 _revertDiff.value = it.revert?.diff?.takeIf { diff -> diff.isNotBlank() }
1485+ _editing.value = false
14511486 }
14521487 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
14531488 }
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+28−3
@@ -120,11 +120,17 @@ fun MessageBubble(
120120 onRetry: (() -> Unit)? = null,
121121 onDismiss: (() -> Unit)? = null,
122122 onShare: (() -> Unit)? = null,
123+ // Whether this is the first message in a consecutive run of the same speaker. When false,
124+ // the assistant avatar/header is suppressed to reduce visual noise in tool-heavy runs.
125+ isFirstOfSpeaker: Boolean = true,
126+ // When true, the bubble gets a transient highlight background (used by the global-search
127+ // deep link to mark the matched message after scrolling it into view).
128+ highlighted: Boolean = false,
123129) {
124130 when (message.info) {
125131 is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile, onRevert, onEdit, onQuote, onBranch, sendStatus, isEdited, onRetry, onDismiss, onShare)
126132 is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier, onOpenFile)
127− else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel, agentLabel, onOpenFile, onRevert, onSpeak, isSpeaking, ttsState, onPause, onResume, onStop, onQuote, onBranch, onRegenerate, onContinue, isEdited, onShare)
133+ else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel, agentLabel, onOpenFile, onRevert, onSpeak, isSpeaking, ttsState, onPause, onResume, onStop, onQuote, onBranch, onRegenerate, onContinue, isEdited, onShare, isFirstOfSpeaker, highlighted)
128134 }
129135}
130136
@@ -682,6 +688,8 @@ private fun AssistantBlock(
682688 onContinue: (() -> Unit)? = null,
683689 isEdited: Boolean = false,
684690 onShare: (() -> Unit)? = null,
691+ isFirstOfSpeaker: Boolean = true,
692+ highlighted: Boolean = false,
685693) {
686694 // Long-press context menu state, hoisted to the block scope so the whole bubble (body and
687695 // footer) is the long-press target — see UserBubble for rationale.
@@ -697,9 +705,25 @@ private fun AssistantBlock(
697705 // only the model/agent label — giving a screen-reader user an explicit role signal
698706 // matching the user bubble's "You" prefix.
699707 val assistantRoleLabel = stringResource(R.string.role_assistant_message)
708+ // Transient highlight background (from global-search deep link). Animated so the highlight
709+ // fades in/out rather than flashing, and so the background clears smoothly when the focus
710+ // is cleared after the delay.
711+ val highlightColor = MaterialTheme.colorScheme.secondaryContainer
712+ val highlightAlpha by androidx.compose.animation.core.animateFloatAsState(
713+ targetValue = if (highlighted) 1f else 0f,
714+ animationSpec = androidx.compose.animation.core.tween(NetworkConfig.motionFadeDurationMs),
715+ label = "msgHighlight",
716+ )
700717 Column(
701718 modifier = modifier
702719 .fillMaxWidth()
720+ .then(
721+ if (highlightAlpha > 0f) {
722+ Modifier.background(highlightColor.copy(alpha = highlightAlpha * 0.6f))
723+ } else {
724+ Modifier
725+ },
726+ )
703727 .semantics(mergeDescendants = true) { contentDescription = assistantRoleLabel }
704728 .pointerInput(Unit) {
705729 detectTapGestures(onLongPress = { offset ->
@@ -714,10 +738,11 @@ private fun AssistantBlock(
714738 verticalArrangement = Arrangement.spacedBy(8.dp),
715739 ) {
716740 val info = message.info
717− if (info is AssistantMessage) {
741+ if (info is AssistantMessage && isFirstOfSpeaker) {
718742 // Avatar + model label row: the robot icon gives the assistant a consistent visual
719743 // identity (left-aligned, mirroring the user's right-aligned bubble) so on a long
720− // scroll the speaker is unambiguous without reading the label.
744+ // scroll the speaker is unambiguous without reading the label. Suppressed on
745+ // consecutive same-speaker messages to reduce visual noise in tool-heavy runs.
721746 AssistantHeader(label = modelLabel ?: info.modelID, agentLabel = agentLabel)
722747 }
723748 message.parts.forEachIndexed { index, part ->
app/src/main/java/soy/iko/opencode/ui/components/CommonStates.ktnew file+170
@@ -0,0 +1,170 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.animation.core.RepeatMode
4+import androidx.compose.animation.core.animateFloat
5+import androidx.compose.animation.core.infiniteRepeatable
6+import androidx.compose.animation.core.rememberInfiniteTransition
7+import androidx.compose.animation.core.tween
8+import androidx.compose.foundation.background
9+import androidx.compose.foundation.layout.Arrangement
10+import androidx.compose.foundation.layout.Box
11+import androidx.compose.foundation.layout.Column
12+import androidx.compose.foundation.layout.fillMaxWidth
13+import androidx.compose.foundation.layout.height
14+import androidx.compose.foundation.layout.padding
15+import androidx.compose.foundation.layout.size
16+import androidx.compose.foundation.shape.RoundedCornerShape
17+import androidx.compose.foundation.text.KeyboardActions
18+import androidx.compose.foundation.text.KeyboardOptions
19+import androidx.compose.material.icons.Icons
20+import androidx.compose.material.icons.filled.Close
21+import androidx.compose.material.icons.filled.ErrorOutline
22+import androidx.compose.material.icons.filled.Search
23+import androidx.compose.material3.Icon
24+import androidx.compose.material3.IconButton
25+import androidx.compose.material3.MaterialTheme
26+import androidx.compose.material3.OutlinedTextField
27+import androidx.compose.material3.Text
28+import androidx.compose.material3.TextButton
29+import androidx.compose.runtime.Composable
30+import androidx.compose.runtime.getValue
31+import androidx.compose.ui.Alignment
32+import androidx.compose.ui.Modifier
33+import androidx.compose.ui.draw.alpha
34+import androidx.compose.ui.draw.clip
35+import androidx.compose.ui.graphics.vector.ImageVector
36+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
37+import androidx.compose.ui.platform.testTag
38+import androidx.compose.ui.semantics.contentDescription
39+import androidx.compose.ui.semantics.semantics
40+import androidx.compose.ui.text.input.ImeAction
41+import androidx.compose.ui.text.style.TextAlign
42+import androidx.compose.ui.unit.Dp
43+import androidx.compose.ui.unit.dp
44+import soy.iko.opencode.R
45+
46+/**
47+ * Shared search/filter text field: a leading [Search] icon, a trailing clear-× (shown when the
48+ * query is non-empty), and [ImeAction.Search] + keyboard-hide on the search action. Extracted
49+ * from the ~9 near-identical search fields across Session/Server/File/Settings/Diagnostics/Usage
50+ * /GlobalSearch/FileViewer/Chat-search so future screens stay consistent.
51+ *
52+ * @param query the current query text.
53+ * @param onQueryChange called when the query changes (typing, clear, paste).
54+ * @param placeholder the placeholder label (e.g. "Search sessions").
55+ * @param modifier optional [Modifier] (the field fills its max width by default).
56+ * @param testTagSuffix appended to the standard `"search_field"` testTag for per-screen uniqueness.
57+ */
58+@Composable
59+fun SearchField(
60+ query: String,
61+ onQueryChange: (String) -> Unit,
62+ placeholder: String,
63+ modifier: Modifier = Modifier,
64+ testTagSuffix: String = "",
65+) {
66+ val keyboard = LocalSoftwareKeyboardController.current
67+ OutlinedTextField(
68+ value = query,
69+ onValueChange = onQueryChange,
70+ modifier = modifier
71+ .fillMaxWidth()
72+ .testTag("search_field$testTagSuffix"),
73+ placeholder = { Text(placeholder) },
74+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
75+ trailingIcon = {
76+ if (query.isNotEmpty()) {
77+ IconButton(onClick = { onQueryChange("") }) {
78+ Icon(Icons.Filled.Close, contentDescription = null)
79+ }
80+ }
81+ },
82+ singleLine = true,
83+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
84+ keyboardActions = KeyboardActions(onSearch = { keyboard?.hide() }),
85+ )
86+}
87+
88+/**
89+ * Shared centered error state: a large error icon, an optional message, and a Retry button.
90+ * Extracted from the ~4 near-identical error states across FileBrowser/FileView/SessionList/
91+ * Diagnostics so every screen's "load failed" state reads and behaves the same. Mirrors
92+ * [EmptyState]'s centered layout.
93+ *
94+ * @param message the error detail to show under the icon.
95+ * @param onRetry invoked when the user taps Retry. When null, the button is hidden (for
96+ * non-retryable errors).
97+ * @param icon the error icon (defaults to [androidx.compose.material.icons.Icons.Filled.ErrorOutline]
98+ * at the callsite — kept as a param so callers can pass a context-specific icon).
99+ */
100+@Composable
101+fun ErrorState(
102+ message: String,
103+ onRetry: (() -> Unit)? = null,
104+ modifier: Modifier = Modifier,
105+ icon: ImageVector = Icons.Filled.ErrorOutline,
106+ retryLabel: String = "",
107+) {
108+ val retry = if (retryLabel.isBlank()) "" else retryLabel
109+ Column(
110+ modifier = modifier.padding(32.dp),
111+ horizontalAlignment = Alignment.CenterHorizontally,
112+ verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
113+ ) {
114+ Icon(
115+ icon,
116+ contentDescription = null,
117+ modifier = Modifier.size(56.dp),
118+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
119+ )
120+ Text(
121+ message,
122+ style = MaterialTheme.typography.bodyMedium,
123+ color = MaterialTheme.colorScheme.onSurfaceVariant,
124+ textAlign = TextAlign.Center,
125+ )
126+ if (onRetry != null && retry.isNotBlank()) {
127+ TextButton(onClick = onRetry, modifier = Modifier.testTag("error_state_retry")) {
128+ Text(retry)
129+ }
130+ }
131+ }
132+}
133+
134+/**
135+ * A pulse-animated placeholder box used to build skeletons. Reuses the same pulse pattern as
136+ * [SkeletonRow] (0.35↔0.7 alpha over 900ms, static 0.5 under [LocalReducedMotion]) so all
137+ * skeletons across the app animate in unison. Extracted so the chat/session/file-viewer
138+ * skeletons can compose from this primitive instead of re-implementing the pulse.
139+ */
140+@Composable
141+fun SkeletonBox(
142+ modifier: Modifier = Modifier,
143+ height: Dp = 14.dp,
144+ cornerRadius: Dp = 4.dp,
145+) {
146+ val reducedMotion = LocalReducedMotion.current
147+ val transition = rememberInfiniteTransition(label = "skeleton_box")
148+ val pulse by transition.animateFloat(
149+ initialValue = 0.35f,
150+ targetValue = 0.7f,
151+ animationSpec = infiniteRepeatable(
152+ tween(900),
153+ RepeatMode.Reverse,
154+ ),
155+ label = "skeleton_box_alpha",
156+ )
157+ val skeletonAlpha = if (reducedMotion) 0.5f else pulse
158+ val skeletonColor = MaterialTheme.colorScheme.surfaceVariant
159+ Box(
160+ modifier = modifier
161+ .height(height)
162+ .clip(RoundedCornerShape(cornerRadius))
163+ .background(skeletonColor)
164+ .alpha(skeletonAlpha),
165+ )
166+}
167+
168+/** Resolves the standard "Loading…" label for skeletons that need a merged a11y description. */
169+@Composable
170+fun skeletonLoadingLabel(): String = androidx.compose.ui.res.stringResource(R.string.loading)
app/src/main/java/soy/iko/opencode/ui/components/CompactNavBar.ktnew file+97
@@ -0,0 +1,97 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.foundation.layout.fillMaxWidth
4+import androidx.compose.material.icons.Icons
5+import androidx.compose.material.icons.automirrored.filled.Chat
6+import androidx.compose.material.icons.filled.Dns
7+import androidx.compose.material.icons.filled.Folder
8+import androidx.compose.material.icons.filled.Search
9+import androidx.compose.material.icons.filled.Settings
10+import androidx.compose.material3.Badge
11+import androidx.compose.material3.BadgedBox
12+import androidx.compose.material3.Icon
13+import androidx.compose.material3.NavigationBar
14+import androidx.compose.material3.NavigationBarItem
15+import androidx.compose.material3.NavigationBarItemDefaults
16+import androidx.compose.material3.Text
17+import androidx.compose.runtime.Composable
18+import androidx.compose.ui.Modifier
19+import androidx.compose.ui.graphics.vector.ImageVector
20+import androidx.compose.ui.res.stringResource
21+import soy.iko.opencode.R
22+import soy.iko.opencode.ui.Routes
23+
24+/** A top-level destination surfaced on the compact-screen bottom navigation bar. Mirrors
25+ * [RailDest] in [LargeScreenNavRail] but is kept separate so the two bars can diverge — a
26+ * bottom bar is limited to 3-5 items for thumb reach, while the rail can show all seven. */
27+private data class BottomDest(
28+ val route: String,
29+ val labelRes: Int,
30+ val icon: ImageVector,
31+ val requiresConnection: Boolean,
32+)
33+
34+// Five items: the most-used destinations that make sense from anywhere in the app. Servers and
35+// Usage/MCP stay in the session list / settings overflow menus to avoid overcrowding the bar.
36+private val BOTTOM_DESTINATIONS = listOf(
37+ BottomDest(Routes.SESSIONS, R.string.sessions_title, Icons.AutoMirrored.Filled.Chat, true),
38+ BottomDest(Routes.SEARCH, R.string.search_all_title, Icons.Filled.Search, true),
39+ BottomDest(Routes.FILES, R.string.files, Icons.Filled.Folder, true),
40+ BottomDest(Routes.SETTINGS, R.string.settings, Icons.Filled.Settings, false),
41+ BottomDest(Routes.SERVERS, R.string.servers_title, Icons.Filled.Dns, false),
42+)
43+
44+/**
45+ * A Material 3 [NavigationBar] shown at the bottom of compact-screen layouts (phones), so the
46+ * top-level destinations are one tap away instead of buried in a screen's overflow menu. Mirrors
47+ * the large-screen [LargeScreenNavRail] — the two are mutually exclusive based on window width.
48+ *
49+ * @param currentRoute the current NavHost destination route, used to highlight the active item.
50+ * @param connected whether a server connection is active; connection-dependent items are disabled.
51+ * @param unreadCount total unread messages across sessions, shown as a badge on the Sessions item.
52+ * @param runActive whether any agent run is active, shown as a dot badge on the Sessions item.
53+ * @param onNavigate invoked with the destination's route when an item is selected.
54+ */
55+@Composable
56+fun CompactNavBar(
57+ currentRoute: String?,
58+ connected: Boolean,
59+ onNavigate: (String) -> Unit,
60+ modifier: Modifier = Modifier,
61+ unreadCount: Int = 0,
62+ runActive: Boolean = false,
63+) {
64+ NavigationBar(modifier = modifier.fillMaxWidth()) {
65+ BOTTOM_DESTINATIONS.forEach { dest ->
66+ val selected = currentRoute == dest.route ||
67+ (dest.route == Routes.SESSIONS && currentRoute != null &&
68+ currentRoute.startsWith("${Routes.CHAT}/")) ||
69+ (dest.route == Routes.FILES && currentRoute != null &&
70+ currentRoute.startsWith(Routes.FILE_VIEW))
71+ val showUnreadBadge = dest.route == Routes.SESSIONS && unreadCount > 0
72+ val showRunBadge = dest.route == Routes.SESSIONS && runActive
73+ NavigationBarItem(
74+ selected = selected,
75+ enabled = !dest.requiresConnection || connected,
76+ onClick = { onNavigate(dest.route) },
77+ icon = {
78+ if (showUnreadBadge || showRunBadge) {
79+ BadgedBox(
80+ badge = {
81+ if (showUnreadBadge) {
82+ Badge { Text(unreadCount.coerceAtMost(99).toString()) }
83+ } else if (showRunBadge) {
84+ Badge()
85+ }
86+ },
87+ ) { Icon(dest.icon, contentDescription = null) }
88+ } else {
89+ Icon(dest.icon, contentDescription = null)
90+ }
91+ },
92+ label = { Text(stringResource(dest.labelRes)) },
93+ colors = NavigationBarItemDefaults.colors(),
94+ )
95+ }
96+ }
97+}
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+15−4
@@ -1,6 +1,8 @@
11package soy.iko.opencode.ui.components
22
33import androidx.compose.animation.AnimatedVisibility
4+import androidx.compose.animation.animateColorAsState
5+import androidx.compose.animation.core.tween
46import androidx.compose.foundation.layout.Arrangement
57import androidx.compose.foundation.layout.BoxScope
68import androidx.compose.foundation.layout.Row
@@ -37,6 +39,7 @@ import kotlinx.coroutines.flow.flatMapLatest
3739import kotlinx.coroutines.flow.flowOf
3840import soy.iko.opencode.R
3941import soy.iko.opencode.data.network.EventStreamClient
42+import soy.iko.opencode.data.network.NetworkConfig
4043import soy.iko.opencode.di.AppContainer
4144
4245/**
@@ -98,10 +101,18 @@ fun ConnectionBanner(
98101 // rejected the connection (not when the device itself is offline).
99102 val isServerFailure = state == EventStreamClient.ConnectionState.Failed ||
100103 state == EventStreamClient.ConnectionState.AuthFailed
101− val container = if (isFailed) MaterialTheme.colorScheme.errorContainer
102− else MaterialTheme.colorScheme.tertiaryContainer
103− val onContainer = if (isFailed) MaterialTheme.colorScheme.onErrorContainer
104− else MaterialTheme.colorScheme.onTertiaryContainer
104+ val container by animateColorAsState(
105+ targetValue = if (isFailed) MaterialTheme.colorScheme.errorContainer
106+ else MaterialTheme.colorScheme.tertiaryContainer,
107+ animationSpec = tween(NetworkConfig.motionFadeDurationMs),
108+ label = "bannerContainer",
109+ )
110+ val onContainer by animateColorAsState(
111+ targetValue = if (isFailed) MaterialTheme.colorScheme.onErrorContainer
112+ else MaterialTheme.colorScheme.onTertiaryContainer,
113+ animationSpec = tween(NetworkConfig.motionFadeDurationMs),
114+ label = "bannerOnContainer",
115+ )
105116 Surface(
106117 modifier = modifier
107118 .fillMaxWidth()
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+2−2
@@ -80,7 +80,7 @@ import soy.iko.opencode.util.runCatchingCancellable
8080@Composable
8181fun GlobalSearchScreen(
8282 container: AppContainer,
83− onOpenSession: (String) -> Unit,
83+ onOpenSession: (String, String?) -> Unit,
8484 onBack: () -> Unit,
8585) {
8686 val vm: GlobalSearchViewModel = viewModel(factory = vmFactory { GlobalSearchViewModel(container) })
@@ -260,7 +260,7 @@ fun GlobalSearchScreen(
260260 hit = hit,
261261 query = state.query.trim(),
262262 ignoreCase = !state.matchCase,
263− onClick = { onOpenSession(hit.session.id) },
263+ onClick = { onOpenSession(hit.session.id, hit.firstMatchMessageId) },
264264 modifier = Modifier.then(reducedMotionAnimateItem()),
265265 )
266266 }
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchViewModel.kt+13−7
@@ -34,13 +34,16 @@ import java.util.concurrent.ConcurrentHashMap
3434
3535/** One search result: the [session] that matched, a [snippet] of the matching text, the total
3636 * number of [matchCount] occurrences (across body + title), and whether the title itself matched
37− * ([matchedTitle]) so the UI can highlight it like the snippet. */
37+ * ([matchedTitle]) so the UI can highlight it like the snippet. [firstMatchMessageId] carries
38+ * the id of the first message whose text matched, so opening the result can scroll the chat
39+ * to that message instead of the conversation's tail. Null when only the title matched. */
3840@Immutable
3941data class SearchHit(
4042 val session: Session,
4143 val snippet: String,
4244 val matchCount: Int = 1,
4345 val matchedTitle: Boolean = false,
46+ val firstMatchMessageId: String? = null,
4447)
4548
4649@Immutable
@@ -209,7 +212,7 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
209212 Log.w("GlobalSearch", "listMessages failed: " + safeExceptionSummary(it))
210213 emptyList()
211214 }
212− val snippet = matchSnippet(messages, query, ignoreCase, filter)
215+ val (snippet, matchMessageId) = matchSnippet(messages, query, ignoreCase, filter)
213216 // The session title isn't a message part, so the type filter doesn't
214217 // apply to it — a title match is always surfaced.
215218 val titleMatched = session.displayTitle.contains(query, ignoreCase = ignoreCase)
@@ -222,6 +225,7 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
222225 ?: session.displayTitle.takeIf { titleMatched }.orEmpty(),
223226 matchCount = count.coerceAtLeast(1),
224227 matchedTitle = titleMatched,
228+ firstMatchMessageId = matchMessageId,
225229 )
226230 }
227231 // Publish progress so the spinner can show "Searched N / M sessions".
@@ -242,21 +246,23 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
242246 }
243247
244248 /** Find the first searchable text containing [query] across [messages] and return a short
245− * snippet centered on the match, or null if nothing matches. [ignoreCase] controls
246− * case-sensitivity; [filter] restricts which part types contribute searchable text. */
249+ * snippet centered on the match plus the id of the matching message, or `(null, null)` if
250+ * nothing matches. [ignoreCase] controls case-sensitivity; [filter] restricts which part
251+ * types contribute searchable text. The message id lets the caller scroll the chat to the
252+ * match when the result is opened. */
247253 private fun matchSnippet(
248254 messages: List<soy.iko.opencode.data.model.MessageWithParts>,
249255 query: String,
250256 ignoreCase: Boolean,
251257 filter: SearchTypeFilter = SearchTypeFilter.ALL,
252− ): String? {
258+ ): Pair<String?, String?> {
253259 for (message in messages) {
254260 for (candidate in searchableTexts(message.parts, filter)) {
255261 val idx = candidate.indexOf(query, ignoreCase = ignoreCase)
256− if (idx >= 0) return buildSnippet(candidate, idx, query.length)
262+ if (idx >= 0) return buildSnippet(candidate, idx, query.length) to message.info.id
257263 }
258264 }
259− return null
265+ return null to null
260266 }
261267
262268 /** The ordered list of searchable strings for a message's parts, scoped by [filter].
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+86
@@ -21,6 +21,7 @@ import androidx.compose.foundation.text.KeyboardOptions
2121import androidx.compose.foundation.verticalScroll
2222import androidx.compose.material.icons.Icons
2323import androidx.compose.material.icons.automirrored.filled.ArrowBack
24+import androidx.compose.material.icons.filled.Delete
2425import androidx.compose.material.icons.filled.ExpandLess
2526import androidx.compose.material.icons.filled.ExpandMore
2627import androidx.compose.material.icons.filled.Visibility
@@ -117,6 +118,33 @@ fun ServerEditScreen(
117118 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
118119 }
119120 },
121+ actions = {
122+ // Delete action in the overflow, only for an existing (saved) profile.
123+ // The active profile is guarded in the ViewModel (delete disconnects it
124+ // if it's the active one). A confirmation dialog gates the destructive action.
125+ if (!state.isNew) {
126+ var showDeleteConfirm by remember { mutableStateOf(false) }
127+ IconButton(onClick = { showDeleteConfirm = true }) {
128+ Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete_server))
129+ }
130+ if (showDeleteConfirm) {
131+ AlertDialog(
132+ onDismissRequest = { showDeleteConfirm = false },
133+ title = { Text(stringResource(R.string.delete_server)) },
134+ text = { Text(stringResource(R.string.delete_server_confirm)) },
135+ confirmButton = {
136+ TextButton(onClick = {
137+ showDeleteConfirm = false
138+ vm.delete(onDone)
139+ }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
140+ },
141+ dismissButton = {
142+ TextButton(onClick = { showDeleteConfirm = false }) { Text(stringResource(R.string.cancel)) }
143+ },
144+ )
145+ }
146+ }
147+ },
120148 )
121149 },
122150 ) { padding ->
@@ -265,6 +293,17 @@ private fun ServerEditForm(
265293 onImeDone = { if (canSave && !state.saving) vm.connect(onDone) },
266294 )
267295 }
296+ // Pre-save reachability probe: lets the user confirm the URL is reachable before
297+ // committing. Extracted to [TestConnectionControl] to keep this function's complexity
298+ // under the detekt threshold.
299+ TestConnectionControl(
300+ canSave = canSave,
301+ testing = state.testingConnection,
302+ saving = state.saving,
303+ testingCredentials = state.testingCredentials,
304+ result = state.connectionResult,
305+ onTest = vm::testConnection,
306+ )
268307 // Single primary action: probe-then-save-and-connect. Replaces the former
269308 // Check-connectivity / Save / Save-&-connect trio.
270309 Button(
@@ -413,6 +452,53 @@ private fun AuthFields(
413452 }
414453}
415454
455+/** The "Test connection" button plus its success/failure feedback line. Extracted from
456+ * [ServerEditForm] to keep that function's cyclomatic complexity under the detekt threshold. */
457+@Composable
458+private fun TestConnectionControl(
459+ canSave: Boolean,
460+ testing: Boolean,
461+ saving: Boolean,
462+ testingCredentials: Boolean,
463+ result: ConnectionProbeResult?,
464+ onTest: () -> Unit,
465+) {
466+ OutlinedButton(
467+ onClick = onTest,
468+ enabled = canSave && !testing && !saving && !testingCredentials,
469+ modifier = Modifier.fillMaxWidth().testTag("server_test_connection"),
470+ ) {
471+ if (testing) {
472+ CircularProgressIndicator(
473+ modifier = Modifier.size(20.dp),
474+ strokeWidth = 2.dp,
475+ )
476+ Text(stringResource(R.string.testing_connection), modifier = Modifier.padding(start = 8.dp))
477+ } else {
478+ Text(stringResource(R.string.test_connection))
479+ }
480+ }
481+ ConnectionProbeFeedback(result)
482+}
483+
484+/** Renders the success/failed feedback line for a "Test connection" probe. Extracted from
485+ * [ServerEditForm] to keep that function's cyclomatic complexity under the detekt threshold. */
486+@Composable
487+private fun ConnectionProbeFeedback(result: ConnectionProbeResult?) {
488+ if (result == null) return
489+ val (msg, color) = when (result) {
490+ is ConnectionProbeResult.Success ->
491+ stringResource(R.string.test_connection_success, result.latencyMs) to MaterialTheme.colorScheme.primary
492+ is ConnectionProbeResult.Failed ->
493+ stringResource(R.string.test_connection_failed, result.message) to MaterialTheme.colorScheme.error
494+ }
495+ Text(
496+ msg,
497+ style = MaterialTheme.typography.bodySmall,
498+ color = color,
499+ )
500+}
501+
416502/**
417503 * Optional TLS hardening, tucked behind a collapsed "Security" expander so the common
418504 * localhost/HTTP setup never sees it. Auto-expands when a loaded profile already uses one of
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+76
@@ -41,6 +41,10 @@ data class ServerEditState(
4141 val testingCredentials: Boolean = false,
4242 /** Non-null when the last credential test succeeded (true) or failed (false). */
4343 val credentialsResult: Boolean? = null,
44+ /** Whether a reachability probe (Test connection) is in progress. */
45+ val testingConnection: Boolean = false,
46+ /** Latency (ms) of the last successful reachability probe, or an error message. */
47+ val connectionResult: ConnectionProbeResult? = null,
4448) {
4549 val canSave: Boolean get() = baseUrl.isNotBlank() && isValidUrl(baseUrl) && certPinValid
4650 val isNew: Boolean get() = id == null
@@ -59,6 +63,13 @@ data class ServerEditState(
5963 }
6064}
6165
66+/** Result of a "Test connection" reachability probe from the edit screen. */
67+@Immutable
68+sealed interface ConnectionProbeResult {
69+ data class Success(val latencyMs: Long) : ConnectionProbeResult
70+ data class Failed(val message: String) : ConnectionProbeResult
71+}
72+
6273/** Snapshot of the profile loaded into the editor, normalized the same way save() stores it. */
6374data class InitialProfile(
6475 val label: String,
@@ -378,6 +389,71 @@ class ServerEditViewModel(
378389 saveInternal(connectAfter = false, onDone = onDone)
379390 }
380391
392+ /**
393+ * Probe the server's reachability *without* requiring credentials (a pre-save "is this URL
394+ * reachable?" check). Surfaces [ServerEditState.connectionResult] so the UI can show
395+ * success/latency or a failure without dismissing the form. Complements [testCredentials],
396+ * which validates auth once the URL is known to be reachable.
397+ */
398+ fun testConnection() {
399+ val s = _state.value
400+ if (!s.canSave || s.testingConnection) return
401+ _state.update { it.copy(testingConnection = true, connectionResult = null, error = null) }
402+ val probedUrl = s.baseUrl.trim()
403+ val probedRequireHttps = s.requireHttps
404+ val probedCertPin = s.certPin.trim().takeIf { it.isNotBlank() }
405+ viewModelScope.launch {
406+ val start = System.currentTimeMillis()
407+ val result = runCatchingCancellable {
408+ withTimeoutOrNull(NetworkConfig.testCredentialsTimeoutMs) {
409+ container.probeServer(probedUrl, probedRequireHttps, probedCertPin)
410+ } ?: throw java.net.SocketTimeoutException("Probe timed out")
411+ }
412+ val latency = System.currentTimeMillis() - start
413+ _state.update {
414+ if (it.baseUrl.trim() != probedUrl) return@update it.copy(testingConnection = false)
415+ val verdict = result.fold(
416+ onSuccess = { probe ->
417+ when (probe) {
418+ ProbeResult.Reachable, is ProbeResult.NeedsAuth ->
419+ ConnectionProbeResult.Success(latency)
420+ is ProbeResult.Unreachable ->
421+ ConnectionProbeResult.Failed(probe.error)
422+ }
423+ },
424+ onFailure = { e -> ConnectionProbeResult.Failed(container.friendlyError(e)) },
425+ )
426+ it.copy(testingConnection = false, connectionResult = verdict)
427+ }
428+ }
429+ }
430+
431+ /**
432+ * Delete the profile being edited (only valid for an existing profile, not a new one).
433+ * Fires [onDone] on success so the screen pops back to the list. The active profile can't
434+ * be deleted here (the list enforces the same guard); surface an error instead.
435+ */
436+ fun delete(onDone: () -> Unit) {
437+ val s = _state.value
438+ val id = s.id ?: return
439+ if (s.saving || s.testingConnection || s.testingCredentials) return
440+ _state.update { it.copy(saving = true, error = null) }
441+ viewModelScope.launch {
442+ val result = runCatchingCancellable { container.profileStore.delete(id) }
443+ result.onSuccess {
444+ // If this was the active profile, disconnect so the app doesn't hold a stale
445+ // connection to a deleted profile. The list screen also guards against deleting
446+ // the active profile, but the edit screen reaches here via its own overflow.
447+ if (container.activeConnection.value?.profile?.id == id) {
448+ runCatchingCancellable { container.disconnect() }
449+ }
450+ onDone()
451+ }.onFailure { e ->
452+ _state.update { it.copy(saving = false, error = container.friendlyError(e)) }
453+ }
454+ }
455+ }
456+
381457 private fun saveInternal(connectAfter: Boolean, onDone: () -> Unit) {
382458 val s = _state.value
383459 if (!s.canSave || s.saving) return
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+3−3
@@ -23,7 +23,7 @@ import androidx.compose.material.icons.filled.Error
2323import androidx.compose.material.icons.filled.Delete
2424import androidx.compose.material.icons.filled.Dns
2525import androidx.compose.material.icons.filled.MoreVert
26−import androidx.compose.material.icons.filled.QrCodeScanner
26+import androidx.compose.material.icons.filled.PhotoLibrary
2727import androidx.compose.material.icons.filled.Search
2828import androidx.compose.material3.AlertDialog
2929import androidx.compose.material3.Button
@@ -261,7 +261,7 @@ fun ServerListScreen(
261261 // permissions; an image picker reuses the system gallery and covers the
262262 // cross-device transfer use case.
263263 IconButton(onClick = { qrImportLauncher.launch(arrayOf("image/*")) }) {
264− Icon(Icons.Filled.QrCodeScanner, contentDescription = stringResource(R.string.import_from_image))
264+ Icon(Icons.Filled.PhotoLibrary, contentDescription = stringResource(R.string.import_from_image))
265265 }
266266 },
267267 )
@@ -687,7 +687,7 @@ private fun EmptyServers(onAdd: () -> Unit, onQrImport: () -> Unit, modifier: Mo
687687 actionIcon = Icons.Filled.Add,
688688 actionLabel = stringResource(R.string.add_server),
689689 onAction = onAdd,
690− secondaryActionIcon = Icons.Filled.QrCodeScanner,
690+ secondaryActionIcon = Icons.Filled.PhotoLibrary,
691691 secondaryActionLabel = stringResource(R.string.import_from_image),
692692 onSecondaryAction = onQrImport,
693693 )
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+45−26
@@ -906,9 +906,9 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
906906 yesterdayLabel = stringResource(R.string.yesterday)
907907 lastWeekLabel = stringResource(R.string.last_week)
908908 olderLabel = stringResource(R.string.older)
909− val groupedEntries = remember(nodes, renderCap, state.pinnedIds, showGroups) {
909+ val groupedSegments = remember(nodes, renderCap, state.pinnedIds, showGroups) {
910910 val capped = if (nodes.size <= renderCap) nodes else nodes.take(renderCap)
911− buildGroupedEntries(capped, state.pinnedIds, showGroups)
911+ buildGroupedSegments(capped, state.pinnedIds, showGroups)
912912 }
913913 PullToRefreshBox(
914914 isRefreshing = refreshing,
@@ -929,18 +929,26 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
929929 }
930930 }
931931 }
932− LazyColumn(
933− state = listState,
934− modifier = Modifier.fillMaxSize(),
935− contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = NetworkConfig.listFabInsetDp.dp),
936− verticalArrangement = Arrangement.spacedBy(12.dp),
937− ) {
938− items(groupedEntries, key = { it.key }) { entry ->
939− when (entry) {
940− is SessionListEntry.Header -> DateGroupHeader(entry.label)
941− is SessionListEntry.Node -> {
942− val node = entry.node
943− val session = node.session
932+ LazyColumn(
933+ state = listState,
934+ modifier = Modifier.fillMaxSize(),
935+ contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = NetworkConfig.listFabInsetDp.dp),
936+ verticalArrangement = Arrangement.spacedBy(12.dp),
937+ ) {
938+ // Render each (header, nodes) segment: the header as a section label
939+ // followed by the session cards in that section. (Sticky headers would
940+ // require LazyListScope.stickyHeader, which is not available in the
941+ // current Compose version — the segments structure keeps the grouping
942+ // ready for a sticky upgrade when the API lands.)
943+ for ((header, segmentNodes) in groupedSegments) {
944+ if (header != null) {
945+ item(key = header.key, contentType = "header") {
946+ DateGroupHeader(header.label)
947+ }
948+ }
949+ items(segmentNodes, key = { it.key }) { entry ->
950+ val node = entry.node
951+ val session = node.session
944952 // Swipe end-to-start reveals a delete affordance and opens the
945953 // same confirmation dialog as the trash icon. We never commit
946954 // the dismissal (always reset to Settled) so the card snaps back
@@ -1075,7 +1083,6 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
10751083 }
10761084 }
10771085 }
1078− }
10791086 }
10801087 }
10811088 }
@@ -1121,41 +1128,53 @@ private fun sessionDateGroup(session: soy.iko.opencode.data.model.Session, now:
11211128 * true. When [showGroups] is false (title sort, selection mode, active query/filter), the
11221129 * nodes are returned flat with only a Pinned header (still useful to signal why pinned
11231130 * sessions float to the top). */
1124−private fun buildGroupedEntries(
1131+private fun buildGroupedSegments(
11251132 nodes: List<SessionNode>,
11261133 pinnedIds: Set<String>,
11271134 showGroups: Boolean,
1128−): List<SessionListEntry> {
1135+): List<Pair<SessionListEntry.Header?, List<SessionListEntry.Node>>> {
11291136 if (nodes.isEmpty()) return emptyList()
11301137 val now = System.currentTimeMillis()
1131− val result = ArrayList<SessionListEntry>(nodes.size + 4)
1138+ val segments = ArrayList<Pair<SessionListEntry.Header?, MutableList<SessionListEntry.Node>>>(4)
11321139 val pinnedHeaderAdded = booleanArrayOf(false)
11331140 val dateHeaderAdded = HashMap<String, Boolean>()
11341141
1135− fun maybeAddPinnedHeader() {
1136− if (!pinnedHeaderAdded[0] && pinnedIds.isNotEmpty()) {
1137− result.add(SessionListEntry.Header("__pinned", pinnedLabel))
1142+ fun ensureSegment(header: SessionListEntry.Header?) {
1143+ segments.add(header to mutableListOf())
1144+ }
1145+
1146+ fun maybeAddPinnedSegment(): MutableList<SessionListEntry.Node>? {
1147+ if (pinnedIds.isEmpty()) return null
1148+ if (!pinnedHeaderAdded[0]) {
1149+ ensureSegment(SessionListEntry.Header("__pinned", pinnedLabel))
11381150 pinnedHeaderAdded[0] = true
11391151 }
1152+ return segments.last().second
11401153 }
11411154
11421155 for (node in nodes) {
11431156 val isPinned = node.session.id in pinnedIds
11441157 if (isPinned) {
1145− maybeAddPinnedHeader()
1146− result.add(SessionListEntry.Node(node))
1158+ val seg = maybeAddPinnedSegment()
1159+ if (seg != null) seg.add(SessionListEntry.Node(node))
11471160 } else {
11481161 if (showGroups) {
11491162 val group = sessionDateGroup(node.session, now)
11501163 if (dateHeaderAdded[group] != true) {
11511164 dateHeaderAdded[group] = true
1152− result.add(SessionListEntry.Header("__date_$group", dateGroupLabel(group)))
1165+ ensureSegment(SessionListEntry.Header("__date_$group", dateGroupLabel(group)))
11531166 }
1167+ segments.last().second.add(SessionListEntry.Node(node))
1168+ } else {
1169+ // No grouping: all non-pinned nodes go into a single headerless segment.
1170+ if (segments.isEmpty() || segments.last().first != null) {
1171+ ensureSegment(null)
1172+ }
1173+ segments.last().second.add(SessionListEntry.Node(node))
11541174 }
1155− result.add(SessionListEntry.Node(node))
11561175 }
11571176 }
1158− return result
1177+ return segments
11591178}
11601179
11611180/** Resolve a date-group key to its display label. */
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+17−8
@@ -780,27 +780,36 @@ fun SettingsScreen(
780780 .padding(vertical = 8.dp),
781781 verticalAlignment = Alignment.CenterVertically,
782782 ) {
783+ val langOptions = remember {
784+ listOf(
785+ "" to R.string.settings_language_system,
786+ "en" to R.string.language_english,
787+ "es" to R.string.language_spanish,
788+ )
789+ }
783790 Column(modifier = Modifier.weight(1f)) {
784791 Text(languageLabel)
792+ val langSubtitle = if (languageOverride.isEmpty()) languageDesc
793+ else langOptions.firstOrNull { it.first == languageOverride }?.let { stringResource(it.second) }
794+ ?: languageOverride
785795 Text(
786− if (languageOverride.isEmpty()) languageDesc
787− else languageOverride,
796+ langSubtitle,
788797 style = MaterialTheme.typography.bodySmall,
789798 color = MaterialTheme.colorScheme.onSurfaceVariant,
790799 )
791800 }
792− val langOptions = remember {
793− listOf("" to R.string.settings_language_system, "en" to R.string.app_name, "es" to R.string.app_name)
794− }
795801 var langMenu by remember { mutableStateOf(false) }
796802 Box {
797803 TextButton(onClick = { langMenu = true }) {
798− Text(if (languageOverride.isEmpty()) stringResource(R.string.settings_language_system) else languageOverride)
804+ Text(
805+ langOptions.firstOrNull { it.first == languageOverride }?.let { stringResource(it.second) }
806+ ?: stringResource(R.string.settings_language_system),
807+ )
799808 }
800809 DropdownMenu(expanded = langMenu, onDismissRequest = { langMenu = false }) {
801− langOptions.forEach { (code, _) ->
810+ langOptions.forEach { (code, labelRes) ->
802811 DropdownMenuItem(
803− text = { Text(if (code.isEmpty()) stringResource(R.string.settings_language_system) else code) },
812+ text = { Text(stringResource(labelRes)) },
804813 onClick = {
805814 langMenu = false
806815 langScope.launch {
app/src/main/res/values-es/strings.xml+8
@@ -82,6 +82,9 @@
8282 <string name="show_password">Mostrar contraseña</string>
8383 <string name="hide_password">Ocultar contraseña</string>
8484 <string name="test_credentials">Probar credenciales</string>
85+ <string name="testing_connection">Probando…</string>
86+ <string name="delete_server">Eliminar servidor</string>
87+ <string name="delete_server_confirm">¿Eliminar este perfil de servidor?</string>
8588 <string name="discover_on_network">Descubrir en la red</string>
8689 <string name="searching_network">Buscando en la red local…</string>
8790 <string name="no_servers_found">No se encontraron servidores de opencode en la red local.</string>
@@ -373,6 +376,7 @@
373376 <!-- Revert -->
374377 <string name="revert_to_here">Revertir hasta aquí</string>
375378 <string name="reverted_banner">Revertido — los mensajes posteriores están ocultos.</string>
379+ <string name="editing_banner">Editando mensaje — los mensajes posteriores están ocultos.</string>
376380
377381 <!-- Sharing / summarize / init / shell -->
378382 <string name="create_share_link">Crear enlace para compartir</string>
@@ -453,6 +457,8 @@
453457 <!-- Message actions -->
454458 <string name="message_actions">Acciones del mensaje</string>
455459 <string name="quote_reply">Responder citando</string>
460+ <string name="quote_reply_banner">Respondiendo a</string>
461+ <string name="cancel_quote_reply">Cancelar respuesta</string>
456462 <string name="branch_session">Iniciar sesión nueva desde aquí</string>
457463 <string name="branch_created">Se inició una sesión nueva desde este mensaje</string>
458464 <string name="branch_failed">No se pudo iniciar una sesión nueva</string>
@@ -868,6 +874,8 @@
868874 <string name="settings_language">Idioma</string>
869875 <string name="settings_language_desc">Anular el idioma del sistema para la app.</string>
870876 <string name="settings_language_system">Predeterminado del sistema</string>
877+ <string name="language_english">English</string>
878+ <string name="language_spanish">Español</string>
871879 <string name="settings_notifications">Notificaciones</string>
872880 <string name="settings_notif_run_complete">Run completado</string>
873881 <string name="settings_notif_run_complete_desc">Notificar cuando termine una ejecución del agente.</string>
app/src/main/res/values/strings.xml+8
@@ -91,6 +91,9 @@
9191 <string name="show_password">Show password</string>
9292 <string name="hide_password">Hide password</string>
9393 <string name="test_credentials">Test credentials</string>
94+ <string name="testing_connection">Testing…</string>
95+ <string name="delete_server">Delete server</string>
96+ <string name="delete_server_confirm">Delete this server profile?</string>
9497 <string name="discover_on_network">Discover on network</string>
9598 <string name="searching_network">Searching the local network…</string>
9699 <string name="no_servers_found">No opencode servers found on the local network.</string>
@@ -397,6 +400,7 @@
397400 <!-- Revert -->
398401 <string name="revert_to_here">Revert to here</string>
399402 <string name="reverted_banner">Reverted — later messages are hidden.</string>
403+ <string name="editing_banner">Editing message — later messages are hidden.</string>
400404 <string name="edit_message">Edit</string>
401405 <string name="read_aloud">Read aloud</string>
402406 <string name="stop_reading">Stop reading</string>
@@ -476,6 +480,8 @@
476480 <!-- Message actions -->
477481 <string name="message_actions">Message actions</string>
478482 <string name="quote_reply">Quote reply</string>
483+ <string name="quote_reply_banner">Replying to</string>
484+ <string name="cancel_quote_reply">Cancel reply</string>
479485 <string name="branch_session">Start new session from here</string>
480486 <string name="branch_created">Started a new session from this message</string>
481487 <string name="branch_failed">Couldn\'t start a new session</string>
@@ -891,6 +897,8 @@
891897 <string name="settings_language">Language</string>
892898 <string name="settings_language_desc">Override the system language for the app.</string>
893899 <string name="settings_language_system">System default</string>
900+ <string name="language_english">English</string>
901+ <string name="language_spanish">Español</string>
894902 <!-- Settings: granular notification toggles. -->
895903 <string name="settings_notifications">Notifications</string>
896904 <string name="settings_notif_run_complete">Run completed</string>