git.iko.soy/opencode-android
on master help sign in
Batch of UI/UX improvements: fix the mislabeled Folders-first chip, file deep-link two-pane routing, file-viewer truncation memory bound, swipe/directory-chip guards during multi-select, the AMOLED surface tiers, the two-pane rotation re-selection, and Locale-stable elapsed-time formatting; add in-conversation search next/prev navigation with focused-match highlight and Ctrl+F, server-list and bulk-delete and crash-report loading/retry states, a scrollable permission dialog, working-row animation, create/rename retry snackbars, clear-search affordances, and full-screen-editor Send-on-Enter; and add a11y improvements (overflow labels with item names, TalkBack date headings and reasoning word-count and merged working row), haptics, a larger assistant avatar, label ellipsizing, a reduced-motion resume refresh, and snackbar-based diagnostics feedback
commit4fd9a3945dd221cb125e51e61b009a4d735b3a3b authoriko <mail@iko.soy> dateSat 4 Jul 2026, 02:03 (3 months ago) parentffa5e31
18 files changed+442−90
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+10−2 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+166−27 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+12−3 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+23−5 app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+9−1 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+5 app/src/main/java/soy/iko/opencode/ui/components/ReducedMotion.kt+21−1 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+4−2 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+4−2 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+20−2 app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+12 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+67−27 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+24−2 app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+9−7 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+22−9 app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+14 app/src/main/res/values-es/strings.xml+10 app/src/main/res/values/strings.xml+10
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+10−2
@@ -219,10 +219,18 @@ fun OpencodeApp(container: AppContainer) {
219219
220220 // Deep-linked file open (opencode://file/{path}): navigate to the file viewer once a
221221 // connection exists (the viewer fetches content from the server). Path comes pre-validated
222− // by MainActivity's deep-link guard.
223− LaunchedEffect(pendingOpenFile, connection) {
222+ // by MainActivity's deep-link guard. In two-pane mode the detail pane (TwoPaneFileBrowser,
223+ // hosted on the FILES route) consumes the request into its detail pane, mirroring how
224+ // session deep links are routed — so don't consume or push the full-screen viewer here.
225+ LaunchedEffect(pendingOpenFile, connection, isTwoPane) {
224226 val path = pendingOpenFile ?: return@LaunchedEffect
225227 if (connection == null) return@LaunchedEffect
228+ if (isTwoPane) {
229+ if (!navController.popBackStack(Routes.FILES, inclusive = false)) {
230+ navController.navigate(Routes.FILES) { launchSingleTop = true }
231+ }
232+ return@LaunchedEffect
233+ }
226234 container.consumePendingOpenFile()
227235 if (!navController.popBackStack(Routes.FILES, inclusive = false)) {
228236 navController.navigate(Routes.FILES) { launchSingleTop = true }
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+166−27
@@ -133,6 +133,7 @@ import androidx.compose.ui.platform.testTag
133133import androidx.compose.ui.res.stringResource
134134import androidx.compose.ui.semantics.Role
135135import androidx.compose.ui.semantics.contentDescription
136+import androidx.compose.ui.semantics.heading
136137import androidx.compose.ui.semantics.semantics
137138import androidx.compose.ui.text.input.ImeAction
138139import androidx.compose.ui.text.style.TextOverflow
@@ -268,6 +269,11 @@ fun ChatScreen(
268269 var showShortcutsDialog by rememberSaveable { mutableStateOf(false) }
269270 var searchActive by rememberSaveable { mutableStateOf(false) }
270271 var chatSearch by rememberSaveable { mutableStateOf("") }
272+ // Index of the currently-focused in-conversation search match (into the filtered list).
273+ // Drives the up/down navigation: the search bar's arrows step through matches and scroll
274+ // the list to each one, mirroring desktop chat apps (the bare filtered list previously
275+ // required reading each message to find the term).
276+ var searchPos by rememberSaveable { mutableIntStateOf(0) }
271277 // Summarize and init (generate AGENTS.md) are irreversible, so a single tap is gated behind
272278 // a confirmation dialog (from both the overflow menu and the command palette).
273279 var showSummarizeConfirm by rememberSaveable { mutableStateOf(false) }
@@ -490,7 +496,11 @@ fun ChatScreen(
490496 if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
491497 when {
492498 ev.isCtrlPressed && ev.key == Key.K -> { showPalette = true; true }
499+ // Ctrl+F opens in-conversation search (the standard find shortcut), focusing
500+ // the field so the user can type immediately.
501+ ev.isCtrlPressed && ev.key == Key.F -> { searchActive = true; true }
493502 ev.key == Key.Escape && showPalette -> { showPalette = false; true }
503+ ev.key == Key.Escape && searchActive -> { searchActive = false; chatSearch = ""; true }
494504 ev.key == Key.Escape && running && !showStopConfirm && !showExitConfirm -> {
495505 showStopConfirm = true; true
496506 }
@@ -824,6 +834,11 @@ fun ChatScreen(
824834 m.parts.any { p -> (p as? TextPart)?.text?.contains(searchQuery, ignoreCase = true) == true }
825835 }
826836 }
837+ // Keep the focused-match index in range as the query (and thus the match set) changes.
838+ // A new query resets to the first match; a shrunk set clamps to the last valid index.
839+ LaunchedEffect(searchQuery, searchMessages.size) {
840+ if (searchPos >= searchMessages.size) searchPos = 0
841+ }
827842 val listItems = remember(searchMessages, todayLabel, yesterdayLabel) {
828843 buildMessageListItems(searchMessages, todayLabel, yesterdayLabel)
829844 }
@@ -887,6 +902,18 @@ fun ChatScreen(
887902 }
888903 }
889904
905+ // Scroll the focused search match into view whenever the user steps through results with
906+ // the up/down arrows. Resolves the filtered message to its slot in listItems (which
907+ // includes date separators) and animates there.
908+ LaunchedEffect(searchPos, searchQuery, listItems) {
909+ if (!searchActive || searchQuery.isEmpty()) return@LaunchedEffect
910+ val target = searchMessages.getOrNull(searchPos) ?: return@LaunchedEffect
911+ val index = listItems.indexOfFirst {
912+ it is MessageListItem.Message && it.message.info.id == target.info.id
913+ }
914+ if (index >= 0) runCatchingCancellable { listState.animateScrollToItem(index) }
915+ }
916+
890917 LaunchedEffect(Unit) {
891918 var prevSize = 0
892919 var prevLen = 0
@@ -1114,11 +1141,33 @@ fun ChatScreen(
11141141 key = { it.key },
11151142 contentType = { it.contentType },
11161143 ) { item ->
1144+ // When in-conversation search is stepping through matches, highlight the
1145+ // focused one's row so the user can spot it at a glance among the filtered
1146+ // results (full in-text term highlighting would require threading the query
1147+ // through the markdown renderer; the row highlight + scroll-to-match covers
1148+ // the navigation use case desktop chat apps solve with next/prev).
1149+ val focusedMatchId = if (searchActive && searchQuery.isNotEmpty()) {
1150+ searchMessages.getOrNull(searchPos)?.info?.id
1151+ } else null
1152+ val isFocusedMatch = focusedMatchId != null &&
1153+ item is MessageListItem.Message &&
1154+ item.message.info.id == focusedMatchId
11171155 // Default placement animation so inserted/moved rows glide in. Skipped
11181156 // entirely under reduced motion so a streaming message growing in place
11191157 // doesn't animate on every token (and respects the a11y preference).
11201158 Box(
1121− if (LocalReducedMotion.current) Modifier else Modifier.animateItem(),
1159+ Modifier
1160+ .then(if (LocalReducedMotion.current) Modifier else Modifier.animateItem())
1161+ .then(
1162+ if (isFocusedMatch) {
1163+ Modifier.background(
1164+ MaterialTheme.colorScheme.secondaryContainer,
1165+ MaterialTheme.shapes.medium,
1166+ )
1167+ } else {
1168+ Modifier
1169+ },
1170+ ),
11221171 ) {
11231172 when (item) {
11241173 is MessageListItem.Separator -> DateSeparator(item.label)
@@ -1256,30 +1305,48 @@ fun ChatScreen(
12561305 }
12571306 val elapsedText = remember(elapsedMs) {
12581307 val total = elapsedMs / 1000
1259− "%d:%02d".format(total / 60, total % 60)
1308+ // Locale.US for stable digit formatting: on locales with
1309+ // non-ASCII digits (ar-/fa-) the default locale would render
1310+ // localized digits inside the Monospace block, breaking the
1311+ // fixed-width alignment the monospace family was chosen for.
1312+ String.format(java.util.Locale.US, "%d:%02d", total / 60, total % 60)
12601313 }
12611314 Row(
12621315 verticalAlignment = Alignment.CenterVertically,
1263− modifier = Modifier.semantics {
1264− contentDescription = workingText
1265− },
1316+ // Animate this slot in/out so the working row doesn't pop in and
1317+ // jolt the message list when a run starts/stops — matching the
1318+ // AnimatedVisibility used by every other transient banner/FAB here.
1319+ modifier = Modifier
1320+ .animateItem()
1321+ .padding(vertical = 4.dp),
12661322 ) {
1267− CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
1268− Text(workingText, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
1269− Spacer(Modifier.size(8.dp))
1270− Icon(
1271− Icons.Filled.Schedule,
1272− contentDescription = null,
1273− modifier = Modifier.size(14.dp),
1274− tint = MaterialTheme.colorScheme.onSurfaceVariant,
1275− )
1276− Text(
1277− elapsedText,
1278− style = MaterialTheme.typography.labelMedium,
1279− fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
1280− color = MaterialTheme.colorScheme.onSurfaceVariant,
1281− modifier = Modifier.padding(start = 4.dp),
1282− )
1323+ // Merge the indicator + label + clock into one TalkBack node so
1324+ // it's announced once (the working label, then the elapsed time)
1325+ // instead of "working… / working… / 0:14" with the label read
1326+ // twice. The Stop IconButton below stays separately focusable.
1327+ Row(
1328+ verticalAlignment = Alignment.CenterVertically,
1329+ modifier = Modifier.semantics(mergeDescendants = true) {
1330+ contentDescription = "$workingText $elapsedText"
1331+ },
1332+ ) {
1333+ CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
1334+ Text(workingText, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
1335+ Spacer(Modifier.size(8.dp))
1336+ Icon(
1337+ Icons.Filled.Schedule,
1338+ contentDescription = null,
1339+ modifier = Modifier.size(14.dp),
1340+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
1341+ )
1342+ Text(
1343+ elapsedText,
1344+ style = MaterialTheme.typography.labelMedium,
1345+ fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
1346+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1347+ modifier = Modifier.padding(start = 4.dp),
1348+ )
1349+ }
12831350 // A trailing Stop on the working row so the run can be aborted
12841351 // without scrolling back to the composer (which may be off-screen
12851352 // while the user reads the streaming output up-thread).
@@ -1387,14 +1454,44 @@ fun ChatScreen(
13871454 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
13881455 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
13891456 )
1457+ // Match counter: "N of M" where N is the focused match (1-based) and
1458+ // M is the total matching messages. Only shown when there's a query.
1459+ val matchCount = searchMessages.size
13901460 Text(
1391− if (searchQuery.isNotEmpty()) {
1392− stringResource(R.string.search_match_count, searchMessages.size, messages.size)
1461+ if (searchQuery.isNotEmpty() && matchCount > 0) {
1462+ stringResource(R.string.search_match_count, searchPos + 1, matchCount)
1463+ } else if (searchQuery.isNotEmpty()) {
1464+ stringResource(R.string.search_match_count, 0, messages.size)
13931465 } else "",
13941466 style = MaterialTheme.typography.labelSmall,
13951467 color = MaterialTheme.colorScheme.onSurfaceVariant,
13961468 modifier = Modifier.padding(horizontal = 4.dp),
13971469 )
1470+ // Step through matches (Previous / Next), disabled when there are
1471+ // fewer than two to navigate. Wrap around at the ends so repeated
1472+ // taps cycle through every hit.
1473+ val prevLabel = stringResource(R.string.find_previous)
1474+ val nextLabel = stringResource(R.string.find_next)
1475+ IconButton(
1476+ onClick = {
1477+ if (matchCount > 0) {
1478+ searchPos = (searchPos - 1 + matchCount) % matchCount
1479+ }
1480+ },
1481+ enabled = matchCount > 1,
1482+ ) {
1483+ Icon(Icons.Filled.KeyboardArrowUp, contentDescription = prevLabel)
1484+ }
1485+ IconButton(
1486+ onClick = {
1487+ if (matchCount > 0) {
1488+ searchPos = (searchPos + 1) % matchCount
1489+ }
1490+ },
1491+ enabled = matchCount > 1,
1492+ ) {
1493+ Icon(Icons.Filled.KeyboardArrowDown, contentDescription = nextLabel)
1494+ }
13981495 IconButton(onClick = {
13991496 searchActive = false
14001497 chatSearch = ""
@@ -1845,6 +1942,7 @@ private fun ChatInputBar(
18451942 },
18461943 canSend = enabled && hasContent,
18471944 running = running,
1945+ sendOnEnter = sendOnEnter,
18481946 attachments = attachments,
18491947 onRemoveAttachment = onRemoveAttachment,
18501948 staging = staging,
@@ -1864,6 +1962,7 @@ private fun FullScreenEditor(
18641962 onSend: () -> Unit,
18651963 canSend: Boolean,
18661964 running: Boolean = false,
1965+ sendOnEnter: Boolean = false,
18671966 attachments: List<PendingAttachment> = emptyList(),
18681967 onRemoveAttachment: (String) -> Unit = {},
18691968 staging: Boolean = false,
@@ -1904,10 +2003,35 @@ private fun FullScreenEditor(
19042003 OutlinedTextField(
19052004 value = value,
19062005 onValueChange = { v -> onValueChange(v.take(NetworkConfig.maxDraftLengthChars)) },
1907− modifier = Modifier.fillMaxSize().weight(1f),
2006+ modifier = Modifier
2007+ .fillMaxSize()
2008+ .weight(1f)
2009+ // Honor the "Send on Enter" setting here too, so the full-screen
2010+ // editor matches the inline composer: Enter sends (Shift+Enter
2011+ // newlines) when on; Enter inserts a newline and Ctrl+Enter sends
2012+ // when off. Previously this hard-coded ImeAction.Default and had no
2013+ // key handler, so a hardware-keyboard user got a different behaviour
2014+ // from the inline composer depending on which they happened to open.
2015+ .onPreviewKeyEvent { event ->
2016+ if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
2017+ val send = enterShouldSend(
2018+ enabled = canSend,
2019+ hasContent = canSend,
2020+ sendOnEnter = sendOnEnter,
2021+ shift = event.isShiftPressed,
2022+ ctrl = event.isCtrlPressed,
2023+ )
2024+ if (send) { onSend(); true } else false
2025+ },
19082026 placeholder = { Text(stringResource(R.string.message_placeholder)) },
19092027 maxLines = Int.MAX_VALUE,
1910− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Default, capitalization = KeyboardCapitalization.Sentences),
2028+ keyboardOptions = KeyboardOptions(
2029+ imeAction = if (sendOnEnter) ImeAction.Send else ImeAction.Default,
2030+ capitalization = KeyboardCapitalization.Sentences,
2031+ ),
2032+ keyboardActions = KeyboardActions(
2033+ onSend = { if (canSend) onSend() },
2034+ ),
19112035 )
19122036 }
19132037 }
@@ -2054,6 +2178,7 @@ private fun EmptyConversation(
20542178/** A banner shown while a revert checkpoint is active, offering an Undo (unrevert). */
20552179@Composable
20562180private fun RevertBanner(onUndo: () -> Unit) {
2181+ val haptics = LocalHapticFeedback.current
20572182 Surface(
20582183 color = MaterialTheme.colorScheme.tertiaryContainer,
20592184 tonalElevation = 2.dp,
@@ -2078,7 +2203,12 @@ private fun RevertBanner(onUndo: () -> Unit) {
20782203 modifier = Modifier.padding(start = 8.dp),
20792204 )
20802205 }
2081− TextButton(onClick = onUndo) { Text(stringResource(R.string.undo)) }
2206+ TextButton(onClick = {
2207+ // Haptic on undo to match the other destructive/important confirmations: a
2208+ // revert restores potentially hundreds of hidden messages.
2209+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2210+ onUndo()
2211+ }) { Text(stringResource(R.string.undo)) }
20822212 }
20832213 }
20842214}
@@ -2123,8 +2253,14 @@ private fun OutboxBanner(
21232253 )
21242254 }
21252255 if (!sending) {
2256+ val haptics = LocalHapticFeedback.current
21262257 TextButton(onClick = onFlush) { Text(stringResource(R.string.outbox_flush)) }
2127− TextButton(onClick = onDiscard) {
2258+ TextButton(onClick = {
2259+ // Haptic on the irreversible "Discard queued" (drops every queued message
2260+ // for this session), matching the error-colored destructive actions.
2261+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
2262+ onDiscard()
2263+ }) {
21282264 Text(stringResource(R.string.outbox_discard_all), color = MaterialTheme.colorScheme.error)
21292265 }
21302266 }
@@ -2377,6 +2513,9 @@ private fun DateSeparator(label: String) {
23772513 Surface(
23782514 color = MaterialTheme.colorScheme.surfaceVariant,
23792515 shape = MaterialTheme.shapes.small,
2516+ // Marked as a heading so TalkBack users can navigate day-by-day with the
2517+ // heading-skip gesture — the canonical affordance for date dividers in a list.
2518+ modifier = Modifier.semantics(mergeDescendants = true) { heading() },
23802519 ) {
23812520 Text(
23822521 label,
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+12−3
@@ -51,6 +51,7 @@ import androidx.compose.ui.res.stringResource
5151import androidx.compose.ui.semantics.Role
5252import androidx.compose.ui.semantics.contentDescription
5353import androidx.compose.ui.semantics.semantics
54+import androidx.compose.ui.text.style.TextOverflow
5455import androidx.compose.ui.unit.dp
5556import androidx.compose.foundation.interaction.MutableInteractionSource
5657import soy.iko.opencode.R
@@ -528,7 +529,7 @@ private fun AssistantBlock(
528529 Row(verticalAlignment = Alignment.CenterVertically) {
529530 Box(
530531 modifier = Modifier
531− .size(20.dp)
532+ .size(28.dp)
532533 .clip(MaterialTheme.shapes.extraSmall)
533534 .background(MaterialTheme.colorScheme.secondaryContainer),
534535 contentAlignment = Alignment.Center,
@@ -536,7 +537,7 @@ private fun AssistantBlock(
536537 Icon(
537538 Icons.Filled.SmartToy,
538539 contentDescription = null,
539− modifier = Modifier.size(14.dp),
540+ modifier = Modifier.size(18.dp),
540541 tint = MaterialTheme.colorScheme.onSecondaryContainer,
541542 )
542543 }
@@ -545,7 +546,11 @@ private fun AssistantBlock(
545546 label,
546547 style = MaterialTheme.typography.labelSmall,
547548 color = MaterialTheme.colorScheme.onSurfaceVariant,
548− modifier = Modifier.padding(start = 6.dp),
549+ maxLines = 1,
550+ overflow = TextOverflow.Ellipsis,
551+ modifier = Modifier
552+ .padding(start = 6.dp)
553+ .weight(1f, fill = false),
549554 )
550555 }
551556 }
@@ -624,6 +629,10 @@ private fun AssistantBlock(
624629 it,
625630 style = MaterialTheme.typography.labelSmall,
626631 color = MaterialTheme.colorScheme.onSurfaceVariant,
632+ // Cap to a single line so a long "1.2k in · 540 out • $0.012"
633+ // summary doesn't wrap the "•" awkwardly at narrow widths.
634+ maxLines = 1,
635+ overflow = TextOverflow.Ellipsis,
627636 )
628637 }
629638 if (isEdited) {
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+23−5
@@ -182,6 +182,20 @@ private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modi
182182 val collapsedState = stringResource(R.string.state_collapsed)
183183 val thinkingLabel = stringResource(R.string.thinking)
184184 val thoughtsLabel = stringResource(R.string.thoughts)
185+ // Hoist the collapsed word count so it can drive both the visible "(N)" label and the
186+ // row's a11y stateDescription. Previously the count rendered as a bare "(12)" that
187+ // TalkBack read as "12" with no context; folding it into the stateDescription makes the
188+ // hidden-content size reachable to screen-reader users.
189+ val collapsedWordCount = remember(text) {
190+ text.trim().split(Regex("\\s+")).count { it.isNotEmpty() }
191+ }
192+ // Resolve the readable "N words hidden" form once (in the composable body — the semantics
193+ // lambda below is not a composable scope, so it can't call pluralStringResource itself).
194+ val collapsedWordsLabel = pluralStringResource(
195+ R.plurals.reasoning_word_count,
196+ collapsedWordCount,
197+ collapsedWordCount,
198+ )
185199 Column(modifier = modifier.fillMaxWidth()) {
186200 Row(
187201 modifier = Modifier
@@ -190,7 +204,14 @@ private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modi
190204 .clickable(role = Role.Button) { userOverride = !expanded }
191205 .padding(vertical = 4.dp)
192206 .semantics {
193− stateDescription = if (expanded) expandedState else collapsedState
207+ // When collapsed (and not streaming), append how much reasoning is hidden
208+ // so the state reads e.g. "Collapsed, 42 words hidden" — giving a TalkBack
209+ // user the same size signal the visible "(42)" gives a sighted user.
210+ stateDescription = when {
211+ expanded -> expandedState
212+ streaming -> collapsedState
213+ else -> "$collapsedState, $collapsedWordsLabel"
214+ }
194215 },
195216 verticalAlignment = Alignment.CenterVertically,
196217 ) {
@@ -218,11 +239,8 @@ private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modi
218239 // content is hidden before deciding to expand. Skipped while streaming (the
219240 // content is growing and the label already says "Thinking…").
220241 if (!streaming && !expanded) {
221− val wordCount = remember(text) {
222− text.trim().split(Regex("\\s+")).count { it.isNotEmpty() }
223− }
224242 Text(
225− "($wordCount)",
243+ "($collapsedWordCount)",
226244 style = MaterialTheme.typography.labelSmall,
227245 color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
228246 modifier = Modifier.padding(start = 4.dp),
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+9−1
@@ -119,8 +119,16 @@ fun PermissionDialog(
119119 }
120120 },
121121 confirmButton = {
122+ // At very large font scales (200%+ accessibility text scale) the four full-width
123+ // buttons can overflow the dialog's max height, pushing Reject off-screen and
124+ // making it unreachable — the opposite of safe. Scroll + cap the column so the
125+ // full set stays answerable; vertically centering when it fits keeps the look
126+ // unchanged at normal scales.
122127 Column(
123− modifier = Modifier.fillMaxWidth(),
128+ modifier = Modifier
129+ .fillMaxWidth()
130+ .heightIn(max = 320.dp)
131+ .verticalScroll(rememberScrollState()),
124132 verticalArrangement = Arrangement.spacedBy(4.dp),
125133 ) {
126134 Button(
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+5
@@ -48,6 +48,7 @@ import androidx.compose.ui.Alignment
4848import androidx.compose.ui.Modifier
4949import androidx.compose.ui.draw.clip
5050import androidx.compose.ui.platform.LocalContext
51+import androidx.compose.ui.platform.LocalHapticFeedback
5152import androidx.compose.ui.res.pluralStringResource
5253import androidx.compose.ui.res.stringResource
5354import androidx.compose.ui.text.AnnotatedString
@@ -244,6 +245,7 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
244245 // visible confirmation even on Android 13+, where the copy toast is suppressed in favor of
245246 // the platform's tiny (and easy to miss) confirmation chip.
246247 var copied by remember { mutableStateOf(false) }
248+ val haptics = LocalHapticFeedback.current
247249 LaunchedEffect(copied) {
248250 if (copied) {
249251 delay(1200)
@@ -294,6 +296,9 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
294296 onClick = {
295297 copyToClipboard(context, context.getString(R.string.clip_label_code), code)
296298 copied = true
299+ // Haptic to match every other copy affordance in the app (the code-fence
300+ // copy is the most-used copy action in a coding assistant).
301+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
297302 },
298303 ) {
299304 Icon(
app/src/main/java/soy/iko/opencode/ui/components/ReducedMotion.kt+21−1
@@ -11,9 +11,16 @@ import androidx.compose.animation.fadeIn
1111import androidx.compose.animation.fadeOut
1212import androidx.compose.animation.shrinkVertically
1313import androidx.compose.runtime.Composable
14+import androidx.compose.runtime.DisposableEffect
15+import androidx.compose.runtime.getValue
16+import androidx.compose.runtime.mutableIntStateOf
1417import androidx.compose.runtime.remember
18+import androidx.compose.runtime.setValue
1519import androidx.compose.runtime.staticCompositionLocalOf
1620import androidx.compose.ui.platform.LocalContext
21+import androidx.lifecycle.Lifecycle
22+import androidx.lifecycle.LifecycleEventObserver
23+import androidx.lifecycle.compose.LocalLifecycleOwner
1724
1825/**
1926 * Whether the user has reduced motion enabled at the system level (animator duration scale
@@ -23,11 +30,24 @@ import androidx.compose.ui.platform.LocalContext
2330 * Note: Compose's animation system does NOT honor [Settings.Global.ANIMATOR_DURATION_SCALE]
2431 * (that setting only gates the View-system ValueAnimator/ObjectAnimator), so each Compose
2532 * animation must opt in via [LocalReducedMotion].
33+ *
34+ * Re-reads on each ON_RESUME so toggling "Remove animations" / animator scale in Developer
35+ * Options takes effect without a process restart (keying only on `context`, which is stable
36+ * for the activity's lifetime, would otherwise cache the first read forever).
2637 */
2738@Composable
2839fun isReducedMotion(): Boolean {
2940 val context = LocalContext.current
30− return remember(context) {
41+ var resumeTick by remember { mutableIntStateOf(0) }
42+ val lifecycleOwner = LocalLifecycleOwner.current
43+ DisposableEffect(lifecycleOwner) {
44+ val observer = LifecycleEventObserver { _, event ->
45+ if (event == Lifecycle.Event.ON_RESUME) resumeTick++
46+ }
47+ lifecycleOwner.lifecycle.addObserver(observer)
48+ onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
49+ }
50+ return remember(context, resumeTick) {
3151 runCatching {
3252 Settings.Global.getFloat(
3353 context.contentResolver,
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+4−2
@@ -553,7 +553,9 @@ private fun DirectoryListing(
553553 }
554554 item(key = "__sort") {
555555 Row(
556− modifier = Modifier.fillMaxWidth().padding(end = 8.dp, top = 2.dp, bottom = 2.dp),
556+ modifier = Modifier.fillMaxWidth()
557+ .horizontalScroll(rememberScrollState())
558+ .padding(end = 8.dp, top = 2.dp, bottom = 2.dp),
557559 horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
558560 verticalAlignment = Alignment.CenterVertically,
559561 ) {
@@ -610,7 +612,7 @@ private fun DirectoryListing(
610612 Icon(Icons.Filled.Folder, contentDescription = null, modifier = Modifier.size(16.dp))
611613 }
612614 },
613− label = { Text(stringResource(R.string.sort_name), style = MaterialTheme.typography.labelSmall) },
615+ label = { Text(stringResource(R.string.folders_first), style = MaterialTheme.typography.labelSmall) },
614616 )
615617 }
616618 }
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+4−2
@@ -154,10 +154,12 @@ fun FileViewScreen(
154154 }
155155 // Split the raw text exactly once here (the line viewer and find-in-file both consume this
156156 // list) and cap it so a huge file doesn't get split twice or overwhelm the LazyColumn.
157+ // `take()` (not `subList()`) so the cap actually releases the tail: subList returns a view
158+ // backed by the full list, which would keep every line (and the full rawText) in memory.
157159 val allLines = remember(rawText) { rawText.split("\n") }
158160 val truncated = allLines.size > MAX_RENDERED_LINES
159− val lines = remember(allLines) {
160− if (allLines.size > MAX_RENDERED_LINES) allLines.subList(0, MAX_RENDERED_LINES) else allLines
161+ val lines = remember(rawText) {
162+ if (allLines.size > MAX_RENDERED_LINES) allLines.take(MAX_RENDERED_LINES) else allLines
161163 }
162164 // The line the viewer was opened at (from a search hit), highlighted until the user scrolls
163165 // or starts an in-file find so the landed line stands out. Null once cleared.
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+20−2
@@ -97,6 +97,7 @@ fun ServerListScreen(
9797) {
9898 val vm: ServerListViewModel = viewModel(factory = vmFactory { ServerListViewModel(container) })
9999 val profiles by vm.profiles.collectAsStateWithLifecycle()
100+ val loading by vm.loading.collectAsStateWithLifecycle()
100101 val connectingId by vm.connectingId.collectAsStateWithLifecycle()
101102 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
102103 val reconnecting by container.reconnecting.collectAsStateWithLifecycle()
@@ -253,7 +254,22 @@ fun ServerListScreen(
253254 .semantics { contentDescription = reconnectingLabel },
254255 )
255256 }
256− if (profiles.isEmpty()) {
257+ if (loading) {
258+ // Initial DataStore read in flight: show a spinner rather than flashing the
259+ // "No servers yet" empty state, which would appear then vanish once profiles
260+ // arrive. Mirrors the session list's explicit loading state.
261+ val loadingLabel = stringResource(R.string.loading)
262+ Box(
263+ modifier = Modifier.fillMaxSize(),
264+ contentAlignment = Alignment.Center,
265+ ) {
266+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
267+ CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
268+ Spacer(Modifier.size(12.dp))
269+ Text(loadingLabel, color = MaterialTheme.colorScheme.onSurfaceVariant)
270+ }
271+ }
272+ } else if (profiles.isEmpty()) {
257273 EmptyServers(
258274 onAdd = onAddProfile,
259275 modifier = Modifier.fillMaxSize(),
@@ -508,7 +524,9 @@ private fun ServerCardContent(
508524 val editLabel = stringResource(R.string.edit)
509525 val duplicateLabel = stringResource(R.string.duplicate_server)
510526 val removeLabel = stringResource(R.string.remove)
511− val moreLabel = stringResource(R.string.more)
527+ // Tie the overflow button's a11y label to this server so a TalkBack user scrolling
528+ // a long list can tell the rows apart (a bare "More" on every row is ambiguous).
529+ val moreLabel = stringResource(R.string.more_options_for, profile.displayLabel)
512530 Box {
513531 IconButton(onClick = { showRowMenu = true }) {
514532 Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+12
@@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.StateFlow
1717import kotlinx.coroutines.flow.asSharedFlow
1818import kotlinx.coroutines.flow.asStateFlow
1919import kotlinx.coroutines.flow.combine
20+import kotlinx.coroutines.flow.map
2021import kotlinx.coroutines.flow.stateIn
2122import kotlinx.coroutines.flow.update
2223import kotlinx.coroutines.launch
@@ -64,6 +65,17 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
6465 private val _connecting = MutableStateFlow<String?>(null)
6566 val connectingId: StateFlow<String?> = _connecting.asStateFlow()
6667
68+ /** True until the first batch of profiles lands from DataStore, so the screen can render a
69+ * loading spinner instead of flashing the empty state (the [profiles] flow's initial value
70+ * is an empty list, which is indistinguishable from "loaded, no servers" without this). */
71+ val loading: StateFlow<Boolean> =
72+ container.profileStore.profiles.map { false }
73+ .stateIn(
74+ scope = viewModelScope,
75+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
76+ initialValue = true,
77+ )
78+
6779 /** One-shot error events surfaced as snackbars. A SharedFlow (not StateFlow) so each
6880 * emission is delivered independently. */
6981 private val _errorEvents = MutableSharedFlow<ConnectError>(
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+67−27
@@ -151,6 +151,7 @@ fun SessionListScreen(
151151 val haptics = LocalHapticFeedback.current
152152 val snackbar = remember { SnackbarHostState() }
153153 val undoLabel = stringResource(R.string.undo)
154+ val retryLabel = stringResource(R.string.retry)
154155 val sessionDeletedLabel = stringResource(R.string.session_deleted)
155156 // One shared timer drives every relative-time label in the session list instead of
156157 // each card spinning up its own coroutine + lifecycle observer while scrolling.
@@ -213,6 +214,19 @@ fun SessionListScreen(
213214 }
214215 }
215216
217+ // Retryable failures (create/rename): offer a Retry action so a transient network error
218+ // is recoverable in one tap instead of forcing the user back through the dialog/FAB.
219+ LaunchedEffect(Unit) {
220+ vm.retryableErrors.collect { err ->
221+ val result = snackbar.showSnackbar(
222+ message = err.message,
223+ actionLabel = retryLabel,
224+ duration = androidx.compose.material3.SnackbarDuration.Long,
225+ )
226+ if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) err.retry()
227+ }
228+ }
229+
216230 // Undo snackbar: when a session is marked for deferred deletion, offer Undo. If the
217231 // action is taken before the delay expires, the session is restored and the REST
218232 // delete never fires. Indefinite + a matching timed dismiss keeps the Undo button
@@ -403,32 +417,37 @@ fun SessionListScreen(
403417 // Exit selection mode on the system back gesture instead of navigating away.
404418 androidx.activity.compose.BackHandler(enabled = inSelection) { selectedIds = emptySet() }
405419 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
406− Box(modifier = Modifier.fillMaxSize().padding(padding)) {
420+ // Banner sits in the column flow (not overlaid via TopCenter) so it pushes the list
421+ // down when it appears instead of covering the search field and top cards. Its own
422+ // AnimatedVisibility collapses it to zero height when the stream is healthy, so the
423+ // body fills the space normally.
424+ Column(modifier = Modifier.fillMaxSize().padding(padding)) {
407425 ConnectionBanner(
408426 state = connectionState,
409− modifier = Modifier.align(Alignment.TopCenter),
410427 isOnline = isOnline,
411428 onRetry = { vm.retryConnection() },
412429 )
413− SessionListBody(
414− state = state,
415− unread = unread,
416− refreshing = refreshing,
417− haptics = haptics,
418− selectedSessionId = selectedSessionId,
419− onRefresh = vm::refresh,
420− onQueryChange = vm::setQuery,
421− onOpenSession = onOpenSession,
422− onCreateSession = openNewSession,
423− onRename = { pendingRenameId = it },
424− onDelete = { pendingDeleteId = it },
425− onPin = { vm.togglePin(it) },
426− onArchive = { vm.toggleArchive(it) },
427− onFilterByDirectory = vm::setDirectoryFilter,
428− inSelection = inSelection,
429− selectedIds = selectedIds,
430− onToggleSelect = ::toggleSelected,
431− )
430+ Box(modifier = Modifier.fillMaxSize()) {
431+ SessionListBody(
432+ state = state,
433+ unread = unread,
434+ refreshing = refreshing,
435+ haptics = haptics,
436+ selectedSessionId = selectedSessionId,
437+ onRefresh = vm::refresh,
438+ onQueryChange = vm::setQuery,
439+ onOpenSession = onOpenSession,
440+ onCreateSession = openNewSession,
441+ onRename = { pendingRenameId = it },
442+ onDelete = { pendingDeleteId = it },
443+ onPin = { vm.togglePin(it) },
444+ onArchive = { vm.toggleArchive(it) },
445+ onFilterByDirectory = vm::setDirectoryFilter,
446+ inSelection = inSelection,
447+ selectedIds = selectedIds,
448+ onToggleSelect = ::toggleSelected,
449+ )
450+ }
432451 }
433452 }
434453 }
@@ -476,8 +495,11 @@ fun SessionListScreen(
476495 val count = selectedIds.size
477496 AlertDialog(
478497 onDismissRequest = { showBulkDelete = false },
479− title = { Text(stringResource(R.string.delete)) },
480− text = { Text(pluralStringResource(R.plurals.selected_count, count, count)) },
498+ title = { Text(stringResource(R.string.bulk_delete_title)) },
499+ // Bulk delete is NOT undoable (unlike single delete which offers Undo), so the
500+ // warning must be explicit — matching the single-delete dialog's irreversibility text
501+ // rather than the bare "%d selected" count.
502+ text = { Text(stringResource(R.string.bulk_delete_text, count)) },
481503 confirmButton = {
482504 TextButton(onClick = {
483505 showBulkDelete = false
@@ -634,6 +656,14 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
634656 style = MaterialTheme.typography.bodyMedium,
635657 color = MaterialTheme.colorScheme.onSurfaceVariant,
636658 )
659+ // An empty filtered list is recoverable in one tap; offer it inline
660+ // rather than making the user hunt for the field's trailing clear icon.
661+ if (state.query.isNotEmpty()) {
662+ Spacer(Modifier.size(8.dp))
663+ TextButton(onClick = { onQueryChange("") }) {
664+ Text(stringResource(R.string.clear_search))
665+ }
666+ }
637667 }
638668 }
639669 } else {
@@ -698,8 +728,12 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
698728 state = swipeState,
699729 // Swipe end-to-start deletes (dialog-guarded); start-to-end
700730 // archives/unarchives (instantly undoable via snackbar).
701− enableDismissFromStartToEnd = true,
702− enableDismissFromEndToStart = true,
731+ // Disabled entirely during selection mode so a swipe can't
732+ // trigger delete/archive while the user is multi-selecting —
733+ // the gestures would conflict and an errant swipe could drop
734+ // a session that was merely being selected.
735+ enableDismissFromStartToEnd = !inSelection,
736+ enableDismissFromEndToStart = !inSelection,
703737 // Indent sub-sessions under their parent (capped so deep
704738 // nesting doesn't squeeze the card off-screen).
705739 modifier = Modifier
@@ -1051,13 +1085,16 @@ private fun SessionCard(
10511085 val filterLabel = stringResource(
10521086 if (isDirectoryFiltered) R.string.clear_project_filter else R.string.filter_by_project,
10531087 )
1088+ // Disable the chip during selection mode so a tap toggles selection rather
1089+ // than silently applying a directory filter mid-multi-select.
1090+ val chipEnabled = onFilterByDirectory != null && !inSelection
10541091 Row(
10551092 verticalAlignment = Alignment.CenterVertically,
10561093 modifier = Modifier
10571094 .padding(top = 2.dp)
10581095 .clip(MaterialTheme.shapes.small)
10591096 .then(
1060− if (onFilterByDirectory != null) {
1097+ if (chipEnabled) {
10611098 Modifier.clickable(role = Role.Button, onClickLabel = filterLabel) {
10621099 onFilterByDirectory(session.directory ?: dir)
10631100 }
@@ -1093,7 +1130,10 @@ private fun SessionCard(
10931130 var showRowMenu by rememberSaveable(session.id) { mutableStateOf(false) }
10941131 val renameLabel = stringResource(R.string.rename)
10951132 val deleteLabel = stringResource(R.string.delete)
1096− val moreLabel = stringResource(R.string.more)
1133+ // Include the session title in the overflow button's a11y label so a TalkBack
1134+ // user scrolling a long list doesn't hear an undifferentiated run of "More"
1135+ // buttons with no indication of which session each belongs to.
1136+ val moreLabel = stringResource(R.string.more_options_for, session.displayTitle)
10971137 val pinLabel = stringResource(if (isPinned) R.string.session_unpin else R.string.session_pin)
10981138 val archiveLabel = stringResource(if (isArchived) R.string.session_unarchive else R.string.session_archive)
10991139 Box {
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+24−2
@@ -189,6 +189,17 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
189189 )
190190 val transientErrors: SharedFlow<String> = _transientErrors.asSharedFlow()
191191
192+ /** A transient error paired with a retry action (create/rename). The retry lambda lets
193+ * the snackbar offer a "Retry" button so a transient network failure doesn't force the
194+ * user back through the dialog/FAB to re-attempt — matching the server list's connect
195+ * error retry. The lambda is one-shot (consumed by the snackbar action). */
196+ data class RetryableError(val message: String, val retry: () -> Unit)
197+ private val _retryableErrors = MutableSharedFlow<RetryableError>(
198+ extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
199+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
200+ )
201+ val retryableErrors: SharedFlow<RetryableError> = _retryableErrors.asSharedFlow()
202+
192203 /** One-shot events carrying the id of a session marked for deferred deletion, so the
193204 * UI can show an Undo snackbar. */
194205 private val _undoEvents = MutableSharedFlow<String>(
@@ -661,7 +672,13 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
661672 .onSuccess { lastChosenDirectory = dir; onCreated(it.id); refresh() }
662673 .onFailure {
663674 _state.update { it.copy(error = null) }
664− _transientErrors.tryEmit(container.friendlyError(it))
675+ // Offer a retry so a transient failure doesn't lose the user's chosen
676+ // directory/title and force them back through the picker.
677+ _retryableErrors.tryEmit(
678+ RetryableError(container.friendlyError(it)) {
679+ createSession(directory = directory, title = title, onCreated = onCreated)
680+ },
681+ )
665682 }
666683 } finally {
667684 _creating.value = false
@@ -844,7 +861,12 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
844861 .onSuccess { refresh() }
845862 .onFailure {
846863 _state.update { it.copy(error = null) }
847− _transientErrors.tryEmit(container.friendlyError(it))
864+ // Offer a retry so a transient rename failure is recoverable in one tap.
865+ _retryableErrors.tryEmit(
866+ RetryableError(container.friendlyError(it)) {
867+ renameSession(session, newTitle)
868+ },
869+ )
848870 }
849871 }
850872 }
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+9−7
@@ -100,13 +100,15 @@ fun TwoPaneSessionChat(
100100 )
101101
102102 // 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 composition (the `autoSelected`
104− // latch, non-saveable so it re-arms after process death), and only once the first list
105− // load has completed. It defers to any explicit selection already present — a restored
106− // `selected`, a deep-link/notification (pendingOpenSession), or a user pick — and, since
107− // the latch is set the moment any of those wins, it never re-selects after a user clears
108− // the pane (BackHandler) or a server switch nulls `selected`.
109− var autoSelected by remember { mutableStateOf(false) }
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`.
111+ var autoSelected by rememberSaveable { mutableStateOf(false) }
110112 LaunchedEffect(
111113 sessionListState.loading,
112114 sessionListState.sessions,
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+22−9
@@ -54,6 +54,7 @@ import androidx.compose.runtime.Composable
5454import androidx.compose.runtime.CompositionLocalProvider
5555import androidx.compose.runtime.LaunchedEffect
5656import androidx.compose.runtime.getValue
57+import androidx.compose.runtime.mutableIntStateOf
5758import androidx.compose.runtime.mutableStateOf
5859import androidx.compose.runtime.remember
5960import androidx.compose.runtime.rememberCoroutineScope
@@ -71,7 +72,6 @@ import soy.iko.opencode.util.runCatchingCancellable
7172import soy.iko.opencode.ui.components.AppTopBar
7273import soy.iko.opencode.ui.components.EmptyState
7374import soy.iko.opencode.ui.components.copyToClipboard
74−import soy.iko.opencode.ui.components.showToast
7575import androidx.compose.ui.Alignment
7676import androidx.compose.ui.Modifier
7777import androidx.compose.ui.draw.clip
@@ -135,7 +135,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
135135 context.contentResolver.openOutputStream(uri)?.use { it.write(all.toByteArray()) }
136136 }
137137 }.isSuccess
138− if (ok) showToast(context, context.getString(R.string.export_all))
138+ if (ok) snackbar.showSnackbar(context.getString(R.string.export_all))
139139 }
140140 }
141141
@@ -327,7 +327,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
327327 // failures (e.g. an oversized report exceeding the Binder limit →
328328 // TransactionTooLargeException) get a generic toast, not that misleading one.
329329 val msg = if (it is ActivityNotFoundException) R.string.no_share_app else R.string.error_generic
330− showToast(context, context.getString(msg))
330+ shareScope.launch { snackbar.showSnackbar(context.getString(msg)) }
331331 }
332332 }
333333 }) {
@@ -370,7 +370,12 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
370370 if (reportName != null) {
371371 var reportContent by remember(reportName) { mutableStateOf<String?>(null) }
372372 var loadFailed by remember(reportName) { mutableStateOf(false) }
373− LaunchedEffect(reportName) {
373+ // A retry trigger: bumping this re-runs the load LaunchedEffect (keyed on it) and
374+ // clears the failure state, so a transient read error is recoverable without closing
375+ // and reopening the report — matching the file viewer's error-retry pattern.
376+ var retryKey by remember(reportName) { mutableIntStateOf(0) }
377+ LaunchedEffect(reportName, retryKey) {
378+ if (retryKey > 0) { reportContent = null; loadFailed = false }
374379 val loaded = withContext(Dispatchers.IO) {
375380 // runCatchingCancellable (not runCatching) so dismissing the dialog or
376381 // switching reports mid-read lets the CancellationException propagate
@@ -420,7 +425,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
420425 // (e.g. an oversized report exceeding the Binder limit → TransactionTooLargeException)
421426 // get a generic toast, not that misleading one.
422427 val msg = if (it is ActivityNotFoundException) R.string.no_share_app else R.string.error_generic
423− showToast(context, context.getString(msg))
428+ shareScope.launch { snackbar.showSnackbar(context.getString(msg)) }
424429 }
425430 },
426431 ) { Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share)) }
@@ -450,10 +455,18 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
450455 modifier = Modifier.fillMaxSize().padding(padding),
451456 contentAlignment = Alignment.Center,
452457 ) {
453− Text(
454− stringResource(R.string.report_load_failed),
455− color = MaterialTheme.colorScheme.error,
456− )
458+ Column(
459+ horizontalAlignment = Alignment.CenterHorizontally,
460+ verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp),
461+ ) {
462+ Text(
463+ stringResource(R.string.report_load_failed),
464+ color = MaterialTheme.colorScheme.error,
465+ )
466+ TextButton(onClick = { retryKey++ }) {
467+ Text(stringResource(R.string.retry))
468+ }
469+ }
457470 }
458471 else -> {
459472 val loadingLabel = stringResource(R.string.loading)
app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+14
@@ -100,11 +100,25 @@ private val LightColors = lightColorScheme(
100100 * displays power off pixels in large black areas, extending battery life. The accent
101101 * colours stay the same so the brand identity is preserved; only the backgrounds and
102102 * surfaces drop to true black.
103+ *
104+ * Every Material 3 surface role is overridden — not just `background`/`surface` — because
105+ * stock M3 components (Card, ModalBottomSheet, AlertDialog, MenuItem) read the
106+ * `surfaceContainer*` / `surfaceBright` / `surfaceDim` tiers internally. Leaving those at
107+ * the Tokyo-Night-dark values would leak non-black dialogs and sheets, undercutting the
108+ * OLED power-off promise. The container tiers step up from pure black so elevated surfaces
109+ * (cards over the list, dialogs) still read as a distinct layer without powering pixels on.
103110 */
104111private val AmoledColors = DarkColors.copy(
105112 background = Color(0xFF000000),
106113 surface = Color(0xFF000000),
107114 surfaceVariant = Color(0xFF1A1F2E),
115+ surfaceDim = Color(0xFF000000),
116+ surfaceBright = Color(0xFF1A1F2E),
117+ surfaceContainerLowest = Color(0xFF000000),
118+ surfaceContainerLow = Color(0xFF000000),
119+ surfaceContainer = Color(0xFF0A0B10),
120+ surfaceContainerHigh = Color(0xFF12141C),
121+ surfaceContainerHighest = Color(0xFF1A1F2E),
108122 inverseOnSurface = Color(0xFF000000),
109123)
110124
app/src/main/res/values-es/strings.xml+10
@@ -671,4 +671,14 @@
671671 <string name="clear_project_filter">Quitar filtro de proyecto</string>
672672 <string name="empty_file_detail_title">Ningún archivo seleccionado</string>
673673 <string name="empty_file_detail_desc">Elige un archivo de la lista para verlo aquí.</string>
674+
675+ <!-- UI/UX batch 4: file browser label, a11y, bulk confirmations -->
676+ <string name="folders_first">Carpetas primero</string>
677+ <string name="more_options_for">Más opciones para “%1$s”</string>
678+ <string name="bulk_delete_title">¿Eliminar sesiones?</string>
679+ <string name="bulk_delete_text">Estas %1$d sesiones se eliminarán permanentemente. No se puede deshacer.</string>
680+ <plurals name="reasoning_word_count">
681+ <item quantity="one">%1$d palabra oculta</item>
682+ <item quantity="other">%1$d palabras ocultas</item>
683+ </plurals>
674684</resources>
app/src/main/res/values/strings.xml+10
@@ -671,4 +671,14 @@
671671 <string name="clear_project_filter">Clear project filter</string>
672672 <string name="empty_file_detail_title">No file selected</string>
673673 <string name="empty_file_detail_desc">Pick a file from the list to view it here.</string>
674+
675+ <!-- UI/UX batch 4: file browser label, a11y, bulk confirmations -->
676+ <string name="folders_first">Folders first</string>
677+ <string name="more_options_for">More options for “%1$s”</string>
678+ <string name="bulk_delete_title">Delete sessions?</string>
679+ <string name="bulk_delete_text">These %1$d sessions will be permanently deleted. This cannot be undone.</string>
680+ <plurals name="reasoning_word_count">
681+ <item quantity="one">%1$d word hidden</item>
682+ <item quantity="other">%1$d words hidden</item>
683+ </plurals>
674684</resources>