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
d15e78eiko
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+69
@@ -256,6 +256,75 @@ object NetworkConfig {
256 * keeps line lengths comfortable. Applied only above [twoPaneWidthThresholdDp]. */
256 * keeps line lengths comfortable. Applied only above [twoPaneWidthThresholdDp]. */
257 const val chatContentMaxWidthDp = 800
257 const val chatContentMaxWidthDp = 800
258
258
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+
259 // --- Motion tokens (shared across screens) ---
328 // --- Motion tokens (shared across screens) ---
260
329
261 /** Duration (ms) for horizontal slide push/pop nav transitions. */
330 /** 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
23import androidx.compose.material3.Text
23import androidx.compose.material3.Text
24import androidx.compose.runtime.Composable
24import androidx.compose.runtime.Composable
25import androidx.compose.runtime.DisposableEffect
25import androidx.compose.runtime.DisposableEffect
26+import androidx.compose.runtime.LaunchedEffect
26import androidx.compose.runtime.getValue
27import androidx.compose.runtime.getValue
27import androidx.compose.runtime.mutableStateOf
28import androidx.compose.runtime.mutableStateOf
28import androidx.compose.runtime.remember
29import androidx.compose.runtime.remember
@@ -108,6 +109,17 @@ fun AppLockGate(enabled: Boolean, reLockDelaySeconds: Int = 0, content: @Composa
108 // would otherwise always hit onAuthenticationError and strand the user on a permanent lockout.
109 // would otherwise always hit onAuthenticationError and strand the user on a permanent lockout.
109 // A transient authenticator error keeps the lock up (the user retries) rather than unlocking.
110 // A transient authenticator error keeps the lock up (the user retries) rather than unlocking.
110 if (appLockPermanentlyUnavailable(context)) {
111 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+ }
111 content()
123 content()
112 return
124 return
113 }
125 }
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+15−1
@@ -26,6 +26,7 @@ import androidx.compose.ui.unit.dp
26import androidx.compose.ui.unit.LayoutDirection
26import androidx.compose.ui.unit.LayoutDirection
27import androidx.compose.ui.platform.LocalLayoutDirection
27import androidx.compose.ui.platform.LocalLayoutDirection
28import androidx.navigation.NavType
28import androidx.navigation.NavType
29+import androidx.navigation.NavGraph.Companion.findStartDestination
29import androidx.navigation.compose.NavHost
30import androidx.navigation.compose.NavHost
30import androidx.navigation.compose.composable
31import androidx.navigation.compose.composable
31import androidx.navigation.compose.currentBackStackEntryAsState
32import androidx.navigation.compose.currentBackStackEntryAsState
@@ -455,7 +456,20 @@ fun OpencodeApp(container: AppContainer) {
455 LargeScreenNavRail(
456 LargeScreenNavRail(
456 currentRoute = currentRoute,
457 currentRoute = currentRoute,
457 connected = connection != null,
458 connected = connection != null,
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 ->
458− onNavigate = { route -> navController.navigate(route) { launchSingleTop = true } },
465+ navController.navigate(route) {
466+ popUpTo(navController.graph.findStartDestination().id) {
467+ saveState = true
468+ }
469+ launchSingleTop = true
470+ restoreState = true
471+ }
472+ },
459 )
473 )
460 Box(modifier = Modifier.weight(1f).fillMaxHeight()) { navHost() }
474 Box(modifier = Modifier.weight(1f).fillMaxHeight()) { navHost() }
461 }
475 }
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+56−21
@@ -5,6 +5,8 @@ import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.fillMaxWidth
6import androidx.compose.foundation.layout.fillMaxWidth
7import androidx.compose.foundation.layout.heightIn
7import androidx.compose.foundation.layout.heightIn
8+import androidx.compose.foundation.layout.imePadding
9+import androidx.compose.foundation.layout.navigationBarsPadding
8import androidx.compose.foundation.layout.padding
10import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.size
11import androidx.compose.foundation.layout.size
10import androidx.compose.foundation.lazy.LazyColumn
12import androidx.compose.foundation.lazy.LazyColumn
@@ -25,11 +27,13 @@ import androidx.compose.material3.ModalBottomSheet
25import androidx.compose.material3.OutlinedTextField
27import androidx.compose.material3.OutlinedTextField
26import androidx.compose.material3.Text
28import androidx.compose.material3.Text
27import androidx.compose.material3.TextButton
29import androidx.compose.material3.TextButton
30+import androidx.compose.material3.rememberModalBottomSheetState
28import androidx.compose.runtime.Composable
31import androidx.compose.runtime.Composable
29import androidx.compose.runtime.LaunchedEffect
32import androidx.compose.runtime.LaunchedEffect
30import androidx.compose.runtime.getValue
33import androidx.compose.runtime.getValue
31import androidx.compose.runtime.mutableStateOf
34import androidx.compose.runtime.mutableStateOf
32import androidx.compose.runtime.remember
35import androidx.compose.runtime.remember
36+import androidx.compose.runtime.rememberCoroutineScope
33import androidx.compose.runtime.saveable.rememberSaveable
37import androidx.compose.runtime.saveable.rememberSaveable
34import androidx.compose.runtime.setValue
38import androidx.compose.runtime.setValue
35import androidx.compose.ui.Alignment
39import androidx.compose.ui.Alignment
@@ -43,12 +47,13 @@ import androidx.compose.ui.res.stringResource
43import androidx.compose.ui.semantics.Role
47import androidx.compose.ui.semantics.Role
44import androidx.compose.ui.semantics.contentDescription
48import androidx.compose.ui.semantics.contentDescription
45import androidx.compose.ui.semantics.role
49import androidx.compose.ui.semantics.role
50+import androidx.compose.ui.semantics.selectableGroup
46import androidx.compose.ui.semantics.selected
51import androidx.compose.ui.semantics.selected
47import androidx.compose.ui.semantics.semantics
52import androidx.compose.ui.semantics.semantics
48import androidx.compose.ui.text.input.ImeAction
53import androidx.compose.ui.text.input.ImeAction
49import androidx.compose.ui.unit.dp
54import androidx.compose.ui.unit.dp
50import androidx.compose.foundation.layout.Box
55import androidx.compose.foundation.layout.Box
51−import androidx.compose.foundation.layout.fillMaxHeight
56+import kotlinx.coroutines.launch
52import soy.iko.opencode.data.model.Agent
57import soy.iko.opencode.data.model.Agent
53import soy.iko.opencode.data.network.NetworkConfig
58import soy.iko.opencode.data.network.NetworkConfig
54import soy.iko.opencode.R
59import soy.iko.opencode.R
@@ -87,19 +92,32 @@ fun AgentPickerSheet(
87 onRetry: () -> Unit,
92 onRetry: () -> Unit,
88 onDismiss: () -> Unit,
93 onDismiss: () -> Unit,
89) {
94) {
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()
90− ModalBottomSheet(onDismissRequest = onDismiss) {
102+ ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
91 Text(
103 Text(
92 stringResource(R.string.agent),
104 stringResource(R.string.agent),
93 style = MaterialTheme.typography.titleMedium,
105 style = MaterialTheme.typography.titleMedium,
94 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
106 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
95 )
107 )
96 if (loading) {
108 if (loading) {
97− val loadingLabel = stringResource(R.string.loading)
109+ val loadingLabel = stringResource(R.string.loading_agents)
98− Box(
110+ Column(
99− modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
111+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp).imePadding(),
100− contentAlignment = Alignment.Center,
112+ horizontalAlignment = Alignment.CenterHorizontally,
101 ) {
113 ) {
102 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
114 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+ )
103 }
121 }
104 } else if (error && agents.isEmpty()) {
122 } else if (error && agents.isEmpty()) {
105 Column(
123 Column(
@@ -149,10 +167,13 @@ fun AgentPickerSheet(
149 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
167 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
150 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
168 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
151 )
169 )
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) {
153 val q = query.trim()
174 val q = query.trim()
154− if (q.isEmpty()) agents
175+ if (q.isEmpty()) distinctAgents
155− else agents.filter {
176+ else distinctAgents.filter {
156 it.name.contains(q, ignoreCase = true) ||
177 it.name.contains(q, ignoreCase = true) ||
157 it.displayDescription.contains(q, ignoreCase = true)
178 it.displayDescription.contains(q, ignoreCase = true)
158 }
179 }
@@ -174,30 +195,43 @@ fun AgentPickerSheet(
174 )
195 )
175 } else {
196 } else {
176 val listState = rememberLazyListState()
197 val listState = rememberLazyListState()
177− val distinctAgents = remember(filtered) { filtered.distinctBy { it.name } }
178 // Flat index of the selected agent, offset by the leading "Default" row when
198 // Flat index of the selected agent, offset by the leading "Default" row when
179 // it's shown. selected == null targets that Default row (index 0). -1 when the
199 // it's shown. selected == null targets that Default row (index 0). -1 when the
180 // selection isn't in the current list.
200 // selection isn't in the current list.
181 val selectedIndex = remember(distinctAgents, selected, defaultMatches) {
201 val selectedIndex = remember(distinctAgents, selected, defaultMatches) {
182 selectedAgentIndex(distinctAgents, selected, defaultMatches)
202 selectedAgentIndex(distinctAgents, selected, defaultMatches)
183 }
203 }
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.
184− LaunchedEffect(Unit) {
207+ LaunchedEffect(filtered, defaultMatches) {
185 if (selectedIndex >= 0) listState.scrollToItem(selectedIndex)
208 if (selectedIndex >= 0) listState.scrollToItem(selectedIndex)
186 }
209 }
187− // Responsive height: half the viewport rather than a hardcoded 420dp cap, so
210+ // Cap the list height against the screen, not the (partial) sheet, so the list
188− // the sheet doesn't cramp in landscape or leave empty space on a tall phone.
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,
189− LazyColumn(state = listState, modifier = Modifier.fillMaxHeight(0.5f)) {
215+ modifier = Modifier
216+ .heightIn(max = NetworkConfig.pickerSheetMaxHeightDp.dp)
217+ .imePadding()
218+ .navigationBarsPadding()
219+ .semantics { selectableGroup() },
220+ ) {
190 if (defaultMatches) {
221 if (defaultMatches) {
191 item(key = "__default") {
222 item(key = "__default") {
192 Column(
223 Column(
193 modifier = Modifier
224 modifier = Modifier
194 .fillMaxWidth()
225 .fillMaxWidth()
195 .semantics(mergeDescendants = true) {
226 .semantics(mergeDescendants = true) {
196− if (selected == null) this.selected = true
227+ this.selected = (selected == null)
197 }
228 }
198 .clickable(role = Role.RadioButton) {
229 .clickable(role = Role.RadioButton) {
199 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
230 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
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() }
200− onSelect(null); onDismiss()
234+ onSelect(null)
201 }
235 }
202 .padding(horizontal = 24.dp, vertical = 12.dp),
236 .padding(horizontal = 24.dp, vertical = 12.dp),
203 ) {
237 ) {
@@ -213,7 +247,7 @@ fun AgentPickerSheet(
213 Icons.Filled.Check,
247 Icons.Filled.Check,
214 contentDescription = null,
248 contentDescription = null,
215 tint = MaterialTheme.colorScheme.primary,
249 tint = MaterialTheme.colorScheme.primary,
216− modifier = Modifier.size(18.dp),
250+ modifier = Modifier.size(20.dp),
217 )
251 )
218 }
252 }
219 }
253 }
@@ -231,17 +265,18 @@ fun AgentPickerSheet(
231 // Namespace the agent keys so an agent literally named "__default" can't
265 // Namespace the agent keys so an agent literally named "__default" can't
232 // collide with the sentinel key of the default item above (a duplicate key
266 // collide with the sentinel key of the default item above (a duplicate key
233 // crashes LazyColumn).
267 // crashes LazyColumn).
234− items(distinctAgents, key = { "agent_" + it.name }) { agent ->
268+ items(filtered, key = { "agent_" + it.name }) { agent ->
235 val isSelected = agent.name == selected
269 val isSelected = agent.name == selected
236 Column(
270 Column(
237 modifier = Modifier
271 modifier = Modifier
238 .fillMaxWidth()
272 .fillMaxWidth()
239 .semantics(mergeDescendants = true) {
273 .semantics(mergeDescendants = true) {
240− if (isSelected) this.selected = true
274+ this.selected = isSelected
241 }
275 }
242 .clickable(role = Role.RadioButton) {
276 .clickable(role = Role.RadioButton) {
243 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
277 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
278+ scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss() }
244− onSelect(agent); onDismiss()
279+ onSelect(agent)
245 }
280 }
246 .padding(horizontal = 24.dp, vertical = 12.dp),
281 .padding(horizontal = 24.dp, vertical = 12.dp),
247 ) {
282 ) {
@@ -257,7 +292,7 @@ fun AgentPickerSheet(
257 Icons.Filled.Check,
292 Icons.Filled.Check,
258 contentDescription = null,
293 contentDescription = null,
259 tint = MaterialTheme.colorScheme.primary,
294 tint = MaterialTheme.colorScheme.primary,
260− modifier = Modifier.size(18.dp),
295+ modifier = Modifier.size(20.dp),
261 )
296 )
262 }
297 }
263 }
298 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+55−9
@@ -165,6 +165,7 @@ import soy.iko.opencode.ui.components.ConnectionBanner
165import soy.iko.opencode.ui.components.DiffView
165import soy.iko.opencode.ui.components.DiffView
166import soy.iko.opencode.ui.components.PaletteAction
166import soy.iko.opencode.ui.components.PaletteAction
167import soy.iko.opencode.ui.components.LocalReducedMotion
167import soy.iko.opencode.ui.components.LocalReducedMotion
168+import soy.iko.opencode.ui.components.reducedMotionAnimateItem
168import soy.iko.opencode.ui.components.copyToClipboard
169import soy.iko.opencode.ui.components.copyToClipboard
169import soy.iko.opencode.ui.components.LocalRelativeTimeTick
170import soy.iko.opencode.ui.components.LocalRelativeTimeTick
170import soy.iko.opencode.ui.components.rememberRelativeTimeTick
171import soy.iko.opencode.ui.components.rememberRelativeTimeTick
@@ -259,6 +260,10 @@ fun ChatScreen(
259 val snackbar = remember { SnackbarHostState() }
260 val snackbar = remember { SnackbarHostState() }
260 val retryLabel = stringResource(R.string.retry)
261 val retryLabel = stringResource(R.string.retry)
261 val inputFocusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
262 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() }
262 var showModelPicker by rememberSaveable { mutableStateOf(false) }
267 var showModelPicker by rememberSaveable { mutableStateOf(false) }
263 var showAgentPicker by rememberSaveable { mutableStateOf(false) }
268 var showAgentPicker by rememberSaveable { mutableStateOf(false) }
264 var showTitleMenu by rememberSaveable { mutableStateOf(false) }
269 var showTitleMenu by rememberSaveable { mutableStateOf(false) }
@@ -673,7 +678,10 @@ fun ChatScreen(
673 onClick = {
678 onClick = {
674 showOverflowMenu = false
679 showOverflowMenu = false
675 searchActive = true
680 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).
677 },
685 },
678 )
686 )
679 androidx.compose.material3.HorizontalDivider()
687 androidx.compose.material3.HorizontalDivider()
@@ -1165,7 +1173,7 @@ fun ChatScreen(
1165 // doesn't animate on every token (and respects the a11y preference).
1173 // doesn't animate on every token (and respects the a11y preference).
1166 Box(
1174 Box(
1167 Modifier
1175 Modifier
1168− .then(if (LocalReducedMotion.current) Modifier else Modifier.animateItem())
1176+ .then(reducedMotionAnimateItem())
1169 .then(
1177 .then(
1170 if (isFocusedMatch) {
1178 if (isFocusedMatch) {
1171 Modifier.background(
1179 Modifier.background(
@@ -1345,7 +1353,7 @@ fun ChatScreen(
1345 // jolt the message list when a run starts/stops — matching the
1353 // jolt the message list when a run starts/stops — matching the
1346 // AnimatedVisibility used by every other transient banner/FAB here.
1354 // AnimatedVisibility used by every other transient banner/FAB here.
1347 modifier = Modifier
1355 modifier = Modifier
1348− .animateItem()
1356+ .then(reducedMotionAnimateItem())
1349 .padding(vertical = 4.dp),
1357 .padding(vertical = 4.dp),
1350 ) {
1358 ) {
1351 // Merge the indicator + label + clock into one TalkBack node so
1359 // Merge the indicator + label + clock into one TalkBack node so
@@ -1469,6 +1477,12 @@ fun ChatScreen(
1469 .padding(horizontal = 12.dp, vertical = 4.dp)
1477 .padding(horizontal = 12.dp, vertical = 4.dp)
1470 .fillMaxWidth(),
1478 .fillMaxWidth(),
1471 ) {
1479 ) {
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+ }
1472 Row(
1486 Row(
1473 modifier = Modifier.padding(start = 8.dp, end = 4.dp),
1487 modifier = Modifier.padding(start = 8.dp, end = 4.dp),
1474 verticalAlignment = Alignment.CenterVertically,
1488 verticalAlignment = Alignment.CenterVertically,
@@ -1476,7 +1490,7 @@ fun ChatScreen(
1476 OutlinedTextField(
1490 OutlinedTextField(
1477 value = chatSearch,
1491 value = chatSearch,
1478 onValueChange = { chatSearch = it },
1492 onValueChange = { chatSearch = it },
1479− modifier = Modifier.weight(1f),
1493+ modifier = Modifier.weight(1f).focusRequester(searchFocusRequester),
1480 placeholder = { Text(stringResource(R.string.search_in_conversation)) },
1494 placeholder = { Text(stringResource(R.string.search_in_conversation)) },
1481 singleLine = true,
1495 singleLine = true,
1482 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
1496 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
@@ -1489,7 +1503,7 @@ fun ChatScreen(
1489 if (searchQuery.isNotEmpty() && matchCount > 0) {
1503 if (searchQuery.isNotEmpty() && matchCount > 0) {
1490 stringResource(R.string.search_match_count, searchPos + 1, matchCount)
1504 stringResource(R.string.search_match_count, searchPos + 1, matchCount)
1491 } else if (searchQuery.isNotEmpty()) {
1505 } else if (searchQuery.isNotEmpty()) {
1492− stringResource(R.string.search_match_count, 0, messages.size)
1506+ stringResource(R.string.search_no_matches)
1493 } else "",
1507 } else "",
1494 style = MaterialTheme.typography.labelSmall,
1508 style = MaterialTheme.typography.labelSmall,
1495 color = MaterialTheme.colorScheme.onSurfaceVariant,
1509 color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -1999,10 +2013,21 @@ private fun FullScreenEditor(
1999 onDismissRequest = onDismiss,
2013 onDismissRequest = onDismiss,
2000 properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
2014 properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
2001 ) {
2015 ) {
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
2002 Scaffold(
2026 Scaffold(
2027+ modifier = Modifier.padding(horizontal = hPad),
2003 topBar = {
2028 topBar = {
2004 androidx.compose.material3.TopAppBar(
2029 androidx.compose.material3.TopAppBar(
2005− title = { Text(stringResource(R.string.message_placeholder)) },
2030+ title = { Text(stringResource(R.string.compose_editor_title)) },
2006 navigationIcon = {
2031 navigationIcon = {
2007 androidx.compose.material3.TextButton(onClick = onDismiss) {
2032 androidx.compose.material3.TextButton(onClick = onDismiss) {
2008 Text(stringResource(R.string.collapse_editor))
2033 Text(stringResource(R.string.collapse_editor))
@@ -2063,6 +2088,7 @@ private fun FullScreenEditor(
2063 )
2088 )
2064 }
2089 }
2065 }
2090 }
2091+ }
2066 }
2092 }
2067}
2093}
2068
2094
@@ -2298,6 +2324,7 @@ private fun OutboxBanner(
2298 onFlush: () -> Unit,
2324 onFlush: () -> Unit,
2299 onDiscard: () -> Unit,
2325 onDiscard: () -> Unit,
2300) {
2326) {
2327+ var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
2301 Surface(
2328 Surface(
2302 color = MaterialTheme.colorScheme.secondaryContainer,
2329 color = MaterialTheme.colorScheme.secondaryContainer,
2303 tonalElevation = 2.dp,
2330 tonalElevation = 2.dp,
@@ -2332,16 +2359,35 @@ private fun OutboxBanner(
2332 val haptics = LocalHapticFeedback.current
2359 val haptics = LocalHapticFeedback.current
2333 TextButton(onClick = onFlush) { Text(stringResource(R.string.outbox_flush)) }
2360 TextButton(onClick = onFlush) { Text(stringResource(R.string.outbox_flush)) }
2334 TextButton(onClick = {
2361 TextButton(onClick = {
2335− // Haptic on the irreversible "Discard queued" (drops every queued message
2362+ // Confirm before discarding all queued messages (irreversible), matching
2336− // for this session), matching the error-colored destructive actions.
2363+ // the confirmation pattern used by deleteSession and other destructive
2364+ // actions. Previously a single tap dropped every queued message.
2337 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
2365 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
2338− onDiscard()
2366+ showDiscardConfirm = true
2339 }) {
2367 }) {
2340 Text(stringResource(R.string.outbox_discard_all), color = MaterialTheme.colorScheme.error)
2368 Text(stringResource(R.string.outbox_discard_all), color = MaterialTheme.colorScheme.error)
2341 }
2369 }
2342 }
2370 }
2343 }
2371 }
2344 }
2372 }
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+ }
2345}
2391}
2346
2392
2347/** Prompt for a one-off shell command to run in the session's worktree. */
2393/** 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
2
2
3import androidx.compose.foundation.clickable
3import androidx.compose.foundation.clickable
4import androidx.compose.foundation.layout.Column
4import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.fillMaxWidth
6import androidx.compose.foundation.layout.fillMaxWidth
6import androidx.compose.foundation.layout.heightIn
7import androidx.compose.foundation.layout.heightIn
8+import androidx.compose.foundation.layout.imePadding
9+import androidx.compose.foundation.layout.navigationBarsPadding
7import androidx.compose.foundation.layout.padding
10import androidx.compose.foundation.layout.padding
8import androidx.compose.foundation.lazy.LazyColumn
11import androidx.compose.foundation.lazy.LazyColumn
9import androidx.compose.foundation.lazy.items
12import androidx.compose.foundation.lazy.items
@@ -23,11 +26,13 @@ import androidx.compose.material3.OutlinedTextField
23import androidx.compose.material3.Text
26import androidx.compose.material3.Text
24import androidx.compose.material3.AlertDialog
27import androidx.compose.material3.AlertDialog
25import androidx.compose.material3.TextButton
28import androidx.compose.material3.TextButton
29+import androidx.compose.material3.rememberModalBottomSheetState
26import androidx.compose.runtime.Composable
30import androidx.compose.runtime.Composable
27import androidx.compose.runtime.LaunchedEffect
31import androidx.compose.runtime.LaunchedEffect
28import androidx.compose.runtime.getValue
32import androidx.compose.runtime.getValue
29import androidx.compose.runtime.mutableStateOf
33import androidx.compose.runtime.mutableStateOf
30import androidx.compose.runtime.remember
34import androidx.compose.runtime.remember
35+import androidx.compose.runtime.rememberCoroutineScope
31import androidx.compose.runtime.saveable.rememberSaveable
36import androidx.compose.runtime.saveable.rememberSaveable
32import androidx.compose.runtime.setValue
37import androidx.compose.runtime.setValue
33import androidx.compose.ui.Alignment
38import androidx.compose.ui.Alignment
@@ -41,12 +46,12 @@ import androidx.compose.ui.res.stringResource
41import androidx.compose.ui.semantics.Role
46import androidx.compose.ui.semantics.Role
42import androidx.compose.ui.semantics.contentDescription
47import androidx.compose.ui.semantics.contentDescription
43import androidx.compose.ui.semantics.semantics
48import androidx.compose.ui.semantics.semantics
49+import androidx.compose.ui.semantics.stateDescription
44import androidx.compose.ui.text.input.ImeAction
50import androidx.compose.ui.text.input.ImeAction
45import androidx.compose.ui.text.style.TextOverflow
51import androidx.compose.ui.text.style.TextOverflow
46import androidx.compose.ui.unit.dp
52import androidx.compose.ui.unit.dp
47import androidx.compose.foundation.layout.Box
53import androidx.compose.foundation.layout.Box
48−import androidx.compose.foundation.layout.fillMaxHeight
54+import kotlinx.coroutines.launch
49−import androidx.compose.ui.platform.LocalConfiguration
50import soy.iko.opencode.data.model.Command
55import soy.iko.opencode.data.model.Command
51import soy.iko.opencode.data.network.NetworkConfig
56import soy.iko.opencode.data.network.NetworkConfig
52import soy.iko.opencode.R
57import soy.iko.opencode.R
@@ -68,19 +73,28 @@ fun CommandPickerSheet(
68 // is open. Re-resolve the full Command from the current list for rendering.
73 // is open. Re-resolve the full Command from the current list for rendering.
69 var pendingCommandName by rememberSaveable { mutableStateOf<String?>(null) }
74 var pendingCommandName by rememberSaveable { mutableStateOf<String?>(null) }
70 val pendingCommand = pendingCommandName?.let { name -> commands.firstOrNull { it.name == name } }
75 val pendingCommand = pendingCommandName?.let { name -> commands.firstOrNull { it.name == name } }
76+ // Hoist SheetState + skipPartiallyExpanded (see AgentPickerSheet for the full rationale).
77+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
78+ val scope = rememberCoroutineScope()
71− ModalBottomSheet(onDismissRequest = onDismiss) {
79+ ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
72 Text(
80 Text(
73 stringResource(R.string.commands),
81 stringResource(R.string.commands),
74 style = MaterialTheme.typography.titleMedium,
82 style = MaterialTheme.typography.titleMedium,
75 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
83 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
76 )
84 )
77 if (loading) {
85 if (loading) {
78− val loadingLabel = stringResource(R.string.loading)
86+ val loadingLabel = stringResource(R.string.loading_commands)
79− Box(
87+ Column(
80− modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
88+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp).imePadding(),
81− contentAlignment = Alignment.Center,
89+ horizontalAlignment = Alignment.CenterHorizontally,
82 ) {
90 ) {
83 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
91 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+ )
84 }
98 }
85 } else if (error && commands.isEmpty()) {
99 } else if (error && commands.isEmpty()) {
86 Column(
100 Column(
@@ -130,10 +144,12 @@ fun CommandPickerSheet(
130 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
144 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
131 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
145 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
132 )
146 )
147+ // Dedupe the source list once, then filter the distinct list.
148+ val distinct = remember(commands) { commands.distinctBy { it.name } }
133− val filtered = remember(commands, query) {
149+ val filtered = remember(distinct, query) {
134 val q = query.trim()
150 val q = query.trim()
135− if (q.isEmpty()) commands
151+ if (q.isEmpty()) distinct
136− else commands.filter {
152+ else distinct.filter {
137 it.name.contains(q, ignoreCase = true) ||
153 it.name.contains(q, ignoreCase = true) ||
138 it.displayDescription.contains(q, ignoreCase = true)
154 it.displayDescription.contains(q, ignoreCase = true)
139 }
155 }
@@ -146,27 +162,33 @@ fun CommandPickerSheet(
146 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
162 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
147 )
163 )
148 } else {
164 } else {
149− // Responsive height: half the viewport rather than a hardcoded 420dp cap.
165+ LazyColumn(
150− LazyColumn(modifier = Modifier.fillMaxHeight(0.5f)) {
166+ modifier = Modifier
151− // distinctBy the name so duplicate command names from the server can't
167+ .heightIn(max = NetworkConfig.pickerSheetMaxHeightDp.dp)
152− // crash the list with LazyColumn's "Key was already used".
168+ .imePadding()
169+ .navigationBarsPadding(),
170+ ) {
153− items(filtered.distinctBy { it.name }, key = { it.name }) { cmd ->
171+ items(filtered, key = { it.name }) { cmd ->
172+ val destructive = isDestructiveCommand(cmd.name)
154 Column(
173 Column(
155 modifier = Modifier
174 modifier = Modifier
156 .fillMaxWidth()
175 .fillMaxWidth()
157 .clickable(role = Role.Button) {
176 .clickable(role = Role.Button) {
158 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
177 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
159− if (isDestructiveCommand(cmd.name)) {
178+ if (destructive) {
160 pendingCommandName = cmd.name
179 pendingCommandName = cmd.name
161 } else {
180 } else {
181+ scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss() }
162 onSelect(cmd)
182 onSelect(cmd)
163− onDismiss()
164 }
183 }
165 }
184 }
185+ .semantics {
186+ if (destructive) stateDescription = "destructive"
187+ }
166 .padding(horizontal = 24.dp, vertical = 12.dp),
188 .padding(horizontal = 24.dp, vertical = 12.dp),
167 ) {
189 ) {
168− androidx.compose.foundation.layout.Row(
190+ Row(
169− verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
191+ verticalAlignment = Alignment.CenterVertically,
170 ) {
192 ) {
171 Icon(Icons.AutoMirrored.Filled.PlaylistPlay, contentDescription = null)
193 Icon(Icons.AutoMirrored.Filled.PlaylistPlay, contentDescription = null)
172 Text(
194 Text(
@@ -200,8 +222,8 @@ fun CommandPickerSheet(
200 confirmButton = {
222 confirmButton = {
201 TextButton(onClick = {
223 TextButton(onClick = {
202 pendingCommandName = null
224 pendingCommandName = null
225+ scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss() }
203 onSelect(cmd)
226 onSelect(cmd)
204− onDismiss()
205 }) { Text(stringResource(R.string.run)) }
227 }) { Text(stringResource(R.string.run)) }
206 },
228 },
207 dismissButton = {
229 dismissButton = {
@@ -219,5 +241,5 @@ fun CommandPickerSheet(
219private fun isDestructiveCommand(name: String): Boolean {
241private fun isDestructiveCommand(name: String): Boolean {
220 val n = name.lowercase()
242 val n = name.lowercase()
221 return n in setOf("clear", "compact", "delete", "reset", "remove", "wipe", "drop", "purge") ||
243 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-")
223}
245}
app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.kt+12−7
@@ -11,6 +11,7 @@ import soy.iko.opencode.data.model.ToolPart
11import soy.iko.opencode.data.model.ToolRunning
11import soy.iko.opencode.data.model.ToolRunning
12import soy.iko.opencode.data.model.UserMessage
12import soy.iko.opencode.data.model.UserMessage
13import soy.iko.opencode.data.model.sourcePath
13import soy.iko.opencode.data.model.sourcePath
14+import soy.iko.opencode.data.network.NetworkConfig
14import java.text.SimpleDateFormat
15import java.text.SimpleDateFormat
15import java.util.Date
16import java.util.Date
16import java.util.Locale
17import java.util.Locale
@@ -121,8 +122,12 @@ private fun appendMetadataHeader(sb: StringBuilder, messages: List<MessageWithPa
121 val firstTime = messages.firstOrNull()?.info?.time?.created
122 val firstTime = messages.firstOrNull()?.info?.time?.created
122 val lastTime = messages.lastOrNull()?.info?.time?.created
123 val lastTime = messages.lastOrNull()?.info?.time?.created
123 sb.append("> ")
124 sb.append("> ")
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).
124− sb.append("$userCount user · $assistantCount assistant message")
127+ sb.append("$userCount user")
125 if (userCount != 1) sb.append("s")
128 if (userCount != 1) sb.append("s")
129+ sb.append(" · $assistantCount assistant message")
130+ if (assistantCount != 1) sb.append("s")
126 if (firstTime != null && lastTime != null) {
131 if (firstTime != null && lastTime != null) {
127 sb.append(" · $firstTime → $lastTime")
132 sb.append(" · $firstTime → $lastTime")
128 }
133 }
@@ -183,19 +188,19 @@ private fun longestBacktickRun(text: String): Int {
183}
188}
184
189
185private fun truncateOutput(output: String): String {
190private fun truncateOutput(output: String): String {
186− if (output.length <= TOOL_OUTPUT_LIMIT) return output
191+ if (output.length <= NetworkConfig.exportToolOutputLimitChars) return output
187− return output.take(TOOL_OUTPUT_LIMIT) + "\n… (truncated)"
192+ return output.take(NetworkConfig.exportToolOutputLimitChars) + "\n… (truncated)"
188}
193}
189
194
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. */
193private fun escapeMarkdown(text: String): String {
198private fun escapeMarkdown(text: String): String {
194 if (text.isEmpty()) return text
199 if (text.isEmpty()) return text
195 val sb = StringBuilder(text.length + 16)
200 val sb = StringBuilder(text.length + 16)
196 for (c in text) {
201 for (c in text) {
197 when (c) {
202 when (c) {
198− '\\', '*', '_', '#', '`', '>' -> sb.append('\\')
203+ '\\', '*', '_', '#', '`', '>', '[', ']', '(', ')', '!', '+', '-' -> sb.append('\\')
199 }
204 }
200 sb.append(c)
205 sb.append(c)
201 }
206 }
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+28−7
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Box
8import androidx.compose.foundation.layout.BoxWithConstraints
8import androidx.compose.foundation.layout.BoxWithConstraints
9import androidx.compose.foundation.layout.Column
9import androidx.compose.foundation.layout.Column
10import androidx.compose.foundation.layout.Row
10import androidx.compose.foundation.layout.Row
11+import androidx.compose.foundation.layout.defaultMinSize
11import androidx.compose.foundation.layout.fillMaxWidth
12import androidx.compose.foundation.layout.fillMaxWidth
12import androidx.compose.foundation.layout.padding
13import androidx.compose.foundation.layout.padding
13import androidx.compose.foundation.layout.size
14import androidx.compose.foundation.layout.size
@@ -36,6 +37,7 @@ import androidx.compose.material3.IconButton
36import androidx.compose.material3.MaterialTheme
37import androidx.compose.material3.MaterialTheme
37import androidx.compose.material3.ripple
38import androidx.compose.material3.ripple
38import androidx.compose.material3.Text
39import androidx.compose.material3.Text
40+import androidx.compose.material3.TextButton
39import androidx.compose.runtime.Composable
41import androidx.compose.runtime.Composable
40import androidx.compose.runtime.getValue
42import androidx.compose.runtime.getValue
41import androidx.compose.runtime.key
43import androidx.compose.runtime.key
@@ -844,7 +846,7 @@ private fun AgentErrorNote(error: kotlinx.serialization.json.JsonElement) {
844 // Extract a readable string from the tolerant-decoded JsonElement: a bare string
846 // Extract a readable string from the tolerant-decoded JsonElement: a bare string
845 // primitive returns its content (the common `{"error": "..."}` case); an object/array
847 // primitive returns its content (the common `{"error": "..."}` case); an object/array
846 // is pretty-printed and trimmed so a verbose error doesn't dominate the bubble.
848 // is pretty-printed and trimmed so a verbose error doesn't dominate the bubble.
847− val text = remember(error) {
849+ val fullText = remember(error) {
848 val raw = if (error is kotlinx.serialization.json.JsonPrimitive) {
850 val raw = if (error is kotlinx.serialization.json.JsonPrimitive) {
849 error.content
851 error.content
850 } else {
852 } else {
@@ -855,8 +857,11 @@ private fun AgentErrorNote(error: kotlinx.serialization.json.JsonElement) {
855 )
857 )
856 }.getOrDefault(error.toString())
858 }.getOrDefault(error.toString())
857 }
859 }
858− if (raw.length <= 500) raw else raw.take(500) + "…"
860+ raw
859 }.takeIf { it.isNotBlank() } ?: return
861 }.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
860 val label = stringResource(R.string.agent_error_label)
865 val label = stringResource(R.string.agent_error_label)
861 Row(
866 Row(
862 modifier = Modifier
867 modifier = Modifier
@@ -878,11 +883,27 @@ private fun AgentErrorNote(error: kotlinx.serialization.json.JsonElement) {
878 style = MaterialTheme.typography.labelMedium,
883 style = MaterialTheme.typography.labelMedium,
879 color = MaterialTheme.colorScheme.onErrorContainer,
884 color = MaterialTheme.colorScheme.onErrorContainer,
880 )
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 {
881− Text(
889+ Text(
882− text,
890+ text,
883− style = MaterialTheme.typography.bodySmall,
891+ style = MaterialTheme.typography.bodySmall,
884− color = MaterialTheme.colorScheme.onErrorContainer,
892+ color = MaterialTheme.colorScheme.onErrorContainer,
885− )
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+ }
886 }
907 }
887 }
908 }
888}
909}
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+44−20
@@ -5,6 +5,8 @@ import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.fillMaxWidth
6import androidx.compose.foundation.layout.fillMaxWidth
7import androidx.compose.foundation.layout.heightIn
7import androidx.compose.foundation.layout.heightIn
8+import androidx.compose.foundation.layout.imePadding
9+import androidx.compose.foundation.layout.navigationBarsPadding
8import androidx.compose.foundation.layout.padding
10import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.size
11import androidx.compose.foundation.layout.size
10import androidx.compose.foundation.lazy.LazyColumn
12import androidx.compose.foundation.lazy.LazyColumn
@@ -25,11 +27,13 @@ import androidx.compose.material3.ModalBottomSheet
25import androidx.compose.material3.OutlinedTextField
27import androidx.compose.material3.OutlinedTextField
26import androidx.compose.material3.Text
28import androidx.compose.material3.Text
27import androidx.compose.material3.TextButton
29import androidx.compose.material3.TextButton
30+import androidx.compose.material3.rememberModalBottomSheetState
28import androidx.compose.runtime.Composable
31import androidx.compose.runtime.Composable
29import androidx.compose.runtime.LaunchedEffect
32import androidx.compose.runtime.LaunchedEffect
30import androidx.compose.runtime.getValue
33import androidx.compose.runtime.getValue
31import androidx.compose.runtime.mutableStateOf
34import androidx.compose.runtime.mutableStateOf
32import androidx.compose.runtime.remember
35import androidx.compose.runtime.remember
36+import androidx.compose.runtime.rememberCoroutineScope
33import androidx.compose.runtime.saveable.rememberSaveable
37import androidx.compose.runtime.saveable.rememberSaveable
34import androidx.compose.runtime.setValue
38import androidx.compose.runtime.setValue
35import androidx.compose.ui.Alignment
39import androidx.compose.ui.Alignment
@@ -43,13 +47,13 @@ import androidx.compose.ui.res.stringResource
43import androidx.compose.ui.semantics.Role
47import androidx.compose.ui.semantics.Role
44import androidx.compose.ui.semantics.contentDescription
48import androidx.compose.ui.semantics.contentDescription
45import androidx.compose.ui.semantics.role
49import androidx.compose.ui.semantics.role
50+import androidx.compose.ui.semantics.selectableGroup
46import androidx.compose.ui.semantics.selected
51import androidx.compose.ui.semantics.selected
47import androidx.compose.ui.semantics.semantics
52import androidx.compose.ui.semantics.semantics
48import androidx.compose.ui.text.input.ImeAction
53import androidx.compose.ui.text.input.ImeAction
49import androidx.compose.ui.unit.dp
54import androidx.compose.ui.unit.dp
50import androidx.compose.foundation.layout.Box
55import androidx.compose.foundation.layout.Box
51−import androidx.compose.foundation.layout.fillMaxHeight
56+import kotlinx.coroutines.launch
52−import androidx.compose.ui.platform.LocalConfiguration
53import soy.iko.opencode.data.model.ModelOption
57import soy.iko.opencode.data.model.ModelOption
54import soy.iko.opencode.data.network.NetworkConfig
58import soy.iko.opencode.data.network.NetworkConfig
55import soy.iko.opencode.R
59import soy.iko.opencode.R
@@ -89,19 +93,29 @@ fun ModelPickerSheet(
89 onRetry: () -> Unit,
93 onRetry: () -> Unit,
90 onDismiss: () -> Unit,
94 onDismiss: () -> Unit,
91) {
95) {
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()
92− ModalBottomSheet(onDismissRequest = onDismiss) {
100+ ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
93 Text(
101 Text(
94 stringResource(R.string.model),
102 stringResource(R.string.model),
95 style = MaterialTheme.typography.titleMedium,
103 style = MaterialTheme.typography.titleMedium,
96 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
104 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
97 )
105 )
98 if (loading) {
106 if (loading) {
99− val loadingLabel = stringResource(R.string.loading)
107+ val loadingLabel = stringResource(R.string.loading_models)
100− Box(
108+ Column(
101− modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
109+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp).imePadding(),
102− contentAlignment = Alignment.Center,
110+ horizontalAlignment = Alignment.CenterHorizontally,
103 ) {
111 ) {
104 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
112 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+ )
105 }
119 }
106 } else if (error && options.isEmpty()) {
120 } else if (error && options.isEmpty()) {
107 Column(
121 Column(
@@ -152,10 +166,13 @@ fun ModelPickerSheet(
152 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
166 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
153 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
167 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
154 )
168 )
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 } }
155− val filtered = remember(options, query) {
172+ val filtered = remember(distinct, query) {
156 val q = query.trim()
173 val q = query.trim()
157− if (q.isEmpty()) options
174+ if (q.isEmpty()) distinct
158− else options.filter {
175+ else distinct.filter {
159 it.modelLabel.contains(q, ignoreCase = true) ||
176 it.modelLabel.contains(q, ignoreCase = true) ||
160 it.providerLabel.contains(q, ignoreCase = true)
177 it.providerLabel.contains(q, ignoreCase = true)
161 }
178 }
@@ -176,12 +193,19 @@ fun ModelPickerSheet(
176 // header rows), so we can scroll it into view when the sheet opens. -1 when
193 // header rows), so we can scroll it into view when the sheet opens. -1 when
177 // nothing is selected or the selection isn't in the current list.
194 // nothing is selected or the selection isn't in the current list.
178 val selectedIndex = remember(grouped, selected) { selectedModelIndex(grouped, selected) }
195 val selectedIndex = remember(grouped, selected) { selectedModelIndex(grouped, selected) }
196+ // Re-scroll on filter change so the selection comes back into view after the
197+ // filter is cleared (mirrors AgentPickerSheet).
179− LaunchedEffect(Unit) {
198+ LaunchedEffect(grouped) {
180 if (selectedIndex >= 0) listState.scrollToItem(selectedIndex)
199 if (selectedIndex >= 0) listState.scrollToItem(selectedIndex)
181 }
200 }
182− // Responsive height: half the viewport rather than a hardcoded 420dp cap, so
201+ LazyColumn(
183− // the sheet doesn't cramp in landscape or leave empty space on a tall phone.
202+ state = listState,
184− LazyColumn(state = listState, modifier = Modifier.fillMaxHeight(0.5f)) {
203+ modifier = Modifier
204+ .heightIn(max = NetworkConfig.pickerSheetMaxHeightDp.dp)
205+ .imePadding()
206+ .navigationBarsPadding()
207+ .semantics { selectableGroup() },
208+ ) {
185 grouped.forEach { (providerID, opts) ->
209 grouped.forEach { (providerID, opts) ->
186 item(key = "header_$providerID") {
210 item(key = "header_$providerID") {
187 Text(
211 Text(
@@ -191,21 +215,21 @@ fun ModelPickerSheet(
191 modifier = Modifier.padding(start = 24.dp, top = 12.dp, bottom = 4.dp),
215 modifier = Modifier.padding(start = 24.dp, top = 12.dp, bottom = 4.dp),
192 )
216 )
193 }
217 }
194− // distinctBy the (provider, model) pair so a duplicate entry from the
218+ // opts is already distinct (filtered derives from the distinct list),
195− // server can't crash the list with LazyColumn's "Key was already used".
219+ // so no per-row distinctBy is needed here.
196− items(opts.distinctBy { it.providerID to it.modelID }, key = { it.providerID to it.modelID }) { option ->
220+ items(opts, key = { it.providerID to it.modelID }) { option ->
197 val isSelected = option.providerID == selected?.providerID &&
221 val isSelected = option.providerID == selected?.providerID &&
198 option.modelID == selected?.modelID
222 option.modelID == selected?.modelID
199 Column(
223 Column(
200 modifier = Modifier
224 modifier = Modifier
201 .fillMaxWidth()
225 .fillMaxWidth()
202 .semantics(mergeDescendants = true) {
226 .semantics(mergeDescendants = true) {
203− if (isSelected) this.selected = true
227+ this.selected = isSelected
204 }
228 }
205 .clickable(role = Role.RadioButton) {
229 .clickable(role = Role.RadioButton) {
206 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
230 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
231+ scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss() }
207 onSelect(option)
232 onSelect(option)
208− onDismiss()
209 }
233 }
210 .padding(horizontal = 24.dp, vertical = 12.dp),
234 .padding(horizontal = 24.dp, vertical = 12.dp),
211 ) {
235 ) {
@@ -221,7 +245,7 @@ fun ModelPickerSheet(
221 Icons.Filled.Check,
245 Icons.Filled.Check,
222 contentDescription = null,
246 contentDescription = null,
223 tint = MaterialTheme.colorScheme.primary,
247 tint = MaterialTheme.colorScheme.primary,
224− modifier = Modifier.size(18.dp),
248+ modifier = Modifier.size(20.dp),
225 )
249 )
226 }
250 }
227 }
251 }
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+27−10
@@ -28,6 +28,7 @@ import androidx.compose.material3.CircularProgressIndicator
28import androidx.compose.material3.Icon
28import androidx.compose.material3.Icon
29import androidx.compose.material3.IconButton
29import androidx.compose.material3.IconButton
30import androidx.compose.material3.MaterialTheme
30import androidx.compose.material3.MaterialTheme
31+import androidx.compose.material3.Surface
31import androidx.compose.material3.Text
32import androidx.compose.material3.Text
32import androidx.compose.material3.TextButton
33import androidx.compose.material3.TextButton
33import androidx.compose.runtime.Composable
34import androidx.compose.runtime.Composable
@@ -63,6 +64,7 @@ import soy.iko.opencode.data.model.ToolCompleted
63import soy.iko.opencode.data.model.ToolError
64import soy.iko.opencode.data.model.ToolError
64import soy.iko.opencode.data.model.ToolPart
65import soy.iko.opencode.data.model.ToolPart
65import soy.iko.opencode.data.model.ToolPending
66import soy.iko.opencode.data.model.ToolPending
67+import soy.iko.opencode.data.network.NetworkConfig
66import soy.iko.opencode.data.model.ToolRunning
68import soy.iko.opencode.data.model.ToolRunning
67import soy.iko.opencode.data.model.ToolState
69import soy.iko.opencode.data.model.ToolState
68import soy.iko.opencode.data.model.ToolUnknown
70import soy.iko.opencode.data.model.ToolUnknown
@@ -82,7 +84,7 @@ import soy.iko.opencode.ui.components.rememberVisibilityTransitions
82import soy.iko.opencode.ui.components.showCopyToast
84import soy.iko.opencode.ui.components.showCopyToast
83import soy.iko.opencode.R
85import soy.iko.opencode.R
84
86
85−private const val COLLAPSED_LIMIT = 4000
87+private val COLLAPSED_LIMIT get() = NetworkConfig.toolOutputCollapsedLimitChars
86
88
87// Pretty-printer for tool inputs. Separate from OpencodeJson (which is tuned for
89// Pretty-printer for tool inputs. Separate from OpencodeJson (which is tuned for
88// resilient decoding) so this stays human-readable; constructed lazily and memoized.
90// resilient decoding) so this stays human-readable; constructed lazily and memoized.
@@ -168,7 +170,7 @@ fun PartView(
168 )
170 )
169 }
171 }
170 }
172 }
171− MarkdownText(part.text, modifier = modifier, streaming = isRunning)
173+ MarkdownText(part.text, modifier = modifier, streaming = isRunning, imageContext = imageContext)
172 }
174 }
173 is ReasoningPart -> ReasoningBlock(part.text, streaming = isRunning, keyId = part.id, modifier = modifier)
175 is ReasoningPart -> ReasoningBlock(part.text, streaming = isRunning, keyId = part.id, modifier = modifier)
174 is ToolPart -> ToolCallView(part, modifier)
176 is ToolPart -> ToolCallView(part, modifier)
@@ -463,6 +465,7 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
463 copyToClipboard(context, context.getString(R.string.clip_label_output), detail)
465 copyToClipboard(context, context.getString(R.string.clip_label_output), detail)
464 },
466 },
465 diffSaveKey = part.id,
467 diffSaveKey = part.id,
468+ isError = part.state is ToolError,
466 )
469 )
467 }
470 }
468 }
471 }
@@ -487,6 +490,7 @@ private fun CollapsibleDetail(
487 collapsedState: String = "",
490 collapsedState: String = "",
488 onCopy: (() -> Unit)? = null,
491 onCopy: (() -> Unit)? = null,
489 diffSaveKey: String? = null,
492 diffSaveKey: String? = null,
493+ isError: Boolean = false,
490) {
494) {
491 Column(modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
495 Column(modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
492 if (label != null) {
496 if (label != null) {
@@ -521,14 +525,27 @@ private fun CollapsibleDetail(
521 // a single line of stdout) instead of only copy-all via the button below.
525 // a single line of stdout) instead of only copy-all via the button below.
522 // Horizontal scroll + softWrap=false so wide stdout / tables scroll instead of
526 // Horizontal scroll + softWrap=false so wide stdout / tables scroll instead of
523 // soft-wrapping into an unreadable ragged block (mirrors the file viewer).
527 // soft-wrapping into an unreadable ragged block (mirrors the file viewer).
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+ ) {
524− SelectionContainer {
537+ SelectionContainer {
525− Text(
538+ Text(
526− detail,
539+ detail,
527− style = MaterialTheme.typography.bodySmall,
540+ style = MaterialTheme.typography.bodySmall,
528− fontFamily = FontFamily.Monospace,
541+ fontFamily = FontFamily.Monospace,
542+ color = textColor,
529− softWrap = false,
543+ softWrap = false,
544+ modifier = Modifier
545+ .padding(8.dp)
530− modifier = Modifier.horizontalScroll(rememberScrollState()),
546+ .horizontalScroll(rememberScrollState()),
531− )
547+ )
548+ }
532 }
549 }
533 }
550 }
534 if (onToggleExpand != null) {
551 if (onToggleExpand != null) {
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+8
@@ -152,6 +152,14 @@ fun PermissionDialog(
152 },
152 },
153 modifier = Modifier.fillMaxWidth(),
153 modifier = Modifier.fillMaxWidth(),
154 ) { Text(stringResource(R.string.always_allow)) }
154 ) { 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+ )
155 // Reject is an OutlinedButton (not a low-prominence TextButton) in the
163 // Reject is an OutlinedButton (not a low-prominence TextButton) in the
156 // same column so the actions are visually balanced — the prior layout buried
164 // same column so the actions are visually balanced — the prior layout buried
157 // Reject as a small dismiss-button, nudging users toward granting. Reject
165 // 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 @@
1package soy.iko.opencode.ui.components
1package soy.iko.opencode.ui.components
2
2
3+import androidx.compose.animation.animateContentSize
3import androidx.compose.foundation.background
4import androidx.compose.foundation.background
5+import androidx.compose.foundation.clickable
4import androidx.compose.foundation.horizontalScroll
6import androidx.compose.foundation.horizontalScroll
5import androidx.compose.foundation.layout.Arrangement
7import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Column
8import androidx.compose.foundation.layout.Column
@@ -14,6 +16,8 @@ import androidx.compose.foundation.layout.width
14import androidx.compose.foundation.rememberScrollState
16import androidx.compose.foundation.rememberScrollState
15import androidx.compose.material.icons.Icons
17import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.filled.ContentCopy
18import androidx.compose.material.icons.filled.ContentCopy
19+import androidx.compose.material.icons.filled.ExpandLess
20+import androidx.compose.material.icons.filled.ExpandMore
17import androidx.compose.material3.Icon
21import androidx.compose.material3.Icon
18import androidx.compose.material3.IconButton
22import androidx.compose.material3.IconButton
19import androidx.compose.material3.MaterialTheme
23import androidx.compose.material3.MaterialTheme
@@ -30,11 +34,17 @@ import androidx.compose.ui.Modifier
30import androidx.compose.ui.draw.clip
34import androidx.compose.ui.draw.clip
31import androidx.compose.ui.graphics.Color
35import androidx.compose.ui.graphics.Color
32import androidx.compose.ui.platform.LocalContext
36import 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
33import androidx.compose.ui.text.font.FontFamily
41import androidx.compose.ui.text.font.FontFamily
42+import androidx.compose.ui.text.font.FontWeight
34import androidx.compose.ui.unit.dp
43import androidx.compose.ui.unit.dp
35import androidx.compose.ui.res.pluralStringResource
44import androidx.compose.ui.res.pluralStringResource
36import androidx.compose.ui.res.stringResource
45import androidx.compose.ui.res.stringResource
37import soy.iko.opencode.R
46import soy.iko.opencode.R
47+import soy.iko.opencode.data.network.NetworkConfig
38
48
39/** A single line of a parsed unified diff. */
49/** A single line of a parsed unified diff. */
40sealed interface DiffLine {
50sealed interface DiffLine {
@@ -111,7 +121,31 @@ fun looksLikeDiff(text: String): Boolean {
111 return false
121 return false
112}
122}
113
123
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+}
115
149
116/**
150/**
117 * Renders a parsed unified diff. Implemented as a plain (non-lazy) [Column] so it is
151 * 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
121 * [Modifier.verticalScroll] in [modifier].
155 * [Modifier.verticalScroll] in [modifier].
122 *
156 *
123 * To avoid composing thousands of [Text] nodes at once (which can ANR/OOM on large
157 * 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
158+ * diffs), only the first [NetworkConfig.collapsedDiffLineThreshold] lines are rendered
125− * expands the view.
159+ * unless the user expands the view. Each hunk can also be collapsed individually via
160+ * its `@@` header.
126 */
161 */
127@Composable
162@Composable
128fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = null) {
163fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = null) {
@@ -141,6 +176,12 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
141 // don't reset expand state on every token. Fall back to `diff` for non-streaming callers
176 // don't reset expand state on every token. Fall back to `diff` for non-streaming callers
142 // (e.g. FileViewScreen) where the content is static.
177 // (e.g. FileViewScreen) where the content is static.
143 var expanded by rememberSaveable(saveKey ?: diff) { mutableStateOf(false) }
178 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()
144
185
145 Column(
186 Column(
146 modifier = modifier
187 modifier = modifier
@@ -149,15 +190,31 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
149 .background(MaterialTheme.colorScheme.surfaceVariant),
190 .background(MaterialTheme.colorScheme.surfaceVariant),
150 ) {
191 ) {
151 Row(
192 Row(
152− modifier = Modifier.fillMaxWidth().padding(top = 2.dp, end = 4.dp),
193+ modifier = Modifier.fillMaxWidth().padding(top = 2.dp, start = 4.dp, end = 4.dp),
153− horizontalArrangement = Arrangement.End,
194+ verticalAlignment = Alignment.CenterVertically,
154 ) {
195 ) {
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))
155 IconButton(onClick = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) }) {
212 IconButton(onClick = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) }) {
156 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
213 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
157 }
214 }
158 }
215 }
159− val visibleLines = if (lines.size <= COLLAPSED_DIFF_LINES || expanded) lines
216+ val visibleLines = if (lines.size <= NetworkConfig.collapsedDiffLineThreshold || expanded) lines
160− else lines.subList(0, COLLAPSED_DIFF_LINES)
217+ else lines.subList(0, NetworkConfig.collapsedDiffLineThreshold)
161 // One horizontalScroll on the container instead of one per row: each modifier
218 // One horizontalScroll on the container instead of one per row: each modifier
162 // adds a layout node + clip + offset pass, so a 200-line collapsed diff was
219 // adds a layout node + clip + offset pass, so a 200-line collapsed diff was
163 // paying for 200 of them. The shared scroll state still synchronizes all rows.
220 // 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
168 val hunkRegex = Regex("""@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@""")
225 val hunkRegex = Regex("""@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@""")
169 Column(modifier = Modifier.horizontalScroll(hScrollState)) {
226 Column(modifier = Modifier.horizontalScroll(hScrollState)) {
170 visibleLines.forEach { line ->
227 visibleLines.forEach { line ->
171− Row(modifier = Modifier.padding(horizontal = 10.dp)) {
228+ when (line) {
172− when (line) {
173− is DiffLine.Hunk -> {
229+ is DiffLine.Hunk -> {
174− val m = hunkRegex.find(line.text)
230+ val m = hunkRegex.find(line.text)
175− if (m != null) {
231+ if (m != null) {
176− oldLine = m.groupValues[1].toInt()
232+ oldLine = m.groupValues[1].toInt()
177− newLine = m.groupValues[2].toInt()
233+ 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− )
186 }
234 }
187− is DiffLine.FileHeader -> Text(
235+ Text(
188 line.text,
236 line.text,
189 style = MaterialTheme.typography.bodySmall,
237 style = MaterialTheme.typography.bodySmall,
190 fontFamily = FontFamily.Monospace,
238 fontFamily = FontFamily.Monospace,
191− color = MaterialTheme.colorScheme.onSurfaceVariant,
239+ color = MaterialTheme.colorScheme.tertiary,
192− modifier = Modifier.padding(vertical = 1.dp),
240+ modifier = Modifier.padding(horizontal = 10.dp, vertical = 2.dp),
193 )
241 )
242+ }
194− is DiffLine.Meta -> Text(
243+ is DiffLine.FileHeader -> {
195− line.text,
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,
196− style = MaterialTheme.typography.bodySmall,
251+ style = MaterialTheme.typography.labelMedium,
197 fontFamily = FontFamily.Monospace,
252 fontFamily = FontFamily.Monospace,
253+ fontWeight = if (isHeader) FontWeight.SemiBold else FontWeight.Normal,
198− color = MaterialTheme.colorScheme.onSurfaceVariant,
254+ color = MaterialTheme.colorScheme.onSurface,
199− modifier = Modifier.padding(vertical = 1.dp),
255+ modifier = Modifier
256+ .fillMaxWidth()
257+ .padding(horizontal = 10.dp, vertical = 3.dp)
258+ .semantics { heading() },
200 )
259 )
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+ )
201− is DiffLine.Add -> {
268+ is DiffLine.Add -> {
269+ val n = newLine++
202− val n = newLine++; DiffRow(line.text, "+", addColor, addText, oldLine = null, newLine = n)
270+ DiffRow(line.text, "+", addColor, addText, oldLine = null, newLine = n, syntax = syntax, palette = palette)
203− }
271+ }
204− is DiffLine.Remove -> {
272+ is DiffLine.Remove -> {
273+ val o = oldLine++
205− val o = oldLine++; DiffRow(line.text, "-", removeColor, removeText, oldLine = o, newLine = null)
274+ DiffRow(line.text, "-", removeColor, removeText, oldLine = o, newLine = null, syntax = syntax, palette = palette)
206− }
275+ }
207− is DiffLine.Context -> {
276+ is DiffLine.Context -> {
208− val o = oldLine++; val n = newLine++
277+ val o = oldLine++; val n = newLine++
209− DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface, oldLine = o, newLine = n)
278+ DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface, oldLine = o, newLine = n, syntax = syntax, palette = palette)
210− }
211 }
279 }
212 }
280 }
213 }
281 }
214 }
282 }
215− if (lines.size > COLLAPSED_DIFF_LINES) {
283+ if (lines.size > NetworkConfig.collapsedDiffLineThreshold) {
216− val hidden = lines.size - COLLAPSED_DIFF_LINES
284+ val hidden = lines.size - NetworkConfig.collapsedDiffLineThreshold
217 TextButton(
285 TextButton(
218 onClick = { expanded = !expanded },
286 onClick = { expanded = !expanded },
219 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
287 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
@@ -225,7 +293,6 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
225 )
293 )
226 }
294 }
227 }
295 }
228− Spacer(Modifier.height(10.dp))
229 }
296 }
230}
297}
231
298
@@ -237,10 +304,26 @@ private fun DiffRow(
237 textColor: androidx.compose.ui.graphics.Color,
304 textColor: androidx.compose.ui.graphics.Color,
238 oldLine: Int? = null,
305 oldLine: Int? = null,
239 newLine: Int? = null,
306 newLine: Int? = null,
307+ syntax: FileSyntax? = null,
308+ palette: HighlightPalette,
240) {
309) {
241 val gutterColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
310 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+ }
242 Row(
323 Row(
243− modifier = Modifier.background(bg),
324+ modifier = Modifier
325+ .background(bg)
326+ .semantics(mergeDescendants = true) { contentDescription = a11yLabel },
244 ) {
327 ) {
245 // Old/new line-number gutters. Blank (not 0) for the side that doesn't have a number
328 // Old/new line-number gutters. Blank (not 0) for the side that doesn't have a number
246 // (an added line has no old number; a removed line has no new number) so the columns
329 // (an added line has no old number; a removed line has no new number) so the columns
@@ -250,24 +333,24 @@ private fun DiffRow(
250 style = MaterialTheme.typography.bodySmall,
333 style = MaterialTheme.typography.bodySmall,
251 fontFamily = FontFamily.Monospace,
334 fontFamily = FontFamily.Monospace,
252 color = gutterColor,
335 color = gutterColor,
253− modifier = Modifier.width(36.dp),
336+ modifier = Modifier.width(NetworkConfig.diffGutterWidthDp.dp),
254 )
337 )
255 Text(
338 Text(
256 newLine?.toString() ?: "",
339 newLine?.toString() ?: "",
257 style = MaterialTheme.typography.bodySmall,
340 style = MaterialTheme.typography.bodySmall,
258 fontFamily = FontFamily.Monospace,
341 fontFamily = FontFamily.Monospace,
259 color = gutterColor,
342 color = gutterColor,
260− modifier = Modifier.width(36.dp),
343+ modifier = Modifier.width(NetworkConfig.diffGutterWidthDp.dp),
261 )
344 )
262 Text(
345 Text(
263 prefix,
346 prefix,
264 style = MaterialTheme.typography.bodySmall,
347 style = MaterialTheme.typography.bodySmall,
265 fontFamily = FontFamily.Monospace,
348 fontFamily = FontFamily.Monospace,
266 color = textColor,
349 color = textColor,
267− modifier = Modifier.width(16.dp),
350+ modifier = Modifier.width(NetworkConfig.diffPrefixWidthDp.dp),
268 )
351 )
269 Text(
352 Text(
270− text,
353+ highlighted,
271 style = MaterialTheme.typography.bodySmall,
354 style = MaterialTheme.typography.bodySmall,
272 fontFamily = FontFamily.Monospace,
355 fontFamily = FontFamily.Monospace,
273 color = textColor,
356 color = textColor,
app/src/main/java/soy/iko/opencode/ui/components/EmptyState.kt+3
@@ -12,6 +12,8 @@ import androidx.compose.runtime.Composable
12import androidx.compose.ui.Alignment
12import androidx.compose.ui.Alignment
13import androidx.compose.ui.Modifier
13import androidx.compose.ui.Modifier
14import androidx.compose.ui.graphics.vector.ImageVector
14import androidx.compose.ui.graphics.vector.ImageVector
15+import androidx.compose.ui.semantics.heading
16+import androidx.compose.ui.semantics.semantics
15import androidx.compose.ui.text.style.TextAlign
17import androidx.compose.ui.text.style.TextAlign
16import androidx.compose.ui.unit.dp
18import androidx.compose.ui.unit.dp
17
19
@@ -45,6 +47,7 @@ fun EmptyState(
45 title,
47 title,
46 style = MaterialTheme.typography.titleMedium,
48 style = MaterialTheme.typography.titleMedium,
47 textAlign = TextAlign.Center,
49 textAlign = TextAlign.Center,
50+ modifier = Modifier.semantics { heading() },
48 )
51 )
49 if (description != null) {
52 if (description != null) {
50 Text(
53 Text(
app/src/main/java/soy/iko/opencode/ui/components/LargeScreenNavRail.kt+6−3
@@ -57,10 +57,13 @@ fun LargeScreenNavRail(
57) {
57) {
58 NavigationRail(modifier = modifier.fillMaxHeight()) {
58 NavigationRail(modifier = modifier.fillMaxHeight()) {
59 RAIL_DESTINATIONS.forEach { dest ->
59 RAIL_DESTINATIONS.forEach { dest ->
60− // Match exact top-level routes. Sub-routes (chat/{id}, file_view) don't highlight any
60+ // Match exact top-level routes, and also highlight the rail item when a detail
61− // rail item — the rail tracks top-level position, not the detail back stack.
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.
62 val selected = currentRoute == dest.route ||
64 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"))
64 NavigationRailItem(
67 NavigationRailItem(
65 selected = selected,
68 selected = selected,
66 enabled = !dest.requiresConnection || connected,
69 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
61import com.mikepenz.markdown.compose.components.markdownComponents
61import com.mikepenz.markdown.compose.components.markdownComponents
62import com.mikepenz.markdown.m3.Markdown
62import com.mikepenz.markdown.m3.Markdown
63import org.intellij.markdown.MarkdownElementTypes
63import org.intellij.markdown.MarkdownElementTypes
64+import org.intellij.markdown.ast.ASTNode
64import org.intellij.markdown.ast.getTextInNode
65import org.intellij.markdown.ast.getTextInNode
65import soy.iko.opencode.R
66import soy.iko.opencode.R
67+import soy.iko.opencode.data.model.FilePart
66import soy.iko.opencode.data.network.NetworkConfig
68import soy.iko.opencode.data.network.NetworkConfig
67
69
68/**
70/**
@@ -98,10 +100,11 @@ fun MarkdownText(
98 modifier: Modifier = Modifier,
100 modifier: Modifier = Modifier,
99 @Suppress("UNUSED_PARAMETER") style: TextStyle = MaterialTheme.typography.bodyLarge,
101 @Suppress("UNUSED_PARAMETER") style: TextStyle = MaterialTheme.typography.bodyLarge,
100 streaming: Boolean = false,
102 streaming: Boolean = false,
103+ imageContext: ImageLoadContext? = null,
101) {
104) {
102 val scale = LocalChatTextScale.current
105 val scale = LocalChatTextScale.current
103 if (scale == 1f) {
106 if (scale == 1f) {
104− MarkdownBody(markdown, modifier, streaming)
107+ MarkdownBody(markdown, modifier, streaming, imageContext)
105 } else {
108 } else {
106 // Scale the entire markdown subtree (headings, lists, code, body) from the user's
109 // Scale the entire markdown subtree (headings, lists, code, body) from the user's
107 // chat text-size preference by nesting a MaterialTheme with a scaled typography:
110 // chat text-size preference by nesting a MaterialTheme with a scaled typography:
@@ -112,7 +115,7 @@ fun MarkdownText(
112 val base = MaterialTheme.typography
115 val base = MaterialTheme.typography
113 val scaled = remember(scale, base) { base.scaledBy(scale) }
116 val scaled = remember(scale, base) { base.scaledBy(scale) }
114 MaterialTheme(typography = scaled) {
117 MaterialTheme(typography = scaled) {
115− MarkdownBody(markdown, modifier, streaming)
118+ MarkdownBody(markdown, modifier, streaming, imageContext)
116 }
119 }
117 }
120 }
118}
121}
@@ -122,12 +125,26 @@ private fun MarkdownBody(
122 markdown: String,
125 markdown: String,
123 modifier: Modifier = Modifier,
126 modifier: Modifier = Modifier,
124 streaming: Boolean = false,
127 streaming: Boolean = false,
128+ imageContext: ImageLoadContext? = null,
125) {
129) {
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.
126− val components = remember {
135+ val components = if (imageContext != null) {
127 markdownComponents(
136 markdownComponents(
128 codeFence = { CodeWithCopy(it) },
137 codeFence = { CodeWithCopy(it) },
129 codeBlock = { CodeWithCopy(it) },
138 codeBlock = { CodeWithCopy(it) },
139+ image = { MarkdownImage(it, imageContext) },
130 )
140 )
141+ } else {
142+ remember {
143+ markdownComponents(
144+ codeFence = { CodeWithCopy(it) },
145+ codeBlock = { CodeWithCopy(it) },
146+ )
147+ }
131 }
148 }
132 if (streaming) {
149 if (streaming) {
133 // Bridge the markdown parameter into snapshot state so snapshotFlow can observe it.
150 // Bridge the markdown parameter into snapshot state so snapshotFlow can observe it.
@@ -214,14 +231,14 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
214 // once, memoized so a recomposition that doesn't change `code` (e.g. a wrap/expand flip)
231 // once, memoized so a recomposition that doesn't change `code` (e.g. a wrap/expand flip)
215 // doesn't re-walk it.
232 // doesn't re-walk it.
216 val lineCount = remember(code) { code.count { it == '\n' } + 1 }
233 val lineCount = remember(code) { code.count { it == '\n' } + 1 }
217− val canCollapse = lineCount > COLLAPSED_CODE_LINES
234+ val canCollapse = lineCount > NetworkConfig.collapsedCodeLineThreshold
218 // Key expand state on the content so a growing (streaming) block re-seeds it. Collapsing
235 // Key expand state on the content so a growing (streaming) block re-seeds it. Collapsing
219 // primarily benefits completed messages; a streaming block that crosses the cap may snap
236 // primarily benefits completed messages; a streaming block that crosses the cap may snap
220 // to collapsed on the token that crosses it, which is acceptable (the head stays visible).
237 // to collapsed on the token that crosses it, which is acceptable (the head stays visible).
221 var expanded by rememberSaveable(code) { mutableStateOf(false) }
238 var expanded by rememberSaveable(code) { mutableStateOf(false) }
222 val displayCode = if (canCollapse && !expanded) {
239 val displayCode = if (canCollapse && !expanded) {
223 remember(code, expanded) {
240 remember(code, expanded) {
224− code.lineSequence().take(COLLAPSED_CODE_LINES).joinToString("\n")
241+ code.lineSequence().take(NetworkConfig.collapsedCodeLineThreshold).joinToString("\n")
225 }
242 }
226 } else {
243 } else {
227 code
244 code
@@ -248,7 +265,7 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
248 val haptics = LocalHapticFeedback.current
265 val haptics = LocalHapticFeedback.current
249 LaunchedEffect(copied) {
266 LaunchedEffect(copied) {
250 if (copied) {
267 if (copied) {
251− delay(1200)
268+ delay(NetworkConfig.copyFeedbackMs.toLong())
252 copied = false
269 copied = false
253 }
270 }
254 }
271 }
@@ -271,7 +288,7 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
271 modifier = Modifier.weight(1f),
288 modifier = Modifier.weight(1f),
272 )
289 )
273 if (canCollapse) {
290 if (canCollapse) {
274− val moreLines = lineCount - COLLAPSED_CODE_LINES
291+ val moreLines = lineCount - NetworkConfig.collapsedCodeLineThreshold
275 IconButton(onClick = { expanded = !expanded }) {
292 IconButton(onClick = { expanded = !expanded }) {
276 Icon(
293 Icon(
277 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
294 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
@@ -328,7 +345,7 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
328 )
345 )
329 }
346 }
330 if (canCollapse && !expanded) {
347 if (canCollapse && !expanded) {
331− val hidden = lineCount - COLLAPSED_CODE_LINES
348+ val hidden = lineCount - NetworkConfig.collapsedCodeLineThreshold
332 TextButton(
349 TextButton(
333 onClick = { expanded = true },
350 onClick = { expanded = true },
334 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 12.dp),
351 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 12.dp),
@@ -342,8 +359,56 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
342 }
359 }
343}
360}
344
361
345−/** Max lines a code fence renders before collapsing to a head with a "show more" affordance. */
362+/**
346−private const val COLLAPSED_CODE_LINES = 200
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+}
347
412
348/**
413/**
349 * A small blinking caret shown at the tail of a streaming assistant reply for a "live"
414 * A small blinking caret shown at the tail of a streaming assistant reply for a "live"
@@ -356,7 +421,7 @@ private fun StreamingCaret() {
356 val alpha by transition.animateFloat(
421 val alpha by transition.animateFloat(
357 initialValue = 0.2f,
422 initialValue = 0.2f,
358 targetValue = 1f,
423 targetValue = 1f,
359− animationSpec = infiniteRepeatable(tween(550), RepeatMode.Reverse),
424+ animationSpec = infiniteRepeatable(tween(NetworkConfig.streamingCaretPeriodMs), RepeatMode.Reverse),
360 label = "caretAlpha",
425 label = "caretAlpha",
361 )
426 )
362 Text(
427 Text(
app/src/main/java/soy/iko/opencode/ui/components/ReducedMotion.kt+32
@@ -10,6 +10,8 @@ import androidx.compose.animation.expandVertically
10import androidx.compose.animation.fadeIn
10import androidx.compose.animation.fadeIn
11import androidx.compose.animation.fadeOut
11import androidx.compose.animation.fadeOut
12import androidx.compose.animation.shrinkVertically
12import androidx.compose.animation.shrinkVertically
13+import androidx.compose.animation.animateContentSize
14+import androidx.compose.foundation.lazy.LazyItemScope
13import androidx.compose.runtime.Composable
15import androidx.compose.runtime.Composable
14import androidx.compose.runtime.DisposableEffect
16import androidx.compose.runtime.DisposableEffect
15import androidx.compose.runtime.getValue
17import androidx.compose.runtime.getValue
@@ -17,6 +19,7 @@ import androidx.compose.runtime.mutableIntStateOf
17import androidx.compose.runtime.remember
19import androidx.compose.runtime.remember
18import androidx.compose.runtime.setValue
20import androidx.compose.runtime.setValue
19import androidx.compose.runtime.staticCompositionLocalOf
21import androidx.compose.runtime.staticCompositionLocalOf
22+import androidx.compose.ui.Modifier
20import androidx.compose.ui.platform.LocalContext
23import androidx.compose.ui.platform.LocalContext
21import androidx.lifecycle.Lifecycle
24import androidx.lifecycle.Lifecycle
22import androidx.lifecycle.LifecycleEventObserver
25import androidx.lifecycle.LifecycleEventObserver
@@ -97,3 +100,32 @@ fun <T> rememberMotionTween(durationMs: Int): AnimationSpec<T> {
97 if (reduced) snap() else tween(durationMs)
100 if (reduced) snap() else tween(durationMs)
98 }
101 }
99}
102}
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
5import androidx.compose.foundation.clickable
5import androidx.compose.foundation.clickable
6import androidx.compose.foundation.gestures.detectTapGestures
6import androidx.compose.foundation.gestures.detectTapGestures
7import androidx.compose.foundation.gestures.detectTransformGestures
7import androidx.compose.foundation.gestures.detectTransformGestures
8+import androidx.compose.foundation.gestures.detectVerticalDragGestures
8import androidx.compose.foundation.layout.Box
9import androidx.compose.foundation.layout.Box
9import androidx.compose.foundation.layout.Column
10import androidx.compose.foundation.layout.Column
11+import androidx.compose.foundation.layout.WindowInsets
10import androidx.compose.foundation.layout.fillMaxSize
12import androidx.compose.foundation.layout.fillMaxSize
11import androidx.compose.foundation.layout.fillMaxWidth
13import androidx.compose.foundation.layout.fillMaxWidth
12import androidx.compose.foundation.layout.heightIn
14import androidx.compose.foundation.layout.heightIn
13import androidx.compose.foundation.layout.padding
15import androidx.compose.foundation.layout.padding
16+import androidx.compose.foundation.layout.statusBars
17+import androidx.compose.foundation.layout.windowInsetsPadding
14import androidx.compose.material3.CircularProgressIndicator
18import androidx.compose.material3.CircularProgressIndicator
15import androidx.compose.material3.Icon
19import androidx.compose.material3.Icon
16import androidx.compose.material3.MaterialTheme
20import androidx.compose.material3.MaterialTheme
@@ -43,6 +47,7 @@ import coil.compose.SubcomposeAsyncImage
43import coil.request.ImageRequest
47import coil.request.ImageRequest
44import kotlinx.coroutines.Dispatchers
48import kotlinx.coroutines.Dispatchers
45import kotlinx.coroutines.launch
49import kotlinx.coroutines.launch
50+import kotlin.math.absoluteValue
46import androidx.compose.animation.core.Animatable
51import androidx.compose.animation.core.Animatable
47import kotlinx.coroutines.withContext
52import kotlinx.coroutines.withContext
48import androidx.compose.material.icons.Icons
53import androidx.compose.material.icons.Icons
@@ -53,6 +58,7 @@ import soy.iko.opencode.data.model.FilePart
53import soy.iko.opencode.data.model.ServerProfile
58import soy.iko.opencode.data.model.ServerProfile
54import soy.iko.opencode.data.model.sourcePath
59import soy.iko.opencode.data.model.sourcePath
55import soy.iko.opencode.data.network.HttpClientFactory
60import soy.iko.opencode.data.network.HttpClientFactory
61+import soy.iko.opencode.data.network.NetworkConfig
56
62
57/**
63/**
58 * Carries the bits needed to load an image off the opencode server: the base URL (for
64 * 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
208 contentDescription = part.filename ?: stringResource(R.string.image),
214 contentDescription = part.filename ?: stringResource(R.string.image),
209 contentScale = ContentScale.FillWidth,
215 contentScale = ContentScale.FillWidth,
210 modifier = modifier
216 modifier = modifier
211− .heightIn(max = 320.dp)
217+ .heightIn(max = NetworkConfig.inlineImageMaxHeightDp.dp)
212 .clip(MaterialTheme.shapes.small)
218 .clip(MaterialTheme.shapes.small)
213 // Tap to open a fullscreen zoomable viewer so the user can inspect details
219 // Tap to open a fullscreen zoomable viewer so the user can inspect details
214 // without the inline 320dp height cap.
220 // without the inline 320dp height cap.
215 .clickable(role = Role.Button) { showFullscreen = true },
221 .clickable(role = Role.Button) { showFullscreen = true },
216 loading = {
222 loading = {
217 Box(
223 Box(
218− modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
224+ modifier = Modifier.fillMaxWidth().heightIn(min = NetworkConfig.inlineImageMinHeightDp.dp),
219 contentAlignment = Alignment.Center,
225 contentAlignment = Alignment.Center,
220 ) {
226 ) {
221 val loadingLabel = stringResource(R.string.loading)
227 val loadingLabel = stringResource(R.string.loading)
@@ -254,7 +260,13 @@ private fun FullscreenImageViewer(
254 // Double-tap zoom tween, collapsed to an instant snap under reduced motion. Resolved in
260 // Double-tap zoom tween, collapsed to an instant snap under reduced motion. Resolved in
255 // the composable body (not inside the pointerInput coroutine) since it reads the
261 // the composable body (not inside the pointerInput coroutine) since it reads the
256 // composition's LocalReducedMotion.
262 // 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
258 Dialog(
270 Dialog(
259 onDismissRequest = onDismiss,
271 onDismissRequest = onDismiss,
260 properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
272 properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
@@ -262,12 +274,12 @@ private fun FullscreenImageViewer(
262 Box(
274 Box(
263 modifier = Modifier
275 modifier = Modifier
264 .fillMaxSize()
276 .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)))
266 .pointerInput(Unit) {
278 .pointerInput(Unit) {
267 detectTransformGestures { _, pan, zoom, _ ->
279 detectTransformGestures { _, pan, zoom, _ ->
268 // Clamp the zoom so the image can't be scaled down below 1× (which would
280 // Clamp the zoom so the image can't be scaled down below 1× (which would
269 // leave empty letterbox) or blown up so far it becomes a handful of pixels.
281 // 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)
271 scope.launch { scale.snapTo(newScale) }
283 scope.launch { scale.snapTo(newScale) }
272 if (newScale <= 1f) {
284 if (newScale <= 1f) {
273 // Returning to 1× recenters so a prior pan doesn't leave the image
285 // Returning to 1× recenters so a prior pan doesn't leave the image
@@ -280,6 +292,28 @@ private fun FullscreenImageViewer(
280 }
292 }
281 }
293 }
282 }
294 }
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+ }
283 // Tap handling on the same surface: a double-tap toggles zoom (animated, with a
317 // Tap handling on the same surface: a double-tap toggles zoom (animated, with a
284 // recenter on the way back to 1×); a single tap dismisses the viewer. detectTapGestures
318 // recenter on the way back to 1×); a single tap dismisses the viewer. detectTapGestures
285 // defers the single-tap callback until it knows the touch isn't the first of a pair.
319 // defers the single-tap callback until it knows the touch isn't the first of a pair.
@@ -292,7 +326,7 @@ private fun FullscreenImageViewer(
292 offsetY = 0f
326 offsetY = 0f
293 scale.animateTo(1f, zoomSpec)
327 scale.animateTo(1f, zoomSpec)
294 } else {
328 } else {
295− scale.animateTo(DOUBLE_TAP_ZOOM, zoomSpec)
329+ scale.animateTo(NetworkConfig.imageViewerDoubleTapZoom, zoomSpec)
296 }
330 }
297 }
331 }
298 },
332 },
@@ -311,13 +345,19 @@ private fun FullscreenImageViewer(
311 scaleX = scale.value,
345 scaleX = scale.value,
312 scaleY = scale.value,
346 scaleY = scale.value,
313 translationX = offsetX,
347 translationX = offsetX,
348+ // Apply the swipe-drag offset on top of any pan offset so the image
349+ // follows the finger during a dismiss swipe.
314− translationY = offsetY,
350+ translationY = offsetY + dragOffsetY,
315 ),
351 ),
316 )
352 )
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.
318 androidx.compose.material3.IconButton(
355 androidx.compose.material3.IconButton(
319 onClick = onDismiss,
356 onClick = onDismiss,
357+ modifier = Modifier
320− modifier = Modifier.align(Alignment.TopEnd).padding(16.dp),
358+ .align(Alignment.TopEnd)
359+ .windowInsetsPadding(WindowInsets.statusBars)
360+ .padding(end = 8.dp),
321 ) {
361 ) {
322 Icon(
362 Icon(
323 Icons.Filled.Close,
363 Icons.Filled.Close,
@@ -329,13 +369,6 @@ private fun FullscreenImageViewer(
329 }
369 }
330}
370}
331
371
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−
339/** Error slot for [RemoteImage]: a broken-image icon plus a "Tap to retry" caption, the whole box
372/** Error slot for [RemoteImage]: a broken-image icon plus a "Tap to retry" caption, the whole box
340 * clickable so a failed load can be re-issued instead of stranding a permanent broken icon. */
373 * clickable so a failed load can be re-issued instead of stranding a permanent broken icon. */
341@Composable
374@Composable
@@ -344,7 +377,7 @@ private fun ImageRetry(onRetry: () -> Unit) {
344 Box(
377 Box(
345 modifier = Modifier
378 modifier = Modifier
346 .fillMaxWidth()
379 .fillMaxWidth()
347− .heightIn(min = 120.dp)
380+ .heightIn(min = NetworkConfig.inlineImageMinHeightDp.dp)
348 .clickable(role = Role.Button, onClick = onRetry),
381 .clickable(role = Role.Button, onClick = onRetry),
349 contentAlignment = Alignment.Center,
382 contentAlignment = Alignment.Center,
350 ) {
383 ) {
@@ -369,7 +402,7 @@ private val ImageResolving = Any()
369@Composable
402@Composable
370private fun ImageStatusBox(content: @Composable () -> Unit) {
403private fun ImageStatusBox(content: @Composable () -> Unit) {
371 Box(
404 Box(
372− modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
405+ modifier = Modifier.fillMaxWidth().heightIn(min = NetworkConfig.inlineImageMinHeightDp.dp),
373 contentAlignment = Alignment.Center,
406 contentAlignment = Alignment.Center,
374 ) { content() }
407 ) { content() }
375}
408}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+29−6
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Row
12import androidx.compose.foundation.layout.Spacer
12import androidx.compose.foundation.layout.Spacer
13import androidx.compose.foundation.layout.defaultMinSize
13import androidx.compose.foundation.layout.defaultMinSize
14import androidx.compose.foundation.layout.fillMaxSize
14import androidx.compose.foundation.layout.fillMaxSize
15+import androidx.compose.foundation.layout.imePadding
15import androidx.compose.foundation.layout.fillMaxWidth
16import androidx.compose.foundation.layout.fillMaxWidth
16import androidx.compose.foundation.layout.padding
17import androidx.compose.foundation.layout.padding
17import androidx.compose.foundation.layout.size
18import androidx.compose.foundation.layout.size
@@ -26,6 +27,7 @@ import androidx.compose.material.icons.Icons
26import androidx.compose.material.icons.filled.ChevronRight
27import androidx.compose.material.icons.filled.ChevronRight
27import androidx.compose.material.icons.filled.Close
28import androidx.compose.material.icons.filled.Close
28import androidx.compose.material.icons.filled.Description
29import androidx.compose.material.icons.filled.Description
30+import androidx.compose.material.icons.filled.ErrorOutline
29import androidx.compose.material.icons.filled.Folder
31import androidx.compose.material.icons.filled.Folder
30import androidx.compose.material.icons.filled.Home
32import androidx.compose.material.icons.filled.Home
31import androidx.compose.material.icons.filled.Image
33import androidx.compose.material.icons.filled.Image
@@ -94,6 +96,7 @@ import soy.iko.opencode.R
94import soy.iko.opencode.ui.components.AppTopBar
96import soy.iko.opencode.ui.components.AppTopBar
95import soy.iko.opencode.ui.components.ConnectionBannerFor
97import soy.iko.opencode.ui.components.ConnectionBannerFor
96import soy.iko.opencode.ui.components.EmptyState
98import soy.iko.opencode.ui.components.EmptyState
99+import soy.iko.opencode.ui.components.reducedMotionAnimateItem
97import soy.iko.opencode.ui.components.copyToClipboard
100import soy.iko.opencode.ui.components.copyToClipboard
98import soy.iko.opencode.ui.components.relativeTime
101import soy.iko.opencode.ui.components.relativeTime
99import soy.iko.opencode.ui.vmFactory
102import soy.iko.opencode.ui.vmFactory
@@ -123,7 +126,7 @@ fun FileBrowserScreen(
123 },
126 },
124 snackbarHost = { SnackbarHost(snackbar) },
127 snackbarHost = { SnackbarHost(snackbar) },
125 ) { padding ->
128 ) { padding ->
126− Column(modifier = Modifier.fillMaxSize().padding(padding)) {
129+ Column(modifier = Modifier.fillMaxSize().imePadding().padding(padding)) {
127 // Surface SSE connection state so a dropped stream is visible while browsing
130 // Surface SSE connection state so a dropped stream is visible while browsing
128 // files, not just on the chat/session screens.
131 // files, not just on the chat/session screens.
129 Box(modifier = Modifier.fillMaxWidth()) {
132 Box(modifier = Modifier.fillMaxWidth()) {
@@ -193,13 +196,31 @@ fun FileBrowserScreen(
193 // Full-screen spinner only for the initial directory load. An in-flight
196 // Full-screen spinner only for the initial directory load. An in-flight
194 // search keeps the previous results visible (a slim top bar below shows the
197 // search keeps the previous results visible (a slim top bar below shows the
195 // progress) instead of blanking the list behind a spinner on every keystroke.
198 // progress) instead of blanking the list behind a spinner on every keystroke.
196− state.loading -> CircularProgressIndicator(
199+ state.loading -> Column(
200+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
201+ horizontalAlignment = Alignment.CenterHorizontally,
202+ ) {
203+ CircularProgressIndicator(
197− Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
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),
198− )
211+ )
212+ }
199 state.error != null -> Column(
213 state.error != null -> Column(
200 modifier = Modifier.align(Alignment.Center).padding(24.dp),
214 modifier = Modifier.align(Alignment.Center).padding(24.dp),
201 horizontalAlignment = Alignment.CenterHorizontally,
215 horizontalAlignment = Alignment.CenterHorizontally,
202 ) {
216 ) {
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))
203 Text(
224 Text(
204 state.error ?: "",
225 state.error ?: "",
205 color = MaterialTheme.colorScheme.error,
226 color = MaterialTheme.colorScheme.error,
@@ -322,7 +343,7 @@ private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
322 onCopyPath = {
343 onCopyPath = {
323 copyToClipboard(context, context.getString(R.string.clip_label_path), path)
344 copyToClipboard(context, context.getString(R.string.clip_label_path), path)
324 },
345 },
325− modifier = Modifier.animateItem(),
346+ modifier = reducedMotionAnimateItem(),
326 )
347 )
327 HorizontalDivider()
348 HorizontalDivider()
328 }
349 }
@@ -377,6 +398,7 @@ private fun TextResults(results: List<FindMatch>, emptyMessage: String?, onOpen:
377 Column(
398 Column(
378 modifier = Modifier
399 modifier = Modifier
379 .fillMaxWidth()
400 .fillMaxWidth()
401+ .defaultMinSize(minHeight = 48.dp)
380 .clickable(role = Role.Button) { onOpen(match.filePath, match.lineNumber) }
402 .clickable(role = Role.Button) { onOpen(match.filePath, match.lineNumber) }
381 .padding(horizontal = 16.dp, vertical = 10.dp),
403 .padding(horizontal = 16.dp, vertical = 10.dp),
382 ) {
404 ) {
@@ -448,6 +470,7 @@ private fun SymbolResults(results: List<SymbolResult>, emptyMessage: String?, on
448 Column(
470 Column(
449 modifier = Modifier
471 modifier = Modifier
450 .fillMaxWidth()
472 .fillMaxWidth()
473+ .defaultMinSize(minHeight = 48.dp)
451 .clickable(role = Role.Button) { onOpen(symbol.filePath, symbol.displayLine) }
474 .clickable(role = Role.Button) { onOpen(symbol.filePath, symbol.displayLine) }
452 .padding(horizontal = 16.dp, vertical = 10.dp),
475 .padding(horizontal = 16.dp, vertical = 10.dp),
453 ) {
476 ) {
@@ -633,7 +656,7 @@ private fun DirectoryListing(
633 size = node.size,
656 size = node.size,
634 mtime = node.mtime,
657 mtime = node.mtime,
635 status = state.statusMap[node.path],
658 status = state.statusMap[node.path],
636− modifier = Modifier.animateItem(),
659+ modifier = reducedMotionAnimateItem(),
637 )
660 )
638 HorizontalDivider()
661 HorizontalDivider()
639 }
662 }
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+2−1
@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.Row
17import androidx.compose.foundation.layout.Spacer
17import androidx.compose.foundation.layout.Spacer
18import androidx.compose.foundation.layout.fillMaxSize
18import androidx.compose.foundation.layout.fillMaxSize
19import androidx.compose.foundation.layout.fillMaxWidth
19import androidx.compose.foundation.layout.fillMaxWidth
20+import androidx.compose.foundation.layout.imePadding
20import androidx.compose.foundation.layout.padding
21import androidx.compose.foundation.layout.padding
21import androidx.compose.foundation.layout.size
22import androidx.compose.foundation.layout.size
22import androidx.compose.foundation.layout.width
23import androidx.compose.foundation.layout.width
@@ -276,7 +277,7 @@ fun FileViewScreen(
276 androidx.compose.material3.pulltorefresh.PullToRefreshBox(
277 androidx.compose.material3.pulltorefresh.PullToRefreshBox(
277 isRefreshing = state.loading && state.content != null,
278 isRefreshing = state.loading && state.content != null,
278 onRefresh = { vm.reload() },
279 onRefresh = { vm.reload() },
279− modifier = Modifier.fillMaxSize().padding(padding),
280+ modifier = Modifier.fillMaxSize().imePadding().padding(padding),
280 ) {
281 ) {
281 Box(modifier = Modifier.fillMaxSize()) {
282 Box(modifier = Modifier.fillMaxSize()) {
282 // Surface SSE connection state so a dropped stream is visible while viewing a file.
283 // 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(
49 }
49 }
50 }
50 }
51
51
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+
52 Row(modifier = modifier.fillMaxSize()) {
71 Row(modifier = modifier.fillMaxSize()) {
53 Box(
72 Box(
54 modifier = Modifier
73 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) {
137 is McpViewModel.State.Error -> EmptyState(
137 is McpViewModel.State.Error -> EmptyState(
138 icon = Icons.Filled.ErrorOutline,
138 icon = Icons.Filled.ErrorOutline,
139 title = stringResource(R.string.mcp_failed),
139 title = stringResource(R.string.mcp_failed),
140+ description = s.message,
140 modifier = Modifier.align(Alignment.Center),
141 modifier = Modifier.align(Alignment.Center),
141 actionLabel = stringResource(R.string.retry),
142 actionLabel = stringResource(R.string.retry),
142 onAction = { vm.load() },
143 onAction = { vm.load() },
@@ -182,16 +183,51 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
182private fun McpServerCard(server: McpServerInfo) {
183private fun McpServerCard(server: McpServerInfo) {
183 val remote = server.type.equals("remote", ignoreCase = true)
184 val remote = server.type.equals("remote", ignoreCase = true)
184 val connected = server.connected
185 val connected = server.connected
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(
185− Card(modifier = Modifier.fillMaxWidth()) {
208+ modifier = Modifier
209+ .fillMaxWidth()
210+ .semantics(mergeDescendants = true) { contentDescription = cardDesc },
211+ ) {
186 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
212 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
187 Row(verticalAlignment = Alignment.CenterVertically) {
213 Row(verticalAlignment = Alignment.CenterVertically) {
188 Icon(
214 Icon(
189 if (remote) Icons.Filled.CloudQueue else Icons.Filled.Computer,
215 if (remote) Icons.Filled.CloudQueue else Icons.Filled.Computer,
190− contentDescription = null,
216+ contentDescription = typeLabel,
191 tint = if (server.enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
217 tint = if (server.enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
192 )
218 )
193 Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
219 Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
220+ Row(verticalAlignment = Alignment.CenterVertically) {
194− Text(server.name, style = MaterialTheme.typography.titleSmall)
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+ }
195 server.target?.let {
231 server.target?.let {
196 Text(
232 Text(
197 it,
233 it,
@@ -203,13 +239,6 @@ private fun McpServerCard(server: McpServerInfo) {
203 )
239 )
204 }
240 }
205 }
241 }
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− }
213 Text(
242 Text(
214 stateLabel,
243 stateLabel,
215 style = MaterialTheme.typography.labelMedium,
244 style = MaterialTheme.typography.labelMedium,
@@ -221,53 +250,60 @@ private fun McpServerCard(server: McpServerInfo) {
221 // cluttered with "unknown" chips.
250 // cluttered with "unknown" chips.
222 if (connected != null || server.error != null) {
251 if (connected != null || server.error != null) {
223 Spacer(Modifier.size(10.dp))
252 Spacer(Modifier.size(10.dp))
224− Row(verticalAlignment = Alignment.CenterVertically) {
253+ McpStatusRow(server = server, connected = connected)
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− }
266 }
254 }
267 }
255 }
268 }
256 }
269}
257}
270
258
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+
271/** Dialog collecting the bits needed to register an MCP server via POST /mcp: a name, whether
307/** Dialog collecting the bits needed to register an MCP server via POST /mcp: a name, whether
272 * it's a local command or a remote URL, the command line / URL, and optional env vars. */
308 * it's a local command or a remote URL, the command line / URL, and optional env vars. */
273@Composable
309@Composable
@@ -315,7 +351,7 @@ private fun AddMcpDialog(
315 OutlinedTextField(
351 OutlinedTextField(
316 value = target,
352 value = target,
317 onValueChange = { target = it },
353 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),
319 label = { Text(if (kind == McpKind.LOCAL) stringResource(R.string.mcp_add_command) else stringResource(R.string.mcp_add_url)) },
355 label = { Text(if (kind == McpKind.LOCAL) stringResource(R.string.mcp_add_command) else stringResource(R.string.mcp_add_url)) },
320 placeholder = {
356 placeholder = {
321 Text(if (kind == McpKind.LOCAL) stringResource(R.string.mcp_add_command_hint) else stringResource(R.string.mcp_add_url_hint))
357 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(
323 singleLine = true,
359 singleLine = true,
324 enabled = !adding,
360 enabled = !adding,
325 )
361 )
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+ }
326 OutlinedTextField(
373 OutlinedTextField(
327 value = env,
374 value = env,
328 onValueChange = { env = it },
375 onValueChange = { env = it },
329 modifier = Modifier.fillMaxWidth(),
376 modifier = Modifier.fillMaxWidth(),
330 label = { Text(stringResource(R.string.mcp_add_env)) },
377 label = { Text(stringResource(R.string.mcp_add_env)) },
331 placeholder = { Text(stringResource(R.string.mcp_add_env_hint)) },
378 placeholder = { Text(stringResource(R.string.mcp_add_env_hint)) },
379+ supportingText = { Text(stringResource(R.string.mcp_env_hint)) },
332 enabled = !adding,
380 enabled = !adding,
333 minLines = 2,
381 minLines = 2,
334 )
382 )
app/src/main/java/soy/iko/opencode/ui/mcp/McpViewModel.kt+5−2
@@ -23,6 +23,7 @@ import kotlinx.serialization.json.putJsonArray
23import kotlinx.serialization.json.putJsonObject
23import kotlinx.serialization.json.putJsonObject
24import soy.iko.opencode.di.AppContainer
24import soy.iko.opencode.di.AppContainer
25import soy.iko.opencode.util.runCatchingCancellable
25import soy.iko.opencode.util.runCatchingCancellable
26+import soy.iko.opencode.util.safeExceptionSummary
26
27
27/** The shape of an MCP server the user is registering from the Add dialog. */
28/** The shape of an MCP server the user is registering from the Add dialog. */
28enum class McpKind { LOCAL, REMOTE }
29enum class McpKind { LOCAL, REMOTE }
@@ -38,7 +39,9 @@ class McpViewModel(private val container: AppContainer) : ViewModel() {
38 sealed interface State {
39 sealed interface State {
39 data object Loading : State
40 data object Loading : State
40 data object Disconnected : State
41 data object Disconnected : 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. */
41− data object Error : State
44+ data class Error(val message: String? = null) : State
42 data class Ready(val servers: List<McpServerInfo>) : State
45 data class Ready(val servers: List<McpServerInfo>) : State
43 }
46 }
44
47
@@ -97,7 +100,7 @@ class McpViewModel(private val container: AppContainer) : ViewModel() {
97 }
100 }
98 }
101 }
99 .onSuccess { _state.value = State.Ready(it) }
102 .onSuccess { _state.value = State.Ready(it) }
100− .onFailure { _state.value = State.Error }
103+ .onFailure { _state.value = State.Error(safeExceptionSummary(it)) }
101 _refreshing.value = false
104 _refreshing.value = false
102 }
105 }
103
106
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+2−1
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.PaddingValues
8import androidx.compose.foundation.layout.Row
8import androidx.compose.foundation.layout.Row
9import androidx.compose.foundation.layout.fillMaxSize
9import androidx.compose.foundation.layout.fillMaxSize
10import androidx.compose.foundation.layout.fillMaxWidth
10import androidx.compose.foundation.layout.fillMaxWidth
11+import androidx.compose.foundation.layout.imePadding
11import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.size
13import androidx.compose.foundation.layout.size
13import androidx.compose.foundation.lazy.LazyColumn
14import androidx.compose.foundation.lazy.LazyColumn
@@ -83,7 +84,7 @@ fun GlobalSearchScreen(
83 AppTopBar(title = stringResource(R.string.search_all_title), onBack = onBack)
84 AppTopBar(title = stringResource(R.string.search_all_title), onBack = onBack)
84 },
85 },
85 ) { padding ->
86 ) { padding ->
86− Column(modifier = Modifier.fillMaxSize().padding(padding)) {
87+ Column(modifier = Modifier.fillMaxSize().imePadding().padding(padding)) {
87 OutlinedTextField(
88 OutlinedTextField(
88 value = state.query,
89 value = state.query,
89 onValueChange = vm::setQuery,
90 onValueChange = vm::setQuery,
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+4
@@ -224,6 +224,10 @@ private fun ServerEditForm(
224 } else {
224 } else {
225 Text(stringResource(R.string.invalid_url))
225 Text(stringResource(R.string.invalid_url))
226 }
226 }
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))
227 }
231 }
228 },
232 },
229 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
233 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
84import soy.iko.opencode.di.AppContainer
84import soy.iko.opencode.di.AppContainer
85import soy.iko.opencode.R
85import soy.iko.opencode.R
86import soy.iko.opencode.ui.components.EmptyState
86import soy.iko.opencode.ui.components.EmptyState
87+import soy.iko.opencode.ui.components.reducedMotionAnimateItem
87import soy.iko.opencode.ui.components.rememberRelativeTime
88import soy.iko.opencode.ui.components.rememberRelativeTime
88import soy.iko.opencode.ui.vmFactory
89import soy.iko.opencode.ui.vmFactory
89
90
@@ -324,7 +325,7 @@ fun ServerListScreen(
324 Card(
325 Card(
325 modifier = Modifier
326 modifier = Modifier
326 .fillMaxWidth()
327 .fillMaxWidth()
327− .animateItem()
328+ .then(reducedMotionAnimateItem())
328 .testTag("server_card")
329 .testTag("server_card")
329 .clickable(enabled = connectingId == null, role = Role.Button) {
330 .clickable(enabled = connectingId == null, role = Role.Button) {
330 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
331 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
@@ -357,7 +358,7 @@ fun ServerListScreen(
357 SwipeToDismissBox(
358 SwipeToDismissBox(
358 state = swipeState,
359 state = swipeState,
359 enableDismissFromStartToEnd = false,
360 enableDismissFromStartToEnd = false,
360− modifier = Modifier.animateItem(),
361+ modifier = reducedMotionAnimateItem(),
361 backgroundContent = {
362 backgroundContent = {
362 Box(
363 Box(
363 modifier = Modifier
364 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
9import androidx.compose.foundation.layout.Column
9import androidx.compose.foundation.layout.Column
10import androidx.compose.foundation.layout.PaddingValues
10import androidx.compose.foundation.layout.PaddingValues
11import androidx.compose.foundation.layout.Row
11import androidx.compose.foundation.layout.Row
12+import androidx.compose.foundation.layout.defaultMinSize
12import androidx.compose.foundation.layout.Spacer
13import androidx.compose.foundation.layout.Spacer
13import androidx.compose.foundation.layout.fillMaxSize
14import androidx.compose.foundation.layout.fillMaxSize
15+import androidx.compose.foundation.layout.imePadding
14import androidx.compose.foundation.layout.fillMaxWidth
16import androidx.compose.foundation.layout.fillMaxWidth
15import androidx.compose.foundation.layout.heightIn
17import androidx.compose.foundation.layout.heightIn
16import androidx.compose.foundation.layout.padding
18import androidx.compose.foundation.layout.padding
@@ -115,6 +117,7 @@ import soy.iko.opencode.R
115import soy.iko.opencode.ui.components.ConnectionBanner
117import soy.iko.opencode.ui.components.ConnectionBanner
116import soy.iko.opencode.ui.components.EmptyState
118import soy.iko.opencode.ui.components.EmptyState
117import soy.iko.opencode.ui.components.LocalRelativeTimeTick
119import soy.iko.opencode.ui.components.LocalRelativeTimeTick
120+import soy.iko.opencode.ui.components.reducedMotionAnimateItem
118import soy.iko.opencode.ui.components.RelativeTimeText
121import soy.iko.opencode.ui.components.RelativeTimeText
119import soy.iko.opencode.ui.components.rememberRelativeTimeTick
122import soy.iko.opencode.ui.components.rememberRelativeTimeTick
120import soy.iko.opencode.ui.vmFactory
123import soy.iko.opencode.ui.vmFactory
@@ -422,7 +425,7 @@ fun SessionListScreen(
422 // down when it appears instead of covering the search field and top cards. Its own
425 // down when it appears instead of covering the search field and top cards. Its own
423 // AnimatedVisibility collapses it to zero height when the stream is healthy, so the
426 // AnimatedVisibility collapses it to zero height when the stream is healthy, so the
424 // body fills the space normally.
427 // body fills the space normally.
425− Column(modifier = Modifier.fillMaxSize().padding(padding)) {
428+ Column(modifier = Modifier.fillMaxSize().imePadding().padding(padding)) {
426 ConnectionBanner(
429 ConnectionBanner(
427 state = connectionState,
430 state = connectionState,
428 isOnline = isOnline,
431 isOnline = isOnline,
@@ -738,7 +741,7 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
738 // Indent sub-sessions under their parent (capped so deep
741 // Indent sub-sessions under their parent (capped so deep
739 // nesting doesn't squeeze the card off-screen).
742 // nesting doesn't squeeze the card off-screen).
740 modifier = Modifier
743 modifier = Modifier
741− .animateItem()
744+ .then(reducedMotionAnimateItem())
742 .padding(start = (node.depth.coerceAtMost(3) * 16).dp),
745 .padding(start = (node.depth.coerceAtMost(3) * 16).dp),
743 backgroundContent = {
746 backgroundContent = {
744 val archiving = swipeState.dismissDirection == SwipeToDismissBoxValue.StartToEnd
747 val archiving = swipeState.dismissDirection == SwipeToDismissBoxValue.StartToEnd
@@ -1093,6 +1096,7 @@ private fun SessionCard(
1093 verticalAlignment = Alignment.CenterVertically,
1096 verticalAlignment = Alignment.CenterVertically,
1094 modifier = Modifier
1097 modifier = Modifier
1095 .padding(top = 2.dp)
1098 .padding(top = 2.dp)
1099+ .defaultMinSize(minHeight = 48.dp)
1096 .clip(MaterialTheme.shapes.small)
1100 .clip(MaterialTheme.shapes.small)
1097 .then(
1101 .then(
1098 if (chipEnabled) {
1102 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
73import soy.iko.opencode.ui.components.AppTopBar
73import soy.iko.opencode.ui.components.AppTopBar
74import soy.iko.opencode.ui.components.EmptyState
74import soy.iko.opencode.ui.components.EmptyState
75import soy.iko.opencode.ui.components.copyToClipboard
75import soy.iko.opencode.ui.components.copyToClipboard
76+import soy.iko.opencode.ui.components.reducedMotionAnimateItem
76import androidx.compose.ui.Alignment
77import androidx.compose.ui.Alignment
77import androidx.compose.ui.Modifier
78import androidx.compose.ui.Modifier
78import androidx.compose.ui.draw.clip
79import androidx.compose.ui.draw.clip
@@ -110,6 +111,10 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
110 val shareScope = rememberCoroutineScope()
111 val shareScope = rememberCoroutineScope()
111 val haptics = LocalHapticFeedback.current
112 val haptics = LocalHapticFeedback.current
112 val snackbar = remember { SnackbarHostState() }
113 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() }
113 val undoLabel = stringResource(R.string.undo)
118 val undoLabel = stringResource(R.string.undo)
114 val reportDeletedLabel = stringResource(R.string.report_deleted)
119 val reportDeletedLabel = stringResource(R.string.report_deleted)
115 val reportsClearedLabel = stringResource(R.string.reports_cleared)
120 val reportsClearedLabel = stringResource(R.string.reports_cleared)
@@ -225,7 +230,14 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
225 },
230 },
226 )
231 )
227 },
232 },
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 {
228− snackbarHost = { SnackbarHost(snackbar) },
237+ SnackbarHost(snackbar)
238+ SnackbarHost(errorSnackbar)
239+ }
240+ },
229 ) { padding ->
241 ) { padding ->
230 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
242 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
231 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
243 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
@@ -276,7 +288,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
276 SwipeToDismissBox(
288 SwipeToDismissBox(
277 state = swipeState,
289 state = swipeState,
278 enableDismissFromStartToEnd = false,
290 enableDismissFromStartToEnd = false,
279− modifier = Modifier.animateItem(),
291+ modifier = reducedMotionAnimateItem(),
280 backgroundContent = {
292 backgroundContent = {
281 Box(
293 Box(
282 modifier = Modifier
294 modifier = Modifier
@@ -332,7 +344,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
332 // failures (e.g. an oversized report exceeding the Binder limit →
344 // failures (e.g. an oversized report exceeding the Binder limit →
333 // TransactionTooLargeException) get a generic toast, not that misleading one.
345 // TransactionTooLargeException) get a generic toast, not that misleading one.
334 val msg = if (it is ActivityNotFoundException) R.string.no_share_app else R.string.error_generic
346 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)) }
336 }
348 }
337 }
349 }
338 }) {
350 }) {
@@ -430,7 +442,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
430 // (e.g. an oversized report exceeding the Binder limit → TransactionTooLargeException)
442 // (e.g. an oversized report exceeding the Binder limit → TransactionTooLargeException)
431 // get a generic toast, not that misleading one.
443 // get a generic toast, not that misleading one.
432 val msg = if (it is ActivityNotFoundException) R.string.no_share_app else R.string.error_generic
444 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)) }
434 }
446 }
435 },
447 },
436 ) { Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share)) }
448 ) { 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(
273 Row(
273 Row(
274 modifier = Modifier
274 modifier = Modifier
275 .fillMaxWidth()
275 .fillMaxWidth()
276+ .defaultMinSize(minHeight = 48.dp)
276 .toggleable(
277 .toggleable(
277 value = s.dynamicColor,
278 value = s.dynamicColor,
278 enabled = !amoledActive,
279 enabled = !amoledActive,
@@ -310,6 +311,7 @@ fun SettingsScreen(
310 Row(
311 Row(
311 modifier = Modifier
312 modifier = Modifier
312 .fillMaxWidth()
313 .fillMaxWidth()
314+ .defaultMinSize(minHeight = 48.dp)
313 .toggleable(
315 .toggleable(
314 value = s.sendOnEnter,
316 value = s.sendOnEnter,
315 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setSendOnEnter(it) } } },
317 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setSendOnEnter(it) } } },
@@ -341,6 +343,7 @@ fun SettingsScreen(
341 Row(
343 Row(
342 modifier = Modifier
344 modifier = Modifier
343 .fillMaxWidth()
345 .fillMaxWidth()
346+ .defaultMinSize(minHeight = 48.dp)
344 .toggleable(
347 .toggleable(
345 value = s.appLock,
348 value = s.appLock,
346 enabled = appLockToggleable,
349 enabled = appLockToggleable,
@@ -397,6 +400,7 @@ fun SettingsScreen(
397 Row(
400 Row(
398 modifier = Modifier
401 modifier = Modifier
399 .fillMaxWidth()
402 .fillMaxWidth()
403+ .defaultMinSize(minHeight = 48.dp)
400 .toggleable(
404 .toggleable(
401 value = codeWrap,
405 value = codeWrap,
402 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setCodeWrap(it) } } },
406 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setCodeWrap(it) } } },
@@ -463,6 +467,7 @@ fun SettingsScreen(
463 TextButton(
467 TextButton(
464 onClick = { container.activeConnection.value?.events?.triggerReconnect() },
468 onClick = { container.activeConnection.value?.events?.triggerReconnect() },
465 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
469 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
470+ modifier = Modifier.defaultMinSize(minHeight = 48.dp),
466 ) { Text(stringResource(R.string.retry_now)) }
471 ) { Text(stringResource(R.string.retry_now)) }
467 }
472 }
468 }
473 }
@@ -532,6 +537,7 @@ fun SettingsScreen(
532 Row(
537 Row(
533 modifier = Modifier
538 modifier = Modifier
534 .fillMaxWidth()
539 .fillMaxWidth()
540+ .defaultMinSize(minHeight = 48.dp)
535 .toggleable(
541 .toggleable(
536 value = includePasswords,
542 value = includePasswords,
537 onValueChange = { includePasswords = it },
543 onValueChange = { includePasswords = it },
@@ -662,6 +668,13 @@ fun SettingsScreen(
662 actionLabel = restartLabel,
668 actionLabel = restartLabel,
663 )
669 )
664 if (result == SnackbarResult.ActionPerformed) {
670 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() }
665 android.os.Process.killProcess(android.os.Process.myPid())
678 android.os.Process.killProcess(android.os.Process.myPid())
666 }
679 }
667 } else {
680 } else {
@@ -805,6 +818,7 @@ private fun ThemeRow(mode: ThemeMode, selected: Boolean, usingSystemColors: Bool
805 Row(
818 Row(
806 modifier = Modifier
819 modifier = Modifier
807 .fillMaxWidth()
820 .fillMaxWidth()
821+ .defaultMinSize(minHeight = 48.dp)
808 .selectable(selected = selected, onClick = onSelect)
822 .selectable(selected = selected, onClick = onSelect)
809 .padding(vertical = 8.dp),
823 .padding(vertical = 8.dp),
810 horizontalArrangement = Arrangement.spacedBy(8.dp),
824 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
16import androidx.compose.foundation.lazy.LazyColumn
16import androidx.compose.foundation.lazy.LazyColumn
17import androidx.compose.foundation.lazy.items
17import androidx.compose.foundation.lazy.items
18import androidx.compose.material.icons.Icons
18import androidx.compose.material.icons.Icons
19+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
19import androidx.compose.material.icons.filled.Close
20import androidx.compose.material.icons.filled.Close
20import androidx.compose.material.icons.filled.CloudOff
21import androidx.compose.material.icons.filled.CloudOff
21import androidx.compose.material.icons.filled.ErrorOutline
22import androidx.compose.material.icons.filled.ErrorOutline
@@ -49,6 +50,7 @@ import androidx.compose.ui.res.stringResource
49import androidx.compose.ui.semantics.Role
50import androidx.compose.ui.semantics.Role
50import androidx.compose.ui.semantics.contentDescription
51import androidx.compose.ui.semantics.contentDescription
51import androidx.compose.ui.semantics.heading
52import androidx.compose.ui.semantics.heading
53+import androidx.compose.ui.semantics.invisibleToUser
52import androidx.compose.ui.semantics.semantics
54import androidx.compose.ui.semantics.semantics
53import androidx.compose.ui.text.style.TextOverflow
55import androidx.compose.ui.text.style.TextOverflow
54import androidx.compose.ui.unit.dp
56import androidx.compose.ui.unit.dp
@@ -112,6 +114,7 @@ fun UsageScreen(container: AppContainer, onBack: () -> Unit, onOpenSession: (Str
112 is UsageViewModel.State.Error -> EmptyState(
114 is UsageViewModel.State.Error -> EmptyState(
113 icon = Icons.Filled.ErrorOutline,
115 icon = Icons.Filled.ErrorOutline,
114 title = stringResource(R.string.usage_failed),
116 title = stringResource(R.string.usage_failed),
117+ description = s.message,
115 modifier = Modifier.align(Alignment.Center),
118 modifier = Modifier.align(Alignment.Center),
116 actionLabel = stringResource(R.string.retry),
119 actionLabel = stringResource(R.string.retry),
117 onAction = { vm.load() },
120 onAction = { vm.load() },
@@ -121,6 +124,7 @@ fun UsageScreen(container: AppContainer, onBack: () -> Unit, onOpenSession: (Str
121 EmptyState(
124 EmptyState(
122 icon = Icons.Filled.QueryStats,
125 icon = Icons.Filled.QueryStats,
123 title = stringResource(R.string.usage_empty),
126 title = stringResource(R.string.usage_empty),
127+ description = stringResource(R.string.usage_empty_desc),
124 modifier = Modifier.align(Alignment.Center),
128 modifier = Modifier.align(Alignment.Center),
125 )
129 )
126 } else {
130 } else {
@@ -223,12 +227,17 @@ private fun fractionOf(part: Double, total: Double): Float =
223/** A slim horizontal bar showing this row's share of the total cost. */
227/** A slim horizontal bar showing this row's share of the total cost. */
224@Composable
228@Composable
225private fun CostBar(fraction: Float) {
229private fun CostBar(fraction: Float) {
230+ val pct = (fraction * 100).toInt()
226 Box(
231 Box(
227 modifier = Modifier
232 modifier = Modifier
228 .fillMaxWidth()
233 .fillMaxWidth()
229 .height(4.dp)
234 .height(4.dp)
230 .clip(MaterialTheme.shapes.small)
235 .clip(MaterialTheme.shapes.small)
231− .background(MaterialTheme.colorScheme.surfaceVariant),
236+ .background(MaterialTheme.colorScheme.surfaceVariant)
237+ .semantics {
238+ contentDescription = "${pct}% of total"
239+ invisibleToUser()
240+ },
232 ) {
241 ) {
233 if (fraction > 0f) {
242 if (fraction > 0f) {
234 Box(
243 Box(
@@ -325,6 +334,17 @@ private fun UsageRow(
325 color = MaterialTheme.colorScheme.primary,
334 color = MaterialTheme.colorScheme.primary,
326 modifier = Modifier.padding(start = 12.dp),
335 modifier = Modifier.padding(start = 12.dp),
327 )
336 )
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+ }
328 }
348 }
329 Spacer(Modifier.size(6.dp))
349 Spacer(Modifier.size(6.dp))
330 CostBar(costFraction)
350 CostBar(costFraction)
app/src/main/java/soy/iko/opencode/ui/usage/UsageViewModel.kt+5−2
@@ -18,6 +18,7 @@ import kotlinx.coroutines.sync.withPermit
18import kotlinx.coroutines.withContext
18import kotlinx.coroutines.withContext
19import soy.iko.opencode.di.AppContainer
19import soy.iko.opencode.di.AppContainer
20import soy.iko.opencode.util.runCatchingCancellable
20import soy.iko.opencode.util.runCatchingCancellable
21+import soy.iko.opencode.util.safeExceptionSummary
21
22
22/**
23/**
23 * Computes a cross-session [UsageReport] by listing the server's sessions and fetching each
24 * 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() {
31 sealed interface State {
32 sealed interface State {
32 data object Loading : State
33 data object Loading : State
33 data object Disconnected : State
34 data object Disconnected : 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. */
34− data object Error : State
37+ data class Error(val message: String? = null) : State
35 data class Ready(val report: UsageReport) : State
38 data class Ready(val report: UsageReport) : State
36 }
39 }
37
40
@@ -90,7 +93,7 @@ class UsageViewModel(private val container: AppContainer) : ViewModel() {
90 withContext(Dispatchers.Default) { aggregateUsage(perSession) }
93 withContext(Dispatchers.Default) { aggregateUsage(perSession) }
91 }
94 }
92 .onSuccess { _state.value = State.Ready(it) }
95 .onSuccess { _state.value = State.Ready(it) }
93− .onFailure { _state.value = State.Error }
96+ .onFailure { _state.value = State.Error(safeExceptionSummary(it)) }
94 _refreshing.value = false
97 _refreshing.value = false
95 }
98 }
96
99
app/src/main/res/values-es/strings.xml+34
@@ -701,4 +701,38 @@
701 <string name="revert_diff_header">Cambios del revertido</string>
701 <string name="revert_diff_header">Cambios del revertido</string>
702 <string name="agent_error_label">El agente informó un error</string>
702 <string name="agent_error_label">El agente informó un error</string>
703 <string name="custom_path_required">Escribe una ruta para crear una sesión aquí.</string>
703 <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>
704</resources>
738</resources>
app/src/main/res/values/strings.xml+34
@@ -480,6 +480,7 @@
480 <string name="usage_filter_sessions">Filter sessions</string>
480 <string name="usage_filter_sessions">Filter sessions</string>
481 <string name="usage_no_sessions">No matching sessions</string>
481 <string name="usage_no_sessions">No matching sessions</string>
482 <string name="usage_empty">No usage recorded yet</string>
482 <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>
483 <plurals name="usage_messages_count">
484 <plurals name="usage_messages_count">
484 <item quantity="one">%1$d assistant message</item>
485 <item quantity="one">%1$d assistant message</item>
485 <item quantity="other">%1$d assistant messages</item>
486 <item quantity="other">%1$d assistant messages</item>
@@ -701,4 +702,37 @@
701 <string name="revert_diff_header">Changes from revert</string>
702 <string name="revert_diff_header">Changes from revert</string>
702 <string name="agent_error_label">The agent reported an error</string>
703 <string name="agent_error_label">The agent reported an error</string>
703 <string name="custom_path_required">Enter a path to create a session here.</string>
704 <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>
704</resources>
738</resources>