git.iko.soy/opencode-android
on master help sign in
UX/UI: directional screen transitions, inline error on connect, session search bar, auto-focus URL field

Bug fixes: remove duplicate DismissError dispatch, fix authRequired flicker on reconnect, skip emit for irrelevant SSE events

commit24b51d2c1c2e3100c500e693b3fd550c0918bc3a authoriko <mail@iko.soy> dateWed 8 Jul 2026, 21:41 (3 months ago) parent9ffe468
4 files changed+90−28
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+33−17 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+10−9 app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+25−1 app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+22−1
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+33−17
@@ -65,7 +65,9 @@ class OpencodeCore(
6565 // The auth fields are showing but empty — sending the request now
6666 // would just 401 again in a loop. Prompt for credentials instead.
6767 if (authRequired && !hasCredentials()) { error = "Enter username and password"; emit(); return }
68− loading = true; error = null; authRequired = false; emit()
68+ // Leave authRequired as-is; probeHealth sets it from the response (true on
69+ // 401, false on success). Resetting it here just flickers the auth fields.
70+ loading = true; error = null; emit()
6971 scope.launch { probeHealth() }
7072 }
7173
@@ -335,15 +337,17 @@ class OpencodeCore(
335337
336338 private fun handleServerEvent(json: String) {
337339 val event = Protocol.parseEvent(json) ?: return
338− applyEvent(event)
339− emit()
340+ // Only re-render if the event actually changed our state; applyEvent returns
341+ // false for events that don't apply (other session, unknown message, …).
342+ if (applyEvent(event)) emit()
340343 }
341344
342− private fun applyEvent(event: Protocol.Event) {
345+ /** Apply a server event to the model. Returns true iff any state actually changed. */
346+ private fun applyEvent(event: Protocol.Event): Boolean {
343347 when (event) {
344348 is Protocol.Event.MessageUpdated -> {
345349 val info = event.info
346− if (info.role != "assistant" || !isCurrent(info.sessionId)) return
350+ if (info.role != "assistant" || !isCurrent(info.sessionId)) return false
347351 val streaming = info.time.completed == null && info.error == null
348352 val existing = messages.firstOrNull { it.id == info.id }
349353 if (existing != null) {
@@ -353,11 +357,12 @@ class OpencodeCore(
353357 messages.add(MsgState(info.id, "assistant", info.time.created).apply { this.streaming = streaming })
354358 }
355359 info.error?.let { error = it.message(); generating = false }
360+ return true
356361 }
357362
358363 is Protocol.Event.PartUpdated -> {
359364 val part = event.part
360− val msg = messages.firstOrNull { it.id == part.messageId } ?: return
365+ val msg = messages.firstOrNull { it.id == part.messageId } ?: return false
361366 when (part) {
362367 is Protocol.Part.Text -> if (!part.synthetic) {
363368 msg.textParts[part.id] = pickText(part.text, event.delta, msg.textParts[part.id])
@@ -367,24 +372,31 @@ class OpencodeCore(
367372 is Protocol.Part.Tool ->
368373 msg.toolParts[part.id] = ToolView(part.tool, part.status, part.title)
369374 }
375+ return true
370376 }
371377
372− is Protocol.Event.PartRemoved -> messages.firstOrNull { it.id == event.messageId }?.remove(event.partId)
378+ is Protocol.Event.PartRemoved -> {
379+ val msg = messages.firstOrNull { it.id == event.messageId } ?: return false
380+ msg.remove(event.partId)
381+ return true
382+ }
373383
374− is Protocol.Event.MessageRemoved -> messages.removeAll { it.id == event.messageId }
384+ is Protocol.Event.MessageRemoved -> return messages.removeAll { it.id == event.messageId }
375385
376− is Protocol.Event.SessionIdle -> if (isCurrent(event.sessionId)) {
386+ is Protocol.Event.SessionIdle -> {
387+ if (!isCurrent(event.sessionId)) return false
377388 generating = false
378389 messages.forEach { it.streaming = false }
390+ return true
379391 }
380392
381393 is Protocol.Event.SessionError -> {
382394 val scoped = event.sessionId == null || isCurrent(event.sessionId)
383− if (scoped) {
384− event.error?.let { error = it.message() }
385− generating = false
386− messages.forEach { it.streaming = false }
387− }
395+ if (!scoped) return false
396+ event.error?.let { error = it.message() }
397+ generating = false
398+ messages.forEach { it.streaming = false }
399+ return true
388400 }
389401
390402 is Protocol.Event.SessionUpserted -> {
@@ -396,18 +408,22 @@ class OpencodeCore(
396408 sessions.add(0, SessionView(event.info.id, title))
397409 }
398410 if (isCurrent(event.info.id)) currentSessionTitle = title
411+ return true
399412 }
400413
401− is Protocol.Event.SessionDeleted -> event.sessionId?.let { id ->
402− sessions.removeAll { it.id == id }
414+ is Protocol.Event.SessionDeleted -> {
415+ val id = event.sessionId ?: return false
416+ val removed = sessions.removeAll { it.id == id }
403417 if (isCurrent(id)) {
404418 currentSessionId = null
405419 messages.clear()
406420 screen = Screen.Sessions
421+ return true
407422 }
423+ return removed
408424 }
409425
410− Protocol.Event.Other -> Unit
426+ Protocol.Event.Other -> return false
411427 }
412428 }
413429
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+10−9
@@ -2,9 +2,8 @@ package soy.iko.opencode.ui
22
33import androidx.activity.compose.BackHandler
44import androidx.compose.animation.AnimatedContent
5−import androidx.compose.animation.SizeTransform
6−import androidx.compose.animation.fadeIn
7−import androidx.compose.animation.fadeOut
5+import androidx.compose.animation.slideInHorizontally
6+import androidx.compose.animation.slideOutHorizontally
87import androidx.compose.animation.togetherWith
98import androidx.compose.foundation.layout.fillMaxSize
109import androidx.compose.foundation.layout.padding
@@ -12,7 +11,6 @@ import androidx.compose.material3.Scaffold
1211import androidx.compose.material3.SnackbarDuration
1312import androidx.compose.material3.SnackbarHost
1413import androidx.compose.material3.SnackbarHostState
15−import androidx.compose.material3.SnackbarResult
1614import androidx.compose.runtime.Composable
1715import androidx.compose.runtime.LaunchedEffect
1816import androidx.compose.runtime.remember
@@ -30,14 +28,11 @@ fun OpencodeApp(state: UiState, dispatch: (Event) -> Unit) {
3028
3129 LaunchedEffect(state.error) {
3230 state.error?.let {
33− val result = snackbar.showSnackbar(
31+ snackbar.showSnackbar(
3432 message = it,
3533 actionLabel = "Dismiss",
3634 duration = SnackbarDuration.Short,
3735 )
38− if (result == SnackbarResult.ActionPerformed) {
39− dispatch(Event.DismissError)
40− }
4136 // Always clear the error from state after showing.
4237 dispatch(Event.DismissError)
4338 }
@@ -56,7 +51,13 @@ fun OpencodeApp(state: UiState, dispatch: (Event) -> Unit) {
5651 targetState = state.screen,
5752 modifier = Modifier.fillMaxSize().padding(padding),
5853 transitionSpec = {
59− (fadeIn() togetherWith fadeOut()).using(SizeTransform(clip = false))
54+ // Compare screen ordinals to decide slide direction: forward navigation
55+ // (Connect → Sessions → Chat) slides in from the right, back slides from the left.
56+ if (targetState.ordinal > initialState.ordinal) {
57+ slideInHorizontally { it } togetherWith slideOutHorizontally { -it }
58+ } else {
59+ slideInHorizontally { -it } togetherWith slideOutHorizontally { it }
60+ }
6061 },
6162 label = "screen",
6263 ) { screen ->
app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+25−1
@@ -24,8 +24,12 @@ import androidx.compose.material3.MaterialTheme
2424import androidx.compose.material3.OutlinedTextField
2525import androidx.compose.material3.Text
2626import androidx.compose.runtime.Composable
27+import androidx.compose.runtime.LaunchedEffect
28+import androidx.compose.runtime.remember
2729import androidx.compose.ui.Alignment
2830import androidx.compose.ui.Modifier
31+import androidx.compose.ui.focus.FocusRequester
32+import androidx.compose.ui.focus.focusRequester
2933import androidx.compose.ui.text.input.ImeAction
3034import androidx.compose.ui.text.input.KeyboardType
3135import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -36,6 +40,14 @@ import soy.iko.opencode.core.UiState
3640
3741@Composable
3842fun ConnectScreen(state: UiState, dispatch: (Event) -> Unit) {
43+ val focusRequester = remember { FocusRequester() }
44+
45+ // Focus the server URL field when the screen opens, but only when it's empty
46+ // so we don't steal focus (and pop the keyboard) on a return visit.
47+ LaunchedEffect(Unit) {
48+ if (state.serverUrl.isEmpty()) focusRequester.requestFocus()
49+ }
50+
3951 Column(
4052 modifier = Modifier
4153 .fillMaxSize()
@@ -72,7 +84,7 @@ fun ConnectScreen(state: UiState, dispatch: (Event) -> Unit) {
7284 label = { Text("Server URL") },
7385 placeholder = { Text("http://192.168.1.10:4096") },
7486 singleLine = true,
75− modifier = Modifier.fillMaxWidth(),
87+ modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
7688 keyboardOptions = KeyboardOptions(
7789 keyboardType = KeyboardType.Uri,
7890 imeAction = if (state.authRequired) ImeAction.Next else ImeAction.Go,
@@ -131,5 +143,17 @@ fun ConnectScreen(state: UiState, dispatch: (Event) -> Unit) {
131143 Text(if (state.authRequired) "Sign in" else "Connect")
132144 }
133145 }
146+
147+ // Surface the error inline as well, so it stays visible while the user
148+ // corrects the form (the snackbar is transient).
149+ if (state.error != null && !state.loading) {
150+ Spacer(Modifier.height(8.dp))
151+ Text(
152+ text = state.error,
153+ style = MaterialTheme.typography.bodySmall,
154+ color = MaterialTheme.colorScheme.error,
155+ textAlign = TextAlign.Center,
156+ )
157+ }
134158 }
135159}
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+22−1
@@ -29,6 +29,7 @@ import androidx.compose.material3.Icon
2929import androidx.compose.material3.IconButton
3030import androidx.compose.material3.LinearProgressIndicator
3131import androidx.compose.material3.MaterialTheme
32+import androidx.compose.material3.OutlinedTextField
3233import androidx.compose.material3.Scaffold
3334import androidx.compose.material3.SwipeToDismissBox
3435import androidx.compose.material3.SwipeToDismissBoxValue
@@ -60,6 +61,7 @@ fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
6061 val haptic = LocalHapticFeedback.current
6162 var pendingDeleteId by remember { mutableStateOf<String?>(null) }
6263 var isPullRefreshing by remember { mutableStateOf(false) }
64+ var searchQuery by remember { mutableStateOf("") }
6365
6466 // The pull-to-refresh spinner should only reflect a user-initiated pull, not
6567 // every loading operation. Clear it once the underlying load completes.
@@ -118,6 +120,25 @@ fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
118120 if (state.loading && !isPullRefreshing) {
119121 LinearProgressIndicator(Modifier.fillMaxWidth())
120122 }
123+ if (state.sessions.isNotEmpty()) {
124+ OutlinedTextField(
125+ value = searchQuery,
126+ onValueChange = { searchQuery = it },
127+ placeholder = { Text("Search sessions") },
128+ singleLine = true,
129+ modifier = Modifier
130+ .fillMaxWidth()
131+ .padding(horizontal = 16.dp, vertical = 8.dp),
132+ )
133+ }
134+ val filteredSessions = if (searchQuery.isEmpty()) {
135+ state.sessions
136+ } else {
137+ state.sessions.filter {
138+ it.title.contains(searchQuery, ignoreCase = true) ||
139+ it.id.contains(searchQuery, ignoreCase = true)
140+ }
141+ }
121142 when {
122143 state.sessions.isEmpty() && !state.loading -> EmptySessions()
123144 else -> PullToRefreshBox(
@@ -133,7 +154,7 @@ fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
133154 modifier = Modifier.fillMaxSize(),
134155 contentPadding = PaddingValues(bottom = 88.dp),
135156 ) {
136− items(state.sessions, key = { it.id }) { session ->
157+ items(filteredSessions, key = { it.id }) { session ->
137158 val dismissState = rememberSwipeToDismissBoxState(
138159 // Only a swipe from the end (right-to-left) triggers deletion;
139160 // return false so the row snaps back and the confirm dialog decides.