git.iko.soy/opencode-android
on master help sign in
Implement 16 UI/UX improvements across the app
commit4cdfbdaf77b2f379a6bb12e836374017dc46fc5b authoriko <mail@iko.soy> dateSun 5 Jul 2026, 05:47 (3 months ago) parent3a67ee1
18 files changed+321−32
app/detekt-baseline.xml+2−2 app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+5 app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+27 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+25 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+49−4 app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+4 app/src/main/java/soy/iko/opencode/ui/components/CompactNavBar.kt+10−1 app/src/main/java/soy/iko/opencode/ui/components/GatedHapticFeedback.kt+33 app/src/main/java/soy/iko/opencode/ui/components/LargeScreenNavRail.kt+7−1 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+51−2 app/src/main/java/soy/iko/opencode/ui/file/TwoPaneFileBrowser.kt+16 app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+18−2 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+5−2 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+42−17 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+11−1 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+6 app/src/main/res/values-es/strings.xml+5 app/src/main/res/values/strings.xml+5
app/detekt-baseline.xml+2−2
@@ -18,8 +18,8 @@
1818 <ID>CyclomaticComplexMethod:MainActivity.kt$MainActivity$private fun handleIntent(intent: Intent?)</ID>
1919 <ID>CyclomaticComplexMethod:OpencodeApp.kt$@Composable fun OpencodeApp(container: AppContainer)</ID>
2020 <ID>CyclomaticComplexMethod:ServerListScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ServerListScreen( container: AppContainer, onConnected: () -&gt; Unit, onAddProfile: () -&gt; Unit, onEditProfile: (String) -&gt; Unit, onDuplicateProfile: (String) -&gt; Unit, )</ID>
21− <ID>CyclomaticComplexMethod:SessionListScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun SessionListScreen( container: AppContainer, onOpenSession: (String) -&gt; Unit, onDisconnect: () -&gt; Unit, onOpenFiles: () -&gt; Unit, onOpenSettings: () -&gt; Unit, onAddServer: () -&gt; Unit, onOpenSearch: () -&gt; Unit = {}, onEditProfile: ((String) -&gt; Unit)? = null, // Incremented by a host (the two-pane empty-detail pane) to open the new-session // directory picker, so that pane shares this screen's dialog instead of bypassing it. externalNewSessionTrigger: Int = 0, selectedSessionId: String? = null, )</ID>
22− <ID>CyclomaticComplexMethod:SettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalCoroutinesApi::class) @Composable fun SettingsScreen( container: AppContainer, onBack: () -&gt; Unit, onManageServers: () -&gt; Unit, onOpenDiagnostics: () -&gt; Unit, onOpenUsage: () -&gt; Unit = {}, onOpenMcp: () -&gt; Unit = {}, )</ID>
21+ <ID>CyclomaticComplexMethod:SessionListScreen.kt$@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable fun SessionListScreen( container: AppContainer, onOpenSession: (String) -&gt; Unit, onDisconnect: () -&gt; Unit, onOpenFiles: () -&gt; Unit, onOpenSettings: () -&gt; Unit, onAddServer: () -&gt; Unit, onOpenSearch: () -&gt; Unit = {}, onEditProfile: ((String) -&gt; Unit)? = null, // Incremented by a host (the two-pane empty-detail pane) to open the new-session // directory picker, so that pane shares this screen's dialog instead of bypassing it. externalNewSessionTrigger: Int = 0, selectedSessionId: String? = null, )</ID>
22+ <ID>CyclomaticComplexMethod:SettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalCoroutinesApi::class) @Composable fun SettingsScreen( container: AppContainer, onBack: () -&gt; Unit, onManageServers: () -&gt; Unit, onOpenDiagnostics: () -&gt; Unit, onOpenUsage: () -&gt; Unit = {}, onOpenMcp: () -&gt; Unit = {}, onEditProfile: ((String) -&gt; Unit)? = null, )</ID>
2323 <ID>LargeClass:AppContainer.kt$AppContainer</ID>
2424 <ID>LargeClass:SessionListViewModel.kt$SessionListViewModel : ViewModel</ID>
2525 <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+5
@@ -57,6 +57,11 @@ object NetworkConfig {
5757 /** How long cached catalog responses (providers/agents/commands) are considered fresh. */
5858 const val catalogCacheTtlMs = 60_000L
5959
60+ /** TTL for files written to the cache dir to hand to external apps via FileProvider
61+ * (open-externally). Stale files past this age are pruned on the next open so repeated
62+ * opens of large files don't accumulate unbounded. 24h covers a typical session. */
63+ const val externalCacheTtlMs = 24L * 60 * 60 * 1000
64+
6065 // --- Session list (SessionListViewModel) ---
6166
6267 /** Max sessions to fetch previews for in one batch. */
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+27
@@ -249,6 +249,33 @@ object SessionNotifications {
249249 .onFailure { Log.w(TAG, "Failed to post permission summary notification", it) }
250250 }
251251
252+ /** Post a notification when a permission prompt auto-rejected on timeout. A user who
253+ * walked away from an in-app prompt returns to find the run stopped with no on-screen
254+ * record of why; this leaves a persistent trace in the shade explaining the stop and
255+ * offering to re-open the session. Uses the permission channel (heads-up) so it's seen. */
256+ @SuppressLint("MissingPermission")
257+ fun postPermissionAutoRejected(context: Context, sessionId: String, sessionTitle: String, profileId: String?) {
258+ if (!canPost(context)) return
259+ val notifId = notifId(NS_PERMISSION, sessionId) + 1 // distinct from the live prompt
260+ val detail = context.getString(R.string.notif_permission_auto_rejected_text)
261+ val builder = NotificationCompat.Builder(context, NotificationChannels.PERMISSION)
262+ .setSmallIcon(R.drawable.ic_stat_notify)
263+ .setContentTitle(context.getString(R.string.notif_permission_auto_rejected_title, sessionTitle))
264+ .setContentText(detail)
265+ .setStyle(NotificationCompat.BigTextStyle().bigText(detail))
266+ .setAutoCancel(true)
267+ .setOnlyAlertOnce(true)
268+ .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
269+ .setCategory(NotificationCompat.CATEGORY_STATUS)
270+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
271+ .setGroup(GROUP_PERMISSION)
272+ .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
273+ runCatching { NotificationManagerCompat.from(context).notify(notifId, builder.build()) }
274+ .onFailure { Log.w(TAG, "Failed to post permission auto-reject notification", it) }
275+ runCatching { postGroupSummary(context, NotificationChannels.PERMISSION, GROUP_PERMISSION, SUMMARY_PERMISSION_ID) }
276+ .onFailure { Log.w(TAG, "Failed to post permission summary notification", it) }
277+ }
278+
252279 /** Post a notification when a background run fails. */
253280 @SuppressLint("MissingPermission")
254281 fun postError(context: Context, sessionId: String, title: String, profileId: String? = null) {
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+25
@@ -25,6 +25,7 @@ import androidx.compose.ui.Modifier
2525import androidx.compose.ui.input.key.Key
2626import androidx.compose.ui.input.key.KeyEventType
2727import androidx.compose.ui.input.key.isCtrlPressed
28+import androidx.compose.ui.input.key.isAltPressed
2829import androidx.compose.ui.input.key.key
2930import androidx.compose.ui.input.key.onPreviewKeyEvent
3031import androidx.compose.ui.input.key.type
@@ -51,12 +52,14 @@ import soy.iko.opencode.ui.file.TwoPaneFileBrowser
5152import soy.iko.opencode.ui.server.ServerEditScreen
5253import soy.iko.opencode.ui.server.ServerListScreen
5354import soy.iko.opencode.ui.search.GlobalSearchScreen
55+import androidx.compose.ui.platform.LocalHapticFeedback
5456import soy.iko.opencode.ui.components.LargeScreenNavRail
5557import soy.iko.opencode.ui.components.CompactNavBar
5658import soy.iko.opencode.ui.components.LocalChatTextScale
5759import soy.iko.opencode.ui.components.LocalCodeWrap
5860import soy.iko.opencode.ui.components.LocalReducedMotion
5961import soy.iko.opencode.ui.components.isReducedMotion
62+import soy.iko.opencode.ui.components.rememberGatedHaptics
6063import soy.iko.opencode.ui.session.SessionListScreen
6164import soy.iko.opencode.ui.session.TwoPaneSessionChat
6265import soy.iko.opencode.ui.settings.DiagnosticsScreen
@@ -81,6 +84,11 @@ fun OpencodeApp(container: AppContainer) {
8184 // renderers honor the user's text-size and code-wrap choices everywhere.
8285 val chatTextScale by container.settingsStore.chatTextScale.collectAsStateWithLifecycle(initialValue = 1f)
8386 val codeWrap by container.settingsStore.codeWrap.collectAsStateWithLifecycle(initialValue = false)
87+ // Haptics gating: collected once at the root so the in-app toggle can disable all
88+ // performHapticFeedback calls everywhere via a single CompositionLocalProvider, instead
89+ // of each call site needing to read the setting. Defaults true (the prior behavior) so
90+ // the brief window before DataStore loads still haptics as before.
91+ val hapticsEnabled by container.settingsStore.hapticsEnabled.collectAsStateWithLifecycle(initialValue = true)
8492
8593 // The foreground service that holds priority while any run is active is driven from the
8694 // process-lived AppContainer scope (see AppContainer.observeRunForegroundService), not
@@ -176,6 +184,7 @@ fun OpencodeApp(container: AppContainer) {
176184 LocalChatTextScale provides clampedChatTextScale,
177185 LocalCodeWrap provides codeWrap,
178186 LocalReducedMotion provides reducedMotion,
187+ LocalHapticFeedback provides rememberGatedHaptics(hapticsEnabled),
179188 ) {
180189 // Slide direction for push/pop transitions, mirrored in RTL so the animation reads
181190 // naturally instead of "going the wrong way". +1 = LTR (push from right), -1 = RTL.
@@ -209,6 +218,21 @@ fun OpencodeApp(container: AppContainer) {
209218 // Esc stop, Ctrl+F find) are handled in each screen's own onPreviewKeyEvent and take
210219 // precedence (they're closer to the focused composable in the tree).
211220 if (ev.type != KeyEventType.KeyDown || !ev.isCtrlPressed) {
221+ // Alt+Left (a familiar desktop "Back" gesture) pops the back stack. Not gated
222+ // on Ctrl so it works as a standalone modifier. Per-screen shortcuts (Esc
223+ // stop, Ctrl+F find, Ctrl+K palette) register their own onPreviewKeyEvent
224+ // closer to the focused composable and consume first, so this only pops when
225+ // nothing nearer handled the key — avoiding conflicts with open dialogs and
226+ // text fields. Esc is deliberately NOT mapped to back here for that reason.
227+ if (ev.type == KeyEventType.KeyDown &&
228+ ev.key == Key.DirectionLeft &&
229+ ev.isAltPressed
230+ ) {
231+ if (navController.previousBackStackEntry != null) {
232+ navController.popBackStack()
233+ return@onPreviewKeyEvent true
234+ }
235+ }
212236 return@onPreviewKeyEvent false
213237 }
214238 val route = when (ev.key) {
@@ -504,6 +528,7 @@ fun OpencodeApp(container: AppContainer) {
504528 onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
505529 onOpenUsage = { navController.navigate(Routes.USAGE) },
506530 onOpenMcp = { navController.navigate(Routes.MCP) },
531+ onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
507532 )
508533 }
509534
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+49−4
@@ -243,6 +243,7 @@ fun ChatScreen(
243243 val preferredModelId by container.settingsStore.preferredModelId.collectAsStateWithLifecycle(initialValue = "")
244244 val recentModelEntries by container.recentModelsStore.entries.collectAsStateWithLifecycle()
245245 val compactSpacing by container.settingsStore.compactMessageSpacing.collectAsStateWithLifecycle(initialValue = false)
246+ val notifPermission by container.settingsStore.notifPermission.collectAsStateWithLifecycle(initialValue = true)
246247 val isOnline by container.isOnline.collectAsStateWithLifecycle()
247248 val haptics = LocalHapticFeedback.current
248249 val layoutDirection = LocalLayoutDirection.current
@@ -657,11 +658,14 @@ fun ChatScreen(
657658 // auto-reconnect re-seed is slow or fails. Shows a brief spinner as
658659 // immediate tap feedback — the SSE reconnect may not visibly change
659660 // the connection state when already Connected, so without it the tap
660− // appears to do nothing.
661+ // appears to do nothing. Disabled while a run is active: a mid-run
662+ // refresh forces a reconnect that can interrupt the streaming reply,
663+ // and the working spinner already signals "wait" — the recovery path
664+ // is only needed when nothing is actively streaming.
661665 val refreshLabel = stringResource(R.string.refresh)
662666 IconButton(
663667 onClick = { vm.refreshMessages() },
664− enabled = activeConnection != null && !refreshing,
668+ enabled = activeConnection != null && !refreshing && !running,
665669 ) {
666670 if (refreshing) {
667671 CircularProgressIndicator(
@@ -1302,7 +1306,12 @@ fun ChatScreen(
13021306 // error/loading states reachable only via a button tap.
13031307 PullToRefreshBox(
13041308 isRefreshing = refreshing,
1305− onRefresh = { vm.refreshMessages() },
1309+ // Suppress the refresh action while a run is active: a mid-stream
1310+ // refresh forces an SSE reconnect that can interrupt the actively
1311+ // streaming reply (matching the top-bar refresh button, which is
1312+ // also disabled while running). The pull gesture still responds, but
1313+ // the release no-ops instead of triggering a disruptive re-seed.
1314+ onRefresh = { if (!running) vm.refreshMessages() },
13061315 // Cap width and center on large screens for readability — full-width
13071316 // bubbles stretch edge-to-edge on tablets, hurting line-length comfort.
13081317 modifier = Modifier.fillMaxSize().wrapContentWidth(Alignment.CenterHorizontally)
@@ -1462,6 +1471,8 @@ fun ChatScreen(
14621471 // callback gives a haptic without triggering reply — so the user gets a
14631472 // perceptible "can't quote this" signal instead of a silently dead gesture.
14641473 val cantQuoteMsg = stringResource(R.string.cannot_quote_this)
1474+ val undoLabel = stringResource(R.string.undo)
1475+ val quoteReplyLabel = stringResource(R.string.quote_reply_banner)
14651476 val swipeState = rememberSwipeToDismissBoxState(
14661477 confirmValueChange = { value ->
14671478 val replyValue = if (layoutDirection == LayoutDirection.Rtl) {
@@ -1474,6 +1485,18 @@ fun ChatScreen(
14741485 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
14751486 vm.quoteReply(quoteText)
14761487 runCatching { inputFocusRequester.requestFocus() }
1488+ // Offer a quick Undo for an accidental swipe,
1489+ // mirroring the app's undo-everywhere ethos so
1490+ // the user needn't reach for the banner's X.
1491+ scope.launch {
1492+ val res = snackbar.showSnackbar(
1493+ message = quoteReplyLabel,
1494+ actionLabel = undoLabel,
1495+ )
1496+ if (res == androidx.compose.material3.SnackbarResult.ActionPerformed) {
1497+ vm.cancelQuoteReply()
1498+ }
1499+ }
14771500 } else {
14781501 // Non-text message: give a longer haptic so the user
14791502 // feels the gesture was received but can't quote it,
@@ -1723,7 +1746,16 @@ fun ChatScreen(
17231746 newContentCount,
17241747 newContentCount,
17251748 )
1726− Badge(modifier = Modifier.semantics { contentDescription = newContentLabel }) {
1749+ Badge(
1750+ modifier = Modifier.semantics {
1751+ contentDescription = newContentLabel
1752+ // Announce new-content counts as they change so a
1753+ // TalkBack user hears "5 new messages" without having
1754+ // to focus the FAB. Polite so it doesn't interrupt an
1755+ // in-progress utterance (e.g. streaming token reads).
1756+ liveRegion = androidx.compose.ui.semantics.LiveRegionMode.Polite
1757+ },
1758+ ) {
17271759 Text(
17281760 if (newContentCount > 99) "99+" else newContentCount.toString(),
17291761 modifier = Modifier.semantics { invisibleToUser() },
@@ -2043,6 +2075,19 @@ fun ChatScreen(
20432075 position = permissionProgress.position,
20442076 total = permissionProgress.total,
20452077 onRespond = { response -> vm.respondPermission(permission, response) },
2078+ // On auto-reject timeout, post a notification so a returning user finds a
2079+ // persistent record of why the run stopped (the dialog itself is gone).
2080+ // Gated by the user's notification setting (notifPermission).
2081+ onAutoReject = {
2082+ val conn = activeConnection
2083+ val ctx = appContext
2084+ val title = sessionTitle ?: sessionId
2085+ if (notifPermission) {
2086+ soy.iko.opencode.notification.SessionNotifications.postPermissionAutoRejected(
2087+ ctx, sessionId, title, conn?.profile?.id,
2088+ )
2089+ }
2090+ },
20462091 )
20472092 }
20482093
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+4
@@ -59,6 +59,7 @@ fun PermissionDialog(
5959 onRespond: (PermissionResponse) -> Unit,
6060 position: Int = 0,
6161 total: Int = 0,
62+ onAutoReject: () -> Unit = {},
6263) {
6364 val haptics = LocalHapticFeedback.current
6465 // Guard against double-respond: back press + button tap, or rapid double-tap,
@@ -96,6 +97,9 @@ fun PermissionDialog(
9697 }
9798 // Haptic on auto-reject so a walked-away user is signaled that the decision was made.
9899 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
100+ // Leave a persistent trace (notification) so a returning user who missed the dialog
101+ // closing understands why the run stopped — the in-app dialog is gone by then.
102+ onAutoReject()
99103 respond(PermissionResponse.REJECT)
100104 }
101105 val showReminder = elapsedMs >= NetworkConfig.permissionReminderThresholdMs
app/src/main/java/soy/iko/opencode/ui/components/CompactNavBar.kt+10−1
@@ -17,6 +17,8 @@ import androidx.compose.material3.Text
1717import androidx.compose.runtime.Composable
1818import androidx.compose.ui.Modifier
1919import androidx.compose.ui.graphics.vector.ImageVector
20+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
21+import androidx.compose.ui.platform.LocalHapticFeedback
2022import androidx.compose.ui.res.stringResource
2123import soy.iko.opencode.R
2224import soy.iko.opencode.ui.Routes
@@ -62,6 +64,7 @@ fun CompactNavBar(
6264 runActive: Boolean = false,
6365) {
6466 NavigationBar(modifier = modifier.fillMaxWidth()) {
67+ val haptics = LocalHapticFeedback.current
6568 BOTTOM_DESTINATIONS.forEach { dest ->
6669 val selected = currentRoute == dest.route ||
6770 (dest.route == Routes.SESSIONS && currentRoute != null &&
@@ -73,7 +76,13 @@ fun CompactNavBar(
7376 NavigationBarItem(
7477 selected = selected,
7578 enabled = !dest.requiresConnection || connected,
76− onClick = { onNavigate(dest.route) },
79+ onClick = {
80+ // Haptic on selection to match the app's haptic-everywhere ethos
81+ // (NavigationBarItem adds none by default). Gated by the root haptics
82+ // CompositionLocal, so the in-app toggle still silences it.
83+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
84+ onNavigate(dest.route)
85+ },
7786 icon = {
7887 if (showUnreadBadge || showRunBadge) {
7988 BadgedBox(
app/src/main/java/soy/iko/opencode/ui/components/GatedHapticFeedback.ktnew file+33
@@ -0,0 +1,33 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.runtime.Composable
4+import androidx.compose.runtime.remember
5+import androidx.compose.ui.hapticfeedback.HapticFeedback
6+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
7+import androidx.compose.ui.platform.LocalHapticFeedback
8+
9+/**
10+ * A [HapticFeedback] wrapper that no-ops all calls when [enabled] is false, gating every
11+ * `LocalHapticFeedback.current.performHapticFeedback(...)` call site at once without each one
12+ * needing to read the setting individually. Provided at the app root via
13+ * `CompositionLocalProvider(LocalHapticFeedback provides rememberGatedHaptics(...))`.
14+ */
15+class GatedHapticFeedback(
16+ private val delegate: HapticFeedback,
17+ private val enabled: () -> Boolean,
18+) : HapticFeedback {
19+ override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) {
20+ if (enabled()) delegate.performHapticFeedback(hapticFeedbackType)
21+ }
22+}
23+
24+/**
25+ * Wraps the platform [LocalHapticFeedback] in a [GatedHapticFeedback] controlled by [enabled].
26+ * Re-created only when the delegate or the enabled flag changes, so toggling the setting
27+ * takes effect immediately without disturbing in-flight compositions.
28+ */
29+@Composable
30+fun rememberGatedHaptics(enabled: Boolean): HapticFeedback {
31+ val delegate = LocalHapticFeedback.current
32+ return remember(delegate, enabled) { GatedHapticFeedback(delegate) { enabled } }
33+}
app/src/main/java/soy/iko/opencode/ui/components/LargeScreenNavRail.kt+7−1
@@ -19,6 +19,8 @@ import androidx.compose.material3.Text
1919import androidx.compose.runtime.Composable
2020import androidx.compose.ui.Modifier
2121import androidx.compose.ui.graphics.vector.ImageVector
22+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
23+import androidx.compose.ui.platform.LocalHapticFeedback
2224import androidx.compose.ui.res.stringResource
2325import soy.iko.opencode.R
2426import soy.iko.opencode.ui.Routes
@@ -63,6 +65,7 @@ fun LargeScreenNavRail(
6365 runActive: Boolean = false,
6466) {
6567 NavigationRail(modifier = modifier.fillMaxHeight()) {
68+ val haptics = LocalHapticFeedback.current
6669 RAIL_DESTINATIONS.forEach { dest ->
6770 // Match exact top-level routes, and also highlight the rail item when a detail
6871 // route pushed on top of it is active: chat/{id} is a detail of `sessions`, and
@@ -79,7 +82,10 @@ fun LargeScreenNavRail(
7982 NavigationRailItem(
8083 selected = selected,
8184 enabled = !dest.requiresConnection || connected,
82− onClick = { onNavigate(dest.route) },
85+ onClick = {
86+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
87+ onNavigate(dest.route)
88+ },
8389 icon = {
8490 if (showUnreadBadge || showRunBadge) {
8591 BadgedBox(
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+51−2
@@ -203,6 +203,9 @@ fun FileViewScreen(
203203 // Find-in-file match-case toggle (default case-insensitive). Persisted so a rotation keeps
204204 // the choice, and applied to the off-thread match scan below.
205205 var caseSensitive by rememberSaveable(path) { mutableStateOf(false) }
206+ // Regex mode: when on, the query is interpreted as a regular expression (Java Pattern)
207+ // instead of a plain substring. A power-user affordance mirroring editor find bars.
208+ var regex by rememberSaveable(path) { mutableStateOf(false) }
206209 // Debounce the query so each keystroke doesn't trigger a full-file scan, and run the
207210 // scan on Dispatchers.Default so a large file doesn't jank the keyboard while typing.
208211 var debouncedFind by remember { mutableStateOf("") }
@@ -210,12 +213,28 @@ fun FileViewScreen(
210213 val q = findQuery.trim()
211214 if (q.isEmpty()) debouncedFind = "" else { delay(150); debouncedFind = q }
212215 }
213− val matchIndices by produceState(emptyList<Int>(), lines, debouncedFind, caseSensitive) {
216+ val matchIndices by produceState(emptyList<Int>(), lines, debouncedFind, caseSensitive, regex) {
214217 val q = debouncedFind
215218 value = if (q.isEmpty()) emptyList()
216219 else withContext(Dispatchers.Default) {
220+ // Compile the pattern once per scan when regex mode is on; a malformed pattern
221+ // yields no matches (rather than throwing) and the FindBar surfaces the error
222+ // state via the zero match count. Case-sensitivity maps to Pattern flags.
223+ val pattern = if (regex) {
224+ runCatching {
225+ val flags = if (caseSensitive) 0 else java.util.regex.Pattern.CASE_INSENSITIVE
226+ java.util.regex.Pattern.compile(q, flags)
227+ }.getOrNull()
228+ } else {
229+ null
230+ }
217231 lines.mapIndexedNotNull { index, line ->
218− index.takeIf { line.contains(q, ignoreCase = !caseSensitive) }
232+ val matched = if (regex) {
233+ pattern?.matcher(line)?.find() == true
234+ } else {
235+ line.contains(q, ignoreCase = !caseSensitive)
236+ }
237+ if (matched) index else null
219238 }
220239 }
221240 }
@@ -280,6 +299,14 @@ fun FileViewScreen(
280299 val ok = runCatchingCancellable {
281300 withContext(Dispatchers.IO) {
282301 val dir = java.io.File(context.cacheDir, "external").apply { mkdirs() }
302+ // Evict stale cache files handed to external apps so repeated
303+ // opens of large files don't accumulate forever. A TTL (vs.
304+ // wiping the dir) preserves files an external app may still be
305+ // reading from a recent open. Pruned before writing the new one.
306+ val cutoff = System.currentTimeMillis() - NetworkConfig.externalCacheTtlMs
307+ dir.listFiles()?.forEach { f ->
308+ runCatching { if (f.lastModified() < cutoff) f.delete() }
309+ }
283310 val file = java.io.File(dir, filename.ifBlank { "file.txt" })
284311 file.writeText(rawText)
285312 val authority = context.packageName + ".fileprovider"
@@ -365,6 +392,8 @@ fun FileViewScreen(
365392 onRetry = { vm.reload() },
366393 caseSensitive = caseSensitive,
367394 onToggleCaseSensitive = { caseSensitive = !caseSensitive },
395+ regex = regex,
396+ onToggleRegex = { regex = !regex },
368397 onShareFull = {
369398 val send = Intent(Intent.ACTION_SEND).apply {
370399 type = "text/plain"
@@ -658,6 +687,8 @@ private fun BoxScope.FileViewStateContent(
658687 onShareFull: () -> Unit,
659688 caseSensitive: Boolean,
660689 onToggleCaseSensitive: () -> Unit,
690+ regex: Boolean,
691+ onToggleRegex: () -> Unit,
661692) {
662693 when {
663694 // Full-screen skeleton only on the initial load (no content yet). A reload keeps the
@@ -767,6 +798,8 @@ private fun BoxScope.FileViewStateContent(
767798 onShareFull = onShareFull,
768799 caseSensitive = caseSensitive,
769800 onToggleCaseSensitive = onToggleCaseSensitive,
801+ regex = regex,
802+ onToggleRegex = onToggleRegex,
770803 )
771804 }
772805}
@@ -796,6 +829,8 @@ private fun BoxScope.FileViewContentBody(
796829 onShareFull: () -> Unit,
797830 caseSensitive: Boolean,
798831 onToggleCaseSensitive: () -> Unit,
832+ regex: Boolean,
833+ onToggleRegex: () -> Unit,
799834) {
800835 // Overlay bar: stacks the diff/raw chips and the find bar at the top so both stay
801836 // reachable while scrolling. Each contributes a top inset so the content below
@@ -856,6 +891,8 @@ private fun BoxScope.FileViewContentBody(
856891 onClose = onCloseFind,
857892 caseSensitive = caseSensitive,
858893 onToggleCaseSensitive = onToggleCaseSensitive,
894+ regex = regex,
895+ onToggleRegex = onToggleRegex,
859896 )
860897 }
861898 }
@@ -908,9 +945,12 @@ private fun FindBar(
908945 onClose: () -> Unit,
909946 caseSensitive: Boolean,
910947 onToggleCaseSensitive: () -> Unit,
948+ regex: Boolean,
949+ onToggleRegex: () -> Unit,
911950) {
912951 val keyboardController = LocalSoftwareKeyboardController.current
913952 val matchCaseLabel = stringResource(R.string.match_case)
953+ val regexLabel = stringResource(R.string.regex_search)
914954 Row(
915955 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
916956 verticalAlignment = Alignment.CenterVertically,
@@ -934,6 +974,15 @@ private fun FindBar(
934974 modifier = Modifier.semantics { contentDescription = matchCaseLabel },
935975 )
936976 Spacer(Modifier.size(8.dp))
977+ // Regex toggle (".*"): when on, the query is a Java Pattern. Mirrors
978+ // editor find bars; the label stays compact to match the "Aa" chip.
979+ FilterChip(
980+ selected = regex,
981+ onClick = onToggleRegex,
982+ label = { Text(".*", style = MaterialTheme.typography.labelSmall) },
983+ modifier = Modifier.semantics { contentDescription = regexLabel },
984+ )
985+ Spacer(Modifier.size(8.dp))
937986 val countText = if (query.isBlank()) ""
938987 else if (matchCount == 0) stringResource(R.string.no_matches_in_file)
939988 else stringResource(R.string.match_count, matchPos + 1, matchCount)
app/src/main/java/soy/iko/opencode/ui/file/TwoPaneFileBrowser.kt+16
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize
88import androidx.compose.foundation.layout.widthIn
99import androidx.compose.material.icons.Icons
1010import androidx.compose.material.icons.filled.Description
11+import androidx.compose.material.icons.filled.Refresh
1112import androidx.compose.material3.VerticalDivider
1213import androidx.compose.runtime.Composable
1314import androidx.compose.runtime.LaunchedEffect
@@ -15,16 +16,21 @@ import androidx.compose.runtime.getValue
1516import androidx.compose.runtime.key
1617import androidx.compose.runtime.mutableStateOf
1718import androidx.compose.runtime.remember
19+import androidx.compose.runtime.rememberCoroutineScope
1820import androidx.compose.runtime.saveable.rememberSaveable
1921import androidx.compose.runtime.setValue
2022import androidx.compose.ui.Modifier
2123import androidx.compose.ui.res.stringResource
2224import androidx.compose.ui.unit.dp
2325import androidx.lifecycle.compose.collectAsStateWithLifecycle
26+import androidx.lifecycle.viewmodel.compose.viewModel
27+import kotlinx.coroutines.launch
2428import soy.iko.opencode.R
2529import soy.iko.opencode.data.network.NetworkConfig
2630import soy.iko.opencode.di.AppContainer
2731import soy.iko.opencode.ui.components.EmptyState
32+import soy.iko.opencode.ui.vmFactory
33+import soy.iko.opencode.util.runCatchingCancellable
2834
2935/**
3036 * Master–detail layout for the file browser on wide screens (≥ 840dp): the directory listing
@@ -77,10 +83,20 @@ fun TwoPaneFileBrowser(
7783 Box(modifier = Modifier.weight(NetworkConfig.twoPaneRightWeight).fillMaxSize()) {
7884 val path = selected
7985 if (path == null) {
86+ // Share the FileBrowserScreen's VM (same ViewModelStoreOwner) so the empty
87+ // detail's "Refresh" action re-fetches the same tree the master pane shows,
88+ // rather than leaving the pane inert with no recovery affordance.
89+ val browserVm: FileBrowserViewModel = viewModel(
90+ factory = vmFactory { FileBrowserViewModel(container) }
91+ )
92+ val scope = rememberCoroutineScope()
8093 EmptyState(
8194 icon = Icons.Filled.Description,
8295 title = stringResource(R.string.empty_file_detail_title),
8396 description = stringResource(R.string.empty_file_detail_desc),
97+ actionIcon = Icons.Filled.Refresh,
98+ actionLabel = stringResource(R.string.refresh),
99+ onAction = { scope.launch { runCatchingCancellable { browserVm.refresh() } } },
84100 modifier = Modifier.fillMaxSize(),
85101 )
86102 } else {
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+18−2
@@ -20,6 +20,7 @@ import androidx.compose.foundation.text.KeyboardActions
2020import androidx.compose.foundation.text.KeyboardOptions
2121import androidx.compose.foundation.verticalScroll
2222import androidx.compose.material.icons.Icons
23+import androidx.compose.material.icons.filled.Refresh
2324import androidx.compose.material.icons.automirrored.filled.ArrowBack
2425import androidx.compose.material.icons.filled.Delete
2526import androidx.compose.material.icons.filled.ExpandLess
@@ -613,14 +614,19 @@ private fun DiscoverySection(onPick: (String) -> Unit, initiallyExpanded: Boolea
613614 }
614615 if (expanded) {
615616 val discovery = remember { NsdDiscovery(context) }
616− val servers by remember(discovery) { discovery.discover() }
617+ // scanNonce drives a manual re-scan: bumping it recreates the discovery flow
618+ // (cancelling the prior collection / Nsd listener) and restarts the no-result
619+ // timeout, so a user on a flaky multicast network can retry without collapsing
620+ // and re-expanding the section.
621+ var scanNonce by remember { mutableStateOf(0) }
622+ val servers by remember(discovery, scanNonce) { discovery.discover() }
617623 .collectAsStateWithLifecycle(initialValue = emptyList())
618624 // mDNS discovery is a continuous flow that never emits a terminal "no results"
619625 // state — on a network with no opencode servers the spinner would spin forever.
620626 // After the timeout, swap the spinner for a "no servers found" message. Discovery
621627 // keeps running, so a late server can still replace the message when it arrives.
622628 var searchTimedOut by remember { mutableStateOf(false) }
623− LaunchedEffect(Unit) {
629+ LaunchedEffect(scanNonce) {
624630 searchTimedOut = false
625631 kotlinx.coroutines.delay(NetworkConfig.nsdDiscoveryNoResultTimeoutMs)
626632 searchTimedOut = true
@@ -664,6 +670,16 @@ private fun DiscoverySection(onPick: (String) -> Unit, initiallyExpanded: Boolea
664670 )
665671 }
666672 }
673+ // Manual re-scan affordance: shown after a timeout (or while results are present)
674+ // so a user on flaky multicast can retry discovery without leaving the section.
675+ TextButton(
676+ onClick = { scanNonce++ },
677+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
678+ modifier = Modifier.padding(top = 4.dp),
679+ ) {
680+ Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(18.dp))
681+ Text(stringResource(R.string.scan_again), modifier = Modifier.padding(start = 8.dp))
682+ }
667683 }
668684 }
669685}
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+5−2
@@ -111,8 +111,11 @@ fun ServerListScreen(
111111 // The active server's real SSE state, so its card reflects a dropped/failed stream instead of
112112 // always reading "Connected" (which is misleading while reconnecting or after an auth failure).
113113 val activeSseState by (activeConnection?.events?.state
114− ?: kotlinx.coroutines.flow.flowOf(soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Connected))
115− .collectAsStateWithLifecycle(initialValue = soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Connected)
114+ ?: kotlinx.coroutines.flow.flowOf(soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Disconnected))
115+ // Initial value Disconnected (not Connected) so the active card doesn't briefly show
116+ // "Connected" before the real SSE state emits on cold start — a neutral label is less
117+ // misleading than a falsely-healthy one while the first emission is still pending.
118+ .collectAsStateWithLifecycle(initialValue = soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Disconnected)
116119 val haptics = LocalHapticFeedback.current
117120 val snackbar = remember { SnackbarHostState() }
118121 // Separate host for transient error messages (connect/probe failures, QR import results)
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+42−17
@@ -144,7 +144,7 @@ import androidx.compose.animation.core.rememberInfiniteTransition
144144import androidx.compose.animation.core.tween
145145import androidx.compose.foundation.shape.RoundedCornerShape
146146
147−@OptIn(ExperimentalMaterial3Api::class)
147+@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
148148@Composable
149149fun SessionListScreen(
150150 container: AppContainer,
@@ -603,9 +603,9 @@ fun SessionListScreen(
603603 AlertDialog(
604604 onDismissRequest = { showBulkDelete = false },
605605 title = { Text(stringResource(R.string.bulk_delete_title)) },
606− // Bulk delete is NOT undoable (unlike single delete which offers Undo), so the
607− // warning must be explicit — matching the single-delete dialog's irreversibility text
608− // rather than the bare "%d selected" count.
606+ // Bulk delete is destructive; the warning stays explicit even though an Undo
607+ // snackbar now offers a recovery window (mirroring single delete). Matching the
608+ // single-delete dialog's irreversibility text rather than the bare "%d selected".
609609 text = { Text(stringResource(R.string.bulk_delete_text, count)) },
610610 confirmButton = {
611611 TextButton(onClick = {
@@ -952,14 +952,13 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
952952 contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = NetworkConfig.listFabInsetDp.dp),
953953 verticalArrangement = Arrangement.spacedBy(12.dp),
954954 ) {
955− // Render each (header, nodes) segment: the header as a section label
956− // followed by the session cards in that section. (Sticky headers would
957− // require LazyListScope.stickyHeader, which is not available in the
958− // current Compose version — the segments structure keeps the grouping
959− // ready for a sticky upgrade when the API lands.)
955+ // Render each (header, nodes) segment: the header as a sticky section
956+ // label (pinned while its segment scrolls past) followed by the session
957+ // cards in that section. stickyHeader keeps "Today"/"Yesterday" visible
958+ // so a user scrolling a long list keeps the date context.
960959 for ((header, segmentNodes) in groupedSegments) {
961960 if (header != null) {
962− item(key = header.key, contentType = "header") {
961+ stickyHeader(key = header.key, contentType = "header") {
963962 DateGroupHeader(header.label)
964963 }
965964 }
@@ -1729,6 +1728,8 @@ private fun SessionActionUndoEffect(vm: SessionListViewModel, snackbar: Snackbar
17291728 SessionActionKind.UNARCHIVED -> unarchived
17301729 SessionActionKind.PINNED -> pinned
17311730 SessionActionKind.UNPINNED -> unpinned
1731+ // Single deletes use the dedicated delete-undo effect, not this path.
1732+ SessionActionKind.DELETED -> return@collectLatest
17321733 }
17331734 val result = snackbar.showSnackbar(
17341735 message = message,
@@ -1754,20 +1755,44 @@ private fun BulkSessionActionUndoEffect(vm: SessionListViewModel, snackbar: Snac
17541755 val undoLabel = stringResource(R.string.undo)
17551756 val archived = stringResource(R.string.bulk_archived)
17561757 val unarchived = stringResource(R.string.bulk_unarchived)
1758+ val deleted = stringResource(R.string.bulk_deleted)
17571759 LaunchedEffect(Unit) {
17581760 vm.bulkSessionActionEvents.collectLatest { event ->
17591761 val message = when (event.kind) {
17601762 SessionActionKind.ARCHIVED -> archived.format(event.ids.size)
17611763 SessionActionKind.UNARCHIVED -> unarchived.format(event.ids.size)
17621764 SessionActionKind.PINNED, SessionActionKind.UNPINNED -> return@collectLatest
1765+ SessionActionKind.DELETED -> deleted.format(event.ids.size)
17631766 }
1764− val result = snackbar.showSnackbar(
1765− message = message,
1766− actionLabel = undoLabel,
1767− duration = androidx.compose.material3.SnackbarDuration.Long,
1768− )
1769− if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) {
1770− vm.undoBulkAction(event)
1767+ // Bulk delete uses a timed Indefinite snackbar aligned to the undo window, so the
1768+ // Undo button stays live for exactly the window in which the deferred REST delete
1769+ // can still be cancelled (mirroring the single-delete undo effect). Other bulk
1770+ // actions are reversible flags and use the standard Long duration.
1771+ if (event.kind == SessionActionKind.DELETED) {
1772+ coroutineScope {
1773+ val dismisser = launch {
1774+ delay(NetworkConfig.undoDeleteDelayMs)
1775+ snackbar.currentSnackbarData?.dismiss()
1776+ }
1777+ val result = snackbar.showSnackbar(
1778+ message = message,
1779+ actionLabel = undoLabel,
1780+ duration = androidx.compose.material3.SnackbarDuration.Indefinite,
1781+ )
1782+ dismisser.cancel()
1783+ if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) {
1784+ vm.undoBulkAction(event)
1785+ }
1786+ }
1787+ } else {
1788+ val result = snackbar.showSnackbar(
1789+ message = message,
1790+ actionLabel = undoLabel,
1791+ duration = androidx.compose.material3.SnackbarDuration.Long,
1792+ )
1793+ if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) {
1794+ vm.undoBulkAction(event)
1795+ }
17711796 }
17721797 }
17731798 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+11−1
@@ -48,7 +48,7 @@ enum class SessionSortMode { RECENT, TITLE }
4848
4949/** A pin/archive action the user just took, surfaced so the UI can confirm it with an
5050 * Undo snackbar. [undoable] via [SessionListViewModel.undoSessionAction]. */
51−enum class SessionActionKind { PINNED, UNPINNED, ARCHIVED, UNARCHIVED }
51+enum class SessionActionKind { PINNED, UNPINNED, ARCHIVED, UNARCHIVED, DELETED }
5252
5353data class SessionActionEvent(val sessionId: String, val kind: SessionActionKind)
5454
@@ -379,6 +379,10 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
379379 SessionActionKind.UNPINNED -> applyPin(event.sessionId, true)
380380 SessionActionKind.ARCHIVED -> applyArchive(event.sessionId, false)
381381 SessionActionKind.UNARCHIVED -> applyArchive(event.sessionId, true)
382+ // Single deletes use the dedicated [undoDelete] path (they carry a deferred
383+ // REST timer, not a reversible flag); this branch is unreachable but kept for
384+ // exhaustiveness so a future emit can't crash.
385+ SessionActionKind.DELETED -> {}
382386 }
383387 }
384388
@@ -874,6 +878,9 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
874878 SessionActionKind.UNARCHIVED -> event.ids.forEach { applyArchive(it, true) }
875879 SessionActionKind.PINNED -> event.ids.forEach { applyPin(it, false) }
876880 SessionActionKind.UNPINNED -> event.ids.forEach { applyPin(it, true) }
881+ // Bulk delete undo: each id has its own deferred REST timer held in
882+ // [pendingDeletes]; cancelling them via [undoDelete] restores the rows.
883+ SessionActionKind.DELETED -> event.ids.forEach { undoDelete(it) }
877884 }
878885 }
879886
@@ -927,6 +934,9 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
927934 },
928935 )
929936 }
937+ // Surface a single Undo snackbar for the batch (mirroring bulk archive/pin), so a
938+ // bulk delete is reversible within the undo window instead of being final on tap.
939+ _bulkSessionActionEvents.tryEmit(BulkSessionActionEvent(ids, SessionActionKind.DELETED))
930940 }
931941
932942 fun setSortMode(mode: SessionSortMode, persist: Boolean = true) {
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+6
@@ -126,6 +126,7 @@ fun SettingsScreen(
126126 onOpenDiagnostics: () -> Unit,
127127 onOpenUsage: () -> Unit = {},
128128 onOpenMcp: () -> Unit = {},
129+ onEditProfile: ((String) -> Unit)? = null,
129130) {
130131 val scope = rememberCoroutineScope()
131132 val snackbar = remember { SnackbarHostState() }
@@ -308,6 +309,11 @@ fun SettingsScreen(
308309 state = connectionState,
309310 isOnline = isOnline,
310311 onRetry = { container.activeConnection.value?.events?.triggerReconnect() },
312+ // Offer "Edit credentials" on an auth failure so a user landing in Settings
313+ // during a 401/403 can fix the offending profile directly instead of only
314+ // seeing "Retry now" (which would re-fail with the same bad credentials).
315+ // Mirrors the recovery affordance ChatScreen and SessionListScreen surface.
316+ onEditCredentials = onEditProfile?.let { cb -> activeProfile?.id?.let { id -> { cb(id) } } },
311317 reconnectAttempts = reconnectAttempts,
312318 )
313319 val s = settings
app/src/main/res/values-es/strings.xml+5
@@ -106,6 +106,7 @@
106106 <string name="discover_on_network">Descubrir en la red</string>
107107 <string name="searching_network">Buscando en la red local…</string>
108108 <string name="no_servers_found">No se encontraron servidores de opencode en la red local.</string>
109+ <string name="scan_again">Buscar de nuevo</string>
109110 <string name="testing_credentials">Probando…</string>
110111 <string name="credentials_ok">Credenciales aceptadas.</string>
111112 <string name="credentials_rejected">Credenciales rechazadas.</string>
@@ -305,6 +306,8 @@
305306 </plurals>
306307 <string name="notif_permission_title">Se necesita aprobación en «%1$s»</string>
307308 <string name="notif_permission_fallback">Una herramienta quiere ejecutarse.</string>
309+ <string name="notif_permission_auto_rejected_title">Ejecución detenida en «%1$s»</string>
310+ <string name="notif_permission_auto_rejected_text">Un aviso de permiso expiró y se rechazó automáticamente.</string>
308311 <string name="notif_error_title">La ejecución falló</string>
309312 <string name="notif_error_text">opencode encontró un error en «%1$s».</string>
310313 <string name="notif_action_allow_once">Permitir una vez</string>
@@ -601,6 +604,7 @@
601604 <string name="session_running_badge">Agente en ejecución</string>
602605 <string name="bulk_archived">%1$d sesiones archivadas</string>
603606 <string name="bulk_unarchived">%1$d sesiones desarchivadas</string>
607+ <string name="bulk_deleted">%1$d sesiones eliminadas</string>
604608 <string name="bulk_marked_read">%1$d sesiones marcadas como leídas</string>
605609 <string name="sort_active">Orden activo</string>
606610 <string name="sort_descending">Descendente</string>
@@ -612,6 +616,7 @@
612616 <string name="go_to_line">Ir a la línea</string>
613617 <string name="go_to_line_hint">Número de línea (1–%1$d)</string>
614618 <string name="match_case">Coincidir mayúsculas</string>
619+ <string name="regex_search">Buscar con expresión regular</string>
615620 <string name="share_full_file">Compartir archivo completo</string>
616621
617622 <!-- Settings backup/restore -->
app/src/main/res/values/strings.xml+5
@@ -115,6 +115,7 @@
115115 <string name="discover_on_network">Discover on network</string>
116116 <string name="searching_network">Searching the local network…</string>
117117 <string name="no_servers_found">No opencode servers found on the local network.</string>
118+ <string name="scan_again">Scan again</string>
118119 <string name="testing_credentials">Testing…</string>
119120 <string name="credentials_ok">Credentials accepted.</string>
120121 <string name="credentials_rejected">Credentials rejected.</string>
@@ -314,6 +315,8 @@
314315 </plurals>
315316 <string name="notif_permission_title">Approval needed in “%1$s”</string>
316317 <string name="notif_permission_fallback">A tool wants to run.</string>
318+ <string name="notif_permission_auto_rejected_title">Run stopped in “%1$s”</string>
319+ <string name="notif_permission_auto_rejected_text">A permission prompt timed out and was auto-rejected.</string>
317320 <string name="notif_error_title">Run failed</string>
318321 <string name="notif_error_text">opencode hit an error in “%1$s”.</string>
319322 <string name="notif_action_allow_once">Allow once</string>
@@ -624,6 +627,7 @@
624627 <string name="session_reverted_badge">Reverted</string>
625628 <string name="bulk_archived">%1$d sessions archived</string>
626629 <string name="bulk_unarchived">%1$d sessions unarchived</string>
630+ <string name="bulk_deleted">%1$d sessions deleted</string>
627631 <string name="bulk_marked_read">%1$d sessions marked as read</string>
628632 <string name="sort_active">Active sort</string>
629633 <string name="sort_descending">Descending</string>
@@ -635,6 +639,7 @@
635639 <string name="go_to_line">Go to line</string>
636640 <string name="go_to_line_hint">Line number (1–%1$d)</string>
637641 <string name="match_case">Match case</string>
642+ <string name="regex_search">Regular expression search</string>
638643 <string name="share_full_file">Share full file</string>
639644
640645 <!-- Settings backup/restore -->