git.iko.soy/opencode-android
on master help sign in
UX/UI: animated session removal, Stop button with label, clear error on typing, improved assistant text readability

Bug fixes: cancel concurrent loadSessions, use brand palette by default, guard against removed message resurrection, validate session exists before opening

commitcd89013a10820fe34b2dc866014576618f89bd49 authoriko <mail@iko.soy> dateThu 9 Jul 2026, 02:47 (3 months ago) parentb9f3596
6 files changed+89−31
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+35−7 app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+8−1 app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+10−2 app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+24−20 app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+1−1 shared/src/app.rs+11
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+35−7
@@ -51,15 +51,23 @@ class OpencodeCore(
5151 private var reconnectAttempts = 0
5252 private var reconnectJob: Job? = null
5353
54+ // Guards against overlapping session refreshes: rapid taps launch multiple
55+ // loadSessions coroutines and the slowest-to-return would otherwise win.
56+ private var loadSessionsJob: Job? = null
57+ // Message IDs removed via SSE; a late MessageUpdated for one must not resurrect it.
58+ private val removedMessageIds = mutableSetOf<String>()
59+
5460 // ── Event entry point ──────────────────────────────────────────────────────
5561 fun dispatch(event: Event) {
5662 when (event) {
5763 // Store the field verbatim while typing; normalizing here would fight
5864 // the user's edits (e.g. eat the "://" as they type it). The URL is
5965 // normalized once, on Connect.
60− is Event.ServerUrlChanged -> { serverUrl = event.url; emit() }
61− is Event.UsernameChanged -> { username = event.value; emit() }
62− is Event.PasswordChanged -> { password = event.value; emit() }
66+ // Clear any prior error as soon as the user edits a field, so a stale
67+ // message (e.g. "Invalid credentials") doesn't linger while they correct it.
68+ is Event.ServerUrlChanged -> { serverUrl = event.url; if (error != null) error = null; emit() }
69+ is Event.UsernameChanged -> { username = event.value; if (error != null) error = null; emit() }
70+ is Event.PasswordChanged -> { password = event.value; if (error != null) error = null; emit() }
6371
6472 Event.Connect -> {
6573 serverUrl = normalizeUrl(serverUrl)
@@ -73,12 +81,21 @@ class OpencodeCore(
7381 scope.launch { probeHealth() }
7482 }
7583
76− Event.LoadSessions -> { loading = true; emit(); scope.launch { loadSessions() } }
84+ Event.LoadSessions -> {
85+ loading = true; emit()
86+ loadSessionsJob?.cancel()
87+ loadSessionsJob = scope.launch { loadSessions() }
88+ }
7789
7890 is Event.SelectSession -> {
79− currentSessionTitle = sessions.firstOrNull { it.id == event.id }?.title.orEmpty()
91+ // The session may have been deleted (e.g. via SSE) while the list was on
92+ // screen; opening it would only 404 on loadMessages. Bail with an error.
93+ val session = sessions.firstOrNull { it.id == event.id }
94+ if (session == null) { error = "Session no longer exists"; emit(); return }
95+ currentSessionTitle = session.title
8096 currentSessionId = event.id
8197 messages.clear()
98+ removedMessageIds.clear()
8299 screen = Screen.Chat
83100 loading = true
84101 generating = false
@@ -134,10 +151,12 @@ class OpencodeCore(
134151 screen = Screen.Sessions
135152 currentSessionId = null
136153 messages.clear()
154+ removedMessageIds.clear()
137155 generating = false
138156 loading = true
139157 emit()
140− scope.launch { loadSessions() }
158+ loadSessionsJob?.cancel()
159+ loadSessionsJob = scope.launch { loadSessions() }
141160 }
142161
143162 Event.NavigateToConnect -> {
@@ -147,6 +166,7 @@ class OpencodeCore(
147166 generating = false
148167 sessions.clear()
149168 messages.clear()
169+ removedMessageIds.clear()
150170 currentSessionId = null
151171 // Reset to the default so a late-firing close callback from the old
152172 // SSE connection can't flash the reconnecting banner on the next connect.
@@ -294,6 +314,7 @@ class OpencodeCore(
294314 loading = false
295315 if (resp.code in 200..299) {
296316 messages.clear()
317+ removedMessageIds.clear()
297318 messages.addAll(Protocol.parseMessages(resp.body).map(::messageFromWire))
298319 generating = messages.any { it.streaming }
299320 } else {
@@ -367,6 +388,9 @@ class OpencodeCore(
367388 is Protocol.Event.MessageUpdated -> {
368389 val info = event.info
369390 if (info.role != "assistant" || !isCurrent(info.sessionId)) return false
391+ // A MessageRemoved may have arrived before this (late/out-of-order) update;
392+ // don't re-create a message the server has already deleted.
393+ if (info.id in removedMessageIds) return false
370394 val streaming = info.time.completed == null && info.error == null
371395 val existing = messages.firstOrNull { it.id == info.id }
372396 if (existing != null) {
@@ -408,7 +432,11 @@ class OpencodeCore(
408432 return true
409433 }
410434
411− is Protocol.Event.MessageRemoved -> return messages.removeAll { it.id == event.messageId }
435+ is Protocol.Event.MessageRemoved -> {
436+ // Remember the ID so a late MessageUpdated can't resurrect the message.
437+ removedMessageIds.add(event.messageId)
438+ return messages.removeAll { it.id == event.messageId }
439+ }
412440
413441 is Protocol.Event.SessionIdle -> {
414442 if (!isCurrent(event.sessionId)) return false
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+8−1
@@ -42,6 +42,7 @@ import androidx.compose.ui.text.font.FontStyle
4242import androidx.compose.ui.text.withStyle
4343import androidx.compose.ui.text.style.TextOverflow
4444import androidx.compose.ui.unit.dp
45+import androidx.compose.ui.unit.sp
4546import soy.iko.opencode.core.MessageStatus
4647import soy.iko.opencode.core.MessageView
4748import soy.iko.opencode.core.ToolView
@@ -95,7 +96,13 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
9596 Text(
9697 text = message.text + if (message.streaming) " ▌" else "",
9798 color = textColor,
98− style = MaterialTheme.typography.bodyLarge,
99+ // Give assistant replies (often long, code-heavy) extra line
100+ // spacing for readability; user bubbles keep the default.
101+ style = if (isUser) {
102+ MaterialTheme.typography.bodyLarge
103+ } else {
104+ MaterialTheme.typography.bodyLarge.copy(lineHeight = 22.sp)
105+ },
99106 )
100107 } else if (message.streaming && message.tools.isEmpty() && message.reasoning == null) {
101108 Text("▌", color = textColor, style = MaterialTheme.typography.bodyLarge)
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+10−2
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Box
1212import androidx.compose.foundation.layout.BoxScope
1313import androidx.compose.foundation.layout.Column
1414import androidx.compose.foundation.layout.Row
15+import androidx.compose.foundation.layout.Spacer
1516import androidx.compose.foundation.layout.fillMaxSize
1617import androidx.compose.foundation.layout.fillMaxWidth
1718import androidx.compose.foundation.layout.PaddingValues
@@ -33,6 +34,7 @@ import androidx.compose.material.icons.filled.Stop
3334import androidx.compose.material3.CircularProgressIndicator
3435import androidx.compose.material3.ExperimentalMaterial3Api
3536import androidx.compose.material3.FilledIconButton
37+import androidx.compose.material3.FilledTonalButton
3638import androidx.compose.material3.FloatingActionButton
3739import androidx.compose.material3.Icon
3840import androidx.compose.material3.IconButton
@@ -308,8 +310,14 @@ private fun ChatInputBar(
308310 }),
309311 )
310312 if (generating) {
311− FilledIconButton(onClick = onStop) {
312− Icon(Icons.Filled.Stop, contentDescription = "Stop generating")
313+ FilledTonalButton(onClick = onStop) {
314+ Icon(
315+ Icons.Filled.Stop,
316+ contentDescription = null,
317+ modifier = Modifier.size(18.dp),
318+ )
319+ Spacer(Modifier.size(6.dp))
320+ Text("Stop")
313321 }
314322 } else {
315323 FilledIconButton(
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+24−20
@@ -162,28 +162,32 @@ fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
162162 contentPadding = PaddingValues(bottom = 88.dp),
163163 ) {
164164 items(filteredSessions, key = { it.id }) { session ->
165− val dismissState = rememberSwipeToDismissBoxState(
166− // Only a swipe from the end (right-to-left) triggers deletion;
167− // return false so the row snaps back and the confirm dialog decides.
168− confirmValueChange = { value ->
169− if (value == SwipeToDismissBoxValue.EndToStart) {
170− pendingDeleteId = session.id
171− }
172− false
173− },
174− )
175− SwipeToDismissBox(
176− state = dismissState,
177− enableDismissFromStartToEnd = false,
178− backgroundContent = { SwipeDeleteBackground() },
179− ) {
180− SessionRow(
181− session = session,
182− onOpen = { dispatch(Event.SelectSession(session.id)) },
183− onDelete = { pendingDeleteId = session.id },
165+ // animateItem() slides the remaining rows into place when one is
166+ // deleted (and fades new rows in) instead of snapping instantly.
167+ Column(modifier = Modifier.animateItem()) {
168+ val dismissState = rememberSwipeToDismissBoxState(
169+ // Only a swipe from the end (right-to-left) triggers deletion;
170+ // return false so the row snaps back and the confirm dialog decides.
171+ confirmValueChange = { value ->
172+ if (value == SwipeToDismissBoxValue.EndToStart) {
173+ pendingDeleteId = session.id
174+ }
175+ false
176+ },
184177 )
178+ SwipeToDismissBox(
179+ state = dismissState,
180+ enableDismissFromStartToEnd = false,
181+ backgroundContent = { SwipeDeleteBackground() },
182+ ) {
183+ SessionRow(
184+ session = session,
185+ onOpen = { dispatch(Event.SelectSession(session.id)) },
186+ onDelete = { pendingDeleteId = session.id },
187+ )
188+ }
189+ HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
185190 }
186− HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
187191 }
188192 }
189193 }
app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+1−1
@@ -35,7 +35,7 @@ private val LightColors = lightColorScheme(
3535@Composable
3636fun OpencodeTheme(
3737 darkTheme: Boolean = isSystemInDarkTheme(),
38− dynamicColor: Boolean = true,
38+ dynamicColor: Boolean = false,
3939 content: @Composable () -> Unit,
4040) {
4141 val context = LocalContext.current
shared/src/app.rs+11
@@ -297,16 +297,27 @@ impl App for OpencodeApp {
297297 // Store the field verbatim while typing; normalizing here would fight
298298 // the user's edits (e.g. eat the "://" as they type it). The URL is
299299 // normalized once, on Connect.
300+ // Clear any prior error as soon as the user edits a field, so a stale
301+ // message (e.g. "Invalid credentials") doesn't linger while they correct it.
300302 Event::ServerUrlChanged(url) => {
301303 model.server_url = url;
304+ if model.error.is_some() {
305+ model.error = None;
306+ }
302307 render()
303308 }
304309 Event::UsernameChanged(u) => {
305310 model.username = u;
311+ if model.error.is_some() {
312+ model.error = None;
313+ }
306314 render()
307315 }
308316 Event::PasswordChanged(p) => {
309317 model.password = p;
318+ if model.error.is_some() {
319+ model.error = None;
320+ }
310321 render()
311322 }
312323