git.iko.soy/opencode-android
on master help sign in
UX/UI: scroll-to-bottom FAB, SSE reconnection banner, collapsible tool rows, friendlier session IDs

Bug fixes: loading state on NavigateToSessions, reset loading on NavigateToConnect, remove dead code (authed, Color0x0F1115), loading indicator for DeleteSession

commit94e38240917b4a827a7187956d9a3ee467273de7 authoriko <mail@iko.soy> dateWed 8 Jul 2026, 21:03 (3 months ago) parent8205c19
6 files changed+124−25
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+17−4 app/src/main/java/soy/iko/opencode/core/ViewModel.kt+1 app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+16−3 app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+85−15 app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+5−1 app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt−2
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+17−4
@@ -29,7 +29,6 @@ class OpencodeCore(
2929 private var username = ""
3030 private var password = ""
3131 private var authRequired = false
32− private var authed = false
3332 private var connected = false
3433 private var loading = false
3534 private var error: String? = null
@@ -40,6 +39,7 @@ class OpencodeCore(
4039 private val messages = mutableListOf<MsgState>()
4140 private var draft = ""
4241 private var generating = false
42+ private var sseConnected = true
4343 private var localSeq = 0L
4444
4545 private val _view = MutableStateFlow(snapshot())
@@ -85,6 +85,7 @@ class OpencodeCore(
8585 val id = event.id
8686 // Delete on the server first; only drop it from the UI once that
8787 // succeeds, so a failed request leaves the session recoverable.
88+ loading = true; emit()
8889 scope.launch {
8990 http.delete("$serverUrl/session/$id", authHeader()).fold(
9091 onSuccess = { resp ->
@@ -98,8 +99,12 @@ class OpencodeCore(
9899 } else {
99100 error = "Failed to delete session"
100101 }
102+ loading = false
103+ },
104+ onFailure = {
105+ error = "Failed to delete session"
106+ loading = false
101107 },
102− onFailure = { error = "Failed to delete session" },
103108 )
104109 emit()
105110 }
@@ -120,6 +125,7 @@ class OpencodeCore(
120125 currentSessionId = null
121126 messages.clear()
122127 generating = false
128+ loading = true
123129 emit()
124130 scope.launch { loadSessions() }
125131 }
@@ -127,6 +133,7 @@ class OpencodeCore(
127133 Event.NavigateToConnect -> {
128134 screen = Screen.Connect
129135 connected = false
136+ loading = false
130137 sessions.clear()
131138 messages.clear()
132139 currentSessionId = null
@@ -195,7 +202,6 @@ class OpencodeCore(
195202 emit()
196203 }
197204 resp.code in 200..299 -> {
198− if (hasCreds) authed = true
199205 connected = true
200206 authRequired = false
201207 error = null
@@ -288,7 +294,13 @@ class OpencodeCore(
288294 url = "$serverUrl/event",
289295 auth = authHeader(),
290296 onEvent = { json -> scope.launch { handleServerEvent(json) } },
291− onStateChange = { open -> if (open) reconnectAttempts = 0 else scheduleReconnect() },
297+ onStateChange = { open ->
298+ scope.launch {
299+ sseConnected = open
300+ if (open) reconnectAttempts = 0 else scheduleReconnect()
301+ emit()
302+ }
303+ },
292304 )
293305 sse = client
294306 client.connect()
@@ -405,6 +417,7 @@ class OpencodeCore(
405417 messages = messages.map { it.toView() },
406418 draft = draft,
407419 generating = generating,
420+ sseConnected = sseConnected,
408421 )
409422
410423 private fun emit() { _view.value = snapshot() }
app/src/main/java/soy/iko/opencode/core/ViewModel.kt+1
@@ -48,6 +48,7 @@ data class UiState(
4848 val messages: List<MessageView> = emptyList(),
4949 val draft: String = "",
5050 val generating: Boolean = false,
51+ val sseConnected: Boolean = true,
5152)
5253
5354/**
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+16−3
@@ -35,6 +35,7 @@ import androidx.compose.ui.draw.clip
3535import androidx.compose.ui.graphics.Color
3636import androidx.compose.ui.platform.LocalContext
3737import androidx.compose.ui.text.font.FontStyle
38+import androidx.compose.ui.text.style.TextOverflow
3839import androidx.compose.ui.unit.dp
3940import java.text.SimpleDateFormat
4041import java.util.Date
@@ -150,9 +151,13 @@ private fun ReasoningBlock(text: String) {
150151
151152@Composable
152153private fun ToolRow(tool: ToolView) {
154+ var expanded by remember { mutableStateOf(false) }
153155 Row(
154156 verticalAlignment = Alignment.CenterVertically,
155− modifier = Modifier.padding(bottom = 6.dp),
157+ modifier = Modifier
158+ .fillMaxWidth()
159+ .clickable { expanded = !expanded }
160+ .padding(bottom = 6.dp),
156161 ) {
157162 Icon(
158163 Icons.Filled.Build,
@@ -165,11 +170,19 @@ private fun ToolRow(tool: ToolView) {
165170 append(tool.name)
166171 append(" · ")
167172 append(tool.status)
168− tool.title?.let { append(" — "); append(it) }
173+ if (expanded) tool.title?.let { append(" — "); append(it) }
169174 },
170175 style = MonoStyle,
171176 color = MaterialTheme.colorScheme.onSurfaceVariant,
172− maxLines = 3,
177+ maxLines = if (expanded) Int.MAX_VALUE else 1,
178+ overflow = TextOverflow.Ellipsis,
179+ modifier = Modifier.weight(1f),
180+ )
181+ Icon(
182+ imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
183+ contentDescription = if (expanded) "Collapse tool details" else "Expand tool details",
184+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
185+ modifier = Modifier.padding(start = 2.dp).widthIn(max = 18.dp),
173186 )
174187 }
175188}
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+85−15
@@ -16,10 +16,12 @@ import androidx.compose.foundation.lazy.rememberLazyListState
1616import androidx.compose.material.icons.Icons
1717import androidx.compose.material.icons.automirrored.filled.ArrowBack
1818import androidx.compose.material.icons.automirrored.filled.Send
19+import androidx.compose.material.icons.filled.KeyboardArrowDown
1920import androidx.compose.material.icons.filled.Stop
2021import androidx.compose.material3.CircularProgressIndicator
2122import androidx.compose.material3.ExperimentalMaterial3Api
2223import androidx.compose.material3.FilledIconButton
24+import androidx.compose.material3.FloatingActionButton
2325import androidx.compose.material3.Icon
2426import androidx.compose.material3.IconButton
2527import androidx.compose.material3.MaterialTheme
@@ -30,10 +32,15 @@ import androidx.compose.material3.Text
3032import androidx.compose.material3.TopAppBar
3133import androidx.compose.runtime.Composable
3234import androidx.compose.runtime.LaunchedEffect
35+import androidx.compose.runtime.derivedStateOf
36+import androidx.compose.runtime.getValue
37+import androidx.compose.runtime.remember
38+import androidx.compose.runtime.rememberCoroutineScope
3339import androidx.compose.ui.Alignment
3440import androidx.compose.ui.Modifier
3541import androidx.compose.ui.text.style.TextOverflow
3642import androidx.compose.ui.unit.dp
43+import kotlinx.coroutines.launch
3744import soy.iko.opencode.core.Event
3845import soy.iko.opencode.core.UiState
3946import soy.iko.opencode.ui.components.MessageBubble
@@ -42,6 +49,19 @@ import soy.iko.opencode.ui.components.MessageBubble
4249@Composable
4350fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
4451 val listState = rememberLazyListState()
52+ val coroutineScope = rememberCoroutineScope()
53+
54+ // Show a jump-to-bottom button once the user scrolls up away from the latest
55+ // message. derivedStateOf keeps this from recomposing on every pixel of scroll —
56+ // it only flips when the near-bottom threshold is actually crossed.
57+ val showScrollButton by remember {
58+ derivedStateOf {
59+ val layoutInfo = listState.layoutInfo
60+ val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
61+ val totalItems = layoutInfo.totalItemsCount
62+ totalItems > 0 && lastVisibleIndex < totalItems - 3
63+ }
64+ }
4565
4666 // Keep the newest content in view as it streams in, but only when the user is
4767 // already near the bottom. If they've scrolled up to read history, leave it alone.
@@ -93,28 +113,78 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
93113 )
94114 },
95115 ) { padding ->
96− Box(Modifier.fillMaxSize().padding(padding)) {
97− if (state.messages.isEmpty() && !state.loading) {
98− Text(
99− text = "Send a message to start the conversation.",
100− style = MaterialTheme.typography.bodyMedium,
101− color = MaterialTheme.colorScheme.onSurfaceVariant,
102− modifier = Modifier.align(Alignment.Center).padding(32.dp),
103− )
116+ Column(Modifier.fillMaxSize().padding(padding)) {
117+ if (!state.sseConnected && !state.generating) {
118+ ReconnectingBanner()
104119 }
105− LazyColumn(
106− state = listState,
107− modifier = Modifier.fillMaxSize(),
108− contentPadding = PaddingValues(vertical = 8.dp),
109− ) {
110− items(state.messages, key = { it.id }) { message ->
111− MessageBubble(message)
120+ Box(Modifier.fillMaxWidth().weight(1f)) {
121+ if (state.messages.isEmpty() && !state.loading) {
122+ Text(
123+ text = "Send a message to start the conversation.",
124+ style = MaterialTheme.typography.bodyMedium,
125+ color = MaterialTheme.colorScheme.onSurfaceVariant,
126+ modifier = Modifier.align(Alignment.Center).padding(32.dp),
127+ )
128+ }
129+ LazyColumn(
130+ state = listState,
131+ modifier = Modifier.fillMaxSize(),
132+ contentPadding = PaddingValues(vertical = 8.dp),
133+ ) {
134+ items(state.messages, key = { it.id }) { message ->
135+ MessageBubble(message)
136+ }
137+ }
138+ if (showScrollButton) {
139+ FloatingActionButton(
140+ onClick = {
141+ coroutineScope.launch {
142+ if (state.messages.isNotEmpty()) {
143+ listState.animateScrollToItem(state.messages.size - 1)
144+ }
145+ }
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+ }
112157 }
113158 }
114159 }
115160 }
116161}
117162
163+@Composable
164+private fun ReconnectingBanner() {
165+ Surface(
166+ modifier = Modifier.fillMaxWidth(),
167+ color = MaterialTheme.colorScheme.tertiaryContainer,
168+ ) {
169+ Row(
170+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
171+ verticalAlignment = Alignment.CenterVertically,
172+ horizontalArrangement = Arrangement.spacedBy(8.dp),
173+ ) {
174+ CircularProgressIndicator(
175+ modifier = Modifier.size(14.dp),
176+ strokeWidth = 2.dp,
177+ color = MaterialTheme.colorScheme.onTertiaryContainer,
178+ )
179+ Text(
180+ text = "Reconnecting…",
181+ style = MaterialTheme.typography.bodySmall,
182+ color = MaterialTheme.colorScheme.onTertiaryContainer,
183+ )
184+ }
185+ }
186+}
187+
118188@Composable
119189private fun ChatInputBar(
120190 draft: String,
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+5−1
@@ -139,7 +139,7 @@ private fun SessionRow(session: SessionView, onOpen: () -> Unit, onDelete: () ->
139139 overflow = TextOverflow.Ellipsis,
140140 )
141141 Text(
142− text = session.id,
142+ text = shortSessionId(session.id),
143143 style = MaterialTheme.typography.bodySmall,
144144 color = MaterialTheme.colorScheme.onSurfaceVariant,
145145 maxLines = 1,
@@ -156,6 +156,10 @@ private fun SessionRow(session: SessionView, onOpen: () -> Unit, onDelete: () ->
156156 }
157157}
158158
159+/** Shorten a session ID for display: "abc12345…" instead of the full UUID. */
160+private fun shortSessionId(id: String): String =
161+ if (id.length > 12) id.take(8) + "…" else id
162+
159163@Composable
160164private fun EmptySessions() {
161165 Column(
app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt−2
@@ -33,8 +33,6 @@ private val LightColors = lightColorScheme(
3333 outline = LightOutline,
3434)
3535
36−private val Color0x0F1115 = androidx.compose.ui.graphics.Color(0xFF0F1115)
37−
3836@Composable
3937fun OpencodeTheme(
4038 darkTheme: Boolean = isSystemInDarkTheme(),