git.iko.soy/opencode-android
on master help sign in
Fix 13 UI/UX issues: search retry/hint/highlight, touch targets, model/agent picker, cost bars, swipe-to-archive, undo consistency, dynamic color default, file-view reload, two-pane divider
59c889diko
app/src/main/java/soy/iko/opencode/MainActivity.kt+1−1
@@ -67,7 +67,7 @@ class MainActivity : FragmentActivity() {
67 notificationPermissionRequested = savedInstanceState.getBoolean(KEY_NOTIF_PERM_REQUESTED, false)
67 notificationPermissionRequested = savedInstanceState.getBoolean(KEY_NOTIF_PERM_REQUESTED, false)
68 }
68 }
69 // Hold the splash until the persisted theme and app-lock settings load, so we never
69 // Hold the splash until the persisted theme and app-lock settings load, so we never
70− // paint a frame with the defaults (dynamicColor=true / SYSTEM, app lock off) that then
70+ // paint a frame with the defaults (dynamicColor=false / SYSTEM, app lock off) that then
71 // snap to the user's real choice — including a brief unlocked, un-FLAG_SECURE'd frame
71 // snap to the user's real choice — including a brief unlocked, un-FLAG_SECURE'd frame
72 // before app lock resolves. Mirrors the SettingsScreen's null-gate.
72 // before app lock resolves. Mirrors the SettingsScreen's null-gate.
73 var settingsLoaded = false
73 var settingsLoaded = false
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+23
@@ -144,6 +144,29 @@ class CrashLogger private constructor(private val appContext: Context) {
144 }
144 }
145 }
145 }
146
146
147+ /** Deferred whole-directory clear, cancellable via [cancelScheduledClearAll] so the bulk
148+ * action gets the same undo window as a single-report delete. Runs on the logger's own
149+ * scope so it commits even if the user leaves the Diagnostics screen. */
150+ private var pendingClearAll: Job? = null
151+
152+ fun scheduleClearAll(delayMs: Long) {
153+ pendingClearAll?.cancel()
154+ pendingClearAll = scope.launch {
155+ delay(delayMs)
156+ pendingClearAll = null
157+ crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }?.forEach { it.delete() }
158+ refresh()
159+ }
160+ }
161+
162+ /** Cancel a pending clear-all (the Undo action). Returns true if it was still pending. */
163+ fun cancelScheduledClearAll(): Boolean {
164+ val job = pendingClearAll ?: return false
165+ pendingClearAll = null
166+ job.cancel()
167+ return true
168+ }
169+
147 fun reportCount(): Int = _reports.value.size
170 fun reportCount(): Int = _reports.value.size
148
171
149 /** Cancel the internal coroutine scope so background work doesn't outlive the
172 /** Cancel the internal coroutine scope so background work doesn't outlive the
app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.kt+4−1
@@ -37,8 +37,11 @@ class SettingsStore(context: Context) {
37 .getOrDefault(ThemeMode.SYSTEM)
37 .getOrDefault(ThemeMode.SYSTEM)
38 }
38 }
39
39
40+ // Default OFF so the app ships with its hand-built "Tokyo Night" brand palette rather than
41+ // the wallpaper-derived Material You scheme, which fully replaces the brand colours. Users
42+ // on Android 12+ can still opt into dynamic color from Settings.
40 val dynamicColor: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
43 val dynamicColor: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
41− prefs[dynamicColorKey] ?: true
44+ prefs[dynamicColorKey] ?: false
42 }
45 }
43
46
44 /** When true, the hardware Enter key sends the prompt; when false, Enter inserts a
47 /** When true, the hardware Enter key sends the prompt; when false, Enter inserts a
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+49−30
@@ -217,6 +217,7 @@ fun ChatScreen(
217 val inputFocusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
217 val inputFocusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
218 var showModelPicker by rememberSaveable { mutableStateOf(false) }
218 var showModelPicker by rememberSaveable { mutableStateOf(false) }
219 var showAgentPicker by rememberSaveable { mutableStateOf(false) }
219 var showAgentPicker by rememberSaveable { mutableStateOf(false) }
220+ var showTitleMenu by rememberSaveable { mutableStateOf(false) }
220 var showCommandPicker by rememberSaveable { mutableStateOf(false) }
221 var showCommandPicker by rememberSaveable { mutableStateOf(false) }
221 var showExitConfirm by rememberSaveable { mutableStateOf(false) }
222 var showExitConfirm by rememberSaveable { mutableStateOf(false) }
222 var showStopConfirm by rememberSaveable { mutableStateOf(false) }
223 var showStopConfirm by rememberSaveable { mutableStateOf(false) }
@@ -429,37 +430,55 @@ fun ChatScreen(
429 TopAppBar(
430 TopAppBar(
430 title = {
431 title = {
431 val changeLabel = stringResource(R.string.change_model_agent)
432 val changeLabel = stringResource(R.string.change_model_agent)
433+ // The subtitle reads "model · agent", so tapping the title offers *both*
434+ // — a small menu picks which picker to open. Previously the tap went
435+ // straight to the model picker, leaving the agent changeable only via
436+ // the overflow menu despite the title implying otherwise.
437+ Box {
432− Row(
438+ Row(
433− modifier = Modifier
439+ modifier = Modifier
434− .clickable(enabled = models.isNotEmpty(), role = Role.Button) { showModelPicker = true }
440+ .clickable(enabled = models.isNotEmpty(), role = Role.Button) { showTitleMenu = true }
435− .semantics(mergeDescendants = true) { contentDescription = changeLabel },
441+ .semantics(mergeDescendants = true) { contentDescription = changeLabel },
436− verticalAlignment = Alignment.CenterVertically,
442+ verticalAlignment = Alignment.CenterVertically,
437− ) {
443+ ) {
438− Column(modifier = Modifier.weight(1f, fill = false)) {
444+ Column(modifier = Modifier.weight(1f, fill = false)) {
439− // maxLines=1 + Ellipsis so a long session title can't wrap and
445+ // maxLines=1 + Ellipsis so a long session title can't wrap and
440− // push the top bar to multiple lines, shifting the whole layout.
446+ // push the top bar to multiple lines, shifting the whole layout.
441− Text(
447+ Text(
442− sessionTitle ?: sessionLabel,
448+ sessionTitle ?: sessionLabel,
443− maxLines = 1,
449+ maxLines = 1,
444− overflow = TextOverflow.Ellipsis,
450+ overflow = TextOverflow.Ellipsis,
445− )
451+ )
446− Text(
452+ Text(
447− subtitle,
453+ subtitle,
448− style = MaterialTheme.typography.labelSmall,
454+ style = MaterialTheme.typography.labelSmall,
449− color = MaterialTheme.colorScheme.onSurfaceVariant,
455+ color = MaterialTheme.colorScheme.onSurfaceVariant,
450− maxLines = 1,
456+ maxLines = 1,
451− overflow = TextOverflow.Ellipsis,
457+ overflow = TextOverflow.Ellipsis,
458+ )
459+ }
460+ // Visual affordance that the title is tappable; without it the
461+ // title is indistinguishable from plain text and the pickers are
462+ // effectively undiscoverable.
463+ if (models.isNotEmpty()) {
464+ Icon(
465+ Icons.Filled.KeyboardArrowDown,
466+ contentDescription = null,
467+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
468+ modifier = Modifier.size(20.dp),
452− )
469+ )
470+ }
453 }
471 }
454− // Visual affordance that the title opens the model picker;
472+ DropdownMenu(expanded = showTitleMenu, onDismissRequest = { showTitleMenu = false }) {
455− // without it the tappable title is indistinguishable from plain
473+ DropdownMenuItem(
456− // text and the picker is effectively undiscoverable.
474+ text = { Text(stringResource(R.string.palette_switch_model)) },
457− if (models.isNotEmpty()) {
475+ enabled = !modelsLoading || models.isNotEmpty(),
458− Icon(
476+ onClick = { showTitleMenu = false; showModelPicker = true },
459− Icons.Filled.KeyboardArrowDown,
477+ )
460− contentDescription = null,
478+ DropdownMenuItem(
461− tint = MaterialTheme.colorScheme.onSurfaceVariant,
479+ text = { Text(stringResource(R.string.palette_choose_agent)) },
462− modifier = Modifier.size(20.dp),
480+ enabled = !agentsLoading || agents.isNotEmpty(),
481+ onClick = { showTitleMenu = false; showAgentPicker = true },
463 )
482 )
464 }
483 }
465 }
484 }
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+11−1
@@ -41,6 +41,7 @@ import soy.iko.opencode.R
41import soy.iko.opencode.data.model.AssistantMessage
41import soy.iko.opencode.data.model.AssistantMessage
42import soy.iko.opencode.data.model.MessageWithParts
42import soy.iko.opencode.data.model.MessageWithParts
43import soy.iko.opencode.data.model.ModelOption
43import soy.iko.opencode.data.model.ModelOption
44+import soy.iko.opencode.data.model.FilePart
44import soy.iko.opencode.data.model.TextPart
45import soy.iko.opencode.data.model.TextPart
45import soy.iko.opencode.data.model.Tokens
46import soy.iko.opencode.data.model.Tokens
46import soy.iko.opencode.data.model.UnknownMessage
47import soy.iko.opencode.data.model.UnknownMessage
@@ -48,6 +49,7 @@ import soy.iko.opencode.data.model.UserMessage
48import soy.iko.opencode.data.network.NetworkConfig
49import soy.iko.opencode.data.network.NetworkConfig
49import soy.iko.opencode.ui.components.ImageLoadContext
50import soy.iko.opencode.ui.components.ImageLoadContext
50import soy.iko.opencode.ui.components.copyToClipboard
51import soy.iko.opencode.ui.components.copyToClipboard
52+import soy.iko.opencode.ui.components.showToast
51import soy.iko.opencode.ui.components.RelativeTimeText
53import soy.iko.opencode.ui.components.RelativeTimeText
52
54
53/**
55/**
@@ -244,7 +246,15 @@ private fun UserBubble(
244 // conversation to before it so a Send replaces it. Only when there's text
246 // conversation to before it so a Send replaces it. Only when there's text
245 // to edit (an image-only prompt has nothing to reload).
247 // to edit (an image-only prompt has nothing to reload).
246 if (onEdit != null && textToCopy != null) {
248 if (onEdit != null && textToCopy != null) {
249+ val dropsAttachmentsMsg = stringResource(R.string.edit_drops_attachments)
250+ val hasAttachments = message.parts.any { it is FilePart }
247− IconButton(onClick = { onEdit(textToCopy) }) {
251+ IconButton(onClick = {
252+ onEdit(textToCopy)
253+ // Editing reloads only the text; if this prompt carried images or
254+ // files, tell the user they won't be reattached rather than
255+ // silently dropping them.
256+ if (hasAttachments) showToast(context, dropsAttachmentsMsg)
257+ }) {
248 Icon(
258 Icon(
249 Icons.Filled.Edit,
259 Icons.Filled.Edit,
250 contentDescription = editLabel,
260 contentDescription = editLabel,
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+4−3
@@ -75,7 +75,7 @@ import soy.iko.opencode.ui.components.RemoteImage
75import soy.iko.opencode.ui.components.copyToClipboard
75import soy.iko.opencode.ui.components.copyToClipboard
76import soy.iko.opencode.ui.components.isImage
76import soy.iko.opencode.ui.components.isImage
77import soy.iko.opencode.ui.components.looksLikeDiff
77import soy.iko.opencode.ui.components.looksLikeDiff
78−import soy.iko.opencode.ui.components.showToast
78+import soy.iko.opencode.ui.components.showCopyToast
79import soy.iko.opencode.R
79import soy.iko.opencode.R
80
80
81private const val COLLAPSED_LIMIT = 600
81private const val COLLAPSED_LIMIT = 600
@@ -496,18 +496,19 @@ private fun FileChip(part: FilePart, modifier: Modifier, onOpenFile: ((String) -
496 } else if (!path.isNullOrBlank()) {
496 } else if (!path.isNullOrBlank()) {
497 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
497 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
498 copyToClipboard(context, "path", path)
498 copyToClipboard(context, "path", path)
499− showToast(context, context.getString(R.string.path_copied))
499+ showCopyToast(context, context.getString(R.string.path_copied))
500 }
500 }
501 },
501 },
502 onLongClick = {
502 onLongClick = {
503 if (!path.isNullOrBlank()) {
503 if (!path.isNullOrBlank()) {
504 haptics.performHapticFeedback(HapticFeedbackType.LongPress)
504 haptics.performHapticFeedback(HapticFeedbackType.LongPress)
505 copyToClipboard(context, "path", path)
505 copyToClipboard(context, "path", path)
506− showToast(context, context.getString(R.string.path_copied))
506+ showCopyToast(context, context.getString(R.string.path_copied))
507 }
507 }
508 },
508 },
509 )
509 )
510 .semantics { contentDescription = semanticsLabel }
510 .semantics { contentDescription = semanticsLabel }
511+ .defaultMinSize(minHeight = 48.dp)
511 .padding(horizontal = 12.dp, vertical = 6.dp),
512 .padding(horizontal = 12.dp, vertical = 6.dp),
512 verticalAlignment = Alignment.CenterVertically,
513 verticalAlignment = Alignment.CenterVertically,
513 ) {
514 ) {
app/src/main/java/soy/iko/opencode/ui/chat/TodoPlan.kt+1−1
@@ -120,7 +120,7 @@ fun TodoPlanBar(todos: List<TodoItem>, modifier: Modifier = Modifier) {
120 Row(
120 Row(
121 modifier = Modifier
121 modifier = Modifier
122 .fillMaxWidth()
122 .fillMaxWidth()
123− .defaultMinSize(minHeight = 44.dp)
123+ .defaultMinSize(minHeight = 48.dp)
124 .clickable(role = Role.Button) { expanded = !expanded }
124 .clickable(role = Role.Button) { expanded = !expanded }
125 .semantics { stateDescription = if (expanded) expandedState else collapsedState }
125 .semantics { stateDescription = if (expanded) expandedState else collapsedState }
126 .padding(horizontal = 12.dp, vertical = 6.dp),
126 .padding(horizontal = 12.dp, vertical = 6.dp),
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+11−2
@@ -3,6 +3,7 @@ package soy.iko.opencode.ui.components
3import android.content.ClipData
3import android.content.ClipData
4import android.content.ClipboardManager
4import android.content.ClipboardManager
5import android.content.Context
5import android.content.Context
6+import android.os.Build
6import android.widget.Toast
7import android.widget.Toast
7import androidx.compose.foundation.background
8import androidx.compose.foundation.background
8import androidx.compose.foundation.horizontalScroll
9import androidx.compose.foundation.horizontalScroll
@@ -293,11 +294,19 @@ internal fun extractCodeText(raw: String, isFenced: Boolean): String {
293 return body.joinToString("\n").trimEnd()
294 return body.joinToString("\n").trimEnd()
294}
295}
295
296
296−/** Copy [text] to the system clipboard and show a confirmation toast. */
297+/** Copy [text] to the system clipboard and show a confirmation toast (except on Android 13+,
298+ * which renders its own system copy confirmation — a toast there would be a redundant double). */
297internal fun copyToClipboard(context: Context, label: String, text: String = label) {
299internal fun copyToClipboard(context: Context, label: String, text: String = label) {
298 val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
300 val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
299 clipboard.setPrimaryClip(ClipData.newPlainText(label.take(40), text))
301 clipboard.setPrimaryClip(ClipData.newPlainText(label.take(40), text))
300− showToast(context, context.getString(R.string.copied))
302+ showCopyToast(context, context.getString(R.string.copied))
303+}
304+
305+/** Toast that self-suppresses on Android 13+ (Build.VERSION_CODES.TIRAMISU), where the platform
306+ * already shows a copy confirmation. Use for clipboard-copy feedback so it isn't doubled. */
307+internal fun showCopyToast(context: Context, message: String) {
308+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return
309+ showToast(context, message)
301}
310}
302
311
303private var lastToast: Toast? = null
312private var lastToast: Toast? = null
app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.kt+5−1
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.StateFlow
12import kotlinx.coroutines.flow.asStateFlow
12import kotlinx.coroutines.flow.asStateFlow
13import kotlinx.coroutines.flow.collectLatest
13import kotlinx.coroutines.flow.collectLatest
14import kotlinx.coroutines.flow.merge
14import kotlinx.coroutines.flow.merge
15+import kotlinx.coroutines.flow.update
15import kotlinx.coroutines.launch
16import kotlinx.coroutines.launch
16import soy.iko.opencode.util.runCatchingCancellable
17import soy.iko.opencode.util.runCatchingCancellable
17
18
@@ -49,7 +50,10 @@ class FileViewModel(
49 _state.value = FileViewState(loading = false, error = container.string(R.string.not_connected))
50 _state.value = FileViewState(loading = false, error = container.string(R.string.not_connected))
50 return@collectLatest
51 return@collectLatest
51 }
52 }
53+ // Keep any already-loaded content visible during a reload (only the initial
54+ // load has null content) so a manual refresh doesn't blank the file — matching
55+ // the file browser, which keeps the prior listing while refreshing.
52− _state.value = FileViewState(loading = true)
56+ _state.update { it.copy(loading = true, error = null) }
53 runCatchingCancellable { conn.api.readFile(path) }
57 runCatchingCancellable { conn.api.readFile(path) }
54 .onSuccess { _state.value = FileViewState(loading = false, content = it) }
58 .onSuccess { _state.value = FileViewState(loading = false, content = it) }
55 .onFailure { _state.value = FileViewState(loading = false, error = container.friendlyError(it)) }
59 .onFailure { _state.value = FileViewState(loading = false, error = container.friendlyError(it)) }
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+11−3
@@ -260,9 +260,15 @@ private fun FileViewTopBar(
260 },
260 },
261 actions = {
261 actions = {
262 // In-place refresh: a file's content can change on the server (the agent may
262 // In-place refresh: a file's content can change on the server (the agent may
263− // be editing it), so reload without requiring a back-out.
263+ // be editing it), so reload without requiring a back-out. Shows a spinner while
264+ // loading since the content now stays on screen during a reload (the spinner is
265+ // the only progress signal).
264 IconButton(onClick = onReload, enabled = !loading) {
266 IconButton(onClick = onReload, enabled = !loading) {
267+ if (loading) {
268+ CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
269+ } else {
265− Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
270+ Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
271+ }
266 }
272 }
267 if (content.isNotEmpty()) {
273 if (content.isNotEmpty()) {
268 // Find-in-file: toggles a search bar over the raw content. Stays enabled even in
274 // Find-in-file: toggles a search bar over the raw content. Stays enabled even in
@@ -311,7 +317,9 @@ private fun BoxScope.FileViewStateContent(
311 onRetry: () -> Unit,
317 onRetry: () -> Unit,
312) {
318) {
313 when {
319 when {
320+ // Full-screen spinner only on the initial load (no content yet). A reload keeps the
321+ // existing content on screen; the top-bar reload button shows the in-flight progress.
314− state.loading -> {
322+ state.loading && state.content == null -> {
315 val loadingLabel = stringResource(R.string.loading)
323 val loadingLabel = stringResource(R.string.loading)
316 CircularProgressIndicator(
324 CircularProgressIndicator(
317 Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
325 Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.kt+47−37
@@ -2,22 +2,21 @@ package soy.iko.opencode.ui.mcp
2
2
3import androidx.compose.foundation.layout.Arrangement
3import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Box
4import androidx.compose.foundation.layout.Box
5−import androidx.compose.foundation.layout.BoxScope
6import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.PaddingValues
6import androidx.compose.foundation.layout.PaddingValues
8import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.Row
9−import androidx.compose.foundation.layout.Spacer
10import androidx.compose.foundation.layout.fillMaxSize
8import androidx.compose.foundation.layout.fillMaxSize
11import androidx.compose.foundation.layout.fillMaxWidth
9import androidx.compose.foundation.layout.fillMaxWidth
12import androidx.compose.foundation.layout.padding
10import androidx.compose.foundation.layout.padding
13−import androidx.compose.foundation.layout.size
14import androidx.compose.foundation.lazy.LazyColumn
11import androidx.compose.foundation.lazy.LazyColumn
15import androidx.compose.foundation.lazy.items
12import androidx.compose.foundation.lazy.items
16import androidx.compose.material.icons.Icons
13import androidx.compose.material.icons.Icons
14+import androidx.compose.material.icons.filled.CloudOff
17import androidx.compose.material.icons.filled.CloudQueue
15import androidx.compose.material.icons.filled.CloudQueue
18import androidx.compose.material.icons.filled.Computer
16import androidx.compose.material.icons.filled.Computer
17+import androidx.compose.material.icons.filled.ErrorOutline
18+import androidx.compose.material.icons.filled.Hub
19import androidx.compose.material.icons.filled.Refresh
19import androidx.compose.material.icons.filled.Refresh
20−import androidx.compose.material3.Button
21import androidx.compose.material3.Card
20import androidx.compose.material3.Card
22import androidx.compose.material3.CircularProgressIndicator
21import androidx.compose.material3.CircularProgressIndicator
23import androidx.compose.material3.ExperimentalMaterial3Api
22import androidx.compose.material3.ExperimentalMaterial3Api
@@ -26,11 +25,14 @@ import androidx.compose.material3.IconButton
26import androidx.compose.material3.MaterialTheme
25import androidx.compose.material3.MaterialTheme
27import androidx.compose.material3.Scaffold
26import androidx.compose.material3.Scaffold
28import androidx.compose.material3.Text
27import androidx.compose.material3.Text
28+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
29import androidx.compose.runtime.Composable
29import androidx.compose.runtime.Composable
30import androidx.compose.runtime.getValue
30import androidx.compose.runtime.getValue
31import androidx.compose.ui.Alignment
31import androidx.compose.ui.Alignment
32import androidx.compose.ui.Modifier
32import androidx.compose.ui.Modifier
33import androidx.compose.ui.res.stringResource
33import androidx.compose.ui.res.stringResource
34+import androidx.compose.ui.semantics.contentDescription
35+import androidx.compose.ui.semantics.semantics
34import androidx.compose.ui.text.style.TextOverflow
36import androidx.compose.ui.text.style.TextOverflow
35import androidx.compose.ui.unit.dp
37import androidx.compose.ui.unit.dp
36import androidx.lifecycle.compose.collectAsStateWithLifecycle
38import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -38,6 +40,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
38import soy.iko.opencode.R
40import soy.iko.opencode.R
39import soy.iko.opencode.di.AppContainer
41import soy.iko.opencode.di.AppContainer
40import soy.iko.opencode.ui.components.AppTopBar
42import soy.iko.opencode.ui.components.AppTopBar
43+import soy.iko.opencode.ui.components.EmptyState
41import soy.iko.opencode.ui.vmFactory
44import soy.iko.opencode.ui.vmFactory
42
45
43@OptIn(ExperimentalMaterial3Api::class)
46@OptIn(ExperimentalMaterial3Api::class)
@@ -45,6 +48,7 @@ import soy.iko.opencode.ui.vmFactory
45fun McpScreen(container: AppContainer, onBack: () -> Unit) {
48fun McpScreen(container: AppContainer, onBack: () -> Unit) {
46 val vm: McpViewModel = viewModel(factory = vmFactory { McpViewModel(container) })
49 val vm: McpViewModel = viewModel(factory = vmFactory { McpViewModel(container) })
47 val state by vm.state.collectAsStateWithLifecycle()
50 val state by vm.state.collectAsStateWithLifecycle()
51+ val refreshing by vm.refreshing.collectAsStateWithLifecycle()
48
52
49 Scaffold(
53 Scaffold(
50 topBar = {
54 topBar = {
@@ -59,45 +63,51 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
59 )
63 )
60 },
64 },
61 ) { padding ->
65 ) { padding ->
66+ PullToRefreshBox(
67+ isRefreshing = refreshing,
68+ onRefresh = { vm.load() },
69+ modifier = Modifier.fillMaxSize().padding(padding),
70+ ) {
62− Box(modifier = Modifier.fillMaxSize().padding(padding)) {
71+ Box(modifier = Modifier.fillMaxSize()) {
72+ val loadingLabel = stringResource(R.string.loading)
63− when (val s = state) {
73+ when (val s = state) {
64− is McpViewModel.State.Loading ->
74+ is McpViewModel.State.Loading -> CircularProgressIndicator(
65− CircularProgressIndicator(Modifier.align(Alignment.Center))
75+ Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
76+ )
77+ is McpViewModel.State.Disconnected -> EmptyState(
78+ icon = Icons.Filled.CloudOff,
66− is McpViewModel.State.Disconnected -> CenteredText(stringResource(R.string.not_connected))
79+ title = stringResource(R.string.not_connected),
80+ modifier = Modifier.align(Alignment.Center),
81+ )
67− is McpViewModel.State.Error -> Column(
82+ is McpViewModel.State.Error -> EmptyState(
68− modifier = Modifier.align(Alignment.Center).padding(24.dp),
83+ icon = Icons.Filled.ErrorOutline,
69− horizontalAlignment = Alignment.CenterHorizontally,
70− ) {
71− Text(stringResource(R.string.mcp_failed), color = MaterialTheme.colorScheme.error)
84+ title = stringResource(R.string.mcp_failed),
72− Spacer(Modifier.size(12.dp))
85+ modifier = Modifier.align(Alignment.Center),
73− Button(onClick = { vm.load() }) { Text(stringResource(R.string.retry)) }
86+ actionLabel = stringResource(R.string.retry),
74− }
87+ onAction = { vm.load() },
88+ )
75− is McpViewModel.State.Ready ->
89+ is McpViewModel.State.Ready ->
76− if (s.servers.isEmpty()) {
90+ if (s.servers.isEmpty()) {
91+ EmptyState(
92+ icon = Icons.Filled.Hub,
77− CenteredText(stringResource(R.string.mcp_empty))
93+ title = stringResource(R.string.mcp_empty),
94+ modifier = Modifier.align(Alignment.Center),
95+ )
78− } else {
96+ } else {
79− LazyColumn(
97+ LazyColumn(
80− modifier = Modifier.fillMaxSize(),
98+ modifier = Modifier.fillMaxSize(),
81− contentPadding = PaddingValues(16.dp),
99+ contentPadding = PaddingValues(16.dp),
82− verticalArrangement = Arrangement.spacedBy(12.dp),
100+ verticalArrangement = Arrangement.spacedBy(12.dp),
83− ) {
101+ ) {
84− items(s.servers, key = { it.name }) { McpServerCard(it) }
102+ items(s.servers, key = { it.name }) { McpServerCard(it) }
103+ }
85 }
104 }
86− }
105+ }
87 }
106 }
88 }
107 }
89 }
108 }
90}
109}
91
110
92−@Composable
93−private fun BoxScope.CenteredText(text: String) {
94− Text(
95− text,
96− color = MaterialTheme.colorScheme.onSurfaceVariant,
97− modifier = Modifier.align(Alignment.Center).padding(24.dp),
98− )
99−}
100−
101@Composable
111@Composable
102private fun McpServerCard(server: McpServerInfo) {
112private fun McpServerCard(server: McpServerInfo) {
103 Card(modifier = Modifier.fillMaxWidth()) {
113 Card(modifier = Modifier.fillMaxWidth()) {
app/src/main/java/soy/iko/opencode/ui/mcp/McpViewModel.kt+9−1
@@ -28,6 +28,11 @@ class McpViewModel(private val container: AppContainer) : ViewModel() {
28 private val _state = MutableStateFlow<State>(State.Loading)
28 private val _state = MutableStateFlow<State>(State.Loading)
29 val state: StateFlow<State> = _state.asStateFlow()
29 val state: StateFlow<State> = _state.asStateFlow()
30
30
31+ /** True while a reload runs on top of already-loaded data, so the list stays visible and the
32+ * pull-to-refresh spinner (not the full-screen one) shows. */
33+ private val _refreshing = MutableStateFlow(false)
34+ val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
35+
31 /** Bumped by [load] (refresh/retry) to re-run without waiting for a connection change. */
36 /** Bumped by [load] (refresh/retry) to re-run without waiting for a connection change. */
32 private val _reload = MutableStateFlow(0)
37 private val _reload = MutableStateFlow(0)
33
38
@@ -49,12 +54,15 @@ class McpViewModel(private val container: AppContainer) : ViewModel() {
49 _state.value = State.Disconnected
54 _state.value = State.Disconnected
50 return
55 return
51 }
56 }
57+ // Keep already-loaded servers on screen during a reload; only show the full-screen
58+ // spinner on the very first load.
52− _state.value = State.Loading
59+ if (_state.value is State.Ready) _refreshing.value = true else _state.value = State.Loading
53 runCatchingCancellable {
60 runCatchingCancellable {
54 val config = conn.api.config()
61 val config = conn.api.config()
55 withContext(Dispatchers.Default) { parseMcpServers(config) }
62 withContext(Dispatchers.Default) { parseMcpServers(config) }
56 }
63 }
57 .onSuccess { _state.value = State.Ready(it) }
64 .onSuccess { _state.value = State.Ready(it) }
58 .onFailure { _state.value = State.Error }
65 .onFailure { _state.value = State.Error }
66+ _refreshing.value = false
59 }
67 }
60}
68}
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+74−12
@@ -15,7 +15,9 @@ import androidx.compose.foundation.text.KeyboardActions
15import androidx.compose.foundation.text.KeyboardOptions
15import androidx.compose.foundation.text.KeyboardOptions
16import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.Icons
17import androidx.compose.material.icons.filled.Close
17import androidx.compose.material.icons.filled.Close
18+import androidx.compose.material.icons.filled.ErrorOutline
18import androidx.compose.material.icons.filled.Search
19import androidx.compose.material.icons.filled.Search
20+import androidx.compose.material.icons.filled.SearchOff
19import androidx.compose.material3.Card
21import androidx.compose.material3.Card
20import androidx.compose.material3.CircularProgressIndicator
22import androidx.compose.material3.CircularProgressIndicator
21import androidx.compose.material3.ExperimentalMaterial3Api
23import androidx.compose.material3.ExperimentalMaterial3Api
@@ -34,14 +36,22 @@ import androidx.compose.ui.Modifier
34import androidx.compose.ui.focus.focusRequester
36import androidx.compose.ui.focus.focusRequester
35import androidx.compose.ui.platform.LocalSoftwareKeyboardController
37import androidx.compose.ui.platform.LocalSoftwareKeyboardController
36import androidx.compose.ui.res.stringResource
38import androidx.compose.ui.res.stringResource
39+import androidx.compose.ui.semantics.contentDescription
40+import androidx.compose.ui.semantics.semantics
41+import androidx.compose.ui.text.AnnotatedString
42+import androidx.compose.ui.text.SpanStyle
43+import androidx.compose.ui.text.buildAnnotatedString
44+import androidx.compose.ui.text.font.FontWeight
37import androidx.compose.ui.text.input.ImeAction
45import androidx.compose.ui.text.input.ImeAction
38import androidx.compose.ui.text.style.TextOverflow
46import androidx.compose.ui.text.style.TextOverflow
47+import androidx.compose.ui.text.withStyle
39import androidx.compose.ui.unit.dp
48import androidx.compose.ui.unit.dp
40import androidx.lifecycle.compose.collectAsStateWithLifecycle
49import androidx.lifecycle.compose.collectAsStateWithLifecycle
41import androidx.lifecycle.viewmodel.compose.viewModel
50import androidx.lifecycle.viewmodel.compose.viewModel
42import soy.iko.opencode.R
51import soy.iko.opencode.R
43import soy.iko.opencode.di.AppContainer
52import soy.iko.opencode.di.AppContainer
44import soy.iko.opencode.ui.components.AppTopBar
53import soy.iko.opencode.ui.components.AppTopBar
54+import soy.iko.opencode.ui.components.EmptyState
45import soy.iko.opencode.ui.components.RelativeTimeText
55import soy.iko.opencode.ui.components.RelativeTimeText
46import soy.iko.opencode.ui.vmFactory
56import soy.iko.opencode.ui.vmFactory
47import soy.iko.opencode.util.runCatchingCancellable
57import soy.iko.opencode.util.runCatchingCancellable
@@ -90,23 +100,42 @@ fun GlobalSearchScreen(
90 keyboardActions = KeyboardActions(onSearch = { keyboard?.hide() }),
100 keyboardActions = KeyboardActions(onSearch = { keyboard?.hide() }),
91 )
101 )
92 Box(modifier = Modifier.fillMaxSize()) {
102 Box(modifier = Modifier.fillMaxSize()) {
103+ val searchingLabel = stringResource(R.string.searching)
93 when {
104 when {
94− state.searching -> CircularProgressIndicator(Modifier.align(Alignment.Center))
105+ state.searching -> CircularProgressIndicator(
95− state.error != null -> Text(
106+ Modifier.align(Alignment.Center).semantics { contentDescription = searchingLabel },
96− state.error ?: "",
97− color = MaterialTheme.colorScheme.error,
98− modifier = Modifier.align(Alignment.Center).padding(24.dp),
99 )
107 )
108+ state.error != null -> EmptyState(
109+ icon = Icons.Filled.ErrorOutline,
110+ title = state.error ?: "",
111+ modifier = Modifier.align(Alignment.Center),
112+ actionLabel = stringResource(R.string.retry),
113+ onAction = vm::retry,
114+ )
100− state.hasSearched && state.results.isEmpty() -> Text(
115+ state.hasSearched && state.results.isEmpty() -> EmptyState(
116+ icon = Icons.Filled.SearchOff,
101− stringResource(R.string.search_no_matches),
117+ title = stringResource(R.string.search_no_matches),
102− color = MaterialTheme.colorScheme.onSurfaceVariant,
118+ modifier = Modifier.align(Alignment.Center),
119+ )
120+ state.results.isEmpty() -> EmptyState(
121+ icon = Icons.Filled.Search,
122+ title = stringResource(R.string.search_all_start),
123+ description = stringResource(R.string.search_all_start_hint),
103− modifier = Modifier.align(Alignment.Center).padding(24.dp),
124+ modifier = Modifier.align(Alignment.Center),
104 )
125 )
105 else -> LazyColumn(
126 else -> LazyColumn(
106 modifier = Modifier.fillMaxSize(),
127 modifier = Modifier.fillMaxSize(),
107 contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp),
128 contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp),
108 verticalArrangement = Arrangement.spacedBy(12.dp),
129 verticalArrangement = Arrangement.spacedBy(12.dp),
109 ) {
130 ) {
131+ item(key = "__count") {
132+ Text(
133+ stringResource(R.string.search_results_count, state.results.size),
134+ style = MaterialTheme.typography.labelMedium,
135+ color = MaterialTheme.colorScheme.onSurfaceVariant,
136+ modifier = Modifier.padding(top = 8.dp),
137+ )
138+ }
110 if (state.truncated) {
139 if (state.truncated) {
111 item(key = "__truncated") {
140 item(key = "__truncated") {
112 Text(
141 Text(
@@ -118,7 +147,11 @@ fun GlobalSearchScreen(
118 }
147 }
119 }
148 }
120 items(state.results, key = { it.session.id }) { hit ->
149 items(state.results, key = { it.session.id }) { hit ->
150+ SearchResultCard(
151+ hit = hit,
152+ query = state.query.trim(),
121− SearchResultCard(hit = hit, onClick = { onOpenSession(hit.session.id) })
153+ onClick = { onOpenSession(hit.session.id) },
154+ )
122 }
155 }
123 }
156 }
124 }
157 }
@@ -128,7 +161,7 @@ fun GlobalSearchScreen(
128}
161}
129
162
130@Composable
163@Composable
131−private fun SearchResultCard(hit: SearchHit, onClick: () -> Unit) {
164+private fun SearchResultCard(hit: SearchHit, query: String, onClick: () -> Unit) {
132 Card(modifier = Modifier.fillMaxWidth().clickable { onClick() }) {
165 Card(modifier = Modifier.fillMaxWidth().clickable { onClick() }) {
133 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
166 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
134 Text(
167 Text(
@@ -141,8 +174,12 @@ private fun SearchResultCard(hit: SearchHit, onClick: () -> Unit) {
141 hit.session.time?.updated ?: hit.session.time?.created,
174 hit.session.time?.updated ?: hit.session.time?.created,
142 modifier = Modifier.padding(top = 2.dp),
175 modifier = Modifier.padding(top = 2.dp),
143 )
176 )
177+ val highlightColor = MaterialTheme.colorScheme.primary
178+ val snippet = remember(hit.snippet, query, highlightColor) {
179+ highlightMatches(hit.snippet, query, highlightColor)
180+ }
144 Text(
181 Text(
145− hit.snippet,
182+ snippet,
146 style = MaterialTheme.typography.bodyMedium,
183 style = MaterialTheme.typography.bodyMedium,
147 color = MaterialTheme.colorScheme.onSurfaceVariant,
184 color = MaterialTheme.colorScheme.onSurfaceVariant,
148 maxLines = 3,
185 maxLines = 3,
@@ -152,3 +189,28 @@ private fun SearchResultCard(hit: SearchHit, onClick: () -> Unit) {
152 }
189 }
153 }
190 }
154}
191}
192+
193+/** Bold + tint every case-insensitive occurrence of [query] within [text] so the user can
194+ * see where the hit is in the surrounding snippet. */
195+private fun highlightMatches(
196+ text: String,
197+ query: String,
198+ color: androidx.compose.ui.graphics.Color,
199+): AnnotatedString {
200+ if (query.isEmpty()) return AnnotatedString(text)
201+ return buildAnnotatedString {
202+ var start = 0
203+ while (true) {
204+ val idx = text.indexOf(query, start, ignoreCase = true)
205+ if (idx < 0) {
206+ append(text.substring(start))
207+ break
208+ }
209+ append(text.substring(start, idx))
210+ withStyle(SpanStyle(color = color, fontWeight = FontWeight.Bold)) {
211+ append(text.substring(idx, idx + query.length))
212+ }
213+ start = idx + query.length
214+ }
215+ }
216+}
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchViewModel.kt+9
@@ -68,6 +68,15 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
68 }
68 }
69 }
69 }
70
70
71+ /** Re-run the current query immediately (no debounce), used by the error-state Retry. */
72+ fun retry() {
73+ val trimmed = _state.value.query.trim()
74+ if (trimmed.length < NetworkConfig.minSearchQueryLength) return
75+ searchJob?.cancel()
76+ _state.update { it.copy(searching = true, error = null) }
77+ searchJob = viewModelScope.launch { runSearch(trimmed) }
78+ }
79+
71 private suspend fun runSearch(query: String) {
80 private suspend fun runSearch(query: String) {
72 val conn = container.activeConnection.value
81 val conn = container.activeConnection.value
73 if (conn == null) {
82 if (conn == null) {
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+83−27
@@ -22,6 +22,7 @@ import androidx.compose.material.icons.automirrored.filled.Logout
22import androidx.compose.material.icons.automirrored.filled.Sort
22import androidx.compose.material.icons.automirrored.filled.Sort
23import androidx.compose.material.icons.filled.Add
23import androidx.compose.material.icons.filled.Add
24import androidx.compose.material.icons.filled.Archive
24import androidx.compose.material.icons.filled.Archive
25+import androidx.compose.material.icons.filled.Unarchive
25import androidx.compose.material.icons.filled.ArrowDownward
26import androidx.compose.material.icons.filled.ArrowDownward
26import androidx.compose.material.icons.filled.ArrowDropDown
27import androidx.compose.material.icons.filled.ArrowDropDown
27import androidx.compose.material.icons.filled.ArrowUpward
28import androidx.compose.material.icons.filled.ArrowUpward
@@ -137,6 +138,7 @@ fun SessionListScreen(
137 val timeTick = rememberRelativeTimeTick()
138 val timeTick = rememberRelativeTimeTick()
138 var showServerMenu by rememberSaveable { mutableStateOf(false) }
139 var showServerMenu by rememberSaveable { mutableStateOf(false) }
139 var showSortMenu by rememberSaveable { mutableStateOf(false) }
140 var showSortMenu by rememberSaveable { mutableStateOf(false) }
141+ var showMainMenu by rememberSaveable { mutableStateOf(false) }
140 var showNewSessionDialog by rememberSaveable { mutableStateOf(false) }
142 var showNewSessionDialog by rememberSaveable { mutableStateOf(false) }
141 // Open the new-session directory picker, kicking off a fetch of the server's known
143 // Open the new-session directory picker, kicking off a fetch of the server's known
142 // directories so they're ready by the time the user looks at the list.
144 // directories so they're ready by the time the user looks at the list.
@@ -245,21 +247,38 @@ fun SessionListScreen(
245 onToggleDirection = { vm.toggleSortDirection() },
247 onToggleDirection = { vm.toggleSortDirection() },
246 onSetShowArchived = { vm.setShowArchived(it) },
248 onSetShowArchived = { vm.setShowArchived(it) },
247 )
249 )
248− IconButton(onClick = { vm.refresh() }) {
249− Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
250− }
251− IconButton(onClick = onOpenFiles) {
252− Icon(Icons.Filled.Folder, contentDescription = stringResource(R.string.files))
253− }
254 IconButton(onClick = onOpenSettings) {
250 IconButton(onClick = onOpenSettings) {
255 Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.settings))
251 Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.settings))
256 }
252 }
253+ // Less-frequent destinations (files, manual refresh, disconnect) live in an
254+ // overflow so the bar stays uncluttered — pull-to-refresh and live SSE make
255+ // the standalone Refresh icon largely redundant.
257− IconButton(onClick = {
256+ IconButton(onClick = { showMainMenu = true }) {
257+ Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.more_options))
258+ }
259+ DropdownMenu(expanded = showMainMenu, onDismissRequest = { showMainMenu = false }) {
260+ DropdownMenuItem(
261+ text = { Text(stringResource(R.string.files)) },
262+ leadingIcon = { Icon(Icons.Filled.Folder, contentDescription = null) },
263+ onClick = { showMainMenu = false; onOpenFiles() },
264+ )
265+ DropdownMenuItem(
266+ text = { Text(stringResource(R.string.refresh)) },
267+ leadingIcon = { Icon(Icons.Filled.Refresh, contentDescription = null) },
268+ onClick = { showMainMenu = false; vm.refresh() },
269+ )
270+ DropdownMenuItem(
271+ text = { Text(stringResource(R.string.disconnect)) },
272+ leadingIcon = {
273+ Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = null)
274+ },
275+ onClick = {
276+ showMainMenu = false
258− // Confirm before disconnecting if an agent run is active in any
277+ // Confirm before disconnecting if an agent run is active in any
259− // session — disconnecting kills the SSE stream and the run.
278+ // session — disconnecting kills the SSE stream and the run.
260− if (anyRunActive) showDisconnectConfirm = true else onDisconnect()
279+ if (anyRunActive) showDisconnectConfirm = true else onDisconnect()
261− }) {
280+ },
262− Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = stringResource(R.string.disconnect))
281+ )
263 }
282 }
264 },
283 },
265 )
284 )
@@ -495,14 +514,30 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
495 // same confirmation dialog as the trash icon. We never commit
514 // same confirmation dialog as the trash icon. We never commit
496 // the dismissal (always reset to Settled) so the card snaps back
515 // the dismissal (always reset to Settled) so the card snaps back
497 // and the dialog guards against accidental data loss.
516 // and the dialog guards against accidental data loss.
517+ val isArchivedForSwipe = session.id in state.archivedIds
498 val swipeState = rememberSwipeToDismissBoxState(
518 val swipeState = rememberSwipeToDismissBoxState(
499− confirmValueChange = {
519+ confirmValueChange = { value ->
500− // Haptic at the trigger so the swipe path matches the icon-path
520+ // Haptic at the trigger so both swipe paths match the icon
501− // delete, which vibrates on confirm. Returns false (snap back) so
521+ // paths, which vibrate on confirm. Always returns false (snap
502− // the dialog guards the actual deletion.
522+ // back): delete is guarded by a dialog, archive is instantly
523+ // undoable via snackbar, so neither commits the dismissal.
524+ when (value) {
525+ SwipeToDismissBoxValue.EndToStart -> {
526+ haptics.performHapticFeedback(
503− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
527+ androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress,
528+ )
504− onDelete(session.id)
529+ onDelete(session.id)
505− false
530+ false
531+ }
532+ SwipeToDismissBoxValue.StartToEnd -> {
533+ haptics.performHapticFeedback(
534+ androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress,
535+ )
536+ onArchive(session)
537+ false
538+ }
539+ SwipeToDismissBoxValue.Settled -> false
540+ }
506 },
541 },
507 )
542 )
508 // Per-item callbacks memoized on the session id so SessionCard
543 // Per-item callbacks memoized on the session id so SessionCard
@@ -519,26 +554,47 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
519 val isArchived = session.id in state.archivedIds
554 val isArchived = session.id in state.archivedIds
520 SwipeToDismissBox(
555 SwipeToDismissBox(
521 state = swipeState,
556 state = swipeState,
557+ // Swipe end-to-start deletes (dialog-guarded); start-to-end
558+ // archives/unarchives (instantly undoable via snackbar).
522− enableDismissFromStartToEnd = false,
559+ enableDismissFromStartToEnd = true,
560+ enableDismissFromEndToStart = true,
523 // Indent sub-sessions under their parent (capped so deep
561 // Indent sub-sessions under their parent (capped so deep
524 // nesting doesn't squeeze the card off-screen).
562 // nesting doesn't squeeze the card off-screen).
525 modifier = Modifier
563 modifier = Modifier
526 .animateItem()
564 .animateItem()
527 .padding(start = (node.depth.coerceAtMost(3) * 16).dp),
565 .padding(start = (node.depth.coerceAtMost(3) * 16).dp),
528 backgroundContent = {
566 backgroundContent = {
567+ val archiving = swipeState.dismissDirection == SwipeToDismissBoxValue.StartToEnd
568+ val bg = if (archiving) {
569+ MaterialTheme.colorScheme.tertiaryContainer
570+ } else {
571+ MaterialTheme.colorScheme.errorContainer
572+ }
529 Box(
573 Box(
530 modifier = Modifier
574 modifier = Modifier
531 .fillMaxSize()
575 .fillMaxSize()
532 .clip(MaterialTheme.shapes.medium)
576 .clip(MaterialTheme.shapes.medium)
533− .background(MaterialTheme.colorScheme.errorContainer)
577+ .background(bg)
534 .padding(horizontal = 20.dp),
578 .padding(horizontal = 20.dp),
535− contentAlignment = Alignment.CenterEnd,
579+ contentAlignment = if (archiving) Alignment.CenterStart else Alignment.CenterEnd,
536 ) {
580 ) {
581+ if (archiving) {
582+ val label = stringResource(
583+ if (isArchivedForSwipe) R.string.session_unarchive
584+ else R.string.session_archive,
585+ )
537− Icon(
586+ Icon(
587+ if (isArchivedForSwipe) Icons.Filled.Unarchive else Icons.Filled.Archive,
588+ contentDescription = label,
589+ tint = MaterialTheme.colorScheme.onTertiaryContainer,
590+ )
591+ } else {
592+ Icon(
538− Icons.Filled.Delete,
593+ Icons.Filled.Delete,
539− contentDescription = stringResource(R.string.delete),
594+ contentDescription = stringResource(R.string.delete),
540− tint = MaterialTheme.colorScheme.onErrorContainer,
595+ tint = MaterialTheme.colorScheme.onErrorContainer,
541− )
596+ )
597+ }
542 }
598 }
543 },
599 },
544 ) {
600 ) {
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+6
@@ -2,6 +2,7 @@ package soy.iko.opencode.ui.session
2
2
3import androidx.compose.foundation.layout.Box
3import androidx.compose.foundation.layout.Box
4import androidx.compose.foundation.layout.Row
4import androidx.compose.foundation.layout.Row
5+import androidx.compose.foundation.layout.fillMaxHeight
5import androidx.compose.foundation.layout.fillMaxSize
6import androidx.compose.foundation.layout.fillMaxSize
6import androidx.compose.foundation.layout.widthIn
7import androidx.compose.foundation.layout.widthIn
7import androidx.activity.compose.BackHandler
8import androidx.activity.compose.BackHandler
@@ -14,6 +15,7 @@ import androidx.compose.runtime.getValue
14import androidx.compose.runtime.key
15import androidx.compose.runtime.key
15import androidx.compose.runtime.mutableStateOf
16import androidx.compose.runtime.mutableStateOf
16import androidx.compose.runtime.remember
17import androidx.compose.runtime.remember
18+import androidx.compose.material3.VerticalDivider
17import androidx.compose.runtime.saveable.rememberSaveable
19import androidx.compose.runtime.saveable.rememberSaveable
18import androidx.compose.runtime.setValue
20import androidx.compose.runtime.setValue
19import androidx.compose.ui.Modifier
21import androidx.compose.ui.Modifier
@@ -142,6 +144,10 @@ fun TwoPaneSessionChat(
142 )
144 )
143 }
145 }
144
146
147+ // Clear boundary between the list and detail panes (the custom split has no divider
148+ // of its own).
149+ VerticalDivider(modifier = Modifier.fillMaxHeight())
150+
145 Box(modifier = Modifier.weight(NetworkConfig.twoPaneRightWeight).fillMaxSize()) {
151 Box(modifier = Modifier.weight(NetworkConfig.twoPaneRightWeight).fillMaxSize()) {
146 val sessionId = selected
152 val sessionId = selected
147 if (sessionId == null) {
153 if (sessionId == null) {
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+26−1
@@ -93,6 +93,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
93 val snackbar = remember { SnackbarHostState() }
93 val snackbar = remember { SnackbarHostState() }
94 val undoLabel = stringResource(R.string.undo)
94 val undoLabel = stringResource(R.string.undo)
95 val reportDeletedLabel = stringResource(R.string.report_deleted)
95 val reportDeletedLabel = stringResource(R.string.report_deleted)
96+ val reportsClearedLabel = stringResource(R.string.reports_cleared)
96 // Deferred report deletions are owned by the CrashLogger (its process-lived scope),
97 // Deferred report deletions are owned by the CrashLogger (its process-lived scope),
97 // so they survive both rotation and navigating away from this screen. The screen only
98 // so they survive both rotation and navigating away from this screen. The screen only
98 // shows the Undo snackbar and asks the logger to cancel a pending delete on undo.
99 // shows the Undo snackbar and asks the logger to cancel a pending delete on undo.
@@ -138,6 +139,30 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
138 }
139 }
139 }
140 }
140
141
142+ // Clear-all undo: defers the bulk delete for the same window as a single-report delete,
143+ // so the previously-irreversible "Clear all" is now recoverable.
144+ val clearAllEvents = remember {
145+ MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
146+ }
147+ LaunchedEffect(Unit) {
148+ clearAllEvents.collectLatest {
149+ logger.scheduleClearAll(NetworkConfig.undoReportDeleteDelayMs)
150+ coroutineScope {
151+ val dismisser = launch {
152+ delay(NetworkConfig.undoReportDeleteDelayMs)
153+ snackbar.currentSnackbarData?.dismiss()
154+ }
155+ val result = snackbar.showSnackbar(
156+ message = reportsClearedLabel,
157+ actionLabel = undoLabel,
158+ duration = SnackbarDuration.Indefinite,
159+ )
160+ dismisser.cancel()
161+ if (result == SnackbarResult.ActionPerformed) logger.cancelScheduledClearAll()
162+ }
163+ }
164+ }
165+
141 Scaffold(
166 Scaffold(
142 topBar = {
167 topBar = {
143 AppTopBar(
168 AppTopBar(
@@ -381,7 +406,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
381 confirmButton = {
406 confirmButton = {
382 TextButton(onClick = {
407 TextButton(onClick = {
383 showClearAll = false
408 showClearAll = false
384− logger.clearAll()
409+ shareScope.launch { clearAllEvents.emit(Unit) }
385 }) { Text(stringResource(R.string.clear_all), color = MaterialTheme.colorScheme.error) }
410 }) { Text(stringResource(R.string.clear_all), color = MaterialTheme.colorScheme.error) }
386 },
411 },
387 dismissButton = {
412 dismissButton = {
app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.kt+104−41
@@ -1,5 +1,6 @@
1package soy.iko.opencode.ui.usage
1package soy.iko.opencode.ui.usage
2
2
3+import androidx.compose.foundation.background
3import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.Column
@@ -8,13 +9,16 @@ import androidx.compose.foundation.layout.Row
8import androidx.compose.foundation.layout.Spacer
9import androidx.compose.foundation.layout.Spacer
9import androidx.compose.foundation.layout.fillMaxSize
10import androidx.compose.foundation.layout.fillMaxSize
10import androidx.compose.foundation.layout.fillMaxWidth
11import androidx.compose.foundation.layout.fillMaxWidth
12+import androidx.compose.foundation.layout.height
11import androidx.compose.foundation.layout.padding
13import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.size
14import androidx.compose.foundation.layout.size
13import androidx.compose.foundation.lazy.LazyColumn
15import androidx.compose.foundation.lazy.LazyColumn
14import androidx.compose.foundation.lazy.items
16import androidx.compose.foundation.lazy.items
15import androidx.compose.material.icons.Icons
17import androidx.compose.material.icons.Icons
18+import androidx.compose.material.icons.filled.CloudOff
19+import androidx.compose.material.icons.filled.ErrorOutline
20+import androidx.compose.material.icons.filled.QueryStats
16import androidx.compose.material.icons.filled.Refresh
21import androidx.compose.material.icons.filled.Refresh
17−import androidx.compose.material3.Button
18import androidx.compose.material3.Card
22import androidx.compose.material3.Card
19import androidx.compose.material3.CircularProgressIndicator
23import androidx.compose.material3.CircularProgressIndicator
20import androidx.compose.material3.ExperimentalMaterial3Api
24import androidx.compose.material3.ExperimentalMaterial3Api
@@ -23,11 +27,15 @@ import androidx.compose.material3.IconButton
23import androidx.compose.material3.MaterialTheme
27import androidx.compose.material3.MaterialTheme
24import androidx.compose.material3.Scaffold
28import androidx.compose.material3.Scaffold
25import androidx.compose.material3.Text
29import androidx.compose.material3.Text
30+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
26import androidx.compose.runtime.Composable
31import androidx.compose.runtime.Composable
27import androidx.compose.runtime.getValue
32import androidx.compose.runtime.getValue
28import androidx.compose.ui.Alignment
33import androidx.compose.ui.Alignment
29import androidx.compose.ui.Modifier
34import androidx.compose.ui.Modifier
35+import androidx.compose.ui.draw.clip
30import androidx.compose.ui.res.stringResource
36import androidx.compose.ui.res.stringResource
37+import androidx.compose.ui.semantics.contentDescription
38+import androidx.compose.ui.semantics.semantics
31import androidx.compose.ui.text.style.TextOverflow
39import androidx.compose.ui.text.style.TextOverflow
32import androidx.compose.ui.unit.dp
40import androidx.compose.ui.unit.dp
33import androidx.lifecycle.compose.collectAsStateWithLifecycle
41import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -36,6 +44,7 @@ import soy.iko.opencode.R
36import soy.iko.opencode.data.model.Tokens
44import soy.iko.opencode.data.model.Tokens
37import soy.iko.opencode.di.AppContainer
45import soy.iko.opencode.di.AppContainer
38import soy.iko.opencode.ui.components.AppTopBar
46import soy.iko.opencode.ui.components.AppTopBar
47+import soy.iko.opencode.ui.components.EmptyState
39import soy.iko.opencode.ui.components.SectionHeader
48import soy.iko.opencode.ui.components.SectionHeader
40import soy.iko.opencode.ui.vmFactory
49import soy.iko.opencode.ui.vmFactory
41import java.text.NumberFormat
50import java.text.NumberFormat
@@ -46,6 +55,7 @@ import java.util.Locale
46fun UsageScreen(container: AppContainer, onBack: () -> Unit) {
55fun UsageScreen(container: AppContainer, onBack: () -> Unit) {
47 val vm: UsageViewModel = viewModel(factory = vmFactory { UsageViewModel(container) })
56 val vm: UsageViewModel = viewModel(factory = vmFactory { UsageViewModel(container) })
48 val state by vm.state.collectAsStateWithLifecycle()
57 val state by vm.state.collectAsStateWithLifecycle()
58+ val refreshing by vm.refreshing.collectAsStateWithLifecycle()
49
59
50 Scaffold(
60 Scaffold(
51 topBar = {
61 topBar = {
@@ -60,29 +70,44 @@ fun UsageScreen(container: AppContainer, onBack: () -> Unit) {
60 )
70 )
61 },
71 },
62 ) { padding ->
72 ) { padding ->
73+ PullToRefreshBox(
74+ isRefreshing = refreshing,
75+ onRefresh = { vm.load() },
76+ modifier = Modifier.fillMaxSize().padding(padding),
77+ ) {
63− Box(modifier = Modifier.fillMaxSize().padding(padding)) {
78+ Box(modifier = Modifier.fillMaxSize()) {
79+ val loadingLabel = stringResource(R.string.usage_loading)
64− when (val s = state) {
80+ when (val s = state) {
65− is UsageViewModel.State.Loading -> Centered {
81+ is UsageViewModel.State.Loading -> Centered {
66− CircularProgressIndicator()
82+ CircularProgressIndicator(
67− Spacer(Modifier.size(12.dp))
83+ Modifier.semantics { contentDescription = loadingLabel },
68− Text(stringResource(R.string.usage_loading), color = MaterialTheme.colorScheme.onSurfaceVariant)
84+ )
69− }
85+ Spacer(Modifier.size(12.dp))
70− is UsageViewModel.State.Disconnected -> Centered {
71− Text(stringResource(R.string.not_connected), color = MaterialTheme.colorScheme.onSurfaceVariant)
72− }
73− is UsageViewModel.State.Error -> Centered {
74− Text(stringResource(R.string.usage_failed), color = MaterialTheme.colorScheme.error)
75− Spacer(Modifier.size(12.dp))
76− Button(onClick = { vm.load() }) { Text(stringResource(R.string.retry)) }
77− }
78− is UsageViewModel.State.Ready ->
79− if (s.report.isEmpty) {
80− Centered {
81− Text(stringResource(R.string.usage_empty), color = MaterialTheme.colorScheme.onSurfaceVariant)
86+ Text(loadingLabel, color = MaterialTheme.colorScheme.onSurfaceVariant)
82− }
83− } else {
84− UsageContent(s.report)
85 }
87 }
88+ is UsageViewModel.State.Disconnected -> EmptyState(
89+ icon = Icons.Filled.CloudOff,
90+ title = stringResource(R.string.not_connected),
91+ modifier = Modifier.align(Alignment.Center),
92+ )
93+ is UsageViewModel.State.Error -> EmptyState(
94+ icon = Icons.Filled.ErrorOutline,
95+ title = stringResource(R.string.usage_failed),
96+ modifier = Modifier.align(Alignment.Center),
97+ actionLabel = stringResource(R.string.retry),
98+ onAction = { vm.load() },
99+ )
100+ is UsageViewModel.State.Ready ->
101+ if (s.report.isEmpty) {
102+ EmptyState(
103+ icon = Icons.Filled.QueryStats,
104+ title = stringResource(R.string.usage_empty),
105+ modifier = Modifier.align(Alignment.Center),
106+ )
107+ } else {
108+ UsageContent(s.report)
109+ }
110+ }
86 }
111 }
87 }
112 }
88 }
113 }
@@ -109,18 +134,55 @@ private fun UsageContent(report: UsageReport) {
109 // Key on provider+model: the same model id served by two providers yields two
134 // Key on provider+model: the same model id served by two providers yields two
110 // rows, so keying on the bare model would duplicate keys and crash the list.
135 // rows, so keying on the bare model would duplicate keys and crash the list.
111 items(report.byModel, key = { it.provider + "/" + it.model }) { m ->
136 items(report.byModel, key = { it.provider + "/" + it.model }) { m ->
137+ UsageRow(
112− UsageRow(title = m.provider + " / " + m.model, cost = m.cost, tokens = m.tokens, messages = m.messages)
138+ title = m.provider + " / " + m.model,
139+ cost = m.cost,
140+ tokens = m.tokens,
141+ messages = m.messages,
142+ costFraction = fractionOf(m.cost, report.totalCost),
143+ )
113 }
144 }
114 }
145 }
115 if (report.bySession.isNotEmpty()) {
146 if (report.bySession.isNotEmpty()) {
116 item { SectionHeader(stringResource(R.string.usage_all_sessions)) }
147 item { SectionHeader(stringResource(R.string.usage_all_sessions)) }
117 items(report.bySession, key = { it.sessionId }) { s ->
148 items(report.bySession, key = { it.sessionId }) { s ->
118− UsageRow(title = s.title, cost = s.cost, tokens = s.tokens, messages = s.messages)
149+ UsageRow(
150+ title = s.title,
151+ cost = s.cost,
152+ tokens = s.tokens,
153+ messages = s.messages,
154+ costFraction = fractionOf(s.cost, report.totalCost),
155+ )
119 }
156 }
120 }
157 }
121 }
158 }
122}
159}
123
160
161+private fun fractionOf(part: Double, total: Double): Float =
162+ if (total > 0.0) (part / total).toFloat().coerceIn(0f, 1f) else 0f
163+
164+/** A slim horizontal bar showing this row's share of the total cost. */
165+@Composable
166+private fun CostBar(fraction: Float) {
167+ Box(
168+ modifier = Modifier
169+ .fillMaxWidth()
170+ .height(4.dp)
171+ .clip(MaterialTheme.shapes.small)
172+ .background(MaterialTheme.colorScheme.surfaceVariant),
173+ ) {
174+ if (fraction > 0f) {
175+ Box(
176+ modifier = Modifier
177+ .fillMaxWidth(fraction)
178+ .height(4.dp)
179+ .clip(MaterialTheme.shapes.small)
180+ .background(MaterialTheme.colorScheme.primary),
181+ )
182+ }
183+ }
184+}
185+
124@Composable
186@Composable
125private fun TotalsCard(report: UsageReport) {
187private fun TotalsCard(report: UsageReport) {
126 Card(modifier = Modifier.fillMaxWidth()) {
188 Card(modifier = Modifier.fillMaxWidth()) {
@@ -176,25 +238,26 @@ private fun StatRow(label: String, value: String) {
176}
238}
177
239
178@Composable
240@Composable
179−private fun UsageRow(title: String, cost: Double, tokens: Tokens, messages: Int) {
241+private fun UsageRow(title: String, cost: Double, tokens: Tokens, messages: Int, costFraction: Float) {
180− Row(
181− modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
242+ Column(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
182− verticalAlignment = Alignment.CenterVertically,
243+ Row(verticalAlignment = Alignment.CenterVertically) {
183− ) {
184− Column(modifier = Modifier.weight(1f)) {
244+ Column(modifier = Modifier.weight(1f)) {
185− Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium)
245+ Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium)
246+ Text(
247+ stringResource(R.string.usage_messages_count, messages) + " · " + formatInt(tokens.total),
248+ style = MaterialTheme.typography.labelSmall,
249+ color = MaterialTheme.colorScheme.onSurfaceVariant,
250+ )
251+ }
186 Text(
252 Text(
187− stringResource(R.string.usage_messages_count, messages) + " · " + formatInt(tokens.total),
253+ formatCost(cost),
188− style = MaterialTheme.typography.labelSmall,
254+ style = MaterialTheme.typography.bodyMedium,
189− color = MaterialTheme.colorScheme.onSurfaceVariant,
255+ color = MaterialTheme.colorScheme.primary,
256+ modifier = Modifier.padding(start = 12.dp),
190 )
257 )
191 }
258 }
192− Text(
259+ Spacer(Modifier.size(6.dp))
193− formatCost(cost),
260+ CostBar(costFraction)
194− style = MaterialTheme.typography.bodyMedium,
195− color = MaterialTheme.colorScheme.primary,
196− modifier = Modifier.padding(start = 12.dp),
197− )
198 }
261 }
199}
262}
200
263
app/src/main/java/soy/iko/opencode/ui/usage/UsageViewModel.kt+9−1
@@ -38,6 +38,11 @@ class UsageViewModel(private val container: AppContainer) : ViewModel() {
38 private val _state = MutableStateFlow<State>(State.Loading)
38 private val _state = MutableStateFlow<State>(State.Loading)
39 val state: StateFlow<State> = _state.asStateFlow()
39 val state: StateFlow<State> = _state.asStateFlow()
40
40
41+ /** True while a reload runs on top of an already-loaded report, so the existing figures stay
42+ * on screen and only the pull-to-refresh spinner shows. */
43+ private val _refreshing = MutableStateFlow(false)
44+ val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
45+
41 /** Bumped by [load] (refresh/retry) to re-run without waiting for a connection change. */
46 /** Bumped by [load] (refresh/retry) to re-run without waiting for a connection change. */
42 private val _reload = MutableStateFlow(0)
47 private val _reload = MutableStateFlow(0)
43
48
@@ -59,7 +64,9 @@ class UsageViewModel(private val container: AppContainer) : ViewModel() {
59 _state.value = State.Disconnected
64 _state.value = State.Disconnected
60 return
65 return
61 }
66 }
67+ // Keep the current report visible during a manual reload; only blank to the full-screen
68+ // spinner on the first load.
62− _state.value = State.Loading
69+ if (_state.value is State.Ready) _refreshing.value = true else _state.value = State.Loading
63 runCatchingCancellable {
70 runCatchingCancellable {
64 val sessions = conn.repository.listSessions()
71 val sessions = conn.repository.listSessions()
65 // Cap concurrent message fetches so a large history doesn't open dozens of
72 // Cap concurrent message fetches so a large history doesn't open dozens of
@@ -84,6 +91,7 @@ class UsageViewModel(private val container: AppContainer) : ViewModel() {
84 }
91 }
85 .onSuccess { _state.value = State.Ready(it) }
92 .onSuccess { _state.value = State.Ready(it) }
86 .onFailure { _state.value = State.Error }
93 .onFailure { _state.value = State.Error }
94+ _refreshing.value = false
87 }
95 }
88
96
89 private companion object {
97 private companion object {
app/src/main/res/values-es/strings.xml+8−1
@@ -25,6 +25,8 @@
25 <string name="rename">Renombrar</string>
25 <string name="rename">Renombrar</string>
26 <string name="disconnect">Desconectar</string>
26 <string name="disconnect">Desconectar</string>
27 <string name="loading">Cargando…</string>
27 <string name="loading">Cargando…</string>
28+ <string name="searching">Buscando…</string>
29+ <string name="edit_drops_attachments">Solo se recarga el texto: los adjuntos no se incluyen en una edición.</string>
28
30
29 <!-- Theme modes -->
31 <!-- Theme modes -->
30 <string name="theme_system">Sistema</string>
32 <string name="theme_system">Sistema</string>
@@ -217,7 +219,8 @@
217 <string name="crash_reports">Informes de fallos</string>
219 <string name="crash_reports">Informes de fallos</string>
218 <string name="no_crash_reports">No hay fallos registrados.</string>
220 <string name="no_crash_reports">No hay fallos registrados.</string>
219 <string name="clear_all">Borrar todo</string>
221 <string name="clear_all">Borrar todo</string>
220− <string name="clear_all_confirm">¿Eliminar todos los informes de fallos guardados? Esta acción no se puede deshacer.</string>
222+ <string name="clear_all_confirm">¿Eliminar todos los informes de fallos guardados?</string>
223+ <string name="reports_cleared">Informes de fallos eliminados</string>
221 <string name="crash_report">Informe de fallos</string>
224 <string name="crash_report">Informe de fallos</string>
222 <string name="close">Cerrar</string>
225 <string name="close">Cerrar</string>
223 <string name="delete_report_title">¿Eliminar informe de fallos?</string>
226 <string name="delete_report_title">¿Eliminar informe de fallos?</string>
@@ -286,6 +289,7 @@
286
289
287 <!-- UX additions: chat overflow, date separators, settings retry -->
290 <!-- UX additions: chat overflow, date separators, settings retry -->
288 <string name="more">Más</string>
291 <string name="more">Más</string>
292+ <string name="more_options">Más opciones</string>
289 <string name="delete_session_chat">Eliminar sesión</string>
293 <string name="delete_session_chat">Eliminar sesión</string>
290 <string name="delete_session_chat_title">¿Eliminar esta sesión?</string>
294 <string name="delete_session_chat_title">¿Eliminar esta sesión?</string>
291 <string name="delete_session_chat_text">«%1$s» se eliminará permanentemente. Esta acción no se puede deshacer.</string>
295 <string name="delete_session_chat_text">«%1$s» se eliminará permanentemente. Esta acción no se puede deshacer.</string>
@@ -358,6 +362,9 @@
358 <string name="search_all_hint">Buscar en todas las conversaciones…</string>
362 <string name="search_all_hint">Buscar en todas las conversaciones…</string>
359 <string name="search_no_matches">No hay mensajes coincidentes</string>
363 <string name="search_no_matches">No hay mensajes coincidentes</string>
360 <string name="search_truncated">Mostrando resultados solo de las sesiones más recientes.</string>
364 <string name="search_truncated">Mostrando resultados solo de las sesiones más recientes.</string>
365+ <string name="search_all_start">Busca en tus conversaciones</string>
366+ <string name="search_all_start_hint">Escribe una palabra o frase para encontrarla en todas las sesiones.</string>
367+ <string name="search_results_count">%1$d resultados</string>
361
368
362 <!-- Plan de tareas del agente (herramienta todowrite) -->
369 <!-- Plan de tareas del agente (herramienta todowrite) -->
363 <string name="plan">Plan</string>
370 <string name="plan">Plan</string>
app/src/main/res/values/strings.xml+8−1
@@ -25,6 +25,8 @@
25 <string name="rename">Rename</string>
25 <string name="rename">Rename</string>
26 <string name="disconnect">Disconnect</string>
26 <string name="disconnect">Disconnect</string>
27 <string name="loading">Loading…</string>
27 <string name="loading">Loading…</string>
28+ <string name="searching">Searching…</string>
29+ <string name="edit_drops_attachments">Only the text is reloaded — attachments aren\'t carried into an edit.</string>
28
30
29 <!-- Theme modes -->
31 <!-- Theme modes -->
30 <string name="theme_system">System</string>
32 <string name="theme_system">System</string>
@@ -217,7 +219,8 @@
217 <string name="crash_reports">Crash reports</string>
219 <string name="crash_reports">Crash reports</string>
218 <string name="no_crash_reports">No crashes recorded.</string>
220 <string name="no_crash_reports">No crashes recorded.</string>
219 <string name="clear_all">Clear all</string>
221 <string name="clear_all">Clear all</string>
220− <string name="clear_all_confirm">Delete all stored crash reports? This cannot be undone.</string>
222+ <string name="clear_all_confirm">Delete all stored crash reports?</string>
223+ <string name="reports_cleared">Crash reports cleared</string>
221 <string name="crash_report">Crash report</string>
224 <string name="crash_report">Crash report</string>
222 <string name="close">Close</string>
225 <string name="close">Close</string>
223 <string name="delete_report_title">Delete crash report?</string>
226 <string name="delete_report_title">Delete crash report?</string>
@@ -286,6 +289,7 @@
286
289
287 <!-- UX additions: chat overflow, date separators, settings retry -->
290 <!-- UX additions: chat overflow, date separators, settings retry -->
288 <string name="more">More</string>
291 <string name="more">More</string>
292+ <string name="more_options">More options</string>
289 <string name="delete_session_chat">Delete session</string>
293 <string name="delete_session_chat">Delete session</string>
290 <string name="delete_session_chat_title">Delete this session?</string>
294 <string name="delete_session_chat_title">Delete this session?</string>
291 <string name="delete_session_chat_text">“%1$s” will be permanently deleted. This cannot be undone.</string>
295 <string name="delete_session_chat_text">“%1$s” will be permanently deleted. This cannot be undone.</string>
@@ -339,6 +343,9 @@
339 <string name="search_all_hint">Search across all conversations…</string>
343 <string name="search_all_hint">Search across all conversations…</string>
340 <string name="search_no_matches">No matching messages</string>
344 <string name="search_no_matches">No matching messages</string>
341 <string name="search_truncated">Showing results from the most recent sessions only.</string>
345 <string name="search_truncated">Showing results from the most recent sessions only.</string>
346+ <string name="search_all_start">Search your conversations</string>
347+ <string name="search_all_start_hint">Type a word or phrase to find it across every session.</string>
348+ <string name="search_results_count">%1$d results</string>
342
349
343 <!-- Sharing / summarize / init / shell -->
350 <!-- Sharing / summarize / init / shell -->
344 <string name="create_share_link">Create share link</string>
351 <string name="create_share_link">Create share link</string>