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
cd89013iko
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+35−7
@@ -51,15 +51,23 @@ class OpencodeCore(
51 private var reconnectAttempts = 0
51 private var reconnectAttempts = 0
52 private var reconnectJob: Job? = null
52 private var reconnectJob: Job? = null
53
53
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+
54 // ── Event entry point ──────────────────────────────────────────────────────
60 // ── Event entry point ──────────────────────────────────────────────────────
55 fun dispatch(event: Event) {
61 fun dispatch(event: Event) {
56 when (event) {
62 when (event) {
57 // Store the field verbatim while typing; normalizing here would fight
63 // Store the field verbatim while typing; normalizing here would fight
58 // the user's edits (e.g. eat the "://" as they type it). The URL is
64 // the user's edits (e.g. eat the "://" as they type it). The URL is
59 // normalized once, on Connect.
65 // normalized once, on Connect.
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.
60− is Event.ServerUrlChanged -> { serverUrl = event.url; emit() }
68+ is Event.ServerUrlChanged -> { serverUrl = event.url; if (error != null) error = null; emit() }
61− is Event.UsernameChanged -> { username = event.value; emit() }
69+ is Event.UsernameChanged -> { username = event.value; if (error != null) error = null; emit() }
62− is Event.PasswordChanged -> { password = event.value; emit() }
70+ is Event.PasswordChanged -> { password = event.value; if (error != null) error = null; emit() }
63
71
64 Event.Connect -> {
72 Event.Connect -> {
65 serverUrl = normalizeUrl(serverUrl)
73 serverUrl = normalizeUrl(serverUrl)
@@ -73,12 +81,21 @@ class OpencodeCore(
73 scope.launch { probeHealth() }
81 scope.launch { probeHealth() }
74 }
82 }
75
83
84+ Event.LoadSessions -> {
85+ loading = true; emit()
86+ loadSessionsJob?.cancel()
76− Event.LoadSessions -> { loading = true; emit(); scope.launch { loadSessions() } }
87+ loadSessionsJob = scope.launch { loadSessions() }
88+ }
77
89
78 is Event.SelectSession -> {
90 is Event.SelectSession -> {
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.
79− currentSessionTitle = sessions.firstOrNull { it.id == event.id }?.title.orEmpty()
93+ val session = sessions.firstOrNull { it.id == event.id }
94+ if (session == null) { error = "Session no longer exists"; emit(); return }
95+ currentSessionTitle = session.title
80 currentSessionId = event.id
96 currentSessionId = event.id
81 messages.clear()
97 messages.clear()
98+ removedMessageIds.clear()
82 screen = Screen.Chat
99 screen = Screen.Chat
83 loading = true
100 loading = true
84 generating = false
101 generating = false
@@ -134,10 +151,12 @@ class OpencodeCore(
134 screen = Screen.Sessions
151 screen = Screen.Sessions
135 currentSessionId = null
152 currentSessionId = null
136 messages.clear()
153 messages.clear()
154+ removedMessageIds.clear()
137 generating = false
155 generating = false
138 loading = true
156 loading = true
139 emit()
157 emit()
158+ loadSessionsJob?.cancel()
140− scope.launch { loadSessions() }
159+ loadSessionsJob = scope.launch { loadSessions() }
141 }
160 }
142
161
143 Event.NavigateToConnect -> {
162 Event.NavigateToConnect -> {
@@ -147,6 +166,7 @@ class OpencodeCore(
147 generating = false
166 generating = false
148 sessions.clear()
167 sessions.clear()
149 messages.clear()
168 messages.clear()
169+ removedMessageIds.clear()
150 currentSessionId = null
170 currentSessionId = null
151 // Reset to the default so a late-firing close callback from the old
171 // Reset to the default so a late-firing close callback from the old
152 // SSE connection can't flash the reconnecting banner on the next connect.
172 // SSE connection can't flash the reconnecting banner on the next connect.
@@ -294,6 +314,7 @@ class OpencodeCore(
294 loading = false
314 loading = false
295 if (resp.code in 200..299) {
315 if (resp.code in 200..299) {
296 messages.clear()
316 messages.clear()
317+ removedMessageIds.clear()
297 messages.addAll(Protocol.parseMessages(resp.body).map(::messageFromWire))
318 messages.addAll(Protocol.parseMessages(resp.body).map(::messageFromWire))
298 generating = messages.any { it.streaming }
319 generating = messages.any { it.streaming }
299 } else {
320 } else {
@@ -367,6 +388,9 @@ class OpencodeCore(
367 is Protocol.Event.MessageUpdated -> {
388 is Protocol.Event.MessageUpdated -> {
368 val info = event.info
389 val info = event.info
369 if (info.role != "assistant" || !isCurrent(info.sessionId)) return false
390 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
370 val streaming = info.time.completed == null && info.error == null
394 val streaming = info.time.completed == null && info.error == null
371 val existing = messages.firstOrNull { it.id == info.id }
395 val existing = messages.firstOrNull { it.id == info.id }
372 if (existing != null) {
396 if (existing != null) {
@@ -408,7 +432,11 @@ class OpencodeCore(
408 return true
432 return true
409 }
433 }
410
434
435+ is Protocol.Event.MessageRemoved -> {
436+ // Remember the ID so a late MessageUpdated can't resurrect the message.
437+ removedMessageIds.add(event.messageId)
411− is Protocol.Event.MessageRemoved -> return messages.removeAll { it.id == event.messageId }
438+ return messages.removeAll { it.id == event.messageId }
439+ }
412
440
413 is Protocol.Event.SessionIdle -> {
441 is Protocol.Event.SessionIdle -> {
414 if (!isCurrent(event.sessionId)) return false
442 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
42import androidx.compose.ui.text.withStyle
42import androidx.compose.ui.text.withStyle
43import androidx.compose.ui.text.style.TextOverflow
43import androidx.compose.ui.text.style.TextOverflow
44import androidx.compose.ui.unit.dp
44import androidx.compose.ui.unit.dp
45+import androidx.compose.ui.unit.sp
45import soy.iko.opencode.core.MessageStatus
46import soy.iko.opencode.core.MessageStatus
46import soy.iko.opencode.core.MessageView
47import soy.iko.opencode.core.MessageView
47import soy.iko.opencode.core.ToolView
48import soy.iko.opencode.core.ToolView
@@ -95,7 +96,13 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
95 Text(
96 Text(
96 text = message.text + if (message.streaming) " ▌" else "",
97 text = message.text + if (message.streaming) " ▌" else "",
97 color = textColor,
98 color = textColor,
99+ // Give assistant replies (often long, code-heavy) extra line
100+ // spacing for readability; user bubbles keep the default.
101+ style = if (isUser) {
98− style = MaterialTheme.typography.bodyLarge,
102+ MaterialTheme.typography.bodyLarge
103+ } else {
104+ MaterialTheme.typography.bodyLarge.copy(lineHeight = 22.sp)
105+ },
99 )
106 )
100 } else if (message.streaming && message.tools.isEmpty() && message.reasoning == null) {
107 } else if (message.streaming && message.tools.isEmpty() && message.reasoning == null) {
101 Text("▌", color = textColor, style = MaterialTheme.typography.bodyLarge)
108 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
12import androidx.compose.foundation.layout.BoxScope
12import androidx.compose.foundation.layout.BoxScope
13import androidx.compose.foundation.layout.Column
13import androidx.compose.foundation.layout.Column
14import androidx.compose.foundation.layout.Row
14import androidx.compose.foundation.layout.Row
15+import androidx.compose.foundation.layout.Spacer
15import androidx.compose.foundation.layout.fillMaxSize
16import androidx.compose.foundation.layout.fillMaxSize
16import androidx.compose.foundation.layout.fillMaxWidth
17import androidx.compose.foundation.layout.fillMaxWidth
17import androidx.compose.foundation.layout.PaddingValues
18import androidx.compose.foundation.layout.PaddingValues
@@ -33,6 +34,7 @@ import androidx.compose.material.icons.filled.Stop
33import androidx.compose.material3.CircularProgressIndicator
34import androidx.compose.material3.CircularProgressIndicator
34import androidx.compose.material3.ExperimentalMaterial3Api
35import androidx.compose.material3.ExperimentalMaterial3Api
35import androidx.compose.material3.FilledIconButton
36import androidx.compose.material3.FilledIconButton
37+import androidx.compose.material3.FilledTonalButton
36import androidx.compose.material3.FloatingActionButton
38import androidx.compose.material3.FloatingActionButton
37import androidx.compose.material3.Icon
39import androidx.compose.material3.Icon
38import androidx.compose.material3.IconButton
40import androidx.compose.material3.IconButton
@@ -308,8 +310,14 @@ private fun ChatInputBar(
308 }),
310 }),
309 )
311 )
310 if (generating) {
312 if (generating) {
311− FilledIconButton(onClick = onStop) {
313+ FilledTonalButton(onClick = onStop) {
314+ Icon(
315+ Icons.Filled.Stop,
312− Icon(Icons.Filled.Stop, contentDescription = "Stop generating")
316+ contentDescription = null,
317+ modifier = Modifier.size(18.dp),
318+ )
319+ Spacer(Modifier.size(6.dp))
320+ Text("Stop")
313 }
321 }
314 } else {
322 } else {
315 FilledIconButton(
323 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) {
162 contentPadding = PaddingValues(bottom = 88.dp),
162 contentPadding = PaddingValues(bottom = 88.dp),
163 ) {
163 ) {
164 items(filteredSessions, key = { it.id }) { session ->
164 items(filteredSessions, key = { it.id }) { session ->
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()) {
165− val dismissState = rememberSwipeToDismissBoxState(
168+ val dismissState = rememberSwipeToDismissBoxState(
166− // Only a swipe from the end (right-to-left) triggers deletion;
169+ // Only a swipe from the end (right-to-left) triggers deletion;
167− // return false so the row snaps back and the confirm dialog decides.
170+ // return false so the row snaps back and the confirm dialog decides.
168− confirmValueChange = { value ->
171+ confirmValueChange = { value ->
169− if (value == SwipeToDismissBoxValue.EndToStart) {
172+ if (value == SwipeToDismissBoxValue.EndToStart) {
170− pendingDeleteId = session.id
173+ pendingDeleteId = session.id
171− }
172− false
173− },
174+ }
174− )
175+ false
175− SwipeToDismissBox(
176+ },
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 },
184 )
177 )
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))
185 }
190 }
186− HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
187 }
191 }
188 }
192 }
189 }
193 }
app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+1−1
@@ -35,7 +35,7 @@ private val LightColors = lightColorScheme(
35@Composable
35@Composable
36fun OpencodeTheme(
36fun OpencodeTheme(
37 darkTheme: Boolean = isSystemInDarkTheme(),
37 darkTheme: Boolean = isSystemInDarkTheme(),
38− dynamicColor: Boolean = true,
38+ dynamicColor: Boolean = false,
39 content: @Composable () -> Unit,
39 content: @Composable () -> Unit,
40) {
40) {
41 val context = LocalContext.current
41 val context = LocalContext.current
shared/src/app.rs+11
@@ -297,16 +297,27 @@ impl App for OpencodeApp {
297 // Store the field verbatim while typing; normalizing here would fight
297 // Store the field verbatim while typing; normalizing here would fight
298 // the user's edits (e.g. eat the "://" as they type it). The URL is
298 // the user's edits (e.g. eat the "://" as they type it). The URL is
299 // normalized once, on Connect.
299 // 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.
300 Event::ServerUrlChanged(url) => {
302 Event::ServerUrlChanged(url) => {
301 model.server_url = url;
303 model.server_url = url;
304+ if model.error.is_some() {
305+ model.error = None;
306+ }
302 render()
307 render()
303 }
308 }
304 Event::UsernameChanged(u) => {
309 Event::UsernameChanged(u) => {
305 model.username = u;
310 model.username = u;
311+ if model.error.is_some() {
312+ model.error = None;
313+ }
306 render()
314 render()
307 }
315 }
308 Event::PasswordChanged(p) => {
316 Event::PasswordChanged(p) => {
309 model.password = p;
317 model.password = p;
318+ if model.error.is_some() {
319+ model.error = None;
320+ }
310 render()
321 render()
311 }
322 }
312
323