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)
1e779bbiko
app/detekt-baseline.xml+1−1
@@ -4,7 +4,7 @@
4 <CurrentIssues>
4 <CurrentIssues>
5 <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>
5 <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>
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>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>
8 <ID>InstanceOfCheckForException:OpencodeApiClient.kt$t is ClientRequestException</ID>
8 <ID>InstanceOfCheckForException:OpencodeApiClient.kt$t is ClientRequestException</ID>
9 <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
9 <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
10 <ID>MatchingDeclarationName:RemoteImage.kt$ImageLoadContext</ID>
10 <ID>MatchingDeclarationName:RemoteImage.kt$ImageLoadContext</ID>
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+4
@@ -104,6 +104,10 @@ object NetworkConfig {
104 * visible before it silently kicks in but doesn't clutter a normal short prompt. */
104 * visible before it silently kicks in but doesn't clutter a normal short prompt. */
105 const val draftCountdownThresholdFraction = 0.8f
105 const val draftCountdownThresholdFraction = 0.8f
106
106
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+
107 // --- Profile store (ServerEditViewModel) ---
111 // --- Profile store (ServerEditViewModel) ---
108
112
109 /** Timeout for loading a profile from DataStore before giving up. */
113 /** 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
9import androidx.compose.runtime.Composable
9import androidx.compose.runtime.Composable
10import androidx.compose.runtime.LaunchedEffect
10import androidx.compose.runtime.LaunchedEffect
11import androidx.compose.runtime.getValue
11import androidx.compose.runtime.getValue
12+import androidx.compose.runtime.mutableStateOf
13+import androidx.compose.runtime.remember
12import androidx.compose.runtime.rememberCoroutineScope
14import androidx.compose.runtime.rememberCoroutineScope
15+import androidx.compose.runtime.setValue
13import androidx.compose.ui.unit.dp
16import androidx.compose.ui.unit.dp
14import androidx.navigation.NavType
17import androidx.navigation.NavType
15import androidx.navigation.compose.NavHost
18import androidx.navigation.compose.NavHost
@@ -43,9 +46,19 @@ fun OpencodeApp(container: AppContainer) {
43 // (or create) a conversation to drop it into. The chosen session's draft is set
46 // (or create) a conversation to drop it into. The chosen session's draft is set
44 // in [onOpenSession] below. Keyed on `connection` too so a share that arrives
47 // in [onOpenSession] below. Keyed on `connection` too so a share that arrives
45 // before auto-reconnect completes is retried once the connection is established.
48 // 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) }
46 LaunchedEffect(pendingShare, connection) {
53 LaunchedEffect(pendingShare, connection) {
47 if (pendingShare == null) return@LaunchedEffect
54 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
49 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
62 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
50 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
63 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
51 }
64 }
@@ -114,6 +127,7 @@ fun OpencodeApp(container: AppContainer) {
114 TwoPaneSessionChat(
127 TwoPaneSessionChat(
115 container = container,
128 container = container,
116 onOpenFiles = { navController.navigate(Routes.FILES) },
129 onOpenFiles = { navController.navigate(Routes.FILES) },
130+ onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
117 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
131 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
118 onDisconnect = {
132 onDisconnect = {
119 scope.launch { runCatchingCancellable { container.disconnect() } }
133 scope.launch { runCatchingCancellable { container.disconnect() } }
@@ -153,6 +167,7 @@ fun OpencodeApp(container: AppContainer) {
153 container = container,
167 container = container,
154 sessionId = entry.arguments?.getString("sessionId").orEmpty(),
168 sessionId = entry.arguments?.getString("sessionId").orEmpty(),
155 onBack = { navController.popBackStack() },
169 onBack = { navController.popBackStack() },
170+ onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
156 )
171 )
157 }
172 }
158
173
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+12−4
@@ -65,6 +65,7 @@ import androidx.compose.runtime.setValue
65import androidx.compose.runtime.snapshotFlow
65import androidx.compose.runtime.snapshotFlow
66import androidx.compose.foundation.text.KeyboardActions
66import androidx.compose.foundation.text.KeyboardActions
67import androidx.compose.foundation.text.KeyboardOptions
67import androidx.compose.foundation.text.KeyboardOptions
68+import androidx.compose.ui.text.input.KeyboardCapitalization
68import androidx.compose.ui.Alignment
69import androidx.compose.ui.Alignment
69import androidx.compose.ui.Modifier
70import androidx.compose.ui.Modifier
70import androidx.compose.ui.focus.focusRequester
71import androidx.compose.ui.focus.focusRequester
@@ -110,6 +111,7 @@ fun ChatScreen(
110 container: AppContainer,
111 container: AppContainer,
111 sessionId: String,
112 sessionId: String,
112 onBack: () -> Unit,
113 onBack: () -> Unit,
114+ onOpenFile: ((String) -> Unit)? = null,
113) {
115) {
114 val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
116 val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
115 val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
117 val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
@@ -212,7 +214,9 @@ fun ChatScreen(
212
214
213 fun doSend() {
215 fun doSend() {
214 if (vm.send(draft)) {
216 if (vm.send(draft)) {
217+ // TextHandleMove (a light tick) is semantically right for a send commit;
218+ // LongPress is reserved for long-press/swipe gestures.
215− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
219+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
216 }
220 }
217 }
221 }
218
222
@@ -529,6 +533,7 @@ fun ChatScreen(
529 isRunning = running && message.info.id == lastMessageId,
533 isRunning = running && message.info.id == lastMessageId,
530 imageContext = imageContext,
534 imageContext = imageContext,
531 modelLabel = modelLabel,
535 modelLabel = modelLabel,
536+ onOpenFile = onOpenFile,
532 )
537 )
533 }
538 }
534 if (running) {
539 if (running) {
@@ -580,7 +585,7 @@ fun ChatScreen(
580 confirmButton = {
585 confirmButton = {
581 TextButton(onClick = {
586 TextButton(onClick = {
582 showExitConfirm = false
587 showExitConfirm = false
583− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
588+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
584 vm.abort()
589 vm.abort()
585 onBack()
590 onBack()
586 }) { Text(stringResource(R.string.stop_and_exit)) }
591 }) { Text(stringResource(R.string.stop_and_exit)) }
@@ -599,7 +604,7 @@ fun ChatScreen(
599 confirmButton = {
604 confirmButton = {
600 TextButton(onClick = {
605 TextButton(onClick = {
601 showStopConfirm = false
606 showStopConfirm = false
602− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
607+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
603 vm.abort()
608 vm.abort()
604 }) { Text(stringResource(R.string.stop), color = MaterialTheme.colorScheme.error) }
609 }) { Text(stringResource(R.string.stop), color = MaterialTheme.colorScheme.error) }
605 },
610 },
@@ -750,7 +755,10 @@ private fun ChatInputBar(
750 )
755 )
751 }
756 }
752 },
757 },
753− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
758+ keyboardOptions = KeyboardOptions(
759+ imeAction = ImeAction.Send,
760+ capitalization = KeyboardCapitalization.Sentences,
761+ ),
754 keyboardActions = KeyboardActions(onSend = {
762 keyboardActions = KeyboardActions(onSend = {
755 if (enabled && value.isNotBlank()) {
763 if (enabled && value.isNotBlank()) {
756 if (running) onQueueFollowUp(value) else onSend()
764 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(
57 isRunning: Boolean = false,
57 isRunning: Boolean = false,
58 imageContext: ImageLoadContext? = null,
58 imageContext: ImageLoadContext? = null,
59 modelLabel: String? = null,
59 modelLabel: String? = null,
60+ onOpenFile: ((String) -> Unit)? = null,
60) {
61) {
61 when (message.info) {
62 when (message.info) {
62− is UserMessage -> UserBubble(message, imageContext, modifier)
63+ is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile)
63− is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier)
64+ is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier, onOpenFile)
64− else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel)
65+ else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel, onOpenFile)
65 }
66 }
66}
67}
67
68
68@Composable
69@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+) {
70 // Forward-compat: a role the client doesn't model. Render a muted note so the user
76 // Forward-compat: a role the client doesn't model. Render a muted note so the user
71 // sees *something* rather than an unlabeled block, plus any parts (e.g. text) the
77 // sees *something* rather than an unlabeled block, plus any parts (e.g. text) the
72 // server attached, so content isn't silently dropped.
78 // server attached, so content isn't silently dropped.
@@ -89,14 +95,19 @@ private fun UnknownMessageBlock(message: MessageWithParts, imageContext: ImageLo
89 )
95 )
90 }
96 }
91 for (part in message.parts) {
97 for (part in message.parts) {
92− PartView(part, imageContext = imageContext)
98+ PartView(part, imageContext = imageContext, onOpenFile = onOpenFile)
93 }
99 }
94 MessageTimestampText(message.info)
100 MessageTimestampText(message.info)
95 }
101 }
96}
102}
97
103
98@Composable
104@Composable
105+private fun UserBubble(
106+ message: MessageWithParts,
99−private fun UserBubble(message: MessageWithParts, imageContext: ImageLoadContext?, modifier: Modifier) {
107+ imageContext: ImageLoadContext?,
108+ modifier: Modifier,
109+ onOpenFile: ((String) -> Unit)? = null,
110+) {
100 val context = LocalContext.current
111 val context = LocalContext.current
101 val copyLabel = stringResource(R.string.copy)
112 val copyLabel = stringResource(R.string.copy)
102 // Collect text from all TextParts for copying, so a user can reuse/repost their
113 // 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
116 .padding(12.dp),
127 .padding(12.dp),
117 verticalArrangement = Arrangement.spacedBy(6.dp),
128 verticalArrangement = Arrangement.spacedBy(6.dp),
118 ) {
129 ) {
119− for (part in message.parts) PartView(part, imageContext = imageContext)
130+ for (part in message.parts) PartView(part, imageContext = imageContext, onOpenFile = onOpenFile)
120 Row(
131 Row(
121 modifier = Modifier.fillMaxWidth(),
132 modifier = Modifier.fillMaxWidth(),
122 horizontalArrangement = Arrangement.SpaceBetween,
133 horizontalArrangement = Arrangement.SpaceBetween,
@@ -133,7 +144,7 @@ private fun UserBubble(message: MessageWithParts, imageContext: ImageLoadContext
133 Icon(
144 Icon(
134 Icons.Filled.ContentCopy,
145 Icons.Filled.ContentCopy,
135 contentDescription = copyLabel,
146 contentDescription = copyLabel,
136− modifier = Modifier.size(14.dp),
147+ modifier = Modifier.size(18.dp),
137 tint = MaterialTheme.colorScheme.onSurfaceVariant,
148 tint = MaterialTheme.colorScheme.onSurfaceVariant,
138 )
149 )
139 }
150 }
@@ -150,6 +161,7 @@ private fun AssistantBlock(
150 imageContext: ImageLoadContext?,
161 imageContext: ImageLoadContext?,
151 modifier: Modifier,
162 modifier: Modifier,
152 modelLabel: String? = null,
163 modelLabel: String? = null,
164+ onOpenFile: ((String) -> Unit)? = null,
153) {
165) {
154 Column(
166 Column(
155 modifier = modifier.fillMaxWidth(),
167 modifier = modifier.fillMaxWidth(),
@@ -167,7 +179,7 @@ private fun AssistantBlock(
167 }
179 }
168 }
180 }
169 for (part in message.parts) {
181 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)
171 }
183 }
172 if (info is AssistantMessage) {
184 if (info is AssistantMessage) {
173 val cost = info.cost
185 val cost = info.cost
@@ -217,7 +229,7 @@ private fun AssistantBlock(
217 Icon(
229 Icon(
218 Icons.Filled.ContentCopy,
230 Icons.Filled.ContentCopy,
219 contentDescription = copyLabel,
231 contentDescription = copyLabel,
220− modifier = Modifier.size(14.dp),
232+ modifier = Modifier.size(18.dp),
221 tint = MaterialTheme.colorScheme.onSurfaceVariant,
233 tint = MaterialTheme.colorScheme.onSurfaceVariant,
222 )
234 )
223 }
235 }
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+67−23
@@ -3,13 +3,14 @@ package soy.iko.opencode.ui.chat
3import androidx.compose.animation.AnimatedVisibility
3import androidx.compose.animation.AnimatedVisibility
4import androidx.compose.foundation.background
4import androidx.compose.foundation.background
5import androidx.compose.foundation.clickable
5import androidx.compose.foundation.clickable
6+import androidx.compose.foundation.combinedClickable
6import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Row
8import androidx.compose.foundation.layout.Row
8import androidx.compose.foundation.layout.defaultMinSize
9import androidx.compose.foundation.layout.defaultMinSize
9import androidx.compose.foundation.layout.fillMaxWidth
10import androidx.compose.foundation.layout.fillMaxWidth
10import androidx.compose.foundation.layout.padding
11import androidx.compose.foundation.layout.padding
11import androidx.compose.foundation.layout.size
12import androidx.compose.foundation.layout.size
12−import androidx.compose.foundation.shape.RoundedCornerShape
13+import androidx.compose.foundation.text.selection.SelectionContainer
13import androidx.compose.material.icons.Icons
14import androidx.compose.material.icons.Icons
14import androidx.compose.material.icons.automirrored.filled.HelpOutline
15import androidx.compose.material.icons.automirrored.filled.HelpOutline
15import androidx.compose.material.icons.filled.Bolt
16import androidx.compose.material.icons.filled.Bolt
@@ -93,9 +94,19 @@ private fun ToolState.inputJson(): JsonElement? = when (this) {
93/**
94/**
94 * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
95 * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
95 * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
96 * 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).
96 */
101 */
97@Composable
102@Composable
103+fun PartView(
104+ part: Part,
105+ modifier: Modifier = Modifier,
106+ isRunning: Boolean = false,
98−fun PartView(part: Part, modifier: Modifier = Modifier, isRunning: Boolean = false, imageContext: ImageLoadContext? = null) {
107+ imageContext: ImageLoadContext? = null,
108+ onOpenFile: ((path: String) -> Unit)? = null,
109+) {
99 when (part) {
110 when (part) {
100 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
111 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
101 MarkdownText(part.text, modifier = modifier, streaming = isRunning)
112 MarkdownText(part.text, modifier = modifier, streaming = isRunning)
@@ -105,7 +116,7 @@ fun PartView(part: Part, modifier: Modifier = Modifier, isRunning: Boolean = fal
105 is FilePart -> if (part.isImage && imageContext != null && (part.source != null || !part.url.isNullOrBlank())) {
116 is FilePart -> if (part.isImage && imageContext != null && (part.source != null || !part.url.isNullOrBlank())) {
106 RemoteImage(part, imageContext, modifier)
117 RemoteImage(part, imageContext, modifier)
107 } else {
118 } else {
108− FileChip(part, modifier)
119+ FileChip(part, modifier, onOpenFile)
109 }
120 }
110 is StepStartPart -> {} // boundary marker — nothing to draw
121 is StepStartPart -> {} // boundary marker — nothing to draw
111 is StepFinishPart -> {} // metrics handled at message level
122 is StepFinishPart -> {} // metrics handled at message level
@@ -184,11 +195,16 @@ private fun ReasoningBlock(text: String, streaming: Boolean, modifier: Modifier)
184 }
195 }
185 AnimatedVisibility(visible = expanded) {
196 AnimatedVisibility(visible = expanded) {
186 Column {
197 Column {
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 {
187− Text(
202+ Text(
188− text,
203+ text,
189− style = MaterialTheme.typography.bodySmall,
204+ style = MaterialTheme.typography.bodySmall,
190− color = MaterialTheme.colorScheme.onSurfaceVariant,
205+ color = MaterialTheme.colorScheme.onSurfaceVariant,
191− )
206+ )
207+ }
192 TextButton(
208 TextButton(
193 onClick = { copyToClipboard(context, "reasoning", text) },
209 onClick = { copyToClipboard(context, "reasoning", text) },
194 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
210 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
@@ -209,7 +225,7 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
209 Column(
225 Column(
210 modifier = modifier
226 modifier = modifier
211 .fillMaxWidth()
227 .fillMaxWidth()
212− .clip(RoundedCornerShape(8.dp))
228+ .clip(MaterialTheme.shapes.small)
213 .background(MaterialTheme.colorScheme.surfaceVariant)
229 .background(MaterialTheme.colorScheme.surfaceVariant)
214 .padding(10.dp),
230 .padding(10.dp),
215 ) {
231 ) {
@@ -325,11 +341,15 @@ private fun CollapsibleDetail(
325 if (isDiff) {
341 if (isDiff) {
326 DiffView(detail)
342 DiffView(detail)
327 } else {
343 } else {
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 {
328− Text(
347+ Text(
329− detail,
348+ detail,
330− style = MaterialTheme.typography.bodySmall,
349+ style = MaterialTheme.typography.bodySmall,
331− fontFamily = FontFamily.Monospace,
350+ fontFamily = FontFamily.Monospace,
332− )
351+ )
352+ }
333 }
353 }
334 if (onToggleExpand != null) {
354 if (onToggleExpand != null) {
335 TextButton(
355 TextButton(
@@ -395,22 +415,46 @@ private fun ToolStatusIcon(state: ToolState) {
395 }
415 }
396}
416}
397
417
418+@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
398@Composable
419@Composable
399−private fun FileChip(part: FilePart, modifier: Modifier) {
420+private fun FileChip(part: FilePart, modifier: Modifier, onOpenFile: ((String) -> Unit)?) {
400 val context = LocalContext.current
421 val context = LocalContext.current
401 val path = part.source ?: part.url ?: part.filename
422 val path = part.source ?: part.url ?: part.filename
402 val copyLabel = stringResource(R.string.copy_path)
423 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
403 Row(
435 Row(
404 modifier = modifier
436 modifier = modifier
405− .clip(RoundedCornerShape(12.dp))
437+ .clip(MaterialTheme.shapes.small)
406 .background(MaterialTheme.colorScheme.surfaceVariant)
438 .background(MaterialTheme.colorScheme.surfaceVariant)
439+ .combinedClickable(
407− .clickable(role = Role.Button) {
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 = {
408− if (!path.isNullOrBlank()) {
451+ if (!path.isNullOrBlank()) {
409− copyToClipboard(context, "path", path)
452+ copyToClipboard(context, "path", path)
410− showToast(context, context.getString(R.string.path_copied))
453+ showToast(context, context.getString(R.string.path_copied))
454+ }
411− }
455+ },
412− }
456+ )
413− .semantics { contentDescription = copyLabel }
457+ .semantics { contentDescription = semanticsLabel }
414 .padding(horizontal = 12.dp, vertical = 6.dp),
458 .padding(horizontal = 12.dp, vertical = 6.dp),
415 verticalAlignment = Alignment.CenterVertically,
459 verticalAlignment = Alignment.CenterVertically,
416 ) {
460 ) {
app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+1−2
@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.height
11import androidx.compose.foundation.layout.padding
11import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.width
12import androidx.compose.foundation.layout.width
13import androidx.compose.foundation.rememberScrollState
13import androidx.compose.foundation.rememberScrollState
14−import androidx.compose.foundation.shape.RoundedCornerShape
15import androidx.compose.material.icons.Icons
14import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.filled.ContentCopy
15import androidx.compose.material.icons.filled.ContentCopy
17import androidx.compose.material3.Icon
16import androidx.compose.material3.Icon
@@ -116,7 +115,7 @@ fun DiffView(diff: String, modifier: Modifier = Modifier) {
116 Column(
115 Column(
117 modifier = modifier
116 modifier = modifier
118 .fillMaxWidth()
117 .fillMaxWidth()
119− .clip(RoundedCornerShape(8.dp))
118+ .clip(MaterialTheme.shapes.small)
120 .background(MaterialTheme.colorScheme.surfaceVariant),
119 .background(MaterialTheme.colorScheme.surfaceVariant),
121 ) {
120 ) {
122 Row(
121 Row(
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+6−7
@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
11import androidx.compose.foundation.layout.padding
11import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.size
12import androidx.compose.foundation.layout.size
13import androidx.compose.foundation.rememberScrollState
13import androidx.compose.foundation.rememberScrollState
14−import androidx.compose.foundation.shape.RoundedCornerShape
15import androidx.compose.foundation.text.selection.SelectionContainer
14import androidx.compose.foundation.text.selection.SelectionContainer
16import androidx.compose.material.icons.Icons
15import androidx.compose.material.icons.Icons
17import androidx.compose.material.icons.filled.ContentCopy
16import androidx.compose.material.icons.filled.ContentCopy
@@ -49,8 +48,7 @@ import soy.iko.opencode.data.network.NetworkConfig
49/**
48/**
50 * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
49 * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
51 * (commonmark-java under the hood). Keeps a local API so callers don't import the library directly.
50 * (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.
54 *
52 *
55 * The rendered text is wrapped in a [SelectionContainer] so the user can select and
53 * The rendered text is wrapped in a [SelectionContainer] so the user can select and
56 * copy a portion of the response (e.g. a single code snippet or paragraph) instead of
54 * copy a portion of the response (e.g. a single code snippet or paragraph) instead of
@@ -120,8 +118,9 @@ fun MarkdownText(
120 }
118 }
121 // SelectionContainer is intentionally omitted during streaming: the content is
119 // SelectionContainer is intentionally omitted during streaming: the content is
122 // changing every ~50ms, and an active selection would be invalidated (and the
120 // changing every ~50ms, and an active selection would be invalidated (and the
123− // selection handles would flicker) on each throttle commit. Long-press still
121+ // selection handles would flicker) on each throttle commit. Partial selection
124− // copies the full raw markdown via the onLongClick below.
122+ // becomes available once the stream finishes; the per-message Copy button in
123+ // MessageBubble copies all TextParts at any time.
125 Markdown(
124 Markdown(
126 content = renderedContent,
125 content = renderedContent,
127 modifier = modifier,
126 modifier = modifier,
@@ -156,7 +155,7 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
156 Box(
155 Box(
157 modifier = Modifier
156 modifier = Modifier
158 .fillMaxWidth()
157 .fillMaxWidth()
159− .clip(RoundedCornerShape(8.dp))
158+ .clip(MaterialTheme.shapes.small)
160 .background(MaterialTheme.colorScheme.surfaceVariant),
159 .background(MaterialTheme.colorScheme.surfaceVariant),
161 ) {
160 ) {
162 Text(
161 Text(
@@ -169,7 +168,7 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
169 )
168 )
170 Surface(
169 Surface(
171 color = MaterialTheme.colorScheme.surface,
170 color = MaterialTheme.colorScheme.surface,
172− shape = RoundedCornerShape(6.dp),
171+ shape = MaterialTheme.shapes.extraSmall,
173 modifier = Modifier
172 modifier = Modifier
174 .align(Alignment.TopEnd)
173 .align(Alignment.TopEnd)
175 .padding(4.dp),
174 .padding(4.dp),
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+1−2
@@ -4,7 +4,6 @@ import android.util.Base64
4import androidx.compose.foundation.layout.Box
4import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.fillMaxWidth
5import androidx.compose.foundation.layout.fillMaxWidth
6import androidx.compose.foundation.layout.heightIn
6import androidx.compose.foundation.layout.heightIn
7−import androidx.compose.foundation.shape.RoundedCornerShape
8import androidx.compose.material3.MaterialTheme
7import androidx.compose.material3.MaterialTheme
9import androidx.compose.runtime.Composable
8import androidx.compose.runtime.Composable
10import androidx.compose.runtime.Immutable
9import androidx.compose.runtime.Immutable
@@ -109,7 +108,7 @@ fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modi
109 contentScale = ContentScale.FillWidth,
108 contentScale = ContentScale.FillWidth,
110 modifier = modifier
109 modifier = modifier
111 .heightIn(max = 320.dp)
110 .heightIn(max = 320.dp)
112− .clip(RoundedCornerShape(12.dp)),
111+ .clip(MaterialTheme.shapes.small),
113 loading = {
112 loading = {
114 Box(
113 Box(
115 modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
114 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(
128 loading = state.loading,
128 loading = state.loading,
129 content = rawText,
129 content = rawText,
130 wrap = wrap,
130 wrap = wrap,
131+ showDiff = showDiff,
131 onBack = onBack,
132 onBack = onBack,
132 onReload = { vm.reload() },
133 onReload = { vm.reload() },
133 onToggleFind = { findActive = !findActive; if (!findActive) findQuery = "" },
134 onToggleFind = { findActive = !findActive; if (!findActive) findQuery = "" },
@@ -158,7 +159,14 @@ fun FileViewScreen(
158 filename = filename,
159 filename = filename,
159 showToggle = showToggle,
160 showToggle = showToggle,
160 showDiff = showDiff,
161 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+ },
162 findActive = findActive,
170 findActive = findActive,
163 findQuery = findQuery,
171 findQuery = findQuery,
164 onQueryChange = { findQuery = it; matchPos = 0 },
172 onQueryChange = { findQuery = it; matchPos = 0 },
@@ -183,6 +191,7 @@ private fun FileViewTopBar(
183 loading: Boolean,
191 loading: Boolean,
184 content: String,
192 content: String,
185 wrap: Boolean,
193 wrap: Boolean,
194+ showDiff: Boolean,
186 onBack: () -> Unit,
195 onBack: () -> Unit,
187 onReload: () -> Unit,
196 onReload: () -> Unit,
188 onToggleFind: () -> Unit,
197 onToggleFind: () -> Unit,
@@ -204,8 +213,11 @@ private fun FileViewTopBar(
204 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
213 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
205 }
214 }
206 if (content.isNotEmpty()) {
215 if (content.isNotEmpty()) {
207− // Find-in-file: toggles a search bar over the raw content.
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.
208− IconButton(onClick = onToggleFind) {
220+ IconButton(onClick = onToggleFind, enabled = !showDiff) {
209 Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file))
221 Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file))
210 }
222 }
211 // Wrap long lines instead of horizontal-scrolling, useful for prose.
223 // Wrap long lines instead of horizontal-scrolling, useful for prose.
@@ -310,7 +322,7 @@ private fun BoxScope.FileViewContentBody(
310 // height (not a hardcoded 52dp/row) so accessibility font scaling — which can
322 // height (not a hardcoded 52dp/row) so accessibility font scaling — which can
311 // make the FilterChip labels and FindBar text field taller than 52dp — doesn't
323 // make the FilterChip labels and FindBar text field taller than 52dp — doesn't
312 // cause the first lines of content to hide behind the overlay.
324 // 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)
314 var overlayHeightPx by remember { mutableIntStateOf(0) }
326 var overlayHeightPx by remember { mutableIntStateOf(0) }
315 val topInset = with(androidx.compose.ui.platform.LocalDensity.current) { overlayHeightPx.toDp() }
327 val topInset = with(androidx.compose.ui.platform.LocalDensity.current) { overlayHeightPx.toDp() }
316 if (overlayRows > 0) {
328 if (overlayRows > 0) {
@@ -340,7 +352,7 @@ private fun BoxScope.FileViewContentBody(
340 )
352 )
341 }
353 }
342 }
354 }
343− if (findActive) {
355+ if (findActive && !showDiff) {
344 FindBar(
356 FindBar(
345 query = findQuery,
357 query = findQuery,
346 onQueryChange = onQueryChange,
358 onQueryChange = onQueryChange,
@@ -412,10 +424,14 @@ private fun FindBar(
412 else if (matchCount == 0) stringResource(R.string.no_matches_in_file)
424 else if (matchCount == 0) stringResource(R.string.no_matches_in_file)
413 else stringResource(R.string.match_count, matchPos + 1, matchCount)
425 else stringResource(R.string.match_count, matchPos + 1, matchCount)
414 if (countText.isNotEmpty()) {
426 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
415 Text(
431 Text(
416 countText,
432 countText,
417 style = MaterialTheme.typography.labelSmall,
433 style = MaterialTheme.typography.labelSmall,
418− color = MaterialTheme.colorScheme.onSurfaceVariant,
434+ color = color,
419 )
435 )
420 }
436 }
421 },
437 },
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+12−3
@@ -104,13 +104,15 @@ fun ServerListScreen(
104
104
105 // Undo snackbar: when a server is marked for deferred deletion, offer Undo. If the
105 // Undo snackbar: when a server is marked for deferred deletion, offer Undo. If the
106 // action is taken before the delay expires, the profile is kept and the delete
106 // 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.
108 LaunchedEffect(Unit) {
110 LaunchedEffect(Unit) {
109 vm.undoEvents.collect { profileId ->
111 vm.undoEvents.collect { profileId ->
110 val result = snackbar.showSnackbar(
112 val result = snackbar.showSnackbar(
111 message = serverRemovedLabel,
113 message = serverRemovedLabel,
112 actionLabel = undoLabel,
114 actionLabel = undoLabel,
113− duration = SnackbarDuration.Short,
115+ duration = SnackbarDuration.Long,
114 )
116 )
115 if (result == SnackbarResult.ActionPerformed) {
117 if (result == SnackbarResult.ActionPerformed) {
116 vm.undoDelete(profileId)
118 vm.undoDelete(profileId)
@@ -207,13 +209,20 @@ fun ServerListScreen(
207 modifier = Modifier.size(22.dp),
209 modifier = Modifier.size(22.dp),
208 )
210 )
209 // Keep edit available on the active server so credentials can be
211 // 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.
211 IconButton(onClick = { onEditProfile(profile.id) }) {
217 IconButton(onClick = { onEditProfile(profile.id) }) {
212 Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))
218 Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))
213 }
219 }
214 IconButton(onClick = { onDuplicateProfile(profile.id) }) {
220 IconButton(onClick = { onDuplicateProfile(profile.id) }) {
215 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.duplicate_server))
221 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.duplicate_server))
216 }
222 }
223+ IconButton(onClick = { pendingDeleteId = profile.id }) {
224+ Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
225+ }
217 }
226 }
218 }
227 }
219 } else {
228 } else {
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+6−3
@@ -79,6 +79,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
79import androidx.lifecycle.viewmodel.compose.viewModel
79import androidx.lifecycle.viewmodel.compose.viewModel
80import soy.iko.opencode.data.model.ServerProfile
80import soy.iko.opencode.data.model.ServerProfile
81import soy.iko.opencode.data.model.Session
81import soy.iko.opencode.data.model.Session
82+import soy.iko.opencode.data.network.NetworkConfig
82import soy.iko.opencode.di.AppContainer
83import soy.iko.opencode.di.AppContainer
83import soy.iko.opencode.R
84import soy.iko.opencode.R
84import soy.iko.opencode.ui.components.ConnectionBanner
85import soy.iko.opencode.ui.components.ConnectionBanner
@@ -137,13 +138,15 @@ fun SessionListScreen(
137
138
138 // Undo snackbar: when a session is marked for deferred deletion, offer Undo. If the
139 // Undo snackbar: when a session is marked for deferred deletion, offer Undo. If the
139 // action is taken before the delay expires, the session is restored and the REST
140 // 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.
141 LaunchedEffect(Unit) {
144 LaunchedEffect(Unit) {
142 vm.undoEvents.collect { sessionId ->
145 vm.undoEvents.collect { sessionId ->
143 val result = snackbar.showSnackbar(
146 val result = snackbar.showSnackbar(
144 message = sessionDeletedLabel,
147 message = sessionDeletedLabel,
145 actionLabel = undoLabel,
148 actionLabel = undoLabel,
146− duration = androidx.compose.material3.SnackbarDuration.Short,
149+ duration = androidx.compose.material3.SnackbarDuration.Long,
147 )
150 )
148 if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) {
151 if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) {
149 vm.undoDelete(sessionId)
152 vm.undoDelete(sessionId)
@@ -465,7 +468,7 @@ private fun RenameSessionDialog(
465 text = {
468 text = {
466 OutlinedTextField(
469 OutlinedTextField(
467 value = title,
470 value = title,
468− onValueChange = onTitleChange,
471+ onValueChange = { v -> onTitleChange(v.take(NetworkConfig.maxSessionTitleChars)) },
469 modifier = Modifier.fillMaxWidth(),
472 modifier = Modifier.fillMaxWidth(),
470 placeholder = { Text(stringResource(R.string.session_title_hint)) },
473 placeholder = { Text(stringResource(R.string.session_title_hint)) },
471 label = { Text(stringResource(R.string.rename_session)) },
474 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
44fun TwoPaneSessionChat(
44fun TwoPaneSessionChat(
45 container: AppContainer,
45 container: AppContainer,
46 onOpenFiles: () -> Unit,
46 onOpenFiles: () -> Unit,
47+ onOpenFile: (String) -> Unit,
47 onOpenSettings: () -> Unit,
48 onOpenSettings: () -> Unit,
48 onDisconnect: () -> Unit,
49 onDisconnect: () -> Unit,
49 onAddServer: () -> Unit,
50 onAddServer: () -> Unit,
@@ -116,6 +117,7 @@ fun TwoPaneSessionChat(
116 container = container,
117 container = container,
117 sessionId = sessionId,
118 sessionId = sessionId,
118 onBack = { selected = null },
119 onBack = { selected = null },
120+ onOpenFile = onOpenFile,
119 )
121 )
120 }
122 }
121 }
123 }
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+8−3
@@ -98,6 +98,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
98 // (saveable) instead of a plain Set so it round-trips through the state registry.
98 // (saveable) instead of a plain Set so it round-trips through the state registry.
99 val deferredDeletes = rememberSaveable { androidx.compose.runtime.mutableStateListOf<String>() }
99 val deferredDeletes = rememberSaveable { androidx.compose.runtime.mutableStateListOf<String>() }
100 val shareLabel = stringResource(R.string.share)
100 val shareLabel = stringResource(R.string.share)
101+ val shareSubject = stringResource(R.string.crash_report_share_subject)
101 val timeTick = rememberRelativeTimeTick()
102 val timeTick = rememberRelativeTimeTick()
102
103
103 Scaffold(
104 Scaffold(
@@ -205,7 +206,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
205 }
206 }
206 val send = Intent(Intent.ACTION_SEND).apply {
207 val send = Intent(Intent.ACTION_SEND).apply {
207 type = "text/plain"
208 type = "text/plain"
208− putExtra(Intent.EXTRA_SUBJECT, shareLabel)
209+ putExtra(Intent.EXTRA_SUBJECT, shareSubject)
209 putExtra(Intent.EXTRA_TEXT, content)
210 putExtra(Intent.EXTRA_TEXT, content)
210 }
211 }
211 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
212 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
@@ -248,7 +249,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
248 TextButton(onClick = {
249 TextButton(onClick = {
249 val send = Intent(Intent.ACTION_SEND).apply {
250 val send = Intent(Intent.ACTION_SEND).apply {
250 type = "text/plain"
251 type = "text/plain"
251− putExtra(Intent.EXTRA_SUBJECT, shareLabel)
252+ putExtra(Intent.EXTRA_SUBJECT, shareSubject)
252 putExtra(Intent.EXTRA_TEXT, content.orEmpty())
253 putExtra(Intent.EXTRA_TEXT, content.orEmpty())
253 }
254 }
254 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
255 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
@@ -321,7 +322,11 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
321 val result = snackbar.showSnackbar(
322 val result = snackbar.showSnackbar(
322 message = reportDeletedLabel,
323 message = reportDeletedLabel,
323 actionLabel = undoLabel,
324 actionLabel = undoLabel,
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.
324− duration = SnackbarDuration.Short,
329+ duration = SnackbarDuration.Long,
325 )
330 )
326 if (result == SnackbarResult.ActionPerformed) {
331 if (result == SnackbarResult.ActionPerformed) {
327 deferredDeletes.remove(name)
332 deferredDeletes.remove(name)
app/src/main/res/values/strings.xml+2
@@ -218,6 +218,7 @@
218 <string name="crash_count_format">%1$d</string>
218 <string name="crash_count_format">%1$d</string>
219 <string name="crash_count_overflow">99+</string>
219 <string name="crash_count_overflow">99+</string>
220 <string name="report_deleted">Crash report deleted</string>
220 <string name="report_deleted">Crash report deleted</string>
221+ <string name="crash_report_share_subject">opencode crash report</string>
221
222
222 <!-- Notifications -->
223 <!-- Notifications -->
223 <string name="notif_channel_status">Agent activity</string>
224 <string name="notif_channel_status">Agent activity</string>
@@ -233,6 +234,7 @@
233 <string name="tool_input">Input</string>
234 <string name="tool_input">Input</string>
234 <string name="copy_path">Copy path</string>
235 <string name="copy_path">Copy path</string>
235 <string name="path_copied">Path copied</string>
236 <string name="path_copied">Path copied</string>
237+ <string name="open_file">Open file</string>
236 <string name="unknown_message">Unknown message type</string>
238 <string name="unknown_message">Unknown message type</string>
237 <string name="unknown_part">Unsupported content</string>
239 <string name="unknown_part">Unsupported content</string>
238 <string name="tool_preview">🔧 %1$s</string>
240 <string name="tool_preview">🔧 %1$s</string>