git.iko.soy/opencode-android
on master help sign in
UX/UI: app icon on connect screen, dismiss action on error snackbar, new-messages badge on scroll FAB, swipe-to-delete on sessions

Bug fixes: thread-safe DateTimeFormatter, pull-to-refresh indicator isolation, auth-required empty credentials guard, progress indicator overlap fix

commit9ffe468f054eb3260ac66f8a41011517cdd0ae46 authoriko <mail@iko.soy> dateWed 8 Jul 2026, 21:34 (3 months ago) parentaa7f54c
6 files changed+119−24
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+3 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+11−1 app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+2−5 app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+36−11 app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+9 app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+58−7
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+3
@@ -62,6 +62,9 @@ class OpencodeCore(
6262 Event.Connect -> {
6363 serverUrl = normalizeUrl(serverUrl)
6464 if (serverUrl.isEmpty()) { error = "Enter a server URL"; emit(); return }
65+ // The auth fields are showing but empty — sending the request now
66+ // would just 401 again in a loop. Prompt for credentials instead.
67+ if (authRequired && !hasCredentials()) { error = "Enter username and password"; emit(); return }
6568 loading = true; error = null; authRequired = false; emit()
6669 scope.launch { probeHealth() }
6770 }
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+11−1
@@ -9,8 +9,10 @@ import androidx.compose.animation.togetherWith
99import androidx.compose.foundation.layout.fillMaxSize
1010import androidx.compose.foundation.layout.padding
1111import androidx.compose.material3.Scaffold
12+import androidx.compose.material3.SnackbarDuration
1213import androidx.compose.material3.SnackbarHost
1314import androidx.compose.material3.SnackbarHostState
15+import androidx.compose.material3.SnackbarResult
1416import androidx.compose.runtime.Composable
1517import androidx.compose.runtime.LaunchedEffect
1618import androidx.compose.runtime.remember
@@ -28,7 +30,15 @@ fun OpencodeApp(state: UiState, dispatch: (Event) -> Unit) {
2830
2931 LaunchedEffect(state.error) {
3032 state.error?.let {
31− snackbar.showSnackbar(it)
33+ val result = snackbar.showSnackbar(
34+ message = it,
35+ actionLabel = "Dismiss",
36+ duration = SnackbarDuration.Short,
37+ )
38+ if (result == SnackbarResult.ActionPerformed) {
39+ dispatch(Event.DismissError)
40+ }
41+ // Always clear the error from state after showing.
3242 dispatch(Event.DismissError)
3343 }
3444 }
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+2−5
@@ -37,15 +37,12 @@ import androidx.compose.ui.platform.LocalContext
3737import androidx.compose.ui.text.font.FontStyle
3838import androidx.compose.ui.text.style.TextOverflow
3939import androidx.compose.ui.unit.dp
40−import java.text.SimpleDateFormat
41−import java.util.Date
42−import java.util.Locale
4340import soy.iko.opencode.core.MessageStatus
4441import soy.iko.opencode.core.MessageView
4542import soy.iko.opencode.core.ToolView
4643import soy.iko.opencode.ui.theme.MonoStyle
4744
48−private val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault())
45+private val timeFormat = java.time.format.DateTimeFormatter.ofPattern("HH:mm")
4946
5047@OptIn(ExperimentalFoundationApi::class)
5148@Composable
@@ -100,7 +97,7 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
10097
10198 if (message.time > 0) {
10299 Text(
103− text = timeFormat.format(Date(message.time)),
100+ text = timeFormat.format(java.time.Instant.ofEpochMilli(message.time).atZone(java.time.ZoneId.systemDefault()).toLocalTime()),
104101 style = MaterialTheme.typography.labelSmall,
105102 color = mutedColor,
106103 modifier = Modifier.padding(top = 4.dp),
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+36−11
@@ -5,6 +5,7 @@ import androidx.compose.animation.fadeIn
55import androidx.compose.animation.fadeOut
66import androidx.compose.animation.scaleIn
77import androidx.compose.animation.scaleOut
8+import androidx.compose.foundation.background
89import androidx.compose.foundation.layout.Arrangement
910import androidx.compose.foundation.layout.Box
1011import androidx.compose.foundation.layout.BoxScope
@@ -19,6 +20,7 @@ import androidx.compose.foundation.layout.size
1920import androidx.compose.foundation.lazy.LazyColumn
2021import androidx.compose.foundation.lazy.items
2122import androidx.compose.foundation.lazy.rememberLazyListState
23+import androidx.compose.foundation.shape.CircleShape
2224import androidx.compose.foundation.text.KeyboardActions
2325import androidx.compose.foundation.text.KeyboardOptions
2426import androidx.compose.material.icons.Icons
@@ -43,10 +45,13 @@ import androidx.compose.runtime.Composable
4345import androidx.compose.runtime.LaunchedEffect
4446import androidx.compose.runtime.derivedStateOf
4547import androidx.compose.runtime.getValue
48+import androidx.compose.runtime.mutableStateOf
4649import androidx.compose.runtime.remember
4750import androidx.compose.runtime.rememberCoroutineScope
51+import androidx.compose.runtime.setValue
4852import androidx.compose.ui.Alignment
4953import androidx.compose.ui.Modifier
54+import androidx.compose.ui.draw.clip
5055import androidx.compose.ui.hapticfeedback.HapticFeedbackType
5156import androidx.compose.ui.platform.LocalHapticFeedback
5257import androidx.compose.ui.text.input.ImeAction
@@ -75,6 +80,16 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
7580 }
7681 }
7782
83+ // Track how many messages the user has "seen" (i.e. was at the bottom for). While
84+ // they're scrolled up, any messages beyond that count are unseen — flag the FAB.
85+ var lastSeenCount by remember { mutableStateOf(0) }
86+ LaunchedEffect(showScrollButton, state.messages.size) {
87+ if (!showScrollButton) {
88+ lastSeenCount = state.messages.size
89+ }
90+ }
91+ val hasUnseenMessages = showScrollButton && state.messages.size > lastSeenCount
92+
7893 // Keep the newest content in view as it streams in, but only when the user is
7994 // already near the bottom. If they've scrolled up to read history, leave it alone.
8095 val lastLen = state.messages.lastOrNull()?.text?.length ?: 0
@@ -152,6 +167,7 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
152167 }
153168 ScrollToBottomButton(
154169 visible = showScrollButton,
170+ showBadge = hasUnseenMessages,
155171 onClick = {
156172 coroutineScope.launch {
157173 if (state.messages.isNotEmpty()) {
@@ -169,23 +185,32 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
169185// resolves cleanly. Called inline inside a Column, the enclosing ColumnScope receiver
170186// would otherwise capture the ColumnScope.AnimatedVisibility overload and fail to compile.
171187@Composable
172−private fun BoxScope.ScrollToBottomButton(visible: Boolean, onClick: () -> Unit) {
188+private fun BoxScope.ScrollToBottomButton(visible: Boolean, showBadge: Boolean, onClick: () -> Unit) {
173189 AnimatedVisibility(
174190 visible = visible,
175191 modifier = Modifier.align(Alignment.BottomEnd),
176192 enter = scaleIn() + fadeIn(),
177193 exit = scaleOut() + fadeOut(),
178194 ) {
179− FloatingActionButton(
180− onClick = onClick,
181− modifier = Modifier
182− .padding(16.dp)
183− .size(44.dp),
184− ) {
185− Icon(
186− Icons.Filled.KeyboardArrowDown,
187− contentDescription = "Scroll to latest message",
188− )
195+ Box(Modifier.padding(16.dp)) {
196+ FloatingActionButton(
197+ onClick = onClick,
198+ modifier = Modifier.size(44.dp),
199+ ) {
200+ Icon(
201+ Icons.Filled.KeyboardArrowDown,
202+ contentDescription = "Scroll to latest message",
203+ )
204+ }
205+ if (showBadge) {
206+ Box(
207+ modifier = Modifier
208+ .align(Alignment.TopEnd)
209+ .size(8.dp)
210+ .clip(CircleShape)
211+ .background(MaterialTheme.colorScheme.error),
212+ )
213+ }
189214 }
190215 }
191216}
app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+9
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
88import androidx.compose.foundation.layout.height
99import androidx.compose.foundation.layout.imePadding
1010import androidx.compose.foundation.layout.padding
11+import androidx.compose.foundation.layout.size
1112import androidx.compose.foundation.rememberScrollState
1213import androidx.compose.foundation.text.KeyboardActions
1314import androidx.compose.foundation.text.KeyboardOptions
@@ -15,6 +16,7 @@ import androidx.compose.foundation.verticalScroll
1516import androidx.compose.material.icons.Icons
1617import androidx.compose.material.icons.filled.Lock
1718import androidx.compose.material.icons.filled.Person
19+import androidx.compose.material.icons.filled.Terminal
1820import androidx.compose.material3.Button
1921import androidx.compose.material3.CircularProgressIndicator
2022import androidx.compose.material3.Icon
@@ -43,6 +45,13 @@ fun ConnectScreen(state: UiState, dispatch: (Event) -> Unit) {
4345 verticalArrangement = Arrangement.Center,
4446 horizontalAlignment = Alignment.CenterHorizontally,
4547 ) {
48+ Icon(
49+ Icons.Filled.Terminal,
50+ contentDescription = null,
51+ tint = MaterialTheme.colorScheme.primary,
52+ modifier = Modifier.size(72.dp),
53+ )
54+ Spacer(Modifier.height(16.dp))
4655 Text(
4756 text = "opencode",
4857 style = MaterialTheme.typography.displaySmall,
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+58−7
@@ -1,7 +1,9 @@
11package soy.iko.opencode.ui.screens
22
3+import androidx.compose.foundation.background
34import androidx.compose.foundation.clickable
45import androidx.compose.foundation.layout.Arrangement
6+import androidx.compose.foundation.layout.Box
57import androidx.compose.foundation.layout.Column
68import androidx.compose.foundation.layout.PaddingValues
79import androidx.compose.foundation.layout.Row
@@ -28,12 +30,16 @@ import androidx.compose.material3.IconButton
2830import androidx.compose.material3.LinearProgressIndicator
2931import androidx.compose.material3.MaterialTheme
3032import androidx.compose.material3.Scaffold
33+import androidx.compose.material3.SwipeToDismissBox
34+import androidx.compose.material3.SwipeToDismissBoxValue
3135import androidx.compose.material3.Text
3236import androidx.compose.material3.TextButton
3337import androidx.compose.material3.TopAppBar
38+import androidx.compose.material3.rememberSwipeToDismissBoxState
3439import androidx.compose.material3.pulltorefresh.PullToRefreshBox
3540import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
3641import androidx.compose.runtime.Composable
42+import androidx.compose.runtime.LaunchedEffect
3743import androidx.compose.runtime.getValue
3844import androidx.compose.runtime.mutableStateOf
3945import androidx.compose.runtime.remember
@@ -53,6 +59,13 @@ import soy.iko.opencode.core.UiState
5359fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
5460 val haptic = LocalHapticFeedback.current
5561 var pendingDeleteId by remember { mutableStateOf<String?>(null) }
62+ var isPullRefreshing by remember { mutableStateOf(false) }
63+
64+ // The pull-to-refresh spinner should only reflect a user-initiated pull, not
65+ // every loading operation. Clear it once the underlying load completes.
66+ LaunchedEffect(state.loading) {
67+ if (!state.loading) isPullRefreshing = false
68+ }
5669
5770 pendingDeleteId?.let { deleteId ->
5871 val pendingSession = state.sessions.firstOrNull { it.id == deleteId }
@@ -102,14 +115,17 @@ fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
102115 },
103116 ) { padding ->
104117 Column(Modifier.fillMaxSize().padding(padding)) {
105− if (state.loading) {
118+ if (state.loading && !isPullRefreshing) {
106119 LinearProgressIndicator(Modifier.fillMaxWidth())
107120 }
108121 when {
109122 state.sessions.isEmpty() && !state.loading -> EmptySessions()
110123 else -> PullToRefreshBox(
111− isRefreshing = state.loading,
112− onRefresh = { dispatch(Event.LoadSessions) },
124+ isRefreshing = isPullRefreshing,
125+ onRefresh = {
126+ isPullRefreshing = true
127+ dispatch(Event.LoadSessions)
128+ },
113129 modifier = Modifier.fillMaxSize(),
114130 state = rememberPullToRefreshState(),
115131 ) {
@@ -118,11 +134,27 @@ fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
118134 contentPadding = PaddingValues(bottom = 88.dp),
119135 ) {
120136 items(state.sessions, key = { it.id }) { session ->
121− SessionRow(
122− session = session,
123− onOpen = { dispatch(Event.SelectSession(session.id)) },
124− onDelete = { pendingDeleteId = session.id },
137+ val dismissState = rememberSwipeToDismissBoxState(
138+ // Only a swipe from the end (right-to-left) triggers deletion;
139+ // return false so the row snaps back and the confirm dialog decides.
140+ confirmValueChange = { value ->
141+ if (value == SwipeToDismissBoxValue.EndToStart) {
142+ pendingDeleteId = session.id
143+ }
144+ false
145+ },
125146 )
147+ SwipeToDismissBox(
148+ state = dismissState,
149+ enableDismissFromStartToEnd = false,
150+ backgroundContent = { SwipeDeleteBackground() },
151+ ) {
152+ SessionRow(
153+ session = session,
154+ onOpen = { dispatch(Event.SelectSession(session.id)) },
155+ onDelete = { pendingDeleteId = session.id },
156+ )
157+ }
126158 HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
127159 }
128160 }
@@ -137,6 +169,7 @@ private fun SessionRow(session: SessionView, onOpen: () -> Unit, onDelete: () ->
137169 Row(
138170 modifier = Modifier
139171 .fillMaxWidth()
172+ .background(MaterialTheme.colorScheme.surface)
140173 .clickable(onClick = onOpen)
141174 .padding(start = 20.dp, end = 8.dp, top = 14.dp, bottom = 14.dp),
142175 verticalAlignment = Alignment.CenterVertically,
@@ -172,6 +205,24 @@ private fun SessionRow(session: SessionView, onOpen: () -> Unit, onDelete: () ->
172205 }
173206}
174207
208+/** Red background with a trailing delete icon, revealed while swiping a session row away. */
209+@Composable
210+private fun SwipeDeleteBackground() {
211+ Box(
212+ modifier = Modifier
213+ .fillMaxSize()
214+ .background(MaterialTheme.colorScheme.error)
215+ .padding(horizontal = 24.dp),
216+ contentAlignment = Alignment.CenterEnd,
217+ ) {
218+ Icon(
219+ Icons.Filled.DeleteOutline,
220+ contentDescription = null,
221+ tint = MaterialTheme.colorScheme.onError,
222+ )
223+ }
224+}
225+
175226/** Shorten a session ID for display: "abc12345…" instead of the full UUID. */
176227private fun shortSessionId(id: String): String =
177228 if (id.length > 12) id.take(8) + "…" else id