git.iko.soy/opencode-android
on master help sign in
Implement comprehensive UI/UX audit findings: i18n time formats, diff colors theme fix, bulk archive undo, retry path recovery, nav rail badges, running session indicator, TTS stop button, permission countdown, agent name in header, copy-as-markdown, global search history/filters, keyboard shortcuts, MCP tools viewer, model picker recents, test connection, and consistency cleanup (LoadingSpinner, dead Dimens.kt, cert pin util, findActivity util, reduced-motion banners, invisibleToUser caret)
commit61cdc7582aa359c0e14aacdf173ba951271c11ca authoriko <mail@iko.soy> dateSat 4 Jul 2026, 12:42 (3 months ago) parent7ce40ed
43 files changed+1400−376
app/detekt-baseline.xml+4−5 app/src/main/java/soy/iko/opencode/OpencodeApp.kt+3−19 app/src/main/java/soy/iko/opencode/data/model/Part.kt+10 app/src/main/java/soy/iko/opencode/data/network/CertPinUtil.kt+35 app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.kt+2−25 app/src/main/java/soy/iko/opencode/data/repo/RecentModelsStore.kt+80 app/src/main/java/soy/iko/opencode/data/repo/SearchHistoryStore.kt+68 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+25−6 app/src/main/java/soy/iko/opencode/ui/AppLockGate.kt+5−5 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+43−10 app/src/main/java/soy/iko/opencode/ui/chat/ChatAttachments.kt+12−5 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+56−5 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+65−27 app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+91−55 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+2 app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+17−1 app/src/main/java/soy/iko/opencode/ui/components/AppTopBar.kt+2−4 app/src/main/java/soy/iko/opencode/ui/components/CommandPalette.kt+2−3 app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+24−8 app/src/main/java/soy/iko/opencode/ui/components/LargeScreenNavRail.kt+36−10 app/src/main/java/soy/iko/opencode/ui/components/LoadingSpinner.kt+9 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+3−1 app/src/main/java/soy/iko/opencode/ui/components/RelativeTimeUnit.kt+41 app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+2−22 app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.kt+20−26 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+2−1 app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.kt+71−3 app/src/main/java/soy/iko/opencode/ui/mcp/McpServerInfo.kt+10−2 app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+109−15 app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchViewModel.kt+83−23 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+60−24 app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+64 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+126−8 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+76−7 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+2−1 app/src/main/java/soy/iko/opencode/ui/theme/Dimens.kt−28 app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+8−10 app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.kt+16−5 app/src/main/java/soy/iko/opencode/util/Contexts.kt+15 app/src/main/res/values-es/strings.xml+34−1 app/src/main/res/values/strings.xml+39−1 app/src/test/java/soy/iko/opencode/UxTest.kt+23−10 app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+5
app/detekt-baseline.xml+4−5
@@ -7,22 +7,21 @@
77 <ID>ComplexCondition:SessionListViewModel.kt$SessionListState$q.isEmpty() &amp;&amp; pinnedIds.isEmpty() &amp;&amp; noArchivedHidden &amp;&amp; directoryFilter == null</ID>
88 <ID>CyclomaticComplexMethod:AppContainer.kt$AppContainer$@OptIn(ExperimentalCoroutinesApi::class) private fun observeMessageActivity()</ID>
99 <ID>CyclomaticComplexMethod:ChatScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( container: AppContainer, sessionId: String, onBack: () -&gt; Unit, onOpenFile: ((String) -&gt; Unit)? = null, onOpenSession: ((String) -&gt; Unit)? = null, )</ID>
10− <ID>CyclomaticComplexMethod:ChatScreen.kt$@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable private fun ChatInputBar( value: String, onValueChange: (String) -&gt; Unit, running: Boolean, aborting: Boolean, enabled: Boolean, sendOnEnter: Boolean, onSend: () -&gt; Unit, onAbort: () -&gt; Unit, queuedFollowUp: String?, onQueueFollowUp: (String) -&gt; Unit, onCancelQueue: () -&gt; Unit, focusRequester: androidx.compose.ui.focus.FocusRequester, attachments: List&lt;PendingAttachment&gt;, staging: Boolean, onRemoveAttachment: (String) -&gt; Unit, onPickPhoto: () -&gt; Unit, onPickFile: () -&gt; Unit, onCamera: () -&gt; Unit, onVoice: () -&gt; Unit, onPasteImage: (List&lt;Uri&gt;) -&gt; Unit, )</ID>
10+ <ID>CyclomaticComplexMethod:ChatScreen.kt$@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable private fun ChatInputBar( value: String, onValueChange: (String) -&gt; Unit, running: Boolean, aborting: Boolean, enabled: Boolean, sendOnEnter: Boolean, onSend: () -&gt; Unit, onAbort: () -&gt; Unit, queuedFollowUp: String?, onQueueFollowUp: (String) -&gt; Unit, onCancelQueue: () -&gt; Unit, focusRequester: androidx.compose.ui.focus.FocusRequester, attachments: List&lt;PendingAttachment&gt;, staging: Boolean, stagingFileCount: Int, onRemoveAttachment: (String) -&gt; Unit, onPickPhoto: () -&gt; Unit, onPickFile: () -&gt; Unit, onCamera: () -&gt; Unit, onVoice: () -&gt; Unit, onPasteImage: (List&lt;Uri&gt;) -&gt; Unit, )</ID>
1111 <ID>CyclomaticComplexMethod:ChatViewModel.kt$ChatViewModel$private fun send(text: String, includeAttachments: Boolean, idempotencyKey: String? = null): Boolean</ID>
12− <ID>CyclomaticComplexMethod:CodeHighlighter.kt$private fun findNumberEnd(line: String, start: Int): Int</ID>
1312 <ID>CyclomaticComplexMethod:CodeHighlighter.kt$fun highlightLine(line: String, syntax: FileSyntax, palette: HighlightPalette, state: BlockCommentState): AnnotatedString</ID>
13+ <ID>CyclomaticComplexMethod:CodeHighlighter.kt$private fun findNumberEnd(line: String, start: Int): Int</ID>
1414 <ID>CyclomaticComplexMethod:ConversationExport.kt$fun buildConversationMarkdown(messages: List&lt;MessageWithParts&gt;, title: String?): String</ID>
1515 <ID>CyclomaticComplexMethod:DiagnosticsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun DiagnosticsScreen(onBack: () -&gt; Unit)</ID>
1616 <ID>CyclomaticComplexMethod:EventStreamClient.kt$EventStreamClient$private fun stream(): Flow&lt;BusEvent&gt;</ID>
1717 <ID>CyclomaticComplexMethod:FileBrowserScreen.kt$@OptIn(ExperimentalFoundationApi::class) @Composable private fun FileRow( icon: Boolean, label: String, onClick: () -&gt; Unit, status: FileStatusEntry? = null, sublabel: String? = null, size: Long? = null, mtime: Long? = null, onCopyPath: (() -&gt; Unit)? = null, modifier: Modifier = Modifier, )</ID>
1818 <ID>CyclomaticComplexMethod:FileViewScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun FileViewScreen( container: AppContainer, path: String, onBack: () -&gt; Unit, initialLine: Int? = null, )</ID>
1919 <ID>CyclomaticComplexMethod:MainActivity.kt$MainActivity$private fun handleIntent(intent: Intent?)</ID>
20− <ID>CyclomaticComplexMethod:ModelPickerSheet.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ModelPickerSheet( options: List&lt;ModelOption&gt;, selected: ModelOption?, loading: Boolean, error: Boolean, onSelect: (ModelOption) -&gt; Unit, onRetry: () -&gt; Unit, onDismiss: () -&gt; Unit, preferredModelId: String = "", onSetPreferredModel: (String) -&gt; Unit = {}, )</ID>
2120 <ID>CyclomaticComplexMethod:OpencodeApp.kt$@Composable fun OpencodeApp(container: AppContainer)</ID>
2221 <ID>CyclomaticComplexMethod:ServerListScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ServerListScreen( container: AppContainer, onConnected: () -&gt; Unit, onAddProfile: () -&gt; Unit, onEditProfile: (String) -&gt; Unit, onDuplicateProfile: (String) -&gt; Unit, )</ID>
2322 <ID>CyclomaticComplexMethod:SessionListScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun SessionListScreen( container: AppContainer, onOpenSession: (String) -&gt; Unit, onDisconnect: () -&gt; Unit, onOpenFiles: () -&gt; Unit, onOpenSettings: () -&gt; Unit, onAddServer: () -&gt; Unit, onOpenSearch: () -&gt; Unit = {}, // Incremented by a host (the two-pane empty-detail pane) to open the new-session // directory picker, so that pane shares this screen's dialog instead of bypassing it. externalNewSessionTrigger: Int = 0, selectedSessionId: String? = null, )</ID>
24− <ID>CyclomaticComplexMethod:SessionListScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun androidx.compose.foundation.layout.BoxScope.SessionListBody( state: SessionListState, unread: Map&lt;String, Int&gt;, mutedSessions: Set&lt;String&gt;, refreshing: Boolean, haptics: androidx.compose.ui.hapticfeedback.HapticFeedback, selectedSessionId: String?, onRefresh: () -&gt; Unit, onQueryChange: (String) -&gt; Unit, onOpenSession: (String) -&gt; Unit, onCreateSession: () -&gt; Unit, onRename: (String) -&gt; Unit, onDelete: (String) -&gt; Unit, onPin: (Session) -&gt; Unit, onArchive: (Session) -&gt; Unit, onMarkUnread: (String) -&gt; Unit, onToggleMute: (String) -&gt; Unit, onFilterByDirectory: (String) -&gt; Unit, inSelection: Boolean, selectedIds: Set&lt;String&gt;, onToggleSelect: (String) -&gt; Unit, )</ID>
25− <ID>CyclomaticComplexMethod:SessionListScreen.kt$@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable private fun SessionCard( session: Session, preview: String?, unreadCount: Int, onClick: () -&gt; Unit, onRename: () -&gt; Unit, onDelete: () -&gt; Unit, onPin: () -&gt; Unit, onArchive: () -&gt; Unit, onMarkUnread: () -&gt; Unit, onToggleMute: () -&gt; Unit, isMuted: Boolean, modifier: Modifier = Modifier, isSelected: Boolean = false, isPinned: Boolean = false, isArchived: Boolean = false, onFilterByDirectory: ((String) -&gt; Unit)? = null, isDirectoryFiltered: Boolean = false, inSelection: Boolean = false, onToggleSelect: (() -&gt; Unit)? = null, )</ID>
23+ <ID>CyclomaticComplexMethod:SessionListScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun androidx.compose.foundation.layout.BoxScope.SessionListBody( state: SessionListState, unread: Map&lt;String, Int&gt;, mutedSessions: Set&lt;String&gt;, refreshing: Boolean, haptics: androidx.compose.ui.hapticfeedback.HapticFeedback, selectedSessionId: String?, connected: Boolean, onRefresh: () -&gt; Unit, onReconnect: () -&gt; Unit, onQueryChange: (String) -&gt; Unit, onOpenSession: (String) -&gt; Unit, onCreateSession: () -&gt; Unit, onRename: (String) -&gt; Unit, onDelete: (String) -&gt; Unit, onPin: (Session) -&gt; Unit, onArchive: (Session) -&gt; Unit, onMarkUnread: (String) -&gt; Unit, onToggleMute: (String) -&gt; Unit, onFilterByDirectory: (String) -&gt; Unit, onClearDirectoryFilter: () -&gt; Unit, onShowArchived: () -&gt; Unit, inSelection: Boolean, selectedIds: Set&lt;String&gt;, onToggleSelect: (String) -&gt; Unit, runningSessionIds: Set&lt;String&gt; = emptySet(), )</ID>
24+ <ID>CyclomaticComplexMethod:SessionListScreen.kt$@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable private fun SessionCard( session: Session, preview: String?, unreadCount: Int, onClick: () -&gt; Unit, onRename: () -&gt; Unit, onDelete: () -&gt; Unit, onPin: () -&gt; Unit, onArchive: () -&gt; Unit, onMarkUnread: () -&gt; Unit, onToggleMute: () -&gt; Unit, isMuted: Boolean, modifier: Modifier = Modifier, isSelected: Boolean = false, isPinned: Boolean = false, isArchived: Boolean = false, isRunning: Boolean = false, onFilterByDirectory: ((String) -&gt; Unit)? = null, isDirectoryFiltered: Boolean = false, inSelection: Boolean = false, onToggleSelect: (() -&gt; Unit)? = null, )</ID>
2625 <ID>CyclomaticComplexMethod:SettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalCoroutinesApi::class) @Composable fun SettingsScreen( container: AppContainer, onBack: () -&gt; Unit, onManageServers: () -&gt; Unit, onOpenDiagnostics: () -&gt; Unit, onOpenUsage: () -&gt; Unit = {}, onOpenMcp: () -&gt; Unit = {}, )</ID>
2726 <ID>LargeClass:AppContainer.kt$AppContainer</ID>
2827 <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
app/src/main/java/soy/iko/opencode/OpencodeApp.kt+3−19
@@ -11,7 +11,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
1111import okhttp3.OkHttpClient
1212import okhttp3.Request
1313import soy.iko.opencode.data.model.ServerProfile
14−import soy.iko.opencode.data.network.HttpClientFactory
14+import soy.iko.opencode.data.network.CertPinUtil
1515import soy.iko.opencode.data.repo.CrashLogger
1616import soy.iko.opencode.di.AppContainer
1717import java.util.concurrent.atomic.AtomicBoolean
@@ -97,12 +97,12 @@ private class PinnedImageCallFactory(private val container: AppContainer) : Call
9797 clientFor(container.activeConnection.value?.profile).newCall(request)
9898
9999 private fun clientFor(profile: ServerProfile?): OkHttpClient {
100− val pins = parsePins(profile?.certPin)
100+ val pins = CertPinUtil.parseCertPins(profile?.certPin)
101101 if (pins.isEmpty()) return base
102102 // effectiveBaseUrl upgrades http->https when pinning is on, so the pinned host matches the
103103 // https origin RemoteImage resolves image URLs against. Fail closed on an unparseable host
104104 // (never fall back to an unpinned client), mirroring HttpClientFactory.create().
105− val effectiveUrl = effectiveBaseUrl(profile!!)
105+ val effectiveUrl = CertPinUtil.effectiveBaseUrl(profile!!)
106106 val host = effectiveUrl.toHttpUrlOrNull()?.host
107107 ?: throw IllegalStateException(
108108 "Certificate pinning is configured but the server host could not be parsed from $effectiveUrl",
@@ -117,20 +117,4 @@ private class PinnedImageCallFactory(private val container: AppContainer) : Call
117117 return cachedClient
118118 }
119119 }
120−
121− // parsePins / effectiveBaseUrl mirror the private helpers in HttpClientFactory (the source of
122− // truth). This file may not edit HttpClientFactory and those helpers aren't exposed, so the
123− // pin-parsing and http->https upgrade logic is replicated here and must be kept in sync.
124− private fun parsePins(raw: String?): List<String> =
125− raw?.split(Regex("[\\s,]+"))?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList()
126−
127− private fun effectiveBaseUrl(profile: ServerProfile): String {
128− val normalized = HttpClientFactory.normalizeBaseUrl(profile.baseUrl)
129− val forceHttps = profile.requireHttps || parsePins(profile.certPin).isNotEmpty()
130− return if (forceHttps && normalized.lowercase().startsWith("http://")) {
131− "https://" + normalized.substring("http://".length)
132− } else {
133− normalized
134− }
135− }
136120}
app/src/main/java/soy/iko/opencode/data/model/Part.kt+10
@@ -95,6 +95,16 @@ data class StepStartPart(
9595 override val sessionID: String? = null,
9696) : Part
9797
98+@Immutable
99+@Serializable
100+@SerialName("agent")
101+data class AgentPart(
102+ override val id: String = "",
103+ override val messageID: String? = null,
104+ override val sessionID: String? = null,
105+ val name: String = "",
106+) : Part
107+
98108@Immutable
99109@Serializable
100110@SerialName("step-finish")
app/src/main/java/soy/iko/opencode/data/network/CertPinUtil.ktnew file+35
@@ -0,0 +1,35 @@
1+package soy.iko.opencode.data.network
2+
3+import soy.iko.opencode.data.model.ServerProfile
4+
5+/**
6+ * Shared helpers for resolving a [ServerProfile]'s certificate-pin field and the
7+ * http→https upgrade it implies. The logic is used in three places that must agree:
8+ * [HttpClientFactory] (the REST/SSE channel), [soy.iko.opencode.OpencodeApp]'s
9+ * PinnedImageCallFactory (the image-loading channel), and [soy.iko.opencode.ui.components.RemoteImage]
10+ * (resolving image base URLs). Previously each call site carried its own private copy
11+ * with a "must be kept in sync" comment; the helpers here are the single source of truth.
12+ */
13+internal object CertPinUtil {
14+
15+ /** Split a certificate-pin field into individual OkHttp pins. Accepts whitespace- or
16+ * comma-separated "sha256/<base64>" entries; blank entries are dropped. */
17+ fun parseCertPins(raw: String?): List<String> =
18+ raw?.split(Regex("[\\s,]+"))?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList()
19+
20+ /** The base URL after applying [ServerProfile.requireHttps]: a cleartext http:// URL is
21+ * upgraded to https:// so the "Require HTTPS" choice is enforced at the transport layer.
22+ * A configured certificate pin also forces TLS: a pin is meaningless over cleartext and
23+ * would otherwise be silently dropped, so we honor the opt-in security control by upgrading
24+ * rather than connecting unpinned in the clear. Relies on [HttpClientFactory.normalizeBaseUrl]
25+ * for scheme/trailing-slash normalization. */
26+ fun effectiveBaseUrl(profile: ServerProfile): String {
27+ val normalized = HttpClientFactory.normalizeBaseUrl(profile.baseUrl)
28+ val forceHttps = profile.requireHttps || parseCertPins(profile.certPin).isNotEmpty()
29+ return if (forceHttps && normalized.lowercase().startsWith("http://")) {
30+ "https://" + normalized.substring("http://".length)
31+ } else {
32+ normalized
33+ }
34+ }
35+}
app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.kt+2−25
@@ -28,17 +28,14 @@ import java.util.concurrent.TimeUnit
2828object HttpClientFactory {
2929
3030 fun create(profile: ServerProfile): HttpClient {
31− // Resolve per-profile TLS options up front so they're available inside the engine
32− // config: optionally upgrade a cleartext http:// URL to https:// ("Require HTTPS"),
33− // and pin the server certificate(s) for HTTPS connections.
34− val normalizedUrl = effectiveBaseUrl(profile)
31+ val normalizedUrl = CertPinUtil.effectiveBaseUrl(profile)
3532 val isHttps = normalizedUrl.lowercase().startsWith("https://")
3633 // Parse the host with OkHttp's own parser (not java.net.URI, which rejects hosts OkHttp
3734 // accepts, e.g. underscores in "my_server.local"). If pinning is configured over HTTPS
3835 // but the host can't be parsed, fail closed: never build an unpinned client — that would
3936 // be a silent security downgrade for a user who opted into pinning.
4037 val pinHost = normalizedUrl.toHttpUrlOrNull()?.host
41− val pins = parsePins(profile.certPin)
38+ val pins = CertPinUtil.parseCertPins(profile.certPin)
4239 if (pins.isNotEmpty() && isHttps && pinHost == null) {
4340 throw IllegalStateException(
4441 "Certificate pinning is configured but the server host could not be parsed from $normalizedUrl",
@@ -152,26 +149,6 @@ object HttpClientFactory {
152149 }
153150 }
154151
155− /** The base URL after applying [ServerProfile.requireHttps]: a cleartext http:// URL is
156− * upgraded to https:// so the "Require HTTPS" choice is enforced at the transport layer.
157− * A configured certificate pin also forces TLS: a pin is meaningless over cleartext and
158− * would otherwise be silently dropped, so we honor the opt-in security control by upgrading
159− * rather than connecting unpinned in the clear. */
160− private fun effectiveBaseUrl(profile: ServerProfile): String {
161− val normalized = normalizeBaseUrl(profile.baseUrl)
162− val forceHttps = profile.requireHttps || parsePins(profile.certPin).isNotEmpty()
163− return if (forceHttps && normalized.lowercase().startsWith("http://")) {
164− "https://" + normalized.substring("http://".length)
165− } else {
166− normalized
167− }
168− }
169−
170− /** Split a certificate-pin field into individual OkHttp pins. Accepts whitespace- or
171− * comma-separated "sha256/<base64>" entries; blank entries are dropped. */
172− private fun parsePins(raw: String?): List<String> =
173− raw?.split(Regex("[\\s,]+"))?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList()
174−
175152 /** Ensure a scheme and a single trailing slash so relative paths resolve correctly.
176153 * Defaults to [https] when no scheme is given so credentials aren't accidentally
177154 * sent over cleartext. Users who need plain HTTP must type the scheme explicitly. */
app/src/main/java/soy/iko/opencode/data/repo/RecentModelsStore.ktnew file+80
@@ -0,0 +1,80 @@
1+package soy.iko.opencode.data.repo
2+
3+import android.content.Context
4+import kotlinx.coroutines.flow.MutableStateFlow
5+import kotlinx.coroutines.flow.StateFlow
6+import kotlinx.coroutines.flow.asStateFlow
7+
8+/**
9+ * Persists the most recently picked models so the model picker can surface them as a
10+ * "Recent" section above the full catalog. Backed by SharedPreferences because the UI
11+ * needs the initial value synchronously and the list is tiny (≤ [MAX_ENTRIES]), mirroring
12+ * the low-stakes-persistence rationale of [SearchHistoryStore]. `open` with a `protected`
13+ * no-arg constructor so tests can subclass and override without a real Context.
14+ *
15+ * Entries are stored as `providerID\modelID` composite keys (the separator is chosen so it
16+ * can't collide with provider or model ids, which are URL/path-safe identifiers).
17+ */
18+open class RecentModelsStore private constructor(
19+ private val appContext: Context?,
20+ @Suppress("unused") private val testMode: Boolean,
21+) {
22+ constructor(context: Context) : this(context.applicationContext, false)
23+ protected constructor() : this(null, true)
24+
25+ private val prefs by lazy {
26+ appContext?.getSharedPreferences("recent_models", Context.MODE_PRIVATE)
27+ ?: error("No context — override methods in test subclass")
28+ }
29+
30+ private val _entries = MutableStateFlow<List<String>>(emptyList())
31+ open val entries: StateFlow<List<String>> = _entries.asStateFlow()
32+
33+ init {
34+ if (appContext != null) {
35+ _entries.value = runCatching {
36+ prefs.getString(KEY, null)
37+ ?.takeIf { it.isNotBlank() }
38+ ?.split(SEPARATOR)
39+ ?.filter { it.isNotBlank() }
40+ ?.take(MAX_ENTRIES)
41+ }.getOrDefault(emptyList()) ?: emptyList()
42+ }
43+ }
44+
45+ /** Insert [providerId]/[modelId] at the head of the recents, deduped and capped at
46+ * [MAX_ENTRIES]. Blank ids are ignored. */
47+ open fun add(providerId: String, modelId: String) {
48+ val p = providerId.trim()
49+ val m = modelId.trim()
50+ if (p.isBlank() || m.isBlank()) return
51+ val key = "$p$SEPARATOR$m"
52+ val updated = (listOf(key) + _entries.value.filter { it != key }).take(MAX_ENTRIES)
53+ _entries.value = updated
54+ runCatching {
55+ prefs.edit().putString(KEY, updated.joinToString(SEPARATOR)).apply()
56+ }
57+ }
58+
59+ /** Split a stored composite key back into its provider/model id pair. Returns null if
60+ * the entry is malformed (shouldn't happen, but a corrupt prefs value must never crash
61+ * the picker). */
62+ fun split(entry: String): Pair<String, String>? {
63+ val parts = entry.split(SEPARATOR)
64+ return if (parts.size == 2 && parts[0].isNotBlank() && parts[1].isNotBlank())
65+ parts[0] to parts[1] else null
66+ }
67+
68+ open fun clear() {
69+ _entries.value = emptyList()
70+ runCatching { prefs.edit().remove(KEY).apply() }
71+ }
72+
73+ private companion object {
74+ const val KEY = "models"
75+ // A newline can't appear in a provider/model id (they're URL/path-safe), so it's a
76+ // safe list separator that won't collide with id contents.
77+ const val SEPARATOR = "\n"
78+ const val MAX_ENTRIES = 5
79+ }
80+}
app/src/main/java/soy/iko/opencode/data/repo/SearchHistoryStore.ktnew file+68
@@ -0,0 +1,68 @@
1+package soy.iko.opencode.data.repo
2+
3+import android.content.Context
4+import kotlinx.coroutines.flow.MutableStateFlow
5+import kotlinx.coroutines.flow.StateFlow
6+import kotlinx.coroutines.flow.asStateFlow
7+import soy.iko.opencode.data.network.NetworkConfig
8+
9+/**
10+ * Persists the most recent unique global-search queries so the search screen can
11+ * surface them as suggestions. Backed by SharedPreferences because the UI needs the
12+ * initial value synchronously and the list is tiny (≤ [MAX_ENTRIES]), mirroring the
13+ * low-stakes-persistence rationale of [DraftStore]. `open` with a `protected`
14+ * no-arg constructor so tests can subclass and override without a real Context.
15+ */
16+open class SearchHistoryStore private constructor(
17+ private val appContext: Context?,
18+ @Suppress("unused") private val testMode: Boolean,
19+) {
20+ constructor(context: Context) : this(context.applicationContext, false)
21+ protected constructor() : this(null, true)
22+
23+ private val prefs by lazy {
24+ appContext?.getSharedPreferences("search_history", Context.MODE_PRIVATE)
25+ ?: error("No context — override methods in test subclass")
26+ }
27+
28+ private val _queries = MutableStateFlow<List<String>>(emptyList())
29+ open val queries: StateFlow<List<String>> = _queries.asStateFlow()
30+
31+ init {
32+ if (appContext != null) {
33+ _queries.value = runCatching {
34+ prefs.getString(KEY, null)
35+ ?.takeIf { it.isNotBlank() }
36+ ?.split(SEPARATOR)
37+ ?.filter { it.isNotBlank() }
38+ ?.take(MAX_ENTRIES)
39+ }.getOrDefault(emptyList()) ?: emptyList()
40+ }
41+ }
42+
43+ /** Insert [query] at the head of the history, deduped and capped at [MAX_ENTRIES].
44+ * Blank or sub-min-length queries are ignored so accidental empties and stray
45+ * keystrokes don't pollute the suggestions. */
46+ open fun add(query: String) {
47+ val trimmed = query.trim()
48+ if (trimmed.isBlank() || trimmed.length < NetworkConfig.minSearchQueryLength) return
49+ val updated = (listOf(trimmed) + _queries.value.filter { it != trimmed }).take(MAX_ENTRIES)
50+ _queries.value = updated
51+ runCatching {
52+ prefs.edit().putString(KEY, updated.joinToString(SEPARATOR)).apply()
53+ }
54+ }
55+
56+ open fun clear() {
57+ _queries.value = emptyList()
58+ runCatching { prefs.edit().remove(KEY).apply() }
59+ }
60+
61+ private companion object {
62+ const val KEY = "queries"
63+ // The search field is single-line, so a newline can't appear in a stored query
64+ // and is safe to use as the list separator.
65+ const val SEPARATOR = "\n"
66+ const val MAX_ENTRIES = 10
67+ }
68+}
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+25−6
@@ -32,6 +32,8 @@ import soy.iko.opencode.data.repo.ErrorKind
3232import soy.iko.opencode.data.repo.MessageCacheStore
3333import soy.iko.opencode.data.repo.OutboxMessage
3434import soy.iko.opencode.data.repo.OutboxStore
35+import soy.iko.opencode.data.repo.RecentModelsStore
36+import soy.iko.opencode.data.repo.SearchHistoryStore
3537import soy.iko.opencode.data.repo.SessionPrefsStore
3638import soy.iko.opencode.data.repo.SessionRepository
3739import soy.iko.opencode.data.repo.ProfileStore
@@ -113,6 +115,8 @@ open class AppContainer private constructor(
113115 open val sessionPrefsStore: SessionPrefsStore by lazy { SessionPrefsStore(appContext!!) }
114116 open val messageCacheStore: MessageCacheStore by lazy { MessageCacheStore(appContext!!) }
115117 open val outboxStore: OutboxStore by lazy { OutboxStore(appContext!!) }
118+ open val searchHistoryStore: SearchHistoryStore by lazy { SearchHistoryStore(appContext!!) }
119+ open val recentModelsStore: RecentModelsStore by lazy { RecentModelsStore(appContext!!) }
116120 open val backupManager: BackupManager by lazy { BackupManager(profileStore, settingsStore, sessionPrefsStore) }
117121
118122 /** True while the outbox is actively flushing queued messages to the server, so the UI
@@ -245,6 +249,12 @@ open class AppContainer private constructor(
245249 private val _anyRunActive = MutableStateFlow(false)
246250 open val anyRunActive: StateFlow<Boolean> = _anyRunActive.asStateFlow()
247251
252+ /** Session ids currently streaming an assistant run, surfaced so the session list can
253+ * badge the row whose run is active. Mirrors [activeRuns] as an immutable snapshot so
254+ * Compose can skip recomposition when the set hasn't changed. */
255+ private val _runningSessionIds = MutableStateFlow<Set<String>>(emptySet())
256+ open val runningSessionIds: StateFlow<Set<String>> = _runningSessionIds.asStateFlow()
257+
248258 /** Latest task-plan progress text for the active run (e.g. "Step 2 of 5"), surfaced in
249259 * the foreground-service notification so the user can see what the agent is working on
250260 * without opening the app. Null when no plan is available or no run is active. */
@@ -468,7 +478,7 @@ open class AppContainer private constructor(
468478 if (hasConnectedBefore) {
469479 synchronized(activeRuns) {
470480 activeRuns.clear()
471− _anyRunActive.value = false
481+ publishRunState()
472482 }
473483 }
474484 hasConnectedBefore = true
@@ -653,7 +663,7 @@ open class AppContainer private constructor(
653663 val esid = event.properties.sessionID ?: return@collect
654664 val wasRunning = synchronized(activeRuns) {
655665 activeRuns.remove(esid).also { removed ->
656− if (removed) _anyRunActive.value = activeRuns.isNotEmpty()
666+ if (removed) publishRunState()
657667 }
658668 }
659669 if (wasRunning && !isActivelyViewing(esid)) {
@@ -673,7 +683,7 @@ open class AppContainer private constructor(
673683 // the completion *notification* is suppressed for the viewed one.
674684 val wasRunning = synchronized(activeRuns) {
675685 activeRuns.remove(idleSid).also { removed ->
676− if (removed) _anyRunActive.value = activeRuns.isNotEmpty()
686+ if (removed) publishRunState()
677687 }
678688 }
679689 // Clear the progress text when the run finishes.
@@ -750,7 +760,7 @@ open class AppContainer private constructor(
750760 // guards the set, so a concurrent reconnect sweep (which clears
751761 // both under this lock) can't interleave and leave the flag pinned
752762 // true with an empty set — a stuck foreground service + "working…".
753− _anyRunActive.value = activeRuns.isNotEmpty()
763+ publishRunState()
754764 }
755765 }
756766 } }.onFailure { Log.w("AppContainer", "Message activity observer failed, will retry: ${safeExceptionSummary(it)}") }
@@ -784,6 +794,15 @@ open class AppContainer private constructor(
784794 return part !is StepFinishPart
785795 }
786796
797+ /** Snapshot [activeRuns] into [_runningSessionIds] and derive [_anyRunActive] from it.
798+ * MUST be called under the [activeRuns] monitor so the published flag and snapshot can't
799+ * diverge from the set a concurrent SSE add()/remove() is mutating — the same invariant
800+ * [connectLocked]/[teardownActiveLocked] rely on. */
801+ private fun publishRunState() {
802+ _anyRunActive.value = activeRuns.isNotEmpty()
803+ _runningSessionIds.value = activeRuns.toSet()
804+ }
805+
787806 /** Session ids currently streaming an assistant run (best-effort, in-process). Backed by
788807 * an access-order [java.util.LinkedHashMap] so re-adding an already-present id on each
789808 * streaming event refreshes its recency (a plain LinkedHashSet.add is a no-op that does
@@ -1127,7 +1146,7 @@ open class AppContainer private constructor(
11271146 // down connection and no SessionIdle to clear it — a stuck foreground service.
11281147 synchronized(activeRuns) {
11291148 activeRuns.clear()
1130− _anyRunActive.value = false
1149+ publishRunState()
11311150 }
11321151 if (previousProfileId != profile.id) {
11331152 _unread.value = emptyMap()
@@ -1171,7 +1190,7 @@ open class AppContainer private constructor(
11711190 // re-pin _anyRunActive after the clear.
11721191 synchronized(activeRuns) {
11731192 activeRuns.clear()
1174− _anyRunActive.value = false
1193+ publishRunState()
11751194 }
11761195 _unread.value = emptyMap()
11771196 unreadMessageIds.clear()
app/src/main/java/soy/iko/opencode/ui/AppLockGate.kt+5−5
@@ -16,7 +16,6 @@ import androidx.compose.material.icons.Icons
1616import androidx.compose.material.icons.filled.Fingerprint
1717import androidx.compose.material.icons.filled.Lock
1818import androidx.compose.material3.Button
19−import androidx.compose.material3.CircularProgressIndicator
2019import androidx.compose.material3.Icon
2120import androidx.compose.material3.MaterialTheme
2221import androidx.compose.material3.Surface
@@ -44,6 +43,8 @@ import androidx.lifecycle.Lifecycle
4443import androidx.lifecycle.LifecycleEventObserver
4544import androidx.lifecycle.compose.LocalLifecycleOwner
4645import soy.iko.opencode.R
46+import soy.iko.opencode.ui.components.LoadingSize
47+import soy.iko.opencode.ui.components.LoadingSpinner
4748
4849/**
4950 * The set of authenticators the app lock accepts. Device credential (PIN/pattern/password)
@@ -211,7 +212,7 @@ fun AppLockGate(enabled: Boolean, reLockDelaySeconds: Int = 0, content: @Composa
211212 // Within the grace period — stay unlocked, no re-prompt.
212213 } else {
213214 unlocked = false
214− if (!unlocked) authenticate()
215+ authenticate()
215216 }
216217 }
217218 else -> {}
@@ -285,9 +286,8 @@ fun AppLockGate(enabled: Boolean, reLockDelaySeconds: Int = 0, content: @Composa
285286 modifier = Modifier.padding(top = 24.dp),
286287 ) {
287288 if (authInFlight) {
288− CircularProgressIndicator(
289− modifier = Modifier.size(18.dp),
290− strokeWidth = 2.dp,
289+ LoadingSpinner(
290+ size = LoadingSize.Small,
291291 color = MaterialTheme.colorScheme.primary,
292292 )
293293 Spacer(Modifier.size(8.dp))
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+43−10
@@ -22,6 +22,12 @@ import androidx.compose.runtime.rememberCoroutineScope
2222import androidx.compose.runtime.saveable.rememberSaveable
2323import androidx.compose.runtime.setValue
2424import androidx.compose.ui.Modifier
25+import androidx.compose.ui.input.key.Key
26+import androidx.compose.ui.input.key.KeyEventType
27+import androidx.compose.ui.input.key.isCtrlPressed
28+import androidx.compose.ui.input.key.key
29+import androidx.compose.ui.input.key.onPreviewKeyEvent
30+import androidx.compose.ui.input.key.type
2531import androidx.compose.ui.platform.LocalContext
2632import androidx.compose.ui.platform.LocalDensity
2733import androidx.compose.ui.unit.dp
@@ -56,6 +62,7 @@ import soy.iko.opencode.ui.settings.DiagnosticsScreen
5662import soy.iko.opencode.ui.settings.SettingsScreen
5763import soy.iko.opencode.ui.usage.UsageScreen
5864import soy.iko.opencode.ui.mcp.McpScreen
65+import soy.iko.opencode.util.findActivity
5966import soy.iko.opencode.util.runCatchingCancellable
6067
6168@Composable
@@ -193,7 +200,35 @@ fun OpencodeApp(container: AppContainer) {
193200 }
194201 }
195202 val isHalfOpened = foldState?.state == androidx.window.layout.FoldingFeature.State.HALF_OPENED
196− BoxWithConstraints {
203+ BoxWithConstraints(
204+ modifier = Modifier.onPreviewKeyEvent { ev ->
205+ // Global keyboard shortcuts for hardware-keyboard users (tablets, DeX, Chromebook):
206+ // Ctrl+1..6 switches between top-level destinations. Only fires on KeyDown so a
207+ // held Ctrl doesn't re-trigger on every repeat. Per-screen shortcuts (Ctrl+K palette,
208+ // Esc stop, Ctrl+F find) are handled in each screen's own onPreviewKeyEvent and take
209+ // precedence (they're closer to the focused composable in the tree).
210+ if (ev.type != KeyEventType.KeyDown || !ev.isCtrlPressed) {
211+ return@onPreviewKeyEvent false
212+ }
213+ val route = when (ev.key) {
214+ Key.NumPad1, Key.One -> Routes.SESSIONS
215+ Key.NumPad2, Key.Two -> Routes.SEARCH
216+ Key.NumPad3, Key.Three -> Routes.FILES
217+ Key.NumPad4, Key.Four -> Routes.USAGE
218+ Key.NumPad5, Key.Five -> Routes.MCP
219+ Key.NumPad6, Key.Six -> Routes.SETTINGS
220+ else -> null
221+ }
222+ if (route != null && connection != null) {
223+ navController.navigate(route) {
224+ popUpTo(navController.graph.findStartDestination().id) { saveState = true }
225+ launchSingleTop = true
226+ restoreState = true
227+ }
228+ true
229+ } else false
230+ },
231+ ) {
197232 val isTwoPane = maxWidth >= NetworkConfig.twoPaneWidthThresholdDp.dp &&
198233 connection != null &&
199234 !isHalfOpened
@@ -482,10 +517,17 @@ fun OpencodeApp(container: AppContainer) {
482517 }
483518
484519 if (showNavRail) {
520+ // Collect unread/run state for the nav rail's Sessions badge, so a user on
521+ // Files/Settings sees pending activity at a glance.
522+ val unread by container.unread.collectAsStateWithLifecycle()
523+ val anyRunActive by container.anyRunActive.collectAsStateWithLifecycle()
524+ val totalUnread = remember(unread) { unread.values.sum() }
485525 Row(modifier = Modifier.fillMaxSize()) {
486526 LargeScreenNavRail(
487527 currentRoute = currentRoute,
488528 connected = connection != null,
529+ unreadCount = totalUnread,
530+ runActive = anyRunActive,
489531 // Use the standard M3 nav-bar pattern: popUpTo the start destination
490532 // saving state, and restoreState on re-entry, so switching between
491533 // top-level tabs via the rail preserves each tab's scroll/selection
@@ -509,12 +551,3 @@ fun OpencodeApp(container: AppContainer) {
509551 }
510552 }
511553}
512−
513−/** Walk the Context wrapper chain to find the hosting Activity, so WindowInfoTracker can
514− * observe the fold state. Returns null when the composable isn't hosted in an Activity
515− * (e.g. a preview), in which case fold-awareness is skipped. */
516−private tailrec fun android.content.Context.findActivity(): android.app.Activity? = when (this) {
517− is android.app.Activity -> this
518− is android.content.ContextWrapper -> baseContext.findActivity()
519− else -> null
520−}
app/src/main/java/soy/iko/opencode/ui/chat/ChatAttachments.kt+12−5
@@ -49,6 +49,7 @@ internal fun AttachmentStrip(
4949 attachments: List<PendingAttachment>,
5050 onRemove: (String) -> Unit,
5151 staging: Boolean = false,
52+ stagingFileCount: Int = 0,
5253) {
5354 // Show the strip while an encode is in flight even before the first chip exists, so the
5455 // user gets immediate feedback that their pick is being processed.
@@ -66,14 +67,20 @@ internal fun AttachmentStrip(
6667 }
6768 // A placeholder chip with an indeterminate spinner while any pick is still being
6869 // read + base64-encoded off the main thread (chips only materialize once done).
69− if (staging) StagingChip()
70+ if (staging) StagingChip(stagingFileCount)
7071 }
7172}
7273
7374/** A placeholder chip shown while an attachment is being encoded. */
7475@Composable
75−private fun StagingChip() {
76− val stagingLabel = stringResource(R.string.attachment_staging)
76+private fun StagingChip(stagingFileCount: Int) {
77+ // Show "Staging N files…" when a multi-file count is known; fall back to the generic
78+ // "Attaching…" for the single/unknown case so the label never reads "Staging 1 files".
79+ val label = if (stagingFileCount > 1) {
80+ stringResource(R.string.staging_n_files, stagingFileCount)
81+ } else {
82+ stringResource(R.string.attachment_staging)
83+ }
7784 Surface(
7885 shape = MaterialTheme.shapes.small,
7986 color = MaterialTheme.colorScheme.surfaceVariant,
@@ -84,11 +91,11 @@ private fun StagingChip() {
8491 modifier = Modifier.padding(horizontal = 10.dp),
8592 ) {
8693 CircularProgressIndicator(
87− Modifier.size(18.dp).semantics { contentDescription = stagingLabel },
94+ Modifier.size(18.dp).semantics { contentDescription = label },
8895 strokeWidth = 2.dp,
8996 )
9097 Text(
91− stagingLabel,
98+ label,
9299 style = MaterialTheme.typography.labelMedium,
93100 maxLines = 1,
94101 modifier = Modifier.padding(start = 8.dp, top = 10.dp, bottom = 10.dp),
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+56−5
@@ -222,6 +222,7 @@ fun ChatScreen(
222222 val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
223223 val sendOnEnter by container.settingsStore.sendOnEnter.collectAsStateWithLifecycle(initialValue = true)
224224 val preferredModelId by container.settingsStore.preferredModelId.collectAsStateWithLifecycle(initialValue = "")
225+ val recentModelEntries by container.recentModelsStore.entries.collectAsStateWithLifecycle()
225226 val compactSpacing by container.settingsStore.compactMessageSpacing.collectAsStateWithLifecycle(initialValue = false)
226227 val isOnline by container.isOnline.collectAsStateWithLifecycle()
227228 val haptics = LocalHapticFeedback.current
@@ -382,6 +383,11 @@ fun ChatScreen(
382383 // Count of picks currently being read + base64-encoded off the main thread, so the
383384 // composer can show a staging placeholder immediately (chips only materialize once done).
384385 var stagingCount by remember { mutableStateOf(0) }
386+ // Total number of files still being staged across all in-flight batches, so the
387+ // staging chip can show "Staging N files…" rather than a bare indeterminate spinner.
388+ // A separate counter from [stagingCount] (which tracks batches, not files) so a
389+ // multi-file pick reads as more work than a single-file pick.
390+ var stagingFileTotal by remember { mutableStateOf(0) }
385391
386392 // Convert each picked Uri to a base64 attachment off the main thread, honoring the
387393 // per-prompt count cap and surfacing per-file errors without aborting the batch.
@@ -389,17 +395,22 @@ fun ChatScreen(
389395 if (uris.isEmpty()) return
390396 scope.launch {
391397 stagingCount++
398+ stagingFileTotal += uris.size
392399 try {
393− for (uri in uris) {
400+ uris.forEachIndexed { index, uri ->
394401 if (vm.attachments.value.size >= NetworkConfig.maxAttachments) {
395402 snackbar.showSnackbar(attachLimitMsg)
396− break
403+ // The cap dropped the rest of this batch; account for the files we
404+ // won't process so stagingFileTotal doesn't linger over-counted.
405+ stagingFileTotal -= uris.size - index
406+ return@forEachIndexed
397407 }
398408 when (val result = uri.toAttachmentResult(appContext)) {
399409 is AttachmentResult.Ok -> vm.addAttachment(result.attachment)
400410 AttachmentResult.TooLarge -> snackbar.showSnackbar(attachTooLargeMsg)
401411 AttachmentResult.Failed -> snackbar.showSnackbar(attachFailedMsg)
402412 }
413+ stagingFileTotal--
403414 }
404415 } finally {
405416 stagingCount--
@@ -637,6 +648,7 @@ fun ChatScreen(
637648 val moreLabel = stringResource(R.string.more)
638649 val loadingLabel = stringResource(R.string.loading)
639650 val shareLabel = stringResource(R.string.share_conversation)
651+ val copyAsMarkdownLabel = stringResource(R.string.copy_as_markdown)
640652 val commandsLabel = stringResource(R.string.commands)
641653 val renameLabel = stringResource(R.string.rename_session_chat)
642654 val deleteLabel = stringResource(R.string.delete_session_chat)
@@ -673,6 +685,21 @@ fun ChatScreen(
673685 }
674686 },
675687 )
688+ // Copy the whole conversation as Markdown to the clipboard, so a
689+ // user can paste into another app without the share-sheet round-trip.
690+ DropdownMenuItem(
691+ text = { Text(copyAsMarkdownLabel) },
692+ enabled = hasMessages,
693+ onClick = {
694+ showOverflowMenu = false
695+ scope.launch {
696+ val md = withContext(Dispatchers.Default) {
697+ buildConversationMarkdown(vm.messages.value, sessionTitle)
698+ }
699+ copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_message), md)
700+ }
701+ },
702+ )
676703 DropdownMenuItem(
677704 text = { Text(commandsLabel) },
678705 enabled = !commandsLoading || commands.isNotEmpty(),
@@ -811,6 +838,7 @@ fun ChatScreen(
811838 focusRequester = inputFocusRequester,
812839 attachments = attachments,
813840 staging = stagingCount > 0,
841+ stagingFileCount = stagingFileTotal,
814842 onRemoveAttachment = vm::removeAttachment,
815843 onPickPhoto = {
816844 photoPicker.launch(
@@ -1199,6 +1227,11 @@ fun ChatScreen(
11991227 (message.info as? soy.iko.opencode.data.model.AssistantMessage)
12001228 ?.let { resolveModelLabel(it, models) }
12011229 }
1230+ val agentLabel = remember(message.parts) {
1231+ message.parts
1232+ .filterIsInstance<soy.iko.opencode.data.model.AgentPart>()
1233+ .firstOrNull { it.name.isNotBlank() }?.name
1234+ }
12021235 // Text used to drive swipe-to-reply (and pre-fill the quote). Empty for
12031236 // image/code-only messages, which then opt out of the swipe gesture.
12041237 val quoteText = remember(message.parts) {
@@ -1244,6 +1277,7 @@ fun ChatScreen(
12441277 isRunning = running && message.info.id == lastMessageId,
12451278 imageContext = imageContext,
12461279 modelLabel = modelLabel,
1280+ agentLabel = agentLabel,
12471281 onOpenFile = onOpenFile,
12481282 onRevert = { vm.revertTo(message.info.id) },
12491283 onEdit = { text ->
@@ -1273,6 +1307,7 @@ fun ChatScreen(
12731307 ttsState = if (message.info.id == speakingMessageId) ttsState else TtsState.IDLE,
12741308 onPause = { tts.pause() },
12751309 onResume = { tts.resume() },
1310+ onStop = { tts.stop() },
12761311 onQuote = { text ->
12771312 vm.quoteReply(text)
12781313 runCatching { inputFocusRequester.requestFocus() }
@@ -1682,18 +1717,31 @@ fun ChatScreen(
16821717 }
16831718
16841719 if (showModelPicker) {
1720+ // Resolve persisted recent composite keys to the live ModelOption entries they refer
1721+ // to, dropping any whose model is no longer offered by the server. Ordered by
1722+ // recency (head of the store first).
1723+ val recentOptions = remember(models, recentModelEntries) {
1724+ val byKey = models.associateBy { it.providerID to it.modelID }
1725+ recentModelEntries.mapNotNull { entry ->
1726+ container.recentModelsStore.split(entry)?.let { (p, m) -> byKey[p to m] }
1727+ }
1728+ }
16851729 ModelPickerSheet(
16861730 options = models,
16871731 selected = selectedModel,
16881732 loading = modelsLoading,
16891733 error = modelsError,
1690− onSelect = { vm.selectModel(it) },
1734+ onSelect = {
1735+ vm.selectModel(it)
1736+ container.recentModelsStore.add(it.providerID, it.modelID)
1737+ },
16911738 onRetry = { vm.reloadModels() },
16921739 onDismiss = { showModelPicker = false },
16931740 preferredModelId = preferredModelId,
16941741 onSetPreferredModel = { id ->
16951742 scope.launch { runCatchingCancellable { container.settingsStore.setPreferredModelId(id) } }
16961743 },
1744+ recent = recentOptions,
16971745 )
16981746 }
16991747
@@ -1790,6 +1838,7 @@ private fun ChatInputBar(
17901838 focusRequester: androidx.compose.ui.focus.FocusRequester,
17911839 attachments: List<PendingAttachment>,
17921840 staging: Boolean,
1841+ stagingFileCount: Int,
17931842 onRemoveAttachment: (String) -> Unit,
17941843 onPickPhoto: () -> Unit,
17951844 onPickFile: () -> Unit,
@@ -1845,7 +1894,7 @@ private fun ChatInputBar(
18451894 }
18461895 }
18471896 // Staged attachments: horizontally-scrollable thumbnails/chips, each removable.
1848− AttachmentStrip(attachments, onRemove = onRemoveAttachment, staging = staging)
1897+ AttachmentStrip(attachments, onRemove = onRemoveAttachment, staging = staging, stagingFileCount = stagingFileCount)
18491898 Row(
18501899 modifier = Modifier.fillMaxWidth().padding(8.dp),
18511900 verticalAlignment = Alignment.Bottom,
@@ -2015,6 +2064,7 @@ private fun ChatInputBar(
20152064 attachments = attachments,
20162065 onRemoveAttachment = onRemoveAttachment,
20172066 staging = staging,
2067+ stagingFileCount = stagingFileCount,
20182068 )
20192069 }
20202070 }
@@ -2035,6 +2085,7 @@ private fun FullScreenEditor(
20352085 attachments: List<PendingAttachment> = emptyList(),
20362086 onRemoveAttachment: (String) -> Unit = {},
20372087 staging: Boolean = false,
2088+ stagingFileCount: Int = 0,
20382089) {
20392090 androidx.compose.ui.window.Dialog(
20402091 onDismissRequest = onDismiss,
@@ -2079,7 +2130,7 @@ private fun FullScreenEditor(
20792130 // Show staged attachments in the full-screen editor too, so a user who staged
20802131 // images then expanded for a long prompt can still see and remove them without
20812132 // collapsing first (mirrors the inline composer's AttachmentStrip).
2082− AttachmentStrip(attachments, onRemove = onRemoveAttachment, staging = staging)
2133+ AttachmentStrip(attachments, onRemove = onRemoveAttachment, staging = staging, stagingFileCount = stagingFileCount)
20832134 OutlinedTextField(
20842135 value = value,
20852136 onValueChange = { v -> onValueChange(v.take(NetworkConfig.maxDraftLengthChars)) },
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+65−27
@@ -29,6 +29,7 @@ import androidx.compose.material.icons.filled.Refresh
2929import androidx.compose.material.icons.filled.Restore
3030import androidx.compose.material.icons.filled.Share
3131import androidx.compose.material.icons.filled.SmartToy
32+import androidx.compose.material.icons.filled.Stop
3233import androidx.compose.material3.DropdownMenu
3334import androidx.compose.material3.DropdownMenuItem
3435import androidx.compose.material3.CircularProgressIndicator
@@ -99,6 +100,7 @@ fun MessageBubble(
99100 isRunning: Boolean = false,
100101 imageContext: ImageLoadContext? = null,
101102 modelLabel: String? = null,
103+ agentLabel: String? = null,
102104 onOpenFile: ((String) -> Unit)? = null,
103105 onRevert: (() -> Unit)? = null,
104106 onEdit: ((String) -> Unit)? = null,
@@ -107,6 +109,7 @@ fun MessageBubble(
107109 ttsState: TtsState = TtsState.IDLE,
108110 onPause: (() -> Unit)? = null,
109111 onResume: (() -> Unit)? = null,
112+ onStop: (() -> Unit)? = null,
110113 onQuote: ((String) -> Unit)? = null,
111114 onBranch: ((String) -> Unit)? = null,
112115 onRegenerate: (() -> Unit)? = null,
@@ -119,7 +122,7 @@ fun MessageBubble(
119122 when (message.info) {
120123 is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile, onRevert, onEdit, onQuote, onBranch, sendStatus, isEdited, onRetry, onDismiss, onShare)
121124 is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier, onOpenFile)
122− else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel, onOpenFile, onRevert, onSpeak, isSpeaking, ttsState, onPause, onResume, onQuote, onBranch, onRegenerate, isEdited, onShare)
125+ else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel, agentLabel, onOpenFile, onRevert, onSpeak, isSpeaking, ttsState, onPause, onResume, onStop, onQuote, onBranch, onRegenerate, isEdited, onShare)
123126 }
124127}
125128
@@ -612,6 +615,7 @@ private fun AssistantBlock(
612615 imageContext: ImageLoadContext?,
613616 modifier: Modifier,
614617 modelLabel: String? = null,
618+ agentLabel: String? = null,
615619 onOpenFile: ((String) -> Unit)? = null,
616620 onRevert: (() -> Unit)? = null,
617621 onSpeak: ((String) -> Unit)? = null,
@@ -619,6 +623,7 @@ private fun AssistantBlock(
619623 ttsState: TtsState = TtsState.IDLE,
620624 onPause: (() -> Unit)? = null,
621625 onResume: (() -> Unit)? = null,
626+ onStop: (() -> Unit)? = null,
622627 onQuote: ((String) -> Unit)? = null,
623628 onBranch: ((String) -> Unit)? = null,
624629 onRegenerate: (() -> Unit)? = null,
@@ -652,7 +657,7 @@ private fun AssistantBlock(
652657 // Avatar + model label row: the robot icon gives the assistant a consistent visual
653658 // identity (left-aligned, mirroring the user's right-aligned bubble) so on a long
654659 // scroll the speaker is unambiguous without reading the label.
655− AssistantHeader(label = modelLabel ?: info.modelID)
660+ AssistantHeader(label = modelLabel ?: info.modelID, agentLabel = agentLabel)
656661 }
657662 message.parts.forEachIndexed { index, part ->
658663 // Only the final part of a running message is actively streaming; earlier parts
@@ -747,6 +752,7 @@ private fun AssistantBlock(
747752 onSpeak = onSpeak,
748753 onPause = onPause,
749754 onResume = onResume,
755+ onStop = onStop,
750756 onRevert = onRevert,
751757 onQuote = onQuote,
752758 onBranch = onBranch,
@@ -837,9 +843,18 @@ private fun AssistantMessageLongPressMenu(
837843}
838844
839845/** Avatar + model label row for an assistant message. Extracted from [AssistantBlock] to keep
840− * that function's cyclomatic complexity under the detekt threshold. */
846+ * that function's cyclomatic complexity under the detekt threshold. When [agentLabel] is
847+ * non-null it is shown before the model label, separated by a middle dot
848+ * (e.g. "build · claude-sonnet-4-20250514"), matching the top-bar subtitle's format. */
841849@Composable
842−private fun AssistantHeader(label: String?) {
850+private fun AssistantHeader(label: String?, agentLabel: String?) {
851+ val combined = remember(label, agentLabel) {
852+ when {
853+ agentLabel != null && label != null -> "$agentLabel · $label"
854+ agentLabel != null -> agentLabel
855+ else -> label
856+ }
857+ }
843858 Row(verticalAlignment = Alignment.CenterVertically) {
844859 Box(
845860 modifier = Modifier
@@ -855,9 +870,9 @@ private fun AssistantHeader(label: String?) {
855870 tint = MaterialTheme.colorScheme.onSecondaryContainer,
856871 )
857872 }
858− if (label != null) {
873+ if (combined != null) {
859874 Text(
860− label,
875+ combined,
861876 style = MaterialTheme.typography.labelSmall,
862877 color = MaterialTheme.colorScheme.onSurfaceVariant,
863878 maxLines = 1,
@@ -880,6 +895,7 @@ private fun AssistantActions(
880895 onSpeak: ((String) -> Unit)?,
881896 onPause: (() -> Unit)?,
882897 onResume: (() -> Unit)?,
898+ onStop: (() -> Unit)? = null,
883899 onRevert: (() -> Unit)?,
884900 onQuote: ((String) -> Unit)? = null,
885901 onBranch: ((String) -> Unit)? = null,
@@ -917,6 +933,7 @@ private fun AssistantActions(
917933 onSpeak = onSpeak,
918934 onPause = onPause,
919935 onResume = onResume,
936+ onStop = onStop,
920937 )
921938 }
922939 onRevert?.let { RevertButton(it) }
@@ -940,9 +957,11 @@ private fun AssistantActions(
940957 }
941958}
942959
943−/** Read-aloud button with play/pause/resume states. Extracted from [AssistantActions] to keep
960+/** Read-aloud button with pause/resume/stop states. Extracted from [AssistantActions] to keep
944961 * that function's cyclomatic complexity under the detekt threshold. The icon reflects the
945− * next action: VolumeUp (play) when idle, Pause when playing, PlayArrow (resume) when paused. */
962+ * next action: VolumeUp (play) when idle, Pause when playing, PlayArrow (resume) when paused.
963+ * A separate Stop button appears when playing or paused so the user can fully stop playback
964+ * without tapping a different message. */
946965@Composable
947966private fun TtsButton(
948967 text: String,
@@ -951,6 +970,7 @@ private fun TtsButton(
951970 onSpeak: (String) -> Unit,
952971 onPause: (() -> Unit)?,
953972 onResume: (() -> Unit)?,
973+ onStop: (() -> Unit)? = null,
954974) {
955975 val haptics = LocalHapticFeedback.current
956976 val label = when {
@@ -958,27 +978,45 @@ private fun TtsButton(
958978 isSpeaking -> stringResource(R.string.pause_reading)
959979 else -> stringResource(R.string.read_aloud)
960980 }
961− IconButton(onClick = {
962− haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
963− when {
964− isSpeaking && ttsState == TtsState.PAUSED -> onResume?.invoke()
965− isSpeaking -> onPause?.invoke()
966− else -> onSpeak(text)
981+ Row(verticalAlignment = Alignment.CenterVertically) {
982+ IconButton(onClick = {
983+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
984+ when {
985+ isSpeaking && ttsState == TtsState.PAUSED -> onResume?.invoke()
986+ isSpeaking -> onPause?.invoke()
987+ else -> onSpeak(text)
988+ }
989+ }) {
990+ val icon = when {
991+ isSpeaking && ttsState == TtsState.PAUSED -> Icons.Filled.PlayArrow
992+ isSpeaking -> Icons.Filled.Pause
993+ else -> Icons.AutoMirrored.Filled.VolumeUp
994+ }
995+ val tint = if (isSpeaking) MaterialTheme.colorScheme.primary
996+ else MaterialTheme.colorScheme.onSurfaceVariant
997+ Icon(
998+ icon,
999+ contentDescription = label,
1000+ modifier = Modifier.size(18.dp),
1001+ tint = tint,
1002+ )
9671003 }
968− }) {
969− val icon = when {
970− isSpeaking && ttsState == TtsState.PAUSED -> Icons.Filled.PlayArrow
971− isSpeaking -> Icons.Filled.Pause
972− else -> Icons.AutoMirrored.Filled.VolumeUp
1004+ // Stop button: fully stops TTS playback (vs pause which keeps the position). Only
1005+ // shown while playing or paused, so idle messages don't have a dead stop button.
1006+ if (isSpeaking && onStop != null) {
1007+ val stopLabel = stringResource(R.string.stop_reading)
1008+ IconButton(onClick = {
1009+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
1010+ onStop()
1011+ }) {
1012+ Icon(
1013+ Icons.Filled.Stop,
1014+ contentDescription = stopLabel,
1015+ modifier = Modifier.size(18.dp),
1016+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
1017+ )
1018+ }
9731019 }
974− val tint = if (isSpeaking) MaterialTheme.colorScheme.primary
975− else MaterialTheme.colorScheme.onSurfaceVariant
976− Icon(
977− icon,
978− contentDescription = label,
979− modifier = Modifier.size(18.dp),
980− tint = tint,
981− )
9821020 }
9831021}
9841022
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+91−55
@@ -94,6 +94,7 @@ fun ModelPickerSheet(
9494 onDismiss: () -> Unit,
9595 preferredModelId: String = "",
9696 onSetPreferredModel: (String) -> Unit = {},
97+ recent: List<ModelOption> = emptyList(),
9798) {
9899 // Hoist SheetState + skipPartiallyExpanded so the sheet opens full-height and selection
99100 // animates out (see AgentPickerSheet for the full rationale).
@@ -200,6 +201,17 @@ fun ModelPickerSheet(
200201 LaunchedEffect(grouped) {
201202 if (selectedIndex >= 0) listState.scrollToItem(selectedIndex)
202203 }
204+ // Recents are only surfaced when the user hasn't filtered: a search query
205+ // already scopes the list, so a duplicate "Recent" section would be noise.
206+ // Entries are also pruned to models still present in the catalog (a recent
207+ // pick may have been removed server-side) and ordered by recency.
208+ val recentShown = remember(filtered, recent, query) {
209+ if (query.isNotBlank()) emptyList()
210+ else {
211+ val available = filtered.associateBy { it.providerID to it.modelID }
212+ recent.mapNotNull { available[it.providerID to it.modelID] }
213+ }
214+ }
203215 LazyColumn(
204216 state = listState,
205217 modifier = Modifier
@@ -208,6 +220,23 @@ fun ModelPickerSheet(
208220 .navigationBarsPadding()
209221 .semantics { selectableGroup() },
210222 ) {
223+ if (recentShown.isNotEmpty()) {
224+ item(key = "header_recent") {
225+ Text(
226+ stringResource(R.string.recent_models),
227+ style = MaterialTheme.typography.labelMedium,
228+ color = MaterialTheme.colorScheme.primary,
229+ modifier = Modifier.padding(start = 24.dp, top = 12.dp, bottom = 4.dp),
230+ )
231+ }
232+ items(recentShown, key = { "recent_${it.providerID}_${it.modelID}" }) { option ->
233+ ModelRow(option, selected) {
234+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
235+ scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss() }
236+ onSelect(option)
237+ }
238+ }
239+ }
211240 grouped.forEach { (providerID, opts) ->
212241 item(key = "header_$providerID") {
213242 Text(
@@ -220,75 +249,82 @@ fun ModelPickerSheet(
220249 // opts is already distinct (filtered derives from the distinct list),
221250 // so no per-row distinctBy is needed here.
222251 items(opts, key = { it.providerID to it.modelID }) { option ->
223− val isSelected = option.providerID == selected?.providerID &&
224− option.modelID == selected?.modelID
225− Column(
226− modifier = Modifier
227− .fillMaxWidth()
228− .semantics(mergeDescendants = true) {
229− this.selected = isSelected
230− }
231− .clickable(role = Role.RadioButton) {
252+ ModelRow(option, selected) {
232253 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
233254 scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss() }
234255 onSelect(option)
235256 }
236− .padding(horizontal = 24.dp, vertical = 12.dp),
237− ) {
238− Row(verticalAlignment = Alignment.CenterVertically) {
239− Text(
240− option.modelLabel,
241− style = MaterialTheme.typography.bodyLarge,
242− color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
243− modifier = Modifier.weight(1f),
244− )
245− if (isSelected) {
246− Icon(
247− Icons.Filled.Check,
248− contentDescription = null,
249− tint = MaterialTheme.colorScheme.primary,
250− modifier = Modifier.size(20.dp),
251− )
252− }
253257 }
254258 }
255259 }
256− }
257− }
258− // Footer: a "set as default" toggle for the currently-selected model, so a user
259− // who always picks a specific model doesn't have to pick it every new session.
260− // Only shown when there's a selection; tapping clears the preference if it's
261− // already the default (toggle behavior).
262− if (selected != null) {
263− val isDefault = preferredModelId == selected.modelID
264− Row(
265− modifier = Modifier
266− .fillMaxWidth()
267− .padding(horizontal = 24.dp, vertical = 8.dp)
268− .navigationBarsPadding(),
269− verticalAlignment = Alignment.CenterVertically,
270− ) {
271− Text(
272− stringResource(
273− if (isDefault) R.string.default_model_current else R.string.set_as_default_model,
274− selected.modelLabel,
275− ),
276− style = MaterialTheme.typography.bodySmall,
277− color = MaterialTheme.colorScheme.onSurfaceVariant,
278− modifier = Modifier.weight(1f),
279− )
280− TextButton(
281− onClick = {
282− onSetPreferredModel(if (isDefault) "" else selected.modelID)
283− },
260+ // Footer: a "set as default" toggle for the currently-selected model, so a user
261+ // who always picks a specific model doesn't have to pick it every new session.
262+ // Only shown when there's a selection; tapping clears the preference if it's
263+ // already the default (toggle behavior).
264+ if (selected != null) {
265+ val isDefault = preferredModelId == selected.modelID
266+ Row(
267+ modifier = Modifier
268+ .fillMaxWidth()
269+ .padding(horizontal = 24.dp, vertical = 8.dp)
270+ .navigationBarsPadding(),
271+ verticalAlignment = Alignment.CenterVertically,
284272 ) {
285273 Text(
286− stringResource(if (isDefault) R.string.clear_default else R.string.set_as_default),
274+ stringResource(
275+ if (isDefault) R.string.default_model_current else R.string.set_as_default_model,
276+ selected.modelLabel,
277+ ),
278+ style = MaterialTheme.typography.bodySmall,
279+ color = MaterialTheme.colorScheme.onSurfaceVariant,
280+ modifier = Modifier.weight(1f),
287281 )
282+ TextButton(
283+ onClick = {
284+ onSetPreferredModel(if (isDefault) "" else selected.modelID)
285+ },
286+ ) {
287+ Text(
288+ stringResource(if (isDefault) R.string.clear_default else R.string.set_as_default),
289+ )
290+ }
288291 }
289292 }
290293 }
291294 }
295+ }
296+}
297+
298+/** A single selectable model row in the picker. Shared by the "Recent" section and the
299+ * provider-grouped list so the two stay visually identical. */
300+@Composable
301+private fun ModelRow(option: ModelOption, selected: ModelOption?, onClick: () -> Unit) {
302+ val isSelected = option.providerID == selected?.providerID &&
303+ option.modelID == selected?.modelID
304+ Column(
305+ modifier = Modifier
306+ .fillMaxWidth()
307+ .semantics(mergeDescendants = true) {
308+ this.selected = isSelected
309+ }
310+ .clickable(role = Role.RadioButton, onClick = onClick)
311+ .padding(horizontal = 24.dp, vertical = 12.dp),
312+ ) {
313+ Row(verticalAlignment = Alignment.CenterVertically) {
314+ Text(
315+ option.modelLabel,
316+ style = MaterialTheme.typography.bodyLarge,
317+ color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
318+ modifier = Modifier.weight(1f),
319+ )
320+ if (isSelected) {
321+ Icon(
322+ Icons.Filled.Check,
323+ contentDescription = null,
324+ tint = MaterialTheme.colorScheme.primary,
325+ modifier = Modifier.size(20.dp),
326+ )
327+ }
292328 }
293329 }
294330}
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+2
@@ -54,6 +54,7 @@ import androidx.compose.ui.res.stringResource
5454import androidx.compose.ui.unit.dp
5555import kotlinx.serialization.json.Json
5656import kotlinx.serialization.json.JsonElement
57+import soy.iko.opencode.data.model.AgentPart
5758import soy.iko.opencode.data.model.FilePart
5859import soy.iko.opencode.data.model.Part
5960import soy.iko.opencode.data.model.ReasoningPart
@@ -181,6 +182,7 @@ fun PartView(
181182 }
182183 is StepStartPart -> {} // boundary marker — nothing to draw
183184 is StepFinishPart -> {} // metrics handled at message level
185+ is AgentPart -> {} // agent name is surfaced in the assistant header, not the body
184186 is UnknownPart -> UnknownPartNote(modifier)
185187 }
186188}
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+17−1
@@ -79,6 +79,9 @@ fun PermissionDialog(
7979 // indefinitely (the foreground service holds the process alive). LaunchedEffect keyed on
8080 // permission.id so a fresh prompt resets the timer. Disabled when the timeout is 0.
8181 val autoRejectMs = NetworkConfig.permissionAutoRejectMs
82+ // Final-warning threshold: show a per-second countdown in the last 10s so the user isn't
83+ // surprised by a silent auto-reject.
84+ val warningThresholdMs = (autoRejectMs - 10_000L).coerceAtLeast(0L)
8285 LaunchedEffect(permission.id, autoRejectMs) {
8386 if (autoRejectMs <= 0L) return@LaunchedEffect
8487 val tickMs = 1000L
@@ -86,9 +89,13 @@ fun PermissionDialog(
8689 delay(tickMs)
8790 elapsedMs = (elapsedMs + tickMs).coerceAtMost(autoRejectMs)
8891 }
92+ // Haptic on auto-reject so a walked-away user is signaled that the decision was made.
93+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
8994 respond(PermissionResponse.REJECT)
9095 }
9196 val showReminder = elapsedMs >= NetworkConfig.permissionReminderThresholdMs
97+ // In the final 10 seconds, show a per-second countdown instead of the minute reminder.
98+ val showSecondsWarning = autoRejectMs > 0L && elapsedMs >= warningThresholdMs
9299 AlertDialog(
93100 // Back press routes here (dismissOnBackPress defaults true) and is treated as an
94101 // explicit reject — the safe default. Tap-outside is disabled below so it can't
@@ -143,7 +150,16 @@ fun PermissionDialog(
143150 )
144151 // After the reminder threshold, surface that the run is waiting so the user
145152 // understands their inaction is holding up the agent. A nudge, not a force.
146− if (showReminder && autoRejectMs > 0L) {
153+ // In the final 10 seconds, switch to a per-second countdown so the imminent
154+ // auto-reject isn't a surprise.
155+ if (showSecondsWarning) {
156+ val remainingSec = ((autoRejectMs - elapsedMs) / 1000L).coerceAtLeast(0L)
157+ Text(
158+ stringResource(R.string.permission_warning_seconds, remainingSec),
159+ style = MaterialTheme.typography.labelSmall,
160+ color = MaterialTheme.colorScheme.error,
161+ )
162+ } else if (showReminder && autoRejectMs > 0L) {
147163 val remainingMin = ((autoRejectMs - elapsedMs) / 60_000L).coerceAtLeast(0L)
148164 Text(
149165 stringResource(R.string.permission_waiting, remainingMin),
app/src/main/java/soy/iko/opencode/ui/components/AppTopBar.kt+2−4
@@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.RowScope
66import androidx.compose.foundation.layout.size
77import androidx.compose.material.icons.Icons
88import androidx.compose.material.icons.automirrored.filled.ArrowBack
9−import androidx.compose.material3.CircularProgressIndicator
109import androidx.compose.material3.ExperimentalMaterial3Api
1110import androidx.compose.material3.Icon
1211import androidx.compose.material3.IconButton
@@ -69,9 +68,8 @@ fun AppTopBar(
6968 actions = {
7069 if (loading) {
7170 Box(modifier = Modifier.size(40.dp), contentAlignment = Alignment.Center) {
72− CircularProgressIndicator(
73− Modifier.size(20.dp),
74− strokeWidth = 2.dp,
71+ LoadingSpinner(
72+ size = LoadingSize.Small,
7573 color = MaterialTheme.colorScheme.onSurfaceVariant,
7674 )
7775 }
app/src/main/java/soy/iko/opencode/ui/components/CommandPalette.kt+2−3
@@ -7,7 +7,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
77import androidx.compose.foundation.layout.heightIn
88import androidx.compose.foundation.layout.padding
99import androidx.compose.foundation.lazy.LazyColumn
10−import androidx.compose.foundation.lazy.items
10+import androidx.compose.foundation.lazy.itemsIndexed
1111import androidx.compose.foundation.lazy.rememberLazyListState
1212import androidx.compose.foundation.text.KeyboardActions
1313import androidx.compose.foundation.text.KeyboardOptions
@@ -121,8 +121,7 @@ fun CommandPalette(
121121 )
122122 } else {
123123 LazyColumn(state = listState, modifier = Modifier.heightIn(max = 360.dp)) {
124− items(filtered, key = { it.id }) { action ->
125− val index = filtered.indexOf(action)
124+ itemsIndexed(filtered, key = { _, action -> action.id }) { index, action ->
126125 val isSelected = index == selectedIndex
127126 Text(
128127 action.label,
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+24−8
@@ -1,8 +1,6 @@
11package soy.iko.opencode.ui.components
22
33import androidx.compose.animation.AnimatedVisibility
4−import androidx.compose.animation.fadeIn
5−import androidx.compose.animation.fadeOut
64import androidx.compose.foundation.layout.Arrangement
75import androidx.compose.foundation.layout.BoxScope
86import androidx.compose.foundation.layout.Row
@@ -10,7 +8,9 @@ import androidx.compose.foundation.layout.defaultMinSize
108import androidx.compose.foundation.layout.fillMaxWidth
119import androidx.compose.foundation.layout.padding
1210import androidx.compose.foundation.layout.size
13−import androidx.compose.material3.CircularProgressIndicator
11+import androidx.compose.material.icons.Icons
12+import androidx.compose.material.icons.filled.CloudOff
13+import androidx.compose.material3.Icon
1414import androidx.compose.material3.MaterialTheme
1515import androidx.compose.material3.Surface
1616import androidx.compose.material3.Text
@@ -28,6 +28,7 @@ import androidx.compose.ui.semantics.contentDescription
2828import androidx.compose.ui.semantics.liveRegion
2929import androidx.compose.ui.semantics.LiveRegionMode
3030import androidx.compose.ui.semantics.semantics
31+import androidx.compose.ui.text.style.TextOverflow
3132import androidx.compose.ui.unit.dp
3233import androidx.lifecycle.compose.collectAsStateWithLifecycle
3334import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -80,7 +81,11 @@ fun ConnectionBanner(
8081 // composition is a documented anti-pattern that can trigger extra recompositions and isn't
8182 // guaranteed a defined ordering. SideEffect runs only after a successful composition.
8283 SideEffect { if (text != null) lastText.value = text }
83− AnimatedVisibility(visible = text != null, enter = fadeIn(), exit = fadeOut()) {
84+ AnimatedVisibility(
85+ visible = text != null,
86+ enter = rememberVisibilityTransitions().enter,
87+ exit = rememberVisibilityTransitions().exit,
88+ ) {
8489 val shown = lastText.value ?: return@AnimatedVisibility
8590 // Distinguish a hard failure (e.g. bad credentials) and an offline device from
8691 // transient connecting/reconnecting states by switching to the error palette,
@@ -115,16 +120,27 @@ fun ConnectionBanner(
115120 horizontalArrangement = Arrangement.Center,
116121 verticalAlignment = Alignment.CenterVertically,
117122 ) {
118− if (!isFailed) {
119− CircularProgressIndicator(
120− Modifier.size(16.dp),
121− strokeWidth = 2.dp,
123+ if (isFailed) {
124+ // An error icon reinforces the failure state visually, matching the
125+ // session list's error state which uses ErrorOutline.
126+ Icon(
127+ Icons.Filled.CloudOff,
128+ contentDescription = null,
129+ modifier = Modifier.size(16.dp),
130+ tint = onContainer,
131+ )
132+ } else {
133+ LoadingSpinner(
134+ size = LoadingSize.Inline,
135+ color = onContainer,
122136 )
123137 }
124138 Text(
125139 shown,
126140 style = MaterialTheme.typography.labelMedium,
127141 color = onContainer,
142+ maxLines = 2,
143+ overflow = TextOverflow.Ellipsis,
128144 modifier = Modifier
129145 .padding(start = 6.dp)
130146 .weight(1f, fill = false),
app/src/main/java/soy/iko/opencode/ui/components/LargeScreenNavRail.kt+36−10
@@ -9,6 +9,8 @@ import androidx.compose.material.icons.filled.Hub
99import androidx.compose.material.icons.filled.QueryStats
1010import androidx.compose.material.icons.filled.Search
1111import androidx.compose.material.icons.filled.Settings
12+import androidx.compose.material3.Badge
13+import androidx.compose.material3.BadgedBox
1214import androidx.compose.material3.Icon
1315import androidx.compose.material3.NavigationRail
1416import androidx.compose.material3.NavigationRailItem
@@ -19,6 +21,7 @@ import androidx.compose.ui.Modifier
1921import androidx.compose.ui.graphics.vector.ImageVector
2022import androidx.compose.ui.res.stringResource
2123import soy.iko.opencode.R
24+import soy.iko.opencode.ui.Routes
2225
2326/** A top-level destination surfaced on the large-screen navigation rail. */
2427private data class RailDest(
@@ -30,13 +33,13 @@ private data class RailDest(
3033)
3134
3235private val RAIL_DESTINATIONS = listOf(
33− RailDest("sessions", R.string.sessions_title, Icons.AutoMirrored.Filled.Chat, true),
34− RailDest("search", R.string.search_all_title, Icons.Filled.Search, true),
35− RailDest("files", R.string.files, Icons.Filled.Folder, true),
36− RailDest("usage", R.string.usage_title, Icons.Filled.QueryStats, true),
37− RailDest("mcp", R.string.mcp_servers, Icons.Filled.Hub, true),
38− RailDest("settings", R.string.settings, Icons.Filled.Settings, false),
39− RailDest("servers", R.string.servers_title, Icons.Filled.Dns, false),
36+ RailDest(Routes.SESSIONS, R.string.sessions_title, Icons.AutoMirrored.Filled.Chat, true),
37+ RailDest(Routes.SEARCH, R.string.search_all_title, Icons.Filled.Search, true),
38+ RailDest(Routes.FILES, R.string.files, Icons.Filled.Folder, true),
39+ RailDest(Routes.USAGE, R.string.usage_title, Icons.Filled.QueryStats, true),
40+ RailDest(Routes.MCP, R.string.mcp_servers, Icons.Filled.Hub, true),
41+ RailDest(Routes.SETTINGS, R.string.settings, Icons.Filled.Settings, false),
42+ RailDest(Routes.SERVERS, R.string.servers_title, Icons.Filled.Dns, false),
4043)
4144
4245/**
@@ -46,6 +49,8 @@ private val RAIL_DESTINATIONS = listOf(
4649 * @param currentRoute the current NavHost destination route, used to highlight the active item.
4750 * @param connected whether a server connection is active; connection-dependent items (sessions,
4851 * files, …) are disabled until one exists.
52+ * @param unreadCount total unread messages across sessions, shown as a badge on the Sessions item.
53+ * @param runActive whether any agent run is active, shown as a dot badge on the Sessions item.
4954 * @param onNavigate invoked with the destination's route when an item is selected.
5055 */
5156@Composable
@@ -54,6 +59,8 @@ fun LargeScreenNavRail(
5459 connected: Boolean,
5560 onNavigate: (String) -> Unit,
5661 modifier: Modifier = Modifier,
62+ unreadCount: Int = 0,
63+ runActive: Boolean = false,
5764) {
5865 NavigationRail(modifier = modifier.fillMaxHeight()) {
5966 RAIL_DESTINATIONS.forEach { dest ->
@@ -62,13 +69,32 @@ fun LargeScreenNavRail(
6269 // file_view?path=… is a detail of `files`. Without this, drilling into a file
6370 // loses the FILES highlight and the user loses their place in the rail.
6471 val selected = currentRoute == dest.route ||
65− (dest.route == "sessions" && currentRoute != null && currentRoute.startsWith("chat/")) ||
66− (dest.route == "files" && currentRoute != null && currentRoute.startsWith("file_view"))
72+ (dest.route == Routes.SESSIONS && currentRoute != null && currentRoute.startsWith("${Routes.CHAT}/")) ||
73+ (dest.route == Routes.FILES && currentRoute != null && currentRoute.startsWith(Routes.FILE_VIEW))
74+ // Badge the Sessions item with unread count and/or a running dot, so a user on
75+ // Files/Settings can see at a glance that sessions need attention or a run is
76+ // active — mirroring the launcher badge but in-app.
77+ val showUnreadBadge = dest.route == Routes.SESSIONS && unreadCount > 0
78+ val showRunBadge = dest.route == Routes.SESSIONS && runActive
6779 NavigationRailItem(
6880 selected = selected,
6981 enabled = !dest.requiresConnection || connected,
7082 onClick = { onNavigate(dest.route) },
71− icon = { Icon(dest.icon, contentDescription = null) },
83+ icon = {
84+ if (showUnreadBadge || showRunBadge) {
85+ BadgedBox(
86+ badge = {
87+ if (showUnreadBadge) {
88+ Badge { Text(unreadCount.coerceAtMost(99).toString()) }
89+ } else if (showRunBadge) {
90+ Badge()
91+ }
92+ },
93+ ) { Icon(dest.icon, contentDescription = null) }
94+ } else {
95+ Icon(dest.icon, contentDescription = null)
96+ }
97+ },
7298 label = { Text(stringResource(dest.labelRes)) },
7399 colors = NavigationRailItemDefaults.colors(),
74100 )
app/src/main/java/soy/iko/opencode/ui/components/LoadingSpinner.kt+9
@@ -2,8 +2,10 @@ package soy.iko.opencode.ui.components
22
33import androidx.compose.foundation.layout.size
44import androidx.compose.material3.CircularProgressIndicator
5+import androidx.compose.material3.MaterialTheme
56import androidx.compose.runtime.Composable
67import androidx.compose.ui.Modifier
8+import androidx.compose.ui.graphics.Color
79import androidx.compose.ui.semantics.contentDescription
810import androidx.compose.ui.semantics.semantics
911import androidx.compose.ui.unit.dp
@@ -20,6 +22,7 @@ import soy.iko.opencode.R
2022fun LoadingSpinner(
2123 size: LoadingSize = LoadingSize.Medium,
2224 modifier: Modifier = Modifier,
25+ color: Color = CircularProgressIndicatorDefaults.color,
2326) {
2427 val label = stringResource(R.string.loading)
2528 CircularProgressIndicator(
@@ -27,5 +30,11 @@ fun LoadingSpinner(
2730 .size(size.sizeDp.dp)
2831 .semantics { contentDescription = label },
2932 strokeWidth = size.strokeDp.dp,
33+ color = color,
3034 )
3135}
36+
37+private object CircularProgressIndicatorDefaults {
38+ val color: Color
39+ @Composable get() = MaterialTheme.colorScheme.primary
40+}
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+3−1
@@ -57,6 +57,7 @@ import androidx.compose.ui.platform.UriHandler
5757import androidx.compose.ui.res.pluralStringResource
5858import androidx.compose.ui.res.stringResource
5959import androidx.compose.ui.semantics.LiveRegionMode
60+import androidx.compose.ui.semantics.invisibleToUser
6061import androidx.compose.ui.semantics.liveRegion
6162import androidx.compose.ui.semantics.semantics
6263import androidx.compose.ui.text.AnnotatedString
@@ -260,7 +261,7 @@ private fun MarkdownBody(
260261 SelectionContainer {
261262 Markdown(
262263 content = markdown,
263− modifier = modifier,
264+ modifier = modifier.semantics { liveRegion = LiveRegionMode.Polite },
264265 components = components,
265266 )
266267 }
@@ -603,6 +604,7 @@ private fun StreamingCaret() {
603604 "▋",
604605 style = MaterialTheme.typography.bodyLarge,
605606 color = MaterialTheme.colorScheme.primary.copy(alpha = alpha),
607+ modifier = Modifier.semantics { invisibleToUser() },
606608 )
607609}
608610
app/src/main/java/soy/iko/opencode/ui/components/RelativeTimeUnit.ktnew file+41
@@ -0,0 +1,41 @@
1+package soy.iko.opencode.ui.components
2+
3+import java.util.concurrent.TimeUnit
4+
5+/**
6+ * The time unit + count resolved from a delta, before locale-specific formatting. Kept as a
7+ * pure value so unit tests can verify the boundary logic without a [android.content.Context]
8+ * (string-resource resolution is Android-only and stubs to null under `isReturnDefaultValues`).
9+ */
10+sealed class RelativeTimeUnit {
11+ data class Now(val count: Int = 0) : RelativeTimeUnit()
12+ data class Minutes(val count: Long) : RelativeTimeUnit()
13+ data class Hours(val count: Long) : RelativeTimeUnit()
14+ data class Days(val count: Long) : RelativeTimeUnit()
15+ data class Weeks(val count: Long) : RelativeTimeUnit()
16+ data class Months(val count: Long) : RelativeTimeUnit()
17+ data class Years(val count: Long) : RelativeTimeUnit()
18+}
19+
20+/**
21+ * Resolves the [RelativeTimeUnit] for a delta without any locale-specific formatting, so the
22+ * boundary logic is unit-testable on the JVM without a [android.content.Context].
23+ */
24+fun relativeTimeUnit(epochMillis: Long?): RelativeTimeUnit? {
25+ if (epochMillis == null || epochMillis <= 0) return null
26+ val now = System.currentTimeMillis()
27+ val diff = now - epochMillis
28+ if (diff < 0) return RelativeTimeUnit.Now()
29+ val minutes = TimeUnit.MILLISECONDS.toMinutes(diff)
30+ if (minutes < 1) return RelativeTimeUnit.Now()
31+ if (minutes < 60) return RelativeTimeUnit.Minutes(minutes)
32+ val hours = TimeUnit.MILLISECONDS.toHours(diff)
33+ if (hours < 24) return RelativeTimeUnit.Hours(hours)
34+ val days = TimeUnit.MILLISECONDS.toDays(diff)
35+ if (days < 7) return RelativeTimeUnit.Days(days)
36+ val weeks = days / 7
37+ if (weeks < 5) return RelativeTimeUnit.Weeks(weeks)
38+ if (days < 365) return RelativeTimeUnit.Months(days / 30)
39+ val years = days / 365
40+ return RelativeTimeUnit.Years(years)
41+}
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+2−22
@@ -61,6 +61,7 @@ import soy.iko.opencode.R
6161import soy.iko.opencode.data.model.FilePart
6262import soy.iko.opencode.data.model.ServerProfile
6363import soy.iko.opencode.data.model.sourcePath
64+import soy.iko.opencode.data.network.CertPinUtil
6465import soy.iko.opencode.data.network.HttpClientFactory
6566import soy.iko.opencode.data.network.NetworkConfig
6667import soy.iko.opencode.util.runCatchingCancellable
@@ -87,30 +88,9 @@ fun ServerProfile.toImageContext(): ImageLoadContext {
8788 // requireHttps (or certificate-pinned) profile never fetches an image — with the Basic auth
8889 // header attached — over cleartext http even when the stored URL is http://. This mirrors
8990 // how HttpClientFactory computes the effective base URL for the REST/SSE channel.
90− return ImageLoadContext(baseUrl = effectiveImageBaseUrl(), basicAuthHeader = auth)
91+ return ImageLoadContext(baseUrl = CertPinUtil.effectiveBaseUrl(this), basicAuthHeader = auth)
9192}
9293
93−/**
94− * The base URL used to resolve image URLs, after applying the same http->https upgrade the
95− * REST/SSE client uses (see HttpClientFactory.effectiveBaseUrl, the source of truth — that
96− * helper and its pin parser are private there and this file may not edit HttpClientFactory, so
97− * the logic is replicated here and must be kept in sync). A pin is meaningless over cleartext, so
98− * a configured certificate pin forces HTTPS too, matching the REST channel.
99− */
100−private fun ServerProfile.effectiveImageBaseUrl(): String {
101− val normalized = HttpClientFactory.normalizeBaseUrl(baseUrl)
102− val forceHttps = requireHttps || parseCertPins(certPin).isNotEmpty()
103− return if (forceHttps && normalized.lowercase().startsWith("http://")) {
104− "https://" + normalized.substring("http://".length)
105− } else {
106− normalized
107− }
108−}
109−
110−/** Split a certificate-pin field into individual pins; mirrors HttpClientFactory.parsePins. */
111−private fun parseCertPins(raw: String?): List<String> =
112− raw?.split(Regex("[\\s,]+"))?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList()
113−
11494/** True if the part references a raster/vector image we can render. */
11595val FilePart.isImage: Boolean
11696 get() = mime?.startsWith("image/") == true
app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.kt+20−26
@@ -19,12 +19,13 @@ import androidx.compose.runtime.setValue
1919import androidx.compose.ui.Modifier
2020import androidx.compose.ui.graphics.Color
2121import androidx.compose.ui.platform.LocalContext
22+import androidx.compose.ui.res.stringResource
2223import androidx.compose.ui.text.TextStyle
2324import androidx.lifecycle.Lifecycle
2425import androidx.lifecycle.compose.LocalLifecycleOwner
2526import androidx.lifecycle.repeatOnLifecycle
2627import kotlinx.coroutines.delay
27−import java.util.concurrent.TimeUnit
28+import soy.iko.opencode.R
2829
2930/**
3031 * Formats an epoch-millis timestamp as a short, compact relative string (e.g. "3m", "2h",
@@ -36,30 +37,21 @@ import java.util.concurrent.TimeUnit
3637 * but that returns a full phrase like "5 min. ago" / "2 hr. ago" — not the compact form the
3738 * examples and the numeric fallback imply — which wraps or crowds those rows.) Always returns a
3839 * non-empty string for a valid timestamp.
40+ *
41+ * The unit suffixes are resolved from string resources so they follow the device locale
42+ * (e.g. "3m" in English, "3 min" in Spanish) instead of a hardcoded English letter.
3943 */
40−fun relativeTime(epochMillis: Long?): String {
41− if (epochMillis == null || epochMillis <= 0) return ""
42− val now = System.currentTimeMillis()
43− val diff = now - epochMillis
44− if (diff < 0) return "now"
45− val minutes = TimeUnit.MILLISECONDS.toMinutes(diff)
46− if (minutes < 1) return "now"
47− if (minutes < 60) return "${minutes}m"
48− val hours = TimeUnit.MILLISECONDS.toHours(diff)
49− if (hours < 24) return "${hours}h"
50− val days = TimeUnit.MILLISECONDS.toDays(diff)
51− if (days < 7) return "${days}d"
52− // DateUtils doesn't offer a short week/month/year abbreviation; use a compact
53− // numeric form. The suffix is locale-stable (English letter) but these are
54− // rare timestamps (older than a week) and the numeric value is the primary signal.
55− val weeks = days / 7
56− if (weeks < 5) return "${weeks}w"
57− // Switch to years only past a full 365 days. Gating on `months < 12` (with months =
58− // days/30) instead misfires for 360–364 days: months == 12 fails the check and falls
59− // through to years, rendering "1y" for a span that's under a year (~11.9 months).
60− if (days < 365) return "${days / 30}mo"
61− val years = days / 365
62− return "${years}y"
44+fun relativeTime(context: Context, epochMillis: Long?): String {
45+ return when (val unit = relativeTimeUnit(epochMillis)) {
46+ null -> ""
47+ is RelativeTimeUnit.Now -> context.getString(R.string.time_now)
48+ is RelativeTimeUnit.Minutes -> context.getString(R.string.time_minutes_abbrev, unit.count)
49+ is RelativeTimeUnit.Hours -> context.getString(R.string.time_hours_abbrev, unit.count)
50+ is RelativeTimeUnit.Days -> context.getString(R.string.time_days_abbrev, unit.count)
51+ is RelativeTimeUnit.Weeks -> context.getString(R.string.time_weeks_abbrev, unit.count)
52+ is RelativeTimeUnit.Months -> context.getString(R.string.time_months_abbrev, unit.count)
53+ is RelativeTimeUnit.Years -> context.getString(R.string.time_years_abbrev, unit.count)
54+ }
6355}
6456
6557/** Re-evaluation interval for relative-time labels — coarse enough for battery, fine
@@ -115,10 +107,11 @@ fun rememberRelativeTimeTick(intervalMs: Long = RELATIVE_TIME_INTERVAL_MS): Long
115107@Composable
116108fun rememberRelativeTime(epochMillis: Long?): String {
117109 val sharedTick = LocalRelativeTimeTick.current
110+ val context = LocalContext.current
118111 return if (sharedTick != 0L) {
119112 // Reading sharedTick above registers a recomposition dependency on the shared
120113 // ticker, so the label recomputes (cheaply) whenever the screen-level tick advances.
121− relativeTime(epochMillis)
114+ relativeTime(context, epochMillis)
122115 } else {
123116 rememberRelativeTimeStandalone(epochMillis)
124117 }
@@ -129,6 +122,7 @@ fun rememberRelativeTime(epochMillis: Long?): String {
129122private fun rememberRelativeTimeStandalone(epochMillis: Long?): String {
130123 var tick by remember { mutableLongStateOf(0L) }
131124 val lifecycleOwner = LocalLifecycleOwner.current
125+ val context = LocalContext.current
132126
133127 androidx.compose.runtime.LaunchedEffect(epochMillis, lifecycleOwner) {
134128 if (epochMillis == null) return@LaunchedEffect
@@ -150,7 +144,7 @@ private fun rememberRelativeTimeStandalone(epochMillis: Long?): String {
150144 derivedStateOf {
151145 // Read tick to create a recomposition dependency on the timer.
152146 tick
153− relativeTime(epochMillis)
147+ relativeTime(context, epochMillis)
154148 }
155149 }
156150 return formatted
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+2−1
@@ -702,6 +702,7 @@ private fun FileRow(
702702 onCopyPath: (() -> Unit)? = null,
703703 modifier: Modifier = Modifier,
704704) {
705+ val context = LocalContext.current
705706 val copyPathLabel = stringResource(R.string.copy_path)
706707 // Haptic on row open for consistency with the breadcrumb taps and other screens.
707708 val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
@@ -765,7 +766,7 @@ private fun FileRow(
765766 if (sublabel != null) add(sublabel)
766767 if (!icon) {
767768 size?.let { add(formatFileSize(it)) }
768− mtime?.let { add(relativeTime(it)) }
769+ mtime?.let { add(relativeTime(context, it)) }
769770 }
770771 }
771772 if (detailParts.isNotEmpty()) {
app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.kt+71−3
@@ -21,10 +21,13 @@ import androidx.compose.material.icons.filled.ErrorOutline
2121import androidx.compose.material.icons.filled.CheckCircle
2222import androidx.compose.material.icons.filled.Error
2323import androidx.compose.material.icons.filled.Hub
24+import androidx.compose.material.icons.filled.MoreVert
2425import androidx.compose.material.icons.filled.Refresh
2526import androidx.compose.material3.AlertDialog
2627import androidx.compose.material3.Card
2728import androidx.compose.material3.CircularProgressIndicator
29+import androidx.compose.material3.DropdownMenu
30+import androidx.compose.material3.DropdownMenuItem
2831import androidx.compose.material3.ExperimentalMaterial3Api
2932import androidx.compose.material3.FilterChip
3033import androidx.compose.material3.FloatingActionButton
@@ -60,6 +63,7 @@ import soy.iko.opencode.di.AppContainer
6063import soy.iko.opencode.ui.components.AppTopBar
6164import soy.iko.opencode.ui.components.ConnectionBannerFor
6265import soy.iko.opencode.ui.components.EmptyState
66+import soy.iko.opencode.ui.components.reducedMotionAnimateItem
6367import soy.iko.opencode.ui.vmFactory
6468
6569@OptIn(ExperimentalMaterial3Api::class)
@@ -148,6 +152,9 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
148152 icon = Icons.Filled.Hub,
149153 title = stringResource(R.string.mcp_empty),
150154 modifier = Modifier.align(Alignment.Center),
155+ actionIcon = Icons.Filled.Add,
156+ actionLabel = stringResource(R.string.mcp_add_server),
157+ onAction = { showAddDialog = true },
151158 )
152159 } else {
153160 LazyColumn(
@@ -157,7 +164,9 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
157164 ),
158165 verticalArrangement = Arrangement.spacedBy(12.dp),
159166 ) {
160− items(s.servers, key = { it.name }) { McpServerCard(it) }
167+ items(s.servers, key = { it.name }) {
168+ McpServerCard(it, modifier = Modifier.then(reducedMotionAnimateItem()))
169+ }
161170 }
162171 }
163172 }
@@ -180,7 +189,7 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
180189}
181190
182191@Composable
183−private fun McpServerCard(server: McpServerInfo) {
192+private fun McpServerCard(server: McpServerInfo, modifier: Modifier = Modifier) {
184193 val remote = server.type.equals("remote", ignoreCase = true)
185194 val connected = server.connected
186195 // Build a single merged content description so TalkBack reads the card as one node
@@ -204,8 +213,10 @@ private fun McpServerCard(server: McpServerInfo) {
204213 append(", ").append(stateLabel)
205214 statusLabel?.let { append(", ").append(it) }
206215 }
216+ var menuExpanded by rememberSaveable { mutableStateOf(false) }
217+ var showTools by rememberSaveable { mutableStateOf(false) }
207218 Card(
208− modifier = Modifier
219+ modifier = modifier
209220 .fillMaxWidth()
210221 .semantics(mergeDescendants = true) { contentDescription = cardDesc },
211222 ) {
@@ -244,6 +255,27 @@ private fun McpServerCard(server: McpServerInfo) {
244255 style = MaterialTheme.typography.labelMedium,
245256 color = if (server.enabled) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.onSurfaceVariant,
246257 )
258+ // A "Remove" item would go here once the opencode server exposes DELETE /mcp
259+ // (or a config PATCH to drop an entry). Only GET and POST /mcp exist today, so
260+ // removing a server isn't reachable from the client; the mcp_remove* strings are
261+ // already declared for when it is.
262+ Box {
263+ IconButton(onClick = { menuExpanded = true }) {
264+ Icon(
265+ Icons.Filled.MoreVert,
266+ contentDescription = stringResource(R.string.more_options_for, server.name),
267+ )
268+ }
269+ DropdownMenu(
270+ expanded = menuExpanded,
271+ onDismissRequest = { menuExpanded = false },
272+ ) {
273+ DropdownMenuItem(
274+ text = { Text(stringResource(R.string.mcp_view_tools)) },
275+ onClick = { menuExpanded = false; showTools = true },
276+ )
277+ }
278+ }
247279 }
248280 // Live status row: connected (with tool count) / error / unknown. Only shown when the
249281 // server reported a runtime state via /mcp, so a static config-only view isn't
@@ -254,6 +286,42 @@ private fun McpServerCard(server: McpServerInfo) {
254286 }
255287 }
256288 }
289+ if (showTools) {
290+ McpToolsDialog(server = server, onDismiss = { showTools = false })
291+ }
292+}
293+
294+@Composable
295+private fun McpToolsDialog(server: McpServerInfo, onDismiss: () -> Unit) {
296+ AlertDialog(
297+ onDismissRequest = onDismiss,
298+ title = { Text(stringResource(R.string.mcp_view_tools)) },
299+ text = {
300+ val tools = server.tools
301+ if (tools.isNullOrEmpty()) {
302+ Text(stringResource(R.string.mcp_no_tools))
303+ } else {
304+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
305+ Text(
306+ stringResource(R.string.mcp_tools_count, tools.size),
307+ style = MaterialTheme.typography.labelMedium,
308+ color = MaterialTheme.colorScheme.onSurfaceVariant,
309+ )
310+ Spacer(Modifier.size(4.dp))
311+ tools.forEach { name ->
312+ Text(
313+ name,
314+ style = MaterialTheme.typography.bodySmall,
315+ fontFamily = FontFamily.Monospace,
316+ )
317+ }
318+ }
319+ }
320+ },
321+ confirmButton = {
322+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.close)) }
323+ },
324+ )
257325}
258326
259327/** Live status row for an MCP server card: connected (with tool count) / error / disconnected.
app/src/main/java/soy/iko/opencode/ui/mcp/McpServerInfo.kt+10−2
@@ -24,6 +24,7 @@ data class McpServerInfo(
2424 val connected: Boolean? = null,
2525 val error: String? = null,
2626 val toolCount: Int? = null,
27+ val tools: List<String>? = null,
2728)
2829
2930/**
@@ -62,7 +63,14 @@ fun mergeMcpStatus(servers: List<McpServerInfo>, status: JsonObject?): List<McpS
6263 val st = status[server.name] as? JsonObject ?: return@map server
6364 val connected = (st["connected"] as? JsonPrimitive)?.booleanOrNull
6465 val error = (st["error"] as? JsonPrimitive)?.contentOrNull
65− val tools = (st["tools"] as? JsonArray)?.size
66− server.copy(connected = connected, error = error, toolCount = tools)
66+ val tools = (st["tools"] as? JsonArray)?.mapNotNull { el -> toolName(el) }
67+ server.copy(connected = connected, error = error, toolCount = tools?.size, tools = tools)
6768 }
6869}
70+
71+private fun toolName(el: kotlinx.serialization.json.JsonElement): String? {
72+ val obj = el as? JsonObject ?: return (el as? JsonPrimitive)?.contentOrNull
73+ val nameKey = obj.entries.firstOrNull { it.key.equals("name", ignoreCase = true) }?.key
74+ return nameKey?.let { (obj[it] as? JsonPrimitive)?.contentOrNull }
75+ ?: obj["id"]?.let { (it as? JsonPrimitive)?.contentOrNull }
76+}
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+109−15
@@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.Column
77import androidx.compose.foundation.layout.Spacer
88import androidx.compose.foundation.layout.PaddingValues
99import androidx.compose.foundation.layout.Row
10+import androidx.compose.foundation.layout.ExperimentalLayoutApi
11+import androidx.compose.foundation.layout.FlowRow
1012import androidx.compose.foundation.layout.fillMaxSize
1113import androidx.compose.foundation.layout.fillMaxWidth
1214import androidx.compose.foundation.layout.imePadding
@@ -19,17 +21,21 @@ import androidx.compose.foundation.text.KeyboardOptions
1921import androidx.compose.material.icons.Icons
2022import androidx.compose.material.icons.filled.Close
2123import androidx.compose.material.icons.filled.ErrorOutline
24+import androidx.compose.material.icons.filled.History
2225import androidx.compose.material.icons.filled.Search
2326import androidx.compose.material.icons.filled.SearchOff
27+import androidx.compose.material3.AssistChip
2428import androidx.compose.material3.Card
2529import androidx.compose.material3.CircularProgressIndicator
2630import androidx.compose.material3.ExperimentalMaterial3Api
31+import androidx.compose.material3.FilterChip
2732import androidx.compose.material3.Icon
2833import androidx.compose.material3.IconButton
2934import androidx.compose.material3.MaterialTheme
3035import androidx.compose.material3.OutlinedTextField
3136import androidx.compose.material3.Scaffold
3237import androidx.compose.material3.Text
38+import androidx.compose.material3.TextButton
3339import androidx.compose.runtime.Composable
3440import androidx.compose.runtime.LaunchedEffect
3541import androidx.compose.runtime.getValue
@@ -60,11 +66,12 @@ import soy.iko.opencode.ui.components.AppTopBar
6066import soy.iko.opencode.ui.components.ConnectionBannerFor
6167import soy.iko.opencode.ui.components.EmptyState
6268import soy.iko.opencode.ui.components.RelativeTimeText
69+import soy.iko.opencode.ui.components.reducedMotionAnimateItem
6370import soy.iko.opencode.ui.vmFactory
6471import soy.iko.opencode.util.runCatchingCancellable
6572
6673/** Cross-session message search: type a query, tap a result to open that conversation. */
67−@OptIn(ExperimentalMaterial3Api::class)
74+@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
6875@Composable
6976fun GlobalSearchScreen(
7077 container: AppContainer,
@@ -75,6 +82,7 @@ fun GlobalSearchScreen(
7582 val state by vm.state.collectAsStateWithLifecycle()
7683 val focusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
7784 val keyboard = LocalSoftwareKeyboardController.current
85+ val matchCaseLabel = stringResource(R.string.match_case)
7886
7987 LaunchedEffect(Unit) {
8088 runCatchingCancellable { focusRequester.requestFocus() }
@@ -97,15 +105,45 @@ fun GlobalSearchScreen(
97105 singleLine = true,
98106 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
99107 trailingIcon = {
100− if (state.query.isNotEmpty()) {
101− IconButton(onClick = { vm.setQuery("") }) {
102− Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
108+ Row(verticalAlignment = Alignment.CenterVertically) {
109+ // "Aa" toggle: a FilterChip beside the clear (x) so the case-sensitivity
110+ // control sits with the query it modifies, not in the type-filter row.
111+ FilterChip(
112+ selected = state.matchCase,
113+ onClick = { vm.setMatchCase(!state.matchCase) },
114+ label = { Text("Aa") },
115+ modifier = Modifier.semantics { contentDescription = matchCaseLabel },
116+ )
117+ if (state.query.isNotEmpty()) {
118+ IconButton(onClick = { vm.setQuery("") }) {
119+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
120+ }
103121 }
104122 }
105123 },
106124 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
107125 keyboardActions = KeyboardActions(onSearch = { keyboard?.hide() }),
108126 )
127+ // Type-filter row: All / Messages / Tool calls / Reasoning. Wraps via FlowRow so a
128+ // long localized label doesn't overflow on narrow screens.
129+ FlowRow(
130+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
131+ horizontalArrangement = Arrangement.spacedBy(8.dp),
132+ ) {
133+ val filters = listOf(
134+ SearchTypeFilter.ALL to R.string.search_filter_all,
135+ SearchTypeFilter.MESSAGES to R.string.search_filter_messages,
136+ SearchTypeFilter.TOOLS to R.string.search_filter_tools,
137+ SearchTypeFilter.REASONING to R.string.search_filter_reasoning,
138+ )
139+ filters.forEach { (filter, labelRes) ->
140+ FilterChip(
141+ selected = state.typeFilter == filter,
142+ onClick = { vm.setTypeFilter(filter) },
143+ label = { Text(stringResource(labelRes)) },
144+ )
145+ }
146+ }
109147 Box(modifier = Modifier.fillMaxSize()) {
110148 ConnectionBannerFor(container)
111149 val searchingLabel = stringResource(R.string.searching)
@@ -147,6 +185,17 @@ fun GlobalSearchScreen(
147185 title = stringResource(R.string.search_keep_typing),
148186 modifier = Modifier.align(Alignment.Center),
149187 )
188+ // Empty query + persisted history: surface suggestions so a returning user can
189+ // re-run a prior search in one tap instead of retyping it.
190+ state.query.isBlank() && state.history.isNotEmpty() -> HistorySuggestions(
191+ history = state.history,
192+ onPick = { query ->
193+ vm.setQuery(query)
194+ keyboard?.hide()
195+ },
196+ onClear = vm::clearHistory,
197+ modifier = Modifier.fillMaxSize(),
198+ )
150199 state.results.isEmpty() -> EmptyState(
151200 icon = Icons.Filled.Search,
152201 title = stringResource(R.string.search_all_start),
@@ -179,10 +228,10 @@ fun GlobalSearchScreen(
179228 style = MaterialTheme.typography.labelSmall,
180229 color = MaterialTheme.colorScheme.onSurfaceVariant,
181230 )
182− androidx.compose.material3.TextButton(
231+ TextButton(
183232 onClick = { vm.searchMore() },
184233 enabled = !state.searching,
185− contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
234+ contentPadding = PaddingValues(horizontal = 0.dp),
186235 ) {
187236 Text(stringResource(R.string.search_more))
188237 }
@@ -193,7 +242,9 @@ fun GlobalSearchScreen(
193242 SearchResultCard(
194243 hit = hit,
195244 query = state.query.trim(),
245+ ignoreCase = !state.matchCase,
196246 onClick = { onOpenSession(hit.session.id) },
247+ modifier = Modifier.then(reducedMotionAnimateItem()),
197248 )
198249 }
199250 }
@@ -203,16 +254,57 @@ fun GlobalSearchScreen(
203254 }
204255}
205256
257+@OptIn(ExperimentalLayoutApi::class)
258+@Composable
259+private fun HistorySuggestions(
260+ history: List<String>,
261+ onPick: (String) -> Unit,
262+ onClear: () -> Unit,
263+ modifier: Modifier = Modifier,
264+) {
265+ Column(modifier = modifier.padding(16.dp)) {
266+ Row(
267+ modifier = Modifier.fillMaxWidth(),
268+ verticalAlignment = Alignment.CenterVertically,
269+ ) {
270+ Text(
271+ stringResource(R.string.recent_searches),
272+ style = MaterialTheme.typography.labelMedium,
273+ color = MaterialTheme.colorScheme.onSurfaceVariant,
274+ modifier = Modifier.semantics { heading() },
275+ )
276+ Spacer(Modifier.weight(1f))
277+ TextButton(onClick = onClear) {
278+ Text(stringResource(R.string.clear_history))
279+ }
280+ }
281+ Spacer(Modifier.size(8.dp))
282+ FlowRow(
283+ modifier = Modifier.fillMaxWidth(),
284+ horizontalArrangement = Arrangement.spacedBy(8.dp),
285+ verticalArrangement = Arrangement.spacedBy(8.dp),
286+ ) {
287+ history.forEach { query ->
288+ AssistChip(
289+ onClick = { onPick(query) },
290+ leadingIcon = { Icon(Icons.Filled.History, contentDescription = null) },
291+ label = { Text(query, maxLines = 1, overflow = TextOverflow.Ellipsis) },
292+ )
293+ }
294+ }
295+ }
296+}
297+
206298@Composable
207−private fun SearchResultCard(hit: SearchHit, query: String, onClick: () -> Unit) {
299+private fun SearchResultCard(hit: SearchHit, query: String, ignoreCase: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
208300 val highlightColor = MaterialTheme.colorScheme.primary
209301 // Highlight the title too when the match was in the title (otherwise the body snippet below
210302 // is highlighted but the title above stays plain — an inconsistency).
211− val title = remember(hit.session.displayTitle, query, hit.matchedTitle, highlightColor) {
212− if (hit.matchedTitle) highlightMatches(hit.session.displayTitle, query, highlightColor)
303+ val title = remember(hit.session.displayTitle, query, hit.matchedTitle, highlightColor, ignoreCase) {
304+ if (hit.matchedTitle) highlightMatches(hit.session.displayTitle, query, highlightColor, ignoreCase)
213305 else AnnotatedString(hit.session.displayTitle)
214306 }
215− Card(modifier = Modifier.fillMaxWidth().clickable(role = Role.Button) { onClick() }) {
307+ Card(modifier = modifier.fillMaxWidth().clickable(role = Role.Button) { onClick() }) {
216308 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
217309 Row(verticalAlignment = Alignment.CenterVertically) {
218310 Text(
@@ -239,8 +331,8 @@ private fun SearchResultCard(hit: SearchHit, query: String, onClick: () -> Unit)
239331 modifier = Modifier.padding(top = 2.dp),
240332 )
241333 if (hit.snippet.isNotEmpty()) {
242− val snippet = remember(hit.snippet, query, highlightColor) {
243− highlightMatches(hit.snippet, query, highlightColor)
334+ val snippet = remember(hit.snippet, query, highlightColor, ignoreCase) {
335+ highlightMatches(hit.snippet, query, highlightColor, ignoreCase)
244336 }
245337 Text(
246338 snippet,
@@ -255,18 +347,20 @@ private fun SearchResultCard(hit: SearchHit, query: String, onClick: () -> Unit)
255347 }
256348}
257349
258−/** Bold + tint every case-insensitive occurrence of [query] within [text] so the user can
259− * see where the hit is in the surrounding snippet. */
350+/** Bold + tint every occurrence of [query] within [text] so the user can see where the hit is
351+ * in the surrounding snippet. [ignoreCase] matches the search's case mode so highlights and
352+ * matches never diverge. */
260353private fun highlightMatches(
261354 text: String,
262355 query: String,
263356 color: androidx.compose.ui.graphics.Color,
357+ ignoreCase: Boolean,
264358): AnnotatedString {
265359 if (query.isEmpty()) return AnnotatedString(text)
266360 return buildAnnotatedString {
267361 var start = 0
268362 while (true) {
269− val idx = text.indexOf(query, start, ignoreCase = true)
363+ val idx = text.indexOf(query, start, ignoreCase = ignoreCase)
270364 if (idx < 0) {
271365 append(text.substring(start))
272366 break
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchViewModel.kt+83−23
@@ -64,8 +64,18 @@ data class GlobalSearchState(
6464 val totalCount: Int = 0,
6565 /** The cap applied to this pass. Grows when the user taps "Search more". */
6666 val sessionCap: Int = NetworkConfig.maxSearchSessions,
67+ /** Message-type filter; ALL matches everything, the others restrict which parts are
68+ * searchable so a user can scope "just tool calls" vs "just reasoning". */
69+ val typeFilter: SearchTypeFilter = SearchTypeFilter.ALL,
70+ /** When true the query matches case-sensitively (the default is case-insensitive). */
71+ val matchCase: Boolean = false,
72+ /** Persisted recent queries shown as suggestions when the search field is empty. */
73+ val history: List<String> = emptyList(),
6774)
6875
76+/** Scopes a global search to a category of message part. */
77+enum class SearchTypeFilter { ALL, MESSAGES, TOOLS, REASONING }
78+
6979/**
7080 * Cross-session message search. opencode's `/find` searches project *files*, not chat
7181 * history, so message search is done client-side: fetch each session's messages (bounded and
@@ -74,7 +84,9 @@ data class GlobalSearchState(
7484 */
7585class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
7686
77− private val _state = MutableStateFlow(GlobalSearchState())
87+ private val _state = MutableStateFlow(
88+ GlobalSearchState(history = container.searchHistoryStore.queries.value),
89+ )
7890 val state: StateFlow<GlobalSearchState> = _state.asStateFlow()
7991
8092 private var searchJob: Job? = null
@@ -101,9 +113,44 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
101113 searchJob = viewModelScope.launch {
102114 delay(NetworkConfig.searchDebounceMs)
103115 runSearch(trimmed)
116+ // Record the query only after a search actually ran (not on every keystroke that
117+ // crossed the min-length threshold), so history reflects real searches.
118+ if (_state.value.query.trim() == trimmed) {
119+ container.searchHistoryStore.add(trimmed)
120+ _state.update { it.copy(history = container.searchHistoryStore.queries.value) }
121+ }
104122 }
105123 }
106124
125+ /** Swap the message-type filter and re-run the current query (no debounce) so the chip
126+ * change is reflected immediately. The message cache is preserved. */
127+ fun setTypeFilter(filter: SearchTypeFilter) {
128+ if (_state.value.typeFilter == filter) return
129+ _state.update { it.copy(typeFilter = filter) }
130+ relaunchCurrent()
131+ }
132+
133+ /** Toggle case-sensitivity and re-run the current query (no debounce). */
134+ fun setMatchCase(matchCase: Boolean) {
135+ if (_state.value.matchCase == matchCase) return
136+ _state.update { it.copy(matchCase = matchCase) }
137+ relaunchCurrent()
138+ }
139+
140+ /** Drop all persisted search history (the "Clear history" action). */
141+ fun clearHistory() {
142+ container.searchHistoryStore.clear()
143+ _state.update { it.copy(history = emptyList()) }
144+ }
145+
146+ private fun relaunchCurrent() {
147+ val trimmed = _state.value.query.trim()
148+ if (trimmed.length < NetworkConfig.minSearchQueryLength) return
149+ searchJob?.cancel()
150+ _state.update { it.copy(searching = true, error = null) }
151+ searchJob = viewModelScope.launch { runSearch(trimmed) }
152+ }
153+
107154 /** Re-run the current query immediately (no debounce), used by the error-state Retry. Clears
108155 * the caches so the retry actually refetches (the prior attempt may have failed). */
109156 fun retry() {
@@ -147,6 +194,8 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
147194 val hits = java.util.Collections.synchronizedMap(HashMap<String, SearchHit>())
148195 val semaphore = Semaphore(NetworkConfig.maxConcurrentPreviews)
149196 val searched = java.util.concurrent.atomic.AtomicInteger(0)
197+ val ignoreCase = !_state.value.matchCase
198+ val filter = _state.value.typeFilter
150199 coroutineScope {
151200 toSearch.forEach { session ->
152201 launch {
@@ -160,11 +209,13 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
160209 Log.w("GlobalSearch", "listMessages failed: " + safeExceptionSummary(it))
161210 emptyList()
162211 }
163− val snippet = matchSnippet(messages, query)
164− val titleMatched = session.displayTitle.contains(query, ignoreCase = true)
212+ val snippet = matchSnippet(messages, query, ignoreCase, filter)
213+ // The session title isn't a message part, so the type filter doesn't
214+ // apply to it — a title match is always surfaced.
215+ val titleMatched = session.displayTitle.contains(query, ignoreCase = ignoreCase)
165216 if (snippet != null || titleMatched) {
166− val count = countMatches(messages, query) +
167− countIn(session.displayTitle, query)
217+ val count = countMatches(messages, query, ignoreCase, filter) +
218+ countIn(session.displayTitle, query, ignoreCase)
168219 hits[session.id] = SearchHit(
169220 session = session,
170221 snippet = snippet
@@ -191,33 +242,39 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
191242 }
192243
193244 /** Find the first searchable text containing [query] across [messages] and return a short
194− * snippet centered on the match, or null if nothing matches. Searches text, reasoning,
195− * tool call names/titles/output/error, and file names/paths — for a coding assistant the
196− * most common searches (an error string, a touched file) live in tool calls. */
245+ * snippet centered on the match, or null if nothing matches. [ignoreCase] controls
246+ * case-sensitivity; [filter] restricts which part types contribute searchable text. */
197247 private fun matchSnippet(
198248 messages: List<soy.iko.opencode.data.model.MessageWithParts>,
199249 query: String,
250+ ignoreCase: Boolean,
251+ filter: SearchTypeFilter = SearchTypeFilter.ALL,
200252 ): String? {
201253 for (message in messages) {
202− for (candidate in searchableTexts(message.parts)) {
203− val idx = candidate.indexOf(query, ignoreCase = true)
254+ for (candidate in searchableTexts(message.parts, filter)) {
255+ val idx = candidate.indexOf(query, ignoreCase = ignoreCase)
204256 if (idx >= 0) return buildSnippet(candidate, idx, query.length)
205257 }
206258 }
207259 return null
208260 }
209261
210− /** The ordered list of searchable strings for a message's parts. Text and reasoning are
211− * searched in full; tool calls contribute their name, title, output, error, and the
212− * stringified input; file parts contribute their filename and source path. */
213− private fun searchableTexts(parts: List<soy.iko.opencode.data.model.Part>): List<String> {
262+ /** The ordered list of searchable strings for a message's parts, scoped by [filter].
263+ * Text and reasoning are searched in full; tool calls contribute their name, title,
264+ * output, error, and the stringified input; file parts contribute their filename and
265+ * source path. A non-ALL filter narrows to only the matching part type, so "Tool calls"
266+ * excludes text/reasoning/file matches. */
267+ private fun searchableTexts(
268+ parts: List<soy.iko.opencode.data.model.Part>,
269+ filter: SearchTypeFilter = SearchTypeFilter.ALL,
270+ ): List<String> {
214271 if (parts.isEmpty()) return emptyList()
215272 val out = ArrayList<String>(parts.size)
216273 for (p in parts) {
217274 when (p) {
218− is TextPart -> out.add(p.text)
219− is ReasoningPart -> out.add(p.text)
220− is ToolPart -> {
275+ is TextPart -> if (filter == SearchTypeFilter.ALL || filter == SearchTypeFilter.MESSAGES) out.add(p.text)
276+ is ReasoningPart -> if (filter == SearchTypeFilter.ALL || filter == SearchTypeFilter.REASONING) out.add(p.text)
277+ is ToolPart -> if (filter == SearchTypeFilter.ALL || filter == SearchTypeFilter.TOOLS) {
221278 out.add(p.tool)
222279 when (p.state) {
223280 is ToolRunning -> p.state.title?.let(out::add)
@@ -230,7 +287,7 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
230287 }
231288 p.state.inputElement()?.toString()?.let(out::add)
232289 }
233− is FilePart -> {
290+ is FilePart -> if (filter == SearchTypeFilter.ALL) {
234291 p.filename?.let(out::add)
235292 p.sourcePath?.let(out::add)
236293 }
@@ -254,23 +311,26 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
254311 private fun countMatches(
255312 messages: List<soy.iko.opencode.data.model.MessageWithParts>,
256313 query: String,
314+ ignoreCase: Boolean,
315+ filter: SearchTypeFilter = SearchTypeFilter.ALL,
257316 ): Int {
258317 var count = 0
259318 for (message in messages) {
260− for (text in searchableTexts(message.parts)) {
261− count += countIn(text, query)
319+ for (text in searchableTexts(message.parts, filter)) {
320+ count += countIn(text, query, ignoreCase)
262321 }
263322 }
264323 return count
265324 }
266325
267− /** Non-overlapping case-insensitive occurrence count of [query] within [text]. */
268− private fun countIn(text: String, query: String): Int {
326+ /** Non-overlapping occurrence count of [query] within [text], case-insensitive unless
327+ * [ignoreCase] is false. */
328+ private fun countIn(text: String, query: String, ignoreCase: Boolean): Int {
269329 if (query.isEmpty()) return 0
270330 var count = 0
271331 var idx = 0
272332 while (true) {
273− val next = text.indexOf(query, idx, ignoreCase = true)
333+ val next = text.indexOf(query, idx, ignoreCase = ignoreCase)
274334 if (next < 0) break
275335 count++
276336 idx = next + query.length
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+60−24
@@ -101,6 +101,7 @@ fun ServerListScreen(
101101 val profiles by vm.profiles.collectAsStateWithLifecycle()
102102 val loading by vm.loading.collectAsStateWithLifecycle()
103103 val connectingId by vm.connectingId.collectAsStateWithLifecycle()
104+ val probingId by vm.probingId.collectAsStateWithLifecycle()
104105 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
105106 val reconnecting by container.reconnecting.collectAsStateWithLifecycle()
106107 // The active server's real SSE state, so its card reflects a dropped/failed stream instead of
@@ -146,6 +147,21 @@ fun ServerListScreen(
146147 }
147148 }
148149
150+ // "Test connection" probe results: surface as a snackbar ("Connected (123ms)" or
151+ // "Failed: …") without navigating away. Collected separately from errorEvents so the
152+ // two flows don't interleave their snackbars unpredictably.
153+ val probeSuccessLabel = stringResource(R.string.test_connection_success)
154+ val probeFailedLabel = stringResource(R.string.test_connection_failed)
155+ LaunchedEffect(Unit) {
156+ vm.probeEvents.collect { result ->
157+ val message = when (result) {
158+ is ProbeTestResult.Success -> probeSuccessLabel.format(result.latencyMs)
159+ is ProbeTestResult.Failed -> probeFailedLabel.format(result.message)
160+ }
161+ snackbar.showSnackbar(message)
162+ }
163+ }
164+
149165 // Undo snackbar: when a server is marked for deferred deletion, offer Undo. If the
150166 // action is taken before the delay expires, the profile is kept and the delete
151167 // never fires — mirrors the session list's undo UX for consistency. Indefinite + a
@@ -350,6 +366,8 @@ fun ServerListScreen(
350366 onDuplicate = { onDuplicateProfile(profile.id) },
351367 onPendingDelete = { pendingDeleteId = profile.id },
352368 onShareQr = { pendingQrProfile = profile },
369+ onTestConnection = { vm.testConnection(profile) },
370+ isProbing = probingId == profile.id,
353371 )
354372 }
355373 } else {
@@ -402,6 +420,8 @@ fun ServerListScreen(
402420 onDuplicate = { onDuplicateProfile(profile.id) },
403421 onPendingDelete = { pendingDeleteId = profile.id },
404422 onShareQr = { pendingQrProfile = profile },
423+ onTestConnection = { vm.testConnection(profile) },
424+ isProbing = probingId == profile.id,
405425 )
406426 }
407427 }
@@ -459,6 +479,8 @@ private fun ServerCardContent(
459479 onDuplicate: () -> Unit,
460480 onPendingDelete: () -> Unit,
461481 onShareQr: () -> Unit = {},
482+ onTestConnection: () -> Unit = {},
483+ isProbing: Boolean = false,
462484 activeState: soy.iko.opencode.data.network.EventStreamClient.ConnectionState? = null,
463485) {
464486 Row(
@@ -559,33 +581,47 @@ private fun ServerCardContent(
559581 val editLabel = stringResource(R.string.edit)
560582 val duplicateLabel = stringResource(R.string.duplicate_server)
561583 val removeLabel = stringResource(R.string.remove)
584+ val testConnLabel = stringResource(R.string.test_connection)
585+ val testConnDesc = stringResource(R.string.test_connection_desc, profile.displayLabel)
562586 // Tie the overflow button's a11y label to this server so a TalkBack user scrolling
563587 // a long list can tell the rows apart (a bare "More" on every row is ambiguous).
564588 val moreLabel = stringResource(R.string.more_options_for, profile.displayLabel)
565− Box {
566− IconButton(onClick = { showRowMenu = true }) {
567− Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
568− }
569− DropdownMenu(
570− expanded = showRowMenu,
571− onDismissRequest = { showRowMenu = false },
572− ) {
573− DropdownMenuItem(
574− text = { Text(editLabel) },
575− onClick = { showRowMenu = false; onEdit() },
576− )
577− DropdownMenuItem(
578− text = { Text(duplicateLabel) },
579− onClick = { showRowMenu = false; onDuplicate() },
580− )
581− DropdownMenuItem(
582− text = { Text(stringResource(R.string.share_qr)) },
583− onClick = { showRowMenu = false; onShareQr() },
584− )
585− DropdownMenuItem(
586− text = { Text(removeLabel, color = MaterialTheme.colorScheme.error) },
587− onClick = { showRowMenu = false; onPendingDelete() },
588− )
589+ // A probe in flight shows a small spinner in place of the overflow button so the
590+ // user sees the test is running (mirrors the connecting spinner above).
591+ if (isProbing) {
592+ CircularProgressIndicator(
593+ modifier = Modifier.size(24.dp).semantics { contentDescription = testConnDesc },
594+ )
595+ } else {
596+ Box {
597+ IconButton(onClick = { showRowMenu = true }) {
598+ Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
599+ }
600+ DropdownMenu(
601+ expanded = showRowMenu,
602+ onDismissRequest = { showRowMenu = false },
603+ ) {
604+ DropdownMenuItem(
605+ text = { Text(editLabel) },
606+ onClick = { showRowMenu = false; onEdit() },
607+ )
608+ DropdownMenuItem(
609+ text = { Text(duplicateLabel) },
610+ onClick = { showRowMenu = false; onDuplicate() },
611+ )
612+ DropdownMenuItem(
613+ text = { Text(testConnLabel) },
614+ onClick = { showRowMenu = false; onTestConnection() },
615+ )
616+ DropdownMenuItem(
617+ text = { Text(stringResource(R.string.share_qr)) },
618+ onClick = { showRowMenu = false; onShareQr() },
619+ )
620+ DropdownMenuItem(
621+ text = { Text(removeLabel, color = MaterialTheme.colorScheme.error) },
622+ onClick = { showRowMenu = false; onPendingDelete() },
623+ )
624+ }
589625 }
590626 }
591627 }
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+64
@@ -27,6 +27,14 @@ import kotlinx.coroutines.withTimeoutOrNull
2727 * failed to connect so the snackbar can offer a Retry action. */
2828data class ConnectError(val message: String, val profile: ServerProfile?)
2929
30+/** Result of a "Test connection" probe: latency in ms on success, or a user-facing message
31+ * on failure. Carried by [ServerListViewModel.probeEvents] so the server list can surface
32+ * it as a snackbar without navigating away. */
33+sealed class ProbeTestResult {
34+ data class Success(val latencyMs: Long) : ProbeTestResult()
35+ data class Failed(val message: String) : ProbeTestResult()
36+}
37+
3038/** Sort order for the server list. RECENT (by lastUsed, the default) or NAME (A→Z). */
3139enum class ServerSortMode { RECENT, NAME }
3240
@@ -92,6 +100,62 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
92100 )
93101 val undoEvents: SharedFlow<String> = _undoEvents.asSharedFlow()
94102
103+ /** One-shot "Test connection" results surfaced as snackbars. A SharedFlow (not StateFlow)
104+ * so each emission is delivered independently. */
105+ private val _probeEvents = MutableSharedFlow<ProbeTestResult>(
106+ extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
107+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
108+ )
109+ val probeEvents: SharedFlow<ProbeTestResult> = _probeEvents.asSharedFlow()
110+
111+ /** Id of the profile currently being probed by [testConnection], so its row can show a
112+ * spinner while the probe is in flight (mirrors [connectingId] for a full connect). */
113+ private val _probing = MutableStateFlow<String?>(null)
114+ val probingId: StateFlow<String?> = _probing.asStateFlow()
115+
116+ /** Probe a server without establishing a long-lived connection or navigating away.
117+ * Measures the round-trip latency of an authenticated ping and emits the result via
118+ * [probeEvents]. Uses [AppContainer.probeWithCredentials] so the probe validates the
119+ * same effective URL + auth the real connection would use, without replacing the
120+ * active connection. Capped at one in-flight probe at a time so rapid taps don't
121+ * multiply. */
122+ fun testConnection(profile: ServerProfile) {
123+ if (_probing.value != null) return
124+ _probing.value = profile.id
125+ viewModelScope.launch {
126+ try {
127+ val start = System.currentTimeMillis()
128+ val result = runCatchingCancellable {
129+ withTimeoutOrNull(NetworkConfig.testCredentialsTimeoutMs) {
130+ container.probeWithCredentials(
131+ baseUrl = profile.baseUrl,
132+ username = profile.username.orEmpty(),
133+ password = profile.password.orEmpty(),
134+ requireHttps = profile.requireHttps,
135+ certPin = profile.certPin,
136+ )
137+ } ?: throw java.net.SocketTimeoutException("Probe timed out")
138+ }
139+ val latency = System.currentTimeMillis() - start
140+ _probeEvents.tryEmit(
141+ result.fold(
142+ onSuccess = { ok ->
143+ // probeWithCredentials returns false on 401/403: the server was
144+ // reached but the credentials were rejected. Surface that as a
145+ // failure rather than a false "Connected", so the user isn't
146+ // misled into thinking auth is fine.
147+ if (ok) ProbeTestResult.Success(latency)
148+ else ProbeTestResult.Failed(container.string(R.string.connection_failed))
149+ },
150+ onFailure = { ProbeTestResult.Failed(container.friendlyError(it)) },
151+ ),
152+ )
153+ } finally {
154+ _probing.value = null
155+ }
156+ }
157+ }
158+
95159 fun connect(profile: ServerProfile, onConnected: () -> Unit) {
96160 if (_connecting.value != null) return
97161 _connecting.value = profile.id
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+126−8
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
44import androidx.activity.compose.BackHandler
55import androidx.compose.foundation.clickable
66import androidx.compose.foundation.combinedClickable
7+import androidx.compose.foundation.horizontalScroll
78import androidx.compose.foundation.layout.Arrangement
89import androidx.compose.foundation.layout.Box
910import androidx.compose.foundation.layout.Column
@@ -19,6 +20,7 @@ import androidx.compose.foundation.layout.padding
1920import androidx.compose.foundation.layout.size
2021import androidx.compose.foundation.lazy.LazyColumn
2122import androidx.compose.foundation.lazy.items
23+import androidx.compose.foundation.rememberScrollState
2224import androidx.compose.foundation.text.KeyboardActions
2325import androidx.compose.foundation.text.KeyboardOptions
2426import androidx.compose.material.icons.Icons
@@ -55,6 +57,7 @@ import androidx.compose.material3.DropdownMenu
5557import androidx.compose.material3.DropdownMenuItem
5658import androidx.compose.material3.ExperimentalMaterial3Api
5759import androidx.compose.material3.ExtendedFloatingActionButton
60+import androidx.compose.material3.FilterChip
5861import androidx.compose.material3.Icon
5962import androidx.compose.material3.IconButton
6063import androidx.compose.material3.MaterialTheme
@@ -118,7 +121,10 @@ import soy.iko.opencode.platform.SessionsWidgetProvider
118121import soy.iko.opencode.R
119122import soy.iko.opencode.ui.components.ConnectionBanner
120123import soy.iko.opencode.ui.components.EmptyState
124+import soy.iko.opencode.ui.components.LoadingSize
125+import soy.iko.opencode.ui.components.LoadingSpinner
121126import soy.iko.opencode.ui.components.LocalRelativeTimeTick
127+import soy.iko.opencode.ui.components.copyToClipboard
122128import soy.iko.opencode.ui.components.reducedMotionAnimateItem
123129import soy.iko.opencode.ui.components.RelativeTimeText
124130import soy.iko.opencode.ui.components.rememberRelativeTimeTick
@@ -151,6 +157,7 @@ fun SessionListScreen(
151157 val creating by vm.creating.collectAsStateWithLifecycle()
152158 val directoryOptions by vm.directoryOptions.collectAsStateWithLifecycle()
153159 val anyRunActive by container.anyRunActive.collectAsStateWithLifecycle()
160+ val runningSessionIds by container.runningSessionIds.collectAsStateWithLifecycle()
154161 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
155162 val isOnline by container.isOnline.collectAsStateWithLifecycle()
156163 val connectedId = activeConnection?.profile?.id
@@ -221,6 +228,12 @@ fun SessionListScreen(
221228 }
222229 }
223230
231+ LaunchedEffect(Unit) {
232+ vm.transientInfo.collect { msg ->
233+ snackbar.showSnackbar(msg)
234+ }
235+ }
236+
224237 // Retryable failures (create/rename): offer a Retry action so a transient network error
225238 // is recoverable in one tap instead of forcing the user back through the dialog/FAB.
226239 LaunchedEffect(Unit) {
@@ -275,6 +288,7 @@ fun SessionListScreen(
275288 }
276289
277290 SessionActionUndoEffect(vm, snackbar)
291+ BulkSessionActionUndoEffect(vm, snackbar)
278292
279293 Scaffold(
280294 topBar = {
@@ -442,7 +456,9 @@ fun SessionListScreen(
442456 refreshing = refreshing,
443457 haptics = haptics,
444458 selectedSessionId = selectedSessionId,
459+ connected = activeConnection != null,
445460 onRefresh = vm::refresh,
461+ onReconnect = vm::retryConnection,
446462 onQueryChange = vm::setQuery,
447463 onOpenSession = onOpenSession,
448464 onCreateSession = openNewSession,
@@ -453,9 +469,12 @@ fun SessionListScreen(
453469 onMarkUnread = { vm.markUnread(it) },
454470 onToggleMute = { container.toggleSessionMute(it) },
455471 onFilterByDirectory = vm::setDirectoryFilter,
472+ onClearDirectoryFilter = { vm.setDirectoryFilter(null) },
473+ onShowArchived = { vm.setShowArchived(true) },
456474 inSelection = inSelection,
457475 selectedIds = selectedIds,
458476 onToggleSelect = ::toggleSelected,
477+ runningSessionIds = runningSessionIds,
459478 )
460479 }
461480 }
@@ -555,7 +574,9 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
555574 refreshing: Boolean,
556575 haptics: androidx.compose.ui.hapticfeedback.HapticFeedback,
557576 selectedSessionId: String?,
577+ connected: Boolean,
558578 onRefresh: () -> Unit,
579+ onReconnect: () -> Unit,
559580 onQueryChange: (String) -> Unit,
560581 onOpenSession: (String) -> Unit,
561582 onCreateSession: () -> Unit,
@@ -566,9 +587,12 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
566587 onMarkUnread: (String) -> Unit,
567588 onToggleMute: (String) -> Unit,
568589 onFilterByDirectory: (String) -> Unit,
590+ onClearDirectoryFilter: () -> Unit,
591+ onShowArchived: () -> Unit,
569592 inSelection: Boolean,
570593 selectedIds: Set<String>,
571594 onToggleSelect: (String) -> Unit,
595+ runningSessionIds: Set<String> = emptySet(),
572596) {
573597 when {
574598 state.loading -> {
@@ -607,7 +631,7 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
607631 textAlign = androidx.compose.ui.text.style.TextAlign.Center,
608632 )
609633 Spacer(Modifier.size(12.dp))
610− TextButton(onClick = onRefresh) {
634+ TextButton(onClick = if (connected) onRefresh else onReconnect) {
611635 Text(stringResource(R.string.retry))
612636 }
613637 }
@@ -644,11 +668,45 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
644668 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
645669 )
646670 }
671+ // List-level indicator that a directory filter is scoping the list. Tapping the
672+ // trailing X clears it (equivalent to tapping the active card chip again). The
673+ // Row scrolls horizontally so a long path doesn't clip the clear action.
674+ state.directoryFilter?.let { dir ->
675+ Row(
676+ modifier = Modifier
677+ .fillMaxWidth()
678+ .horizontalScroll(rememberScrollState())
679+ .padding(horizontal = 16.dp, vertical = 4.dp),
680+ ) {
681+ FilterChip(
682+ selected = true,
683+ onClick = onClearDirectoryFilter,
684+ label = { Text(stringResource(R.string.directory_filter_chip, dir)) },
685+ trailingIcon = {
686+ Icon(
687+ Icons.Filled.Close,
688+ contentDescription = stringResource(R.string.clear_filter),
689+ )
690+ },
691+ )
692+ }
693+ }
647694 val sessions = remember(
648695 state.sessions, state.query, state.previews,
649696 state.pinnedIds, state.archivedIds, state.showArchived,
650697 ) { state.filtered }
651698 if (sessions.isEmpty()) {
699+ // Distinguish why the list is empty so the offered recovery action matches
700+ // the active filter: a query clears the search, a directory filter clears
701+ // the project scope, and an all-archived list reveals archived sessions.
702+ val message = when {
703+ state.query.isNotEmpty() ->
704+ stringResource(R.string.no_sessions_match, state.query)
705+ state.directoryFilter != null ->
706+ stringResource(R.string.no_sessions_in_directory)
707+ else ->
708+ stringResource(R.string.no_sessions_archived_hidden)
709+ }
652710 Box(
653711 modifier = Modifier.fillMaxSize(),
654712 contentAlignment = Alignment.Center,
@@ -665,16 +723,29 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
665723 )
666724 Spacer(Modifier.size(12.dp))
667725 Text(
668− stringResource(R.string.no_sessions_match, state.query),
726+ message,
669727 style = MaterialTheme.typography.bodyMedium,
670728 color = MaterialTheme.colorScheme.onSurfaceVariant,
729+ textAlign = androidx.compose.ui.text.style.TextAlign.Center,
671730 )
672− // An empty filtered list is recoverable in one tap; offer it inline
673− // rather than making the user hunt for the field's trailing clear icon.
674− if (state.query.isNotEmpty()) {
675− Spacer(Modifier.size(8.dp))
676− TextButton(onClick = { onQueryChange("") }) {
677− Text(stringResource(R.string.clear_search))
731+ when {
732+ state.query.isNotEmpty() -> {
733+ Spacer(Modifier.size(8.dp))
734+ TextButton(onClick = { onQueryChange("") }) {
735+ Text(stringResource(R.string.clear_search))
736+ }
737+ }
738+ state.directoryFilter != null -> {
739+ Spacer(Modifier.size(8.dp))
740+ TextButton(onClick = onClearDirectoryFilter) {
741+ Text(stringResource(R.string.clear_filter))
742+ }
743+ }
744+ state.hiddenArchivedCount > 0 -> {
745+ Spacer(Modifier.size(8.dp))
746+ TextButton(onClick = onShowArchived) {
747+ Text(stringResource(R.string.show_archived))
748+ }
678749 }
679750 }
680751 }
@@ -823,6 +894,7 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
823894 isPinned = isPinned,
824895 isArchived = isArchived,
825896 isMuted = isMuted,
897+ isRunning = session.id in runningSessionIds,
826898 onClick = onCardClick,
827899 onRename = onCardRename,
828900 onDelete = onCardDelete,
@@ -988,6 +1060,7 @@ private fun SessionCard(
9881060 isSelected: Boolean = false,
9891061 isPinned: Boolean = false,
9901062 isArchived: Boolean = false,
1063+ isRunning: Boolean = false,
9911064 onFilterByDirectory: ((String) -> Unit)? = null,
9921065 isDirectoryFiltered: Boolean = false,
9931066 inSelection: Boolean = false,
@@ -1045,6 +1118,15 @@ private fun SessionCard(
10451118 }
10461119 Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
10471120 Row(verticalAlignment = Alignment.CenterVertically) {
1121+ if (isRunning) {
1122+ val runningLabel = stringResource(R.string.session_running_badge)
1123+ LoadingSpinner(
1124+ size = LoadingSize.Inline,
1125+ modifier = Modifier
1126+ .padding(end = 8.dp)
1127+ .semantics { contentDescription = runningLabel },
1128+ )
1129+ }
10481130 if (unreadCount > 0 && !isMuted) {
10491131 val unreadLabel = stringResource(R.string.unread_count, unreadCount)
10501132 // Count badge: shows the number of unread messages so the user
@@ -1198,6 +1280,9 @@ private fun SessionCard(
11981280 val archiveLabel = stringResource(if (isArchived) R.string.session_unarchive else R.string.session_archive)
11991281 val markUnreadLabel = stringResource(R.string.session_mark_unread)
12001282 val muteLabel = stringResource(if (isMuted) R.string.session_unmute else R.string.session_mute)
1283+ val copyIdLabel = stringResource(R.string.copy_session_id)
1284+ val rowContext = LocalContext.current
1285+ val clipLabelSessionId = stringResource(R.string.clip_label_session_id)
12011286 Box {
12021287 IconButton(onClick = { showRowMenu = true }) {
12031288 Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
@@ -1226,6 +1311,13 @@ private fun SessionCard(
12261311 text = { Text(muteLabel) },
12271312 onClick = { showRowMenu = false; onToggleMute() },
12281313 )
1314+ DropdownMenuItem(
1315+ text = { Text(copyIdLabel) },
1316+ onClick = {
1317+ showRowMenu = false
1318+ copyToClipboard(rowContext, clipLabelSessionId, session.id)
1319+ },
1320+ )
12291321 DropdownMenuItem(
12301322 text = { Text(renameLabel) },
12311323 onClick = { showRowMenu = false; onRename() },
@@ -1320,6 +1412,32 @@ private fun SessionActionUndoEffect(vm: SessionListViewModel, snackbar: Snackbar
13201412 }
13211413}
13221414
1415+// Confirmation + Undo for bulk archive actions. Mirrors SessionActionUndoEffect but for
1416+// the bulk path, so archiving N sessions at once shows a single snackbar (not N) with Undo.
1417+@Composable
1418+private fun BulkSessionActionUndoEffect(vm: SessionListViewModel, snackbar: SnackbarHostState) {
1419+ val undoLabel = stringResource(R.string.undo)
1420+ val archived = stringResource(R.string.bulk_archived)
1421+ val unarchived = stringResource(R.string.bulk_unarchived)
1422+ LaunchedEffect(Unit) {
1423+ vm.bulkSessionActionEvents.collectLatest { event ->
1424+ val message = when (event.kind) {
1425+ SessionActionKind.ARCHIVED -> archived.format(event.ids.size)
1426+ SessionActionKind.UNARCHIVED -> unarchived.format(event.ids.size)
1427+ SessionActionKind.PINNED, SessionActionKind.UNPINNED -> return@collectLatest
1428+ }
1429+ val result = snackbar.showSnackbar(
1430+ message = message,
1431+ actionLabel = undoLabel,
1432+ duration = androidx.compose.material3.SnackbarDuration.Short,
1433+ )
1434+ if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) {
1435+ vm.undoBulkAction(event)
1436+ }
1437+ }
1438+ }
1439+}
1440+
13231441@Composable
13241442private fun SortMenu(
13251443 sortMode: SessionSortMode,
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+76−7
@@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.StateFlow
3333import kotlinx.coroutines.flow.asSharedFlow
3434import kotlinx.coroutines.flow.asStateFlow
3535import kotlinx.coroutines.flow.collectLatest
36+import kotlinx.coroutines.flow.first
3637import kotlinx.coroutines.flow.flatMapLatest
3738import kotlinx.coroutines.flow.flowOf
3839import kotlinx.coroutines.flow.stateIn
@@ -51,6 +52,11 @@ enum class SessionActionKind { PINNED, UNPINNED, ARCHIVED, UNARCHIVED }
5152
5253data class SessionActionEvent(val sessionId: String, val kind: SessionActionKind)
5354
55+/** A bulk pin/archive action the user just took, surfaced so the UI can confirm it with a
56+ * single Undo snackbar (per-item snackbars would spam). [undoable] via
57+ * [SessionListViewModel.undoBulkAction]. */
58+data class BulkSessionActionEvent(val ids: Set<String>, val kind: SessionActionKind)
59+
5460/** Directory options for the new-session picker: worktree paths the server knows about
5561 * ([projects]) and the server's default working directory ([serverDefault], its cwd).
5662 * [loaded] distinguishes "not fetched yet" from "fetched, none found". */
@@ -189,6 +195,15 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
189195 )
190196 val transientErrors: SharedFlow<String> = _transientErrors.asSharedFlow()
191197
198+ /** One-shot transient *informational* confirmations (not errors) surfaced as snackbars,
199+ * e.g. "N sessions marked as read". Separate from [transientErrors] so the naming stays
200+ * honest, though both are rendered the same way by the UI. */
201+ private val _transientInfo = MutableSharedFlow<String>(
202+ extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
203+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
204+ )
205+ val transientInfo: SharedFlow<String> = _transientInfo.asSharedFlow()
206+
192207 /** A transient error paired with a retry action (create/rename). The retry lambda lets
193208 * the snackbar offer a "Retry" button so a transient network failure doesn't force the
194209 * user back through the dialog/FAB to re-attempt — matching the server list's connect
@@ -217,6 +232,14 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
217232 )
218233 val sessionActionEvents: SharedFlow<SessionActionEvent> = _sessionActionEvents.asSharedFlow()
219234
235+ /** One-shot events carrying a bulk pin/archive action, so the UI can show a single
236+ * confirmation + Undo snackbar instead of one per session. */
237+ private val _bulkSessionActionEvents = MutableSharedFlow<BulkSessionActionEvent>(
238+ extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
239+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
240+ )
241+ val bulkSessionActionEvents: SharedFlow<BulkSessionActionEvent> = _bulkSessionActionEvents.asSharedFlow()
242+
220243 /** Session awaiting the undo window to expire before its REST delete fires. Holds the
221244 * Session so Undo can restore it. */
222245 // Keyed by session id, not a single field: the undo window lets several deletes be
@@ -517,10 +540,34 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
517540 }
518541
519542 /** Force the SSE stream to reconnect (recovery path for a Failed banner) and
520− * re-fetch the session list so the UI reflects the current server state. */
543+ * re-fetch the session list so the UI reflects the current server state. When the
544+ * active connection is null (e.g. a double-failure server switch left the user
545+ * disconnected from both the new and old servers), reconnect to the most-recently-
546+ * used profile so the Retry button can recover instead of emitting "not connected". */
521547 fun retryConnection() {
522− container.activeConnection.value?.events?.triggerReconnect()
523− refresh()
548+ val conn = container.activeConnection.value
549+ if (conn != null) {
550+ conn.events.triggerReconnect()
551+ refresh()
552+ return
553+ }
554+ viewModelScope.launch {
555+ val recent = runCatchingCancellable { container.profileStore.profiles.first() }
556+ .getOrDefault(emptyList())
557+ .firstOrNull()
558+ if (recent == null) {
559+ _transientErrors.tryEmit(container.string(R.string.no_servers_to_reconnect))
560+ return@launch
561+ }
562+ runCatchingCancellable {
563+ val c = container.connect(recent)
564+ c.api.ping()
565+ }.onSuccess { refresh() }
566+ .onFailure {
567+ container.disconnect()
568+ _transientErrors.tryEmit(container.friendlyError(it))
569+ }
570+ }
524571 }
525572
526573 fun refresh() {
@@ -781,16 +828,38 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
781828 }
782829 }
783830
784− /** Bulk-archive (or unarchive) the given sessions. Quietly applies state + persists; the
785− * caller surfaces a single confirmation (bulk actions batch, so per-item snackbars would
786− * spam). Reversible by toggling "show archived" and un-archiving. */
831+ /** Bulk-archive (or unarchive) the given sessions. Applies state + persists and emits a
832+ * single [BulkSessionActionEvent] so the caller can show one Undo snackbar (per-item
833+ * snackbars would spam). Reversible by toggling "show archived" and un-archiving, or
834+ * via the Undo action on the snackbar. */
787835 fun bulkArchive(ids: Set<String>, archive: Boolean) {
836+ if (ids.isEmpty()) return
788837 ids.forEach { applyArchive(it, archive) }
838+ _bulkSessionActionEvents.tryEmit(
839+ BulkSessionActionEvent(
840+ ids,
841+ if (archive) SessionActionKind.ARCHIVED else SessionActionKind.UNARCHIVED,
842+ ),
843+ )
844+ }
845+
846+ /** Reverse a bulk archive action surfaced via [bulkSessionActionEvents] (Undo snackbar). */
847+ fun undoBulkAction(event: BulkSessionActionEvent) {
848+ val undo = when (event.kind) {
849+ SessionActionKind.ARCHIVED -> false
850+ SessionActionKind.UNARCHIVED -> true
851+ SessionActionKind.PINNED, SessionActionKind.UNPINNED -> return
852+ }
853+ event.ids.forEach { applyArchive(it, undo) }
789854 }
790855
791− /** Mark all of the given sessions as read (clears their unread badge). */
856+ /** Mark all of the given sessions as read (clears their unread badge). Emits a transient
857+ * confirmation so the user gets feedback that N sessions were marked read (the badges
858+ * just vanish otherwise, which could be mistaken for a glitch). */
792859 fun bulkMarkRead(ids: Set<String>) {
860+ if (ids.isEmpty()) return
793861 ids.forEach { container.markSessionRead(it) }
862+ _transientInfo.tryEmit(container.string(R.string.bulk_marked_read, ids.size))
794863 }
795864
796865 /** Mark a single session as unread (re-badges it in the list). */
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+2−1
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Column
1111import androidx.compose.foundation.layout.Row
1212import androidx.compose.foundation.layout.fillMaxSize
1313import androidx.compose.foundation.layout.fillMaxWidth
14+import androidx.compose.foundation.layout.imePadding
1415import androidx.compose.foundation.layout.padding
1516import androidx.compose.foundation.lazy.LazyColumn
1617import androidx.compose.foundation.lazy.items
@@ -240,7 +241,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
240241 },
241242 ) { padding ->
242243 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
243− Column(modifier = Modifier.fillMaxSize().padding(padding)) {
244+ Column(modifier = Modifier.fillMaxSize().imePadding().padding(padding)) {
244245 Text(
245246 stringResource(R.string.diagnostics_desc),
246247 style = MaterialTheme.typography.bodySmall,
app/src/main/java/soy/iko/opencode/ui/theme/Dimens.ktdeleted−28
@@ -1,28 +0,0 @@
1−package soy.iko.opencode.ui.theme
2−
3−import androidx.compose.ui.unit.dp
4−
5−/**
6− * Centralized dimension tokens for icon sizes and spacing. The UI previously scattered bare
7− * `.size(N.dp)` and `Spacer(Modifier.size(N.dp))` literals across dozens of call sites with
8− * slightly different values (12/14/16/18/20/22/24dp for icons; 4/6/8/10/12/14/16/20/24/32dp for
9− * spacing). Naming them keeps the design system consistent and lets a global bump happen in
10− * one place. New code should prefer these over raw literals.
11− */
12−object IconSize {
13− /** Dense inline icons inside rows/headers (e.g. a 14dp copy icon inside a card header). */
14− const val dense = 14
15− /** Standard inline icon inside an IconButton or a row. */
16− const val standard = 18
17− /** Full-size icon, typically inside a 48dp IconButton. */
18− const val large = 24
19−}
20−
21−object Spacing {
22− val xs = 4.dp
23− val sm = 8.dp
24− val md = 12.dp
25− val lg = 16.dp
26− val xl = 24.dp
27− val xxl = 32.dp
28−}
app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+8−10
@@ -1,8 +1,5 @@
11package soy.iko.opencode.ui.theme
22
3−import android.app.Activity
4−import android.content.Context
5−import android.content.ContextWrapper
63import android.os.Build
74import androidx.compose.foundation.isSystemInDarkTheme
85import androidx.compose.material3.MaterialTheme
@@ -13,9 +10,11 @@ import androidx.compose.material3.lightColorScheme
1310import androidx.compose.runtime.Composable
1411import androidx.compose.runtime.SideEffect
1512import androidx.compose.ui.graphics.Color
13+import androidx.compose.ui.graphics.luminance
1614import androidx.compose.ui.platform.LocalContext
1715import androidx.compose.ui.platform.LocalView
1816import androidx.core.view.WindowCompat
17+import soy.iko.opencode.util.findActivity
1918
2019/**
2120 * Tokyo Night — a dark, muted blue/purple palette. Every Material 3 color role is set
@@ -207,13 +206,12 @@ data class DiffColors(val addText: Color, val addBg: Color, val removeText: Colo
207206
208207@Composable
209208fun diffColors(): DiffColors {
210− val dark = androidx.compose.foundation.isSystemInDarkTheme()
209+ // Read the resolved darkTheme flag (which honors ThemeMode/System/AMOLED) instead of the
210+ // raw system setting, so a user who forces ThemeMode.LIGHT on a dark system gets the light
211+ // diff palette, not the dark one. Computed from the active colorScheme's surface luminance
212+ // so it stays correct under dynamic color too.
213+ val surface = MaterialTheme.colorScheme.surface
214+ val dark = surface.luminance() < 0.5f
211215 return if (dark) DiffColors(DarkDiffAdd, DarkDiffAddBg, DarkDiffRemove, DarkDiffRemoveBg)
212216 else DiffColors(LightDiffAdd, LightDiffAddBg, LightDiffRemove, LightDiffRemoveBg)
213217}
214−
215−private tailrec fun Context.findActivity(): Activity? = when (this) {
216− is Activity -> this
217− is ContextWrapper -> baseContext.findActivity()
218− else -> null
219−}
app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.kt+16−5
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Spacer
1313import androidx.compose.foundation.layout.fillMaxSize
1414import androidx.compose.foundation.layout.fillMaxWidth
1515import androidx.compose.foundation.layout.height
16+import androidx.compose.foundation.layout.imePadding
1617import androidx.compose.foundation.layout.padding
1718import androidx.compose.foundation.layout.size
1819import androidx.compose.foundation.lazy.LazyColumn
@@ -67,6 +68,7 @@ import soy.iko.opencode.ui.components.AppTopBar
6768import soy.iko.opencode.ui.components.ConnectionBannerFor
6869import soy.iko.opencode.ui.components.EmptyState
6970import soy.iko.opencode.ui.components.SectionHeader
71+import soy.iko.opencode.ui.components.reducedMotionAnimateItem
7072import soy.iko.opencode.ui.vmFactory
7173import java.text.NumberFormat
7274import java.util.Locale
@@ -96,7 +98,7 @@ fun UsageScreen(container: AppContainer, onBack: () -> Unit, onOpenSession: (Str
9698 PullToRefreshBox(
9799 isRefreshing = refreshing,
98100 onRefresh = { vm.load() },
99− modifier = Modifier.fillMaxSize().padding(padding),
101+ modifier = Modifier.fillMaxSize().imePadding().padding(padding),
100102 ) {
101103 Box(modifier = Modifier.fillMaxSize()) {
102104 ConnectionBannerFor(container)
@@ -198,7 +200,7 @@ private fun UsageContent(
198200 }
199201 }
200202 }
201− item { TotalsCard(report) }
203+ item { TotalsCard(report, timeRange) }
202204 item { TokenBreakdownCard(report.totalTokens) }
203205 if (report.byModel.isNotEmpty()) {
204206 item { SectionHeader(stringResource(R.string.usage_by_model), Modifier.semantics { heading() }) }
@@ -211,6 +213,7 @@ private fun UsageContent(
211213 tokens = m.tokens,
212214 messages = m.messages,
213215 costFraction = fractionOf(m.cost, report.totalCost),
216+ modifier = Modifier.then(reducedMotionAnimateItem()),
214217 )
215218 }
216219 }
@@ -254,6 +257,7 @@ private fun UsageContent(
254257 messages = s.messages,
255258 costFraction = fractionOf(s.cost, report.totalCost),
256259 onClick = { onOpenSession(s.sessionId) },
260+ modifier = Modifier.then(reducedMotionAnimateItem()),
257261 )
258262 }
259263 }
@@ -291,11 +295,17 @@ private fun CostBar(fraction: Float) {
291295}
292296
293297@Composable
294−private fun TotalsCard(report: UsageReport) {
298+private fun TotalsCard(report: UsageReport, timeRange: UsageTimeRange) {
299+ val rangeLabelRes = when (timeRange) {
300+ UsageTimeRange.TWENTY_FOUR_HOURS -> R.string.usage_range_24h
301+ UsageTimeRange.SEVEN_DAYS -> R.string.usage_range_7d
302+ UsageTimeRange.THIRTY_DAYS -> R.string.usage_range_30d
303+ UsageTimeRange.ALL_TIME -> R.string.usage_range_all
304+ }
295305 Card(modifier = Modifier.fillMaxWidth()) {
296306 Column(Modifier.padding(16.dp)) {
297307 Text(
298− stringResource(R.string.usage_total_cost),
308+ stringResource(R.string.usage_total_cost_with_range, stringResource(rangeLabelRes)),
299309 style = MaterialTheme.typography.labelMedium,
300310 color = MaterialTheme.colorScheme.onSurfaceVariant,
301311 )
@@ -352,8 +362,9 @@ private fun UsageRow(
352362 messages: Int,
353363 costFraction: Float,
354364 onClick: (() -> Unit)? = null,
365+ modifier: Modifier = Modifier,
355366) {
356− val rowModifier = Modifier
367+ val rowModifier = modifier
357368 .fillMaxWidth()
358369 .let { if (onClick != null) it.clickable(role = Role.Button, onClick = onClick) else it }
359370 .padding(vertical = 6.dp)
app/src/main/java/soy/iko/opencode/util/Contexts.ktnew file+15
@@ -0,0 +1,15 @@
1+package soy.iko.opencode.util
2+
3+import android.app.Activity
4+import android.content.Context
5+import android.content.ContextWrapper
6+
7+/** Walk the [Context] wrapper chain to find the hosting [Activity], so window/fold/lifecycle
8+ * APIs reachable only from an Activity can be used from a Composable via [LocalContext].
9+ * Returns null when the context isn't hosted in an Activity (e.g. a preview), in which case
10+ * the caller should skip the Activity-dependent path. */
11+tailrec fun Context.findActivity(): Activity? = when (this) {
12+ is Activity -> this
13+ is ContextWrapper -> baseContext.findActivity()
14+ else -> null
15+}
app/src/main/res/values-es/strings.xml+34−1
@@ -19,6 +19,7 @@
1919 <string name="settings">Ajustes</string>
2020 <string name="files">Archivos</string>
2121 <string name="share">Compartir</string>
22+ <string name="copy_as_markdown">Copiar como Markdown</string>
2223 <string name="share_subject">Sesión de opencode</string>
2324 <string name="send">Enviar</string>
2425 <string name="stop">Detener</string>
@@ -462,6 +463,7 @@
462463 <string name="usage">Uso</string>
463464 <string name="usage_title">Uso y coste</string>
464465 <string name="usage_total_cost">Coste total</string>
466+ <string name="usage_total_cost_with_range">Coste total (%1$s)</string>
465467 <string name="usage_total_tokens">Tokens totales</string>
466468 <string name="usage_input">Entrada</string>
467469 <string name="usage_output">Salida</string>
@@ -565,6 +567,10 @@
565567 <string name="session_archived_badge">Archivada</string>
566568 <string name="session_shared_badge">Compartida</string>
567569 <string name="session_reverted_badge">Revertida</string>
570+ <string name="session_running_badge">Agente en ejecución</string>
571+ <string name="bulk_archived">%1$d sesiones archivadas</string>
572+ <string name="bulk_unarchived">%1$d sesiones desarchivadas</string>
573+ <string name="bulk_marked_read">%1$d sesiones marcadas como leídas</string>
568574 <string name="sort_active">Orden activo</string>
569575 <string name="sort_descending">Descendente</string>
570576 <string name="sort_ascending">Ascendente</string>
@@ -587,6 +593,7 @@
587593 <!-- UI/UX batch: permisos, confirmaciones, indicaciones de búsqueda, accesibilidad -->
588594 <string name="permission_progress">%1$d de %2$d</string>
589595 <string name="permission_waiting">El agente está esperando. Se rechazará automáticamente en %1$d min si no eliges.</string>
596+ <string name="permission_warning_seconds">Rechazo automático en %1$d s</string>
590597 <string name="action_continue">Continuar</string>
591598 <string name="summarize_confirm_title">¿Resumir la conversación?</string>
592599 <string name="summarize_confirm_text">Los mensajes anteriores se compactan en un resumen para liberar contexto. Esto no se puede deshacer aquí.</string>
@@ -601,6 +608,7 @@
601608 <string name="clip_label_path">Ruta</string>
602609 <string name="clip_label_message">Mensaje</string>
603610 <string name="clip_label_share">Enlace para compartir</string>
611+ <string name="clip_label_session_id">ID de sesión</string>
604612 <string name="search_contents_hint">Escribe para buscar en el contenido del archivo</string>
605613 <string name="search_symbols_hint">Escribe para buscar símbolos</string>
606614 <string name="search_keep_typing">Sigue escribiendo para buscar…</string>
@@ -653,7 +661,6 @@
653661 <string name="regenerate">Regenerar</string>
654662 <string name="find_in_conversation">Buscar en la conversación</string>
655663 <string name="search_in_conversation">Buscar en la conversación</string>
656− <string name="search_match_count">%1$d de %2$d</string>
657664 <string name="keyboard_shortcuts">Atajos de teclado</string>
658665 <string name="keyboard_shortcuts_title">Atajos de teclado</string>
659666 <string name="shortcut_open_palette">Abrir paleta de comandos</string>
@@ -694,6 +701,10 @@
694701 <string name="file_details_size_date">%1$s · %2$s</string>
695702 <string name="filter_by_project">Filtrar por este proyecto</string>
696703 <string name="clear_project_filter">Quitar filtro de proyecto</string>
704+ <string name="directory_filter_chip">Directorio: %1$s</string>
705+ <string name="clear_filter">Quitar filtro</string>
706+ <string name="no_sessions_in_directory">No hay sesiones en este directorio</string>
707+ <string name="no_sessions_archived_hidden">No hay sesiones</string>
697708 <string name="empty_file_detail_title">Ningún archivo seleccionado</string>
698709 <string name="empty_file_detail_desc">Elige un archivo de la lista para verlo aquí.</string>
699710
@@ -772,6 +783,7 @@
772783 <string name="mark_unread">Marcar como no leído</string>
773784 <string name="search_progress">Buscadas %1$d / %2$d sesiones</string>
774785 <string name="recent_searches">Búsquedas recientes</string>
786+ <string name="clear_history">Borrar historial</string>
775787 <string name="load_more_lines">Cargar %1$d líneas más</string>
776788 <string name="detect_from_server">Detectar desde el servidor</string>
777789 <string name="detecting_pin">Detectando pin de certificado…</string>
@@ -784,6 +796,9 @@
784796 <string name="mcp_remove_title">¿Quitar servidor MCP?</string>
785797 <string name="mcp_remove_text">“%1$s” se quitará de tus servidores MCP.</string>
786798 <string name="mcp_disabled_locally">Desactivado en este dispositivo</string>
799+ <string name="mcp_view_tools">Ver herramientas</string>
800+ <string name="mcp_no_tools">No hay herramientas disponibles</string>
801+ <string name="mcp_add_server">Añadir servidor MCP</string>
787802 <string name="file_share_too_large">El archivo es demasiado grande para compartir</string>
788803 <string name="search_filter_all">Todo</string>
789804 <string name="search_filter_messages">Mensajes</string>
@@ -795,4 +810,22 @@
795810 <string name="server_http">HTTP · no seguro</string>
796811 <string name="server_https">HTTPS</string>
797812 <string name="server_https_pinned">HTTPS · cert fijado</string>
813+
814+ <string name="time_now">ahora</string>
815+ <string name="time_minutes_abbrev">%1$d min</string>
816+ <string name="time_hours_abbrev">%1$d h</string>
817+ <string name="time_days_abbrev">%1$d d</string>
818+ <string name="time_weeks_abbrev">%1$d sem</string>
819+ <string name="time_months_abbrev">%1$d mes</string>
820+ <string name="time_years_abbrev">%1$d a</string>
821+
822+ <string name="search_match_count">Mensaje %1$d de %2$d</string>
823+
824+ <string name="copy_session_id">Copiar ID de sesión</string>
825+ <string name="staging_n_files">Preparando %1$d archivos…</string>
826+ <string name="recent_models">Recientes</string>
827+ <string name="test_connection">Probar conexión</string>
828+ <string name="test_connection_desc">Probando conexión a %1$s</string>
829+ <string name="test_connection_success">Conectado (%1$dms)</string>
830+ <string name="test_connection_failed">Error: %1$s</string>
798831</resources>
app/src/main/res/values/strings.xml+39−1
@@ -19,6 +19,7 @@
1919 <string name="settings">Settings</string>
2020 <string name="files">Files</string>
2121 <string name="share">Share</string>
22+ <string name="copy_as_markdown">Copy as Markdown</string>
2223 <string name="share_subject">opencode session</string>
2324 <string name="send">Send</string>
2425 <string name="stop">Stop</string>
@@ -378,6 +379,7 @@
378379 <string name="session_archive">Archive</string>
379380 <string name="session_unarchive">Unarchive</string>
380381 <string name="session_pinned">Pinned</string>
382+ <string name="session_running_badge">Agent running</string>
381383 <string name="session_mark_unread">Mark as unread</string>
382384 <string name="session_mute">Mute notifications</string>
383385 <string name="session_unmute">Unmute notifications</string>
@@ -484,6 +486,7 @@
484486 <string name="usage">Usage</string>
485487 <string name="usage_title">Usage &amp; cost</string>
486488 <string name="usage_total_cost">Total cost</string>
489+ <string name="usage_total_cost_with_range">Total cost (%1$s)</string>
487490 <string name="usage_total_tokens">Total tokens</string>
488491 <string name="usage_input">Input</string>
489492 <string name="usage_output">Output</string>
@@ -588,6 +591,9 @@
588591 <string name="session_archived_badge">Archived</string>
589592 <string name="session_shared_badge">Shared</string>
590593 <string name="session_reverted_badge">Reverted</string>
594+ <string name="bulk_archived">%1$d sessions archived</string>
595+ <string name="bulk_unarchived">%1$d sessions unarchived</string>
596+ <string name="bulk_marked_read">%1$d sessions marked as read</string>
591597 <string name="sort_active">Active sort</string>
592598 <string name="sort_descending">Descending</string>
593599 <string name="sort_ascending">Ascending</string>
@@ -610,6 +616,7 @@
610616 <!-- UI/UX batch: permissions, confirmations, search prompts, a11y -->
611617 <string name="permission_progress">%1$d of %2$d</string>
612618 <string name="permission_waiting">The agent is waiting. Auto-rejecting in %1$d min if you don\'t choose.</string>
619+ <string name="permission_warning_seconds">Auto-rejecting in %1$d s</string>
613620 <string name="action_continue">Continue</string>
614621 <string name="summarize_confirm_title">Summarize conversation?</string>
615622 <string name="summarize_confirm_text">Earlier messages are compacted into a summary to free up context. This can\'t be undone here.</string>
@@ -624,6 +631,7 @@
624631 <string name="clip_label_path">Path</string>
625632 <string name="clip_label_message">Message</string>
626633 <string name="clip_label_share">Share link</string>
634+ <string name="clip_label_session_id">Session ID</string>
627635 <string name="search_contents_hint">Type to search file contents</string>
628636 <string name="search_symbols_hint">Type to search symbols</string>
629637 <string name="search_keep_typing">Keep typing to search…</string>
@@ -654,7 +662,6 @@
654662 <string name="regenerate">Regenerate</string>
655663 <string name="find_in_conversation">Find in conversation</string>
656664 <string name="search_in_conversation">Search in conversation</string>
657− <string name="search_match_count">%1$d of %2$d</string>
658665 <string name="keyboard_shortcuts">Keyboard shortcuts</string>
659666 <string name="keyboard_shortcuts_title">Keyboard shortcuts</string>
660667 <string name="shortcut_open_palette">Open command palette</string>
@@ -695,6 +702,10 @@
695702 <string name="file_details_size_date">%1$s · %2$s</string>
696703 <string name="filter_by_project">Filter by this project</string>
697704 <string name="clear_project_filter">Clear project filter</string>
705+ <string name="directory_filter_chip">Directory: %1$s</string>
706+ <string name="clear_filter">Clear filter</string>
707+ <string name="no_sessions_in_directory">No sessions in this directory</string>
708+ <string name="no_sessions_archived_hidden">No sessions</string>
698709 <string name="empty_file_detail_title">No file selected</string>
699710 <string name="empty_file_detail_desc">Pick a file from the list to view it here.</string>
700711
@@ -772,6 +783,7 @@
772783 <string name="mark_unread">Mark as unread</string>
773784 <string name="search_progress">Searched %1$d / %2$d sessions</string>
774785 <string name="recent_searches">Recent searches</string>
786+ <string name="clear_history">Clear history</string>
775787 <string name="load_more_lines">Load %1$d more lines</string>
776788 <string name="detect_from_server">Detect from server</string>
777789 <string name="detecting_pin">Detecting certificate pin…</string>
@@ -784,6 +796,9 @@
784796 <string name="mcp_remove_title">Remove MCP server?</string>
785797 <string name="mcp_remove_text">“%1$s” will be removed from your MCP servers.</string>
786798 <string name="mcp_disabled_locally">Disabled on this device</string>
799+ <string name="mcp_view_tools">View tools</string>
800+ <string name="mcp_no_tools">No tools available</string>
801+ <string name="mcp_add_server">Add MCP server</string>
787802 <string name="file_share_too_large">File is too large to share</string>
788803 <string name="search_filter_all">All</string>
789804 <string name="search_filter_messages">Messages</string>
@@ -795,4 +810,27 @@
795810 <string name="server_http">HTTP · not secured</string>
796811 <string name="server_https">HTTPS</string>
797812 <string name="server_https_pinned">HTTPS · cert pinned</string>
813+
814+ <!-- Relative time suffixes (compact form for dense list rows). The numeric value is
815+ formatted by the caller; these provide the locale-correct unit suffix so "3m" in
816+ English reads "3 min" in Spanish instead of the ambiguous "3m". -->
817+ <string name="time_now">now</string>
818+ <string name="time_minutes_abbrev">%1$dm</string>
819+ <string name="time_hours_abbrev">%1$dh</string>
820+ <string name="time_days_abbrev">%1$dd</string>
821+ <string name="time_weeks_abbrev">%1$dw</string>
822+ <string name="time_months_abbrev">%1$dmo</string>
823+ <string name="time_years_abbrev">%1$dy</string>
824+
825+ <!-- In-conversation search match counter. Clarifies that M is the count of matching
826+ messages, not occurrences, so "1 of 47" isn't read as 47 total hits. -->
827+ <string name="search_match_count">Message %1$d of %2$d</string>
828+
829+ <string name="copy_session_id">Copy session ID</string>
830+ <string name="staging_n_files">Staging %1$d files…</string>
831+ <string name="recent_models">Recent</string>
832+ <string name="test_connection">Test connection</string>
833+ <string name="test_connection_desc">Testing connection to %1$s</string>
834+ <string name="test_connection_success">Connected (%1$dms)</string>
835+ <string name="test_connection_failed">Failed: %1$s</string>
798836</resources>
app/src/test/java/soy/iko/opencode/UxTest.kt+23−10
@@ -5,34 +5,47 @@ import org.junit.Assert.assertTrue
55import org.junit.Test
66import soy.iko.opencode.data.model.Session
77import soy.iko.opencode.data.model.TimeInfo
8−import soy.iko.opencode.ui.components.relativeTime
8+import soy.iko.opencode.ui.components.RelativeTimeUnit
9+import soy.iko.opencode.ui.components.relativeTimeUnit
910import soy.iko.opencode.ui.session.SessionListState
1011import java.util.concurrent.TimeUnit
1112
1213class UxTest {
1314
14− // --- relativeTime ---
15+ // --- relativeTimeUnit (pure logic, locale-independent) ---
1516
1617 @Test
1718 fun relativeTimeHandlesNullAndZero() {
18− assertEquals("", relativeTime(null))
19− assertEquals("", relativeTime(0))
20− assertEquals("", relativeTime(-1))
19+ assertEquals(null, relativeTimeUnit(null))
20+ assertEquals(null, relativeTimeUnit(0))
21+ assertEquals(null, relativeTimeUnit(-1))
2122 }
2223
2324 @Test
2425 fun relativeTimeFormatsMinutes() {
2526 val now = System.currentTimeMillis()
26− assertEquals("now", relativeTime(now))
27− assertEquals("5m", relativeTime(now - TimeUnit.MINUTES.toMillis(5)))
27+ assertEquals(RelativeTimeUnit.Now(), relativeTimeUnit(now))
28+ assertEquals(
29+ RelativeTimeUnit.Minutes(5),
30+ relativeTimeUnit(now - TimeUnit.MINUTES.toMillis(5)),
31+ )
2832 }
2933
3034 @Test
3135 fun relativeTimeFormatsHoursAndDays() {
3236 val now = System.currentTimeMillis()
33− assertEquals("3h", relativeTime(now - TimeUnit.HOURS.toMillis(3)))
34− assertEquals("2d", relativeTime(now - TimeUnit.DAYS.toMillis(2)))
35− assertEquals("1w", relativeTime(now - TimeUnit.DAYS.toMillis(7)))
37+ assertEquals(
38+ RelativeTimeUnit.Hours(3),
39+ relativeTimeUnit(now - TimeUnit.HOURS.toMillis(3)),
40+ )
41+ assertEquals(
42+ RelativeTimeUnit.Days(2),
43+ relativeTimeUnit(now - TimeUnit.DAYS.toMillis(2)),
44+ )
45+ assertEquals(
46+ RelativeTimeUnit.Weeks(1),
47+ relativeTimeUnit(now - TimeUnit.DAYS.toMillis(7)),
48+ )
3649 }
3750
3851 // --- Session search/filter ---
app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+5
@@ -392,6 +392,9 @@ class FakeAppContainer : AppContainer() {
392392 val fakeSessionPrefsStore = FakeSessionPrefsStore()
393393 val fakeAttachmentDraftStore = FakeAttachmentDraftStore()
394394 val fakeMessageCacheStore = FakeMessageCacheStore()
395+ // In-memory search history (no Context / disk).
396+ val fakeSearchHistoryStore = object : soy.iko.opencode.data.repo.SearchHistoryStore() {}
397+ val fakeRecentModelsStore = object : soy.iko.opencode.data.repo.RecentModelsStore() {}
395398 // In-memory outbox (no Context / disk); exercises the same reduce logic the app uses.
396399 val fakeOutboxStore = object : OutboxStore() {}
397400 val fakeUnread = MutableStateFlow<Map<String, Int>>(emptyMap())
@@ -402,6 +405,8 @@ class FakeAppContainer : AppContainer() {
402405 override val sessionPrefsStore: SessionPrefsStore = fakeSessionPrefsStore
403406 override val attachmentDraftStore: AttachmentDraftStore = fakeAttachmentDraftStore
404407 override val messageCacheStore: MessageCacheStore = fakeMessageCacheStore
408+ override val searchHistoryStore: soy.iko.opencode.data.repo.SearchHistoryStore = fakeSearchHistoryStore
409+ override val recentModelsStore: soy.iko.opencode.data.repo.RecentModelsStore = fakeRecentModelsStore
405410 override val outboxStore: OutboxStore = fakeOutboxStore
406411 override val activeConnection: StateFlow<OpencodeConnection?> = fakeActiveConnection.asStateFlow()
407412 override val unread: StateFlow<Map<String, Int>> = fakeUnread.asStateFlow()