git.iko.soy/opencode-android
on master help sign in
UX/UI: selectable message text, disabled input during loading, tool status colors, inline error banner

Bug fixes: restore draft on failed send, skip emit for synthetic text parts, cancel duplicate reconnect jobs

commitb9f3596fc1e492225eebb697464677e7aba92877 authoriko <mail@iko.soy> dateWed 8 Jul 2026, 21:57 (3 months ago) parent3c09796
3 files changed+103−22
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+26−6 app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+36−14 app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+41−2
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+26−6
@@ -1,6 +1,7 @@
11package soy.iko.opencode.core
22
33import kotlinx.coroutines.CoroutineScope
4+import kotlinx.coroutines.Job
45import kotlinx.coroutines.delay
56import kotlinx.coroutines.flow.MutableStateFlow
67import kotlinx.coroutines.flow.StateFlow
@@ -48,6 +49,7 @@ class OpencodeCore(
4849 private var sse: SseClient? = null
4950 private var sseClosedIntentionally = false
5051 private var reconnectAttempts = 0
52+ private var reconnectJob: Job? = null
5153
5254 // ── Event entry point ──────────────────────────────────────────────────────
5355 fun dispatch(event: Event) {
@@ -175,6 +177,9 @@ class OpencodeCore(
175177 textParts["local"] = text
176178 }
177179 messages.add(user)
180+ // Keep the draft text so we can restore it if the send fails; otherwise the
181+ // user's message is lost and they'd have to retype it.
182+ val savedDraft = draft
178183 draft = ""
179184 generating = true
180185 error = null
@@ -191,6 +196,7 @@ class OpencodeCore(
191196 messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Failed
192197 generating = false
193198 messages.forEach { it.streaming = false }
199+ draft = savedDraft
194200 error = "Server returned status ${resp.code}"
195201 }
196202 },
@@ -198,6 +204,7 @@ class OpencodeCore(
198204 messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Failed
199205 generating = false
200206 messages.forEach { it.streaming = false }
207+ draft = savedDraft
201208 error = "Request failed: ${e.message}"
202209 },
203210 )
@@ -326,6 +333,8 @@ class OpencodeCore(
326333
327334 private fun closeSse() {
328335 sseClosedIntentionally = true
336+ reconnectJob?.cancel()
337+ reconnectJob = null
329338 sse?.close()
330339 sse = null
331340 }
@@ -336,7 +345,10 @@ class OpencodeCore(
336345 // stream reconnects (onStateChange(true)) so a healthy server starts fresh.
337346 val delayMs = (2000L * (1L shl reconnectAttempts)).coerceAtMost(30_000L)
338347 if (delayMs < 30_000L) reconnectAttempts += 1
339− scope.launch {
348+ // Cancel any pending reconnect so rapid network flapping can't stack multiple
349+ // coroutines that each open a new SSE connection.
350+ reconnectJob?.cancel()
351+ reconnectJob = scope.launch {
340352 delay(delayMs)
341353 if (!sseClosedIntentionally && connected && screen != Screen.Connect) startSse()
342354 }
@@ -370,16 +382,24 @@ class OpencodeCore(
370382 is Protocol.Event.PartUpdated -> {
371383 val part = event.part
372384 val msg = messages.firstOrNull { it.id == part.messageId } ?: return false
373− when (part) {
374− is Protocol.Part.Text -> if (!part.synthetic) {
385+ // Synthetic text parts are intentionally skipped; report no change so we
386+ // don't trigger a needless re-render/emit for them.
387+ return when (part) {
388+ is Protocol.Part.Text -> if (part.synthetic) {
389+ false
390+ } else {
375391 msg.textParts[part.id] = pickText(part.text, event.delta, msg.textParts[part.id])
392+ true
376393 }
377− is Protocol.Part.Reasoning ->
394+ is Protocol.Part.Reasoning -> {
378395 msg.reasoningParts[part.id] = pickText(part.text, event.delta, msg.reasoningParts[part.id])
379− is Protocol.Part.Tool ->
396+ true
397+ }
398+ is Protocol.Part.Tool -> {
380399 msg.toolParts[part.id] = ToolView(part.tool, part.status, part.title)
400+ true
401+ }
381402 }
382− return true
383403 }
384404
385405 is Protocol.Event.PartRemoved -> {
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+36−14
@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
1515import androidx.compose.foundation.layout.padding
1616import androidx.compose.foundation.layout.widthIn
1717import androidx.compose.foundation.shape.RoundedCornerShape
18+import androidx.compose.foundation.text.selection.SelectionContainer
1819import androidx.compose.material.icons.Icons
1920import androidx.compose.material.icons.filled.Build
2021import androidx.compose.material.icons.filled.Check
@@ -35,7 +36,10 @@ import androidx.compose.ui.Modifier
3536import androidx.compose.ui.draw.clip
3637import androidx.compose.ui.graphics.Color
3738import androidx.compose.ui.platform.LocalContext
39+import androidx.compose.ui.text.SpanStyle
40+import androidx.compose.ui.text.buildAnnotatedString
3841import androidx.compose.ui.text.font.FontStyle
42+import androidx.compose.ui.text.withStyle
3943import androidx.compose.ui.text.style.TextOverflow
4044import androidx.compose.ui.unit.dp
4145import soy.iko.opencode.core.MessageStatus
@@ -86,14 +90,16 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
8690
8791 message.tools.forEach { ToolRow(it) }
8892
89− if (message.text.isNotEmpty()) {
90− Text(
91− text = message.text + if (message.streaming) " ▌" else "",
92− color = textColor,
93− style = MaterialTheme.typography.bodyLarge,
94− )
95− } else if (message.streaming && message.tools.isEmpty() && message.reasoning == null) {
96− Text("▌", color = textColor, style = MaterialTheme.typography.bodyLarge)
93+ SelectionContainer {
94+ if (message.text.isNotEmpty()) {
95+ Text(
96+ text = message.text + if (message.streaming) " ▌" else "",
97+ color = textColor,
98+ style = MaterialTheme.typography.bodyLarge,
99+ )
100+ } else if (message.streaming && message.tools.isEmpty() && message.reasoning == null) {
101+ Text("▌", color = textColor, style = MaterialTheme.typography.bodyLarge)
102+ }
97103 }
98104
99105 if (message.time > 0) {
@@ -162,6 +168,13 @@ private fun ReasoningBlock(text: String, context: Context) {
162168@Composable
163169private fun ToolRow(tool: ToolView) {
164170 var expanded by remember { mutableStateOf(false) }
171+ val baseColor = MaterialTheme.colorScheme.onSurfaceVariant
172+ val statusColor = when (tool.status) {
173+ "completed", "success" -> MaterialTheme.colorScheme.primary
174+ "error", "failed" -> MaterialTheme.colorScheme.error
175+ "pending", "running", "in_progress" -> MaterialTheme.colorScheme.onSurfaceVariant
176+ else -> MaterialTheme.colorScheme.onSurfaceVariant
177+ }
165178 Row(
166179 verticalAlignment = Alignment.CenterVertically,
167180 modifier = Modifier
@@ -176,14 +189,23 @@ private fun ToolRow(tool: ToolView) {
176189 modifier = Modifier.padding(end = 6.dp).widthIn(max = 16.dp),
177190 )
178191 Text(
179− text = buildString {
180− append(tool.name)
181− append(" · ")
182− append(tool.status)
183− if (expanded) tool.title?.let { append(" — "); append(it) }
192+ text = buildAnnotatedString {
193+ withStyle(SpanStyle(color = baseColor)) {
194+ append(tool.name)
195+ append(" · ")
196+ }
197+ withStyle(SpanStyle(color = statusColor)) {
198+ append(tool.status)
199+ }
200+ if (expanded) tool.title?.let {
201+ withStyle(SpanStyle(color = baseColor)) {
202+ append(" — ")
203+ append(it)
204+ }
205+ }
184206 },
185207 style = MonoStyle,
186− color = MaterialTheme.colorScheme.onSurfaceVariant,
208+ color = baseColor,
187209 maxLines = if (expanded) Int.MAX_VALUE else 1,
188210 overflow = TextOverflow.Ellipsis,
189211 modifier = Modifier.weight(1f),
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+41−2
@@ -27,6 +27,7 @@ import androidx.compose.foundation.text.KeyboardOptions
2727import androidx.compose.material.icons.Icons
2828import androidx.compose.material.icons.automirrored.filled.ArrowBack
2929import androidx.compose.material.icons.automirrored.filled.Send
30+import androidx.compose.material.icons.filled.Close
3031import androidx.compose.material.icons.filled.KeyboardArrowDown
3132import androidx.compose.material.icons.filled.Stop
3233import androidx.compose.material3.CircularProgressIndicator
@@ -136,6 +137,7 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
136137 ChatInputBar(
137138 draft = state.draft,
138139 generating = state.generating,
140+ loading = state.loading && state.messages.isEmpty(),
139141 onDraftChange = { dispatch(Event.DraftChanged(it)) },
140142 onSend = { dispatch(Event.SendMessage(state.draft)) },
141143 onStop = { dispatch(Event.CancelGeneration) },
@@ -149,6 +151,12 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
149151 if (state.loading && state.messages.isEmpty()) {
150152 LinearProgressIndicator(Modifier.fillMaxWidth())
151153 }
154+ if (state.error != null && state.messages.isNotEmpty()) {
155+ ErrorBanner(
156+ message = state.error,
157+ onDismiss = { dispatch(Event.DismissError) },
158+ )
159+ }
152160 Box(Modifier.fillMaxWidth().weight(1f)) {
153161 if (state.messages.isEmpty() && !state.loading) {
154162 Text(
@@ -242,10 +250,40 @@ private fun ReconnectingBanner() {
242250 }
243251}
244252
253+@Composable
254+private fun ErrorBanner(message: String, onDismiss: () -> Unit) {
255+ Surface(
256+ modifier = Modifier.fillMaxWidth(),
257+ color = MaterialTheme.colorScheme.errorContainer,
258+ ) {
259+ Row(
260+ modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 6.dp, bottom = 6.dp, end = 4.dp),
261+ verticalAlignment = Alignment.CenterVertically,
262+ horizontalArrangement = Arrangement.spacedBy(8.dp),
263+ ) {
264+ Text(
265+ text = message,
266+ style = MaterialTheme.typography.bodySmall,
267+ color = MaterialTheme.colorScheme.onErrorContainer,
268+ modifier = Modifier.weight(1f),
269+ )
270+ IconButton(onClick = onDismiss, modifier = Modifier.size(32.dp)) {
271+ Icon(
272+ Icons.Filled.Close,
273+ contentDescription = "Dismiss error",
274+ tint = MaterialTheme.colorScheme.onErrorContainer,
275+ modifier = Modifier.size(18.dp),
276+ )
277+ }
278+ }
279+ }
280+}
281+
245282@Composable
246283private fun ChatInputBar(
247284 draft: String,
248285 generating: Boolean,
286+ loading: Boolean,
249287 onDraftChange: (String) -> Unit,
250288 onSend: () -> Unit,
251289 onStop: () -> Unit,
@@ -261,11 +299,12 @@ private fun ChatInputBar(
261299 value = draft,
262300 onValueChange = onDraftChange,
263301 modifier = Modifier.weight(1f),
302+ enabled = !loading,
264303 placeholder = { Text("Message opencode…") },
265304 maxLines = 5,
266305 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
267306 keyboardActions = KeyboardActions(onSend = {
268− if (!generating && draft.isNotBlank()) onSend()
307+ if (!generating && !loading && draft.isNotBlank()) onSend()
269308 }),
270309 )
271310 if (generating) {
@@ -278,7 +317,7 @@ private fun ChatInputBar(
278317 haptic.performHapticFeedback(HapticFeedbackType.LongPress)
279318 onSend()
280319 },
281− enabled = draft.isNotBlank(),
320+ enabled = draft.isNotBlank() && !loading,
282321 ) {
283322 Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
284323 }