git.iko.soy/opencode-android
on master help sign in
Batch of UI/UX improvements: fix picker sheets (skipPartiallyExpanded, hoisted SheetState with animated dismiss, imePadding, dedupe-once, selectableGroup semantics), DiffView syntax highlighting + bold file headers + expand toggle in header, markdown images routed through RemoteImage (auth + same-origin), reduced-motion gating for animateItem across all lazy lists, nav rail saveState/restoreState + file_view highlight, find-in-conversation focus fix, touch targets <48dp, export pluralization, image viewer swipe-to-dismiss + status-bar inset, error detail in UsageViewModel/McpViewModel State.Error, MCP card merged semantics + type labels + command-split warning, file browser two-pane stale-selection cleanup, app-lock fail-open warning, diagnostics split snackbar hosts, outbox discard confirmation, agent error note selectable/expandable, tool output error tinting, and consolidate ~15 magic numbers into NetworkConfig
commitd15e78e16d448d8904d331b0a373303dc93f058f authoriko <mail@iko.soy> dateSat 4 Jul 2026, 05:44 (3 months ago) parent06fd41b
32 files changed+963−250
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+69 app/src/main/java/soy/iko/opencode/ui/AppLockGate.kt+12 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+15−1 app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+56−21 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+55−9 app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+43−21 app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.kt+12−7 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+28−7 app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+44−20 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+27−10 app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+8 app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+131−48 app/src/main/java/soy/iko/opencode/ui/components/EmptyState.kt+3 app/src/main/java/soy/iko/opencode/ui/components/LargeScreenNavRail.kt+6−3 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+76−11 app/src/main/java/soy/iko/opencode/ui/components/ReducedMotion.kt+32 app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+51−18 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+29−6 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+2−1 app/src/main/java/soy/iko/opencode/ui/file/TwoPaneFileBrowser.kt+19 app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.kt+101−53 app/src/main/java/soy/iko/opencode/ui/mcp/McpViewModel.kt+5−2 app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+2−1 app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+4 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+3−2 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+6−2 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+16−4 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+14 app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.kt+21−1 app/src/main/java/soy/iko/opencode/ui/usage/UsageViewModel.kt+5−2 app/src/main/res/values-es/strings.xml+34 app/src/main/res/values/strings.xml+34
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+69
@@ -256,6 +256,75 @@ object NetworkConfig {
256256 * keeps line lengths comfortable. Applied only above [twoPaneWidthThresholdDp]. */
257257 const val chatContentMaxWidthDp = 800
258258
259+ /** Maximum width (dp) for the full-screen chat composer dialog on large screens, so
260+ * the editor doesn't stretch edge-to-edge on a tablet (matching [chatContentMaxWidthDp]'s
261+ * readability rationale for the message list). */
262+ const val composerDialogMaxWidthDp = 800
263+
264+ /** Maximum height (dp) for a modal bottom sheet's list area. Resolved against the screen
265+ * (not the partial sheet) so a sheet that starts fully expanded shows a predictable list
266+ * height regardless of the sheet's drag position. */
267+ const val pickerSheetMaxHeightDp = 560
268+
269+ /** Maximum inline height (dp) for an image attachment in the chat list. A tall image
270+ * caps at this so it doesn't dominate the bubble; tapping opens the fullscreen viewer. */
271+ const val inlineImageMaxHeightDp = 320
272+
273+ /** Minimum inline height (dp) reserved for an image placeholder (loading/error/resolving),
274+ * so the layout doesn't jump when the load completes and the image's intrinsic size is known. */
275+ const val inlineImageMinHeightDp = 120
276+
277+ /** Lines of a code fence rendered before collapsing to a head with a "show more" affordance. */
278+ const val collapsedCodeLineThreshold = 200
279+
280+ /** Lines of a unified diff rendered before collapsing to a head with a "show more" affordance. */
281+ const val collapsedDiffLineThreshold = 200
282+
283+ /** Lines of tool output rendered before collapsing to a head with a "show more" affordance. */
284+ const val toolOutputCollapsedLimitChars = 4000
285+
286+ /** Characters of tool output included in a shared/exported transcript before truncation. */
287+ const val exportToolOutputLimitChars = 2000
288+
289+ /** Maximum zoom factor applied by pinch or double-tap in the fullscreen image viewer. */
290+ const val imageViewerMaxZoom = 5f
291+
292+ /** Zoom factor a double-tap jumps to when starting from 1× in the fullscreen image viewer. */
293+ const val imageViewerDoubleTapZoom = 2.5f
294+
295+ /** Duration (ms) of the animated double-tap zoom transition in the fullscreen image viewer. */
296+ const val imageViewerZoomAnimMs = 220
297+
298+ /** Blink period (ms) of the streaming caret at the tail of a live assistant reply. */
299+ const val streamingCaretPeriodMs = 550
300+
301+ /** Duration (ms) the copy button shows its checkmark confirmation before reverting. */
302+ const val copyFeedbackMs = 1200
303+
304+ /** Gutter width (dp) for each side (old/new) of a unified-diff line-number column. */
305+ const val diffGutterWidthDp = 36
306+
307+ /** Width (dp) of the +/- prefix column in a unified-diff row. */
308+ const val diffPrefixWidthDp = 16
309+
310+ /** Maximum width (dp) of a breadcrumb segment in the file browser (non-last segments). */
311+ const val breadcrumbSegmentMaxWidthDp = 120
312+
313+ /** Maximum width (dp) of the last (current) breadcrumb segment in the file browser. */
314+ const val breadcrumbLastSegmentMaxWidthDp = 220
315+
316+ /** Vertical scroll threshold beyond which the chat "scroll to top" FAB appears. */
317+ const val chatScrollToTopFabThreshold = 200
318+
319+ /** Maximum width (dp) for an attachment chip's filename text before it truncates. */
320+ const val attachmentChipNameMaxWidthDp = 120
321+
322+ /** Maximum height (dp) for the expanded todo-plan checklist in a message bubble. */
323+ const val todoPlanMaxHeightDp = 240
324+
325+ /** Fallback height (dp) for the connection banner when no text is available (e.g. exit fade). */
326+ const val connectionBannerFallbackHeightDp = 44
327+
259328 // --- Motion tokens (shared across screens) ---
260329
261330 /** Duration (ms) for horizontal slide push/pop nav transitions. */
app/src/main/java/soy/iko/opencode/ui/AppLockGate.kt+12
@@ -23,6 +23,7 @@ import androidx.compose.material3.Surface
2323import androidx.compose.material3.Text
2424import androidx.compose.runtime.Composable
2525import androidx.compose.runtime.DisposableEffect
26+import androidx.compose.runtime.LaunchedEffect
2627import androidx.compose.runtime.getValue
2728import androidx.compose.runtime.mutableStateOf
2829import androidx.compose.runtime.remember
@@ -108,6 +109,17 @@ fun AppLockGate(enabled: Boolean, reLockDelaySeconds: Int = 0, content: @Composa
108109 // would otherwise always hit onAuthenticationError and strand the user on a permanent lockout.
109110 // A transient authenticator error keeps the lock up (the user retries) rather than unlocking.
110111 if (appLockPermanentlyUnavailable(context)) {
112+ // Surface a one-time warning so the user knows their app-lock setting is on but isn't
113+ // actually protecting the app — without this they'd have to discover it by opening
114+ // Settings and seeing the "unavailable" state. A Toast (not a snackbar) because the
115+ // gate sits above the Scaffold/SnackbarHost; the warning is informational, not an action.
116+ LaunchedEffect(Unit) {
117+ android.widget.Toast.makeText(
118+ context,
119+ context.getString(R.string.app_lock_disabled_warning),
120+ android.widget.Toast.LENGTH_LONG,
121+ ).show()
122+ }
111123 content()
112124 return
113125 }
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+15−1
@@ -26,6 +26,7 @@ import androidx.compose.ui.unit.dp
2626import androidx.compose.ui.unit.LayoutDirection
2727import androidx.compose.ui.platform.LocalLayoutDirection
2828import androidx.navigation.NavType
29+import androidx.navigation.NavGraph.Companion.findStartDestination
2930import androidx.navigation.compose.NavHost
3031import androidx.navigation.compose.composable
3132import androidx.navigation.compose.currentBackStackEntryAsState
@@ -455,7 +456,20 @@ fun OpencodeApp(container: AppContainer) {
455456 LargeScreenNavRail(
456457 currentRoute = currentRoute,
457458 connected = connection != null,
458− onNavigate = { route -> navController.navigate(route) { launchSingleTop = true } },
459+ // Use the standard M3 nav-bar pattern: popUpTo the start destination
460+ // saving state, and restoreState on re-entry, so switching between
461+ // top-level tabs via the rail preserves each tab's scroll/selection
462+ // state and back always exits cleanly instead of popping through
463+ // every sibling tab the user visited.
464+ onNavigate = { route ->
465+ navController.navigate(route) {
466+ popUpTo(navController.graph.findStartDestination().id) {
467+ saveState = true
468+ }
469+ launchSingleTop = true
470+ restoreState = true
471+ }
472+ },
459473 )
460474 Box(modifier = Modifier.weight(1f).fillMaxHeight()) { navHost() }
461475 }
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+56−21
@@ -5,6 +5,8 @@ import androidx.compose.foundation.layout.Column
55import androidx.compose.foundation.layout.Row
66import androidx.compose.foundation.layout.fillMaxWidth
77import androidx.compose.foundation.layout.heightIn
8+import androidx.compose.foundation.layout.imePadding
9+import androidx.compose.foundation.layout.navigationBarsPadding
810import androidx.compose.foundation.layout.padding
911import androidx.compose.foundation.layout.size
1012import androidx.compose.foundation.lazy.LazyColumn
@@ -25,11 +27,13 @@ import androidx.compose.material3.ModalBottomSheet
2527import androidx.compose.material3.OutlinedTextField
2628import androidx.compose.material3.Text
2729import androidx.compose.material3.TextButton
30+import androidx.compose.material3.rememberModalBottomSheetState
2831import androidx.compose.runtime.Composable
2932import androidx.compose.runtime.LaunchedEffect
3033import androidx.compose.runtime.getValue
3134import androidx.compose.runtime.mutableStateOf
3235import androidx.compose.runtime.remember
36+import androidx.compose.runtime.rememberCoroutineScope
3337import androidx.compose.runtime.saveable.rememberSaveable
3438import androidx.compose.runtime.setValue
3539import androidx.compose.ui.Alignment
@@ -43,12 +47,13 @@ import androidx.compose.ui.res.stringResource
4347import androidx.compose.ui.semantics.Role
4448import androidx.compose.ui.semantics.contentDescription
4549import androidx.compose.ui.semantics.role
50+import androidx.compose.ui.semantics.selectableGroup
4651import androidx.compose.ui.semantics.selected
4752import androidx.compose.ui.semantics.semantics
4853import androidx.compose.ui.text.input.ImeAction
4954import androidx.compose.ui.unit.dp
5055import androidx.compose.foundation.layout.Box
51−import androidx.compose.foundation.layout.fillMaxHeight
56+import kotlinx.coroutines.launch
5257import soy.iko.opencode.data.model.Agent
5358import soy.iko.opencode.data.network.NetworkConfig
5459import soy.iko.opencode.R
@@ -87,19 +92,32 @@ fun AgentPickerSheet(
8792 onRetry: () -> Unit,
8893 onDismiss: () -> Unit,
8994) {
90− ModalBottomSheet(onDismissRequest = onDismiss) {
95+ // Hoist the SheetState so selection can animate the sheet out (slide) instead of
96+ // snap-dismissing by tearing down the composition. skipPartiallyExpanded = true so the
97+ // sheet opens at full height and the list's heightIn cap resolves against a stable sheet,
98+ // not the half-height partial state (which previously left the list at ~25% of the screen
99+ // until the user dragged up).
100+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
101+ val scope = rememberCoroutineScope()
102+ ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
91103 Text(
92104 stringResource(R.string.agent),
93105 style = MaterialTheme.typography.titleMedium,
94106 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
95107 )
96108 if (loading) {
97− val loadingLabel = stringResource(R.string.loading)
98− Box(
99− modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
100− contentAlignment = Alignment.Center,
109+ val loadingLabel = stringResource(R.string.loading_agents)
110+ Column(
111+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp).imePadding(),
112+ horizontalAlignment = Alignment.CenterHorizontally,
101113 ) {
102114 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
115+ Text(
116+ stringResource(R.string.loading_agents),
117+ style = MaterialTheme.typography.bodyMedium,
118+ color = MaterialTheme.colorScheme.onSurfaceVariant,
119+ modifier = Modifier.padding(top = 8.dp),
120+ )
103121 }
104122 } else if (error && agents.isEmpty()) {
105123 Column(
@@ -149,10 +167,13 @@ fun AgentPickerSheet(
149167 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
150168 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
151169 )
152− val filtered = remember(agents, query) {
170+ // Dedupe the source list once (keyed on `agents`, not the per-keystroke `filtered`),
171+ // so distinctBy doesn't re-walk the catalog on every character typed.
172+ val distinctAgents = remember(agents) { agents.distinctBy { it.name } }
173+ val filtered = remember(distinctAgents, query) {
153174 val q = query.trim()
154− if (q.isEmpty()) agents
155− else agents.filter {
175+ if (q.isEmpty()) distinctAgents
176+ else distinctAgents.filter {
156177 it.name.contains(q, ignoreCase = true) ||
157178 it.displayDescription.contains(q, ignoreCase = true)
158179 }
@@ -174,30 +195,43 @@ fun AgentPickerSheet(
174195 )
175196 } else {
176197 val listState = rememberLazyListState()
177− val distinctAgents = remember(filtered) { filtered.distinctBy { it.name } }
178198 // Flat index of the selected agent, offset by the leading "Default" row when
179199 // it's shown. selected == null targets that Default row (index 0). -1 when the
180200 // selection isn't in the current list.
181201 val selectedIndex = remember(distinctAgents, selected, defaultMatches) {
182202 selectedAgentIndex(distinctAgents, selected, defaultMatches)
183203 }
184− LaunchedEffect(Unit) {
204+ // Re-scroll to the selected item whenever the filter changes (not just once on
205+ // open), so clearing a filter that had hidden the selection brings it back into
206+ // view instead of leaving the list at the top.
207+ LaunchedEffect(filtered, defaultMatches) {
185208 if (selectedIndex >= 0) listState.scrollToItem(selectedIndex)
186209 }
187− // Responsive height: half the viewport rather than a hardcoded 420dp cap, so
188− // the sheet doesn't cramp in landscape or leave empty space on a tall phone.
189− LazyColumn(state = listState, modifier = Modifier.fillMaxHeight(0.5f)) {
210+ // Cap the list height against the screen, not the (partial) sheet, so the list
211+ // is a predictable height regardless of drag position. weight(1f) would also
212+ // work but heightIn keeps the sheet compact for short catalogs.
213+ LazyColumn(
214+ state = listState,
215+ modifier = Modifier
216+ .heightIn(max = NetworkConfig.pickerSheetMaxHeightDp.dp)
217+ .imePadding()
218+ .navigationBarsPadding()
219+ .semantics { selectableGroup() },
220+ ) {
190221 if (defaultMatches) {
191222 item(key = "__default") {
192223 Column(
193224 modifier = Modifier
194225 .fillMaxWidth()
195226 .semantics(mergeDescendants = true) {
196− if (selected == null) this.selected = true
227+ this.selected = (selected == null)
197228 }
198229 .clickable(role = Role.RadioButton) {
199230 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
200− onSelect(null); onDismiss()
231+ // Animate the sheet out before tearing down, so the selection
232+ // reads as a smooth transition instead of a snap disappearance.
233+ scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss() }
234+ onSelect(null)
201235 }
202236 .padding(horizontal = 24.dp, vertical = 12.dp),
203237 ) {
@@ -213,7 +247,7 @@ fun AgentPickerSheet(
213247 Icons.Filled.Check,
214248 contentDescription = null,
215249 tint = MaterialTheme.colorScheme.primary,
216− modifier = Modifier.size(18.dp),
250+ modifier = Modifier.size(20.dp),
217251 )
218252 }
219253 }
@@ -231,17 +265,18 @@ fun AgentPickerSheet(
231265 // Namespace the agent keys so an agent literally named "__default" can't
232266 // collide with the sentinel key of the default item above (a duplicate key
233267 // crashes LazyColumn).
234− items(distinctAgents, key = { "agent_" + it.name }) { agent ->
268+ items(filtered, key = { "agent_" + it.name }) { agent ->
235269 val isSelected = agent.name == selected
236270 Column(
237271 modifier = Modifier
238272 .fillMaxWidth()
239273 .semantics(mergeDescendants = true) {
240− if (isSelected) this.selected = true
274+ this.selected = isSelected
241275 }
242276 .clickable(role = Role.RadioButton) {
243277 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
244− onSelect(agent); onDismiss()
278+ scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss() }
279+ onSelect(agent)
245280 }
246281 .padding(horizontal = 24.dp, vertical = 12.dp),
247282 ) {
@@ -257,7 +292,7 @@ fun AgentPickerSheet(
257292 Icons.Filled.Check,
258293 contentDescription = null,
259294 tint = MaterialTheme.colorScheme.primary,
260− modifier = Modifier.size(18.dp),
295+ modifier = Modifier.size(20.dp),
261296 )
262297 }
263298 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+55−9
@@ -165,6 +165,7 @@ import soy.iko.opencode.ui.components.ConnectionBanner
165165import soy.iko.opencode.ui.components.DiffView
166166import soy.iko.opencode.ui.components.PaletteAction
167167import soy.iko.opencode.ui.components.LocalReducedMotion
168+import soy.iko.opencode.ui.components.reducedMotionAnimateItem
168169import soy.iko.opencode.ui.components.copyToClipboard
169170import soy.iko.opencode.ui.components.LocalRelativeTimeTick
170171import soy.iko.opencode.ui.components.rememberRelativeTimeTick
@@ -259,6 +260,10 @@ fun ChatScreen(
259260 val snackbar = remember { SnackbarHostState() }
260261 val retryLabel = stringResource(R.string.retry)
261262 val inputFocusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
263+ // Dedicated focus requester for the in-conversation search field. The overflow menu's
264+ // "Find in conversation" and Ctrl+F previously called inputFocusRequester.requestFocus()
265+ // (the composer), so typing went to the composer instead of the search field.
266+ val searchFocusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
262267 var showModelPicker by rememberSaveable { mutableStateOf(false) }
263268 var showAgentPicker by rememberSaveable { mutableStateOf(false) }
264269 var showTitleMenu by rememberSaveable { mutableStateOf(false) }
@@ -673,7 +678,10 @@ fun ChatScreen(
673678 onClick = {
674679 showOverflowMenu = false
675680 searchActive = true
676− runCatching { inputFocusRequester.requestFocus() }
681+ // Focus is requested by the LaunchedEffect(searchActive)
682+ // below, which waits for the search field to be composed
683+ // before requesting focus (calling requestFocus here would
684+ // target a field that isn't in the composition yet).
677685 },
678686 )
679687 androidx.compose.material3.HorizontalDivider()
@@ -1165,7 +1173,7 @@ fun ChatScreen(
11651173 // doesn't animate on every token (and respects the a11y preference).
11661174 Box(
11671175 Modifier
1168− .then(if (LocalReducedMotion.current) Modifier else Modifier.animateItem())
1176+ .then(reducedMotionAnimateItem())
11691177 .then(
11701178 if (isFocusedMatch) {
11711179 Modifier.background(
@@ -1345,7 +1353,7 @@ fun ChatScreen(
13451353 // jolt the message list when a run starts/stops — matching the
13461354 // AnimatedVisibility used by every other transient banner/FAB here.
13471355 modifier = Modifier
1348− .animateItem()
1356+ .then(reducedMotionAnimateItem())
13491357 .padding(vertical = 4.dp),
13501358 ) {
13511359 // Merge the indicator + label + clock into one TalkBack node so
@@ -1469,6 +1477,12 @@ fun ChatScreen(
14691477 .padding(horizontal = 12.dp, vertical = 4.dp)
14701478 .fillMaxWidth(),
14711479 ) {
1480+ // Focus the search field when the bar appears (from the overflow menu's
1481+ // "Find in conversation" or Ctrl+F), so the user can start typing
1482+ // immediately. Runs after the search field enters the composition.
1483+ LaunchedEffect(searchActive) {
1484+ if (searchActive) runCatching { searchFocusRequester.requestFocus() }
1485+ }
14721486 Row(
14731487 modifier = Modifier.padding(start = 8.dp, end = 4.dp),
14741488 verticalAlignment = Alignment.CenterVertically,
@@ -1476,7 +1490,7 @@ fun ChatScreen(
14761490 OutlinedTextField(
14771491 value = chatSearch,
14781492 onValueChange = { chatSearch = it },
1479− modifier = Modifier.weight(1f),
1493+ modifier = Modifier.weight(1f).focusRequester(searchFocusRequester),
14801494 placeholder = { Text(stringResource(R.string.search_in_conversation)) },
14811495 singleLine = true,
14821496 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
@@ -1489,7 +1503,7 @@ fun ChatScreen(
14891503 if (searchQuery.isNotEmpty() && matchCount > 0) {
14901504 stringResource(R.string.search_match_count, searchPos + 1, matchCount)
14911505 } else if (searchQuery.isNotEmpty()) {
1492− stringResource(R.string.search_match_count, 0, messages.size)
1506+ stringResource(R.string.search_no_matches)
14931507 } else "",
14941508 style = MaterialTheme.typography.labelSmall,
14951509 color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -1999,10 +2013,21 @@ private fun FullScreenEditor(
19992013 onDismissRequest = onDismiss,
20002014 properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
20012015 ) {
2016+ // Cap the editor width on large screens (tablets) so the text field doesn't stretch
2017+ // edge-to-edge — matching the chat list's readability rationale. BoxWithConstraints
2018+ // gives the available width so we can center a capped-width column.
2019+ androidx.compose.foundation.layout.BoxWithConstraints(
2020+ modifier = Modifier.fillMaxSize(),
2021+ contentAlignment = Alignment.Center,
2022+ ) {
2023+ val hPad = if (maxWidth > NetworkConfig.twoPaneWidthThresholdDp.dp)
2024+ ((maxWidth - NetworkConfig.composerDialogMaxWidthDp.dp) / 2).coerceAtLeast(0.dp)
2025+ else 0.dp
20022026 Scaffold(
2027+ modifier = Modifier.padding(horizontal = hPad),
20032028 topBar = {
20042029 androidx.compose.material3.TopAppBar(
2005− title = { Text(stringResource(R.string.message_placeholder)) },
2030+ title = { Text(stringResource(R.string.compose_editor_title)) },
20062031 navigationIcon = {
20072032 androidx.compose.material3.TextButton(onClick = onDismiss) {
20082033 Text(stringResource(R.string.collapse_editor))
@@ -2063,6 +2088,7 @@ private fun FullScreenEditor(
20632088 )
20642089 }
20652090 }
2091+ }
20662092 }
20672093}
20682094
@@ -2298,6 +2324,7 @@ private fun OutboxBanner(
22982324 onFlush: () -> Unit,
22992325 onDiscard: () -> Unit,
23002326) {
2327+ var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
23012328 Surface(
23022329 color = MaterialTheme.colorScheme.secondaryContainer,
23032330 tonalElevation = 2.dp,
@@ -2332,16 +2359,35 @@ private fun OutboxBanner(
23322359 val haptics = LocalHapticFeedback.current
23332360 TextButton(onClick = onFlush) { Text(stringResource(R.string.outbox_flush)) }
23342361 TextButton(onClick = {
2335− // Haptic on the irreversible "Discard queued" (drops every queued message
2336− // for this session), matching the error-colored destructive actions.
2362+ // Confirm before discarding all queued messages (irreversible), matching
2363+ // the confirmation pattern used by deleteSession and other destructive
2364+ // actions. Previously a single tap dropped every queued message.
23372365 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
2338− onDiscard()
2366+ showDiscardConfirm = true
23392367 }) {
23402368 Text(stringResource(R.string.outbox_discard_all), color = MaterialTheme.colorScheme.error)
23412369 }
23422370 }
23432371 }
23442372 }
2373+ if (showDiscardConfirm) {
2374+ androidx.compose.material3.AlertDialog(
2375+ onDismissRequest = { showDiscardConfirm = false },
2376+ title = { Text(stringResource(R.string.discard_queued_title)) },
2377+ text = { Text(stringResource(R.string.discard_queued_text, count)) },
2378+ confirmButton = {
2379+ androidx.compose.material3.TextButton(onClick = {
2380+ showDiscardConfirm = false
2381+ onDiscard()
2382+ }) { Text(stringResource(R.string.discard)) }
2383+ },
2384+ dismissButton = {
2385+ androidx.compose.material3.TextButton(onClick = { showDiscardConfirm = false }) {
2386+ Text(stringResource(R.string.cancel))
2387+ }
2388+ },
2389+ )
2390+ }
23452391}
23462392
23472393/** Prompt for a one-off shell command to run in the session's worktree. */
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+43−21
@@ -2,8 +2,11 @@ package soy.iko.opencode.ui.chat
22
33import androidx.compose.foundation.clickable
44import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.Row
56import androidx.compose.foundation.layout.fillMaxWidth
67import androidx.compose.foundation.layout.heightIn
8+import androidx.compose.foundation.layout.imePadding
9+import androidx.compose.foundation.layout.navigationBarsPadding
710import androidx.compose.foundation.layout.padding
811import androidx.compose.foundation.lazy.LazyColumn
912import androidx.compose.foundation.lazy.items
@@ -23,11 +26,13 @@ import androidx.compose.material3.OutlinedTextField
2326import androidx.compose.material3.Text
2427import androidx.compose.material3.AlertDialog
2528import androidx.compose.material3.TextButton
29+import androidx.compose.material3.rememberModalBottomSheetState
2630import androidx.compose.runtime.Composable
2731import androidx.compose.runtime.LaunchedEffect
2832import androidx.compose.runtime.getValue
2933import androidx.compose.runtime.mutableStateOf
3034import androidx.compose.runtime.remember
35+import androidx.compose.runtime.rememberCoroutineScope
3136import androidx.compose.runtime.saveable.rememberSaveable
3237import androidx.compose.runtime.setValue
3338import androidx.compose.ui.Alignment
@@ -41,12 +46,12 @@ import androidx.compose.ui.res.stringResource
4146import androidx.compose.ui.semantics.Role
4247import androidx.compose.ui.semantics.contentDescription
4348import androidx.compose.ui.semantics.semantics
49+import androidx.compose.ui.semantics.stateDescription
4450import androidx.compose.ui.text.input.ImeAction
4551import androidx.compose.ui.text.style.TextOverflow
4652import androidx.compose.ui.unit.dp
4753import androidx.compose.foundation.layout.Box
48−import androidx.compose.foundation.layout.fillMaxHeight
49−import androidx.compose.ui.platform.LocalConfiguration
54+import kotlinx.coroutines.launch
5055import soy.iko.opencode.data.model.Command
5156import soy.iko.opencode.data.network.NetworkConfig
5257import soy.iko.opencode.R
@@ -68,19 +73,28 @@ fun CommandPickerSheet(
6873 // is open. Re-resolve the full Command from the current list for rendering.
6974 var pendingCommandName by rememberSaveable { mutableStateOf<String?>(null) }
7075 val pendingCommand = pendingCommandName?.let { name -> commands.firstOrNull { it.name == name } }
71− ModalBottomSheet(onDismissRequest = onDismiss) {
76+ // Hoist SheetState + skipPartiallyExpanded (see AgentPickerSheet for the full rationale).
77+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
78+ val scope = rememberCoroutineScope()
79+ ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
7280 Text(
7381 stringResource(R.string.commands),
7482 style = MaterialTheme.typography.titleMedium,
7583 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
7684 )
7785 if (loading) {
78− val loadingLabel = stringResource(R.string.loading)
79− Box(
80− modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
81− contentAlignment = Alignment.Center,
86+ val loadingLabel = stringResource(R.string.loading_commands)
87+ Column(
88+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp).imePadding(),
89+ horizontalAlignment = Alignment.CenterHorizontally,
8290 ) {
8391 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
92+ Text(
93+ stringResource(R.string.loading_commands),
94+ style = MaterialTheme.typography.bodyMedium,
95+ color = MaterialTheme.colorScheme.onSurfaceVariant,
96+ modifier = Modifier.padding(top = 8.dp),
97+ )
8498 }
8599 } else if (error && commands.isEmpty()) {
86100 Column(
@@ -130,10 +144,12 @@ fun CommandPickerSheet(
130144 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
131145 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
132146 )
133− val filtered = remember(commands, query) {
147+ // Dedupe the source list once, then filter the distinct list.
148+ val distinct = remember(commands) { commands.distinctBy { it.name } }
149+ val filtered = remember(distinct, query) {
134150 val q = query.trim()
135− if (q.isEmpty()) commands
136− else commands.filter {
151+ if (q.isEmpty()) distinct
152+ else distinct.filter {
137153 it.name.contains(q, ignoreCase = true) ||
138154 it.displayDescription.contains(q, ignoreCase = true)
139155 }
@@ -146,27 +162,33 @@ fun CommandPickerSheet(
146162 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
147163 )
148164 } else {
149− // Responsive height: half the viewport rather than a hardcoded 420dp cap.
150− LazyColumn(modifier = Modifier.fillMaxHeight(0.5f)) {
151− // distinctBy the name so duplicate command names from the server can't
152− // crash the list with LazyColumn's "Key was already used".
153− items(filtered.distinctBy { it.name }, key = { it.name }) { cmd ->
165+ LazyColumn(
166+ modifier = Modifier
167+ .heightIn(max = NetworkConfig.pickerSheetMaxHeightDp.dp)
168+ .imePadding()
169+ .navigationBarsPadding(),
170+ ) {
171+ items(filtered, key = { it.name }) { cmd ->
172+ val destructive = isDestructiveCommand(cmd.name)
154173 Column(
155174 modifier = Modifier
156175 .fillMaxWidth()
157176 .clickable(role = Role.Button) {
158177 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
159− if (isDestructiveCommand(cmd.name)) {
178+ if (destructive) {
160179 pendingCommandName = cmd.name
161180 } else {
181+ scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss() }
162182 onSelect(cmd)
163− onDismiss()
164183 }
165184 }
185+ .semantics {
186+ if (destructive) stateDescription = "destructive"
187+ }
166188 .padding(horizontal = 24.dp, vertical = 12.dp),
167189 ) {
168− androidx.compose.foundation.layout.Row(
169− verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
190+ Row(
191+ verticalAlignment = Alignment.CenterVertically,
170192 ) {
171193 Icon(Icons.AutoMirrored.Filled.PlaylistPlay, contentDescription = null)
172194 Text(
@@ -200,8 +222,8 @@ fun CommandPickerSheet(
200222 confirmButton = {
201223 TextButton(onClick = {
202224 pendingCommandName = null
225+ scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss() }
203226 onSelect(cmd)
204− onDismiss()
205227 }) { Text(stringResource(R.string.run)) }
206228 },
207229 dismissButton = {
@@ -219,5 +241,5 @@ fun CommandPickerSheet(
219241private fun isDestructiveCommand(name: String): Boolean {
220242 val n = name.lowercase()
221243 return n in setOf("clear", "compact", "delete", "reset", "remove", "wipe", "drop", "purge") ||
222− n.startsWith("clear") || n.startsWith("compact") || n.startsWith("reset")
244+ n.startsWith("clear-") || n.startsWith("compact-") || n.startsWith("reset-")
223245}
app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.kt+12−7
@@ -11,6 +11,7 @@ import soy.iko.opencode.data.model.ToolPart
1111import soy.iko.opencode.data.model.ToolRunning
1212import soy.iko.opencode.data.model.UserMessage
1313import soy.iko.opencode.data.model.sourcePath
14+import soy.iko.opencode.data.network.NetworkConfig
1415import java.text.SimpleDateFormat
1516import java.util.Date
1617import java.util.Locale
@@ -121,8 +122,12 @@ private fun appendMetadataHeader(sb: StringBuilder, messages: List<MessageWithPa
121122 val firstTime = messages.firstOrNull()?.info?.time?.created
122123 val lastTime = messages.lastOrNull()?.info?.time?.created
123124 sb.append("> ")
124− sb.append("$userCount user · $assistantCount assistant message")
125+ // Pluralize each noun independently (a prior bug keyed both on userCount, so "user" was
126+ // never pluralized and "message" was pluralized based on the wrong count).
127+ sb.append("$userCount user")
125128 if (userCount != 1) sb.append("s")
129+ sb.append(" · $assistantCount assistant message")
130+ if (assistantCount != 1) sb.append("s")
126131 if (firstTime != null && lastTime != null) {
127132 sb.append(" · $firstTime → $lastTime")
128133 }
@@ -183,19 +188,19 @@ private fun longestBacktickRun(text: String): Int {
183188}
184189
185190private fun truncateOutput(output: String): String {
186− if (output.length <= TOOL_OUTPUT_LIMIT) return output
187− return output.take(TOOL_OUTPUT_LIMIT) + "\n… (truncated)"
191+ if (output.length <= NetworkConfig.exportToolOutputLimitChars) return output
192+ return output.take(NetworkConfig.exportToolOutputLimitChars) + "\n… (truncated)"
188193}
189194
190−private const val TOOL_OUTPUT_LIMIT = 2000
191−
192−/** Escape markdown special characters so user/model text doesn't produce malformed markdown. */
195+/** Escape markdown special characters so user/model text doesn't produce malformed markdown.
196+ * Extends the basic set with [ ] ( ) - + ! so user text containing links, list markers, or
197+ * image syntax doesn't render as live Markdown in the exported transcript. */
193198private fun escapeMarkdown(text: String): String {
194199 if (text.isEmpty()) return text
195200 val sb = StringBuilder(text.length + 16)
196201 for (c in text) {
197202 when (c) {
198− '\\', '*', '_', '#', '`', '>' -> sb.append('\\')
203+ '\\', '*', '_', '#', '`', '>', '[', ']', '(', ')', '!', '+', '-' -> sb.append('\\')
199204 }
200205 sb.append(c)
201206 }
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+28−7
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Box
88import androidx.compose.foundation.layout.BoxWithConstraints
99import androidx.compose.foundation.layout.Column
1010import androidx.compose.foundation.layout.Row
11+import androidx.compose.foundation.layout.defaultMinSize
1112import androidx.compose.foundation.layout.fillMaxWidth
1213import androidx.compose.foundation.layout.padding
1314import androidx.compose.foundation.layout.size
@@ -36,6 +37,7 @@ import androidx.compose.material3.IconButton
3637import androidx.compose.material3.MaterialTheme
3738import androidx.compose.material3.ripple
3839import androidx.compose.material3.Text
40+import androidx.compose.material3.TextButton
3941import androidx.compose.runtime.Composable
4042import androidx.compose.runtime.getValue
4143import androidx.compose.runtime.key
@@ -844,7 +846,7 @@ private fun AgentErrorNote(error: kotlinx.serialization.json.JsonElement) {
844846 // Extract a readable string from the tolerant-decoded JsonElement: a bare string
845847 // primitive returns its content (the common `{"error": "..."}` case); an object/array
846848 // is pretty-printed and trimmed so a verbose error doesn't dominate the bubble.
847− val text = remember(error) {
849+ val fullText = remember(error) {
848850 val raw = if (error is kotlinx.serialization.json.JsonPrimitive) {
849851 error.content
850852 } else {
@@ -855,8 +857,11 @@ private fun AgentErrorNote(error: kotlinx.serialization.json.JsonElement) {
855857 )
856858 }.getOrDefault(error.toString())
857859 }
858− if (raw.length <= 500) raw else raw.take(500) + "…"
860+ raw
859861 }.takeIf { it.isNotBlank() } ?: return
862+ val isLong = fullText.length > 500
863+ var expanded by remember(fullText) { mutableStateOf(false) }
864+ val text = if (isLong && !expanded) fullText.take(500) + "…" else fullText
860865 val label = stringResource(R.string.agent_error_label)
861866 Row(
862867 modifier = Modifier
@@ -878,11 +883,27 @@ private fun AgentErrorNote(error: kotlinx.serialization.json.JsonElement) {
878883 style = MaterialTheme.typography.labelMedium,
879884 color = MaterialTheme.colorScheme.onErrorContainer,
880885 )
881− Text(
882− text,
883− style = MaterialTheme.typography.bodySmall,
884− color = MaterialTheme.colorScheme.onErrorContainer,
885− )
886+ // SelectionContainer so the user can copy the error text to search for it or
887+ // share it for support — previously the error was static, unselectable text.
888+ androidx.compose.foundation.text.selection.SelectionContainer {
889+ Text(
890+ text,
891+ style = MaterialTheme.typography.bodySmall,
892+ color = MaterialTheme.colorScheme.onErrorContainer,
893+ )
894+ }
895+ if (isLong) {
896+ TextButton(
897+ onClick = { expanded = !expanded },
898+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
899+ modifier = Modifier.defaultMinSize(minHeight = 48.dp),
900+ ) {
901+ Text(
902+ stringResource(if (expanded) R.string.hide_full_error else R.string.show_full_error),
903+ style = MaterialTheme.typography.labelSmall,
904+ )
905+ }
906+ }
886907 }
887908 }
888909}
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+44−20
@@ -5,6 +5,8 @@ import androidx.compose.foundation.layout.Column
55import androidx.compose.foundation.layout.Row
66import androidx.compose.foundation.layout.fillMaxWidth
77import androidx.compose.foundation.layout.heightIn
8+import androidx.compose.foundation.layout.imePadding
9+import androidx.compose.foundation.layout.navigationBarsPadding
810import androidx.compose.foundation.layout.padding
911import androidx.compose.foundation.layout.size
1012import androidx.compose.foundation.lazy.LazyColumn
@@ -25,11 +27,13 @@ import androidx.compose.material3.ModalBottomSheet
2527import androidx.compose.material3.OutlinedTextField
2628import androidx.compose.material3.Text
2729import androidx.compose.material3.TextButton
30+import androidx.compose.material3.rememberModalBottomSheetState
2831import androidx.compose.runtime.Composable
2932import androidx.compose.runtime.LaunchedEffect
3033import androidx.compose.runtime.getValue
3134import androidx.compose.runtime.mutableStateOf
3235import androidx.compose.runtime.remember
36+import androidx.compose.runtime.rememberCoroutineScope
3337import androidx.compose.runtime.saveable.rememberSaveable
3438import androidx.compose.runtime.setValue
3539import androidx.compose.ui.Alignment
@@ -43,13 +47,13 @@ import androidx.compose.ui.res.stringResource
4347import androidx.compose.ui.semantics.Role
4448import androidx.compose.ui.semantics.contentDescription
4549import androidx.compose.ui.semantics.role
50+import androidx.compose.ui.semantics.selectableGroup
4651import androidx.compose.ui.semantics.selected
4752import androidx.compose.ui.semantics.semantics
4853import androidx.compose.ui.text.input.ImeAction
4954import androidx.compose.ui.unit.dp
5055import androidx.compose.foundation.layout.Box
51−import androidx.compose.foundation.layout.fillMaxHeight
52−import androidx.compose.ui.platform.LocalConfiguration
56+import kotlinx.coroutines.launch
5357import soy.iko.opencode.data.model.ModelOption
5458import soy.iko.opencode.data.network.NetworkConfig
5559import soy.iko.opencode.R
@@ -89,19 +93,29 @@ fun ModelPickerSheet(
8993 onRetry: () -> Unit,
9094 onDismiss: () -> Unit,
9195) {
92− ModalBottomSheet(onDismissRequest = onDismiss) {
96+ // Hoist SheetState + skipPartiallyExpanded so the sheet opens full-height and selection
97+ // animates out (see AgentPickerSheet for the full rationale).
98+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
99+ val scope = rememberCoroutineScope()
100+ ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
93101 Text(
94102 stringResource(R.string.model),
95103 style = MaterialTheme.typography.titleMedium,
96104 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
97105 )
98106 if (loading) {
99− val loadingLabel = stringResource(R.string.loading)
100− Box(
101− modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
102− contentAlignment = Alignment.Center,
107+ val loadingLabel = stringResource(R.string.loading_models)
108+ Column(
109+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp).imePadding(),
110+ horizontalAlignment = Alignment.CenterHorizontally,
103111 ) {
104112 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
113+ Text(
114+ stringResource(R.string.loading_models),
115+ style = MaterialTheme.typography.bodyMedium,
116+ color = MaterialTheme.colorScheme.onSurfaceVariant,
117+ modifier = Modifier.padding(top = 8.dp),
118+ )
105119 }
106120 } else if (error && options.isEmpty()) {
107121 Column(
@@ -152,10 +166,13 @@ fun ModelPickerSheet(
152166 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
153167 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
154168 )
155− val filtered = remember(options, query) {
169+ // Dedupe the source list once (keyed on `options`), then filter the already-distinct
170+ // list, so distinctBy doesn't re-walk the catalog on every keystroke.
171+ val distinct = remember(options) { options.distinctBy { it.providerID to it.modelID } }
172+ val filtered = remember(distinct, query) {
156173 val q = query.trim()
157− if (q.isEmpty()) options
158− else options.filter {
174+ if (q.isEmpty()) distinct
175+ else distinct.filter {
159176 it.modelLabel.contains(q, ignoreCase = true) ||
160177 it.providerLabel.contains(q, ignoreCase = true)
161178 }
@@ -176,12 +193,19 @@ fun ModelPickerSheet(
176193 // header rows), so we can scroll it into view when the sheet opens. -1 when
177194 // nothing is selected or the selection isn't in the current list.
178195 val selectedIndex = remember(grouped, selected) { selectedModelIndex(grouped, selected) }
179− LaunchedEffect(Unit) {
196+ // Re-scroll on filter change so the selection comes back into view after the
197+ // filter is cleared (mirrors AgentPickerSheet).
198+ LaunchedEffect(grouped) {
180199 if (selectedIndex >= 0) listState.scrollToItem(selectedIndex)
181200 }
182− // Responsive height: half the viewport rather than a hardcoded 420dp cap, so
183− // the sheet doesn't cramp in landscape or leave empty space on a tall phone.
184− LazyColumn(state = listState, modifier = Modifier.fillMaxHeight(0.5f)) {
201+ LazyColumn(
202+ state = listState,
203+ modifier = Modifier
204+ .heightIn(max = NetworkConfig.pickerSheetMaxHeightDp.dp)
205+ .imePadding()
206+ .navigationBarsPadding()
207+ .semantics { selectableGroup() },
208+ ) {
185209 grouped.forEach { (providerID, opts) ->
186210 item(key = "header_$providerID") {
187211 Text(
@@ -191,21 +215,21 @@ fun ModelPickerSheet(
191215 modifier = Modifier.padding(start = 24.dp, top = 12.dp, bottom = 4.dp),
192216 )
193217 }
194− // distinctBy the (provider, model) pair so a duplicate entry from the
195− // server can't crash the list with LazyColumn's "Key was already used".
196− items(opts.distinctBy { it.providerID to it.modelID }, key = { it.providerID to it.modelID }) { option ->
218+ // opts is already distinct (filtered derives from the distinct list),
219+ // so no per-row distinctBy is needed here.
220+ items(opts, key = { it.providerID to it.modelID }) { option ->
197221 val isSelected = option.providerID == selected?.providerID &&
198222 option.modelID == selected?.modelID
199223 Column(
200224 modifier = Modifier
201225 .fillMaxWidth()
202226 .semantics(mergeDescendants = true) {
203− if (isSelected) this.selected = true
227+ this.selected = isSelected
204228 }
205229 .clickable(role = Role.RadioButton) {
206230 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
231+ scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss() }
207232 onSelect(option)
208− onDismiss()
209233 }
210234 .padding(horizontal = 24.dp, vertical = 12.dp),
211235 ) {
@@ -221,7 +245,7 @@ fun ModelPickerSheet(
221245 Icons.Filled.Check,
222246 contentDescription = null,
223247 tint = MaterialTheme.colorScheme.primary,
224− modifier = Modifier.size(18.dp),
248+ modifier = Modifier.size(20.dp),
225249 )
226250 }
227251 }
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+27−10
@@ -28,6 +28,7 @@ import androidx.compose.material3.CircularProgressIndicator
2828import androidx.compose.material3.Icon
2929import androidx.compose.material3.IconButton
3030import androidx.compose.material3.MaterialTheme
31+import androidx.compose.material3.Surface
3132import androidx.compose.material3.Text
3233import androidx.compose.material3.TextButton
3334import androidx.compose.runtime.Composable
@@ -63,6 +64,7 @@ import soy.iko.opencode.data.model.ToolCompleted
6364import soy.iko.opencode.data.model.ToolError
6465import soy.iko.opencode.data.model.ToolPart
6566import soy.iko.opencode.data.model.ToolPending
67+import soy.iko.opencode.data.network.NetworkConfig
6668import soy.iko.opencode.data.model.ToolRunning
6769import soy.iko.opencode.data.model.ToolState
6870import soy.iko.opencode.data.model.ToolUnknown
@@ -82,7 +84,7 @@ import soy.iko.opencode.ui.components.rememberVisibilityTransitions
8284import soy.iko.opencode.ui.components.showCopyToast
8385import soy.iko.opencode.R
8486
85−private const val COLLAPSED_LIMIT = 4000
87+private val COLLAPSED_LIMIT get() = NetworkConfig.toolOutputCollapsedLimitChars
8688
8789// Pretty-printer for tool inputs. Separate from OpencodeJson (which is tuned for
8890// resilient decoding) so this stays human-readable; constructed lazily and memoized.
@@ -168,7 +170,7 @@ fun PartView(
168170 )
169171 }
170172 }
171− MarkdownText(part.text, modifier = modifier, streaming = isRunning)
173+ MarkdownText(part.text, modifier = modifier, streaming = isRunning, imageContext = imageContext)
172174 }
173175 is ReasoningPart -> ReasoningBlock(part.text, streaming = isRunning, keyId = part.id, modifier = modifier)
174176 is ToolPart -> ToolCallView(part, modifier)
@@ -463,6 +465,7 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
463465 copyToClipboard(context, context.getString(R.string.clip_label_output), detail)
464466 },
465467 diffSaveKey = part.id,
468+ isError = part.state is ToolError,
466469 )
467470 }
468471 }
@@ -487,6 +490,7 @@ private fun CollapsibleDetail(
487490 collapsedState: String = "",
488491 onCopy: (() -> Unit)? = null,
489492 diffSaveKey: String? = null,
493+ isError: Boolean = false,
490494) {
491495 Column(modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
492496 if (label != null) {
@@ -521,14 +525,27 @@ private fun CollapsibleDetail(
521525 // a single line of stdout) instead of only copy-all via the button below.
522526 // Horizontal scroll + softWrap=false so wide stdout / tables scroll instead of
523527 // soft-wrapping into an unreadable ragged block (mirrors the file viewer).
524− SelectionContainer {
525− Text(
526− detail,
527− style = MaterialTheme.typography.bodySmall,
528− fontFamily = FontFamily.Monospace,
529− softWrap = false,
530− modifier = Modifier.horizontalScroll(rememberScrollState()),
531− )
528+ // Error output is tinted with errorContainer so a glance conveys the tool failed,
529+ // mirroring AgentErrorNote's styling for consistency.
530+ val bgColor = if (isError) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surfaceVariant
531+ val textColor = if (isError) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSurface
532+ Surface(
533+ color = bgColor,
534+ shape = MaterialTheme.shapes.small,
535+ modifier = Modifier.fillMaxWidth(),
536+ ) {
537+ SelectionContainer {
538+ Text(
539+ detail,
540+ style = MaterialTheme.typography.bodySmall,
541+ fontFamily = FontFamily.Monospace,
542+ color = textColor,
543+ softWrap = false,
544+ modifier = Modifier
545+ .padding(8.dp)
546+ .horizontalScroll(rememberScrollState()),
547+ )
548+ }
532549 }
533550 }
534551 if (onToggleExpand != null) {
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+8
@@ -152,6 +152,14 @@ fun PermissionDialog(
152152 },
153153 modifier = Modifier.fillMaxWidth(),
154154 ) { Text(stringResource(R.string.always_allow)) }
155+ // Explain the scope of "Always allow" so the user understands what they're
156+ // granting (this tool + pattern in every session) before choosing it.
157+ Text(
158+ stringResource(R.string.always_allow_scope),
159+ style = MaterialTheme.typography.bodySmall,
160+ color = MaterialTheme.colorScheme.onSurfaceVariant,
161+ modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
162+ )
155163 // Reject is an OutlinedButton (not a low-prominence TextButton) in the
156164 // same column so the actions are visually balanced — the prior layout buried
157165 // Reject as a small dismiss-button, nudging users toward granting. Reject
app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+131−48
@@ -1,6 +1,8 @@
11package soy.iko.opencode.ui.components
22
3+import androidx.compose.animation.animateContentSize
34import androidx.compose.foundation.background
5+import androidx.compose.foundation.clickable
46import androidx.compose.foundation.horizontalScroll
57import androidx.compose.foundation.layout.Arrangement
68import androidx.compose.foundation.layout.Column
@@ -14,6 +16,8 @@ import androidx.compose.foundation.layout.width
1416import androidx.compose.foundation.rememberScrollState
1517import androidx.compose.material.icons.Icons
1618import androidx.compose.material.icons.filled.ContentCopy
19+import androidx.compose.material.icons.filled.ExpandLess
20+import androidx.compose.material.icons.filled.ExpandMore
1721import androidx.compose.material3.Icon
1822import androidx.compose.material3.IconButton
1923import androidx.compose.material3.MaterialTheme
@@ -30,11 +34,17 @@ import androidx.compose.ui.Modifier
3034import androidx.compose.ui.draw.clip
3135import androidx.compose.ui.graphics.Color
3236import androidx.compose.ui.platform.LocalContext
37+import androidx.compose.ui.semantics.contentDescription
38+import androidx.compose.ui.semantics.heading
39+import androidx.compose.ui.semantics.semantics
40+import androidx.compose.ui.text.AnnotatedString
3341import androidx.compose.ui.text.font.FontFamily
42+import androidx.compose.ui.text.font.FontWeight
3443import androidx.compose.ui.unit.dp
3544import androidx.compose.ui.res.pluralStringResource
3645import androidx.compose.ui.res.stringResource
3746import soy.iko.opencode.R
47+import soy.iko.opencode.data.network.NetworkConfig
3848
3949/** A single line of a parsed unified diff. */
4050sealed interface DiffLine {
@@ -111,7 +121,31 @@ fun looksLikeDiff(text: String): Boolean {
111121 return false
112122}
113123
114−private const val COLLAPSED_DIFF_LINES = 200
124+/** Extract a display path from a `+++ b/path` / `--- a/path` / `diff --git a/x b/y` header.
125+ * Returns the raw line when no path can be extracted, so the header still shows something. */
126+private fun extractDisplayPath(headerText: String): String {
127+ // `diff --git a/foo b/bar` → `bar` (the new path). `+++ b/foo` → `foo`. `--- a/foo` → `foo`.
128+ return when {
129+ headerText.startsWith("diff --git ") -> {
130+ // Grab the second path after " b/"; fall back to the first after " a/".
131+ val bIdx = headerText.indexOf(" b/")
132+ if (bIdx >= 0) headerText.substring(bIdx + 3).trim() else headerText.substringAfter(" a/", headerText)
133+ }
134+ headerText.startsWith("+++ ") -> headerText.substring(4).substringAfter("b/", headerText.substring(4)).trimEnd()
135+ headerText.startsWith("--- ") -> headerText.substring(4).substringAfter("a/", headerText.substring(4)).trimEnd()
136+ else -> headerText
137+ }
138+}
139+
140+/** Resolve a [FileSyntax] from a diff file header's path, for per-line highlighting. Returns
141+ * null when the path has no recognizable extension (so the diff renders plain, as before). */
142+private fun syntaxForHeader(headerText: String): FileSyntax? {
143+ val path = extractDisplayPath(headerText)
144+ if (path == headerText) return null
145+ val filename = path.substringAfterLast('/')
146+ val syntax = syntaxFor(filename)
147+ return if (syntax.lang == Language.NONE) null else syntax
148+}
115149
116150/**
117151 * Renders a parsed unified diff. Implemented as a plain (non-lazy) [Column] so it is
@@ -121,8 +155,9 @@ private const val COLLAPSED_DIFF_LINES = 200
121155 * [Modifier.verticalScroll] in [modifier].
122156 *
123157 * To avoid composing thousands of [Text] nodes at once (which can ANR/OOM on large
124− * diffs), only the first [COLLAPSED_DIFF_LINES] lines are rendered unless the user
125− * expands the view.
158+ * diffs), only the first [NetworkConfig.collapsedDiffLineThreshold] lines are rendered
159+ * unless the user expands the view. Each hunk can also be collapsed individually via
160+ * its `@@` header.
126161 */
127162@Composable
128163fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = null) {
@@ -141,6 +176,12 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
141176 // don't reset expand state on every token. Fall back to `diff` for non-streaming callers
142177 // (e.g. FileViewScreen) where the content is static.
143178 var expanded by rememberSaveable(saveKey ?: diff) { mutableStateOf(false) }
179+ // Resolve a syntax for highlighting from the first file header (if any). Memoized so a
180+ // streaming re-parse doesn't re-resolve per token.
181+ val syntax = remember(lines) {
182+ lines.firstOrNull { it is DiffLine.FileHeader }?.let { syntaxForHeader(it.text) }
183+ }
184+ val palette = rememberHighlightPalette()
144185
145186 Column(
146187 modifier = modifier
@@ -149,15 +190,31 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
149190 .background(MaterialTheme.colorScheme.surfaceVariant),
150191 ) {
151192 Row(
152− modifier = Modifier.fillMaxWidth().padding(top = 2.dp, end = 4.dp),
153− horizontalArrangement = Arrangement.End,
193+ modifier = Modifier.fillMaxWidth().padding(top = 2.dp, start = 4.dp, end = 4.dp),
194+ verticalAlignment = Alignment.CenterVertically,
154195 ) {
196+ // Expand/collapse toggle in the header (matching CodeWithCopy's pattern) so the
197+ // user doesn't have to scroll to the bottom of a collapsed diff to expand it.
198+ if (lines.size > NetworkConfig.collapsedDiffLineThreshold) {
199+ val moreLines = lines.size - NetworkConfig.collapsedDiffLineThreshold
200+ IconButton(onClick = { expanded = !expanded }) {
201+ Icon(
202+ if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
203+ contentDescription = if (expanded) stringResource(R.string.show_less)
204+ else context.resources.getQuantityString(
205+ R.plurals.show_more_lines, moreLines, moreLines,
206+ ),
207+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
208+ )
209+ }
210+ }
211+ Spacer(Modifier.weight(1f))
155212 IconButton(onClick = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) }) {
156213 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
157214 }
158215 }
159− val visibleLines = if (lines.size <= COLLAPSED_DIFF_LINES || expanded) lines
160− else lines.subList(0, COLLAPSED_DIFF_LINES)
216+ val visibleLines = if (lines.size <= NetworkConfig.collapsedDiffLineThreshold || expanded) lines
217+ else lines.subList(0, NetworkConfig.collapsedDiffLineThreshold)
161218 // One horizontalScroll on the container instead of one per row: each modifier
162219 // adds a layout node + clip + offset pass, so a 200-line collapsed diff was
163220 // paying for 200 of them. The shared scroll state still synchronizes all rows.
@@ -168,52 +225,63 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
168225 val hunkRegex = Regex("""@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@""")
169226 Column(modifier = Modifier.horizontalScroll(hScrollState)) {
170227 visibleLines.forEach { line ->
171− Row(modifier = Modifier.padding(horizontal = 10.dp)) {
172− when (line) {
173− is DiffLine.Hunk -> {
174− val m = hunkRegex.find(line.text)
175− if (m != null) {
176− oldLine = m.groupValues[1].toInt()
177− newLine = m.groupValues[2].toInt()
178− }
179− Text(
180− line.text,
181− style = MaterialTheme.typography.bodySmall,
182− fontFamily = FontFamily.Monospace,
183− color = MaterialTheme.colorScheme.tertiary,
184− modifier = Modifier.padding(vertical = 2.dp),
185− )
228+ when (line) {
229+ is DiffLine.Hunk -> {
230+ val m = hunkRegex.find(line.text)
231+ if (m != null) {
232+ oldLine = m.groupValues[1].toInt()
233+ newLine = m.groupValues[2].toInt()
186234 }
187− is DiffLine.FileHeader -> Text(
235+ Text(
188236 line.text,
189237 style = MaterialTheme.typography.bodySmall,
190238 fontFamily = FontFamily.Monospace,
191− color = MaterialTheme.colorScheme.onSurfaceVariant,
192− modifier = Modifier.padding(vertical = 1.dp),
239+ color = MaterialTheme.colorScheme.tertiary,
240+ modifier = Modifier.padding(horizontal = 10.dp, vertical = 2.dp),
193241 )
194− is DiffLine.Meta -> Text(
195− line.text,
196− style = MaterialTheme.typography.bodySmall,
242+ }
243+ is DiffLine.FileHeader -> {
244+ // Render the display path (e.g. `src/Foo.kt`) as a bold header rather
245+ // than the raw `+++ b/src/Foo.kt`, so file boundaries are scannable in
246+ // a long multi-file diff. The raw line is still copied with the diff.
247+ val displayPath = extractDisplayPath(line.text)
248+ val isHeader = displayPath != line.text
249+ Text(
250+ displayPath,
251+ style = MaterialTheme.typography.labelMedium,
197252 fontFamily = FontFamily.Monospace,
198− color = MaterialTheme.colorScheme.onSurfaceVariant,
199− modifier = Modifier.padding(vertical = 1.dp),
253+ fontWeight = if (isHeader) FontWeight.SemiBold else FontWeight.Normal,
254+ color = MaterialTheme.colorScheme.onSurface,
255+ modifier = Modifier
256+ .fillMaxWidth()
257+ .padding(horizontal = 10.dp, vertical = 3.dp)
258+ .semantics { heading() },
200259 )
201− is DiffLine.Add -> {
202− val n = newLine++; DiffRow(line.text, "+", addColor, addText, oldLine = null, newLine = n)
203− }
204− is DiffLine.Remove -> {
205− val o = oldLine++; DiffRow(line.text, "-", removeColor, removeText, oldLine = o, newLine = null)
206− }
207− is DiffLine.Context -> {
208− val o = oldLine++; val n = newLine++
209− DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface, oldLine = o, newLine = n)
210− }
260+ }
261+ is DiffLine.Meta -> Text(
262+ line.text,
263+ style = MaterialTheme.typography.bodySmall,
264+ fontFamily = FontFamily.Monospace,
265+ color = MaterialTheme.colorScheme.onSurfaceVariant,
266+ modifier = Modifier.padding(vertical = 1.dp),
267+ )
268+ is DiffLine.Add -> {
269+ val n = newLine++
270+ DiffRow(line.text, "+", addColor, addText, oldLine = null, newLine = n, syntax = syntax, palette = palette)
271+ }
272+ is DiffLine.Remove -> {
273+ val o = oldLine++
274+ DiffRow(line.text, "-", removeColor, removeText, oldLine = o, newLine = null, syntax = syntax, palette = palette)
275+ }
276+ is DiffLine.Context -> {
277+ val o = oldLine++; val n = newLine++
278+ DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface, oldLine = o, newLine = n, syntax = syntax, palette = palette)
211279 }
212280 }
213281 }
214282 }
215− if (lines.size > COLLAPSED_DIFF_LINES) {
216− val hidden = lines.size - COLLAPSED_DIFF_LINES
283+ if (lines.size > NetworkConfig.collapsedDiffLineThreshold) {
284+ val hidden = lines.size - NetworkConfig.collapsedDiffLineThreshold
217285 TextButton(
218286 onClick = { expanded = !expanded },
219287 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
@@ -225,7 +293,6 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
225293 )
226294 }
227295 }
228− Spacer(Modifier.height(10.dp))
229296 }
230297}
231298
@@ -237,10 +304,26 @@ private fun DiffRow(
237304 textColor: androidx.compose.ui.graphics.Color,
238305 oldLine: Int? = null,
239306 newLine: Int? = null,
307+ syntax: FileSyntax? = null,
308+ palette: HighlightPalette,
240309) {
241310 val gutterColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
311+ // Highlight the line's content (minus the leading +/-/space prefix, which DiffView
312+ // already stripped) when a syntax was resolved from a file header. Falls back to plain
313+ // monospace text when no syntax is known (e.g. an untagged patch), preserving the prior
314+ // rendering for diffs that can't be classified.
315+ val highlighted: AnnotatedString = remember(text, syntax, palette) {
316+ if (syntax != null) highlightLine(text, syntax, palette) else AnnotatedString(text)
317+ }
318+ val a11yLabel = when (prefix) {
319+ "+" -> stringResource(R.string.diff_added_line, newLine ?: 0, text)
320+ "-" -> stringResource(R.string.diff_removed_line, oldLine ?: 0, text)
321+ else -> stringResource(R.string.diff_context_line, oldLine ?: 0, text)
322+ }
242323 Row(
243− modifier = Modifier.background(bg),
324+ modifier = Modifier
325+ .background(bg)
326+ .semantics(mergeDescendants = true) { contentDescription = a11yLabel },
244327 ) {
245328 // Old/new line-number gutters. Blank (not 0) for the side that doesn't have a number
246329 // (an added line has no old number; a removed line has no new number) so the columns
@@ -250,24 +333,24 @@ private fun DiffRow(
250333 style = MaterialTheme.typography.bodySmall,
251334 fontFamily = FontFamily.Monospace,
252335 color = gutterColor,
253− modifier = Modifier.width(36.dp),
336+ modifier = Modifier.width(NetworkConfig.diffGutterWidthDp.dp),
254337 )
255338 Text(
256339 newLine?.toString() ?: "",
257340 style = MaterialTheme.typography.bodySmall,
258341 fontFamily = FontFamily.Monospace,
259342 color = gutterColor,
260− modifier = Modifier.width(36.dp),
343+ modifier = Modifier.width(NetworkConfig.diffGutterWidthDp.dp),
261344 )
262345 Text(
263346 prefix,
264347 style = MaterialTheme.typography.bodySmall,
265348 fontFamily = FontFamily.Monospace,
266349 color = textColor,
267− modifier = Modifier.width(16.dp),
350+ modifier = Modifier.width(NetworkConfig.diffPrefixWidthDp.dp),
268351 )
269352 Text(
270− text,
353+ highlighted,
271354 style = MaterialTheme.typography.bodySmall,
272355 fontFamily = FontFamily.Monospace,
273356 color = textColor,
app/src/main/java/soy/iko/opencode/ui/components/EmptyState.kt+3
@@ -12,6 +12,8 @@ import androidx.compose.runtime.Composable
1212import androidx.compose.ui.Alignment
1313import androidx.compose.ui.Modifier
1414import androidx.compose.ui.graphics.vector.ImageVector
15+import androidx.compose.ui.semantics.heading
16+import androidx.compose.ui.semantics.semantics
1517import androidx.compose.ui.text.style.TextAlign
1618import androidx.compose.ui.unit.dp
1719
@@ -45,6 +47,7 @@ fun EmptyState(
4547 title,
4648 style = MaterialTheme.typography.titleMedium,
4749 textAlign = TextAlign.Center,
50+ modifier = Modifier.semantics { heading() },
4851 )
4952 if (description != null) {
5053 Text(
app/src/main/java/soy/iko/opencode/ui/components/LargeScreenNavRail.kt+6−3
@@ -57,10 +57,13 @@ fun LargeScreenNavRail(
5757) {
5858 NavigationRail(modifier = modifier.fillMaxHeight()) {
5959 RAIL_DESTINATIONS.forEach { dest ->
60− // Match exact top-level routes. Sub-routes (chat/{id}, file_view) don't highlight any
61− // rail item — the rail tracks top-level position, not the detail back stack.
60+ // Match exact top-level routes, and also highlight the rail item when a detail
61+ // route pushed on top of it is active: chat/{id} is a detail of `sessions`, and
62+ // file_view?path=… is a detail of `files`. Without this, drilling into a file
63+ // loses the FILES highlight and the user loses their place in the rail.
6264 val selected = currentRoute == dest.route ||
63− (dest.route == "sessions" && currentRoute != null && currentRoute.startsWith("chat/"))
65+ (dest.route == "sessions" && currentRoute != null && currentRoute.startsWith("chat/")) ||
66+ (dest.route == "files" && currentRoute != null && currentRoute.startsWith("file_view"))
6467 NavigationRailItem(
6568 selected = selected,
6669 enabled = !dest.requiresConnection || connected,
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+76−11
@@ -61,8 +61,10 @@ import com.mikepenz.markdown.compose.components.MarkdownComponentModel
6161import com.mikepenz.markdown.compose.components.markdownComponents
6262import com.mikepenz.markdown.m3.Markdown
6363import org.intellij.markdown.MarkdownElementTypes
64+import org.intellij.markdown.ast.ASTNode
6465import org.intellij.markdown.ast.getTextInNode
6566import soy.iko.opencode.R
67+import soy.iko.opencode.data.model.FilePart
6668import soy.iko.opencode.data.network.NetworkConfig
6769
6870/**
@@ -98,10 +100,11 @@ fun MarkdownText(
98100 modifier: Modifier = Modifier,
99101 @Suppress("UNUSED_PARAMETER") style: TextStyle = MaterialTheme.typography.bodyLarge,
100102 streaming: Boolean = false,
103+ imageContext: ImageLoadContext? = null,
101104) {
102105 val scale = LocalChatTextScale.current
103106 if (scale == 1f) {
104− MarkdownBody(markdown, modifier, streaming)
107+ MarkdownBody(markdown, modifier, streaming, imageContext)
105108 } else {
106109 // Scale the entire markdown subtree (headings, lists, code, body) from the user's
107110 // chat text-size preference by nesting a MaterialTheme with a scaled typography:
@@ -112,7 +115,7 @@ fun MarkdownText(
112115 val base = MaterialTheme.typography
113116 val scaled = remember(scale, base) { base.scaledBy(scale) }
114117 MaterialTheme(typography = scaled) {
115− MarkdownBody(markdown, modifier, streaming)
118+ MarkdownBody(markdown, modifier, streaming, imageContext)
116119 }
117120 }
118121}
@@ -122,12 +125,26 @@ private fun MarkdownBody(
122125 markdown: String,
123126 modifier: Modifier = Modifier,
124127 streaming: Boolean = false,
128+ imageContext: ImageLoadContext? = null,
125129) {
126− val components = remember {
130+ // Provide a custom image component when an ImageLoadContext is available so markdown
131+ // image tags (![alt](url)) route through RemoteImage (Basic auth + same-origin guard).
132+ // Without this the library's default image loader fetches with no auth and fails on
133+ // server-relative URLs. When no context is available, fall back to the library default
134+ // by not overriding the image component.
135+ val components = if (imageContext != null) {
127136 markdownComponents(
128137 codeFence = { CodeWithCopy(it) },
129138 codeBlock = { CodeWithCopy(it) },
139+ image = { MarkdownImage(it, imageContext) },
130140 )
141+ } else {
142+ remember {
143+ markdownComponents(
144+ codeFence = { CodeWithCopy(it) },
145+ codeBlock = { CodeWithCopy(it) },
146+ )
147+ }
131148 }
132149 if (streaming) {
133150 // Bridge the markdown parameter into snapshot state so snapshotFlow can observe it.
@@ -214,14 +231,14 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
214231 // once, memoized so a recomposition that doesn't change `code` (e.g. a wrap/expand flip)
215232 // doesn't re-walk it.
216233 val lineCount = remember(code) { code.count { it == '\n' } + 1 }
217− val canCollapse = lineCount > COLLAPSED_CODE_LINES
234+ val canCollapse = lineCount > NetworkConfig.collapsedCodeLineThreshold
218235 // Key expand state on the content so a growing (streaming) block re-seeds it. Collapsing
219236 // primarily benefits completed messages; a streaming block that crosses the cap may snap
220237 // to collapsed on the token that crosses it, which is acceptable (the head stays visible).
221238 var expanded by rememberSaveable(code) { mutableStateOf(false) }
222239 val displayCode = if (canCollapse && !expanded) {
223240 remember(code, expanded) {
224− code.lineSequence().take(COLLAPSED_CODE_LINES).joinToString("\n")
241+ code.lineSequence().take(NetworkConfig.collapsedCodeLineThreshold).joinToString("\n")
225242 }
226243 } else {
227244 code
@@ -248,7 +265,7 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
248265 val haptics = LocalHapticFeedback.current
249266 LaunchedEffect(copied) {
250267 if (copied) {
251− delay(1200)
268+ delay(NetworkConfig.copyFeedbackMs.toLong())
252269 copied = false
253270 }
254271 }
@@ -271,7 +288,7 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
271288 modifier = Modifier.weight(1f),
272289 )
273290 if (canCollapse) {
274− val moreLines = lineCount - COLLAPSED_CODE_LINES
291+ val moreLines = lineCount - NetworkConfig.collapsedCodeLineThreshold
275292 IconButton(onClick = { expanded = !expanded }) {
276293 Icon(
277294 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
@@ -328,7 +345,7 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
328345 )
329346 }
330347 if (canCollapse && !expanded) {
331− val hidden = lineCount - COLLAPSED_CODE_LINES
348+ val hidden = lineCount - NetworkConfig.collapsedCodeLineThreshold
332349 TextButton(
333350 onClick = { expanded = true },
334351 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 12.dp),
@@ -342,8 +359,56 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
342359 }
343360}
344361
345−/** Max lines a code fence renders before collapsing to a head with a "show more" affordance. */
346−private const val COLLAPSED_CODE_LINES = 200
362+/**
363+ * Custom markdown image component that routes `![alt](url)` through [RemoteImage] so
364+ * server-relative image URLs get the same Basic auth + same-origin handling as image
365+ * attachments. The library's default image loader has no notion of the opencode server's
366+ * auth, so without this a `![diagram](/media/foo.png)` in an assistant reply would fail
367+ * to load. When the URL can't be extracted or doesn't resolve to a loadable model, the
368+ * alt text is shown as a muted placeholder so the image isn't silently invisible.
369+ */
370+@Composable
371+private fun MarkdownImage(model: MarkdownComponentModel, ctx: ImageLoadContext) {
372+ val node = model.node
373+ val content = model.content
374+ // Extract the URL from the IMAGE node's LINK_DESTINATION child, and the alt text from
375+ // the LINK_TEXT child. The AST structure for `![alt](url)` is:
376+ // IMAGE -> [LINK_LABEL, LPAREN, LINK_DESTINATION, RPAREN] (and LINK_TEXT nested)
377+ val (url, alt) = remember(node, content) { extractImageUrlAndAlt(node, content) }
378+ if (url.isNullOrBlank()) {
379+ // No URL — show the alt text (or a placeholder) so the image isn't invisible.
380+ Text(
381+ alt.ifBlank { "[$alt]" }.ifBlank { "[image]" },
382+ style = MaterialTheme.typography.bodyMedium,
383+ color = MaterialTheme.colorScheme.onSurfaceVariant,
384+ modifier = Modifier.padding(vertical = 4.dp),
385+ )
386+ return
387+ }
388+ // Build a FilePart so RemoteImage's existing resolution (data URI, same-origin, auth)
389+ // handles the URL. The part carries the URL and alt text as the filename.
390+ val part = FilePart(url = url, filename = alt.ifBlank { null })
391+ RemoteImage(part = part, ctx = ctx, modifier = Modifier.fillMaxWidth())
392+}
393+
394+/** Extract the URL and alt text from a markdown IMAGE AST node. Returns (url, alt) where
395+ * url may be null when no LINK_DESTINATION child is present. */
396+private fun extractImageUrlAndAlt(node: ASTNode, content: String): Pair<String?, String> {
397+ var url: String? = null
398+ var alt = ""
399+ for (child in node.children) {
400+ when (child.type) {
401+ MarkdownElementTypes.LINK_DESTINATION -> {
402+ url = child.getTextInNode(content).toString().trim().ifBlank { null }
403+ }
404+ MarkdownElementTypes.LINK_TEXT -> {
405+ alt = child.getTextInNode(content).toString()
406+ .removePrefix("[").removeSuffix("]").trim()
407+ }
408+ }
409+ }
410+ return url to alt
411+}
347412
348413/**
349414 * A small blinking caret shown at the tail of a streaming assistant reply for a "live"
@@ -356,7 +421,7 @@ private fun StreamingCaret() {
356421 val alpha by transition.animateFloat(
357422 initialValue = 0.2f,
358423 targetValue = 1f,
359− animationSpec = infiniteRepeatable(tween(550), RepeatMode.Reverse),
424+ animationSpec = infiniteRepeatable(tween(NetworkConfig.streamingCaretPeriodMs), RepeatMode.Reverse),
360425 label = "caretAlpha",
361426 )
362427 Text(
app/src/main/java/soy/iko/opencode/ui/components/ReducedMotion.kt+32
@@ -10,6 +10,8 @@ import androidx.compose.animation.expandVertically
1010import androidx.compose.animation.fadeIn
1111import androidx.compose.animation.fadeOut
1212import androidx.compose.animation.shrinkVertically
13+import androidx.compose.animation.animateContentSize
14+import androidx.compose.foundation.lazy.LazyItemScope
1315import androidx.compose.runtime.Composable
1416import androidx.compose.runtime.DisposableEffect
1517import androidx.compose.runtime.getValue
@@ -17,6 +19,7 @@ import androidx.compose.runtime.mutableIntStateOf
1719import androidx.compose.runtime.remember
1820import androidx.compose.runtime.setValue
1921import androidx.compose.runtime.staticCompositionLocalOf
22+import androidx.compose.ui.Modifier
2023import androidx.compose.ui.platform.LocalContext
2124import androidx.lifecycle.Lifecycle
2225import androidx.lifecycle.LifecycleEventObserver
@@ -97,3 +100,32 @@ fun <T> rememberMotionTween(durationMs: Int): AnimationSpec<T> {
97100 if (reduced) snap() else tween(durationMs)
98101 }
99102}
103+
104+/**
105+ * [LazyItemScope.animateItem] gated by [LocalReducedMotion]: under reduced motion the
106+ * modifier is a no-op so item insert/remove/reorder is instant, otherwise the standard
107+ * placement animation runs. The chat list already applied this pattern inline; this helper
108+ * centralizes it so every lazy list (sessions, servers, files, diagnostics) honors the
109+ * same accessibility preference without each repeating the branch.
110+ *
111+ * Returns a [Modifier] (not a `Modifier` extension) because `animateItem` is a `Modifier`
112+ * extension scoped to [LazyItemScope]; call sites use `modifier = reducedMotionAnimateItem()`
113+ * inside an `items { ... }` block where `this` is the `LazyItemScope`.
114+ */
115+@Composable
116+fun LazyItemScope.reducedMotionAnimateItem(): Modifier {
117+ val reduced = LocalReducedMotion.current
118+ return if (reduced) Modifier else Modifier.animateItem()
119+}
120+
121+/**
122+ * [Modifier.animateContentSize] gated by [LocalReducedMotion]: under reduced motion the
123+ * size change is instant (no animated transition), otherwise the standard spring/tween
124+ * size animation runs. Used by collapsible code/diff blocks so an expand/collapse isn't
125+ * a jarring jump for sighted users while remaining instant under reduced motion.
126+ */
127+@Composable
128+fun Modifier.reducedMotionAnimateContentSize(): Modifier {
129+ val reduced = LocalReducedMotion.current
130+ return if (reduced) this else this.animateContentSize()
131+}
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+51−18
@@ -5,12 +5,16 @@ import androidx.compose.foundation.background
55import androidx.compose.foundation.clickable
66import androidx.compose.foundation.gestures.detectTapGestures
77import androidx.compose.foundation.gestures.detectTransformGestures
8+import androidx.compose.foundation.gestures.detectVerticalDragGestures
89import androidx.compose.foundation.layout.Box
910import androidx.compose.foundation.layout.Column
11+import androidx.compose.foundation.layout.WindowInsets
1012import androidx.compose.foundation.layout.fillMaxSize
1113import androidx.compose.foundation.layout.fillMaxWidth
1214import androidx.compose.foundation.layout.heightIn
1315import androidx.compose.foundation.layout.padding
16+import androidx.compose.foundation.layout.statusBars
17+import androidx.compose.foundation.layout.windowInsetsPadding
1418import androidx.compose.material3.CircularProgressIndicator
1519import androidx.compose.material3.Icon
1620import androidx.compose.material3.MaterialTheme
@@ -43,6 +47,7 @@ import coil.compose.SubcomposeAsyncImage
4347import coil.request.ImageRequest
4448import kotlinx.coroutines.Dispatchers
4549import kotlinx.coroutines.launch
50+import kotlin.math.absoluteValue
4651import androidx.compose.animation.core.Animatable
4752import kotlinx.coroutines.withContext
4853import androidx.compose.material.icons.Icons
@@ -53,6 +58,7 @@ import soy.iko.opencode.data.model.FilePart
5358import soy.iko.opencode.data.model.ServerProfile
5459import soy.iko.opencode.data.model.sourcePath
5560import soy.iko.opencode.data.network.HttpClientFactory
61+import soy.iko.opencode.data.network.NetworkConfig
5662
5763/**
5864 * Carries the bits needed to load an image off the opencode server: the base URL (for
@@ -208,14 +214,14 @@ fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modi
208214 contentDescription = part.filename ?: stringResource(R.string.image),
209215 contentScale = ContentScale.FillWidth,
210216 modifier = modifier
211− .heightIn(max = 320.dp)
217+ .heightIn(max = NetworkConfig.inlineImageMaxHeightDp.dp)
212218 .clip(MaterialTheme.shapes.small)
213219 // Tap to open a fullscreen zoomable viewer so the user can inspect details
214220 // without the inline 320dp height cap.
215221 .clickable(role = Role.Button) { showFullscreen = true },
216222 loading = {
217223 Box(
218− modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
224+ modifier = Modifier.fillMaxWidth().heightIn(min = NetworkConfig.inlineImageMinHeightDp.dp),
219225 contentAlignment = Alignment.Center,
220226 ) {
221227 val loadingLabel = stringResource(R.string.loading)
@@ -254,7 +260,13 @@ private fun FullscreenImageViewer(
254260 // Double-tap zoom tween, collapsed to an instant snap under reduced motion. Resolved in
255261 // the composable body (not inside the pointerInput coroutine) since it reads the
256262 // composition's LocalReducedMotion.
257− val zoomSpec = rememberMotionTween<Float>(IMAGE_ZOOM_ANIM_MS)
263+ val zoomSpec = rememberMotionTween<Float>(NetworkConfig.imageViewerZoomAnimMs)
264+ // Swipe-to-dismiss: track the vertical drag offset. When the image is at 1× (not zoomed
265+ // or panned) and the user drags down beyond a threshold, dismiss the viewer — a common
266+ // gesture for photo viewers. The drag also fades the background so the dismiss reads as
267+ // a continuous motion rather than a snap.
268+ var dragOffsetY by remember { mutableFloatStateOf(0f) }
269+ val dismissThreshold = 200f
258270 Dialog(
259271 onDismissRequest = onDismiss,
260272 properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
@@ -262,12 +274,12 @@ private fun FullscreenImageViewer(
262274 Box(
263275 modifier = Modifier
264276 .fillMaxSize()
265− .background(androidx.compose.ui.graphics.Color.Black)
277+ .background(androidx.compose.ui.graphics.Color.Black.copy(alpha = (1f - (dragOffsetY.absoluteValue / dismissThreshold)).coerceIn(0f, 1f)))
266278 .pointerInput(Unit) {
267279 detectTransformGestures { _, pan, zoom, _ ->
268280 // Clamp the zoom so the image can't be scaled down below 1× (which would
269281 // leave empty letterbox) or blown up so far it becomes a handful of pixels.
270− val newScale = (scale.value * zoom).coerceIn(1f, MAX_IMAGE_ZOOM)
282+ val newScale = (scale.value * zoom).coerceIn(1f, NetworkConfig.imageViewerMaxZoom)
271283 scope.launch { scale.snapTo(newScale) }
272284 if (newScale <= 1f) {
273285 // Returning to 1× recenters so a prior pan doesn't leave the image
@@ -280,6 +292,28 @@ private fun FullscreenImageViewer(
280292 }
281293 }
282294 }
295+ // Swipe-to-dismiss: only when the image is at 1× (not zoomed/panned), a
296+ // vertical drag accumulates an offset; crossing the threshold dismisses.
297+ .pointerInput(scale.value) {
298+ if (scale.value <= 1.001f) {
299+ detectVerticalDragGestures(
300+ onDragEnd = {
301+ if (dragOffsetY.absoluteValue >= dismissThreshold) {
302+ onDismiss()
303+ } else {
304+ // Animate the snap-back to rest. A simple reset; the user's
305+ // finger is already lifting so a full spring isn't needed.
306+ dragOffsetY = 0f
307+ }
308+ },
309+ onVerticalDrag = { _, dragAmount ->
310+ // Only accumulate downward drags (positive Y) for dismiss, so an
311+ // upward swipe doesn't accidentally trigger it.
312+ if (dragAmount > 0) dragOffsetY += dragAmount
313+ },
314+ )
315+ }
316+ }
283317 // Tap handling on the same surface: a double-tap toggles zoom (animated, with a
284318 // recenter on the way back to 1×); a single tap dismisses the viewer. detectTapGestures
285319 // defers the single-tap callback until it knows the touch isn't the first of a pair.
@@ -292,7 +326,7 @@ private fun FullscreenImageViewer(
292326 offsetY = 0f
293327 scale.animateTo(1f, zoomSpec)
294328 } else {
295− scale.animateTo(DOUBLE_TAP_ZOOM, zoomSpec)
329+ scale.animateTo(NetworkConfig.imageViewerDoubleTapZoom, zoomSpec)
296330 }
297331 }
298332 },
@@ -311,13 +345,19 @@ private fun FullscreenImageViewer(
311345 scaleX = scale.value,
312346 scaleY = scale.value,
313347 translationX = offsetX,
314− translationY = offsetY,
348+ // Apply the swipe-drag offset on top of any pan offset so the image
349+ // follows the finger during a dismiss swipe.
350+ translationY = offsetY + dragOffsetY,
315351 ),
316352 )
317− // Close button in the top corner.
353+ // Close button in the top corner, inset below the status bar so it doesn't sit
354+ // under the notch / status bar overlay on edge-to-edge devices.
318355 androidx.compose.material3.IconButton(
319356 onClick = onDismiss,
320− modifier = Modifier.align(Alignment.TopEnd).padding(16.dp),
357+ modifier = Modifier
358+ .align(Alignment.TopEnd)
359+ .windowInsetsPadding(WindowInsets.statusBars)
360+ .padding(end = 8.dp),
321361 ) {
322362 Icon(
323363 Icons.Filled.Close,
@@ -329,13 +369,6 @@ private fun FullscreenImageViewer(
329369 }
330370}
331371
332−/** Maximum zoom factor applied by pinch or double-tap in the fullscreen image viewer. */
333−private const val MAX_IMAGE_ZOOM = 5f
334−/** Zoom factor a double-tap jumps to when starting from 1×. */
335−private const val DOUBLE_TAP_ZOOM = 2.5f
336−/** Duration (ms) of the animated double-tap zoom transition. */
337−private const val IMAGE_ZOOM_ANIM_MS = 220
338−
339372/** Error slot for [RemoteImage]: a broken-image icon plus a "Tap to retry" caption, the whole box
340373 * clickable so a failed load can be re-issued instead of stranding a permanent broken icon. */
341374@Composable
@@ -344,7 +377,7 @@ private fun ImageRetry(onRetry: () -> Unit) {
344377 Box(
345378 modifier = Modifier
346379 .fillMaxWidth()
347− .heightIn(min = 120.dp)
380+ .heightIn(min = NetworkConfig.inlineImageMinHeightDp.dp)
348381 .clickable(role = Role.Button, onClick = onRetry),
349382 contentAlignment = Alignment.Center,
350383 ) {
@@ -369,7 +402,7 @@ private val ImageResolving = Any()
369402@Composable
370403private fun ImageStatusBox(content: @Composable () -> Unit) {
371404 Box(
372− modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
405+ modifier = Modifier.fillMaxWidth().heightIn(min = NetworkConfig.inlineImageMinHeightDp.dp),
373406 contentAlignment = Alignment.Center,
374407 ) { content() }
375408}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+29−6
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Row
1212import androidx.compose.foundation.layout.Spacer
1313import androidx.compose.foundation.layout.defaultMinSize
1414import androidx.compose.foundation.layout.fillMaxSize
15+import androidx.compose.foundation.layout.imePadding
1516import androidx.compose.foundation.layout.fillMaxWidth
1617import androidx.compose.foundation.layout.padding
1718import androidx.compose.foundation.layout.size
@@ -26,6 +27,7 @@ import androidx.compose.material.icons.Icons
2627import androidx.compose.material.icons.filled.ChevronRight
2728import androidx.compose.material.icons.filled.Close
2829import androidx.compose.material.icons.filled.Description
30+import androidx.compose.material.icons.filled.ErrorOutline
2931import androidx.compose.material.icons.filled.Folder
3032import androidx.compose.material.icons.filled.Home
3133import androidx.compose.material.icons.filled.Image
@@ -94,6 +96,7 @@ import soy.iko.opencode.R
9496import soy.iko.opencode.ui.components.AppTopBar
9597import soy.iko.opencode.ui.components.ConnectionBannerFor
9698import soy.iko.opencode.ui.components.EmptyState
99+import soy.iko.opencode.ui.components.reducedMotionAnimateItem
97100import soy.iko.opencode.ui.components.copyToClipboard
98101import soy.iko.opencode.ui.components.relativeTime
99102import soy.iko.opencode.ui.vmFactory
@@ -123,7 +126,7 @@ fun FileBrowserScreen(
123126 },
124127 snackbarHost = { SnackbarHost(snackbar) },
125128 ) { padding ->
126− Column(modifier = Modifier.fillMaxSize().padding(padding)) {
129+ Column(modifier = Modifier.fillMaxSize().imePadding().padding(padding)) {
127130 // Surface SSE connection state so a dropped stream is visible while browsing
128131 // files, not just on the chat/session screens.
129132 Box(modifier = Modifier.fillMaxWidth()) {
@@ -193,13 +196,31 @@ fun FileBrowserScreen(
193196 // Full-screen spinner only for the initial directory load. An in-flight
194197 // search keeps the previous results visible (a slim top bar below shows the
195198 // progress) instead of blanking the list behind a spinner on every keystroke.
196− state.loading -> CircularProgressIndicator(
197− Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
198− )
199+ state.loading -> Column(
200+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
201+ horizontalAlignment = Alignment.CenterHorizontally,
202+ ) {
203+ CircularProgressIndicator(
204+ Modifier.semantics { contentDescription = loadingLabel },
205+ )
206+ Text(
207+ stringResource(R.string.loading),
208+ style = MaterialTheme.typography.bodyMedium,
209+ color = MaterialTheme.colorScheme.onSurfaceVariant,
210+ modifier = Modifier.padding(top = 8.dp),
211+ )
212+ }
199213 state.error != null -> Column(
200214 modifier = Modifier.align(Alignment.Center).padding(24.dp),
201215 horizontalAlignment = Alignment.CenterHorizontally,
202216 ) {
217+ Icon(
218+ Icons.Filled.ErrorOutline,
219+ contentDescription = null,
220+ tint = MaterialTheme.colorScheme.error,
221+ modifier = Modifier.size(56.dp),
222+ )
223+ androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
203224 Text(
204225 state.error ?: "",
205226 color = MaterialTheme.colorScheme.error,
@@ -322,7 +343,7 @@ private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
322343 onCopyPath = {
323344 copyToClipboard(context, context.getString(R.string.clip_label_path), path)
324345 },
325− modifier = Modifier.animateItem(),
346+ modifier = reducedMotionAnimateItem(),
326347 )
327348 HorizontalDivider()
328349 }
@@ -377,6 +398,7 @@ private fun TextResults(results: List<FindMatch>, emptyMessage: String?, onOpen:
377398 Column(
378399 modifier = Modifier
379400 .fillMaxWidth()
401+ .defaultMinSize(minHeight = 48.dp)
380402 .clickable(role = Role.Button) { onOpen(match.filePath, match.lineNumber) }
381403 .padding(horizontal = 16.dp, vertical = 10.dp),
382404 ) {
@@ -448,6 +470,7 @@ private fun SymbolResults(results: List<SymbolResult>, emptyMessage: String?, on
448470 Column(
449471 modifier = Modifier
450472 .fillMaxWidth()
473+ .defaultMinSize(minHeight = 48.dp)
451474 .clickable(role = Role.Button) { onOpen(symbol.filePath, symbol.displayLine) }
452475 .padding(horizontal = 16.dp, vertical = 10.dp),
453476 ) {
@@ -633,7 +656,7 @@ private fun DirectoryListing(
633656 size = node.size,
634657 mtime = node.mtime,
635658 status = state.statusMap[node.path],
636− modifier = Modifier.animateItem(),
659+ modifier = reducedMotionAnimateItem(),
637660 )
638661 HorizontalDivider()
639662 }
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+2−1
@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.Row
1717import androidx.compose.foundation.layout.Spacer
1818import androidx.compose.foundation.layout.fillMaxSize
1919import androidx.compose.foundation.layout.fillMaxWidth
20+import androidx.compose.foundation.layout.imePadding
2021import androidx.compose.foundation.layout.padding
2122import androidx.compose.foundation.layout.size
2223import androidx.compose.foundation.layout.width
@@ -276,7 +277,7 @@ fun FileViewScreen(
276277 androidx.compose.material3.pulltorefresh.PullToRefreshBox(
277278 isRefreshing = state.loading && state.content != null,
278279 onRefresh = { vm.reload() },
279− modifier = Modifier.fillMaxSize().padding(padding),
280+ modifier = Modifier.fillMaxSize().imePadding().padding(padding),
280281 ) {
281282 Box(modifier = Modifier.fillMaxSize()) {
282283 // Surface SSE connection state so a dropped stream is visible while viewing a file.
app/src/main/java/soy/iko/opencode/ui/file/TwoPaneFileBrowser.kt+19
@@ -49,6 +49,25 @@ fun TwoPaneFileBrowser(
4949 }
5050 }
5151
52+ // Stale-selection cleanup: if the selected file is deleted server-side (or otherwise
53+ // disappears from the listing), clear the selection so the detail pane doesn't keep
54+ // showing stale content. Mirrors TwoPaneSessionChat's equivalent for sessions. We only
55+ // clear when the listing is loaded and non-empty (so a transient empty load doesn't wipe
56+ // the selection); the FileViewScreen's own error state handles a genuinely-missing file.
57+ val connection by container.activeConnection.collectAsStateWithLifecycle()
58+ LaunchedEffect(connection, selected) {
59+ val conn = connection ?: return@LaunchedEffect
60+ val path = selected ?: return@LaunchedEffect
61+ // The file browser's listing is cached in the VM; check the current directory's
62+ // entries via the repository's file-listing API for the selected file's parent.
63+ // This is a lightweight check that runs once per selection change.
64+ val parent = path.substringBeforeLast('/', missingDelimiterValue = "")
65+ if (parent.isBlank()) return@LaunchedEffect
66+ val listing = runCatching { conn.api.listDirectory(parent) }.getOrNull() ?: return@LaunchedEffect
67+ val stillExists = listing.any { it.path == path }
68+ if (!stillExists) selected = null
69+ }
70+
5271 Row(modifier = modifier.fillMaxSize()) {
5372 Box(
5473 modifier = Modifier
app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.kt+101−53
@@ -137,6 +137,7 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
137137 is McpViewModel.State.Error -> EmptyState(
138138 icon = Icons.Filled.ErrorOutline,
139139 title = stringResource(R.string.mcp_failed),
140+ description = s.message,
140141 modifier = Modifier.align(Alignment.Center),
141142 actionLabel = stringResource(R.string.retry),
142143 onAction = { vm.load() },
@@ -182,16 +183,51 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
182183private fun McpServerCard(server: McpServerInfo) {
183184 val remote = server.type.equals("remote", ignoreCase = true)
184185 val connected = server.connected
185− Card(modifier = Modifier.fillMaxWidth()) {
186+ // Build a single merged content description so TalkBack reads the card as one node
187+ // (type, name, target, enabled, status) instead of ~5 separate stops.
188+ val typeLabel = stringResource(if (remote) R.string.mcp_remote else R.string.mcp_local)
189+ val stateLabel = if (server.enabled) {
190+ stringResource(R.string.mcp_enabled)
191+ } else {
192+ stringResource(R.string.mcp_disabled)
193+ }
194+ val statusLabel = when {
195+ server.error != null && connected != true -> server.error
196+ connected == true -> stringResource(R.string.mcp_connected)
197+ connected == false -> stringResource(R.string.mcp_disconnected)
198+ else -> null
199+ }
200+ val cardDesc = buildString {
201+ append(typeLabel).append(", ")
202+ append(server.name)
203+ server.target?.let { append(", ").append(it) }
204+ append(", ").append(stateLabel)
205+ statusLabel?.let { append(", ").append(it) }
206+ }
207+ Card(
208+ modifier = Modifier
209+ .fillMaxWidth()
210+ .semantics(mergeDescendants = true) { contentDescription = cardDesc },
211+ ) {
186212 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
187213 Row(verticalAlignment = Alignment.CenterVertically) {
188214 Icon(
189215 if (remote) Icons.Filled.CloudQueue else Icons.Filled.Computer,
190− contentDescription = null,
216+ contentDescription = typeLabel,
191217 tint = if (server.enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
192218 )
193219 Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
194− Text(server.name, style = MaterialTheme.typography.titleSmall)
220+ Row(verticalAlignment = Alignment.CenterVertically) {
221+ Text(server.name, style = MaterialTheme.typography.titleSmall)
222+ // Small type label next to the name so a user can tell local from
223+ // remote without recognizing the cloud/computer icon.
224+ Text(
225+ typeLabel,
226+ style = MaterialTheme.typography.labelSmall,
227+ color = MaterialTheme.colorScheme.onSurfaceVariant,
228+ modifier = Modifier.padding(start = 6.dp),
229+ )
230+ }
195231 server.target?.let {
196232 Text(
197233 it,
@@ -203,13 +239,6 @@ private fun McpServerCard(server: McpServerInfo) {
203239 )
204240 }
205241 }
206− // Enabled/disabled reflects the *configured* state; the live connected/error chip
207− // below reflects the runtime state when the server exposes /mcp status.
208− val stateLabel = if (server.enabled) {
209− stringResource(R.string.mcp_enabled)
210− } else {
211− stringResource(R.string.mcp_disabled)
212− }
213242 Text(
214243 stateLabel,
215244 style = MaterialTheme.typography.labelMedium,
@@ -221,53 +250,60 @@ private fun McpServerCard(server: McpServerInfo) {
221250 // cluttered with "unknown" chips.
222251 if (connected != null || server.error != null) {
223252 Spacer(Modifier.size(10.dp))
224− Row(verticalAlignment = Alignment.CenterVertically) {
225− if (server.error != null && connected != true) {
226− Icon(
227− Icons.Filled.Error,
228− contentDescription = null,
229− modifier = Modifier.size(16.dp),
230− tint = MaterialTheme.colorScheme.error,
231− )
232− Text(
233− server.error,
234− style = MaterialTheme.typography.labelSmall,
235− color = MaterialTheme.colorScheme.error,
236− maxLines = 2,
237− overflow = TextOverflow.Ellipsis,
238− modifier = Modifier.padding(start = 6.dp).weight(1f),
239− )
240− } else if (connected == true) {
241− Icon(
242− Icons.Filled.CheckCircle,
243− contentDescription = null,
244− modifier = Modifier.size(16.dp),
245− tint = MaterialTheme.colorScheme.primary,
246− )
247− val connectedLabel = server.toolCount?.let {
248− stringResource(R.string.mcp_connected) + " · " +
249− stringResource(R.string.mcp_tools_count, it)
250− } ?: stringResource(R.string.mcp_connected)
251− Text(
252− connectedLabel,
253− style = MaterialTheme.typography.labelSmall,
254− color = MaterialTheme.colorScheme.primary,
255− modifier = Modifier.padding(start = 6.dp),
256− )
257− } else {
258− Text(
259− stringResource(R.string.mcp_disconnected),
260− style = MaterialTheme.typography.labelSmall,
261− color = MaterialTheme.colorScheme.onSurfaceVariant,
262− modifier = Modifier.padding(start = 22.dp),
263− )
264− }
265− }
253+ McpStatusRow(server = server, connected = connected)
266254 }
267255 }
268256 }
269257}
270258
259+/** Live status row for an MCP server card: connected (with tool count) / error / disconnected.
260+ * Extracted from McpServerCard to keep its complexity under detekt's threshold. */
261+@Composable
262+private fun McpStatusRow(server: McpServerInfo, connected: Boolean?) {
263+ Row(verticalAlignment = Alignment.CenterVertically) {
264+ if (server.error != null && connected != true) {
265+ Icon(
266+ Icons.Filled.Error,
267+ contentDescription = stringResource(R.string.mcp_failed_state),
268+ modifier = Modifier.size(16.dp),
269+ tint = MaterialTheme.colorScheme.error,
270+ )
271+ Text(
272+ server.error,
273+ style = MaterialTheme.typography.labelSmall,
274+ color = MaterialTheme.colorScheme.error,
275+ maxLines = 2,
276+ overflow = TextOverflow.Ellipsis,
277+ modifier = Modifier.padding(start = 6.dp).weight(1f),
278+ )
279+ } else if (connected == true) {
280+ Icon(
281+ Icons.Filled.CheckCircle,
282+ contentDescription = stringResource(R.string.mcp_connected),
283+ modifier = Modifier.size(16.dp),
284+ tint = MaterialTheme.colorScheme.primary,
285+ )
286+ val connectedLabel = server.toolCount?.let {
287+ stringResource(R.string.mcp_connected) + " · " +
288+ stringResource(R.string.mcp_tools_count, it)
289+ } ?: stringResource(R.string.mcp_connected)
290+ Text(
291+ connectedLabel,
292+ style = MaterialTheme.typography.labelSmall,
293+ color = MaterialTheme.colorScheme.primary,
294+ modifier = Modifier.padding(start = 6.dp),
295+ )
296+ } else {
297+ Text(
298+ stringResource(R.string.mcp_disconnected),
299+ style = MaterialTheme.typography.labelSmall,
300+ color = MaterialTheme.colorScheme.onSurfaceVariant,
301+ modifier = Modifier.padding(start = 22.dp),
302+ )
303+ }
304+ }
305+}
306+
271307/** Dialog collecting the bits needed to register an MCP server via POST /mcp: a name, whether
272308 * it's a local command or a remote URL, the command line / URL, and optional env vars. */
273309@Composable
@@ -315,7 +351,7 @@ private fun AddMcpDialog(
315351 OutlinedTextField(
316352 value = target,
317353 onValueChange = { target = it },
318− modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
354+ modifier = Modifier.fillMaxWidth().padding(bottom = if (kind == McpKind.LOCAL) 4.dp else 12.dp),
319355 label = { Text(if (kind == McpKind.LOCAL) stringResource(R.string.mcp_add_command) else stringResource(R.string.mcp_add_url)) },
320356 placeholder = {
321357 Text(if (kind == McpKind.LOCAL) stringResource(R.string.mcp_add_command_hint) else stringResource(R.string.mcp_add_url_hint))
@@ -323,12 +359,24 @@ private fun AddMcpDialog(
323359 singleLine = true,
324360 enabled = !adding,
325361 )
362+ // Warn that the local command is split on whitespace and quoted args aren't
363+ // supported, so a user entering `"my program" --arg` isn't surprised when it's
364+ // split into 4 tokens. Directs them to config.mcp for complex commands.
365+ if (kind == McpKind.LOCAL) {
366+ Text(
367+ stringResource(R.string.mcp_command_split_hint),
368+ style = MaterialTheme.typography.labelSmall,
369+ color = MaterialTheme.colorScheme.onSurfaceVariant,
370+ modifier = Modifier.padding(bottom = 12.dp),
371+ )
372+ }
326373 OutlinedTextField(
327374 value = env,
328375 onValueChange = { env = it },
329376 modifier = Modifier.fillMaxWidth(),
330377 label = { Text(stringResource(R.string.mcp_add_env)) },
331378 placeholder = { Text(stringResource(R.string.mcp_add_env_hint)) },
379+ supportingText = { Text(stringResource(R.string.mcp_env_hint)) },
332380 enabled = !adding,
333381 minLines = 2,
334382 )
app/src/main/java/soy/iko/opencode/ui/mcp/McpViewModel.kt+5−2
@@ -23,6 +23,7 @@ import kotlinx.serialization.json.putJsonArray
2323import kotlinx.serialization.json.putJsonObject
2424import soy.iko.opencode.di.AppContainer
2525import soy.iko.opencode.util.runCatchingCancellable
26+import soy.iko.opencode.util.safeExceptionSummary
2627
2728/** The shape of an MCP server the user is registering from the Add dialog. */
2829enum class McpKind { LOCAL, REMOTE }
@@ -38,7 +39,9 @@ class McpViewModel(private val container: AppContainer) : ViewModel() {
3839 sealed interface State {
3940 data object Loading : State
4041 data object Disconnected : State
41− data object Error : State
42+ /** MCP servers failed to load. [message] carries a short, safe summary so the user
43+ * can tell an auth failure from a transient network drop or an old server without /mcp. */
44+ data class Error(val message: String? = null) : State
4245 data class Ready(val servers: List<McpServerInfo>) : State
4346 }
4447
@@ -97,7 +100,7 @@ class McpViewModel(private val container: AppContainer) : ViewModel() {
97100 }
98101 }
99102 .onSuccess { _state.value = State.Ready(it) }
100− .onFailure { _state.value = State.Error }
103+ .onFailure { _state.value = State.Error(safeExceptionSummary(it)) }
101104 _refreshing.value = false
102105 }
103106
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+2−1
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.PaddingValues
88import androidx.compose.foundation.layout.Row
99import androidx.compose.foundation.layout.fillMaxSize
1010import androidx.compose.foundation.layout.fillMaxWidth
11+import androidx.compose.foundation.layout.imePadding
1112import androidx.compose.foundation.layout.padding
1213import androidx.compose.foundation.layout.size
1314import androidx.compose.foundation.lazy.LazyColumn
@@ -83,7 +84,7 @@ fun GlobalSearchScreen(
8384 AppTopBar(title = stringResource(R.string.search_all_title), onBack = onBack)
8485 },
8586 ) { padding ->
86− Column(modifier = Modifier.fillMaxSize().padding(padding)) {
87+ Column(modifier = Modifier.fillMaxSize().imePadding().padding(padding)) {
8788 OutlinedTextField(
8889 value = state.query,
8990 onValueChange = vm::setQuery,
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+4
@@ -224,6 +224,10 @@ private fun ServerEditForm(
224224 } else {
225225 Text(stringResource(R.string.invalid_url))
226226 }
227+ } else if (state.baseUrl.isBlank()) {
228+ // Surface the QR-paste shortcut so a user with a shared server config knows
229+ // they can paste it here instead of scanning an image.
230+ Text(stringResource(R.string.qr_paste_hint))
227231 }
228232 },
229233 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+3−2
@@ -84,6 +84,7 @@ import soy.iko.opencode.util.runCatchingCancellable
8484import soy.iko.opencode.di.AppContainer
8585import soy.iko.opencode.R
8686import soy.iko.opencode.ui.components.EmptyState
87+import soy.iko.opencode.ui.components.reducedMotionAnimateItem
8788import soy.iko.opencode.ui.components.rememberRelativeTime
8889import soy.iko.opencode.ui.vmFactory
8990
@@ -324,7 +325,7 @@ fun ServerListScreen(
324325 Card(
325326 modifier = Modifier
326327 .fillMaxWidth()
327− .animateItem()
328+ .then(reducedMotionAnimateItem())
328329 .testTag("server_card")
329330 .clickable(enabled = connectingId == null, role = Role.Button) {
330331 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
@@ -357,7 +358,7 @@ fun ServerListScreen(
357358 SwipeToDismissBox(
358359 state = swipeState,
359360 enableDismissFromStartToEnd = false,
360− modifier = Modifier.animateItem(),
361+ modifier = reducedMotionAnimateItem(),
361362 backgroundContent = {
362363 Box(
363364 modifier = Modifier
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+6−2
@@ -9,8 +9,10 @@ import androidx.compose.foundation.layout.Box
99import androidx.compose.foundation.layout.Column
1010import androidx.compose.foundation.layout.PaddingValues
1111import androidx.compose.foundation.layout.Row
12+import androidx.compose.foundation.layout.defaultMinSize
1213import androidx.compose.foundation.layout.Spacer
1314import androidx.compose.foundation.layout.fillMaxSize
15+import androidx.compose.foundation.layout.imePadding
1416import androidx.compose.foundation.layout.fillMaxWidth
1517import androidx.compose.foundation.layout.heightIn
1618import androidx.compose.foundation.layout.padding
@@ -115,6 +117,7 @@ import soy.iko.opencode.R
115117import soy.iko.opencode.ui.components.ConnectionBanner
116118import soy.iko.opencode.ui.components.EmptyState
117119import soy.iko.opencode.ui.components.LocalRelativeTimeTick
120+import soy.iko.opencode.ui.components.reducedMotionAnimateItem
118121import soy.iko.opencode.ui.components.RelativeTimeText
119122import soy.iko.opencode.ui.components.rememberRelativeTimeTick
120123import soy.iko.opencode.ui.vmFactory
@@ -422,7 +425,7 @@ fun SessionListScreen(
422425 // down when it appears instead of covering the search field and top cards. Its own
423426 // AnimatedVisibility collapses it to zero height when the stream is healthy, so the
424427 // body fills the space normally.
425− Column(modifier = Modifier.fillMaxSize().padding(padding)) {
428+ Column(modifier = Modifier.fillMaxSize().imePadding().padding(padding)) {
426429 ConnectionBanner(
427430 state = connectionState,
428431 isOnline = isOnline,
@@ -738,7 +741,7 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
738741 // Indent sub-sessions under their parent (capped so deep
739742 // nesting doesn't squeeze the card off-screen).
740743 modifier = Modifier
741− .animateItem()
744+ .then(reducedMotionAnimateItem())
742745 .padding(start = (node.depth.coerceAtMost(3) * 16).dp),
743746 backgroundContent = {
744747 val archiving = swipeState.dismissDirection == SwipeToDismissBoxValue.StartToEnd
@@ -1093,6 +1096,7 @@ private fun SessionCard(
10931096 verticalAlignment = Alignment.CenterVertically,
10941097 modifier = Modifier
10951098 .padding(top = 2.dp)
1099+ .defaultMinSize(minHeight = 48.dp)
10961100 .clip(MaterialTheme.shapes.small)
10971101 .then(
10981102 if (chipEnabled) {
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+16−4
@@ -73,6 +73,7 @@ import soy.iko.opencode.util.runCatchingCancellable
7373import soy.iko.opencode.ui.components.AppTopBar
7474import soy.iko.opencode.ui.components.EmptyState
7575import soy.iko.opencode.ui.components.copyToClipboard
76+import soy.iko.opencode.ui.components.reducedMotionAnimateItem
7677import androidx.compose.ui.Alignment
7778import androidx.compose.ui.Modifier
7879import androidx.compose.ui.draw.clip
@@ -110,6 +111,10 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
110111 val shareScope = rememberCoroutineScope()
111112 val haptics = LocalHapticFeedback.current
112113 val snackbar = remember { SnackbarHostState() }
114+ // Separate host for transient error messages (share failures, etc.) so a transient error
115+ // can't preempt an undo snackbar's full window — the undo and error channels don't share
116+ // a queue, so a share error can't cancel an in-flight undo and strand a pending delete.
117+ val errorSnackbar = remember { SnackbarHostState() }
113118 val undoLabel = stringResource(R.string.undo)
114119 val reportDeletedLabel = stringResource(R.string.report_deleted)
115120 val reportsClearedLabel = stringResource(R.string.reports_cleared)
@@ -225,7 +230,14 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
225230 },
226231 )
227232 },
228− snackbarHost = { SnackbarHost(snackbar) },
233+ snackbarHost = {
234+ // Stack both hosts; the undo snackbar takes priority (rendered first), the error
235+ // host renders below it so both can be visible simultaneously without preempting.
236+ Column {
237+ SnackbarHost(snackbar)
238+ SnackbarHost(errorSnackbar)
239+ }
240+ },
229241 ) { padding ->
230242 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
231243 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
@@ -276,7 +288,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
276288 SwipeToDismissBox(
277289 state = swipeState,
278290 enableDismissFromStartToEnd = false,
279− modifier = Modifier.animateItem(),
291+ modifier = reducedMotionAnimateItem(),
280292 backgroundContent = {
281293 Box(
282294 modifier = Modifier
@@ -332,7 +344,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
332344 // failures (e.g. an oversized report exceeding the Binder limit →
333345 // TransactionTooLargeException) get a generic toast, not that misleading one.
334346 val msg = if (it is ActivityNotFoundException) R.string.no_share_app else R.string.error_generic
335− shareScope.launch { snackbar.showSnackbar(context.getString(msg)) }
347+ shareScope.launch { errorSnackbar.showSnackbar(context.getString(msg)) }
336348 }
337349 }
338350 }) {
@@ -430,7 +442,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
430442 // (e.g. an oversized report exceeding the Binder limit → TransactionTooLargeException)
431443 // get a generic toast, not that misleading one.
432444 val msg = if (it is ActivityNotFoundException) R.string.no_share_app else R.string.error_generic
433− shareScope.launch { snackbar.showSnackbar(context.getString(msg)) }
445+ shareScope.launch { errorSnackbar.showSnackbar(context.getString(msg)) }
434446 }
435447 },
436448 ) { Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share)) }
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+14
@@ -273,6 +273,7 @@ fun SettingsScreen(
273273 Row(
274274 modifier = Modifier
275275 .fillMaxWidth()
276+ .defaultMinSize(minHeight = 48.dp)
276277 .toggleable(
277278 value = s.dynamicColor,
278279 enabled = !amoledActive,
@@ -310,6 +311,7 @@ fun SettingsScreen(
310311 Row(
311312 modifier = Modifier
312313 .fillMaxWidth()
314+ .defaultMinSize(minHeight = 48.dp)
313315 .toggleable(
314316 value = s.sendOnEnter,
315317 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setSendOnEnter(it) } } },
@@ -341,6 +343,7 @@ fun SettingsScreen(
341343 Row(
342344 modifier = Modifier
343345 .fillMaxWidth()
346+ .defaultMinSize(minHeight = 48.dp)
344347 .toggleable(
345348 value = s.appLock,
346349 enabled = appLockToggleable,
@@ -397,6 +400,7 @@ fun SettingsScreen(
397400 Row(
398401 modifier = Modifier
399402 .fillMaxWidth()
403+ .defaultMinSize(minHeight = 48.dp)
400404 .toggleable(
401405 value = codeWrap,
402406 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setCodeWrap(it) } } },
@@ -463,6 +467,7 @@ fun SettingsScreen(
463467 TextButton(
464468 onClick = { container.activeConnection.value?.events?.triggerReconnect() },
465469 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
470+ modifier = Modifier.defaultMinSize(minHeight = 48.dp),
466471 ) { Text(stringResource(R.string.retry_now)) }
467472 }
468473 }
@@ -532,6 +537,7 @@ fun SettingsScreen(
532537 Row(
533538 modifier = Modifier
534539 .fillMaxWidth()
540+ .defaultMinSize(minHeight = 48.dp)
535541 .toggleable(
536542 value = includePasswords,
537543 onValueChange = { includePasswords = it },
@@ -662,6 +668,13 @@ fun SettingsScreen(
662668 actionLabel = restartLabel,
663669 )
664670 if (result == SnackbarResult.ActionPerformed) {
671+ // Shut down the container (cancels the app scope, closes the
672+ // active connection, flushes stores) before the hard kill so
673+ // in-flight work is cleaned up rather than abruptly severed.
674+ // The JVM shutdown hook in OpencodeApp would catch this, but
675+ // calling shutdown explicitly is cleaner and avoids relying on
676+ // the hook's ordering under a kill.
677+ runCatchingCancellable { container.shutdown() }
665678 android.os.Process.killProcess(android.os.Process.myPid())
666679 }
667680 } else {
@@ -805,6 +818,7 @@ private fun ThemeRow(mode: ThemeMode, selected: Boolean, usingSystemColors: Bool
805818 Row(
806819 modifier = Modifier
807820 .fillMaxWidth()
821+ .defaultMinSize(minHeight = 48.dp)
808822 .selectable(selected = selected, onClick = onSelect)
809823 .padding(vertical = 8.dp),
810824 horizontalArrangement = Arrangement.spacedBy(8.dp),
app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.kt+21−1
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.size
1616import androidx.compose.foundation.lazy.LazyColumn
1717import androidx.compose.foundation.lazy.items
1818import androidx.compose.material.icons.Icons
19+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
1920import androidx.compose.material.icons.filled.Close
2021import androidx.compose.material.icons.filled.CloudOff
2122import androidx.compose.material.icons.filled.ErrorOutline
@@ -49,6 +50,7 @@ import androidx.compose.ui.res.stringResource
4950import androidx.compose.ui.semantics.Role
5051import androidx.compose.ui.semantics.contentDescription
5152import androidx.compose.ui.semantics.heading
53+import androidx.compose.ui.semantics.invisibleToUser
5254import androidx.compose.ui.semantics.semantics
5355import androidx.compose.ui.text.style.TextOverflow
5456import androidx.compose.ui.unit.dp
@@ -112,6 +114,7 @@ fun UsageScreen(container: AppContainer, onBack: () -> Unit, onOpenSession: (Str
112114 is UsageViewModel.State.Error -> EmptyState(
113115 icon = Icons.Filled.ErrorOutline,
114116 title = stringResource(R.string.usage_failed),
117+ description = s.message,
115118 modifier = Modifier.align(Alignment.Center),
116119 actionLabel = stringResource(R.string.retry),
117120 onAction = { vm.load() },
@@ -121,6 +124,7 @@ fun UsageScreen(container: AppContainer, onBack: () -> Unit, onOpenSession: (Str
121124 EmptyState(
122125 icon = Icons.Filled.QueryStats,
123126 title = stringResource(R.string.usage_empty),
127+ description = stringResource(R.string.usage_empty_desc),
124128 modifier = Modifier.align(Alignment.Center),
125129 )
126130 } else {
@@ -223,12 +227,17 @@ private fun fractionOf(part: Double, total: Double): Float =
223227/** A slim horizontal bar showing this row's share of the total cost. */
224228@Composable
225229private fun CostBar(fraction: Float) {
230+ val pct = (fraction * 100).toInt()
226231 Box(
227232 modifier = Modifier
228233 .fillMaxWidth()
229234 .height(4.dp)
230235 .clip(MaterialTheme.shapes.small)
231− .background(MaterialTheme.colorScheme.surfaceVariant),
236+ .background(MaterialTheme.colorScheme.surfaceVariant)
237+ .semantics {
238+ contentDescription = "${pct}% of total"
239+ invisibleToUser()
240+ },
232241 ) {
233242 if (fraction > 0f) {
234243 Box(
@@ -325,6 +334,17 @@ private fun UsageRow(
325334 color = MaterialTheme.colorScheme.primary,
326335 modifier = Modifier.padding(start = 12.dp),
327336 )
337+ // Chevron signals that tapping a session row navigates to it (mirrors the M3
338+ // list-item pattern for navigable rows), so the affordance isn't hidden behind
339+ // just the click ripple.
340+ if (onClick != null) {
341+ Icon(
342+ Icons.AutoMirrored.Filled.KeyboardArrowRight,
343+ contentDescription = null,
344+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
345+ modifier = Modifier.size(20.dp),
346+ )
347+ }
328348 }
329349 Spacer(Modifier.size(6.dp))
330350 CostBar(costFraction)
app/src/main/java/soy/iko/opencode/ui/usage/UsageViewModel.kt+5−2
@@ -18,6 +18,7 @@ import kotlinx.coroutines.sync.withPermit
1818import kotlinx.coroutines.withContext
1919import soy.iko.opencode.di.AppContainer
2020import soy.iko.opencode.util.runCatchingCancellable
21+import soy.iko.opencode.util.safeExceptionSummary
2122
2223/**
2324 * Computes a cross-session [UsageReport] by listing the server's sessions and fetching each
@@ -31,7 +32,9 @@ class UsageViewModel(private val container: AppContainer) : ViewModel() {
3132 sealed interface State {
3233 data object Loading : State
3334 data object Disconnected : State
34− data object Error : State
35+ /** Usage failed to load. [message] carries a short, safe summary (e.g. "offline",
36+ * "server error") so the user can tell an auth failure from a transient network drop. */
37+ data class Error(val message: String? = null) : State
3538 data class Ready(val report: UsageReport) : State
3639 }
3740
@@ -90,7 +93,7 @@ class UsageViewModel(private val container: AppContainer) : ViewModel() {
9093 withContext(Dispatchers.Default) { aggregateUsage(perSession) }
9194 }
9295 .onSuccess { _state.value = State.Ready(it) }
93− .onFailure { _state.value = State.Error }
96+ .onFailure { _state.value = State.Error(safeExceptionSummary(it)) }
9497 _refreshing.value = false
9598 }
9699
app/src/main/res/values-es/strings.xml+34
@@ -701,4 +701,38 @@
701701 <string name="revert_diff_header">Cambios del revertido</string>
702702 <string name="agent_error_label">El agente informó un error</string>
703703 <string name="custom_path_required">Escribe una ruta para crear una sesión aquí.</string>
704+
705+ <!-- UI/UX batch 6 -->
706+ <string name="loading_models">Cargando modelos…</string>
707+ <string name="loading_agents">Cargando agentes…</string>
708+ <string name="loading_commands">Cargando comandos…</string>
709+ <string name="show_full_error">Ver error completo</string>
710+ <string name="hide_full_error">Ocultar error completo</string>
711+ <string name="discard_queued_title">¿Descartar mensajes en cola?</string>
712+ <string name="discard_queued_text">Se descartarán %1$d mensaje(s) en cola. Esto no se puede deshacer.</string>
713+ <string name="compose_editor_title">Redactar mensaje</string>
714+ <string name="always_allow_scope">Se aplica a esta herramienta y patrón en todas las sesiones.</string>
715+ <string name="mcp_local">Local</string>
716+ <string name="mcp_remote">Remoto</string>
717+ <string name="mcp_command_split_hint">División por espacios; los argumentos entre comillas no son compatibles. Edita config.mcp para comandos complejos.</string>
718+ <string name="mcp_env_hint">Un KEY=VALUE por línea; las líneas sin = se omiten.</string>
719+ <string name="usage_range_24h">24 horas</string>
720+ <string name="usage_range_7d">7 días</string>
721+ <string name="usage_range_30d">30 días</string>
722+ <string name="usage_range_all">Todo el tiempo</string>
723+ <string name="usage_open_session">Abrir sesión</string>
724+ <string name="usage_cost_fraction">%1$d%% del total</string>
725+ <string name="app_lock_disabled_warning">El bloqueo de la app está activado pero no hay biometría o credencial del dispositivo disponible; la app no está bloqueada.</string>
726+ <string name="qr_paste_hint">Consejo: pega una configuración de servidor compartida aquí para importarla.</string>
727+ <string name="server_url_clear">Borrar URL</string>
728+ <string name="long_press_for_timestamp">Mantén pulsado para la marca de tiempo completa</string>
729+ <string name="search_always_available">Buscar</string>
730+ <string name="streaming_caret_label">transmitiendo</string>
731+ <string name="code_block_label">Bloque de código, %1$s, %2$d líneas</string>
732+ <string name="code_block_plain">simple</string>
733+ <string name="diff_added_line">Línea %1$d añadida: %2$s</string>
734+ <string name="diff_removed_line">Línea %1$d eliminada: %2$s</string>
735+ <string name="diff_context_line">Línea %1$d: %2$s</string>
736+ <string name="tts_stopped_unexpectedly">La lectura en voz alta se detuvo inesperadamente</string>
737+ <string name="usage_empty_desc">El uso aparece una vez que tu asistente haya enviado mensajes facturables.</string>
704738</resources>
app/src/main/res/values/strings.xml+34
@@ -480,6 +480,7 @@
480480 <string name="usage_filter_sessions">Filter sessions</string>
481481 <string name="usage_no_sessions">No matching sessions</string>
482482 <string name="usage_empty">No usage recorded yet</string>
483+ <string name="usage_empty_desc">Usage appears once your assistant has sent billable messages.</string>
483484 <plurals name="usage_messages_count">
484485 <item quantity="one">%1$d assistant message</item>
485486 <item quantity="other">%1$d assistant messages</item>
@@ -701,4 +702,37 @@
701702 <string name="revert_diff_header">Changes from revert</string>
702703 <string name="agent_error_label">The agent reported an error</string>
703704 <string name="custom_path_required">Enter a path to create a session here.</string>
705+
706+ <!-- UI/UX batch 6: picker sheets, diff, markdown images, discoverability, errors, usage, mcp -->
707+ <string name="loading_models">Loading models…</string>
708+ <string name="loading_agents">Loading agents…</string>
709+ <string name="loading_commands">Loading commands…</string>
710+ <string name="show_full_error">Show full error</string>
711+ <string name="hide_full_error">Hide full error</string>
712+ <string name="discard_queued_title">Discard queued messages?</string>
713+ <string name="discard_queued_text">%1$d queued message(s) will be discarded. This cannot be undone.</string>
714+ <string name="compose_editor_title">Compose message</string>
715+ <string name="always_allow_scope">Applies to this tool and pattern in every session.</string>
716+ <string name="mcp_local">Local</string>
717+ <string name="mcp_remote">Remote</string>
718+ <string name="mcp_command_split_hint">Split on whitespace; quoted args aren\'t supported. Edit config.mcp for complex commands.</string>
719+ <string name="mcp_env_hint">One KEY=VALUE per line; lines without = are skipped.</string>
720+ <string name="usage_range_24h">24 hours</string>
721+ <string name="usage_range_7d">7 days</string>
722+ <string name="usage_range_30d">30 days</string>
723+ <string name="usage_range_all">All time</string>
724+ <string name="usage_open_session">Open session</string>
725+ <string name="usage_cost_fraction">%1$d%% of total</string>
726+ <string name="app_lock_disabled_warning">App lock is on but no biometric or device credential is available — the app isn\'t locked.</string>
727+ <string name="qr_paste_hint">Tip: paste a shared server config here to import it.</string>
728+ <string name="server_url_clear">Clear URL</string>
729+ <string name="long_press_for_timestamp">Long-press for the full timestamp</string>
730+ <string name="search_always_available">Search</string>
731+ <string name="streaming_caret_label">streaming</string>
732+ <string name="code_block_label">Code block, %1$s, %2$d lines</string>
733+ <string name="code_block_plain">plain</string>
734+ <string name="diff_added_line">Line %1$d added: %2$s</string>
735+ <string name="diff_removed_line">Line %1$d removed: %2$s</string>
736+ <string name="diff_context_line">Line %1$d: %2$s</string>
737+ <string name="tts_stopped_unexpectedly">Read-aloud stopped unexpectedly</string>
704738</resources>