git.iko.soy/opencode-android
on master help sign in
UX/UI: FAB hides on keyboard, server URL hint, date separators in chat, hide FAB during search

Bug fixes: cancel stale loadMessages on session switch, add missing M3 color roles to custom palette

commit7571397d08d5ca6f08e419734a3b55582d7fac52 authoriko <mail@iko.soy> dateThu 9 Jul 2026, 03:02 (3 months ago) parentb19f82a
5 files changed+98−9
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+7−1 app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+58−3 app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+12 app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+9−5 app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+12
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+7−1
@@ -57,6 +57,9 @@ class OpencodeCore(
5757 // Tracks the in-flight createSession coroutine so navigating away can cancel it
5858 // before it dispatches SelectSession into a screen the user already left.
5959 private var createSessionJob: Job? = null
60+ // Tracks the in-flight loadMessages coroutine. Rapidly switching sessions launches
61+ // several; cancel the prior one so only the newest fetch runs (saves needless requests).
62+ private var loadMessagesJob: Job? = null
6063 // Message IDs removed via SSE; a late MessageUpdated for one must not resurrect it.
6164 private val removedMessageIds = mutableSetOf<String>()
6265
@@ -104,7 +107,8 @@ class OpencodeCore(
104107 generating = false
105108 error = null
106109 emit()
107− scope.launch { loadMessages(event.id) }
110+ loadMessagesJob?.cancel()
111+ loadMessagesJob = scope.launch { loadMessages(event.id) }
108112 }
109113
110114 Event.CreateSession -> {
@@ -164,6 +168,7 @@ class OpencodeCore(
164168 loading = true
165169 emit()
166170 createSessionJob?.cancel()
171+ loadMessagesJob?.cancel()
167172 loadSessionsJob?.cancel()
168173 loadSessionsJob = scope.launch { loadSessions() }
169174 }
@@ -182,6 +187,7 @@ class OpencodeCore(
182187 // SSE connection can't flash the reconnecting banner on the next connect.
183188 sseConnected = true
184189 createSessionJob?.cancel()
190+ loadMessagesJob?.cancel()
185191 closeSse()
186192 emit()
187193 }
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+58−3
@@ -21,11 +21,13 @@ import androidx.compose.foundation.layout.Spacer
2121import androidx.compose.foundation.layout.fillMaxSize
2222import androidx.compose.foundation.layout.fillMaxWidth
2323import androidx.compose.foundation.layout.PaddingValues
24+import androidx.compose.foundation.layout.WindowInsets
25+import androidx.compose.foundation.layout.ime
2426import androidx.compose.foundation.layout.imePadding
2527import androidx.compose.foundation.layout.padding
2628import androidx.compose.foundation.layout.size
2729import androidx.compose.foundation.lazy.LazyColumn
28−import androidx.compose.foundation.lazy.items
30+import androidx.compose.foundation.lazy.itemsIndexed
2931import androidx.compose.foundation.lazy.rememberLazyListState
3032import androidx.compose.foundation.shape.CircleShape
3133import androidx.compose.foundation.shape.RoundedCornerShape
@@ -63,9 +65,11 @@ import androidx.compose.ui.Alignment
6365import androidx.compose.ui.Modifier
6466import androidx.compose.ui.draw.clip
6567import androidx.compose.ui.hapticfeedback.HapticFeedbackType
68+import androidx.compose.ui.platform.LocalDensity
6669import androidx.compose.ui.platform.LocalHapticFeedback
6770import androidx.compose.ui.text.font.FontStyle
6871import androidx.compose.ui.text.input.ImeAction
72+import androidx.compose.ui.text.style.TextAlign
6973import androidx.compose.ui.text.style.TextOverflow
7074import androidx.compose.ui.unit.dp
7175import kotlinx.coroutines.launch
@@ -79,6 +83,11 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
7983 val listState = rememberLazyListState()
8084 val coroutineScope = rememberCoroutineScope()
8185
86+ // Hide the jump-to-bottom FAB while the keyboard is up: it would otherwise
87+ // sit over the top of the IME. Reading the ime inset bottom during composition
88+ // observes snapshot state, so this flips as the keyboard opens/closes.
89+ val isImeVisible = WindowInsets.ime.getBottom(LocalDensity.current) > 0
90+
8291 // Show a jump-to-bottom button once the user scrolls up away from the latest
8392 // message. derivedStateOf keeps this from recomposing on every pixel of scroll —
8493 // it only flips when the near-bottom threshold is actually crossed.
@@ -180,7 +189,13 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
180189 modifier = Modifier.fillMaxSize(),
181190 contentPadding = PaddingValues(vertical = 8.dp),
182191 ) {
183− items(state.messages, key = { it.id }) { message ->
192+ itemsIndexed(state.messages, key = { _, message -> message.id }) { index, message ->
193+ // Insert a date separator whenever a message falls on a different
194+ // calendar day than the one before it (and above the first message).
195+ val previous = state.messages.getOrNull(index - 1)
196+ if (shouldShowDateSeparator(previous, message)) {
197+ DateSeparator(message.time)
198+ }
184199 MessageBubble(
185200 message,
186201 onRetry = { dispatch(Event.SendMessage(message.text)) },
@@ -196,7 +211,7 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
196211 }
197212 }
198213 ScrollToBottomButton(
199− visible = showScrollButton,
214+ visible = showScrollButton && !isImeVisible,
200215 showBadge = hasUnseenMessages,
201216 onClick = {
202217 coroutineScope.launch {
@@ -245,6 +260,46 @@ private fun BoxScope.ScrollToBottomButton(visible: Boolean, showBadge: Boolean,
245260 }
246261}
247262
263+private val dateSeparatorFormat = java.time.format.DateTimeFormatter.ofPattern("MMM d")
264+
265+/** Convert an epoch-millis timestamp to the local calendar day it falls on. */
266+private fun localDayOf(epochMillis: Long): java.time.LocalDate =
267+ java.time.Instant.ofEpochMilli(epochMillis)
268+ .atZone(java.time.ZoneId.systemDefault())
269+ .toLocalDate()
270+
271+/**
272+ * A separator is shown above a message when it starts a new calendar day — i.e. the
273+ * first timestamped message, or one whose day differs from the previous message's.
274+ * Messages without a valid time (time <= 0) never get one.
275+ */
276+private fun shouldShowDateSeparator(previous: soy.iko.opencode.core.MessageView?, current: soy.iko.opencode.core.MessageView): Boolean {
277+ if (current.time <= 0) return false
278+ if (previous == null || previous.time <= 0) return true
279+ return localDayOf(previous.time) != localDayOf(current.time)
280+}
281+
282+/** Human-friendly day label: "Today", "Yesterday", or "MMM d" (e.g. "Jul 8"). */
283+private fun dateSeparatorLabel(day: java.time.LocalDate): String {
284+ val today = java.time.LocalDate.now()
285+ return when (day) {
286+ today -> "Today"
287+ today.minusDays(1) -> "Yesterday"
288+ else -> day.format(dateSeparatorFormat)
289+ }
290+}
291+
292+@Composable
293+private fun DateSeparator(epochMillis: Long) {
294+ Text(
295+ text = dateSeparatorLabel(localDayOf(epochMillis)),
296+ style = MaterialTheme.typography.labelSmall,
297+ color = MaterialTheme.colorScheme.onSurfaceVariant,
298+ textAlign = TextAlign.Center,
299+ modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
300+ )
301+}
302+
248303// Assistant-style bubble with three sequentially-pulsing dots, shown as a stand-in
249304// until the real streaming reply starts. Mirrors MessageBubble's left-aligned,
250305// surfaceVariant look so it reads as an incoming message.
app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+12
@@ -101,6 +101,18 @@ fun ConnectScreen(state: UiState, dispatch: (Event) -> Unit) {
101101 enabled = !state.loading,
102102 )
103103
104+ // Point first-time users at the expected address format. Hidden once they
105+ // start typing so it doesn't compete with the field's own contents.
106+ if (state.serverUrl.isEmpty()) {
107+ Spacer(Modifier.height(6.dp))
108+ Text(
109+ text = "Enter the URL of your opencode server (e.g. http://192.168.1.10:4096)",
110+ style = MaterialTheme.typography.bodySmall,
111+ color = MaterialTheme.colorScheme.onSurfaceVariant,
112+ textAlign = TextAlign.Center,
113+ )
114+ }
115+
104116 if (state.authRequired) {
105117 Spacer(Modifier.height(12.dp))
106118 OutlinedTextField(
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+9−5
@@ -112,11 +112,15 @@ fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
112112 )
113113 },
114114 floatingActionButton = {
115− ExtendedFloatingActionButton(
116− onClick = { dispatch(Event.CreateSession) },
117− icon = { Icon(Icons.Filled.Add, contentDescription = null) },
118− text = { Text("New session") },
119− )
115+ // Give the search results the full width while searching — the FAB would
116+ // otherwise cover the bottom rows and isn't relevant mid-search anyway.
117+ if (searchQuery.isEmpty()) {
118+ ExtendedFloatingActionButton(
119+ onClick = { dispatch(Event.CreateSession) },
120+ icon = { Icon(Icons.Filled.Add, contentDescription = null) },
121+ text = { Text("New session") },
122+ )
123+ }
120124 },
121125 ) { padding ->
122126 Column(Modifier.fillMaxSize().padding(padding)) {
app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+12
@@ -14,7 +14,13 @@ import androidx.compose.ui.platform.LocalContext
1414private val DarkColors = darkColorScheme(
1515 primary = Emerald,
1616 onPrimary = Color(0xFF0F1115),
17+ primaryContainer = Color(0xFF1A3D2A),
18+ onPrimaryContainer = Color(0xFFA8E6C1),
1719 secondary = EmeraldDark,
20+ tertiaryContainer = Color(0xFF2A2A3D),
21+ onTertiaryContainer = Color(0xFFC5C5E6),
22+ errorContainer = Color(0xFF4D2626),
23+ onErrorContainer = Color(0xFFFFB4B4),
1824 background = DarkBackground,
1925 surface = DarkSurface,
2026 surfaceVariant = DarkSurfaceVariant,
@@ -24,7 +30,13 @@ private val DarkColors = darkColorScheme(
2430
2531private val LightColors = lightColorScheme(
2632 primary = EmeraldDark,
33+ primaryContainer = Color(0xFFD7F5E3),
34+ onPrimaryContainer = Color(0xFF0A3D22),
2735 secondary = Emerald,
36+ tertiaryContainer = Color(0xFFE8E8F5),
37+ onTertiaryContainer = Color(0xFF2A2A4D),
38+ errorContainer = Color(0xFFFFE0E0),
39+ onErrorContainer = Color(0xFF660000),
2840 background = LightBackground,
2941 surface = LightSurface,
3042 surfaceVariant = LightSurfaceVariant,