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
commit59c889d6b3b1ea331cf574c23a556074db923d1e authoriko <mail@iko.soy> dateFri 3 Jul 2026, 11:14 (3 months ago) parentacb4dcc
21 files changed+503−165
app/src/main/java/soy/iko/opencode/MainActivity.kt+1−1 app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+23 app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.kt+4−1 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+49−30 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+11−1 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+4−3 app/src/main/java/soy/iko/opencode/ui/chat/TodoPlan.kt+1−1 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+11−2 app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.kt+5−1 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+11−3 app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.kt+47−37 app/src/main/java/soy/iko/opencode/ui/mcp/McpViewModel.kt+9−1 app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+74−12 app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchViewModel.kt+9 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+83−27 app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+6 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+26−1 app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.kt+104−41 app/src/main/java/soy/iko/opencode/ui/usage/UsageViewModel.kt+9−1 app/src/main/res/values-es/strings.xml+8−1 app/src/main/res/values/strings.xml+8−1
app/src/main/java/soy/iko/opencode/MainActivity.kt+1−1
@@ -67,7 +67,7 @@ class MainActivity : FragmentActivity() {
6767 notificationPermissionRequested = savedInstanceState.getBoolean(KEY_NOTIF_PERM_REQUESTED, false)
6868 }
6969 // 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
7171 // snap to the user's real choice — including a brief unlocked, un-FLAG_SECURE'd frame
7272 // before app lock resolves. Mirrors the SettingsScreen's null-gate.
7373 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) {
144144 }
145145 }
146146
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+
147170 fun reportCount(): Int = _reports.value.size
148171
149172 /** 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) {
3737 .getOrDefault(ThemeMode.SYSTEM)
3838 }
3939
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.
4043 val dynamicColor: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
41− prefs[dynamicColorKey] ?: true
44+ prefs[dynamicColorKey] ?: false
4245 }
4346
4447 /** 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(
217217 val inputFocusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
218218 var showModelPicker by rememberSaveable { mutableStateOf(false) }
219219 var showAgentPicker by rememberSaveable { mutableStateOf(false) }
220+ var showTitleMenu by rememberSaveable { mutableStateOf(false) }
220221 var showCommandPicker by rememberSaveable { mutableStateOf(false) }
221222 var showExitConfirm by rememberSaveable { mutableStateOf(false) }
222223 var showStopConfirm by rememberSaveable { mutableStateOf(false) }
@@ -429,37 +430,55 @@ fun ChatScreen(
429430 TopAppBar(
430431 title = {
431432 val changeLabel = stringResource(R.string.change_model_agent)
432− Row(
433− modifier = Modifier
434− .clickable(enabled = models.isNotEmpty(), role = Role.Button) { showModelPicker = true }
435− .semantics(mergeDescendants = true) { contentDescription = changeLabel },
436− verticalAlignment = Alignment.CenterVertically,
437− ) {
438− Column(modifier = Modifier.weight(1f, fill = false)) {
439− // maxLines=1 + Ellipsis so a long session title can't wrap and
440− // push the top bar to multiple lines, shifting the whole layout.
441− Text(
442− sessionTitle ?: sessionLabel,
443− maxLines = 1,
444− overflow = TextOverflow.Ellipsis,
445− )
446− Text(
447− subtitle,
448− style = MaterialTheme.typography.labelSmall,
449− color = MaterialTheme.colorScheme.onSurfaceVariant,
450− maxLines = 1,
451− overflow = TextOverflow.Ellipsis,
452− )
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 {
438+ Row(
439+ modifier = Modifier
440+ .clickable(enabled = models.isNotEmpty(), role = Role.Button) { showTitleMenu = true }
441+ .semantics(mergeDescendants = true) { contentDescription = changeLabel },
442+ verticalAlignment = Alignment.CenterVertically,
443+ ) {
444+ Column(modifier = Modifier.weight(1f, fill = false)) {
445+ // maxLines=1 + Ellipsis so a long session title can't wrap and
446+ // push the top bar to multiple lines, shifting the whole layout.
447+ Text(
448+ sessionTitle ?: sessionLabel,
449+ maxLines = 1,
450+ overflow = TextOverflow.Ellipsis,
451+ )
452+ Text(
453+ subtitle,
454+ style = MaterialTheme.typography.labelSmall,
455+ color = MaterialTheme.colorScheme.onSurfaceVariant,
456+ maxLines = 1,
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),
469+ )
470+ }
453471 }
454− // Visual affordance that the title opens the model picker;
455− // without it the tappable title is indistinguishable from plain
456− // text and the picker is effectively undiscoverable.
457− if (models.isNotEmpty()) {
458− Icon(
459− Icons.Filled.KeyboardArrowDown,
460− contentDescription = null,
461− tint = MaterialTheme.colorScheme.onSurfaceVariant,
462− modifier = Modifier.size(20.dp),
472+ DropdownMenu(expanded = showTitleMenu, onDismissRequest = { showTitleMenu = false }) {
473+ DropdownMenuItem(
474+ text = { Text(stringResource(R.string.palette_switch_model)) },
475+ enabled = !modelsLoading || models.isNotEmpty(),
476+ onClick = { showTitleMenu = false; showModelPicker = true },
477+ )
478+ DropdownMenuItem(
479+ text = { Text(stringResource(R.string.palette_choose_agent)) },
480+ enabled = !agentsLoading || agents.isNotEmpty(),
481+ onClick = { showTitleMenu = false; showAgentPicker = true },
463482 )
464483 }
465484 }
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+11−1
@@ -41,6 +41,7 @@ import soy.iko.opencode.R
4141import soy.iko.opencode.data.model.AssistantMessage
4242import soy.iko.opencode.data.model.MessageWithParts
4343import soy.iko.opencode.data.model.ModelOption
44+import soy.iko.opencode.data.model.FilePart
4445import soy.iko.opencode.data.model.TextPart
4546import soy.iko.opencode.data.model.Tokens
4647import soy.iko.opencode.data.model.UnknownMessage
@@ -48,6 +49,7 @@ import soy.iko.opencode.data.model.UserMessage
4849import soy.iko.opencode.data.network.NetworkConfig
4950import soy.iko.opencode.ui.components.ImageLoadContext
5051import soy.iko.opencode.ui.components.copyToClipboard
52+import soy.iko.opencode.ui.components.showToast
5153import soy.iko.opencode.ui.components.RelativeTimeText
5254
5355/**
@@ -244,7 +246,15 @@ private fun UserBubble(
244246 // conversation to before it so a Send replaces it. Only when there's text
245247 // to edit (an image-only prompt has nothing to reload).
246248 if (onEdit != null && textToCopy != null) {
247− IconButton(onClick = { onEdit(textToCopy) }) {
249+ val dropsAttachmentsMsg = stringResource(R.string.edit_drops_attachments)
250+ val hasAttachments = message.parts.any { it is FilePart }
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+ }) {
248258 Icon(
249259 Icons.Filled.Edit,
250260 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
7575import soy.iko.opencode.ui.components.copyToClipboard
7676import soy.iko.opencode.ui.components.isImage
7777import soy.iko.opencode.ui.components.looksLikeDiff
78−import soy.iko.opencode.ui.components.showToast
78+import soy.iko.opencode.ui.components.showCopyToast
7979import soy.iko.opencode.R
8080
8181private const val COLLAPSED_LIMIT = 600
@@ -496,18 +496,19 @@ private fun FileChip(part: FilePart, modifier: Modifier, onOpenFile: ((String) -
496496 } else if (!path.isNullOrBlank()) {
497497 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
498498 copyToClipboard(context, "path", path)
499− showToast(context, context.getString(R.string.path_copied))
499+ showCopyToast(context, context.getString(R.string.path_copied))
500500 }
501501 },
502502 onLongClick = {
503503 if (!path.isNullOrBlank()) {
504504 haptics.performHapticFeedback(HapticFeedbackType.LongPress)
505505 copyToClipboard(context, "path", path)
506− showToast(context, context.getString(R.string.path_copied))
506+ showCopyToast(context, context.getString(R.string.path_copied))
507507 }
508508 },
509509 )
510510 .semantics { contentDescription = semanticsLabel }
511+ .defaultMinSize(minHeight = 48.dp)
511512 .padding(horizontal = 12.dp, vertical = 6.dp),
512513 verticalAlignment = Alignment.CenterVertically,
513514 ) {
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) {
120120 Row(
121121 modifier = Modifier
122122 .fillMaxWidth()
123− .defaultMinSize(minHeight = 44.dp)
123+ .defaultMinSize(minHeight = 48.dp)
124124 .clickable(role = Role.Button) { expanded = !expanded }
125125 .semantics { stateDescription = if (expanded) expandedState else collapsedState }
126126 .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
33import android.content.ClipData
44import android.content.ClipboardManager
55import android.content.Context
6+import android.os.Build
67import android.widget.Toast
78import androidx.compose.foundation.background
89import androidx.compose.foundation.horizontalScroll
@@ -293,11 +294,19 @@ internal fun extractCodeText(raw: String, isFenced: Boolean): String {
293294 return body.joinToString("\n").trimEnd()
294295}
295296
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). */
297299internal fun copyToClipboard(context: Context, label: String, text: String = label) {
298300 val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
299301 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)
301310}
302311
303312private 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
1212import kotlinx.coroutines.flow.asStateFlow
1313import kotlinx.coroutines.flow.collectLatest
1414import kotlinx.coroutines.flow.merge
15+import kotlinx.coroutines.flow.update
1516import kotlinx.coroutines.launch
1617import soy.iko.opencode.util.runCatchingCancellable
1718
@@ -49,7 +50,10 @@ class FileViewModel(
4950 _state.value = FileViewState(loading = false, error = container.string(R.string.not_connected))
5051 return@collectLatest
5152 }
52− _state.value = FileViewState(loading = true)
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.
56+ _state.update { it.copy(loading = true, error = null) }
5357 runCatchingCancellable { conn.api.readFile(path) }
5458 .onSuccess { _state.value = FileViewState(loading = false, content = it) }
5559 .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(
260260 },
261261 actions = {
262262 // 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).
264266 IconButton(onClick = onReload, enabled = !loading) {
265− Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
267+ if (loading) {
268+ CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
269+ } else {
270+ Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
271+ }
266272 }
267273 if (content.isNotEmpty()) {
268274 // Find-in-file: toggles a search bar over the raw content. Stays enabled even in
@@ -311,7 +317,9 @@ private fun BoxScope.FileViewStateContent(
311317 onRetry: () -> Unit,
312318) {
313319 when {
314− state.loading -> {
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.
322+ state.loading && state.content == null -> {
315323 val loadingLabel = stringResource(R.string.loading)
316324 CircularProgressIndicator(
317325 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
22
33import androidx.compose.foundation.layout.Arrangement
44import androidx.compose.foundation.layout.Box
5−import androidx.compose.foundation.layout.BoxScope
65import androidx.compose.foundation.layout.Column
76import androidx.compose.foundation.layout.PaddingValues
87import androidx.compose.foundation.layout.Row
9−import androidx.compose.foundation.layout.Spacer
108import androidx.compose.foundation.layout.fillMaxSize
119import androidx.compose.foundation.layout.fillMaxWidth
1210import androidx.compose.foundation.layout.padding
13−import androidx.compose.foundation.layout.size
1411import androidx.compose.foundation.lazy.LazyColumn
1512import androidx.compose.foundation.lazy.items
1613import androidx.compose.material.icons.Icons
14+import androidx.compose.material.icons.filled.CloudOff
1715import androidx.compose.material.icons.filled.CloudQueue
1816import androidx.compose.material.icons.filled.Computer
17+import androidx.compose.material.icons.filled.ErrorOutline
18+import androidx.compose.material.icons.filled.Hub
1919import androidx.compose.material.icons.filled.Refresh
20−import androidx.compose.material3.Button
2120import androidx.compose.material3.Card
2221import androidx.compose.material3.CircularProgressIndicator
2322import androidx.compose.material3.ExperimentalMaterial3Api
@@ -26,11 +25,14 @@ import androidx.compose.material3.IconButton
2625import androidx.compose.material3.MaterialTheme
2726import androidx.compose.material3.Scaffold
2827import androidx.compose.material3.Text
28+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
2929import androidx.compose.runtime.Composable
3030import androidx.compose.runtime.getValue
3131import androidx.compose.ui.Alignment
3232import androidx.compose.ui.Modifier
3333import androidx.compose.ui.res.stringResource
34+import androidx.compose.ui.semantics.contentDescription
35+import androidx.compose.ui.semantics.semantics
3436import androidx.compose.ui.text.style.TextOverflow
3537import androidx.compose.ui.unit.dp
3638import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -38,6 +40,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
3840import soy.iko.opencode.R
3941import soy.iko.opencode.di.AppContainer
4042import soy.iko.opencode.ui.components.AppTopBar
43+import soy.iko.opencode.ui.components.EmptyState
4144import soy.iko.opencode.ui.vmFactory
4245
4346@OptIn(ExperimentalMaterial3Api::class)
@@ -45,6 +48,7 @@ import soy.iko.opencode.ui.vmFactory
4548fun McpScreen(container: AppContainer, onBack: () -> Unit) {
4649 val vm: McpViewModel = viewModel(factory = vmFactory { McpViewModel(container) })
4750 val state by vm.state.collectAsStateWithLifecycle()
51+ val refreshing by vm.refreshing.collectAsStateWithLifecycle()
4852
4953 Scaffold(
5054 topBar = {
@@ -59,45 +63,51 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
5963 )
6064 },
6165 ) { padding ->
62− Box(modifier = Modifier.fillMaxSize().padding(padding)) {
63− when (val s = state) {
64− is McpViewModel.State.Loading ->
65− CircularProgressIndicator(Modifier.align(Alignment.Center))
66− is McpViewModel.State.Disconnected -> CenteredText(stringResource(R.string.not_connected))
67− is McpViewModel.State.Error -> Column(
68− modifier = Modifier.align(Alignment.Center).padding(24.dp),
69− horizontalAlignment = Alignment.CenterHorizontally,
70− ) {
71− Text(stringResource(R.string.mcp_failed), color = MaterialTheme.colorScheme.error)
72− Spacer(Modifier.size(12.dp))
73− Button(onClick = { vm.load() }) { Text(stringResource(R.string.retry)) }
74− }
75− is McpViewModel.State.Ready ->
76− if (s.servers.isEmpty()) {
77− CenteredText(stringResource(R.string.mcp_empty))
78− } else {
79− LazyColumn(
80− modifier = Modifier.fillMaxSize(),
81− contentPadding = PaddingValues(16.dp),
82− verticalArrangement = Arrangement.spacedBy(12.dp),
83− ) {
84− items(s.servers, key = { it.name }) { McpServerCard(it) }
66+ PullToRefreshBox(
67+ isRefreshing = refreshing,
68+ onRefresh = { vm.load() },
69+ modifier = Modifier.fillMaxSize().padding(padding),
70+ ) {
71+ Box(modifier = Modifier.fillMaxSize()) {
72+ val loadingLabel = stringResource(R.string.loading)
73+ when (val s = state) {
74+ is McpViewModel.State.Loading -> CircularProgressIndicator(
75+ Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
76+ )
77+ is McpViewModel.State.Disconnected -> EmptyState(
78+ icon = Icons.Filled.CloudOff,
79+ title = stringResource(R.string.not_connected),
80+ modifier = Modifier.align(Alignment.Center),
81+ )
82+ is McpViewModel.State.Error -> EmptyState(
83+ icon = Icons.Filled.ErrorOutline,
84+ title = stringResource(R.string.mcp_failed),
85+ modifier = Modifier.align(Alignment.Center),
86+ actionLabel = stringResource(R.string.retry),
87+ onAction = { vm.load() },
88+ )
89+ is McpViewModel.State.Ready ->
90+ if (s.servers.isEmpty()) {
91+ EmptyState(
92+ icon = Icons.Filled.Hub,
93+ title = stringResource(R.string.mcp_empty),
94+ modifier = Modifier.align(Alignment.Center),
95+ )
96+ } else {
97+ LazyColumn(
98+ modifier = Modifier.fillMaxSize(),
99+ contentPadding = PaddingValues(16.dp),
100+ verticalArrangement = Arrangement.spacedBy(12.dp),
101+ ) {
102+ items(s.servers, key = { it.name }) { McpServerCard(it) }
103+ }
85104 }
86− }
105+ }
87106 }
88107 }
89108 }
90109}
91110
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−
101111@Composable
102112private fun McpServerCard(server: McpServerInfo) {
103113 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() {
2828 private val _state = MutableStateFlow<State>(State.Loading)
2929 val state: StateFlow<State> = _state.asStateFlow()
3030
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+
3136 /** Bumped by [load] (refresh/retry) to re-run without waiting for a connection change. */
3237 private val _reload = MutableStateFlow(0)
3338
@@ -49,12 +54,15 @@ class McpViewModel(private val container: AppContainer) : ViewModel() {
4954 _state.value = State.Disconnected
5055 return
5156 }
52− _state.value = State.Loading
57+ // Keep already-loaded servers on screen during a reload; only show the full-screen
58+ // spinner on the very first load.
59+ if (_state.value is State.Ready) _refreshing.value = true else _state.value = State.Loading
5360 runCatchingCancellable {
5461 val config = conn.api.config()
5562 withContext(Dispatchers.Default) { parseMcpServers(config) }
5663 }
5764 .onSuccess { _state.value = State.Ready(it) }
5865 .onFailure { _state.value = State.Error }
66+ _refreshing.value = false
5967 }
6068}
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+74−12
@@ -15,7 +15,9 @@ import androidx.compose.foundation.text.KeyboardActions
1515import androidx.compose.foundation.text.KeyboardOptions
1616import androidx.compose.material.icons.Icons
1717import androidx.compose.material.icons.filled.Close
18+import androidx.compose.material.icons.filled.ErrorOutline
1819import androidx.compose.material.icons.filled.Search
20+import androidx.compose.material.icons.filled.SearchOff
1921import androidx.compose.material3.Card
2022import androidx.compose.material3.CircularProgressIndicator
2123import androidx.compose.material3.ExperimentalMaterial3Api
@@ -34,14 +36,22 @@ import androidx.compose.ui.Modifier
3436import androidx.compose.ui.focus.focusRequester
3537import androidx.compose.ui.platform.LocalSoftwareKeyboardController
3638import 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
3745import androidx.compose.ui.text.input.ImeAction
3846import androidx.compose.ui.text.style.TextOverflow
47+import androidx.compose.ui.text.withStyle
3948import androidx.compose.ui.unit.dp
4049import androidx.lifecycle.compose.collectAsStateWithLifecycle
4150import androidx.lifecycle.viewmodel.compose.viewModel
4251import soy.iko.opencode.R
4352import soy.iko.opencode.di.AppContainer
4453import soy.iko.opencode.ui.components.AppTopBar
54+import soy.iko.opencode.ui.components.EmptyState
4555import soy.iko.opencode.ui.components.RelativeTimeText
4656import soy.iko.opencode.ui.vmFactory
4757import soy.iko.opencode.util.runCatchingCancellable
@@ -90,23 +100,42 @@ fun GlobalSearchScreen(
90100 keyboardActions = KeyboardActions(onSearch = { keyboard?.hide() }),
91101 )
92102 Box(modifier = Modifier.fillMaxSize()) {
103+ val searchingLabel = stringResource(R.string.searching)
93104 when {
94− state.searching -> CircularProgressIndicator(Modifier.align(Alignment.Center))
95− state.error != null -> Text(
96− state.error ?: "",
97− color = MaterialTheme.colorScheme.error,
98− modifier = Modifier.align(Alignment.Center).padding(24.dp),
105+ state.searching -> CircularProgressIndicator(
106+ Modifier.align(Alignment.Center).semantics { contentDescription = searchingLabel },
99107 )
100− state.hasSearched && state.results.isEmpty() -> Text(
101− stringResource(R.string.search_no_matches),
102− color = MaterialTheme.colorScheme.onSurfaceVariant,
103− modifier = Modifier.align(Alignment.Center).padding(24.dp),
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+ )
115+ state.hasSearched && state.results.isEmpty() -> EmptyState(
116+ icon = Icons.Filled.SearchOff,
117+ title = stringResource(R.string.search_no_matches),
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),
124+ modifier = Modifier.align(Alignment.Center),
104125 )
105126 else -> LazyColumn(
106127 modifier = Modifier.fillMaxSize(),
107128 contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp),
108129 verticalArrangement = Arrangement.spacedBy(12.dp),
109130 ) {
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+ }
110139 if (state.truncated) {
111140 item(key = "__truncated") {
112141 Text(
@@ -118,7 +147,11 @@ fun GlobalSearchScreen(
118147 }
119148 }
120149 items(state.results, key = { it.session.id }) { hit ->
121− SearchResultCard(hit = hit, onClick = { onOpenSession(hit.session.id) })
150+ SearchResultCard(
151+ hit = hit,
152+ query = state.query.trim(),
153+ onClick = { onOpenSession(hit.session.id) },
154+ )
122155 }
123156 }
124157 }
@@ -128,7 +161,7 @@ fun GlobalSearchScreen(
128161}
129162
130163@Composable
131−private fun SearchResultCard(hit: SearchHit, onClick: () -> Unit) {
164+private fun SearchResultCard(hit: SearchHit, query: String, onClick: () -> Unit) {
132165 Card(modifier = Modifier.fillMaxWidth().clickable { onClick() }) {
133166 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
134167 Text(
@@ -141,8 +174,12 @@ private fun SearchResultCard(hit: SearchHit, onClick: () -> Unit) {
141174 hit.session.time?.updated ?: hit.session.time?.created,
142175 modifier = Modifier.padding(top = 2.dp),
143176 )
177+ val highlightColor = MaterialTheme.colorScheme.primary
178+ val snippet = remember(hit.snippet, query, highlightColor) {
179+ highlightMatches(hit.snippet, query, highlightColor)
180+ }
144181 Text(
145− hit.snippet,
182+ snippet,
146183 style = MaterialTheme.typography.bodyMedium,
147184 color = MaterialTheme.colorScheme.onSurfaceVariant,
148185 maxLines = 3,
@@ -152,3 +189,28 @@ private fun SearchResultCard(hit: SearchHit, onClick: () -> Unit) {
152189 }
153190 }
154191}
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() {
6868 }
6969 }
7070
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+
7180 private suspend fun runSearch(query: String) {
7281 val conn = container.activeConnection.value
7382 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
2222import androidx.compose.material.icons.automirrored.filled.Sort
2323import androidx.compose.material.icons.filled.Add
2424import androidx.compose.material.icons.filled.Archive
25+import androidx.compose.material.icons.filled.Unarchive
2526import androidx.compose.material.icons.filled.ArrowDownward
2627import androidx.compose.material.icons.filled.ArrowDropDown
2728import androidx.compose.material.icons.filled.ArrowUpward
@@ -137,6 +138,7 @@ fun SessionListScreen(
137138 val timeTick = rememberRelativeTimeTick()
138139 var showServerMenu by rememberSaveable { mutableStateOf(false) }
139140 var showSortMenu by rememberSaveable { mutableStateOf(false) }
141+ var showMainMenu by rememberSaveable { mutableStateOf(false) }
140142 var showNewSessionDialog by rememberSaveable { mutableStateOf(false) }
141143 // Open the new-session directory picker, kicking off a fetch of the server's known
142144 // directories so they're ready by the time the user looks at the list.
@@ -245,21 +247,38 @@ fun SessionListScreen(
245247 onToggleDirection = { vm.toggleSortDirection() },
246248 onSetShowArchived = { vm.setShowArchived(it) },
247249 )
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− }
254250 IconButton(onClick = onOpenSettings) {
255251 Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.settings))
256252 }
257− IconButton(onClick = {
258− // Confirm before disconnecting if an agent run is active in any
259− // session — disconnecting kills the SSE stream and the run.
260− if (anyRunActive) showDisconnectConfirm = true else onDisconnect()
261− }) {
262− Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = stringResource(R.string.disconnect))
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.
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
277+ // Confirm before disconnecting if an agent run is active in any
278+ // session — disconnecting kills the SSE stream and the run.
279+ if (anyRunActive) showDisconnectConfirm = true else onDisconnect()
280+ },
281+ )
263282 }
264283 },
265284 )
@@ -495,14 +514,30 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
495514 // same confirmation dialog as the trash icon. We never commit
496515 // the dismissal (always reset to Settled) so the card snaps back
497516 // and the dialog guards against accidental data loss.
517+ val isArchivedForSwipe = session.id in state.archivedIds
498518 val swipeState = rememberSwipeToDismissBoxState(
499− confirmValueChange = {
500− // Haptic at the trigger so the swipe path matches the icon-path
501− // delete, which vibrates on confirm. Returns false (snap back) so
502− // the dialog guards the actual deletion.
503− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
504− onDelete(session.id)
505− false
519+ confirmValueChange = { value ->
520+ // Haptic at the trigger so both swipe paths match the icon
521+ // paths, which vibrate on confirm. Always returns false (snap
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(
527+ androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress,
528+ )
529+ onDelete(session.id)
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+ }
506541 },
507542 )
508543 // Per-item callbacks memoized on the session id so SessionCard
@@ -519,26 +554,47 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
519554 val isArchived = session.id in state.archivedIds
520555 SwipeToDismissBox(
521556 state = swipeState,
522− enableDismissFromStartToEnd = false,
557+ // Swipe end-to-start deletes (dialog-guarded); start-to-end
558+ // archives/unarchives (instantly undoable via snackbar).
559+ enableDismissFromStartToEnd = true,
560+ enableDismissFromEndToStart = true,
523561 // Indent sub-sessions under their parent (capped so deep
524562 // nesting doesn't squeeze the card off-screen).
525563 modifier = Modifier
526564 .animateItem()
527565 .padding(start = (node.depth.coerceAtMost(3) * 16).dp),
528566 backgroundContent = {
567+ val archiving = swipeState.dismissDirection == SwipeToDismissBoxValue.StartToEnd
568+ val bg = if (archiving) {
569+ MaterialTheme.colorScheme.tertiaryContainer
570+ } else {
571+ MaterialTheme.colorScheme.errorContainer
572+ }
529573 Box(
530574 modifier = Modifier
531575 .fillMaxSize()
532576 .clip(MaterialTheme.shapes.medium)
533− .background(MaterialTheme.colorScheme.errorContainer)
577+ .background(bg)
534578 .padding(horizontal = 20.dp),
535− contentAlignment = Alignment.CenterEnd,
579+ contentAlignment = if (archiving) Alignment.CenterStart else Alignment.CenterEnd,
536580 ) {
537− Icon(
538− Icons.Filled.Delete,
539− contentDescription = stringResource(R.string.delete),
540− tint = MaterialTheme.colorScheme.onErrorContainer,
541− )
581+ if (archiving) {
582+ val label = stringResource(
583+ if (isArchivedForSwipe) R.string.session_unarchive
584+ else R.string.session_archive,
585+ )
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(
593+ Icons.Filled.Delete,
594+ contentDescription = stringResource(R.string.delete),
595+ tint = MaterialTheme.colorScheme.onErrorContainer,
596+ )
597+ }
542598 }
543599 },
544600 ) {
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+6
@@ -2,6 +2,7 @@ package soy.iko.opencode.ui.session
22
33import androidx.compose.foundation.layout.Box
44import androidx.compose.foundation.layout.Row
5+import androidx.compose.foundation.layout.fillMaxHeight
56import androidx.compose.foundation.layout.fillMaxSize
67import androidx.compose.foundation.layout.widthIn
78import androidx.activity.compose.BackHandler
@@ -14,6 +15,7 @@ import androidx.compose.runtime.getValue
1415import androidx.compose.runtime.key
1516import androidx.compose.runtime.mutableStateOf
1617import androidx.compose.runtime.remember
18+import androidx.compose.material3.VerticalDivider
1719import androidx.compose.runtime.saveable.rememberSaveable
1820import androidx.compose.runtime.setValue
1921import androidx.compose.ui.Modifier
@@ -142,6 +144,10 @@ fun TwoPaneSessionChat(
142144 )
143145 }
144146
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+
145151 Box(modifier = Modifier.weight(NetworkConfig.twoPaneRightWeight).fillMaxSize()) {
146152 val sessionId = selected
147153 if (sessionId == null) {
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+26−1
@@ -93,6 +93,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
9393 val snackbar = remember { SnackbarHostState() }
9494 val undoLabel = stringResource(R.string.undo)
9595 val reportDeletedLabel = stringResource(R.string.report_deleted)
96+ val reportsClearedLabel = stringResource(R.string.reports_cleared)
9697 // Deferred report deletions are owned by the CrashLogger (its process-lived scope),
9798 // so they survive both rotation and navigating away from this screen. The screen only
9899 // shows the Undo snackbar and asks the logger to cancel a pending delete on undo.
@@ -138,6 +139,30 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
138139 }
139140 }
140141
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+
141166 Scaffold(
142167 topBar = {
143168 AppTopBar(
@@ -381,7 +406,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
381406 confirmButton = {
382407 TextButton(onClick = {
383408 showClearAll = false
384− logger.clearAll()
409+ shareScope.launch { clearAllEvents.emit(Unit) }
385410 }) { Text(stringResource(R.string.clear_all), color = MaterialTheme.colorScheme.error) }
386411 },
387412 dismissButton = {
app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.kt+104−41
@@ -1,5 +1,6 @@
11package soy.iko.opencode.ui.usage
22
3+import androidx.compose.foundation.background
34import androidx.compose.foundation.layout.Arrangement
45import androidx.compose.foundation.layout.Box
56import androidx.compose.foundation.layout.Column
@@ -8,13 +9,16 @@ import androidx.compose.foundation.layout.Row
89import androidx.compose.foundation.layout.Spacer
910import androidx.compose.foundation.layout.fillMaxSize
1011import androidx.compose.foundation.layout.fillMaxWidth
12+import androidx.compose.foundation.layout.height
1113import androidx.compose.foundation.layout.padding
1214import androidx.compose.foundation.layout.size
1315import androidx.compose.foundation.lazy.LazyColumn
1416import androidx.compose.foundation.lazy.items
1517import 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
1621import androidx.compose.material.icons.filled.Refresh
17−import androidx.compose.material3.Button
1822import androidx.compose.material3.Card
1923import androidx.compose.material3.CircularProgressIndicator
2024import androidx.compose.material3.ExperimentalMaterial3Api
@@ -23,11 +27,15 @@ import androidx.compose.material3.IconButton
2327import androidx.compose.material3.MaterialTheme
2428import androidx.compose.material3.Scaffold
2529import androidx.compose.material3.Text
30+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
2631import androidx.compose.runtime.Composable
2732import androidx.compose.runtime.getValue
2833import androidx.compose.ui.Alignment
2934import androidx.compose.ui.Modifier
35+import androidx.compose.ui.draw.clip
3036import androidx.compose.ui.res.stringResource
37+import androidx.compose.ui.semantics.contentDescription
38+import androidx.compose.ui.semantics.semantics
3139import androidx.compose.ui.text.style.TextOverflow
3240import androidx.compose.ui.unit.dp
3341import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -36,6 +44,7 @@ import soy.iko.opencode.R
3644import soy.iko.opencode.data.model.Tokens
3745import soy.iko.opencode.di.AppContainer
3846import soy.iko.opencode.ui.components.AppTopBar
47+import soy.iko.opencode.ui.components.EmptyState
3948import soy.iko.opencode.ui.components.SectionHeader
4049import soy.iko.opencode.ui.vmFactory
4150import java.text.NumberFormat
@@ -46,6 +55,7 @@ import java.util.Locale
4655fun UsageScreen(container: AppContainer, onBack: () -> Unit) {
4756 val vm: UsageViewModel = viewModel(factory = vmFactory { UsageViewModel(container) })
4857 val state by vm.state.collectAsStateWithLifecycle()
58+ val refreshing by vm.refreshing.collectAsStateWithLifecycle()
4959
5060 Scaffold(
5161 topBar = {
@@ -60,29 +70,44 @@ fun UsageScreen(container: AppContainer, onBack: () -> Unit) {
6070 )
6171 },
6272 ) { padding ->
63− Box(modifier = Modifier.fillMaxSize().padding(padding)) {
64− when (val s = state) {
65− is UsageViewModel.State.Loading -> Centered {
66− CircularProgressIndicator()
67− Spacer(Modifier.size(12.dp))
68− Text(stringResource(R.string.usage_loading), color = MaterialTheme.colorScheme.onSurfaceVariant)
69− }
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)
82− }
83− } else {
84− UsageContent(s.report)
73+ PullToRefreshBox(
74+ isRefreshing = refreshing,
75+ onRefresh = { vm.load() },
76+ modifier = Modifier.fillMaxSize().padding(padding),
77+ ) {
78+ Box(modifier = Modifier.fillMaxSize()) {
79+ val loadingLabel = stringResource(R.string.usage_loading)
80+ when (val s = state) {
81+ is UsageViewModel.State.Loading -> Centered {
82+ CircularProgressIndicator(
83+ Modifier.semantics { contentDescription = loadingLabel },
84+ )
85+ Spacer(Modifier.size(12.dp))
86+ Text(loadingLabel, color = MaterialTheme.colorScheme.onSurfaceVariant)
8587 }
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+ }
86111 }
87112 }
88113 }
@@ -109,18 +134,55 @@ private fun UsageContent(report: UsageReport) {
109134 // Key on provider+model: the same model id served by two providers yields two
110135 // rows, so keying on the bare model would duplicate keys and crash the list.
111136 items(report.byModel, key = { it.provider + "/" + it.model }) { m ->
112− UsageRow(title = m.provider + " / " + m.model, cost = m.cost, tokens = m.tokens, messages = m.messages)
137+ UsageRow(
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+ )
113144 }
114145 }
115146 if (report.bySession.isNotEmpty()) {
116147 item { SectionHeader(stringResource(R.string.usage_all_sessions)) }
117148 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+ )
119156 }
120157 }
121158 }
122159}
123160
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+
124186@Composable
125187private fun TotalsCard(report: UsageReport) {
126188 Card(modifier = Modifier.fillMaxWidth()) {
@@ -176,25 +238,26 @@ private fun StatRow(label: String, value: String) {
176238}
177239
178240@Composable
179−private fun UsageRow(title: String, cost: Double, tokens: Tokens, messages: Int) {
180− Row(
181− modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
182− verticalAlignment = Alignment.CenterVertically,
183− ) {
184− Column(modifier = Modifier.weight(1f)) {
185− Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium)
241+private fun UsageRow(title: String, cost: Double, tokens: Tokens, messages: Int, costFraction: Float) {
242+ Column(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
243+ Row(verticalAlignment = Alignment.CenterVertically) {
244+ Column(modifier = Modifier.weight(1f)) {
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+ }
186252 Text(
187− stringResource(R.string.usage_messages_count, messages) + " · " + formatInt(tokens.total),
188− style = MaterialTheme.typography.labelSmall,
189− color = MaterialTheme.colorScheme.onSurfaceVariant,
253+ formatCost(cost),
254+ style = MaterialTheme.typography.bodyMedium,
255+ color = MaterialTheme.colorScheme.primary,
256+ modifier = Modifier.padding(start = 12.dp),
190257 )
191258 }
192− Text(
193− formatCost(cost),
194− style = MaterialTheme.typography.bodyMedium,
195− color = MaterialTheme.colorScheme.primary,
196− modifier = Modifier.padding(start = 12.dp),
197− )
259+ Spacer(Modifier.size(6.dp))
260+ CostBar(costFraction)
198261 }
199262}
200263
app/src/main/java/soy/iko/opencode/ui/usage/UsageViewModel.kt+9−1
@@ -38,6 +38,11 @@ class UsageViewModel(private val container: AppContainer) : ViewModel() {
3838 private val _state = MutableStateFlow<State>(State.Loading)
3939 val state: StateFlow<State> = _state.asStateFlow()
4040
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+
4146 /** Bumped by [load] (refresh/retry) to re-run without waiting for a connection change. */
4247 private val _reload = MutableStateFlow(0)
4348
@@ -59,7 +64,9 @@ class UsageViewModel(private val container: AppContainer) : ViewModel() {
5964 _state.value = State.Disconnected
6065 return
6166 }
62− _state.value = State.Loading
67+ // Keep the current report visible during a manual reload; only blank to the full-screen
68+ // spinner on the first load.
69+ if (_state.value is State.Ready) _refreshing.value = true else _state.value = State.Loading
6370 runCatchingCancellable {
6471 val sessions = conn.repository.listSessions()
6572 // Cap concurrent message fetches so a large history doesn't open dozens of
@@ -84,6 +91,7 @@ class UsageViewModel(private val container: AppContainer) : ViewModel() {
8491 }
8592 .onSuccess { _state.value = State.Ready(it) }
8693 .onFailure { _state.value = State.Error }
94+ _refreshing.value = false
8795 }
8896
8997 private companion object {
app/src/main/res/values-es/strings.xml+8−1
@@ -25,6 +25,8 @@
2525 <string name="rename">Renombrar</string>
2626 <string name="disconnect">Desconectar</string>
2727 <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>
2830
2931 <!-- Theme modes -->
3032 <string name="theme_system">Sistema</string>
@@ -217,7 +219,8 @@
217219 <string name="crash_reports">Informes de fallos</string>
218220 <string name="no_crash_reports">No hay fallos registrados.</string>
219221 <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>
221224 <string name="crash_report">Informe de fallos</string>
222225 <string name="close">Cerrar</string>
223226 <string name="delete_report_title">¿Eliminar informe de fallos?</string>
@@ -286,6 +289,7 @@
286289
287290 <!-- UX additions: chat overflow, date separators, settings retry -->
288291 <string name="more">Más</string>
292+ <string name="more_options">Más opciones</string>
289293 <string name="delete_session_chat">Eliminar sesión</string>
290294 <string name="delete_session_chat_title">¿Eliminar esta sesión?</string>
291295 <string name="delete_session_chat_text">«%1$s» se eliminará permanentemente. Esta acción no se puede deshacer.</string>
@@ -358,6 +362,9 @@
358362 <string name="search_all_hint">Buscar en todas las conversaciones…</string>
359363 <string name="search_no_matches">No hay mensajes coincidentes</string>
360364 <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>
361368
362369 <!-- Plan de tareas del agente (herramienta todowrite) -->
363370 <string name="plan">Plan</string>
app/src/main/res/values/strings.xml+8−1
@@ -25,6 +25,8 @@
2525 <string name="rename">Rename</string>
2626 <string name="disconnect">Disconnect</string>
2727 <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>
2830
2931 <!-- Theme modes -->
3032 <string name="theme_system">System</string>
@@ -217,7 +219,8 @@
217219 <string name="crash_reports">Crash reports</string>
218220 <string name="no_crash_reports">No crashes recorded.</string>
219221 <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>
221224 <string name="crash_report">Crash report</string>
222225 <string name="close">Close</string>
223226 <string name="delete_report_title">Delete crash report?</string>
@@ -286,6 +289,7 @@
286289
287290 <!-- UX additions: chat overflow, date separators, settings retry -->
288291 <string name="more">More</string>
292+ <string name="more_options">More options</string>
289293 <string name="delete_session_chat">Delete session</string>
290294 <string name="delete_session_chat_title">Delete this session?</string>
291295 <string name="delete_session_chat_text">“%1$s” will be permanently deleted. This cannot be undone.</string>
@@ -339,6 +343,9 @@
339343 <string name="search_all_hint">Search across all conversations…</string>
340344 <string name="search_no_matches">No matching messages</string>
341345 <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>
342349
343350 <!-- Sharing / summarize / init / shell -->
344351 <string name="create_share_link">Create share link</string>