git.iko.soy/opencode-android
on master help sign in
Improve UX across chat, picker, file, and server screens with distinct load-error state, refresh feedback, user-bubble copy, command/agent search parity, file-view refresh, and connect/save haptics
commit9b25fb8a69eabf2ab6f9d30b787f6579971a9f89 authoriko <mail@iko.soy> dateWed 1 Jul 2026, 09:44 (3 months ago) parentd29e57b
10 files changed+201−31
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+5 app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+10−1 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+37−3 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+37−1 app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+58−23 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+33−1 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+7 app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+9−2 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+1 app/src/main/res/values/strings.xml+4
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+5
@@ -116,6 +116,11 @@ object NetworkConfig {
116116 /** Buffer capacity for transient error SharedFlows that drive snackbars. */
117117 const val snackbarEventBufferCapacity = 16
118118
119+ /** How long the chat top-bar refresh icon stays in its "refreshing" spinner state
120+ * after a tap, giving immediate feedback that the refresh was triggered even when
121+ * the connection state is already Connected (so the ConnectionBanner doesn't change). */
122+ const val refreshFeedbackMs = 1_200L
123+
119124 // --- UI layout constants ---
120125
121126 /** Minimum window width (dp) for two-pane layout on tablets / unfolded foldables. */
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+10−1
@@ -105,7 +105,15 @@ fun AgentPickerSheet(
105105 it.displayDescription.contains(q, ignoreCase = true)
106106 }
107107 }
108− LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
108+ if (filtered.isEmpty()) {
109+ Text(
110+ stringResource(R.string.no_agents_match, query.trim()),
111+ style = MaterialTheme.typography.bodyMedium,
112+ color = MaterialTheme.colorScheme.onSurfaceVariant,
113+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
114+ )
115+ } else {
116+ LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
109117 item(key = "__default") {
110118 Column(
111119 modifier = Modifier
@@ -174,6 +182,7 @@ fun AgentPickerSheet(
174182 }
175183 }
176184 }
185+ }
177186 }
178187 }
179188}
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+37−3
@@ -110,6 +110,8 @@ fun ChatScreen(
110110 val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
111111 val running by vm.running.collectAsStateWithLifecycle()
112112 val loading by vm.loading.collectAsStateWithLifecycle()
113+ val loadError by vm.loadError.collectAsStateWithLifecycle()
114+ val refreshing by vm.refreshing.collectAsStateWithLifecycle()
113115 val models by vm.models.collectAsStateWithLifecycle()
114116 val modelsLoading by vm.modelsLoading.collectAsStateWithLifecycle()
115117 val modelsError by vm.modelsError.collectAsStateWithLifecycle()
@@ -260,9 +262,23 @@ fun ChatScreen(
260262 }
261263 // Manual refresh: forces an SSE reconnect, which re-seeds messages
262264 // from REST. A recovery path when the stream silently drops and the
263− // auto-reconnect re-seed is slow or fails.
264− IconButton(onClick = { vm.refreshMessages() }, enabled = activeConnection != null) {
265− Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
265+ // auto-reconnect re-seed is slow or fails. Shows a brief spinner as
266+ // immediate tap feedback — the SSE reconnect may not visibly change
267+ // the connection state when already Connected, so without it the tap
268+ // appears to do nothing.
269+ val refreshLabel = stringResource(R.string.refresh)
270+ IconButton(
271+ onClick = { vm.refreshMessages() },
272+ enabled = activeConnection != null && !refreshing,
273+ ) {
274+ if (refreshing) {
275+ CircularProgressIndicator(
276+ Modifier.size(18.dp).semantics { contentDescription = refreshLabel },
277+ strokeWidth = 2.dp,
278+ )
279+ } else {
280+ Icon(Icons.Filled.Refresh, contentDescription = refreshLabel)
281+ }
266282 }
267283 IconButton(
268284 onClick = {
@@ -384,6 +400,24 @@ fun ChatScreen(
384400 .align(Alignment.Center)
385401 .semantics { contentDescription = loadingLabel },
386402 )
403+ } else if (loadError && messages.isEmpty()) {
404+ // A failed load with nothing to show. Distinct from the empty
405+ // conversation state below — the conversation isn't empty, it
406+ // failed to load, and offering "Start a conversation" here is
407+ // misleading. Offer a Retry instead, mirroring SessionListScreen.
408+ Column(
409+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
410+ horizontalAlignment = Alignment.CenterHorizontally,
411+ ) {
412+ Text(
413+ stringResource(R.string.load_messages_failed),
414+ color = MaterialTheme.colorScheme.error,
415+ )
416+ Spacer(Modifier.size(12.dp))
417+ Button(onClick = { vm.refreshMessages() }) {
418+ Text(stringResource(R.string.retry))
419+ }
420+ }
387421 } else if (messages.isEmpty() && running) {
388422 // A run just started but no parts have arrived yet — the empty
389423 // list with only the trailing "working…" row looks broken, so
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+37−1
@@ -61,6 +61,18 @@ class ChatViewModel(
6161 private val _loading = MutableStateFlow(true)
6262 val loading: StateFlow<Boolean> = _loading.asStateFlow()
6363
64+ /** Set when the messages flow failed to load (and the list is empty), so the UI
65+ * can render a distinct error state instead of masquerading as an empty
66+ * conversation. Cleared on a successful non-empty emission. */
67+ private val _loadError = MutableStateFlow(false)
68+ val loadError: StateFlow<Boolean> = _loadError.asStateFlow()
69+
70+ /** Tracks whether the messages flow has ever emitted a non-empty list, so
71+ * [messages]' retryWhen can decide whether a re-fetch failure should set
72+ * [loadError] (nothing shown yet → error screen) or just emit a snackbar
73+ * (already showing messages → don't replace the conversation with an error). */
74+ private var hasShownMessages = false
75+
6476 /** Separate from [loading]: tracks the manual reconnect() flow so its spinner
6577 * doesn't conflict with the messages flow's loading state. */
6678 private val _reconnecting = MutableStateFlow(false)
@@ -85,9 +97,18 @@ class ChatViewModel(
8597 }
8698 .onEach {
8799 _loading.value = false
100+ if (it.isNotEmpty()) {
101+ hasShownMessages = true
102+ _loadError.value = false
103+ }
88104 }
89105 .retryWhen { cause, _ ->
90106 _loading.value = false
107+ // Only surface the persistent error state when there's nothing to
108+ // show — if we already have messages, a transient re-fetch failure
109+ // is better surfaced as a snackbar (via errorEvents) than by
110+ // replacing the visible conversation with an error screen.
111+ if (!hasShownMessages) _loadError.value = true
91112 _errorEvents.tryEmit(container.friendlyError(cause))
92113 // Delay before retrying to avoid a tight loop on persistent errors.
93114 delay(NetworkConfig.retryInitialDelayMs)
@@ -464,14 +485,29 @@ class ChatViewModel(
464485 _queuedFollowUp.value = trimmed.takeIf { it.isNotEmpty() }
465486 }
466487
488+ /** Transient flag set by [refreshMessages] so the top-bar refresh icon can show
489+ * a brief spinner as immediate tap feedback. The SSE reconnect triggered by
490+ * refreshMessages may not visibly change [connectionState] (it's already
491+ * Connected), so without this the tap appears to do nothing. Clears after a
492+ * short delay or when the connection state next becomes Connected. */
493+ private val _refreshing = MutableStateFlow(false)
494+ val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
495+
467496 /** Manually re-fetch messages by forcing the SSE stream to reconnect, which
468497 * triggers the repository's re-seed-from-REST logic. Gives the user a recovery
469498 * path when the SSE stream silently drops and the auto-reconnect re-seed is slow
470499 * or fails — a pull-to-refresh or top-bar refresh forces a fresh fetch under the
471− * existing generation-guarded merge. */
500+ * existing generation-guarded merge. Also clears [loadError] optimistically and
501+ * sets [refreshing] for immediate tap feedback. */
472502 fun refreshMessages() {
473503 val conn = connection ?: return
504+ _loadError.value = false
505+ _refreshing.value = true
474506 conn.events.triggerReconnect()
507+ viewModelScope.launch {
508+ delay(NetworkConfig.refreshFeedbackMs)
509+ _refreshing.value = false
510+ }
475511 }
476512
477513 /** Invoke a slash-command by name via the server's /command endpoint. */
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+58−23
@@ -7,23 +7,31 @@ import androidx.compose.foundation.layout.heightIn
77import androidx.compose.foundation.layout.padding
88import androidx.compose.foundation.lazy.LazyColumn
99import androidx.compose.foundation.lazy.items
10+import androidx.compose.foundation.text.KeyboardOptions
1011import androidx.compose.material.icons.Icons
1112import androidx.compose.material.icons.automirrored.filled.PlaylistPlay
13+import androidx.compose.material.icons.filled.Search
1214import androidx.compose.material3.CircularProgressIndicator
1315import androidx.compose.material3.ExperimentalMaterial3Api
1416import androidx.compose.material3.Icon
1517import androidx.compose.material3.MaterialTheme
1618import androidx.compose.material3.ModalBottomSheet
19+import androidx.compose.material3.OutlinedTextField
1720import androidx.compose.material3.Text
1821import androidx.compose.material3.TextButton
1922import androidx.compose.runtime.Composable
23+import androidx.compose.runtime.getValue
24+import androidx.compose.runtime.mutableStateOf
25+import androidx.compose.runtime.remember
26+import androidx.compose.runtime.saveable.rememberSaveable
27+import androidx.compose.runtime.setValue
2028import androidx.compose.ui.Alignment
2129import androidx.compose.ui.Modifier
2230import androidx.compose.ui.res.stringResource
2331import androidx.compose.ui.semantics.Role
2432import androidx.compose.ui.semantics.contentDescription
25−import androidx.compose.ui.semantics.role
2633import androidx.compose.ui.semantics.semantics
34+import androidx.compose.ui.text.input.ImeAction
2735import androidx.compose.ui.text.style.TextOverflow
2836import androidx.compose.ui.unit.dp
2937import androidx.compose.foundation.layout.Box
@@ -75,32 +83,59 @@ fun CommandPickerSheet(
7583 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
7684 )
7785 } else {
78− LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
79− items(commands, key = { it.name }) { cmd ->
80− Column(
81− modifier = Modifier
82− .fillMaxWidth()
83− .clickable(role = Role.Button) { onSelect(cmd); onDismiss() }
84− .padding(horizontal = 24.dp, vertical = 12.dp),
85− ) {
86− androidx.compose.foundation.layout.Row(
87− verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
86+ var query by rememberSaveable { mutableStateOf("") }
87+ OutlinedTextField(
88+ value = query,
89+ onValueChange = { query = it },
90+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp),
91+ placeholder = { Text(stringResource(R.string.search_commands)) },
92+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
93+ singleLine = true,
94+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
95+ )
96+ val filtered = remember(commands, query) {
97+ val q = query.trim()
98+ if (q.isEmpty()) commands
99+ else commands.filter {
100+ it.name.contains(q, ignoreCase = true) ||
101+ it.displayDescription.contains(q, ignoreCase = true)
102+ }
103+ }
104+ if (filtered.isEmpty()) {
105+ Text(
106+ stringResource(R.string.no_commands_match, query.trim()),
107+ style = MaterialTheme.typography.bodyMedium,
108+ color = MaterialTheme.colorScheme.onSurfaceVariant,
109+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
110+ )
111+ } else {
112+ LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
113+ items(filtered, key = { it.name }) { cmd ->
114+ Column(
115+ modifier = Modifier
116+ .fillMaxWidth()
117+ .clickable(role = Role.Button) { onSelect(cmd); onDismiss() }
118+ .padding(horizontal = 24.dp, vertical = 12.dp),
88119 ) {
89− Icon(Icons.AutoMirrored.Filled.PlaylistPlay, contentDescription = null)
120+ androidx.compose.foundation.layout.Row(
121+ verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
122+ ) {
123+ Icon(Icons.AutoMirrored.Filled.PlaylistPlay, contentDescription = null)
124+ Text(
125+ "/${cmd.name}",
126+ style = MaterialTheme.typography.bodyLarge,
127+ modifier = Modifier.padding(start = 8.dp),
128+ )
129+ }
90130 Text(
91− "/${cmd.name}",
92− style = MaterialTheme.typography.bodyLarge,
93− modifier = Modifier.padding(start = 8.dp),
131+ cmd.displayDescription,
132+ style = MaterialTheme.typography.bodySmall,
133+ color = MaterialTheme.colorScheme.onSurfaceVariant,
134+ maxLines = 2,
135+ overflow = TextOverflow.Ellipsis,
136+ modifier = Modifier.padding(start = 28.dp, top = 2.dp),
94137 )
95138 }
96− Text(
97− cmd.displayDescription,
98− style = MaterialTheme.typography.bodySmall,
99− color = MaterialTheme.colorScheme.onSurfaceVariant,
100− maxLines = 2,
101− overflow = TextOverflow.Ellipsis,
102− modifier = Modifier.padding(start = 28.dp, top = 2.dp),
103− )
104139 }
105140 }
106141 }
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+33−1
@@ -49,6 +49,16 @@ fun MessageBubble(
4949
5050@Composable
5151private fun UserBubble(message: MessageWithParts, imageContext: ImageLoadContext?, modifier: Modifier) {
52+ val context = LocalContext.current
53+ val copyLabel = stringResource(R.string.copy)
54+ // Collect text from all TextParts for copying, so a user can reuse/repost their
55+ // own prompt. Memoized so a scroll-induced recomposition doesn't re-scan the list.
56+ val textToCopy = remember(message.parts) {
57+ message.parts
58+ .filterIsInstance<TextPart>()
59+ .joinToString("\n\n") { it.text }
60+ .takeIf { it.isNotBlank() }
61+ }
5262 Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
5363 Column(
5464 modifier = Modifier
@@ -59,7 +69,29 @@ private fun UserBubble(message: MessageWithParts, imageContext: ImageLoadContext
5969 verticalArrangement = Arrangement.spacedBy(6.dp),
6070 ) {
6171 for (part in message.parts) PartView(part, imageContext = imageContext)
62− MessageTimestamp(message.info)
72+ Row(
73+ modifier = Modifier.fillMaxWidth(),
74+ horizontalArrangement = Arrangement.SpaceBetween,
75+ verticalAlignment = Alignment.CenterVertically,
76+ ) {
77+ MessageTimestamp(message.info)
78+ // Copy affordance for user prompts, mirroring the assistant block.
79+ // Without it, reusing a prior prompt requires discovering the
80+ // long-press on the markdown text (and only TextParts support that).
81+ if (textToCopy != null) {
82+ IconButton(
83+ onClick = { copyToClipboard(context, "message", textToCopy) },
84+ modifier = Modifier.size(20.dp),
85+ ) {
86+ Icon(
87+ Icons.Filled.ContentCopy,
88+ contentDescription = copyLabel,
89+ modifier = Modifier.size(14.dp),
90+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
91+ )
92+ }
93+ }
94+ }
6395 }
6496 }
6597}
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+7
@@ -18,6 +18,7 @@ import androidx.compose.foundation.verticalScroll
1818import androidx.compose.material.icons.Icons
1919import androidx.compose.material.icons.automirrored.filled.ArrowBack
2020import androidx.compose.material.icons.filled.ContentCopy
21+import androidx.compose.material.icons.filled.Refresh
2122import androidx.compose.material.icons.filled.Share
2223import androidx.compose.material3.CircularProgressIndicator
2324import androidx.compose.material3.ExperimentalMaterial3Api
@@ -72,6 +73,12 @@ fun FileViewScreen(
7273 }
7374 },
7475 actions = {
76+ // In-place refresh: a file's content can change on the server (the
77+ // agent may be editing it), so reload without requiring a back-out.
78+ // Disabled while loading to avoid stacking parallel fetches.
79+ IconButton(onClick = { vm.reload() }, enabled = !state.loading) {
80+ Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
81+ }
7582 val content = state.content?.content.orEmpty()
7683 if (content.isNotEmpty()) {
7784 IconButton(onClick = { copyToClipboard(context, filename, content) }) {
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+9−2
@@ -139,6 +139,7 @@ private fun ServerEditForm(
139139 vm: ServerEditViewModel,
140140 onDone: () -> Unit,
141141) {
142+ val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
142143 Column(
143144 modifier = Modifier
144145 .padding(padding)
@@ -209,7 +210,10 @@ private fun ServerEditForm(
209210 horizontalArrangement = Arrangement.spacedBy(12.dp),
210211 ) {
211212 Button(
212− onClick = { vm.save(onDone) },
213+ onClick = {
214+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
215+ vm.save(onDone)
216+ },
213217 enabled = state.canSave && !state.saving,
214218 modifier = Modifier.weight(1f),
215219 ) {
@@ -225,7 +229,10 @@ private fun ServerEditForm(
225229 }
226230 }
227231 Button(
228− onClick = { vm.saveAndConnect(onDone) },
232+ onClick = {
233+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
234+ vm.saveAndConnect(onDone)
235+ },
229236 enabled = state.canSave && !state.saving,
230237 modifier = Modifier.weight(1f),
231238 ) {
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+1
@@ -131,6 +131,7 @@ fun ServerListScreen(
131131 .fillMaxWidth()
132132 .testTag("server_card")
133133 .clickable(enabled = connectingId == null, role = Role.Button) {
134+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
134135 vm.connect(profile, onConnected)
135136 },
136137 ) {
app/src/main/res/values/strings.xml+4
@@ -97,6 +97,7 @@
9797 <string name="message_placeholder">Message opencode…</string>
9898 <string name="empty_chat_title">Start a conversation</string>
9999 <string name="empty_chat_hint">Type a message below to begin.</string>
100+ <string name="load_messages_failed">Couldn\'t load messages.</string>
100101 <string name="empty_detail_pane">Select a session to view the conversation.</string>
101102 <string name="working">working…</string>
102103 <string name="latest">Latest</string>
@@ -151,12 +152,15 @@
151152 <string name="default_agent">Default</string>
152153 <string name="default_agent_desc">Server default agent</string>
153154 <string name="no_commands">No commands reported by the server.</string>
155+ <string name="no_commands_match">No commands match "%1$s".</string>
154156 <string name="load_failed">Failed to load. Tap retry.</string>
155157 <string name="saving">Saving…</string>
156158 <string name="image_failed">Failed to load image</string>
157159 <string name="search_models">Search models…</string>
158160 <string name="search_agents">Search agents…</string>
161+ <string name="search_commands">Search commands…</string>
159162 <string name="no_models_match">No models match "%1$s".</string>
163+ <string name="no_agents_match">No agents match "%1$s".</string>
160164
161165 <!-- File browser -->
162166 <string name="search_files">Search files…</string>