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
9ba5eeciko
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+5−2
@@ -84,13 +84,13 @@ class OpencodeCore(
84 scope.launch { loadMessages(event.id) }
84 scope.launch { loadMessages(event.id) }
85 }
85 }
86
86
87− Event.CreateSession -> { loading = true; emit(); scope.launch { createSession() } }
87+ Event.CreateSession -> { loading = true; error = null; emit(); scope.launch { createSession() } }
88
88
89 is Event.DeleteSession -> {
89 is Event.DeleteSession -> {
90 val id = event.id
90 val id = event.id
91 // Delete on the server first; only drop it from the UI once that
91 // Delete on the server first; only drop it from the UI once that
92 // succeeds, so a failed request leaves the session recoverable.
92 // succeeds, so a failed request leaves the session recoverable.
93− loading = true; emit()
93+ loading = true; error = null; emit()
94 scope.launch {
94 scope.launch {
95 http.delete("$serverUrl/session/$id", authHeader()).fold(
95 http.delete("$serverUrl/session/$id", authHeader()).fold(
96 onSuccess = { resp ->
96 onSuccess = { resp ->
@@ -145,6 +145,9 @@ class OpencodeCore(
145 sessions.clear()
145 sessions.clear()
146 messages.clear()
146 messages.clear()
147 currentSessionId = null
147 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
148 closeSse()
151 closeSse()
149 emit()
152 emit()
150 }
153 }
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+13−2
@@ -81,7 +81,7 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
81 )
81 )
82 .padding(horizontal = 14.dp, vertical = 10.dp),
82 .padding(horizontal = 14.dp, vertical = 10.dp),
83 ) {
83 ) {
84− message.reasoning?.let { ReasoningBlock(it) }
84+ message.reasoning?.let { ReasoningBlock(it, context) }
85
85
86 message.tools.forEach { ToolRow(it) }
86 message.tools.forEach { ToolRow(it) }
87
87
@@ -117,7 +117,7 @@ private fun copyToClipboard(context: Context, text: String) {
117}
117}
118
118
119@Composable
119@Composable
120−private fun ReasoningBlock(text: String) {
120+private fun ReasoningBlock(text: String, context: Context) {
121 var expanded by remember { mutableStateOf(false) }
121 var expanded by remember { mutableStateOf(false) }
122 Column(modifier = Modifier.padding(bottom = 6.dp)) {
122 Column(modifier = Modifier.padding(bottom = 6.dp)) {
123 Row(
123 Row(
@@ -143,6 +143,17 @@ private fun ReasoningBlock(text: String) {
143 color = MaterialTheme.colorScheme.onSurfaceVariant,
143 color = MaterialTheme.colorScheme.onSurfaceVariant,
144 modifier = Modifier.padding(top = 4.dp),
144 modifier = Modifier.padding(top = 4.dp),
145 )
145 )
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+ )
146 }
157 }
147 }
158 }
148}
159}
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+2
@@ -6,6 +6,7 @@ import androidx.compose.animation.fadeOut
6import androidx.compose.animation.scaleIn
6import androidx.compose.animation.scaleIn
7import androidx.compose.animation.scaleOut
7import androidx.compose.animation.scaleOut
8import androidx.compose.foundation.background
8import androidx.compose.foundation.background
9+import androidx.compose.foundation.clickable
9import androidx.compose.foundation.layout.Arrangement
10import androidx.compose.foundation.layout.Arrangement
10import androidx.compose.foundation.layout.Box
11import androidx.compose.foundation.layout.Box
11import androidx.compose.foundation.layout.BoxScope
12import androidx.compose.foundation.layout.BoxScope
@@ -113,6 +114,7 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
113 text = state.currentSessionTitle.ifEmpty { "Chat" },
114 text = state.currentSessionTitle.ifEmpty { "Chat" },
114 maxLines = 1,
115 maxLines = 1,
115 overflow = TextOverflow.Ellipsis,
116 overflow = TextOverflow.Ellipsis,
117+ modifier = Modifier.clickable { dispatch(Event.NavigateToSessions) },
116 )
118 )
117 },
119 },
118 navigationIcon = {
120 navigationIcon = {
app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+17−1
@@ -17,15 +17,21 @@ import androidx.compose.material.icons.Icons
17import androidx.compose.material.icons.filled.Lock
17import androidx.compose.material.icons.filled.Lock
18import androidx.compose.material.icons.filled.Person
18import androidx.compose.material.icons.filled.Person
19import androidx.compose.material.icons.filled.Terminal
19import androidx.compose.material.icons.filled.Terminal
20+import androidx.compose.material.icons.filled.Visibility
21+import androidx.compose.material.icons.filled.VisibilityOff
20import androidx.compose.material3.Button
22import androidx.compose.material3.Button
21import androidx.compose.material3.CircularProgressIndicator
23import androidx.compose.material3.CircularProgressIndicator
22import androidx.compose.material3.Icon
24import androidx.compose.material3.Icon
25+import androidx.compose.material3.IconButton
23import androidx.compose.material3.MaterialTheme
26import androidx.compose.material3.MaterialTheme
24import androidx.compose.material3.OutlinedTextField
27import androidx.compose.material3.OutlinedTextField
25import androidx.compose.material3.Text
28import androidx.compose.material3.Text
26import androidx.compose.runtime.Composable
29import androidx.compose.runtime.Composable
27import androidx.compose.runtime.LaunchedEffect
30import androidx.compose.runtime.LaunchedEffect
31+import androidx.compose.runtime.getValue
32+import androidx.compose.runtime.mutableStateOf
28import androidx.compose.runtime.remember
33import androidx.compose.runtime.remember
34+import androidx.compose.runtime.setValue
29import androidx.compose.ui.Alignment
35import androidx.compose.ui.Alignment
30import androidx.compose.ui.Modifier
36import androidx.compose.ui.Modifier
31import androidx.compose.ui.focus.FocusRequester
37import androidx.compose.ui.focus.FocusRequester
@@ -33,6 +39,7 @@ import androidx.compose.ui.focus.focusRequester
33import androidx.compose.ui.text.input.ImeAction
39import androidx.compose.ui.text.input.ImeAction
34import androidx.compose.ui.text.input.KeyboardType
40import androidx.compose.ui.text.input.KeyboardType
35import androidx.compose.ui.text.input.PasswordVisualTransformation
41import androidx.compose.ui.text.input.PasswordVisualTransformation
42+import androidx.compose.ui.text.input.VisualTransformation
36import androidx.compose.ui.text.style.TextAlign
43import androidx.compose.ui.text.style.TextAlign
37import androidx.compose.ui.unit.dp
44import androidx.compose.ui.unit.dp
38import soy.iko.opencode.core.Event
45import soy.iko.opencode.core.Event
@@ -41,6 +48,7 @@ import soy.iko.opencode.core.UiState
41@Composable
48@Composable
42fun ConnectScreen(state: UiState, dispatch: (Event) -> Unit) {
49fun ConnectScreen(state: UiState, dispatch: (Event) -> Unit) {
43 val focusRequester = remember { FocusRequester() }
50 val focusRequester = remember { FocusRequester() }
51+ var passwordVisible by remember { mutableStateOf(false) }
44
52
45 // Focus the server URL field when the screen opens, but only when it's empty
53 // 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.
54 // 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) {
111 onValueChange = { dispatch(Event.PasswordChanged(it)) },
119 onValueChange = { dispatch(Event.PasswordChanged(it)) },
112 label = { Text("Password") },
120 label = { Text("Password") },
113 singleLine = true,
121 singleLine = true,
114− visualTransformation = PasswordVisualTransformation(),
122+ visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
115 leadingIcon = { Icon(Icons.Filled.Lock, contentDescription = null) },
123 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+ },
116 modifier = Modifier.fillMaxWidth(),
132 modifier = Modifier.fillMaxWidth(),
117 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
133 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
118 keyboardActions = KeyboardActions(onGo = { dispatch(Event.Connect) }),
134 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) {
141 }
141 }
142 when {
142 when {
143 state.sessions.isEmpty() && !state.loading -> EmptySessions()
143 state.sessions.isEmpty() && !state.loading -> EmptySessions()
144+ filteredSessions.isEmpty() && searchQuery.isNotEmpty() -> NoSearchResults(searchQuery)
144 else -> PullToRefreshBox(
145 else -> PullToRefreshBox(
145 isRefreshing = isPullRefreshing,
146 isRefreshing = isPullRefreshing,
146 onRefresh = {
147 onRefresh = {
@@ -248,6 +249,20 @@ private fun SwipeDeleteBackground() {
248private fun shortSessionId(id: String): String =
249private fun shortSessionId(id: String): String =
249 if (id.length > 12) id.take(8) + "…" else id
250 if (id.length > 12) id.take(8) + "…" else id
250
251
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+
251@Composable
266@Composable
252private fun EmptySessions() {
267private fun EmptySessions() {
253 Column(
268 Column(