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)
61cdc75iko
app/detekt-baseline.xml+4−5
@@ -7,22 +7,21 @@
7 <ID>ComplexCondition:SessionListViewModel.kt$SessionListState$q.isEmpty() &amp;&amp; pinnedIds.isEmpty() &amp;&amp; noArchivedHidden &amp;&amp; directoryFilter == null</ID>
7 <ID>ComplexCondition:SessionListViewModel.kt$SessionListState$q.isEmpty() &amp;&amp; pinnedIds.isEmpty() &amp;&amp; noArchivedHidden &amp;&amp; directoryFilter == null</ID>
8 <ID>CyclomaticComplexMethod:AppContainer.kt$AppContainer$@OptIn(ExperimentalCoroutinesApi::class) private fun observeMessageActivity()</ID>
8 <ID>CyclomaticComplexMethod:AppContainer.kt$AppContainer$@OptIn(ExperimentalCoroutinesApi::class) private fun observeMessageActivity()</ID>
9 <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>
9 <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>
11 <ID>CyclomaticComplexMethod:ChatViewModel.kt$ChatViewModel$private fun send(text: String, includeAttachments: Boolean, idempotencyKey: String? = null): Boolean</ID>
11 <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>
13 <ID>CyclomaticComplexMethod:CodeHighlighter.kt$fun highlightLine(line: String, syntax: FileSyntax, palette: HighlightPalette, state: BlockCommentState): AnnotatedString</ID>
12 <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>
14 <ID>CyclomaticComplexMethod:ConversationExport.kt$fun buildConversationMarkdown(messages: List&lt;MessageWithParts&gt;, title: String?): String</ID>
14 <ID>CyclomaticComplexMethod:ConversationExport.kt$fun buildConversationMarkdown(messages: List&lt;MessageWithParts&gt;, title: String?): String</ID>
15 <ID>CyclomaticComplexMethod:DiagnosticsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun DiagnosticsScreen(onBack: () -&gt; Unit)</ID>
15 <ID>CyclomaticComplexMethod:DiagnosticsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun DiagnosticsScreen(onBack: () -&gt; Unit)</ID>
16 <ID>CyclomaticComplexMethod:EventStreamClient.kt$EventStreamClient$private fun stream(): Flow&lt;BusEvent&gt;</ID>
16 <ID>CyclomaticComplexMethod:EventStreamClient.kt$EventStreamClient$private fun stream(): Flow&lt;BusEvent&gt;</ID>
17 <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>
17 <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>
18 <ID>CyclomaticComplexMethod:FileViewScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun FileViewScreen( container: AppContainer, path: String, onBack: () -&gt; Unit, initialLine: Int? = null, )</ID>
18 <ID>CyclomaticComplexMethod:FileViewScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun FileViewScreen( container: AppContainer, path: String, onBack: () -&gt; Unit, initialLine: Int? = null, )</ID>
19 <ID>CyclomaticComplexMethod:MainActivity.kt$MainActivity$private fun handleIntent(intent: Intent?)</ID>
19 <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>
21 <ID>CyclomaticComplexMethod:OpencodeApp.kt$@Composable fun OpencodeApp(container: AppContainer)</ID>
20 <ID>CyclomaticComplexMethod:OpencodeApp.kt$@Composable fun OpencodeApp(container: AppContainer)</ID>
22 <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>
21 <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>
23 <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>
22 <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>
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>
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>
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>
26 <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>
25 <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>
27 <ID>LargeClass:AppContainer.kt$AppContainer</ID>
26 <ID>LargeClass:AppContainer.kt$AppContainer</ID>
28 <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
27 <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
11import okhttp3.OkHttpClient
11import okhttp3.OkHttpClient
12import okhttp3.Request
12import okhttp3.Request
13import soy.iko.opencode.data.model.ServerProfile
13import soy.iko.opencode.data.model.ServerProfile
14−import soy.iko.opencode.data.network.HttpClientFactory
14+import soy.iko.opencode.data.network.CertPinUtil
15import soy.iko.opencode.data.repo.CrashLogger
15import soy.iko.opencode.data.repo.CrashLogger
16import soy.iko.opencode.di.AppContainer
16import soy.iko.opencode.di.AppContainer
17import java.util.concurrent.atomic.AtomicBoolean
17import java.util.concurrent.atomic.AtomicBoolean
@@ -97,12 +97,12 @@ private class PinnedImageCallFactory(private val container: AppContainer) : Call
97 clientFor(container.activeConnection.value?.profile).newCall(request)
97 clientFor(container.activeConnection.value?.profile).newCall(request)
98
98
99 private fun clientFor(profile: ServerProfile?): OkHttpClient {
99 private fun clientFor(profile: ServerProfile?): OkHttpClient {
100− val pins = parsePins(profile?.certPin)
100+ val pins = CertPinUtil.parseCertPins(profile?.certPin)
101 if (pins.isEmpty()) return base
101 if (pins.isEmpty()) return base
102 // effectiveBaseUrl upgrades http->https when pinning is on, so the pinned host matches the
102 // effectiveBaseUrl upgrades http->https when pinning is on, so the pinned host matches the
103 // https origin RemoteImage resolves image URLs against. Fail closed on an unparseable host
103 // https origin RemoteImage resolves image URLs against. Fail closed on an unparseable host
104 // (never fall back to an unpinned client), mirroring HttpClientFactory.create().
104 // (never fall back to an unpinned client), mirroring HttpClientFactory.create().
105− val effectiveUrl = effectiveBaseUrl(profile!!)
105+ val effectiveUrl = CertPinUtil.effectiveBaseUrl(profile!!)
106 val host = effectiveUrl.toHttpUrlOrNull()?.host
106 val host = effectiveUrl.toHttpUrlOrNull()?.host
107 ?: throw IllegalStateException(
107 ?: throw IllegalStateException(
108 "Certificate pinning is configured but the server host could not be parsed from $effectiveUrl",
108 "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
117 return cachedClient
117 return cachedClient
118 }
118 }
119 }
119 }
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− }
136}
120}
app/src/main/java/soy/iko/opencode/data/model/Part.kt+10
@@ -95,6 +95,16 @@ data class StepStartPart(
95 override val sessionID: String? = null,
95 override val sessionID: String? = null,
96) : Part
96) : Part
97
97
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+
98@Immutable
108@Immutable
99@Serializable
109@Serializable
100@SerialName("step-finish")
110@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
28object HttpClientFactory {
28object HttpClientFactory {
29
29
30 fun create(profile: ServerProfile): HttpClient {
30 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)
35 val isHttps = normalizedUrl.lowercase().startsWith("https://")
32 val isHttps = normalizedUrl.lowercase().startsWith("https://")
36 // Parse the host with OkHttp's own parser (not java.net.URI, which rejects hosts OkHttp
33 // Parse the host with OkHttp's own parser (not java.net.URI, which rejects hosts OkHttp
37 // accepts, e.g. underscores in "my_server.local"). If pinning is configured over HTTPS
34 // accepts, e.g. underscores in "my_server.local"). If pinning is configured over HTTPS
38 // but the host can't be parsed, fail closed: never build an unpinned client — that would
35 // but the host can't be parsed, fail closed: never build an unpinned client — that would
39 // be a silent security downgrade for a user who opted into pinning.
36 // be a silent security downgrade for a user who opted into pinning.
40 val pinHost = normalizedUrl.toHttpUrlOrNull()?.host
37 val pinHost = normalizedUrl.toHttpUrlOrNull()?.host
41− val pins = parsePins(profile.certPin)
38+ val pins = CertPinUtil.parseCertPins(profile.certPin)
42 if (pins.isNotEmpty() && isHttps && pinHost == null) {
39 if (pins.isNotEmpty() && isHttps && pinHost == null) {
43 throw IllegalStateException(
40 throw IllegalStateException(
44 "Certificate pinning is configured but the server host could not be parsed from $normalizedUrl",
41 "Certificate pinning is configured but the server host could not be parsed from $normalizedUrl",
@@ -152,26 +149,6 @@ object HttpClientFactory {
152 }
149 }
153 }
150 }
154
151
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−
175 /** Ensure a scheme and a single trailing slash so relative paths resolve correctly.
152 /** Ensure a scheme and a single trailing slash so relative paths resolve correctly.
176 * Defaults to [https] when no scheme is given so credentials aren't accidentally
153 * Defaults to [https] when no scheme is given so credentials aren't accidentally
177 * sent over cleartext. Users who need plain HTTP must type the scheme explicitly. */
154 * 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
32import soy.iko.opencode.data.repo.MessageCacheStore
32import soy.iko.opencode.data.repo.MessageCacheStore
33import soy.iko.opencode.data.repo.OutboxMessage
33import soy.iko.opencode.data.repo.OutboxMessage
34import soy.iko.opencode.data.repo.OutboxStore
34import soy.iko.opencode.data.repo.OutboxStore
35+import soy.iko.opencode.data.repo.RecentModelsStore
36+import soy.iko.opencode.data.repo.SearchHistoryStore
35import soy.iko.opencode.data.repo.SessionPrefsStore
37import soy.iko.opencode.data.repo.SessionPrefsStore
36import soy.iko.opencode.data.repo.SessionRepository
38import soy.iko.opencode.data.repo.SessionRepository
37import soy.iko.opencode.data.repo.ProfileStore
39import soy.iko.opencode.data.repo.ProfileStore
@@ -113,6 +115,8 @@ open class AppContainer private constructor(
113 open val sessionPrefsStore: SessionPrefsStore by lazy { SessionPrefsStore(appContext!!) }
115 open val sessionPrefsStore: SessionPrefsStore by lazy { SessionPrefsStore(appContext!!) }
114 open val messageCacheStore: MessageCacheStore by lazy { MessageCacheStore(appContext!!) }
116 open val messageCacheStore: MessageCacheStore by lazy { MessageCacheStore(appContext!!) }
115 open val outboxStore: OutboxStore by lazy { OutboxStore(appContext!!) }
117 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!!) }
116 open val backupManager: BackupManager by lazy { BackupManager(profileStore, settingsStore, sessionPrefsStore) }
120 open val backupManager: BackupManager by lazy { BackupManager(profileStore, settingsStore, sessionPrefsStore) }
117
121
118 /** True while the outbox is actively flushing queued messages to the server, so the UI
122 /** True while the outbox is actively flushing queued messages to the server, so the UI
@@ -245,6 +249,12 @@ open class AppContainer private constructor(
245 private val _anyRunActive = MutableStateFlow(false)
249 private val _anyRunActive = MutableStateFlow(false)
246 open val anyRunActive: StateFlow<Boolean> = _anyRunActive.asStateFlow()
250 open val anyRunActive: StateFlow<Boolean> = _anyRunActive.asStateFlow()
247
251
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+
248 /** Latest task-plan progress text for the active run (e.g. "Step 2 of 5"), surfaced in
258 /** Latest task-plan progress text for the active run (e.g. "Step 2 of 5"), surfaced in
249 * the foreground-service notification so the user can see what the agent is working on
259 * the foreground-service notification so the user can see what the agent is working on
250 * without opening the app. Null when no plan is available or no run is active. */
260 * without opening the app. Null when no plan is available or no run is active. */
@@ -468,7 +478,7 @@ open class AppContainer private constructor(
468 if (hasConnectedBefore) {
478 if (hasConnectedBefore) {
469 synchronized(activeRuns) {
479 synchronized(activeRuns) {
470 activeRuns.clear()
480 activeRuns.clear()
471− _anyRunActive.value = false
481+ publishRunState()
472 }
482 }
473 }
483 }
474 hasConnectedBefore = true
484 hasConnectedBefore = true
@@ -653,7 +663,7 @@ open class AppContainer private constructor(
653 val esid = event.properties.sessionID ?: return@collect
663 val esid = event.properties.sessionID ?: return@collect
654 val wasRunning = synchronized(activeRuns) {
664 val wasRunning = synchronized(activeRuns) {
655 activeRuns.remove(esid).also { removed ->
665 activeRuns.remove(esid).also { removed ->
656− if (removed) _anyRunActive.value = activeRuns.isNotEmpty()
666+ if (removed) publishRunState()
657 }
667 }
658 }
668 }
659 if (wasRunning && !isActivelyViewing(esid)) {
669 if (wasRunning && !isActivelyViewing(esid)) {
@@ -673,7 +683,7 @@ open class AppContainer private constructor(
673 // the completion *notification* is suppressed for the viewed one.
683 // the completion *notification* is suppressed for the viewed one.
674 val wasRunning = synchronized(activeRuns) {
684 val wasRunning = synchronized(activeRuns) {
675 activeRuns.remove(idleSid).also { removed ->
685 activeRuns.remove(idleSid).also { removed ->
676− if (removed) _anyRunActive.value = activeRuns.isNotEmpty()
686+ if (removed) publishRunState()
677 }
687 }
678 }
688 }
679 // Clear the progress text when the run finishes.
689 // Clear the progress text when the run finishes.
@@ -750,7 +760,7 @@ open class AppContainer private constructor(
750 // guards the set, so a concurrent reconnect sweep (which clears
760 // guards the set, so a concurrent reconnect sweep (which clears
751 // both under this lock) can't interleave and leave the flag pinned
761 // both under this lock) can't interleave and leave the flag pinned
752 // true with an empty set — a stuck foreground service + "working…".
762 // true with an empty set — a stuck foreground service + "working…".
753− _anyRunActive.value = activeRuns.isNotEmpty()
763+ publishRunState()
754 }
764 }
755 }
765 }
756 } }.onFailure { Log.w("AppContainer", "Message activity observer failed, will retry: ${safeExceptionSummary(it)}") }
766 } }.onFailure { Log.w("AppContainer", "Message activity observer failed, will retry: ${safeExceptionSummary(it)}") }
@@ -784,6 +794,15 @@ open class AppContainer private constructor(
784 return part !is StepFinishPart
794 return part !is StepFinishPart
785 }
795 }
786
796
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+
787 /** Session ids currently streaming an assistant run (best-effort, in-process). Backed by
806 /** Session ids currently streaming an assistant run (best-effort, in-process). Backed by
788 * an access-order [java.util.LinkedHashMap] so re-adding an already-present id on each
807 * an access-order [java.util.LinkedHashMap] so re-adding an already-present id on each
789 * streaming event refreshes its recency (a plain LinkedHashSet.add is a no-op that does
808 * streaming event refreshes its recency (a plain LinkedHashSet.add is a no-op that does
@@ -1127,7 +1146,7 @@ open class AppContainer private constructor(
1127 // down connection and no SessionIdle to clear it — a stuck foreground service.
1146 // down connection and no SessionIdle to clear it — a stuck foreground service.
1128 synchronized(activeRuns) {
1147 synchronized(activeRuns) {
1129 activeRuns.clear()
1148 activeRuns.clear()
1130− _anyRunActive.value = false
1149+ publishRunState()
1131 }
1150 }
1132 if (previousProfileId != profile.id) {
1151 if (previousProfileId != profile.id) {
1133 _unread.value = emptyMap()
1152 _unread.value = emptyMap()
@@ -1171,7 +1190,7 @@ open class AppContainer private constructor(
1171 // re-pin _anyRunActive after the clear.
1190 // re-pin _anyRunActive after the clear.
1172 synchronized(activeRuns) {
1191 synchronized(activeRuns) {
1173 activeRuns.clear()
1192 activeRuns.clear()
1174− _anyRunActive.value = false
1193+ publishRunState()
1175 }
1194 }
1176 _unread.value = emptyMap()
1195 _unread.value = emptyMap()
1177 unreadMessageIds.clear()
1196 unreadMessageIds.clear()
app/src/main/java/soy/iko/opencode/ui/AppLockGate.kt+5−5
@@ -16,7 +16,6 @@ import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.filled.Fingerprint
16import androidx.compose.material.icons.filled.Fingerprint
17import androidx.compose.material.icons.filled.Lock
17import androidx.compose.material.icons.filled.Lock
18import androidx.compose.material3.Button
18import androidx.compose.material3.Button
19−import androidx.compose.material3.CircularProgressIndicator
20import androidx.compose.material3.Icon
19import androidx.compose.material3.Icon
21import androidx.compose.material3.MaterialTheme
20import androidx.compose.material3.MaterialTheme
22import androidx.compose.material3.Surface
21import androidx.compose.material3.Surface
@@ -44,6 +43,8 @@ import androidx.lifecycle.Lifecycle
44import androidx.lifecycle.LifecycleEventObserver
43import androidx.lifecycle.LifecycleEventObserver
45import androidx.lifecycle.compose.LocalLifecycleOwner
44import androidx.lifecycle.compose.LocalLifecycleOwner
46import soy.iko.opencode.R
45import soy.iko.opencode.R
46+import soy.iko.opencode.ui.components.LoadingSize
47+import soy.iko.opencode.ui.components.LoadingSpinner
47
48
48/**
49/**
49 * The set of authenticators the app lock accepts. Device credential (PIN/pattern/password)
50 * 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
211 // Within the grace period — stay unlocked, no re-prompt.
212 // Within the grace period — stay unlocked, no re-prompt.
212 } else {
213 } else {
213 unlocked = false
214 unlocked = false
214− if (!unlocked) authenticate()
215+ authenticate()
215 }
216 }
216 }
217 }
217 else -> {}
218 else -> {}
@@ -285,9 +286,8 @@ fun AppLockGate(enabled: Boolean, reLockDelaySeconds: Int = 0, content: @Composa
285 modifier = Modifier.padding(top = 24.dp),
286 modifier = Modifier.padding(top = 24.dp),
286 ) {
287 ) {
287 if (authInFlight) {
288 if (authInFlight) {
288− CircularProgressIndicator(
289+ LoadingSpinner(
289− modifier = Modifier.size(18.dp),
290+ size = LoadingSize.Small,
290− strokeWidth = 2.dp,
291 color = MaterialTheme.colorScheme.primary,
291 color = MaterialTheme.colorScheme.primary,
292 )
292 )
293 Spacer(Modifier.size(8.dp))
293 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
22import androidx.compose.runtime.saveable.rememberSaveable
22import androidx.compose.runtime.saveable.rememberSaveable
23import androidx.compose.runtime.setValue
23import androidx.compose.runtime.setValue
24import androidx.compose.ui.Modifier
24import 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
25import androidx.compose.ui.platform.LocalContext
31import androidx.compose.ui.platform.LocalContext
26import androidx.compose.ui.platform.LocalDensity
32import androidx.compose.ui.platform.LocalDensity
27import androidx.compose.ui.unit.dp
33import androidx.compose.ui.unit.dp
@@ -56,6 +62,7 @@ import soy.iko.opencode.ui.settings.DiagnosticsScreen
56import soy.iko.opencode.ui.settings.SettingsScreen
62import soy.iko.opencode.ui.settings.SettingsScreen
57import soy.iko.opencode.ui.usage.UsageScreen
63import soy.iko.opencode.ui.usage.UsageScreen
58import soy.iko.opencode.ui.mcp.McpScreen
64import soy.iko.opencode.ui.mcp.McpScreen
65+import soy.iko.opencode.util.findActivity
59import soy.iko.opencode.util.runCatchingCancellable
66import soy.iko.opencode.util.runCatchingCancellable
60
67
61@Composable
68@Composable
@@ -193,7 +200,35 @@ fun OpencodeApp(container: AppContainer) {
193 }
200 }
194 }
201 }
195 val isHalfOpened = foldState?.state == androidx.window.layout.FoldingFeature.State.HALF_OPENED
202 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+ ) {
197 val isTwoPane = maxWidth >= NetworkConfig.twoPaneWidthThresholdDp.dp &&
232 val isTwoPane = maxWidth >= NetworkConfig.twoPaneWidthThresholdDp.dp &&
198 connection != null &&
233 connection != null &&
199 !isHalfOpened
234 !isHalfOpened
@@ -482,10 +517,17 @@ fun OpencodeApp(container: AppContainer) {
482 }
517 }
483
518
484 if (showNavRail) {
519 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() }
485 Row(modifier = Modifier.fillMaxSize()) {
525 Row(modifier = Modifier.fillMaxSize()) {
486 LargeScreenNavRail(
526 LargeScreenNavRail(
487 currentRoute = currentRoute,
527 currentRoute = currentRoute,
488 connected = connection != null,
528 connected = connection != null,
529+ unreadCount = totalUnread,
530+ runActive = anyRunActive,
489 // Use the standard M3 nav-bar pattern: popUpTo the start destination
531 // Use the standard M3 nav-bar pattern: popUpTo the start destination
490 // saving state, and restoreState on re-entry, so switching between
532 // saving state, and restoreState on re-entry, so switching between
491 // top-level tabs via the rail preserves each tab's scroll/selection
533 // top-level tabs via the rail preserves each tab's scroll/selection
@@ -509,12 +551,3 @@ fun OpencodeApp(container: AppContainer) {
509 }
551 }
510 }
552 }
511}
553}
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(
49 attachments: List<PendingAttachment>,
49 attachments: List<PendingAttachment>,
50 onRemove: (String) -> Unit,
50 onRemove: (String) -> Unit,
51 staging: Boolean = false,
51 staging: Boolean = false,
52+ stagingFileCount: Int = 0,
52) {
53) {
53 // Show the strip while an encode is in flight even before the first chip exists, so the
54 // Show the strip while an encode is in flight even before the first chip exists, so the
54 // user gets immediate feedback that their pick is being processed.
55 // user gets immediate feedback that their pick is being processed.
@@ -66,14 +67,20 @@ internal fun AttachmentStrip(
66 }
67 }
67 // A placeholder chip with an indeterminate spinner while any pick is still being
68 // A placeholder chip with an indeterminate spinner while any pick is still being
68 // read + base64-encoded off the main thread (chips only materialize once done).
69 // read + base64-encoded off the main thread (chips only materialize once done).
69− if (staging) StagingChip()
70+ if (staging) StagingChip(stagingFileCount)
70 }
71 }
71}
72}
72
73
73/** A placeholder chip shown while an attachment is being encoded. */
74/** A placeholder chip shown while an attachment is being encoded. */
74@Composable
75@Composable
75−private fun StagingChip() {
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 {
76− val stagingLabel = stringResource(R.string.attachment_staging)
82+ stringResource(R.string.attachment_staging)
83+ }
77 Surface(
84 Surface(
78 shape = MaterialTheme.shapes.small,
85 shape = MaterialTheme.shapes.small,
79 color = MaterialTheme.colorScheme.surfaceVariant,
86 color = MaterialTheme.colorScheme.surfaceVariant,
@@ -84,11 +91,11 @@ private fun StagingChip() {
84 modifier = Modifier.padding(horizontal = 10.dp),
91 modifier = Modifier.padding(horizontal = 10.dp),
85 ) {
92 ) {
86 CircularProgressIndicator(
93 CircularProgressIndicator(
87− Modifier.size(18.dp).semantics { contentDescription = stagingLabel },
94+ Modifier.size(18.dp).semantics { contentDescription = label },
88 strokeWidth = 2.dp,
95 strokeWidth = 2.dp,
89 )
96 )
90 Text(
97 Text(
91− stagingLabel,
98+ label,
92 style = MaterialTheme.typography.labelMedium,
99 style = MaterialTheme.typography.labelMedium,
93 maxLines = 1,
100 maxLines = 1,
94 modifier = Modifier.padding(start = 8.dp, top = 10.dp, bottom = 10.dp),
101 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(
222 val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
222 val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
223 val sendOnEnter by container.settingsStore.sendOnEnter.collectAsStateWithLifecycle(initialValue = true)
223 val sendOnEnter by container.settingsStore.sendOnEnter.collectAsStateWithLifecycle(initialValue = true)
224 val preferredModelId by container.settingsStore.preferredModelId.collectAsStateWithLifecycle(initialValue = "")
224 val preferredModelId by container.settingsStore.preferredModelId.collectAsStateWithLifecycle(initialValue = "")
225+ val recentModelEntries by container.recentModelsStore.entries.collectAsStateWithLifecycle()
225 val compactSpacing by container.settingsStore.compactMessageSpacing.collectAsStateWithLifecycle(initialValue = false)
226 val compactSpacing by container.settingsStore.compactMessageSpacing.collectAsStateWithLifecycle(initialValue = false)
226 val isOnline by container.isOnline.collectAsStateWithLifecycle()
227 val isOnline by container.isOnline.collectAsStateWithLifecycle()
227 val haptics = LocalHapticFeedback.current
228 val haptics = LocalHapticFeedback.current
@@ -382,6 +383,11 @@ fun ChatScreen(
382 // Count of picks currently being read + base64-encoded off the main thread, so the
383 // Count of picks currently being read + base64-encoded off the main thread, so the
383 // composer can show a staging placeholder immediately (chips only materialize once done).
384 // composer can show a staging placeholder immediately (chips only materialize once done).
384 var stagingCount by remember { mutableStateOf(0) }
385 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) }
385
391
386 // Convert each picked Uri to a base64 attachment off the main thread, honoring the
392 // Convert each picked Uri to a base64 attachment off the main thread, honoring the
387 // per-prompt count cap and surfacing per-file errors without aborting the batch.
393 // per-prompt count cap and surfacing per-file errors without aborting the batch.
@@ -389,17 +395,22 @@ fun ChatScreen(
389 if (uris.isEmpty()) return
395 if (uris.isEmpty()) return
390 scope.launch {
396 scope.launch {
391 stagingCount++
397 stagingCount++
398+ stagingFileTotal += uris.size
392 try {
399 try {
393− for (uri in uris) {
400+ uris.forEachIndexed { index, uri ->
394 if (vm.attachments.value.size >= NetworkConfig.maxAttachments) {
401 if (vm.attachments.value.size >= NetworkConfig.maxAttachments) {
395 snackbar.showSnackbar(attachLimitMsg)
402 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
397 }
407 }
398 when (val result = uri.toAttachmentResult(appContext)) {
408 when (val result = uri.toAttachmentResult(appContext)) {
399 is AttachmentResult.Ok -> vm.addAttachment(result.attachment)
409 is AttachmentResult.Ok -> vm.addAttachment(result.attachment)
400 AttachmentResult.TooLarge -> snackbar.showSnackbar(attachTooLargeMsg)
410 AttachmentResult.TooLarge -> snackbar.showSnackbar(attachTooLargeMsg)
401 AttachmentResult.Failed -> snackbar.showSnackbar(attachFailedMsg)
411 AttachmentResult.Failed -> snackbar.showSnackbar(attachFailedMsg)
402 }
412 }
413+ stagingFileTotal--
403 }
414 }
404 } finally {
415 } finally {
405 stagingCount--
416 stagingCount--
@@ -637,6 +648,7 @@ fun ChatScreen(
637 val moreLabel = stringResource(R.string.more)
648 val moreLabel = stringResource(R.string.more)
638 val loadingLabel = stringResource(R.string.loading)
649 val loadingLabel = stringResource(R.string.loading)
639 val shareLabel = stringResource(R.string.share_conversation)
650 val shareLabel = stringResource(R.string.share_conversation)
651+ val copyAsMarkdownLabel = stringResource(R.string.copy_as_markdown)
640 val commandsLabel = stringResource(R.string.commands)
652 val commandsLabel = stringResource(R.string.commands)
641 val renameLabel = stringResource(R.string.rename_session_chat)
653 val renameLabel = stringResource(R.string.rename_session_chat)
642 val deleteLabel = stringResource(R.string.delete_session_chat)
654 val deleteLabel = stringResource(R.string.delete_session_chat)
@@ -673,6 +685,21 @@ fun ChatScreen(
673 }
685 }
674 },
686 },
675 )
687 )
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+ )
676 DropdownMenuItem(
703 DropdownMenuItem(
677 text = { Text(commandsLabel) },
704 text = { Text(commandsLabel) },
678 enabled = !commandsLoading || commands.isNotEmpty(),
705 enabled = !commandsLoading || commands.isNotEmpty(),
@@ -811,6 +838,7 @@ fun ChatScreen(
811 focusRequester = inputFocusRequester,
838 focusRequester = inputFocusRequester,
812 attachments = attachments,
839 attachments = attachments,
813 staging = stagingCount > 0,
840 staging = stagingCount > 0,
841+ stagingFileCount = stagingFileTotal,
814 onRemoveAttachment = vm::removeAttachment,
842 onRemoveAttachment = vm::removeAttachment,
815 onPickPhoto = {
843 onPickPhoto = {
816 photoPicker.launch(
844 photoPicker.launch(
@@ -1199,6 +1227,11 @@ fun ChatScreen(
1199 (message.info as? soy.iko.opencode.data.model.AssistantMessage)
1227 (message.info as? soy.iko.opencode.data.model.AssistantMessage)
1200 ?.let { resolveModelLabel(it, models) }
1228 ?.let { resolveModelLabel(it, models) }
1201 }
1229 }
1230+ val agentLabel = remember(message.parts) {
1231+ message.parts
1232+ .filterIsInstance<soy.iko.opencode.data.model.AgentPart>()
1233+ .firstOrNull { it.name.isNotBlank() }?.name
1234+ }
1202 // Text used to drive swipe-to-reply (and pre-fill the quote). Empty for
1235 // Text used to drive swipe-to-reply (and pre-fill the quote). Empty for
1203 // image/code-only messages, which then opt out of the swipe gesture.
1236 // image/code-only messages, which then opt out of the swipe gesture.
1204 val quoteText = remember(message.parts) {
1237 val quoteText = remember(message.parts) {
@@ -1244,6 +1277,7 @@ fun ChatScreen(
1244 isRunning = running && message.info.id == lastMessageId,
1277 isRunning = running && message.info.id == lastMessageId,
1245 imageContext = imageContext,
1278 imageContext = imageContext,
1246 modelLabel = modelLabel,
1279 modelLabel = modelLabel,
1280+ agentLabel = agentLabel,
1247 onOpenFile = onOpenFile,
1281 onOpenFile = onOpenFile,
1248 onRevert = { vm.revertTo(message.info.id) },
1282 onRevert = { vm.revertTo(message.info.id) },
1249 onEdit = { text ->
1283 onEdit = { text ->
@@ -1273,6 +1307,7 @@ fun ChatScreen(
1273 ttsState = if (message.info.id == speakingMessageId) ttsState else TtsState.IDLE,
1307 ttsState = if (message.info.id == speakingMessageId) ttsState else TtsState.IDLE,
1274 onPause = { tts.pause() },
1308 onPause = { tts.pause() },
1275 onResume = { tts.resume() },
1309 onResume = { tts.resume() },
1310+ onStop = { tts.stop() },
1276 onQuote = { text ->
1311 onQuote = { text ->
1277 vm.quoteReply(text)
1312 vm.quoteReply(text)
1278 runCatching { inputFocusRequester.requestFocus() }
1313 runCatching { inputFocusRequester.requestFocus() }
@@ -1682,18 +1717,31 @@ fun ChatScreen(
1682 }
1717 }
1683
1718
1684 if (showModelPicker) {
1719 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+ }
1685 ModelPickerSheet(
1729 ModelPickerSheet(
1686 options = models,
1730 options = models,
1687 selected = selectedModel,
1731 selected = selectedModel,
1688 loading = modelsLoading,
1732 loading = modelsLoading,
1689 error = modelsError,
1733 error = modelsError,
1734+ onSelect = {
1690− onSelect = { vm.selectModel(it) },
1735+ vm.selectModel(it)
1736+ container.recentModelsStore.add(it.providerID, it.modelID)
1737+ },
1691 onRetry = { vm.reloadModels() },
1738 onRetry = { vm.reloadModels() },
1692 onDismiss = { showModelPicker = false },
1739 onDismiss = { showModelPicker = false },
1693 preferredModelId = preferredModelId,
1740 preferredModelId = preferredModelId,
1694 onSetPreferredModel = { id ->
1741 onSetPreferredModel = { id ->
1695 scope.launch { runCatchingCancellable { container.settingsStore.setPreferredModelId(id) } }
1742 scope.launch { runCatchingCancellable { container.settingsStore.setPreferredModelId(id) } }
1696 },
1743 },
1744+ recent = recentOptions,
1697 )
1745 )
1698 }
1746 }
1699
1747
@@ -1790,6 +1838,7 @@ private fun ChatInputBar(
1790 focusRequester: androidx.compose.ui.focus.FocusRequester,
1838 focusRequester: androidx.compose.ui.focus.FocusRequester,
1791 attachments: List<PendingAttachment>,
1839 attachments: List<PendingAttachment>,
1792 staging: Boolean,
1840 staging: Boolean,
1841+ stagingFileCount: Int,
1793 onRemoveAttachment: (String) -> Unit,
1842 onRemoveAttachment: (String) -> Unit,
1794 onPickPhoto: () -> Unit,
1843 onPickPhoto: () -> Unit,
1795 onPickFile: () -> Unit,
1844 onPickFile: () -> Unit,
@@ -1845,7 +1894,7 @@ private fun ChatInputBar(
1845 }
1894 }
1846 }
1895 }
1847 // Staged attachments: horizontally-scrollable thumbnails/chips, each removable.
1896 // Staged attachments: horizontally-scrollable thumbnails/chips, each removable.
1848− AttachmentStrip(attachments, onRemove = onRemoveAttachment, staging = staging)
1897+ AttachmentStrip(attachments, onRemove = onRemoveAttachment, staging = staging, stagingFileCount = stagingFileCount)
1849 Row(
1898 Row(
1850 modifier = Modifier.fillMaxWidth().padding(8.dp),
1899 modifier = Modifier.fillMaxWidth().padding(8.dp),
1851 verticalAlignment = Alignment.Bottom,
1900 verticalAlignment = Alignment.Bottom,
@@ -2015,6 +2064,7 @@ private fun ChatInputBar(
2015 attachments = attachments,
2064 attachments = attachments,
2016 onRemoveAttachment = onRemoveAttachment,
2065 onRemoveAttachment = onRemoveAttachment,
2017 staging = staging,
2066 staging = staging,
2067+ stagingFileCount = stagingFileCount,
2018 )
2068 )
2019 }
2069 }
2020 }
2070 }
@@ -2035,6 +2085,7 @@ private fun FullScreenEditor(
2035 attachments: List<PendingAttachment> = emptyList(),
2085 attachments: List<PendingAttachment> = emptyList(),
2036 onRemoveAttachment: (String) -> Unit = {},
2086 onRemoveAttachment: (String) -> Unit = {},
2037 staging: Boolean = false,
2087 staging: Boolean = false,
2088+ stagingFileCount: Int = 0,
2038) {
2089) {
2039 androidx.compose.ui.window.Dialog(
2090 androidx.compose.ui.window.Dialog(
2040 onDismissRequest = onDismiss,
2091 onDismissRequest = onDismiss,
@@ -2079,7 +2130,7 @@ private fun FullScreenEditor(
2079 // Show staged attachments in the full-screen editor too, so a user who staged
2130 // Show staged attachments in the full-screen editor too, so a user who staged
2080 // images then expanded for a long prompt can still see and remove them without
2131 // images then expanded for a long prompt can still see and remove them without
2081 // collapsing first (mirrors the inline composer's AttachmentStrip).
2132 // collapsing first (mirrors the inline composer's AttachmentStrip).
2082− AttachmentStrip(attachments, onRemove = onRemoveAttachment, staging = staging)
2133+ AttachmentStrip(attachments, onRemove = onRemoveAttachment, staging = staging, stagingFileCount = stagingFileCount)
2083 OutlinedTextField(
2134 OutlinedTextField(
2084 value = value,
2135 value = value,
2085 onValueChange = { v -> onValueChange(v.take(NetworkConfig.maxDraftLengthChars)) },
2136 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
29import androidx.compose.material.icons.filled.Restore
29import androidx.compose.material.icons.filled.Restore
30import androidx.compose.material.icons.filled.Share
30import androidx.compose.material.icons.filled.Share
31import androidx.compose.material.icons.filled.SmartToy
31import androidx.compose.material.icons.filled.SmartToy
32+import androidx.compose.material.icons.filled.Stop
32import androidx.compose.material3.DropdownMenu
33import androidx.compose.material3.DropdownMenu
33import androidx.compose.material3.DropdownMenuItem
34import androidx.compose.material3.DropdownMenuItem
34import androidx.compose.material3.CircularProgressIndicator
35import androidx.compose.material3.CircularProgressIndicator
@@ -99,6 +100,7 @@ fun MessageBubble(
99 isRunning: Boolean = false,
100 isRunning: Boolean = false,
100 imageContext: ImageLoadContext? = null,
101 imageContext: ImageLoadContext? = null,
101 modelLabel: String? = null,
102 modelLabel: String? = null,
103+ agentLabel: String? = null,
102 onOpenFile: ((String) -> Unit)? = null,
104 onOpenFile: ((String) -> Unit)? = null,
103 onRevert: (() -> Unit)? = null,
105 onRevert: (() -> Unit)? = null,
104 onEdit: ((String) -> Unit)? = null,
106 onEdit: ((String) -> Unit)? = null,
@@ -107,6 +109,7 @@ fun MessageBubble(
107 ttsState: TtsState = TtsState.IDLE,
109 ttsState: TtsState = TtsState.IDLE,
108 onPause: (() -> Unit)? = null,
110 onPause: (() -> Unit)? = null,
109 onResume: (() -> Unit)? = null,
111 onResume: (() -> Unit)? = null,
112+ onStop: (() -> Unit)? = null,
110 onQuote: ((String) -> Unit)? = null,
113 onQuote: ((String) -> Unit)? = null,
111 onBranch: ((String) -> Unit)? = null,
114 onBranch: ((String) -> Unit)? = null,
112 onRegenerate: (() -> Unit)? = null,
115 onRegenerate: (() -> Unit)? = null,
@@ -119,7 +122,7 @@ fun MessageBubble(
119 when (message.info) {
122 when (message.info) {
120 is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile, onRevert, onEdit, onQuote, onBranch, sendStatus, isEdited, onRetry, onDismiss, onShare)
123 is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile, onRevert, onEdit, onQuote, onBranch, sendStatus, isEdited, onRetry, onDismiss, onShare)
121 is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier, onOpenFile)
124 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)
123 }
126 }
124}
127}
125
128
@@ -612,6 +615,7 @@ private fun AssistantBlock(
612 imageContext: ImageLoadContext?,
615 imageContext: ImageLoadContext?,
613 modifier: Modifier,
616 modifier: Modifier,
614 modelLabel: String? = null,
617 modelLabel: String? = null,
618+ agentLabel: String? = null,
615 onOpenFile: ((String) -> Unit)? = null,
619 onOpenFile: ((String) -> Unit)? = null,
616 onRevert: (() -> Unit)? = null,
620 onRevert: (() -> Unit)? = null,
617 onSpeak: ((String) -> Unit)? = null,
621 onSpeak: ((String) -> Unit)? = null,
@@ -619,6 +623,7 @@ private fun AssistantBlock(
619 ttsState: TtsState = TtsState.IDLE,
623 ttsState: TtsState = TtsState.IDLE,
620 onPause: (() -> Unit)? = null,
624 onPause: (() -> Unit)? = null,
621 onResume: (() -> Unit)? = null,
625 onResume: (() -> Unit)? = null,
626+ onStop: (() -> Unit)? = null,
622 onQuote: ((String) -> Unit)? = null,
627 onQuote: ((String) -> Unit)? = null,
623 onBranch: ((String) -> Unit)? = null,
628 onBranch: ((String) -> Unit)? = null,
624 onRegenerate: (() -> Unit)? = null,
629 onRegenerate: (() -> Unit)? = null,
@@ -652,7 +657,7 @@ private fun AssistantBlock(
652 // Avatar + model label row: the robot icon gives the assistant a consistent visual
657 // Avatar + model label row: the robot icon gives the assistant a consistent visual
653 // identity (left-aligned, mirroring the user's right-aligned bubble) so on a long
658 // identity (left-aligned, mirroring the user's right-aligned bubble) so on a long
654 // scroll the speaker is unambiguous without reading the label.
659 // scroll the speaker is unambiguous without reading the label.
655− AssistantHeader(label = modelLabel ?: info.modelID)
660+ AssistantHeader(label = modelLabel ?: info.modelID, agentLabel = agentLabel)
656 }
661 }
657 message.parts.forEachIndexed { index, part ->
662 message.parts.forEachIndexed { index, part ->
658 // Only the final part of a running message is actively streaming; earlier parts
663 // Only the final part of a running message is actively streaming; earlier parts
@@ -747,6 +752,7 @@ private fun AssistantBlock(
747 onSpeak = onSpeak,
752 onSpeak = onSpeak,
748 onPause = onPause,
753 onPause = onPause,
749 onResume = onResume,
754 onResume = onResume,
755+ onStop = onStop,
750 onRevert = onRevert,
756 onRevert = onRevert,
751 onQuote = onQuote,
757 onQuote = onQuote,
752 onBranch = onBranch,
758 onBranch = onBranch,
@@ -837,9 +843,18 @@ private fun AssistantMessageLongPressMenu(
837}
843}
838
844
839/** Avatar + model label row for an assistant message. Extracted from [AssistantBlock] to keep
845/** 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. */
841@Composable
849@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+ }
843 Row(verticalAlignment = Alignment.CenterVertically) {
858 Row(verticalAlignment = Alignment.CenterVertically) {
844 Box(
859 Box(
845 modifier = Modifier
860 modifier = Modifier
@@ -855,9 +870,9 @@ private fun AssistantHeader(label: String?) {
855 tint = MaterialTheme.colorScheme.onSecondaryContainer,
870 tint = MaterialTheme.colorScheme.onSecondaryContainer,
856 )
871 )
857 }
872 }
858− if (label != null) {
873+ if (combined != null) {
859 Text(
874 Text(
860− label,
875+ combined,
861 style = MaterialTheme.typography.labelSmall,
876 style = MaterialTheme.typography.labelSmall,
862 color = MaterialTheme.colorScheme.onSurfaceVariant,
877 color = MaterialTheme.colorScheme.onSurfaceVariant,
863 maxLines = 1,
878 maxLines = 1,
@@ -880,6 +895,7 @@ private fun AssistantActions(
880 onSpeak: ((String) -> Unit)?,
895 onSpeak: ((String) -> Unit)?,
881 onPause: (() -> Unit)?,
896 onPause: (() -> Unit)?,
882 onResume: (() -> Unit)?,
897 onResume: (() -> Unit)?,
898+ onStop: (() -> Unit)? = null,
883 onRevert: (() -> Unit)?,
899 onRevert: (() -> Unit)?,
884 onQuote: ((String) -> Unit)? = null,
900 onQuote: ((String) -> Unit)? = null,
885 onBranch: ((String) -> Unit)? = null,
901 onBranch: ((String) -> Unit)? = null,
@@ -917,6 +933,7 @@ private fun AssistantActions(
917 onSpeak = onSpeak,
933 onSpeak = onSpeak,
918 onPause = onPause,
934 onPause = onPause,
919 onResume = onResume,
935 onResume = onResume,
936+ onStop = onStop,
920 )
937 )
921 }
938 }
922 onRevert?.let { RevertButton(it) }
939 onRevert?.let { RevertButton(it) }
@@ -940,9 +957,11 @@ private fun AssistantActions(
940 }
957 }
941}
958}
942
959
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
944 * that function's cyclomatic complexity under the detekt threshold. The icon reflects the
961 * 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. */
946@Composable
965@Composable
947private fun TtsButton(
966private fun TtsButton(
948 text: String,
967 text: String,
@@ -951,6 +970,7 @@ private fun TtsButton(
951 onSpeak: (String) -> Unit,
970 onSpeak: (String) -> Unit,
952 onPause: (() -> Unit)?,
971 onPause: (() -> Unit)?,
953 onResume: (() -> Unit)?,
972 onResume: (() -> Unit)?,
973+ onStop: (() -> Unit)? = null,
954) {
974) {
955 val haptics = LocalHapticFeedback.current
975 val haptics = LocalHapticFeedback.current
956 val label = when {
976 val label = when {
@@ -958,27 +978,45 @@ private fun TtsButton(
958 isSpeaking -> stringResource(R.string.pause_reading)
978 isSpeaking -> stringResource(R.string.pause_reading)
959 else -> stringResource(R.string.read_aloud)
979 else -> stringResource(R.string.read_aloud)
960 }
980 }
981+ Row(verticalAlignment = Alignment.CenterVertically) {
961− IconButton(onClick = {
982+ IconButton(onClick = {
962− haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
983+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
963− when {
984+ when {
964− isSpeaking && ttsState == TtsState.PAUSED -> onResume?.invoke()
985+ isSpeaking && ttsState == TtsState.PAUSED -> onResume?.invoke()
965− isSpeaking -> onPause?.invoke()
986+ isSpeaking -> onPause?.invoke()
966− else -> onSpeak(text)
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+ )
967 }
1003 }
968− }) {
1004+ // Stop button: fully stops TTS playback (vs pause which keeps the position). Only
969− val icon = when {
1005+ // shown while playing or paused, so idle messages don't have a dead stop button.
970− isSpeaking && ttsState == TtsState.PAUSED -> Icons.Filled.PlayArrow
1006+ if (isSpeaking && onStop != null) {
971− isSpeaking -> Icons.Filled.Pause
1007+ val stopLabel = stringResource(R.string.stop_reading)
972− else -> Icons.AutoMirrored.Filled.VolumeUp
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+ }
973 }
1019 }
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− )
982 }
1020 }
983}
1021}
984
1022
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+91−55
@@ -94,6 +94,7 @@ fun ModelPickerSheet(
94 onDismiss: () -> Unit,
94 onDismiss: () -> Unit,
95 preferredModelId: String = "",
95 preferredModelId: String = "",
96 onSetPreferredModel: (String) -> Unit = {},
96 onSetPreferredModel: (String) -> Unit = {},
97+ recent: List<ModelOption> = emptyList(),
97) {
98) {
98 // Hoist SheetState + skipPartiallyExpanded so the sheet opens full-height and selection
99 // Hoist SheetState + skipPartiallyExpanded so the sheet opens full-height and selection
99 // animates out (see AgentPickerSheet for the full rationale).
100 // animates out (see AgentPickerSheet for the full rationale).
@@ -200,6 +201,17 @@ fun ModelPickerSheet(
200 LaunchedEffect(grouped) {
201 LaunchedEffect(grouped) {
201 if (selectedIndex >= 0) listState.scrollToItem(selectedIndex)
202 if (selectedIndex >= 0) listState.scrollToItem(selectedIndex)
202 }
203 }
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+ }
203 LazyColumn(
215 LazyColumn(
204 state = listState,
216 state = listState,
205 modifier = Modifier
217 modifier = Modifier
@@ -208,6 +220,23 @@ fun ModelPickerSheet(
208 .navigationBarsPadding()
220 .navigationBarsPadding()
209 .semantics { selectableGroup() },
221 .semantics { selectableGroup() },
210 ) {
222 ) {
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+ }
211 grouped.forEach { (providerID, opts) ->
240 grouped.forEach { (providerID, opts) ->
212 item(key = "header_$providerID") {
241 item(key = "header_$providerID") {
213 Text(
242 Text(
@@ -220,75 +249,82 @@ fun ModelPickerSheet(
220 // opts is already distinct (filtered derives from the distinct list),
249 // opts is already distinct (filtered derives from the distinct list),
221 // so no per-row distinctBy is needed here.
250 // so no per-row distinctBy is needed here.
222 items(opts, key = { it.providerID to it.modelID }) { option ->
251 items(opts, key = { it.providerID to it.modelID }) { option ->
223− val isSelected = option.providerID == selected?.providerID &&
252+ ModelRow(option, selected) {
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) {
232 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
253 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
233 scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss() }
254 scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss() }
234 onSelect(option)
255 onSelect(option)
235 }
256 }
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− }
253 }
257 }
254 }
258 }
255 }
259 }
256− }
257− }
258− // Footer: a "set as default" toggle for the currently-selected model, so a user
260+ // 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.
261+ // 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
262+ // Only shown when there's a selection; tapping clears the preference if it's
261− // already the default (toggle behavior).
263+ // already the default (toggle behavior).
262− if (selected != null) {
264+ if (selected != null) {
263− val isDefault = preferredModelId == selected.modelID
265+ val isDefault = preferredModelId == selected.modelID
264− Row(
266+ Row(
265− modifier = Modifier
267+ modifier = Modifier
266− .fillMaxWidth()
268+ .fillMaxWidth()
267− .padding(horizontal = 24.dp, vertical = 8.dp)
269+ .padding(horizontal = 24.dp, vertical = 8.dp)
268− .navigationBarsPadding(),
270+ .navigationBarsPadding(),
269− verticalAlignment = Alignment.CenterVertically,
271+ 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− },
284 ) {
272 ) {
285 Text(
273 Text(
274+ stringResource(
286− stringResource(if (isDefault) R.string.clear_default else R.string.set_as_default),
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),
287 )
281 )
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+ }
288 }
291 }
289 }
292 }
290 }
293 }
291 }
294 }
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+ }
292 }
328 }
293 }
329 }
294}
330}
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+2
@@ -54,6 +54,7 @@ import androidx.compose.ui.res.stringResource
54import androidx.compose.ui.unit.dp
54import androidx.compose.ui.unit.dp
55import kotlinx.serialization.json.Json
55import kotlinx.serialization.json.Json
56import kotlinx.serialization.json.JsonElement
56import kotlinx.serialization.json.JsonElement
57+import soy.iko.opencode.data.model.AgentPart
57import soy.iko.opencode.data.model.FilePart
58import soy.iko.opencode.data.model.FilePart
58import soy.iko.opencode.data.model.Part
59import soy.iko.opencode.data.model.Part
59import soy.iko.opencode.data.model.ReasoningPart
60import soy.iko.opencode.data.model.ReasoningPart
@@ -181,6 +182,7 @@ fun PartView(
181 }
182 }
182 is StepStartPart -> {} // boundary marker — nothing to draw
183 is StepStartPart -> {} // boundary marker — nothing to draw
183 is StepFinishPart -> {} // metrics handled at message level
184 is StepFinishPart -> {} // metrics handled at message level
185+ is AgentPart -> {} // agent name is surfaced in the assistant header, not the body
184 is UnknownPart -> UnknownPartNote(modifier)
186 is UnknownPart -> UnknownPartNote(modifier)
185 }
187 }
186}
188}
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+17−1
@@ -79,6 +79,9 @@ fun PermissionDialog(
79 // indefinitely (the foreground service holds the process alive). LaunchedEffect keyed on
79 // indefinitely (the foreground service holds the process alive). LaunchedEffect keyed on
80 // permission.id so a fresh prompt resets the timer. Disabled when the timeout is 0.
80 // permission.id so a fresh prompt resets the timer. Disabled when the timeout is 0.
81 val autoRejectMs = NetworkConfig.permissionAutoRejectMs
81 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)
82 LaunchedEffect(permission.id, autoRejectMs) {
85 LaunchedEffect(permission.id, autoRejectMs) {
83 if (autoRejectMs <= 0L) return@LaunchedEffect
86 if (autoRejectMs <= 0L) return@LaunchedEffect
84 val tickMs = 1000L
87 val tickMs = 1000L
@@ -86,9 +89,13 @@ fun PermissionDialog(
86 delay(tickMs)
89 delay(tickMs)
87 elapsedMs = (elapsedMs + tickMs).coerceAtMost(autoRejectMs)
90 elapsedMs = (elapsedMs + tickMs).coerceAtMost(autoRejectMs)
88 }
91 }
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)
89 respond(PermissionResponse.REJECT)
94 respond(PermissionResponse.REJECT)
90 }
95 }
91 val showReminder = elapsedMs >= NetworkConfig.permissionReminderThresholdMs
96 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
92 AlertDialog(
99 AlertDialog(
93 // Back press routes here (dismissOnBackPress defaults true) and is treated as an
100 // Back press routes here (dismissOnBackPress defaults true) and is treated as an
94 // explicit reject — the safe default. Tap-outside is disabled below so it can't
101 // explicit reject — the safe default. Tap-outside is disabled below so it can't
@@ -143,7 +150,16 @@ fun PermissionDialog(
143 )
150 )
144 // After the reminder threshold, surface that the run is waiting so the user
151 // After the reminder threshold, surface that the run is waiting so the user
145 // understands their inaction is holding up the agent. A nudge, not a force.
152 // understands their inaction is holding up the agent. A nudge, not a force.
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+ )
146− if (showReminder && autoRejectMs > 0L) {
162+ } else if (showReminder && autoRejectMs > 0L) {
147 val remainingMin = ((autoRejectMs - elapsedMs) / 60_000L).coerceAtLeast(0L)
163 val remainingMin = ((autoRejectMs - elapsedMs) / 60_000L).coerceAtLeast(0L)
148 Text(
164 Text(
149 stringResource(R.string.permission_waiting, remainingMin),
165 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
6import androidx.compose.foundation.layout.size
6import androidx.compose.foundation.layout.size
7import androidx.compose.material.icons.Icons
7import androidx.compose.material.icons.Icons
8import androidx.compose.material.icons.automirrored.filled.ArrowBack
8import androidx.compose.material.icons.automirrored.filled.ArrowBack
9−import androidx.compose.material3.CircularProgressIndicator
10import androidx.compose.material3.ExperimentalMaterial3Api
9import androidx.compose.material3.ExperimentalMaterial3Api
11import androidx.compose.material3.Icon
10import androidx.compose.material3.Icon
12import androidx.compose.material3.IconButton
11import androidx.compose.material3.IconButton
@@ -69,9 +68,8 @@ fun AppTopBar(
69 actions = {
68 actions = {
70 if (loading) {
69 if (loading) {
71 Box(modifier = Modifier.size(40.dp), contentAlignment = Alignment.Center) {
70 Box(modifier = Modifier.size(40.dp), contentAlignment = Alignment.Center) {
72− CircularProgressIndicator(
71+ LoadingSpinner(
73− Modifier.size(20.dp),
72+ size = LoadingSize.Small,
74− strokeWidth = 2.dp,
75 color = MaterialTheme.colorScheme.onSurfaceVariant,
73 color = MaterialTheme.colorScheme.onSurfaceVariant,
76 )
74 )
77 }
75 }
app/src/main/java/soy/iko/opencode/ui/components/CommandPalette.kt+2−3
@@ -7,7 +7,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
7import androidx.compose.foundation.layout.heightIn
7import androidx.compose.foundation.layout.heightIn
8import androidx.compose.foundation.layout.padding
8import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.lazy.LazyColumn
9import androidx.compose.foundation.lazy.LazyColumn
10−import androidx.compose.foundation.lazy.items
10+import androidx.compose.foundation.lazy.itemsIndexed
11import androidx.compose.foundation.lazy.rememberLazyListState
11import androidx.compose.foundation.lazy.rememberLazyListState
12import androidx.compose.foundation.text.KeyboardActions
12import androidx.compose.foundation.text.KeyboardActions
13import androidx.compose.foundation.text.KeyboardOptions
13import androidx.compose.foundation.text.KeyboardOptions
@@ -121,8 +121,7 @@ fun CommandPalette(
121 )
121 )
122 } else {
122 } else {
123 LazyColumn(state = listState, modifier = Modifier.heightIn(max = 360.dp)) {
123 LazyColumn(state = listState, modifier = Modifier.heightIn(max = 360.dp)) {
124− items(filtered, key = { it.id }) { action ->
124+ itemsIndexed(filtered, key = { _, action -> action.id }) { index, action ->
125− val index = filtered.indexOf(action)
126 val isSelected = index == selectedIndex
125 val isSelected = index == selectedIndex
127 Text(
126 Text(
128 action.label,
127 action.label,
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+24−8
@@ -1,8 +1,6 @@
1package soy.iko.opencode.ui.components
1package soy.iko.opencode.ui.components
2
2
3import androidx.compose.animation.AnimatedVisibility
3import androidx.compose.animation.AnimatedVisibility
4−import androidx.compose.animation.fadeIn
5−import androidx.compose.animation.fadeOut
6import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Arrangement
7import androidx.compose.foundation.layout.BoxScope
5import androidx.compose.foundation.layout.BoxScope
8import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.Row
@@ -10,7 +8,9 @@ import androidx.compose.foundation.layout.defaultMinSize
10import androidx.compose.foundation.layout.fillMaxWidth
8import androidx.compose.foundation.layout.fillMaxWidth
11import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.size
10import 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
14import androidx.compose.material3.MaterialTheme
14import androidx.compose.material3.MaterialTheme
15import androidx.compose.material3.Surface
15import androidx.compose.material3.Surface
16import androidx.compose.material3.Text
16import androidx.compose.material3.Text
@@ -28,6 +28,7 @@ import androidx.compose.ui.semantics.contentDescription
28import androidx.compose.ui.semantics.liveRegion
28import androidx.compose.ui.semantics.liveRegion
29import androidx.compose.ui.semantics.LiveRegionMode
29import androidx.compose.ui.semantics.LiveRegionMode
30import androidx.compose.ui.semantics.semantics
30import androidx.compose.ui.semantics.semantics
31+import androidx.compose.ui.text.style.TextOverflow
31import androidx.compose.ui.unit.dp
32import androidx.compose.ui.unit.dp
32import androidx.lifecycle.compose.collectAsStateWithLifecycle
33import androidx.lifecycle.compose.collectAsStateWithLifecycle
33import kotlinx.coroutines.ExperimentalCoroutinesApi
34import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -80,7 +81,11 @@ fun ConnectionBanner(
80 // composition is a documented anti-pattern that can trigger extra recompositions and isn't
81 // composition is a documented anti-pattern that can trigger extra recompositions and isn't
81 // guaranteed a defined ordering. SideEffect runs only after a successful composition.
82 // guaranteed a defined ordering. SideEffect runs only after a successful composition.
82 SideEffect { if (text != null) lastText.value = text }
83 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+ ) {
84 val shown = lastText.value ?: return@AnimatedVisibility
89 val shown = lastText.value ?: return@AnimatedVisibility
85 // Distinguish a hard failure (e.g. bad credentials) and an offline device from
90 // Distinguish a hard failure (e.g. bad credentials) and an offline device from
86 // transient connecting/reconnecting states by switching to the error palette,
91 // transient connecting/reconnecting states by switching to the error palette,
@@ -115,16 +120,27 @@ fun ConnectionBanner(
115 horizontalArrangement = Arrangement.Center,
120 horizontalArrangement = Arrangement.Center,
116 verticalAlignment = Alignment.CenterVertically,
121 verticalAlignment = Alignment.CenterVertically,
117 ) {
122 ) {
118− if (!isFailed) {
123+ if (isFailed) {
119− CircularProgressIndicator(
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,
120− Modifier.size(16.dp),
129+ modifier = Modifier.size(16.dp),
121− strokeWidth = 2.dp,
130+ tint = onContainer,
131+ )
132+ } else {
133+ LoadingSpinner(
134+ size = LoadingSize.Inline,
135+ color = onContainer,
122 )
136 )
123 }
137 }
124 Text(
138 Text(
125 shown,
139 shown,
126 style = MaterialTheme.typography.labelMedium,
140 style = MaterialTheme.typography.labelMedium,
127 color = onContainer,
141 color = onContainer,
142+ maxLines = 2,
143+ overflow = TextOverflow.Ellipsis,
128 modifier = Modifier
144 modifier = Modifier
129 .padding(start = 6.dp)
145 .padding(start = 6.dp)
130 .weight(1f, fill = false),
146 .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
9import androidx.compose.material.icons.filled.QueryStats
9import androidx.compose.material.icons.filled.QueryStats
10import androidx.compose.material.icons.filled.Search
10import androidx.compose.material.icons.filled.Search
11import androidx.compose.material.icons.filled.Settings
11import androidx.compose.material.icons.filled.Settings
12+import androidx.compose.material3.Badge
13+import androidx.compose.material3.BadgedBox
12import androidx.compose.material3.Icon
14import androidx.compose.material3.Icon
13import androidx.compose.material3.NavigationRail
15import androidx.compose.material3.NavigationRail
14import androidx.compose.material3.NavigationRailItem
16import androidx.compose.material3.NavigationRailItem
@@ -19,6 +21,7 @@ import androidx.compose.ui.Modifier
19import androidx.compose.ui.graphics.vector.ImageVector
21import androidx.compose.ui.graphics.vector.ImageVector
20import androidx.compose.ui.res.stringResource
22import androidx.compose.ui.res.stringResource
21import soy.iko.opencode.R
23import soy.iko.opencode.R
24+import soy.iko.opencode.ui.Routes
22
25
23/** A top-level destination surfaced on the large-screen navigation rail. */
26/** A top-level destination surfaced on the large-screen navigation rail. */
24private data class RailDest(
27private data class RailDest(
@@ -30,13 +33,13 @@ private data class RailDest(
30)
33)
31
34
32private val RAIL_DESTINATIONS = listOf(
35private val RAIL_DESTINATIONS = listOf(
33− RailDest("sessions", R.string.sessions_title, Icons.AutoMirrored.Filled.Chat, true),
36+ RailDest(Routes.SESSIONS, R.string.sessions_title, Icons.AutoMirrored.Filled.Chat, true),
34− RailDest("search", R.string.search_all_title, Icons.Filled.Search, true),
37+ RailDest(Routes.SEARCH, R.string.search_all_title, Icons.Filled.Search, true),
35− RailDest("files", R.string.files, Icons.Filled.Folder, true),
38+ RailDest(Routes.FILES, R.string.files, Icons.Filled.Folder, true),
36− RailDest("usage", R.string.usage_title, Icons.Filled.QueryStats, true),
39+ RailDest(Routes.USAGE, R.string.usage_title, Icons.Filled.QueryStats, true),
37− RailDest("mcp", R.string.mcp_servers, Icons.Filled.Hub, true),
40+ RailDest(Routes.MCP, R.string.mcp_servers, Icons.Filled.Hub, true),
38− RailDest("settings", R.string.settings, Icons.Filled.Settings, false),
41+ RailDest(Routes.SETTINGS, R.string.settings, Icons.Filled.Settings, false),
39− RailDest("servers", R.string.servers_title, Icons.Filled.Dns, false),
42+ RailDest(Routes.SERVERS, R.string.servers_title, Icons.Filled.Dns, false),
40)
43)
41
44
42/**
45/**
@@ -46,6 +49,8 @@ private val RAIL_DESTINATIONS = listOf(
46 * @param currentRoute the current NavHost destination route, used to highlight the active item.
49 * @param currentRoute the current NavHost destination route, used to highlight the active item.
47 * @param connected whether a server connection is active; connection-dependent items (sessions,
50 * @param connected whether a server connection is active; connection-dependent items (sessions,
48 * files, …) are disabled until one exists.
51 * 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.
49 * @param onNavigate invoked with the destination's route when an item is selected.
54 * @param onNavigate invoked with the destination's route when an item is selected.
50 */
55 */
51@Composable
56@Composable
@@ -54,6 +59,8 @@ fun LargeScreenNavRail(
54 connected: Boolean,
59 connected: Boolean,
55 onNavigate: (String) -> Unit,
60 onNavigate: (String) -> Unit,
56 modifier: Modifier = Modifier,
61 modifier: Modifier = Modifier,
62+ unreadCount: Int = 0,
63+ runActive: Boolean = false,
57) {
64) {
58 NavigationRail(modifier = modifier.fillMaxHeight()) {
65 NavigationRail(modifier = modifier.fillMaxHeight()) {
59 RAIL_DESTINATIONS.forEach { dest ->
66 RAIL_DESTINATIONS.forEach { dest ->
@@ -62,13 +69,32 @@ fun LargeScreenNavRail(
62 // file_view?path=… is a detail of `files`. Without this, drilling into a file
69 // file_view?path=… is a detail of `files`. Without this, drilling into a file
63 // loses the FILES highlight and the user loses their place in the rail.
70 // loses the FILES highlight and the user loses their place in the rail.
64 val selected = currentRoute == dest.route ||
71 val selected = currentRoute == dest.route ||
65− (dest.route == "sessions" && currentRoute != null && currentRoute.startsWith("chat/")) ||
72+ (dest.route == Routes.SESSIONS && currentRoute != null && currentRoute.startsWith("${Routes.CHAT}/")) ||
66− (dest.route == "files" && currentRoute != null && currentRoute.startsWith("file_view"))
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
67 NavigationRailItem(
79 NavigationRailItem(
68 selected = selected,
80 selected = selected,
69 enabled = !dest.requiresConnection || connected,
81 enabled = !dest.requiresConnection || connected,
70 onClick = { onNavigate(dest.route) },
82 onClick = { onNavigate(dest.route) },
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+ },
71− icon = { Icon(dest.icon, contentDescription = null) },
93+ ) { Icon(dest.icon, contentDescription = null) }
94+ } else {
95+ Icon(dest.icon, contentDescription = null)
96+ }
97+ },
72 label = { Text(stringResource(dest.labelRes)) },
98 label = { Text(stringResource(dest.labelRes)) },
73 colors = NavigationRailItemDefaults.colors(),
99 colors = NavigationRailItemDefaults.colors(),
74 )
100 )
app/src/main/java/soy/iko/opencode/ui/components/LoadingSpinner.kt+9
@@ -2,8 +2,10 @@ package soy.iko.opencode.ui.components
2
2
3import androidx.compose.foundation.layout.size
3import androidx.compose.foundation.layout.size
4import androidx.compose.material3.CircularProgressIndicator
4import androidx.compose.material3.CircularProgressIndicator
5+import androidx.compose.material3.MaterialTheme
5import androidx.compose.runtime.Composable
6import androidx.compose.runtime.Composable
6import androidx.compose.ui.Modifier
7import androidx.compose.ui.Modifier
8+import androidx.compose.ui.graphics.Color
7import androidx.compose.ui.semantics.contentDescription
9import androidx.compose.ui.semantics.contentDescription
8import androidx.compose.ui.semantics.semantics
10import androidx.compose.ui.semantics.semantics
9import androidx.compose.ui.unit.dp
11import androidx.compose.ui.unit.dp
@@ -20,6 +22,7 @@ import soy.iko.opencode.R
20fun LoadingSpinner(
22fun LoadingSpinner(
21 size: LoadingSize = LoadingSize.Medium,
23 size: LoadingSize = LoadingSize.Medium,
22 modifier: Modifier = Modifier,
24 modifier: Modifier = Modifier,
25+ color: Color = CircularProgressIndicatorDefaults.color,
23) {
26) {
24 val label = stringResource(R.string.loading)
27 val label = stringResource(R.string.loading)
25 CircularProgressIndicator(
28 CircularProgressIndicator(
@@ -27,5 +30,11 @@ fun LoadingSpinner(
27 .size(size.sizeDp.dp)
30 .size(size.sizeDp.dp)
28 .semantics { contentDescription = label },
31 .semantics { contentDescription = label },
29 strokeWidth = size.strokeDp.dp,
32 strokeWidth = size.strokeDp.dp,
33+ color = color,
30 )
34 )
31}
35}
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
57import androidx.compose.ui.res.pluralStringResource
57import androidx.compose.ui.res.pluralStringResource
58import androidx.compose.ui.res.stringResource
58import androidx.compose.ui.res.stringResource
59import androidx.compose.ui.semantics.LiveRegionMode
59import androidx.compose.ui.semantics.LiveRegionMode
60+import androidx.compose.ui.semantics.invisibleToUser
60import androidx.compose.ui.semantics.liveRegion
61import androidx.compose.ui.semantics.liveRegion
61import androidx.compose.ui.semantics.semantics
62import androidx.compose.ui.semantics.semantics
62import androidx.compose.ui.text.AnnotatedString
63import androidx.compose.ui.text.AnnotatedString
@@ -260,7 +261,7 @@ private fun MarkdownBody(
260 SelectionContainer {
261 SelectionContainer {
261 Markdown(
262 Markdown(
262 content = markdown,
263 content = markdown,
263− modifier = modifier,
264+ modifier = modifier.semantics { liveRegion = LiveRegionMode.Polite },
264 components = components,
265 components = components,
265 )
266 )
266 }
267 }
@@ -603,6 +604,7 @@ private fun StreamingCaret() {
603 "▋",
604 "▋",
604 style = MaterialTheme.typography.bodyLarge,
605 style = MaterialTheme.typography.bodyLarge,
605 color = MaterialTheme.colorScheme.primary.copy(alpha = alpha),
606 color = MaterialTheme.colorScheme.primary.copy(alpha = alpha),
607+ modifier = Modifier.semantics { invisibleToUser() },
606 )
608 )
607}
609}
608
610
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
61import soy.iko.opencode.data.model.FilePart
61import soy.iko.opencode.data.model.FilePart
62import soy.iko.opencode.data.model.ServerProfile
62import soy.iko.opencode.data.model.ServerProfile
63import soy.iko.opencode.data.model.sourcePath
63import soy.iko.opencode.data.model.sourcePath
64+import soy.iko.opencode.data.network.CertPinUtil
64import soy.iko.opencode.data.network.HttpClientFactory
65import soy.iko.opencode.data.network.HttpClientFactory
65import soy.iko.opencode.data.network.NetworkConfig
66import soy.iko.opencode.data.network.NetworkConfig
66import soy.iko.opencode.util.runCatchingCancellable
67import soy.iko.opencode.util.runCatchingCancellable
@@ -87,30 +88,9 @@ fun ServerProfile.toImageContext(): ImageLoadContext {
87 // requireHttps (or certificate-pinned) profile never fetches an image — with the Basic auth
88 // requireHttps (or certificate-pinned) profile never fetches an image — with the Basic auth
88 // header attached — over cleartext http even when the stored URL is http://. This mirrors
89 // header attached — over cleartext http even when the stored URL is http://. This mirrors
89 // how HttpClientFactory computes the effective base URL for the REST/SSE channel.
90 // 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)
91}
92}
92
93
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−
114/** True if the part references a raster/vector image we can render. */
94/** True if the part references a raster/vector image we can render. */
115val FilePart.isImage: Boolean
95val FilePart.isImage: Boolean
116 get() = mime?.startsWith("image/") == true
96 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
19import androidx.compose.ui.Modifier
19import androidx.compose.ui.Modifier
20import androidx.compose.ui.graphics.Color
20import androidx.compose.ui.graphics.Color
21import androidx.compose.ui.platform.LocalContext
21import androidx.compose.ui.platform.LocalContext
22+import androidx.compose.ui.res.stringResource
22import androidx.compose.ui.text.TextStyle
23import androidx.compose.ui.text.TextStyle
23import androidx.lifecycle.Lifecycle
24import androidx.lifecycle.Lifecycle
24import androidx.lifecycle.compose.LocalLifecycleOwner
25import androidx.lifecycle.compose.LocalLifecycleOwner
25import androidx.lifecycle.repeatOnLifecycle
26import androidx.lifecycle.repeatOnLifecycle
26import kotlinx.coroutines.delay
27import kotlinx.coroutines.delay
27−import java.util.concurrent.TimeUnit
28+import soy.iko.opencode.R
28
29
29/**
30/**
30 * Formats an epoch-millis timestamp as a short, compact relative string (e.g. "3m", "2h",
31 * Formats an epoch-millis timestamp as a short, compact relative string (e.g. "3m", "2h",
@@ -36,30 +37,21 @@ import java.util.concurrent.TimeUnit
36 * but that returns a full phrase like "5 min. ago" / "2 hr. ago" — not the compact form the
37 * but that returns a full phrase like "5 min. ago" / "2 hr. ago" — not the compact form the
37 * examples and the numeric fallback imply — which wraps or crowds those rows.) Always returns a
38 * examples and the numeric fallback imply — which wraps or crowds those rows.) Always returns a
38 * non-empty string for a valid timestamp.
39 * 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.
39 */
43 */
40−fun relativeTime(epochMillis: Long?): String {
44+fun relativeTime(context: Context, epochMillis: Long?): String {
41− if (epochMillis == null || epochMillis <= 0) return ""
45+ return when (val unit = relativeTimeUnit(epochMillis)) {
42− val now = System.currentTimeMillis()
46+ null -> ""
43− val diff = now - epochMillis
47+ is RelativeTimeUnit.Now -> context.getString(R.string.time_now)
44− if (diff < 0) return "now"
48+ is RelativeTimeUnit.Minutes -> context.getString(R.string.time_minutes_abbrev, unit.count)
45− val minutes = TimeUnit.MILLISECONDS.toMinutes(diff)
49+ is RelativeTimeUnit.Hours -> context.getString(R.string.time_hours_abbrev, unit.count)
46− if (minutes < 1) return "now"
50+ is RelativeTimeUnit.Days -> context.getString(R.string.time_days_abbrev, unit.count)
47− if (minutes < 60) return "${minutes}m"
51+ is RelativeTimeUnit.Weeks -> context.getString(R.string.time_weeks_abbrev, unit.count)
48− val hours = TimeUnit.MILLISECONDS.toHours(diff)
52+ is RelativeTimeUnit.Months -> context.getString(R.string.time_months_abbrev, unit.count)
49− if (hours < 24) return "${hours}h"
53+ is RelativeTimeUnit.Years -> context.getString(R.string.time_years_abbrev, unit.count)
50− val days = TimeUnit.MILLISECONDS.toDays(diff)
54+ }
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"
63}
55}
64
56
65/** Re-evaluation interval for relative-time labels — coarse enough for battery, fine
57/** 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
115@Composable
107@Composable
116fun rememberRelativeTime(epochMillis: Long?): String {
108fun rememberRelativeTime(epochMillis: Long?): String {
117 val sharedTick = LocalRelativeTimeTick.current
109 val sharedTick = LocalRelativeTimeTick.current
110+ val context = LocalContext.current
118 return if (sharedTick != 0L) {
111 return if (sharedTick != 0L) {
119 // Reading sharedTick above registers a recomposition dependency on the shared
112 // Reading sharedTick above registers a recomposition dependency on the shared
120 // ticker, so the label recomputes (cheaply) whenever the screen-level tick advances.
113 // ticker, so the label recomputes (cheaply) whenever the screen-level tick advances.
121− relativeTime(epochMillis)
114+ relativeTime(context, epochMillis)
122 } else {
115 } else {
123 rememberRelativeTimeStandalone(epochMillis)
116 rememberRelativeTimeStandalone(epochMillis)
124 }
117 }
@@ -129,6 +122,7 @@ fun rememberRelativeTime(epochMillis: Long?): String {
129private fun rememberRelativeTimeStandalone(epochMillis: Long?): String {
122private fun rememberRelativeTimeStandalone(epochMillis: Long?): String {
130 var tick by remember { mutableLongStateOf(0L) }
123 var tick by remember { mutableLongStateOf(0L) }
131 val lifecycleOwner = LocalLifecycleOwner.current
124 val lifecycleOwner = LocalLifecycleOwner.current
125+ val context = LocalContext.current
132
126
133 androidx.compose.runtime.LaunchedEffect(epochMillis, lifecycleOwner) {
127 androidx.compose.runtime.LaunchedEffect(epochMillis, lifecycleOwner) {
134 if (epochMillis == null) return@LaunchedEffect
128 if (epochMillis == null) return@LaunchedEffect
@@ -150,7 +144,7 @@ private fun rememberRelativeTimeStandalone(epochMillis: Long?): String {
150 derivedStateOf {
144 derivedStateOf {
151 // Read tick to create a recomposition dependency on the timer.
145 // Read tick to create a recomposition dependency on the timer.
152 tick
146 tick
153− relativeTime(epochMillis)
147+ relativeTime(context, epochMillis)
154 }
148 }
155 }
149 }
156 return formatted
150 return formatted
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+2−1
@@ -702,6 +702,7 @@ private fun FileRow(
702 onCopyPath: (() -> Unit)? = null,
702 onCopyPath: (() -> Unit)? = null,
703 modifier: Modifier = Modifier,
703 modifier: Modifier = Modifier,
704) {
704) {
705+ val context = LocalContext.current
705 val copyPathLabel = stringResource(R.string.copy_path)
706 val copyPathLabel = stringResource(R.string.copy_path)
706 // Haptic on row open for consistency with the breadcrumb taps and other screens.
707 // Haptic on row open for consistency with the breadcrumb taps and other screens.
707 val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
708 val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
@@ -765,7 +766,7 @@ private fun FileRow(
765 if (sublabel != null) add(sublabel)
766 if (sublabel != null) add(sublabel)
766 if (!icon) {
767 if (!icon) {
767 size?.let { add(formatFileSize(it)) }
768 size?.let { add(formatFileSize(it)) }
768− mtime?.let { add(relativeTime(it)) }
769+ mtime?.let { add(relativeTime(context, it)) }
769 }
770 }
770 }
771 }
771 if (detailParts.isNotEmpty()) {
772 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
21import androidx.compose.material.icons.filled.CheckCircle
21import androidx.compose.material.icons.filled.CheckCircle
22import androidx.compose.material.icons.filled.Error
22import androidx.compose.material.icons.filled.Error
23import androidx.compose.material.icons.filled.Hub
23import androidx.compose.material.icons.filled.Hub
24+import androidx.compose.material.icons.filled.MoreVert
24import androidx.compose.material.icons.filled.Refresh
25import androidx.compose.material.icons.filled.Refresh
25import androidx.compose.material3.AlertDialog
26import androidx.compose.material3.AlertDialog
26import androidx.compose.material3.Card
27import androidx.compose.material3.Card
27import androidx.compose.material3.CircularProgressIndicator
28import androidx.compose.material3.CircularProgressIndicator
29+import androidx.compose.material3.DropdownMenu
30+import androidx.compose.material3.DropdownMenuItem
28import androidx.compose.material3.ExperimentalMaterial3Api
31import androidx.compose.material3.ExperimentalMaterial3Api
29import androidx.compose.material3.FilterChip
32import androidx.compose.material3.FilterChip
30import androidx.compose.material3.FloatingActionButton
33import androidx.compose.material3.FloatingActionButton
@@ -60,6 +63,7 @@ import soy.iko.opencode.di.AppContainer
60import soy.iko.opencode.ui.components.AppTopBar
63import soy.iko.opencode.ui.components.AppTopBar
61import soy.iko.opencode.ui.components.ConnectionBannerFor
64import soy.iko.opencode.ui.components.ConnectionBannerFor
62import soy.iko.opencode.ui.components.EmptyState
65import soy.iko.opencode.ui.components.EmptyState
66+import soy.iko.opencode.ui.components.reducedMotionAnimateItem
63import soy.iko.opencode.ui.vmFactory
67import soy.iko.opencode.ui.vmFactory
64
68
65@OptIn(ExperimentalMaterial3Api::class)
69@OptIn(ExperimentalMaterial3Api::class)
@@ -148,6 +152,9 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
148 icon = Icons.Filled.Hub,
152 icon = Icons.Filled.Hub,
149 title = stringResource(R.string.mcp_empty),
153 title = stringResource(R.string.mcp_empty),
150 modifier = Modifier.align(Alignment.Center),
154 modifier = Modifier.align(Alignment.Center),
155+ actionIcon = Icons.Filled.Add,
156+ actionLabel = stringResource(R.string.mcp_add_server),
157+ onAction = { showAddDialog = true },
151 )
158 )
152 } else {
159 } else {
153 LazyColumn(
160 LazyColumn(
@@ -157,7 +164,9 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
157 ),
164 ),
158 verticalArrangement = Arrangement.spacedBy(12.dp),
165 verticalArrangement = Arrangement.spacedBy(12.dp),
159 ) {
166 ) {
160− items(s.servers, key = { it.name }) { McpServerCard(it) }
167+ items(s.servers, key = { it.name }) {
168+ McpServerCard(it, modifier = Modifier.then(reducedMotionAnimateItem()))
169+ }
161 }
170 }
162 }
171 }
163 }
172 }
@@ -180,7 +189,7 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
180}
189}
181
190
182@Composable
191@Composable
183−private fun McpServerCard(server: McpServerInfo) {
192+private fun McpServerCard(server: McpServerInfo, modifier: Modifier = Modifier) {
184 val remote = server.type.equals("remote", ignoreCase = true)
193 val remote = server.type.equals("remote", ignoreCase = true)
185 val connected = server.connected
194 val connected = server.connected
186 // Build a single merged content description so TalkBack reads the card as one node
195 // Build a single merged content description so TalkBack reads the card as one node
@@ -204,8 +213,10 @@ private fun McpServerCard(server: McpServerInfo) {
204 append(", ").append(stateLabel)
213 append(", ").append(stateLabel)
205 statusLabel?.let { append(", ").append(it) }
214 statusLabel?.let { append(", ").append(it) }
206 }
215 }
216+ var menuExpanded by rememberSaveable { mutableStateOf(false) }
217+ var showTools by rememberSaveable { mutableStateOf(false) }
207 Card(
218 Card(
208− modifier = Modifier
219+ modifier = modifier
209 .fillMaxWidth()
220 .fillMaxWidth()
210 .semantics(mergeDescendants = true) { contentDescription = cardDesc },
221 .semantics(mergeDescendants = true) { contentDescription = cardDesc },
211 ) {
222 ) {
@@ -244,6 +255,27 @@ private fun McpServerCard(server: McpServerInfo) {
244 style = MaterialTheme.typography.labelMedium,
255 style = MaterialTheme.typography.labelMedium,
245 color = if (server.enabled) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.onSurfaceVariant,
256 color = if (server.enabled) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.onSurfaceVariant,
246 )
257 )
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+ }
247 }
279 }
248 // Live status row: connected (with tool count) / error / unknown. Only shown when the
280 // Live status row: connected (with tool count) / error / unknown. Only shown when the
249 // server reported a runtime state via /mcp, so a static config-only view isn't
281 // server reported a runtime state via /mcp, so a static config-only view isn't
@@ -254,6 +286,42 @@ private fun McpServerCard(server: McpServerInfo) {
254 }
286 }
255 }
287 }
256 }
288 }
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+ )
257}
325}
258
326
259/** Live status row for an MCP server card: connected (with tool count) / error / disconnected.
327/** 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(
24 val connected: Boolean? = null,
24 val connected: Boolean? = null,
25 val error: String? = null,
25 val error: String? = null,
26 val toolCount: Int? = null,
26 val toolCount: Int? = null,
27+ val tools: List<String>? = null,
27)
28)
28
29
29/**
30/**
@@ -62,7 +63,14 @@ fun mergeMcpStatus(servers: List<McpServerInfo>, status: JsonObject?): List<McpS
62 val st = status[server.name] as? JsonObject ?: return@map server
63 val st = status[server.name] as? JsonObject ?: return@map server
63 val connected = (st["connected"] as? JsonPrimitive)?.booleanOrNull
64 val connected = (st["connected"] as? JsonPrimitive)?.booleanOrNull
64 val error = (st["error"] as? JsonPrimitive)?.contentOrNull
65 val error = (st["error"] as? JsonPrimitive)?.contentOrNull
65− val tools = (st["tools"] as? JsonArray)?.size
66+ val tools = (st["tools"] as? JsonArray)?.mapNotNull { el -> toolName(el) }
66− server.copy(connected = connected, error = error, toolCount = tools)
67+ server.copy(connected = connected, error = error, toolCount = tools?.size, tools = tools)
67 }
68 }
68}
69}
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
7import androidx.compose.foundation.layout.Spacer
7import androidx.compose.foundation.layout.Spacer
8import androidx.compose.foundation.layout.PaddingValues
8import androidx.compose.foundation.layout.PaddingValues
9import androidx.compose.foundation.layout.Row
9import androidx.compose.foundation.layout.Row
10+import androidx.compose.foundation.layout.ExperimentalLayoutApi
11+import androidx.compose.foundation.layout.FlowRow
10import androidx.compose.foundation.layout.fillMaxSize
12import androidx.compose.foundation.layout.fillMaxSize
11import androidx.compose.foundation.layout.fillMaxWidth
13import androidx.compose.foundation.layout.fillMaxWidth
12import androidx.compose.foundation.layout.imePadding
14import androidx.compose.foundation.layout.imePadding
@@ -19,17 +21,21 @@ import androidx.compose.foundation.text.KeyboardOptions
19import androidx.compose.material.icons.Icons
21import androidx.compose.material.icons.Icons
20import androidx.compose.material.icons.filled.Close
22import androidx.compose.material.icons.filled.Close
21import androidx.compose.material.icons.filled.ErrorOutline
23import androidx.compose.material.icons.filled.ErrorOutline
24+import androidx.compose.material.icons.filled.History
22import androidx.compose.material.icons.filled.Search
25import androidx.compose.material.icons.filled.Search
23import androidx.compose.material.icons.filled.SearchOff
26import androidx.compose.material.icons.filled.SearchOff
27+import androidx.compose.material3.AssistChip
24import androidx.compose.material3.Card
28import androidx.compose.material3.Card
25import androidx.compose.material3.CircularProgressIndicator
29import androidx.compose.material3.CircularProgressIndicator
26import androidx.compose.material3.ExperimentalMaterial3Api
30import androidx.compose.material3.ExperimentalMaterial3Api
31+import androidx.compose.material3.FilterChip
27import androidx.compose.material3.Icon
32import androidx.compose.material3.Icon
28import androidx.compose.material3.IconButton
33import androidx.compose.material3.IconButton
29import androidx.compose.material3.MaterialTheme
34import androidx.compose.material3.MaterialTheme
30import androidx.compose.material3.OutlinedTextField
35import androidx.compose.material3.OutlinedTextField
31import androidx.compose.material3.Scaffold
36import androidx.compose.material3.Scaffold
32import androidx.compose.material3.Text
37import androidx.compose.material3.Text
38+import androidx.compose.material3.TextButton
33import androidx.compose.runtime.Composable
39import androidx.compose.runtime.Composable
34import androidx.compose.runtime.LaunchedEffect
40import androidx.compose.runtime.LaunchedEffect
35import androidx.compose.runtime.getValue
41import androidx.compose.runtime.getValue
@@ -60,11 +66,12 @@ import soy.iko.opencode.ui.components.AppTopBar
60import soy.iko.opencode.ui.components.ConnectionBannerFor
66import soy.iko.opencode.ui.components.ConnectionBannerFor
61import soy.iko.opencode.ui.components.EmptyState
67import soy.iko.opencode.ui.components.EmptyState
62import soy.iko.opencode.ui.components.RelativeTimeText
68import soy.iko.opencode.ui.components.RelativeTimeText
69+import soy.iko.opencode.ui.components.reducedMotionAnimateItem
63import soy.iko.opencode.ui.vmFactory
70import soy.iko.opencode.ui.vmFactory
64import soy.iko.opencode.util.runCatchingCancellable
71import soy.iko.opencode.util.runCatchingCancellable
65
72
66/** Cross-session message search: type a query, tap a result to open that conversation. */
73/** Cross-session message search: type a query, tap a result to open that conversation. */
67−@OptIn(ExperimentalMaterial3Api::class)
74+@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
68@Composable
75@Composable
69fun GlobalSearchScreen(
76fun GlobalSearchScreen(
70 container: AppContainer,
77 container: AppContainer,
@@ -75,6 +82,7 @@ fun GlobalSearchScreen(
75 val state by vm.state.collectAsStateWithLifecycle()
82 val state by vm.state.collectAsStateWithLifecycle()
76 val focusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
83 val focusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
77 val keyboard = LocalSoftwareKeyboardController.current
84 val keyboard = LocalSoftwareKeyboardController.current
85+ val matchCaseLabel = stringResource(R.string.match_case)
78
86
79 LaunchedEffect(Unit) {
87 LaunchedEffect(Unit) {
80 runCatchingCancellable { focusRequester.requestFocus() }
88 runCatchingCancellable { focusRequester.requestFocus() }
@@ -97,15 +105,45 @@ fun GlobalSearchScreen(
97 singleLine = true,
105 singleLine = true,
98 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
106 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
99 trailingIcon = {
107 trailingIcon = {
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+ )
100− if (state.query.isNotEmpty()) {
117+ if (state.query.isNotEmpty()) {
101− IconButton(onClick = { vm.setQuery("") }) {
118+ IconButton(onClick = { vm.setQuery("") }) {
102− Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
119+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
120+ }
103 }
121 }
104 }
122 }
105 },
123 },
106 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
124 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
107 keyboardActions = KeyboardActions(onSearch = { keyboard?.hide() }),
125 keyboardActions = KeyboardActions(onSearch = { keyboard?.hide() }),
108 )
126 )
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+ }
109 Box(modifier = Modifier.fillMaxSize()) {
147 Box(modifier = Modifier.fillMaxSize()) {
110 ConnectionBannerFor(container)
148 ConnectionBannerFor(container)
111 val searchingLabel = stringResource(R.string.searching)
149 val searchingLabel = stringResource(R.string.searching)
@@ -147,6 +185,17 @@ fun GlobalSearchScreen(
147 title = stringResource(R.string.search_keep_typing),
185 title = stringResource(R.string.search_keep_typing),
148 modifier = Modifier.align(Alignment.Center),
186 modifier = Modifier.align(Alignment.Center),
149 )
187 )
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+ )
150 state.results.isEmpty() -> EmptyState(
199 state.results.isEmpty() -> EmptyState(
151 icon = Icons.Filled.Search,
200 icon = Icons.Filled.Search,
152 title = stringResource(R.string.search_all_start),
201 title = stringResource(R.string.search_all_start),
@@ -179,10 +228,10 @@ fun GlobalSearchScreen(
179 style = MaterialTheme.typography.labelSmall,
228 style = MaterialTheme.typography.labelSmall,
180 color = MaterialTheme.colorScheme.onSurfaceVariant,
229 color = MaterialTheme.colorScheme.onSurfaceVariant,
181 )
230 )
182− androidx.compose.material3.TextButton(
231+ TextButton(
183 onClick = { vm.searchMore() },
232 onClick = { vm.searchMore() },
184 enabled = !state.searching,
233 enabled = !state.searching,
185− contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
234+ contentPadding = PaddingValues(horizontal = 0.dp),
186 ) {
235 ) {
187 Text(stringResource(R.string.search_more))
236 Text(stringResource(R.string.search_more))
188 }
237 }
@@ -193,7 +242,9 @@ fun GlobalSearchScreen(
193 SearchResultCard(
242 SearchResultCard(
194 hit = hit,
243 hit = hit,
195 query = state.query.trim(),
244 query = state.query.trim(),
245+ ignoreCase = !state.matchCase,
196 onClick = { onOpenSession(hit.session.id) },
246 onClick = { onOpenSession(hit.session.id) },
247+ modifier = Modifier.then(reducedMotionAnimateItem()),
197 )
248 )
198 }
249 }
199 }
250 }
@@ -203,16 +254,57 @@ fun GlobalSearchScreen(
203 }
254 }
204}
255}
205
256
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+
206@Composable
298@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) {
208 val highlightColor = MaterialTheme.colorScheme.primary
300 val highlightColor = MaterialTheme.colorScheme.primary
209 // Highlight the title too when the match was in the title (otherwise the body snippet below
301 // Highlight the title too when the match was in the title (otherwise the body snippet below
210 // is highlighted but the title above stays plain — an inconsistency).
302 // is highlighted but the title above stays plain — an inconsistency).
211− val title = remember(hit.session.displayTitle, query, hit.matchedTitle, highlightColor) {
303+ val title = remember(hit.session.displayTitle, query, hit.matchedTitle, highlightColor, ignoreCase) {
212− if (hit.matchedTitle) highlightMatches(hit.session.displayTitle, query, highlightColor)
304+ if (hit.matchedTitle) highlightMatches(hit.session.displayTitle, query, highlightColor, ignoreCase)
213 else AnnotatedString(hit.session.displayTitle)
305 else AnnotatedString(hit.session.displayTitle)
214 }
306 }
215− Card(modifier = Modifier.fillMaxWidth().clickable(role = Role.Button) { onClick() }) {
307+ Card(modifier = modifier.fillMaxWidth().clickable(role = Role.Button) { onClick() }) {
216 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
308 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
217 Row(verticalAlignment = Alignment.CenterVertically) {
309 Row(verticalAlignment = Alignment.CenterVertically) {
218 Text(
310 Text(
@@ -239,8 +331,8 @@ private fun SearchResultCard(hit: SearchHit, query: String, onClick: () -> Unit)
239 modifier = Modifier.padding(top = 2.dp),
331 modifier = Modifier.padding(top = 2.dp),
240 )
332 )
241 if (hit.snippet.isNotEmpty()) {
333 if (hit.snippet.isNotEmpty()) {
242− val snippet = remember(hit.snippet, query, highlightColor) {
334+ val snippet = remember(hit.snippet, query, highlightColor, ignoreCase) {
243− highlightMatches(hit.snippet, query, highlightColor)
335+ highlightMatches(hit.snippet, query, highlightColor, ignoreCase)
244 }
336 }
245 Text(
337 Text(
246 snippet,
338 snippet,
@@ -255,18 +347,20 @@ private fun SearchResultCard(hit: SearchHit, query: String, onClick: () -> Unit)
255 }
347 }
256}
348}
257
349
258−/** Bold + tint every case-insensitive occurrence of [query] within [text] so the user can
350+/** Bold + tint every occurrence of [query] within [text] so the user can see where the hit is
259− * see where the hit is in the surrounding snippet. */
351+ * in the surrounding snippet. [ignoreCase] matches the search's case mode so highlights and
352+ * matches never diverge. */
260private fun highlightMatches(
353private fun highlightMatches(
261 text: String,
354 text: String,
262 query: String,
355 query: String,
263 color: androidx.compose.ui.graphics.Color,
356 color: androidx.compose.ui.graphics.Color,
357+ ignoreCase: Boolean,
264): AnnotatedString {
358): AnnotatedString {
265 if (query.isEmpty()) return AnnotatedString(text)
359 if (query.isEmpty()) return AnnotatedString(text)
266 return buildAnnotatedString {
360 return buildAnnotatedString {
267 var start = 0
361 var start = 0
268 while (true) {
362 while (true) {
269− val idx = text.indexOf(query, start, ignoreCase = true)
363+ val idx = text.indexOf(query, start, ignoreCase = ignoreCase)
270 if (idx < 0) {
364 if (idx < 0) {
271 append(text.substring(start))
365 append(text.substring(start))
272 break
366 break
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchViewModel.kt+83−23
@@ -64,8 +64,18 @@ data class GlobalSearchState(
64 val totalCount: Int = 0,
64 val totalCount: Int = 0,
65 /** The cap applied to this pass. Grows when the user taps "Search more". */
65 /** The cap applied to this pass. Grows when the user taps "Search more". */
66 val sessionCap: Int = NetworkConfig.maxSearchSessions,
66 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(),
67)
74)
68
75
76+/** Scopes a global search to a category of message part. */
77+enum class SearchTypeFilter { ALL, MESSAGES, TOOLS, REASONING }
78+
69/**
79/**
70 * Cross-session message search. opencode's `/find` searches project *files*, not chat
80 * Cross-session message search. opencode's `/find` searches project *files*, not chat
71 * history, so message search is done client-side: fetch each session's messages (bounded and
81 * history, so message search is done client-side: fetch each session's messages (bounded and
@@ -74,7 +84,9 @@ data class GlobalSearchState(
74 */
84 */
75class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
85class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
76
86
77− private val _state = MutableStateFlow(GlobalSearchState())
87+ private val _state = MutableStateFlow(
88+ GlobalSearchState(history = container.searchHistoryStore.queries.value),
89+ )
78 val state: StateFlow<GlobalSearchState> = _state.asStateFlow()
90 val state: StateFlow<GlobalSearchState> = _state.asStateFlow()
79
91
80 private var searchJob: Job? = null
92 private var searchJob: Job? = null
@@ -101,9 +113,44 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
101 searchJob = viewModelScope.launch {
113 searchJob = viewModelScope.launch {
102 delay(NetworkConfig.searchDebounceMs)
114 delay(NetworkConfig.searchDebounceMs)
103 runSearch(trimmed)
115 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+ }
104 }
122 }
105 }
123 }
106
124
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+
107 /** Re-run the current query immediately (no debounce), used by the error-state Retry. Clears
154 /** Re-run the current query immediately (no debounce), used by the error-state Retry. Clears
108 * the caches so the retry actually refetches (the prior attempt may have failed). */
155 * the caches so the retry actually refetches (the prior attempt may have failed). */
109 fun retry() {
156 fun retry() {
@@ -147,6 +194,8 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
147 val hits = java.util.Collections.synchronizedMap(HashMap<String, SearchHit>())
194 val hits = java.util.Collections.synchronizedMap(HashMap<String, SearchHit>())
148 val semaphore = Semaphore(NetworkConfig.maxConcurrentPreviews)
195 val semaphore = Semaphore(NetworkConfig.maxConcurrentPreviews)
149 val searched = java.util.concurrent.atomic.AtomicInteger(0)
196 val searched = java.util.concurrent.atomic.AtomicInteger(0)
197+ val ignoreCase = !_state.value.matchCase
198+ val filter = _state.value.typeFilter
150 coroutineScope {
199 coroutineScope {
151 toSearch.forEach { session ->
200 toSearch.forEach { session ->
152 launch {
201 launch {
@@ -160,11 +209,13 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
160 Log.w("GlobalSearch", "listMessages failed: " + safeExceptionSummary(it))
209 Log.w("GlobalSearch", "listMessages failed: " + safeExceptionSummary(it))
161 emptyList()
210 emptyList()
162 }
211 }
163− val snippet = matchSnippet(messages, query)
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.
164− val titleMatched = session.displayTitle.contains(query, ignoreCase = true)
215+ val titleMatched = session.displayTitle.contains(query, ignoreCase = ignoreCase)
165 if (snippet != null || titleMatched) {
216 if (snippet != null || titleMatched) {
166− val count = countMatches(messages, query) +
217+ val count = countMatches(messages, query, ignoreCase, filter) +
167− countIn(session.displayTitle, query)
218+ countIn(session.displayTitle, query, ignoreCase)
168 hits[session.id] = SearchHit(
219 hits[session.id] = SearchHit(
169 session = session,
220 session = session,
170 snippet = snippet
221 snippet = snippet
@@ -191,33 +242,39 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
191 }
242 }
192
243
193 /** Find the first searchable text containing [query] across [messages] and return a short
244 /** 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,
245+ * snippet centered on the match, or null if nothing matches. [ignoreCase] controls
195− * tool call names/titles/output/error, and file names/paths — for a coding assistant the
246+ * case-sensitivity; [filter] restricts which part types contribute searchable text. */
196− * most common searches (an error string, a touched file) live in tool calls. */
197 private fun matchSnippet(
247 private fun matchSnippet(
198 messages: List<soy.iko.opencode.data.model.MessageWithParts>,
248 messages: List<soy.iko.opencode.data.model.MessageWithParts>,
199 query: String,
249 query: String,
250+ ignoreCase: Boolean,
251+ filter: SearchTypeFilter = SearchTypeFilter.ALL,
200 ): String? {
252 ): String? {
201 for (message in messages) {
253 for (message in messages) {
202− for (candidate in searchableTexts(message.parts)) {
254+ for (candidate in searchableTexts(message.parts, filter)) {
203− val idx = candidate.indexOf(query, ignoreCase = true)
255+ val idx = candidate.indexOf(query, ignoreCase = ignoreCase)
204 if (idx >= 0) return buildSnippet(candidate, idx, query.length)
256 if (idx >= 0) return buildSnippet(candidate, idx, query.length)
205 }
257 }
206 }
258 }
207 return null
259 return null
208 }
260 }
209
261
210− /** The ordered list of searchable strings for a message's parts. Text and reasoning are
262+ /** The ordered list of searchable strings for a message's parts, scoped by [filter].
211− * searched in full; tool calls contribute their name, title, output, error, and the
263+ * Text and reasoning are searched in full; tool calls contribute their name, title,
212− * stringified input; file parts contribute their filename and source path. */
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(
213− private fun searchableTexts(parts: List<soy.iko.opencode.data.model.Part>): List<String> {
268+ parts: List<soy.iko.opencode.data.model.Part>,
269+ filter: SearchTypeFilter = SearchTypeFilter.ALL,
270+ ): List<String> {
214 if (parts.isEmpty()) return emptyList()
271 if (parts.isEmpty()) return emptyList()
215 val out = ArrayList<String>(parts.size)
272 val out = ArrayList<String>(parts.size)
216 for (p in parts) {
273 for (p in parts) {
217 when (p) {
274 when (p) {
218− is TextPart -> out.add(p.text)
275+ is TextPart -> if (filter == SearchTypeFilter.ALL || filter == SearchTypeFilter.MESSAGES) out.add(p.text)
219− is ReasoningPart -> out.add(p.text)
276+ is ReasoningPart -> if (filter == SearchTypeFilter.ALL || filter == SearchTypeFilter.REASONING) out.add(p.text)
220− is ToolPart -> {
277+ is ToolPart -> if (filter == SearchTypeFilter.ALL || filter == SearchTypeFilter.TOOLS) {
221 out.add(p.tool)
278 out.add(p.tool)
222 when (p.state) {
279 when (p.state) {
223 is ToolRunning -> p.state.title?.let(out::add)
280 is ToolRunning -> p.state.title?.let(out::add)
@@ -230,7 +287,7 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
230 }
287 }
231 p.state.inputElement()?.toString()?.let(out::add)
288 p.state.inputElement()?.toString()?.let(out::add)
232 }
289 }
233− is FilePart -> {
290+ is FilePart -> if (filter == SearchTypeFilter.ALL) {
234 p.filename?.let(out::add)
291 p.filename?.let(out::add)
235 p.sourcePath?.let(out::add)
292 p.sourcePath?.let(out::add)
236 }
293 }
@@ -254,23 +311,26 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
254 private fun countMatches(
311 private fun countMatches(
255 messages: List<soy.iko.opencode.data.model.MessageWithParts>,
312 messages: List<soy.iko.opencode.data.model.MessageWithParts>,
256 query: String,
313 query: String,
314+ ignoreCase: Boolean,
315+ filter: SearchTypeFilter = SearchTypeFilter.ALL,
257 ): Int {
316 ): Int {
258 var count = 0
317 var count = 0
259 for (message in messages) {
318 for (message in messages) {
260− for (text in searchableTexts(message.parts)) {
319+ for (text in searchableTexts(message.parts, filter)) {
261− count += countIn(text, query)
320+ count += countIn(text, query, ignoreCase)
262 }
321 }
263 }
322 }
264 return count
323 return count
265 }
324 }
266
325
267− /** Non-overlapping case-insensitive occurrence count of [query] within [text]. */
326+ /** Non-overlapping occurrence count of [query] within [text], case-insensitive unless
327+ * [ignoreCase] is false. */
268− private fun countIn(text: String, query: String): Int {
328+ private fun countIn(text: String, query: String, ignoreCase: Boolean): Int {
269 if (query.isEmpty()) return 0
329 if (query.isEmpty()) return 0
270 var count = 0
330 var count = 0
271 var idx = 0
331 var idx = 0
272 while (true) {
332 while (true) {
273− val next = text.indexOf(query, idx, ignoreCase = true)
333+ val next = text.indexOf(query, idx, ignoreCase = ignoreCase)
274 if (next < 0) break
334 if (next < 0) break
275 count++
335 count++
276 idx = next + query.length
336 idx = next + query.length
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+60−24
@@ -101,6 +101,7 @@ fun ServerListScreen(
101 val profiles by vm.profiles.collectAsStateWithLifecycle()
101 val profiles by vm.profiles.collectAsStateWithLifecycle()
102 val loading by vm.loading.collectAsStateWithLifecycle()
102 val loading by vm.loading.collectAsStateWithLifecycle()
103 val connectingId by vm.connectingId.collectAsStateWithLifecycle()
103 val connectingId by vm.connectingId.collectAsStateWithLifecycle()
104+ val probingId by vm.probingId.collectAsStateWithLifecycle()
104 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
105 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
105 val reconnecting by container.reconnecting.collectAsStateWithLifecycle()
106 val reconnecting by container.reconnecting.collectAsStateWithLifecycle()
106 // The active server's real SSE state, so its card reflects a dropped/failed stream instead of
107 // The active server's real SSE state, so its card reflects a dropped/failed stream instead of
@@ -146,6 +147,21 @@ fun ServerListScreen(
146 }
147 }
147 }
148 }
148
149
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+
149 // Undo snackbar: when a server is marked for deferred deletion, offer Undo. If the
165 // Undo snackbar: when a server is marked for deferred deletion, offer Undo. If the
150 // action is taken before the delay expires, the profile is kept and the delete
166 // action is taken before the delay expires, the profile is kept and the delete
151 // never fires — mirrors the session list's undo UX for consistency. Indefinite + a
167 // never fires — mirrors the session list's undo UX for consistency. Indefinite + a
@@ -350,6 +366,8 @@ fun ServerListScreen(
350 onDuplicate = { onDuplicateProfile(profile.id) },
366 onDuplicate = { onDuplicateProfile(profile.id) },
351 onPendingDelete = { pendingDeleteId = profile.id },
367 onPendingDelete = { pendingDeleteId = profile.id },
352 onShareQr = { pendingQrProfile = profile },
368 onShareQr = { pendingQrProfile = profile },
369+ onTestConnection = { vm.testConnection(profile) },
370+ isProbing = probingId == profile.id,
353 )
371 )
354 }
372 }
355 } else {
373 } else {
@@ -402,6 +420,8 @@ fun ServerListScreen(
402 onDuplicate = { onDuplicateProfile(profile.id) },
420 onDuplicate = { onDuplicateProfile(profile.id) },
403 onPendingDelete = { pendingDeleteId = profile.id },
421 onPendingDelete = { pendingDeleteId = profile.id },
404 onShareQr = { pendingQrProfile = profile },
422 onShareQr = { pendingQrProfile = profile },
423+ onTestConnection = { vm.testConnection(profile) },
424+ isProbing = probingId == profile.id,
405 )
425 )
406 }
426 }
407 }
427 }
@@ -459,6 +479,8 @@ private fun ServerCardContent(
459 onDuplicate: () -> Unit,
479 onDuplicate: () -> Unit,
460 onPendingDelete: () -> Unit,
480 onPendingDelete: () -> Unit,
461 onShareQr: () -> Unit = {},
481 onShareQr: () -> Unit = {},
482+ onTestConnection: () -> Unit = {},
483+ isProbing: Boolean = false,
462 activeState: soy.iko.opencode.data.network.EventStreamClient.ConnectionState? = null,
484 activeState: soy.iko.opencode.data.network.EventStreamClient.ConnectionState? = null,
463) {
485) {
464 Row(
486 Row(
@@ -559,33 +581,47 @@ private fun ServerCardContent(
559 val editLabel = stringResource(R.string.edit)
581 val editLabel = stringResource(R.string.edit)
560 val duplicateLabel = stringResource(R.string.duplicate_server)
582 val duplicateLabel = stringResource(R.string.duplicate_server)
561 val removeLabel = stringResource(R.string.remove)
583 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)
562 // Tie the overflow button's a11y label to this server so a TalkBack user scrolling
586 // Tie the overflow button's a11y label to this server so a TalkBack user scrolling
563 // a long list can tell the rows apart (a bare "More" on every row is ambiguous).
587 // a long list can tell the rows apart (a bare "More" on every row is ambiguous).
564 val moreLabel = stringResource(R.string.more_options_for, profile.displayLabel)
588 val moreLabel = stringResource(R.string.more_options_for, profile.displayLabel)
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 {
565− Box {
596+ Box {
566− IconButton(onClick = { showRowMenu = true }) {
597+ IconButton(onClick = { showRowMenu = true }) {
567− Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
598+ Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
568− }
599+ }
569− DropdownMenu(
600+ DropdownMenu(
570− expanded = showRowMenu,
601+ expanded = showRowMenu,
571− onDismissRequest = { showRowMenu = false },
602+ onDismissRequest = { showRowMenu = false },
572− ) {
603+ ) {
573− DropdownMenuItem(
604+ DropdownMenuItem(
574− text = { Text(editLabel) },
605+ text = { Text(editLabel) },
575− onClick = { showRowMenu = false; onEdit() },
606+ onClick = { showRowMenu = false; onEdit() },
576− )
607+ )
577− DropdownMenuItem(
608+ DropdownMenuItem(
578− text = { Text(duplicateLabel) },
609+ text = { Text(duplicateLabel) },
579− onClick = { showRowMenu = false; onDuplicate() },
610+ onClick = { showRowMenu = false; onDuplicate() },
580− )
611+ )
581− DropdownMenuItem(
612+ DropdownMenuItem(
613+ text = { Text(testConnLabel) },
614+ onClick = { showRowMenu = false; onTestConnection() },
615+ )
616+ DropdownMenuItem(
582− text = { Text(stringResource(R.string.share_qr)) },
617+ text = { Text(stringResource(R.string.share_qr)) },
583− onClick = { showRowMenu = false; onShareQr() },
618+ onClick = { showRowMenu = false; onShareQr() },
584− )
619+ )
585− DropdownMenuItem(
620+ DropdownMenuItem(
586− text = { Text(removeLabel, color = MaterialTheme.colorScheme.error) },
621+ text = { Text(removeLabel, color = MaterialTheme.colorScheme.error) },
587− onClick = { showRowMenu = false; onPendingDelete() },
622+ onClick = { showRowMenu = false; onPendingDelete() },
588− )
623+ )
624+ }
589 }
625 }
590 }
626 }
591 }
627 }
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+64
@@ -27,6 +27,14 @@ import kotlinx.coroutines.withTimeoutOrNull
27 * failed to connect so the snackbar can offer a Retry action. */
27 * failed to connect so the snackbar can offer a Retry action. */
28data class ConnectError(val message: String, val profile: ServerProfile?)
28data class ConnectError(val message: String, val profile: ServerProfile?)
29
29
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+
30/** Sort order for the server list. RECENT (by lastUsed, the default) or NAME (A→Z). */
38/** Sort order for the server list. RECENT (by lastUsed, the default) or NAME (A→Z). */
31enum class ServerSortMode { RECENT, NAME }
39enum class ServerSortMode { RECENT, NAME }
32
40
@@ -92,6 +100,62 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
92 )
100 )
93 val undoEvents: SharedFlow<String> = _undoEvents.asSharedFlow()
101 val undoEvents: SharedFlow<String> = _undoEvents.asSharedFlow()
94
102
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+
95 fun connect(profile: ServerProfile, onConnected: () -> Unit) {
159 fun connect(profile: ServerProfile, onConnected: () -> Unit) {
96 if (_connecting.value != null) return
160 if (_connecting.value != null) return
97 _connecting.value = profile.id
161 _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
4import androidx.activity.compose.BackHandler
4import androidx.activity.compose.BackHandler
5import androidx.compose.foundation.clickable
5import androidx.compose.foundation.clickable
6import androidx.compose.foundation.combinedClickable
6import androidx.compose.foundation.combinedClickable
7+import androidx.compose.foundation.horizontalScroll
7import androidx.compose.foundation.layout.Arrangement
8import androidx.compose.foundation.layout.Arrangement
8import androidx.compose.foundation.layout.Box
9import androidx.compose.foundation.layout.Box
9import androidx.compose.foundation.layout.Column
10import androidx.compose.foundation.layout.Column
@@ -19,6 +20,7 @@ import androidx.compose.foundation.layout.padding
19import androidx.compose.foundation.layout.size
20import androidx.compose.foundation.layout.size
20import androidx.compose.foundation.lazy.LazyColumn
21import androidx.compose.foundation.lazy.LazyColumn
21import androidx.compose.foundation.lazy.items
22import androidx.compose.foundation.lazy.items
23+import androidx.compose.foundation.rememberScrollState
22import androidx.compose.foundation.text.KeyboardActions
24import androidx.compose.foundation.text.KeyboardActions
23import androidx.compose.foundation.text.KeyboardOptions
25import androidx.compose.foundation.text.KeyboardOptions
24import androidx.compose.material.icons.Icons
26import androidx.compose.material.icons.Icons
@@ -55,6 +57,7 @@ import androidx.compose.material3.DropdownMenu
55import androidx.compose.material3.DropdownMenuItem
57import androidx.compose.material3.DropdownMenuItem
56import androidx.compose.material3.ExperimentalMaterial3Api
58import androidx.compose.material3.ExperimentalMaterial3Api
57import androidx.compose.material3.ExtendedFloatingActionButton
59import androidx.compose.material3.ExtendedFloatingActionButton
60+import androidx.compose.material3.FilterChip
58import androidx.compose.material3.Icon
61import androidx.compose.material3.Icon
59import androidx.compose.material3.IconButton
62import androidx.compose.material3.IconButton
60import androidx.compose.material3.MaterialTheme
63import androidx.compose.material3.MaterialTheme
@@ -118,7 +121,10 @@ import soy.iko.opencode.platform.SessionsWidgetProvider
118import soy.iko.opencode.R
121import soy.iko.opencode.R
119import soy.iko.opencode.ui.components.ConnectionBanner
122import soy.iko.opencode.ui.components.ConnectionBanner
120import soy.iko.opencode.ui.components.EmptyState
123import soy.iko.opencode.ui.components.EmptyState
124+import soy.iko.opencode.ui.components.LoadingSize
125+import soy.iko.opencode.ui.components.LoadingSpinner
121import soy.iko.opencode.ui.components.LocalRelativeTimeTick
126import soy.iko.opencode.ui.components.LocalRelativeTimeTick
127+import soy.iko.opencode.ui.components.copyToClipboard
122import soy.iko.opencode.ui.components.reducedMotionAnimateItem
128import soy.iko.opencode.ui.components.reducedMotionAnimateItem
123import soy.iko.opencode.ui.components.RelativeTimeText
129import soy.iko.opencode.ui.components.RelativeTimeText
124import soy.iko.opencode.ui.components.rememberRelativeTimeTick
130import soy.iko.opencode.ui.components.rememberRelativeTimeTick
@@ -151,6 +157,7 @@ fun SessionListScreen(
151 val creating by vm.creating.collectAsStateWithLifecycle()
157 val creating by vm.creating.collectAsStateWithLifecycle()
152 val directoryOptions by vm.directoryOptions.collectAsStateWithLifecycle()
158 val directoryOptions by vm.directoryOptions.collectAsStateWithLifecycle()
153 val anyRunActive by container.anyRunActive.collectAsStateWithLifecycle()
159 val anyRunActive by container.anyRunActive.collectAsStateWithLifecycle()
160+ val runningSessionIds by container.runningSessionIds.collectAsStateWithLifecycle()
154 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
161 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
155 val isOnline by container.isOnline.collectAsStateWithLifecycle()
162 val isOnline by container.isOnline.collectAsStateWithLifecycle()
156 val connectedId = activeConnection?.profile?.id
163 val connectedId = activeConnection?.profile?.id
@@ -221,6 +228,12 @@ fun SessionListScreen(
221 }
228 }
222 }
229 }
223
230
231+ LaunchedEffect(Unit) {
232+ vm.transientInfo.collect { msg ->
233+ snackbar.showSnackbar(msg)
234+ }
235+ }
236+
224 // Retryable failures (create/rename): offer a Retry action so a transient network error
237 // Retryable failures (create/rename): offer a Retry action so a transient network error
225 // is recoverable in one tap instead of forcing the user back through the dialog/FAB.
238 // is recoverable in one tap instead of forcing the user back through the dialog/FAB.
226 LaunchedEffect(Unit) {
239 LaunchedEffect(Unit) {
@@ -275,6 +288,7 @@ fun SessionListScreen(
275 }
288 }
276
289
277 SessionActionUndoEffect(vm, snackbar)
290 SessionActionUndoEffect(vm, snackbar)
291+ BulkSessionActionUndoEffect(vm, snackbar)
278
292
279 Scaffold(
293 Scaffold(
280 topBar = {
294 topBar = {
@@ -442,7 +456,9 @@ fun SessionListScreen(
442 refreshing = refreshing,
456 refreshing = refreshing,
443 haptics = haptics,
457 haptics = haptics,
444 selectedSessionId = selectedSessionId,
458 selectedSessionId = selectedSessionId,
459+ connected = activeConnection != null,
445 onRefresh = vm::refresh,
460 onRefresh = vm::refresh,
461+ onReconnect = vm::retryConnection,
446 onQueryChange = vm::setQuery,
462 onQueryChange = vm::setQuery,
447 onOpenSession = onOpenSession,
463 onOpenSession = onOpenSession,
448 onCreateSession = openNewSession,
464 onCreateSession = openNewSession,
@@ -453,9 +469,12 @@ fun SessionListScreen(
453 onMarkUnread = { vm.markUnread(it) },
469 onMarkUnread = { vm.markUnread(it) },
454 onToggleMute = { container.toggleSessionMute(it) },
470 onToggleMute = { container.toggleSessionMute(it) },
455 onFilterByDirectory = vm::setDirectoryFilter,
471 onFilterByDirectory = vm::setDirectoryFilter,
472+ onClearDirectoryFilter = { vm.setDirectoryFilter(null) },
473+ onShowArchived = { vm.setShowArchived(true) },
456 inSelection = inSelection,
474 inSelection = inSelection,
457 selectedIds = selectedIds,
475 selectedIds = selectedIds,
458 onToggleSelect = ::toggleSelected,
476 onToggleSelect = ::toggleSelected,
477+ runningSessionIds = runningSessionIds,
459 )
478 )
460 }
479 }
461 }
480 }
@@ -555,7 +574,9 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
555 refreshing: Boolean,
574 refreshing: Boolean,
556 haptics: androidx.compose.ui.hapticfeedback.HapticFeedback,
575 haptics: androidx.compose.ui.hapticfeedback.HapticFeedback,
557 selectedSessionId: String?,
576 selectedSessionId: String?,
577+ connected: Boolean,
558 onRefresh: () -> Unit,
578 onRefresh: () -> Unit,
579+ onReconnect: () -> Unit,
559 onQueryChange: (String) -> Unit,
580 onQueryChange: (String) -> Unit,
560 onOpenSession: (String) -> Unit,
581 onOpenSession: (String) -> Unit,
561 onCreateSession: () -> Unit,
582 onCreateSession: () -> Unit,
@@ -566,9 +587,12 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
566 onMarkUnread: (String) -> Unit,
587 onMarkUnread: (String) -> Unit,
567 onToggleMute: (String) -> Unit,
588 onToggleMute: (String) -> Unit,
568 onFilterByDirectory: (String) -> Unit,
589 onFilterByDirectory: (String) -> Unit,
590+ onClearDirectoryFilter: () -> Unit,
591+ onShowArchived: () -> Unit,
569 inSelection: Boolean,
592 inSelection: Boolean,
570 selectedIds: Set<String>,
593 selectedIds: Set<String>,
571 onToggleSelect: (String) -> Unit,
594 onToggleSelect: (String) -> Unit,
595+ runningSessionIds: Set<String> = emptySet(),
572) {
596) {
573 when {
597 when {
574 state.loading -> {
598 state.loading -> {
@@ -607,7 +631,7 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
607 textAlign = androidx.compose.ui.text.style.TextAlign.Center,
631 textAlign = androidx.compose.ui.text.style.TextAlign.Center,
608 )
632 )
609 Spacer(Modifier.size(12.dp))
633 Spacer(Modifier.size(12.dp))
610− TextButton(onClick = onRefresh) {
634+ TextButton(onClick = if (connected) onRefresh else onReconnect) {
611 Text(stringResource(R.string.retry))
635 Text(stringResource(R.string.retry))
612 }
636 }
613 }
637 }
@@ -644,11 +668,45 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
644 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
668 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
645 )
669 )
646 }
670 }
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+ }
647 val sessions = remember(
694 val sessions = remember(
648 state.sessions, state.query, state.previews,
695 state.sessions, state.query, state.previews,
649 state.pinnedIds, state.archivedIds, state.showArchived,
696 state.pinnedIds, state.archivedIds, state.showArchived,
650 ) { state.filtered }
697 ) { state.filtered }
651 if (sessions.isEmpty()) {
698 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+ }
652 Box(
710 Box(
653 modifier = Modifier.fillMaxSize(),
711 modifier = Modifier.fillMaxSize(),
654 contentAlignment = Alignment.Center,
712 contentAlignment = Alignment.Center,
@@ -665,16 +723,29 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
665 )
723 )
666 Spacer(Modifier.size(12.dp))
724 Spacer(Modifier.size(12.dp))
667 Text(
725 Text(
668− stringResource(R.string.no_sessions_match, state.query),
726+ message,
669 style = MaterialTheme.typography.bodyMedium,
727 style = MaterialTheme.typography.bodyMedium,
670 color = MaterialTheme.colorScheme.onSurfaceVariant,
728 color = MaterialTheme.colorScheme.onSurfaceVariant,
729+ textAlign = androidx.compose.ui.text.style.TextAlign.Center,
671 )
730 )
672− // An empty filtered list is recoverable in one tap; offer it inline
731+ when {
673− // rather than making the user hunt for the field's trailing clear icon.
674− if (state.query.isNotEmpty()) {
732+ state.query.isNotEmpty() -> {
675− Spacer(Modifier.size(8.dp))
733+ Spacer(Modifier.size(8.dp))
676− TextButton(onClick = { onQueryChange("") }) {
734+ TextButton(onClick = { onQueryChange("") }) {
677− Text(stringResource(R.string.clear_search))
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+ }
678 }
749 }
679 }
750 }
680 }
751 }
@@ -823,6 +894,7 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
823 isPinned = isPinned,
894 isPinned = isPinned,
824 isArchived = isArchived,
895 isArchived = isArchived,
825 isMuted = isMuted,
896 isMuted = isMuted,
897+ isRunning = session.id in runningSessionIds,
826 onClick = onCardClick,
898 onClick = onCardClick,
827 onRename = onCardRename,
899 onRename = onCardRename,
828 onDelete = onCardDelete,
900 onDelete = onCardDelete,
@@ -988,6 +1060,7 @@ private fun SessionCard(
988 isSelected: Boolean = false,
1060 isSelected: Boolean = false,
989 isPinned: Boolean = false,
1061 isPinned: Boolean = false,
990 isArchived: Boolean = false,
1062 isArchived: Boolean = false,
1063+ isRunning: Boolean = false,
991 onFilterByDirectory: ((String) -> Unit)? = null,
1064 onFilterByDirectory: ((String) -> Unit)? = null,
992 isDirectoryFiltered: Boolean = false,
1065 isDirectoryFiltered: Boolean = false,
993 inSelection: Boolean = false,
1066 inSelection: Boolean = false,
@@ -1045,6 +1118,15 @@ private fun SessionCard(
1045 }
1118 }
1046 Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
1119 Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
1047 Row(verticalAlignment = Alignment.CenterVertically) {
1120 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+ }
1048 if (unreadCount > 0 && !isMuted) {
1130 if (unreadCount > 0 && !isMuted) {
1049 val unreadLabel = stringResource(R.string.unread_count, unreadCount)
1131 val unreadLabel = stringResource(R.string.unread_count, unreadCount)
1050 // Count badge: shows the number of unread messages so the user
1132 // Count badge: shows the number of unread messages so the user
@@ -1198,6 +1280,9 @@ private fun SessionCard(
1198 val archiveLabel = stringResource(if (isArchived) R.string.session_unarchive else R.string.session_archive)
1280 val archiveLabel = stringResource(if (isArchived) R.string.session_unarchive else R.string.session_archive)
1199 val markUnreadLabel = stringResource(R.string.session_mark_unread)
1281 val markUnreadLabel = stringResource(R.string.session_mark_unread)
1200 val muteLabel = stringResource(if (isMuted) R.string.session_unmute else R.string.session_mute)
1282 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)
1201 Box {
1286 Box {
1202 IconButton(onClick = { showRowMenu = true }) {
1287 IconButton(onClick = { showRowMenu = true }) {
1203 Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
1288 Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
@@ -1226,6 +1311,13 @@ private fun SessionCard(
1226 text = { Text(muteLabel) },
1311 text = { Text(muteLabel) },
1227 onClick = { showRowMenu = false; onToggleMute() },
1312 onClick = { showRowMenu = false; onToggleMute() },
1228 )
1313 )
1314+ DropdownMenuItem(
1315+ text = { Text(copyIdLabel) },
1316+ onClick = {
1317+ showRowMenu = false
1318+ copyToClipboard(rowContext, clipLabelSessionId, session.id)
1319+ },
1320+ )
1229 DropdownMenuItem(
1321 DropdownMenuItem(
1230 text = { Text(renameLabel) },
1322 text = { Text(renameLabel) },
1231 onClick = { showRowMenu = false; onRename() },
1323 onClick = { showRowMenu = false; onRename() },
@@ -1320,6 +1412,32 @@ private fun SessionActionUndoEffect(vm: SessionListViewModel, snackbar: Snackbar
1320 }
1412 }
1321}
1413}
1322
1414
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+
1323@Composable
1441@Composable
1324private fun SortMenu(
1442private fun SortMenu(
1325 sortMode: SessionSortMode,
1443 sortMode: SessionSortMode,
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+76−7
@@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.StateFlow
33import kotlinx.coroutines.flow.asSharedFlow
33import kotlinx.coroutines.flow.asSharedFlow
34import kotlinx.coroutines.flow.asStateFlow
34import kotlinx.coroutines.flow.asStateFlow
35import kotlinx.coroutines.flow.collectLatest
35import kotlinx.coroutines.flow.collectLatest
36+import kotlinx.coroutines.flow.first
36import kotlinx.coroutines.flow.flatMapLatest
37import kotlinx.coroutines.flow.flatMapLatest
37import kotlinx.coroutines.flow.flowOf
38import kotlinx.coroutines.flow.flowOf
38import kotlinx.coroutines.flow.stateIn
39import kotlinx.coroutines.flow.stateIn
@@ -51,6 +52,11 @@ enum class SessionActionKind { PINNED, UNPINNED, ARCHIVED, UNARCHIVED }
51
52
52data class SessionActionEvent(val sessionId: String, val kind: SessionActionKind)
53data class SessionActionEvent(val sessionId: String, val kind: SessionActionKind)
53
54
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+
54/** Directory options for the new-session picker: worktree paths the server knows about
60/** Directory options for the new-session picker: worktree paths the server knows about
55 * ([projects]) and the server's default working directory ([serverDefault], its cwd).
61 * ([projects]) and the server's default working directory ([serverDefault], its cwd).
56 * [loaded] distinguishes "not fetched yet" from "fetched, none found". */
62 * [loaded] distinguishes "not fetched yet" from "fetched, none found". */
@@ -189,6 +195,15 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
189 )
195 )
190 val transientErrors: SharedFlow<String> = _transientErrors.asSharedFlow()
196 val transientErrors: SharedFlow<String> = _transientErrors.asSharedFlow()
191
197
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+
192 /** A transient error paired with a retry action (create/rename). The retry lambda lets
207 /** A transient error paired with a retry action (create/rename). The retry lambda lets
193 * the snackbar offer a "Retry" button so a transient network failure doesn't force the
208 * the snackbar offer a "Retry" button so a transient network failure doesn't force the
194 * user back through the dialog/FAB to re-attempt — matching the server list's connect
209 * 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() {
217 )
232 )
218 val sessionActionEvents: SharedFlow<SessionActionEvent> = _sessionActionEvents.asSharedFlow()
233 val sessionActionEvents: SharedFlow<SessionActionEvent> = _sessionActionEvents.asSharedFlow()
219
234
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+
220 /** Session awaiting the undo window to expire before its REST delete fires. Holds the
243 /** Session awaiting the undo window to expire before its REST delete fires. Holds the
221 * Session so Undo can restore it. */
244 * Session so Undo can restore it. */
222 // Keyed by session id, not a single field: the undo window lets several deletes be
245 // 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() {
517 }
540 }
518
541
519 /** Force the SSE stream to reconnect (recovery path for a Failed banner) and
542 /** 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". */
521 fun retryConnection() {
547 fun retryConnection() {
522− container.activeConnection.value?.events?.triggerReconnect()
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()
523− refresh()
565+ }.onSuccess { refresh() }
566+ .onFailure {
567+ container.disconnect()
568+ _transientErrors.tryEmit(container.friendlyError(it))
569+ }
570+ }
524 }
571 }
525
572
526 fun refresh() {
573 fun refresh() {
@@ -781,16 +828,38 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
781 }
828 }
782 }
829 }
783
830
784− /** Bulk-archive (or unarchive) the given sessions. Quietly applies state + persists; the
831+ /** Bulk-archive (or unarchive) the given sessions. Applies state + persists and emits a
785− * caller surfaces a single confirmation (bulk actions batch, so per-item snackbars would
832+ * single [BulkSessionActionEvent] so the caller can show one Undo snackbar (per-item
786− * spam). Reversible by toggling "show archived" and un-archiving. */
833+ * snackbars would spam). Reversible by toggling "show archived" and un-archiving, or
834+ * via the Undo action on the snackbar. */
787 fun bulkArchive(ids: Set<String>, archive: Boolean) {
835 fun bulkArchive(ids: Set<String>, archive: Boolean) {
836+ if (ids.isEmpty()) return
788 ids.forEach { applyArchive(it, archive) }
837 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) }
789 }
854 }
790
855
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). */
792 fun bulkMarkRead(ids: Set<String>) {
859 fun bulkMarkRead(ids: Set<String>) {
860+ if (ids.isEmpty()) return
793 ids.forEach { container.markSessionRead(it) }
861 ids.forEach { container.markSessionRead(it) }
862+ _transientInfo.tryEmit(container.string(R.string.bulk_marked_read, ids.size))
794 }
863 }
795
864
796 /** Mark a single session as unread (re-badges it in the list). */
865 /** 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
11import androidx.compose.foundation.layout.Row
11import androidx.compose.foundation.layout.Row
12import androidx.compose.foundation.layout.fillMaxSize
12import androidx.compose.foundation.layout.fillMaxSize
13import androidx.compose.foundation.layout.fillMaxWidth
13import androidx.compose.foundation.layout.fillMaxWidth
14+import androidx.compose.foundation.layout.imePadding
14import androidx.compose.foundation.layout.padding
15import androidx.compose.foundation.layout.padding
15import androidx.compose.foundation.lazy.LazyColumn
16import androidx.compose.foundation.lazy.LazyColumn
16import androidx.compose.foundation.lazy.items
17import androidx.compose.foundation.lazy.items
@@ -240,7 +241,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
240 },
241 },
241 ) { padding ->
242 ) { padding ->
242 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
243 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
243− Column(modifier = Modifier.fillMaxSize().padding(padding)) {
244+ Column(modifier = Modifier.fillMaxSize().imePadding().padding(padding)) {
244 Text(
245 Text(
245 stringResource(R.string.diagnostics_desc),
246 stringResource(R.string.diagnostics_desc),
246 style = MaterialTheme.typography.bodySmall,
247 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 @@
1package soy.iko.opencode.ui.theme
1package soy.iko.opencode.ui.theme
2
2
3−import android.app.Activity
4−import android.content.Context
5−import android.content.ContextWrapper
6import android.os.Build
3import android.os.Build
7import androidx.compose.foundation.isSystemInDarkTheme
4import androidx.compose.foundation.isSystemInDarkTheme
8import androidx.compose.material3.MaterialTheme
5import androidx.compose.material3.MaterialTheme
@@ -13,9 +10,11 @@ import androidx.compose.material3.lightColorScheme
13import androidx.compose.runtime.Composable
10import androidx.compose.runtime.Composable
14import androidx.compose.runtime.SideEffect
11import androidx.compose.runtime.SideEffect
15import androidx.compose.ui.graphics.Color
12import androidx.compose.ui.graphics.Color
13+import androidx.compose.ui.graphics.luminance
16import androidx.compose.ui.platform.LocalContext
14import androidx.compose.ui.platform.LocalContext
17import androidx.compose.ui.platform.LocalView
15import androidx.compose.ui.platform.LocalView
18import androidx.core.view.WindowCompat
16import androidx.core.view.WindowCompat
17+import soy.iko.opencode.util.findActivity
19
18
20/**
19/**
21 * Tokyo Night — a dark, muted blue/purple palette. Every Material 3 color role is set
20 * 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
207
206
208@Composable
207@Composable
209fun diffColors(): DiffColors {
208fun 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
211 return if (dark) DiffColors(DarkDiffAdd, DarkDiffAddBg, DarkDiffRemove, DarkDiffRemoveBg)
215 return if (dark) DiffColors(DarkDiffAdd, DarkDiffAddBg, DarkDiffRemove, DarkDiffRemoveBg)
212 else DiffColors(LightDiffAdd, LightDiffAddBg, LightDiffRemove, LightDiffRemoveBg)
216 else DiffColors(LightDiffAdd, LightDiffAddBg, LightDiffRemove, LightDiffRemoveBg)
213}
217}
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
13import androidx.compose.foundation.layout.fillMaxSize
13import androidx.compose.foundation.layout.fillMaxSize
14import androidx.compose.foundation.layout.fillMaxWidth
14import androidx.compose.foundation.layout.fillMaxWidth
15import androidx.compose.foundation.layout.height
15import androidx.compose.foundation.layout.height
16+import androidx.compose.foundation.layout.imePadding
16import androidx.compose.foundation.layout.padding
17import androidx.compose.foundation.layout.padding
17import androidx.compose.foundation.layout.size
18import androidx.compose.foundation.layout.size
18import androidx.compose.foundation.lazy.LazyColumn
19import androidx.compose.foundation.lazy.LazyColumn
@@ -67,6 +68,7 @@ import soy.iko.opencode.ui.components.AppTopBar
67import soy.iko.opencode.ui.components.ConnectionBannerFor
68import soy.iko.opencode.ui.components.ConnectionBannerFor
68import soy.iko.opencode.ui.components.EmptyState
69import soy.iko.opencode.ui.components.EmptyState
69import soy.iko.opencode.ui.components.SectionHeader
70import soy.iko.opencode.ui.components.SectionHeader
71+import soy.iko.opencode.ui.components.reducedMotionAnimateItem
70import soy.iko.opencode.ui.vmFactory
72import soy.iko.opencode.ui.vmFactory
71import java.text.NumberFormat
73import java.text.NumberFormat
72import java.util.Locale
74import java.util.Locale
@@ -96,7 +98,7 @@ fun UsageScreen(container: AppContainer, onBack: () -> Unit, onOpenSession: (Str
96 PullToRefreshBox(
98 PullToRefreshBox(
97 isRefreshing = refreshing,
99 isRefreshing = refreshing,
98 onRefresh = { vm.load() },
100 onRefresh = { vm.load() },
99− modifier = Modifier.fillMaxSize().padding(padding),
101+ modifier = Modifier.fillMaxSize().imePadding().padding(padding),
100 ) {
102 ) {
101 Box(modifier = Modifier.fillMaxSize()) {
103 Box(modifier = Modifier.fillMaxSize()) {
102 ConnectionBannerFor(container)
104 ConnectionBannerFor(container)
@@ -198,7 +200,7 @@ private fun UsageContent(
198 }
200 }
199 }
201 }
200 }
202 }
201− item { TotalsCard(report) }
203+ item { TotalsCard(report, timeRange) }
202 item { TokenBreakdownCard(report.totalTokens) }
204 item { TokenBreakdownCard(report.totalTokens) }
203 if (report.byModel.isNotEmpty()) {
205 if (report.byModel.isNotEmpty()) {
204 item { SectionHeader(stringResource(R.string.usage_by_model), Modifier.semantics { heading() }) }
206 item { SectionHeader(stringResource(R.string.usage_by_model), Modifier.semantics { heading() }) }
@@ -211,6 +213,7 @@ private fun UsageContent(
211 tokens = m.tokens,
213 tokens = m.tokens,
212 messages = m.messages,
214 messages = m.messages,
213 costFraction = fractionOf(m.cost, report.totalCost),
215 costFraction = fractionOf(m.cost, report.totalCost),
216+ modifier = Modifier.then(reducedMotionAnimateItem()),
214 )
217 )
215 }
218 }
216 }
219 }
@@ -254,6 +257,7 @@ private fun UsageContent(
254 messages = s.messages,
257 messages = s.messages,
255 costFraction = fractionOf(s.cost, report.totalCost),
258 costFraction = fractionOf(s.cost, report.totalCost),
256 onClick = { onOpenSession(s.sessionId) },
259 onClick = { onOpenSession(s.sessionId) },
260+ modifier = Modifier.then(reducedMotionAnimateItem()),
257 )
261 )
258 }
262 }
259 }
263 }
@@ -291,11 +295,17 @@ private fun CostBar(fraction: Float) {
291}
295}
292
296
293@Composable
297@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+ }
295 Card(modifier = Modifier.fillMaxWidth()) {
305 Card(modifier = Modifier.fillMaxWidth()) {
296 Column(Modifier.padding(16.dp)) {
306 Column(Modifier.padding(16.dp)) {
297 Text(
307 Text(
298− stringResource(R.string.usage_total_cost),
308+ stringResource(R.string.usage_total_cost_with_range, stringResource(rangeLabelRes)),
299 style = MaterialTheme.typography.labelMedium,
309 style = MaterialTheme.typography.labelMedium,
300 color = MaterialTheme.colorScheme.onSurfaceVariant,
310 color = MaterialTheme.colorScheme.onSurfaceVariant,
301 )
311 )
@@ -352,8 +362,9 @@ private fun UsageRow(
352 messages: Int,
362 messages: Int,
353 costFraction: Float,
363 costFraction: Float,
354 onClick: (() -> Unit)? = null,
364 onClick: (() -> Unit)? = null,
365+ modifier: Modifier = Modifier,
355) {
366) {
356− val rowModifier = Modifier
367+ val rowModifier = modifier
357 .fillMaxWidth()
368 .fillMaxWidth()
358 .let { if (onClick != null) it.clickable(role = Role.Button, onClick = onClick) else it }
369 .let { if (onClick != null) it.clickable(role = Role.Button, onClick = onClick) else it }
359 .padding(vertical = 6.dp)
370 .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 @@
19 <string name="settings">Ajustes</string>
19 <string name="settings">Ajustes</string>
20 <string name="files">Archivos</string>
20 <string name="files">Archivos</string>
21 <string name="share">Compartir</string>
21 <string name="share">Compartir</string>
22+ <string name="copy_as_markdown">Copiar como Markdown</string>
22 <string name="share_subject">Sesión de opencode</string>
23 <string name="share_subject">Sesión de opencode</string>
23 <string name="send">Enviar</string>
24 <string name="send">Enviar</string>
24 <string name="stop">Detener</string>
25 <string name="stop">Detener</string>
@@ -462,6 +463,7 @@
462 <string name="usage">Uso</string>
463 <string name="usage">Uso</string>
463 <string name="usage_title">Uso y coste</string>
464 <string name="usage_title">Uso y coste</string>
464 <string name="usage_total_cost">Coste total</string>
465 <string name="usage_total_cost">Coste total</string>
466+ <string name="usage_total_cost_with_range">Coste total (%1$s)</string>
465 <string name="usage_total_tokens">Tokens totales</string>
467 <string name="usage_total_tokens">Tokens totales</string>
466 <string name="usage_input">Entrada</string>
468 <string name="usage_input">Entrada</string>
467 <string name="usage_output">Salida</string>
469 <string name="usage_output">Salida</string>
@@ -565,6 +567,10 @@
565 <string name="session_archived_badge">Archivada</string>
567 <string name="session_archived_badge">Archivada</string>
566 <string name="session_shared_badge">Compartida</string>
568 <string name="session_shared_badge">Compartida</string>
567 <string name="session_reverted_badge">Revertida</string>
569 <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>
568 <string name="sort_active">Orden activo</string>
574 <string name="sort_active">Orden activo</string>
569 <string name="sort_descending">Descendente</string>
575 <string name="sort_descending">Descendente</string>
570 <string name="sort_ascending">Ascendente</string>
576 <string name="sort_ascending">Ascendente</string>
@@ -587,6 +593,7 @@
587 <!-- UI/UX batch: permisos, confirmaciones, indicaciones de búsqueda, accesibilidad -->
593 <!-- UI/UX batch: permisos, confirmaciones, indicaciones de búsqueda, accesibilidad -->
588 <string name="permission_progress">%1$d de %2$d</string>
594 <string name="permission_progress">%1$d de %2$d</string>
589 <string name="permission_waiting">El agente está esperando. Se rechazará automáticamente en %1$d min si no eliges.</string>
595 <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>
590 <string name="action_continue">Continuar</string>
597 <string name="action_continue">Continuar</string>
591 <string name="summarize_confirm_title">¿Resumir la conversación?</string>
598 <string name="summarize_confirm_title">¿Resumir la conversación?</string>
592 <string name="summarize_confirm_text">Los mensajes anteriores se compactan en un resumen para liberar contexto. Esto no se puede deshacer aquí.</string>
599 <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 @@
601 <string name="clip_label_path">Ruta</string>
608 <string name="clip_label_path">Ruta</string>
602 <string name="clip_label_message">Mensaje</string>
609 <string name="clip_label_message">Mensaje</string>
603 <string name="clip_label_share">Enlace para compartir</string>
610 <string name="clip_label_share">Enlace para compartir</string>
611+ <string name="clip_label_session_id">ID de sesión</string>
604 <string name="search_contents_hint">Escribe para buscar en el contenido del archivo</string>
612 <string name="search_contents_hint">Escribe para buscar en el contenido del archivo</string>
605 <string name="search_symbols_hint">Escribe para buscar símbolos</string>
613 <string name="search_symbols_hint">Escribe para buscar símbolos</string>
606 <string name="search_keep_typing">Sigue escribiendo para buscar…</string>
614 <string name="search_keep_typing">Sigue escribiendo para buscar…</string>
@@ -653,7 +661,6 @@
653 <string name="regenerate">Regenerar</string>
661 <string name="regenerate">Regenerar</string>
654 <string name="find_in_conversation">Buscar en la conversación</string>
662 <string name="find_in_conversation">Buscar en la conversación</string>
655 <string name="search_in_conversation">Buscar en la conversación</string>
663 <string name="search_in_conversation">Buscar en la conversación</string>
656− <string name="search_match_count">%1$d de %2$d</string>
657 <string name="keyboard_shortcuts">Atajos de teclado</string>
664 <string name="keyboard_shortcuts">Atajos de teclado</string>
658 <string name="keyboard_shortcuts_title">Atajos de teclado</string>
665 <string name="keyboard_shortcuts_title">Atajos de teclado</string>
659 <string name="shortcut_open_palette">Abrir paleta de comandos</string>
666 <string name="shortcut_open_palette">Abrir paleta de comandos</string>
@@ -694,6 +701,10 @@
694 <string name="file_details_size_date">%1$s · %2$s</string>
701 <string name="file_details_size_date">%1$s · %2$s</string>
695 <string name="filter_by_project">Filtrar por este proyecto</string>
702 <string name="filter_by_project">Filtrar por este proyecto</string>
696 <string name="clear_project_filter">Quitar filtro de proyecto</string>
703 <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>
697 <string name="empty_file_detail_title">Ningún archivo seleccionado</string>
708 <string name="empty_file_detail_title">Ningún archivo seleccionado</string>
698 <string name="empty_file_detail_desc">Elige un archivo de la lista para verlo aquí.</string>
709 <string name="empty_file_detail_desc">Elige un archivo de la lista para verlo aquí.</string>
699
710
@@ -772,6 +783,7 @@
772 <string name="mark_unread">Marcar como no leído</string>
783 <string name="mark_unread">Marcar como no leído</string>
773 <string name="search_progress">Buscadas %1$d / %2$d sesiones</string>
784 <string name="search_progress">Buscadas %1$d / %2$d sesiones</string>
774 <string name="recent_searches">Búsquedas recientes</string>
785 <string name="recent_searches">Búsquedas recientes</string>
786+ <string name="clear_history">Borrar historial</string>
775 <string name="load_more_lines">Cargar %1$d líneas más</string>
787 <string name="load_more_lines">Cargar %1$d líneas más</string>
776 <string name="detect_from_server">Detectar desde el servidor</string>
788 <string name="detect_from_server">Detectar desde el servidor</string>
777 <string name="detecting_pin">Detectando pin de certificado…</string>
789 <string name="detecting_pin">Detectando pin de certificado…</string>
@@ -784,6 +796,9 @@
784 <string name="mcp_remove_title">¿Quitar servidor MCP?</string>
796 <string name="mcp_remove_title">¿Quitar servidor MCP?</string>
785 <string name="mcp_remove_text">“%1$s” se quitará de tus servidores MCP.</string>
797 <string name="mcp_remove_text">“%1$s” se quitará de tus servidores MCP.</string>
786 <string name="mcp_disabled_locally">Desactivado en este dispositivo</string>
798 <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>
787 <string name="file_share_too_large">El archivo es demasiado grande para compartir</string>
802 <string name="file_share_too_large">El archivo es demasiado grande para compartir</string>
788 <string name="search_filter_all">Todo</string>
803 <string name="search_filter_all">Todo</string>
789 <string name="search_filter_messages">Mensajes</string>
804 <string name="search_filter_messages">Mensajes</string>
@@ -795,4 +810,22 @@
795 <string name="server_http">HTTP · no seguro</string>
810 <string name="server_http">HTTP · no seguro</string>
796 <string name="server_https">HTTPS</string>
811 <string name="server_https">HTTPS</string>
797 <string name="server_https_pinned">HTTPS · cert fijado</string>
812 <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>
798</resources>
831</resources>
app/src/main/res/values/strings.xml+39−1
@@ -19,6 +19,7 @@
19 <string name="settings">Settings</string>
19 <string name="settings">Settings</string>
20 <string name="files">Files</string>
20 <string name="files">Files</string>
21 <string name="share">Share</string>
21 <string name="share">Share</string>
22+ <string name="copy_as_markdown">Copy as Markdown</string>
22 <string name="share_subject">opencode session</string>
23 <string name="share_subject">opencode session</string>
23 <string name="send">Send</string>
24 <string name="send">Send</string>
24 <string name="stop">Stop</string>
25 <string name="stop">Stop</string>
@@ -378,6 +379,7 @@
378 <string name="session_archive">Archive</string>
379 <string name="session_archive">Archive</string>
379 <string name="session_unarchive">Unarchive</string>
380 <string name="session_unarchive">Unarchive</string>
380 <string name="session_pinned">Pinned</string>
381 <string name="session_pinned">Pinned</string>
382+ <string name="session_running_badge">Agent running</string>
381 <string name="session_mark_unread">Mark as unread</string>
383 <string name="session_mark_unread">Mark as unread</string>
382 <string name="session_mute">Mute notifications</string>
384 <string name="session_mute">Mute notifications</string>
383 <string name="session_unmute">Unmute notifications</string>
385 <string name="session_unmute">Unmute notifications</string>
@@ -484,6 +486,7 @@
484 <string name="usage">Usage</string>
486 <string name="usage">Usage</string>
485 <string name="usage_title">Usage &amp; cost</string>
487 <string name="usage_title">Usage &amp; cost</string>
486 <string name="usage_total_cost">Total cost</string>
488 <string name="usage_total_cost">Total cost</string>
489+ <string name="usage_total_cost_with_range">Total cost (%1$s)</string>
487 <string name="usage_total_tokens">Total tokens</string>
490 <string name="usage_total_tokens">Total tokens</string>
488 <string name="usage_input">Input</string>
491 <string name="usage_input">Input</string>
489 <string name="usage_output">Output</string>
492 <string name="usage_output">Output</string>
@@ -588,6 +591,9 @@
588 <string name="session_archived_badge">Archived</string>
591 <string name="session_archived_badge">Archived</string>
589 <string name="session_shared_badge">Shared</string>
592 <string name="session_shared_badge">Shared</string>
590 <string name="session_reverted_badge">Reverted</string>
593 <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>
591 <string name="sort_active">Active sort</string>
597 <string name="sort_active">Active sort</string>
592 <string name="sort_descending">Descending</string>
598 <string name="sort_descending">Descending</string>
593 <string name="sort_ascending">Ascending</string>
599 <string name="sort_ascending">Ascending</string>
@@ -610,6 +616,7 @@
610 <!-- UI/UX batch: permissions, confirmations, search prompts, a11y -->
616 <!-- UI/UX batch: permissions, confirmations, search prompts, a11y -->
611 <string name="permission_progress">%1$d of %2$d</string>
617 <string name="permission_progress">%1$d of %2$d</string>
612 <string name="permission_waiting">The agent is waiting. Auto-rejecting in %1$d min if you don\'t choose.</string>
618 <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>
613 <string name="action_continue">Continue</string>
620 <string name="action_continue">Continue</string>
614 <string name="summarize_confirm_title">Summarize conversation?</string>
621 <string name="summarize_confirm_title">Summarize conversation?</string>
615 <string name="summarize_confirm_text">Earlier messages are compacted into a summary to free up context. This can\'t be undone here.</string>
622 <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 @@
624 <string name="clip_label_path">Path</string>
631 <string name="clip_label_path">Path</string>
625 <string name="clip_label_message">Message</string>
632 <string name="clip_label_message">Message</string>
626 <string name="clip_label_share">Share link</string>
633 <string name="clip_label_share">Share link</string>
634+ <string name="clip_label_session_id">Session ID</string>
627 <string name="search_contents_hint">Type to search file contents</string>
635 <string name="search_contents_hint">Type to search file contents</string>
628 <string name="search_symbols_hint">Type to search symbols</string>
636 <string name="search_symbols_hint">Type to search symbols</string>
629 <string name="search_keep_typing">Keep typing to search…</string>
637 <string name="search_keep_typing">Keep typing to search…</string>
@@ -654,7 +662,6 @@
654 <string name="regenerate">Regenerate</string>
662 <string name="regenerate">Regenerate</string>
655 <string name="find_in_conversation">Find in conversation</string>
663 <string name="find_in_conversation">Find in conversation</string>
656 <string name="search_in_conversation">Search in conversation</string>
664 <string name="search_in_conversation">Search in conversation</string>
657− <string name="search_match_count">%1$d of %2$d</string>
658 <string name="keyboard_shortcuts">Keyboard shortcuts</string>
665 <string name="keyboard_shortcuts">Keyboard shortcuts</string>
659 <string name="keyboard_shortcuts_title">Keyboard shortcuts</string>
666 <string name="keyboard_shortcuts_title">Keyboard shortcuts</string>
660 <string name="shortcut_open_palette">Open command palette</string>
667 <string name="shortcut_open_palette">Open command palette</string>
@@ -695,6 +702,10 @@
695 <string name="file_details_size_date">%1$s · %2$s</string>
702 <string name="file_details_size_date">%1$s · %2$s</string>
696 <string name="filter_by_project">Filter by this project</string>
703 <string name="filter_by_project">Filter by this project</string>
697 <string name="clear_project_filter">Clear project filter</string>
704 <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>
698 <string name="empty_file_detail_title">No file selected</string>
709 <string name="empty_file_detail_title">No file selected</string>
699 <string name="empty_file_detail_desc">Pick a file from the list to view it here.</string>
710 <string name="empty_file_detail_desc">Pick a file from the list to view it here.</string>
700
711
@@ -772,6 +783,7 @@
772 <string name="mark_unread">Mark as unread</string>
783 <string name="mark_unread">Mark as unread</string>
773 <string name="search_progress">Searched %1$d / %2$d sessions</string>
784 <string name="search_progress">Searched %1$d / %2$d sessions</string>
774 <string name="recent_searches">Recent searches</string>
785 <string name="recent_searches">Recent searches</string>
786+ <string name="clear_history">Clear history</string>
775 <string name="load_more_lines">Load %1$d more lines</string>
787 <string name="load_more_lines">Load %1$d more lines</string>
776 <string name="detect_from_server">Detect from server</string>
788 <string name="detect_from_server">Detect from server</string>
777 <string name="detecting_pin">Detecting certificate pin…</string>
789 <string name="detecting_pin">Detecting certificate pin…</string>
@@ -784,6 +796,9 @@
784 <string name="mcp_remove_title">Remove MCP server?</string>
796 <string name="mcp_remove_title">Remove MCP server?</string>
785 <string name="mcp_remove_text">“%1$s” will be removed from your MCP servers.</string>
797 <string name="mcp_remove_text">“%1$s” will be removed from your MCP servers.</string>
786 <string name="mcp_disabled_locally">Disabled on this device</string>
798 <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>
787 <string name="file_share_too_large">File is too large to share</string>
802 <string name="file_share_too_large">File is too large to share</string>
788 <string name="search_filter_all">All</string>
803 <string name="search_filter_all">All</string>
789 <string name="search_filter_messages">Messages</string>
804 <string name="search_filter_messages">Messages</string>
@@ -795,4 +810,27 @@
795 <string name="server_http">HTTP · not secured</string>
810 <string name="server_http">HTTP · not secured</string>
796 <string name="server_https">HTTPS</string>
811 <string name="server_https">HTTPS</string>
797 <string name="server_https_pinned">HTTPS · cert pinned</string>
812 <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>
798</resources>
836</resources>
app/src/test/java/soy/iko/opencode/UxTest.kt+23−10
@@ -5,34 +5,47 @@ import org.junit.Assert.assertTrue
5import org.junit.Test
5import org.junit.Test
6import soy.iko.opencode.data.model.Session
6import soy.iko.opencode.data.model.Session
7import soy.iko.opencode.data.model.TimeInfo
7import 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
9import soy.iko.opencode.ui.session.SessionListState
10import soy.iko.opencode.ui.session.SessionListState
10import java.util.concurrent.TimeUnit
11import java.util.concurrent.TimeUnit
11
12
12class UxTest {
13class UxTest {
13
14
14− // --- relativeTime ---
15+ // --- relativeTimeUnit (pure logic, locale-independent) ---
15
16
16 @Test
17 @Test
17 fun relativeTimeHandlesNullAndZero() {
18 fun relativeTimeHandlesNullAndZero() {
18− assertEquals("", relativeTime(null))
19+ assertEquals(null, relativeTimeUnit(null))
19− assertEquals("", relativeTime(0))
20+ assertEquals(null, relativeTimeUnit(0))
20− assertEquals("", relativeTime(-1))
21+ assertEquals(null, relativeTimeUnit(-1))
21 }
22 }
22
23
23 @Test
24 @Test
24 fun relativeTimeFormatsMinutes() {
25 fun relativeTimeFormatsMinutes() {
25 val now = System.currentTimeMillis()
26 val now = System.currentTimeMillis()
26− assertEquals("now", relativeTime(now))
27+ assertEquals(RelativeTimeUnit.Now(), relativeTimeUnit(now))
28+ assertEquals(
29+ RelativeTimeUnit.Minutes(5),
27− assertEquals("5m", relativeTime(now - TimeUnit.MINUTES.toMillis(5)))
30+ relativeTimeUnit(now - TimeUnit.MINUTES.toMillis(5)),
31+ )
28 }
32 }
29
33
30 @Test
34 @Test
31 fun relativeTimeFormatsHoursAndDays() {
35 fun relativeTimeFormatsHoursAndDays() {
32 val now = System.currentTimeMillis()
36 val now = System.currentTimeMillis()
37+ assertEquals(
38+ RelativeTimeUnit.Hours(3),
33− assertEquals("3h", relativeTime(now - TimeUnit.HOURS.toMillis(3)))
39+ relativeTimeUnit(now - TimeUnit.HOURS.toMillis(3)),
40+ )
41+ assertEquals(
42+ RelativeTimeUnit.Days(2),
34− assertEquals("2d", relativeTime(now - TimeUnit.DAYS.toMillis(2)))
43+ relativeTimeUnit(now - TimeUnit.DAYS.toMillis(2)),
44+ )
45+ assertEquals(
46+ RelativeTimeUnit.Weeks(1),
35− assertEquals("1w", relativeTime(now - TimeUnit.DAYS.toMillis(7)))
47+ relativeTimeUnit(now - TimeUnit.DAYS.toMillis(7)),
48+ )
36 }
49 }
37
50
38 // --- Session search/filter ---
51 // --- Session search/filter ---
app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+5
@@ -392,6 +392,9 @@ class FakeAppContainer : AppContainer() {
392 val fakeSessionPrefsStore = FakeSessionPrefsStore()
392 val fakeSessionPrefsStore = FakeSessionPrefsStore()
393 val fakeAttachmentDraftStore = FakeAttachmentDraftStore()
393 val fakeAttachmentDraftStore = FakeAttachmentDraftStore()
394 val fakeMessageCacheStore = FakeMessageCacheStore()
394 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() {}
395 // In-memory outbox (no Context / disk); exercises the same reduce logic the app uses.
398 // In-memory outbox (no Context / disk); exercises the same reduce logic the app uses.
396 val fakeOutboxStore = object : OutboxStore() {}
399 val fakeOutboxStore = object : OutboxStore() {}
397 val fakeUnread = MutableStateFlow<Map<String, Int>>(emptyMap())
400 val fakeUnread = MutableStateFlow<Map<String, Int>>(emptyMap())
@@ -402,6 +405,8 @@ class FakeAppContainer : AppContainer() {
402 override val sessionPrefsStore: SessionPrefsStore = fakeSessionPrefsStore
405 override val sessionPrefsStore: SessionPrefsStore = fakeSessionPrefsStore
403 override val attachmentDraftStore: AttachmentDraftStore = fakeAttachmentDraftStore
406 override val attachmentDraftStore: AttachmentDraftStore = fakeAttachmentDraftStore
404 override val messageCacheStore: MessageCacheStore = fakeMessageCacheStore
407 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
405 override val outboxStore: OutboxStore = fakeOutboxStore
410 override val outboxStore: OutboxStore = fakeOutboxStore
406 override val activeConnection: StateFlow<OpencodeConnection?> = fakeActiveConnection.asStateFlow()
411 override val activeConnection: StateFlow<OpencodeConnection?> = fakeActiveConnection.asStateFlow()
407 override val unread: StateFlow<Map<String, Int>> = fakeUnread.asStateFlow()
412 override val unread: StateFlow<Map<String, Int>> = fakeUnread.asStateFlow()