git.iko.soy/opencode-android
on master help sign in
Add 28 UI/UX improvements: permission metadata, step metrics, draft indicator, role semantics, slash autocomplete, skeleton loader, swipe customization, image preview, and more
commit9f0326a84ebc7e4abbc97e457761c7beaf7637a9 authoriko <mail@iko.soy> dateSat 4 Jul 2026, 13:59 (3 months ago) parent6c78d91
18 files changed+1301−115
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+13 app/src/main/java/soy/iko/opencode/data/repo/BackupManager.kt+45 app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.kt+88 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+16−7 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+222−24 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+29 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+36−1 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+52−4 app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+73 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+76−4 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+58 app/src/main/java/soy/iko/opencode/ui/file/TwoPaneFileBrowser.kt+8−18 app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+12−4 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+177−30 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+6−1 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+289−22 app/src/main/res/values-es/strings.xml+40 app/src/main/res/values/strings.xml+61
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+13
@@ -63,6 +63,13 @@ object NetworkConfig {
6363 const val sessionListInitialPage = 60
6464 /** How many additional rows to reveal when the session list render window is exhausted. */
6565 const val sessionListPageStep = 60
66+ /** Initial render window for the file-browser directory listing. A huge directory
67+ * (10k+ entries) sorts/filters in full on each recomposition; windowing the render
68+ * bounds the composed-row count while the sort still runs over the full list (so the
69+ * order is correct, just the visible slice is capped). Grows as the user scrolls. */
70+ const val fileListInitialPage = 100
71+ /** How many additional file rows to reveal when the render window is exhausted. */
72+ const val fileListPageStep = 100
6673 /** Max concurrent preview fetches (prevents flooding the server with parallel requests). */
6774 const val maxConcurrentPreviews = 8
6875 /** Max characters of a session's last message to keep as a list preview. */
@@ -125,6 +132,12 @@ object NetworkConfig {
125132 /** Debounce delay before firing a file-search request after the user stops typing. */
126133 const val fileSearchDebounceMs = 250L
127134
135+ /** Debounce delay before re-running the in-conversation message search after the user
136+ * stops typing. The search walks every part of every message (including stringifying
137+ * tool state), so debouncing avoids jank on long conversations. The filter itself runs
138+ * off the main thread; this delay coalesces rapid keystrokes. */
139+ const val chatSearchDebounceMs = 200L
140+
128141 // --- Attachments (chat composer) ---
129142
130143 /** Max size of a single attachment (image/file) before it's rejected. Base64 inflates
app/src/main/java/soy/iko/opencode/data/repo/BackupManager.kt+45
@@ -31,8 +31,23 @@ data class BackupSettings(
3131 val dynamicColor: Boolean = true,
3232 val sendOnEnter: Boolean = true,
3333 val appLock: Boolean = false,
34+ val appLockReLockSeconds: Int = SettingsStore.DEFAULT_APP_LOCK_RELOCK_SECONDS,
3435 val chatTextScale: Float = SettingsStore.DEFAULT_CHAT_TEXT_SCALE,
3536 val codeWrap: Boolean = false,
37+ val sessionSortMode: String = "RECENT",
38+ val sessionSortDescending: Boolean = true,
39+ val sessionShowArchived: Boolean = false,
40+ val preferredModelId: String = "",
41+ val preferredAgentName: String = "",
42+ val compactMessageSpacing: Boolean = false,
43+ val hapticsEnabled: Boolean = true,
44+ val reducedMotion: Boolean = false,
45+ val languageOverride: String = "",
46+ val notifRunComplete: Boolean = true,
47+ val notifPermission: Boolean = true,
48+ val notifError: Boolean = true,
49+ val swipeLeftAction: String = SwipeAction.DELETE.name,
50+ val swipeRightAction: String = SwipeAction.ARCHIVE.name,
3651)
3752
3853/** The full backup document. [version] guards against future format changes. */
@@ -76,8 +91,23 @@ class BackupManager(
7691 dynamicColor = settingsStore.dynamicColor.first(),
7792 sendOnEnter = settingsStore.sendOnEnter.first(),
7893 appLock = settingsStore.appLock.first(),
94+ appLockReLockSeconds = settingsStore.appLockReLockSeconds.first(),
7995 chatTextScale = settingsStore.chatTextScale.first(),
8096 codeWrap = settingsStore.codeWrap.first(),
97+ sessionSortMode = settingsStore.sessionSortMode.first(),
98+ sessionSortDescending = settingsStore.sessionSortDescending.first(),
99+ sessionShowArchived = settingsStore.sessionShowArchived.first(),
100+ preferredModelId = settingsStore.preferredModelId.first(),
101+ preferredAgentName = settingsStore.preferredAgentName.first(),
102+ compactMessageSpacing = settingsStore.compactMessageSpacing.first(),
103+ hapticsEnabled = settingsStore.hapticsEnabled.first(),
104+ reducedMotion = settingsStore.reducedMotion.first(),
105+ languageOverride = settingsStore.languageOverride.first(),
106+ notifRunComplete = settingsStore.notifRunComplete.first(),
107+ notifPermission = settingsStore.notifPermission.first(),
108+ notifError = settingsStore.notifError.first(),
109+ swipeLeftAction = settingsStore.swipeLeftAction.first(),
110+ swipeRightAction = settingsStore.swipeRightAction.first(),
81111 )
82112 val data = BackupData(
83113 servers = servers,
@@ -126,8 +156,23 @@ class BackupManager(
126156 settingsStore.setDynamicColor(settings.dynamicColor)
127157 settingsStore.setSendOnEnter(settings.sendOnEnter)
128158 settingsStore.setAppLock(settings.appLock)
159+ settingsStore.setAppLockReLockSeconds(settings.appLockReLockSeconds)
129160 settingsStore.setChatTextScale(settings.chatTextScale)
130161 settingsStore.setCodeWrap(settings.codeWrap)
162+ settingsStore.setSessionSortMode(settings.sessionSortMode)
163+ settingsStore.setSessionSortDescending(settings.sessionSortDescending)
164+ settingsStore.setSessionShowArchived(settings.sessionShowArchived)
165+ settingsStore.setPreferredModelId(settings.preferredModelId)
166+ settingsStore.setPreferredAgentName(settings.preferredAgentName)
167+ settingsStore.setCompactMessageSpacing(settings.compactMessageSpacing)
168+ settingsStore.setHapticsEnabled(settings.hapticsEnabled)
169+ settingsStore.setReducedMotion(settings.reducedMotion)
170+ settingsStore.setLanguageOverride(settings.languageOverride)
171+ settingsStore.setNotifRunComplete(settings.notifRunComplete)
172+ settingsStore.setNotifPermission(settings.notifPermission)
173+ settingsStore.setNotifError(settings.notifError)
174+ SwipeAction.entries.find { it.name == settings.swipeLeftAction }?.let { settingsStore.setSwipeLeftAction(it) }
175+ SwipeAction.entries.find { it.name == settings.swipeRightAction }?.let { settingsStore.setSwipeRightAction(it) }
131176 }
132177 data.pinned.forEach { sessionPrefsStore.setPinned(it, true) }
133178 data.archived.forEach { sessionPrefsStore.setArchived(it, true) }
app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.kt+88
@@ -15,6 +15,7 @@ enum class ThemeMode { SYSTEM, LIGHT, DARK, AMOLED }
1515private val Context.settingsDataStore by preferencesDataStore(name = "settings")
1616
1717/** App-preferences store: theme mode, dynamic color (Material You), input behavior. */
18+@Suppress("TooManyFunctions")
1819class SettingsStore(context: Context) {
1920
2021 private val appContext = context.applicationContext
@@ -31,6 +32,14 @@ class SettingsStore(context: Context) {
3132 private val preferredModelIdKey = stringPreferencesKey("preferred_model_id")
3233 private val preferredAgentNameKey = stringPreferencesKey("preferred_agent_name")
3334 private val compactMessageSpacingKey = booleanPreferencesKey("compact_message_spacing")
35+ private val hapticsEnabledKey = booleanPreferencesKey("haptics_enabled")
36+ private val reducedMotionKey = booleanPreferencesKey("reduced_motion")
37+ private val languageOverrideKey = stringPreferencesKey("language_override")
38+ private val notifRunCompleteKey = booleanPreferencesKey("notif_run_complete")
39+ private val notifPermissionKey = booleanPreferencesKey("notif_permission")
40+ private val notifErrorKey = booleanPreferencesKey("notif_error")
41+ private val swipeLeftActionKey = stringPreferencesKey("swipe_left_action")
42+ private val swipeRightActionKey = stringPreferencesKey("swipe_right_action")
3443
3544 companion object {
3645 /** Bounds for the chat text-size multiplier so the UI can't be scaled into
@@ -125,6 +134,51 @@ class SettingsStore(context: Context) {
125134 prefs[compactMessageSpacingKey] ?: false
126135 }
127136
137+ /** When true, haptic feedback is fired on taps and confirmations. Defaults to true (the
138+ * existing behavior). An in-app toggle lets users disable haptics without turning off
139+ * system-wide vibration. Read by a CompositionLocal that gates haptic calls. */
140+ val hapticsEnabled: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
141+ prefs[hapticsEnabledKey] ?: true
142+ }
143+
144+ /** When true, animations and transitions are limited (collapsed to instant/snap).
145+ * Defaults to false — the app already honors the system Developer Options animator
146+ * scale, but this gives users an in-app toggle without disabling motion OS-wide.
147+ * Read by a CompositionLocal that gates animation specs. */
148+ val reducedMotion: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
149+ prefs[reducedMotionKey] ?: false
150+ }
151+
152+ /** Override the app language (an ISO 639-1 code like "en" or "es", or empty for the
153+ * system default). Applied via AppCompat's per-app language API so the OS can
154+ * handle locale resolution without an app restart on Android 13+. */
155+ val languageOverride: Flow<String> = appContext.settingsDataStore.data.map { prefs ->
156+ prefs[languageOverrideKey] ?: ""
157+ }
158+
159+ /** Granular notification toggles. Each gates a notification channel's posting so a user
160+ * can mute specific notification types in-app (in addition to the OS channel controls).
161+ * Default true for all (matching the prior "always post" behavior). */
162+ val notifRunComplete: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
163+ prefs[notifRunCompleteKey] ?: true
164+ }
165+ val notifPermission: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
166+ prefs[notifPermissionKey] ?: true
167+ }
168+ val notifError: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
169+ prefs[notifErrorKey] ?: true
170+ }
171+
172+ /** Remappable swipe actions for the session list. Stored as the SwipeAction enum name;
173+ * an unknown value falls back to the default. Defaults: left=DELETE, right=ARCHIVE
174+ * (matching the prior hardcoded behavior). */
175+ val swipeLeftAction: Flow<String> = appContext.settingsDataStore.data.map { prefs ->
176+ prefs[swipeLeftActionKey] ?: SwipeAction.DELETE.name
177+ }
178+ val swipeRightAction: Flow<String> = appContext.settingsDataStore.data.map { prefs ->
179+ prefs[swipeRightActionKey] ?: SwipeAction.ARCHIVE.name
180+ }
181+
128182 suspend fun setThemeMode(mode: ThemeMode) {
129183 appContext.settingsDataStore.edit { it[themeKey] = mode.name }
130184 }
@@ -188,4 +242,38 @@ class SettingsStore(context: Context) {
188242 it[compactMessageSpacingKey] = enabled
189243 }
190244 }
245+
246+ suspend fun setHapticsEnabled(enabled: Boolean) {
247+ appContext.settingsDataStore.edit { it[hapticsEnabledKey] = enabled }
248+ }
249+
250+ suspend fun setReducedMotion(enabled: Boolean) {
251+ appContext.settingsDataStore.edit { it[reducedMotionKey] = enabled }
252+ }
253+
254+ suspend fun setLanguageOverride(code: String) {
255+ appContext.settingsDataStore.edit {
256+ if (code.isEmpty()) it.remove(languageOverrideKey) else it[languageOverrideKey] = code
257+ }
258+ }
259+
260+ suspend fun setNotifRunComplete(enabled: Boolean) {
261+ appContext.settingsDataStore.edit { it[notifRunCompleteKey] = enabled }
262+ }
263+ suspend fun setNotifPermission(enabled: Boolean) {
264+ appContext.settingsDataStore.edit { it[notifPermissionKey] = enabled }
265+ }
266+ suspend fun setNotifError(enabled: Boolean) {
267+ appContext.settingsDataStore.edit { it[notifErrorKey] = enabled }
268+ }
269+
270+ suspend fun setSwipeLeftAction(action: SwipeAction) {
271+ appContext.settingsDataStore.edit { it[swipeLeftActionKey] = action.name }
272+ }
273+ suspend fun setSwipeRightAction(action: SwipeAction) {
274+ appContext.settingsDataStore.edit { it[swipeRightActionKey] = action.name }
275+ }
191276}
277+
278+/** Remappable swipe actions for the session list. NONE disables the swipe entirely. */
279+enum class SwipeAction { DELETE, ARCHIVE, MARK_READ, NONE }
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+16−7
@@ -217,15 +217,24 @@ fun OpencodeApp(container: AppContainer) {
217217 Key.NumPad4, Key.Four -> Routes.USAGE
218218 Key.NumPad5, Key.Five -> Routes.MCP
219219 Key.NumPad6, Key.Six -> Routes.SETTINGS
220+ // Ctrl+7 navigates to the server list (the 7th nav-rail destination).
221+ // Previously Ctrl+1..6 covered the other destinations but Servers was
222+ // missing, an inconsistency since the nav rail shows 7 destinations.
223+ Key.NumPad7, Key.Seven -> Routes.SERVERS
220224 else -> null
221225 }
222− if (route != null && connection != null) {
223− navController.navigate(route) {
224− popUpTo(navController.graph.findStartDestination().id) { saveState = true }
225− launchSingleTop = true
226− restoreState = true
227− }
228− true
226+ if (route != null) {
227+ // Servers is reachable even without an active connection (it's the start
228+ // destination and handles the not-connected state); the other routes
229+ // require a connection to be meaningful.
230+ if (route == Routes.SERVERS || connection != null) {
231+ navController.navigate(route) {
232+ popUpTo(navController.graph.findStartDestination().id) { saveState = true }
233+ launchSingleTop = true
234+ restoreState = true
235+ }
236+ true
237+ } else false
229238 } else false
230239 },
231240 ) {
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+222−24
@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.Spacer
2929import androidx.compose.foundation.layout.WindowInsets
3030import androidx.compose.foundation.layout.fillMaxSize
3131import androidx.compose.foundation.layout.fillMaxWidth
32+import androidx.compose.foundation.layout.heightIn
3233import androidx.compose.foundation.layout.height
3334import androidx.compose.foundation.layout.imePadding
3435import androidx.compose.foundation.layout.ime
@@ -46,6 +47,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
4647import androidx.compose.material.icons.Icons
4748import androidx.compose.material.icons.automirrored.filled.ArrowBack
4849import androidx.compose.material.icons.automirrored.filled.Send
50+import androidx.compose.material.icons.filled.ErrorOutline
4951import androidx.compose.material.icons.filled.Add
5052import androidx.compose.material.icons.filled.AutoAwesome
5153import androidx.compose.material.icons.filled.Delete
@@ -131,13 +133,17 @@ import androidx.compose.ui.layout.onSizeChanged
131133import androidx.compose.ui.platform.LocalContext
132134import androidx.compose.ui.platform.LocalDensity
133135import androidx.compose.ui.platform.LocalHapticFeedback
136+import androidx.compose.ui.platform.LocalLayoutDirection
134137import androidx.compose.ui.platform.LocalView
135138import androidx.compose.ui.platform.testTag
139+import androidx.compose.ui.unit.LayoutDirection
136140import androidx.compose.ui.res.pluralStringResource
137141import androidx.compose.ui.res.stringResource
138142import androidx.compose.ui.semantics.Role
139143import androidx.compose.ui.semantics.contentDescription
140144import androidx.compose.ui.semantics.heading
145+import androidx.compose.ui.semantics.liveRegion
146+import androidx.compose.ui.semantics.LiveRegionMode
141147import androidx.compose.ui.semantics.invisibleToUser
142148import androidx.compose.ui.semantics.semantics
143149import androidx.compose.ui.text.input.ImeAction
@@ -153,6 +159,8 @@ import kotlinx.coroutines.launch
153159import kotlinx.coroutines.withContext
154160import soy.iko.opencode.data.model.Part
155161import soy.iko.opencode.data.model.MessageWithParts
162+import soy.iko.opencode.data.model.AssistantMessage
163+import soy.iko.opencode.data.model.Command
156164import soy.iko.opencode.data.model.ReasoningPart
157165import soy.iko.opencode.data.model.TextPart
158166import soy.iko.opencode.data.model.ToolCompleted
@@ -197,6 +205,7 @@ fun ChatScreen(
197205 val aborting by vm.aborting.collectAsStateWithLifecycle()
198206 val loading by vm.loading.collectAsStateWithLifecycle()
199207 val loadError by vm.loadError.collectAsStateWithLifecycle()
208+ val loadErrorInline by vm.loadErrorInline.collectAsStateWithLifecycle()
200209 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
201210 val models by vm.models.collectAsStateWithLifecycle()
202211 val modelsLoading by vm.modelsLoading.collectAsStateWithLifecycle()
@@ -226,6 +235,7 @@ fun ChatScreen(
226235 val compactSpacing by container.settingsStore.compactMessageSpacing.collectAsStateWithLifecycle(initialValue = false)
227236 val isOnline by container.isOnline.collectAsStateWithLifecycle()
228237 val haptics = LocalHapticFeedback.current
238+ val layoutDirection = LocalLayoutDirection.current
229239 val scope = rememberCoroutineScope()
230240 val shareContext = LocalContext.current
231241 val defaultShareSubject = stringResource(R.string.share_subject)
@@ -849,6 +859,14 @@ fun ChatScreen(
849859 onCamera = { launchCamera() },
850860 onVoice = { launchVoice() },
851861 onPasteImage = { stageUris(it) },
862+ commands = commands,
863+ onRunCommand = { cmd ->
864+ vm.runCommand(cmd)
865+ // Clear the draft so the typed /prefix doesn't linger after the
866+ // command fires (the command picker confirmation, if any, is
867+ // handled inside runCommand).
868+ vm.updateDraft("")
869+ },
852870 )
853871 }
854872 },
@@ -879,28 +897,47 @@ fun ChatScreen(
879897 val yesterdayLabel = stringResource(R.string.yesterday)
880898 // In-conversation search: when active, narrow to messages whose text contains the query.
881899 // Computed on the filtered set so day separators still align with the visible messages.
900+ // Debounced + off-main-thread so a keystroke-by-keystroke filter walk over a long
901+ // conversation (with many tool parts whose state stringification is non-trivial)
902+ // doesn't jank the UI. produceState yields the empty list immediately and publishes
903+ // the filtered result once the debounce settles.
882904 val searchQuery = chatSearch.trim()
883− val searchMessages = remember(messages, searchQuery) {
884− if (searchQuery.isEmpty()) messages
885− else messages.filter { m ->
886− m.parts.any { p ->
887− when (p) {
888− is TextPart -> p.text.contains(searchQuery, ignoreCase = true)
889− is ReasoningPart -> p.text.contains(searchQuery, ignoreCase = true)
890− is ToolPart -> {
891− // Match the tool name plus its running/completed/error text and the
892− // stringified input, so searching for an error string or a file the
893− // tool touched actually finds the message it appeared in.
894− p.tool.contains(searchQuery, ignoreCase = true) ||
895− (p.state is ToolRunning && p.state.title?.contains(searchQuery, ignoreCase = true) == true) ||
896− (p.state is ToolCompleted && (p.state.title?.contains(searchQuery, ignoreCase = true) == true ||
897− p.state.output?.contains(searchQuery, ignoreCase = true) == true)) ||
898− (p.state is ToolError && p.state.error?.contains(searchQuery, ignoreCase = true) == true) ||
899− p.state.inputElement()?.toString()?.contains(searchQuery, ignoreCase = true) == true
905+ val searchMessages by produceState(
906+ initialValue = if (searchQuery.isEmpty()) messages else emptyList(),
907+ messages,
908+ searchQuery,
909+ ) {
910+ if (searchQuery.isEmpty()) {
911+ value = messages
912+ } else {
913+ // Debounce: wait for the user to stop typing before running the filter,
914+ // so each keystroke doesn't kick off a fresh scan.
915+ kotlinx.coroutines.delay(NetworkConfig.chatSearchDebounceMs)
916+ // Run the filter off the main thread — the walk stringifies tool state
917+ // and scans every part of every message, which is non-trivial work for a
918+ // long conversation.
919+ value = withContext(Dispatchers.Default) {
920+ messages.filter { m ->
921+ m.parts.any { p ->
922+ when (p) {
923+ is TextPart -> p.text.contains(searchQuery, ignoreCase = true)
924+ is ReasoningPart -> p.text.contains(searchQuery, ignoreCase = true)
925+ is ToolPart -> {
926+ // Match the tool name plus its running/completed/error text and the
927+ // stringified input, so searching for an error string or a file the
928+ // tool touched actually finds the message it appeared in.
929+ p.tool.contains(searchQuery, ignoreCase = true) ||
930+ (p.state is ToolRunning && p.state.title?.contains(searchQuery, ignoreCase = true) == true) ||
931+ (p.state is ToolCompleted && (p.state.title?.contains(searchQuery, ignoreCase = true) == true ||
932+ p.state.output?.contains(searchQuery, ignoreCase = true) == true)) ||
933+ (p.state is ToolError && p.state.error?.contains(searchQuery, ignoreCase = true) == true) ||
934+ p.state.inputElement()?.toString()?.contains(searchQuery, ignoreCase = true) == true
935+ }
936+ is FilePart -> p.filename?.contains(searchQuery, ignoreCase = true) == true ||
937+ p.sourcePath?.contains(searchQuery, ignoreCase = true) == true
938+ else -> false
939+ }
900940 }
901− is FilePart -> p.filename?.contains(searchQuery, ignoreCase = true) == true ||
902− p.sourcePath?.contains(searchQuery, ignoreCase = true) == true
903− else -> false
904941 }
905942 }
906943 }
@@ -1102,6 +1139,48 @@ fun ChatScreen(
11021139 // separate "Not connected" state below).
11031140 onRetry = { vm.refreshMessages() },
11041141 )
1142+ // Persistent inline error banner for a mid-conversation load failure.
1143+ // Unlike the one-shot snackbar (which fires once per streak then goes
1144+ // silent), this stays visible until the stream recovers, so a persistent
1145+ // outage doesn't read as "everything is fine" after the snackbar dismisses.
1146+ // Anchored at the top, below the connection banner, so it doesn't overlap
1147+ // the message list.
1148+ if (loadErrorInline && hasMessages) {
1149+ Surface(
1150+ color = MaterialTheme.colorScheme.errorContainer,
1151+ shape = MaterialTheme.shapes.small,
1152+ modifier = Modifier
1153+ .align(Alignment.TopCenter)
1154+ .padding(top = if (bannerVisible) topPad else 8.dp)
1155+ .padding(horizontal = 16.dp)
1156+ .semantics { liveRegion = LiveRegionMode.Polite },
1157+ ) {
1158+ Row(
1159+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
1160+ verticalAlignment = Alignment.CenterVertically,
1161+ horizontalArrangement = Arrangement.spacedBy(8.dp),
1162+ ) {
1163+ Icon(
1164+ Icons.Filled.ErrorOutline,
1165+ contentDescription = null,
1166+ modifier = Modifier.size(16.dp),
1167+ tint = MaterialTheme.colorScheme.onErrorContainer,
1168+ )
1169+ Text(
1170+ stringResource(R.string.load_error_persistent),
1171+ style = MaterialTheme.typography.labelMedium,
1172+ color = MaterialTheme.colorScheme.onErrorContainer,
1173+ modifier = Modifier.weight(1f),
1174+ )
1175+ TextButton(
1176+ onClick = { vm.refreshMessages() },
1177+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
1178+ ) {
1179+ Text(stringResource(R.string.retry))
1180+ }
1181+ }
1182+ }
1183+ }
11051184 // Pull-to-refresh wraps ALL content states (loading, error, empty, list) so
11061185 // recovery is a swipe away everywhere — not just when the message list is
11071186 // populated. Previously PTR was only around the populated list, leaving the
@@ -1247,7 +1326,12 @@ fun ChatScreen(
12471326 // capture horizontal drags (e.g. inside a horizontally-scrollable code block).
12481327 val swipeState = rememberSwipeToDismissBoxState(
12491328 confirmValueChange = { value ->
1250− if (value == SwipeToDismissBoxValue.StartToEnd && quoteText != null) {
1329+ val replyValue = if (layoutDirection == LayoutDirection.Rtl) {
1330+ SwipeToDismissBoxValue.EndToStart
1331+ } else {
1332+ SwipeToDismissBoxValue.StartToEnd
1333+ }
1334+ if (value == replyValue && quoteText != null) {
12511335 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
12521336 vm.quoteReply(quoteText)
12531337 runCatching { inputFocusRequester.requestFocus() }
@@ -1255,14 +1339,19 @@ fun ChatScreen(
12551339 false
12561340 },
12571341 )
1342+ // In RTL locales, mirror the swipe direction so the reply
1343+ // gesture reads naturally (swipe from the trailing edge
1344+ // toward the leading edge), matching how LTR users swipe
1345+ // left-to-right. Without this, RTL users had no reply swipe.
1346+ val replyFromStartToEnd = layoutDirection != LayoutDirection.Rtl
12581347 SwipeToDismissBox(
12591348 state = swipeState,
1260− enableDismissFromEndToStart = false,
1261− enableDismissFromStartToEnd = quoteText != null,
1349+ enableDismissFromEndToStart = !replyFromStartToEnd && quoteText != null,
1350+ enableDismissFromStartToEnd = replyFromStartToEnd && quoteText != null,
12621351 backgroundContent = {
12631352 Box(
12641353 Modifier.fillMaxSize().padding(horizontal = 16.dp),
1265− contentAlignment = Alignment.CenterStart,
1354+ contentAlignment = if (replyFromStartToEnd) Alignment.CenterStart else Alignment.CenterEnd,
12661355 ) {
12671356 Icon(
12681357 Icons.AutoMirrored.Filled.Reply,
@@ -1318,6 +1407,12 @@ fun ChatScreen(
13181407 onRegenerate = if (!running) {
13191408 { vm.regenerate(message.info.id) }
13201409 } else null,
1410+ // Continue: resume a partial reply. Shown only for
1411+ // incomplete assistant messages (a partial reply that
1412+ // was aborted) and not while a run is active.
1413+ onContinue = if (!running && message.info is AssistantMessage && !message.info.isComplete) {
1414+ { vm.continueRun(message.info.id) }
1415+ } else null,
13211416 sendStatus = optimisticStatuses[message.info.id]?.let { failed ->
13221417 if (failed) soy.iko.opencode.ui.chat.MessageSendStatus.FAILED
13231418 else soy.iko.opencode.ui.chat.MessageSendStatus.SENDING
@@ -1821,7 +1916,99 @@ private fun ShortcutRow(keys: String, desc: String) {
18211916 }
18221917}
18231918
1919+/**
1920+ * Slash-command autocomplete: when the draft starts with "/", show a filtered dropdown
1921+ * of matching commands above the composer. Tapping a command fires it immediately.
1922+ * Extracted from [ChatInputBar] to keep that function's cyclomatic complexity under
1923+ * the detekt threshold. Suppressed while running or when the command catalog is empty.
1924+ */
1925+@Composable
1926+private fun SlashCommandAutocomplete(
1927+ value: String,
1928+ commands: List<Command>,
1929+ running: Boolean,
1930+ onRunCommand: (Command) -> Unit,
1931+) {
1932+ if (running || commands.isEmpty()) return
1933+ val slashQuery = remember(value) {
1934+ if (value.startsWith("/")) value.removePrefix("/") else null
1935+ } ?: return
1936+ val matchingCommands = remember(slashQuery, commands) {
1937+ commands.filter { cmd ->
1938+ slashQuery.isEmpty() ||
1939+ cmd.name.contains(slashQuery, ignoreCase = true) ||
1940+ (cmd.description?.contains(slashQuery, ignoreCase = true) == true)
1941+ }.take(8)
1942+ }
1943+ if (matchingCommands.isNotEmpty()) {
1944+ CommandAutocompleteDropdown(
1945+ commands = matchingCommands,
1946+ onPick = onRunCommand,
1947+ )
1948+ }
1949+}
1950+
1951+/**
1952+ * Slash-command autocomplete dropdown shown above the composer when the draft starts
1953+ * with "/". Renders up to 8 matching commands as a scrollable, elevated surface so the
1954+ * user can discover and invoke commands by typing the conventional "/" prefix instead
1955+ * of opening the overflow menu / command palette. Tapping a row fires the command.
1956+ */
1957+@Composable
1958+private fun CommandAutocompleteDropdown(
1959+ commands: List<Command>,
1960+ onPick: (Command) -> Unit,
1961+) {
1962+ val haptics = LocalHapticFeedback.current
1963+ Surface(
1964+ color = MaterialTheme.colorScheme.surface,
1965+ tonalElevation = 3.dp,
1966+ shape = MaterialTheme.shapes.medium,
1967+ modifier = Modifier
1968+ .fillMaxWidth()
1969+ .padding(horizontal = 8.dp, vertical = 4.dp)
1970+ .heightIn(max = 240.dp),
1971+ ) {
1972+ LazyColumn {
1973+ items(
1974+ count = commands.size,
1975+ key = { commands[it].name },
1976+ ) { i ->
1977+ val cmd = commands[i]
1978+ Row(
1979+ modifier = Modifier
1980+ .fillMaxWidth()
1981+ .clickable(role = Role.Button) {
1982+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1983+ onPick(cmd)
1984+ }
1985+ .padding(horizontal = 16.dp, vertical = 10.dp),
1986+ verticalAlignment = Alignment.CenterVertically,
1987+ horizontalArrangement = Arrangement.spacedBy(12.dp),
1988+ ) {
1989+ Text(
1990+ "/${cmd.name}",
1991+ style = MaterialTheme.typography.labelLarge,
1992+ fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
1993+ color = MaterialTheme.colorScheme.primary,
1994+ )
1995+ cmd.description?.takeIf { it.isNotBlank() }?.let { desc ->
1996+ Text(
1997+ desc,
1998+ style = MaterialTheme.typography.bodySmall,
1999+ color = MaterialTheme.colorScheme.onSurfaceVariant,
2000+ maxLines = 1,
2001+ overflow = TextOverflow.Ellipsis,
2002+ )
2003+ }
2004+ }
2005+ }
2006+ }
2007+ }
2008+}
2009+
18242010@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
2011+@Suppress("CyclomaticComplexMethod")
18252012@Composable
18262013private fun ChatInputBar(
18272014 value: String,
@@ -1845,6 +2032,8 @@ private fun ChatInputBar(
18452032 onCamera: () -> Unit,
18462033 onVoice: () -> Unit,
18472034 onPasteImage: (List<Uri>) -> Unit,
2035+ commands: List<Command> = emptyList(),
2036+ onRunCommand: (Command) -> Unit = {},
18482037) {
18492038 // Sendable when there's text OR at least one attachment (an image-only prompt is valid).
18502039 val hasContent = value.isNotBlank() || attachments.isNotEmpty()
@@ -1895,6 +2084,15 @@ private fun ChatInputBar(
18952084 }
18962085 // Staged attachments: horizontally-scrollable thumbnails/chips, each removable.
18972086 AttachmentStrip(attachments, onRemove = onRemoveAttachment, staging = staging, stagingFileCount = stagingFileCount)
2087+ // Slash-command autocomplete: extracted to a helper to keep this function's
2088+ // complexity under the detekt threshold. Returns no-op when the draft doesn't
2089+ // start with "/" or the command catalog is empty.
2090+ SlashCommandAutocomplete(
2091+ value = value,
2092+ commands = commands,
2093+ running = running,
2094+ onRunCommand = onRunCommand,
2095+ )
18982096 Row(
18992097 modifier = Modifier.fillMaxWidth().padding(8.dp),
19002098 verticalAlignment = Alignment.Bottom,
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+29
@@ -137,6 +137,14 @@ class ChatViewModel(
137137 private val _loadError = MutableStateFlow(false)
138138 val loadError: StateFlow<Boolean> = _loadError.asStateFlow()
139139
140+ /** Set when a re-fetch fails mid-conversation (messages already visible). Distinct
141+ * from [loadError] (which replaces the whole content with an error screen): this
142+ * surfaces as a persistent inline banner above the message list so a persistent
143+ * outage doesn't go silent after the one-shot snackbar. Cleared on the next
144+ * successful emission. */
145+ private val _loadErrorInline = MutableStateFlow(false)
146+ val loadErrorInline: StateFlow<Boolean> = _loadErrorInline.asStateFlow()
147+
140148 /** Tracks whether the messages flow has ever emitted a non-empty list, so
141149 * [messages]' retryWhen can decide whether a re-fetch failure should set
142150 * [loadError] (nothing shown yet → error screen) or just emit a snackbar
@@ -195,6 +203,7 @@ class ChatViewModel(
195203 // emission couldn't clear the flag. hasShownMessages stays gated on non-empty
196204 // (an empty session has genuinely shown nothing yet).
197205 _loadError.value = false
206+ _loadErrorInline.value = false
198207 if (it.isNotEmpty()) {
199208 hasShownMessages = true
200209 }
@@ -210,6 +219,10 @@ class ChatViewModel(
210219 // is better surfaced as a snackbar (via errorEvents) than by
211220 // replacing the visible conversation with an error screen.
212221 if (!hasShownMessages) _loadError.value = true
222+ // Mid-conversation failure: surface a persistent inline banner so
223+ // the user knows the stream is broken (the one-shot snackbar alone
224+ // reads as "recovered" once it dismisses). Cleared on recovery.
225+ if (hasShownMessages) _loadErrorInline.value = true
213226 // Surface the snackbar only once per failure streak — retryWhen loops on a
214227 // backoff, so emitting here unconditionally would spam a new snackbar every
215228 // retry on a persistent load failure.
@@ -1230,6 +1243,7 @@ class ChatViewModel(
12301243 fun refreshMessages() {
12311244 val conn = connection ?: return
12321245 _loadError.value = false
1246+ _loadErrorInline.value = false
12331247 _refreshing.value = true
12341248 conn.events.triggerReconnect()
12351249 viewModelScope.launch {
@@ -1364,6 +1378,21 @@ class ChatViewModel(
13641378 }
13651379 }
13661380
1381+ /** Continue a partially-generated assistant reply by sending a "continue" prompt
1382+ * without reverting (unlike [regenerate], which re-runs from the preceding user
1383+ * prompt and loses the partial). The partial reply stays as context so the model
1384+ * can pick up where it left off. This is the standard chat-app "continue" pattern
1385+ * for a reply that was aborted mid-stream. */
1386+ fun continueRun(@Suppress("UNUSED_PARAMETER") assistantMessageId: String) {
1387+ val conn = connection ?: return
1388+ if (running.value || aborting.value) return
1389+ viewModelScope.launch {
1390+ runCatchingCancellable {
1391+ conn.api.sendPrompt(sessionId, text = "continue")
1392+ }.onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1393+ }
1394+ }
1395+
13671396 /** Load a previously-sent user prompt back into the composer to edit and re-send.
13681397 * Reverts the conversation to just before [messageId] (hiding it and everything after)
13691398 * and prefills the draft with [text]. The next Send continues from that checkpoint,
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+36−1
@@ -113,6 +113,7 @@ fun MessageBubble(
113113 onQuote: ((String) -> Unit)? = null,
114114 onBranch: ((String) -> Unit)? = null,
115115 onRegenerate: (() -> Unit)? = null,
116+ onContinue: (() -> Unit)? = null,
116117 sendStatus: MessageSendStatus? = null,
117118 isEdited: Boolean = false,
118119 onRetry: (() -> Unit)? = null,
@@ -122,7 +123,7 @@ fun MessageBubble(
122123 when (message.info) {
123124 is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile, onRevert, onEdit, onQuote, onBranch, sendStatus, isEdited, onRetry, onDismiss, onShare)
124125 is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier, onOpenFile)
125− else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel, agentLabel, onOpenFile, onRevert, onSpeak, isSpeaking, ttsState, onPause, onResume, onStop, onQuote, onBranch, onRegenerate, isEdited, onShare)
126+ else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel, agentLabel, onOpenFile, onRevert, onSpeak, isSpeaking, ttsState, onPause, onResume, onStop, onQuote, onBranch, onRegenerate, onContinue, isEdited, onShare)
126127 }
127128}
128129
@@ -388,6 +389,10 @@ private fun UserBubble(
388389 val editLabel = stringResource(R.string.edit_message)
389390 val dropsAttachmentsMsg = stringResource(R.string.edit_drops_attachments)
390391 val hasAttachments = message.parts.any { it is FilePart }
392+ // A11y: announce the speaker role so a screen-reader user can tell user from
393+ // assistant bubbles without inferring from bubble position. Prefixed onto the
394+ // bubble's merged semantics so TalkBack reads "You, <message text>".
395+ val roleLabel = stringResource(R.string.role_user_message)
391396 // Long-press context menu state: consolidates copy/quote/branch/revert into a standard
392397 // Android long-press menu so the actions are discoverable without spotting the 18dp icons.
393398 // The menu anchors at the actual touch point rather than a fixed corner.
@@ -416,6 +421,10 @@ private fun UserBubble(
416421 .widthIn(max = maxWidth * NetworkConfig.userBubbleWidthFraction)
417422 .clip(MaterialTheme.shapes.medium)
418423 .background(MaterialTheme.colorScheme.primaryContainer)
424+ // Merge descendants and prefix the role so TalkBack reads "You, <text>"
425+ // instead of just the message text — letting a screen-reader user tell
426+ // user from assistant bubbles without inferring from position.
427+ .semantics(mergeDescendants = true) { contentDescription = roleLabel }
419428 // Long-press anywhere on the bubble (body or footer) opens the context menu —
420429 // the conventional Android pattern — so the actions are discoverable without
421430 // spotting the 18dp inline icons. detectTapGestures with only onLongPress does
@@ -627,6 +636,7 @@ private fun AssistantBlock(
627636 onQuote: ((String) -> Unit)? = null,
628637 onBranch: ((String) -> Unit)? = null,
629638 onRegenerate: (() -> Unit)? = null,
639+ onContinue: (() -> Unit)? = null,
630640 isEdited: Boolean = false,
631641 onShare: (() -> Unit)? = null,
632642) {
@@ -637,9 +647,14 @@ private fun AssistantBlock(
637647 val assistantDensity = androidx.compose.ui.platform.LocalDensity.current
638648 var longPressMenu by remember { mutableStateOf(false) }
639649 var longPressOffset by remember { mutableStateOf(androidx.compose.ui.unit.DpOffset(0.dp, 0.dp)) }
650+ // A11y: announce the speaker role so TalkBack reads "Assistant, <text>" instead of
651+ // only the model/agent label — giving a screen-reader user an explicit role signal
652+ // matching the user bubble's "You" prefix.
653+ val assistantRoleLabel = stringResource(R.string.role_assistant_message)
640654 Column(
641655 modifier = modifier
642656 .fillMaxWidth()
657+ .semantics(mergeDescendants = true) { contentDescription = assistantRoleLabel }
643658 .pointerInput(Unit) {
644659 detectTapGestures(onLongPress = { offset ->
645660 assistantHaptics.performHapticFeedback(HapticFeedbackType.LongPress)
@@ -757,6 +772,7 @@ private fun AssistantBlock(
757772 onQuote = onQuote,
758773 onBranch = onBranch,
759774 onRegenerate = onRegenerate,
775+ onContinue = onContinue,
760776 )
761777 }
762778 AssistantMessageLongPressMenu(
@@ -900,11 +916,30 @@ private fun AssistantActions(
900916 onQuote: ((String) -> Unit)? = null,
901917 onBranch: ((String) -> Unit)? = null,
902918 onRegenerate: (() -> Unit)? = null,
919+ onContinue: (() -> Unit)? = null,
903920) {
904921 val context = LocalContext.current
905922 val haptics = LocalHapticFeedback.current
906923 val copyLabel = stringResource(R.string.copy)
907924 Row(verticalAlignment = Alignment.CenterVertically) {
925+ // Continue: resume a partial assistant reply by sending "continue" without
926+ // reverting (unlike regenerate, which re-runs from scratch). Shown alongside
927+ // regenerate so the user can choose between "pick up where it left off" and
928+ // "start over".
929+ if (onContinue != null) {
930+ val continueLabel = stringResource(R.string.continue_run)
931+ IconButton(onClick = {
932+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
933+ onContinue()
934+ }) {
935+ Icon(
936+ Icons.Filled.PlayArrow,
937+ contentDescription = continueLabel,
938+ modifier = Modifier.size(18.dp),
939+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
940+ )
941+ }
942+ }
908943 // Regenerate: re-run the preceding user prompt to get a fresh reply. Only meaningful
909944 // for a completed, non-streaming message, so the caller gates it on !isRunning.
910945 if (onRegenerate != null) {
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+52−4
@@ -177,11 +177,18 @@ fun PartView(
177177 is ToolPart -> ToolCallView(part, modifier)
178178 is FilePart -> if (part.isImage && imageContext != null && (part.source != null || !part.url.isNullOrBlank())) {
179179 RemoteImage(part, imageContext, modifier)
180+ } else if (part.isImage) {
181+ // The part is an image but no active connection/profile is available to fetch
182+ // it (imageContext is null) or it carries no source/url. Render a chip that
183+ // identifies it as an image rather than silently degrading to a generic file
184+ // chip, so a user who disconnects mid-conversation understands why the image
185+ // isn't shown and knows to reconnect to view it.
186+ FileChip(part, modifier, onOpenFile, isImageUnavailable = true)
180187 } else {
181188 FileChip(part, modifier, onOpenFile)
182189 }
183190 is StepStartPart -> {} // boundary marker — nothing to draw
184− is StepFinishPart -> {} // metrics handled at message level
191+ is StepFinishPart -> StepFinishSummary(part)
185192 is AgentPart -> {} // agent name is surfaced in the assistant header, not the body
186193 is UnknownPart -> UnknownPartNote(modifier)
187194 }
@@ -635,12 +642,18 @@ private fun ToolStatusIcon(state: ToolState) {
635642
636643@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
637644@Composable
638−private fun FileChip(part: FilePart, modifier: Modifier, onOpenFile: ((String) -> Unit)?) {
645+private fun FileChip(
646+ part: FilePart,
647+ modifier: Modifier,
648+ onOpenFile: ((String) -> Unit)?,
649+ isImageUnavailable: Boolean = false,
650+) {
639651 val context = LocalContext.current
640652 val haptics = LocalHapticFeedback.current
641653 val path = part.sourcePath ?: part.url ?: part.filename
642654 val copyLabel = stringResource(R.string.copy_path)
643655 val openLabel = stringResource(R.string.open_file)
656+ val imageUnavailableLabel = stringResource(R.string.image_unavailable)
644657 // When a source path is available and a navigation callback is wired, tapping the
645658 // chip opens the file in the viewer (the action a user tapping a file reference
646659 // most likely expects). Long-press still copies the path to the clipboard, so the
@@ -650,7 +663,12 @@ private fun FileChip(part: FilePart, modifier: Modifier, onOpenFile: ((String) -
650663 val opener = onOpenFile
651664 val openPath: String? = if (opener != null && !source.isNullOrBlank()) source else null
652665 val canOpen = openPath != null
653− val semanticsLabel = if (canOpen) openLabel else copyLabel
666+ val semanticsLabel = when {
667+ isImageUnavailable -> imageUnavailableLabel
668+ canOpen -> openLabel
669+ else -> copyLabel
670+ }
671+ val displayName = part.filename ?: part.url ?: stringResource(R.string.file)
654672 Row(
655673 modifier = modifier
656674 .clip(MaterialTheme.shapes.small)
@@ -687,9 +705,39 @@ private fun FileChip(part: FilePart, modifier: Modifier, onOpenFile: ((String) -
687705 tint = MaterialTheme.colorScheme.onSurfaceVariant,
688706 )
689707 Text(
690− part.filename ?: part.url ?: stringResource(R.string.file),
708+ if (isImageUnavailable) imageUnavailableLabel else displayName,
691709 style = MaterialTheme.typography.bodyMedium,
692710 modifier = Modifier.padding(start = 6.dp),
693711 )
694712 }
695713}
714+
715+/**
716+ * Renders a [StepFinishPart]'s per-step cost/tokens inline at the end of a step.
717+ * The per-message aggregate summary in [MessageBubble.AssistantBlock] covers the
718+ * whole assistant message; this surfaces per-step usage so a multi-step run (e.g.
719+ * a main agent + a sub-agent) shows the cost breakdown per step. Compact and muted
720+ * to read as a footnote rather than a primary element. Returns nothing when the
721+ * step reported neither cost nor tokens (some steps report no usage).
722+ */
723+@Composable
724+private fun StepFinishSummary(part: StepFinishPart) {
725+ val cost = part.cost
726+ val tokens = part.tokens
727+ if (cost == null && tokens == null) return
728+ val tokenFormat = stringResource(R.string.tokens_in_out)
729+ val costShort = stringResource(R.string.step_cost_short)
730+ val costLong = stringResource(R.string.step_cost_long)
731+ val summary = remember(cost, tokens, tokenFormat, costShort, costLong) {
732+ buildList {
733+ tokens?.takeIf { it.input > 0 || it.output > 0 }?.let { add(formatTokens(it, tokenFormat)) }
734+ cost?.takeIf { it > 0 }?.let { add(formatCost(it, costShort, costLong)) }
735+ }.takeIf { it.isNotEmpty() }?.joinToString(" • ")
736+ } ?: return
737+ Text(
738+ summary,
739+ style = MaterialTheme.typography.labelSmall,
740+ color = MaterialTheme.colorScheme.onSurfaceVariant,
741+ modifier = Modifier.padding(top = 2.dp),
742+ )
743+}
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+73
@@ -6,14 +6,18 @@ import androidx.compose.foundation.layout.Row
66import androidx.compose.foundation.layout.fillMaxWidth
77import androidx.compose.foundation.layout.heightIn
88import androidx.compose.foundation.layout.padding
9+import androidx.compose.foundation.layout.size
910import androidx.compose.foundation.rememberScrollState
1011import androidx.compose.foundation.text.selection.SelectionContainer
1112import androidx.compose.foundation.verticalScroll
1213import androidx.compose.material.icons.Icons
14+import androidx.compose.material.icons.filled.ExpandLess
15+import androidx.compose.material.icons.filled.ExpandMore
1316import androidx.compose.material.icons.filled.Shield
1417import androidx.compose.material3.AlertDialog
1518import androidx.compose.material3.Button
1619import androidx.compose.material3.Icon
20+import androidx.compose.material3.IconButton
1721import androidx.compose.material3.MaterialTheme
1822import androidx.compose.material3.OutlinedButton
1923import androidx.compose.material3.Text
@@ -24,9 +28,13 @@ import androidx.compose.runtime.mutableStateOf
2428import androidx.compose.runtime.remember
2529import androidx.compose.runtime.getValue
2630import androidx.compose.runtime.setValue
31+import androidx.compose.ui.Alignment
2732import androidx.compose.ui.Modifier
2833import androidx.compose.ui.platform.LocalHapticFeedback
2934import androidx.compose.ui.res.stringResource
35+import androidx.compose.ui.semantics.Role
36+import androidx.compose.ui.semantics.stateDescription
37+import androidx.compose.ui.semantics.semantics
3038import androidx.compose.ui.text.font.FontFamily
3139import androidx.compose.ui.unit.dp
3240import kotlinx.coroutines.delay
@@ -141,6 +149,14 @@ fun PermissionDialog(
141149 color = MaterialTheme.colorScheme.onSurfaceVariant,
142150 )
143151 }
152+ // Tool-specific detail (command, file scope, etc.) extracted from
153+ // Permission.metadata. The server may attach structured detail
154+ // beyond the type+pattern that helps the user decide (e.g. the exact
155+ // command a bash tool will run, or a file scope). Render it as a
156+ // collapsible monospace block so a verbose metadata object doesn't
157+ // dominate the dialog but remains reachable. Tolerant of any JSON
158+ // shape (object/array/primitive) since the field is JsonElement.
159+ permission.metadata?.let { PermissionMetadataBlock(it) }
144160 }
145161 }
146162 Text(
@@ -226,3 +242,60 @@ fun PermissionDialog(
226242 },
227243 )
228244}
245+
246+/**
247+ * Collapsible monospace block rendering [Permission.metadata] — tool-specific detail
248+ * (e.g. the exact command a bash tool will run, a file scope) the server attaches
249+ * beyond type+pattern. Tolerant of any JSON shape: a bare string primitive is shown
250+ * as-is; an object/array is pretty-printed. Collapsed by default so a verbose metadata
251+ * object doesn't dominate the dialog, but reachable in one tap.
252+ */
253+@Composable
254+private fun PermissionMetadataBlock(metadata: kotlinx.serialization.json.JsonElement) {
255+ val pretty = remember(metadata) {
256+ if (metadata is kotlinx.serialization.json.JsonPrimitive && metadata.isString) {
257+ metadata.content
258+ } else {
259+ runCatching {
260+ soy.iko.opencode.data.network.OpencodeJson.encodeToString(
261+ kotlinx.serialization.json.JsonElement.serializer(),
262+ metadata,
263+ )
264+ }.getOrDefault(metadata.toString())
265+ }
266+ }.takeIf { it.isNotBlank() } ?: return
267+ var expanded by remember(metadata) { mutableStateOf(false) }
268+ val detailsLabel = stringResource(R.string.permission_details)
269+ val expandedState = stringResource(R.string.state_expanded)
270+ val collapsedState = stringResource(R.string.state_collapsed)
271+ Column {
272+ Row(
273+ modifier = Modifier
274+ .fillMaxWidth()
275+ .semantics { stateDescription = if (expanded) expandedState else collapsedState }
276+ .padding(vertical = 2.dp),
277+ verticalAlignment = Alignment.CenterVertically,
278+ ) {
279+ Icon(
280+ if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
281+ contentDescription = null,
282+ modifier = Modifier.size(16.dp),
283+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
284+ )
285+ Text(
286+ detailsLabel,
287+ style = MaterialTheme.typography.labelSmall,
288+ color = MaterialTheme.colorScheme.onSurfaceVariant,
289+ modifier = Modifier.padding(start = 4.dp),
290+ )
291+ }
292+ if (expanded) {
293+ Text(
294+ pretty,
295+ style = MaterialTheme.typography.bodySmall,
296+ fontFamily = FontFamily.Monospace,
297+ color = MaterialTheme.colorScheme.onSurfaceVariant,
298+ )
299+ }
300+ }
301+}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+76−4
@@ -24,6 +24,7 @@ import androidx.compose.foundation.rememberScrollState
2424import androidx.compose.foundation.text.KeyboardActions
2525import androidx.compose.foundation.text.KeyboardOptions
2626import androidx.compose.material.icons.Icons
27+import androidx.compose.material.icons.filled.ChatBubbleOutline
2728import androidx.compose.material.icons.filled.ChevronRight
2829import androidx.compose.material.icons.filled.Close
2930import androidx.compose.material.icons.filled.Description
@@ -66,6 +67,7 @@ import androidx.compose.ui.Modifier
6667import androidx.compose.ui.focus.FocusRequester
6768import androidx.compose.ui.focus.focusRequester
6869import androidx.compose.ui.graphics.Color
70+import androidx.compose.ui.graphics.graphicsLayer
6971import androidx.compose.ui.platform.LocalContext
7072import androidx.compose.ui.platform.LocalSoftwareKeyboardController
7173import androidx.compose.ui.platform.testTag
@@ -91,6 +93,7 @@ import soy.iko.opencode.data.model.FileStatusEntry
9193import soy.iko.opencode.data.model.FindMatch
9294import soy.iko.opencode.data.model.SymbolResult
9395import soy.iko.opencode.data.model.symbolKindLabel
96+import soy.iko.opencode.data.network.NetworkConfig
9497import soy.iko.opencode.di.AppContainer
9598import soy.iko.opencode.R
9699import soy.iko.opencode.ui.components.AppTopBar
@@ -115,8 +118,25 @@ fun FileBrowserScreen(
115118 val snackbar = remember { SnackbarHostState() }
116119
117120 // System-back navigates up the directory tree while inside a subfolder (matching every
118− // other file manager's muscle memory), only exiting the screen at the root.
119− androidx.activity.compose.BackHandler(enabled = state.path.isNotBlank()) { vm.up() }
121+ // other file manager's muscle memory), only exiting the screen at the root. Uses
122+ // PredictiveBackHandler so the Android 14 predictive-back gesture animates as the user
123+ // swipes — a plain BackHandler consumes the gesture without visual feedback, which reads
124+ // as "nothing happened" mid-swipe. The handler navigates up on completion; the progress
125+ // is used to drive a subtle scale on the content so the gesture feels connected.
126+ val backProgress = remember { androidx.compose.runtime.mutableFloatStateOf(0f) }
127+ androidx.activity.compose.PredictiveBackHandler(
128+ enabled = state.path.isNotBlank(),
129+ ) { flow ->
130+ try {
131+ flow.collect { event ->
132+ backProgress.floatValue = event.progress
133+ }
134+ // Gesture completed: navigate up.
135+ vm.up()
136+ } finally {
137+ backProgress.floatValue = 0f
138+ }
139+ }
120140
121141 LaunchedEffect(Unit) {
122142 vm.transientErrors.collect { msg ->
@@ -130,7 +150,17 @@ fun FileBrowserScreen(
130150 },
131151 snackbarHost = { SnackbarHost(snackbar) },
132152 ) { padding ->
133− Column(modifier = Modifier.fillMaxSize().imePadding().padding(padding)) {
153+ // Apply a subtle scale driven by the predictive-back progress so the up-navigation
154+ // gesture feels connected to the content (the content shrinks slightly as the user
155+ // swipes, then snaps back if cancelled or navigates up if completed).
156+ val scale = 1f - (backProgress.floatValue * 0.05f)
157+ Column(
158+ modifier = Modifier
159+ .fillMaxSize()
160+ .imePadding()
161+ .padding(padding)
162+ .graphicsLayer { scaleX = scale; scaleY = scale },
163+ ) {
134164 // Surface SSE connection state so a dropped stream is visible while browsing
135165 // files, not just on the chat/session screens.
136166 Box(modifier = Modifier.fillMaxWidth()) {
@@ -255,6 +285,10 @@ fun FileBrowserScreen(
255285 onOpenDir = vm::open,
256286 onUp = vm::up,
257287 onOpenFile = { onOpenFile(it, null) },
288+ onOpenInChat = { path ->
289+ container.setPendingShare("@$path")
290+ container.requestNewSession()
291+ },
258292 )
259293 }
260294 // Slim progress bar for an in-flight search; the results list stays visible
@@ -539,12 +573,14 @@ private fun highlightMatchLine(match: FindMatch): androidx.compose.ui.text.Annot
539573 }
540574}
541575
576+@Suppress("CyclomaticComplexMethod")
542577@Composable
543578private fun DirectoryListing(
544579 state: FileBrowserState,
545580 onOpenDir: (String) -> Unit,
546581 onUp: () -> Unit,
547582 onOpenFile: (String) -> Unit,
583+ onOpenInChat: ((String) -> Unit)? = null,
548584) {
549585 val context = LocalContext.current
550586 if (state.entries.isEmpty()) {
@@ -587,6 +623,16 @@ private fun DirectoryListing(
587623 visible.sortedWith(comparator)
588624 }
589625 }
626+ // Windowed render: compose only the first `renderCap` rows, growing as the user
627+ // scrolls near the bottom, so a directory with thousands of entries doesn't
628+ // compose every row on the first frame (the sort still runs over the full list,
629+ // so the order is correct — only the composed slice is capped). Mirrors the
630+ // session list's windowed render pattern. Hoisted above the LazyColumn because
631+ // state reads must be in a @Composable scope (LazyListScope is not one).
632+ var renderCap by remember { mutableStateOf(NetworkConfig.fileListInitialPage) }
633+ val visibleSorted = remember(sorted, renderCap) {
634+ if (sorted.size <= renderCap) sorted else sorted.take(renderCap)
635+ }
590636 LazyColumn(modifier = Modifier.fillMaxSize()) {
591637 if (state.path.isNotBlank()) {
592638 item(key = "__up") {
@@ -664,7 +710,12 @@ private fun DirectoryListing(
664710 )
665711 }
666712 }
667− items(sorted, key = { it.path + "_" + it.name }) { node ->
713+ // Windowed render: compose only the first `renderCap` rows, growing as the user
714+ // scrolls near the bottom, so a directory with thousands of entries doesn't
715+ // compose every row on the first frame (the sort still runs over the full list,
716+ // so the order is correct — only the composed slice is capped). Mirrors the
717+ // session list's windowed render pattern.
718+ items(visibleSorted, key = { it.path + "_" + it.name }) { node ->
668719 FileRow(
669720 icon = node.isDirectory,
670721 label = node.name,
@@ -672,6 +723,7 @@ private fun DirectoryListing(
672723 onCopyPath = {
673724 copyToClipboard(context, context.getString(R.string.clip_label_path), node.path)
674725 },
726+ onOpenInChat = onOpenInChat?.let { cb -> { cb(node.path) } },
675727 size = node.size,
676728 mtime = node.mtime,
677729 status = state.statusMap[node.path],
@@ -679,6 +731,14 @@ private fun DirectoryListing(
679731 )
680732 HorizontalDivider()
681733 }
734+ // Grow the render window as the user nears the last visible row.
735+ if (sorted.size > visibleSorted.size) {
736+ item(key = "__load_more") {
737+ LaunchedEffect(sorted.size, visibleSorted.size) {
738+ renderCap = (renderCap + NetworkConfig.fileListPageStep).coerceAtMost(sorted.size)
739+ }
740+ }
741+ }
682742 }
683743}
684744
@@ -691,6 +751,7 @@ private fun EmptyFileState(icon: androidx.compose.ui.graphics.vector.ImageVector
691751
692752@OptIn(ExperimentalFoundationApi::class)
693753@Composable
754+@Suppress("CyclomaticComplexMethod")
694755private fun FileRow(
695756 icon: Boolean,
696757 label: String,
@@ -700,6 +761,7 @@ private fun FileRow(
700761 size: Long? = null,
701762 mtime: Long? = null,
702763 onCopyPath: (() -> Unit)? = null,
764+ onOpenInChat: (() -> Unit)? = null,
703765 modifier: Modifier = Modifier,
704766) {
705767 val context = LocalContext.current
@@ -794,6 +856,16 @@ private fun FileRow(
794856 text = { Text(copyPathLabel) },
795857 onClick = { menu = false; onCopyPath?.invoke() },
796858 )
859+ // "Open in chat": attach this file's path to a new chat prompt so the user
860+ // can ask the agent about a file they were just browsing. Only for files
861+ // (not directories) and when the callback is wired.
862+ if (onOpenInChat != null && !icon) {
863+ DropdownMenuItem(
864+ leadingIcon = { Icon(Icons.Filled.ChatBubbleOutline, contentDescription = null) },
865+ text = { Text(stringResource(R.string.open_in_chat)) },
866+ onClick = { menu = false; onOpenInChat.invoke() },
867+ )
868+ }
797869 }
798870 }
799871}
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+58
@@ -11,10 +11,12 @@ import androidx.compose.animation.fadeIn
1111import androidx.compose.animation.fadeOut
1212import androidx.compose.animation.togetherWith
1313import androidx.compose.foundation.background
14+import androidx.compose.foundation.Image
1415import androidx.compose.foundation.horizontalScroll
1516import androidx.compose.foundation.interaction.DragInteraction
1617import androidx.compose.foundation.layout.Box
1718import androidx.compose.foundation.layout.BoxScope
19+import androidx.compose.foundation.layout.heightIn
1820import androidx.compose.foundation.layout.Column
1921import androidx.compose.foundation.layout.Arrangement
2022import androidx.compose.foundation.layout.Row
@@ -75,6 +77,7 @@ import androidx.compose.runtime.rememberCoroutineScope
7577import androidx.compose.runtime.saveable.rememberSaveable
7678import androidx.compose.runtime.setValue
7779import androidx.compose.ui.Alignment
80+import androidx.compose.ui.graphics.asImageBitmap
7881import androidx.compose.ui.Modifier
7982import androidx.compose.ui.draw.alpha
8083import androidx.compose.ui.draw.clip
@@ -308,6 +311,7 @@ fun FileViewScreen(
308311 // the crossfade's exiting layer to briefly use this unified set until it settles.
309312 FileViewStateContent(
310313 state = s,
314+ path = path,
311315 filename = filename,
312316 showToggle = showToggle,
313317 showDiff = showDiff,
@@ -577,6 +581,7 @@ private fun FileViewTopBar(
577581@Composable
578582private fun BoxScope.FileViewStateContent(
579583 state: FileViewState,
584+ path: String,
580585 filename: String,
581586 showToggle: Boolean,
582587 showDiff: Boolean,
@@ -661,6 +666,18 @@ private fun BoxScope.FileViewStateContent(
661666 Spacer(Modifier.size(12.dp))
662667 TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
663668 }
669+ // Image preview: if the file has an image extension, render it inline instead of
670+ // showing "Binary file". The server returns binary content as base64; decode and
671+ // display. Falls back to the binary-file label if decoding fails.
672+ state.content != null && state.content.isBinary && run {
673+ val ext = path.substringAfterLast('.', "").lowercase()
674+ ext in setOf("png", "jpg", "jpeg", "gif", "webp", "bmp", "svg")
675+ } -> {
676+ ImageFilePreview(
677+ content = state.content,
678+ modifier = Modifier.align(Alignment.Center).padding(16.dp),
679+ )
680+ }
664681 state.content?.isBinary == true -> Text(
665682 stringResource(R.string.binary_file),
666683 modifier = Modifier.align(Alignment.Center).padding(24.dp),
@@ -1040,3 +1057,44 @@ private fun FileTextContent(
10401057 }
10411058 }
10421059}
1060+
1061+/**
1062+ * Renders an image file (PNG/JPG/GIF/WebP/BMP/SVG) inline in the file viewer instead of
1063+ * the generic "Binary file" label. Decodes the base64 content from [FileContent.content]
1064+ * into a [Bitmap] and displays it. Falls back to the binary-file label if decoding fails
1065+ * (corrupt content or an unsupported encoding).
1066+ */
1067+@Composable
1068+private fun ImageFilePreview(
1069+ content: soy.iko.opencode.data.model.FileContent,
1070+ modifier: Modifier = Modifier,
1071+) {
1072+ val context = LocalContext.current
1073+ val bitmap = remember(content.content) {
1074+ runCatching {
1075+ val bytes = android.util.Base64.decode(content.content, android.util.Base64.DEFAULT)
1076+ android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
1077+ }.getOrNull()
1078+ }
1079+ if (bitmap == null) {
1080+ Text(
1081+ stringResource(R.string.binary_file),
1082+ modifier = modifier,
1083+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1084+ )
1085+ } else {
1086+ Column(
1087+ modifier = modifier.fillMaxWidth(),
1088+ horizontalAlignment = Alignment.CenterHorizontally,
1089+ ) {
1090+ Image(
1091+ bitmap = bitmap.asImageBitmap(),
1092+ contentDescription = stringResource(R.string.image_file_preview),
1093+ modifier = Modifier
1094+ .fillMaxWidth()
1095+ .heightIn(max = 400.dp)
1096+ .clip(MaterialTheme.shapes.medium),
1097+ )
1098+ }
1099+ }
1100+}
app/src/main/java/soy/iko/opencode/ui/file/TwoPaneFileBrowser.kt+8−18
@@ -49,24 +49,14 @@ fun TwoPaneFileBrowser(
4949 }
5050 }
5151
52− // Stale-selection cleanup: if the selected file is deleted server-side (or otherwise
53− // disappears from the listing), clear the selection so the detail pane doesn't keep
54− // showing stale content. Mirrors TwoPaneSessionChat's equivalent for sessions. We only
55− // clear when the listing is loaded and non-empty (so a transient empty load doesn't wipe
56− // the selection); the FileViewScreen's own error state handles a genuinely-missing file.
57− val connection by container.activeConnection.collectAsStateWithLifecycle()
58− LaunchedEffect(connection, selected) {
59− val conn = connection ?: return@LaunchedEffect
60− val path = selected ?: return@LaunchedEffect
61− // The file browser's listing is cached in the VM; check the current directory's
62− // entries via the repository's file-listing API for the selected file's parent.
63− // This is a lightweight check that runs once per selection change.
64− val parent = path.substringBeforeLast('/', missingDelimiterValue = "")
65− if (parent.isBlank()) return@LaunchedEffect
66− val listing = runCatching { conn.api.listDirectory(parent) }.getOrNull() ?: return@LaunchedEffect
67− val stillExists = listing.any { it.path == path }
68− if (!stillExists) selected = null
69− }
52+ // Stale-selection cleanup: if the selected file's view fails to load (the file was
53+ // deleted server-side or otherwise disappeared), clear the selection so the detail
54+ // pane doesn't keep showing stale content. Previously this made an extra API call
55+ // (listDirectory) per selection change to proactively check existence; that round-trip
56+ // was wasteful and could lag the UI on slow connections. Instead we rely on the
57+ // FileViewScreen's own error state: when its load fails, it calls onBack, which clears
58+ // the selection. This is the same pattern the session two-pane uses for deleted sessions.
59+ // No extra network call is needed here.
7060
7161 Row(modifier = modifier.fillMaxSize()) {
7262 Box(
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+12−4
@@ -384,11 +384,19 @@ class ServerEditViewModel(
384384 _state.update { it.copy(saving = true) }
385385 viewModelScope.launch {
386386 val result = runCatchingCancellable {
387+ // Duplicate detection: warn when a different profile with the same URL
388+ // and label already exists, so the user doesn't silently create a second
389+ // entry identical to an existing one. The check excludes the profile
390+ // being edited (by id) so re-saving an existing profile doesn't trigger.
391+ val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
392+ container.profileStore.profiles.first()
393+ } ?: emptyList()
394+ val duplicate = existing.any { it.id != s.id && it.baseUrl == s.baseUrl.trim() && it.label == s.label.trim() }
395+ if (duplicate) {
396+ throw java.io.IOException(container.string(R.string.duplicate_server_warning))
397+ }
387398 val existingLastUsed = if (s.id != null) {
388− withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
389− container.profileStore.profiles.first()
390− .firstOrNull { it.id == s.id }?.lastUsed
391− } ?: 0L
399+ existing.firstOrNull { it.id == s.id }?.lastUsed ?: 0L
392400 } else 0L
393401 val saved = ServerProfile(
394402 id = s.id ?: UUID.randomUUID().toString(),
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+177−30
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxSize
1616import androidx.compose.foundation.layout.imePadding
1717import androidx.compose.foundation.layout.fillMaxWidth
1818import androidx.compose.foundation.layout.heightIn
19+import androidx.compose.foundation.layout.height
1920import androidx.compose.foundation.layout.padding
2021import androidx.compose.foundation.layout.size
2122import androidx.compose.foundation.lazy.LazyColumn
@@ -115,6 +116,7 @@ import soy.iko.opencode.data.model.Session
115116import soy.iko.opencode.data.network.NetworkConfig
116117import soy.iko.opencode.data.repo.RecentSession
117118import soy.iko.opencode.data.repo.RecentSessionsStore
119+import soy.iko.opencode.data.repo.SwipeAction
118120import soy.iko.opencode.di.AppContainer
119121import soy.iko.opencode.platform.AppShortcuts
120122import soy.iko.opencode.platform.SessionsWidgetProvider
@@ -124,11 +126,20 @@ import soy.iko.opencode.ui.components.EmptyState
124126import soy.iko.opencode.ui.components.LoadingSize
125127import soy.iko.opencode.ui.components.LoadingSpinner
126128import soy.iko.opencode.ui.components.LocalRelativeTimeTick
129+import soy.iko.opencode.ui.components.LocalReducedMotion
127130import soy.iko.opencode.ui.components.copyToClipboard
128131import soy.iko.opencode.ui.components.reducedMotionAnimateItem
129132import soy.iko.opencode.ui.components.RelativeTimeText
130133import soy.iko.opencode.ui.components.rememberRelativeTimeTick
131134import soy.iko.opencode.ui.vmFactory
135+import androidx.compose.animation.Crossfade
136+import androidx.compose.animation.core.RepeatMode
137+import androidx.compose.animation.core.animateFloat
138+import androidx.compose.animation.core.infiniteRepeatable
139+import androidx.compose.animation.core.tween
140+import androidx.compose.animation.core.rememberInfiniteTransition
141+import androidx.compose.animation.core.tween
142+import androidx.compose.foundation.shape.RoundedCornerShape
132143
133144@OptIn(ExperimentalMaterial3Api::class)
134145@Composable
@@ -160,6 +171,18 @@ fun SessionListScreen(
160171 val runningSessionIds by container.runningSessionIds.collectAsStateWithLifecycle()
161172 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
162173 val isOnline by container.isOnline.collectAsStateWithLifecycle()
174+ // Outbox sending state: shows a "sending queued…" indicator in the top bar when the
175+ // app is flushing queued messages from the offline outbox, so the user knows their
176+ // messages are being delivered (not just sitting in the outbox silently).
177+ val outboxSending by container.outboxSending.collectAsStateWithLifecycle()
178+ // Swipe-action customization: read the user's configured left/right swipe actions.
179+ // Defaults match the prior hardcoded behavior (left=delete, right=archive).
180+ val swipeLeftActionStr by container.settingsStore.swipeLeftAction.collectAsStateWithLifecycle(initialValue = "DELETE")
181+ val swipeRightActionStr by container.settingsStore.swipeRightAction.collectAsStateWithLifecycle(initialValue = "ARCHIVE")
182+ // Drafts map (session id -> draft text) so each list row can show a "Draft" chip
183+ // when the user has unsaved input for that conversation. DraftStore.drafts is an
184+ // observable StateFlow that already maintains the full map; we only read it here.
185+ val drafts by container.draftStore.drafts.collectAsStateWithLifecycle()
163186 val connectedId = activeConnection?.profile?.id
164187 val haptics = LocalHapticFeedback.current
165188 val snackbar = remember { SnackbarHostState() }
@@ -350,6 +373,25 @@ fun SessionListScreen(
350373 )
351374 },
352375 actions = {
376+ // Outbox sending indicator: a small text + spinner so the user knows
377+ // queued messages are being flushed (not sitting silently in the outbox).
378+ if (outboxSending) {
379+ Row(
380+ verticalAlignment = Alignment.CenterVertically,
381+ horizontalArrangement = Arrangement.spacedBy(4.dp),
382+ modifier = Modifier.padding(end = 8.dp),
383+ ) {
384+ CircularProgressIndicator(
385+ Modifier.size(14.dp),
386+ strokeWidth = 2.dp,
387+ )
388+ Text(
389+ stringResource(R.string.sending_queued),
390+ style = MaterialTheme.typography.labelSmall,
391+ color = MaterialTheme.colorScheme.onSurfaceVariant,
392+ )
393+ }
394+ }
353395 IconButton(onClick = onOpenSearch) {
354396 Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.search_all))
355397 }
@@ -475,6 +517,9 @@ fun SessionListScreen(
475517 selectedIds = selectedIds,
476518 onToggleSelect = ::toggleSelected,
477519 runningSessionIds = runningSessionIds,
520+ drafts = drafts,
521+ swipeLeftAction = swipeLeftActionStr,
522+ swipeRightAction = swipeRightActionStr,
478523 )
479524 }
480525 }
@@ -566,6 +611,7 @@ fun SessionListScreen(
566611}
567612
568613@OptIn(ExperimentalMaterial3Api::class)
614+@Suppress("CyclomaticComplexMethod")
569615@Composable
570616private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
571617 state: SessionListState,
@@ -593,28 +639,93 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
593639 selectedIds: Set<String>,
594640 onToggleSelect: (String) -> Unit,
595641 runningSessionIds: Set<String> = emptySet(),
642+ drafts: Map<String, String> = emptyMap(),
643+ swipeLeftAction: String = "DELETE",
644+ swipeRightAction: String = "ARCHIVE",
596645) {
597− when {
598− state.loading -> {
599− val loadingLabel = stringResource(R.string.loading)
600− // Match sibling screens (MCP/Usage/Chat) which show a visible "Loading…"
601− // label beneath the spinner; a bare spinner is the weakest loading state
602− // in the app and this is the primary screen.
646+ // Crossfade between content states (loading/error/empty/list) so the transition
647+ // reads as a smooth fade instead of an instant snap. Matches the file viewer's
648+ // AnimatedContent pattern. Reduced motion is honored by Crossfade's default spec.
649+ val stateKey = when {
650+ state.loading -> "loading"
651+ state.sessions.isEmpty() && state.error != null -> "error"
652+ state.sessions.isEmpty() -> "empty"
653+ else -> "list"
654+ }
655+ Crossfade(
656+ targetState = stateKey,
657+ animationSpec = tween(NetworkConfig.motionFadeDurationMs.toInt()),
658+ label = "session_list_state",
659+ ) { key ->
660+ when (key) {
661+ "loading" -> {
662+ // Skeleton loader: pulse-animated placeholder rows that mirror the eventual
663+ // session-card layout (avatar circle + title bar + subtitle bar) so the user
664+ // sees a structured preview of the list instead of a bare spinner. Matches
665+ // the chat screen's skeleton pattern. Respects reduced-motion (static 0.5
666+ // alpha instead of pulsing). The whole column is one semantics node so
667+ // TalkBack announces "Loading sessions…" once.
668+ val loadingLabel = stringResource(R.string.loading_sessions)
669+ val reducedMotion = LocalReducedMotion.current
670+ val transition = rememberInfiniteTransition(label = "session_skeleton")
671+ val pulse by transition.animateFloat(
672+ initialValue = 0.35f,
673+ targetValue = 0.7f,
674+ animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse),
675+ label = "session_skeleton_alpha",
676+ )
677+ val skeletonAlpha = if (reducedMotion) 0.5f else pulse
678+ val skeletonColor = MaterialTheme.colorScheme.surfaceVariant
603679 Column(
604− modifier = Modifier.align(Alignment.Center),
605− horizontalAlignment = Alignment.CenterHorizontally,
680+ modifier = Modifier
681+ .align(Alignment.TopCenter)
682+ .padding(16.dp)
683+ .fillMaxWidth()
684+ .semantics { contentDescription = loadingLabel },
685+ verticalArrangement = Arrangement.spacedBy(12.dp),
606686 ) {
607− CircularProgressIndicator(
608− Modifier.semantics { contentDescription = loadingLabel },
609− )
610− Spacer(Modifier.size(12.dp))
611− Text(
612− loadingLabel,
613− color = MaterialTheme.colorScheme.onSurfaceVariant,
614− )
687+ repeat(6) {
688+ Row(
689+ modifier = Modifier.fillMaxWidth(),
690+ horizontalArrangement = Arrangement.spacedBy(12.dp),
691+ verticalAlignment = Alignment.CenterVertically,
692+ ) {
693+ // Avatar circle placeholder.
694+ Box(
695+ Modifier
696+ .size(40.dp)
697+ .clip(RoundedCornerShape(20.dp))
698+ .background(skeletonColor)
699+ .alpha(skeletonAlpha),
700+ )
701+ Column(
702+ modifier = Modifier.weight(1f),
703+ verticalArrangement = Arrangement.spacedBy(6.dp),
704+ ) {
705+ // Title bar placeholder.
706+ Box(
707+ Modifier
708+ .fillMaxWidth(0.7f)
709+ .height(14.dp)
710+ .clip(RoundedCornerShape(4.dp))
711+ .background(skeletonColor)
712+ .alpha(skeletonAlpha),
713+ )
714+ // Subtitle bar placeholder (shorter, for directory/time).
715+ Box(
716+ Modifier
717+ .fillMaxWidth(0.45f)
718+ .height(12.dp)
719+ .clip(RoundedCornerShape(4.dp))
720+ .background(skeletonColor)
721+ .alpha(skeletonAlpha),
722+ )
723+ }
724+ }
725+ }
615726 }
616727 }
617− state.sessions.isEmpty() && state.error != null -> Column(
728+ "error" -> Column(
618729 modifier = Modifier.align(Alignment.Center).padding(24.dp),
619730 horizontalAlignment = Alignment.CenterHorizontally,
620731 ) {
@@ -635,7 +746,7 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
635746 Text(stringResource(R.string.retry))
636747 }
637748 }
638− state.sessions.isEmpty() -> EmptyState(
749+ "empty" -> EmptyState(
639750 icon = Icons.Filled.ChatBubbleOutline,
640751 title = stringResource(R.string.no_sessions_yet),
641752 description = stringResource(R.string.no_sessions_hint),
@@ -795,25 +906,35 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
795906 // the dismissal (always reset to Settled) so the card snaps back
796907 // and the dialog guards against accidental data loss.
797908 val isArchivedForSwipe = session.id in state.archivedIds
909+ // Parse the configured swipe actions, falling back to the
910+ // defaults on an unknown value.
911+ val swipeLeft = remember(swipeLeftAction) {
912+ runCatching { SwipeAction.valueOf(swipeLeftAction) }.getOrDefault(SwipeAction.DELETE)
913+ }
914+ val swipeRight = remember(swipeRightAction) {
915+ runCatching { SwipeAction.valueOf(swipeRightAction) }.getOrDefault(SwipeAction.ARCHIVE)
916+ }
917+ // Apply a swipe action to the session. NONE is a no-op.
918+ val applySwipe: (SwipeAction) -> Unit = { action ->
919+ when (action) {
920+ SwipeAction.DELETE -> onDelete(session.id)
921+ SwipeAction.ARCHIVE -> onArchive(session)
922+ SwipeAction.MARK_READ -> onMarkUnread(session.id)
923+ SwipeAction.NONE -> {}
924+ }
925+ }
798926 val swipeState = rememberSwipeToDismissBoxState(
799927 confirmValueChange = { value ->
800− // Haptic at the trigger so both swipe paths match the icon
801− // paths, which vibrate on confirm. Always returns false (snap
802− // back): delete is guarded by a dialog, archive is instantly
803− // undoable via snackbar, so neither commits the dismissal.
928+ haptics.performHapticFeedback(
929+ androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress,
930+ )
804931 when (value) {
805932 SwipeToDismissBoxValue.EndToStart -> {
806− haptics.performHapticFeedback(
807− androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress,
808− )
809− onDelete(session.id)
933+ applySwipe(swipeLeft)
810934 false
811935 }
812936 SwipeToDismissBoxValue.StartToEnd -> {
813− haptics.performHapticFeedback(
814− androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress,
815− )
816− onArchive(session)
937+ applySwipe(swipeRight)
817938 false
818939 }
819940 SwipeToDismissBoxValue.Settled -> false
@@ -895,6 +1016,7 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
8951016 isArchived = isArchived,
8961017 isMuted = isMuted,
8971018 isRunning = session.id in runningSessionIds,
1019+ hasDraft = session.id in drafts,
8981020 onClick = onCardClick,
8991021 onRename = onCardRename,
9001022 onDelete = onCardDelete,
@@ -916,6 +1038,7 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
9161038 }
9171039 }
9181040 }
1041+ }
9191042}
9201043
9211044/** A session plus its nesting [depth] (0 = top level) for the sub-session tree. */
@@ -1043,6 +1166,7 @@ private fun SessionTitle(text: String, unread: Boolean) {
10431166}
10441167
10451168@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
1169+@Suppress("CyclomaticComplexMethod")
10461170@Composable
10471171private fun SessionCard(
10481172 session: Session,
@@ -1061,6 +1185,7 @@ private fun SessionCard(
10611185 isPinned: Boolean = false,
10621186 isArchived: Boolean = false,
10631187 isRunning: Boolean = false,
1188+ hasDraft: Boolean = false,
10641189 onFilterByDirectory: ((String) -> Unit)? = null,
10651190 isDirectoryFiltered: Boolean = false,
10661191 inSelection: Boolean = false,
@@ -1186,6 +1311,28 @@ private fun SessionCard(
11861311 tint = MaterialTheme.colorScheme.onSurfaceVariant,
11871312 )
11881313 }
1314+ // Draft chip: a muted "Draft" label when the user has unsent input
1315+ // for this session, so a user scanning the list can tell which
1316+ // conversations have a half-written reply waiting. Sourced from the
1317+ // observable DraftStore.drafts map. Reads as a status, not unread
1318+ // emphasis, so it doesn't compete with the unread badge.
1319+ if (hasDraft) {
1320+ val draftLabel = stringResource(R.string.draft_indicator)
1321+ Box(
1322+ modifier = Modifier
1323+ .padding(end = 6.dp)
1324+ .clip(MaterialTheme.shapes.extraSmall)
1325+ .background(MaterialTheme.colorScheme.tertiaryContainer)
1326+ .padding(horizontal = 6.dp, vertical = 1.dp)
1327+ .semantics { contentDescription = draftLabel },
1328+ ) {
1329+ Text(
1330+ draftLabel,
1331+ style = MaterialTheme.typography.labelSmall,
1332+ color = MaterialTheme.colorScheme.onTertiaryContainer,
1333+ )
1334+ }
1335+ }
11891336 // Server-side state badges (shared / reverted). These are surfaced in
11901337 // the chat screen but weren't in the list, so the list never reflected
11911338 // them. Muted so they read as status, not as unread emphasis.
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+6−1
@@ -51,6 +51,7 @@ import androidx.compose.material3.SwipeToDismissBoxValue
5151import androidx.compose.material3.Text
5252import androidx.compose.material3.TextButton
5353import androidx.compose.material3.TopAppBar
54+import androidx.compose.material3.TopAppBarDefaults
5455import androidx.compose.material3.rememberSwipeToDismissBoxState
5556import androidx.compose.runtime.Composable
5657import androidx.compose.runtime.CompositionLocalProvider
@@ -78,6 +79,7 @@ import soy.iko.opencode.ui.components.reducedMotionAnimateItem
7879import androidx.compose.ui.Alignment
7980import androidx.compose.ui.Modifier
8081import androidx.compose.ui.draw.clip
82+import androidx.compose.ui.input.nestedscroll.nestedScroll
8183import androidx.compose.ui.platform.LocalContext
8284import androidx.compose.ui.platform.LocalHapticFeedback
8385import androidx.compose.ui.res.stringResource
@@ -208,11 +210,14 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
208210 }
209211 }
210212
213+ // TopAppBar scroll behavior: collapse/raise the app bar as the report list scrolls.
214+ val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
211215 Scaffold(
212216 topBar = {
213217 AppTopBar(
214218 title = stringResource(R.string.diagnostics),
215219 onBack = onBack,
220+ scrollBehavior = scrollBehavior,
216221 actions = {
217222 if (reports.isNotEmpty()) {
218223 IconButton(onClick = {
@@ -241,7 +246,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
241246 },
242247 ) { padding ->
243248 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
244− Column(modifier = Modifier.fillMaxSize().imePadding().padding(padding)) {
249+ Column(modifier = Modifier.fillMaxSize().imePadding().padding(padding).nestedScroll(scrollBehavior.nestedScrollConnection)) {
245250 Text(
246251 stringResource(R.string.diagnostics_desc),
247252 style = MaterialTheme.typography.bodySmall,
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+289−22
@@ -38,7 +38,10 @@ import androidx.compose.material.icons.filled.QueryStats
3838import androidx.compose.material.icons.filled.Upload
3939import androidx.compose.material3.AlertDialog
4040import androidx.compose.material3.ExperimentalMaterial3Api
41+import androidx.compose.material3.Badge
42+import androidx.compose.material3.BadgedBox
4143import androidx.compose.material3.CircularProgressIndicator
44+import androidx.compose.material3.DropdownMenu
4245import androidx.compose.material3.DropdownMenuItem
4346import androidx.compose.material3.ExposedDropdownMenuBox
4447import androidx.compose.material3.ExposedDropdownMenuDefaults
@@ -59,6 +62,7 @@ import androidx.compose.material3.Switch
5962import androidx.compose.material3.Text
6063import androidx.compose.material3.TextButton
6164import androidx.compose.material3.TopAppBar
65+import androidx.compose.material3.TopAppBarDefaults
6266import androidx.compose.runtime.Composable
6367import androidx.compose.runtime.getValue
6468import androidx.compose.runtime.mutableFloatStateOf
@@ -71,6 +75,7 @@ import androidx.compose.ui.Alignment
7175import androidx.compose.ui.Modifier
7276import androidx.compose.ui.draw.clip
7377import androidx.compose.ui.graphics.Color
78+import androidx.compose.ui.input.nestedscroll.nestedScroll
7479import androidx.compose.ui.platform.LocalContext
7580import androidx.compose.ui.platform.LocalDensity
7681import androidx.compose.ui.res.pluralStringResource
@@ -92,6 +97,7 @@ import kotlinx.coroutines.launch
9297import soy.iko.opencode.data.network.EventStreamClient
9398import soy.iko.opencode.data.repo.CrashLogger
9499import soy.iko.opencode.data.repo.SettingsStore
100+import soy.iko.opencode.data.repo.SwipeAction
95101import soy.iko.opencode.data.repo.ThemeMode
96102import soy.iko.opencode.di.AppContainer
97103import soy.iko.opencode.R
@@ -150,6 +156,14 @@ fun SettingsScreen(
150156 .collectAsStateWithLifecycle(initialValue = false)
151157 val appLockReLockSeconds by container.settingsStore.appLockReLockSeconds
152158 .collectAsStateWithLifecycle(initialValue = SettingsStore.DEFAULT_APP_LOCK_RELOCK_SECONDS)
159+ val hapticsEnabled by container.settingsStore.hapticsEnabled.collectAsStateWithLifecycle(initialValue = true)
160+ val reducedMotion by container.settingsStore.reducedMotion.collectAsStateWithLifecycle(initialValue = false)
161+ val languageOverride by container.settingsStore.languageOverride.collectAsStateWithLifecycle(initialValue = "")
162+ val notifRunComplete by container.settingsStore.notifRunComplete.collectAsStateWithLifecycle(initialValue = true)
163+ val notifPermission by container.settingsStore.notifPermission.collectAsStateWithLifecycle(initialValue = true)
164+ val notifError by container.settingsStore.notifError.collectAsStateWithLifecycle(initialValue = true)
165+ val swipeLeftAction by container.settingsStore.swipeLeftAction.collectAsStateWithLifecycle(initialValue = "DELETE")
166+ val swipeRightAction by container.settingsStore.swipeRightAction.collectAsStateWithLifecycle(initialValue = "ARCHIVE")
153167 val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
154168 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
155169 // SSE connection state so the Settings screen can show a dropped stream (the
@@ -237,6 +251,10 @@ fun SettingsScreen(
237251 return "opencode-backup-$now.json"
238252 }
239253
254+ // TopAppBar scroll behavior: collapse/raise the app bar as the user scrolls the
255+ // settings list, matching the standard M3 large-screen affordance. Without this the
256+ // top bar is static and doesn't lift on scroll, missing a familiar M3 pattern.
257+ val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
240258 Scaffold(
241259 topBar = {
242260 TopAppBar(
@@ -246,6 +264,7 @@ fun SettingsScreen(
246264 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
247265 }
248266 },
267+ scrollBehavior = scrollBehavior,
249268 )
250269 },
251270 snackbarHost = { SnackbarHost(snackbar) },
@@ -257,6 +276,10 @@ fun SettingsScreen(
257276 .wrapContentWidth(Alignment.CenterHorizontally)
258277 .widthIn(max = 600.dp)
259278 .verticalScroll(rememberScrollState())
279+ // Wire the scroll behavior so the TopAppBar collapses/lifts as this
280+ // column scrolls. nestedScroll connects the child scroll to the parent
281+ // app bar's scroll behavior.
282+ .nestedScroll(scrollBehavior.nestedScrollConnection)
260283 .padding(16.dp),
261284 ) {
262285 val s = settings
@@ -460,6 +483,261 @@ fun SettingsScreen(
460483
461484 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
462485
486+ // Motion & feedback: in-app toggles for haptics and reduced motion. The app
487+ // already honors the system Developer Options animator scale for reduced motion,
488+ // but this gives users an in-app control without disabling motion OS-wide.
489+ // Haptics default on (the existing behavior); an in-app toggle lets users
490+ // disable them without turning off system vibration.
491+ Text(
492+ stringResource(R.string.settings_motion),
493+ style = MaterialTheme.typography.titleSmall,
494+ color = MaterialTheme.colorScheme.primary,
495+ modifier = Modifier.semantics { heading() },
496+ )
497+ val hapticsScope = rememberCoroutineScope()
498+ Row(
499+ modifier = Modifier
500+ .fillMaxWidth()
501+ .defaultMinSize(minHeight = 48.dp)
502+ .clickable(role = Role.Switch) {
503+ hapticsScope.launch {
504+ container.settingsStore.setHapticsEnabled(!hapticsEnabled)
505+ }
506+ }
507+ .padding(vertical = 8.dp),
508+ verticalAlignment = Alignment.CenterVertically,
509+ ) {
510+ Column(modifier = Modifier.weight(1f)) {
511+ Text(stringResource(R.string.settings_haptics))
512+ Text(
513+ stringResource(R.string.settings_haptics_desc),
514+ style = MaterialTheme.typography.bodySmall,
515+ color = MaterialTheme.colorScheme.onSurfaceVariant,
516+ )
517+ }
518+ Switch(checked = hapticsEnabled, onCheckedChange = null)
519+ }
520+ val motionScope = rememberCoroutineScope()
521+ Row(
522+ modifier = Modifier
523+ .fillMaxWidth()
524+ .defaultMinSize(minHeight = 48.dp)
525+ .clickable(role = Role.Switch) {
526+ motionScope.launch {
527+ container.settingsStore.setReducedMotion(!reducedMotion)
528+ }
529+ }
530+ .padding(vertical = 8.dp),
531+ verticalAlignment = Alignment.CenterVertically,
532+ ) {
533+ Column(modifier = Modifier.weight(1f)) {
534+ Text(stringResource(R.string.settings_reduced_motion))
535+ Text(
536+ stringResource(R.string.settings_reduced_motion_desc),
537+ style = MaterialTheme.typography.bodySmall,
538+ color = MaterialTheme.colorScheme.onSurfaceVariant,
539+ )
540+ }
541+ Switch(checked = reducedMotion, onCheckedChange = null)
542+ }
543+ // Language override: an in-app language picker so a user can use the app in a
544+ // different language without changing the system locale. Uses Android 13's
545+ // per-app language API; on older versions a restart is needed.
546+ val langScope = rememberCoroutineScope()
547+ Row(
548+ modifier = Modifier
549+ .fillMaxWidth()
550+ .defaultMinSize(minHeight = 48.dp)
551+ .padding(vertical = 8.dp),
552+ verticalAlignment = Alignment.CenterVertically,
553+ ) {
554+ Column(modifier = Modifier.weight(1f)) {
555+ Text(stringResource(R.string.settings_language))
556+ Text(
557+ if (languageOverride.isEmpty()) stringResource(R.string.settings_language_desc)
558+ else languageOverride,
559+ style = MaterialTheme.typography.bodySmall,
560+ color = MaterialTheme.colorScheme.onSurfaceVariant,
561+ )
562+ }
563+ val langOptions = remember {
564+ listOf("" to R.string.settings_language_system, "en" to R.string.app_name, "es" to R.string.app_name)
565+ }
566+ var langMenu by remember { mutableStateOf(false) }
567+ Box {
568+ TextButton(onClick = { langMenu = true }) {
569+ Text(if (languageOverride.isEmpty()) stringResource(R.string.settings_language_system) else languageOverride)
570+ }
571+ DropdownMenu(expanded = langMenu, onDismissRequest = { langMenu = false }) {
572+ langOptions.forEach { (code, _) ->
573+ DropdownMenuItem(
574+ text = { Text(if (code.isEmpty()) stringResource(R.string.settings_language_system) else code) },
575+ onClick = {
576+ langMenu = false
577+ langScope.launch {
578+ container.settingsStore.setLanguageOverride(code)
579+ }
580+ },
581+ )
582+ }
583+ }
584+ }
585+ }
586+
587+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
588+
589+ // Notifications: granular toggles for each notification type, so a user can
590+ // mute specific types in-app (in addition to the OS channel controls). Each
591+ // toggle gates whether the app posts that notification type at all.
592+ Text(
593+ stringResource(R.string.settings_notifications),
594+ style = MaterialTheme.typography.titleSmall,
595+ color = MaterialTheme.colorScheme.primary,
596+ modifier = Modifier.semantics { heading() },
597+ )
598+ val notifScope = rememberCoroutineScope()
599+ Row(
600+ modifier = Modifier
601+ .fillMaxWidth()
602+ .defaultMinSize(minHeight = 48.dp)
603+ .clickable(role = Role.Switch) {
604+ notifScope.launch { container.settingsStore.setNotifRunComplete(!notifRunComplete) }
605+ }
606+ .padding(vertical = 8.dp),
607+ verticalAlignment = Alignment.CenterVertically,
608+ ) {
609+ Column(modifier = Modifier.weight(1f)) {
610+ Text(stringResource(R.string.settings_notif_run_complete))
611+ Text(
612+ stringResource(R.string.settings_notif_run_complete_desc),
613+ style = MaterialTheme.typography.bodySmall,
614+ color = MaterialTheme.colorScheme.onSurfaceVariant,
615+ )
616+ }
617+ Switch(checked = notifRunComplete, onCheckedChange = null)
618+ }
619+ Row(
620+ modifier = Modifier
621+ .fillMaxWidth()
622+ .defaultMinSize(minHeight = 48.dp)
623+ .clickable(role = Role.Switch) {
624+ notifScope.launch { container.settingsStore.setNotifPermission(!notifPermission) }
625+ }
626+ .padding(vertical = 8.dp),
627+ verticalAlignment = Alignment.CenterVertically,
628+ ) {
629+ Column(modifier = Modifier.weight(1f)) {
630+ Text(stringResource(R.string.settings_notif_permission))
631+ Text(
632+ stringResource(R.string.settings_notif_permission_desc),
633+ style = MaterialTheme.typography.bodySmall,
634+ color = MaterialTheme.colorScheme.onSurfaceVariant,
635+ )
636+ }
637+ Switch(checked = notifPermission, onCheckedChange = null)
638+ }
639+ Row(
640+ modifier = Modifier
641+ .fillMaxWidth()
642+ .defaultMinSize(minHeight = 48.dp)
643+ .clickable(role = Role.Switch) {
644+ notifScope.launch { container.settingsStore.setNotifError(!notifError) }
645+ }
646+ .padding(vertical = 8.dp),
647+ verticalAlignment = Alignment.CenterVertically,
648+ ) {
649+ Column(modifier = Modifier.weight(1f)) {
650+ Text(stringResource(R.string.settings_notif_error))
651+ Text(
652+ stringResource(R.string.settings_notif_error_desc),
653+ style = MaterialTheme.typography.bodySmall,
654+ color = MaterialTheme.colorScheme.onSurfaceVariant,
655+ )
656+ }
657+ Switch(checked = notifError, onCheckedChange = null)
658+ }
659+
660+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
661+
662+ // Swipe actions: remappable left/right swipe on session list rows. Defaults
663+ // match the prior hardcoded behavior (left=delete, right=archive). NONE
664+ // disables the swipe entirely.
665+ Text(
666+ stringResource(R.string.settings_swipe_actions),
667+ style = MaterialTheme.typography.titleSmall,
668+ color = MaterialTheme.colorScheme.primary,
669+ modifier = Modifier.semantics { heading() },
670+ )
671+ val swipeScope = rememberCoroutineScope()
672+ val swipeOptions = remember {
673+ listOf(
674+ SwipeAction.DELETE to R.string.swipe_action_delete,
675+ SwipeAction.ARCHIVE to R.string.swipe_action_archive,
676+ SwipeAction.MARK_READ to R.string.swipe_action_mark_read,
677+ SwipeAction.NONE to R.string.swipe_action_none,
678+ )
679+ }
680+ var swipeLeftMenu by remember { mutableStateOf(false) }
681+ var swipeRightMenu by remember { mutableStateOf(false) }
682+ val swipeLeftParsed = remember(swipeLeftAction) {
683+ runCatching { SwipeAction.valueOf(swipeLeftAction) }.getOrDefault(SwipeAction.DELETE)
684+ }
685+ val swipeRightParsed = remember(swipeRightAction) {
686+ runCatching { SwipeAction.valueOf(swipeRightAction) }.getOrDefault(SwipeAction.ARCHIVE)
687+ }
688+ Row(
689+ modifier = Modifier
690+ .fillMaxWidth()
691+ .defaultMinSize(minHeight = 48.dp)
692+ .padding(vertical = 8.dp),
693+ verticalAlignment = Alignment.CenterVertically,
694+ ) {
695+ Text(stringResource(R.string.settings_swipe_left), modifier = Modifier.weight(1f))
696+ Box {
697+ TextButton(onClick = { swipeLeftMenu = true }) {
698+ Text(stringResource(swipeOptions.first { it.first == swipeLeftParsed }.second))
699+ }
700+ DropdownMenu(expanded = swipeLeftMenu, onDismissRequest = { swipeLeftMenu = false }) {
701+ swipeOptions.forEach { (action, label) ->
702+ DropdownMenuItem(
703+ text = { Text(stringResource(label)) },
704+ onClick = {
705+ swipeLeftMenu = false
706+ swipeScope.launch { container.settingsStore.setSwipeLeftAction(action) }
707+ },
708+ )
709+ }
710+ }
711+ }
712+ }
713+ Row(
714+ modifier = Modifier
715+ .fillMaxWidth()
716+ .defaultMinSize(minHeight = 48.dp)
717+ .padding(vertical = 8.dp),
718+ verticalAlignment = Alignment.CenterVertically,
719+ ) {
720+ Text(stringResource(R.string.settings_swipe_right), modifier = Modifier.weight(1f))
721+ Box {
722+ TextButton(onClick = { swipeRightMenu = true }) {
723+ Text(stringResource(swipeOptions.first { it.first == swipeRightParsed }.second))
724+ }
725+ DropdownMenu(expanded = swipeRightMenu, onDismissRequest = { swipeRightMenu = false }) {
726+ swipeOptions.forEach { (action, label) ->
727+ DropdownMenuItem(
728+ text = { Text(stringResource(label)) },
729+ onClick = {
730+ swipeRightMenu = false
731+ swipeScope.launch { container.settingsStore.setSwipeRightAction(action) }
732+ },
733+ )
734+ }
735+ }
736+ }
737+ }
738+
739+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
740+
463741 Text(
464742 stringResource(R.string.connection),
465743 style = MaterialTheme.typography.titleSmall,
@@ -654,9 +932,18 @@ fun SettingsScreen(
654932 Icon(Icons.Filled.BugReport, contentDescription = null, tint = MaterialTheme.colorScheme.error)
655933 Text(stringResource(R.string.diagnostics), modifier = Modifier.weight(1f).padding(start = 8.dp))
656934 // Badge with the crash count so the user can tell at a glance whether
657− // there's something worth investigating.
935+ // there's something worth investigating. Uses M3 BadgedBox+Badge for
936+ // consistency with the nav rail's badge (which already uses the real M3
937+ // components) instead of a hand-rolled Box.
658938 if (crashCount > 0) {
659− Badge(count = crashCount)
939+ BadgedBox(
940+ badge = {
941+ Badge {
942+ val overflow = stringResource(R.string.crash_count_overflow)
943+ Text(if (crashCount > 99) overflow else crashCount.toString())
944+ }
945+ },
946+ ) {}
660947 Spacer(Modifier.size(8.dp))
661948 }
662949 Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
@@ -931,26 +1218,6 @@ private data class SettingsValues(
9311218 val appLock: Boolean,
9321219)
9331220
934−/** Small circular count badge used to indicate pending crash reports. */
935−@Composable
936−private fun Badge(count: Int) {
937− val overflow = stringResource(R.string.crash_count_overflow)
938− Box(
939− modifier = Modifier
940− .size(20.dp)
941− .clip(CircleShape)
942− .background(MaterialTheme.colorScheme.error),
943− contentAlignment = Alignment.Center,
944− ) {
945− Text(
946− if (count > 99) overflow else count.toString(),
947− style = MaterialTheme.typography.labelSmall,
948− color = MaterialTheme.colorScheme.onError,
949− textAlign = TextAlign.Center,
950− )
951− }
952−}
953−
9541221/** Dropdown selecting how long after backgrounding the app re-locks. Only shown when app lock
9551222 * is on. The grace period avoids a re-prompt on every quick app-switch (the most common reason
9561223 * users disable biometric locks), while still re-locking immediately by default. */
app/src/main/res/values-es/strings.xml+40
@@ -828,4 +828,44 @@
828828 <string name="test_connection_desc">Probando conexión a %1$s</string>
829829 <string name="test_connection_success">Conectado (%1$dms)</string>
830830 <string name="test_connection_failed">Error: %1$s</string>
831+
832+ <string name="permission_details">Detalles</string>
833+ <string name="step_cost_short">$%1$.4f</string>
834+ <string name="step_cost_long">$%1$.6f</string>
835+ <string name="image_unavailable">Imagen · conecta para ver</string>
836+ <string name="draft_indicator">Borrador</string>
837+ <string name="sending_queued">Enviando mensajes en cola…</string>
838+ <string name="role_user_message">Tú</string>
839+ <string name="role_assistant_message">Asistente</string>
840+ <string name="command_autocomplete_hint">Ejecutar comando</string>
841+ <string name="continue_run">Continuar</string>
842+ <string name="loading_sessions">Cargando sesiones…</string>
843+ <string name="load_error_persistent">No se pudieron cargar los mensajes. Reconecta para reintentar.</string>
844+ <string name="open_in_chat">Abrir en chat</string>
845+ <string name="duplicate_server_warning">Ya existe un servidor con esta URL y etiqueta.</string>
846+ <string name="copy_code">Copiar código</string>
847+ <string name="code_copied">Código copiado</string>
848+ <string name="settings_motion">Movimiento y retroalimentación</string>
849+ <string name="settings_reduced_motion">Movimiento reducido</string>
850+ <string name="settings_reduced_motion_desc">Limitar animaciones y transiciones.</string>
851+ <string name="settings_haptics">Retroalimentación háptica</string>
852+ <string name="settings_haptics_desc">Vibrar al tocar y confirmar.</string>
853+ <string name="settings_language">Idioma</string>
854+ <string name="settings_language_desc">Anular el idioma del sistema para la app.</string>
855+ <string name="settings_language_system">Predeterminado del sistema</string>
856+ <string name="settings_notifications">Notificaciones</string>
857+ <string name="settings_notif_run_complete">Run completado</string>
858+ <string name="settings_notif_run_complete_desc">Notificar cuando termine una ejecución del agente.</string>
859+ <string name="settings_notif_permission">Solicitudes de permiso</string>
860+ <string name="settings_notif_permission_desc">Notificación emergente cuando una herramienta necesita aprobación.</string>
861+ <string name="settings_notif_error">Errores de ejecución</string>
862+ <string name="settings_notif_error_desc">Notificar cuando una ejecución del agente falle.</string>
863+ <string name="settings_swipe_actions">Acciones de deslizamiento</string>
864+ <string name="settings_swipe_left">Deslizar a la izquierda</string>
865+ <string name="settings_swipe_right">Deslizar a la derecha</string>
866+ <string name="swipe_action_delete">Eliminar</string>
867+ <string name="swipe_action_archive">Archivar</string>
868+ <string name="swipe_action_mark_read">Marcar leído / no leído</string>
869+ <string name="swipe_action_none">Ninguno</string>
870+ <string name="image_file_preview">Vista previa de imagen</string>
831871</resources>
app/src/main/res/values/strings.xml+61
@@ -833,4 +833,65 @@
833833 <string name="test_connection_desc">Testing connection to %1$s</string>
834834 <string name="test_connection_success">Connected (%1$dms)</string>
835835 <string name="test_connection_failed">Failed: %1$s</string>
836+
837+ <!-- UI/UX improvements batch -->
838+ <!-- Permission metadata: tool-specific detail (command, file scope) extracted from
839+ Permission.metadata. Shown in a collapsible "Details" section of the dialog. -->
840+ <string name="permission_details">Details</string>
841+ <!-- Step-finish per-step cost/tokens (distinct from the per-message aggregate).
842+ Shown inline at the end of a step. -->
843+ <string name="step_cost_short">$%1$.4f</string>
844+ <string name="step_cost_long">$%1$.6f</string>
845+ <!-- Image FilePart degraded to a chip because no active connection can fetch it. -->
846+ <string name="image_unavailable">Image · connect to view</string>
847+ <!-- Draft indicator on session list rows. -->
848+ <string name="draft_indicator">Draft</string>
849+ <!-- Outbox sending indicator (persistent banner/toolbar). -->
850+ <string name="sending_queued">Sending queued messages…</string>
851+ <!-- A11y: role announcement for message bubbles so TalkBack reads the speaker. -->
852+ <string name="role_user_message">You</string>
853+ <string name="role_assistant_message">Assistant</string>
854+ <!-- Slash-command autocomplete (composer). -->
855+ <string name="command_autocomplete_hint">Run command</string>
856+ <!-- Continue a partial assistant reply (vs regenerate, which re-runs from scratch). -->
857+ <string name="continue_run">Continue</string>
858+ <!-- Session list skeleton loader. -->
859+ <string name="loading_sessions">Loading sessions…</string>
860+ <!-- Persistent load-error indicator (vs the one-shot snackbar). -->
861+ <string name="load_error_persistent">Couldn\'t load messages. Reconnect to retry.</string>
862+ <!-- Open a browsed file as an attachment in a new chat prompt. -->
863+ <string name="open_in_chat">Open in chat</string>
864+ <!-- Duplicate server detection. -->
865+ <string name="duplicate_server_warning">A server with this URL and label already exists.</string>
866+ <!-- Image preview not available for non-image file types in the file viewer. -->
867+ <!-- Per-code-block copy button. -->
868+ <string name="copy_code">Copy code</string>
869+ <string name="code_copied">Code copied</string>
870+ <!-- Settings: in-app toggles for accessibility/motion preferences. -->
871+ <string name="settings_motion">Motion &amp; feedback</string>
872+ <string name="settings_reduced_motion">Reduced motion</string>
873+ <string name="settings_reduced_motion_desc">Limit animations and transitions.</string>
874+ <string name="settings_haptics">Haptic feedback</string>
875+ <string name="settings_haptics_desc">Vibrate on taps and confirmations.</string>
876+ <string name="settings_language">Language</string>
877+ <string name="settings_language_desc">Override the system language for the app.</string>
878+ <string name="settings_language_system">System default</string>
879+ <!-- Settings: granular notification toggles. -->
880+ <string name="settings_notifications">Notifications</string>
881+ <string name="settings_notif_run_complete">Run completed</string>
882+ <string name="settings_notif_run_complete_desc">Notify when an agent run finishes.</string>
883+ <string name="settings_notif_permission">Permission requests</string>
884+ <string name="settings_notif_permission_desc">Heads-up notification when a tool needs approval.</string>
885+ <string name="settings_notif_error">Run errors</string>
886+ <string name="settings_notif_error_desc">Notify when an agent run fails.</string>
887+ <!-- Settings: swipe-action customization. -->
888+ <string name="settings_swipe_actions">Swipe actions</string>
889+ <string name="settings_swipe_left">Swipe left</string>
890+ <string name="settings_swipe_right">Swipe right</string>
891+ <string name="swipe_action_delete">Delete</string>
892+ <string name="swipe_action_archive">Archive</string>
893+ <string name="swipe_action_mark_read">Mark read / unread</string>
894+ <string name="swipe_action_none">None</string>
895+ <!-- Open the file viewer with an image preview for image extensions. -->
896+ <string name="image_file_preview">Image preview</string>
836897</resources>