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
b19f82aiko
app/src/main/java/soy/iko/opencode/CoreViewModel.kt+9
@@ -28,6 +28,15 @@ class CoreViewModel(app: Application) : AndroidViewModel(app) {
28 // the core is already connected (no-op otherwise — it guards on `connected`).
28 // the core is already connected (no-op otherwise — it guards on `connected`).
29 core.resumeStreamingIfConnected()
29 core.resumeStreamingIfConnected()
30
30
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+
31 // Persist connection details once a connection succeeds.
40 // Persist connection details once a connection succeeds.
32 viewModelScope.launch {
41 viewModelScope.launch {
33 core.view.distinctUntilChangedBy { it.connected to it.serverUrl }.collect { state ->
42 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(
54 // Guards against overlapping session refreshes: rapid taps launch multiple
54 // Guards against overlapping session refreshes: rapid taps launch multiple
55 // loadSessions coroutines and the slowest-to-return would otherwise win.
55 // loadSessions coroutines and the slowest-to-return would otherwise win.
56 private var loadSessionsJob: Job? = null
56 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
57 // Message IDs removed via SSE; a late MessageUpdated for one must not resurrect it.
60 // Message IDs removed via SSE; a late MessageUpdated for one must not resurrect it.
58 private val removedMessageIds = mutableSetOf<String>()
61 private val removedMessageIds = mutableSetOf<String>()
59
62
@@ -104,7 +107,11 @@ class OpencodeCore(
104 scope.launch { loadMessages(event.id) }
107 scope.launch { loadMessages(event.id) }
105 }
108 }
106
109
110+ Event.CreateSession -> {
107− Event.CreateSession -> { loading = true; error = null; emit(); scope.launch { createSession() } }
111+ loading = true; error = null; emit()
112+ createSessionJob?.cancel()
113+ createSessionJob = scope.launch { createSession() }
114+ }
108
115
109 is Event.DeleteSession -> {
116 is Event.DeleteSession -> {
110 val id = event.id
117 val id = event.id
@@ -152,9 +159,11 @@ class OpencodeCore(
152 currentSessionId = null
159 currentSessionId = null
153 messages.clear()
160 messages.clear()
154 removedMessageIds.clear()
161 removedMessageIds.clear()
162+ draft = ""
155 generating = false
163 generating = false
156 loading = true
164 loading = true
157 emit()
165 emit()
166+ createSessionJob?.cancel()
158 loadSessionsJob?.cancel()
167 loadSessionsJob?.cancel()
159 loadSessionsJob = scope.launch { loadSessions() }
168 loadSessionsJob = scope.launch { loadSessions() }
160 }
169 }
@@ -168,9 +177,11 @@ class OpencodeCore(
168 messages.clear()
177 messages.clear()
169 removedMessageIds.clear()
178 removedMessageIds.clear()
170 currentSessionId = null
179 currentSessionId = null
180+ draft = ""
171 // Reset to the default so a late-firing close callback from the old
181 // Reset to the default so a late-firing close callback from the old
172 // SSE connection can't flash the reconnecting banner on the next connect.
182 // SSE connection can't flash the reconnecting banner on the next connect.
173 sseConnected = true
183 sseConnected = true
184+ createSessionJob?.cancel()
174 closeSse()
185 closeSse()
175 emit()
186 emit()
176 }
187 }
@@ -253,7 +264,10 @@ class OpencodeCore(
253 loading = true
264 loading = true
254 emit()
265 emit()
255 startSse()
266 startSse()
267+ // Route through loadSessionsJob so a prior in-flight loadSessions
268+ // (e.g. from a rapid double-connect) is cancelled first.
269+ loadSessionsJob?.cancel()
256− loadSessions()
270+ loadSessionsJob = scope.launch { loadSessions() }
257 }
271 }
258 else -> {
272 else -> {
259 loading = false
273 loading = false
@@ -276,6 +290,9 @@ class OpencodeCore(
276 if (resp.code in 200..299) {
290 if (resp.code in 200..299) {
277 sessions.clear()
291 sessions.clear()
278 sessions.addAll(Protocol.parseSessions(resp.body).map { SessionView(it.id, displayTitle(it.title)) })
292 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() }
279 } else {
296 } else {
280 error = "Server returned status ${resp.code}"
297 error = "Server returned status ${resp.code}"
281 }
298 }
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")
52
52
53@OptIn(ExperimentalFoundationApi::class)
53@OptIn(ExperimentalFoundationApi::class)
54@Composable
54@Composable
55−fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
55+fun MessageBubble(message: MessageView, modifier: Modifier = Modifier, onRetry: () -> Unit = {}) {
56 val isUser = message.isUser
56 val isUser = message.isUser
57 val bubbleColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
57 val bubbleColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
58 val textColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
58 val textColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
@@ -119,7 +119,7 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
119 }
119 }
120
120
121 if (isUser && message.status != MessageStatus.Sent) {
121 if (isUser && message.status != MessageStatus.Sent) {
122− StatusLine(message.status)
122+ StatusLine(message.status, onRetry = onRetry)
123 }
123 }
124 }
124 }
125 }
125 }
@@ -227,7 +227,7 @@ private fun ToolRow(tool: ToolView) {
227}
227}
228
228
229@Composable
229@Composable
230−private fun StatusLine(status: MessageStatus) {
230+private fun StatusLine(status: MessageStatus, onRetry: () -> Unit) {
231 val (icon, label, tint) = when (status) {
231 val (icon, label, tint) = when (status) {
232 MessageStatus.Pending -> Triple(Icons.Filled.Schedule, "Sending…", MaterialTheme.colorScheme.onPrimaryContainer)
232 MessageStatus.Pending -> Triple(Icons.Filled.Schedule, "Sending…", MaterialTheme.colorScheme.onPrimaryContainer)
233 MessageStatus.Failed -> Triple(Icons.Filled.ErrorOutline, "Failed to send", MaterialTheme.colorScheme.error)
233 MessageStatus.Failed -> Triple(Icons.Filled.ErrorOutline, "Failed to send", MaterialTheme.colorScheme.error)
@@ -245,5 +245,15 @@ private fun StatusLine(status: MessageStatus) {
245 color = tint,
245 color = tint,
246 modifier = Modifier.padding(start = 4.dp),
246 modifier = Modifier.padding(start = 4.dp),
247 )
247 )
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+ }
248 }
258 }
249}
259}
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+67−1
@@ -1,6 +1,11 @@
1package soy.iko.opencode.ui.screens
1package soy.iko.opencode.ui.screens
2
2
3import androidx.compose.animation.AnimatedVisibility
3import 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
4import androidx.compose.animation.fadeIn
9import androidx.compose.animation.fadeIn
5import androidx.compose.animation.fadeOut
10import androidx.compose.animation.fadeOut
6import androidx.compose.animation.scaleIn
11import androidx.compose.animation.scaleIn
@@ -23,6 +28,7 @@ import androidx.compose.foundation.lazy.LazyColumn
23import androidx.compose.foundation.lazy.items
28import androidx.compose.foundation.lazy.items
24import androidx.compose.foundation.lazy.rememberLazyListState
29import androidx.compose.foundation.lazy.rememberLazyListState
25import androidx.compose.foundation.shape.CircleShape
30import androidx.compose.foundation.shape.CircleShape
31+import androidx.compose.foundation.shape.RoundedCornerShape
26import androidx.compose.foundation.text.KeyboardActions
32import androidx.compose.foundation.text.KeyboardActions
27import androidx.compose.foundation.text.KeyboardOptions
33import androidx.compose.foundation.text.KeyboardOptions
28import androidx.compose.material.icons.Icons
34import androidx.compose.material.icons.Icons
@@ -58,6 +64,7 @@ import androidx.compose.ui.Modifier
58import androidx.compose.ui.draw.clip
64import androidx.compose.ui.draw.clip
59import androidx.compose.ui.hapticfeedback.HapticFeedbackType
65import androidx.compose.ui.hapticfeedback.HapticFeedbackType
60import androidx.compose.ui.platform.LocalHapticFeedback
66import androidx.compose.ui.platform.LocalHapticFeedback
67+import androidx.compose.ui.text.font.FontStyle
61import androidx.compose.ui.text.input.ImeAction
68import androidx.compose.ui.text.input.ImeAction
62import androidx.compose.ui.text.style.TextOverflow
69import androidx.compose.ui.text.style.TextOverflow
63import androidx.compose.ui.unit.dp
70import androidx.compose.ui.unit.dp
@@ -174,7 +181,18 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
174 contentPadding = PaddingValues(vertical = 8.dp),
181 contentPadding = PaddingValues(vertical = 8.dp),
175 ) {
182 ) {
176 items(state.messages, key = { it.id }) { message ->
183 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+ }
178 }
196 }
179 }
197 }
180 ScrollToBottomButton(
198 ScrollToBottomButton(
@@ -227,6 +245,54 @@ private fun BoxScope.ScrollToBottomButton(visible: Boolean, showBadge: Boolean,
227 }
245 }
228}
246}
229
247
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+
230@Composable
296@Composable
231private fun ReconnectingBanner() {
297private fun ReconnectingBanner() {
232 Surface(
298 Surface(
shared/src/app.rs+3
@@ -352,6 +352,9 @@ impl App for OpencodeApp {
352 title: display_title(&s.title),
352 title: display_title(&s.title),
353 })
353 })
354 .collect();
354 .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());
355 }
358 }
356 Err(e) => model.error = Some(e),
359 Err(e) => model.error = Some(e),
357 }
360 }