git.iko.soy/opencode-android
on master help sign in
UX/UI: password visibility toggle, empty search results message, copy reasoning button, clickable chat title

Bug fixes: clear error on CreateSession/DeleteSession, reset sseConnected on NavigateToConnect

commit9ba5eec377c1582fcb437e79a7c8d19d9f448cc6 authoriko <mail@iko.soy> dateWed 8 Jul 2026, 21:45 (3 months ago) parent24b51d2
5 files changed+52−5
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+5−2 app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+13−2 app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+2 app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+17−1 app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+15
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+5−2
@@ -84,13 +84,13 @@ class OpencodeCore(
8484 scope.launch { loadMessages(event.id) }
8585 }
8686
87− Event.CreateSession -> { loading = true; emit(); scope.launch { createSession() } }
87+ Event.CreateSession -> { loading = true; error = null; emit(); scope.launch { createSession() } }
8888
8989 is Event.DeleteSession -> {
9090 val id = event.id
9191 // Delete on the server first; only drop it from the UI once that
9292 // succeeds, so a failed request leaves the session recoverable.
93− loading = true; emit()
93+ loading = true; error = null; emit()
9494 scope.launch {
9595 http.delete("$serverUrl/session/$id", authHeader()).fold(
9696 onSuccess = { resp ->
@@ -145,6 +145,9 @@ class OpencodeCore(
145145 sessions.clear()
146146 messages.clear()
147147 currentSessionId = null
148+ // Reset to the default so a late-firing close callback from the old
149+ // SSE connection can't flash the reconnecting banner on the next connect.
150+ sseConnected = true
148151 closeSse()
149152 emit()
150153 }
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+13−2
@@ -81,7 +81,7 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
8181 )
8282 .padding(horizontal = 14.dp, vertical = 10.dp),
8383 ) {
84− message.reasoning?.let { ReasoningBlock(it) }
84+ message.reasoning?.let { ReasoningBlock(it, context) }
8585
8686 message.tools.forEach { ToolRow(it) }
8787
@@ -117,7 +117,7 @@ private fun copyToClipboard(context: Context, text: String) {
117117}
118118
119119@Composable
120−private fun ReasoningBlock(text: String) {
120+private fun ReasoningBlock(text: String, context: Context) {
121121 var expanded by remember { mutableStateOf(false) }
122122 Column(modifier = Modifier.padding(bottom = 6.dp)) {
123123 Row(
@@ -143,6 +143,17 @@ private fun ReasoningBlock(text: String) {
143143 color = MaterialTheme.colorScheme.onSurfaceVariant,
144144 modifier = Modifier.padding(top = 4.dp),
145145 )
146+ Text(
147+ text = "Copy",
148+ style = MaterialTheme.typography.labelSmall,
149+ color = MaterialTheme.colorScheme.primary,
150+ modifier = Modifier
151+ .padding(top = 4.dp)
152+ .clickable {
153+ copyToClipboard(context, text)
154+ Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
155+ },
156+ )
146157 }
147158 }
148159}
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+2
@@ -6,6 +6,7 @@ import androidx.compose.animation.fadeOut
66import androidx.compose.animation.scaleIn
77import androidx.compose.animation.scaleOut
88import androidx.compose.foundation.background
9+import androidx.compose.foundation.clickable
910import androidx.compose.foundation.layout.Arrangement
1011import androidx.compose.foundation.layout.Box
1112import androidx.compose.foundation.layout.BoxScope
@@ -113,6 +114,7 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
113114 text = state.currentSessionTitle.ifEmpty { "Chat" },
114115 maxLines = 1,
115116 overflow = TextOverflow.Ellipsis,
117+ modifier = Modifier.clickable { dispatch(Event.NavigateToSessions) },
116118 )
117119 },
118120 navigationIcon = {
app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+17−1
@@ -17,15 +17,21 @@ import androidx.compose.material.icons.Icons
1717import androidx.compose.material.icons.filled.Lock
1818import androidx.compose.material.icons.filled.Person
1919import androidx.compose.material.icons.filled.Terminal
20+import androidx.compose.material.icons.filled.Visibility
21+import androidx.compose.material.icons.filled.VisibilityOff
2022import androidx.compose.material3.Button
2123import androidx.compose.material3.CircularProgressIndicator
2224import androidx.compose.material3.Icon
25+import androidx.compose.material3.IconButton
2326import androidx.compose.material3.MaterialTheme
2427import androidx.compose.material3.OutlinedTextField
2528import androidx.compose.material3.Text
2629import androidx.compose.runtime.Composable
2730import androidx.compose.runtime.LaunchedEffect
31+import androidx.compose.runtime.getValue
32+import androidx.compose.runtime.mutableStateOf
2833import androidx.compose.runtime.remember
34+import androidx.compose.runtime.setValue
2935import androidx.compose.ui.Alignment
3036import androidx.compose.ui.Modifier
3137import androidx.compose.ui.focus.FocusRequester
@@ -33,6 +39,7 @@ import androidx.compose.ui.focus.focusRequester
3339import androidx.compose.ui.text.input.ImeAction
3440import androidx.compose.ui.text.input.KeyboardType
3541import androidx.compose.ui.text.input.PasswordVisualTransformation
42+import androidx.compose.ui.text.input.VisualTransformation
3643import androidx.compose.ui.text.style.TextAlign
3744import androidx.compose.ui.unit.dp
3845import soy.iko.opencode.core.Event
@@ -41,6 +48,7 @@ import soy.iko.opencode.core.UiState
4148@Composable
4249fun ConnectScreen(state: UiState, dispatch: (Event) -> Unit) {
4350 val focusRequester = remember { FocusRequester() }
51+ var passwordVisible by remember { mutableStateOf(false) }
4452
4553 // Focus the server URL field when the screen opens, but only when it's empty
4654 // so we don't steal focus (and pop the keyboard) on a return visit.
@@ -111,8 +119,16 @@ fun ConnectScreen(state: UiState, dispatch: (Event) -> Unit) {
111119 onValueChange = { dispatch(Event.PasswordChanged(it)) },
112120 label = { Text("Password") },
113121 singleLine = true,
114− visualTransformation = PasswordVisualTransformation(),
122+ visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
115123 leadingIcon = { Icon(Icons.Filled.Lock, contentDescription = null) },
124+ trailingIcon = {
125+ IconButton(onClick = { passwordVisible = !passwordVisible }) {
126+ Icon(
127+ imageVector = if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
128+ contentDescription = if (passwordVisible) "Hide password" else "Show password",
129+ )
130+ }
131+ },
116132 modifier = Modifier.fillMaxWidth(),
117133 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
118134 keyboardActions = KeyboardActions(onGo = { dispatch(Event.Connect) }),
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+15
@@ -141,6 +141,7 @@ fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
141141 }
142142 when {
143143 state.sessions.isEmpty() && !state.loading -> EmptySessions()
144+ filteredSessions.isEmpty() && searchQuery.isNotEmpty() -> NoSearchResults(searchQuery)
144145 else -> PullToRefreshBox(
145146 isRefreshing = isPullRefreshing,
146147 onRefresh = {
@@ -248,6 +249,20 @@ private fun SwipeDeleteBackground() {
248249private fun shortSessionId(id: String): String =
249250 if (id.length > 12) id.take(8) + "…" else id
250251
252+@Composable
253+private fun NoSearchResults(query: String) {
254+ Box(
255+ modifier = Modifier.fillMaxSize().padding(32.dp),
256+ contentAlignment = Alignment.Center,
257+ ) {
258+ Text(
259+ text = "No results for “$query”",
260+ style = MaterialTheme.typography.bodyMedium,
261+ color = MaterialTheme.colorScheme.onSurfaceVariant,
262+ )
263+ }
264+}
265+
251266@Composable
252267private fun EmptySessions() {
253268 Column(