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
b9f3596iko
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+26−6
@@ -1,6 +1,7 @@
1package soy.iko.opencode.core
1package soy.iko.opencode.core
2
2
3import kotlinx.coroutines.CoroutineScope
3import kotlinx.coroutines.CoroutineScope
4+import kotlinx.coroutines.Job
4import kotlinx.coroutines.delay
5import kotlinx.coroutines.delay
5import kotlinx.coroutines.flow.MutableStateFlow
6import kotlinx.coroutines.flow.MutableStateFlow
6import kotlinx.coroutines.flow.StateFlow
7import kotlinx.coroutines.flow.StateFlow
@@ -48,6 +49,7 @@ class OpencodeCore(
48 private var sse: SseClient? = null
49 private var sse: SseClient? = null
49 private var sseClosedIntentionally = false
50 private var sseClosedIntentionally = false
50 private var reconnectAttempts = 0
51 private var reconnectAttempts = 0
52+ private var reconnectJob: Job? = null
51
53
52 // ── Event entry point ──────────────────────────────────────────────────────
54 // ── Event entry point ──────────────────────────────────────────────────────
53 fun dispatch(event: Event) {
55 fun dispatch(event: Event) {
@@ -175,6 +177,9 @@ class OpencodeCore(
175 textParts["local"] = text
177 textParts["local"] = text
176 }
178 }
177 messages.add(user)
179 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
178 draft = ""
183 draft = ""
179 generating = true
184 generating = true
180 error = null
185 error = null
@@ -191,6 +196,7 @@ class OpencodeCore(
191 messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Failed
196 messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Failed
192 generating = false
197 generating = false
193 messages.forEach { it.streaming = false }
198 messages.forEach { it.streaming = false }
199+ draft = savedDraft
194 error = "Server returned status ${resp.code}"
200 error = "Server returned status ${resp.code}"
195 }
201 }
196 },
202 },
@@ -198,6 +204,7 @@ class OpencodeCore(
198 messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Failed
204 messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Failed
199 generating = false
205 generating = false
200 messages.forEach { it.streaming = false }
206 messages.forEach { it.streaming = false }
207+ draft = savedDraft
201 error = "Request failed: ${e.message}"
208 error = "Request failed: ${e.message}"
202 },
209 },
203 )
210 )
@@ -326,6 +333,8 @@ class OpencodeCore(
326
333
327 private fun closeSse() {
334 private fun closeSse() {
328 sseClosedIntentionally = true
335 sseClosedIntentionally = true
336+ reconnectJob?.cancel()
337+ reconnectJob = null
329 sse?.close()
338 sse?.close()
330 sse = null
339 sse = null
331 }
340 }
@@ -336,7 +345,10 @@ class OpencodeCore(
336 // stream reconnects (onStateChange(true)) so a healthy server starts fresh.
345 // stream reconnects (onStateChange(true)) so a healthy server starts fresh.
337 val delayMs = (2000L * (1L shl reconnectAttempts)).coerceAtMost(30_000L)
346 val delayMs = (2000L * (1L shl reconnectAttempts)).coerceAtMost(30_000L)
338 if (delayMs < 30_000L) reconnectAttempts += 1
347 if (delayMs < 30_000L) reconnectAttempts += 1
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()
339− scope.launch {
351+ reconnectJob = scope.launch {
340 delay(delayMs)
352 delay(delayMs)
341 if (!sseClosedIntentionally && connected && screen != Screen.Connect) startSse()
353 if (!sseClosedIntentionally && connected && screen != Screen.Connect) startSse()
342 }
354 }
@@ -370,16 +382,24 @@ class OpencodeCore(
370 is Protocol.Event.PartUpdated -> {
382 is Protocol.Event.PartUpdated -> {
371 val part = event.part
383 val part = event.part
372 val msg = messages.firstOrNull { it.id == part.messageId } ?: return false
384 val msg = messages.firstOrNull { it.id == part.messageId } ?: return false
385+ // Synthetic text parts are intentionally skipped; report no change so we
386+ // don't trigger a needless re-render/emit for them.
373− when (part) {
387+ return when (part) {
374− is Protocol.Part.Text -> if (!part.synthetic) {
388+ is Protocol.Part.Text -> if (part.synthetic) {
389+ false
390+ } else {
375 msg.textParts[part.id] = pickText(part.text, event.delta, msg.textParts[part.id])
391 msg.textParts[part.id] = pickText(part.text, event.delta, msg.textParts[part.id])
392+ true
376 }
393 }
377− is Protocol.Part.Reasoning ->
394+ is Protocol.Part.Reasoning -> {
378 msg.reasoningParts[part.id] = pickText(part.text, event.delta, msg.reasoningParts[part.id])
395 msg.reasoningParts[part.id] = pickText(part.text, event.delta, msg.reasoningParts[part.id])
396+ true
397+ }
379− is Protocol.Part.Tool ->
398+ is Protocol.Part.Tool -> {
380 msg.toolParts[part.id] = ToolView(part.tool, part.status, part.title)
399 msg.toolParts[part.id] = ToolView(part.tool, part.status, part.title)
400+ true
401+ }
381 }
402 }
382− return true
383 }
403 }
384
404
385 is Protocol.Event.PartRemoved -> {
405 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
15import androidx.compose.foundation.layout.padding
15import androidx.compose.foundation.layout.padding
16import androidx.compose.foundation.layout.widthIn
16import androidx.compose.foundation.layout.widthIn
17import androidx.compose.foundation.shape.RoundedCornerShape
17import androidx.compose.foundation.shape.RoundedCornerShape
18+import androidx.compose.foundation.text.selection.SelectionContainer
18import androidx.compose.material.icons.Icons
19import androidx.compose.material.icons.Icons
19import androidx.compose.material.icons.filled.Build
20import androidx.compose.material.icons.filled.Build
20import androidx.compose.material.icons.filled.Check
21import androidx.compose.material.icons.filled.Check
@@ -35,7 +36,10 @@ import androidx.compose.ui.Modifier
35import androidx.compose.ui.draw.clip
36import androidx.compose.ui.draw.clip
36import androidx.compose.ui.graphics.Color
37import androidx.compose.ui.graphics.Color
37import androidx.compose.ui.platform.LocalContext
38import androidx.compose.ui.platform.LocalContext
39+import androidx.compose.ui.text.SpanStyle
40+import androidx.compose.ui.text.buildAnnotatedString
38import androidx.compose.ui.text.font.FontStyle
41import androidx.compose.ui.text.font.FontStyle
42+import androidx.compose.ui.text.withStyle
39import androidx.compose.ui.text.style.TextOverflow
43import androidx.compose.ui.text.style.TextOverflow
40import androidx.compose.ui.unit.dp
44import androidx.compose.ui.unit.dp
41import soy.iko.opencode.core.MessageStatus
45import soy.iko.opencode.core.MessageStatus
@@ -86,14 +90,16 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
86
90
87 message.tools.forEach { ToolRow(it) }
91 message.tools.forEach { ToolRow(it) }
88
92
93+ SelectionContainer {
89− if (message.text.isNotEmpty()) {
94+ if (message.text.isNotEmpty()) {
90− Text(
95+ Text(
91− text = message.text + if (message.streaming) " ▌" else "",
96+ text = message.text + if (message.streaming) " ▌" else "",
92− color = textColor,
97+ color = textColor,
93− style = MaterialTheme.typography.bodyLarge,
98+ style = MaterialTheme.typography.bodyLarge,
94− )
99+ )
95− } else if (message.streaming && message.tools.isEmpty() && message.reasoning == null) {
100+ } else if (message.streaming && message.tools.isEmpty() && message.reasoning == null) {
96− Text("▌", color = textColor, style = MaterialTheme.typography.bodyLarge)
101+ Text("▌", color = textColor, style = MaterialTheme.typography.bodyLarge)
102+ }
97 }
103 }
98
104
99 if (message.time > 0) {
105 if (message.time > 0) {
@@ -162,6 +168,13 @@ private fun ReasoningBlock(text: String, context: Context) {
162@Composable
168@Composable
163private fun ToolRow(tool: ToolView) {
169private fun ToolRow(tool: ToolView) {
164 var expanded by remember { mutableStateOf(false) }
170 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+ }
165 Row(
178 Row(
166 verticalAlignment = Alignment.CenterVertically,
179 verticalAlignment = Alignment.CenterVertically,
167 modifier = Modifier
180 modifier = Modifier
@@ -176,14 +189,23 @@ private fun ToolRow(tool: ToolView) {
176 modifier = Modifier.padding(end = 6.dp).widthIn(max = 16.dp),
189 modifier = Modifier.padding(end = 6.dp).widthIn(max = 16.dp),
177 )
190 )
178 Text(
191 Text(
179− text = buildString {
192+ text = buildAnnotatedString {
193+ withStyle(SpanStyle(color = baseColor)) {
180− append(tool.name)
194+ append(tool.name)
181− append(" · ")
195+ append(" · ")
196+ }
197+ withStyle(SpanStyle(color = statusColor)) {
182− append(tool.status)
198+ append(tool.status)
199+ }
183− if (expanded) tool.title?.let { append(" — "); append(it) }
200+ if (expanded) tool.title?.let {
201+ withStyle(SpanStyle(color = baseColor)) {
202+ append(" — ")
203+ append(it)
204+ }
205+ }
184 },
206 },
185 style = MonoStyle,
207 style = MonoStyle,
186− color = MaterialTheme.colorScheme.onSurfaceVariant,
208+ color = baseColor,
187 maxLines = if (expanded) Int.MAX_VALUE else 1,
209 maxLines = if (expanded) Int.MAX_VALUE else 1,
188 overflow = TextOverflow.Ellipsis,
210 overflow = TextOverflow.Ellipsis,
189 modifier = Modifier.weight(1f),
211 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
27import androidx.compose.material.icons.Icons
27import androidx.compose.material.icons.Icons
28import androidx.compose.material.icons.automirrored.filled.ArrowBack
28import androidx.compose.material.icons.automirrored.filled.ArrowBack
29import androidx.compose.material.icons.automirrored.filled.Send
29import androidx.compose.material.icons.automirrored.filled.Send
30+import androidx.compose.material.icons.filled.Close
30import androidx.compose.material.icons.filled.KeyboardArrowDown
31import androidx.compose.material.icons.filled.KeyboardArrowDown
31import androidx.compose.material.icons.filled.Stop
32import androidx.compose.material.icons.filled.Stop
32import androidx.compose.material3.CircularProgressIndicator
33import androidx.compose.material3.CircularProgressIndicator
@@ -136,6 +137,7 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
136 ChatInputBar(
137 ChatInputBar(
137 draft = state.draft,
138 draft = state.draft,
138 generating = state.generating,
139 generating = state.generating,
140+ loading = state.loading && state.messages.isEmpty(),
139 onDraftChange = { dispatch(Event.DraftChanged(it)) },
141 onDraftChange = { dispatch(Event.DraftChanged(it)) },
140 onSend = { dispatch(Event.SendMessage(state.draft)) },
142 onSend = { dispatch(Event.SendMessage(state.draft)) },
141 onStop = { dispatch(Event.CancelGeneration) },
143 onStop = { dispatch(Event.CancelGeneration) },
@@ -149,6 +151,12 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
149 if (state.loading && state.messages.isEmpty()) {
151 if (state.loading && state.messages.isEmpty()) {
150 LinearProgressIndicator(Modifier.fillMaxWidth())
152 LinearProgressIndicator(Modifier.fillMaxWidth())
151 }
153 }
154+ if (state.error != null && state.messages.isNotEmpty()) {
155+ ErrorBanner(
156+ message = state.error,
157+ onDismiss = { dispatch(Event.DismissError) },
158+ )
159+ }
152 Box(Modifier.fillMaxWidth().weight(1f)) {
160 Box(Modifier.fillMaxWidth().weight(1f)) {
153 if (state.messages.isEmpty() && !state.loading) {
161 if (state.messages.isEmpty() && !state.loading) {
154 Text(
162 Text(
@@ -242,10 +250,40 @@ private fun ReconnectingBanner() {
242 }
250 }
243}
251}
244
252
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+
245@Composable
282@Composable
246private fun ChatInputBar(
283private fun ChatInputBar(
247 draft: String,
284 draft: String,
248 generating: Boolean,
285 generating: Boolean,
286+ loading: Boolean,
249 onDraftChange: (String) -> Unit,
287 onDraftChange: (String) -> Unit,
250 onSend: () -> Unit,
288 onSend: () -> Unit,
251 onStop: () -> Unit,
289 onStop: () -> Unit,
@@ -261,11 +299,12 @@ private fun ChatInputBar(
261 value = draft,
299 value = draft,
262 onValueChange = onDraftChange,
300 onValueChange = onDraftChange,
263 modifier = Modifier.weight(1f),
301 modifier = Modifier.weight(1f),
302+ enabled = !loading,
264 placeholder = { Text("Message opencode…") },
303 placeholder = { Text("Message opencode…") },
265 maxLines = 5,
304 maxLines = 5,
266 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
305 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
267 keyboardActions = KeyboardActions(onSend = {
306 keyboardActions = KeyboardActions(onSend = {
268− if (!generating && draft.isNotBlank()) onSend()
307+ if (!generating && !loading && draft.isNotBlank()) onSend()
269 }),
308 }),
270 )
309 )
271 if (generating) {
310 if (generating) {
@@ -278,7 +317,7 @@ private fun ChatInputBar(
278 haptic.performHapticFeedback(HapticFeedbackType.LongPress)
317 haptic.performHapticFeedback(HapticFeedbackType.LongPress)
279 onSend()
318 onSend()
280 },
319 },
281− enabled = draft.isNotBlank(),
320+ enabled = draft.isNotBlank() && !loading,
282 ) {
321 ) {
283 Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
322 Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
284 }
323 }