git.iko.soy/opencode-android
on master help sign in
Implement 16 UI/UX improvements across the app
4cdfbdaiko
app/detekt-baseline.xml+2−2
@@ -18,8 +18,8 @@
18 <ID>CyclomaticComplexMethod:MainActivity.kt$MainActivity$private fun handleIntent(intent: Intent?)</ID>
18 <ID>CyclomaticComplexMethod:MainActivity.kt$MainActivity$private fun handleIntent(intent: Intent?)</ID>
19 <ID>CyclomaticComplexMethod:OpencodeApp.kt$@Composable fun OpencodeApp(container: AppContainer)</ID>
19 <ID>CyclomaticComplexMethod:OpencodeApp.kt$@Composable fun OpencodeApp(container: AppContainer)</ID>
20 <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>
20 <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>
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 = {}, )</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>
23 <ID>LargeClass:AppContainer.kt$AppContainer</ID>
23 <ID>LargeClass:AppContainer.kt$AppContainer</ID>
24 <ID>LargeClass:SessionListViewModel.kt$SessionListViewModel : ViewModel</ID>
24 <ID>LargeClass:SessionListViewModel.kt$SessionListViewModel : ViewModel</ID>
25 <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
25 <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+5
@@ -57,6 +57,11 @@ object NetworkConfig {
57 /** How long cached catalog responses (providers/agents/commands) are considered fresh. */
57 /** How long cached catalog responses (providers/agents/commands) are considered fresh. */
58 const val catalogCacheTtlMs = 60_000L
58 const val catalogCacheTtlMs = 60_000L
59
59
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+
60 // --- Session list (SessionListViewModel) ---
65 // --- Session list (SessionListViewModel) ---
61
66
62 /** Max sessions to fetch previews for in one batch. */
67 /** 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 {
249 .onFailure { Log.w(TAG, "Failed to post permission summary notification", it) }
249 .onFailure { Log.w(TAG, "Failed to post permission summary notification", it) }
250 }
250 }
251
251
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+
252 /** Post a notification when a background run fails. */
279 /** Post a notification when a background run fails. */
253 @SuppressLint("MissingPermission")
280 @SuppressLint("MissingPermission")
254 fun postError(context: Context, sessionId: String, title: String, profileId: String? = null) {
281 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
25import androidx.compose.ui.input.key.Key
25import androidx.compose.ui.input.key.Key
26import androidx.compose.ui.input.key.KeyEventType
26import androidx.compose.ui.input.key.KeyEventType
27import androidx.compose.ui.input.key.isCtrlPressed
27import androidx.compose.ui.input.key.isCtrlPressed
28+import androidx.compose.ui.input.key.isAltPressed
28import androidx.compose.ui.input.key.key
29import androidx.compose.ui.input.key.key
29import androidx.compose.ui.input.key.onPreviewKeyEvent
30import androidx.compose.ui.input.key.onPreviewKeyEvent
30import androidx.compose.ui.input.key.type
31import androidx.compose.ui.input.key.type
@@ -51,12 +52,14 @@ import soy.iko.opencode.ui.file.TwoPaneFileBrowser
51import soy.iko.opencode.ui.server.ServerEditScreen
52import soy.iko.opencode.ui.server.ServerEditScreen
52import soy.iko.opencode.ui.server.ServerListScreen
53import soy.iko.opencode.ui.server.ServerListScreen
53import soy.iko.opencode.ui.search.GlobalSearchScreen
54import soy.iko.opencode.ui.search.GlobalSearchScreen
55+import androidx.compose.ui.platform.LocalHapticFeedback
54import soy.iko.opencode.ui.components.LargeScreenNavRail
56import soy.iko.opencode.ui.components.LargeScreenNavRail
55import soy.iko.opencode.ui.components.CompactNavBar
57import soy.iko.opencode.ui.components.CompactNavBar
56import soy.iko.opencode.ui.components.LocalChatTextScale
58import soy.iko.opencode.ui.components.LocalChatTextScale
57import soy.iko.opencode.ui.components.LocalCodeWrap
59import soy.iko.opencode.ui.components.LocalCodeWrap
58import soy.iko.opencode.ui.components.LocalReducedMotion
60import soy.iko.opencode.ui.components.LocalReducedMotion
59import soy.iko.opencode.ui.components.isReducedMotion
61import soy.iko.opencode.ui.components.isReducedMotion
62+import soy.iko.opencode.ui.components.rememberGatedHaptics
60import soy.iko.opencode.ui.session.SessionListScreen
63import soy.iko.opencode.ui.session.SessionListScreen
61import soy.iko.opencode.ui.session.TwoPaneSessionChat
64import soy.iko.opencode.ui.session.TwoPaneSessionChat
62import soy.iko.opencode.ui.settings.DiagnosticsScreen
65import soy.iko.opencode.ui.settings.DiagnosticsScreen
@@ -81,6 +84,11 @@ fun OpencodeApp(container: AppContainer) {
81 // renderers honor the user's text-size and code-wrap choices everywhere.
84 // renderers honor the user's text-size and code-wrap choices everywhere.
82 val chatTextScale by container.settingsStore.chatTextScale.collectAsStateWithLifecycle(initialValue = 1f)
85 val chatTextScale by container.settingsStore.chatTextScale.collectAsStateWithLifecycle(initialValue = 1f)
83 val codeWrap by container.settingsStore.codeWrap.collectAsStateWithLifecycle(initialValue = false)
86 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)
84
92
85 // The foreground service that holds priority while any run is active is driven from the
93 // The foreground service that holds priority while any run is active is driven from the
86 // process-lived AppContainer scope (see AppContainer.observeRunForegroundService), not
94 // process-lived AppContainer scope (see AppContainer.observeRunForegroundService), not
@@ -176,6 +184,7 @@ fun OpencodeApp(container: AppContainer) {
176 LocalChatTextScale provides clampedChatTextScale,
184 LocalChatTextScale provides clampedChatTextScale,
177 LocalCodeWrap provides codeWrap,
185 LocalCodeWrap provides codeWrap,
178 LocalReducedMotion provides reducedMotion,
186 LocalReducedMotion provides reducedMotion,
187+ LocalHapticFeedback provides rememberGatedHaptics(hapticsEnabled),
179 ) {
188 ) {
180 // Slide direction for push/pop transitions, mirrored in RTL so the animation reads
189 // Slide direction for push/pop transitions, mirrored in RTL so the animation reads
181 // naturally instead of "going the wrong way". +1 = LTR (push from right), -1 = RTL.
190 // naturally instead of "going the wrong way". +1 = LTR (push from right), -1 = RTL.
@@ -209,6 +218,21 @@ fun OpencodeApp(container: AppContainer) {
209 // Esc stop, Ctrl+F find) are handled in each screen's own onPreviewKeyEvent and take
218 // Esc stop, Ctrl+F find) are handled in each screen's own onPreviewKeyEvent and take
210 // precedence (they're closer to the focused composable in the tree).
219 // precedence (they're closer to the focused composable in the tree).
211 if (ev.type != KeyEventType.KeyDown || !ev.isCtrlPressed) {
220 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+ }
212 return@onPreviewKeyEvent false
236 return@onPreviewKeyEvent false
213 }
237 }
214 val route = when (ev.key) {
238 val route = when (ev.key) {
@@ -504,6 +528,7 @@ fun OpencodeApp(container: AppContainer) {
504 onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
528 onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
505 onOpenUsage = { navController.navigate(Routes.USAGE) },
529 onOpenUsage = { navController.navigate(Routes.USAGE) },
506 onOpenMcp = { navController.navigate(Routes.MCP) },
530 onOpenMcp = { navController.navigate(Routes.MCP) },
531+ onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
507 )
532 )
508 }
533 }
509
534
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+49−4
@@ -243,6 +243,7 @@ fun ChatScreen(
243 val preferredModelId by container.settingsStore.preferredModelId.collectAsStateWithLifecycle(initialValue = "")
243 val preferredModelId by container.settingsStore.preferredModelId.collectAsStateWithLifecycle(initialValue = "")
244 val recentModelEntries by container.recentModelsStore.entries.collectAsStateWithLifecycle()
244 val recentModelEntries by container.recentModelsStore.entries.collectAsStateWithLifecycle()
245 val compactSpacing by container.settingsStore.compactMessageSpacing.collectAsStateWithLifecycle(initialValue = false)
245 val compactSpacing by container.settingsStore.compactMessageSpacing.collectAsStateWithLifecycle(initialValue = false)
246+ val notifPermission by container.settingsStore.notifPermission.collectAsStateWithLifecycle(initialValue = true)
246 val isOnline by container.isOnline.collectAsStateWithLifecycle()
247 val isOnline by container.isOnline.collectAsStateWithLifecycle()
247 val haptics = LocalHapticFeedback.current
248 val haptics = LocalHapticFeedback.current
248 val layoutDirection = LocalLayoutDirection.current
249 val layoutDirection = LocalLayoutDirection.current
@@ -657,11 +658,14 @@ fun ChatScreen(
657 // auto-reconnect re-seed is slow or fails. Shows a brief spinner as
658 // auto-reconnect re-seed is slow or fails. Shows a brief spinner as
658 // immediate tap feedback — the SSE reconnect may not visibly change
659 // immediate tap feedback — the SSE reconnect may not visibly change
659 // the connection state when already Connected, so without it the tap
660 // 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.
661 val refreshLabel = stringResource(R.string.refresh)
665 val refreshLabel = stringResource(R.string.refresh)
662 IconButton(
666 IconButton(
663 onClick = { vm.refreshMessages() },
667 onClick = { vm.refreshMessages() },
664− enabled = activeConnection != null && !refreshing,
668+ enabled = activeConnection != null && !refreshing && !running,
665 ) {
669 ) {
666 if (refreshing) {
670 if (refreshing) {
667 CircularProgressIndicator(
671 CircularProgressIndicator(
@@ -1302,7 +1306,12 @@ fun ChatScreen(
1302 // error/loading states reachable only via a button tap.
1306 // error/loading states reachable only via a button tap.
1303 PullToRefreshBox(
1307 PullToRefreshBox(
1304 isRefreshing = refreshing,
1308 isRefreshing = refreshing,
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.
1305− onRefresh = { vm.refreshMessages() },
1314+ onRefresh = { if (!running) vm.refreshMessages() },
1306 // Cap width and center on large screens for readability — full-width
1315 // Cap width and center on large screens for readability — full-width
1307 // bubbles stretch edge-to-edge on tablets, hurting line-length comfort.
1316 // bubbles stretch edge-to-edge on tablets, hurting line-length comfort.
1308 modifier = Modifier.fillMaxSize().wrapContentWidth(Alignment.CenterHorizontally)
1317 modifier = Modifier.fillMaxSize().wrapContentWidth(Alignment.CenterHorizontally)
@@ -1462,6 +1471,8 @@ fun ChatScreen(
1462 // callback gives a haptic without triggering reply — so the user gets a
1471 // callback gives a haptic without triggering reply — so the user gets a
1463 // perceptible "can't quote this" signal instead of a silently dead gesture.
1472 // perceptible "can't quote this" signal instead of a silently dead gesture.
1464 val cantQuoteMsg = stringResource(R.string.cannot_quote_this)
1473 val cantQuoteMsg = stringResource(R.string.cannot_quote_this)
1474+ val undoLabel = stringResource(R.string.undo)
1475+ val quoteReplyLabel = stringResource(R.string.quote_reply_banner)
1465 val swipeState = rememberSwipeToDismissBoxState(
1476 val swipeState = rememberSwipeToDismissBoxState(
1466 confirmValueChange = { value ->
1477 confirmValueChange = { value ->
1467 val replyValue = if (layoutDirection == LayoutDirection.Rtl) {
1478 val replyValue = if (layoutDirection == LayoutDirection.Rtl) {
@@ -1474,6 +1485,18 @@ fun ChatScreen(
1474 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1485 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1475 vm.quoteReply(quoteText)
1486 vm.quoteReply(quoteText)
1476 runCatching { inputFocusRequester.requestFocus() }
1487 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+ }
1477 } else {
1500 } else {
1478 // Non-text message: give a longer haptic so the user
1501 // Non-text message: give a longer haptic so the user
1479 // feels the gesture was received but can't quote it,
1502 // feels the gesture was received but can't quote it,
@@ -1723,7 +1746,16 @@ fun ChatScreen(
1723 newContentCount,
1746 newContentCount,
1724 newContentCount,
1747 newContentCount,
1725 )
1748 )
1749+ Badge(
1750+ modifier = Modifier.semantics {
1726− Badge(modifier = Modifier.semantics { contentDescription = newContentLabel }) {
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+ ) {
1727 Text(
1759 Text(
1728 if (newContentCount > 99) "99+" else newContentCount.toString(),
1760 if (newContentCount > 99) "99+" else newContentCount.toString(),
1729 modifier = Modifier.semantics { invisibleToUser() },
1761 modifier = Modifier.semantics { invisibleToUser() },
@@ -2043,6 +2075,19 @@ fun ChatScreen(
2043 position = permissionProgress.position,
2075 position = permissionProgress.position,
2044 total = permissionProgress.total,
2076 total = permissionProgress.total,
2045 onRespond = { response -> vm.respondPermission(permission, response) },
2077 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+ },
2046 )
2091 )
2047 }
2092 }
2048
2093
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+4
@@ -59,6 +59,7 @@ fun PermissionDialog(
59 onRespond: (PermissionResponse) -> Unit,
59 onRespond: (PermissionResponse) -> Unit,
60 position: Int = 0,
60 position: Int = 0,
61 total: Int = 0,
61 total: Int = 0,
62+ onAutoReject: () -> Unit = {},
62) {
63) {
63 val haptics = LocalHapticFeedback.current
64 val haptics = LocalHapticFeedback.current
64 // Guard against double-respond: back press + button tap, or rapid double-tap,
65 // Guard against double-respond: back press + button tap, or rapid double-tap,
@@ -96,6 +97,9 @@ fun PermissionDialog(
96 }
97 }
97 // Haptic on auto-reject so a walked-away user is signaled that the decision was made.
98 // Haptic on auto-reject so a walked-away user is signaled that the decision was made.
98 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
99 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()
99 respond(PermissionResponse.REJECT)
103 respond(PermissionResponse.REJECT)
100 }
104 }
101 val showReminder = elapsedMs >= NetworkConfig.permissionReminderThresholdMs
105 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
17import androidx.compose.runtime.Composable
17import androidx.compose.runtime.Composable
18import androidx.compose.ui.Modifier
18import androidx.compose.ui.Modifier
19import androidx.compose.ui.graphics.vector.ImageVector
19import androidx.compose.ui.graphics.vector.ImageVector
20+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
21+import androidx.compose.ui.platform.LocalHapticFeedback
20import androidx.compose.ui.res.stringResource
22import androidx.compose.ui.res.stringResource
21import soy.iko.opencode.R
23import soy.iko.opencode.R
22import soy.iko.opencode.ui.Routes
24import soy.iko.opencode.ui.Routes
@@ -62,6 +64,7 @@ fun CompactNavBar(
62 runActive: Boolean = false,
64 runActive: Boolean = false,
63) {
65) {
64 NavigationBar(modifier = modifier.fillMaxWidth()) {
66 NavigationBar(modifier = modifier.fillMaxWidth()) {
67+ val haptics = LocalHapticFeedback.current
65 BOTTOM_DESTINATIONS.forEach { dest ->
68 BOTTOM_DESTINATIONS.forEach { dest ->
66 val selected = currentRoute == dest.route ||
69 val selected = currentRoute == dest.route ||
67 (dest.route == Routes.SESSIONS && currentRoute != null &&
70 (dest.route == Routes.SESSIONS && currentRoute != null &&
@@ -73,7 +76,13 @@ fun CompactNavBar(
73 NavigationBarItem(
76 NavigationBarItem(
74 selected = selected,
77 selected = selected,
75 enabled = !dest.requiresConnection || connected,
78 enabled = !dest.requiresConnection || connected,
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)
76− onClick = { onNavigate(dest.route) },
84+ onNavigate(dest.route)
85+ },
77 icon = {
86 icon = {
78 if (showUnreadBadge || showRunBadge) {
87 if (showUnreadBadge || showRunBadge) {
79 BadgedBox(
88 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
19import androidx.compose.runtime.Composable
19import androidx.compose.runtime.Composable
20import androidx.compose.ui.Modifier
20import androidx.compose.ui.Modifier
21import androidx.compose.ui.graphics.vector.ImageVector
21import androidx.compose.ui.graphics.vector.ImageVector
22+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
23+import androidx.compose.ui.platform.LocalHapticFeedback
22import androidx.compose.ui.res.stringResource
24import androidx.compose.ui.res.stringResource
23import soy.iko.opencode.R
25import soy.iko.opencode.R
24import soy.iko.opencode.ui.Routes
26import soy.iko.opencode.ui.Routes
@@ -63,6 +65,7 @@ fun LargeScreenNavRail(
63 runActive: Boolean = false,
65 runActive: Boolean = false,
64) {
66) {
65 NavigationRail(modifier = modifier.fillMaxHeight()) {
67 NavigationRail(modifier = modifier.fillMaxHeight()) {
68+ val haptics = LocalHapticFeedback.current
66 RAIL_DESTINATIONS.forEach { dest ->
69 RAIL_DESTINATIONS.forEach { dest ->
67 // Match exact top-level routes, and also highlight the rail item when a detail
70 // Match exact top-level routes, and also highlight the rail item when a detail
68 // route pushed on top of it is active: chat/{id} is a detail of `sessions`, and
71 // route pushed on top of it is active: chat/{id} is a detail of `sessions`, and
@@ -79,7 +82,10 @@ fun LargeScreenNavRail(
79 NavigationRailItem(
82 NavigationRailItem(
80 selected = selected,
83 selected = selected,
81 enabled = !dest.requiresConnection || connected,
84 enabled = !dest.requiresConnection || connected,
85+ onClick = {
86+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
82− onClick = { onNavigate(dest.route) },
87+ onNavigate(dest.route)
88+ },
83 icon = {
89 icon = {
84 if (showUnreadBadge || showRunBadge) {
90 if (showUnreadBadge || showRunBadge) {
85 BadgedBox(
91 BadgedBox(
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+51−2
@@ -203,6 +203,9 @@ fun FileViewScreen(
203 // Find-in-file match-case toggle (default case-insensitive). Persisted so a rotation keeps
203 // Find-in-file match-case toggle (default case-insensitive). Persisted so a rotation keeps
204 // the choice, and applied to the off-thread match scan below.
204 // the choice, and applied to the off-thread match scan below.
205 var caseSensitive by rememberSaveable(path) { mutableStateOf(false) }
205 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) }
206 // Debounce the query so each keystroke doesn't trigger a full-file scan, and run the
209 // Debounce the query so each keystroke doesn't trigger a full-file scan, and run the
207 // scan on Dispatchers.Default so a large file doesn't jank the keyboard while typing.
210 // scan on Dispatchers.Default so a large file doesn't jank the keyboard while typing.
208 var debouncedFind by remember { mutableStateOf("") }
211 var debouncedFind by remember { mutableStateOf("") }
@@ -210,12 +213,28 @@ fun FileViewScreen(
210 val q = findQuery.trim()
213 val q = findQuery.trim()
211 if (q.isEmpty()) debouncedFind = "" else { delay(150); debouncedFind = q }
214 if (q.isEmpty()) debouncedFind = "" else { delay(150); debouncedFind = q }
212 }
215 }
213− val matchIndices by produceState(emptyList<Int>(), lines, debouncedFind, caseSensitive) {
216+ val matchIndices by produceState(emptyList<Int>(), lines, debouncedFind, caseSensitive, regex) {
214 val q = debouncedFind
217 val q = debouncedFind
215 value = if (q.isEmpty()) emptyList()
218 value = if (q.isEmpty()) emptyList()
216 else withContext(Dispatchers.Default) {
219 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+ }
217 lines.mapIndexedNotNull { index, line ->
231 lines.mapIndexedNotNull { index, line ->
232+ val matched = if (regex) {
233+ pattern?.matcher(line)?.find() == true
234+ } else {
218− index.takeIf { line.contains(q, ignoreCase = !caseSensitive) }
235+ line.contains(q, ignoreCase = !caseSensitive)
236+ }
237+ if (matched) index else null
219 }
238 }
220 }
239 }
221 }
240 }
@@ -280,6 +299,14 @@ fun FileViewScreen(
280 val ok = runCatchingCancellable {
299 val ok = runCatchingCancellable {
281 withContext(Dispatchers.IO) {
300 withContext(Dispatchers.IO) {
282 val dir = java.io.File(context.cacheDir, "external").apply { mkdirs() }
301 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+ }
283 val file = java.io.File(dir, filename.ifBlank { "file.txt" })
310 val file = java.io.File(dir, filename.ifBlank { "file.txt" })
284 file.writeText(rawText)
311 file.writeText(rawText)
285 val authority = context.packageName + ".fileprovider"
312 val authority = context.packageName + ".fileprovider"
@@ -365,6 +392,8 @@ fun FileViewScreen(
365 onRetry = { vm.reload() },
392 onRetry = { vm.reload() },
366 caseSensitive = caseSensitive,
393 caseSensitive = caseSensitive,
367 onToggleCaseSensitive = { caseSensitive = !caseSensitive },
394 onToggleCaseSensitive = { caseSensitive = !caseSensitive },
395+ regex = regex,
396+ onToggleRegex = { regex = !regex },
368 onShareFull = {
397 onShareFull = {
369 val send = Intent(Intent.ACTION_SEND).apply {
398 val send = Intent(Intent.ACTION_SEND).apply {
370 type = "text/plain"
399 type = "text/plain"
@@ -658,6 +687,8 @@ private fun BoxScope.FileViewStateContent(
658 onShareFull: () -> Unit,
687 onShareFull: () -> Unit,
659 caseSensitive: Boolean,
688 caseSensitive: Boolean,
660 onToggleCaseSensitive: () -> Unit,
689 onToggleCaseSensitive: () -> Unit,
690+ regex: Boolean,
691+ onToggleRegex: () -> Unit,
661) {
692) {
662 when {
693 when {
663 // Full-screen skeleton only on the initial load (no content yet). A reload keeps the
694 // Full-screen skeleton only on the initial load (no content yet). A reload keeps the
@@ -767,6 +798,8 @@ private fun BoxScope.FileViewStateContent(
767 onShareFull = onShareFull,
798 onShareFull = onShareFull,
768 caseSensitive = caseSensitive,
799 caseSensitive = caseSensitive,
769 onToggleCaseSensitive = onToggleCaseSensitive,
800 onToggleCaseSensitive = onToggleCaseSensitive,
801+ regex = regex,
802+ onToggleRegex = onToggleRegex,
770 )
803 )
771 }
804 }
772}
805}
@@ -796,6 +829,8 @@ private fun BoxScope.FileViewContentBody(
796 onShareFull: () -> Unit,
829 onShareFull: () -> Unit,
797 caseSensitive: Boolean,
830 caseSensitive: Boolean,
798 onToggleCaseSensitive: () -> Unit,
831 onToggleCaseSensitive: () -> Unit,
832+ regex: Boolean,
833+ onToggleRegex: () -> Unit,
799) {
834) {
800 // Overlay bar: stacks the diff/raw chips and the find bar at the top so both stay
835 // Overlay bar: stacks the diff/raw chips and the find bar at the top so both stay
801 // reachable while scrolling. Each contributes a top inset so the content below
836 // reachable while scrolling. Each contributes a top inset so the content below
@@ -856,6 +891,8 @@ private fun BoxScope.FileViewContentBody(
856 onClose = onCloseFind,
891 onClose = onCloseFind,
857 caseSensitive = caseSensitive,
892 caseSensitive = caseSensitive,
858 onToggleCaseSensitive = onToggleCaseSensitive,
893 onToggleCaseSensitive = onToggleCaseSensitive,
894+ regex = regex,
895+ onToggleRegex = onToggleRegex,
859 )
896 )
860 }
897 }
861 }
898 }
@@ -908,9 +945,12 @@ private fun FindBar(
908 onClose: () -> Unit,
945 onClose: () -> Unit,
909 caseSensitive: Boolean,
946 caseSensitive: Boolean,
910 onToggleCaseSensitive: () -> Unit,
947 onToggleCaseSensitive: () -> Unit,
948+ regex: Boolean,
949+ onToggleRegex: () -> Unit,
911) {
950) {
912 val keyboardController = LocalSoftwareKeyboardController.current
951 val keyboardController = LocalSoftwareKeyboardController.current
913 val matchCaseLabel = stringResource(R.string.match_case)
952 val matchCaseLabel = stringResource(R.string.match_case)
953+ val regexLabel = stringResource(R.string.regex_search)
914 Row(
954 Row(
915 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
955 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
916 verticalAlignment = Alignment.CenterVertically,
956 verticalAlignment = Alignment.CenterVertically,
@@ -934,6 +974,15 @@ private fun FindBar(
934 modifier = Modifier.semantics { contentDescription = matchCaseLabel },
974 modifier = Modifier.semantics { contentDescription = matchCaseLabel },
935 )
975 )
936 Spacer(Modifier.size(8.dp))
976 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))
937 val countText = if (query.isBlank()) ""
986 val countText = if (query.isBlank()) ""
938 else if (matchCount == 0) stringResource(R.string.no_matches_in_file)
987 else if (matchCount == 0) stringResource(R.string.no_matches_in_file)
939 else stringResource(R.string.match_count, matchPos + 1, matchCount)
988 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
8import androidx.compose.foundation.layout.widthIn
8import androidx.compose.foundation.layout.widthIn
9import androidx.compose.material.icons.Icons
9import androidx.compose.material.icons.Icons
10import androidx.compose.material.icons.filled.Description
10import androidx.compose.material.icons.filled.Description
11+import androidx.compose.material.icons.filled.Refresh
11import androidx.compose.material3.VerticalDivider
12import androidx.compose.material3.VerticalDivider
12import androidx.compose.runtime.Composable
13import androidx.compose.runtime.Composable
13import androidx.compose.runtime.LaunchedEffect
14import androidx.compose.runtime.LaunchedEffect
@@ -15,16 +16,21 @@ import androidx.compose.runtime.getValue
15import androidx.compose.runtime.key
16import androidx.compose.runtime.key
16import androidx.compose.runtime.mutableStateOf
17import androidx.compose.runtime.mutableStateOf
17import androidx.compose.runtime.remember
18import androidx.compose.runtime.remember
19+import androidx.compose.runtime.rememberCoroutineScope
18import androidx.compose.runtime.saveable.rememberSaveable
20import androidx.compose.runtime.saveable.rememberSaveable
19import androidx.compose.runtime.setValue
21import androidx.compose.runtime.setValue
20import androidx.compose.ui.Modifier
22import androidx.compose.ui.Modifier
21import androidx.compose.ui.res.stringResource
23import androidx.compose.ui.res.stringResource
22import androidx.compose.ui.unit.dp
24import androidx.compose.ui.unit.dp
23import androidx.lifecycle.compose.collectAsStateWithLifecycle
25import androidx.lifecycle.compose.collectAsStateWithLifecycle
26+import androidx.lifecycle.viewmodel.compose.viewModel
27+import kotlinx.coroutines.launch
24import soy.iko.opencode.R
28import soy.iko.opencode.R
25import soy.iko.opencode.data.network.NetworkConfig
29import soy.iko.opencode.data.network.NetworkConfig
26import soy.iko.opencode.di.AppContainer
30import soy.iko.opencode.di.AppContainer
27import soy.iko.opencode.ui.components.EmptyState
31import soy.iko.opencode.ui.components.EmptyState
32+import soy.iko.opencode.ui.vmFactory
33+import soy.iko.opencode.util.runCatchingCancellable
28
34
29/**
35/**
30 * Master–detail layout for the file browser on wide screens (≥ 840dp): the directory listing
36 * Master–detail layout for the file browser on wide screens (≥ 840dp): the directory listing
@@ -77,10 +83,20 @@ fun TwoPaneFileBrowser(
77 Box(modifier = Modifier.weight(NetworkConfig.twoPaneRightWeight).fillMaxSize()) {
83 Box(modifier = Modifier.weight(NetworkConfig.twoPaneRightWeight).fillMaxSize()) {
78 val path = selected
84 val path = selected
79 if (path == null) {
85 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()
80 EmptyState(
93 EmptyState(
81 icon = Icons.Filled.Description,
94 icon = Icons.Filled.Description,
82 title = stringResource(R.string.empty_file_detail_title),
95 title = stringResource(R.string.empty_file_detail_title),
83 description = stringResource(R.string.empty_file_detail_desc),
96 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() } } },
84 modifier = Modifier.fillMaxSize(),
100 modifier = Modifier.fillMaxSize(),
85 )
101 )
86 } else {
102 } else {
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+18−2
@@ -20,6 +20,7 @@ import androidx.compose.foundation.text.KeyboardActions
20import androidx.compose.foundation.text.KeyboardOptions
20import androidx.compose.foundation.text.KeyboardOptions
21import androidx.compose.foundation.verticalScroll
21import androidx.compose.foundation.verticalScroll
22import androidx.compose.material.icons.Icons
22import androidx.compose.material.icons.Icons
23+import androidx.compose.material.icons.filled.Refresh
23import androidx.compose.material.icons.automirrored.filled.ArrowBack
24import androidx.compose.material.icons.automirrored.filled.ArrowBack
24import androidx.compose.material.icons.filled.Delete
25import androidx.compose.material.icons.filled.Delete
25import androidx.compose.material.icons.filled.ExpandLess
26import androidx.compose.material.icons.filled.ExpandLess
@@ -613,14 +614,19 @@ private fun DiscoverySection(onPick: (String) -> Unit, initiallyExpanded: Boolea
613 }
614 }
614 if (expanded) {
615 if (expanded) {
615 val discovery = remember { NsdDiscovery(context) }
616 val discovery = remember { NsdDiscovery(context) }
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) }
616− val servers by remember(discovery) { discovery.discover() }
622+ val servers by remember(discovery, scanNonce) { discovery.discover() }
617 .collectAsStateWithLifecycle(initialValue = emptyList())
623 .collectAsStateWithLifecycle(initialValue = emptyList())
618 // mDNS discovery is a continuous flow that never emits a terminal "no results"
624 // mDNS discovery is a continuous flow that never emits a terminal "no results"
619 // state — on a network with no opencode servers the spinner would spin forever.
625 // state — on a network with no opencode servers the spinner would spin forever.
620 // After the timeout, swap the spinner for a "no servers found" message. Discovery
626 // After the timeout, swap the spinner for a "no servers found" message. Discovery
621 // keeps running, so a late server can still replace the message when it arrives.
627 // keeps running, so a late server can still replace the message when it arrives.
622 var searchTimedOut by remember { mutableStateOf(false) }
628 var searchTimedOut by remember { mutableStateOf(false) }
623− LaunchedEffect(Unit) {
629+ LaunchedEffect(scanNonce) {
624 searchTimedOut = false
630 searchTimedOut = false
625 kotlinx.coroutines.delay(NetworkConfig.nsdDiscoveryNoResultTimeoutMs)
631 kotlinx.coroutines.delay(NetworkConfig.nsdDiscoveryNoResultTimeoutMs)
626 searchTimedOut = true
632 searchTimedOut = true
@@ -664,6 +670,16 @@ private fun DiscoverySection(onPick: (String) -> Unit, initiallyExpanded: Boolea
664 )
670 )
665 }
671 }
666 }
672 }
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+ }
667 }
683 }
668 }
684 }
669}
685}
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+5−2
@@ -111,8 +111,11 @@ fun ServerListScreen(
111 // The active server's real SSE state, so its card reflects a dropped/failed stream instead of
111 // The active server's real SSE state, so its card reflects a dropped/failed stream instead of
112 // always reading "Connected" (which is misleading while reconnecting or after an auth failure).
112 // always reading "Connected" (which is misleading while reconnecting or after an auth failure).
113 val activeSseState by (activeConnection?.events?.state
113 val activeSseState by (activeConnection?.events?.state
114− ?: kotlinx.coroutines.flow.flowOf(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.
115− .collectAsStateWithLifecycle(initialValue = soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Connected)
118+ .collectAsStateWithLifecycle(initialValue = soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Disconnected)
116 val haptics = LocalHapticFeedback.current
119 val haptics = LocalHapticFeedback.current
117 val snackbar = remember { SnackbarHostState() }
120 val snackbar = remember { SnackbarHostState() }
118 // Separate host for transient error messages (connect/probe failures, QR import results)
121 // 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
144import androidx.compose.animation.core.tween
144import androidx.compose.animation.core.tween
145import androidx.compose.foundation.shape.RoundedCornerShape
145import androidx.compose.foundation.shape.RoundedCornerShape
146
146
147−@OptIn(ExperimentalMaterial3Api::class)
147+@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
148@Composable
148@Composable
149fun SessionListScreen(
149fun SessionListScreen(
150 container: AppContainer,
150 container: AppContainer,
@@ -603,9 +603,9 @@ fun SessionListScreen(
603 AlertDialog(
603 AlertDialog(
604 onDismissRequest = { showBulkDelete = false },
604 onDismissRequest = { showBulkDelete = false },
605 title = { Text(stringResource(R.string.bulk_delete_title)) },
605 title = { Text(stringResource(R.string.bulk_delete_title)) },
606− // Bulk delete is NOT undoable (unlike single delete which offers Undo), so the
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
607− // warning must be explicit — matching the single-delete dialog's irreversibility text
608+ // single-delete dialog's irreversibility text rather than the bare "%d selected".
608− // rather than the bare "%d selected" count.
609 text = { Text(stringResource(R.string.bulk_delete_text, count)) },
609 text = { Text(stringResource(R.string.bulk_delete_text, count)) },
610 confirmButton = {
610 confirmButton = {
611 TextButton(onClick = {
611 TextButton(onClick = {
@@ -952,14 +952,13 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
952 contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = NetworkConfig.listFabInsetDp.dp),
952 contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = NetworkConfig.listFabInsetDp.dp),
953 verticalArrangement = Arrangement.spacedBy(12.dp),
953 verticalArrangement = Arrangement.spacedBy(12.dp),
954 ) {
954 ) {
955− // Render each (header, nodes) segment: the header as a section label
955+ // Render each (header, nodes) segment: the header as a sticky section
956− // followed by the session cards in that section. (Sticky headers would
956+ // label (pinned while its segment scrolls past) followed by the session
957− // require LazyListScope.stickyHeader, which is not available in the
957+ // cards in that section. stickyHeader keeps "Today"/"Yesterday" visible
958− // current Compose version — the segments structure keeps the grouping
958+ // so a user scrolling a long list keeps the date context.
959− // ready for a sticky upgrade when the API lands.)
960 for ((header, segmentNodes) in groupedSegments) {
959 for ((header, segmentNodes) in groupedSegments) {
961 if (header != null) {
960 if (header != null) {
962− item(key = header.key, contentType = "header") {
961+ stickyHeader(key = header.key, contentType = "header") {
963 DateGroupHeader(header.label)
962 DateGroupHeader(header.label)
964 }
963 }
965 }
964 }
@@ -1729,6 +1728,8 @@ private fun SessionActionUndoEffect(vm: SessionListViewModel, snackbar: Snackbar
1729 SessionActionKind.UNARCHIVED -> unarchived
1728 SessionActionKind.UNARCHIVED -> unarchived
1730 SessionActionKind.PINNED -> pinned
1729 SessionActionKind.PINNED -> pinned
1731 SessionActionKind.UNPINNED -> unpinned
1730 SessionActionKind.UNPINNED -> unpinned
1731+ // Single deletes use the dedicated delete-undo effect, not this path.
1732+ SessionActionKind.DELETED -> return@collectLatest
1732 }
1733 }
1733 val result = snackbar.showSnackbar(
1734 val result = snackbar.showSnackbar(
1734 message = message,
1735 message = message,
@@ -1754,20 +1755,44 @@ private fun BulkSessionActionUndoEffect(vm: SessionListViewModel, snackbar: Snac
1754 val undoLabel = stringResource(R.string.undo)
1755 val undoLabel = stringResource(R.string.undo)
1755 val archived = stringResource(R.string.bulk_archived)
1756 val archived = stringResource(R.string.bulk_archived)
1756 val unarchived = stringResource(R.string.bulk_unarchived)
1757 val unarchived = stringResource(R.string.bulk_unarchived)
1758+ val deleted = stringResource(R.string.bulk_deleted)
1757 LaunchedEffect(Unit) {
1759 LaunchedEffect(Unit) {
1758 vm.bulkSessionActionEvents.collectLatest { event ->
1760 vm.bulkSessionActionEvents.collectLatest { event ->
1759 val message = when (event.kind) {
1761 val message = when (event.kind) {
1760 SessionActionKind.ARCHIVED -> archived.format(event.ids.size)
1762 SessionActionKind.ARCHIVED -> archived.format(event.ids.size)
1761 SessionActionKind.UNARCHIVED -> unarchived.format(event.ids.size)
1763 SessionActionKind.UNARCHIVED -> unarchived.format(event.ids.size)
1762 SessionActionKind.PINNED, SessionActionKind.UNPINNED -> return@collectLatest
1764 SessionActionKind.PINNED, SessionActionKind.UNPINNED -> return@collectLatest
1765+ SessionActionKind.DELETED -> deleted.format(event.ids.size)
1763 }
1766 }
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+ }
1764− val result = snackbar.showSnackbar(
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(
1765− message = message,
1789+ message = message,
1766− actionLabel = undoLabel,
1790+ actionLabel = undoLabel,
1767− duration = androidx.compose.material3.SnackbarDuration.Long,
1791+ duration = androidx.compose.material3.SnackbarDuration.Long,
1768− )
1792+ )
1769− if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) {
1793+ if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) {
1770− vm.undoBulkAction(event)
1794+ vm.undoBulkAction(event)
1795+ }
1771 }
1796 }
1772 }
1797 }
1773 }
1798 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+11−1
@@ -48,7 +48,7 @@ enum class SessionSortMode { RECENT, TITLE }
48
48
49/** A pin/archive action the user just took, surfaced so the UI can confirm it with an
49/** A pin/archive action the user just took, surfaced so the UI can confirm it with an
50 * Undo snackbar. [undoable] via [SessionListViewModel.undoSessionAction]. */
50 * Undo snackbar. [undoable] via [SessionListViewModel.undoSessionAction]. */
51−enum class SessionActionKind { PINNED, UNPINNED, ARCHIVED, UNARCHIVED }
51+enum class SessionActionKind { PINNED, UNPINNED, ARCHIVED, UNARCHIVED, DELETED }
52
52
53data class SessionActionEvent(val sessionId: String, val kind: SessionActionKind)
53data class SessionActionEvent(val sessionId: String, val kind: SessionActionKind)
54
54
@@ -379,6 +379,10 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
379 SessionActionKind.UNPINNED -> applyPin(event.sessionId, true)
379 SessionActionKind.UNPINNED -> applyPin(event.sessionId, true)
380 SessionActionKind.ARCHIVED -> applyArchive(event.sessionId, false)
380 SessionActionKind.ARCHIVED -> applyArchive(event.sessionId, false)
381 SessionActionKind.UNARCHIVED -> applyArchive(event.sessionId, true)
381 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 -> {}
382 }
386 }
383 }
387 }
384
388
@@ -874,6 +878,9 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
874 SessionActionKind.UNARCHIVED -> event.ids.forEach { applyArchive(it, true) }
878 SessionActionKind.UNARCHIVED -> event.ids.forEach { applyArchive(it, true) }
875 SessionActionKind.PINNED -> event.ids.forEach { applyPin(it, false) }
879 SessionActionKind.PINNED -> event.ids.forEach { applyPin(it, false) }
876 SessionActionKind.UNPINNED -> event.ids.forEach { applyPin(it, true) }
880 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) }
877 }
884 }
878 }
885 }
879
886
@@ -927,6 +934,9 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
927 },
934 },
928 )
935 )
929 }
936 }
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))
930 }
940 }
931
941
932 fun setSortMode(mode: SessionSortMode, persist: Boolean = true) {
942 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(
126 onOpenDiagnostics: () -> Unit,
126 onOpenDiagnostics: () -> Unit,
127 onOpenUsage: () -> Unit = {},
127 onOpenUsage: () -> Unit = {},
128 onOpenMcp: () -> Unit = {},
128 onOpenMcp: () -> Unit = {},
129+ onEditProfile: ((String) -> Unit)? = null,
129) {
130) {
130 val scope = rememberCoroutineScope()
131 val scope = rememberCoroutineScope()
131 val snackbar = remember { SnackbarHostState() }
132 val snackbar = remember { SnackbarHostState() }
@@ -308,6 +309,11 @@ fun SettingsScreen(
308 state = connectionState,
309 state = connectionState,
309 isOnline = isOnline,
310 isOnline = isOnline,
310 onRetry = { container.activeConnection.value?.events?.triggerReconnect() },
311 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) } } },
311 reconnectAttempts = reconnectAttempts,
317 reconnectAttempts = reconnectAttempts,
312 )
318 )
313 val s = settings
319 val s = settings
app/src/main/res/values-es/strings.xml+5
@@ -106,6 +106,7 @@
106 <string name="discover_on_network">Descubrir en la red</string>
106 <string name="discover_on_network">Descubrir en la red</string>
107 <string name="searching_network">Buscando en la red local…</string>
107 <string name="searching_network">Buscando en la red local…</string>
108 <string name="no_servers_found">No se encontraron servidores de opencode en la red local.</string>
108 <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>
109 <string name="testing_credentials">Probando…</string>
110 <string name="testing_credentials">Probando…</string>
110 <string name="credentials_ok">Credenciales aceptadas.</string>
111 <string name="credentials_ok">Credenciales aceptadas.</string>
111 <string name="credentials_rejected">Credenciales rechazadas.</string>
112 <string name="credentials_rejected">Credenciales rechazadas.</string>
@@ -305,6 +306,8 @@
305 </plurals>
306 </plurals>
306 <string name="notif_permission_title">Se necesita aprobación en «%1$s»</string>
307 <string name="notif_permission_title">Se necesita aprobación en «%1$s»</string>
307 <string name="notif_permission_fallback">Una herramienta quiere ejecutarse.</string>
308 <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>
308 <string name="notif_error_title">La ejecución falló</string>
311 <string name="notif_error_title">La ejecución falló</string>
309 <string name="notif_error_text">opencode encontró un error en «%1$s».</string>
312 <string name="notif_error_text">opencode encontró un error en «%1$s».</string>
310 <string name="notif_action_allow_once">Permitir una vez</string>
313 <string name="notif_action_allow_once">Permitir una vez</string>
@@ -601,6 +604,7 @@
601 <string name="session_running_badge">Agente en ejecución</string>
604 <string name="session_running_badge">Agente en ejecución</string>
602 <string name="bulk_archived">%1$d sesiones archivadas</string>
605 <string name="bulk_archived">%1$d sesiones archivadas</string>
603 <string name="bulk_unarchived">%1$d sesiones desarchivadas</string>
606 <string name="bulk_unarchived">%1$d sesiones desarchivadas</string>
607+ <string name="bulk_deleted">%1$d sesiones eliminadas</string>
604 <string name="bulk_marked_read">%1$d sesiones marcadas como leídas</string>
608 <string name="bulk_marked_read">%1$d sesiones marcadas como leídas</string>
605 <string name="sort_active">Orden activo</string>
609 <string name="sort_active">Orden activo</string>
606 <string name="sort_descending">Descendente</string>
610 <string name="sort_descending">Descendente</string>
@@ -612,6 +616,7 @@
612 <string name="go_to_line">Ir a la línea</string>
616 <string name="go_to_line">Ir a la línea</string>
613 <string name="go_to_line_hint">Número de línea (1–%1$d)</string>
617 <string name="go_to_line_hint">Número de línea (1–%1$d)</string>
614 <string name="match_case">Coincidir mayúsculas</string>
618 <string name="match_case">Coincidir mayúsculas</string>
619+ <string name="regex_search">Buscar con expresión regular</string>
615 <string name="share_full_file">Compartir archivo completo</string>
620 <string name="share_full_file">Compartir archivo completo</string>
616
621
617 <!-- Settings backup/restore -->
622 <!-- Settings backup/restore -->
app/src/main/res/values/strings.xml+5
@@ -115,6 +115,7 @@
115 <string name="discover_on_network">Discover on network</string>
115 <string name="discover_on_network">Discover on network</string>
116 <string name="searching_network">Searching the local network…</string>
116 <string name="searching_network">Searching the local network…</string>
117 <string name="no_servers_found">No opencode servers found on the local network.</string>
117 <string name="no_servers_found">No opencode servers found on the local network.</string>
118+ <string name="scan_again">Scan again</string>
118 <string name="testing_credentials">Testing…</string>
119 <string name="testing_credentials">Testing…</string>
119 <string name="credentials_ok">Credentials accepted.</string>
120 <string name="credentials_ok">Credentials accepted.</string>
120 <string name="credentials_rejected">Credentials rejected.</string>
121 <string name="credentials_rejected">Credentials rejected.</string>
@@ -314,6 +315,8 @@
314 </plurals>
315 </plurals>
315 <string name="notif_permission_title">Approval needed in “%1$s”</string>
316 <string name="notif_permission_title">Approval needed in “%1$s”</string>
316 <string name="notif_permission_fallback">A tool wants to run.</string>
317 <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>
317 <string name="notif_error_title">Run failed</string>
320 <string name="notif_error_title">Run failed</string>
318 <string name="notif_error_text">opencode hit an error in “%1$s”.</string>
321 <string name="notif_error_text">opencode hit an error in “%1$s”.</string>
319 <string name="notif_action_allow_once">Allow once</string>
322 <string name="notif_action_allow_once">Allow once</string>
@@ -624,6 +627,7 @@
624 <string name="session_reverted_badge">Reverted</string>
627 <string name="session_reverted_badge">Reverted</string>
625 <string name="bulk_archived">%1$d sessions archived</string>
628 <string name="bulk_archived">%1$d sessions archived</string>
626 <string name="bulk_unarchived">%1$d sessions unarchived</string>
629 <string name="bulk_unarchived">%1$d sessions unarchived</string>
630+ <string name="bulk_deleted">%1$d sessions deleted</string>
627 <string name="bulk_marked_read">%1$d sessions marked as read</string>
631 <string name="bulk_marked_read">%1$d sessions marked as read</string>
628 <string name="sort_active">Active sort</string>
632 <string name="sort_active">Active sort</string>
629 <string name="sort_descending">Descending</string>
633 <string name="sort_descending">Descending</string>
@@ -635,6 +639,7 @@
635 <string name="go_to_line">Go to line</string>
639 <string name="go_to_line">Go to line</string>
636 <string name="go_to_line_hint">Line number (1–%1$d)</string>
640 <string name="go_to_line_hint">Line number (1–%1$d)</string>
637 <string name="match_case">Match case</string>
641 <string name="match_case">Match case</string>
642+ <string name="regex_search">Regular expression search</string>
638 <string name="share_full_file">Share full file</string>
643 <string name="share_full_file">Share full file</string>
639
644
640 <!-- Settings backup/restore -->
645 <!-- Settings backup/restore -->