git.iko.soy/opencode-android
on master help sign in
UX/UI: typing indicator, alphabetical session sort, auto-connect on launch, retry button on failed messages

Bug fixes: cancel createSession on navigation, cancel loadSessions on connect, clear draft on navigation

commitb19f82af49d1d67f6b8b40f5ee4c4c6f4bfe7708 authoriko <mail@iko.soy> dateThu 9 Jul 2026, 02:55 (3 months ago) parentcd89013
5 files changed+111−6
app/src/main/java/soy/iko/opencode/CoreViewModel.kt+9 app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+19−2 app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+13−3 app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+67−1 shared/src/app.rs+3
app/src/main/java/soy/iko/opencode/CoreViewModel.kt+9
@@ -28,6 +28,15 @@ class CoreViewModel(app: Application) : AndroidViewModel(app) {
2828 // the core is already connected (no-op otherwise — it guards on `connected`).
2929 core.resumeStreamingIfConnected()
3030
31+ // Returning users shouldn't have to tap Connect: if a URL was restored and we
32+ // aren't already connected, kick off the connection attempt on launch. The core
33+ // handles an empty/invalid URL, so this only fires when there's something to try.
34+ core.view.value.let { restored ->
35+ if (restored.serverUrl.isNotEmpty() && !restored.connected) {
36+ core.dispatch(Event.Connect)
37+ }
38+ }
39+
3140 // Persist connection details once a connection succeeds.
3241 viewModelScope.launch {
3342 core.view.distinctUntilChangedBy { it.connected to it.serverUrl }.collect { state ->
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+19−2
@@ -54,6 +54,9 @@ class OpencodeCore(
5454 // Guards against overlapping session refreshes: rapid taps launch multiple
5555 // loadSessions coroutines and the slowest-to-return would otherwise win.
5656 private var loadSessionsJob: Job? = null
57+ // Tracks the in-flight createSession coroutine so navigating away can cancel it
58+ // before it dispatches SelectSession into a screen the user already left.
59+ private var createSessionJob: Job? = null
5760 // Message IDs removed via SSE; a late MessageUpdated for one must not resurrect it.
5861 private val removedMessageIds = mutableSetOf<String>()
5962
@@ -104,7 +107,11 @@ class OpencodeCore(
104107 scope.launch { loadMessages(event.id) }
105108 }
106109
107− Event.CreateSession -> { loading = true; error = null; emit(); scope.launch { createSession() } }
110+ Event.CreateSession -> {
111+ loading = true; error = null; emit()
112+ createSessionJob?.cancel()
113+ createSessionJob = scope.launch { createSession() }
114+ }
108115
109116 is Event.DeleteSession -> {
110117 val id = event.id
@@ -152,9 +159,11 @@ class OpencodeCore(
152159 currentSessionId = null
153160 messages.clear()
154161 removedMessageIds.clear()
162+ draft = ""
155163 generating = false
156164 loading = true
157165 emit()
166+ createSessionJob?.cancel()
158167 loadSessionsJob?.cancel()
159168 loadSessionsJob = scope.launch { loadSessions() }
160169 }
@@ -168,9 +177,11 @@ class OpencodeCore(
168177 messages.clear()
169178 removedMessageIds.clear()
170179 currentSessionId = null
180+ draft = ""
171181 // Reset to the default so a late-firing close callback from the old
172182 // SSE connection can't flash the reconnecting banner on the next connect.
173183 sseConnected = true
184+ createSessionJob?.cancel()
174185 closeSse()
175186 emit()
176187 }
@@ -253,7 +264,10 @@ class OpencodeCore(
253264 loading = true
254265 emit()
255266 startSse()
256− loadSessions()
267+ // Route through loadSessionsJob so a prior in-flight loadSessions
268+ // (e.g. from a rapid double-connect) is cancelled first.
269+ loadSessionsJob?.cancel()
270+ loadSessionsJob = scope.launch { loadSessions() }
257271 }
258272 else -> {
259273 loading = false
@@ -276,6 +290,9 @@ class OpencodeCore(
276290 if (resp.code in 200..299) {
277291 sessions.clear()
278292 sessions.addAll(Protocol.parseSessions(resp.body).map { SessionView(it.id, displayTitle(it.title)) })
293+ // Impose a stable alphabetical order so the list doesn't shuffle with
294+ // whatever order the server happens to return sessions in.
295+ sessions.sortBy { it.title.lowercase() }
279296 } else {
280297 error = "Server returned status ${resp.code}"
281298 }
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+13−3
@@ -52,7 +52,7 @@ private val timeFormat = java.time.format.DateTimeFormatter.ofPattern("HH:mm")
5252
5353@OptIn(ExperimentalFoundationApi::class)
5454@Composable
55−fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
55+fun MessageBubble(message: MessageView, modifier: Modifier = Modifier, onRetry: () -> Unit = {}) {
5656 val isUser = message.isUser
5757 val bubbleColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
5858 val textColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
@@ -119,7 +119,7 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
119119 }
120120
121121 if (isUser && message.status != MessageStatus.Sent) {
122− StatusLine(message.status)
122+ StatusLine(message.status, onRetry = onRetry)
123123 }
124124 }
125125 }
@@ -227,7 +227,7 @@ private fun ToolRow(tool: ToolView) {
227227}
228228
229229@Composable
230−private fun StatusLine(status: MessageStatus) {
230+private fun StatusLine(status: MessageStatus, onRetry: () -> Unit) {
231231 val (icon, label, tint) = when (status) {
232232 MessageStatus.Pending -> Triple(Icons.Filled.Schedule, "Sending…", MaterialTheme.colorScheme.onPrimaryContainer)
233233 MessageStatus.Failed -> Triple(Icons.Filled.ErrorOutline, "Failed to send", MaterialTheme.colorScheme.error)
@@ -245,5 +245,15 @@ private fun StatusLine(status: MessageStatus) {
245245 color = tint,
246246 modifier = Modifier.padding(start = 4.dp),
247247 )
248+ if (status == MessageStatus.Failed) {
249+ Text(
250+ text = "Retry",
251+ style = MaterialTheme.typography.labelSmall,
252+ color = MaterialTheme.colorScheme.primary,
253+ modifier = Modifier
254+ .padding(start = 12.dp)
255+ .clickable { onRetry() },
256+ )
257+ }
248258 }
249259}
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+67−1
@@ -1,6 +1,11 @@
11package soy.iko.opencode.ui.screens
22
33import androidx.compose.animation.AnimatedVisibility
4+import androidx.compose.animation.core.RepeatMode
5+import androidx.compose.animation.core.animateFloat
6+import androidx.compose.animation.core.infiniteRepeatable
7+import androidx.compose.animation.core.rememberInfiniteTransition
8+import androidx.compose.animation.core.tween
49import androidx.compose.animation.fadeIn
510import androidx.compose.animation.fadeOut
611import androidx.compose.animation.scaleIn
@@ -23,6 +28,7 @@ import androidx.compose.foundation.lazy.LazyColumn
2328import androidx.compose.foundation.lazy.items
2429import androidx.compose.foundation.lazy.rememberLazyListState
2530import androidx.compose.foundation.shape.CircleShape
31+import androidx.compose.foundation.shape.RoundedCornerShape
2632import androidx.compose.foundation.text.KeyboardActions
2733import androidx.compose.foundation.text.KeyboardOptions
2834import androidx.compose.material.icons.Icons
@@ -58,6 +64,7 @@ import androidx.compose.ui.Modifier
5864import androidx.compose.ui.draw.clip
5965import androidx.compose.ui.hapticfeedback.HapticFeedbackType
6066import androidx.compose.ui.platform.LocalHapticFeedback
67+import androidx.compose.ui.text.font.FontStyle
6168import androidx.compose.ui.text.input.ImeAction
6269import androidx.compose.ui.text.style.TextOverflow
6370import androidx.compose.ui.unit.dp
@@ -174,7 +181,18 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
174181 contentPadding = PaddingValues(vertical = 8.dp),
175182 ) {
176183 items(state.messages, key = { it.id }) { message ->
177− MessageBubble(message)
184+ MessageBubble(
185+ message,
186+ onRetry = { dispatch(Event.SendMessage(message.text)) },
187+ )
188+ }
189+ // While the assistant is spinning up but hasn't emitted a streaming
190+ // message yet (only the user's message is present), show a typing
191+ // indicator so the wait doesn't look like a stall.
192+ if (state.generating && state.messages.none { it.streaming }) {
193+ item(key = "typing-indicator") {
194+ TypingIndicator()
195+ }
178196 }
179197 }
180198 ScrollToBottomButton(
@@ -227,6 +245,54 @@ private fun BoxScope.ScrollToBottomButton(visible: Boolean, showBadge: Boolean,
227245 }
228246}
229247
248+// Assistant-style bubble with three sequentially-pulsing dots, shown as a stand-in
249+// until the real streaming reply starts. Mirrors MessageBubble's left-aligned,
250+// surfaceVariant look so it reads as an incoming message.
251+@Composable
252+private fun TypingIndicator() {
253+ val shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 4.dp, bottomEnd = 16.dp)
254+ val transition = rememberInfiniteTransition(label = "typing")
255+ Row(
256+ modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
257+ horizontalArrangement = Arrangement.Start,
258+ ) {
259+ Row(
260+ modifier = Modifier
261+ .clip(shape)
262+ .background(MaterialTheme.colorScheme.surfaceVariant)
263+ .padding(horizontal = 14.dp, vertical = 12.dp),
264+ verticalAlignment = Alignment.CenterVertically,
265+ horizontalArrangement = Arrangement.spacedBy(6.dp),
266+ ) {
267+ Text(
268+ text = "Thinking",
269+ style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic),
270+ color = MaterialTheme.colorScheme.onSurfaceVariant,
271+ )
272+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
273+ // Stagger the three dots so they fade in and out in sequence.
274+ listOf(0, 150, 300).forEach { staggerMs ->
275+ val alpha by transition.animateFloat(
276+ initialValue = 0.2f,
277+ targetValue = 1f,
278+ animationSpec = infiniteRepeatable(
279+ animation = tween(durationMillis = 600, delayMillis = staggerMs),
280+ repeatMode = RepeatMode.Reverse,
281+ ),
282+ label = "dot",
283+ )
284+ Box(
285+ modifier = Modifier
286+ .size(6.dp)
287+ .clip(CircleShape)
288+ .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha)),
289+ )
290+ }
291+ }
292+ }
293+ }
294+}
295+
230296@Composable
231297private fun ReconnectingBanner() {
232298 Surface(
shared/src/app.rs+3
@@ -352,6 +352,9 @@ impl App for OpencodeApp {
352352 title: display_title(&s.title),
353353 })
354354 .collect();
355+ // Impose a stable alphabetical order so the list doesn't shuffle
356+ // with whatever order the server happens to return sessions in.
357+ model.sessions.sort_by_key(|s| s.title.to_lowercase());
355358 }
356359 Err(e) => model.error = Some(e),
357360 }