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
24b51d2iko
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+33−17
@@ -65,7 +65,9 @@ class OpencodeCore(
65 // The auth fields are showing but empty — sending the request now
65 // The auth fields are showing but empty — sending the request now
66 // would just 401 again in a loop. Prompt for credentials instead.
66 // would just 401 again in a loop. Prompt for credentials instead.
67 if (authRequired && !hasCredentials()) { error = "Enter username and password"; emit(); return }
67 if (authRequired && !hasCredentials()) { error = "Enter username and password"; emit(); return }
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.
68− loading = true; error = null; authRequired = false; emit()
70+ loading = true; error = null; emit()
69 scope.launch { probeHealth() }
71 scope.launch { probeHealth() }
70 }
72 }
71
73
@@ -335,15 +337,17 @@ class OpencodeCore(
335
337
336 private fun handleServerEvent(json: String) {
338 private fun handleServerEvent(json: String) {
337 val event = Protocol.parseEvent(json) ?: return
339 val event = Protocol.parseEvent(json) ?: return
338− applyEvent(event)
340+ // Only re-render if the event actually changed our state; applyEvent returns
339− emit()
341+ // false for events that don't apply (other session, unknown message, …).
342+ if (applyEvent(event)) emit()
340 }
343 }
341
344
345+ /** Apply a server event to the model. Returns true iff any state actually changed. */
342− private fun applyEvent(event: Protocol.Event) {
346+ private fun applyEvent(event: Protocol.Event): Boolean {
343 when (event) {
347 when (event) {
344 is Protocol.Event.MessageUpdated -> {
348 is Protocol.Event.MessageUpdated -> {
345 val info = event.info
349 val info = event.info
346− if (info.role != "assistant" || !isCurrent(info.sessionId)) return
350+ if (info.role != "assistant" || !isCurrent(info.sessionId)) return false
347 val streaming = info.time.completed == null && info.error == null
351 val streaming = info.time.completed == null && info.error == null
348 val existing = messages.firstOrNull { it.id == info.id }
352 val existing = messages.firstOrNull { it.id == info.id }
349 if (existing != null) {
353 if (existing != null) {
@@ -353,11 +357,12 @@ class OpencodeCore(
353 messages.add(MsgState(info.id, "assistant", info.time.created).apply { this.streaming = streaming })
357 messages.add(MsgState(info.id, "assistant", info.time.created).apply { this.streaming = streaming })
354 }
358 }
355 info.error?.let { error = it.message(); generating = false }
359 info.error?.let { error = it.message(); generating = false }
360+ return true
356 }
361 }
357
362
358 is Protocol.Event.PartUpdated -> {
363 is Protocol.Event.PartUpdated -> {
359 val part = event.part
364 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
361 when (part) {
366 when (part) {
362 is Protocol.Part.Text -> if (!part.synthetic) {
367 is Protocol.Part.Text -> if (!part.synthetic) {
363 msg.textParts[part.id] = pickText(part.text, event.delta, msg.textParts[part.id])
368 msg.textParts[part.id] = pickText(part.text, event.delta, msg.textParts[part.id])
@@ -367,24 +372,31 @@ class OpencodeCore(
367 is Protocol.Part.Tool ->
372 is Protocol.Part.Tool ->
368 msg.toolParts[part.id] = ToolView(part.tool, part.status, part.title)
373 msg.toolParts[part.id] = ToolView(part.tool, part.status, part.title)
369 }
374 }
375+ return true
370 }
376 }
371
377
378+ is Protocol.Event.PartRemoved -> {
372− is Protocol.Event.PartRemoved -> messages.firstOrNull { it.id == event.messageId }?.remove(event.partId)
379+ val msg = messages.firstOrNull { it.id == event.messageId } ?: return false
380+ msg.remove(event.partId)
381+ return true
382+ }
373
383
374− is Protocol.Event.MessageRemoved -> messages.removeAll { it.id == event.messageId }
384+ is Protocol.Event.MessageRemoved -> return messages.removeAll { it.id == event.messageId }
375
385
376− is Protocol.Event.SessionIdle -> if (isCurrent(event.sessionId)) {
386+ is Protocol.Event.SessionIdle -> {
387+ if (!isCurrent(event.sessionId)) return false
377 generating = false
388 generating = false
378 messages.forEach { it.streaming = false }
389 messages.forEach { it.streaming = false }
390+ return true
379 }
391 }
380
392
381 is Protocol.Event.SessionError -> {
393 is Protocol.Event.SessionError -> {
382 val scoped = event.sessionId == null || isCurrent(event.sessionId)
394 val scoped = event.sessionId == null || isCurrent(event.sessionId)
383− if (scoped) {
395+ if (!scoped) return false
384− event.error?.let { error = it.message() }
396+ event.error?.let { error = it.message() }
385− generating = false
397+ generating = false
386− messages.forEach { it.streaming = false }
398+ messages.forEach { it.streaming = false }
387− }
399+ return true
388 }
400 }
389
401
390 is Protocol.Event.SessionUpserted -> {
402 is Protocol.Event.SessionUpserted -> {
@@ -396,18 +408,22 @@ class OpencodeCore(
396 sessions.add(0, SessionView(event.info.id, title))
408 sessions.add(0, SessionView(event.info.id, title))
397 }
409 }
398 if (isCurrent(event.info.id)) currentSessionTitle = title
410 if (isCurrent(event.info.id)) currentSessionTitle = title
411+ return true
399 }
412 }
400
413
401− is Protocol.Event.SessionDeleted -> event.sessionId?.let { id ->
414+ is Protocol.Event.SessionDeleted -> {
415+ val id = event.sessionId ?: return false
402− sessions.removeAll { it.id == id }
416+ val removed = sessions.removeAll { it.id == id }
403 if (isCurrent(id)) {
417 if (isCurrent(id)) {
404 currentSessionId = null
418 currentSessionId = null
405 messages.clear()
419 messages.clear()
406 screen = Screen.Sessions
420 screen = Screen.Sessions
421+ return true
407 }
422 }
423+ return removed
408 }
424 }
409
425
410− Protocol.Event.Other -> Unit
426+ Protocol.Event.Other -> return false
411 }
427 }
412 }
428 }
413
429
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+10−9
@@ -2,9 +2,8 @@ package soy.iko.opencode.ui
2
2
3import androidx.activity.compose.BackHandler
3import androidx.activity.compose.BackHandler
4import androidx.compose.animation.AnimatedContent
4import androidx.compose.animation.AnimatedContent
5−import androidx.compose.animation.SizeTransform
5+import androidx.compose.animation.slideInHorizontally
6−import androidx.compose.animation.fadeIn
6+import androidx.compose.animation.slideOutHorizontally
7−import androidx.compose.animation.fadeOut
8import androidx.compose.animation.togetherWith
7import androidx.compose.animation.togetherWith
9import androidx.compose.foundation.layout.fillMaxSize
8import androidx.compose.foundation.layout.fillMaxSize
10import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.padding
@@ -12,7 +11,6 @@ import androidx.compose.material3.Scaffold
12import androidx.compose.material3.SnackbarDuration
11import androidx.compose.material3.SnackbarDuration
13import androidx.compose.material3.SnackbarHost
12import androidx.compose.material3.SnackbarHost
14import androidx.compose.material3.SnackbarHostState
13import androidx.compose.material3.SnackbarHostState
15−import androidx.compose.material3.SnackbarResult
16import androidx.compose.runtime.Composable
14import androidx.compose.runtime.Composable
17import androidx.compose.runtime.LaunchedEffect
15import androidx.compose.runtime.LaunchedEffect
18import androidx.compose.runtime.remember
16import androidx.compose.runtime.remember
@@ -30,14 +28,11 @@ fun OpencodeApp(state: UiState, dispatch: (Event) -> Unit) {
30
28
31 LaunchedEffect(state.error) {
29 LaunchedEffect(state.error) {
32 state.error?.let {
30 state.error?.let {
33− val result = snackbar.showSnackbar(
31+ snackbar.showSnackbar(
34 message = it,
32 message = it,
35 actionLabel = "Dismiss",
33 actionLabel = "Dismiss",
36 duration = SnackbarDuration.Short,
34 duration = SnackbarDuration.Short,
37 )
35 )
38− if (result == SnackbarResult.ActionPerformed) {
39− dispatch(Event.DismissError)
40− }
41 // Always clear the error from state after showing.
36 // Always clear the error from state after showing.
42 dispatch(Event.DismissError)
37 dispatch(Event.DismissError)
43 }
38 }
@@ -56,7 +51,13 @@ fun OpencodeApp(state: UiState, dispatch: (Event) -> Unit) {
56 targetState = state.screen,
51 targetState = state.screen,
57 modifier = Modifier.fillMaxSize().padding(padding),
52 modifier = Modifier.fillMaxSize().padding(padding),
58 transitionSpec = {
53 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+ }
60 },
61 },
61 label = "screen",
62 label = "screen",
62 ) { screen ->
63 ) { screen ->
app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+25−1
@@ -24,8 +24,12 @@ import androidx.compose.material3.MaterialTheme
24import androidx.compose.material3.OutlinedTextField
24import androidx.compose.material3.OutlinedTextField
25import androidx.compose.material3.Text
25import androidx.compose.material3.Text
26import androidx.compose.runtime.Composable
26import androidx.compose.runtime.Composable
27+import androidx.compose.runtime.LaunchedEffect
28+import androidx.compose.runtime.remember
27import androidx.compose.ui.Alignment
29import androidx.compose.ui.Alignment
28import androidx.compose.ui.Modifier
30import androidx.compose.ui.Modifier
31+import androidx.compose.ui.focus.FocusRequester
32+import androidx.compose.ui.focus.focusRequester
29import androidx.compose.ui.text.input.ImeAction
33import androidx.compose.ui.text.input.ImeAction
30import androidx.compose.ui.text.input.KeyboardType
34import androidx.compose.ui.text.input.KeyboardType
31import androidx.compose.ui.text.input.PasswordVisualTransformation
35import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -36,6 +40,14 @@ import soy.iko.opencode.core.UiState
36
40
37@Composable
41@Composable
38fun ConnectScreen(state: UiState, dispatch: (Event) -> Unit) {
42fun 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+
39 Column(
51 Column(
40 modifier = Modifier
52 modifier = Modifier
41 .fillMaxSize()
53 .fillMaxSize()
@@ -72,7 +84,7 @@ fun ConnectScreen(state: UiState, dispatch: (Event) -> Unit) {
72 label = { Text("Server URL") },
84 label = { Text("Server URL") },
73 placeholder = { Text("http://192.168.1.10:4096") },
85 placeholder = { Text("http://192.168.1.10:4096") },
74 singleLine = true,
86 singleLine = true,
75− modifier = Modifier.fillMaxWidth(),
87+ modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
76 keyboardOptions = KeyboardOptions(
88 keyboardOptions = KeyboardOptions(
77 keyboardType = KeyboardType.Uri,
89 keyboardType = KeyboardType.Uri,
78 imeAction = if (state.authRequired) ImeAction.Next else ImeAction.Go,
90 imeAction = if (state.authRequired) ImeAction.Next else ImeAction.Go,
@@ -131,5 +143,17 @@ fun ConnectScreen(state: UiState, dispatch: (Event) -> Unit) {
131 Text(if (state.authRequired) "Sign in" else "Connect")
143 Text(if (state.authRequired) "Sign in" else "Connect")
132 }
144 }
133 }
145 }
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+ }
134 }
158 }
135}
159}
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+22−1
@@ -29,6 +29,7 @@ import androidx.compose.material3.Icon
29import androidx.compose.material3.IconButton
29import androidx.compose.material3.IconButton
30import androidx.compose.material3.LinearProgressIndicator
30import androidx.compose.material3.LinearProgressIndicator
31import androidx.compose.material3.MaterialTheme
31import androidx.compose.material3.MaterialTheme
32+import androidx.compose.material3.OutlinedTextField
32import androidx.compose.material3.Scaffold
33import androidx.compose.material3.Scaffold
33import androidx.compose.material3.SwipeToDismissBox
34import androidx.compose.material3.SwipeToDismissBox
34import androidx.compose.material3.SwipeToDismissBoxValue
35import androidx.compose.material3.SwipeToDismissBoxValue
@@ -60,6 +61,7 @@ fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
60 val haptic = LocalHapticFeedback.current
61 val haptic = LocalHapticFeedback.current
61 var pendingDeleteId by remember { mutableStateOf<String?>(null) }
62 var pendingDeleteId by remember { mutableStateOf<String?>(null) }
62 var isPullRefreshing by remember { mutableStateOf(false) }
63 var isPullRefreshing by remember { mutableStateOf(false) }
64+ var searchQuery by remember { mutableStateOf("") }
63
65
64 // The pull-to-refresh spinner should only reflect a user-initiated pull, not
66 // The pull-to-refresh spinner should only reflect a user-initiated pull, not
65 // every loading operation. Clear it once the underlying load completes.
67 // every loading operation. Clear it once the underlying load completes.
@@ -118,6 +120,25 @@ fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
118 if (state.loading && !isPullRefreshing) {
120 if (state.loading && !isPullRefreshing) {
119 LinearProgressIndicator(Modifier.fillMaxWidth())
121 LinearProgressIndicator(Modifier.fillMaxWidth())
120 }
122 }
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+ }
121 when {
142 when {
122 state.sessions.isEmpty() && !state.loading -> EmptySessions()
143 state.sessions.isEmpty() && !state.loading -> EmptySessions()
123 else -> PullToRefreshBox(
144 else -> PullToRefreshBox(
@@ -133,7 +154,7 @@ fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
133 modifier = Modifier.fillMaxSize(),
154 modifier = Modifier.fillMaxSize(),
134 contentPadding = PaddingValues(bottom = 88.dp),
155 contentPadding = PaddingValues(bottom = 88.dp),
135 ) {
156 ) {
136− items(state.sessions, key = { it.id }) { session ->
157+ items(filteredSessions, key = { it.id }) { session ->
137 val dismissState = rememberSwipeToDismissBoxState(
158 val dismissState = rememberSwipeToDismissBoxState(
138 // Only a swipe from the end (right-to-left) triggers deletion;
159 // Only a swipe from the end (right-to-left) triggers deletion;
139 // return false so the row snaps back and the confirm dialog decides.
160 // return false so the row snaps back and the confirm dialog decides.