git.iko.soy/opencode-android
on master help sign in
UX/UI: message grouping, swipe delete label, URL validation hint, assistant avatar icon
f4a0a6fiko
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+12−2
@@ -60,6 +60,9 @@ class OpencodeCore(
60 // Tracks the in-flight loadMessages coroutine. Rapidly switching sessions launches
60 // Tracks the in-flight loadMessages coroutine. Rapidly switching sessions launches
61 // several; cancel the prior one so only the newest fetch runs (saves needless requests).
61 // several; cancel the prior one so only the newest fetch runs (saves needless requests).
62 private var loadMessagesJob: Job? = null
62 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
63 // Message IDs removed via SSE; a late MessageUpdated for one must not resurrect it.
66 // Message IDs removed via SSE; a late MessageUpdated for one must not resurrect it.
64 private val removedMessageIds = mutableSetOf<String>()
67 private val removedMessageIds = mutableSetOf<String>()
65
68
@@ -84,7 +87,8 @@ class OpencodeCore(
84 // Leave authRequired as-is; probeHealth sets it from the response (true on
87 // Leave authRequired as-is; probeHealth sets it from the response (true on
85 // 401, false on success). Resetting it here just flickers the auth fields.
88 // 401, false on success). Resetting it here just flickers the auth fields.
86 loading = true; error = null; emit()
89 loading = true; error = null; emit()
90+ connectJob?.cancel()
87− scope.launch { probeHealth() }
91+ connectJob = scope.launch { probeHealth() }
88 }
92 }
89
93
90 Event.LoadSessions -> {
94 Event.LoadSessions -> {
@@ -208,6 +212,9 @@ class OpencodeCore(
208 val text = raw.trim()
212 val text = raw.trim()
209 val session = currentSessionId ?: return
213 val session = currentSessionId ?: return
210 if (text.isEmpty()) return
214 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
211 localSeq += 1
218 localSeq += 1
212 val user = MsgState("local-$localSeq", "user", System.currentTimeMillis()).apply {
219 val user = MsgState("local-$localSeq", "user", System.currentTimeMillis()).apply {
213 status = MessageStatus.Pending
220 status = MessageStatus.Pending
@@ -422,7 +429,7 @@ class OpencodeCore(
422 } else {
429 } else {
423 messages.add(MsgState(info.id, "assistant", info.time.created).apply { this.streaming = streaming })
430 messages.add(MsgState(info.id, "assistant", info.time.created).apply { this.streaming = streaming })
424 }
431 }
425− info.error?.let { error = it.message(); generating = false }
432+ info.error?.let { error = it.message(); messages.forEach { m -> m.streaming = false }; generating = false }
426 return true
433 return true
427 }
434 }
428
435
@@ -485,6 +492,9 @@ class OpencodeCore(
485 } else {
492 } else {
486 sessions.add(0, SessionView(event.info.id, title))
493 sessions.add(0, SessionView(event.info.id, title))
487 }
494 }
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() }
488 if (isCurrent(event.info.id)) currentSessionTitle = title
498 if (isCurrent(event.info.id)) currentSessionTitle = title
489 return true
499 return true
490 }
500 }
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+12−2
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Column
13import androidx.compose.foundation.layout.Row
13import androidx.compose.foundation.layout.Row
14import androidx.compose.foundation.layout.fillMaxWidth
14import androidx.compose.foundation.layout.fillMaxWidth
15import androidx.compose.foundation.layout.padding
15import androidx.compose.foundation.layout.padding
16+import androidx.compose.foundation.layout.size
16import androidx.compose.foundation.layout.widthIn
17import androidx.compose.foundation.layout.widthIn
17import androidx.compose.foundation.shape.RoundedCornerShape
18import androidx.compose.foundation.shape.RoundedCornerShape
18import androidx.compose.foundation.text.selection.SelectionContainer
19import androidx.compose.foundation.text.selection.SelectionContainer
@@ -23,6 +24,7 @@ import androidx.compose.material.icons.filled.ErrorOutline
23import androidx.compose.material.icons.filled.ExpandLess
24import androidx.compose.material.icons.filled.ExpandLess
24import androidx.compose.material.icons.filled.ExpandMore
25import androidx.compose.material.icons.filled.ExpandMore
25import androidx.compose.material.icons.filled.Schedule
26import androidx.compose.material.icons.filled.Schedule
27+import androidx.compose.material.icons.filled.SmartToy
26import androidx.compose.material3.Icon
28import androidx.compose.material3.Icon
27import androidx.compose.material3.MaterialTheme
29import androidx.compose.material3.MaterialTheme
28import androidx.compose.material3.Text
30import androidx.compose.material3.Text
@@ -52,7 +54,7 @@ private val timeFormat = java.time.format.DateTimeFormatter.ofPattern("HH:mm")
52
54
53@OptIn(ExperimentalFoundationApi::class)
55@OptIn(ExperimentalFoundationApi::class)
54@Composable
56@Composable
55−fun MessageBubble(message: MessageView, modifier: Modifier = Modifier, onRetry: () -> Unit = {}) {
57+fun MessageBubble(message: MessageView, modifier: Modifier = Modifier, onRetry: () -> Unit = {}, compactSpacing: Boolean = false) {
56 val isUser = message.isUser
58 val isUser = message.isUser
57 val bubbleColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
59 val bubbleColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
58 val textColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
60 val textColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
@@ -67,9 +69,17 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier, onRetry:
67 val context = LocalContext.current
69 val context = LocalContext.current
68
70
69 Row(
71 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),
71 horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
73 horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
72 ) {
74 ) {
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+ }
73 Column(
83 Column(
74 modifier = Modifier
84 modifier = Modifier
75 .fillMaxWidth(0.85f)
85 .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) {
196 if (shouldShowDateSeparator(previous, message)) {
196 if (shouldShowDateSeparator(previous, message)) {
197 DateSeparator(message.time)
197 DateSeparator(message.time)
198 }
198 }
199+ val sameSenderAsPrevious = index > 0 && state.messages[index - 1].isUser == message.isUser
199 MessageBubble(
200 MessageBubble(
200 message,
201 message,
201 onRetry = { dispatch(Event.SendMessage(message.text)) },
202 onRetry = { dispatch(Event.SendMessage(message.text)) },
203+ compactSpacing = sameSenderAsPrevious,
202 )
204 )
203 }
205 }
204 // While the assistant is spinning up but hasn't emitted a streaming
206 // 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) {
113 )
113 )
114 }
114 }
115
115
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+
116 if (state.authRequired) {
128 if (state.authRequired) {
117 Spacer(Modifier.height(12.dp))
129 Spacer(Modifier.height(12.dp))
118 OutlinedTextField(
130 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: () ->
247 }
247 }
248}
248}
249
249
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. */
251@Composable
251@Composable
252private fun SwipeDeleteBackground() {
252private fun SwipeDeleteBackground() {
253 Box(
253 Box(
@@ -257,11 +257,19 @@ private fun SwipeDeleteBackground() {
257 .padding(horizontal = 24.dp),
257 .padding(horizontal = 24.dp),
258 contentAlignment = Alignment.CenterEnd,
258 contentAlignment = Alignment.CenterEnd,
259 ) {
259 ) {
260+ Row(verticalAlignment = Alignment.CenterVertically) {
260− Icon(
261+ Icon(
261− Icons.Filled.DeleteOutline,
262+ Icons.Filled.DeleteOutline,
262− contentDescription = null,
263+ contentDescription = null,
263− tint = MaterialTheme.colorScheme.onError,
264+ tint = MaterialTheme.colorScheme.onError,
264− )
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+ }
265 }
273 }
266}
274}
267
275