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
4fd9a39iko
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+10−2
@@ -219,10 +219,18 @@ fun OpencodeApp(container: AppContainer) {
219
219
220 // Deep-linked file open (opencode://file/{path}): navigate to the file viewer once a
220 // Deep-linked file open (opencode://file/{path}): navigate to the file viewer once a
221 // connection exists (the viewer fetches content from the server). Path comes pre-validated
221 // connection exists (the viewer fetches content from the server). Path comes pre-validated
222− // by MainActivity's deep-link guard.
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.
223− LaunchedEffect(pendingOpenFile, connection) {
225+ LaunchedEffect(pendingOpenFile, connection, isTwoPane) {
224 val path = pendingOpenFile ?: return@LaunchedEffect
226 val path = pendingOpenFile ?: return@LaunchedEffect
225 if (connection == null) return@LaunchedEffect
227 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+ }
226 container.consumePendingOpenFile()
234 container.consumePendingOpenFile()
227 if (!navController.popBackStack(Routes.FILES, inclusive = false)) {
235 if (!navController.popBackStack(Routes.FILES, inclusive = false)) {
228 navController.navigate(Routes.FILES) { launchSingleTop = true }
236 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
133import androidx.compose.ui.res.stringResource
133import androidx.compose.ui.res.stringResource
134import androidx.compose.ui.semantics.Role
134import androidx.compose.ui.semantics.Role
135import androidx.compose.ui.semantics.contentDescription
135import androidx.compose.ui.semantics.contentDescription
136+import androidx.compose.ui.semantics.heading
136import androidx.compose.ui.semantics.semantics
137import androidx.compose.ui.semantics.semantics
137import androidx.compose.ui.text.input.ImeAction
138import androidx.compose.ui.text.input.ImeAction
138import androidx.compose.ui.text.style.TextOverflow
139import androidx.compose.ui.text.style.TextOverflow
@@ -268,6 +269,11 @@ fun ChatScreen(
268 var showShortcutsDialog by rememberSaveable { mutableStateOf(false) }
269 var showShortcutsDialog by rememberSaveable { mutableStateOf(false) }
269 var searchActive by rememberSaveable { mutableStateOf(false) }
270 var searchActive by rememberSaveable { mutableStateOf(false) }
270 var chatSearch by rememberSaveable { mutableStateOf("") }
271 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) }
271 // Summarize and init (generate AGENTS.md) are irreversible, so a single tap is gated behind
277 // Summarize and init (generate AGENTS.md) are irreversible, so a single tap is gated behind
272 // a confirmation dialog (from both the overflow menu and the command palette).
278 // a confirmation dialog (from both the overflow menu and the command palette).
273 var showSummarizeConfirm by rememberSaveable { mutableStateOf(false) }
279 var showSummarizeConfirm by rememberSaveable { mutableStateOf(false) }
@@ -490,7 +496,11 @@ fun ChatScreen(
490 if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
496 if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
491 when {
497 when {
492 ev.isCtrlPressed && ev.key == Key.K -> { showPalette = true; true }
498 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 }
493 ev.key == Key.Escape && showPalette -> { showPalette = false; true }
502 ev.key == Key.Escape && showPalette -> { showPalette = false; true }
503+ ev.key == Key.Escape && searchActive -> { searchActive = false; chatSearch = ""; true }
494 ev.key == Key.Escape && running && !showStopConfirm && !showExitConfirm -> {
504 ev.key == Key.Escape && running && !showStopConfirm && !showExitConfirm -> {
495 showStopConfirm = true; true
505 showStopConfirm = true; true
496 }
506 }
@@ -824,6 +834,11 @@ fun ChatScreen(
824 m.parts.any { p -> (p as? TextPart)?.text?.contains(searchQuery, ignoreCase = true) == true }
834 m.parts.any { p -> (p as? TextPart)?.text?.contains(searchQuery, ignoreCase = true) == true }
825 }
835 }
826 }
836 }
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+ }
827 val listItems = remember(searchMessages, todayLabel, yesterdayLabel) {
842 val listItems = remember(searchMessages, todayLabel, yesterdayLabel) {
828 buildMessageListItems(searchMessages, todayLabel, yesterdayLabel)
843 buildMessageListItems(searchMessages, todayLabel, yesterdayLabel)
829 }
844 }
@@ -887,6 +902,18 @@ fun ChatScreen(
887 }
902 }
888 }
903 }
889
904
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+
890 LaunchedEffect(Unit) {
917 LaunchedEffect(Unit) {
891 var prevSize = 0
918 var prevSize = 0
892 var prevLen = 0
919 var prevLen = 0
@@ -1114,11 +1141,33 @@ fun ChatScreen(
1114 key = { it.key },
1141 key = { it.key },
1115 contentType = { it.contentType },
1142 contentType = { it.contentType },
1116 ) { item ->
1143 ) { 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
1117 // Default placement animation so inserted/moved rows glide in. Skipped
1155 // Default placement animation so inserted/moved rows glide in. Skipped
1118 // entirely under reduced motion so a streaming message growing in place
1156 // entirely under reduced motion so a streaming message growing in place
1119 // doesn't animate on every token (and respects the a11y preference).
1157 // doesn't animate on every token (and respects the a11y preference).
1120 Box(
1158 Box(
1159+ Modifier
1121− if (LocalReducedMotion.current) Modifier else Modifier.animateItem(),
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+ ),
1122 ) {
1171 ) {
1123 when (item) {
1172 when (item) {
1124 is MessageListItem.Separator -> DateSeparator(item.label)
1173 is MessageListItem.Separator -> DateSeparator(item.label)
@@ -1256,30 +1305,48 @@ fun ChatScreen(
1256 }
1305 }
1257 val elapsedText = remember(elapsedMs) {
1306 val elapsedText = remember(elapsedMs) {
1258 val total = elapsedMs / 1000
1307 val total = elapsedMs / 1000
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.
1259− "%d:%02d".format(total / 60, total % 60)
1312+ String.format(java.util.Locale.US, "%d:%02d", total / 60, total % 60)
1260 }
1313 }
1261 Row(
1314 Row(
1262 verticalAlignment = Alignment.CenterVertically,
1315 verticalAlignment = Alignment.CenterVertically,
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.
1263− modifier = Modifier.semantics {
1319+ modifier = Modifier
1264− contentDescription = workingText
1320+ .animateItem()
1265− },
1321+ .padding(vertical = 4.dp),
1266 ) {
1322 ) {
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+ ) {
1267− CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
1333+ CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
1268− Text(workingText, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
1334+ Text(workingText, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
1269− Spacer(Modifier.size(8.dp))
1335+ Spacer(Modifier.size(8.dp))
1270− Icon(
1336+ Icon(
1271− Icons.Filled.Schedule,
1337+ Icons.Filled.Schedule,
1272− contentDescription = null,
1338+ contentDescription = null,
1273− modifier = Modifier.size(14.dp),
1339+ modifier = Modifier.size(14.dp),
1274− tint = MaterialTheme.colorScheme.onSurfaceVariant,
1340+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
1275− )
1341+ )
1276− Text(
1342+ Text(
1277− elapsedText,
1343+ elapsedText,
1278− style = MaterialTheme.typography.labelMedium,
1344+ style = MaterialTheme.typography.labelMedium,
1279− fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
1345+ fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
1280− color = MaterialTheme.colorScheme.onSurfaceVariant,
1346+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1281− modifier = Modifier.padding(start = 4.dp),
1347+ modifier = Modifier.padding(start = 4.dp),
1282− )
1348+ )
1349+ }
1283 // A trailing Stop on the working row so the run can be aborted
1350 // A trailing Stop on the working row so the run can be aborted
1284 // without scrolling back to the composer (which may be off-screen
1351 // without scrolling back to the composer (which may be off-screen
1285 // while the user reads the streaming output up-thread).
1352 // while the user reads the streaming output up-thread).
@@ -1387,14 +1454,44 @@ fun ChatScreen(
1387 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
1454 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
1388 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
1455 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
1389 )
1456 )
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
1390 Text(
1460 Text(
1391− if (searchQuery.isNotEmpty()) {
1461+ if (searchQuery.isNotEmpty() && matchCount > 0) {
1392− stringResource(R.string.search_match_count, searchMessages.size, messages.size)
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)
1393 } else "",
1465 } else "",
1394 style = MaterialTheme.typography.labelSmall,
1466 style = MaterialTheme.typography.labelSmall,
1395 color = MaterialTheme.colorScheme.onSurfaceVariant,
1467 color = MaterialTheme.colorScheme.onSurfaceVariant,
1396 modifier = Modifier.padding(horizontal = 4.dp),
1468 modifier = Modifier.padding(horizontal = 4.dp),
1397 )
1469 )
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+ }
1398 IconButton(onClick = {
1495 IconButton(onClick = {
1399 searchActive = false
1496 searchActive = false
1400 chatSearch = ""
1497 chatSearch = ""
@@ -1845,6 +1942,7 @@ private fun ChatInputBar(
1845 },
1942 },
1846 canSend = enabled && hasContent,
1943 canSend = enabled && hasContent,
1847 running = running,
1944 running = running,
1945+ sendOnEnter = sendOnEnter,
1848 attachments = attachments,
1946 attachments = attachments,
1849 onRemoveAttachment = onRemoveAttachment,
1947 onRemoveAttachment = onRemoveAttachment,
1850 staging = staging,
1948 staging = staging,
@@ -1864,6 +1962,7 @@ private fun FullScreenEditor(
1864 onSend: () -> Unit,
1962 onSend: () -> Unit,
1865 canSend: Boolean,
1963 canSend: Boolean,
1866 running: Boolean = false,
1964 running: Boolean = false,
1965+ sendOnEnter: Boolean = false,
1867 attachments: List<PendingAttachment> = emptyList(),
1966 attachments: List<PendingAttachment> = emptyList(),
1868 onRemoveAttachment: (String) -> Unit = {},
1967 onRemoveAttachment: (String) -> Unit = {},
1869 staging: Boolean = false,
1968 staging: Boolean = false,
@@ -1904,10 +2003,35 @@ private fun FullScreenEditor(
1904 OutlinedTextField(
2003 OutlinedTextField(
1905 value = value,
2004 value = value,
1906 onValueChange = { v -> onValueChange(v.take(NetworkConfig.maxDraftLengthChars)) },
2005 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+ },
1908 placeholder = { Text(stringResource(R.string.message_placeholder)) },
2026 placeholder = { Text(stringResource(R.string.message_placeholder)) },
1909 maxLines = Int.MAX_VALUE,
2027 maxLines = Int.MAX_VALUE,
2028+ keyboardOptions = KeyboardOptions(
2029+ imeAction = if (sendOnEnter) ImeAction.Send else ImeAction.Default,
1910− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Default, capitalization = KeyboardCapitalization.Sentences),
2030+ capitalization = KeyboardCapitalization.Sentences,
2031+ ),
2032+ keyboardActions = KeyboardActions(
2033+ onSend = { if (canSend) onSend() },
2034+ ),
1911 )
2035 )
1912 }
2036 }
1913 }
2037 }
@@ -2054,6 +2178,7 @@ private fun EmptyConversation(
2054/** A banner shown while a revert checkpoint is active, offering an Undo (unrevert). */
2178/** A banner shown while a revert checkpoint is active, offering an Undo (unrevert). */
2055@Composable
2179@Composable
2056private fun RevertBanner(onUndo: () -> Unit) {
2180private fun RevertBanner(onUndo: () -> Unit) {
2181+ val haptics = LocalHapticFeedback.current
2057 Surface(
2182 Surface(
2058 color = MaterialTheme.colorScheme.tertiaryContainer,
2183 color = MaterialTheme.colorScheme.tertiaryContainer,
2059 tonalElevation = 2.dp,
2184 tonalElevation = 2.dp,
@@ -2078,7 +2203,12 @@ private fun RevertBanner(onUndo: () -> Unit) {
2078 modifier = Modifier.padding(start = 8.dp),
2203 modifier = Modifier.padding(start = 8.dp),
2079 )
2204 )
2080 }
2205 }
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()
2081− TextButton(onClick = onUndo) { Text(stringResource(R.string.undo)) }
2211+ }) { Text(stringResource(R.string.undo)) }
2082 }
2212 }
2083 }
2213 }
2084}
2214}
@@ -2123,8 +2253,14 @@ private fun OutboxBanner(
2123 )
2253 )
2124 }
2254 }
2125 if (!sending) {
2255 if (!sending) {
2256+ val haptics = LocalHapticFeedback.current
2126 TextButton(onClick = onFlush) { Text(stringResource(R.string.outbox_flush)) }
2257 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+ }) {
2128 Text(stringResource(R.string.outbox_discard_all), color = MaterialTheme.colorScheme.error)
2264 Text(stringResource(R.string.outbox_discard_all), color = MaterialTheme.colorScheme.error)
2129 }
2265 }
2130 }
2266 }
@@ -2377,6 +2513,9 @@ private fun DateSeparator(label: String) {
2377 Surface(
2513 Surface(
2378 color = MaterialTheme.colorScheme.surfaceVariant,
2514 color = MaterialTheme.colorScheme.surfaceVariant,
2379 shape = MaterialTheme.shapes.small,
2515 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() },
2380 ) {
2519 ) {
2381 Text(
2520 Text(
2382 label,
2521 label,
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+12−3
@@ -51,6 +51,7 @@ import androidx.compose.ui.res.stringResource
51import androidx.compose.ui.semantics.Role
51import androidx.compose.ui.semantics.Role
52import androidx.compose.ui.semantics.contentDescription
52import androidx.compose.ui.semantics.contentDescription
53import androidx.compose.ui.semantics.semantics
53import androidx.compose.ui.semantics.semantics
54+import androidx.compose.ui.text.style.TextOverflow
54import androidx.compose.ui.unit.dp
55import androidx.compose.ui.unit.dp
55import androidx.compose.foundation.interaction.MutableInteractionSource
56import androidx.compose.foundation.interaction.MutableInteractionSource
56import soy.iko.opencode.R
57import soy.iko.opencode.R
@@ -528,7 +529,7 @@ private fun AssistantBlock(
528 Row(verticalAlignment = Alignment.CenterVertically) {
529 Row(verticalAlignment = Alignment.CenterVertically) {
529 Box(
530 Box(
530 modifier = Modifier
531 modifier = Modifier
531− .size(20.dp)
532+ .size(28.dp)
532 .clip(MaterialTheme.shapes.extraSmall)
533 .clip(MaterialTheme.shapes.extraSmall)
533 .background(MaterialTheme.colorScheme.secondaryContainer),
534 .background(MaterialTheme.colorScheme.secondaryContainer),
534 contentAlignment = Alignment.Center,
535 contentAlignment = Alignment.Center,
@@ -536,7 +537,7 @@ private fun AssistantBlock(
536 Icon(
537 Icon(
537 Icons.Filled.SmartToy,
538 Icons.Filled.SmartToy,
538 contentDescription = null,
539 contentDescription = null,
539− modifier = Modifier.size(14.dp),
540+ modifier = Modifier.size(18.dp),
540 tint = MaterialTheme.colorScheme.onSecondaryContainer,
541 tint = MaterialTheme.colorScheme.onSecondaryContainer,
541 )
542 )
542 }
543 }
@@ -545,7 +546,11 @@ private fun AssistantBlock(
545 label,
546 label,
546 style = MaterialTheme.typography.labelSmall,
547 style = MaterialTheme.typography.labelSmall,
547 color = MaterialTheme.colorScheme.onSurfaceVariant,
548 color = MaterialTheme.colorScheme.onSurfaceVariant,
549+ maxLines = 1,
550+ overflow = TextOverflow.Ellipsis,
548− modifier = Modifier.padding(start = 6.dp),
551+ modifier = Modifier
552+ .padding(start = 6.dp)
553+ .weight(1f, fill = false),
549 )
554 )
550 }
555 }
551 }
556 }
@@ -624,6 +629,10 @@ private fun AssistantBlock(
624 it,
629 it,
625 style = MaterialTheme.typography.labelSmall,
630 style = MaterialTheme.typography.labelSmall,
626 color = MaterialTheme.colorScheme.onSurfaceVariant,
631 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,
627 )
636 )
628 }
637 }
629 if (isEdited) {
638 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
182 val collapsedState = stringResource(R.string.state_collapsed)
182 val collapsedState = stringResource(R.string.state_collapsed)
183 val thinkingLabel = stringResource(R.string.thinking)
183 val thinkingLabel = stringResource(R.string.thinking)
184 val thoughtsLabel = stringResource(R.string.thoughts)
184 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+ )
185 Column(modifier = modifier.fillMaxWidth()) {
199 Column(modifier = modifier.fillMaxWidth()) {
186 Row(
200 Row(
187 modifier = Modifier
201 modifier = Modifier
@@ -190,7 +204,14 @@ private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modi
190 .clickable(role = Role.Button) { userOverride = !expanded }
204 .clickable(role = Role.Button) { userOverride = !expanded }
191 .padding(vertical = 4.dp)
205 .padding(vertical = 4.dp)
192 .semantics {
206 .semantics {
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 {
193− stateDescription = if (expanded) expandedState else collapsedState
211+ expanded -> expandedState
212+ streaming -> collapsedState
213+ else -> "$collapsedState, $collapsedWordsLabel"
214+ }
194 },
215 },
195 verticalAlignment = Alignment.CenterVertically,
216 verticalAlignment = Alignment.CenterVertically,
196 ) {
217 ) {
@@ -218,11 +239,8 @@ private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modi
218 // content is hidden before deciding to expand. Skipped while streaming (the
239 // content is hidden before deciding to expand. Skipped while streaming (the
219 // content is growing and the label already says "Thinking…").
240 // content is growing and the label already says "Thinking…").
220 if (!streaming && !expanded) {
241 if (!streaming && !expanded) {
221− val wordCount = remember(text) {
222− text.trim().split(Regex("\\s+")).count { it.isNotEmpty() }
223− }
224 Text(
242 Text(
225− "($wordCount)",
243+ "($collapsedWordCount)",
226 style = MaterialTheme.typography.labelSmall,
244 style = MaterialTheme.typography.labelSmall,
227 color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
245 color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
228 modifier = Modifier.padding(start = 4.dp),
246 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(
119 }
119 }
120 },
120 },
121 confirmButton = {
121 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.
122 Column(
127 Column(
123− modifier = Modifier.fillMaxWidth(),
128+ modifier = Modifier
129+ .fillMaxWidth()
130+ .heightIn(max = 320.dp)
131+ .verticalScroll(rememberScrollState()),
124 verticalArrangement = Arrangement.spacedBy(4.dp),
132 verticalArrangement = Arrangement.spacedBy(4.dp),
125 ) {
133 ) {
126 Button(
134 Button(
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+5
@@ -48,6 +48,7 @@ import androidx.compose.ui.Alignment
48import androidx.compose.ui.Modifier
48import androidx.compose.ui.Modifier
49import androidx.compose.ui.draw.clip
49import androidx.compose.ui.draw.clip
50import androidx.compose.ui.platform.LocalContext
50import androidx.compose.ui.platform.LocalContext
51+import androidx.compose.ui.platform.LocalHapticFeedback
51import androidx.compose.ui.res.pluralStringResource
52import androidx.compose.ui.res.pluralStringResource
52import androidx.compose.ui.res.stringResource
53import androidx.compose.ui.res.stringResource
53import androidx.compose.ui.text.AnnotatedString
54import androidx.compose.ui.text.AnnotatedString
@@ -244,6 +245,7 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
244 // visible confirmation even on Android 13+, where the copy toast is suppressed in favor of
245 // visible confirmation even on Android 13+, where the copy toast is suppressed in favor of
245 // the platform's tiny (and easy to miss) confirmation chip.
246 // the platform's tiny (and easy to miss) confirmation chip.
246 var copied by remember { mutableStateOf(false) }
247 var copied by remember { mutableStateOf(false) }
248+ val haptics = LocalHapticFeedback.current
247 LaunchedEffect(copied) {
249 LaunchedEffect(copied) {
248 if (copied) {
250 if (copied) {
249 delay(1200)
251 delay(1200)
@@ -294,6 +296,9 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
294 onClick = {
296 onClick = {
295 copyToClipboard(context, context.getString(R.string.clip_label_code), code)
297 copyToClipboard(context, context.getString(R.string.clip_label_code), code)
296 copied = true
298 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)
297 },
302 },
298 ) {
303 ) {
299 Icon(
304 Icon(
app/src/main/java/soy/iko/opencode/ui/components/ReducedMotion.kt+21−1
@@ -11,9 +11,16 @@ import androidx.compose.animation.fadeIn
11import androidx.compose.animation.fadeOut
11import androidx.compose.animation.fadeOut
12import androidx.compose.animation.shrinkVertically
12import androidx.compose.animation.shrinkVertically
13import androidx.compose.runtime.Composable
13import androidx.compose.runtime.Composable
14+import androidx.compose.runtime.DisposableEffect
15+import androidx.compose.runtime.getValue
16+import androidx.compose.runtime.mutableIntStateOf
14import androidx.compose.runtime.remember
17import androidx.compose.runtime.remember
18+import androidx.compose.runtime.setValue
15import androidx.compose.runtime.staticCompositionLocalOf
19import androidx.compose.runtime.staticCompositionLocalOf
16import androidx.compose.ui.platform.LocalContext
20import androidx.compose.ui.platform.LocalContext
21+import androidx.lifecycle.Lifecycle
22+import androidx.lifecycle.LifecycleEventObserver
23+import androidx.lifecycle.compose.LocalLifecycleOwner
17
24
18/**
25/**
19 * Whether the user has reduced motion enabled at the system level (animator duration scale
26 * Whether the user has reduced motion enabled at the system level (animator duration scale
@@ -23,11 +30,24 @@ import androidx.compose.ui.platform.LocalContext
23 * Note: Compose's animation system does NOT honor [Settings.Global.ANIMATOR_DURATION_SCALE]
30 * Note: Compose's animation system does NOT honor [Settings.Global.ANIMATOR_DURATION_SCALE]
24 * (that setting only gates the View-system ValueAnimator/ObjectAnimator), so each Compose
31 * (that setting only gates the View-system ValueAnimator/ObjectAnimator), so each Compose
25 * animation must opt in via [LocalReducedMotion].
32 * 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).
26 */
37 */
27@Composable
38@Composable
28fun isReducedMotion(): Boolean {
39fun isReducedMotion(): Boolean {
29 val context = LocalContext.current
40 val context = LocalContext.current
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+ }
30− return remember(context) {
50+ return remember(context, resumeTick) {
31 runCatching {
51 runCatching {
32 Settings.Global.getFloat(
52 Settings.Global.getFloat(
33 context.contentResolver,
53 context.contentResolver,
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+4−2
@@ -553,7 +553,9 @@ private fun DirectoryListing(
553 }
553 }
554 item(key = "__sort") {
554 item(key = "__sort") {
555 Row(
555 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),
557 horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
559 horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
558 verticalAlignment = Alignment.CenterVertically,
560 verticalAlignment = Alignment.CenterVertically,
559 ) {
561 ) {
@@ -610,7 +612,7 @@ private fun DirectoryListing(
610 Icon(Icons.Filled.Folder, contentDescription = null, modifier = Modifier.size(16.dp))
612 Icon(Icons.Filled.Folder, contentDescription = null, modifier = Modifier.size(16.dp))
611 }
613 }
612 },
614 },
613− label = { Text(stringResource(R.string.sort_name), style = MaterialTheme.typography.labelSmall) },
615+ label = { Text(stringResource(R.string.folders_first), style = MaterialTheme.typography.labelSmall) },
614 )
616 )
615 }
617 }
616 }
618 }
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+4−2
@@ -154,10 +154,12 @@ fun FileViewScreen(
154 }
154 }
155 // Split the raw text exactly once here (the line viewer and find-in-file both consume this
155 // Split the raw text exactly once here (the line viewer and find-in-file both consume this
156 // list) and cap it so a huge file doesn't get split twice or overwhelm the LazyColumn.
156 // 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.
157 val allLines = remember(rawText) { rawText.split("\n") }
159 val allLines = remember(rawText) { rawText.split("\n") }
158 val truncated = allLines.size > MAX_RENDERED_LINES
160 val truncated = allLines.size > MAX_RENDERED_LINES
159− val lines = remember(allLines) {
161+ val lines = remember(rawText) {
160− if (allLines.size > MAX_RENDERED_LINES) allLines.subList(0, MAX_RENDERED_LINES) else allLines
162+ if (allLines.size > MAX_RENDERED_LINES) allLines.take(MAX_RENDERED_LINES) else allLines
161 }
163 }
162 // The line the viewer was opened at (from a search hit), highlighted until the user scrolls
164 // The line the viewer was opened at (from a search hit), highlighted until the user scrolls
163 // or starts an in-file find so the landed line stands out. Null once cleared.
165 // 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(
97) {
97) {
98 val vm: ServerListViewModel = viewModel(factory = vmFactory { ServerListViewModel(container) })
98 val vm: ServerListViewModel = viewModel(factory = vmFactory { ServerListViewModel(container) })
99 val profiles by vm.profiles.collectAsStateWithLifecycle()
99 val profiles by vm.profiles.collectAsStateWithLifecycle()
100+ val loading by vm.loading.collectAsStateWithLifecycle()
100 val connectingId by vm.connectingId.collectAsStateWithLifecycle()
101 val connectingId by vm.connectingId.collectAsStateWithLifecycle()
101 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
102 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
102 val reconnecting by container.reconnecting.collectAsStateWithLifecycle()
103 val reconnecting by container.reconnecting.collectAsStateWithLifecycle()
@@ -253,7 +254,22 @@ fun ServerListScreen(
253 .semantics { contentDescription = reconnectingLabel },
254 .semantics { contentDescription = reconnectingLabel },
254 )
255 )
255 }
256 }
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+ }
256− if (profiles.isEmpty()) {
272+ } else if (profiles.isEmpty()) {
257 EmptyServers(
273 EmptyServers(
258 onAdd = onAddProfile,
274 onAdd = onAddProfile,
259 modifier = Modifier.fillMaxSize(),
275 modifier = Modifier.fillMaxSize(),
@@ -508,7 +524,9 @@ private fun ServerCardContent(
508 val editLabel = stringResource(R.string.edit)
524 val editLabel = stringResource(R.string.edit)
509 val duplicateLabel = stringResource(R.string.duplicate_server)
525 val duplicateLabel = stringResource(R.string.duplicate_server)
510 val removeLabel = stringResource(R.string.remove)
526 val removeLabel = stringResource(R.string.remove)
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).
511− val moreLabel = stringResource(R.string.more)
529+ val moreLabel = stringResource(R.string.more_options_for, profile.displayLabel)
512 Box {
530 Box {
513 IconButton(onClick = { showRowMenu = true }) {
531 IconButton(onClick = { showRowMenu = true }) {
514 Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
532 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
17import kotlinx.coroutines.flow.asSharedFlow
17import kotlinx.coroutines.flow.asSharedFlow
18import kotlinx.coroutines.flow.asStateFlow
18import kotlinx.coroutines.flow.asStateFlow
19import kotlinx.coroutines.flow.combine
19import kotlinx.coroutines.flow.combine
20+import kotlinx.coroutines.flow.map
20import kotlinx.coroutines.flow.stateIn
21import kotlinx.coroutines.flow.stateIn
21import kotlinx.coroutines.flow.update
22import kotlinx.coroutines.flow.update
22import kotlinx.coroutines.launch
23import kotlinx.coroutines.launch
@@ -64,6 +65,17 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
64 private val _connecting = MutableStateFlow<String?>(null)
65 private val _connecting = MutableStateFlow<String?>(null)
65 val connectingId: StateFlow<String?> = _connecting.asStateFlow()
66 val connectingId: StateFlow<String?> = _connecting.asStateFlow()
66
67
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+
67 /** One-shot error events surfaced as snackbars. A SharedFlow (not StateFlow) so each
79 /** One-shot error events surfaced as snackbars. A SharedFlow (not StateFlow) so each
68 * emission is delivered independently. */
80 * emission is delivered independently. */
69 private val _errorEvents = MutableSharedFlow<ConnectError>(
81 private val _errorEvents = MutableSharedFlow<ConnectError>(
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+67−27
@@ -151,6 +151,7 @@ fun SessionListScreen(
151 val haptics = LocalHapticFeedback.current
151 val haptics = LocalHapticFeedback.current
152 val snackbar = remember { SnackbarHostState() }
152 val snackbar = remember { SnackbarHostState() }
153 val undoLabel = stringResource(R.string.undo)
153 val undoLabel = stringResource(R.string.undo)
154+ val retryLabel = stringResource(R.string.retry)
154 val sessionDeletedLabel = stringResource(R.string.session_deleted)
155 val sessionDeletedLabel = stringResource(R.string.session_deleted)
155 // One shared timer drives every relative-time label in the session list instead of
156 // One shared timer drives every relative-time label in the session list instead of
156 // each card spinning up its own coroutine + lifecycle observer while scrolling.
157 // each card spinning up its own coroutine + lifecycle observer while scrolling.
@@ -213,6 +214,19 @@ fun SessionListScreen(
213 }
214 }
214 }
215 }
215
216
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+
216 // Undo snackbar: when a session is marked for deferred deletion, offer Undo. If the
230 // Undo snackbar: when a session is marked for deferred deletion, offer Undo. If the
217 // action is taken before the delay expires, the session is restored and the REST
231 // action is taken before the delay expires, the session is restored and the REST
218 // delete never fires. Indefinite + a matching timed dismiss keeps the Undo button
232 // delete never fires. Indefinite + a matching timed dismiss keeps the Undo button
@@ -403,32 +417,37 @@ fun SessionListScreen(
403 // Exit selection mode on the system back gesture instead of navigating away.
417 // Exit selection mode on the system back gesture instead of navigating away.
404 androidx.activity.compose.BackHandler(enabled = inSelection) { selectedIds = emptySet() }
418 androidx.activity.compose.BackHandler(enabled = inSelection) { selectedIds = emptySet() }
405 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
419 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
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.
406− Box(modifier = Modifier.fillMaxSize().padding(padding)) {
424+ Column(modifier = Modifier.fillMaxSize().padding(padding)) {
407 ConnectionBanner(
425 ConnectionBanner(
408 state = connectionState,
426 state = connectionState,
409− modifier = Modifier.align(Alignment.TopCenter),
410 isOnline = isOnline,
427 isOnline = isOnline,
411 onRetry = { vm.retryConnection() },
428 onRetry = { vm.retryConnection() },
412 )
429 )
430+ Box(modifier = Modifier.fillMaxSize()) {
413− SessionListBody(
431+ SessionListBody(
414− state = state,
432+ state = state,
415− unread = unread,
433+ unread = unread,
416− refreshing = refreshing,
434+ refreshing = refreshing,
417− haptics = haptics,
435+ haptics = haptics,
418− selectedSessionId = selectedSessionId,
436+ selectedSessionId = selectedSessionId,
419− onRefresh = vm::refresh,
437+ onRefresh = vm::refresh,
420− onQueryChange = vm::setQuery,
438+ onQueryChange = vm::setQuery,
421− onOpenSession = onOpenSession,
439+ onOpenSession = onOpenSession,
422− onCreateSession = openNewSession,
440+ onCreateSession = openNewSession,
423− onRename = { pendingRenameId = it },
441+ onRename = { pendingRenameId = it },
424− onDelete = { pendingDeleteId = it },
442+ onDelete = { pendingDeleteId = it },
425− onPin = { vm.togglePin(it) },
443+ onPin = { vm.togglePin(it) },
426− onArchive = { vm.toggleArchive(it) },
444+ onArchive = { vm.toggleArchive(it) },
427− onFilterByDirectory = vm::setDirectoryFilter,
445+ onFilterByDirectory = vm::setDirectoryFilter,
428− inSelection = inSelection,
446+ inSelection = inSelection,
429− selectedIds = selectedIds,
447+ selectedIds = selectedIds,
430− onToggleSelect = ::toggleSelected,
448+ onToggleSelect = ::toggleSelected,
431− )
449+ )
450+ }
432 }
451 }
433 }
452 }
434 }
453 }
@@ -476,8 +495,11 @@ fun SessionListScreen(
476 val count = selectedIds.size
495 val count = selectedIds.size
477 AlertDialog(
496 AlertDialog(
478 onDismissRequest = { showBulkDelete = false },
497 onDismissRequest = { showBulkDelete = false },
479− title = { Text(stringResource(R.string.delete)) },
498+ title = { Text(stringResource(R.string.bulk_delete_title)) },
480− text = { Text(pluralStringResource(R.plurals.selected_count, count, count)) },
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)) },
481 confirmButton = {
503 confirmButton = {
482 TextButton(onClick = {
504 TextButton(onClick = {
483 showBulkDelete = false
505 showBulkDelete = false
@@ -634,6 +656,14 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
634 style = MaterialTheme.typography.bodyMedium,
656 style = MaterialTheme.typography.bodyMedium,
635 color = MaterialTheme.colorScheme.onSurfaceVariant,
657 color = MaterialTheme.colorScheme.onSurfaceVariant,
636 )
658 )
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+ }
637 }
667 }
638 }
668 }
639 } else {
669 } else {
@@ -698,8 +728,12 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
698 state = swipeState,
728 state = swipeState,
699 // Swipe end-to-start deletes (dialog-guarded); start-to-end
729 // Swipe end-to-start deletes (dialog-guarded); start-to-end
700 // archives/unarchives (instantly undoable via snackbar).
730 // archives/unarchives (instantly undoable via snackbar).
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.
701− enableDismissFromStartToEnd = true,
735+ enableDismissFromStartToEnd = !inSelection,
702− enableDismissFromEndToStart = true,
736+ enableDismissFromEndToStart = !inSelection,
703 // Indent sub-sessions under their parent (capped so deep
737 // Indent sub-sessions under their parent (capped so deep
704 // nesting doesn't squeeze the card off-screen).
738 // nesting doesn't squeeze the card off-screen).
705 modifier = Modifier
739 modifier = Modifier
@@ -1051,13 +1085,16 @@ private fun SessionCard(
1051 val filterLabel = stringResource(
1085 val filterLabel = stringResource(
1052 if (isDirectoryFiltered) R.string.clear_project_filter else R.string.filter_by_project,
1086 if (isDirectoryFiltered) R.string.clear_project_filter else R.string.filter_by_project,
1053 )
1087 )
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
1054 Row(
1091 Row(
1055 verticalAlignment = Alignment.CenterVertically,
1092 verticalAlignment = Alignment.CenterVertically,
1056 modifier = Modifier
1093 modifier = Modifier
1057 .padding(top = 2.dp)
1094 .padding(top = 2.dp)
1058 .clip(MaterialTheme.shapes.small)
1095 .clip(MaterialTheme.shapes.small)
1059 .then(
1096 .then(
1060− if (onFilterByDirectory != null) {
1097+ if (chipEnabled) {
1061 Modifier.clickable(role = Role.Button, onClickLabel = filterLabel) {
1098 Modifier.clickable(role = Role.Button, onClickLabel = filterLabel) {
1062 onFilterByDirectory(session.directory ?: dir)
1099 onFilterByDirectory(session.directory ?: dir)
1063 }
1100 }
@@ -1093,7 +1130,10 @@ private fun SessionCard(
1093 var showRowMenu by rememberSaveable(session.id) { mutableStateOf(false) }
1130 var showRowMenu by rememberSaveable(session.id) { mutableStateOf(false) }
1094 val renameLabel = stringResource(R.string.rename)
1131 val renameLabel = stringResource(R.string.rename)
1095 val deleteLabel = stringResource(R.string.delete)
1132 val deleteLabel = stringResource(R.string.delete)
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.
1096− val moreLabel = stringResource(R.string.more)
1136+ val moreLabel = stringResource(R.string.more_options_for, session.displayTitle)
1097 val pinLabel = stringResource(if (isPinned) R.string.session_unpin else R.string.session_pin)
1137 val pinLabel = stringResource(if (isPinned) R.string.session_unpin else R.string.session_pin)
1098 val archiveLabel = stringResource(if (isArchived) R.string.session_unarchive else R.string.session_archive)
1138 val archiveLabel = stringResource(if (isArchived) R.string.session_unarchive else R.string.session_archive)
1099 Box {
1139 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() {
189 )
189 )
190 val transientErrors: SharedFlow<String> = _transientErrors.asSharedFlow()
190 val transientErrors: SharedFlow<String> = _transientErrors.asSharedFlow()
191
191
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+
192 /** One-shot events carrying the id of a session marked for deferred deletion, so the
203 /** One-shot events carrying the id of a session marked for deferred deletion, so the
193 * UI can show an Undo snackbar. */
204 * UI can show an Undo snackbar. */
194 private val _undoEvents = MutableSharedFlow<String>(
205 private val _undoEvents = MutableSharedFlow<String>(
@@ -661,7 +672,13 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
661 .onSuccess { lastChosenDirectory = dir; onCreated(it.id); refresh() }
672 .onSuccess { lastChosenDirectory = dir; onCreated(it.id); refresh() }
662 .onFailure {
673 .onFailure {
663 _state.update { it.copy(error = null) }
674 _state.update { it.copy(error = null) }
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(
664− _transientErrors.tryEmit(container.friendlyError(it))
678+ RetryableError(container.friendlyError(it)) {
679+ createSession(directory = directory, title = title, onCreated = onCreated)
680+ },
681+ )
665 }
682 }
666 } finally {
683 } finally {
667 _creating.value = false
684 _creating.value = false
@@ -844,7 +861,12 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
844 .onSuccess { refresh() }
861 .onSuccess { refresh() }
845 .onFailure {
862 .onFailure {
846 _state.update { it.copy(error = null) }
863 _state.update { it.copy(error = null) }
864+ // Offer a retry so a transient rename failure is recoverable in one tap.
865+ _retryableErrors.tryEmit(
847− _transientErrors.tryEmit(container.friendlyError(it))
866+ RetryableError(container.friendlyError(it)) {
867+ renameSession(session, newTitle)
868+ },
869+ )
848 }
870 }
849 }
871 }
850 }
872 }
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+9−7
@@ -100,13 +100,15 @@ fun TwoPaneSessionChat(
100 )
100 )
101
101
102 // Auto-select the most-recent session into the detail pane so a wide screen doesn't
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 composition (the `autoSelected`
103+ // open on a blank right pane. Runs at most once per Activity instance (the `autoSelected`
104− // latch, non-saveable so it re-arms after process death), and only once the first list
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
105− // load has completed. It defers to any explicit selection already present — a restored
107+ // explicit selection already present — a restored `selected`, a deep-link/notification
106− // `selected`, a deep-link/notification (pendingOpenSession), or a user pick — and, since
108+ // (pendingOpenSession), or a user pick — and, since the latch is set the moment any of
107− // the latch is set the moment any of those wins, it never re-selects after a user clears
109+ // those wins, it never re-selects after a user clears the pane (BackHandler) or a server
108− // the pane (BackHandler) or a server switch nulls `selected`.
110+ // switch nulls `selected`.
109− var autoSelected by remember { mutableStateOf(false) }
111+ var autoSelected by rememberSaveable { mutableStateOf(false) }
110 LaunchedEffect(
112 LaunchedEffect(
111 sessionListState.loading,
113 sessionListState.loading,
112 sessionListState.sessions,
114 sessionListState.sessions,
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+22−9
@@ -54,6 +54,7 @@ import androidx.compose.runtime.Composable
54import androidx.compose.runtime.CompositionLocalProvider
54import androidx.compose.runtime.CompositionLocalProvider
55import androidx.compose.runtime.LaunchedEffect
55import androidx.compose.runtime.LaunchedEffect
56import androidx.compose.runtime.getValue
56import androidx.compose.runtime.getValue
57+import androidx.compose.runtime.mutableIntStateOf
57import androidx.compose.runtime.mutableStateOf
58import androidx.compose.runtime.mutableStateOf
58import androidx.compose.runtime.remember
59import androidx.compose.runtime.remember
59import androidx.compose.runtime.rememberCoroutineScope
60import androidx.compose.runtime.rememberCoroutineScope
@@ -71,7 +72,6 @@ import soy.iko.opencode.util.runCatchingCancellable
71import soy.iko.opencode.ui.components.AppTopBar
72import soy.iko.opencode.ui.components.AppTopBar
72import soy.iko.opencode.ui.components.EmptyState
73import soy.iko.opencode.ui.components.EmptyState
73import soy.iko.opencode.ui.components.copyToClipboard
74import soy.iko.opencode.ui.components.copyToClipboard
74−import soy.iko.opencode.ui.components.showToast
75import androidx.compose.ui.Alignment
75import androidx.compose.ui.Alignment
76import androidx.compose.ui.Modifier
76import androidx.compose.ui.Modifier
77import androidx.compose.ui.draw.clip
77import androidx.compose.ui.draw.clip
@@ -135,7 +135,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
135 context.contentResolver.openOutputStream(uri)?.use { it.write(all.toByteArray()) }
135 context.contentResolver.openOutputStream(uri)?.use { it.write(all.toByteArray()) }
136 }
136 }
137 }.isSuccess
137 }.isSuccess
138− if (ok) showToast(context, context.getString(R.string.export_all))
138+ if (ok) snackbar.showSnackbar(context.getString(R.string.export_all))
139 }
139 }
140 }
140 }
141
141
@@ -327,7 +327,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
327 // failures (e.g. an oversized report exceeding the Binder limit →
327 // failures (e.g. an oversized report exceeding the Binder limit →
328 // TransactionTooLargeException) get a generic toast, not that misleading one.
328 // TransactionTooLargeException) get a generic toast, not that misleading one.
329 val msg = if (it is ActivityNotFoundException) R.string.no_share_app else R.string.error_generic
329 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)) }
331 }
331 }
332 }
332 }
333 }) {
333 }) {
@@ -370,7 +370,12 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
370 if (reportName != null) {
370 if (reportName != null) {
371 var reportContent by remember(reportName) { mutableStateOf<String?>(null) }
371 var reportContent by remember(reportName) { mutableStateOf<String?>(null) }
372 var loadFailed by remember(reportName) { mutableStateOf(false) }
372 var loadFailed by remember(reportName) { mutableStateOf(false) }
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) }
373− LaunchedEffect(reportName) {
377+ LaunchedEffect(reportName, retryKey) {
378+ if (retryKey > 0) { reportContent = null; loadFailed = false }
374 val loaded = withContext(Dispatchers.IO) {
379 val loaded = withContext(Dispatchers.IO) {
375 // runCatchingCancellable (not runCatching) so dismissing the dialog or
380 // runCatchingCancellable (not runCatching) so dismissing the dialog or
376 // switching reports mid-read lets the CancellationException propagate
381 // switching reports mid-read lets the CancellationException propagate
@@ -420,7 +425,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
420 // (e.g. an oversized report exceeding the Binder limit → TransactionTooLargeException)
425 // (e.g. an oversized report exceeding the Binder limit → TransactionTooLargeException)
421 // get a generic toast, not that misleading one.
426 // get a generic toast, not that misleading one.
422 val msg = if (it is ActivityNotFoundException) R.string.no_share_app else R.string.error_generic
427 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)) }
424 }
429 }
425 },
430 },
426 ) { Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share)) }
431 ) { Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share)) }
@@ -450,10 +455,18 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
450 modifier = Modifier.fillMaxSize().padding(padding),
455 modifier = Modifier.fillMaxSize().padding(padding),
451 contentAlignment = Alignment.Center,
456 contentAlignment = Alignment.Center,
452 ) {
457 ) {
458+ Column(
459+ horizontalAlignment = Alignment.CenterHorizontally,
460+ verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp),
461+ ) {
453− Text(
462+ Text(
454− stringResource(R.string.report_load_failed),
463+ stringResource(R.string.report_load_failed),
455− color = MaterialTheme.colorScheme.error,
464+ color = MaterialTheme.colorScheme.error,
456− )
465+ )
466+ TextButton(onClick = { retryKey++ }) {
467+ Text(stringResource(R.string.retry))
468+ }
469+ }
457 }
470 }
458 else -> {
471 else -> {
459 val loadingLabel = stringResource(R.string.loading)
472 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(
100 * displays power off pixels in large black areas, extending battery life. The accent
100 * displays power off pixels in large black areas, extending battery life. The accent
101 * colours stay the same so the brand identity is preserved; only the backgrounds and
101 * colours stay the same so the brand identity is preserved; only the backgrounds and
102 * surfaces drop to true black.
102 * 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.
103 */
110 */
104private val AmoledColors = DarkColors.copy(
111private val AmoledColors = DarkColors.copy(
105 background = Color(0xFF000000),
112 background = Color(0xFF000000),
106 surface = Color(0xFF000000),
113 surface = Color(0xFF000000),
107 surfaceVariant = Color(0xFF1A1F2E),
114 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),
108 inverseOnSurface = Color(0xFF000000),
122 inverseOnSurface = Color(0xFF000000),
109)
123)
110
124
app/src/main/res/values-es/strings.xml+10
@@ -671,4 +671,14 @@
671 <string name="clear_project_filter">Quitar filtro de proyecto</string>
671 <string name="clear_project_filter">Quitar filtro de proyecto</string>
672 <string name="empty_file_detail_title">Ningún archivo seleccionado</string>
672 <string name="empty_file_detail_title">Ningún archivo seleccionado</string>
673 <string name="empty_file_detail_desc">Elige un archivo de la lista para verlo aquí.</string>
673 <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>
674</resources>
684</resources>
app/src/main/res/values/strings.xml+10
@@ -671,4 +671,14 @@
671 <string name="clear_project_filter">Clear project filter</string>
671 <string name="clear_project_filter">Clear project filter</string>
672 <string name="empty_file_detail_title">No file selected</string>
672 <string name="empty_file_detail_title">No file selected</string>
673 <string name="empty_file_detail_desc">Pick a file from the list to view it here.</string>
673 <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>
674</resources>
684</resources>