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
9b25fb8iko
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+5
@@ -116,6 +116,11 @@ object NetworkConfig {
116 /** Buffer capacity for transient error SharedFlows that drive snackbars. */
116 /** Buffer capacity for transient error SharedFlows that drive snackbars. */
117 const val snackbarEventBufferCapacity = 16
117 const val snackbarEventBufferCapacity = 16
118
118
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+
119 // --- UI layout constants ---
124 // --- UI layout constants ---
120
125
121 /** Minimum window width (dp) for two-pane layout on tablets / unfolded foldables. */
126 /** 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(
105 it.displayDescription.contains(q, ignoreCase = true)
105 it.displayDescription.contains(q, ignoreCase = true)
106 }
106 }
107 }
107 }
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 {
108− LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
116+ LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
109 item(key = "__default") {
117 item(key = "__default") {
110 Column(
118 Column(
111 modifier = Modifier
119 modifier = Modifier
@@ -174,6 +182,7 @@ fun AgentPickerSheet(
174 }
182 }
175 }
183 }
176 }
184 }
185+ }
177 }
186 }
178 }
187 }
179}
188}
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+37−3
@@ -110,6 +110,8 @@ fun ChatScreen(
110 val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
110 val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
111 val running by vm.running.collectAsStateWithLifecycle()
111 val running by vm.running.collectAsStateWithLifecycle()
112 val loading by vm.loading.collectAsStateWithLifecycle()
112 val loading by vm.loading.collectAsStateWithLifecycle()
113+ val loadError by vm.loadError.collectAsStateWithLifecycle()
114+ val refreshing by vm.refreshing.collectAsStateWithLifecycle()
113 val models by vm.models.collectAsStateWithLifecycle()
115 val models by vm.models.collectAsStateWithLifecycle()
114 val modelsLoading by vm.modelsLoading.collectAsStateWithLifecycle()
116 val modelsLoading by vm.modelsLoading.collectAsStateWithLifecycle()
115 val modelsError by vm.modelsError.collectAsStateWithLifecycle()
117 val modelsError by vm.modelsError.collectAsStateWithLifecycle()
@@ -260,9 +262,23 @@ fun ChatScreen(
260 }
262 }
261 // Manual refresh: forces an SSE reconnect, which re-seeds messages
263 // Manual refresh: forces an SSE reconnect, which re-seeds messages
262 // from REST. A recovery path when the stream silently drops and the
264 // from REST. A recovery path when the stream silently drops and the
263− // auto-reconnect re-seed is slow or fails.
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(
264− IconButton(onClick = { vm.refreshMessages() }, enabled = activeConnection != null) {
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 {
265− Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
280+ Icon(Icons.Filled.Refresh, contentDescription = refreshLabel)
281+ }
266 }
282 }
267 IconButton(
283 IconButton(
268 onClick = {
284 onClick = {
@@ -384,6 +400,24 @@ fun ChatScreen(
384 .align(Alignment.Center)
400 .align(Alignment.Center)
385 .semantics { contentDescription = loadingLabel },
401 .semantics { contentDescription = loadingLabel },
386 )
402 )
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+ }
387 } else if (messages.isEmpty() && running) {
421 } else if (messages.isEmpty() && running) {
388 // A run just started but no parts have arrived yet — the empty
422 // A run just started but no parts have arrived yet — the empty
389 // list with only the trailing "working…" row looks broken, so
423 // 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(
61 private val _loading = MutableStateFlow(true)
61 private val _loading = MutableStateFlow(true)
62 val loading: StateFlow<Boolean> = _loading.asStateFlow()
62 val loading: StateFlow<Boolean> = _loading.asStateFlow()
63
63
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+
64 /** Separate from [loading]: tracks the manual reconnect() flow so its spinner
76 /** Separate from [loading]: tracks the manual reconnect() flow so its spinner
65 * doesn't conflict with the messages flow's loading state. */
77 * doesn't conflict with the messages flow's loading state. */
66 private val _reconnecting = MutableStateFlow(false)
78 private val _reconnecting = MutableStateFlow(false)
@@ -85,9 +97,18 @@ class ChatViewModel(
85 }
97 }
86 .onEach {
98 .onEach {
87 _loading.value = false
99 _loading.value = false
100+ if (it.isNotEmpty()) {
101+ hasShownMessages = true
102+ _loadError.value = false
103+ }
88 }
104 }
89 .retryWhen { cause, _ ->
105 .retryWhen { cause, _ ->
90 _loading.value = false
106 _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
91 _errorEvents.tryEmit(container.friendlyError(cause))
112 _errorEvents.tryEmit(container.friendlyError(cause))
92 // Delay before retrying to avoid a tight loop on persistent errors.
113 // Delay before retrying to avoid a tight loop on persistent errors.
93 delay(NetworkConfig.retryInitialDelayMs)
114 delay(NetworkConfig.retryInitialDelayMs)
@@ -464,14 +485,29 @@ class ChatViewModel(
464 _queuedFollowUp.value = trimmed.takeIf { it.isNotEmpty() }
485 _queuedFollowUp.value = trimmed.takeIf { it.isNotEmpty() }
465 }
486 }
466
487
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+
467 /** Manually re-fetch messages by forcing the SSE stream to reconnect, which
496 /** Manually re-fetch messages by forcing the SSE stream to reconnect, which
468 * triggers the repository's re-seed-from-REST logic. Gives the user a recovery
497 * triggers the repository's re-seed-from-REST logic. Gives the user a recovery
469 * path when the SSE stream silently drops and the auto-reconnect re-seed is slow
498 * path when the SSE stream silently drops and the auto-reconnect re-seed is slow
470 * or fails — a pull-to-refresh or top-bar refresh forces a fresh fetch under the
499 * 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. */
472 fun refreshMessages() {
502 fun refreshMessages() {
473 val conn = connection ?: return
503 val conn = connection ?: return
504+ _loadError.value = false
505+ _refreshing.value = true
474 conn.events.triggerReconnect()
506 conn.events.triggerReconnect()
507+ viewModelScope.launch {
508+ delay(NetworkConfig.refreshFeedbackMs)
509+ _refreshing.value = false
510+ }
475 }
511 }
476
512
477 /** Invoke a slash-command by name via the server's /command endpoint. */
513 /** 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
7import androidx.compose.foundation.layout.padding
7import androidx.compose.foundation.layout.padding
8import androidx.compose.foundation.lazy.LazyColumn
8import androidx.compose.foundation.lazy.LazyColumn
9import androidx.compose.foundation.lazy.items
9import androidx.compose.foundation.lazy.items
10+import androidx.compose.foundation.text.KeyboardOptions
10import androidx.compose.material.icons.Icons
11import androidx.compose.material.icons.Icons
11import androidx.compose.material.icons.automirrored.filled.PlaylistPlay
12import androidx.compose.material.icons.automirrored.filled.PlaylistPlay
13+import androidx.compose.material.icons.filled.Search
12import androidx.compose.material3.CircularProgressIndicator
14import androidx.compose.material3.CircularProgressIndicator
13import androidx.compose.material3.ExperimentalMaterial3Api
15import androidx.compose.material3.ExperimentalMaterial3Api
14import androidx.compose.material3.Icon
16import androidx.compose.material3.Icon
15import androidx.compose.material3.MaterialTheme
17import androidx.compose.material3.MaterialTheme
16import androidx.compose.material3.ModalBottomSheet
18import androidx.compose.material3.ModalBottomSheet
19+import androidx.compose.material3.OutlinedTextField
17import androidx.compose.material3.Text
20import androidx.compose.material3.Text
18import androidx.compose.material3.TextButton
21import androidx.compose.material3.TextButton
19import androidx.compose.runtime.Composable
22import 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
20import androidx.compose.ui.Alignment
28import androidx.compose.ui.Alignment
21import androidx.compose.ui.Modifier
29import androidx.compose.ui.Modifier
22import androidx.compose.ui.res.stringResource
30import androidx.compose.ui.res.stringResource
23import androidx.compose.ui.semantics.Role
31import androidx.compose.ui.semantics.Role
24import androidx.compose.ui.semantics.contentDescription
32import androidx.compose.ui.semantics.contentDescription
25−import androidx.compose.ui.semantics.role
26import androidx.compose.ui.semantics.semantics
33import androidx.compose.ui.semantics.semantics
34+import androidx.compose.ui.text.input.ImeAction
27import androidx.compose.ui.text.style.TextOverflow
35import androidx.compose.ui.text.style.TextOverflow
28import androidx.compose.ui.unit.dp
36import androidx.compose.ui.unit.dp
29import androidx.compose.foundation.layout.Box
37import androidx.compose.foundation.layout.Box
@@ -75,32 +83,59 @@ fun CommandPickerSheet(
75 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
83 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
76 )
84 )
77 } else {
85 } else {
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 {
78− LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
112+ LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
79− items(commands, key = { it.name }) { cmd ->
113+ items(filtered, key = { it.name }) { cmd ->
80− Column(
114+ Column(
81− modifier = Modifier
115+ modifier = Modifier
82− .fillMaxWidth()
116+ .fillMaxWidth()
83− .clickable(role = Role.Button) { onSelect(cmd); onDismiss() }
117+ .clickable(role = Role.Button) { onSelect(cmd); onDismiss() }
84− .padding(horizontal = 24.dp, vertical = 12.dp),
118+ .padding(horizontal = 24.dp, vertical = 12.dp),
85− ) {
86− androidx.compose.foundation.layout.Row(
87− verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
88 ) {
119 ) {
120+ androidx.compose.foundation.layout.Row(
121+ verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
122+ ) {
89− Icon(Icons.AutoMirrored.Filled.PlaylistPlay, contentDescription = null)
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+ }
90 Text(
130 Text(
91− "/${cmd.name}",
131+ cmd.displayDescription,
92− style = MaterialTheme.typography.bodyLarge,
132+ style = MaterialTheme.typography.bodySmall,
133+ color = MaterialTheme.colorScheme.onSurfaceVariant,
134+ maxLines = 2,
135+ overflow = TextOverflow.Ellipsis,
93− modifier = Modifier.padding(start = 8.dp),
136+ modifier = Modifier.padding(start = 28.dp, top = 2.dp),
94 )
137 )
95 }
138 }
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− )
104 }
139 }
105 }
140 }
106 }
141 }
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+33−1
@@ -49,6 +49,16 @@ fun MessageBubble(
49
49
50@Composable
50@Composable
51private fun UserBubble(message: MessageWithParts, imageContext: ImageLoadContext?, modifier: Modifier) {
51private 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+ }
52 Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
62 Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
53 Column(
63 Column(
54 modifier = Modifier
64 modifier = Modifier
@@ -59,7 +69,29 @@ private fun UserBubble(message: MessageWithParts, imageContext: ImageLoadContext
59 verticalArrangement = Arrangement.spacedBy(6.dp),
69 verticalArrangement = Arrangement.spacedBy(6.dp),
60 ) {
70 ) {
61 for (part in message.parts) PartView(part, imageContext = imageContext)
71 for (part in message.parts) PartView(part, imageContext = imageContext)
72+ Row(
73+ modifier = Modifier.fillMaxWidth(),
74+ horizontalArrangement = Arrangement.SpaceBetween,
75+ verticalAlignment = Alignment.CenterVertically,
76+ ) {
62− MessageTimestamp(message.info)
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+ }
63 }
95 }
64 }
96 }
65}
97}
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+7
@@ -18,6 +18,7 @@ import androidx.compose.foundation.verticalScroll
18import androidx.compose.material.icons.Icons
18import androidx.compose.material.icons.Icons
19import androidx.compose.material.icons.automirrored.filled.ArrowBack
19import androidx.compose.material.icons.automirrored.filled.ArrowBack
20import androidx.compose.material.icons.filled.ContentCopy
20import androidx.compose.material.icons.filled.ContentCopy
21+import androidx.compose.material.icons.filled.Refresh
21import androidx.compose.material.icons.filled.Share
22import androidx.compose.material.icons.filled.Share
22import androidx.compose.material3.CircularProgressIndicator
23import androidx.compose.material3.CircularProgressIndicator
23import androidx.compose.material3.ExperimentalMaterial3Api
24import androidx.compose.material3.ExperimentalMaterial3Api
@@ -72,6 +73,12 @@ fun FileViewScreen(
72 }
73 }
73 },
74 },
74 actions = {
75 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+ }
75 val content = state.content?.content.orEmpty()
82 val content = state.content?.content.orEmpty()
76 if (content.isNotEmpty()) {
83 if (content.isNotEmpty()) {
77 IconButton(onClick = { copyToClipboard(context, filename, content) }) {
84 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(
139 vm: ServerEditViewModel,
139 vm: ServerEditViewModel,
140 onDone: () -> Unit,
140 onDone: () -> Unit,
141) {
141) {
142+ val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
142 Column(
143 Column(
143 modifier = Modifier
144 modifier = Modifier
144 .padding(padding)
145 .padding(padding)
@@ -209,7 +210,10 @@ private fun ServerEditForm(
209 horizontalArrangement = Arrangement.spacedBy(12.dp),
210 horizontalArrangement = Arrangement.spacedBy(12.dp),
210 ) {
211 ) {
211 Button(
212 Button(
213+ onClick = {
214+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
212− onClick = { vm.save(onDone) },
215+ vm.save(onDone)
216+ },
213 enabled = state.canSave && !state.saving,
217 enabled = state.canSave && !state.saving,
214 modifier = Modifier.weight(1f),
218 modifier = Modifier.weight(1f),
215 ) {
219 ) {
@@ -225,7 +229,10 @@ private fun ServerEditForm(
225 }
229 }
226 }
230 }
227 Button(
231 Button(
232+ onClick = {
233+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
228− onClick = { vm.saveAndConnect(onDone) },
234+ vm.saveAndConnect(onDone)
235+ },
229 enabled = state.canSave && !state.saving,
236 enabled = state.canSave && !state.saving,
230 modifier = Modifier.weight(1f),
237 modifier = Modifier.weight(1f),
231 ) {
238 ) {
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+1
@@ -131,6 +131,7 @@ fun ServerListScreen(
131 .fillMaxWidth()
131 .fillMaxWidth()
132 .testTag("server_card")
132 .testTag("server_card")
133 .clickable(enabled = connectingId == null, role = Role.Button) {
133 .clickable(enabled = connectingId == null, role = Role.Button) {
134+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
134 vm.connect(profile, onConnected)
135 vm.connect(profile, onConnected)
135 },
136 },
136 ) {
137 ) {
app/src/main/res/values/strings.xml+4
@@ -97,6 +97,7 @@
97 <string name="message_placeholder">Message opencode…</string>
97 <string name="message_placeholder">Message opencode…</string>
98 <string name="empty_chat_title">Start a conversation</string>
98 <string name="empty_chat_title">Start a conversation</string>
99 <string name="empty_chat_hint">Type a message below to begin.</string>
99 <string name="empty_chat_hint">Type a message below to begin.</string>
100+ <string name="load_messages_failed">Couldn\'t load messages.</string>
100 <string name="empty_detail_pane">Select a session to view the conversation.</string>
101 <string name="empty_detail_pane">Select a session to view the conversation.</string>
101 <string name="working">working…</string>
102 <string name="working">working…</string>
102 <string name="latest">Latest</string>
103 <string name="latest">Latest</string>
@@ -151,12 +152,15 @@
151 <string name="default_agent">Default</string>
152 <string name="default_agent">Default</string>
152 <string name="default_agent_desc">Server default agent</string>
153 <string name="default_agent_desc">Server default agent</string>
153 <string name="no_commands">No commands reported by the server.</string>
154 <string name="no_commands">No commands reported by the server.</string>
155+ <string name="no_commands_match">No commands match "%1$s".</string>
154 <string name="load_failed">Failed to load. Tap retry.</string>
156 <string name="load_failed">Failed to load. Tap retry.</string>
155 <string name="saving">Saving…</string>
157 <string name="saving">Saving…</string>
156 <string name="image_failed">Failed to load image</string>
158 <string name="image_failed">Failed to load image</string>
157 <string name="search_models">Search models…</string>
159 <string name="search_models">Search models…</string>
158 <string name="search_agents">Search agents…</string>
160 <string name="search_agents">Search agents…</string>
161+ <string name="search_commands">Search commands…</string>
159 <string name="no_models_match">No models match "%1$s".</string>
162 <string name="no_models_match">No models match "%1$s".</string>
163+ <string name="no_agents_match">No agents match "%1$s".</string>
160
164
161 <!-- File browser -->
165 <!-- File browser -->
162 <string name="search_files">Search files…</string>
166 <string name="search_files">Search files…</string>