git.iko.soy/opencode-android
on master help sign in
Add 38 UI/UX improvements: date grouping, per-hunk diff collapse, tablet width caps, skeleton loaders, settings search, JSON export, file viewer zoom/metadata, notification theming, and consistency fixes across screens
commitd647ff24b1c00c340738695378d94ca20725fe03 authoriko <mail@iko.soy> dateSat 4 Jul 2026, 15:58 (3 months ago) parent9f0326a
32 files changed+2492−844
app/src/main/java/soy/iko/opencode/MainActivity.kt+55−1 app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+13 app/src/main/java/soy/iko/opencode/data/repo/FileBrowserPrefs.kt+90 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+2 app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+12−4 app/src/main/java/soy/iko/opencode/ui/chat/ChatAttachments.kt+42 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+46−1 app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.kt+28 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+65−5 app/src/main/java/soy/iko/opencode/ui/chat/ViewSourceDialog.kt+76 app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+4−1 app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+129−22 app/src/main/java/soy/iko/opencode/ui/components/EmptyState.kt+29−1 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+136−14 app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+32−1 app/src/main/java/soy/iko/opencode/ui/components/SkeletonRow.kt+97 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+125−42 app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.kt+29−1 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+91−27 app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.kt+41−13 app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+34−3 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+70−39 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+154−9 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+19−6 app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+16−35 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+37−3 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+954−599 app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.kt+23−17 app/src/main/res/values-es/strings.xml+17 app/src/main/res/values-night/colors.xml+3 app/src/main/res/values/colors.xml+5 app/src/main/res/values/strings.xml+18
app/src/main/java/soy/iko/opencode/MainActivity.kt+55−1
@@ -47,6 +47,9 @@ class MainActivity : FragmentActivity() {
4747 private var openSessionHandled = false
4848 private var newSessionHandled = false
4949 private var openFileHandled = false
50+ // Set when a deep link was present but failed validation (malformed id/path). Surfaced
51+ // as a toast from onResume so the user knows the link was rejected, not silently ignored.
52+ private var showInvalidLinkToast = false
5053 // Cold-start prompts (crash report, notification rationale) rendered as Compose M3
5154 // dialogs. Hoisted as Activity-level mutableState so the non-composable trigger logic
5255 // (onCreate/onResume) can flip them and the Compose tree (inside OpencodeTheme) reads
@@ -91,6 +94,26 @@ class MainActivity : FragmentActivity() {
9194 // before app lock resolves. Mirrors the SettingsScreen's null-gate.
9295 var settingsLoaded = false
9396 splash.setKeepOnScreenCondition { !settingsLoaded }
97+ // Custom splash exit: a quick fade-out instead of the system default's slide, so the
98+ // hand-off to Compose reads as a branded dissolve. The listener must call
99+ // splashScreenViewProvider.remove() to release the splash view; otherwise the system
100+ // keeps it on screen for its default duration. Reduced-motion is respected via the
101+ // animator's duration scale (the system zeroes it under reduced motion).
102+ splash.setOnExitAnimationListener { splashScreenViewProvider ->
103+ val view = splashScreenViewProvider.view
104+ val fade = android.view.animation.AlphaAnimation(1f, 0f).apply {
105+ duration = 180
106+ fillAfter = true
107+ }
108+ fade.setAnimationListener(object : android.view.animation.Animation.AnimationListener {
109+ override fun onAnimationStart(animation: android.view.animation.Animation?) {}
110+ override fun onAnimationEnd(animation: android.view.animation.Animation?) {
111+ splashScreenViewProvider.remove()
112+ }
113+ override fun onAnimationRepeat(animation: android.view.animation.Animation?) {}
114+ })
115+ view.startAnimation(fade)
116+ }
94117 setContent {
95118 val theme by remember(container) {
96119 container.settingsStore.themeMode
@@ -210,6 +233,12 @@ class MainActivity : FragmentActivity() {
210233 // ensures the user is prompted once per Activity instance when still ungranted rather
211234 // than silently losing all notifications.
212235 maybeRequestNotificationPermission()
236+ // Surface a rejected deep link once, so a malformed opencode:// link doesn't appear
237+ // to be silently ignored. Cleared after firing so a rotation doesn't re-toast.
238+ if (showInvalidLinkToast) {
239+ showInvalidLinkToast = false
240+ android.widget.Toast.makeText(this, R.string.deep_link_invalid, android.widget.Toast.LENGTH_SHORT).show()
241+ }
213242 }
214243
215244 /** Capture text shared from another app so it can be prefilled into a session draft,
@@ -240,6 +269,24 @@ class MainActivity : FragmentActivity() {
240269 container.setPendingSharedMedia(uris.map { it.toString() })
241270 shareIntentHandled = true
242271 }
272+ } else if (action == Intent.ACTION_SEND) {
273+ // A non-image file (e.g. a PDF or text file shared from another app): stage it
274+ // as an attachment too, so the user can send it to the agent. The attachment
275+ // pipeline handles non-image mimes with a generic file icon. Falls back to
276+ // EXTRA_TEXT when no stream is present (a plain text share).
277+ val uri = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
278+ if (uri != null) {
279+ container.setPendingSharedMedia(listOf(uri.toString()))
280+ shareIntentHandled = true
281+ } else {
282+ val text = intent.getStringExtra(Intent.EXTRA_TEXT)
283+ ?.takeIf { it.isNotBlank() }
284+ ?.take(10_000) // cap to prevent unbounded memory usage from malicious shares
285+ if (text != null) {
286+ container.setPendingShare(text)
287+ shareIntentHandled = true
288+ }
289+ }
243290 } else {
244291 val text = intent.getStringExtra(Intent.EXTRA_TEXT)
245292 ?.takeIf { it.isNotBlank() }
@@ -266,6 +313,11 @@ class MainActivity : FragmentActivity() {
266313 ?.takeIf { it.isNotBlank() }
267314 container.requestOpenSession(it, profileId)
268315 openSessionHandled = true
316+ } ?: run {
317+ // The link carried a session host but no valid id (or a blank/traversal
318+ // attempt). Surface a toast so the user knows the link was rejected,
319+ // rather than silently no-op'ing (which reads as "the app did nothing").
320+ if (!seg.isNullOrBlank()) showInvalidLinkToast = true
269321 }
270322 }
271323 }
@@ -275,7 +327,9 @@ class MainActivity : FragmentActivity() {
275327 val rawPath = data.path?.removePrefix("/file/")?.let { android.net.Uri.decode(it) }
276328 if (!openFileHandled) {
277329 rawPath?.takeIf { it.isNotBlank() && VALID_FILE_PATH.containsMatchIn(it) && !it.contains("..") }
278− ?.let { container.requestOpenFile(it); openFileHandled = true }
330+ ?.let { container.requestOpenFile(it); openFileHandled = true } ?: run {
331+ if (!rawPath.isNullOrBlank()) showInvalidLinkToast = true
332+ }
279333 }
280334 }
281335 "new" -> {
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+13
@@ -260,6 +260,12 @@ object NetworkConfig {
260260 /** Extra bottom content padding (dp) for the chat message list, reserving room for the
261261 * jump-to-latest FAB so it never floats over the last message. */
262262 const val chatListFabInsetDp = 80
263+
264+ /** Extra bottom content padding (dp) for single-pane list screens that host a FAB
265+ * (session list, server list, MCP), reserving room so the FAB never floats over the
266+ * last row. Unified across screens so the inset doesn't drift (the session list
267+ * previously hardcoded 96dp while the chat list used [chatListFabInsetDp]). */
268+ const val listFabInsetDp = 96
263269 /** Left pane weight in the two-pane layout (session list). */
264270 const val twoPaneLeftWeight = 0.38f
265271 /** Right pane weight in the two-pane layout (chat detail). */
@@ -278,6 +284,13 @@ object NetworkConfig {
278284 * keeps line lengths comfortable. Applied only above [twoPaneWidthThresholdDp]. */
279285 const val chatContentMaxWidthDp = 800
280286
287+ /** Maximum width (dp) for a single-pane list/form screen on large screens (Diagnostics,
288+ * MCP, Usage, Global Search, File Browser). Mirrors [chatContentMaxWidthDp]'s rationale
289+ * at a tighter width suited to dense list rows and form fields. Settings and ServerEdit
290+ * use 600dp directly; this constant centralizes the same value for the screens that
291+ * previously stretched edge-to-edge on tablets. */
292+ const val listContentMaxWidthDp = 600
293+
281294 /** Maximum width (dp) for the full-screen chat composer dialog on large screens, so
282295 * the editor doesn't stretch edge-to-edge on a tablet (matching [chatContentMaxWidthDp]'s
283296 * readability rationale for the message list). */
app/src/main/java/soy/iko/opencode/data/repo/FileBrowserPrefs.ktnew file+90
@@ -0,0 +1,90 @@
1+package soy.iko.opencode.data.repo
2+
3+import android.content.Context
4+import androidx.datastore.preferences.core.booleanPreferencesKey
5+import androidx.datastore.preferences.core.edit
6+import androidx.datastore.preferences.core.stringPreferencesKey
7+import androidx.datastore.preferences.preferencesDataStore
8+import kotlinx.coroutines.flow.Flow
9+import kotlinx.coroutines.flow.flowOf
10+import kotlinx.coroutines.flow.map
11+
12+private val Context.fileBrowserPrefsDataStore by preferencesDataStore(name = "file_browser_prefs")
13+
14+/** Sort axis for the file-browser directory listing. Name is always available; Size/Modified
15+ * depend on the server emitting size/mtime (otherwise they sort nulls to the bottom). */
16+enum class FileSortKey { NAME, SIZE, MODIFIED }
17+
18+/**
19+ * Persisted file-browser display prefs (folders-first, show-hidden, sort key + direction,
20+ * changed-only filter) so a user who prefers size-sort or hidden-files-visible keeps it
21+ * across restarts, not just rotation.
22+ *
23+ * `open` with a `protected` no-arg constructor so tests can subclass and override the flows
24+ * without a real DataStore (mirrors the other stores). The flow accessors are getters (not
25+ * eager initializers) and null-guard the context so the test constructor never touches disk.
26+ */
27+@Suppress("TooManyFunctions")
28+open class FileBrowserPrefs private constructor(
29+ private val appContext: Context?,
30+ @Suppress("unused") private val testMode: Boolean,
31+) {
32+ constructor(context: Context) : this(context.applicationContext, false)
33+ protected constructor() : this(null, true)
34+
35+ private val foldersFirstKey = booleanPreferencesKey("folders_first")
36+ private val showHiddenKey = booleanPreferencesKey("show_hidden")
37+ private val sortKeyPref = stringPreferencesKey("sort_key")
38+ private val sortDescKey = booleanPreferencesKey("sort_desc")
39+ private val changedOnlyKey = booleanPreferencesKey("changed_only")
40+
41+ // Folders-first is the most useful default; the toggle flips between folders-first name
42+ // sort and the server's raw order.
43+ open val foldersFirst: Flow<Boolean>
44+ get() = appContext?.fileBrowserPrefsDataStore?.data?.map { it[foldersFirstKey] ?: true }
45+ ?: flowOf(true)
46+
47+ // Default off so the listing isn't cluttered with dotfiles; the user reveals .env / .git
48+ // / .opencode on demand.
49+ open val showHidden: Flow<Boolean>
50+ get() = appContext?.fileBrowserPrefsDataStore?.data?.map { it[showHiddenKey] ?: false }
51+ ?: flowOf(false)
52+
53+ // Stored as the enum name; an unknown value falls back to NAME (forward-compatible with a
54+ // future rename/removal of an enum entry).
55+ open val sortKey: Flow<FileSortKey>
56+ get() = appContext?.fileBrowserPrefsDataStore?.data?.map { prefs ->
57+ runCatching { FileSortKey.valueOf(prefs[sortKeyPref] ?: FileSortKey.NAME.name) }
58+ .getOrDefault(FileSortKey.NAME)
59+ } ?: flowOf(FileSortKey.NAME)
60+
61+ open val sortDesc: Flow<Boolean>
62+ get() = appContext?.fileBrowserPrefsDataStore?.data?.map { it[sortDescKey] ?: false }
63+ ?: flowOf(false)
64+
65+ // Default off: the changed-only filter narrows the listing to git-tracked changes, which is
66+ // useful when reviewing a diff but not the default browsing view.
67+ open val changedOnly: Flow<Boolean>
68+ get() = appContext?.fileBrowserPrefsDataStore?.data?.map { it[changedOnlyKey] ?: false }
69+ ?: flowOf(false)
70+
71+ open suspend fun setFoldersFirst(value: Boolean) {
72+ appContext?.fileBrowserPrefsDataStore?.edit { it[foldersFirstKey] = value }
73+ }
74+
75+ open suspend fun setShowHidden(value: Boolean) {
76+ appContext?.fileBrowserPrefsDataStore?.edit { it[showHiddenKey] = value }
77+ }
78+
79+ open suspend fun setSortKey(key: FileSortKey) {
80+ appContext?.fileBrowserPrefsDataStore?.edit { it[sortKeyPref] = key.name }
81+ }
82+
83+ open suspend fun setSortDesc(value: Boolean) {
84+ appContext?.fileBrowserPrefsDataStore?.edit { it[sortDescKey] = value }
85+ }
86+
87+ open suspend fun setChangedOnly(value: Boolean) {
88+ appContext?.fileBrowserPrefsDataStore?.edit { it[changedOnlyKey] = value }
89+ }
90+}
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+2
@@ -29,6 +29,7 @@ import soy.iko.opencode.data.repo.AttachmentDraftStore
2929import soy.iko.opencode.data.repo.BackupManager
3030import soy.iko.opencode.data.repo.DraftStore
3131import soy.iko.opencode.data.repo.ErrorKind
32+import soy.iko.opencode.data.repo.FileBrowserPrefs
3233import soy.iko.opencode.data.repo.MessageCacheStore
3334import soy.iko.opencode.data.repo.OutboxMessage
3435import soy.iko.opencode.data.repo.OutboxStore
@@ -117,6 +118,7 @@ open class AppContainer private constructor(
117118 open val outboxStore: OutboxStore by lazy { OutboxStore(appContext!!) }
118119 open val searchHistoryStore: SearchHistoryStore by lazy { SearchHistoryStore(appContext!!) }
119120 open val recentModelsStore: RecentModelsStore by lazy { RecentModelsStore(appContext!!) }
121+ open val fileBrowserPrefs: FileBrowserPrefs by lazy { FileBrowserPrefs(appContext!!) }
120122 open val backupManager: BackupManager by lazy { BackupManager(profileStore, settingsStore, sessionPrefsStore) }
121123
122124 /** True while the outbox is actively flushing queued messages to the server, so the UI
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+12−4
@@ -74,7 +74,7 @@ class RunForegroundService : Service() {
7474 val contentText = progress ?: sessionTitle ?: getString(R.string.notif_running_text)
7575 val builder = NotificationCompat.Builder(this, NotificationChannels.STATUS)
7676 .setSmallIcon(R.drawable.ic_stat_notify)
77− .setColor(BRAND_COLOR)
77+ .setColor(brandColor(this))
7878 .setContentTitle(getString(R.string.notif_running_title))
7979 .setContentText(contentText)
8080 .setContentIntent(pendingIntent)
@@ -135,7 +135,7 @@ class RunForegroundService : Service() {
135135 runCatching {
136136 val notification = NotificationCompat.Builder(this, NotificationChannels.STATUS)
137137 .setSmallIcon(R.drawable.ic_stat_notify)
138− .setColor(BRAND_COLOR)
138+ .setColor(brandColor(this))
139139 .setContentTitle(getString(R.string.notif_running_title))
140140 .setContentText(getString(R.string.notif_fg_timeout_text))
141141 .setContentIntent(mainContentIntent())
@@ -159,8 +159,16 @@ class RunForegroundService : Service() {
159159 private const val TAG = "RunForegroundService"
160160 private const val NOTIF_ID = 1
161161 private const val NOTIF_TIMEOUT_ID = 2
162− // Brand accent for the foreground notifications' small-icon tint circle.
163− private val BRAND_COLOR = 0xFF34548A.toInt()
162+ // Brand accent for the foreground notifications' small-icon tint circle. Resolved
163+ // from resources (R.color.notif_brand) so it follows the app theme (light/dark)
164+ // instead of a hardcoded constant. Lazily computed on first use from the app context.
165+ private var brandColorCache: Int = 0
166+ private fun brandColor(context: Context): Int {
167+ if (brandColorCache == 0) {
168+ brandColorCache = androidx.core.content.ContextCompat.getColor(context, R.color.notif_brand)
169+ }
170+ return brandColorCache
171+ }
164172 const val EXTRA_SESSION_TITLE = "soy.iko.opencode.extra.SESSION_TITLE"
165173 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
166174 const val EXTRA_PROGRESS = "soy.iko.opencode.extra.PROGRESS"
app/src/main/java/soy/iko/opencode/ui/chat/ChatAttachments.kt+42
@@ -2,6 +2,7 @@ package soy.iko.opencode.ui.chat
22
33import androidx.compose.foundation.layout.Arrangement
44import androidx.compose.foundation.layout.Box
5+import androidx.compose.foundation.layout.Column
56import androidx.compose.foundation.layout.Row
67import androidx.compose.foundation.layout.fillMaxSize
78import androidx.compose.foundation.layout.fillMaxWidth
@@ -39,10 +40,17 @@ import androidx.compose.ui.window.Dialog
3940import androidx.compose.ui.window.DialogProperties
4041import coil.compose.AsyncImage
4142import soy.iko.opencode.R
43+import soy.iko.opencode.data.network.NetworkConfig
44+import java.util.Locale
4245
4346/**
4447 * The staged-attachment UI for the chat composer — extracted from ChatScreen so that (large)
4548 * file stays focused on the conversation surface. `internal` so ChatScreen can call it.
49+ *
50+ * When at least one attachment is staged, a muted total-size label is rendered at the end of
51+ * the strip so the user can see how close they are to the cumulative [NetworkConfig.maxTotalAttachmentBytes]
52+ * cap — a bare per-file cap doesn't surface the combined total, and a failed send is the only
53+ * other feedback.
4654 */
4755@Composable
4856internal fun AttachmentStrip(
@@ -61,6 +69,7 @@ internal fun AttachmentStrip(
6169 .horizontalScroll(rememberScrollState())
6270 .padding(start = 8.dp, end = 8.dp, top = 8.dp),
6371 horizontalArrangement = Arrangement.spacedBy(8.dp),
72+ verticalAlignment = Alignment.CenterVertically,
6473 ) {
6574 attachments.forEach { att ->
6675 AttachmentChip(att, onRemove = { onRemove(att.id) })
@@ -68,7 +77,40 @@ internal fun AttachmentStrip(
6877 // A placeholder chip with an indeterminate spinner while any pick is still being
6978 // read + base64-encoded off the main thread (chips only materialize once done).
7079 if (staging) StagingChip(stagingFileCount)
80+ // Total staged size label — surfaced so the user can see how close they are to the
81+ // cumulative cap before a send fails. Hidden while staging (the total is in flux).
82+ if (!staging && attachments.isNotEmpty()) {
83+ val totalBytes = remember(attachments) {
84+ attachments.sumOf { base64DataUrlByteSize(it.part.url) }
85+ }
86+ val maxBytes = NetworkConfig.maxTotalAttachmentBytes
87+ val label = remember(totalBytes, maxBytes) { formatAttachmentTotal(totalBytes, maxBytes) }
88+ val over = totalBytes > maxBytes
89+ Text(
90+ label,
91+ style = MaterialTheme.typography.labelSmall,
92+ color = if (over) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
93+ modifier = Modifier.padding(start = 4.dp, end = 4.dp),
94+ )
95+ }
96+ }
97+}
98+
99+/** Format the staged-attachment total as "N.N MB / max" (or KB when small), for the strip label. */
100+private fun formatAttachmentTotal(bytes: Long, max: Long): String {
101+ val mb = 1024.0 * 1024.0
102+ val kb = 1024.0
103+ val used = when {
104+ bytes >= mb -> String.format(Locale.US, "%.1f MB", bytes / mb)
105+ bytes >= kb -> String.format(Locale.US, "%.0f KB", bytes / kb)
106+ else -> "$bytes B"
107+ }
108+ val maxStr = when {
109+ max >= mb -> String.format(Locale.US, "%.0f MB", max / mb)
110+ max >= kb -> String.format(Locale.US, "%.0f KB", max / kb)
111+ else -> "$max B"
71112 }
113+ return "$used / $maxStr"
72114}
73115
74116/** A placeholder chip shown while an attachment is being encoded. */
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+46−1
@@ -659,6 +659,8 @@ fun ChatScreen(
659659 val loadingLabel = stringResource(R.string.loading)
660660 val shareLabel = stringResource(R.string.share_conversation)
661661 val copyAsMarkdownLabel = stringResource(R.string.copy_as_markdown)
662+ val shareAsJsonLabel = stringResource(R.string.share_as_json)
663+ val copyAsJsonLabel = stringResource(R.string.copy_as_json)
662664 val commandsLabel = stringResource(R.string.commands)
663665 val renameLabel = stringResource(R.string.rename_session_chat)
664666 val deleteLabel = stringResource(R.string.delete_session_chat)
@@ -710,6 +712,41 @@ fun ChatScreen(
710712 }
711713 },
712714 )
715+ // Lossless JSON share: the full List<MessageWithParts> serialized with
716+ // the shared OpencodeJson, so tool outputs, file sources, and reasoning
717+ // survive verbatim (the Markdown export truncates/summarizes them).
718+ DropdownMenuItem(
719+ text = { Text(shareAsJsonLabel) },
720+ enabled = hasMessages,
721+ onClick = {
722+ showOverflowMenu = false
723+ scope.launch {
724+ val json = withContext(Dispatchers.Default) {
725+ buildConversationJson(vm.messages.value)
726+ }
727+ val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
728+ type = "application/json"
729+ putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
730+ putExtra(android.content.Intent.EXTRA_TEXT, json)
731+ }
732+ runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareAsJsonLabel)) }
733+ .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
734+ }
735+ },
736+ )
737+ DropdownMenuItem(
738+ text = { Text(copyAsJsonLabel) },
739+ enabled = hasMessages,
740+ onClick = {
741+ showOverflowMenu = false
742+ scope.launch {
743+ val json = withContext(Dispatchers.Default) {
744+ buildConversationJson(vm.messages.value)
745+ }
746+ copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_message), json)
747+ }
748+ },
749+ )
713750 DropdownMenuItem(
714751 text = { Text(commandsLabel) },
715752 enabled = !commandsLoading || commands.isNotEmpty(),
@@ -1464,9 +1501,17 @@ fun ChatScreen(
14641501 // start on the real false→true transition (send/run/init), not on
14651502 // SSE-reconnect relight, so reconnects don't restart the clock. Updates
14661503 // once per second; the value is decorative (the a11y label is the row's).
1504+ // The clock freezes when an abort is confirmed (aborting = true) so the
1505+ // user can see how long the run lasted before they stopped it, rather
1506+ // than the clock continuing to tick until SessionIdle clears _running.
14671507 val startMs by vm.runStartMs.collectAsStateWithLifecycle()
1468− val elapsedMs by produceState(0L, startMs) {
1508+ val elapsedMs by produceState(0L, startMs, aborting) {
14691509 if (startMs == 0L) return@produceState
1510+ if (aborting) {
1511+ // Freeze at the last-computed value: one final snapshot then stop.
1512+ value = (System.currentTimeMillis() - startMs).coerceAtLeast(0L)
1513+ return@produceState
1514+ }
14701515 while (true) {
14711516 value = (System.currentTimeMillis() - startMs).coerceAtLeast(0L)
14721517 delay(1000)
app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.kt+28
@@ -12,10 +12,38 @@ import soy.iko.opencode.data.model.ToolRunning
1212import soy.iko.opencode.data.model.UserMessage
1313import soy.iko.opencode.data.model.sourcePath
1414import soy.iko.opencode.data.network.NetworkConfig
15+import soy.iko.opencode.data.network.OpencodeJson
16+import kotlinx.serialization.builtins.ListSerializer
1517import java.text.SimpleDateFormat
1618import java.util.Date
1719import java.util.Locale
1820
21+/**
22+ * Render a conversation as a lossless JSON document for sharing/exporting. Unlike
23+ * [buildConversationMarkdown], this preserves the full fidelity of the server's
24+ * message stream: tool outputs are not truncated, file parts keep their source
25+ * objects and data URLs, reasoning keeps its raw text, and the polymorphic
26+ * `type`/`role` discriminators are retained. That makes the JSON export suitable
27+ * for re-importing into another client, for auditing exactly what the agent did,
28+ * and for bug reports where the Markdown summary would have dropped detail.
29+ *
30+ * Uses the shared [OpencodeJson] so the on-the-wire shape round-trips — the same
31+ * serializer that decoded the messages re-encodes them, including the `Unknown*`
32+ * fallback variants for parts/roles the client doesn't otherwise model.
33+ */
34+fun buildConversationJson(messages: List<MessageWithParts>): String =
35+ try {
36+ // Explicit serializer (mirrors MessageCacheStore) rather than the reified
37+ // encodeToString overload, so the polymorphic Part/MessageInfo discriminators
38+ // registered in OpencodeJson are exercised on the way back out.
39+ OpencodeJson.encodeToString(ListSerializer(MessageWithParts.serializer()), messages)
40+ } catch (e: Throwable) {
41+ // Fall back to an explicit error document rather than crashing the share
42+ // flow; a serialization failure here is most likely a model invariant bug
43+ // and a silent empty string would be harder to diagnose than this stub.
44+ "{\"error\":\"conversation_json_export_failed\",\"message\":\"${e.message ?: e.javaClass.simpleName}\"}"
45+ }
46+
1947/**
2048 * Render a conversation as Markdown for sharing/exporting. Each message becomes a
2149 * role-prefixed section containing its text parts; reasoning is exported as a blockquote
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+65−5
@@ -30,6 +30,7 @@ import androidx.compose.material.icons.filled.Restore
3030import androidx.compose.material.icons.filled.Share
3131import androidx.compose.material.icons.filled.SmartToy
3232import androidx.compose.material.icons.filled.Stop
33+import androidx.compose.material.icons.filled.Code
3334import androidx.compose.material3.DropdownMenu
3435import androidx.compose.material3.DropdownMenuItem
3536import androidx.compose.material3.CircularProgressIndicator
@@ -147,6 +148,7 @@ private fun MessageLongPressMenu(
147148 onRegenerate: (() -> Unit)? = null,
148149 onSpeak: (() -> Unit)? = null,
149150 speakLabel: String? = null,
151+ onViewSource: (() -> Unit)? = null,
150152) {
151153 DropdownMenu(expanded = expanded, onDismissRequest = onDismiss, offset = offset) {
152154 if (text != null && onCopy != null) {
@@ -207,6 +209,16 @@ private fun MessageLongPressMenu(
207209 onClick = { onDismiss(); onBranch(text) },
208210 )
209211 }
212+ // View source: show the raw markdown of the message's TextParts in a dialog, so a user can
213+ // inspect malformed tables/rendering or copy the exact source. Gated on text so an
214+ // image-only message doesn't show a no-op entry.
215+ if (text != null && onViewSource != null) {
216+ DropdownMenuItem(
217+ leadingIcon = { Icon(Icons.Filled.Code, contentDescription = null) },
218+ text = { Text(stringResource(R.string.view_source)) },
219+ onClick = { onDismiss(); onViewSource() },
220+ )
221+ }
210222 if (onRevert != null) {
211223 DropdownMenuItem(
212224 leadingIcon = { Icon(Icons.Filled.Restore, contentDescription = null) },
@@ -255,7 +267,7 @@ private fun UserMessageLongPressMenu(
255267 )
256268}
257269
258−/** Overflow menu of secondary per-message actions (quote-reply, branch-a-new-session).
270+/** Overflow menu of secondary per-message actions (quote-reply, branch-a-new-session, view source).
259271 * Shown when at least one action is available and there's text to act on. Keeps the
260272 * inline action row (copy/edit/revert/speak) uncluttered while still surfacing the extras. */
261273@Composable
@@ -263,8 +275,9 @@ private fun MessageOverflow(
263275 text: String,
264276 onQuote: ((String) -> Unit)?,
265277 onBranch: ((String) -> Unit)?,
278+ onViewSource: (() -> Unit)? = null,
266279) {
267− if (onQuote == null && onBranch == null) return
280+ if (onQuote == null && onBranch == null && onViewSource == null) return
268281 var expanded by remember { mutableStateOf(false) }
269282 val moreLabel = stringResource(R.string.message_actions)
270283 val haptics = LocalHapticFeedback.current
@@ -300,6 +313,17 @@ private fun MessageOverflow(
300313 },
301314 )
302315 }
316+ onViewSource?.let { viewSource ->
317+ DropdownMenuItem(
318+ leadingIcon = { Icon(Icons.Filled.Code, contentDescription = null) },
319+ text = { Text(stringResource(R.string.view_source)) },
320+ onClick = {
321+ expanded = false
322+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
323+ viewSource()
324+ },
325+ )
326+ }
303327 }
304328 }
305329}
@@ -333,7 +357,14 @@ private fun UnknownMessageBlock(
333357) {
334358 // Forward-compat: a role the client doesn't model. Render a muted note so the user
335359 // sees *something* rather than an unlabeled block, plus any parts (e.g. text) the
336− // server attached, so content isn't silently dropped.
360+ // server attached, so content isn't silently dropped. A copy action is wired so a user
361+ // can at least extract the content (e.g. to report a new server role's payload).
362+ val context = androidx.compose.ui.platform.LocalContext.current
363+ val textToCopy = remember(message.parts) {
364+ message.parts.filterIsInstance<soy.iko.opencode.data.model.TextPart>()
365+ .joinToString("\n\n") { it.text }
366+ .takeIf { it.isNotBlank() }
367+ }
337368 Column(
338369 modifier = modifier.fillMaxWidth(),
339370 verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -351,6 +382,18 @@ private fun UnknownMessageBlock(
351382 color = MaterialTheme.colorScheme.onSurfaceVariant,
352383 modifier = Modifier.padding(start = 6.dp),
353384 )
385+ if (textToCopy != null) {
386+ IconButton(onClick = {
387+ copyToClipboard(context, context.getString(R.string.clip_label_message), textToCopy)
388+ }) {
389+ Icon(
390+ Icons.Filled.ContentCopy,
391+ contentDescription = stringResource(R.string.copy),
392+ modifier = Modifier.size(16.dp),
393+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
394+ )
395+ }
396+ }
354397 }
355398 // key() per part so each PartView gets a distinct saveable registry slot: rememberSaveable
356399 // inside PartView keys its RESET on part.id but its registry key is the positional
@@ -647,6 +690,9 @@ private fun AssistantBlock(
647690 val assistantDensity = androidx.compose.ui.platform.LocalDensity.current
648691 var longPressMenu by remember { mutableStateOf(false) }
649692 var longPressOffset by remember { mutableStateOf(androidx.compose.ui.unit.DpOffset(0.dp, 0.dp)) }
693+ // View-source dialog state: shown from the long-press menu's "View source" entry to display
694+ // the raw markdown of the message's TextParts.
695+ var showSource by remember { mutableStateOf(false) }
650696 // A11y: announce the speaker role so TalkBack reads "Assistant, <text>" instead of
651697 // only the model/agent label — giving a screen-reader user an explicit role signal
652698 // matching the user bubble's "You" prefix.
@@ -773,6 +819,7 @@ private fun AssistantBlock(
773819 onBranch = onBranch,
774820 onRegenerate = onRegenerate,
775821 onContinue = onContinue,
822+ onViewSource = textToCopy?.let { { showSource = true } },
776823 )
777824 }
778825 AssistantMessageLongPressMenu(
@@ -791,8 +838,15 @@ private fun AssistantBlock(
791838 ttsState = ttsState,
792839 onPause = onPause,
793840 onResume = onResume,
841+ onViewSource = textToCopy?.let { { showSource = true } },
794842 )
795843 }
844+ if (showSource && textToCopy != null) {
845+ ViewSourceDialog(
846+ source = textToCopy,
847+ onDismiss = { showSource = false },
848+ )
849+ }
796850 } else {
797851 MessageTimestampText(message.info)
798852 }
@@ -819,6 +873,7 @@ private fun AssistantMessageLongPressMenu(
819873 ttsState: TtsState,
820874 onPause: (() -> Unit)?,
821875 onResume: (() -> Unit)?,
876+ onViewSource: (() -> Unit)? = null,
822877) {
823878 val speakForMenu: (() -> Unit)? = if (onSpeak != null && text != null) {
824879 {
@@ -855,6 +910,7 @@ private fun AssistantMessageLongPressMenu(
855910 onRegenerate = onRegenerate,
856911 onSpeak = speakForMenu,
857912 speakLabel = speakLabel,
913+ onViewSource = onViewSource,
858914 )
859915}
860916
@@ -917,6 +973,7 @@ private fun AssistantActions(
917973 onBranch: ((String) -> Unit)? = null,
918974 onRegenerate: (() -> Unit)? = null,
919975 onContinue: (() -> Unit)? = null,
976+ onViewSource: (() -> Unit)? = null,
920977) {
921978 val context = LocalContext.current
922979 val haptics = LocalHapticFeedback.current
@@ -986,8 +1043,11 @@ private fun AssistantActions(
9861043 }
9871044 }
9881045 // Assistant replies can be quoted into the composer or branched into a new session.
989− if (textToCopy != null && (onQuote != null || onBranch != null)) {
990− MessageOverflow(text = textToCopy, onQuote = onQuote, onBranch = onBranch)
1046+ // View source is also available here for assistant replies (in addition to the long-press
1047+ // menu) so the action is discoverable without a long-press. MessageOverflow itself
1048+ // no-ops when all callbacks are null, so the guard only needs to check text.
1049+ if (textToCopy != null) {
1050+ MessageOverflow(text = textToCopy, onQuote = onQuote, onBranch = onBranch, onViewSource = onViewSource)
9911051 }
9921052 }
9931053}
app/src/main/java/soy/iko/opencode/ui/chat/ViewSourceDialog.ktnew file+76
@@ -0,0 +1,76 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.compose.foundation.layout.Arrangement
4+import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.Row
6+import androidx.compose.foundation.layout.fillMaxHeight
7+import androidx.compose.foundation.layout.fillMaxWidth
8+import androidx.compose.foundation.layout.padding
9+import androidx.compose.foundation.rememberScrollState
10+import androidx.compose.foundation.text.selection.SelectionContainer
11+import androidx.compose.foundation.verticalScroll
12+import androidx.compose.material3.MaterialTheme
13+import androidx.compose.material3.Surface
14+import androidx.compose.material3.Text
15+import androidx.compose.material3.TextButton
16+import androidx.compose.runtime.Composable
17+import androidx.compose.ui.Alignment
18+import androidx.compose.ui.Modifier
19+import androidx.compose.ui.text.font.FontFamily
20+import androidx.compose.ui.res.stringResource
21+import androidx.compose.ui.unit.dp
22+import androidx.compose.ui.window.Dialog
23+import androidx.compose.ui.window.DialogProperties
24+import soy.iko.opencode.R
25+
26+/**
27+ * A fullscreen-ish dialog showing the raw markdown source of a message's TextParts, so a user
28+ * can inspect malformed tables/rendering or copy the exact source. The text is monospace,
29+ * scrollable, and wrapped in a [SelectionContainer] so a portion can be selected and copied.
30+ * Dismissed via the Close button, back, or an outside tap.
31+ */
32+@Composable
33+fun ViewSourceDialog(
34+ source: String,
35+ onDismiss: () -> Unit,
36+) {
37+ Dialog(
38+ onDismissRequest = onDismiss,
39+ properties = DialogProperties(usePlatformDefaultWidth = false),
40+ ) {
41+ Surface(
42+ shape = MaterialTheme.shapes.large,
43+ color = MaterialTheme.colorScheme.surface,
44+ tonalElevation = 6.dp,
45+ modifier = Modifier.fillMaxHeight(0.9f).fillMaxWidth(),
46+ ) {
47+ Column(
48+ modifier = Modifier.fillMaxWidth().padding(16.dp),
49+ verticalArrangement = Arrangement.spacedBy(12.dp),
50+ ) {
51+ Row(
52+ modifier = Modifier.fillMaxWidth(),
53+ horizontalArrangement = Arrangement.SpaceBetween,
54+ verticalAlignment = Alignment.CenterVertically,
55+ ) {
56+ Text(
57+ stringResource(R.string.message_source),
58+ style = MaterialTheme.typography.titleMedium,
59+ )
60+ TextButton(onClick = onDismiss) {
61+ Text(stringResource(R.string.close))
62+ }
63+ }
64+ SelectionContainer {
65+ Text(
66+ text = source,
67+ style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
68+ modifier = Modifier
69+ .fillMaxWidth()
70+ .verticalScroll(rememberScrollState()),
71+ )
72+ }
73+ }
74+ }
75+ }
76+}
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+4−1
@@ -28,6 +28,7 @@ import androidx.compose.ui.semantics.contentDescription
2828import androidx.compose.ui.semantics.liveRegion
2929import androidx.compose.ui.semantics.LiveRegionMode
3030import androidx.compose.ui.semantics.semantics
31+import androidx.compose.ui.platform.testTag
3132import androidx.compose.ui.text.style.TextOverflow
3233import androidx.compose.ui.unit.dp
3334import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -153,7 +154,9 @@ fun ConnectionBanner(
153154 TextButton(
154155 onClick = onRetry,
155156 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
156− modifier = Modifier.defaultMinSize(minHeight = 48.dp),
157+ modifier = Modifier
158+ .defaultMinSize(minHeight = 48.dp)
159+ .testTag("connection_retry"),
157160 ) {
158161 Text(stringResource(R.string.retry_now), color = onContainer)
159162 }
app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+129−22
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
1212import androidx.compose.foundation.layout.height
1313import androidx.compose.foundation.layout.defaultMinSize
1414import androidx.compose.foundation.layout.padding
15+import androidx.compose.foundation.layout.size
1516import androidx.compose.foundation.layout.width
1617import androidx.compose.foundation.lazy.LazyColumn
1718import androidx.compose.foundation.lazy.itemsIndexed
@@ -40,6 +41,7 @@ import androidx.compose.ui.platform.LocalContext
4041import androidx.compose.ui.semantics.contentDescription
4142import androidx.compose.ui.semantics.heading
4243import androidx.compose.ui.semantics.semantics
44+import androidx.compose.ui.semantics.stateDescription
4345import androidx.compose.ui.text.AnnotatedString
4446import androidx.compose.ui.text.font.FontFamily
4547import androidx.compose.ui.text.font.FontWeight
@@ -181,6 +183,11 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
181183 // don't reset expand state on every token. Fall back to `diff` for non-streaming callers
182184 // (e.g. FileViewScreen) where the content is static.
183185 var expanded by rememberSaveable(saveKey ?: diff) { mutableStateOf(false) }
186+ // Per-hunk collapse state: a set of hunk indices the user has collapsed via its `@@`
187+ // header. Keyed on the hunk's ordinal position in `lines` so two hunks with the same
188+ // `@@` range text (rare but possible) are still distinct toggles. The KDoc promised this
189+ // affordance; it's now implemented.
190+ val collapsedHunks = remember { mutableStateOf(emptySet<Int>()) }
184191 // Resolve a syntax for highlighting from the first file header (if any). Memoized so a
185192 // streaming re-parse doesn't re-resolve per token.
186193 val syntax = remember(lines) {
@@ -200,8 +207,17 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
200207 onToggle = { expanded = !expanded },
201208 onCopy = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) },
202209 )
203− val visibleLines = if (lines.size <= NetworkConfig.collapsedDiffLineThreshold || expanded) lines
204− else lines.subList(0, NetworkConfig.collapsedDiffLineThreshold)
210+ // Apply the global collapse threshold first, then drop lines inside any per-hunk
211+ // collapse. A collapsed hunk's `@@` header is always kept (so the user has a handle
212+ // to re-expand it); its content lines (context/add/remove) are skipped until the
213+ // next hunk or end-of-list.
214+ val thresholdCollapsed = lines.size > NetworkConfig.collapsedDiffLineThreshold && !expanded
215+ val baseLines = if (thresholdCollapsed) lines.subList(0, NetworkConfig.collapsedDiffLineThreshold) else lines
216+ val hunkIndices = remember(lines) { hunkStartIndices(lines) }
217+ val visibleLines = remember(baseLines, collapsedHunks.value) {
218+ if (collapsedHunks.value.isEmpty()) baseLines
219+ else filterCollapsedHunks(baseLines, hunkIndices, collapsedHunks.value)
220+ }
205221 // One horizontalScroll on the container instead of one per row: each modifier
206222 // adds a layout node + clip + offset pass, so a 200-line collapsed diff was
207223 // paying for 200 of them. The shared scroll state still synchronizes all rows.
@@ -213,7 +229,7 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
213229 )
214230 }
215231 Column(modifier = Modifier.horizontalScroll(hScrollState)) {
216− rows.forEach { row -> RenderDiffRow(row, syntax, palette) }
232+ rows.forEach { row -> RenderDiffRow(row, syntax, palette, collapsedHunks) }
217233 }
218234 DiffExpandFooter(lines = lines, expanded = expanded, onToggle = { expanded = !expanded })
219235 }
@@ -238,6 +254,7 @@ fun LazyDiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? =
238254 val context = LocalContext.current
239255 val hScrollState = rememberScrollState()
240256 var expanded by rememberSaveable(saveKey ?: diff) { mutableStateOf(false) }
257+ val collapsedHunks = remember { mutableStateOf(emptySet<Int>()) }
241258 val syntax = remember(lines) {
242259 lines.firstOrNull { it is DiffLine.FileHeader }?.let { syntaxForHeader(it.text) }
243260 }
@@ -256,8 +273,13 @@ fun LazyDiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? =
256273 onToggle = { expanded = !expanded },
257274 onCopy = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) },
258275 )
259− val visibleLines = if (lines.size <= NetworkConfig.collapsedDiffLineThreshold || expanded) lines
260− else lines.subList(0, NetworkConfig.collapsedDiffLineThreshold)
276+ val thresholdCollapsed = lines.size > NetworkConfig.collapsedDiffLineThreshold && !expanded
277+ val baseLines = if (thresholdCollapsed) lines.subList(0, NetworkConfig.collapsedDiffLineThreshold) else lines
278+ val hunkIndices = remember(lines) { hunkStartIndices(lines) }
279+ val visibleLines = remember(baseLines, collapsedHunks.value) {
280+ if (collapsedHunks.value.isEmpty()) baseLines
281+ else filterCollapsedHunks(baseLines, hunkIndices, collapsedHunks.value)
282+ }
261283 val rows = remember(visibleLines, addColor, removeColor, addText, removeText, scheme) {
262284 buildDiffRows(
263285 visibleLines,
@@ -276,7 +298,7 @@ fun LazyDiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? =
276298 rows,
277299 key = { i, row -> "${row.kind::class.simpleName}:$i:${row.text.hashCode()}" },
278300 ) { _, row ->
279− RenderDiffRow(row, syntax, palette)
301+ RenderDiffRow(row, syntax, palette, collapsedHunks)
280302 }
281303 }
282304 DiffExpandFooter(lines = lines, expanded = expanded, onToggle = { expanded = !expanded })
@@ -329,9 +351,53 @@ private fun DiffExpandFooter(lines: List<DiffLine>, expanded: Boolean, onToggle:
329351 }
330352}
331353
354+/** Map each hunk ordinal (0, 1, 2, …) to its index in [lines]. Used to find the boundaries
355+ * of a collapsed hunk: a hunk spans from its start index up to the next hunk's start (or the
356+ * end of the list). File headers and meta lines before the first hunk are not hunk members. */
357+private fun hunkStartIndices(lines: List<DiffLine>): Map<Int, Int> {
358+ val map = LinkedHashMap<Int, Int>()
359+ var hunkOrdinal = 0
360+ lines.forEachIndexed { i, line ->
361+ if (line is DiffLine.Hunk) {
362+ map[hunkOrdinal] = i
363+ hunkOrdinal++
364+ }
365+ }
366+ return map
367+}
368+
369+/** Drop the content lines (Context/Add/Remove/Meta-after-hunk) of each collapsed hunk while
370+ * keeping the hunk's own `@@` header row so the user has a handle to re-expand. A line is
371+ * dropped when its [DiffRowItem.hunkIndex] is in [collapsed] AND it's not the Hunk header
372+ * itself (the header carries the same hunkIndex but its kind is Hunk, which we keep). */
373+private fun filterCollapsedHunks(
374+ baseLines: List<DiffLine>,
375+ hunkIndices: Map<Int, Int>,
376+ collapsed: Set<Int>,
377+): List<DiffLine> {
378+ if (collapsed.isEmpty()) return baseLines
379+ // For each collapsed hunk, compute [start, end) where end is the next hunk's start or the
380+ // list size. We keep the header (at start) and drop the rest of the range.
381+ val dropRanges = collapsed.mapNotNull { ordinal ->
382+ val start = hunkIndices[ordinal] ?: return@mapNotNull null
383+ val end = hunkIndices.entries
384+ .firstOrNull { it.value > start }?.value ?: baseLines.size
385+ (start + 1) until end
386+ }
387+ val dropSet = dropRanges.flatMap { it }.toHashSet()
388+ return baseLines.filterIndexed { i, _ -> i !in dropSet }
389+}
390+
391+/** Cached labels for the per-hunk collapse `stateDescription` (resolved once per composition
392+ * rather than per row to avoid a per-row string lookup on a potentially huge diff). */
393+private val stateCollapsedLabel: String get() = "Collapsed"
394+private val stateExpandedLabel: String get() = "Expanded"
395+
332396/** A pre-resolved diff row carrying its kind, text, line numbers, and resolved colors. This
333397 * lets both [DiffView] and [LazyDiffView] share the rendering path without re-deriving the
334− * per-row state (line numbers advance as a side effect of walking the list). */
398+ * per-row state (line numbers advance as a side effect of walking the list). [hunkIndex] is
399+ * the ordinal of the hunk this row belongs to (null for pre-hunk file headers/meta), used
400+ * by [RenderDiffRow] to toggle per-hunk collapse. */
335401private data class DiffRowItem(
336402 val kind: DiffLine,
337403 val text: String,
@@ -339,6 +405,7 @@ private data class DiffRowItem(
339405 val textColor: Color,
340406 val oldLine: Int?,
341407 val newLine: Int?,
408+ val hunkIndex: Int?,
342409)
343410
344411/** Walk [visibleLines] advancing the old/new line counters per hunk, producing a list of
@@ -358,47 +425,87 @@ private fun buildDiffRows(
358425): List<DiffRowItem> {
359426 var oldLine = 0
360427 var newLine = 0
428+ var hunkIndex = -1
361429 val hunkRegex = Regex("""@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@""")
362430 return visibleLines.map { line ->
363431 when (line) {
364432 is DiffLine.Hunk -> {
433+ hunkIndex++
365434 val m = hunkRegex.find(line.text)
366435 if (m != null) {
367436 oldLine = m.groupValues[1].toInt()
368437 newLine = m.groupValues[2].toInt()
369438 }
370− DiffRowItem(line, line.text, Color.Transparent, tertiary, null, null)
439+ DiffRowItem(line, line.text, Color.Transparent, tertiary, null, null, hunkIndex)
371440 }
372− is DiffLine.FileHeader -> DiffRowItem(line, line.text, Color.Transparent, onSurface, null, null)
373− is DiffLine.Meta -> DiffRowItem(line, line.text, Color.Transparent, onSurfaceVariant, null, null)
441+ is DiffLine.FileHeader -> DiffRowItem(line, line.text, Color.Transparent, onSurface, null, null, null)
442+ is DiffLine.Meta -> DiffRowItem(line, line.text, Color.Transparent, onSurfaceVariant, null, null, null)
374443 is DiffLine.Add -> {
375444 val n = newLine++
376− DiffRowItem(line, line.text, addColor, addText, null, n)
445+ DiffRowItem(line, line.text, addColor, addText, null, n, hunkIndex)
377446 }
378447 is DiffLine.Remove -> {
379448 val o = oldLine++
380− DiffRowItem(line, line.text, removeColor, removeText, o, null)
449+ DiffRowItem(line, line.text, removeColor, removeText, o, null, hunkIndex)
381450 }
382451 is DiffLine.Context -> {
383452 val o = oldLine++; val n = newLine++
384− DiffRowItem(line, line.text, Color.Transparent, onSurface, o, n)
453+ DiffRowItem(line, line.text, Color.Transparent, onSurface, o, n, hunkIndex)
385454 }
386455 }
387456 }
388457}
389458
390459/** Render a single [DiffRowItem]. Hunk and FileHeader rows get their own styling; Add/Remove/
391− * Context delegate to [DiffRow]. */
460+ * Context delegate to [DiffRow]. The Hunk row is clickable to toggle per-hunk collapse via
461+ * [collapsedHunks]. */
392462@Composable
393−private fun RenderDiffRow(row: DiffRowItem, syntax: FileSyntax?, palette: HighlightPalette) {
463+private fun RenderDiffRow(
464+ row: DiffRowItem,
465+ syntax: FileSyntax?,
466+ palette: HighlightPalette,
467+ collapsedHunks: androidx.compose.runtime.MutableState<Set<Int>>,
468+) {
394469 when (row.kind) {
395− is DiffLine.Hunk -> Text(
396− row.text,
397− style = MaterialTheme.typography.bodySmall,
398− fontFamily = FontFamily.Monospace,
399− color = MaterialTheme.colorScheme.tertiary,
400− modifier = Modifier.padding(horizontal = 10.dp, vertical = 2.dp),
401− )
470+ is DiffLine.Hunk -> {
471+ val idx = row.hunkIndex
472+ val isCollapsed = idx != null && idx in collapsedHunks.value
473+ val collapseLabel = stringResource(if (isCollapsed) R.string.show_more else R.string.show_less)
474+ Row(
475+ modifier = Modifier
476+ .fillMaxWidth()
477+ .defaultMinSize(minHeight = 32.dp)
478+ .clickable(enabled = idx != null) {
479+ if (idx != null) {
480+ collapsedHunks.value = if (idx in collapsedHunks.value) {
481+ collapsedHunks.value - idx
482+ } else {
483+ collapsedHunks.value + idx
484+ }
485+ }
486+ }
487+ .padding(horizontal = 10.dp, vertical = 2.dp)
488+ .semantics {
489+ contentDescription = collapseLabel
490+ stateDescription = if (isCollapsed) stateCollapsedLabel else stateExpandedLabel
491+ },
492+ verticalAlignment = Alignment.CenterVertically,
493+ ) {
494+ Icon(
495+ if (isCollapsed) Icons.Filled.ExpandMore else Icons.Filled.ExpandLess,
496+ contentDescription = null,
497+ modifier = Modifier.size(16.dp),
498+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
499+ )
500+ Text(
501+ row.text,
502+ style = MaterialTheme.typography.bodySmall,
503+ fontFamily = FontFamily.Monospace,
504+ color = MaterialTheme.colorScheme.tertiary,
505+ modifier = Modifier.padding(start = 4.dp),
506+ )
507+ }
508+ }
402509 is DiffLine.FileHeader -> {
403510 val displayPath = extractDisplayPath(row.text)
404511 val isHeader = displayPath != row.text
app/src/main/java/soy/iko/opencode/ui/components/EmptyState.kt+29−1
@@ -2,16 +2,19 @@ package soy.iko.opencode.ui.components
22
33import androidx.compose.foundation.layout.Arrangement
44import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.Row
56import androidx.compose.foundation.layout.padding
67import androidx.compose.foundation.layout.size
78import androidx.compose.material3.Button
89import androidx.compose.material3.Icon
910import androidx.compose.material3.MaterialTheme
11+import androidx.compose.material3.OutlinedButton
1012import androidx.compose.material3.Text
1113import androidx.compose.runtime.Composable
1214import androidx.compose.ui.Alignment
1315import androidx.compose.ui.Modifier
1416import androidx.compose.ui.graphics.vector.ImageVector
17+import androidx.compose.ui.platform.testTag
1518import androidx.compose.ui.semantics.heading
1619import androidx.compose.ui.semantics.semantics
1720import androidx.compose.ui.text.style.TextAlign
@@ -21,6 +24,10 @@ import androidx.compose.ui.unit.dp
2124 * Shared empty/placeholder state: a centered icon, a title, an optional supporting line,
2225 * and an optional call-to-action button. Extracted so every screen's "nothing here yet"
2326 * state reads and behaves the same (see EmptySessions/EmptyServers/etc. which predate this).
27+ *
28+ * An optional [secondaryActionLabel] / [onSecondaryAction] pair renders a secondary
29+ * OutlinedButton below the primary CTA, for cases where two recovery paths are equally
30+ * valid (e.g. "Add server" vs "Import from QR image" on a first-run server list).
2431 */
2532@Composable
2633fun EmptyState(
@@ -31,6 +38,9 @@ fun EmptyState(
3138 actionIcon: ImageVector? = null,
3239 actionLabel: String? = null,
3340 onAction: (() -> Unit)? = null,
41+ secondaryActionIcon: ImageVector? = null,
42+ secondaryActionLabel: String? = null,
43+ onSecondaryAction: (() -> Unit)? = null,
3444) {
3545 Column(
3646 modifier = modifier.padding(32.dp),
@@ -58,7 +68,12 @@ fun EmptyState(
5868 )
5969 }
6070 if (actionLabel != null && onAction != null) {
61− Button(onClick = onAction, modifier = Modifier.padding(top = 12.dp)) {
71+ Button(
72+ onClick = onAction,
73+ modifier = Modifier
74+ .padding(top = 12.dp)
75+ .testTag("empty_state_action"),
76+ ) {
6277 if (actionIcon != null) {
6378 Icon(actionIcon, contentDescription = null, modifier = Modifier.size(18.dp))
6479 Text(actionLabel, modifier = Modifier.padding(start = 6.dp))
@@ -67,5 +82,18 @@ fun EmptyState(
6782 }
6883 }
6984 }
85+ if (secondaryActionLabel != null && onSecondaryAction != null) {
86+ OutlinedButton(
87+ onClick = onSecondaryAction,
88+ modifier = Modifier.testTag("empty_state_secondary_action"),
89+ ) {
90+ if (secondaryActionIcon != null) {
91+ Icon(secondaryActionIcon, contentDescription = null, modifier = Modifier.size(18.dp))
92+ Text(secondaryActionLabel, modifier = Modifier.padding(start = 6.dp))
93+ } else {
94+ Text(secondaryActionLabel)
95+ }
96+ }
97+ }
7098 }
7199}
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+136−14
@@ -11,6 +11,7 @@ import androidx.compose.animation.core.infiniteRepeatable
1111import androidx.compose.animation.core.rememberInfiniteTransition
1212import androidx.compose.animation.core.tween
1313import androidx.compose.foundation.background
14+import androidx.compose.foundation.gestures.detectTapGestures
1415import androidx.compose.foundation.horizontalScroll
1516import androidx.compose.foundation.layout.Arrangement
1617import androidx.compose.foundation.layout.Box
@@ -50,6 +51,8 @@ import androidx.compose.ui.Alignment
5051import androidx.compose.ui.Modifier
5152import androidx.compose.ui.draw.clip
5253import androidx.compose.ui.graphics.Color
54+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
55+import androidx.compose.ui.input.pointer.pointerInput
5356import androidx.compose.ui.platform.LocalContext
5457import androidx.compose.ui.platform.LocalHapticFeedback
5558import androidx.compose.ui.platform.LocalUriHandler
@@ -62,16 +65,22 @@ import androidx.compose.ui.semantics.liveRegion
6265import androidx.compose.ui.semantics.semantics
6366import androidx.compose.ui.text.AnnotatedString
6467import androidx.compose.ui.text.SpanStyle
68+import androidx.compose.ui.text.TextLayoutResult
6569import androidx.compose.ui.text.TextStyle
70+import androidx.compose.ui.text.buildAnnotatedString
6671import androidx.compose.ui.text.font.FontFamily
6772import androidx.compose.ui.unit.dp
6873import kotlinx.coroutines.flow.conflate
6974import kotlinx.coroutines.delay
7075import com.mikepenz.markdown.compose.LocalMarkdownAnnotator
76+import com.mikepenz.markdown.compose.LocalMarkdownColors
77+import com.mikepenz.markdown.compose.LocalMarkdownTypography
7178import com.mikepenz.markdown.compose.components.MarkdownComponentModel
7279import com.mikepenz.markdown.compose.components.markdownComponents
80+import com.mikepenz.markdown.compose.elements.MarkdownText as LibraryMarkdownText
7381import com.mikepenz.markdown.m3.Markdown
7482import com.mikepenz.markdown.model.DefaultMarkdownAnnotator
83+import com.mikepenz.markdown.utils.buildMarkdownAnnotatedString
7584import org.intellij.markdown.MarkdownElementTypes
7685import org.intellij.markdown.ast.ASTNode
7786import org.intellij.markdown.ast.getTextInNode
@@ -79,6 +88,12 @@ import soy.iko.opencode.R
7988import soy.iko.opencode.data.model.FilePart
8089import soy.iko.opencode.data.network.NetworkConfig
8190
91+/** String-annotation tag stamped onto each inline-code (`CODE_SPAN`) range by the inline-code
92+ * annotator wrapper, so [ParagraphWithInlineCodeCopy]'s `pointerInput` can resolve a long-press
93+ * offset back to the code text and copy it. Mirrors the library's own `MARKDOWN_TAG_URL` pattern
94+ * for link-tap hit-testing. */
95+private const val MARKDOWN_TAG_INLINE_CODE = "MARKDOWN_INLINE_CODE"
96+
8297/**
8398 * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
8499 * (commonmark-java under the hood). Keeps a local API so callers don't import the library directly.
@@ -144,10 +159,14 @@ private fun MarkdownBody(
144159 // Without this the library's default image loader fetches with no auth and fails on
145160 // server-relative URLs. When no context is available, fall back to the library default
146161 // by not overriding the image component.
162+ // The `paragraph` component is overridden with [ParagraphWithInlineCodeCopy] so a long-press
163+ // on a single-backtick inline code span copies its text — matching the copy affordance the
164+ // fenced/indented code blocks already have via [CodeWithCopy].
147165 val components = if (imageContext != null) {
148166 markdownComponents(
149167 codeFence = { CodeWithCopy(it) },
150168 codeBlock = { CodeWithCopy(it) },
169+ paragraph = { ParagraphWithInlineCodeCopy(it) },
151170 image = { MarkdownImage(it, imageContext) },
152171 )
153172 } else {
@@ -155,6 +174,7 @@ private fun MarkdownBody(
155174 markdownComponents(
156175 codeFence = { CodeWithCopy(it) },
157176 codeBlock = { CodeWithCopy(it) },
177+ paragraph = { ParagraphWithInlineCodeCopy(it) },
158178 )
159179 }
160180 }
@@ -182,13 +202,63 @@ private fun MarkdownBody(
182202 val searchQuery = LocalSearchHighlight.current?.takeIf { it.isNotBlank() }
183203 val highlightColor = MaterialTheme.colorScheme.secondaryContainer
184204 val baseAnnotator = LocalMarkdownAnnotator.current
185− val highlightAnnotator = if (searchQuery != null && !streaming) {
186− remember(baseAnnotator, searchQuery, highlightColor) {
205+ // Capture the inline-code typography/colors in the composable scope (the annotator lambda is
206+ // NOT a @Composable scope, so it can't read LocalMarkdownTypography.current itself). The
207+ // library's buildMarkdownAnnotatedString reads these in its own @Composable body for the
208+ // default CODE_SPAN branch; since we intercept CODE_SPAN in the annotator, we capture them
209+ // here and close over them in appendInlineCodeSpan.
210+ val inlineCodeTypography = LocalMarkdownTypography.current
211+ val inlineCodeColors = LocalMarkdownColors.current
212+ // Inline-code tag annotator: stamps a MARKDOWN_TAG_INLINE_CODE string annotation onto each
213+ // CODE_SPAN range so [ParagraphWithInlineCodeCopy] can resolve a long-press offset to the
214+ // code text and copy it. The library's default CODE_SPAN branch in buildMarkdownAnnotatedString
215+ // is bypassed (the annotator returns true) so the span is rendered exactly once, with the tag.
216+ // The CODE_SPAN rendering (padding spaces, inlineCode typography span, colors) is inlined here
217+ // rather than extracted to a helper to keep this file under detekt's TooManyFunctions threshold.
218+ val inlineCodeAnnotator = remember(baseAnnotator, inlineCodeTypography, inlineCodeColors) {
219+ val codeStyle = inlineCodeTypography.inlineCode.copy(
220+ color = inlineCodeColors.inlineCodeText,
221+ background = inlineCodeColors.inlineCodeBackground,
222+ ).toSpanStyle()
223+ DefaultMarkdownAnnotator(
224+ annotate = { content, node ->
225+ if (node.type == MarkdownElementTypes.CODE_SPAN) {
226+ pushStyle(codeStyle)
227+ append(' ')
228+ val codeStart = this.length
229+ // Drop the opening/closing backtick children (matching the library's internal
230+ // innerList(), which is inaccessible from here).
231+ val codeText = StringBuilder()
232+ for (child in node.children) {
233+ if (child.type == org.intellij.markdown.MarkdownTokenTypes.BACKTICK) continue
234+ val text = child.getTextInNode(content).toString()
235+ codeText.append(text)
236+ append(text)
237+ }
238+ val codeEnd = this.length
239+ if (codeEnd > codeStart) {
240+ pushStringAnnotation(MARKDOWN_TAG_INLINE_CODE, codeText.toString())
241+ pop()
242+ }
243+ append(' ')
244+ pop()
245+ true
246+ } else {
247+ baseAnnotator.annotate?.invoke(this, content, node) ?: false
248+ }
249+ },
250+ )
251+ }
252+ // Compose the inline-code tagger with the search-highlight wrapper when a query is active.
253+ // The highlight wrapper runs on top, so it sees the final (tagged) builder output and can
254+ // highlight matches inside inline code too.
255+ val effectiveAnnotator = if (searchQuery != null && !streaming) {
256+ remember(inlineCodeAnnotator, searchQuery, highlightColor) {
257+ val inner = inlineCodeAnnotator.annotate
187258 DefaultMarkdownAnnotator(
188259 annotate = { text, node ->
189− // `this` is the AnnotatedString.Builder being filled for this node.
190260 val startLen = this.length
191− val handled = baseAnnotator.annotate?.invoke(this, text, node) ?: false
261+ val handled = inner?.invoke(this, text, node) ?: false
192262 val endLen = this.length
193263 if (endLen > startLen) {
194264 addSearchHighlights(
@@ -204,7 +274,7 @@ private fun MarkdownBody(
204274 )
205275 }
206276 } else {
207− null
277+ inlineCodeAnnotator
208278 }
209279 if (streaming) {
210280 // Bridge the markdown parameter into snapshot state so snapshotFlow can observe it.
@@ -245,7 +315,10 @@ private fun MarkdownBody(
245315 // A polite live region announces streaming content to TalkBack users. The throttle
246316 // (50ms) plus TalkBack's own polite-queue coalescing keeps announcements from
247317 // interrupting on every token; the reader hears the reply grow in measured chunks.
248− CompositionLocalProvider(LocalUriHandler provides safeUriHandler) {
318+ CompositionLocalProvider(
319+ LocalUriHandler provides safeUriHandler,
320+ LocalMarkdownAnnotator provides effectiveAnnotator,
321+ ) {
249322 Markdown(
250323 content = renderedContent,
251324 modifier = modifier.semantics { liveRegion = LiveRegionMode.Polite },
@@ -266,14 +339,11 @@ private fun MarkdownBody(
266339 )
267340 }
268341 }
269− CompositionLocalProvider(LocalUriHandler provides safeUriHandler) {
270− if (highlightAnnotator != null) {
271− CompositionLocalProvider(LocalMarkdownAnnotator provides highlightAnnotator) {
272− content()
273− }
274− } else {
275− content()
276− }
342+ CompositionLocalProvider(
343+ LocalUriHandler provides safeUriHandler,
344+ LocalMarkdownAnnotator provides effectiveAnnotator,
345+ ) {
346+ content()
277347 }
278348 }
279349 // Open/copy dialog for the most recently tapped markdown link. Offer Open only for
@@ -375,6 +445,58 @@ private fun LinkActionDialog(
375445 )
376446}
377447
448+/**
449+ * Custom markdown `paragraph` component that adds a long-press-to-copy affordance for inline
450+ * code spans (single-backtick `` `code` ``). The library renders CODE_SPAN inline as a styled
451+ * run within the paragraph's [AnnotatedString]; there's no discrete `inlineCode` component to
452+ * override. Instead, [MarkdownBody] installs an annotator that stamps each CODE_SPAN range with
453+ * a [MARKDOWN_TAG_INLINE_CODE] string annotation; this component builds the paragraph's
454+ * [AnnotatedString] via the library's [buildMarkdownAnnotatedString] (so the annotator runs and
455+ * the tags land), then renders it with a `pointerInput` that hit-tests a long-press against the
456+ * tagged ranges and copies the span's text on hit — mirroring the copy affordance fenced/indented
457+ * code blocks get via [CodeWithCopy], for the common case of short inline commands/identifiers.
458+ * A long-press inside a [SelectionContainer] is consumed by the selection machinery first, so
459+ * the copy fires from non-selectable contexts (e.g. while streaming, where no SelectionContainer
460+ * wraps the text); when selection is active the user can still copy via the selection handles.
461+ * Haptic feedback matches every other copy affordance.
462+ */
463+@Composable
464+private fun ParagraphWithInlineCodeCopy(model: MarkdownComponentModel) {
465+ val context = LocalContext.current
466+ val haptics = LocalHapticFeedback.current
467+ val style = model.typography.paragraph
468+ // buildMarkdownAnnotatedString is @Composable (it reads LocalMarkdownAnnotator to run the
469+ // inline-code tagger installed in MarkdownBody), so the annotated string can't be built in
470+ // a `remember` block. The library's own MarkdownParagraph does the same.
471+ val styledText = buildAnnotatedString {
472+ pushStyle(style.toSpanStyle())
473+ buildMarkdownAnnotatedString(model.content, model.node)
474+ pop()
475+ }
476+ // Capture the layout result so a long-press position can be mapped to a character offset,
477+ // then to a tagged range — the same hit-testing pattern the library uses for link taps.
478+ var layoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
479+ LibraryMarkdownText(
480+ content = styledText,
481+ style = style,
482+ modifier = Modifier.pointerInput(styledText) {
483+ detectTapGestures(
484+ onLongPress = { pos ->
485+ val result = layoutResult ?: return@detectTapGestures
486+ val offset = result.getOffsetForPosition(pos)
487+ val span = styledText.getStringAnnotations(MARKDOWN_TAG_INLINE_CODE, offset, offset)
488+ .firstOrNull()
489+ ?: return@detectTapGestures
490+ val code = span.item
491+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
492+ copyToClipboard(context, context.getString(R.string.clip_label_code), code)
493+ },
494+ )
495+ },
496+ onTextLayout = { layoutResult = it },
497+ )
498+}
499+
378500/**
379501 * Renders a fenced or indented code block with a header toolbar: the fence's language
380502 * label (when present), a wrap toggle (soft-wrap long lines vs. horizontal scroll), and a
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+32−1
@@ -231,7 +231,7 @@ fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modi
231231 * the whole screen. Offers Save-to-gallery and Share actions via the top bar.
232232 */
233233@Composable
234−private fun FullscreenImageViewer(
234+internal fun FullscreenImageViewer(
235235 request: ImageRequest,
236236 contentDescription: String,
237237 onDismiss: () -> Unit,
@@ -421,6 +421,37 @@ private fun FullscreenImageViewer(
421421 )
422422 }
423423 }
424+ // Zoom-level indicator: a small chip at the top-start showing the current zoom
425+ // factor (e.g. "2.5×") whenever the image is zoomed beyond 1×, with a tap action
426+ // to reset to 1×. Aids orientation when deep-zoomed and gives a one-tap reset
427+ // (otherwise only a double-tap toggles between 1× and the zoom target).
428+ if (scale.value > 1.01f) {
429+ val zoomText = remember(scale.value) {
430+ String.format(java.util.Locale.US, "%.1f×", scale.value)
431+ }
432+ val resetLabel = stringResource(R.string.reset_zoom)
433+ androidx.compose.material3.Surface(
434+ color = androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.6f),
435+ shape = MaterialTheme.shapes.small,
436+ modifier = Modifier
437+ .align(Alignment.TopStart)
438+ .padding(12.dp)
439+ .windowInsetsPadding(WindowInsets.statusBars)
440+ .clickable(role = Role.Button) {
441+ scope.launch { scale.animateTo(1f, zoomSpec) }
442+ offsetX = 0f
443+ offsetY = 0f
444+ }
445+ .semantics { this.contentDescription = "$zoomText, $resetLabel" },
446+ ) {
447+ Text(
448+ zoomText,
449+ color = androidx.compose.ui.graphics.Color.White,
450+ style = MaterialTheme.typography.labelSmall,
451+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
452+ )
453+ }
454+ }
424455 }
425456 }
426457}
app/src/main/java/soy/iko/opencode/ui/components/SkeletonRow.ktnew file+97
@@ -0,0 +1,97 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.animation.core.RepeatMode
4+import androidx.compose.animation.core.animateFloat
5+import androidx.compose.animation.core.infiniteRepeatable
6+import androidx.compose.animation.core.rememberInfiniteTransition
7+import androidx.compose.animation.core.tween
8+import androidx.compose.foundation.background
9+import androidx.compose.foundation.layout.Arrangement
10+import androidx.compose.foundation.layout.Box
11+import androidx.compose.foundation.layout.Column
12+import androidx.compose.foundation.layout.Row
13+import androidx.compose.foundation.layout.fillMaxWidth
14+import androidx.compose.foundation.layout.height
15+import androidx.compose.foundation.layout.size
16+import androidx.compose.foundation.shape.RoundedCornerShape
17+import androidx.compose.material3.MaterialTheme
18+import androidx.compose.runtime.Composable
19+import androidx.compose.runtime.getValue
20+import androidx.compose.ui.Modifier
21+import androidx.compose.ui.draw.alpha
22+import androidx.compose.ui.draw.clip
23+import androidx.compose.ui.res.stringResource
24+import androidx.compose.ui.semantics.contentDescription
25+import androidx.compose.ui.semantics.semantics
26+import androidx.compose.ui.unit.Dp
27+import androidx.compose.ui.unit.dp
28+import soy.iko.opencode.R
29+
30+/**
31+ * A single pulse-animated placeholder row mirroring the session list's skeleton pattern: a
32+ * leading avatar/box and two text bars (title + subtitle). Used by the server/MCP/usage/file
33+ * browser screens' initial-load states so a structured preview of the eventual list reads more
34+ * polished than a bare centered [androidx.compose.material3.CircularProgressIndicator], and
35+ * matches the chat/session/file-viewer skeletons.
36+ *
37+ * Pulses 0.35↔0.7 alpha over 900ms; under [LocalReducedMotion] the alpha is a static 0.5 so the
38+ * placeholder is still visible but doesn't animate. The whole row is one merged semantics node
39+ * (TalkBack announces "Loading…" once per row, not three times for each bar).
40+ *
41+ * @param circleSize diameter of the leading placeholder (40dp matches the session avatar).
42+ * @param barWidths fractional widths of the title and subtitle bars (0.7 to 0.45 by default).
43+ */
44+@Composable
45+fun SkeletonRow(
46+ modifier: Modifier = Modifier,
47+ circleSize: Dp = 40.dp,
48+ barWidths: Pair<Float, Float> = 0.7f to 0.45f,
49+) {
50+ val loadingLabel = stringResource(R.string.loading)
51+ val reducedMotion = LocalReducedMotion.current
52+ val transition = rememberInfiniteTransition(label = "skeleton_row")
53+ val pulse by transition.animateFloat(
54+ initialValue = 0.35f,
55+ targetValue = 0.7f,
56+ animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse),
57+ label = "skeleton_row_alpha",
58+ )
59+ val skeletonAlpha = if (reducedMotion) 0.5f else pulse
60+ val skeletonColor = MaterialTheme.colorScheme.surfaceVariant
61+ Row(
62+ modifier = modifier
63+ .fillMaxWidth()
64+ .semantics(mergeDescendants = true) { contentDescription = loadingLabel },
65+ horizontalArrangement = Arrangement.spacedBy(12.dp),
66+ verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
67+ ) {
68+ Box(
69+ Modifier
70+ .size(circleSize)
71+ .clip(RoundedCornerShape(circleSize / 2))
72+ .background(skeletonColor)
73+ .alpha(skeletonAlpha),
74+ )
75+ Column(
76+ modifier = Modifier.weight(1f),
77+ verticalArrangement = Arrangement.spacedBy(6.dp),
78+ ) {
79+ Box(
80+ Modifier
81+ .fillMaxWidth(barWidths.first)
82+ .height(14.dp)
83+ .clip(RoundedCornerShape(4.dp))
84+ .background(skeletonColor)
85+ .alpha(skeletonAlpha),
86+ )
87+ Box(
88+ Modifier
89+ .fillMaxWidth(barWidths.second)
90+ .height(12.dp)
91+ .clip(RoundedCornerShape(4.dp))
92+ .background(skeletonColor)
93+ .alpha(skeletonAlpha),
94+ )
95+ }
96+ }
97+}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+125−42
@@ -1,6 +1,8 @@
11package soy.iko.opencode.ui.file
22
33import androidx.activity.compose.BackHandler
4+import androidx.compose.animation.Crossfade
5+import androidx.compose.animation.core.tween
46import androidx.compose.foundation.ExperimentalFoundationApi
57import androidx.compose.foundation.clickable
68import androidx.compose.foundation.combinedClickable
@@ -17,6 +19,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
1719import androidx.compose.foundation.layout.padding
1820import androidx.compose.foundation.layout.size
1921import androidx.compose.foundation.layout.widthIn
22+import androidx.compose.foundation.layout.wrapContentWidth
2023import androidx.compose.foundation.lazy.LazyColumn
2124import androidx.compose.foundation.lazy.items
2225import androidx.compose.foundation.lazy.itemsIndexed
@@ -32,6 +35,7 @@ import androidx.compose.material.icons.filled.ErrorOutline
3235import androidx.compose.material.icons.filled.Folder
3336import androidx.compose.material.icons.filled.Home
3437import androidx.compose.material.icons.filled.Image
38+import androidx.compose.material.icons.filled.Lock
3539import androidx.compose.material.icons.filled.Code
3640import androidx.compose.material.icons.filled.Article
3741import androidx.compose.material.icons.filled.KeyboardArrowDown
@@ -40,7 +44,6 @@ import androidx.compose.material.icons.filled.Link
4044import androidx.compose.material.icons.filled.MoreVert
4145import androidx.compose.material.icons.filled.Search
4246import androidx.compose.material.icons.filled.SortByAlpha
43−import androidx.compose.material3.CircularProgressIndicator
4447import androidx.compose.material3.DropdownMenu
4548import androidx.compose.material3.DropdownMenuItem
4649import androidx.compose.material3.ExperimentalMaterial3Api
@@ -60,6 +63,7 @@ import androidx.compose.runtime.LaunchedEffect
6063import androidx.compose.runtime.getValue
6164import androidx.compose.runtime.mutableStateOf
6265import androidx.compose.runtime.remember
66+import androidx.compose.runtime.rememberCoroutineScope
6367import androidx.compose.runtime.saveable.rememberSaveable
6468import androidx.compose.runtime.setValue
6569import androidx.compose.ui.Alignment
@@ -94,16 +98,19 @@ import soy.iko.opencode.data.model.FindMatch
9498import soy.iko.opencode.data.model.SymbolResult
9599import soy.iko.opencode.data.model.symbolKindLabel
96100import soy.iko.opencode.data.network.NetworkConfig
101+import soy.iko.opencode.data.repo.FileSortKey
97102import soy.iko.opencode.di.AppContainer
98103import soy.iko.opencode.R
99104import soy.iko.opencode.ui.components.AppTopBar
100105import soy.iko.opencode.ui.components.ConnectionBannerFor
101106import soy.iko.opencode.ui.components.EmptyState
107+import soy.iko.opencode.ui.components.SkeletonRow
102108import soy.iko.opencode.ui.components.reducedMotionAnimateItem
103109import soy.iko.opencode.ui.components.copyToClipboard
104110import soy.iko.opencode.ui.components.relativeTime
105111import soy.iko.opencode.ui.vmFactory
106112import soy.iko.opencode.util.runCatchingCancellable
113+import kotlinx.coroutines.launch
107114
108115@OptIn(ExperimentalMaterial3Api::class)
109116@Composable
@@ -156,7 +163,9 @@ fun FileBrowserScreen(
156163 val scale = 1f - (backProgress.floatValue * 0.05f)
157164 Column(
158165 modifier = Modifier
159− .fillMaxSize()
166+ .fillMaxWidth()
167+ .wrapContentWidth(Alignment.CenterHorizontally)
168+ .widthIn(max = soy.iko.opencode.data.network.NetworkConfig.listContentMaxWidthDp.dp)
160169 .imePadding()
161170 .padding(padding)
162171 .graphicsLayer { scaleX = scale; scaleY = scale },
@@ -226,37 +235,56 @@ fun FileBrowserScreen(
226235 ) {
227236 Box(modifier = Modifier.fillMaxSize()) {
228237 val loadingLabel = stringResource(R.string.loading)
229− when {
230− // Full-screen spinner only for the initial directory load. An in-flight
231− // search keeps the previous results visible (a slim top bar below shows the
232− // progress) instead of blanking the list behind a spinner on every keystroke.
238+ // Crossfade between content states so transitions read as a smooth fade
239+ // instead of an instant snap. Matches the session list's Crossfade pattern;
240+ // reduced motion is honored by Crossfade's default spec.
241+ val stateKey = fileBrowserStateKey(state)
242+ @Suppress("UnusedCrossfadeTargetStateParameter")
243+ Crossfade(
244+ targetState = stateKey,
245+ animationSpec = tween(NetworkConfig.motionFadeDurationMs.toInt()),
246+ label = "file_browser_state",
247+ ) {
248+ when {
249+ // Skeleton loader for the initial directory load. An in-flight search
250+ // keeps the previous results visible (a slim top bar below shows the
251+ // progress) instead of blanking the list on every keystroke.
233252 state.loading -> Column(
234− modifier = Modifier.align(Alignment.Center).padding(24.dp),
235− horizontalAlignment = Alignment.CenterHorizontally,
253+ modifier = Modifier.fillMaxSize().padding(16.dp),
254+ verticalArrangement = Arrangement.spacedBy(12.dp),
236255 ) {
237− CircularProgressIndicator(
238− Modifier.semantics { contentDescription = loadingLabel },
239− )
240− Text(
241− stringResource(R.string.loading),
242− style = MaterialTheme.typography.bodyMedium,
243− color = MaterialTheme.colorScheme.onSurfaceVariant,
244− modifier = Modifier.padding(top = 8.dp),
245− )
256+ repeat(6) { SkeletonRow() }
246257 }
247− state.error != null -> Column(
258+ state.error != null -> {
259+ // Detect a permission-denied error (the server returns a 403/EACCES
260+ // when the workspace directory isn't readable) and show a specific
261+ // message + icon instead of the generic error, so the user
262+ // understands the cause is access, not a network failure.
263+ val err = state.error
264+ val isPermissionDenied = err != null && (
265+ err.contains("permission", ignoreCase = true) ||
266+ err.contains("denied", ignoreCase = true) ||
267+ err.contains("403", ignoreCase = true)
268+ )
269+ val errorIcon = if (isPermissionDenied) Icons.Filled.Lock else Icons.Filled.ErrorOutline
270+ val errorText = if (isPermissionDenied) {
271+ stringResource(R.string.permission_denied_files)
272+ } else {
273+ err ?: ""
274+ }
275+ Column(
248276 modifier = Modifier.align(Alignment.Center).padding(24.dp),
249277 horizontalAlignment = Alignment.CenterHorizontally,
250278 ) {
251279 Icon(
252− Icons.Filled.ErrorOutline,
280+ errorIcon,
253281 contentDescription = null,
254282 tint = MaterialTheme.colorScheme.error,
255283 modifier = Modifier.size(56.dp),
256284 )
257285 androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
258286 Text(
259− state.error ?: "",
287+ errorText,
260288 color = MaterialTheme.colorScheme.error,
261289 )
262290 androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
@@ -269,6 +297,7 @@ fun FileBrowserScreen(
269297 Text(stringResource(R.string.retry))
270298 }
271299 }
300+ }
272301 state.mode == SearchMode.TEXT ->
273302 TextResults(
274303 state.textResults,
@@ -281,6 +310,7 @@ fun FileBrowserScreen(
281310 ) { path, line -> onOpenFile(path, line) }
282311 state.isSearching -> SearchResults(state.results) { onOpenFile(it, null) }
283312 else -> DirectoryListing(
313+ container = container,
284314 state = state,
285315 onOpenDir = vm::open,
286316 onUp = vm::up,
@@ -290,6 +320,7 @@ fun FileBrowserScreen(
290320 container.requestNewSession()
291321 },
292322 )
323+ }
293324 }
294325 // Slim progress bar for an in-flight search; the results list stays visible
295326 // underneath so incremental typing doesn't flash an empty screen each keystroke.
@@ -307,6 +338,18 @@ fun FileBrowserScreen(
307338 }
308339}
309340
341+/** Maps the file-browser view state to a stable string key for the Crossfade, so the screen's
342+ * content-state transitions fade smoothly. Extracted from [FileBrowserScreen] to keep its
343+ * cyclomatic complexity under the detekt threshold. */
344+private fun fileBrowserStateKey(state: FileBrowserState): String = when {
345+ state.loading -> "loading"
346+ state.error != null -> "error"
347+ state.mode == SearchMode.TEXT -> "text"
348+ state.mode == SearchMode.SYMBOL -> "symbol"
349+ state.isSearching -> "search"
350+ else -> "dir"
351+}
352+
310353@Composable
311354private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
312355 val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
@@ -576,6 +619,7 @@ private fun highlightMatchLine(match: FindMatch): androidx.compose.ui.text.Annot
576619@Suppress("CyclomaticComplexMethod")
577620@Composable
578621private fun DirectoryListing(
622+ container: AppContainer,
579623 state: FileBrowserState,
580624 onOpenDir: (String) -> Unit,
581625 onUp: () -> Unit,
@@ -591,20 +635,39 @@ private fun DirectoryListing(
591635 )
592636 return
593637 }
594− // Sort preference for the directory listing. Folders-first + name sort is the most useful
595− // default; the toggle flips between folders-first name sort and the server's raw order.
596− var foldersFirst by rememberSaveable { mutableStateOf(true) }
597− // Hidden-files preference. The server may already include dotfiles in its listing; this
598− // hides them by default (the common case for browsing a repo) and lets the user reveal
599− // .env / .git / .opencode on demand. Default off so the listing isn't cluttered.
600− var showHidden by rememberSaveable { mutableStateOf(false) }
601− // Sort key for files within the listing. Name is the default (and the only axis the server
602− // reliably supports); Size/Modified appear only when the server emits size/mtime (otherwise
603− // those sorts are no-ops on null fields), gated behind a menu so they don't clutter the bar.
604− var sortKey by rememberSaveable { mutableStateOf(FileSortKey.NAME) }
605− var sortDesc by rememberSaveable { mutableStateOf(false) }
606− val visible = remember(state.entries, showHidden) {
607− if (showHidden) state.entries else state.entries.filterNot { it.name.startsWith(".") }
638+ // Sort/filter preferences are persisted via FileBrowserPrefs (DataStore) so a user who
639+ // prefers size-sort or hidden-files-visible keeps it across process death, not just
640+ // rotation. Defaults match the prior rememberSaveable defaults so the first-run view is
641+ // unchanged: folders-first ON, hidden OFF, sort NAME ascending, changed-only OFF.
642+ val prefs = container.fileBrowserPrefs
643+ val scope = rememberCoroutineScope()
644+ val foldersFirst by prefs.foldersFirst.collectAsStateWithLifecycle(initialValue = true)
645+ val showHidden by prefs.showHidden.collectAsStateWithLifecycle(initialValue = false)
646+ val sortKey by prefs.sortKey.collectAsStateWithLifecycle(initialValue = FileSortKey.NAME)
647+ val sortDesc by prefs.sortDesc.collectAsStateWithLifecycle(initialValue = false)
648+ val changedOnly by prefs.changedOnly.collectAsStateWithLifecycle(initialValue = false)
649+ fun updateFoldersFirst(value: Boolean) {
650+ scope.launch { runCatchingCancellable { prefs.setFoldersFirst(value) } }
651+ }
652+ fun updateShowHidden(value: Boolean) {
653+ scope.launch { runCatchingCancellable { prefs.setShowHidden(value) } }
654+ }
655+ fun updateSortKey(key: FileSortKey) {
656+ scope.launch { runCatchingCancellable { prefs.setSortKey(key) } }
657+ }
658+ fun updateSortDesc(value: Boolean) {
659+ scope.launch { runCatchingCancellable { prefs.setSortDesc(value) } }
660+ }
661+ fun updateChangedOnly(value: Boolean) {
662+ scope.launch { runCatchingCancellable { prefs.setChangedOnly(value) } }
663+ }
664+ val visible = remember(state.entries, showHidden, changedOnly, state.statusMap) {
665+ val byHidden = if (showHidden) state.entries else state.entries.filterNot { it.name.startsWith(".") }
666+ // "Changed only" narrows to entries with a git status badge (A/M/D). Directories are
667+ // kept when the filter is OFF (so navigation isn't broken mid-review) but dropped when
668+ // it's ON, since git status is per-file and a directory row would never match.
669+ if (changedOnly) byHidden.filter { !it.isDirectory && state.statusMap[it.path] != null }
670+ else byHidden
608671 }
609672 val sorted = remember(visible, foldersFirst, sortKey, sortDesc) {
610673 // Null size/mtime coerce to values that sink the unknown entries to the bottom in the
@@ -634,6 +697,19 @@ private fun DirectoryListing(
634697 if (sorted.size <= renderCap) sorted else sorted.take(renderCap)
635698 }
636699 LazyColumn(modifier = Modifier.fillMaxSize()) {
700+ // Item-count summary header so a large directory is self-describing ("137 items")
701+ // mirroring the search-results count header. Uses the pre-hidden-filter count so the
702+ // user sees the true directory size; hidden entries are a display filter, not a
703+ // property of the folder.
704+ item(key = "__count") {
705+ val count = state.entries.size
706+ Text(
707+ pluralStringResource(R.plurals.file_count, count, count),
708+ style = MaterialTheme.typography.labelSmall,
709+ color = MaterialTheme.colorScheme.onSurfaceVariant,
710+ modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp),
711+ )
712+ }
637713 if (state.path.isNotBlank()) {
638714 item(key = "__up") {
639715 FileRow(icon = true, label = "..", onClick = onUp)
@@ -687,7 +763,8 @@ private fun DirectoryListing(
687763 onClick = {
688764 // Tapping the active key flips the direction; tapping a different
689765 // key switches to it (ascending by default).
690− if (sortKey == key) sortDesc = !sortDesc else { sortKey = key; sortDesc = false }
766+ if (sortKey == key) updateSortDesc(!sortDesc)
767+ else { updateSortKey(key); updateSortDesc(false) }
691768 showSortMenu = false
692769 },
693770 )
@@ -695,12 +772,12 @@ private fun DirectoryListing(
695772 }
696773 androidx.compose.material3.FilterChip(
697774 selected = showHidden,
698− onClick = { showHidden = !showHidden },
775+ onClick = { updateShowHidden(!showHidden) },
699776 label = { Text(stringResource(R.string.show_hidden), style = MaterialTheme.typography.labelSmall) },
700777 )
701778 androidx.compose.material3.FilterChip(
702779 selected = foldersFirst,
703− onClick = { foldersFirst = !foldersFirst },
780+ onClick = { updateFoldersFirst(!foldersFirst) },
704781 leadingIcon = {
705782 if (foldersFirst) {
706783 Icon(Icons.Filled.Folder, contentDescription = null, modifier = Modifier.size(16.dp))
@@ -708,6 +785,16 @@ private fun DirectoryListing(
708785 },
709786 label = { Text(stringResource(R.string.folders_first), style = MaterialTheme.typography.labelSmall) },
710787 )
788+ androidx.compose.material3.FilterChip(
789+ selected = changedOnly,
790+ onClick = { updateChangedOnly(!changedOnly) },
791+ leadingIcon = {
792+ if (changedOnly) {
793+ Icon(Icons.Filled.Code, contentDescription = null, modifier = Modifier.size(16.dp))
794+ }
795+ },
796+ label = { Text(stringResource(R.string.changed_only), style = MaterialTheme.typography.labelSmall) },
797+ )
711798 }
712799 }
713800 // Windowed render: compose only the first `renderCap` rows, growing as the user
@@ -899,13 +986,9 @@ private fun StatusBadge(status: FileStatusEntry) {
899986 }
900987}
901988
902−/** Sort axis for the directory listing. Name is always available; Size/Modified depend on the
903− * server emitting size/mtime (otherwise they sort nulls to the bottom). */
904−private enum class FileSortKey { NAME, SIZE, MODIFIED }
905−
906989/** Format a byte count as a compact human-readable size (e.g. 1.2 KB, 3.4 MB). Uses 1024-based
907990 * units with one decimal place, matching how file managers render sizes. */
908−private fun formatFileSize(bytes: Long): String {
991+internal fun formatFileSize(bytes: Long): String {
909992 if (bytes < 1024) return "$bytes B"
910993 val units = arrayOf("KB", "MB", "GB", "TB")
911994 var value = bytes.toDouble() / 1024.0
app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.kt+29−1
@@ -21,6 +21,8 @@ data class FileViewState(
2121 val loading: Boolean = true,
2222 val content: FileContent? = null,
2323 val error: String? = null,
24+ val size: Long? = null,
25+ val mtime: Long? = null,
2426)
2527
2628@OptIn(ExperimentalCoroutinesApi::class)
@@ -55,9 +57,35 @@ class FileViewModel(
5557 // the file browser, which keeps the prior listing while refreshing.
5658 _state.update { it.copy(loading = true, error = null) }
5759 runCatchingCancellable { conn.api.readFile(path) }
58− .onSuccess { _state.value = FileViewState(loading = false, content = it) }
60+ .onSuccess { result ->
61+ // Fetch the file's size/mtime. The server doesn't expose these on the
62+ // content endpoint, so look the file up in its parent directory listing
63+ // (which carries size/mtime when the server emits them). A missing/failed
64+ // metadata fetch is non-fatal — the header just stays hidden — so a
65+ // metadata failure can't clobber a successful read.
66+ val meta = runCatchingCancellable { fetchMetadata(conn.api, path) }.getOrNull()
67+ _state.value = FileViewState(
68+ loading = false,
69+ content = result,
70+ size = meta?.size,
71+ mtime = meta?.mtime,
72+ )
73+ }
5974 .onFailure { _state.value = FileViewState(loading = false, error = container.friendlyError(it)) }
6075 }
6176 }
6277 }
78+
79+ /** Look up [path] in its parent directory listing to read size/mtime. Returns null when
80+ * the server doesn't emit size/mtime or the listing fails — the caller treats metadata
81+ * as optional. Matches by the full [path] (FileNode.path), falling back to the name so a
82+ * server that returns only relative names still resolves. */
83+ private suspend fun fetchMetadata(api: soy.iko.opencode.data.network.OpencodeApiClient, path: String): soy.iko.opencode.data.model.FileNode? {
84+ val trimmed = path.trimEnd('/')
85+ val parent = trimmed.substringBeforeLast('/', "")
86+ val name = trimmed.substringAfterLast('/')
87+ val listing = api.listDirectory(parent.ifBlank { "/" })
88+ return listing.firstOrNull { it.path == path }
89+ ?: listing.firstOrNull { it.name == name }
90+ }
6391}
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+91−27
@@ -11,7 +11,7 @@ import androidx.compose.animation.fadeIn
1111import androidx.compose.animation.fadeOut
1212import androidx.compose.animation.togetherWith
1313import androidx.compose.foundation.background
14−import androidx.compose.foundation.Image
14+import androidx.compose.foundation.clickable
1515import androidx.compose.foundation.horizontalScroll
1616import androidx.compose.foundation.interaction.DragInteraction
1717import androidx.compose.foundation.layout.Box
@@ -41,6 +41,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
4141import androidx.compose.material.icons.automirrored.filled.WrapText
4242import androidx.compose.material.icons.filled.Close
4343import androidx.compose.material.icons.filled.ContentCopy
44+import androidx.compose.material.icons.filled.ErrorOutline
4445import androidx.compose.material.icons.filled.KeyboardArrowDown
4546import androidx.compose.material.icons.filled.KeyboardArrowUp
4647import androidx.compose.material.icons.filled.Link
@@ -77,7 +78,6 @@ import androidx.compose.runtime.rememberCoroutineScope
7778import androidx.compose.runtime.saveable.rememberSaveable
7879import androidx.compose.runtime.setValue
7980import androidx.compose.ui.Alignment
80−import androidx.compose.ui.graphics.asImageBitmap
8181import androidx.compose.ui.Modifier
8282import androidx.compose.ui.draw.alpha
8383import androidx.compose.ui.draw.clip
@@ -85,6 +85,7 @@ import androidx.compose.ui.layout.onSizeChanged
8585import androidx.compose.ui.platform.LocalContext
8686import androidx.compose.ui.platform.LocalDensity
8787import androidx.compose.ui.platform.LocalSoftwareKeyboardController
88+import androidx.compose.ui.semantics.Role
8889import androidx.compose.ui.semantics.contentDescription
8990import androidx.compose.ui.semantics.semantics
9091import androidx.compose.ui.text.font.FontFamily
@@ -95,14 +96,18 @@ import androidx.compose.ui.unit.dp
9596import androidx.compose.ui.res.stringResource
9697import androidx.lifecycle.compose.collectAsStateWithLifecycle
9798import androidx.lifecycle.viewmodel.compose.viewModel
99+import coil.compose.SubcomposeAsyncImage
100+import coil.request.ImageRequest
98101import soy.iko.opencode.di.AppContainer
99102import soy.iko.opencode.R
100103import soy.iko.opencode.ui.components.ConnectionBannerFor
101104import soy.iko.opencode.ui.components.DiffView
105+import soy.iko.opencode.ui.components.FullscreenImageViewer
102106import soy.iko.opencode.ui.components.LocalChatTextScale
103107import soy.iko.opencode.ui.components.LocalCodeWrap
104108import soy.iko.opencode.ui.components.copyToClipboard
105109import soy.iko.opencode.ui.components.highlightLine
110+import soy.iko.opencode.ui.components.relativeTime
106111import soy.iko.opencode.ui.components.syntaxFor
107112import soy.iko.opencode.ui.components.rememberHighlightPalette
108113import soy.iko.opencode.ui.components.scaledBy
@@ -162,6 +167,14 @@ fun FileViewScreen(
162167 val parent = trimmed.substringBeforeLast('/', "")
163168 parent.ifBlank { path }
164169 }
170+ // Compact "12.3 KB · 2d ago" metadata line for the top bar. Built from the size/mtime the
171+ // ViewModel resolved via the parent directory listing (null when the server doesn't emit
172+ // them). Uses the same formatFileSize/relativeTime helpers as the file browser's FileRow so
173+ // the formatting is identical across screens. Built here (not in the top bar) because it
174+ // needs the Context and the state, and the top bar is a stateless composable.
175+ val metadataLine = remember(state.size, state.mtime) {
176+ buildMetadataLine(context, state.size, state.mtime)
177+ }
165178 // Split the raw text exactly once here (the line viewer and find-in-file both consume this
166179 // list) and cap it so a huge file doesn't get split twice or overwhelm the LazyColumn.
167180 // `take()` (not `subList()`) so the cap actually releases the tail: subList returns a view
@@ -226,6 +239,7 @@ fun FileViewScreen(
226239 FileViewTopBar(
227240 filename = filename,
228241 subtitle = pathSubtitle,
242+ metadata = metadataLine,
229243 loading = state.loading,
230244 content = rawText,
231245 wrap = wrap,
@@ -459,11 +473,24 @@ private fun rememberJumpToLineHighlight(
459473 return highlight
460474}
461475
476+/** Build the compact "12.3 KB · 2d ago" metadata line for the file viewer's top bar from the
477+ * size/mtime the ViewModel resolved. Returns an empty string when neither is present so the
478+ * caller can drop the line in unconditionally. Uses the same helpers as the file browser's
479+ * FileRow so the formatting is identical across screens. */
480+private fun buildMetadataLine(context: android.content.Context, size: Long?, mtime: Long?): String {
481+ val parts = buildList {
482+ size?.let { add(formatFileSize(it)) }
483+ mtime?.let { add(relativeTime(context, it)) }
484+ }
485+ return parts.joinToString(" · ")
486+}
487+
462488@OptIn(ExperimentalMaterial3Api::class)
463489@Composable
464490private fun FileViewTopBar(
465491 filename: String,
466492 subtitle: String,
493+ metadata: String,
467494 loading: Boolean,
468495 content: String,
469496 wrap: Boolean,
@@ -480,9 +507,10 @@ private fun FileViewTopBar(
480507) {
481508 TopAppBar(
482509 title = {
483− // Two-line title: filename (primary) + the directory portion of the path
484− // (secondary), so a file opened from deep in a workspace keeps its location
485− // visible without an extra breadcrumb row.
510+ // Two- or three-line title: filename (primary), the directory portion of the path
511+ // (secondary), and an optional muted metadata line (size · mtime) so a user who
512+ // has drilled into a deep workspace keeps context of where this file lives and how
513+ // stale it is without an extra breadcrumb row.
486514 Column {
487515 Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis)
488516 if (subtitle.isNotBlank()) {
@@ -494,6 +522,15 @@ private fun FileViewTopBar(
494522 color = MaterialTheme.colorScheme.onSurfaceVariant,
495523 )
496524 }
525+ if (metadata.isNotEmpty()) {
526+ Text(
527+ metadata,
528+ maxLines = 1,
529+ overflow = TextOverflow.Ellipsis,
530+ style = MaterialTheme.typography.labelSmall,
531+ color = MaterialTheme.colorScheme.onSurfaceVariant,
532+ )
533+ }
497534 }
498535 },
499536 navigationIcon = {
@@ -662,6 +699,13 @@ private fun BoxScope.FileViewStateContent(
662699 modifier = Modifier.align(Alignment.Center).padding(24.dp),
663700 horizontalAlignment = Alignment.CenterHorizontally,
664701 ) {
702+ Icon(
703+ Icons.Filled.ErrorOutline,
704+ contentDescription = null,
705+ modifier = Modifier.size(56.dp),
706+ tint = MaterialTheme.colorScheme.error,
707+ )
708+ Spacer(Modifier.size(12.dp))
665709 Text(state.error ?: "", color = MaterialTheme.colorScheme.error)
666710 Spacer(Modifier.size(12.dp))
667711 TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
@@ -1061,8 +1105,10 @@ private fun FileTextContent(
10611105/**
10621106 * Renders an image file (PNG/JPG/GIF/WebP/BMP/SVG) inline in the file viewer instead of
10631107 * the generic "Binary file" label. Decodes the base64 content from [FileContent.content]
1064− * into a [Bitmap] and displays it. Falls back to the binary-file label if decoding fails
1065− * (corrupt content or an unsupported encoding).
1108+ * into bytes and hands them to Coil, which renders the image and powers the fullscreen
1109+ * zoomable viewer (pinch-zoom, save, share) on tap — the same [FullscreenImageViewer] the
1110+ * chat attachments use. Falls back to the binary-file label if the base64 payload can't be
1111+ * decoded (corrupt content or an unsupported encoding).
10661112 */
10671113@Composable
10681114private fun ImageFilePreview(
@@ -1070,31 +1116,49 @@ private fun ImageFilePreview(
10701116 modifier: Modifier = Modifier,
10711117) {
10721118 val context = LocalContext.current
1073− val bitmap = remember(content.content) {
1074− runCatching {
1075− val bytes = android.util.Base64.decode(content.content, android.util.Base64.DEFAULT)
1076− android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
1077− }.getOrNull()
1119+ val desc = stringResource(R.string.image_file_preview)
1120+ // Decode the base64 payload to bytes once (off-thread via produceState so a large image
1121+ // doesn't block the first frame). Coil takes the ByteArray as its model and decodes it to
1122+ // a Bitmap internally — the same request drives both the inline preview and the fullscreen
1123+ // viewer, so a tap reuses the already-cached decode for the zoomed view.
1124+ val bytesState by produceState<ByteArray?>(initialValue = null, content.content) {
1125+ value = withContext(Dispatchers.Default) {
1126+ runCatching {
1127+ android.util.Base64.decode(content.content, android.util.Base64.DEFAULT)
1128+ }.getOrNull()
1129+ }
10781130 }
1079− if (bitmap == null) {
1131+ val bytes = bytesState
1132+ if (bytes == null) {
10801133 Text(
10811134 stringResource(R.string.binary_file),
10821135 modifier = modifier,
10831136 color = MaterialTheme.colorScheme.onSurfaceVariant,
10841137 )
1085− } else {
1086− Column(
1087− modifier = modifier.fillMaxWidth(),
1088− horizontalAlignment = Alignment.CenterHorizontally,
1089− ) {
1090− Image(
1091− bitmap = bitmap.asImageBitmap(),
1092− contentDescription = stringResource(R.string.image_file_preview),
1093− modifier = Modifier
1094− .fillMaxWidth()
1095− .heightIn(max = 400.dp)
1096− .clip(MaterialTheme.shapes.medium),
1097− )
1098− }
1138+ return
1139+ }
1140+ var showFullscreen by remember { mutableStateOf(false) }
1141+ val request = remember(bytes) {
1142+ ImageRequest.Builder(context)
1143+ .data(bytes)
1144+ .crossfade(true)
1145+ .build()
1146+ }
1147+ Column(
1148+ modifier = modifier.fillMaxWidth(),
1149+ horizontalAlignment = Alignment.CenterHorizontally,
1150+ ) {
1151+ SubcomposeAsyncImage(
1152+ model = request,
1153+ contentDescription = desc,
1154+ modifier = Modifier
1155+ .fillMaxWidth()
1156+ .heightIn(max = 400.dp)
1157+ .clip(MaterialTheme.shapes.medium)
1158+ .clickable(role = Role.Button) { showFullscreen = true },
1159+ )
1160+ }
1161+ if (showFullscreen) {
1162+ FullscreenImageViewer(request = request, contentDescription = desc) { showFullscreen = false }
10991163 }
11001164}
app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.kt+41−13
@@ -8,10 +8,14 @@ import androidx.compose.foundation.layout.Row
88import androidx.compose.foundation.layout.Spacer
99import androidx.compose.foundation.layout.fillMaxSize
1010import androidx.compose.foundation.layout.fillMaxWidth
11+import androidx.compose.foundation.layout.imePadding
1112import androidx.compose.foundation.layout.padding
1213import androidx.compose.foundation.layout.size
14+import androidx.compose.foundation.layout.widthIn
1315import androidx.compose.foundation.lazy.LazyColumn
1416import androidx.compose.foundation.lazy.items
17+import androidx.compose.animation.Crossfade
18+import androidx.compose.animation.core.tween
1519import androidx.compose.material.icons.Icons
1620import androidx.compose.material.icons.filled.Add
1721import androidx.compose.material.icons.filled.CloudOff
@@ -53,6 +57,7 @@ import androidx.compose.ui.Modifier
5357import androidx.compose.ui.res.stringResource
5458import androidx.compose.ui.semantics.contentDescription
5559import androidx.compose.ui.semantics.semantics
60+import androidx.compose.ui.platform.testTag
5661import androidx.compose.ui.text.font.FontFamily
5762import androidx.compose.ui.text.style.TextOverflow
5863import androidx.compose.ui.unit.dp
@@ -63,6 +68,7 @@ import soy.iko.opencode.di.AppContainer
6368import soy.iko.opencode.ui.components.AppTopBar
6469import soy.iko.opencode.ui.components.ConnectionBannerFor
6570import soy.iko.opencode.ui.components.EmptyState
71+import soy.iko.opencode.ui.components.SkeletonRow
6672import soy.iko.opencode.ui.components.reducedMotionAnimateItem
6773import soy.iko.opencode.ui.vmFactory
6874
@@ -117,21 +123,29 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
117123 PullToRefreshBox(
118124 isRefreshing = refreshing,
119125 onRefresh = { vm.load() },
120− modifier = Modifier.fillMaxSize().padding(padding),
126+ modifier = Modifier.fillMaxSize().imePadding().padding(padding),
121127 ) {
122− Box(modifier = Modifier.fillMaxSize()) {
128+ Box(
129+ modifier = Modifier.fillMaxSize().widthIn(max = 600.dp),
130+ contentAlignment = Alignment.Center,
131+ ) {
123132 ConnectionBannerFor(container)
124− val loadingLabel = stringResource(R.string.loading)
125− when (val s = state) {
133+ // Crossfade between content states so transitions read as a smooth fade instead
134+ // of an instant snap. Matches the session list's Crossfade pattern; reduced
135+ // motion is honored by Crossfade's default spec.
136+ val stateKey = state::class.simpleName
137+ @Suppress("UnusedCrossfadeTargetStateParameter")
138+ Crossfade(
139+ targetState = stateKey,
140+ animationSpec = tween(soy.iko.opencode.data.network.NetworkConfig.motionFadeDurationMs.toInt()),
141+ label = "mcp_state",
142+ ) {
143+ when (val s = state) {
126144 is McpViewModel.State.Loading -> Column(
127− modifier = Modifier.align(Alignment.Center),
128− horizontalAlignment = Alignment.CenterHorizontally,
145+ modifier = Modifier.fillMaxSize().padding(16.dp),
146+ verticalArrangement = Arrangement.spacedBy(12.dp),
129147 ) {
130− CircularProgressIndicator(
131− Modifier.semantics { contentDescription = loadingLabel },
132− )
133− Spacer(Modifier.size(12.dp))
134− Text(loadingLabel, color = MaterialTheme.colorScheme.onSurfaceVariant)
148+ repeat(6) { SkeletonRow() }
135149 }
136150 is McpViewModel.State.Disconnected -> EmptyState(
137151 icon = Icons.Filled.CloudOff,
@@ -158,9 +172,9 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
158172 )
159173 } else {
160174 LazyColumn(
161− modifier = Modifier.fillMaxSize(),
175+ modifier = Modifier.fillMaxWidth(),
162176 contentPadding = PaddingValues(
163− start = 16.dp, end = 16.dp, top = 16.dp, bottom = 96.dp,
177+ start = 16.dp, end = 16.dp, top = 16.dp, bottom = soy.iko.opencode.data.network.NetworkConfig.listFabInsetDp.dp,
164178 ),
165179 verticalArrangement = Arrangement.spacedBy(12.dp),
166180 ) {
@@ -169,6 +183,7 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
169183 }
170184 }
171185 }
186+ }
172187 }
173188 }
174189 }
@@ -218,6 +233,7 @@ private fun McpServerCard(server: McpServerInfo, modifier: Modifier = Modifier)
218233 Card(
219234 modifier = modifier
220235 .fillMaxWidth()
236+ .testTag("mcp_card")
221237 .semantics(mergeDescendants = true) { contentDescription = cardDesc },
222238 ) {
223239 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
@@ -400,6 +416,10 @@ private fun AddMcpDialog(
400416 label = { Text(stringResource(R.string.mcp_add_name)) },
401417 singleLine = true,
402418 enabled = !adding,
419+ isError = !nameValid,
420+ supportingText = if (!nameValid) {
421+ { Text(stringResource(R.string.mcp_add_name_required)) }
422+ } else null,
403423 )
404424 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
405425 FilterChip(
@@ -426,6 +446,10 @@ private fun AddMcpDialog(
426446 },
427447 singleLine = true,
428448 enabled = !adding,
449+ isError = !targetValid,
450+ supportingText = if (!targetValid) {
451+ { Text(stringResource(R.string.mcp_add_target_required)) }
452+ } else null,
429453 )
430454 // Warn that the local command is split on whitespace and quoted args aren't
431455 // supported, so a user entering `"my program" --arg` isn't surprised when it's
@@ -447,6 +471,10 @@ private fun AddMcpDialog(
447471 supportingText = { Text(stringResource(R.string.mcp_env_hint)) },
448472 enabled = !adding,
449473 minLines = 2,
474+ keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
475+ capitalization = androidx.compose.ui.text.input.KeyboardCapitalization.None,
476+ imeAction = androidx.compose.ui.text.input.ImeAction.Default,
477+ ),
450478 )
451479 }
452480 },
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+34−3
@@ -1,5 +1,7 @@
11package soy.iko.opencode.ui.search
22
3+import androidx.compose.animation.Crossfade
4+import androidx.compose.animation.core.tween
35import androidx.compose.foundation.clickable
46import androidx.compose.foundation.layout.Arrangement
57import androidx.compose.foundation.layout.Box
@@ -14,6 +16,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
1416import androidx.compose.foundation.layout.imePadding
1517import androidx.compose.foundation.layout.padding
1618import androidx.compose.foundation.layout.size
19+import androidx.compose.foundation.layout.widthIn
20+import androidx.compose.foundation.layout.wrapContentWidth
1721import androidx.compose.foundation.lazy.LazyColumn
1822import androidx.compose.foundation.lazy.items
1923import androidx.compose.foundation.text.KeyboardActions
@@ -44,6 +48,7 @@ import androidx.compose.ui.Alignment
4448import androidx.compose.ui.Modifier
4549import androidx.compose.ui.focus.focusRequester
4650import androidx.compose.ui.platform.LocalSoftwareKeyboardController
51+import androidx.compose.ui.platform.testTag
4752import androidx.compose.ui.res.pluralStringResource
4853import androidx.compose.ui.res.stringResource
4954import androidx.compose.ui.semantics.Role
@@ -93,14 +98,16 @@ fun GlobalSearchScreen(
9398 AppTopBar(title = stringResource(R.string.search_all_title), onBack = onBack)
9499 },
95100 ) { padding ->
96− Column(modifier = Modifier.fillMaxSize().imePadding().padding(padding)) {
101+ Column(modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.CenterHorizontally).widthIn(max = soy.iko.opencode.data.network.NetworkConfig.listContentMaxWidthDp.dp).imePadding().padding(padding)) {
97102 OutlinedTextField(
98103 value = state.query,
99104 onValueChange = vm::setQuery,
100105 modifier = Modifier
101106 .fillMaxWidth()
102107 .padding(horizontal = 16.dp, vertical = 8.dp)
103− .focusRequester(focusRequester),
108+ .focusRequester(focusRequester)
109+ .testTag("global_search"),
110+ label = { Text(stringResource(R.string.search_all_hint)) },
104111 placeholder = { Text(stringResource(R.string.search_all_hint)) },
105112 singleLine = true,
106113 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
@@ -147,7 +154,17 @@ fun GlobalSearchScreen(
147154 Box(modifier = Modifier.fillMaxSize()) {
148155 ConnectionBannerFor(container)
149156 val searchingLabel = stringResource(R.string.searching)
150− when {
157+ // Crossfade between content states so transitions read as a smooth fade instead
158+ // of an instant snap. Matches the session list's Crossfade pattern; reduced
159+ // motion is honored by Crossfade's default spec.
160+ val stateKey = globalSearchStateKey(state)
161+ @Suppress("UnusedCrossfadeTargetStateParameter")
162+ Crossfade(
163+ targetState = stateKey,
164+ animationSpec = tween(soy.iko.opencode.data.network.NetworkConfig.motionFadeDurationMs.toInt()),
165+ label = "global_search_state",
166+ ) {
167+ when {
151168 state.searching -> Column(
152169 modifier = Modifier.align(Alignment.Center),
153170 horizontalAlignment = Alignment.CenterHorizontally,
@@ -248,12 +265,26 @@ fun GlobalSearchScreen(
248265 )
249266 }
250267 }
268+ }
251269 }
252270 }
253271 }
254272 }
255273}
256274
275+/** Maps the global-search view state to a stable string key for the Crossfade, so the screen's
276+ * content-state transitions fade smoothly. Extracted from [GlobalSearchScreen] to keep its
277+ * cyclomatic complexity under the detekt threshold. */
278+private fun globalSearchStateKey(state: GlobalSearchState): String = when {
279+ state.searching -> "searching"
280+ state.error != null -> "error"
281+ state.hasSearched && state.results.isEmpty() -> "no_matches"
282+ state.query.isNotBlank() && !state.hasSearched -> "keep_typing"
283+ state.query.isBlank() && state.history.isNotEmpty() -> "history"
284+ state.results.isEmpty() -> "start"
285+ else -> "results"
286+}
287+
257288@OptIn(ExperimentalLayoutApi::class)
258289@Composable
259290private fun HistorySuggestions(
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+70−39
@@ -32,6 +32,7 @@ import androidx.compose.material3.CircularProgressIndicator
3232import androidx.compose.material3.DropdownMenu
3333import androidx.compose.material3.DropdownMenuItem
3434import androidx.compose.material3.ExperimentalMaterial3Api
35+import androidx.compose.material3.ExtendedFloatingActionButton
3536import androidx.compose.material3.FloatingActionButton
3637import androidx.compose.material3.Icon
3738import androidx.compose.material3.IconButton
@@ -71,6 +72,8 @@ import androidx.compose.ui.unit.dp
7172import androidx.compose.ui.res.stringResource
7273import androidx.activity.compose.BackHandler
7374import androidx.activity.compose.rememberLauncherForActivityResult
75+import androidx.compose.animation.Crossfade
76+import androidx.compose.animation.core.tween
7477import androidx.lifecycle.compose.collectAsStateWithLifecycle
7578import androidx.lifecycle.viewmodel.compose.viewModel
7679import kotlinx.coroutines.coroutineScope
@@ -84,6 +87,7 @@ import soy.iko.opencode.util.runCatchingCancellable
8487import soy.iko.opencode.di.AppContainer
8588import soy.iko.opencode.R
8689import soy.iko.opencode.ui.components.EmptyState
90+import soy.iko.opencode.ui.components.SkeletonRow
8791import soy.iko.opencode.ui.components.reducedMotionAnimateItem
8892import soy.iko.opencode.ui.components.rememberRelativeTime
8993import soy.iko.opencode.ui.vmFactory
@@ -111,6 +115,11 @@ fun ServerListScreen(
111115 .collectAsStateWithLifecycle(initialValue = soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Connected)
112116 val haptics = LocalHapticFeedback.current
113117 val snackbar = remember { SnackbarHostState() }
118+ // Separate host for transient error messages (connect/probe failures, QR import results)
119+ // so a transient error can't preempt an undo snackbar's full window — the undo and error
120+ // channels don't share a queue, so a connect error can't cancel an in-flight undo and
121+ // strand a pending server delete.
122+ val errorSnackbar = remember { SnackbarHostState() }
114123 val retryLabel = stringResource(R.string.retry)
115124 val undoLabel = stringResource(R.string.undo)
116125 val serverRemovedLabel = stringResource(R.string.server_removed)
@@ -130,16 +139,16 @@ fun ServerListScreen(
130139 val qrImportLauncher = ServerQrImportHandler(
131140 vm = vm,
132141 scope = scope,
133− snackbar = snackbar,
142+ snackbar = errorSnackbar,
134143 context = context,
135144 )
136145
137146 LaunchedEffect(Unit) {
138147 vm.errorEvents.collect { event ->
139148 val result = if (event.profile != null) {
140− snackbar.showSnackbar(message = event.message, actionLabel = retryLabel)
149+ errorSnackbar.showSnackbar(message = event.message, actionLabel = retryLabel)
141150 } else {
142− snackbar.showSnackbar(event.message)
151+ errorSnackbar.showSnackbar(event.message)
143152 }
144153 if (result == SnackbarResult.ActionPerformed && event.profile != null) {
145154 vm.connect(event.profile, onConnected)
@@ -158,7 +167,7 @@ fun ServerListScreen(
158167 is ProbeTestResult.Success -> probeSuccessLabel.format(result.latencyMs)
159168 is ProbeTestResult.Failed -> probeFailedLabel.format(result.message)
160169 }
161− snackbar.showSnackbar(message)
170+ errorSnackbar.showSnackbar(message)
162171 }
163172 }
164173
@@ -257,12 +266,24 @@ fun ServerListScreen(
257266 },
258267 )
259268 },
260− snackbarHost = { SnackbarHost(snackbar) },
261− floatingActionButton = {
262− FloatingActionButton(onClick = onAddProfile) {
263− Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_server))
269+ snackbarHost = {
270+ // Stack both hosts; the undo snackbar takes priority (rendered first), the error
271+ // host renders below it so both can be visible simultaneously without preempting.
272+ Column {
273+ SnackbarHost(snackbar)
274+ SnackbarHost(errorSnackbar)
264275 }
265276 },
277+ floatingActionButton = {
278+ // Extended FAB with a text label, matching the session list's "New session" FAB
279+ // so the two home screens read consistently. The icon-only FAB here was the lone
280+ // exception among the app's primary surfaces.
281+ ExtendedFloatingActionButton(
282+ onClick = onAddProfile,
283+ icon = { Icon(Icons.Filled.Add, contentDescription = null) },
284+ text = { Text(stringResource(R.string.add_server)) },
285+ )
286+ },
266287 ) { padding ->
267288 // Consume the top inset on the Column so the reconnecting indicator (its first child)
268289 // renders just below the TopAppBar instead of at y=0 under it. The children below then
@@ -277,41 +298,46 @@ fun ServerListScreen(
277298 .semantics { contentDescription = reconnectingLabel },
278299 )
279300 }
280− if (loading) {
281− // Initial DataStore read in flight: show a spinner rather than flashing the
282− // "No servers yet" empty state, which would appear then vanish once profiles
283− // arrive. Mirrors the session list's explicit loading state.
284− val loadingLabel = stringResource(R.string.loading)
285− Box(
286− modifier = Modifier.fillMaxSize(),
287− contentAlignment = Alignment.Center,
301+ // Crossfade between content states (loading/empty/list) so the transition reads as a
302+ // smooth fade instead of an instant snap. Matches the session list's Crossfade
303+ // pattern; reduced motion is honored by Crossfade's default spec.
304+ val stateKey = when {
305+ loading -> "loading"
306+ profiles.isEmpty() -> "empty"
307+ else -> "list"
308+ }
309+ Crossfade(
310+ targetState = stateKey,
311+ animationSpec = tween(NetworkConfig.motionFadeDurationMs.toInt()),
312+ label = "server_list_state",
313+ ) { key ->
314+ when (key) {
315+ "loading" -> Column(
316+ modifier = Modifier.fillMaxSize().padding(16.dp),
317+ verticalArrangement = Arrangement.spacedBy(12.dp),
288318 ) {
289− Column(horizontalAlignment = Alignment.CenterHorizontally) {
290− CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
291− Spacer(Modifier.size(12.dp))
292− Text(loadingLabel, color = MaterialTheme.colorScheme.onSurfaceVariant)
293− }
319+ repeat(6) { SkeletonRow() }
294320 }
295− } else if (profiles.isEmpty()) {
296− EmptyServers(
321+ "empty" -> EmptyServers(
297322 onAdd = onAddProfile,
323+ onQrImport = { qrImportLauncher.launch(arrayOf("image/*")) },
298324 modifier = Modifier.fillMaxSize(),
299325 )
300− } else {
301− val filtered = remember(profiles, serverQuery) { filterServerProfiles(profiles, serverQuery) }
302− PullToRefreshBox(
303− isRefreshing = connectingId != null,
304− onRefresh = { vm.refresh(onConnected) },
305− modifier = Modifier.fillMaxSize(),
306− ) {
307− LazyColumn(
326+ else -> {
327+ val filtered = remember(profiles, serverQuery) { filterServerProfiles(profiles, serverQuery) }
328+ PullToRefreshBox(
329+ isRefreshing = connectingId != null,
330+ onRefresh = { vm.refresh(onConnected) },
308331 modifier = Modifier.fillMaxSize(),
309− contentPadding = androidx.compose.foundation.layout.PaddingValues(
310− start = 16.dp, end = 16.dp, top = 16.dp,
311− bottom = 96.dp + padding.calculateBottomPadding(),
312− ),
313− verticalArrangement = Arrangement.spacedBy(12.dp),
314332 ) {
333+ LazyColumn(
334+ modifier = Modifier.fillMaxSize(),
335+ contentPadding = androidx.compose.foundation.layout.PaddingValues(
336+ start = 16.dp, end = 16.dp, top = 16.dp,
337+ bottom = 96.dp + padding.calculateBottomPadding(),
338+ ),
339+ verticalArrangement = Arrangement.spacedBy(12.dp),
340+ ) {
315341 // Show the filter field only past a small threshold so a typical
316342 // 1–2 server setup isn't cluttered with a search bar.
317343 if (profiles.size >= NetworkConfig.serverListSearchThreshold) {
@@ -429,10 +455,11 @@ fun ServerListScreen(
429455 }
430456 }
431457 }
458+ }
459+ }
432460 }
433461 }
434462 }
435−
436463 profiles.find { it.id == pendingDeleteId }?.let { profile ->
437464 val isActiveProfile = profile.id == connectedId
438465 AlertDialog(
@@ -651,7 +678,7 @@ private fun LastUsedText(lastUsed: Long) {
651678}
652679
653680@Composable
654−private fun EmptyServers(onAdd: () -> Unit, modifier: Modifier = Modifier) {
681+private fun EmptyServers(onAdd: () -> Unit, onQrImport: () -> Unit, modifier: Modifier = Modifier) {
655682 Box(modifier = modifier, contentAlignment = Alignment.Center) {
656683 EmptyState(
657684 icon = Icons.Filled.Dns,
@@ -660,6 +687,9 @@ private fun EmptyServers(onAdd: () -> Unit, modifier: Modifier = Modifier) {
660687 actionIcon = Icons.Filled.Add,
661688 actionLabel = stringResource(R.string.add_server),
662689 onAction = onAdd,
690+ secondaryActionIcon = Icons.Filled.QrCodeScanner,
691+ secondaryActionLabel = stringResource(R.string.import_from_image),
692+ onSecondaryAction = onQrImport,
663693 )
664694 }
665695}
@@ -745,7 +775,8 @@ private fun ServerListSearchField(query: String, onQueryChange: (String) -> Unit
745775 OutlinedTextField(
746776 value = query,
747777 onValueChange = onQueryChange,
748− modifier = Modifier.fillMaxWidth(),
778+ modifier = Modifier.fillMaxWidth().testTag("server_search"),
779+ label = { Text(stringResource(R.string.search_servers)) },
749780 placeholder = { Text(stringResource(R.string.search_servers)) },
750781 singleLine = true,
751782 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+154−9
@@ -1,3 +1,5 @@
1+@file:Suppress("TooManyFunctions")
2+
13package soy.iko.opencode.ui.session
24
35import androidx.compose.foundation.background
@@ -93,6 +95,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
9395import androidx.compose.ui.platform.testTag
9496import androidx.compose.ui.semantics.Role
9597import androidx.compose.ui.semantics.contentDescription
98+import androidx.compose.ui.semantics.heading
9699import androidx.compose.ui.semantics.semantics
97100import androidx.compose.ui.text.TextRange
98101import androidx.compose.ui.text.font.FontWeight
@@ -186,6 +189,10 @@ fun SessionListScreen(
186189 val connectedId = activeConnection?.profile?.id
187190 val haptics = LocalHapticFeedback.current
188191 val snackbar = remember { SnackbarHostState() }
192+ // Separate host for transient error messages so a transient error can't preempt an undo
193+ // snackbar's full window — the undo and error channels don't share a queue, so a share
194+ // error can't cancel an in-flight undo and strand a pending delete.
195+ val errorSnackbar = remember { SnackbarHostState() }
189196 val undoLabel = stringResource(R.string.undo)
190197 val retryLabel = stringResource(R.string.retry)
191198 val sessionDeletedLabel = stringResource(R.string.session_deleted)
@@ -247,13 +254,13 @@ fun SessionListScreen(
247254
248255 LaunchedEffect(Unit) {
249256 vm.transientErrors.collect { msg ->
250− snackbar.showSnackbar(msg)
257+ errorSnackbar.showSnackbar(msg)
251258 }
252259 }
253260
254261 LaunchedEffect(Unit) {
255262 vm.transientInfo.collect { msg ->
256− snackbar.showSnackbar(msg)
263+ errorSnackbar.showSnackbar(msg)
257264 }
258265 }
259266
@@ -261,7 +268,7 @@ fun SessionListScreen(
261268 // is recoverable in one tap instead of forcing the user back through the dialog/FAB.
262269 LaunchedEffect(Unit) {
263270 vm.retryableErrors.collect { err ->
264− val result = snackbar.showSnackbar(
271+ val result = errorSnackbar.showSnackbar(
265272 message = err.message,
266273 actionLabel = retryLabel,
267274 duration = androidx.compose.material3.SnackbarDuration.Long,
@@ -340,6 +347,12 @@ fun SessionListScreen(
340347 }) {
341348 Icon(Icons.Filled.MarkChatRead, contentDescription = stringResource(R.string.bulk_mark_read))
342349 }
350+ IconButton(onClick = {
351+ vm.bulkPin(selectedIds, pin = true)
352+ selectedIds = emptySet()
353+ }) {
354+ Icon(Icons.Filled.PushPin, contentDescription = stringResource(R.string.bulk_pin))
355+ }
343356 IconButton(onClick = {
344357 vm.bulkArchive(selectedIds, archive = true)
345358 selectedIds = emptySet()
@@ -452,7 +465,14 @@ fun SessionListScreen(
452465 )
453466 }
454467 },
455− snackbarHost = { SnackbarHost(snackbar) },
468+ snackbarHost = {
469+ // Stack both hosts; the undo snackbar takes priority (rendered first), the error
470+ // host renders below it so both can be visible simultaneously without preempting.
471+ Column {
472+ SnackbarHost(snackbar)
473+ SnackbarHost(errorSnackbar)
474+ }
475+ },
456476 floatingActionButton = {
457477 if (!inSelection) {
458478 ExtendedFloatingActionButton(
@@ -871,8 +891,24 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
871891 var renderCap by remember {
872892 mutableStateOf(NetworkConfig.sessionListInitialPage)
873893 }
874− val visibleNodes = remember(nodes, renderCap) {
875− if (nodes.size <= renderCap) nodes else nodes.take(renderCap)
894+ // Build the grouped entry list: a Pinned header (if any pinned sessions are
895+ // present), then date-grouped sections (Today / Yesterday / Last week / Older)
896+ // when sorted by recency, or a flat list when sorted by title. Date grouping
897+ // the home screen makes a long flat list scannable; the headers are suppressed
898+ // during selection mode and when a query/filter is active (those re-order the
899+ // list away from recency, so date headers would mislead).
900+ val showGroups = state.sortMode == SessionSortMode.RECENT &&
901+ !inSelection && state.query.isEmpty() && state.directoryFilter == null
902+ // Resolve section labels once (in the @Composable scope) so buildGroupedEntries
903+ // can stay a plain function. Captured into the file-level lateinit fields.
904+ pinnedLabel = stringResource(R.string.session_pinned)
905+ todayLabel = stringResource(R.string.today)
906+ yesterdayLabel = stringResource(R.string.yesterday)
907+ lastWeekLabel = stringResource(R.string.last_week)
908+ olderLabel = stringResource(R.string.older)
909+ val groupedEntries = remember(nodes, renderCap, state.pinnedIds, showGroups) {
910+ val capped = if (nodes.size <= renderCap) nodes else nodes.take(renderCap)
911+ buildGroupedEntries(capped, state.pinnedIds, showGroups)
876912 }
877913 PullToRefreshBox(
878914 isRefreshing = refreshing,
@@ -896,11 +932,15 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
896932 LazyColumn(
897933 state = listState,
898934 modifier = Modifier.fillMaxSize(),
899− contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 96.dp),
935+ contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = NetworkConfig.listFabInsetDp.dp),
900936 verticalArrangement = Arrangement.spacedBy(12.dp),
901937 ) {
902− items(visibleNodes, key = { it.session.id }) { node ->
903− val session = node.session
938+ items(groupedEntries, key = { it.key }) { entry ->
939+ when (entry) {
940+ is SessionListEntry.Header -> DateGroupHeader(entry.label)
941+ is SessionListEntry.Node -> {
942+ val node = entry.node
943+ val session = node.session
904944 // Swipe end-to-start reveals a delete affordance and opens the
905945 // same confirmation dialog as the trash icon. We never commit
906946 // the dismissal (always reset to Settled) so the card snaps back
@@ -1032,6 +1072,8 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
10321072 modifier = Modifier.testTag("session_card"),
10331073 )
10341074 }
1075+ }
1076+ }
10351077 }
10361078 }
10371079 }
@@ -1044,6 +1086,109 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
10441086/** A session plus its nesting [depth] (0 = top level) for the sub-session tree. */
10451087private data class SessionNode(val session: soy.iko.opencode.data.model.Session, val depth: Int)
10461088
1089+/** A renderable entry in the grouped session list: either a section header (Pinned, Today,
1090+ * Yesterday, Last week, Older) or a session [SessionNode]. The sealed type lets the
1091+ * LazyColumn mix headers and cards in one `items()` call with stable keys. */
1092+private sealed interface SessionListEntry {
1093+ val key: String
1094+
1095+ /** A section header carrying a display label. */
1096+ data class Header(override val key: String, val label: String) : SessionListEntry
1097+
1098+ /** A session row. */
1099+ data class Node(val node: SessionNode) : SessionListEntry {
1100+ override val key: String get() = node.session.id
1101+ }
1102+}
1103+
1104+/** Bucket a session's last-activity timestamp into a display group. Returns null when no
1105+ * timestamp is available (the session is then grouped under "Older"). */
1106+private fun sessionDateGroup(session: soy.iko.opencode.data.model.Session, now: Long): String {
1107+ val ts = session.time?.updated ?: session.time?.created ?: return "older"
1108+ val dayMs = 24L * 60 * 60 * 1000
1109+ val todayStart = (now / dayMs) * dayMs
1110+ return when {
1111+ ts >= todayStart -> "today"
1112+ ts >= todayStart - dayMs -> "yesterday"
1113+ ts >= todayStart - 7 * dayMs -> "last_week"
1114+ else -> "older"
1115+ }
1116+}
1117+
1118+/** Build the grouped entry list for the LazyColumn. Inserts a "Pinned" header before pinned
1119+ * sessions (when [showGroups] is true and pinned sessions exist), and date-group headers
1120+ * (Today / Yesterday / Last week / Older) between unpinned sessions when [showGroups] is
1121+ * true. When [showGroups] is false (title sort, selection mode, active query/filter), the
1122+ * nodes are returned flat with only a Pinned header (still useful to signal why pinned
1123+ * sessions float to the top). */
1124+private fun buildGroupedEntries(
1125+ nodes: List<SessionNode>,
1126+ pinnedIds: Set<String>,
1127+ showGroups: Boolean,
1128+): List<SessionListEntry> {
1129+ if (nodes.isEmpty()) return emptyList()
1130+ val now = System.currentTimeMillis()
1131+ val result = ArrayList<SessionListEntry>(nodes.size + 4)
1132+ val pinnedHeaderAdded = booleanArrayOf(false)
1133+ val dateHeaderAdded = HashMap<String, Boolean>()
1134+
1135+ fun maybeAddPinnedHeader() {
1136+ if (!pinnedHeaderAdded[0] && pinnedIds.isNotEmpty()) {
1137+ result.add(SessionListEntry.Header("__pinned", pinnedLabel))
1138+ pinnedHeaderAdded[0] = true
1139+ }
1140+ }
1141+
1142+ for (node in nodes) {
1143+ val isPinned = node.session.id in pinnedIds
1144+ if (isPinned) {
1145+ maybeAddPinnedHeader()
1146+ result.add(SessionListEntry.Node(node))
1147+ } else {
1148+ if (showGroups) {
1149+ val group = sessionDateGroup(node.session, now)
1150+ if (dateHeaderAdded[group] != true) {
1151+ dateHeaderAdded[group] = true
1152+ result.add(SessionListEntry.Header("__date_$group", dateGroupLabel(group)))
1153+ }
1154+ }
1155+ result.add(SessionListEntry.Node(node))
1156+ }
1157+ }
1158+ return result
1159+}
1160+
1161+/** Resolve a date-group key to its display label. */
1162+private fun dateGroupLabel(group: String): String = when (group) {
1163+ "today" -> todayLabel
1164+ "yesterday" -> yesterdayLabel
1165+ "last_week" -> lastWeekLabel
1166+ else -> olderLabel
1167+}
1168+
1169+// Cached labels resolved lazily from resources at first use. Resolved in the composable
1170+// scope via stringResource and captured here so buildGroupedEntries (a plain function) can
1171+// use them without a @Composable context.
1172+private lateinit var pinnedLabel: String
1173+private lateinit var todayLabel: String
1174+private lateinit var yesterdayLabel: String
1175+private lateinit var lastWeekLabel: String
1176+private lateinit var olderLabel: String
1177+
1178+/** A date-group section header in the session list. Rendered as a muted titleSmall label
1179+ * with heading semantics so TalkBack users can skip between groups. */
1180+@Composable
1181+private fun DateGroupHeader(label: String) {
1182+ Text(
1183+ label,
1184+ style = MaterialTheme.typography.titleSmall,
1185+ color = MaterialTheme.colorScheme.primary,
1186+ modifier = Modifier
1187+ .padding(top = 8.dp, bottom = 4.dp)
1188+ .semantics { heading() },
1189+ )
1190+}
1191+
10471192/**
10481193 * Keep the home-screen widget, launcher shortcuts, and "Resume last" in sync with the
10491194 * loaded session list. When the list is populated, write recents and point "Resume last"
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+19−6
@@ -843,14 +843,27 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
843843 )
844844 }
845845
846− /** Reverse a bulk archive action surfaced via [bulkSessionActionEvents] (Undo snackbar). */
846+ /** Pin or unpin all of the given sessions. Emits a single bulk action event so the UI can
847+ * show one Undo snackbar for the batch (per-session snackbars would spam). */
848+ fun bulkPin(ids: Set<String>, pin: Boolean) {
849+ if (ids.isEmpty()) return
850+ ids.forEach { applyPin(it, pin) }
851+ _bulkSessionActionEvents.tryEmit(
852+ BulkSessionActionEvent(
853+ ids,
854+ if (pin) SessionActionKind.PINNED else SessionActionKind.UNPINNED,
855+ ),
856+ )
857+ }
858+
859+ /** Reverse a bulk archive/pin action surfaced via [bulkSessionActionEvents] (Undo snackbar). */
847860 fun undoBulkAction(event: BulkSessionActionEvent) {
848− val undo = when (event.kind) {
849− SessionActionKind.ARCHIVED -> false
850− SessionActionKind.UNARCHIVED -> true
851− SessionActionKind.PINNED, SessionActionKind.UNPINNED -> return
861+ when (event.kind) {
862+ SessionActionKind.ARCHIVED -> event.ids.forEach { applyArchive(it, false) }
863+ SessionActionKind.UNARCHIVED -> event.ids.forEach { applyArchive(it, true) }
864+ SessionActionKind.PINNED -> event.ids.forEach { applyPin(it, false) }
865+ SessionActionKind.UNPINNED -> event.ids.forEach { applyPin(it, true) }
852866 }
853− event.ids.forEach { applyArchive(it, undo) }
854867 }
855868
856869 /** Mark all of the given sessions as read (clears their unread badge). Emits a transient
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+16−35
@@ -99,34 +99,31 @@ fun TwoPaneSessionChat(
9999 onClear = { selected = null },
100100 )
101101
102− // Auto-select the most-recent session into the detail pane so a wide screen doesn't
103− // open on a blank right pane. Runs at most once per Activity instance (the `autoSelected`
104− // latch, saveable so an explicit "clear detail pane" via BackHandler survives a rotation
105− // — otherwise rotation resets the latch, re-arms the effect, and re-selects a session the
106− // user just cleared), and only once the first list load has completed. It defers to any
107− // explicit selection already present — a restored `selected`, a deep-link/notification
108− // (pendingOpenSession), or a user pick — and, since the latch is set the moment any of
109− // those wins, it never re-selects after a user clears the pane (BackHandler) or a server
110− // switch nulls `selected`.
102+ // Auto-select the top of the sorted/filtered list into the detail pane so a wide screen
103+ // doesn't open on a blank right pane. Runs at most once per Activity instance (the
104+ // `autoSelected` latch, saveable so an explicit "clear detail pane" via BackHandler
105+ // survives a rotation — otherwise rotation resets the latch, re-arms the effect, and
106+ // re-selects a session the user just cleared), and only once the first list load has
107+ // completed. It defers to any explicit selection already present — a restored `selected`,
108+ // a deep-link/notification (pendingOpenSession), or a user pick — and, since the latch
109+ // is set the moment any of those wins, it never re-selects after a user clears the pane
110+ // (BackHandler) or a server switch nulls `selected`. Using state.filtered (not a raw
111+ // recency sort) means the auto-selected detail matches the top list row under the
112+ // active sort mode, pinned float, archived-hidden, and directory filter.
111113 var autoSelected by rememberSaveable { mutableStateOf(false) }
114+ val autoSelectCandidate = sessionListState.filtered.firstOrNull()?.id
112115 LaunchedEffect(
113116 sessionListState.loading,
114− sessionListState.sessions,
115− sessionListState.archivedIds,
116− sessionListState.showArchived,
117+ autoSelectCandidate,
117118 pendingOpenSession,
118119 selected,
119120 ) {
120121 if (autoSelected || sessionListState.loading) return@LaunchedEffect
121122 // A pending deep-link or an existing selection takes precedence; yield the pane to it.
122123 if (pendingOpenSession != null || selected != null) { autoSelected = true; return@LaunchedEffect }
123− // Pick by recency regardless of the list's current sort mode, but only among
124− // sessions visible under the current archive filter so we never open a hidden row.
125− val id = mostRecentSessionId(
126− sessionListState.sessions,
127− sessionListState.archivedIds,
128− sessionListState.showArchived,
129− ) ?: return@LaunchedEffect
124+ // Pick the top of the filtered list (respects sort mode, pinned, archived-hidden,
125+ // and directory filter) so the auto-selected detail matches the top list row.
126+ val id = autoSelectCandidate ?: return@LaunchedEffect
130127 selected = id
131128 autoSelected = true
132129 }
@@ -199,22 +196,6 @@ fun TwoPaneSessionChat(
199196 }
200197}
201198
202−/**
203− * Id of the most recently updated (or created) session that is *visible* under the current
204− * archive filter (archived sessions excluded unless [showArchived]), or null when none
205− * qualify. Auto-selecting a hidden/archived session would open the detail pane on a row with
206− * no corresponding highlighted entry in the list.
207− */
208−private fun mostRecentSessionId(
209− sessions: List<Session>,
210− archivedIds: Set<String>,
211− showArchived: Boolean,
212−): String? =
213− sessions
214− .filter { showArchived || it.id !in archivedIds }
215− .maxByOrNull { it.time?.updated ?: it.time?.created ?: 0L }
216− ?.id
217−
218199/** Whether a session with [id] is present in [sessions]. */
219200private fun containsSession(sessions: List<Session>, id: String): Boolean =
220201 sessions.any { it.id == id }
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+37−3
@@ -3,6 +3,8 @@ package soy.iko.opencode.ui.settings
33import android.content.ActivityNotFoundException
44import android.content.Intent
55import android.util.Log
6+import androidx.compose.animation.Crossfade
7+import androidx.compose.animation.core.tween
68import androidx.compose.foundation.background
79import androidx.compose.foundation.clickable
810import androidx.compose.foundation.layout.Arrangement
@@ -11,6 +13,8 @@ import androidx.compose.foundation.layout.Column
1113import androidx.compose.foundation.layout.Row
1214import androidx.compose.foundation.layout.fillMaxSize
1315import androidx.compose.foundation.layout.fillMaxWidth
16+import androidx.compose.foundation.layout.widthIn
17+import androidx.compose.foundation.layout.wrapContentWidth
1418import androidx.compose.foundation.layout.imePadding
1519import androidx.compose.foundation.layout.padding
1620import androidx.compose.foundation.lazy.LazyColumn
@@ -246,7 +250,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
246250 },
247251 ) { padding ->
248252 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
249− Column(modifier = Modifier.fillMaxSize().imePadding().padding(padding).nestedScroll(scrollBehavior.nestedScrollConnection)) {
253+ Column(modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.CenterHorizontally).widthIn(max = soy.iko.opencode.data.network.NetworkConfig.listContentMaxWidthDp.dp).imePadding().padding(padding).nestedScroll(scrollBehavior.nestedScrollConnection)) {
250254 Text(
251255 stringResource(R.string.diagnostics_desc),
252256 style = MaterialTheme.typography.bodySmall,
@@ -261,11 +265,29 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
261265 .padding(start = 16.dp, bottom = 8.dp)
262266 .semantics { heading() },
263267 )
264− if (reports.isEmpty()) {
268+ // Crossfade between empty and list states so the transition reads as a smooth fade
269+ // instead of an instant snap. Matches the session list's Crossfade pattern; reduced
270+ // motion is honored by Crossfade's default spec.
271+ val stateKey = if (reports.isEmpty()) "empty" else "list"
272+ @Suppress("UnusedCrossfadeTargetStateParameter")
273+ Crossfade(
274+ targetState = stateKey,
275+ animationSpec = tween(NetworkConfig.motionFadeDurationMs.toInt()),
276+ label = "diagnostics_state",
277+ ) {
278+ if (reports.isEmpty()) {
265279 EmptyState(
266280 icon = Icons.Filled.BugReport,
267281 title = stringResource(R.string.no_crash_reports),
268282 description = stringResource(R.string.no_crash_reports_desc),
283+ actionLabel = stringResource(R.string.report_issue),
284+ onAction = {
285+ runCatching {
286+ context.startActivity(
287+ Intent(Intent.ACTION_VIEW, android.net.Uri.parse(context.getString(R.string.url_report_issue))),
288+ )
289+ }
290+ },
269291 modifier = Modifier.fillMaxWidth().padding(24.dp),
270292 )
271293 } else {
@@ -326,12 +348,23 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
326348 tint = MaterialTheme.colorScheme.error,
327349 )
328350 Column(modifier = Modifier.weight(1f)) {
329− Text(report.preview, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
351+ Text(report.preview, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis)
330352 Text(
331353 rememberRelativeTime(report.timestamp),
332354 style = MaterialTheme.typography.bodySmall,
333355 color = MaterialTheme.colorScheme.onSurfaceVariant,
334356 )
357+ // The filename often encodes the exception class (the
358+ // CrashLogger names reports "<ExceptionClass>_<ts>.txt"),
359+ // so showing it muted under the relative time lets a user
360+ // scan for a specific exception type without opening each.
361+ Text(
362+ report.fileName,
363+ style = MaterialTheme.typography.labelSmall,
364+ color = MaterialTheme.colorScheme.onSurfaceVariant,
365+ maxLines = 1,
366+ overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
367+ )
335368 }
336369 IconButton(onClick = {
337370 shareScope.launch {
@@ -385,6 +418,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
385418 }
386419 }
387420 }
421+ }
388422 }
389423 }
390424 }
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+954−599
@@ -22,12 +22,15 @@ import androidx.compose.foundation.layout.wrapContentWidth
2222import androidx.compose.foundation.rememberScrollState
2323import androidx.compose.foundation.selection.selectable
2424import androidx.compose.foundation.selection.toggleable
25+import androidx.compose.foundation.text.KeyboardActions
26+import androidx.compose.foundation.text.KeyboardOptions
2527import androidx.compose.foundation.verticalScroll
2628import androidx.compose.foundation.shape.CircleShape
2729import androidx.compose.material.icons.Icons
2830import androidx.compose.material.icons.automirrored.filled.ArrowBack
2931import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
3032import androidx.compose.material.icons.filled.BugReport
33+import androidx.compose.material.icons.filled.Clear
3134import androidx.compose.material.icons.filled.Code
3235import androidx.compose.material.icons.filled.Dns
3336import androidx.compose.material.icons.filled.Download
@@ -35,6 +38,7 @@ import androidx.compose.material.icons.filled.Feedback
3538import androidx.compose.material.icons.filled.Hub
3639import androidx.compose.material.icons.filled.Notifications
3740import androidx.compose.material.icons.filled.QueryStats
41+import androidx.compose.material.icons.filled.Search
3842import androidx.compose.material.icons.filled.Upload
3943import androidx.compose.material3.AlertDialog
4044import androidx.compose.material3.ExperimentalMaterial3Api
@@ -78,12 +82,15 @@ import androidx.compose.ui.graphics.Color
7882import androidx.compose.ui.input.nestedscroll.nestedScroll
7983import androidx.compose.ui.platform.LocalContext
8084import androidx.compose.ui.platform.LocalDensity
85+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
86+import androidx.compose.ui.platform.testTag
8187import androidx.compose.ui.res.pluralStringResource
8288import androidx.compose.ui.res.stringResource
8389import androidx.compose.ui.semantics.Role
8490import androidx.compose.ui.semantics.heading
8591import androidx.compose.ui.semantics.role
8692import androidx.compose.ui.semantics.semantics
93+import androidx.compose.ui.text.input.ImeAction
8794import androidx.compose.ui.text.style.TextAlign
8895import androidx.compose.ui.unit.dp
8996import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -202,6 +209,9 @@ fun SettingsScreen(
202209 // import reads a chosen file and applies it. Feedback goes through the snackbar host so it's
203210 // accessible to TalkBack and consistent with the rest of the app (Toast isn't).
204211 var includePasswords by rememberSaveable { mutableStateOf(false) }
212+ // Settings search/filter query. rememberSaveable so the filter survives config changes
213+ // (rotation, theme switch) — the same reason the backup toggle below is saved.
214+ var settingsQuery by rememberSaveable { mutableStateOf("") }
205215 // True while an export or import is in flight. Used to disable both NavRow triggers so a
206216 // double-tap (or a slow device) can't launch a second file picker or start a second
207217 // operation while the first is still reading/writing. Mirrors the busy-flag pattern used
@@ -283,698 +293,1003 @@ fun SettingsScreen(
283293 .padding(16.dp),
284294 ) {
285295 val s = settings
286− Text(
287− stringResource(R.string.appearance),
288− style = MaterialTheme.typography.titleSmall,
289− color = MaterialTheme.colorScheme.primary,
290− modifier = Modifier.semantics { heading() },
296+ // Resolve section-header strings once so the filter logic below can match
297+ // against them without re-resolving per recomposition. These mirror the
298+ // Text() headers rendered further down.
299+ val appearanceHeader = stringResource(R.string.appearance)
300+ val textDisplayHeader = stringResource(R.string.text_display)
301+ val motionHeader = stringResource(R.string.settings_motion)
302+ val notificationsHeader = stringResource(R.string.settings_notifications)
303+ val swipeActionsHeader = stringResource(R.string.settings_swipe_actions)
304+ val connectionHeader = stringResource(R.string.connection)
305+ val backupHeader = stringResource(R.string.backup_restore)
306+ val aboutHeader = stringResource(R.string.about)
307+ // Row label/description strings used for filtering. Kept in the same order as the
308+ // rows appear, so a future editor can line them up with the rows below.
309+ val dynamicColorLabel = stringResource(R.string.dynamic_color)
310+ val dynamicColorDesc = stringResource(R.string.dynamic_color_desc)
311+ val sendOnEnterLabel = stringResource(R.string.send_on_enter)
312+ val sendOnEnterDesc = stringResource(R.string.send_on_enter_desc)
313+ val appLockLabel = stringResource(R.string.app_lock)
314+ val appLockDesc = stringResource(R.string.app_lock_desc)
315+ val wrapCodeLabel = stringResource(R.string.wrap_code_blocks)
316+ val wrapCodeDesc = stringResource(R.string.wrap_code_blocks_desc)
317+ val compactSpacingLabel = stringResource(R.string.compact_message_spacing)
318+ val compactSpacingDesc = stringResource(R.string.compact_message_spacing_desc)
319+ val hapticsLabel = stringResource(R.string.settings_haptics)
320+ val hapticsDesc = stringResource(R.string.settings_haptics_desc)
321+ val reducedMotionLabel = stringResource(R.string.settings_reduced_motion)
322+ val reducedMotionDesc = stringResource(R.string.settings_reduced_motion_desc)
323+ val languageLabel = stringResource(R.string.settings_language)
324+ val languageDesc = stringResource(R.string.settings_language_desc)
325+ val notifRunCompleteLabel = stringResource(R.string.settings_notif_run_complete)
326+ val notifRunCompleteDesc = stringResource(R.string.settings_notif_run_complete_desc)
327+ val notifPermissionLabel = stringResource(R.string.settings_notif_permission)
328+ val notifPermissionDesc = stringResource(R.string.settings_notif_permission_desc)
329+ val notifErrorLabel = stringResource(R.string.settings_notif_error)
330+ val notifErrorDesc = stringResource(R.string.settings_notif_error_desc)
331+ val swipeLeftLabel = stringResource(R.string.settings_swipe_left)
332+ val swipeRightLabel = stringResource(R.string.settings_swipe_right)
333+ val manageServersLabel = stringResource(R.string.manage_servers)
334+ val usageLabel = stringResource(R.string.usage_title)
335+ val mcpLabel = stringResource(R.string.mcp_servers)
336+ val notifSettingsLabel = stringResource(R.string.notification_settings)
337+ val backupIncludePasswordsLabel = stringResource(R.string.backup_include_passwords)
338+ val backupIncludePasswordsDesc = stringResource(R.string.backup_include_passwords_desc)
339+ val exportBackupLabel = stringResource(R.string.export_backup)
340+ val importBackupLabel = stringResource(R.string.import_backup)
341+ val diagnosticsLabel = stringResource(R.string.diagnostics)
342+ val sourceCodeLabel = stringResource(R.string.source_code)
343+ val reportIssueLabel = stringResource(R.string.report_issue)
344+ val chatTextSizeDesc = stringResource(R.string.chat_text_size_desc)
345+ val backupDescText = stringResource(R.string.backup_desc)
346+ val aboutDescText = stringResource(R.string.about_desc)
347+
348+ // Per-section filter state. For each section we compute:
349+ // - headerMatches: the section header itself matches the query
350+ // - anyRowMatches: at least one row in the section matches
351+ // A section is shown when either is true (or when the query is empty).
352+ // A row is shown when the header matches alone (browse the whole matched section),
353+ // or when the row itself matches.
354+ val appearanceHeaderMatches = matchesQuery(settingsQuery, appearanceHeader)
355+ val appearanceAnyRowMatches = matchesQuery(
356+ settingsQuery,
357+ dynamicColorLabel, dynamicColorDesc,
358+ sendOnEnterLabel, sendOnEnterDesc,
359+ appLockLabel, appLockDesc,
291360 )
292− if (s != null) {
293− ThemeMode.entries.forEach { mode ->
294− ThemeRow(
295− mode = mode,
296− selected = s.themeMode == mode,
297− // When Material You is active the static Tokyo Night swatches would be a
298− // lie, so ThemeRow hides them and notes that system colors are in use.
299− usingSystemColors = s.dynamicColor && dynamicColorAvailable,
300− onSelect = { scope.launch { runCatchingCancellable { container.settingsStore.setThemeMode(mode) } } },
301− )
302− }
361+ val showAppearance = appearanceHeaderMatches || appearanceAnyRowMatches
362+ val textDisplayHeaderMatches = matchesQuery(settingsQuery, textDisplayHeader)
363+ val textDisplayAnyRowMatches = matchesQuery(
364+ settingsQuery,
365+ wrapCodeLabel, wrapCodeDesc,
366+ compactSpacingLabel, compactSpacingDesc,
367+ chatTextSizeDesc,
368+ )
369+ val showTextDisplay = textDisplayHeaderMatches || textDisplayAnyRowMatches
370+ val motionHeaderMatches = matchesQuery(settingsQuery, motionHeader)
371+ val motionAnyRowMatches = matchesQuery(
372+ settingsQuery,
373+ hapticsLabel, hapticsDesc,
374+ reducedMotionLabel, reducedMotionDesc,
375+ languageLabel, languageDesc,
376+ )
377+ val showMotion = motionHeaderMatches || motionAnyRowMatches
378+ val notificationsHeaderMatches = matchesQuery(settingsQuery, notificationsHeader)
379+ val notificationsAnyRowMatches = matchesQuery(
380+ settingsQuery,
381+ notifRunCompleteLabel, notifRunCompleteDesc,
382+ notifPermissionLabel, notifPermissionDesc,
383+ notifErrorLabel, notifErrorDesc,
384+ )
385+ val showNotifications = notificationsHeaderMatches || notificationsAnyRowMatches
386+ val swipeHeaderMatches = matchesQuery(settingsQuery, swipeActionsHeader)
387+ val swipeAnyRowMatches = matchesQuery(
388+ settingsQuery,
389+ swipeLeftLabel,
390+ swipeRightLabel,
391+ )
392+ val showSwipeActions = swipeHeaderMatches || swipeAnyRowMatches
393+ val connectionHeaderMatches = matchesQuery(settingsQuery, connectionHeader)
394+ val connectionAnyRowMatches = matchesQuery(
395+ settingsQuery,
396+ manageServersLabel,
397+ usageLabel,
398+ mcpLabel,
399+ notifSettingsLabel,
400+ )
401+ val showConnection = connectionHeaderMatches || connectionAnyRowMatches
402+ val backupHeaderMatches = matchesQuery(settingsQuery, backupHeader)
403+ val backupAnyRowMatches = matchesQuery(
404+ settingsQuery,
405+ backupDescText,
406+ backupIncludePasswordsLabel, backupIncludePasswordsDesc,
407+ exportBackupLabel,
408+ importBackupLabel,
409+ )
410+ val showBackup = backupHeaderMatches || backupAnyRowMatches
411+ val aboutHeaderMatches = matchesQuery(settingsQuery, aboutHeader)
412+ val aboutAnyRowMatches = matchesQuery(
413+ settingsQuery,
414+ aboutDescText,
415+ diagnosticsLabel,
416+ sourceCodeLabel,
417+ reportIssueLabel,
418+ )
419+ val showAbout = aboutHeaderMatches || aboutAnyRowMatches
303420
304− Spacer(Modifier.size(8.dp))
305− // Dynamic color (Material You) only works on Android 12+, so hide the toggle
306− // on older devices instead of offering a control that silently does nothing.
307− if (dynamicColorAvailable) {
308− val amoledActive = s.themeMode == ThemeMode.AMOLED
309− Row(
310− modifier = Modifier
311− .fillMaxWidth()
312− .defaultMinSize(minHeight = 48.dp)
313− .toggleable(
314− value = s.dynamicColor,
315− enabled = !amoledActive,
316− onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
317− role = Role.Switch,
421+ val keyboardController = LocalSoftwareKeyboardController.current
422+ OutlinedTextField(
423+ value = settingsQuery,
424+ onValueChange = { settingsQuery = it },
425+ modifier = Modifier
426+ .fillMaxWidth()
427+ .padding(bottom = 8.dp)
428+ .testTag("settings_search"),
429+ label = { Text(stringResource(R.string.search_settings)) },
430+ placeholder = { Text(stringResource(R.string.search_settings)) },
431+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
432+ trailingIcon = if (settingsQuery.isNotEmpty()) {
433+ {
434+ IconButton(onClick = { settingsQuery = "" }) {
435+ Icon(Icons.Filled.Clear, contentDescription = stringResource(R.string.clear_search))
436+ }
437+ }
438+ } else null,
439+ singleLine = true,
440+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
441+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
442+ )
443+
444+ if (showAppearance) {
445+ Text(
446+ appearanceHeader,
447+ style = MaterialTheme.typography.titleSmall,
448+ color = MaterialTheme.colorScheme.primary,
449+ modifier = Modifier.semantics { heading() },
450+ )
451+ if (s != null) {
452+ ThemeMode.entries.forEach { mode ->
453+ // Theme rows have no description text; match against the mode label only.
454+ // ThemeRow renders the label internally via stringResource, so resolve it
455+ // here for the filter check.
456+ val modeLabel = when (mode) {
457+ ThemeMode.SYSTEM -> stringResource(R.string.theme_system)
458+ ThemeMode.LIGHT -> stringResource(R.string.theme_light)
459+ ThemeMode.DARK -> stringResource(R.string.theme_dark)
460+ ThemeMode.AMOLED -> stringResource(R.string.theme_amoled)
461+ }
462+ if (rowVisible(settingsQuery, appearanceHeaderMatches, appearanceAnyRowMatches, modeLabel)) {
463+ ThemeRow(
464+ mode = mode,
465+ selected = s.themeMode == mode,
466+ // When Material You is active the static Tokyo Night swatches would be a
467+ // lie, so ThemeRow hides them and notes that system colors are in use.
468+ usingSystemColors = s.dynamicColor && dynamicColorAvailable,
469+ onSelect = { scope.launch { runCatchingCancellable { container.settingsStore.setThemeMode(mode) } } },
318470 )
319− .padding(vertical = 8.dp),
320− verticalAlignment = Alignment.CenterVertically,
471+ }
472+ }
473+
474+ if (dynamicColorAvailable && rowVisible(
475+ settingsQuery,
476+ appearanceHeaderMatches,
477+ appearanceAnyRowMatches,
478+ dynamicColorLabel, dynamicColorDesc,
479+ )
321480 ) {
322− Column(modifier = Modifier.weight(1f)) {
323− Text(stringResource(R.string.dynamic_color), style = MaterialTheme.typography.bodyLarge)
324− Text(
325− stringResource(R.string.dynamic_color_desc),
326− style = MaterialTheme.typography.bodySmall,
327− color = MaterialTheme.colorScheme.onSurfaceVariant,
328− )
329− if (amoledActive) {
481+ Spacer(Modifier.size(8.dp))
482+ // Dynamic color (Material You) only works on Android 12+, so hide the toggle
483+ // on older devices instead of offering a control that silently does nothing.
484+ val amoledActive = s.themeMode == ThemeMode.AMOLED
485+ Row(
486+ modifier = Modifier
487+ .fillMaxWidth()
488+ .defaultMinSize(minHeight = 48.dp)
489+ .toggleable(
490+ value = s.dynamicColor,
491+ enabled = !amoledActive,
492+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
493+ role = Role.Switch,
494+ )
495+ .padding(vertical = 8.dp),
496+ verticalAlignment = Alignment.CenterVertically,
497+ ) {
498+ Column(modifier = Modifier.weight(1f)) {
499+ Text(dynamicColorLabel, style = MaterialTheme.typography.bodyLarge)
330500 Text(
331− stringResource(R.string.dynamic_color_disabled_amoled),
501+ dynamicColorDesc,
332502 style = MaterialTheme.typography.bodySmall,
333503 color = MaterialTheme.colorScheme.onSurfaceVariant,
334504 )
505+ if (amoledActive) {
506+ Text(
507+ stringResource(R.string.dynamic_color_disabled_amoled),
508+ style = MaterialTheme.typography.bodySmall,
509+ color = MaterialTheme.colorScheme.onSurfaceVariant,
510+ )
511+ }
335512 }
513+ Switch(
514+ checked = s.dynamicColor,
515+ onCheckedChange = null,
516+ enabled = !amoledActive,
517+ )
336518 }
337− Switch(
338− checked = s.dynamicColor,
339− onCheckedChange = null,
340− enabled = !amoledActive,
341− )
342519 }
343− }
344520
345− // Send-on-Enter: controls hardware-keyboard Enter behavior in the chat input.
346− Spacer(Modifier.size(4.dp))
347− Row(
348− modifier = Modifier
349− .fillMaxWidth()
350− .defaultMinSize(minHeight = 48.dp)
351− .toggleable(
352− value = s.sendOnEnter,
353− onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setSendOnEnter(it) } } },
354− role = Role.Switch,
355− )
356− .padding(vertical = 8.dp),
357− verticalAlignment = Alignment.CenterVertically,
358− ) {
359− Column(modifier = Modifier.weight(1f)) {
360− Text(stringResource(R.string.send_on_enter), style = MaterialTheme.typography.bodyLarge)
361− Text(
362− stringResource(R.string.send_on_enter_desc),
363− style = MaterialTheme.typography.bodySmall,
364− color = MaterialTheme.colorScheme.onSurfaceVariant,
521+ // Send-on-Enter: controls hardware-keyboard Enter behavior in the chat input.
522+ if (rowVisible(
523+ settingsQuery,
524+ appearanceHeaderMatches,
525+ appearanceAnyRowMatches,
526+ sendOnEnterLabel, sendOnEnterDesc,
365527 )
528+ ) {
529+ Spacer(Modifier.size(4.dp))
530+ Row(
531+ modifier = Modifier
532+ .fillMaxWidth()
533+ .defaultMinSize(minHeight = 48.dp)
534+ .toggleable(
535+ value = s.sendOnEnter,
536+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setSendOnEnter(it) } } },
537+ role = Role.Switch,
538+ )
539+ .padding(vertical = 8.dp),
540+ verticalAlignment = Alignment.CenterVertically,
541+ ) {
542+ Column(modifier = Modifier.weight(1f)) {
543+ Text(sendOnEnterLabel, style = MaterialTheme.typography.bodyLarge)
544+ Text(
545+ sendOnEnterDesc,
546+ style = MaterialTheme.typography.bodySmall,
547+ color = MaterialTheme.colorScheme.onSurfaceVariant,
548+ )
549+ }
550+ Switch(
551+ checked = s.sendOnEnter,
552+ onCheckedChange = null,
553+ )
554+ }
366555 }
367− Switch(
368− checked = s.sendOnEnter,
369− onCheckedChange = null,
370− )
371− }
372556
373− // App lock: gate on device capability. Always allow turning it *off* (in case
374− // the enrolled biometric was later removed), but only allow turning it on when
375− // a biometric or device credential is actually available.
376− val appLockAvailable = remember(biometricCheckTick) { canAuthenticateForAppLock(context) }
377− val appLockToggleable = appLockAvailable || s.appLock
378− Spacer(Modifier.size(4.dp))
379− Row(
380− modifier = Modifier
381− .fillMaxWidth()
382− .defaultMinSize(minHeight = 48.dp)
383− .toggleable(
384− value = s.appLock,
385− enabled = appLockToggleable,
386− onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setAppLock(it) } } },
387− role = Role.Switch,
388− )
389− .padding(vertical = 8.dp),
390− verticalAlignment = Alignment.CenterVertically,
391− ) {
392− Column(modifier = Modifier.weight(1f)) {
393− Text(stringResource(R.string.app_lock), style = MaterialTheme.typography.bodyLarge)
394− Text(
395− stringResource(if (appLockToggleable) R.string.app_lock_desc else R.string.app_lock_unavailable),
396− style = MaterialTheme.typography.bodySmall,
397− color = MaterialTheme.colorScheme.onSurfaceVariant,
557+ // App lock: gate on device capability. Always allow turning it *off* (in case
558+ // the enrolled biometric was later removed), but only allow turning it on when
559+ // a biometric or device credential is actually available.
560+ if (rowVisible(
561+ settingsQuery,
562+ appearanceHeaderMatches,
563+ appearanceAnyRowMatches,
564+ appLockLabel, appLockDesc,
398565 )
566+ ) {
567+ val appLockAvailable = remember(biometricCheckTick) { canAuthenticateForAppLock(context) }
568+ val appLockToggleable = appLockAvailable || s.appLock
569+ Spacer(Modifier.size(4.dp))
570+ Row(
571+ modifier = Modifier
572+ .fillMaxWidth()
573+ .defaultMinSize(minHeight = 48.dp)
574+ .toggleable(
575+ value = s.appLock,
576+ enabled = appLockToggleable,
577+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setAppLock(it) } } },
578+ role = Role.Switch,
579+ )
580+ .padding(vertical = 8.dp),
581+ verticalAlignment = Alignment.CenterVertically,
582+ ) {
583+ Column(modifier = Modifier.weight(1f)) {
584+ Text(appLockLabel, style = MaterialTheme.typography.bodyLarge)
585+ Text(
586+ stringResource(if (appLockToggleable) R.string.app_lock_desc else R.string.app_lock_unavailable),
587+ style = MaterialTheme.typography.bodySmall,
588+ color = MaterialTheme.colorScheme.onSurfaceVariant,
589+ )
590+ }
591+ Switch(
592+ checked = s.appLock,
593+ onCheckedChange = null,
594+ enabled = appLockToggleable,
595+ )
596+ }
597+ if (s.appLock) {
598+ AppLockReLockSelector(
599+ seconds = appLockReLockSeconds,
600+ onSelect = { sec ->
601+ scope.launch { runCatchingCancellable { container.settingsStore.setAppLockReLockSeconds(sec) } }
602+ },
603+ )
604+ }
605+ }
606+ } else {
607+ Box(
608+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
609+ contentAlignment = Alignment.Center,
610+ ) {
611+ CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp)
399612 }
400− Switch(
401− checked = s.appLock,
402− onCheckedChange = null,
403− enabled = appLockToggleable,
404− )
405− }
406− if (s.appLock) {
407− AppLockReLockSelector(
408− seconds = appLockReLockSeconds,
409− onSelect = { sec ->
410− scope.launch { runCatchingCancellable { container.settingsStore.setAppLockReLockSeconds(sec) } }
411− },
412− )
413− }
414− } else {
415− Box(
416− modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
417− contentAlignment = Alignment.Center,
418− ) {
419− CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp)
420613 }
421614 }
422615
423− HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
616+ if (showAppearance) {
617+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
618+ }
424619
425− Text(
426− stringResource(R.string.text_display),
427− style = MaterialTheme.typography.titleSmall,
428− color = MaterialTheme.colorScheme.primary,
429− modifier = Modifier.semantics { heading() },
430− )
431− ChatTextSizeControl(
432− scale = chatTextScale,
433− onScaleChange = { scope.launch { runCatchingCancellable { container.settingsStore.setChatTextScale(it) } } },
434− )
435− Spacer(Modifier.size(4.dp))
436− Row(
437− modifier = Modifier
438− .fillMaxWidth()
439− .defaultMinSize(minHeight = 48.dp)
440− .toggleable(
441− value = codeWrap,
442− onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setCodeWrap(it) } } },
443− role = Role.Switch,
620+ if (showTextDisplay) {
621+ Text(
622+ textDisplayHeader,
623+ style = MaterialTheme.typography.titleSmall,
624+ color = MaterialTheme.colorScheme.primary,
625+ modifier = Modifier.semantics { heading() },
626+ )
627+ if (rowVisible(settingsQuery, textDisplayHeaderMatches, textDisplayAnyRowMatches, chatTextSizeDesc)) {
628+ ChatTextSizeControl(
629+ scale = chatTextScale,
630+ onScaleChange = { scope.launch { runCatchingCancellable { container.settingsStore.setChatTextScale(it) } } },
444631 )
445− .padding(vertical = 8.dp),
446− verticalAlignment = Alignment.CenterVertically,
447− ) {
448− Column(modifier = Modifier.weight(1f)) {
449− Text(stringResource(R.string.wrap_code_blocks), style = MaterialTheme.typography.bodyLarge)
450− Text(
451− stringResource(R.string.wrap_code_blocks_desc),
452− style = MaterialTheme.typography.bodySmall,
453− color = MaterialTheme.colorScheme.onSurfaceVariant,
632+ }
633+ if (rowVisible(
634+ settingsQuery,
635+ textDisplayHeaderMatches,
636+ textDisplayAnyRowMatches,
637+ wrapCodeLabel, wrapCodeDesc,
454638 )
639+ ) {
640+ Spacer(Modifier.size(4.dp))
641+ Row(
642+ modifier = Modifier
643+ .fillMaxWidth()
644+ .defaultMinSize(minHeight = 48.dp)
645+ .toggleable(
646+ value = codeWrap,
647+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setCodeWrap(it) } } },
648+ role = Role.Switch,
649+ )
650+ .padding(vertical = 8.dp),
651+ verticalAlignment = Alignment.CenterVertically,
652+ ) {
653+ Column(modifier = Modifier.weight(1f)) {
654+ Text(wrapCodeLabel, style = MaterialTheme.typography.bodyLarge)
655+ Text(
656+ wrapCodeDesc,
657+ style = MaterialTheme.typography.bodySmall,
658+ color = MaterialTheme.colorScheme.onSurfaceVariant,
659+ )
660+ }
661+ Switch(checked = codeWrap, onCheckedChange = null)
662+ }
455663 }
456− Switch(checked = codeWrap, onCheckedChange = null)
457− }
458664
459− // Compact message spacing: a power-user toggle to fit more conversation on screen.
460− Spacer(Modifier.size(4.dp))
461− Row(
462− modifier = Modifier
463− .fillMaxWidth()
464− .defaultMinSize(minHeight = 48.dp)
465− .toggleable(
466− value = compactSpacing,
467− onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setCompactMessageSpacing(it) } } },
468− role = Role.Switch,
469− )
470− .padding(vertical = 8.dp),
471− verticalAlignment = Alignment.CenterVertically,
472− ) {
473− Column(modifier = Modifier.weight(1f)) {
474− Text(stringResource(R.string.compact_message_spacing), style = MaterialTheme.typography.bodyLarge)
475− Text(
476− stringResource(R.string.compact_message_spacing_desc),
477− style = MaterialTheme.typography.bodySmall,
478− color = MaterialTheme.colorScheme.onSurfaceVariant,
665+ // Compact message spacing: a power-user toggle to fit more conversation on screen.
666+ if (rowVisible(
667+ settingsQuery,
668+ textDisplayHeaderMatches,
669+ textDisplayAnyRowMatches,
670+ compactSpacingLabel, compactSpacingDesc,
479671 )
672+ ) {
673+ Spacer(Modifier.size(4.dp))
674+ Row(
675+ modifier = Modifier
676+ .fillMaxWidth()
677+ .defaultMinSize(minHeight = 48.dp)
678+ .toggleable(
679+ value = compactSpacing,
680+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setCompactMessageSpacing(it) } } },
681+ role = Role.Switch,
682+ )
683+ .padding(vertical = 8.dp),
684+ verticalAlignment = Alignment.CenterVertically,
685+ ) {
686+ Column(modifier = Modifier.weight(1f)) {
687+ Text(compactSpacingLabel, style = MaterialTheme.typography.bodyLarge)
688+ Text(
689+ compactSpacingDesc,
690+ style = MaterialTheme.typography.bodySmall,
691+ color = MaterialTheme.colorScheme.onSurfaceVariant,
692+ )
693+ }
694+ Switch(checked = compactSpacing, onCheckedChange = null)
695+ }
480696 }
481− Switch(checked = compactSpacing, onCheckedChange = null)
482697 }
483698
484− HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
699+ if (showTextDisplay) {
700+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
701+ }
485702
486703 // Motion & feedback: in-app toggles for haptics and reduced motion. The app
487704 // already honors the system Developer Options animator scale for reduced motion,
488705 // but this gives users an in-app control without disabling motion OS-wide.
489706 // Haptics default on (the existing behavior); an in-app toggle lets users
490707 // disable them without turning off system vibration.
491− Text(
492− stringResource(R.string.settings_motion),
493− style = MaterialTheme.typography.titleSmall,
494− color = MaterialTheme.colorScheme.primary,
495− modifier = Modifier.semantics { heading() },
496− )
497− val hapticsScope = rememberCoroutineScope()
498− Row(
499− modifier = Modifier
500− .fillMaxWidth()
501− .defaultMinSize(minHeight = 48.dp)
502− .clickable(role = Role.Switch) {
503− hapticsScope.launch {
504− container.settingsStore.setHapticsEnabled(!hapticsEnabled)
708+ if (showMotion) {
709+ Text(
710+ motionHeader,
711+ style = MaterialTheme.typography.titleSmall,
712+ color = MaterialTheme.colorScheme.primary,
713+ modifier = Modifier.semantics { heading() },
714+ )
715+ if (rowVisible(settingsQuery, motionHeaderMatches, motionAnyRowMatches, hapticsLabel, hapticsDesc)) {
716+ val hapticsScope = rememberCoroutineScope()
717+ Row(
718+ modifier = Modifier
719+ .fillMaxWidth()
720+ .defaultMinSize(minHeight = 48.dp)
721+ .clickable(role = Role.Switch) {
722+ hapticsScope.launch {
723+ container.settingsStore.setHapticsEnabled(!hapticsEnabled)
724+ }
725+ }
726+ .padding(vertical = 8.dp),
727+ verticalAlignment = Alignment.CenterVertically,
728+ ) {
729+ Column(modifier = Modifier.weight(1f)) {
730+ Text(hapticsLabel)
731+ Text(
732+ hapticsDesc,
733+ style = MaterialTheme.typography.bodySmall,
734+ color = MaterialTheme.colorScheme.onSurfaceVariant,
735+ )
505736 }
737+ Switch(checked = hapticsEnabled, onCheckedChange = null)
506738 }
507− .padding(vertical = 8.dp),
508− verticalAlignment = Alignment.CenterVertically,
509− ) {
510− Column(modifier = Modifier.weight(1f)) {
511− Text(stringResource(R.string.settings_haptics))
512− Text(
513− stringResource(R.string.settings_haptics_desc),
514− style = MaterialTheme.typography.bodySmall,
515− color = MaterialTheme.colorScheme.onSurfaceVariant,
516− )
517739 }
518− Switch(checked = hapticsEnabled, onCheckedChange = null)
519− }
520− val motionScope = rememberCoroutineScope()
521− Row(
522− modifier = Modifier
523− .fillMaxWidth()
524− .defaultMinSize(minHeight = 48.dp)
525− .clickable(role = Role.Switch) {
526− motionScope.launch {
527− container.settingsStore.setReducedMotion(!reducedMotion)
740+ if (rowVisible(
741+ settingsQuery,
742+ motionHeaderMatches,
743+ motionAnyRowMatches,
744+ reducedMotionLabel, reducedMotionDesc,
745+ )
746+ ) {
747+ val motionScope = rememberCoroutineScope()
748+ Row(
749+ modifier = Modifier
750+ .fillMaxWidth()
751+ .defaultMinSize(minHeight = 48.dp)
752+ .clickable(role = Role.Switch) {
753+ motionScope.launch {
754+ container.settingsStore.setReducedMotion(!reducedMotion)
755+ }
756+ }
757+ .padding(vertical = 8.dp),
758+ verticalAlignment = Alignment.CenterVertically,
759+ ) {
760+ Column(modifier = Modifier.weight(1f)) {
761+ Text(reducedMotionLabel)
762+ Text(
763+ reducedMotionDesc,
764+ style = MaterialTheme.typography.bodySmall,
765+ color = MaterialTheme.colorScheme.onSurfaceVariant,
766+ )
528767 }
768+ Switch(checked = reducedMotion, onCheckedChange = null)
529769 }
530− .padding(vertical = 8.dp),
531− verticalAlignment = Alignment.CenterVertically,
532− ) {
533− Column(modifier = Modifier.weight(1f)) {
534− Text(stringResource(R.string.settings_reduced_motion))
535− Text(
536− stringResource(R.string.settings_reduced_motion_desc),
537− style = MaterialTheme.typography.bodySmall,
538− color = MaterialTheme.colorScheme.onSurfaceVariant,
539− )
540770 }
541− Switch(checked = reducedMotion, onCheckedChange = null)
542− }
543− // Language override: an in-app language picker so a user can use the app in a
544− // different language without changing the system locale. Uses Android 13's
545− // per-app language API; on older versions a restart is needed.
546− val langScope = rememberCoroutineScope()
547− Row(
548− modifier = Modifier
549− .fillMaxWidth()
550− .defaultMinSize(minHeight = 48.dp)
551− .padding(vertical = 8.dp),
552− verticalAlignment = Alignment.CenterVertically,
553− ) {
554− Column(modifier = Modifier.weight(1f)) {
555− Text(stringResource(R.string.settings_language))
556− Text(
557− if (languageOverride.isEmpty()) stringResource(R.string.settings_language_desc)
558− else languageOverride,
559− style = MaterialTheme.typography.bodySmall,
560− color = MaterialTheme.colorScheme.onSurfaceVariant,
561− )
562− }
563− val langOptions = remember {
564− listOf("" to R.string.settings_language_system, "en" to R.string.app_name, "es" to R.string.app_name)
565− }
566− var langMenu by remember { mutableStateOf(false) }
567− Box {
568− TextButton(onClick = { langMenu = true }) {
569− Text(if (languageOverride.isEmpty()) stringResource(R.string.settings_language_system) else languageOverride)
570− }
571− DropdownMenu(expanded = langMenu, onDismissRequest = { langMenu = false }) {
572− langOptions.forEach { (code, _) ->
573− DropdownMenuItem(
574− text = { Text(if (code.isEmpty()) stringResource(R.string.settings_language_system) else code) },
575− onClick = {
576− langMenu = false
577− langScope.launch {
578− container.settingsStore.setLanguageOverride(code)
579− }
580− },
771+ // Language override: an in-app language picker so a user can use the app in a
772+ // different language without changing the system locale. Uses Android 13's
773+ // per-app language API; on older versions a restart is needed.
774+ if (rowVisible(settingsQuery, motionHeaderMatches, motionAnyRowMatches, languageLabel, languageDesc)) {
775+ val langScope = rememberCoroutineScope()
776+ Row(
777+ modifier = Modifier
778+ .fillMaxWidth()
779+ .defaultMinSize(minHeight = 48.dp)
780+ .padding(vertical = 8.dp),
781+ verticalAlignment = Alignment.CenterVertically,
782+ ) {
783+ Column(modifier = Modifier.weight(1f)) {
784+ Text(languageLabel)
785+ Text(
786+ if (languageOverride.isEmpty()) languageDesc
787+ else languageOverride,
788+ style = MaterialTheme.typography.bodySmall,
789+ color = MaterialTheme.colorScheme.onSurfaceVariant,
581790 )
582791 }
792+ val langOptions = remember {
793+ listOf("" to R.string.settings_language_system, "en" to R.string.app_name, "es" to R.string.app_name)
794+ }
795+ var langMenu by remember { mutableStateOf(false) }
796+ Box {
797+ TextButton(onClick = { langMenu = true }) {
798+ Text(if (languageOverride.isEmpty()) stringResource(R.string.settings_language_system) else languageOverride)
799+ }
800+ DropdownMenu(expanded = langMenu, onDismissRequest = { langMenu = false }) {
801+ langOptions.forEach { (code, _) ->
802+ DropdownMenuItem(
803+ text = { Text(if (code.isEmpty()) stringResource(R.string.settings_language_system) else code) },
804+ onClick = {
805+ langMenu = false
806+ langScope.launch {
807+ container.settingsStore.setLanguageOverride(code)
808+ }
809+ },
810+ )
811+ }
812+ }
813+ }
583814 }
584815 }
585816 }
586817
587− HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
818+ if (showMotion) {
819+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
820+ }
588821
589822 // Notifications: granular toggles for each notification type, so a user can
590823 // mute specific types in-app (in addition to the OS channel controls). Each
591824 // toggle gates whether the app posts that notification type at all.
592− Text(
593− stringResource(R.string.settings_notifications),
594− style = MaterialTheme.typography.titleSmall,
595− color = MaterialTheme.colorScheme.primary,
596− modifier = Modifier.semantics { heading() },
597− )
598− val notifScope = rememberCoroutineScope()
599− Row(
600− modifier = Modifier
601− .fillMaxWidth()
602− .defaultMinSize(minHeight = 48.dp)
603− .clickable(role = Role.Switch) {
604− notifScope.launch { container.settingsStore.setNotifRunComplete(!notifRunComplete) }
605− }
606− .padding(vertical = 8.dp),
607− verticalAlignment = Alignment.CenterVertically,
608− ) {
609− Column(modifier = Modifier.weight(1f)) {
610− Text(stringResource(R.string.settings_notif_run_complete))
611− Text(
612− stringResource(R.string.settings_notif_run_complete_desc),
613− style = MaterialTheme.typography.bodySmall,
614− color = MaterialTheme.colorScheme.onSurfaceVariant,
825+ if (showNotifications) {
826+ Text(
827+ notificationsHeader,
828+ style = MaterialTheme.typography.titleSmall,
829+ color = MaterialTheme.colorScheme.primary,
830+ modifier = Modifier.semantics { heading() },
831+ )
832+ if (rowVisible(
833+ settingsQuery,
834+ notificationsHeaderMatches,
835+ notificationsAnyRowMatches,
836+ notifRunCompleteLabel, notifRunCompleteDesc,
615837 )
616− }
617− Switch(checked = notifRunComplete, onCheckedChange = null)
618− }
619− Row(
620− modifier = Modifier
621− .fillMaxWidth()
622− .defaultMinSize(minHeight = 48.dp)
623− .clickable(role = Role.Switch) {
624− notifScope.launch { container.settingsStore.setNotifPermission(!notifPermission) }
838+ ) {
839+ val notifScope = rememberCoroutineScope()
840+ Row(
841+ modifier = Modifier
842+ .fillMaxWidth()
843+ .defaultMinSize(minHeight = 48.dp)
844+ .clickable(role = Role.Switch) {
845+ notifScope.launch { container.settingsStore.setNotifRunComplete(!notifRunComplete) }
846+ }
847+ .padding(vertical = 8.dp),
848+ verticalAlignment = Alignment.CenterVertically,
849+ ) {
850+ Column(modifier = Modifier.weight(1f)) {
851+ Text(notifRunCompleteLabel)
852+ Text(
853+ notifRunCompleteDesc,
854+ style = MaterialTheme.typography.bodySmall,
855+ color = MaterialTheme.colorScheme.onSurfaceVariant,
856+ )
857+ }
858+ Switch(checked = notifRunComplete, onCheckedChange = null)
625859 }
626− .padding(vertical = 8.dp),
627− verticalAlignment = Alignment.CenterVertically,
628− ) {
629− Column(modifier = Modifier.weight(1f)) {
630− Text(stringResource(R.string.settings_notif_permission))
631− Text(
632− stringResource(R.string.settings_notif_permission_desc),
633− style = MaterialTheme.typography.bodySmall,
634− color = MaterialTheme.colorScheme.onSurfaceVariant,
635− )
636860 }
637− Switch(checked = notifPermission, onCheckedChange = null)
638− }
639− Row(
640− modifier = Modifier
641− .fillMaxWidth()
642− .defaultMinSize(minHeight = 48.dp)
643− .clickable(role = Role.Switch) {
644− notifScope.launch { container.settingsStore.setNotifError(!notifError) }
861+ if (rowVisible(
862+ settingsQuery,
863+ notificationsHeaderMatches,
864+ notificationsAnyRowMatches,
865+ notifPermissionLabel, notifPermissionDesc,
866+ )
867+ ) {
868+ val notifScope = rememberCoroutineScope()
869+ Row(
870+ modifier = Modifier
871+ .fillMaxWidth()
872+ .defaultMinSize(minHeight = 48.dp)
873+ .clickable(role = Role.Switch) {
874+ notifScope.launch { container.settingsStore.setNotifPermission(!notifPermission) }
875+ }
876+ .padding(vertical = 8.dp),
877+ verticalAlignment = Alignment.CenterVertically,
878+ ) {
879+ Column(modifier = Modifier.weight(1f)) {
880+ Text(notifPermissionLabel)
881+ Text(
882+ notifPermissionDesc,
883+ style = MaterialTheme.typography.bodySmall,
884+ color = MaterialTheme.colorScheme.onSurfaceVariant,
885+ )
886+ }
887+ Switch(checked = notifPermission, onCheckedChange = null)
645888 }
646− .padding(vertical = 8.dp),
647− verticalAlignment = Alignment.CenterVertically,
648− ) {
649− Column(modifier = Modifier.weight(1f)) {
650− Text(stringResource(R.string.settings_notif_error))
651− Text(
652− stringResource(R.string.settings_notif_error_desc),
653− style = MaterialTheme.typography.bodySmall,
654− color = MaterialTheme.colorScheme.onSurfaceVariant,
889+ }
890+ if (rowVisible(
891+ settingsQuery,
892+ notificationsHeaderMatches,
893+ notificationsAnyRowMatches,
894+ notifErrorLabel, notifErrorDesc,
655895 )
896+ ) {
897+ val notifScope = rememberCoroutineScope()
898+ Row(
899+ modifier = Modifier
900+ .fillMaxWidth()
901+ .defaultMinSize(minHeight = 48.dp)
902+ .clickable(role = Role.Switch) {
903+ notifScope.launch { container.settingsStore.setNotifError(!notifError) }
904+ }
905+ .padding(vertical = 8.dp),
906+ verticalAlignment = Alignment.CenterVertically,
907+ ) {
908+ Column(modifier = Modifier.weight(1f)) {
909+ Text(notifErrorLabel)
910+ Text(
911+ notifErrorDesc,
912+ style = MaterialTheme.typography.bodySmall,
913+ color = MaterialTheme.colorScheme.onSurfaceVariant,
914+ )
915+ }
916+ Switch(checked = notifError, onCheckedChange = null)
917+ }
656918 }
657− Switch(checked = notifError, onCheckedChange = null)
658919 }
659920
660− HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
921+ if (showNotifications) {
922+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
923+ }
661924
662925 // Swipe actions: remappable left/right swipe on session list rows. Defaults
663926 // match the prior hardcoded behavior (left=delete, right=archive). NONE
664927 // disables the swipe entirely.
665− Text(
666− stringResource(R.string.settings_swipe_actions),
667− style = MaterialTheme.typography.titleSmall,
668− color = MaterialTheme.colorScheme.primary,
669− modifier = Modifier.semantics { heading() },
670− )
671− val swipeScope = rememberCoroutineScope()
672− val swipeOptions = remember {
673− listOf(
674− SwipeAction.DELETE to R.string.swipe_action_delete,
675− SwipeAction.ARCHIVE to R.string.swipe_action_archive,
676− SwipeAction.MARK_READ to R.string.swipe_action_mark_read,
677− SwipeAction.NONE to R.string.swipe_action_none,
928+ if (showSwipeActions) {
929+ Text(
930+ swipeActionsHeader,
931+ style = MaterialTheme.typography.titleSmall,
932+ color = MaterialTheme.colorScheme.primary,
933+ modifier = Modifier.semantics { heading() },
678934 )
679− }
680− var swipeLeftMenu by remember { mutableStateOf(false) }
681− var swipeRightMenu by remember { mutableStateOf(false) }
682− val swipeLeftParsed = remember(swipeLeftAction) {
683− runCatching { SwipeAction.valueOf(swipeLeftAction) }.getOrDefault(SwipeAction.DELETE)
684− }
685− val swipeRightParsed = remember(swipeRightAction) {
686− runCatching { SwipeAction.valueOf(swipeRightAction) }.getOrDefault(SwipeAction.ARCHIVE)
687− }
688− Row(
689− modifier = Modifier
690− .fillMaxWidth()
691− .defaultMinSize(minHeight = 48.dp)
692− .padding(vertical = 8.dp),
693− verticalAlignment = Alignment.CenterVertically,
694− ) {
695− Text(stringResource(R.string.settings_swipe_left), modifier = Modifier.weight(1f))
696− Box {
697− TextButton(onClick = { swipeLeftMenu = true }) {
698− Text(stringResource(swipeOptions.first { it.first == swipeLeftParsed }.second))
935+ val swipeScope = rememberCoroutineScope()
936+ val swipeOptions = remember {
937+ listOf(
938+ SwipeAction.DELETE to R.string.swipe_action_delete,
939+ SwipeAction.ARCHIVE to R.string.swipe_action_archive,
940+ SwipeAction.MARK_READ to R.string.swipe_action_mark_read,
941+ SwipeAction.NONE to R.string.swipe_action_none,
942+ )
943+ }
944+ var swipeLeftMenu by remember { mutableStateOf(false) }
945+ var swipeRightMenu by remember { mutableStateOf(false) }
946+ val swipeLeftParsed = remember(swipeLeftAction) {
947+ runCatching { SwipeAction.valueOf(swipeLeftAction) }.getOrDefault(SwipeAction.DELETE)
948+ }
949+ val swipeRightParsed = remember(swipeRightAction) {
950+ runCatching { SwipeAction.valueOf(swipeRightAction) }.getOrDefault(SwipeAction.ARCHIVE)
951+ }
952+ if (rowVisible(settingsQuery, swipeHeaderMatches, swipeAnyRowMatches, swipeLeftLabel)) {
953+ Row(
954+ modifier = Modifier
955+ .fillMaxWidth()
956+ .defaultMinSize(minHeight = 48.dp)
957+ .padding(vertical = 8.dp),
958+ verticalAlignment = Alignment.CenterVertically,
959+ ) {
960+ Text(swipeLeftLabel, modifier = Modifier.weight(1f))
961+ Box {
962+ TextButton(onClick = { swipeLeftMenu = true }) {
963+ Text(stringResource(swipeOptions.first { it.first == swipeLeftParsed }.second))
964+ }
965+ DropdownMenu(expanded = swipeLeftMenu, onDismissRequest = { swipeLeftMenu = false }) {
966+ swipeOptions.forEach { (action, label) ->
967+ DropdownMenuItem(
968+ text = { Text(stringResource(label)) },
969+ onClick = {
970+ swipeLeftMenu = false
971+ swipeScope.launch { container.settingsStore.setSwipeLeftAction(action) }
972+ },
973+ )
974+ }
975+ }
976+ }
699977 }
700− DropdownMenu(expanded = swipeLeftMenu, onDismissRequest = { swipeLeftMenu = false }) {
701− swipeOptions.forEach { (action, label) ->
702− DropdownMenuItem(
703− text = { Text(stringResource(label)) },
704− onClick = {
705− swipeLeftMenu = false
706− swipeScope.launch { container.settingsStore.setSwipeLeftAction(action) }
707− },
708− )
978+ }
979+ if (rowVisible(settingsQuery, swipeHeaderMatches, swipeAnyRowMatches, swipeRightLabel)) {
980+ Row(
981+ modifier = Modifier
982+ .fillMaxWidth()
983+ .defaultMinSize(minHeight = 48.dp)
984+ .padding(vertical = 8.dp),
985+ verticalAlignment = Alignment.CenterVertically,
986+ ) {
987+ Text(swipeRightLabel, modifier = Modifier.weight(1f))
988+ Box {
989+ TextButton(onClick = { swipeRightMenu = true }) {
990+ Text(stringResource(swipeOptions.first { it.first == swipeRightParsed }.second))
991+ }
992+ DropdownMenu(expanded = swipeRightMenu, onDismissRequest = { swipeRightMenu = false }) {
993+ swipeOptions.forEach { (action, label) ->
994+ DropdownMenuItem(
995+ text = { Text(stringResource(label)) },
996+ onClick = {
997+ swipeRightMenu = false
998+ swipeScope.launch { container.settingsStore.setSwipeRightAction(action) }
999+ },
1000+ )
1001+ }
1002+ }
7091003 }
7101004 }
7111005 }
7121006 }
713− Row(
714− modifier = Modifier
715− .fillMaxWidth()
716− .defaultMinSize(minHeight = 48.dp)
717− .padding(vertical = 8.dp),
718− verticalAlignment = Alignment.CenterVertically,
719− ) {
720− Text(stringResource(R.string.settings_swipe_right), modifier = Modifier.weight(1f))
721− Box {
722− TextButton(onClick = { swipeRightMenu = true }) {
723− Text(stringResource(swipeOptions.first { it.first == swipeRightParsed }.second))
1007+
1008+ if (showSwipeActions) {
1009+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
1010+ }
1011+
1012+ if (showConnection) {
1013+ Text(
1014+ connectionHeader,
1015+ style = MaterialTheme.typography.titleSmall,
1016+ color = MaterialTheme.colorScheme.primary,
1017+ modifier = Modifier.semantics { heading() },
1018+ )
1019+ // The connection status block (profile label + URL + SSE state + retry) has no
1020+ // single stable label to match against, so show it whenever the section is visible.
1021+ if (activeProfile != null) {
1022+ Text(activeProfile.displayLabel, modifier = Modifier.padding(top = 8.dp))
1023+ Text(
1024+ activeProfile.baseUrl + if (activeProfile.hasAuth) stringResource(R.string.server_auth_basic) else "",
1025+ style = MaterialTheme.typography.bodySmall,
1026+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1027+ )
1028+ // Surface the SSE stream state so a user who opens Settings during a
1029+ // reconnect saw it (the ConnectionBanner on other screens handles this
1030+ // elsewhere). Only show non-Connected states to avoid clutter. On a hard
1031+ // failure offer an inline Retry — without it a user who opened Settings
1032+ // during a failure is stuck on an error message with no recovery path
1033+ // (the ConnectionBanner used elsewhere has Retry, but Settings doesn't
1034+ // host it). Retry forces an SSE reconnect, which re-seeds from REST.
1035+ val stateText = when (connectionState) {
1036+ EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
1037+ EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
1038+ EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed_endpoint)
1039+ EventStreamClient.ConnectionState.AuthFailed -> stringResource(R.string.connection_failed)
1040+ EventStreamClient.ConnectionState.Connected -> null
7241041 }
725− DropdownMenu(expanded = swipeRightMenu, onDismissRequest = { swipeRightMenu = false }) {
726− swipeOptions.forEach { (action, label) ->
727− DropdownMenuItem(
728− text = { Text(stringResource(label)) },
729− onClick = {
730− swipeRightMenu = false
731− swipeScope.launch { container.settingsStore.setSwipeRightAction(action) }
732− },
1042+ stateText?.let {
1043+ Row(
1044+ modifier = Modifier.padding(top = 4.dp),
1045+ verticalAlignment = Alignment.CenterVertically,
1046+ ) {
1047+ val isHardFailure = connectionState == EventStreamClient.ConnectionState.Failed ||
1048+ connectionState == EventStreamClient.ConnectionState.AuthFailed
1049+ Text(
1050+ it,
1051+ style = MaterialTheme.typography.bodySmall,
1052+ color = if (isHardFailure)
1053+ MaterialTheme.colorScheme.error
1054+ else MaterialTheme.colorScheme.onSurfaceVariant,
7331055 )
1056+ if (isHardFailure) {
1057+ TextButton(
1058+ onClick = { container.activeConnection.value?.events?.triggerReconnect() },
1059+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
1060+ modifier = Modifier.defaultMinSize(minHeight = 48.dp),
1061+ ) { Text(stringResource(R.string.retry_now)) }
1062+ }
7341063 }
7351064 }
1065+ } else {
1066+ Text(
1067+ stringResource(R.string.not_connected),
1068+ modifier = Modifier.padding(top = 8.dp),
1069+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1070+ )
1071+ }
1072+ if (rowVisible(settingsQuery, connectionHeaderMatches, connectionAnyRowMatches, manageServersLabel)) {
1073+ Row(
1074+ modifier = Modifier
1075+ .fillMaxWidth()
1076+ .defaultMinSize(minHeight = 48.dp)
1077+ .padding(top = 8.dp)
1078+ .clickable(role = Role.Button) { onManageServers() }
1079+ .padding(vertical = 8.dp),
1080+ verticalAlignment = Alignment.CenterVertically,
1081+ ) {
1082+ Icon(Icons.Filled.Dns, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
1083+ Text(manageServersLabel, modifier = Modifier.weight(1f).padding(start = 8.dp))
1084+ Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
1085+ }
1086+ }
1087+ if (rowVisible(settingsQuery, connectionHeaderMatches, connectionAnyRowMatches, usageLabel)) {
1088+ NavRow(
1089+ icon = Icons.Filled.QueryStats,
1090+ label = usageLabel,
1091+ enabled = activeProfile != null,
1092+ onClick = onOpenUsage,
1093+ )
1094+ }
1095+ if (rowVisible(settingsQuery, connectionHeaderMatches, connectionAnyRowMatches, mcpLabel)) {
1096+ NavRow(
1097+ icon = Icons.Filled.Hub,
1098+ label = mcpLabel,
1099+ enabled = activeProfile != null,
1100+ onClick = onOpenMcp,
1101+ )
1102+ }
1103+ if (rowVisible(settingsQuery, connectionHeaderMatches, connectionAnyRowMatches, notifSettingsLabel)) {
1104+ NavRow(
1105+ icon = Icons.Filled.Notifications,
1106+ label = notifSettingsLabel,
1107+ enabled = true,
1108+ onClick = {
1109+ scope.launch {
1110+ val opened = runCatchingCancellable {
1111+ val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
1112+ .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
1113+ context.startActivity(intent)
1114+ }.isSuccess
1115+ if (!opened) snackbar.showSnackbar(couldNotOpenLinkMsg)
1116+ }
1117+ },
1118+ )
7361119 }
7371120 }
7381121
739− HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
1122+ if (showConnection) {
1123+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
1124+ }
7401125
741− Text(
742− stringResource(R.string.connection),
743− style = MaterialTheme.typography.titleSmall,
744− color = MaterialTheme.colorScheme.primary,
745− modifier = Modifier.semantics { heading() },
746− )
747− if (activeProfile != null) {
748− Text(activeProfile.displayLabel, modifier = Modifier.padding(top = 8.dp))
1126+ if (showBackup) {
7491127 Text(
750− activeProfile.baseUrl + if (activeProfile.hasAuth) stringResource(R.string.server_auth_basic) else "",
751− style = MaterialTheme.typography.bodySmall,
752− color = MaterialTheme.colorScheme.onSurfaceVariant,
1128+ backupHeader,
1129+ style = MaterialTheme.typography.titleSmall,
1130+ color = MaterialTheme.colorScheme.primary,
1131+ modifier = Modifier.semantics { heading() },
7531132 )
754− // Surface the SSE stream state so a user who opens Settings during a
755− // reconnect saw it (the ConnectionBanner on other screens handles this
756− // elsewhere). Only show non-Connected states to avoid clutter. On a hard
757− // failure offer an inline Retry — without it a user who opened Settings
758− // during a failure is stuck on an error message with no recovery path
759− // (the ConnectionBanner used elsewhere has Retry, but Settings doesn't
760− // host it). Retry forces an SSE reconnect, which re-seeds from REST.
761− val stateText = when (connectionState) {
762− EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
763− EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
764− EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed_endpoint)
765− EventStreamClient.ConnectionState.AuthFailed -> stringResource(R.string.connection_failed)
766− EventStreamClient.ConnectionState.Connected -> null
1133+ if (rowVisible(settingsQuery, backupHeaderMatches, backupAnyRowMatches, backupDescText)) {
1134+ Text(
1135+ backupDescText,
1136+ style = MaterialTheme.typography.bodySmall,
1137+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1138+ modifier = Modifier.padding(top = 4.dp),
1139+ )
7671140 }
768− stateText?.let {
1141+ if (rowVisible(
1142+ settingsQuery,
1143+ backupHeaderMatches,
1144+ backupAnyRowMatches,
1145+ backupIncludePasswordsLabel, backupIncludePasswordsDesc,
1146+ )
1147+ ) {
7691148 Row(
770− modifier = Modifier.padding(top = 4.dp),
1149+ modifier = Modifier
1150+ .fillMaxWidth()
1151+ .defaultMinSize(minHeight = 48.dp)
1152+ .toggleable(
1153+ value = includePasswords,
1154+ onValueChange = { includePasswords = it },
1155+ role = Role.Switch,
1156+ )
1157+ .padding(vertical = 8.dp),
7711158 verticalAlignment = Alignment.CenterVertically,
7721159 ) {
773− val isHardFailure = connectionState == EventStreamClient.ConnectionState.Failed ||
774− connectionState == EventStreamClient.ConnectionState.AuthFailed
775− Text(
776− it,
777− style = MaterialTheme.typography.bodySmall,
778− color = if (isHardFailure)
779− MaterialTheme.colorScheme.error
780− else MaterialTheme.colorScheme.onSurfaceVariant,
781− )
782− if (isHardFailure) {
783− TextButton(
784− onClick = { container.activeConnection.value?.events?.triggerReconnect() },
785− contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
786− modifier = Modifier.defaultMinSize(minHeight = 48.dp),
787− ) { Text(stringResource(R.string.retry_now)) }
1160+ Column(modifier = Modifier.weight(1f)) {
1161+ Text(backupIncludePasswordsLabel, style = MaterialTheme.typography.bodyLarge)
1162+ Text(
1163+ backupIncludePasswordsDesc,
1164+ style = MaterialTheme.typography.bodySmall,
1165+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1166+ )
7881167 }
1168+ Switch(checked = includePasswords, onCheckedChange = null)
7891169 }
7901170 }
791− } else {
792− Text(
793− stringResource(R.string.not_connected),
794− modifier = Modifier.padding(top = 8.dp),
795− color = MaterialTheme.colorScheme.onSurfaceVariant,
796− )
797− }
798− Row(
799− modifier = Modifier
800− .fillMaxWidth()
801− .defaultMinSize(minHeight = 48.dp)
802− .padding(top = 8.dp)
803− .clickable(role = Role.Button) { onManageServers() }
804− .padding(vertical = 8.dp),
805− verticalAlignment = Alignment.CenterVertically,
806− ) {
807− Icon(Icons.Filled.Dns, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
808− Text(stringResource(R.string.manage_servers), modifier = Modifier.weight(1f).padding(start = 8.dp))
809− Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
1171+ if (rowVisible(settingsQuery, backupHeaderMatches, backupAnyRowMatches, exportBackupLabel)) {
1172+ NavRow(
1173+ icon = Icons.Filled.Upload,
1174+ label = exportBackupLabel,
1175+ enabled = !backupBusy,
1176+ // Warn before writing plaintext credentials out of the app; confirm then launches
1177+ // the file picker. Without passwords, export straight away.
1178+ onClick = {
1179+ if (includePasswords) showExportPasswordWarning = true
1180+ else runCatching { exportLauncher.launch(backupFilename()) }
1181+ },
1182+ )
1183+ }
1184+ if (rowVisible(settingsQuery, backupHeaderMatches, backupAnyRowMatches, importBackupLabel)) {
1185+ NavRow(
1186+ icon = Icons.Filled.Download,
1187+ label = importBackupLabel,
1188+ enabled = !backupBusy,
1189+ onClick = {
1190+ runCatching {
1191+ importLauncher.launch(arrayOf("application/json", "text/plain", "application/octet-stream"))
1192+ }
1193+ },
1194+ )
1195+ }
1196+ // Surface an inline progress indicator while a backup write/read is in flight so
1197+ // the user gets immediate feedback that the (potentially slow on a big file)
1198+ // operation is running, and isn't tempted to tap again.
1199+ if (backupBusy) {
1200+ LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(top = 4.dp))
1201+ }
8101202 }
811− NavRow(
812− icon = Icons.Filled.QueryStats,
813− label = stringResource(R.string.usage_title),
814− enabled = activeProfile != null,
815− onClick = onOpenUsage,
816− )
817− NavRow(
818− icon = Icons.Filled.Hub,
819− label = stringResource(R.string.mcp_servers),
820− enabled = activeProfile != null,
821− onClick = onOpenMcp,
822− )
823− NavRow(
824− icon = Icons.Filled.Notifications,
825− label = stringResource(R.string.notification_settings),
826− enabled = true,
827− onClick = {
828− scope.launch {
829− val opened = runCatchingCancellable {
830− val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
831− .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
832− context.startActivity(intent)
833− }.isSuccess
834− if (!opened) snackbar.showSnackbar(couldNotOpenLinkMsg)
835− }
836− },
837− )
8381203
839− HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
1204+ if (showBackup) {
1205+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
1206+ }
8401207
841− Text(
842− stringResource(R.string.backup_restore),
843− style = MaterialTheme.typography.titleSmall,
844− color = MaterialTheme.colorScheme.primary,
845− modifier = Modifier.semantics { heading() },
846− )
847− Text(
848− stringResource(R.string.backup_desc),
849− style = MaterialTheme.typography.bodySmall,
850− color = MaterialTheme.colorScheme.onSurfaceVariant,
851− modifier = Modifier.padding(top = 4.dp),
852− )
853− Row(
854− modifier = Modifier
855− .fillMaxWidth()
856− .defaultMinSize(minHeight = 48.dp)
857− .toggleable(
858− value = includePasswords,
859− onValueChange = { includePasswords = it },
860− role = Role.Switch,
861− )
862− .padding(vertical = 8.dp),
863− verticalAlignment = Alignment.CenterVertically,
864− ) {
865− Column(modifier = Modifier.weight(1f)) {
866− Text(stringResource(R.string.backup_include_passwords), style = MaterialTheme.typography.bodyLarge)
1208+ if (showAbout) {
1209+ Text(
1210+ aboutHeader,
1211+ style = MaterialTheme.typography.titleSmall,
1212+ color = MaterialTheme.colorScheme.primary,
1213+ modifier = Modifier.semantics { heading() },
1214+ )
1215+ // The version line is the About section's main content; show it whenever the
1216+ // section is visible (it has no separate label to filter on).
1217+ Text(
1218+ stringResource(R.string.about_version, versionName),
1219+ style = MaterialTheme.typography.bodyMedium,
1220+ modifier = Modifier.padding(top = 8.dp),
1221+ )
1222+ if (rowVisible(settingsQuery, aboutHeaderMatches, aboutAnyRowMatches, aboutDescText)) {
8671223 Text(
868− stringResource(R.string.backup_include_passwords_desc),
1224+ aboutDescText,
8691225 style = MaterialTheme.typography.bodySmall,
8701226 color = MaterialTheme.colorScheme.onSurfaceVariant,
1227+ modifier = Modifier.padding(top = 4.dp),
8711228 )
8721229 }
873− Switch(checked = includePasswords, onCheckedChange = null)
874− }
875− NavRow(
876− icon = Icons.Filled.Upload,
877− label = stringResource(R.string.export_backup),
878− enabled = !backupBusy,
879− // Warn before writing plaintext credentials out of the app; confirm then launches
880− // the file picker. Without passwords, export straight away.
881− onClick = {
882− if (includePasswords) showExportPasswordWarning = true
883− else runCatching { exportLauncher.launch(backupFilename()) }
884− },
885− )
886− NavRow(
887− icon = Icons.Filled.Download,
888− label = stringResource(R.string.import_backup),
889− enabled = !backupBusy,
890− onClick = {
891− runCatching {
892− importLauncher.launch(arrayOf("application/json", "text/plain", "application/octet-stream"))
893− }
894− },
895− )
896− // Surface an inline progress indicator while a backup write/read is in flight so
897− // the user gets immediate feedback that the (potentially slow on a big file)
898− // operation is running, and isn't tempted to tap again.
899− if (backupBusy) {
900− LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(top = 4.dp))
901− }
902−
903− HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
904−
905− Text(
906− stringResource(R.string.about),
907− style = MaterialTheme.typography.titleSmall,
908− color = MaterialTheme.colorScheme.primary,
909− modifier = Modifier.semantics { heading() },
910− )
911− Text(
912− stringResource(R.string.about_version, versionName),
913− style = MaterialTheme.typography.bodyMedium,
914− modifier = Modifier.padding(top = 8.dp),
915− )
916− Text(
917− stringResource(R.string.about_desc),
918− style = MaterialTheme.typography.bodySmall,
919− color = MaterialTheme.colorScheme.onSurfaceVariant,
920− modifier = Modifier.padding(top = 4.dp),
921− )
9221230
923− Spacer(Modifier.size(16.dp))
924− Row(
925− modifier = Modifier
926− .fillMaxWidth()
927− .defaultMinSize(minHeight = 48.dp)
928− .clickable(role = Role.Button) { onOpenDiagnostics() }
929− .padding(vertical = 8.dp),
930− verticalAlignment = Alignment.CenterVertically,
931− ) {
932− Icon(Icons.Filled.BugReport, contentDescription = null, tint = MaterialTheme.colorScheme.error)
933− Text(stringResource(R.string.diagnostics), modifier = Modifier.weight(1f).padding(start = 8.dp))
934− // Badge with the crash count so the user can tell at a glance whether
935− // there's something worth investigating. Uses M3 BadgedBox+Badge for
936− // consistency with the nav rail's badge (which already uses the real M3
937− // components) instead of a hand-rolled Box.
938− if (crashCount > 0) {
939− BadgedBox(
940− badge = {
941− Badge {
942− val overflow = stringResource(R.string.crash_count_overflow)
943− Text(if (crashCount > 99) overflow else crashCount.toString())
944− }
1231+ if (rowVisible(settingsQuery, aboutHeaderMatches, aboutAnyRowMatches, diagnosticsLabel)) {
1232+ Spacer(Modifier.size(16.dp))
1233+ Row(
1234+ modifier = Modifier
1235+ .fillMaxWidth()
1236+ .defaultMinSize(minHeight = 48.dp)
1237+ .clickable(role = Role.Button) { onOpenDiagnostics() }
1238+ .padding(vertical = 8.dp),
1239+ verticalAlignment = Alignment.CenterVertically,
1240+ ) {
1241+ Icon(Icons.Filled.BugReport, contentDescription = null, tint = MaterialTheme.colorScheme.error)
1242+ Text(diagnosticsLabel, modifier = Modifier.weight(1f).padding(start = 8.dp))
1243+ // Badge with the crash count so the user can tell at a glance whether
1244+ // there's something worth investigating. Uses M3 BadgedBox+Badge for
1245+ // consistency with the nav rail's badge (which already uses the real M3
1246+ // components) instead of a hand-rolled Box.
1247+ if (crashCount > 0) {
1248+ BadgedBox(
1249+ badge = {
1250+ Badge {
1251+ val overflow = stringResource(R.string.crash_count_overflow)
1252+ Text(if (crashCount > 99) overflow else crashCount.toString())
1253+ }
1254+ },
1255+ ) {}
1256+ Spacer(Modifier.size(8.dp))
1257+ }
1258+ Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
1259+ }
1260+ }
1261+ // Discoverability links from the About section: source code and issue tracker.
1262+ // Opens in the browser via ACTION_VIEW; a device without a browser (e.g. a de-Googled
1263+ // or work-profile split) silently no-ops otherwise, so surface a snackbar on failure.
1264+ if (rowVisible(settingsQuery, aboutHeaderMatches, aboutAnyRowMatches, sourceCodeLabel)) {
1265+ NavRow(
1266+ icon = Icons.Filled.Code,
1267+ label = sourceCodeLabel,
1268+ enabled = true,
1269+ onClick = {
1270+ runCatching {
1271+ context.startActivity(
1272+ Intent(Intent.ACTION_VIEW, android.net.Uri.parse(context.getString(R.string.url_source_code))),
1273+ )
1274+ }.onFailure { scope.launch { snackbar.showSnackbar(couldNotOpenLinkMsg) } }
9451275 },
946− ) {}
947− Spacer(Modifier.size(8.dp))
1276+ )
1277+ }
1278+ if (rowVisible(settingsQuery, aboutHeaderMatches, aboutAnyRowMatches, reportIssueLabel)) {
1279+ NavRow(
1280+ icon = Icons.Filled.Feedback,
1281+ label = reportIssueLabel,
1282+ enabled = true,
1283+ onClick = {
1284+ runCatching {
1285+ context.startActivity(
1286+ Intent(Intent.ACTION_VIEW, android.net.Uri.parse(context.getString(R.string.url_report_issue))),
1287+ )
1288+ }.onFailure { scope.launch { snackbar.showSnackbar(couldNotOpenLinkMsg) } }
1289+ },
1290+ )
9481291 }
949− Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
9501292 }
951− // Discoverability links from the About section: source code and issue tracker.
952− // Opens in the browser via ACTION_VIEW; a device without a browser (e.g. a de-Googled
953− // or work-profile split) silently no-ops otherwise, so surface a snackbar on failure.
954− NavRow(
955− icon = Icons.Filled.Code,
956− label = stringResource(R.string.source_code),
957− enabled = true,
958− onClick = {
959− runCatching {
960− context.startActivity(
961− Intent(Intent.ACTION_VIEW, android.net.Uri.parse(context.getString(R.string.url_source_code))),
962− )
963− }.onFailure { scope.launch { snackbar.showSnackbar(couldNotOpenLinkMsg) } }
964− },
965− )
966− NavRow(
967− icon = Icons.Filled.Feedback,
968− label = stringResource(R.string.report_issue),
969− enabled = true,
970− onClick = {
971− runCatching {
972− context.startActivity(
973− Intent(Intent.ACTION_VIEW, android.net.Uri.parse(context.getString(R.string.url_report_issue))),
974− )
975− }.onFailure { scope.launch { snackbar.showSnackbar(couldNotOpenLinkMsg) } }
976− },
977− )
9781293 }
9791294 }
9801295
@@ -1002,13 +1317,25 @@ fun SettingsScreen(
10021317 )
10031318 if (result == SnackbarResult.ActionPerformed) {
10041319 // Shut down the container (cancels the app scope, closes the
1005− // active connection, flushes stores) before the hard kill so
1320+ // active connection, flushes stores) before restarting so
10061321 // in-flight work is cleaned up rather than abruptly severed.
1007− // The JVM shutdown hook in OpencodeApp would catch this, but
1008− // calling shutdown explicitly is cleaner and avoids relying on
1009− // the hook's ordering under a kill.
10101322 runCatchingCancellable { container.shutdown() }
1011− android.os.Process.killProcess(android.os.Process.myPid())
1323+ // Graceful restart: relaunch the app's main activity via the
1324+ // launcher intent and finish the current task, instead of
1325+ // killing the process (which the system may log as a crash
1326+ // and which skips normal lifecycle teardown). Falling back to
1327+ // a hard kill only when no launch intent is resolvable.
1328+ val pm = context.packageManager
1329+ val intent = pm.getLaunchIntentForPackage(context.packageName)
1330+ if (intent != null) {
1331+ intent.addFlags(
1332+ android.content.Intent.FLAG_ACTIVITY_NEW_TASK or
1333+ android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK,
1334+ )
1335+ context.startActivity(intent)
1336+ } else {
1337+ android.os.Process.killProcess(android.os.Process.myPid())
1338+ }
10121339 }
10131340 } else {
10141341 snackbar.showSnackbar(importFailedMsg)
@@ -1209,6 +1536,34 @@ private val AmoledSwatches: List<Color> = AmoledPaletteSwatches
12091536@Composable
12101537private fun isSystemInDarkThemeStatic(): Boolean = androidx.compose.foundation.isSystemInDarkTheme()
12111538
1539+/**
1540+ * Case-insensitive substring match against any of [texts]. An empty query matches everything
1541+ * (so the search filter is a no-op when the field is cleared). Used to gate section/row
1542+ * visibility in the settings list.
1543+ */
1544+private fun matchesQuery(query: String, vararg texts: String): Boolean {
1545+ if (query.isEmpty()) return true
1546+ val q = query.trim().lowercase()
1547+ if (q.isEmpty()) return true
1548+ return texts.any { it.lowercase().contains(q) }
1549+}
1550+
1551+/**
1552+ * Per-row visibility given the section's precomputed header/any-row match state. When the
1553+ * header matched but no row did, the whole section is browsable so every row is shown;
1554+ * otherwise a row shows only when its own texts match the query.
1555+ */
1556+private fun rowVisible(
1557+ query: String,
1558+ headerMatches: Boolean,
1559+ anyRowMatches: Boolean,
1560+ vararg rowTexts: String,
1561+): Boolean {
1562+ if (query.isEmpty()) return true
1563+ if (headerMatches && !anyRowMatches) return true
1564+ return matchesQuery(query, *rowTexts)
1565+}
1566+
12121567/** Small wrapper so the three appearance settings load atomically and the UI can gate
12131568 * on a single null check instead of flashing hardcoded defaults. */
12141569private data class SettingsValues(
app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.kt+23−17
@@ -16,8 +16,12 @@ import androidx.compose.foundation.layout.height
1616import androidx.compose.foundation.layout.imePadding
1717import androidx.compose.foundation.layout.padding
1818import androidx.compose.foundation.layout.size
19+import androidx.compose.foundation.layout.widthIn
20+import androidx.compose.foundation.layout.wrapContentWidth
1921import androidx.compose.foundation.lazy.LazyColumn
2022import androidx.compose.foundation.lazy.items
23+import androidx.compose.animation.Crossfade
24+import androidx.compose.animation.core.tween
2125import androidx.compose.material.icons.Icons
2226import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
2327import androidx.compose.material.icons.filled.Close
@@ -28,7 +32,6 @@ import androidx.compose.material.icons.filled.Refresh
2832import androidx.compose.material.icons.filled.Search
2933import androidx.compose.material.icons.filled.SearchOff
3034import androidx.compose.material3.Card
31−import androidx.compose.material3.CircularProgressIndicator
3235import androidx.compose.material3.ExperimentalMaterial3Api
3336import androidx.compose.material3.FilterChip
3437import androidx.compose.material3.Icon
@@ -68,6 +71,7 @@ import soy.iko.opencode.ui.components.AppTopBar
6871import soy.iko.opencode.ui.components.ConnectionBannerFor
6972import soy.iko.opencode.ui.components.EmptyState
7073import soy.iko.opencode.ui.components.SectionHeader
74+import soy.iko.opencode.ui.components.SkeletonRow
7175import soy.iko.opencode.ui.components.reducedMotionAnimateItem
7276import soy.iko.opencode.ui.vmFactory
7377import java.text.NumberFormat
@@ -98,18 +102,26 @@ fun UsageScreen(container: AppContainer, onBack: () -> Unit, onOpenSession: (Str
98102 PullToRefreshBox(
99103 isRefreshing = refreshing,
100104 onRefresh = { vm.load() },
101− modifier = Modifier.fillMaxSize().imePadding().padding(padding),
105+ modifier = Modifier.fillMaxSize().wrapContentWidth(Alignment.CenterHorizontally).widthIn(max = soy.iko.opencode.data.network.NetworkConfig.listContentMaxWidthDp.dp).imePadding().padding(padding),
102106 ) {
103107 Box(modifier = Modifier.fillMaxSize()) {
104108 ConnectionBannerFor(container)
105− val loadingLabel = stringResource(R.string.usage_loading)
106− when (val s = state) {
107− is UsageViewModel.State.Loading -> Centered {
108− CircularProgressIndicator(
109− Modifier.semantics { contentDescription = loadingLabel },
110− )
111− Spacer(Modifier.size(12.dp))
112− Text(loadingLabel, color = MaterialTheme.colorScheme.onSurfaceVariant)
109+ // Crossfade between content states so transitions read as a smooth fade instead
110+ // of an instant snap. Matches the session list's Crossfade pattern; reduced
111+ // motion is honored by Crossfade's default spec.
112+ val stateKey = state::class.simpleName
113+ @Suppress("UnusedCrossfadeTargetStateParameter")
114+ Crossfade(
115+ targetState = stateKey,
116+ animationSpec = tween(NetworkConfig.motionFadeDurationMs.toInt()),
117+ label = "usage_state",
118+ ) {
119+ when (val s = state) {
120+ is UsageViewModel.State.Loading -> Column(
121+ modifier = Modifier.fillMaxSize().padding(16.dp),
122+ verticalArrangement = Arrangement.spacedBy(12.dp),
123+ ) {
124+ repeat(6) { SkeletonRow() }
113125 }
114126 is UsageViewModel.State.Disconnected -> EmptyState(
115127 icon = Icons.Filled.CloudOff,
@@ -141,19 +153,13 @@ fun UsageScreen(container: AppContainer, onBack: () -> Unit, onOpenSession: (Str
141153 onTimeRangeChange = vm::setTimeRange,
142154 )
143155 }
156+ }
144157 }
145158 }
146159 }
147160 }
148161}
149162
150−@Composable
151−private fun Centered(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {
152− Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
153− Column(horizontalAlignment = Alignment.CenterHorizontally, content = content)
154− }
155−}
156−
157163@OptIn(ExperimentalLayoutApi::class)
158164@Composable
159165private fun UsageContent(
app/src/main/res/values-es/strings.xml+17
@@ -20,6 +20,8 @@
2020 <string name="files">Archivos</string>
2121 <string name="share">Compartir</string>
2222 <string name="copy_as_markdown">Copiar como Markdown</string>
23+ <string name="share_as_json">Compartir como JSON</string>
24+ <string name="copy_as_json">Copiar como JSON</string>
2325 <string name="share_subject">Sesión de opencode</string>
2426 <string name="send">Enviar</string>
2527 <string name="stop">Detener</string>
@@ -54,6 +56,7 @@
5456 <string name="no_servers_yet">Aún no hay servidores</string>
5557 <string name="no_servers_hint">Añade un endpoint de opencode serve para conectar.</string>
5658 <string name="search_servers">Filtrar servidores</string>
59+ <string name="search_settings">Buscar ajustes…</string>
5760 <string name="no_server_matches">Ningún servidor coincide</string>
5861 <string name="remove_server_title">¿Quitar servidor?</string>
5962 <string name="remove_server_text">Se quitará «%1$s» de tus servidores guardados.</string>
@@ -192,6 +195,7 @@
192195 <string name="image_failed">Error al cargar la imagen</string>
193196 <string name="save_image">Guardar en galería</string>
194197 <string name="share_image">Compartir imagen</string>
198+ <string name="reset_zoom">Restablecer zoom</string>
195199 <string name="image_saved">Imagen guardada en la galería</string>
196200 <string name="image_save_failed">No se pudo guardar la imagen</string>
197201 <string name="search_models">Buscar modelos…</string>
@@ -213,6 +217,7 @@
213217 <string name="git_deleted">Eliminado</string>
214218 <string name="binary_file">Archivo binario — vista previa no compatible.</string>
215219 <string name="empty_folder">Esta carpeta está vacía</string>
220+ <string name="permission_denied_files">Sin permiso para leer esta carpeta.</string>
216221 <string name="empty_file">Este archivo está vacío</string>
217222 <string name="show_hidden">Mostrar ocultos</string>
218223 <string name="parent_dir">Directorio superior</string>
@@ -339,6 +344,8 @@
339344 <string name="session_deleted_chat">Sesión eliminada</string>
340345 <string name="today">Hoy</string>
341346 <string name="yesterday">Ayer</string>
347+ <string name="last_week">Semana pasada</string>
348+ <string name="older">Anteriores</string>
342349
343350 <!-- Search -->
344351 <string name="clear_search">Borrar búsqueda</string>
@@ -621,6 +628,10 @@
621628 <item quantity="one">%1$d resultado</item>
622629 <item quantity="other">%1$d resultados</item>
623630 </plurals>
631+ <plurals name="file_count">
632+ <item quantity="one">%1$d elemento</item>
633+ <item quantity="other">%1$d elementos</item>
634+ </plurals>
624635
625636 <!-- UI/UX batch 2: message status, crash prompt, server connection -->
626637 <string name="message_sending">Enviando…</string>
@@ -694,6 +705,7 @@
694705 <item quantity="other">%d seleccionados</item>
695706 </plurals>
696707 <string name="bulk_archive">Archivar</string>
708+ <string name="bulk_pin">Fijar</string>
697709 <string name="bulk_delete">Eliminar</string>
698710 <string name="bulk_mark_read">Marcar leído</string>
699711 <string name="deep_link_invalid">Enlace no admitido</string>
@@ -710,6 +722,7 @@
710722
711723 <!-- UI/UX batch 4: file browser label, a11y, bulk confirmations -->
712724 <string name="folders_first">Carpetas primero</string>
725+ <string name="changed_only">Solo modificados</string>
713726 <string name="more_options_for">Más opciones para “%1$s”</string>
714727 <string name="bulk_delete_title">¿Eliminar sesiones?</string>
715728 <string name="bulk_delete_text">Estas %1$d sesiones se eliminarán permanentemente. No se puede deshacer.</string>
@@ -799,6 +812,8 @@
799812 <string name="mcp_view_tools">Ver herramientas</string>
800813 <string name="mcp_no_tools">No hay herramientas disponibles</string>
801814 <string name="mcp_add_server">Añadir servidor MCP</string>
815+ <string name="mcp_add_name_required">El nombre es obligatorio.</string>
816+ <string name="mcp_add_target_required">Obligatorio.</string>
802817 <string name="file_share_too_large">El archivo es demasiado grande para compartir</string>
803818 <string name="search_filter_all">Todo</string>
804819 <string name="search_filter_messages">Mensajes</string>
@@ -868,4 +883,6 @@
868883 <string name="swipe_action_mark_read">Marcar leído / no leído</string>
869884 <string name="swipe_action_none">Ninguno</string>
870885 <string name="image_file_preview">Vista previa de imagen</string>
886+ <string name="view_source">Ver código</string>
887+ <string name="message_source">Código del mensaje</string>
871888</resources>
app/src/main/res/values-night/colors.xml+3
@@ -12,4 +12,7 @@
1212 <color name="widget_on_surface_variant">#9AA5CE</color>
1313 <color name="widget_divider">#33565F89</color>
1414 <color name="widget_background">#EE1F2335</color>
15+
16+ <!-- Notification brand accent at night — the Tokyo Night primary blue. -->
17+ <color name="notif_brand">#7AA2F7</color>
1518</resources>
app/src/main/res/values/colors.xml+5
@@ -14,4 +14,9 @@
1414 <color name="widget_on_surface_variant">#444B6E</color>
1515 <color name="widget_divider">#33747DAA</color>
1616 <color name="widget_background">#EEF1F3FA</color>
17+
18+ <!-- Notification brand accent (small-icon tint circle). Resolved from resources so the
19+ notifications can follow the app theme (values-night overrides for dark/AMOLED)
20+ instead of a hardcoded constant that ignores the user's theme choice. -->
21+ <color name="notif_brand">#34548A</color>
1722</resources>
app/src/main/res/values/strings.xml+18
@@ -20,6 +20,8 @@
2020 <string name="files">Files</string>
2121 <string name="share">Share</string>
2222 <string name="copy_as_markdown">Copy as Markdown</string>
23+ <string name="share_as_json">Share as JSON</string>
24+ <string name="copy_as_json">Copy as JSON</string>
2325 <string name="share_subject">opencode session</string>
2426 <string name="send">Send</string>
2527 <string name="stop">Stop</string>
@@ -55,6 +57,7 @@
5557 <string name="no_servers_yet">No servers yet</string>
5658 <string name="no_servers_hint">Add an opencode serve endpoint to connect.</string>
5759 <string name="search_servers">Filter servers</string>
60+ <string name="search_settings">Search settings…</string>
5861 <string name="no_server_matches">No matching servers</string>
5962 <string name="remove_server_title">Remove server?</string>
6063 <string name="remove_server_text">“%1$s” will be removed from your saved servers.</string>
@@ -201,6 +204,7 @@
201204 <string name="image_failed">Failed to load image</string>
202205 <string name="save_image">Save to gallery</string>
203206 <string name="share_image">Share image</string>
207+ <string name="reset_zoom">Reset zoom</string>
204208 <string name="image_saved">Image saved to gallery</string>
205209 <string name="image_save_failed">Couldn\'t save image</string>
206210 <string name="search_models">Search models…</string>
@@ -222,6 +226,7 @@
222226 <string name="git_deleted">Deleted</string>
223227 <string name="binary_file">Binary file — preview not supported.</string>
224228 <string name="empty_folder">This folder is empty</string>
229+ <string name="permission_denied_files">No permission to read this folder.</string>
225230 <string name="empty_file">This file is empty</string>
226231 <string name="show_hidden">Show hidden</string>
227232 <string name="parent_dir">Parent directory</string>
@@ -349,6 +354,8 @@
349354 <string name="session_deleted_chat">Session deleted</string>
350355 <string name="today">Today</string>
351356 <string name="yesterday">Yesterday</string>
357+ <string name="last_week">Last week</string>
358+ <string name="older">Older</string>
352359
353360 <!-- Search -->
354361 <string name="clear_search">Clear search</string>
@@ -644,6 +651,10 @@
644651 <item quantity="one">%1$d result</item>
645652 <item quantity="other">%1$d results</item>
646653 </plurals>
654+ <plurals name="file_count">
655+ <item quantity="one">%1$d item</item>
656+ <item quantity="other">%1$d items</item>
657+ </plurals>
647658
648659 <!-- UI/UX batch 2: message status, crash prompt, server connection -->
649660 <string name="message_sending">Sending…</string>
@@ -695,6 +706,7 @@
695706 <item quantity="other">%d selected</item>
696707 </plurals>
697708 <string name="bulk_archive">Archive</string>
709+ <string name="bulk_pin">Pin</string>
698710 <string name="bulk_delete">Delete</string>
699711 <string name="bulk_mark_read">Mark read</string>
700712 <string name="deep_link_invalid">Unsupported link</string>
@@ -711,6 +723,7 @@
711723
712724 <!-- UI/UX batch 4: file browser label, a11y, bulk confirmations -->
713725 <string name="folders_first">Folders first</string>
726+ <string name="changed_only">Changed only</string>
714727 <string name="more_options_for">More options for “%1$s”</string>
715728 <string name="bulk_delete_title">Delete sessions?</string>
716729 <string name="bulk_delete_text">These %1$d sessions will be permanently deleted. This cannot be undone.</string>
@@ -799,6 +812,8 @@
799812 <string name="mcp_view_tools">View tools</string>
800813 <string name="mcp_no_tools">No tools available</string>
801814 <string name="mcp_add_server">Add MCP server</string>
815+ <string name="mcp_add_name_required">Name is required.</string>
816+ <string name="mcp_add_target_required">Required.</string>
802817 <string name="file_share_too_large">File is too large to share</string>
803818 <string name="search_filter_all">All</string>
804819 <string name="search_filter_messages">Messages</string>
@@ -894,4 +909,7 @@
894909 <string name="swipe_action_none">None</string>
895910 <!-- Open the file viewer with an image preview for image extensions. -->
896911 <string name="image_file_preview">Image preview</string>
912+ <!-- Markdown source view on a message's TextParts. -->
913+ <string name="view_source">View source</string>
914+ <string name="message_source">Message source</string>
897915</resources>