git.iko.soy/opencode-android
on master help sign in
UX/UI: animated scroll FAB, keyboard send action, empty state icon, chat loading indicator

Bug fixes: call resumeStreamingIfConnected on init, show auth fields on 401 with creds, clear streaming on SessionError, reset generating on NavigateToConnect

commitce9b0b682b975a38836775f4e4419960bbbab1b1 authoriko <mail@iko.soy> dateWed 8 Jul 2026, 21:11 (3 months ago) parent94e3824
4 files changed+71−20
app/src/main/java/soy/iko/opencode/CoreViewModel.kt+4 app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+6−1 app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+51−19 app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+10
app/src/main/java/soy/iko/opencode/CoreViewModel.kt+4
@@ -24,6 +24,10 @@ class CoreViewModel(app: Application) : AndroidViewModel(app) {
2424 prefs.getString(KEY_URL, null)?.let { core.dispatch(Event.ServerUrlChanged(it)) }
2525 prefs.getString(KEY_USER, null)?.let { core.dispatch(Event.UsernameChanged(it)) }
2626
27+ // On a fresh process after a configuration change, resume the SSE stream if
28+ // the core is already connected (no-op otherwise — it guards on `connected`).
29+ core.resumeStreamingIfConnected()
30+
2731 // Persist connection details once a connection succeeds.
2832 viewModelScope.launch {
2933 core.view.distinctUntilChangedBy { it.connected to it.serverUrl }.collect { state ->
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+6−1
@@ -134,6 +134,7 @@ class OpencodeCore(
134134 screen = Screen.Connect
135135 connected = false
136136 loading = false
137+ generating = false
137138 sessions.clear()
138139 messages.clear()
139140 currentSessionId = null
@@ -198,7 +199,10 @@ class OpencodeCore(
198199 when {
199200 resp.code == 401 -> {
200201 loading = false
201− if (hasCreds) error = "Invalid credentials" else authRequired = true
202+ // Always reveal the auth fields so the user can see/edit
203+ // their credentials — even when the ones supplied were wrong.
204+ authRequired = true
205+ if (hasCreds) error = "Invalid credentials"
202206 emit()
203207 }
204208 resp.code in 200..299 -> {
@@ -374,6 +378,7 @@ class OpencodeCore(
374378 if (scoped) {
375379 event.error?.let { error = it.message() }
376380 generating = false
381+ messages.forEach { it.streaming = false }
377382 }
378383 }
379384
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+51−19
@@ -1,7 +1,13 @@
11package soy.iko.opencode.ui.screens
22
3+import androidx.compose.animation.AnimatedVisibility
4+import androidx.compose.animation.fadeIn
5+import androidx.compose.animation.fadeOut
6+import androidx.compose.animation.scaleIn
7+import androidx.compose.animation.scaleOut
38import androidx.compose.foundation.layout.Arrangement
49import androidx.compose.foundation.layout.Box
10+import androidx.compose.foundation.layout.BoxScope
511import androidx.compose.foundation.layout.Column
612import androidx.compose.foundation.layout.Row
713import androidx.compose.foundation.layout.fillMaxSize
@@ -13,6 +19,8 @@ import androidx.compose.foundation.layout.size
1319import androidx.compose.foundation.lazy.LazyColumn
1420import androidx.compose.foundation.lazy.items
1521import androidx.compose.foundation.lazy.rememberLazyListState
22+import androidx.compose.foundation.text.KeyboardActions
23+import androidx.compose.foundation.text.KeyboardOptions
1624import androidx.compose.material.icons.Icons
1725import androidx.compose.material.icons.automirrored.filled.ArrowBack
1826import androidx.compose.material.icons.automirrored.filled.Send
@@ -24,6 +32,7 @@ import androidx.compose.material3.FilledIconButton
2432import androidx.compose.material3.FloatingActionButton
2533import androidx.compose.material3.Icon
2634import androidx.compose.material3.IconButton
35+import androidx.compose.material3.LinearProgressIndicator
2736import androidx.compose.material3.MaterialTheme
2837import androidx.compose.material3.OutlinedTextField
2938import androidx.compose.material3.Scaffold
@@ -38,6 +47,7 @@ import androidx.compose.runtime.remember
3847import androidx.compose.runtime.rememberCoroutineScope
3948import androidx.compose.ui.Alignment
4049import androidx.compose.ui.Modifier
50+import androidx.compose.ui.text.input.ImeAction
4151import androidx.compose.ui.text.style.TextOverflow
4252import androidx.compose.ui.unit.dp
4353import kotlinx.coroutines.launch
@@ -117,6 +127,9 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
117127 if (!state.sseConnected && !state.generating) {
118128 ReconnectingBanner()
119129 }
130+ if (state.loading && state.messages.isEmpty()) {
131+ LinearProgressIndicator(Modifier.fillMaxWidth())
132+ }
120133 Box(Modifier.fillMaxWidth().weight(1f)) {
121134 if (state.messages.isEmpty() && !state.loading) {
122135 Text(
@@ -135,31 +148,46 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
135148 MessageBubble(message)
136149 }
137150 }
138− if (showScrollButton) {
139− FloatingActionButton(
140− onClick = {
141− coroutineScope.launch {
142− if (state.messages.isNotEmpty()) {
143− listState.animateScrollToItem(state.messages.size - 1)
144− }
151+ ScrollToBottomButton(
152+ visible = showScrollButton,
153+ onClick = {
154+ coroutineScope.launch {
155+ if (state.messages.isNotEmpty()) {
156+ listState.animateScrollToItem(state.messages.size - 1)
145157 }
146− },
147− modifier = Modifier
148− .align(Alignment.BottomEnd)
149− .padding(16.dp)
150− .size(44.dp),
151− ) {
152− Icon(
153− Icons.Filled.KeyboardArrowDown,
154− contentDescription = "Scroll to latest message",
155− )
156− }
157− }
158+ }
159+ },
160+ )
158161 }
159162 }
160163 }
161164}
162165
166+// Extracted into a BoxScope extension so the top-level AnimatedVisibility overload
167+// resolves cleanly. Called inline inside a Column, the enclosing ColumnScope receiver
168+// would otherwise capture the ColumnScope.AnimatedVisibility overload and fail to compile.
169+@Composable
170+private fun BoxScope.ScrollToBottomButton(visible: Boolean, onClick: () -> Unit) {
171+ AnimatedVisibility(
172+ visible = visible,
173+ modifier = Modifier.align(Alignment.BottomEnd),
174+ enter = scaleIn() + fadeIn(),
175+ exit = scaleOut() + fadeOut(),
176+ ) {
177+ FloatingActionButton(
178+ onClick = onClick,
179+ modifier = Modifier
180+ .padding(16.dp)
181+ .size(44.dp),
182+ ) {
183+ Icon(
184+ Icons.Filled.KeyboardArrowDown,
185+ contentDescription = "Scroll to latest message",
186+ )
187+ }
188+ }
189+}
190+
163191@Composable
164192private fun ReconnectingBanner() {
165193 Surface(
@@ -205,6 +233,10 @@ private fun ChatInputBar(
205233 modifier = Modifier.weight(1f),
206234 placeholder = { Text("Message opencode…") },
207235 maxLines = 5,
236+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
237+ keyboardActions = KeyboardActions(onSend = {
238+ if (!generating && draft.isNotBlank()) onSend()
239+ }),
208240 )
209241 if (generating) {
210242 FilledIconButton(onClick = onStop) {
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+10
@@ -5,9 +5,12 @@ import androidx.compose.foundation.layout.Arrangement
55import androidx.compose.foundation.layout.Column
66import androidx.compose.foundation.layout.PaddingValues
77import androidx.compose.foundation.layout.Row
8+import androidx.compose.foundation.layout.Spacer
89import androidx.compose.foundation.layout.fillMaxSize
910import androidx.compose.foundation.layout.fillMaxWidth
11+import androidx.compose.foundation.layout.height
1012import androidx.compose.foundation.layout.padding
13+import androidx.compose.foundation.layout.size
1114import androidx.compose.foundation.lazy.LazyColumn
1215import androidx.compose.foundation.lazy.items
1316import androidx.compose.material.icons.Icons
@@ -167,6 +170,13 @@ private fun EmptySessions() {
167170 verticalArrangement = Arrangement.Center,
168171 horizontalAlignment = Alignment.CenterHorizontally,
169172 ) {
173+ Icon(
174+ Icons.Filled.ChatBubbleOutline,
175+ contentDescription = null,
176+ modifier = Modifier.size(64.dp),
177+ tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
178+ )
179+ Spacer(Modifier.height(16.dp))
170180 Text(
171181 text = "No sessions yet",
172182 style = MaterialTheme.typography.titleMedium,