git.iko.soy/opencode-android
on master help sign in
Fix 2 P0 UX bugs (undo snackbar vanishing before deferred delete on 3 list screens, find-in-file broken in diff mode) and add 14 P1/P2 polish fixes (file chip opens file viewer, selectable reasoning/tool output, deletable active server, stale markdown comment, send/confirm haptic type, shape scale adoption, rename length cap, crash report share subject, find no-matches error color, pending share re-navigation, sentence capitalization, larger copy icons)
commit1e779bbb3dbaf85ea82b0c48c52f999dcd167f59 authoriko <mail@iko.soy> dateWed 1 Jul 2026, 14:52 (3 months ago) parenteb4a7b1
15 files changed+182−65
app/detekt-baseline.xml+1−1 app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+4 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+16−1 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+12−4 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+22−10 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+67−23 app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+1−2 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+6−7 app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+1−2 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+22−6 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+12−3 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+6−3 app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+2 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+8−3 app/src/main/res/values/strings.xml+2
app/detekt-baseline.xml+1−1
@@ -4,7 +4,7 @@
44 <CurrentIssues>
55 <ID>ComplexCondition:ChatScreen.kt$event.type == KeyEventType.KeyDown &amp;&amp; event.key == Key.Enter &amp;&amp; !event.isShiftPressed &amp;&amp; enabled &amp;&amp; value.isNotBlank()</ID>
66 <ID>ComplexCondition:PartRenderer.kt$part.isImage &amp;&amp; imageContext != null &amp;&amp; (part.source != null || !part.url.isNullOrBlank())</ID>
7− <ID>CyclomaticComplexMethod:ChatScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( container: AppContainer, sessionId: String, onBack: () -&gt; Unit, )</ID>
7+ <ID>CyclomaticComplexMethod:ChatScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( container: AppContainer, sessionId: String, onBack: () -&gt; Unit, onOpenFile: ((String) -&gt; Unit)? = null, )</ID>
88 <ID>InstanceOfCheckForException:OpencodeApiClient.kt$t is ClientRequestException</ID>
99 <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
1010 <ID>MatchingDeclarationName:RemoteImage.kt$ImageLoadContext</ID>
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+4
@@ -104,6 +104,10 @@ object NetworkConfig {
104104 * visible before it silently kicks in but doesn't clutter a normal short prompt. */
105105 const val draftCountdownThresholdFraction = 0.8f
106106
107+ /** Maximum characters allowed in a session title (rename dialog). Prevents the
108+ * server from rejecting an overly long title and keeps list rows readable. */
109+ const val maxSessionTitleChars = 200
110+
107111 // --- Profile store (ServerEditViewModel) ---
108112
109113 /** Timeout for loading a profile from DataStore before giving up. */
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+16−1
@@ -9,7 +9,10 @@ import androidx.compose.foundation.layout.BoxWithConstraints
99import androidx.compose.runtime.Composable
1010import androidx.compose.runtime.LaunchedEffect
1111import androidx.compose.runtime.getValue
12+import androidx.compose.runtime.mutableStateOf
13+import androidx.compose.runtime.remember
1214import androidx.compose.runtime.rememberCoroutineScope
15+import androidx.compose.runtime.setValue
1316import androidx.compose.ui.unit.dp
1417import androidx.navigation.NavType
1518import androidx.navigation.compose.NavHost
@@ -43,9 +46,19 @@ fun OpencodeApp(container: AppContainer) {
4346 // (or create) a conversation to drop it into. The chosen session's draft is set
4447 // in [onOpenSession] below. Keyed on `connection` too so a share that arrives
4548 // before auto-reconnect completes is retried once the connection is established.
49+ // Tracks the last connection id seen so a server switch (a non-null -> non-null
50+ // change) doesn't re-bounce the user to the session list for a share they've
51+ // already decided to abandon; only the null -> non-null initial connect retries.
52+ var lastConnectionId by remember { mutableStateOf<String?>(null) }
4653 LaunchedEffect(pendingShare, connection) {
4754 if (pendingShare == null) return@LaunchedEffect
48− if (connection == null) return@LaunchedEffect
55+ if (connection == null) { lastConnectionId = null; return@LaunchedEffect }
56+ val currentId = connection?.profile?.id
57+ // Only (re)navigate on the initial null -> non-null connect. A subsequent
58+ // server switch while a share is pending (rare) leaves the user where they
59+ // are instead of yanking them back to the session list.
60+ if (lastConnectionId != null && lastConnectionId == currentId) return@LaunchedEffect
61+ lastConnectionId = currentId
4962 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
5063 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
5164 }
@@ -114,6 +127,7 @@ fun OpencodeApp(container: AppContainer) {
114127 TwoPaneSessionChat(
115128 container = container,
116129 onOpenFiles = { navController.navigate(Routes.FILES) },
130+ onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
117131 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
118132 onDisconnect = {
119133 scope.launch { runCatchingCancellable { container.disconnect() } }
@@ -153,6 +167,7 @@ fun OpencodeApp(container: AppContainer) {
153167 container = container,
154168 sessionId = entry.arguments?.getString("sessionId").orEmpty(),
155169 onBack = { navController.popBackStack() },
170+ onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
156171 )
157172 }
158173
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+12−4
@@ -65,6 +65,7 @@ import androidx.compose.runtime.setValue
6565import androidx.compose.runtime.snapshotFlow
6666import androidx.compose.foundation.text.KeyboardActions
6767import androidx.compose.foundation.text.KeyboardOptions
68+import androidx.compose.ui.text.input.KeyboardCapitalization
6869import androidx.compose.ui.Alignment
6970import androidx.compose.ui.Modifier
7071import androidx.compose.ui.focus.focusRequester
@@ -110,6 +111,7 @@ fun ChatScreen(
110111 container: AppContainer,
111112 sessionId: String,
112113 onBack: () -> Unit,
114+ onOpenFile: ((String) -> Unit)? = null,
113115) {
114116 val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
115117 val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
@@ -212,7 +214,9 @@ fun ChatScreen(
212214
213215 fun doSend() {
214216 if (vm.send(draft)) {
215− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
217+ // TextHandleMove (a light tick) is semantically right for a send commit;
218+ // LongPress is reserved for long-press/swipe gestures.
219+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
216220 }
217221 }
218222
@@ -529,6 +533,7 @@ fun ChatScreen(
529533 isRunning = running && message.info.id == lastMessageId,
530534 imageContext = imageContext,
531535 modelLabel = modelLabel,
536+ onOpenFile = onOpenFile,
532537 )
533538 }
534539 if (running) {
@@ -580,7 +585,7 @@ fun ChatScreen(
580585 confirmButton = {
581586 TextButton(onClick = {
582587 showExitConfirm = false
583− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
588+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
584589 vm.abort()
585590 onBack()
586591 }) { Text(stringResource(R.string.stop_and_exit)) }
@@ -599,7 +604,7 @@ fun ChatScreen(
599604 confirmButton = {
600605 TextButton(onClick = {
601606 showStopConfirm = false
602− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
607+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
603608 vm.abort()
604609 }) { Text(stringResource(R.string.stop), color = MaterialTheme.colorScheme.error) }
605610 },
@@ -750,7 +755,10 @@ private fun ChatInputBar(
750755 )
751756 }
752757 },
753− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
758+ keyboardOptions = KeyboardOptions(
759+ imeAction = ImeAction.Send,
760+ capitalization = KeyboardCapitalization.Sentences,
761+ ),
754762 keyboardActions = KeyboardActions(onSend = {
755763 if (enabled && value.isNotBlank()) {
756764 if (running) onQueueFollowUp(value) else onSend()
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+22−10
@@ -57,16 +57,22 @@ fun MessageBubble(
5757 isRunning: Boolean = false,
5858 imageContext: ImageLoadContext? = null,
5959 modelLabel: String? = null,
60+ onOpenFile: ((String) -> Unit)? = null,
6061) {
6162 when (message.info) {
62− is UserMessage -> UserBubble(message, imageContext, modifier)
63− is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier)
64− else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel)
63+ is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile)
64+ is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier, onOpenFile)
65+ else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel, onOpenFile)
6566 }
6667}
6768
6869@Composable
69−private fun UnknownMessageBlock(message: MessageWithParts, imageContext: ImageLoadContext?, modifier: Modifier) {
70+private fun UnknownMessageBlock(
71+ message: MessageWithParts,
72+ imageContext: ImageLoadContext?,
73+ modifier: Modifier,
74+ onOpenFile: ((String) -> Unit)? = null,
75+) {
7076 // Forward-compat: a role the client doesn't model. Render a muted note so the user
7177 // sees *something* rather than an unlabeled block, plus any parts (e.g. text) the
7278 // server attached, so content isn't silently dropped.
@@ -89,14 +95,19 @@ private fun UnknownMessageBlock(message: MessageWithParts, imageContext: ImageLo
8995 )
9096 }
9197 for (part in message.parts) {
92− PartView(part, imageContext = imageContext)
98+ PartView(part, imageContext = imageContext, onOpenFile = onOpenFile)
9399 }
94100 MessageTimestampText(message.info)
95101 }
96102}
97103
98104@Composable
99−private fun UserBubble(message: MessageWithParts, imageContext: ImageLoadContext?, modifier: Modifier) {
105+private fun UserBubble(
106+ message: MessageWithParts,
107+ imageContext: ImageLoadContext?,
108+ modifier: Modifier,
109+ onOpenFile: ((String) -> Unit)? = null,
110+) {
100111 val context = LocalContext.current
101112 val copyLabel = stringResource(R.string.copy)
102113 // Collect text from all TextParts for copying, so a user can reuse/repost their
@@ -116,7 +127,7 @@ private fun UserBubble(message: MessageWithParts, imageContext: ImageLoadContext
116127 .padding(12.dp),
117128 verticalArrangement = Arrangement.spacedBy(6.dp),
118129 ) {
119− for (part in message.parts) PartView(part, imageContext = imageContext)
130+ for (part in message.parts) PartView(part, imageContext = imageContext, onOpenFile = onOpenFile)
120131 Row(
121132 modifier = Modifier.fillMaxWidth(),
122133 horizontalArrangement = Arrangement.SpaceBetween,
@@ -133,7 +144,7 @@ private fun UserBubble(message: MessageWithParts, imageContext: ImageLoadContext
133144 Icon(
134145 Icons.Filled.ContentCopy,
135146 contentDescription = copyLabel,
136− modifier = Modifier.size(14.dp),
147+ modifier = Modifier.size(18.dp),
137148 tint = MaterialTheme.colorScheme.onSurfaceVariant,
138149 )
139150 }
@@ -150,6 +161,7 @@ private fun AssistantBlock(
150161 imageContext: ImageLoadContext?,
151162 modifier: Modifier,
152163 modelLabel: String? = null,
164+ onOpenFile: ((String) -> Unit)? = null,
153165) {
154166 Column(
155167 modifier = modifier.fillMaxWidth(),
@@ -167,7 +179,7 @@ private fun AssistantBlock(
167179 }
168180 }
169181 for (part in message.parts) {
170− PartView(part, isRunning = isRunning, modifier = Modifier.fillMaxWidth(), imageContext = imageContext)
182+ PartView(part, isRunning = isRunning, modifier = Modifier.fillMaxWidth(), imageContext = imageContext, onOpenFile = onOpenFile)
171183 }
172184 if (info is AssistantMessage) {
173185 val cost = info.cost
@@ -217,7 +229,7 @@ private fun AssistantBlock(
217229 Icon(
218230 Icons.Filled.ContentCopy,
219231 contentDescription = copyLabel,
220− modifier = Modifier.size(14.dp),
232+ modifier = Modifier.size(18.dp),
221233 tint = MaterialTheme.colorScheme.onSurfaceVariant,
222234 )
223235 }
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+67−23
@@ -3,13 +3,14 @@ package soy.iko.opencode.ui.chat
33import androidx.compose.animation.AnimatedVisibility
44import androidx.compose.foundation.background
55import androidx.compose.foundation.clickable
6+import androidx.compose.foundation.combinedClickable
67import androidx.compose.foundation.layout.Column
78import androidx.compose.foundation.layout.Row
89import androidx.compose.foundation.layout.defaultMinSize
910import androidx.compose.foundation.layout.fillMaxWidth
1011import androidx.compose.foundation.layout.padding
1112import androidx.compose.foundation.layout.size
12−import androidx.compose.foundation.shape.RoundedCornerShape
13+import androidx.compose.foundation.text.selection.SelectionContainer
1314import androidx.compose.material.icons.Icons
1415import androidx.compose.material.icons.automirrored.filled.HelpOutline
1516import androidx.compose.material.icons.filled.Bolt
@@ -93,9 +94,19 @@ private fun ToolState.inputJson(): JsonElement? = when (this) {
9394/**
9495 * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
9596 * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
97+ *
98+ * [onOpenFile] is invoked when the user taps a [FilePart] chip that references a
99+ * source path the viewer can open; null leaves the chip as a copy-path affordance
100+ * (used when no file navigation is wired, e.g. in tests).
96101 */
97102@Composable
98−fun PartView(part: Part, modifier: Modifier = Modifier, isRunning: Boolean = false, imageContext: ImageLoadContext? = null) {
103+fun PartView(
104+ part: Part,
105+ modifier: Modifier = Modifier,
106+ isRunning: Boolean = false,
107+ imageContext: ImageLoadContext? = null,
108+ onOpenFile: ((path: String) -> Unit)? = null,
109+) {
99110 when (part) {
100111 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
101112 MarkdownText(part.text, modifier = modifier, streaming = isRunning)
@@ -105,7 +116,7 @@ fun PartView(part: Part, modifier: Modifier = Modifier, isRunning: Boolean = fal
105116 is FilePart -> if (part.isImage && imageContext != null && (part.source != null || !part.url.isNullOrBlank())) {
106117 RemoteImage(part, imageContext, modifier)
107118 } else {
108− FileChip(part, modifier)
119+ FileChip(part, modifier, onOpenFile)
109120 }
110121 is StepStartPart -> {} // boundary marker — nothing to draw
111122 is StepFinishPart -> {} // metrics handled at message level
@@ -184,11 +195,16 @@ private fun ReasoningBlock(text: String, streaming: Boolean, modifier: Modifier)
184195 }
185196 AnimatedVisibility(visible = expanded) {
186197 Column {
187− Text(
188− text,
189− style = MaterialTheme.typography.bodySmall,
190− color = MaterialTheme.colorScheme.onSurfaceVariant,
191− )
198+ // Wrap in SelectionContainer so the user can select a portion of the
199+ // reasoning (e.g. a single step) instead of the all-or-nothing copy
200+ // button below. Matches the markdown text's selectability.
201+ SelectionContainer {
202+ Text(
203+ text,
204+ style = MaterialTheme.typography.bodySmall,
205+ color = MaterialTheme.colorScheme.onSurfaceVariant,
206+ )
207+ }
192208 TextButton(
193209 onClick = { copyToClipboard(context, "reasoning", text) },
194210 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
@@ -209,7 +225,7 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
209225 Column(
210226 modifier = modifier
211227 .fillMaxWidth()
212− .clip(RoundedCornerShape(8.dp))
228+ .clip(MaterialTheme.shapes.small)
213229 .background(MaterialTheme.colorScheme.surfaceVariant)
214230 .padding(10.dp),
215231 ) {
@@ -325,11 +341,15 @@ private fun CollapsibleDetail(
325341 if (isDiff) {
326342 DiffView(detail)
327343 } else {
328− Text(
329− detail,
330− style = MaterialTheme.typography.bodySmall,
331− fontFamily = FontFamily.Monospace,
332− )
344+ // SelectionContainer so the user can select a portion of the output (e.g.
345+ // a single line of stdout) instead of only copy-all via the button below.
346+ SelectionContainer {
347+ Text(
348+ detail,
349+ style = MaterialTheme.typography.bodySmall,
350+ fontFamily = FontFamily.Monospace,
351+ )
352+ }
333353 }
334354 if (onToggleExpand != null) {
335355 TextButton(
@@ -395,22 +415,46 @@ private fun ToolStatusIcon(state: ToolState) {
395415 }
396416}
397417
418+@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
398419@Composable
399−private fun FileChip(part: FilePart, modifier: Modifier) {
420+private fun FileChip(part: FilePart, modifier: Modifier, onOpenFile: ((String) -> Unit)?) {
400421 val context = LocalContext.current
401422 val path = part.source ?: part.url ?: part.filename
402423 val copyLabel = stringResource(R.string.copy_path)
424+ val openLabel = stringResource(R.string.open_file)
425+ // When a source path is available and a navigation callback is wired, tapping the
426+ // chip opens the file in the viewer (the action a user tapping a file reference
427+ // most likely expects). Long-press still copies the path to the clipboard, so the
428+ // copy affordance is preserved without being the default tap action. When no
429+ // navigation is wired (e.g. tests), falls back to copy-path on tap.
430+ val source = part.source
431+ val opener = onOpenFile
432+ val openPath: String? = if (opener != null && !source.isNullOrBlank()) source else null
433+ val canOpen = openPath != null
434+ val semanticsLabel = if (canOpen) openLabel else copyLabel
403435 Row(
404436 modifier = modifier
405− .clip(RoundedCornerShape(12.dp))
437+ .clip(MaterialTheme.shapes.small)
406438 .background(MaterialTheme.colorScheme.surfaceVariant)
407− .clickable(role = Role.Button) {
408− if (!path.isNullOrBlank()) {
409− copyToClipboard(context, "path", path)
410− showToast(context, context.getString(R.string.path_copied))
411− }
412− }
413− .semantics { contentDescription = copyLabel }
439+ .combinedClickable(
440+ role = Role.Button,
441+ onClick = {
442+ val toOpen = openPath
443+ if (toOpen != null) {
444+ opener?.invoke(toOpen)
445+ } else if (!path.isNullOrBlank()) {
446+ copyToClipboard(context, "path", path)
447+ showToast(context, context.getString(R.string.path_copied))
448+ }
449+ },
450+ onLongClick = {
451+ if (!path.isNullOrBlank()) {
452+ copyToClipboard(context, "path", path)
453+ showToast(context, context.getString(R.string.path_copied))
454+ }
455+ },
456+ )
457+ .semantics { contentDescription = semanticsLabel }
414458 .padding(horizontal = 12.dp, vertical = 6.dp),
415459 verticalAlignment = Alignment.CenterVertically,
416460 ) {
app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+1−2
@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.height
1111import androidx.compose.foundation.layout.padding
1212import androidx.compose.foundation.layout.width
1313import androidx.compose.foundation.rememberScrollState
14−import androidx.compose.foundation.shape.RoundedCornerShape
1514import androidx.compose.material.icons.Icons
1615import androidx.compose.material.icons.filled.ContentCopy
1716import androidx.compose.material3.Icon
@@ -116,7 +115,7 @@ fun DiffView(diff: String, modifier: Modifier = Modifier) {
116115 Column(
117116 modifier = modifier
118117 .fillMaxWidth()
119− .clip(RoundedCornerShape(8.dp))
118+ .clip(MaterialTheme.shapes.small)
120119 .background(MaterialTheme.colorScheme.surfaceVariant),
121120 ) {
122121 Row(
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+6−7
@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
1111import androidx.compose.foundation.layout.padding
1212import androidx.compose.foundation.layout.size
1313import androidx.compose.foundation.rememberScrollState
14−import androidx.compose.foundation.shape.RoundedCornerShape
1514import androidx.compose.foundation.text.selection.SelectionContainer
1615import androidx.compose.material.icons.Icons
1716import androidx.compose.material.icons.filled.ContentCopy
@@ -49,8 +48,7 @@ import soy.iko.opencode.data.network.NetworkConfig
4948/**
5049 * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
5150 * (commonmark-java under the hood). Keeps a local API so callers don't import the library directly.
52− * Long-press copies the raw markdown to the system clipboard. Code blocks/fences get an
53− * inline copy button.
51+ * Code blocks/fences get an inline copy button.
5452 *
5553 * The rendered text is wrapped in a [SelectionContainer] so the user can select and
5654 * copy a portion of the response (e.g. a single code snippet or paragraph) instead of
@@ -120,8 +118,9 @@ fun MarkdownText(
120118 }
121119 // SelectionContainer is intentionally omitted during streaming: the content is
122120 // changing every ~50ms, and an active selection would be invalidated (and the
123− // selection handles would flicker) on each throttle commit. Long-press still
124− // copies the full raw markdown via the onLongClick below.
121+ // selection handles would flicker) on each throttle commit. Partial selection
122+ // becomes available once the stream finishes; the per-message Copy button in
123+ // MessageBubble copies all TextParts at any time.
125124 Markdown(
126125 content = renderedContent,
127126 modifier = modifier,
@@ -156,7 +155,7 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
156155 Box(
157156 modifier = Modifier
158157 .fillMaxWidth()
159− .clip(RoundedCornerShape(8.dp))
158+ .clip(MaterialTheme.shapes.small)
160159 .background(MaterialTheme.colorScheme.surfaceVariant),
161160 ) {
162161 Text(
@@ -169,7 +168,7 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
169168 )
170169 Surface(
171170 color = MaterialTheme.colorScheme.surface,
172− shape = RoundedCornerShape(6.dp),
171+ shape = MaterialTheme.shapes.extraSmall,
173172 modifier = Modifier
174173 .align(Alignment.TopEnd)
175174 .padding(4.dp),
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+1−2
@@ -4,7 +4,6 @@ import android.util.Base64
44import androidx.compose.foundation.layout.Box
55import androidx.compose.foundation.layout.fillMaxWidth
66import androidx.compose.foundation.layout.heightIn
7−import androidx.compose.foundation.shape.RoundedCornerShape
87import androidx.compose.material3.MaterialTheme
98import androidx.compose.runtime.Composable
109import androidx.compose.runtime.Immutable
@@ -109,7 +108,7 @@ fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modi
109108 contentScale = ContentScale.FillWidth,
110109 modifier = modifier
111110 .heightIn(max = 320.dp)
112− .clip(RoundedCornerShape(12.dp)),
111+ .clip(MaterialTheme.shapes.small),
113112 loading = {
114113 Box(
115114 modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+22−6
@@ -128,6 +128,7 @@ fun FileViewScreen(
128128 loading = state.loading,
129129 content = rawText,
130130 wrap = wrap,
131+ showDiff = showDiff,
131132 onBack = onBack,
132133 onReload = { vm.reload() },
133134 onToggleFind = { findActive = !findActive; if (!findActive) findQuery = "" },
@@ -158,7 +159,14 @@ fun FileViewScreen(
158159 filename = filename,
159160 showToggle = showToggle,
160161 showDiff = showDiff,
161− onSetShowDiff = { showDiff = it },
162+ onSetShowDiff = {
163+ showDiff = it
164+ // Close any open find bar when entering diff mode: find searches
165+ // the raw content and scrolls the line LazyColumn, neither of
166+ // which is visible in diff mode, so leaving it open would be a
167+ // confusing no-op.
168+ if (it) { findActive = false; findQuery = "" }
169+ },
162170 findActive = findActive,
163171 findQuery = findQuery,
164172 onQueryChange = { findQuery = it; matchPos = 0 },
@@ -183,6 +191,7 @@ private fun FileViewTopBar(
183191 loading: Boolean,
184192 content: String,
185193 wrap: Boolean,
194+ showDiff: Boolean,
186195 onBack: () -> Unit,
187196 onReload: () -> Unit,
188197 onToggleFind: () -> Unit,
@@ -204,8 +213,11 @@ private fun FileViewTopBar(
204213 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
205214 }
206215 if (content.isNotEmpty()) {
207− // Find-in-file: toggles a search bar over the raw content.
208− IconButton(onClick = onToggleFind) {
216+ // Find-in-file: toggles a search bar over the raw content. Disabled in
217+ // diff mode — the find bar searches the raw text and scrolls the line
218+ // LazyColumn, neither of which is visible when the DiffView is shown, so
219+ // find would silently no-op. The user can switch to Raw to find.
220+ IconButton(onClick = onToggleFind, enabled = !showDiff) {
209221 Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file))
210222 }
211223 // Wrap long lines instead of horizontal-scrolling, useful for prose.
@@ -310,7 +322,7 @@ private fun BoxScope.FileViewContentBody(
310322 // height (not a hardcoded 52dp/row) so accessibility font scaling — which can
311323 // make the FilterChip labels and FindBar text field taller than 52dp — doesn't
312324 // cause the first lines of content to hide behind the overlay.
313− val overlayRows = (if (showToggle) 1 else 0) + (if (findActive) 1 else 0)
325+ val overlayRows = (if (showToggle) 1 else 0) + (if (findActive && !showDiff) 1 else 0)
314326 var overlayHeightPx by remember { mutableIntStateOf(0) }
315327 val topInset = with(androidx.compose.ui.platform.LocalDensity.current) { overlayHeightPx.toDp() }
316328 if (overlayRows > 0) {
@@ -340,7 +352,7 @@ private fun BoxScope.FileViewContentBody(
340352 )
341353 }
342354 }
343− if (findActive) {
355+ if (findActive && !showDiff) {
344356 FindBar(
345357 query = findQuery,
346358 onQueryChange = onQueryChange,
@@ -412,10 +424,14 @@ private fun FindBar(
412424 else if (matchCount == 0) stringResource(R.string.no_matches_in_file)
413425 else stringResource(R.string.match_count, matchPos + 1, matchCount)
414426 if (countText.isNotEmpty()) {
427+ // Use error color for "no matches" so the zero-result state is
428+ // distinguishable from a positive match count at a glance.
429+ val color = if (query.isNotBlank() && matchCount == 0) MaterialTheme.colorScheme.error
430+ else MaterialTheme.colorScheme.onSurfaceVariant
415431 Text(
416432 countText,
417433 style = MaterialTheme.typography.labelSmall,
418− color = MaterialTheme.colorScheme.onSurfaceVariant,
434+ color = color,
419435 )
420436 }
421437 },
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+12−3
@@ -104,13 +104,15 @@ fun ServerListScreen(
104104
105105 // Undo snackbar: when a server is marked for deferred deletion, offer Undo. If the
106106 // action is taken before the delay expires, the profile is kept and the delete
107− // never fires — mirrors the session list's undo UX for consistency.
107+ // never fires — mirrors the session list's undo UX for consistency. Use Long
108+ // (~10s) so the Undo button outlasts the undoServerDeleteDelayMs window (5s);
109+ // Short (~1.5s) vanished before the delete fired, leaving no way to cancel.
108110 LaunchedEffect(Unit) {
109111 vm.undoEvents.collect { profileId ->
110112 val result = snackbar.showSnackbar(
111113 message = serverRemovedLabel,
112114 actionLabel = undoLabel,
113− duration = SnackbarDuration.Short,
115+ duration = SnackbarDuration.Long,
114116 )
115117 if (result == SnackbarResult.ActionPerformed) {
116118 vm.undoDelete(profileId)
@@ -207,13 +209,20 @@ fun ServerListScreen(
207209 modifier = Modifier.size(22.dp),
208210 )
209211 // Keep edit available on the active server so credentials can be
210− // fixed without disconnecting first. Only delete is hidden.
212+ // fixed without disconnecting first. Delete is also offered: the
213+ // confirmation dialog warns that removing the active server also
214+ // disconnects (remove_server_active_text), and the ViewModel
215+ // disconnects immediately on confirm. Swipe remains disabled for
216+ // the active row so an accidental swipe can't disconnect.
211217 IconButton(onClick = { onEditProfile(profile.id) }) {
212218 Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))
213219 }
214220 IconButton(onClick = { onDuplicateProfile(profile.id) }) {
215221 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.duplicate_server))
216222 }
223+ IconButton(onClick = { pendingDeleteId = profile.id }) {
224+ Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
225+ }
217226 }
218227 }
219228 } else {
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+6−3
@@ -79,6 +79,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
7979import androidx.lifecycle.viewmodel.compose.viewModel
8080import soy.iko.opencode.data.model.ServerProfile
8181import soy.iko.opencode.data.model.Session
82+import soy.iko.opencode.data.network.NetworkConfig
8283import soy.iko.opencode.di.AppContainer
8384import soy.iko.opencode.R
8485import soy.iko.opencode.ui.components.ConnectionBanner
@@ -137,13 +138,15 @@ fun SessionListScreen(
137138
138139 // Undo snackbar: when a session is marked for deferred deletion, offer Undo. If the
139140 // action is taken before the delay expires, the session is restored and the REST
140− // delete never fires.
141+ // delete never fires. Use Long (~10s) so the Undo button stays available for the
142+ // full undoDeleteDelayMs window (5s); Short (~1.5s) vanished before the delete
143+ // fired, leaving the user unable to cancel during the last 3.5s.
141144 LaunchedEffect(Unit) {
142145 vm.undoEvents.collect { sessionId ->
143146 val result = snackbar.showSnackbar(
144147 message = sessionDeletedLabel,
145148 actionLabel = undoLabel,
146− duration = androidx.compose.material3.SnackbarDuration.Short,
149+ duration = androidx.compose.material3.SnackbarDuration.Long,
147150 )
148151 if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) {
149152 vm.undoDelete(sessionId)
@@ -465,7 +468,7 @@ private fun RenameSessionDialog(
465468 text = {
466469 OutlinedTextField(
467470 value = title,
468− onValueChange = onTitleChange,
471+ onValueChange = { v -> onTitleChange(v.take(NetworkConfig.maxSessionTitleChars)) },
469472 modifier = Modifier.fillMaxWidth(),
470473 placeholder = { Text(stringResource(R.string.session_title_hint)) },
471474 label = { Text(stringResource(R.string.rename_session)) },
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+2
@@ -44,6 +44,7 @@ import soy.iko.opencode.ui.chat.ChatScreen
4444fun TwoPaneSessionChat(
4545 container: AppContainer,
4646 onOpenFiles: () -> Unit,
47+ onOpenFile: (String) -> Unit,
4748 onOpenSettings: () -> Unit,
4849 onDisconnect: () -> Unit,
4950 onAddServer: () -> Unit,
@@ -116,6 +117,7 @@ fun TwoPaneSessionChat(
116117 container = container,
117118 sessionId = sessionId,
118119 onBack = { selected = null },
120+ onOpenFile = onOpenFile,
119121 )
120122 }
121123 }
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+8−3
@@ -98,6 +98,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
9898 // (saveable) instead of a plain Set so it round-trips through the state registry.
9999 val deferredDeletes = rememberSaveable { androidx.compose.runtime.mutableStateListOf<String>() }
100100 val shareLabel = stringResource(R.string.share)
101+ val shareSubject = stringResource(R.string.crash_report_share_subject)
101102 val timeTick = rememberRelativeTimeTick()
102103
103104 Scaffold(
@@ -205,7 +206,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
205206 }
206207 val send = Intent(Intent.ACTION_SEND).apply {
207208 type = "text/plain"
208− putExtra(Intent.EXTRA_SUBJECT, shareLabel)
209+ putExtra(Intent.EXTRA_SUBJECT, shareSubject)
209210 putExtra(Intent.EXTRA_TEXT, content)
210211 }
211212 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
@@ -248,7 +249,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
248249 TextButton(onClick = {
249250 val send = Intent(Intent.ACTION_SEND).apply {
250251 type = "text/plain"
251− putExtra(Intent.EXTRA_SUBJECT, shareLabel)
252+ putExtra(Intent.EXTRA_SUBJECT, shareSubject)
252253 putExtra(Intent.EXTRA_TEXT, content.orEmpty())
253254 }
254255 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
@@ -321,7 +322,11 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
321322 val result = snackbar.showSnackbar(
322323 message = reportDeletedLabel,
323324 actionLabel = undoLabel,
324− duration = SnackbarDuration.Short,
325+ // Long (~10s) outlasts the undoReportDeleteDelayMs window
326+ // (5s) so the Undo button remains available for the whole
327+ // undo period; Short (~1.5s) vanished before the delete
328+ // fired, leaving no way to cancel during the last 3.5s.
329+ duration = SnackbarDuration.Long,
325330 )
326331 if (result == SnackbarResult.ActionPerformed) {
327332 deferredDeletes.remove(name)
app/src/main/res/values/strings.xml+2
@@ -218,6 +218,7 @@
218218 <string name="crash_count_format">%1$d</string>
219219 <string name="crash_count_overflow">99+</string>
220220 <string name="report_deleted">Crash report deleted</string>
221+ <string name="crash_report_share_subject">opencode crash report</string>
221222
222223 <!-- Notifications -->
223224 <string name="notif_channel_status">Agent activity</string>
@@ -233,6 +234,7 @@
233234 <string name="tool_input">Input</string>
234235 <string name="copy_path">Copy path</string>
235236 <string name="path_copied">Path copied</string>
237+ <string name="open_file">Open file</string>
236238 <string name="unknown_message">Unknown message type</string>
237239 <string name="unknown_part">Unsupported content</string>
238240 <string name="tool_preview">🔧 %1$s</string>