git.iko.soy/opencode-android
on master help sign in
UX/UI: message grouping, swipe delete label, URL validation hint, assistant avatar icon

Bug fixes: debounce rapid send, cancel concurrent probeHealth, maintain sort on session upsert, clear streaming on message error

commitf4a0a6ff5f4defe5d30bb9e383b22a1dfdd4c2a8 authoriko <mail@iko.soy> dateThu 9 Jul 2026, 03:09 (3 months ago) parent7571397
5 files changed+52−10
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+12−2 app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+12−2 app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+2 app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+12 app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+14−6
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+12−2
@@ -60,6 +60,9 @@ class OpencodeCore(
6060 // Tracks the in-flight loadMessages coroutine. Rapidly switching sessions launches
6161 // several; cancel the prior one so only the newest fetch runs (saves needless requests).
6262 private var loadMessagesJob: Job? = null
63+ // Tracks the in-flight probeHealth coroutine. Rapid double-taps on Connect would
64+ // otherwise launch two concurrent health probes; cancel the prior one first.
65+ private var connectJob: Job? = null
6366 // Message IDs removed via SSE; a late MessageUpdated for one must not resurrect it.
6467 private val removedMessageIds = mutableSetOf<String>()
6568
@@ -84,7 +87,8 @@ class OpencodeCore(
8487 // Leave authRequired as-is; probeHealth sets it from the response (true on
8588 // 401, false on success). Resetting it here just flickers the auth fields.
8689 loading = true; error = null; emit()
87− scope.launch { probeHealth() }
90+ connectJob?.cancel()
91+ connectJob = scope.launch { probeHealth() }
8892 }
8993
9094 Event.LoadSessions -> {
@@ -208,6 +212,9 @@ class OpencodeCore(
208212 val text = raw.trim()
209213 val session = currentSessionId ?: return
210214 if (text.isEmpty()) return
215+ // Debounce rapid double-taps on send: a send is already in flight, so a second
216+ // tap would create a duplicate user message and fire a duplicate request.
217+ if (generating) return
211218 localSeq += 1
212219 val user = MsgState("local-$localSeq", "user", System.currentTimeMillis()).apply {
213220 status = MessageStatus.Pending
@@ -422,7 +429,7 @@ class OpencodeCore(
422429 } else {
423430 messages.add(MsgState(info.id, "assistant", info.time.created).apply { this.streaming = streaming })
424431 }
425− info.error?.let { error = it.message(); generating = false }
432+ info.error?.let { error = it.message(); messages.forEach { m -> m.streaming = false }; generating = false }
426433 return true
427434 }
428435
@@ -485,6 +492,9 @@ class OpencodeCore(
485492 } else {
486493 sessions.add(0, SessionView(event.info.id, title))
487494 }
495+ // Keep the list alphabetically sorted (matching loadSessions) so an
496+ // upserted session doesn't jump to the top and break the ordering.
497+ sessions.sortBy { it.title.lowercase() }
488498 if (isCurrent(event.info.id)) currentSessionTitle = title
489499 return true
490500 }
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+12−2
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Column
1313import androidx.compose.foundation.layout.Row
1414import androidx.compose.foundation.layout.fillMaxWidth
1515import androidx.compose.foundation.layout.padding
16+import androidx.compose.foundation.layout.size
1617import androidx.compose.foundation.layout.widthIn
1718import androidx.compose.foundation.shape.RoundedCornerShape
1819import androidx.compose.foundation.text.selection.SelectionContainer
@@ -23,6 +24,7 @@ import androidx.compose.material.icons.filled.ErrorOutline
2324import androidx.compose.material.icons.filled.ExpandLess
2425import androidx.compose.material.icons.filled.ExpandMore
2526import androidx.compose.material.icons.filled.Schedule
27+import androidx.compose.material.icons.filled.SmartToy
2628import androidx.compose.material3.Icon
2729import androidx.compose.material3.MaterialTheme
2830import androidx.compose.material3.Text
@@ -52,7 +54,7 @@ private val timeFormat = java.time.format.DateTimeFormatter.ofPattern("HH:mm")
5254
5355@OptIn(ExperimentalFoundationApi::class)
5456@Composable
55−fun MessageBubble(message: MessageView, modifier: Modifier = Modifier, onRetry: () -> Unit = {}) {
57+fun MessageBubble(message: MessageView, modifier: Modifier = Modifier, onRetry: () -> Unit = {}, compactSpacing: Boolean = false) {
5658 val isUser = message.isUser
5759 val bubbleColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
5860 val textColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
@@ -67,9 +69,17 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier, onRetry:
6769 val context = LocalContext.current
6870
6971 Row(
70− modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
72+ modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = if (compactSpacing) 1.dp else 4.dp),
7173 horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
7274 ) {
75+ if (!isUser) {
76+ Icon(
77+ Icons.Filled.SmartToy,
78+ contentDescription = null,
79+ tint = MaterialTheme.colorScheme.primary,
80+ modifier = Modifier.align(Alignment.Top).padding(end = 6.dp, top = 2.dp).size(28.dp),
81+ )
82+ }
7383 Column(
7484 modifier = Modifier
7585 .fillMaxWidth(0.85f)
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+2
@@ -196,9 +196,11 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
196196 if (shouldShowDateSeparator(previous, message)) {
197197 DateSeparator(message.time)
198198 }
199+ val sameSenderAsPrevious = index > 0 && state.messages[index - 1].isUser == message.isUser
199200 MessageBubble(
200201 message,
201202 onRetry = { dispatch(Event.SendMessage(message.text)) },
203+ compactSpacing = sameSenderAsPrevious,
202204 )
203205 }
204206 // While the assistant is spinning up but hasn't emitted a streaming
app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+12
@@ -113,6 +113,18 @@ fun ConnectScreen(state: UiState, dispatch: (Event) -> Unit) {
113113 )
114114 }
115115
116+ // Nudge the user if the URL looks like it's missing a scheme — a bare host:port
117+ // won't connect. Informational (tertiary), not an error, since it's a heuristic.
118+ if (state.serverUrl.isNotBlank() && "://" !in state.serverUrl && !state.serverUrl.startsWith("http")) {
119+ Spacer(Modifier.height(6.dp))
120+ Text(
121+ text = "Tip: Add http:// if connecting to a local server",
122+ style = MaterialTheme.typography.bodySmall,
123+ color = MaterialTheme.colorScheme.tertiary,
124+ textAlign = TextAlign.Center,
125+ )
126+ }
127+
116128 if (state.authRequired) {
117129 Spacer(Modifier.height(12.dp))
118130 OutlinedTextField(
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+14−6
@@ -247,7 +247,7 @@ private fun SessionRow(session: SessionView, onOpen: () -> Unit, onDelete: () ->
247247 }
248248}
249249
250−/** Red background with a trailing delete icon, revealed while swiping a session row away. */
250+/** Red background with a trailing delete icon and label, revealed while swiping a session row away. */
251251@Composable
252252private fun SwipeDeleteBackground() {
253253 Box(
@@ -257,11 +257,19 @@ private fun SwipeDeleteBackground() {
257257 .padding(horizontal = 24.dp),
258258 contentAlignment = Alignment.CenterEnd,
259259 ) {
260− Icon(
261− Icons.Filled.DeleteOutline,
262− contentDescription = null,
263− tint = MaterialTheme.colorScheme.onError,
264− )
260+ Row(verticalAlignment = Alignment.CenterVertically) {
261+ Icon(
262+ Icons.Filled.DeleteOutline,
263+ contentDescription = null,
264+ tint = MaterialTheme.colorScheme.onError,
265+ )
266+ Text(
267+ text = "Delete",
268+ style = MaterialTheme.typography.labelLarge,
269+ color = MaterialTheme.colorScheme.onError,
270+ modifier = Modifier.padding(start = 4.dp),
271+ )
272+ }
265273 }
266274}
267275