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
16c6baciko
app/detekt-baseline.xml+1−1
@@ -6,7 +6,7 @@
6 <ID>ComplexCondition:PartRenderer.kt$part.isImage &amp;&amp; imageContext != null &amp;&amp; (part.source != null || !part.url.isNullOrBlank())</ID>
6 <ID>ComplexCondition:PartRenderer.kt$part.isImage &amp;&amp; imageContext != null &amp;&amp; (part.source != null || !part.url.isNullOrBlank())</ID>
7 <ID>ComplexCondition:SessionListViewModel.kt$SessionListState$q.isEmpty() &amp;&amp; pinnedIds.isEmpty() &amp;&amp; noArchivedHidden &amp;&amp; directoryFilter == null</ID>
7 <ID>ComplexCondition:SessionListViewModel.kt$SessionListState$q.isEmpty() &amp;&amp; pinnedIds.isEmpty() &amp;&amp; noArchivedHidden &amp;&amp; directoryFilter == null</ID>
8 <ID>CyclomaticComplexMethod:AppContainer.kt$AppContainer$@OptIn(ExperimentalCoroutinesApi::class) private fun observeMessageActivity()</ID>
8 <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>
10 <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>
10 <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>
11 <ID>CyclomaticComplexMethod:ChatViewModel.kt$ChatViewModel$private fun send(text: String, includeAttachments: Boolean, idempotencyKey: String? = null): Boolean</ID>
11 <ID>CyclomaticComplexMethod:ChatViewModel.kt$ChatViewModel$private fun send(text: String, includeAttachments: Boolean, idempotencyKey: String? = null): Boolean</ID>
12 <ID>CyclomaticComplexMethod:CodeHighlighter.kt$fun highlightLine(line: String, syntax: FileSyntax, palette: HighlightPalette, state: BlockCommentState): AnnotatedString</ID>
12 <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 {
256 /** Minimum number of sessions before the session list shows its search/filter field.
256 /** Minimum number of sessions before the session list shows its search/filter field.
257 * Matches the sibling screens' convention: a short list is faster to scan by eye than
257 * Matches the sibling screens' convention: a short list is faster to scan by eye than
258 * type into, so the field earns its vertical space only once the list grows. */
258 * type into, so the field earns its vertical space only once the list grows. */
259− const val sessionListSearchThreshold = 3
259+ const val sessionListSearchThreshold = 5
260 /** Extra bottom content padding (dp) for the chat message list, reserving room for the
260 /** Extra bottom content padding (dp) for the chat message list, reserving room for the
261 * jump-to-latest FAB so it never floats over the last message. */
261 * jump-to-latest FAB so it never floats over the last message. */
262 const val chatListFabInsetDp = 80
262 const val chatListFabInsetDp = 80
@@ -309,11 +309,14 @@ object NetworkConfig {
309 * so the layout doesn't jump when the load completes and the image's intrinsic size is known. */
309 * so the layout doesn't jump when the load completes and the image's intrinsic size is known. */
310 const val inlineImageMinHeightDp = 120
310 const val inlineImageMinHeightDp = 120
311
311
312− /** Lines of a code fence rendered before collapsing to a head with a "show more" affordance. */
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. */
313− const val collapsedCodeLineThreshold = 200
315+ const val collapsedCodeLineThreshold = 80
314
316
315− /** Lines of a unified diff rendered before collapsing to a head with a "show more" affordance. */
317+ /** Lines of a unified diff rendered before collapsing to a head with a "show more" affordance.
318+ * Same mobile-performance rationale as [collapsedCodeLineThreshold]. */
316− const val collapsedDiffLineThreshold = 200
319+ const val collapsedDiffLineThreshold = 80
317
320
318 /** Lines of tool output rendered before collapsing to a head with a "show more" affordance. */
321 /** Lines of tool output rendered before collapsing to a head with a "show more" affordance. */
319 const val toolOutputCollapsedLimitChars = 4000
322 const val toolOutputCollapsedLimitChars = 4000
@@ -334,7 +337,7 @@ object NetworkConfig {
334 const val imageViewerSwipeDismissThreshold = 200f
337 const val imageViewerSwipeDismissThreshold = 200f
335
338
336 /** Blink period (ms) of the streaming caret at the tail of a live assistant reply. */
339 /** 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
338
341
339 /** Auto-reject a tool-permission prompt after this many ms if the user hasn't responded,
342 /** Auto-reject a tool-permission prompt after this many ms if the user hasn't responded,
340 * so a forgotten prompt doesn't block the run indefinitely. The foreground service keeps
343 * so a forgotten prompt doesn't block the run indefinitely. The foreground service keeps
@@ -376,8 +379,9 @@ object NetworkConfig {
376
379
377 // --- Motion tokens (shared across screens) ---
380 // --- Motion tokens (shared across screens) ---
378
381
379− /** Duration (ms) for horizontal slide push/pop nav transitions. */
382+ /** Duration (ms) for horizontal slide push/pop nav transitions. M3 spec recommends 200ms
383+ * for medium-duration container transitions. */
380− const val motionSlideDurationMs = 220
384+ const val motionSlideDurationMs = 200
381 /** Duration (ms) for fade in/out nav and content transitions. */
385 /** Duration (ms) for fade in/out nav and content transitions. */
382 const val motionFadeDurationMs = 180
386 const val motionFadeDurationMs = 180
383
387
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+36−9
@@ -52,6 +52,7 @@ import soy.iko.opencode.ui.server.ServerEditScreen
52import soy.iko.opencode.ui.server.ServerListScreen
52import soy.iko.opencode.ui.server.ServerListScreen
53import soy.iko.opencode.ui.search.GlobalSearchScreen
53import soy.iko.opencode.ui.search.GlobalSearchScreen
54import soy.iko.opencode.ui.components.LargeScreenNavRail
54import soy.iko.opencode.ui.components.LargeScreenNavRail
55+import soy.iko.opencode.ui.components.CompactNavBar
55import soy.iko.opencode.ui.components.LocalChatTextScale
56import soy.iko.opencode.ui.components.LocalChatTextScale
56import soy.iko.opencode.ui.components.LocalCodeWrap
57import soy.iko.opencode.ui.components.LocalCodeWrap
57import soy.iko.opencode.ui.components.LocalReducedMotion
58import soy.iko.opencode.ui.components.LocalReducedMotion
@@ -426,12 +427,16 @@ fun OpencodeApp(container: AppContainer) {
426 }
427 }
427
428
428 composable(
429 composable(
429− route = "${Routes.CHAT}/{sessionId}",
430+ route = "${Routes.CHAT}/{sessionId}?focus={focus}",
431+ arguments = listOf(
430− arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
432+ navArgument("sessionId") { type = NavType.StringType },
433+ navArgument("focus") { type = NavType.StringType; nullable = true; defaultValue = null },
434+ ),
431 ) { entry ->
435 ) { entry ->
432 ChatScreen(
436 ChatScreen(
433 container = container,
437 container = container,
434 sessionId = entry.arguments?.getString("sessionId").orEmpty(),
438 sessionId = entry.arguments?.getString("sessionId").orEmpty(),
439+ focusMessageId = entry.arguments?.getString("focus"),
435 onBack = { navController.popBackStack() },
440 onBack = { navController.popBackStack() },
436 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
441 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
437 onOpenSession = { id ->
442 onOpenSession = { id ->
@@ -518,19 +523,20 @@ fun OpencodeApp(container: AppContainer) {
518 composable(Routes.SEARCH) {
523 composable(Routes.SEARCH) {
519 GlobalSearchScreen(
524 GlobalSearchScreen(
520 container = container,
525 container = container,
521− onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
526+ onOpenSession = { id, focusMessageId -> navController.navigate(Routes.chat(id, focusMessageId)) },
522 onBack = { navController.popBackStack() },
527 onBack = { navController.popBackStack() },
523 )
528 )
524 }
529 }
525 }
530 }
526 }
531 }
527
532
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+
528 if (showNavRail) {
539 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() }
534 Row(modifier = Modifier.fillMaxSize()) {
540 Row(modifier = Modifier.fillMaxSize()) {
535 LargeScreenNavRail(
541 LargeScreenNavRail(
536 currentRoute = currentRoute,
542 currentRoute = currentRoute,
@@ -555,7 +561,28 @@ fun OpencodeApp(container: AppContainer) {
555 Box(modifier = Modifier.weight(1f).fillMaxHeight()) { navHost() }
561 Box(modifier = Modifier.weight(1f).fillMaxHeight()) { navHost() }
556 }
562 }
557 } else {
563 } 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+ }
559 }
586 }
560 }
587 }
561 }
588 }
app/src/main/java/soy/iko/opencode/ui/Routes.kt+10−1
@@ -19,7 +19,16 @@ object Routes {
19 fun serverEditDuplicate(sourceId: String) = "$SERVER_EDIT?dup=${android.net.Uri.encode(sourceId)}"
19 fun serverEditDuplicate(sourceId: String) = "$SERVER_EDIT?dup=${android.net.Uri.encode(sourceId)}"
20
20
21 const val CHAT = "chat"
21 const val CHAT = "chat"
22+ fun chat(sessionId: String, focusMessageId: String? = null): String {
22− fun chat(sessionId: String) = "$CHAT/${android.net.Uri.encode(sessionId)}"
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+ }
23
32
24 const val FILE_VIEW = "file_view"
33 const val FILE_VIEW = "file_view"
25 fun fileView(path: String, line: Int? = null): String {
34 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
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
192
193
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,
201) {
206) {
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(
855 ) {
862 ) {
856 RevertBanner(
863 RevertBanner(
857 diff = revertDiff,
864 diff = revertDiff,
865+ isEditing = editing,
858 onUndo = { vm.unrevert() },
866 onUndo = { vm.unrevert() },
859 )
867 )
860 }
868 }
@@ -870,6 +878,13 @@ fun ChatScreen(
870 onDiscard = { vm.discardAllQueued() },
878 onDiscard = { vm.discardAllQueued() },
871 )
879 )
872 }
880 }
881+ AnimatedVisibility(
882+ visible = pendingQuote != null,
883+ enter = bannerMotion.enter,
884+ exit = bannerMotion.exit,
885+ ) {
886+ pendingQuote?.let { QuoteReplyBanner(it, onCancel = { vm.cancelQuoteReply() }) }
887+ }
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) }
1060 }
1075 }
1061
1076
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+
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) },
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
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)) }
1487 }
1518 }
1488 },
1519 },
1520+ isFirstOfSpeaker = item.isFirstOfSpeaker,
1521+ highlighted = focusedMessageId != null && message.info.id == focusedMessageId,
1489 )
1522 )
1490 }
1523 }
1491 }
1524 }
@@ -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) {
2578 ) {
2611 ) {
2579 Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
2612 Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
2580 Icon(
2613 Icon(
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),
2585 )
2618 )
2586 Text(
2619 Text(
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) {
2643 }
2676 }
2644}
2677}
2645
2678
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+
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
2820
2904
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
2823 }
2915 }
2824
2916
@@ -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
2872 }
2972 }
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"
2884 }
2984 }
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
2886 }
2989 }
2887 return result
2990 return result
2888}
2991}
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+36−1
@@ -540,6 +540,13 @@ class ChatViewModel(
540 private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
540 private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
541 val draft: StateFlow<String> = _draft.asStateFlow()
541 val draft: StateFlow<String> = _draft.asStateFlow()
542
542
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+
543 /** Messages composed for this session while offline/disconnected, queued in the outbox and
550 /** Messages composed for this session while offline/disconnected, queued in the outbox and
544 * awaiting an automatic flush on reconnect. Surfaced so the composer can show a "queued"
551 * awaiting an automatic flush on reconnect. Surfaced so the composer can show a "queued"
545 * chip with a discard/send-now affordance. */
552 * chip with a discard/send-now affordance. */
@@ -564,6 +571,12 @@ class ChatViewModel(
564 private val _reverted = MutableStateFlow(false)
571 private val _reverted = MutableStateFlow(false)
565 val reverted: StateFlow<Boolean> = _reverted.asStateFlow()
572 val reverted: StateFlow<Boolean> = _reverted.asStateFlow()
566
573
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+
567 /** The diff of the active revert checkpoint, when the server includes one. Shown in the
580 /** The diff of the active revert checkpoint, when the server includes one. Shown in the
568 * revert banner so the user can see what changed before deciding to undo. Null when the
581 * revert banner so the user can see what changed before deciding to undo. Null when the
569 * session isn't reverted or the server didn't send a diff. */
582 * session isn't reverted or the server didn't send a diff. */
@@ -1019,6 +1032,8 @@ class ChatViewModel(
1019 if (_draft.value.trim() == trimmed) {
1032 if (_draft.value.trim() == trimmed) {
1020 suppressDraftPersist.set(true)
1033 suppressDraftPersist.set(true)
1021 _draft.value = ""
1034 _draft.value = ""
1035+ _pendingQuote.value = null
1036+ _editing.value = false
1022 }
1037 }
1023 // Inject an optimistic user message so the outgoing prompt is visible immediately,
1038 // Inject an optimistic user message so the outgoing prompt is visible immediately,
1024 // before the server echoes it back via SSE. Removed by reconcileOptimistic once the
1039 // before the server echoes it back via SSE. Removed by reconcileOptimistic once the
@@ -1343,6 +1358,7 @@ class ChatViewModel(
1343 .onSuccess {
1358 .onSuccess {
1344 _reverted.value = it.isReverted
1359 _reverted.value = it.isReverted
1345 _revertDiff.value = it.revert?.diff?.takeIf { diff -> diff.isNotBlank() }
1360 _revertDiff.value = it.revert?.diff?.takeIf { diff -> diff.isNotBlank() }
1361+ _editing.value = false
1346 }
1362 }
1347 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1363 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1348 }
1364 }
@@ -1401,11 +1417,14 @@ class ChatViewModel(
1401 fun editMessage(messageId: String, text: String) {
1417 fun editMessage(messageId: String, text: String) {
1402 revertTo(messageId)
1418 revertTo(messageId)
1403 _draft.value = text.take(NetworkConfig.maxDraftLengthChars)
1419 _draft.value = text.take(NetworkConfig.maxDraftLengthChars)
1420+ _pendingQuote.value = null
1421+ _editing.value = true
1404 }
1422 }
1405
1423
1406 /** Prefill the composer with [text] as a Markdown blockquote so the user can respond to a
1424 /** Prefill the composer with [text] as a Markdown blockquote so the user can respond to a
1407 * specific message inline. Appends to any existing draft (with a blank line) rather than
1425 * 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. */
1409 fun quoteReply(text: String) {
1428 fun quoteReply(text: String) {
1410 val quoted = text.trim().lineSequence().joinToString("\n") { "> $it" }
1429 val quoted = text.trim().lineSequence().joinToString("\n") { "> $it" }
1411 if (quoted.isBlank()) return
1430 if (quoted.isBlank()) return
@@ -1414,6 +1433,21 @@ class ChatViewModel(
1414 // Cap like the composer's typed/pasted/dictated input so quoting onto a near-full draft
1433 // Cap like the composer's typed/pasted/dictated input so quoting onto a near-full draft
1415 // can't push it past the limit (negative remaining counter + an over-cap prompt sent).
1434 // can't push it past the limit (negative remaining counter + an over-cap prompt sent).
1416 _draft.value = combined.take(NetworkConfig.maxDraftLengthChars)
1435 _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()
1417 }
1451 }
1418
1452
1419 /** Fork the conversation by creating a brand-new session seeded with [text] as its first
1453 /** Fork the conversation by creating a brand-new session seeded with [text] as its first
@@ -1448,6 +1482,7 @@ class ChatViewModel(
1448 .onSuccess {
1482 .onSuccess {
1449 _reverted.value = it.isReverted
1483 _reverted.value = it.isReverted
1450 _revertDiff.value = it.revert?.diff?.takeIf { diff -> diff.isNotBlank() }
1484 _revertDiff.value = it.revert?.diff?.takeIf { diff -> diff.isNotBlank() }
1485+ _editing.value = false
1451 }
1486 }
1452 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1487 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1453 }
1488 }
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+28−3
@@ -120,11 +120,17 @@ fun MessageBubble(
120 onRetry: (() -> Unit)? = null,
120 onRetry: (() -> Unit)? = null,
121 onDismiss: (() -> Unit)? = null,
121 onDismiss: (() -> Unit)? = null,
122 onShare: (() -> Unit)? = null,
122 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,
123) {
129) {
124 when (message.info) {
130 when (message.info) {
125 is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile, onRevert, onEdit, onQuote, onBranch, sendStatus, isEdited, onRetry, onDismiss, onShare)
131 is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile, onRevert, onEdit, onQuote, onBranch, sendStatus, isEdited, onRetry, onDismiss, onShare)
126 is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier, onOpenFile)
132 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)
128 }
134 }
129}
135}
130
136
@@ -682,6 +688,8 @@ private fun AssistantBlock(
682 onContinue: (() -> Unit)? = null,
688 onContinue: (() -> Unit)? = null,
683 isEdited: Boolean = false,
689 isEdited: Boolean = false,
684 onShare: (() -> Unit)? = null,
690 onShare: (() -> Unit)? = null,
691+ isFirstOfSpeaker: Boolean = true,
692+ highlighted: Boolean = false,
685) {
693) {
686 // Long-press context menu state, hoisted to the block scope so the whole bubble (body and
694 // Long-press context menu state, hoisted to the block scope so the whole bubble (body and
687 // footer) is the long-press target — see UserBubble for rationale.
695 // footer) is the long-press target — see UserBubble for rationale.
@@ -697,9 +705,25 @@ private fun AssistantBlock(
697 // only the model/agent label — giving a screen-reader user an explicit role signal
705 // only the model/agent label — giving a screen-reader user an explicit role signal
698 // matching the user bubble's "You" prefix.
706 // matching the user bubble's "You" prefix.
699 val assistantRoleLabel = stringResource(R.string.role_assistant_message)
707 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+ )
700 Column(
717 Column(
701 modifier = modifier
718 modifier = modifier
702 .fillMaxWidth()
719 .fillMaxWidth()
720+ .then(
721+ if (highlightAlpha > 0f) {
722+ Modifier.background(highlightColor.copy(alpha = highlightAlpha * 0.6f))
723+ } else {
724+ Modifier
725+ },
726+ )
703 .semantics(mergeDescendants = true) { contentDescription = assistantRoleLabel }
727 .semantics(mergeDescendants = true) { contentDescription = assistantRoleLabel }
704 .pointerInput(Unit) {
728 .pointerInput(Unit) {
705 detectTapGestures(onLongPress = { offset ->
729 detectTapGestures(onLongPress = { offset ->
@@ -714,10 +738,11 @@ private fun AssistantBlock(
714 verticalArrangement = Arrangement.spacedBy(8.dp),
738 verticalArrangement = Arrangement.spacedBy(8.dp),
715 ) {
739 ) {
716 val info = message.info
740 val info = message.info
717− if (info is AssistantMessage) {
741+ if (info is AssistantMessage && isFirstOfSpeaker) {
718 // Avatar + model label row: the robot icon gives the assistant a consistent visual
742 // Avatar + model label row: the robot icon gives the assistant a consistent visual
719 // identity (left-aligned, mirroring the user's right-aligned bubble) so on a long
743 // 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.
721 AssistantHeader(label = modelLabel ?: info.modelID, agentLabel = agentLabel)
746 AssistantHeader(label = modelLabel ?: info.modelID, agentLabel = agentLabel)
722 }
747 }
723 message.parts.forEachIndexed { index, part ->
748 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 @@
1package soy.iko.opencode.ui.components
1package soy.iko.opencode.ui.components
2
2
3import androidx.compose.animation.AnimatedVisibility
3import androidx.compose.animation.AnimatedVisibility
4+import androidx.compose.animation.animateColorAsState
5+import androidx.compose.animation.core.tween
4import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.BoxScope
7import androidx.compose.foundation.layout.BoxScope
6import androidx.compose.foundation.layout.Row
8import androidx.compose.foundation.layout.Row
@@ -37,6 +39,7 @@ import kotlinx.coroutines.flow.flatMapLatest
37import kotlinx.coroutines.flow.flowOf
39import kotlinx.coroutines.flow.flowOf
38import soy.iko.opencode.R
40import soy.iko.opencode.R
39import soy.iko.opencode.data.network.EventStreamClient
41import soy.iko.opencode.data.network.EventStreamClient
42+import soy.iko.opencode.data.network.NetworkConfig
40import soy.iko.opencode.di.AppContainer
43import soy.iko.opencode.di.AppContainer
41
44
42/**
45/**
@@ -98,10 +101,18 @@ fun ConnectionBanner(
98 // rejected the connection (not when the device itself is offline).
101 // rejected the connection (not when the device itself is offline).
99 val isServerFailure = state == EventStreamClient.ConnectionState.Failed ||
102 val isServerFailure = state == EventStreamClient.ConnectionState.Failed ||
100 state == EventStreamClient.ConnectionState.AuthFailed
103 state == EventStreamClient.ConnectionState.AuthFailed
104+ val container by animateColorAsState(
101− val container = if (isFailed) MaterialTheme.colorScheme.errorContainer
105+ targetValue = if (isFailed) MaterialTheme.colorScheme.errorContainer
102− else MaterialTheme.colorScheme.tertiaryContainer
106+ else MaterialTheme.colorScheme.tertiaryContainer,
107+ animationSpec = tween(NetworkConfig.motionFadeDurationMs),
108+ label = "bannerContainer",
109+ )
110+ val onContainer by animateColorAsState(
103− val onContainer = if (isFailed) MaterialTheme.colorScheme.onErrorContainer
111+ targetValue = if (isFailed) MaterialTheme.colorScheme.onErrorContainer
104− else MaterialTheme.colorScheme.onTertiaryContainer
112+ else MaterialTheme.colorScheme.onTertiaryContainer,
113+ animationSpec = tween(NetworkConfig.motionFadeDurationMs),
114+ label = "bannerOnContainer",
115+ )
105 Surface(
116 Surface(
106 modifier = modifier
117 modifier = modifier
107 .fillMaxWidth()
118 .fillMaxWidth()
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+2−2
@@ -80,7 +80,7 @@ import soy.iko.opencode.util.runCatchingCancellable
80@Composable
80@Composable
81fun GlobalSearchScreen(
81fun GlobalSearchScreen(
82 container: AppContainer,
82 container: AppContainer,
83− onOpenSession: (String) -> Unit,
83+ onOpenSession: (String, String?) -> Unit,
84 onBack: () -> Unit,
84 onBack: () -> Unit,
85) {
85) {
86 val vm: GlobalSearchViewModel = viewModel(factory = vmFactory { GlobalSearchViewModel(container) })
86 val vm: GlobalSearchViewModel = viewModel(factory = vmFactory { GlobalSearchViewModel(container) })
@@ -260,7 +260,7 @@ fun GlobalSearchScreen(
260 hit = hit,
260 hit = hit,
261 query = state.query.trim(),
261 query = state.query.trim(),
262 ignoreCase = !state.matchCase,
262 ignoreCase = !state.matchCase,
263− onClick = { onOpenSession(hit.session.id) },
263+ onClick = { onOpenSession(hit.session.id, hit.firstMatchMessageId) },
264 modifier = Modifier.then(reducedMotionAnimateItem()),
264 modifier = Modifier.then(reducedMotionAnimateItem()),
265 )
265 )
266 }
266 }
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchViewModel.kt+13−7
@@ -34,13 +34,16 @@ import java.util.concurrent.ConcurrentHashMap
34
34
35/** One search result: the [session] that matched, a [snippet] of the matching text, the total
35/** One search result: the [session] that matched, a [snippet] of the matching text, the total
36 * number of [matchCount] occurrences (across body + title), and whether the title itself matched
36 * 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. */
38@Immutable
40@Immutable
39data class SearchHit(
41data class SearchHit(
40 val session: Session,
42 val session: Session,
41 val snippet: String,
43 val snippet: String,
42 val matchCount: Int = 1,
44 val matchCount: Int = 1,
43 val matchedTitle: Boolean = false,
45 val matchedTitle: Boolean = false,
46+ val firstMatchMessageId: String? = null,
44)
47)
45
48
46@Immutable
49@Immutable
@@ -209,7 +212,7 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
209 Log.w("GlobalSearch", "listMessages failed: " + safeExceptionSummary(it))
212 Log.w("GlobalSearch", "listMessages failed: " + safeExceptionSummary(it))
210 emptyList()
213 emptyList()
211 }
214 }
212− val snippet = matchSnippet(messages, query, ignoreCase, filter)
215+ val (snippet, matchMessageId) = matchSnippet(messages, query, ignoreCase, filter)
213 // The session title isn't a message part, so the type filter doesn't
216 // The session title isn't a message part, so the type filter doesn't
214 // apply to it — a title match is always surfaced.
217 // apply to it — a title match is always surfaced.
215 val titleMatched = session.displayTitle.contains(query, ignoreCase = ignoreCase)
218 val titleMatched = session.displayTitle.contains(query, ignoreCase = ignoreCase)
@@ -222,6 +225,7 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
222 ?: session.displayTitle.takeIf { titleMatched }.orEmpty(),
225 ?: session.displayTitle.takeIf { titleMatched }.orEmpty(),
223 matchCount = count.coerceAtLeast(1),
226 matchCount = count.coerceAtLeast(1),
224 matchedTitle = titleMatched,
227 matchedTitle = titleMatched,
228+ firstMatchMessageId = matchMessageId,
225 )
229 )
226 }
230 }
227 // Publish progress so the spinner can show "Searched N / M sessions".
231 // Publish progress so the spinner can show "Searched N / M sessions".
@@ -242,21 +246,23 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
242 }
246 }
243
247
244 /** Find the first searchable text containing [query] across [messages] and return a short
248 /** 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
249+ * snippet centered on the match plus the id of the matching message, or `(null, null)` if
246− * case-sensitivity; [filter] restricts which part types contribute searchable text. */
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. */
247 private fun matchSnippet(
253 private fun matchSnippet(
248 messages: List<soy.iko.opencode.data.model.MessageWithParts>,
254 messages: List<soy.iko.opencode.data.model.MessageWithParts>,
249 query: String,
255 query: String,
250 ignoreCase: Boolean,
256 ignoreCase: Boolean,
251 filter: SearchTypeFilter = SearchTypeFilter.ALL,
257 filter: SearchTypeFilter = SearchTypeFilter.ALL,
252− ): String? {
258+ ): Pair<String?, String?> {
253 for (message in messages) {
259 for (message in messages) {
254 for (candidate in searchableTexts(message.parts, filter)) {
260 for (candidate in searchableTexts(message.parts, filter)) {
255 val idx = candidate.indexOf(query, ignoreCase = ignoreCase)
261 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
257 }
263 }
258 }
264 }
259− return null
265+ return null to null
260 }
266 }
261
267
262 /** The ordered list of searchable strings for a message's parts, scoped by [filter].
268 /** 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
21import androidx.compose.foundation.verticalScroll
21import androidx.compose.foundation.verticalScroll
22import androidx.compose.material.icons.Icons
22import androidx.compose.material.icons.Icons
23import androidx.compose.material.icons.automirrored.filled.ArrowBack
23import androidx.compose.material.icons.automirrored.filled.ArrowBack
24+import androidx.compose.material.icons.filled.Delete
24import androidx.compose.material.icons.filled.ExpandLess
25import androidx.compose.material.icons.filled.ExpandLess
25import androidx.compose.material.icons.filled.ExpandMore
26import androidx.compose.material.icons.filled.ExpandMore
26import androidx.compose.material.icons.filled.Visibility
27import androidx.compose.material.icons.filled.Visibility
@@ -117,6 +118,33 @@ fun ServerEditScreen(
117 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
118 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
118 }
119 }
119 },
120 },
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+ },
120 )
148 )
121 },
149 },
122 ) { padding ->
150 ) { padding ->
@@ -265,6 +293,17 @@ private fun ServerEditForm(
265 onImeDone = { if (canSave && !state.saving) vm.connect(onDone) },
293 onImeDone = { if (canSave && !state.saving) vm.connect(onDone) },
266 )
294 )
267 }
295 }
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+ )
268 // Single primary action: probe-then-save-and-connect. Replaces the former
307 // Single primary action: probe-then-save-and-connect. Replaces the former
269 // Check-connectivity / Save / Save-&-connect trio.
308 // Check-connectivity / Save / Save-&-connect trio.
270 Button(
309 Button(
@@ -413,6 +452,53 @@ private fun AuthFields(
413 }
452 }
414}
453}
415
454
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+
416/**
502/**
417 * Optional TLS hardening, tucked behind a collapsed "Security" expander so the common
503 * Optional TLS hardening, tucked behind a collapsed "Security" expander so the common
418 * localhost/HTTP setup never sees it. Auto-expands when a loaded profile already uses one of
504 * 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(
41 val testingCredentials: Boolean = false,
41 val testingCredentials: Boolean = false,
42 /** Non-null when the last credential test succeeded (true) or failed (false). */
42 /** Non-null when the last credential test succeeded (true) or failed (false). */
43 val credentialsResult: Boolean? = null,
43 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,
44) {
48) {
45 val canSave: Boolean get() = baseUrl.isNotBlank() && isValidUrl(baseUrl) && certPinValid
49 val canSave: Boolean get() = baseUrl.isNotBlank() && isValidUrl(baseUrl) && certPinValid
46 val isNew: Boolean get() = id == null
50 val isNew: Boolean get() = id == null
@@ -59,6 +63,13 @@ data class ServerEditState(
59 }
63 }
60}
64}
61
65
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+
62/** Snapshot of the profile loaded into the editor, normalized the same way save() stores it. */
73/** Snapshot of the profile loaded into the editor, normalized the same way save() stores it. */
63data class InitialProfile(
74data class InitialProfile(
64 val label: String,
75 val label: String,
@@ -378,6 +389,71 @@ class ServerEditViewModel(
378 saveInternal(connectAfter = false, onDone = onDone)
389 saveInternal(connectAfter = false, onDone = onDone)
379 }
390 }
380
391
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+
381 private fun saveInternal(connectAfter: Boolean, onDone: () -> Unit) {
457 private fun saveInternal(connectAfter: Boolean, onDone: () -> Unit) {
382 val s = _state.value
458 val s = _state.value
383 if (!s.canSave || s.saving) return
459 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
23import androidx.compose.material.icons.filled.Delete
23import androidx.compose.material.icons.filled.Delete
24import androidx.compose.material.icons.filled.Dns
24import androidx.compose.material.icons.filled.Dns
25import androidx.compose.material.icons.filled.MoreVert
25import androidx.compose.material.icons.filled.MoreVert
26−import androidx.compose.material.icons.filled.QrCodeScanner
26+import androidx.compose.material.icons.filled.PhotoLibrary
27import androidx.compose.material.icons.filled.Search
27import androidx.compose.material.icons.filled.Search
28import androidx.compose.material3.AlertDialog
28import androidx.compose.material3.AlertDialog
29import androidx.compose.material3.Button
29import androidx.compose.material3.Button
@@ -261,7 +261,7 @@ fun ServerListScreen(
261 // permissions; an image picker reuses the system gallery and covers the
261 // permissions; an image picker reuses the system gallery and covers the
262 // cross-device transfer use case.
262 // cross-device transfer use case.
263 IconButton(onClick = { qrImportLauncher.launch(arrayOf("image/*")) }) {
263 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))
265 }
265 }
266 },
266 },
267 )
267 )
@@ -687,7 +687,7 @@ private fun EmptyServers(onAdd: () -> Unit, onQrImport: () -> Unit, modifier: Mo
687 actionIcon = Icons.Filled.Add,
687 actionIcon = Icons.Filled.Add,
688 actionLabel = stringResource(R.string.add_server),
688 actionLabel = stringResource(R.string.add_server),
689 onAction = onAdd,
689 onAction = onAdd,
690− secondaryActionIcon = Icons.Filled.QrCodeScanner,
690+ secondaryActionIcon = Icons.Filled.PhotoLibrary,
691 secondaryActionLabel = stringResource(R.string.import_from_image),
691 secondaryActionLabel = stringResource(R.string.import_from_image),
692 onSecondaryAction = onQrImport,
692 onSecondaryAction = onQrImport,
693 )
693 )
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(
906 yesterdayLabel = stringResource(R.string.yesterday)
906 yesterdayLabel = stringResource(R.string.yesterday)
907 lastWeekLabel = stringResource(R.string.last_week)
907 lastWeekLabel = stringResource(R.string.last_week)
908 olderLabel = stringResource(R.string.older)
908 olderLabel = stringResource(R.string.older)
909− val groupedEntries = remember(nodes, renderCap, state.pinnedIds, showGroups) {
909+ val groupedSegments = remember(nodes, renderCap, state.pinnedIds, showGroups) {
910 val capped = if (nodes.size <= renderCap) nodes else nodes.take(renderCap)
910 val capped = if (nodes.size <= renderCap) nodes else nodes.take(renderCap)
911− buildGroupedEntries(capped, state.pinnedIds, showGroups)
911+ buildGroupedSegments(capped, state.pinnedIds, showGroups)
912 }
912 }
913 PullToRefreshBox(
913 PullToRefreshBox(
914 isRefreshing = refreshing,
914 isRefreshing = refreshing,
@@ -929,18 +929,26 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
929 }
929 }
930 }
930 }
931 }
931 }
932− LazyColumn(
932+ LazyColumn(
933− state = listState,
933+ state = listState,
934− modifier = Modifier.fillMaxSize(),
934+ modifier = Modifier.fillMaxSize(),
935− contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = NetworkConfig.listFabInsetDp.dp),
935+ contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = NetworkConfig.listFabInsetDp.dp),
936− verticalArrangement = Arrangement.spacedBy(12.dp),
936+ verticalArrangement = Arrangement.spacedBy(12.dp),
937− ) {
937+ ) {
938− items(groupedEntries, key = { it.key }) { entry ->
938+ // Render each (header, nodes) segment: the header as a section label
939− when (entry) {
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") {
940− is SessionListEntry.Header -> DateGroupHeader(entry.label)
946+ DateGroupHeader(header.label)
941− is SessionListEntry.Node -> {
947+ }
948+ }
949+ items(segmentNodes, key = { it.key }) { entry ->
942− val node = entry.node
950+ val node = entry.node
943− val session = node.session
951+ val session = node.session
944 // Swipe end-to-start reveals a delete affordance and opens the
952 // Swipe end-to-start reveals a delete affordance and opens the
945 // same confirmation dialog as the trash icon. We never commit
953 // same confirmation dialog as the trash icon. We never commit
946 // the dismissal (always reset to Settled) so the card snaps back
954 // the dismissal (always reset to Settled) so the card snaps back
@@ -1075,7 +1083,6 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
1075 }
1083 }
1076 }
1084 }
1077 }
1085 }
1078− }
1079 }
1086 }
1080 }
1087 }
1081 }
1088 }
@@ -1121,41 +1128,53 @@ private fun sessionDateGroup(session: soy.iko.opencode.data.model.Session, now:
1121 * true. When [showGroups] is false (title sort, selection mode, active query/filter), the
1128 * true. When [showGroups] is false (title sort, selection mode, active query/filter), the
1122 * nodes are returned flat with only a Pinned header (still useful to signal why pinned
1129 * nodes are returned flat with only a Pinned header (still useful to signal why pinned
1123 * sessions float to the top). */
1130 * sessions float to the top). */
1124−private fun buildGroupedEntries(
1131+private fun buildGroupedSegments(
1125 nodes: List<SessionNode>,
1132 nodes: List<SessionNode>,
1126 pinnedIds: Set<String>,
1133 pinnedIds: Set<String>,
1127 showGroups: Boolean,
1134 showGroups: Boolean,
1128−): List<SessionListEntry> {
1135+): List<Pair<SessionListEntry.Header?, List<SessionListEntry.Node>>> {
1129 if (nodes.isEmpty()) return emptyList()
1136 if (nodes.isEmpty()) return emptyList()
1130 val now = System.currentTimeMillis()
1137 val now = System.currentTimeMillis()
1131− val result = ArrayList<SessionListEntry>(nodes.size + 4)
1138+ val segments = ArrayList<Pair<SessionListEntry.Header?, MutableList<SessionListEntry.Node>>>(4)
1132 val pinnedHeaderAdded = booleanArrayOf(false)
1139 val pinnedHeaderAdded = booleanArrayOf(false)
1133 val dateHeaderAdded = HashMap<String, Boolean>()
1140 val dateHeaderAdded = HashMap<String, Boolean>()
1134
1141
1135− fun maybeAddPinnedHeader() {
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
1136− if (!pinnedHeaderAdded[0] && pinnedIds.isNotEmpty()) {
1148+ if (!pinnedHeaderAdded[0]) {
1137− result.add(SessionListEntry.Header("__pinned", pinnedLabel))
1149+ ensureSegment(SessionListEntry.Header("__pinned", pinnedLabel))
1138 pinnedHeaderAdded[0] = true
1150 pinnedHeaderAdded[0] = true
1139 }
1151 }
1152+ return segments.last().second
1140 }
1153 }
1141
1154
1142 for (node in nodes) {
1155 for (node in nodes) {
1143 val isPinned = node.session.id in pinnedIds
1156 val isPinned = node.session.id in pinnedIds
1144 if (isPinned) {
1157 if (isPinned) {
1145− maybeAddPinnedHeader()
1158+ val seg = maybeAddPinnedSegment()
1146− result.add(SessionListEntry.Node(node))
1159+ if (seg != null) seg.add(SessionListEntry.Node(node))
1147 } else {
1160 } else {
1148 if (showGroups) {
1161 if (showGroups) {
1149 val group = sessionDateGroup(node.session, now)
1162 val group = sessionDateGroup(node.session, now)
1150 if (dateHeaderAdded[group] != true) {
1163 if (dateHeaderAdded[group] != true) {
1151 dateHeaderAdded[group] = true
1164 dateHeaderAdded[group] = true
1152− result.add(SessionListEntry.Header("__date_$group", dateGroupLabel(group)))
1165+ ensureSegment(SessionListEntry.Header("__date_$group", dateGroupLabel(group)))
1153 }
1166 }
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))
1154 }
1174 }
1155− result.add(SessionListEntry.Node(node))
1156 }
1175 }
1157 }
1176 }
1158− return result
1177+ return segments
1159}
1178}
1160
1179
1161/** Resolve a date-group key to its display label. */
1180/** 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(
780 .padding(vertical = 8.dp),
780 .padding(vertical = 8.dp),
781 verticalAlignment = Alignment.CenterVertically,
781 verticalAlignment = Alignment.CenterVertically,
782 ) {
782 ) {
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+ }
783 Column(modifier = Modifier.weight(1f)) {
790 Column(modifier = Modifier.weight(1f)) {
784 Text(languageLabel)
791 Text(languageLabel)
792+ val langSubtitle = if (languageOverride.isEmpty()) languageDesc
793+ else langOptions.firstOrNull { it.first == languageOverride }?.let { stringResource(it.second) }
794+ ?: languageOverride
785 Text(
795 Text(
786− if (languageOverride.isEmpty()) languageDesc
796+ langSubtitle,
787− else languageOverride,
788 style = MaterialTheme.typography.bodySmall,
797 style = MaterialTheme.typography.bodySmall,
789 color = MaterialTheme.colorScheme.onSurfaceVariant,
798 color = MaterialTheme.colorScheme.onSurfaceVariant,
790 )
799 )
791 }
800 }
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− }
795 var langMenu by remember { mutableStateOf(false) }
801 var langMenu by remember { mutableStateOf(false) }
796 Box {
802 Box {
797 TextButton(onClick = { langMenu = true }) {
803 TextButton(onClick = { langMenu = true }) {
804+ Text(
805+ langOptions.firstOrNull { it.first == languageOverride }?.let { stringResource(it.second) }
798− Text(if (languageOverride.isEmpty()) stringResource(R.string.settings_language_system) else languageOverride)
806+ ?: stringResource(R.string.settings_language_system),
807+ )
799 }
808 }
800 DropdownMenu(expanded = langMenu, onDismissRequest = { langMenu = false }) {
809 DropdownMenu(expanded = langMenu, onDismissRequest = { langMenu = false }) {
801− langOptions.forEach { (code, _) ->
810+ langOptions.forEach { (code, labelRes) ->
802 DropdownMenuItem(
811 DropdownMenuItem(
803− text = { Text(if (code.isEmpty()) stringResource(R.string.settings_language_system) else code) },
812+ text = { Text(stringResource(labelRes)) },
804 onClick = {
813 onClick = {
805 langMenu = false
814 langMenu = false
806 langScope.launch {
815 langScope.launch {
app/src/main/res/values-es/strings.xml+8
@@ -82,6 +82,9 @@
82 <string name="show_password">Mostrar contraseña</string>
82 <string name="show_password">Mostrar contraseña</string>
83 <string name="hide_password">Ocultar contraseña</string>
83 <string name="hide_password">Ocultar contraseña</string>
84 <string name="test_credentials">Probar credenciales</string>
84 <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>
85 <string name="discover_on_network">Descubrir en la red</string>
88 <string name="discover_on_network">Descubrir en la red</string>
86 <string name="searching_network">Buscando en la red local…</string>
89 <string name="searching_network">Buscando en la red local…</string>
87 <string name="no_servers_found">No se encontraron servidores de opencode en la red local.</string>
90 <string name="no_servers_found">No se encontraron servidores de opencode en la red local.</string>
@@ -373,6 +376,7 @@
373 <!-- Revert -->
376 <!-- Revert -->
374 <string name="revert_to_here">Revertir hasta aquí</string>
377 <string name="revert_to_here">Revertir hasta aquí</string>
375 <string name="reverted_banner">Revertido — los mensajes posteriores están ocultos.</string>
378 <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>
376
380
377 <!-- Sharing / summarize / init / shell -->
381 <!-- Sharing / summarize / init / shell -->
378 <string name="create_share_link">Crear enlace para compartir</string>
382 <string name="create_share_link">Crear enlace para compartir</string>
@@ -453,6 +457,8 @@
453 <!-- Message actions -->
457 <!-- Message actions -->
454 <string name="message_actions">Acciones del mensaje</string>
458 <string name="message_actions">Acciones del mensaje</string>
455 <string name="quote_reply">Responder citando</string>
459 <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>
456 <string name="branch_session">Iniciar sesión nueva desde aquí</string>
462 <string name="branch_session">Iniciar sesión nueva desde aquí</string>
457 <string name="branch_created">Se inició una sesión nueva desde este mensaje</string>
463 <string name="branch_created">Se inició una sesión nueva desde este mensaje</string>
458 <string name="branch_failed">No se pudo iniciar una sesión nueva</string>
464 <string name="branch_failed">No se pudo iniciar una sesión nueva</string>
@@ -868,6 +874,8 @@
868 <string name="settings_language">Idioma</string>
874 <string name="settings_language">Idioma</string>
869 <string name="settings_language_desc">Anular el idioma del sistema para la app.</string>
875 <string name="settings_language_desc">Anular el idioma del sistema para la app.</string>
870 <string name="settings_language_system">Predeterminado del sistema</string>
876 <string name="settings_language_system">Predeterminado del sistema</string>
877+ <string name="language_english">English</string>
878+ <string name="language_spanish">Español</string>
871 <string name="settings_notifications">Notificaciones</string>
879 <string name="settings_notifications">Notificaciones</string>
872 <string name="settings_notif_run_complete">Run completado</string>
880 <string name="settings_notif_run_complete">Run completado</string>
873 <string name="settings_notif_run_complete_desc">Notificar cuando termine una ejecución del agente.</string>
881 <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 @@
91 <string name="show_password">Show password</string>
91 <string name="show_password">Show password</string>
92 <string name="hide_password">Hide password</string>
92 <string name="hide_password">Hide password</string>
93 <string name="test_credentials">Test credentials</string>
93 <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>
94 <string name="discover_on_network">Discover on network</string>
97 <string name="discover_on_network">Discover on network</string>
95 <string name="searching_network">Searching the local network…</string>
98 <string name="searching_network">Searching the local network…</string>
96 <string name="no_servers_found">No opencode servers found on the local network.</string>
99 <string name="no_servers_found">No opencode servers found on the local network.</string>
@@ -397,6 +400,7 @@
397 <!-- Revert -->
400 <!-- Revert -->
398 <string name="revert_to_here">Revert to here</string>
401 <string name="revert_to_here">Revert to here</string>
399 <string name="reverted_banner">Reverted — later messages are hidden.</string>
402 <string name="reverted_banner">Reverted — later messages are hidden.</string>
403+ <string name="editing_banner">Editing message — later messages are hidden.</string>
400 <string name="edit_message">Edit</string>
404 <string name="edit_message">Edit</string>
401 <string name="read_aloud">Read aloud</string>
405 <string name="read_aloud">Read aloud</string>
402 <string name="stop_reading">Stop reading</string>
406 <string name="stop_reading">Stop reading</string>
@@ -476,6 +480,8 @@
476 <!-- Message actions -->
480 <!-- Message actions -->
477 <string name="message_actions">Message actions</string>
481 <string name="message_actions">Message actions</string>
478 <string name="quote_reply">Quote reply</string>
482 <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>
479 <string name="branch_session">Start new session from here</string>
485 <string name="branch_session">Start new session from here</string>
480 <string name="branch_created">Started a new session from this message</string>
486 <string name="branch_created">Started a new session from this message</string>
481 <string name="branch_failed">Couldn\'t start a new session</string>
487 <string name="branch_failed">Couldn\'t start a new session</string>
@@ -891,6 +897,8 @@
891 <string name="settings_language">Language</string>
897 <string name="settings_language">Language</string>
892 <string name="settings_language_desc">Override the system language for the app.</string>
898 <string name="settings_language_desc">Override the system language for the app.</string>
893 <string name="settings_language_system">System default</string>
899 <string name="settings_language_system">System default</string>
900+ <string name="language_english">English</string>
901+ <string name="language_spanish">Español</string>
894 <!-- Settings: granular notification toggles. -->
902 <!-- Settings: granular notification toggles. -->
895 <string name="settings_notifications">Notifications</string>
903 <string name="settings_notifications">Notifications</string>
896 <string name="settings_notif_run_complete">Run completed</string>
904 <string name="settings_notif_run_complete">Run completed</string>