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
9f0326aiko
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+13
@@ -63,6 +63,13 @@ object NetworkConfig {
63 const val sessionListInitialPage = 60
63 const val sessionListInitialPage = 60
64 /** How many additional rows to reveal when the session list render window is exhausted. */
64 /** How many additional rows to reveal when the session list render window is exhausted. */
65 const val sessionListPageStep = 60
65 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
66 /** Max concurrent preview fetches (prevents flooding the server with parallel requests). */
73 /** Max concurrent preview fetches (prevents flooding the server with parallel requests). */
67 const val maxConcurrentPreviews = 8
74 const val maxConcurrentPreviews = 8
68 /** Max characters of a session's last message to keep as a list preview. */
75 /** Max characters of a session's last message to keep as a list preview. */
@@ -125,6 +132,12 @@ object NetworkConfig {
125 /** Debounce delay before firing a file-search request after the user stops typing. */
132 /** Debounce delay before firing a file-search request after the user stops typing. */
126 const val fileSearchDebounceMs = 250L
133 const val fileSearchDebounceMs = 250L
127
134
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+
128 // --- Attachments (chat composer) ---
141 // --- Attachments (chat composer) ---
129
142
130 /** Max size of a single attachment (image/file) before it's rejected. Base64 inflates
143 /** 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(
31 val dynamicColor: Boolean = true,
31 val dynamicColor: Boolean = true,
32 val sendOnEnter: Boolean = true,
32 val sendOnEnter: Boolean = true,
33 val appLock: Boolean = false,
33 val appLock: Boolean = false,
34+ val appLockReLockSeconds: Int = SettingsStore.DEFAULT_APP_LOCK_RELOCK_SECONDS,
34 val chatTextScale: Float = SettingsStore.DEFAULT_CHAT_TEXT_SCALE,
35 val chatTextScale: Float = SettingsStore.DEFAULT_CHAT_TEXT_SCALE,
35 val codeWrap: Boolean = false,
36 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,
36)
51)
37
52
38/** The full backup document. [version] guards against future format changes. */
53/** The full backup document. [version] guards against future format changes. */
@@ -76,8 +91,23 @@ class BackupManager(
76 dynamicColor = settingsStore.dynamicColor.first(),
91 dynamicColor = settingsStore.dynamicColor.first(),
77 sendOnEnter = settingsStore.sendOnEnter.first(),
92 sendOnEnter = settingsStore.sendOnEnter.first(),
78 appLock = settingsStore.appLock.first(),
93 appLock = settingsStore.appLock.first(),
94+ appLockReLockSeconds = settingsStore.appLockReLockSeconds.first(),
79 chatTextScale = settingsStore.chatTextScale.first(),
95 chatTextScale = settingsStore.chatTextScale.first(),
80 codeWrap = settingsStore.codeWrap.first(),
96 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(),
81 )
111 )
82 val data = BackupData(
112 val data = BackupData(
83 servers = servers,
113 servers = servers,
@@ -126,8 +156,23 @@ class BackupManager(
126 settingsStore.setDynamicColor(settings.dynamicColor)
156 settingsStore.setDynamicColor(settings.dynamicColor)
127 settingsStore.setSendOnEnter(settings.sendOnEnter)
157 settingsStore.setSendOnEnter(settings.sendOnEnter)
128 settingsStore.setAppLock(settings.appLock)
158 settingsStore.setAppLock(settings.appLock)
159+ settingsStore.setAppLockReLockSeconds(settings.appLockReLockSeconds)
129 settingsStore.setChatTextScale(settings.chatTextScale)
160 settingsStore.setChatTextScale(settings.chatTextScale)
130 settingsStore.setCodeWrap(settings.codeWrap)
161 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) }
131 }
176 }
132 data.pinned.forEach { sessionPrefsStore.setPinned(it, true) }
177 data.pinned.forEach { sessionPrefsStore.setPinned(it, true) }
133 data.archived.forEach { sessionPrefsStore.setArchived(it, true) }
178 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 }
15private val Context.settingsDataStore by preferencesDataStore(name = "settings")
15private val Context.settingsDataStore by preferencesDataStore(name = "settings")
16
16
17/** App-preferences store: theme mode, dynamic color (Material You), input behavior. */
17/** App-preferences store: theme mode, dynamic color (Material You), input behavior. */
18+@Suppress("TooManyFunctions")
18class SettingsStore(context: Context) {
19class SettingsStore(context: Context) {
19
20
20 private val appContext = context.applicationContext
21 private val appContext = context.applicationContext
@@ -31,6 +32,14 @@ class SettingsStore(context: Context) {
31 private val preferredModelIdKey = stringPreferencesKey("preferred_model_id")
32 private val preferredModelIdKey = stringPreferencesKey("preferred_model_id")
32 private val preferredAgentNameKey = stringPreferencesKey("preferred_agent_name")
33 private val preferredAgentNameKey = stringPreferencesKey("preferred_agent_name")
33 private val compactMessageSpacingKey = booleanPreferencesKey("compact_message_spacing")
34 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")
34
43
35 companion object {
44 companion object {
36 /** Bounds for the chat text-size multiplier so the UI can't be scaled into
45 /** Bounds for the chat text-size multiplier so the UI can't be scaled into
@@ -125,6 +134,51 @@ class SettingsStore(context: Context) {
125 prefs[compactMessageSpacingKey] ?: false
134 prefs[compactMessageSpacingKey] ?: false
126 }
135 }
127
136
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+
128 suspend fun setThemeMode(mode: ThemeMode) {
182 suspend fun setThemeMode(mode: ThemeMode) {
129 appContext.settingsDataStore.edit { it[themeKey] = mode.name }
183 appContext.settingsDataStore.edit { it[themeKey] = mode.name }
130 }
184 }
@@ -188,4 +242,38 @@ class SettingsStore(context: Context) {
188 it[compactMessageSpacingKey] = enabled
242 it[compactMessageSpacingKey] = enabled
189 }
243 }
190 }
244 }
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+ }
191}
276}
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) {
217 Key.NumPad4, Key.Four -> Routes.USAGE
217 Key.NumPad4, Key.Four -> Routes.USAGE
218 Key.NumPad5, Key.Five -> Routes.MCP
218 Key.NumPad5, Key.Five -> Routes.MCP
219 Key.NumPad6, Key.Six -> Routes.SETTINGS
219 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
220 else -> null
224 else -> null
221 }
225 }
222− if (route != null && connection != null) {
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) {
223− navController.navigate(route) {
231+ navController.navigate(route) {
224− popUpTo(navController.graph.findStartDestination().id) { saveState = true }
232+ popUpTo(navController.graph.findStartDestination().id) { saveState = true }
225− launchSingleTop = true
233+ launchSingleTop = true
226− restoreState = true
234+ restoreState = true
227− }
235+ }
228− true
236+ true
237+ } else false
229 } else false
238 } else false
230 },
239 },
231 ) {
240 ) {
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+222−24
@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.Spacer
29import androidx.compose.foundation.layout.WindowInsets
29import androidx.compose.foundation.layout.WindowInsets
30import androidx.compose.foundation.layout.fillMaxSize
30import androidx.compose.foundation.layout.fillMaxSize
31import androidx.compose.foundation.layout.fillMaxWidth
31import androidx.compose.foundation.layout.fillMaxWidth
32+import androidx.compose.foundation.layout.heightIn
32import androidx.compose.foundation.layout.height
33import androidx.compose.foundation.layout.height
33import androidx.compose.foundation.layout.imePadding
34import androidx.compose.foundation.layout.imePadding
34import androidx.compose.foundation.layout.ime
35import androidx.compose.foundation.layout.ime
@@ -46,6 +47,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
46import androidx.compose.material.icons.Icons
47import androidx.compose.material.icons.Icons
47import androidx.compose.material.icons.automirrored.filled.ArrowBack
48import androidx.compose.material.icons.automirrored.filled.ArrowBack
48import androidx.compose.material.icons.automirrored.filled.Send
49import androidx.compose.material.icons.automirrored.filled.Send
50+import androidx.compose.material.icons.filled.ErrorOutline
49import androidx.compose.material.icons.filled.Add
51import androidx.compose.material.icons.filled.Add
50import androidx.compose.material.icons.filled.AutoAwesome
52import androidx.compose.material.icons.filled.AutoAwesome
51import androidx.compose.material.icons.filled.Delete
53import androidx.compose.material.icons.filled.Delete
@@ -131,13 +133,17 @@ import androidx.compose.ui.layout.onSizeChanged
131import androidx.compose.ui.platform.LocalContext
133import androidx.compose.ui.platform.LocalContext
132import androidx.compose.ui.platform.LocalDensity
134import androidx.compose.ui.platform.LocalDensity
133import androidx.compose.ui.platform.LocalHapticFeedback
135import androidx.compose.ui.platform.LocalHapticFeedback
136+import androidx.compose.ui.platform.LocalLayoutDirection
134import androidx.compose.ui.platform.LocalView
137import androidx.compose.ui.platform.LocalView
135import androidx.compose.ui.platform.testTag
138import androidx.compose.ui.platform.testTag
139+import androidx.compose.ui.unit.LayoutDirection
136import androidx.compose.ui.res.pluralStringResource
140import androidx.compose.ui.res.pluralStringResource
137import androidx.compose.ui.res.stringResource
141import androidx.compose.ui.res.stringResource
138import androidx.compose.ui.semantics.Role
142import androidx.compose.ui.semantics.Role
139import androidx.compose.ui.semantics.contentDescription
143import androidx.compose.ui.semantics.contentDescription
140import androidx.compose.ui.semantics.heading
144import androidx.compose.ui.semantics.heading
145+import androidx.compose.ui.semantics.liveRegion
146+import androidx.compose.ui.semantics.LiveRegionMode
141import androidx.compose.ui.semantics.invisibleToUser
147import androidx.compose.ui.semantics.invisibleToUser
142import androidx.compose.ui.semantics.semantics
148import androidx.compose.ui.semantics.semantics
143import androidx.compose.ui.text.input.ImeAction
149import androidx.compose.ui.text.input.ImeAction
@@ -153,6 +159,8 @@ import kotlinx.coroutines.launch
153import kotlinx.coroutines.withContext
159import kotlinx.coroutines.withContext
154import soy.iko.opencode.data.model.Part
160import soy.iko.opencode.data.model.Part
155import soy.iko.opencode.data.model.MessageWithParts
161import soy.iko.opencode.data.model.MessageWithParts
162+import soy.iko.opencode.data.model.AssistantMessage
163+import soy.iko.opencode.data.model.Command
156import soy.iko.opencode.data.model.ReasoningPart
164import soy.iko.opencode.data.model.ReasoningPart
157import soy.iko.opencode.data.model.TextPart
165import soy.iko.opencode.data.model.TextPart
158import soy.iko.opencode.data.model.ToolCompleted
166import soy.iko.opencode.data.model.ToolCompleted
@@ -197,6 +205,7 @@ fun ChatScreen(
197 val aborting by vm.aborting.collectAsStateWithLifecycle()
205 val aborting by vm.aborting.collectAsStateWithLifecycle()
198 val loading by vm.loading.collectAsStateWithLifecycle()
206 val loading by vm.loading.collectAsStateWithLifecycle()
199 val loadError by vm.loadError.collectAsStateWithLifecycle()
207 val loadError by vm.loadError.collectAsStateWithLifecycle()
208+ val loadErrorInline by vm.loadErrorInline.collectAsStateWithLifecycle()
200 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
209 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
201 val models by vm.models.collectAsStateWithLifecycle()
210 val models by vm.models.collectAsStateWithLifecycle()
202 val modelsLoading by vm.modelsLoading.collectAsStateWithLifecycle()
211 val modelsLoading by vm.modelsLoading.collectAsStateWithLifecycle()
@@ -226,6 +235,7 @@ fun ChatScreen(
226 val compactSpacing by container.settingsStore.compactMessageSpacing.collectAsStateWithLifecycle(initialValue = false)
235 val compactSpacing by container.settingsStore.compactMessageSpacing.collectAsStateWithLifecycle(initialValue = false)
227 val isOnline by container.isOnline.collectAsStateWithLifecycle()
236 val isOnline by container.isOnline.collectAsStateWithLifecycle()
228 val haptics = LocalHapticFeedback.current
237 val haptics = LocalHapticFeedback.current
238+ val layoutDirection = LocalLayoutDirection.current
229 val scope = rememberCoroutineScope()
239 val scope = rememberCoroutineScope()
230 val shareContext = LocalContext.current
240 val shareContext = LocalContext.current
231 val defaultShareSubject = stringResource(R.string.share_subject)
241 val defaultShareSubject = stringResource(R.string.share_subject)
@@ -849,6 +859,14 @@ fun ChatScreen(
849 onCamera = { launchCamera() },
859 onCamera = { launchCamera() },
850 onVoice = { launchVoice() },
860 onVoice = { launchVoice() },
851 onPasteImage = { stageUris(it) },
861 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+ },
852 )
870 )
853 }
871 }
854 },
872 },
@@ -879,28 +897,47 @@ fun ChatScreen(
879 val yesterdayLabel = stringResource(R.string.yesterday)
897 val yesterdayLabel = stringResource(R.string.yesterday)
880 // In-conversation search: when active, narrow to messages whose text contains the query.
898 // In-conversation search: when active, narrow to messages whose text contains the query.
881 // Computed on the filtered set so day separators still align with the visible messages.
899 // 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.
882 val searchQuery = chatSearch.trim()
904 val searchQuery = chatSearch.trim()
883− val searchMessages = remember(messages, searchQuery) {
905+ val searchMessages by produceState(
906+ initialValue = if (searchQuery.isEmpty()) messages else emptyList(),
907+ messages,
908+ searchQuery,
909+ ) {
884− if (searchQuery.isEmpty()) messages
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) {
885− else messages.filter { m ->
920+ messages.filter { m ->
886− m.parts.any { p ->
921+ m.parts.any { p ->
887− when (p) {
922+ when (p) {
888− is TextPart -> p.text.contains(searchQuery, ignoreCase = true)
923+ is TextPart -> p.text.contains(searchQuery, ignoreCase = true)
889− is ReasoningPart -> p.text.contains(searchQuery, ignoreCase = true)
924+ is ReasoningPart -> p.text.contains(searchQuery, ignoreCase = true)
890− is ToolPart -> {
925+ is ToolPart -> {
891− // Match the tool name plus its running/completed/error text and the
926+ // 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
927+ // stringified input, so searching for an error string or a file the
893− // tool touched actually finds the message it appeared in.
928+ // tool touched actually finds the message it appeared in.
894− p.tool.contains(searchQuery, ignoreCase = true) ||
929+ p.tool.contains(searchQuery, ignoreCase = true) ||
895− (p.state is ToolRunning && p.state.title?.contains(searchQuery, ignoreCase = true) == true) ||
930+ (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 ||
931+ (p.state is ToolCompleted && (p.state.title?.contains(searchQuery, ignoreCase = true) == true ||
897− p.state.output?.contains(searchQuery, ignoreCase = true) == true)) ||
932+ p.state.output?.contains(searchQuery, ignoreCase = true) == true)) ||
898− (p.state is ToolError && p.state.error?.contains(searchQuery, ignoreCase = true) == true) ||
933+ (p.state is ToolError && p.state.error?.contains(searchQuery, ignoreCase = true) == true) ||
899− p.state.inputElement()?.toString()?.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+ }
900 }
940 }
901− is FilePart -> p.filename?.contains(searchQuery, ignoreCase = true) == true ||
902− p.sourcePath?.contains(searchQuery, ignoreCase = true) == true
903− else -> false
904 }
941 }
905 }
942 }
906 }
943 }
@@ -1102,6 +1139,48 @@ fun ChatScreen(
1102 // separate "Not connected" state below).
1139 // separate "Not connected" state below).
1103 onRetry = { vm.refreshMessages() },
1140 onRetry = { vm.refreshMessages() },
1104 )
1141 )
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+ }
1105 // Pull-to-refresh wraps ALL content states (loading, error, empty, list) so
1184 // Pull-to-refresh wraps ALL content states (loading, error, empty, list) so
1106 // recovery is a swipe away everywhere — not just when the message list is
1185 // recovery is a swipe away everywhere — not just when the message list is
1107 // populated. Previously PTR was only around the populated list, leaving the
1186 // populated. Previously PTR was only around the populated list, leaving the
@@ -1247,7 +1326,12 @@ fun ChatScreen(
1247 // capture horizontal drags (e.g. inside a horizontally-scrollable code block).
1326 // capture horizontal drags (e.g. inside a horizontally-scrollable code block).
1248 val swipeState = rememberSwipeToDismissBoxState(
1327 val swipeState = rememberSwipeToDismissBoxState(
1249 confirmValueChange = { value ->
1328 confirmValueChange = { value ->
1329+ val replyValue = if (layoutDirection == LayoutDirection.Rtl) {
1330+ SwipeToDismissBoxValue.EndToStart
1331+ } else {
1250− if (value == SwipeToDismissBoxValue.StartToEnd && quoteText != null) {
1332+ SwipeToDismissBoxValue.StartToEnd
1333+ }
1334+ if (value == replyValue && quoteText != null) {
1251 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1335 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1252 vm.quoteReply(quoteText)
1336 vm.quoteReply(quoteText)
1253 runCatching { inputFocusRequester.requestFocus() }
1337 runCatching { inputFocusRequester.requestFocus() }
@@ -1255,14 +1339,19 @@ fun ChatScreen(
1255 false
1339 false
1256 },
1340 },
1257 )
1341 )
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
1258 SwipeToDismissBox(
1347 SwipeToDismissBox(
1259 state = swipeState,
1348 state = swipeState,
1260− enableDismissFromEndToStart = false,
1349+ enableDismissFromEndToStart = !replyFromStartToEnd && quoteText != null,
1261− enableDismissFromStartToEnd = quoteText != null,
1350+ enableDismissFromStartToEnd = replyFromStartToEnd && quoteText != null,
1262 backgroundContent = {
1351 backgroundContent = {
1263 Box(
1352 Box(
1264 Modifier.fillMaxSize().padding(horizontal = 16.dp),
1353 Modifier.fillMaxSize().padding(horizontal = 16.dp),
1265− contentAlignment = Alignment.CenterStart,
1354+ contentAlignment = if (replyFromStartToEnd) Alignment.CenterStart else Alignment.CenterEnd,
1266 ) {
1355 ) {
1267 Icon(
1356 Icon(
1268 Icons.AutoMirrored.Filled.Reply,
1357 Icons.AutoMirrored.Filled.Reply,
@@ -1318,6 +1407,12 @@ fun ChatScreen(
1318 onRegenerate = if (!running) {
1407 onRegenerate = if (!running) {
1319 { vm.regenerate(message.info.id) }
1408 { vm.regenerate(message.info.id) }
1320 } else null,
1409 } 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,
1321 sendStatus = optimisticStatuses[message.info.id]?.let { failed ->
1416 sendStatus = optimisticStatuses[message.info.id]?.let { failed ->
1322 if (failed) soy.iko.opencode.ui.chat.MessageSendStatus.FAILED
1417 if (failed) soy.iko.opencode.ui.chat.MessageSendStatus.FAILED
1323 else soy.iko.opencode.ui.chat.MessageSendStatus.SENDING
1418 else soy.iko.opencode.ui.chat.MessageSendStatus.SENDING
@@ -1821,7 +1916,99 @@ private fun ShortcutRow(keys: String, desc: String) {
1821 }
1916 }
1822}
1917}
1823
1918
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+
1824@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
2010@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
2011+@Suppress("CyclomaticComplexMethod")
1825@Composable
2012@Composable
1826private fun ChatInputBar(
2013private fun ChatInputBar(
1827 value: String,
2014 value: String,
@@ -1845,6 +2032,8 @@ private fun ChatInputBar(
1845 onCamera: () -> Unit,
2032 onCamera: () -> Unit,
1846 onVoice: () -> Unit,
2033 onVoice: () -> Unit,
1847 onPasteImage: (List<Uri>) -> Unit,
2034 onPasteImage: (List<Uri>) -> Unit,
2035+ commands: List<Command> = emptyList(),
2036+ onRunCommand: (Command) -> Unit = {},
1848) {
2037) {
1849 // Sendable when there's text OR at least one attachment (an image-only prompt is valid).
2038 // Sendable when there's text OR at least one attachment (an image-only prompt is valid).
1850 val hasContent = value.isNotBlank() || attachments.isNotEmpty()
2039 val hasContent = value.isNotBlank() || attachments.isNotEmpty()
@@ -1895,6 +2084,15 @@ private fun ChatInputBar(
1895 }
2084 }
1896 // Staged attachments: horizontally-scrollable thumbnails/chips, each removable.
2085 // Staged attachments: horizontally-scrollable thumbnails/chips, each removable.
1897 AttachmentStrip(attachments, onRemove = onRemoveAttachment, staging = staging, stagingFileCount = stagingFileCount)
2086 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+ )
1898 Row(
2096 Row(
1899 modifier = Modifier.fillMaxWidth().padding(8.dp),
2097 modifier = Modifier.fillMaxWidth().padding(8.dp),
1900 verticalAlignment = Alignment.Bottom,
2098 verticalAlignment = Alignment.Bottom,
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+29
@@ -137,6 +137,14 @@ class ChatViewModel(
137 private val _loadError = MutableStateFlow(false)
137 private val _loadError = MutableStateFlow(false)
138 val loadError: StateFlow<Boolean> = _loadError.asStateFlow()
138 val loadError: StateFlow<Boolean> = _loadError.asStateFlow()
139
139
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+
140 /** Tracks whether the messages flow has ever emitted a non-empty list, so
148 /** Tracks whether the messages flow has ever emitted a non-empty list, so
141 * [messages]' retryWhen can decide whether a re-fetch failure should set
149 * [messages]' retryWhen can decide whether a re-fetch failure should set
142 * [loadError] (nothing shown yet → error screen) or just emit a snackbar
150 * [loadError] (nothing shown yet → error screen) or just emit a snackbar
@@ -195,6 +203,7 @@ class ChatViewModel(
195 // emission couldn't clear the flag. hasShownMessages stays gated on non-empty
203 // emission couldn't clear the flag. hasShownMessages stays gated on non-empty
196 // (an empty session has genuinely shown nothing yet).
204 // (an empty session has genuinely shown nothing yet).
197 _loadError.value = false
205 _loadError.value = false
206+ _loadErrorInline.value = false
198 if (it.isNotEmpty()) {
207 if (it.isNotEmpty()) {
199 hasShownMessages = true
208 hasShownMessages = true
200 }
209 }
@@ -210,6 +219,10 @@ class ChatViewModel(
210 // is better surfaced as a snackbar (via errorEvents) than by
219 // is better surfaced as a snackbar (via errorEvents) than by
211 // replacing the visible conversation with an error screen.
220 // replacing the visible conversation with an error screen.
212 if (!hasShownMessages) _loadError.value = true
221 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
213 // Surface the snackbar only once per failure streak — retryWhen loops on a
226 // Surface the snackbar only once per failure streak — retryWhen loops on a
214 // backoff, so emitting here unconditionally would spam a new snackbar every
227 // backoff, so emitting here unconditionally would spam a new snackbar every
215 // retry on a persistent load failure.
228 // retry on a persistent load failure.
@@ -1230,6 +1243,7 @@ class ChatViewModel(
1230 fun refreshMessages() {
1243 fun refreshMessages() {
1231 val conn = connection ?: return
1244 val conn = connection ?: return
1232 _loadError.value = false
1245 _loadError.value = false
1246+ _loadErrorInline.value = false
1233 _refreshing.value = true
1247 _refreshing.value = true
1234 conn.events.triggerReconnect()
1248 conn.events.triggerReconnect()
1235 viewModelScope.launch {
1249 viewModelScope.launch {
@@ -1364,6 +1378,21 @@ class ChatViewModel(
1364 }
1378 }
1365 }
1379 }
1366
1380
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+
1367 /** Load a previously-sent user prompt back into the composer to edit and re-send.
1396 /** Load a previously-sent user prompt back into the composer to edit and re-send.
1368 * Reverts the conversation to just before [messageId] (hiding it and everything after)
1397 * Reverts the conversation to just before [messageId] (hiding it and everything after)
1369 * and prefills the draft with [text]. The next Send continues from that checkpoint,
1398 * 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(
113 onQuote: ((String) -> Unit)? = null,
113 onQuote: ((String) -> Unit)? = null,
114 onBranch: ((String) -> Unit)? = null,
114 onBranch: ((String) -> Unit)? = null,
115 onRegenerate: (() -> Unit)? = null,
115 onRegenerate: (() -> Unit)? = null,
116+ onContinue: (() -> Unit)? = null,
116 sendStatus: MessageSendStatus? = null,
117 sendStatus: MessageSendStatus? = null,
117 isEdited: Boolean = false,
118 isEdited: Boolean = false,
118 onRetry: (() -> Unit)? = null,
119 onRetry: (() -> Unit)? = null,
@@ -122,7 +123,7 @@ fun MessageBubble(
122 when (message.info) {
123 when (message.info) {
123 is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile, onRevert, onEdit, onQuote, onBranch, sendStatus, isEdited, onRetry, onDismiss, onShare)
124 is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile, onRevert, onEdit, onQuote, onBranch, sendStatus, isEdited, onRetry, onDismiss, onShare)
124 is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier, onOpenFile)
125 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)
126 }
127 }
127}
128}
128
129
@@ -388,6 +389,10 @@ private fun UserBubble(
388 val editLabel = stringResource(R.string.edit_message)
389 val editLabel = stringResource(R.string.edit_message)
389 val dropsAttachmentsMsg = stringResource(R.string.edit_drops_attachments)
390 val dropsAttachmentsMsg = stringResource(R.string.edit_drops_attachments)
390 val hasAttachments = message.parts.any { it is FilePart }
391 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)
391 // Long-press context menu state: consolidates copy/quote/branch/revert into a standard
396 // Long-press context menu state: consolidates copy/quote/branch/revert into a standard
392 // Android long-press menu so the actions are discoverable without spotting the 18dp icons.
397 // Android long-press menu so the actions are discoverable without spotting the 18dp icons.
393 // The menu anchors at the actual touch point rather than a fixed corner.
398 // The menu anchors at the actual touch point rather than a fixed corner.
@@ -416,6 +421,10 @@ private fun UserBubble(
416 .widthIn(max = maxWidth * NetworkConfig.userBubbleWidthFraction)
421 .widthIn(max = maxWidth * NetworkConfig.userBubbleWidthFraction)
417 .clip(MaterialTheme.shapes.medium)
422 .clip(MaterialTheme.shapes.medium)
418 .background(MaterialTheme.colorScheme.primaryContainer)
423 .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 }
419 // Long-press anywhere on the bubble (body or footer) opens the context menu —
428 // Long-press anywhere on the bubble (body or footer) opens the context menu —
420 // the conventional Android pattern — so the actions are discoverable without
429 // the conventional Android pattern — so the actions are discoverable without
421 // spotting the 18dp inline icons. detectTapGestures with only onLongPress does
430 // spotting the 18dp inline icons. detectTapGestures with only onLongPress does
@@ -627,6 +636,7 @@ private fun AssistantBlock(
627 onQuote: ((String) -> Unit)? = null,
636 onQuote: ((String) -> Unit)? = null,
628 onBranch: ((String) -> Unit)? = null,
637 onBranch: ((String) -> Unit)? = null,
629 onRegenerate: (() -> Unit)? = null,
638 onRegenerate: (() -> Unit)? = null,
639+ onContinue: (() -> Unit)? = null,
630 isEdited: Boolean = false,
640 isEdited: Boolean = false,
631 onShare: (() -> Unit)? = null,
641 onShare: (() -> Unit)? = null,
632) {
642) {
@@ -637,9 +647,14 @@ private fun AssistantBlock(
637 val assistantDensity = androidx.compose.ui.platform.LocalDensity.current
647 val assistantDensity = androidx.compose.ui.platform.LocalDensity.current
638 var longPressMenu by remember { mutableStateOf(false) }
648 var longPressMenu by remember { mutableStateOf(false) }
639 var longPressOffset by remember { mutableStateOf(androidx.compose.ui.unit.DpOffset(0.dp, 0.dp)) }
649 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)
640 Column(
654 Column(
641 modifier = modifier
655 modifier = modifier
642 .fillMaxWidth()
656 .fillMaxWidth()
657+ .semantics(mergeDescendants = true) { contentDescription = assistantRoleLabel }
643 .pointerInput(Unit) {
658 .pointerInput(Unit) {
644 detectTapGestures(onLongPress = { offset ->
659 detectTapGestures(onLongPress = { offset ->
645 assistantHaptics.performHapticFeedback(HapticFeedbackType.LongPress)
660 assistantHaptics.performHapticFeedback(HapticFeedbackType.LongPress)
@@ -757,6 +772,7 @@ private fun AssistantBlock(
757 onQuote = onQuote,
772 onQuote = onQuote,
758 onBranch = onBranch,
773 onBranch = onBranch,
759 onRegenerate = onRegenerate,
774 onRegenerate = onRegenerate,
775+ onContinue = onContinue,
760 )
776 )
761 }
777 }
762 AssistantMessageLongPressMenu(
778 AssistantMessageLongPressMenu(
@@ -900,11 +916,30 @@ private fun AssistantActions(
900 onQuote: ((String) -> Unit)? = null,
916 onQuote: ((String) -> Unit)? = null,
901 onBranch: ((String) -> Unit)? = null,
917 onBranch: ((String) -> Unit)? = null,
902 onRegenerate: (() -> Unit)? = null,
918 onRegenerate: (() -> Unit)? = null,
919+ onContinue: (() -> Unit)? = null,
903) {
920) {
904 val context = LocalContext.current
921 val context = LocalContext.current
905 val haptics = LocalHapticFeedback.current
922 val haptics = LocalHapticFeedback.current
906 val copyLabel = stringResource(R.string.copy)
923 val copyLabel = stringResource(R.string.copy)
907 Row(verticalAlignment = Alignment.CenterVertically) {
924 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+ }
908 // Regenerate: re-run the preceding user prompt to get a fresh reply. Only meaningful
943 // Regenerate: re-run the preceding user prompt to get a fresh reply. Only meaningful
909 // for a completed, non-streaming message, so the caller gates it on !isRunning.
944 // for a completed, non-streaming message, so the caller gates it on !isRunning.
910 if (onRegenerate != null) {
945 if (onRegenerate != null) {
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+52−4
@@ -177,11 +177,18 @@ fun PartView(
177 is ToolPart -> ToolCallView(part, modifier)
177 is ToolPart -> ToolCallView(part, modifier)
178 is FilePart -> if (part.isImage && imageContext != null && (part.source != null || !part.url.isNullOrBlank())) {
178 is FilePart -> if (part.isImage && imageContext != null && (part.source != null || !part.url.isNullOrBlank())) {
179 RemoteImage(part, imageContext, modifier)
179 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)
180 } else {
187 } else {
181 FileChip(part, modifier, onOpenFile)
188 FileChip(part, modifier, onOpenFile)
182 }
189 }
183 is StepStartPart -> {} // boundary marker — nothing to draw
190 is StepStartPart -> {} // boundary marker — nothing to draw
184− is StepFinishPart -> {} // metrics handled at message level
191+ is StepFinishPart -> StepFinishSummary(part)
185 is AgentPart -> {} // agent name is surfaced in the assistant header, not the body
192 is AgentPart -> {} // agent name is surfaced in the assistant header, not the body
186 is UnknownPart -> UnknownPartNote(modifier)
193 is UnknownPart -> UnknownPartNote(modifier)
187 }
194 }
@@ -635,12 +642,18 @@ private fun ToolStatusIcon(state: ToolState) {
635
642
636@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
643@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
637@Composable
644@Composable
645+private fun FileChip(
646+ part: FilePart,
647+ modifier: Modifier,
638−private fun FileChip(part: FilePart, modifier: Modifier, onOpenFile: ((String) -> Unit)?) {
648+ onOpenFile: ((String) -> Unit)?,
649+ isImageUnavailable: Boolean = false,
650+) {
639 val context = LocalContext.current
651 val context = LocalContext.current
640 val haptics = LocalHapticFeedback.current
652 val haptics = LocalHapticFeedback.current
641 val path = part.sourcePath ?: part.url ?: part.filename
653 val path = part.sourcePath ?: part.url ?: part.filename
642 val copyLabel = stringResource(R.string.copy_path)
654 val copyLabel = stringResource(R.string.copy_path)
643 val openLabel = stringResource(R.string.open_file)
655 val openLabel = stringResource(R.string.open_file)
656+ val imageUnavailableLabel = stringResource(R.string.image_unavailable)
644 // When a source path is available and a navigation callback is wired, tapping the
657 // When a source path is available and a navigation callback is wired, tapping the
645 // chip opens the file in the viewer (the action a user tapping a file reference
658 // chip opens the file in the viewer (the action a user tapping a file reference
646 // most likely expects). Long-press still copies the path to the clipboard, so the
659 // 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) -
650 val opener = onOpenFile
663 val opener = onOpenFile
651 val openPath: String? = if (opener != null && !source.isNullOrBlank()) source else null
664 val openPath: String? = if (opener != null && !source.isNullOrBlank()) source else null
652 val canOpen = openPath != null
665 val canOpen = openPath != null
666+ val semanticsLabel = when {
667+ isImageUnavailable -> imageUnavailableLabel
653− val semanticsLabel = if (canOpen) openLabel else copyLabel
668+ canOpen -> openLabel
669+ else -> copyLabel
670+ }
671+ val displayName = part.filename ?: part.url ?: stringResource(R.string.file)
654 Row(
672 Row(
655 modifier = modifier
673 modifier = modifier
656 .clip(MaterialTheme.shapes.small)
674 .clip(MaterialTheme.shapes.small)
@@ -687,9 +705,39 @@ private fun FileChip(part: FilePart, modifier: Modifier, onOpenFile: ((String) -
687 tint = MaterialTheme.colorScheme.onSurfaceVariant,
705 tint = MaterialTheme.colorScheme.onSurfaceVariant,
688 )
706 )
689 Text(
707 Text(
690− part.filename ?: part.url ?: stringResource(R.string.file),
708+ if (isImageUnavailable) imageUnavailableLabel else displayName,
691 style = MaterialTheme.typography.bodyMedium,
709 style = MaterialTheme.typography.bodyMedium,
692 modifier = Modifier.padding(start = 6.dp),
710 modifier = Modifier.padding(start = 6.dp),
693 )
711 )
694 }
712 }
695}
713}
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
6import androidx.compose.foundation.layout.fillMaxWidth
6import androidx.compose.foundation.layout.fillMaxWidth
7import androidx.compose.foundation.layout.heightIn
7import androidx.compose.foundation.layout.heightIn
8import androidx.compose.foundation.layout.padding
8import androidx.compose.foundation.layout.padding
9+import androidx.compose.foundation.layout.size
9import androidx.compose.foundation.rememberScrollState
10import androidx.compose.foundation.rememberScrollState
10import androidx.compose.foundation.text.selection.SelectionContainer
11import androidx.compose.foundation.text.selection.SelectionContainer
11import androidx.compose.foundation.verticalScroll
12import androidx.compose.foundation.verticalScroll
12import androidx.compose.material.icons.Icons
13import androidx.compose.material.icons.Icons
14+import androidx.compose.material.icons.filled.ExpandLess
15+import androidx.compose.material.icons.filled.ExpandMore
13import androidx.compose.material.icons.filled.Shield
16import androidx.compose.material.icons.filled.Shield
14import androidx.compose.material3.AlertDialog
17import androidx.compose.material3.AlertDialog
15import androidx.compose.material3.Button
18import androidx.compose.material3.Button
16import androidx.compose.material3.Icon
19import androidx.compose.material3.Icon
20+import androidx.compose.material3.IconButton
17import androidx.compose.material3.MaterialTheme
21import androidx.compose.material3.MaterialTheme
18import androidx.compose.material3.OutlinedButton
22import androidx.compose.material3.OutlinedButton
19import androidx.compose.material3.Text
23import androidx.compose.material3.Text
@@ -24,9 +28,13 @@ import androidx.compose.runtime.mutableStateOf
24import androidx.compose.runtime.remember
28import androidx.compose.runtime.remember
25import androidx.compose.runtime.getValue
29import androidx.compose.runtime.getValue
26import androidx.compose.runtime.setValue
30import androidx.compose.runtime.setValue
31+import androidx.compose.ui.Alignment
27import androidx.compose.ui.Modifier
32import androidx.compose.ui.Modifier
28import androidx.compose.ui.platform.LocalHapticFeedback
33import androidx.compose.ui.platform.LocalHapticFeedback
29import androidx.compose.ui.res.stringResource
34import 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
30import androidx.compose.ui.text.font.FontFamily
38import androidx.compose.ui.text.font.FontFamily
31import androidx.compose.ui.unit.dp
39import androidx.compose.ui.unit.dp
32import kotlinx.coroutines.delay
40import kotlinx.coroutines.delay
@@ -141,6 +149,14 @@ fun PermissionDialog(
141 color = MaterialTheme.colorScheme.onSurfaceVariant,
149 color = MaterialTheme.colorScheme.onSurfaceVariant,
142 )
150 )
143 }
151 }
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) }
144 }
160 }
145 }
161 }
146 Text(
162 Text(
@@ -226,3 +242,60 @@ fun PermissionDialog(
226 },
242 },
227 )
243 )
228}
244}
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
24import androidx.compose.foundation.text.KeyboardActions
24import androidx.compose.foundation.text.KeyboardActions
25import androidx.compose.foundation.text.KeyboardOptions
25import androidx.compose.foundation.text.KeyboardOptions
26import androidx.compose.material.icons.Icons
26import androidx.compose.material.icons.Icons
27+import androidx.compose.material.icons.filled.ChatBubbleOutline
27import androidx.compose.material.icons.filled.ChevronRight
28import androidx.compose.material.icons.filled.ChevronRight
28import androidx.compose.material.icons.filled.Close
29import androidx.compose.material.icons.filled.Close
29import androidx.compose.material.icons.filled.Description
30import androidx.compose.material.icons.filled.Description
@@ -66,6 +67,7 @@ import androidx.compose.ui.Modifier
66import androidx.compose.ui.focus.FocusRequester
67import androidx.compose.ui.focus.FocusRequester
67import androidx.compose.ui.focus.focusRequester
68import androidx.compose.ui.focus.focusRequester
68import androidx.compose.ui.graphics.Color
69import androidx.compose.ui.graphics.Color
70+import androidx.compose.ui.graphics.graphicsLayer
69import androidx.compose.ui.platform.LocalContext
71import androidx.compose.ui.platform.LocalContext
70import androidx.compose.ui.platform.LocalSoftwareKeyboardController
72import androidx.compose.ui.platform.LocalSoftwareKeyboardController
71import androidx.compose.ui.platform.testTag
73import androidx.compose.ui.platform.testTag
@@ -91,6 +93,7 @@ import soy.iko.opencode.data.model.FileStatusEntry
91import soy.iko.opencode.data.model.FindMatch
93import soy.iko.opencode.data.model.FindMatch
92import soy.iko.opencode.data.model.SymbolResult
94import soy.iko.opencode.data.model.SymbolResult
93import soy.iko.opencode.data.model.symbolKindLabel
95import soy.iko.opencode.data.model.symbolKindLabel
96+import soy.iko.opencode.data.network.NetworkConfig
94import soy.iko.opencode.di.AppContainer
97import soy.iko.opencode.di.AppContainer
95import soy.iko.opencode.R
98import soy.iko.opencode.R
96import soy.iko.opencode.ui.components.AppTopBar
99import soy.iko.opencode.ui.components.AppTopBar
@@ -115,8 +118,25 @@ fun FileBrowserScreen(
115 val snackbar = remember { SnackbarHostState() }
118 val snackbar = remember { SnackbarHostState() }
116
119
117 // System-back navigates up the directory tree while inside a subfolder (matching every
120 // 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.
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(
119− androidx.activity.compose.BackHandler(enabled = state.path.isNotBlank()) { vm.up() }
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+ }
120
140
121 LaunchedEffect(Unit) {
141 LaunchedEffect(Unit) {
122 vm.transientErrors.collect { msg ->
142 vm.transientErrors.collect { msg ->
@@ -130,7 +150,17 @@ fun FileBrowserScreen(
130 },
150 },
131 snackbarHost = { SnackbarHost(snackbar) },
151 snackbarHost = { SnackbarHost(snackbar) },
132 ) { padding ->
152 ) { 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(
133− Column(modifier = Modifier.fillMaxSize().imePadding().padding(padding)) {
158+ modifier = Modifier
159+ .fillMaxSize()
160+ .imePadding()
161+ .padding(padding)
162+ .graphicsLayer { scaleX = scale; scaleY = scale },
163+ ) {
134 // Surface SSE connection state so a dropped stream is visible while browsing
164 // Surface SSE connection state so a dropped stream is visible while browsing
135 // files, not just on the chat/session screens.
165 // files, not just on the chat/session screens.
136 Box(modifier = Modifier.fillMaxWidth()) {
166 Box(modifier = Modifier.fillMaxWidth()) {
@@ -255,6 +285,10 @@ fun FileBrowserScreen(
255 onOpenDir = vm::open,
285 onOpenDir = vm::open,
256 onUp = vm::up,
286 onUp = vm::up,
257 onOpenFile = { onOpenFile(it, null) },
287 onOpenFile = { onOpenFile(it, null) },
288+ onOpenInChat = { path ->
289+ container.setPendingShare("@$path")
290+ container.requestNewSession()
291+ },
258 )
292 )
259 }
293 }
260 // Slim progress bar for an in-flight search; the results list stays visible
294 // 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
539 }
573 }
540}
574}
541
575
576+@Suppress("CyclomaticComplexMethod")
542@Composable
577@Composable
543private fun DirectoryListing(
578private fun DirectoryListing(
544 state: FileBrowserState,
579 state: FileBrowserState,
545 onOpenDir: (String) -> Unit,
580 onOpenDir: (String) -> Unit,
546 onUp: () -> Unit,
581 onUp: () -> Unit,
547 onOpenFile: (String) -> Unit,
582 onOpenFile: (String) -> Unit,
583+ onOpenInChat: ((String) -> Unit)? = null,
548) {
584) {
549 val context = LocalContext.current
585 val context = LocalContext.current
550 if (state.entries.isEmpty()) {
586 if (state.entries.isEmpty()) {
@@ -587,6 +623,16 @@ private fun DirectoryListing(
587 visible.sortedWith(comparator)
623 visible.sortedWith(comparator)
588 }
624 }
589 }
625 }
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+ }
590 LazyColumn(modifier = Modifier.fillMaxSize()) {
636 LazyColumn(modifier = Modifier.fillMaxSize()) {
591 if (state.path.isNotBlank()) {
637 if (state.path.isNotBlank()) {
592 item(key = "__up") {
638 item(key = "__up") {
@@ -664,7 +710,12 @@ private fun DirectoryListing(
664 )
710 )
665 }
711 }
666 }
712 }
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.
667− items(sorted, key = { it.path + "_" + it.name }) { node ->
718+ items(visibleSorted, key = { it.path + "_" + it.name }) { node ->
668 FileRow(
719 FileRow(
669 icon = node.isDirectory,
720 icon = node.isDirectory,
670 label = node.name,
721 label = node.name,
@@ -672,6 +723,7 @@ private fun DirectoryListing(
672 onCopyPath = {
723 onCopyPath = {
673 copyToClipboard(context, context.getString(R.string.clip_label_path), node.path)
724 copyToClipboard(context, context.getString(R.string.clip_label_path), node.path)
674 },
725 },
726+ onOpenInChat = onOpenInChat?.let { cb -> { cb(node.path) } },
675 size = node.size,
727 size = node.size,
676 mtime = node.mtime,
728 mtime = node.mtime,
677 status = state.statusMap[node.path],
729 status = state.statusMap[node.path],
@@ -679,6 +731,14 @@ private fun DirectoryListing(
679 )
731 )
680 HorizontalDivider()
732 HorizontalDivider()
681 }
733 }
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+ }
682 }
742 }
683}
743}
684
744
@@ -691,6 +751,7 @@ private fun EmptyFileState(icon: androidx.compose.ui.graphics.vector.ImageVector
691
751
692@OptIn(ExperimentalFoundationApi::class)
752@OptIn(ExperimentalFoundationApi::class)
693@Composable
753@Composable
754+@Suppress("CyclomaticComplexMethod")
694private fun FileRow(
755private fun FileRow(
695 icon: Boolean,
756 icon: Boolean,
696 label: String,
757 label: String,
@@ -700,6 +761,7 @@ private fun FileRow(
700 size: Long? = null,
761 size: Long? = null,
701 mtime: Long? = null,
762 mtime: Long? = null,
702 onCopyPath: (() -> Unit)? = null,
763 onCopyPath: (() -> Unit)? = null,
764+ onOpenInChat: (() -> Unit)? = null,
703 modifier: Modifier = Modifier,
765 modifier: Modifier = Modifier,
704) {
766) {
705 val context = LocalContext.current
767 val context = LocalContext.current
@@ -794,6 +856,16 @@ private fun FileRow(
794 text = { Text(copyPathLabel) },
856 text = { Text(copyPathLabel) },
795 onClick = { menu = false; onCopyPath?.invoke() },
857 onClick = { menu = false; onCopyPath?.invoke() },
796 )
858 )
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+ }
797 }
869 }
798 }
870 }
799}
871}
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+58
@@ -11,10 +11,12 @@ import androidx.compose.animation.fadeIn
11import androidx.compose.animation.fadeOut
11import androidx.compose.animation.fadeOut
12import androidx.compose.animation.togetherWith
12import androidx.compose.animation.togetherWith
13import androidx.compose.foundation.background
13import androidx.compose.foundation.background
14+import androidx.compose.foundation.Image
14import androidx.compose.foundation.horizontalScroll
15import androidx.compose.foundation.horizontalScroll
15import androidx.compose.foundation.interaction.DragInteraction
16import androidx.compose.foundation.interaction.DragInteraction
16import androidx.compose.foundation.layout.Box
17import androidx.compose.foundation.layout.Box
17import androidx.compose.foundation.layout.BoxScope
18import androidx.compose.foundation.layout.BoxScope
19+import androidx.compose.foundation.layout.heightIn
18import androidx.compose.foundation.layout.Column
20import androidx.compose.foundation.layout.Column
19import androidx.compose.foundation.layout.Arrangement
21import androidx.compose.foundation.layout.Arrangement
20import androidx.compose.foundation.layout.Row
22import androidx.compose.foundation.layout.Row
@@ -75,6 +77,7 @@ import androidx.compose.runtime.rememberCoroutineScope
75import androidx.compose.runtime.saveable.rememberSaveable
77import androidx.compose.runtime.saveable.rememberSaveable
76import androidx.compose.runtime.setValue
78import androidx.compose.runtime.setValue
77import androidx.compose.ui.Alignment
79import androidx.compose.ui.Alignment
80+import androidx.compose.ui.graphics.asImageBitmap
78import androidx.compose.ui.Modifier
81import androidx.compose.ui.Modifier
79import androidx.compose.ui.draw.alpha
82import androidx.compose.ui.draw.alpha
80import androidx.compose.ui.draw.clip
83import androidx.compose.ui.draw.clip
@@ -308,6 +311,7 @@ fun FileViewScreen(
308 // the crossfade's exiting layer to briefly use this unified set until it settles.
311 // the crossfade's exiting layer to briefly use this unified set until it settles.
309 FileViewStateContent(
312 FileViewStateContent(
310 state = s,
313 state = s,
314+ path = path,
311 filename = filename,
315 filename = filename,
312 showToggle = showToggle,
316 showToggle = showToggle,
313 showDiff = showDiff,
317 showDiff = showDiff,
@@ -577,6 +581,7 @@ private fun FileViewTopBar(
577@Composable
581@Composable
578private fun BoxScope.FileViewStateContent(
582private fun BoxScope.FileViewStateContent(
579 state: FileViewState,
583 state: FileViewState,
584+ path: String,
580 filename: String,
585 filename: String,
581 showToggle: Boolean,
586 showToggle: Boolean,
582 showDiff: Boolean,
587 showDiff: Boolean,
@@ -661,6 +666,18 @@ private fun BoxScope.FileViewStateContent(
661 Spacer(Modifier.size(12.dp))
666 Spacer(Modifier.size(12.dp))
662 TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
667 TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
663 }
668 }
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+ }
664 state.content?.isBinary == true -> Text(
681 state.content?.isBinary == true -> Text(
665 stringResource(R.string.binary_file),
682 stringResource(R.string.binary_file),
666 modifier = Modifier.align(Alignment.Center).padding(24.dp),
683 modifier = Modifier.align(Alignment.Center).padding(24.dp),
@@ -1040,3 +1057,44 @@ private fun FileTextContent(
1040 }
1057 }
1041 }
1058 }
1042}
1059}
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(
49 }
49 }
50 }
50 }
51
51
52− // Stale-selection cleanup: if the selected file is deleted server-side (or otherwise
52+ // Stale-selection cleanup: if the selected file's view fails to load (the file was
53− // disappears from the listing), clear the selection so the detail pane doesn't keep
53+ // deleted server-side or otherwise disappeared), clear the selection so the detail
54− // showing stale content. Mirrors TwoPaneSessionChat's equivalent for sessions. We only
54+ // pane doesn't keep showing stale content. Previously this made an extra API call
55− // clear when the listing is loaded and non-empty (so a transient empty load doesn't wipe
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
56− // the selection); the FileViewScreen's own error state handles a genuinely-missing file.
57+ // FileViewScreen's own error state: when its load fails, it calls onBack, which clears
57− val connection by container.activeConnection.collectAsStateWithLifecycle()
58+ // the selection. This is the same pattern the session two-pane uses for deleted sessions.
58− LaunchedEffect(connection, selected) {
59+ // No extra network call is needed here.
59− val conn = connection ?: return@LaunchedEffect
60− val path = selected ?: return@LaunchedEffect
61− // The file browser's listing is cached in the VM; check the current directory's
62− // entries via the repository's file-listing API for the selected file's parent.
63− // This is a lightweight check that runs once per selection change.
64− val parent = path.substringBeforeLast('/', missingDelimiterValue = "")
65− if (parent.isBlank()) return@LaunchedEffect
66− val listing = runCatching { conn.api.listDirectory(parent) }.getOrNull() ?: return@LaunchedEffect
67− val stillExists = listing.any { it.path == path }
68− if (!stillExists) selected = null
69− }
70
60
71 Row(modifier = modifier.fillMaxSize()) {
61 Row(modifier = modifier.fillMaxSize()) {
72 Box(
62 Box(
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+12−4
@@ -384,11 +384,19 @@ class ServerEditViewModel(
384 _state.update { it.copy(saving = true) }
384 _state.update { it.copy(saving = true) }
385 viewModelScope.launch {
385 viewModelScope.launch {
386 val result = runCatchingCancellable {
386 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+ }
387 val existingLastUsed = if (s.id != null) {
398 val existingLastUsed = if (s.id != null) {
388− withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
389− container.profileStore.profiles.first()
390− .firstOrNull { it.id == s.id }?.lastUsed
399+ existing.firstOrNull { it.id == s.id }?.lastUsed ?: 0L
391− } ?: 0L
392 } else 0L
400 } else 0L
393 val saved = ServerProfile(
401 val saved = ServerProfile(
394 id = s.id ?: UUID.randomUUID().toString(),
402 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
16import androidx.compose.foundation.layout.imePadding
16import androidx.compose.foundation.layout.imePadding
17import androidx.compose.foundation.layout.fillMaxWidth
17import androidx.compose.foundation.layout.fillMaxWidth
18import androidx.compose.foundation.layout.heightIn
18import androidx.compose.foundation.layout.heightIn
19+import androidx.compose.foundation.layout.height
19import androidx.compose.foundation.layout.padding
20import androidx.compose.foundation.layout.padding
20import androidx.compose.foundation.layout.size
21import androidx.compose.foundation.layout.size
21import androidx.compose.foundation.lazy.LazyColumn
22import androidx.compose.foundation.lazy.LazyColumn
@@ -115,6 +116,7 @@ import soy.iko.opencode.data.model.Session
115import soy.iko.opencode.data.network.NetworkConfig
116import soy.iko.opencode.data.network.NetworkConfig
116import soy.iko.opencode.data.repo.RecentSession
117import soy.iko.opencode.data.repo.RecentSession
117import soy.iko.opencode.data.repo.RecentSessionsStore
118import soy.iko.opencode.data.repo.RecentSessionsStore
119+import soy.iko.opencode.data.repo.SwipeAction
118import soy.iko.opencode.di.AppContainer
120import soy.iko.opencode.di.AppContainer
119import soy.iko.opencode.platform.AppShortcuts
121import soy.iko.opencode.platform.AppShortcuts
120import soy.iko.opencode.platform.SessionsWidgetProvider
122import soy.iko.opencode.platform.SessionsWidgetProvider
@@ -124,11 +126,20 @@ import soy.iko.opencode.ui.components.EmptyState
124import soy.iko.opencode.ui.components.LoadingSize
126import soy.iko.opencode.ui.components.LoadingSize
125import soy.iko.opencode.ui.components.LoadingSpinner
127import soy.iko.opencode.ui.components.LoadingSpinner
126import soy.iko.opencode.ui.components.LocalRelativeTimeTick
128import soy.iko.opencode.ui.components.LocalRelativeTimeTick
129+import soy.iko.opencode.ui.components.LocalReducedMotion
127import soy.iko.opencode.ui.components.copyToClipboard
130import soy.iko.opencode.ui.components.copyToClipboard
128import soy.iko.opencode.ui.components.reducedMotionAnimateItem
131import soy.iko.opencode.ui.components.reducedMotionAnimateItem
129import soy.iko.opencode.ui.components.RelativeTimeText
132import soy.iko.opencode.ui.components.RelativeTimeText
130import soy.iko.opencode.ui.components.rememberRelativeTimeTick
133import soy.iko.opencode.ui.components.rememberRelativeTimeTick
131import soy.iko.opencode.ui.vmFactory
134import 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
132
143
133@OptIn(ExperimentalMaterial3Api::class)
144@OptIn(ExperimentalMaterial3Api::class)
134@Composable
145@Composable
@@ -160,6 +171,18 @@ fun SessionListScreen(
160 val runningSessionIds by container.runningSessionIds.collectAsStateWithLifecycle()
171 val runningSessionIds by container.runningSessionIds.collectAsStateWithLifecycle()
161 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
172 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
162 val isOnline by container.isOnline.collectAsStateWithLifecycle()
173 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()
163 val connectedId = activeConnection?.profile?.id
186 val connectedId = activeConnection?.profile?.id
164 val haptics = LocalHapticFeedback.current
187 val haptics = LocalHapticFeedback.current
165 val snackbar = remember { SnackbarHostState() }
188 val snackbar = remember { SnackbarHostState() }
@@ -350,6 +373,25 @@ fun SessionListScreen(
350 )
373 )
351 },
374 },
352 actions = {
375 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+ }
353 IconButton(onClick = onOpenSearch) {
395 IconButton(onClick = onOpenSearch) {
354 Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.search_all))
396 Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.search_all))
355 }
397 }
@@ -475,6 +517,9 @@ fun SessionListScreen(
475 selectedIds = selectedIds,
517 selectedIds = selectedIds,
476 onToggleSelect = ::toggleSelected,
518 onToggleSelect = ::toggleSelected,
477 runningSessionIds = runningSessionIds,
519 runningSessionIds = runningSessionIds,
520+ drafts = drafts,
521+ swipeLeftAction = swipeLeftActionStr,
522+ swipeRightAction = swipeRightActionStr,
478 )
523 )
479 }
524 }
480 }
525 }
@@ -566,6 +611,7 @@ fun SessionListScreen(
566}
611}
567
612
568@OptIn(ExperimentalMaterial3Api::class)
613@OptIn(ExperimentalMaterial3Api::class)
614+@Suppress("CyclomaticComplexMethod")
569@Composable
615@Composable
570private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
616private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
571 state: SessionListState,
617 state: SessionListState,
@@ -593,28 +639,93 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
593 selectedIds: Set<String>,
639 selectedIds: Set<String>,
594 onToggleSelect: (String) -> Unit,
640 onToggleSelect: (String) -> Unit,
595 runningSessionIds: Set<String> = emptySet(),
641 runningSessionIds: Set<String> = emptySet(),
642+ drafts: Map<String, String> = emptyMap(),
643+ swipeLeftAction: String = "DELETE",
644+ swipeRightAction: String = "ARCHIVE",
596) {
645) {
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.
597− when {
649+ val stateKey = when {
598− state.loading -> {
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.
599− val loadingLabel = stringResource(R.string.loading)
668+ val loadingLabel = stringResource(R.string.loading_sessions)
600− // Match sibling screens (MCP/Usage/Chat) which show a visible "Loading…"
669+ val reducedMotion = LocalReducedMotion.current
601− // label beneath the spinner; a bare spinner is the weakest loading state
670+ val transition = rememberInfiniteTransition(label = "session_skeleton")
602− // in the app and this is the primary screen.
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
603 Column(
679 Column(
604− modifier = Modifier.align(Alignment.Center),
680+ modifier = Modifier
605− horizontalAlignment = Alignment.CenterHorizontally,
681+ .align(Alignment.TopCenter)
682+ .padding(16.dp)
683+ .fillMaxWidth()
684+ .semantics { contentDescription = loadingLabel },
685+ verticalArrangement = Arrangement.spacedBy(12.dp),
606 ) {
686 ) {
607− CircularProgressIndicator(
687+ repeat(6) {
608− Modifier.semantics { contentDescription = loadingLabel },
688+ Row(
689+ modifier = Modifier.fillMaxWidth(),
690+ horizontalArrangement = Arrangement.spacedBy(12.dp),
691+ verticalAlignment = Alignment.CenterVertically,
609− )
692+ ) {
693+ // Avatar circle placeholder.
694+ Box(
610− Spacer(Modifier.size(12.dp))
695+ Modifier
611− Text(
696+ .size(40.dp)
612− loadingLabel,
697+ .clip(RoundedCornerShape(20.dp))
613− color = MaterialTheme.colorScheme.onSurfaceVariant,
698+ .background(skeletonColor)
614− )
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+ }
615 }
726 }
616 }
727 }
617− state.sessions.isEmpty() && state.error != null -> Column(
728+ "error" -> Column(
618 modifier = Modifier.align(Alignment.Center).padding(24.dp),
729 modifier = Modifier.align(Alignment.Center).padding(24.dp),
619 horizontalAlignment = Alignment.CenterHorizontally,
730 horizontalAlignment = Alignment.CenterHorizontally,
620 ) {
731 ) {
@@ -635,7 +746,7 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
635 Text(stringResource(R.string.retry))
746 Text(stringResource(R.string.retry))
636 }
747 }
637 }
748 }
638− state.sessions.isEmpty() -> EmptyState(
749+ "empty" -> EmptyState(
639 icon = Icons.Filled.ChatBubbleOutline,
750 icon = Icons.Filled.ChatBubbleOutline,
640 title = stringResource(R.string.no_sessions_yet),
751 title = stringResource(R.string.no_sessions_yet),
641 description = stringResource(R.string.no_sessions_hint),
752 description = stringResource(R.string.no_sessions_hint),
@@ -795,25 +906,35 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
795 // the dismissal (always reset to Settled) so the card snaps back
906 // the dismissal (always reset to Settled) so the card snaps back
796 // and the dialog guards against accidental data loss.
907 // and the dialog guards against accidental data loss.
797 val isArchivedForSwipe = session.id in state.archivedIds
908 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+ }
798 val swipeState = rememberSwipeToDismissBoxState(
926 val swipeState = rememberSwipeToDismissBoxState(
799 confirmValueChange = { value ->
927 confirmValueChange = { value ->
800− // Haptic at the trigger so both swipe paths match the icon
928+ haptics.performHapticFeedback(
801− // paths, which vibrate on confirm. Always returns false (snap
929+ androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress,
802− // back): delete is guarded by a dialog, archive is instantly
930+ )
803− // undoable via snackbar, so neither commits the dismissal.
804 when (value) {
931 when (value) {
805 SwipeToDismissBoxValue.EndToStart -> {
932 SwipeToDismissBoxValue.EndToStart -> {
806− haptics.performHapticFeedback(
933+ applySwipe(swipeLeft)
807− androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress,
808− )
809− onDelete(session.id)
810 false
934 false
811 }
935 }
812 SwipeToDismissBoxValue.StartToEnd -> {
936 SwipeToDismissBoxValue.StartToEnd -> {
813− haptics.performHapticFeedback(
937+ applySwipe(swipeRight)
814− androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress,
815− )
816− onArchive(session)
817 false
938 false
818 }
939 }
819 SwipeToDismissBoxValue.Settled -> false
940 SwipeToDismissBoxValue.Settled -> false
@@ -895,6 +1016,7 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
895 isArchived = isArchived,
1016 isArchived = isArchived,
896 isMuted = isMuted,
1017 isMuted = isMuted,
897 isRunning = session.id in runningSessionIds,
1018 isRunning = session.id in runningSessionIds,
1019+ hasDraft = session.id in drafts,
898 onClick = onCardClick,
1020 onClick = onCardClick,
899 onRename = onCardRename,
1021 onRename = onCardRename,
900 onDelete = onCardDelete,
1022 onDelete = onCardDelete,
@@ -916,6 +1038,7 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
916 }
1038 }
917 }
1039 }
918 }
1040 }
1041+ }
919}
1042}
920
1043
921/** A session plus its nesting [depth] (0 = top level) for the sub-session tree. */
1044/** 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) {
1043}
1166}
1044
1167
1045@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
1168@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
1169+@Suppress("CyclomaticComplexMethod")
1046@Composable
1170@Composable
1047private fun SessionCard(
1171private fun SessionCard(
1048 session: Session,
1172 session: Session,
@@ -1061,6 +1185,7 @@ private fun SessionCard(
1061 isPinned: Boolean = false,
1185 isPinned: Boolean = false,
1062 isArchived: Boolean = false,
1186 isArchived: Boolean = false,
1063 isRunning: Boolean = false,
1187 isRunning: Boolean = false,
1188+ hasDraft: Boolean = false,
1064 onFilterByDirectory: ((String) -> Unit)? = null,
1189 onFilterByDirectory: ((String) -> Unit)? = null,
1065 isDirectoryFiltered: Boolean = false,
1190 isDirectoryFiltered: Boolean = false,
1066 inSelection: Boolean = false,
1191 inSelection: Boolean = false,
@@ -1186,6 +1311,28 @@ private fun SessionCard(
1186 tint = MaterialTheme.colorScheme.onSurfaceVariant,
1311 tint = MaterialTheme.colorScheme.onSurfaceVariant,
1187 )
1312 )
1188 }
1313 }
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+ }
1189 // Server-side state badges (shared / reverted). These are surfaced in
1336 // Server-side state badges (shared / reverted). These are surfaced in
1190 // the chat screen but weren't in the list, so the list never reflected
1337 // the chat screen but weren't in the list, so the list never reflected
1191 // them. Muted so they read as status, not as unread emphasis.
1338 // 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
51import androidx.compose.material3.Text
51import androidx.compose.material3.Text
52import androidx.compose.material3.TextButton
52import androidx.compose.material3.TextButton
53import androidx.compose.material3.TopAppBar
53import androidx.compose.material3.TopAppBar
54+import androidx.compose.material3.TopAppBarDefaults
54import androidx.compose.material3.rememberSwipeToDismissBoxState
55import androidx.compose.material3.rememberSwipeToDismissBoxState
55import androidx.compose.runtime.Composable
56import androidx.compose.runtime.Composable
56import androidx.compose.runtime.CompositionLocalProvider
57import androidx.compose.runtime.CompositionLocalProvider
@@ -78,6 +79,7 @@ import soy.iko.opencode.ui.components.reducedMotionAnimateItem
78import androidx.compose.ui.Alignment
79import androidx.compose.ui.Alignment
79import androidx.compose.ui.Modifier
80import androidx.compose.ui.Modifier
80import androidx.compose.ui.draw.clip
81import androidx.compose.ui.draw.clip
82+import androidx.compose.ui.input.nestedscroll.nestedScroll
81import androidx.compose.ui.platform.LocalContext
83import androidx.compose.ui.platform.LocalContext
82import androidx.compose.ui.platform.LocalHapticFeedback
84import androidx.compose.ui.platform.LocalHapticFeedback
83import androidx.compose.ui.res.stringResource
85import androidx.compose.ui.res.stringResource
@@ -208,11 +210,14 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
208 }
210 }
209 }
211 }
210
212
213+ // TopAppBar scroll behavior: collapse/raise the app bar as the report list scrolls.
214+ val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
211 Scaffold(
215 Scaffold(
212 topBar = {
216 topBar = {
213 AppTopBar(
217 AppTopBar(
214 title = stringResource(R.string.diagnostics),
218 title = stringResource(R.string.diagnostics),
215 onBack = onBack,
219 onBack = onBack,
220+ scrollBehavior = scrollBehavior,
216 actions = {
221 actions = {
217 if (reports.isNotEmpty()) {
222 if (reports.isNotEmpty()) {
218 IconButton(onClick = {
223 IconButton(onClick = {
@@ -241,7 +246,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
241 },
246 },
242 ) { padding ->
247 ) { padding ->
243 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
248 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
244− Column(modifier = Modifier.fillMaxSize().imePadding().padding(padding)) {
249+ Column(modifier = Modifier.fillMaxSize().imePadding().padding(padding).nestedScroll(scrollBehavior.nestedScrollConnection)) {
245 Text(
250 Text(
246 stringResource(R.string.diagnostics_desc),
251 stringResource(R.string.diagnostics_desc),
247 style = MaterialTheme.typography.bodySmall,
252 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
38import androidx.compose.material.icons.filled.Upload
38import androidx.compose.material.icons.filled.Upload
39import androidx.compose.material3.AlertDialog
39import androidx.compose.material3.AlertDialog
40import androidx.compose.material3.ExperimentalMaterial3Api
40import androidx.compose.material3.ExperimentalMaterial3Api
41+import androidx.compose.material3.Badge
42+import androidx.compose.material3.BadgedBox
41import androidx.compose.material3.CircularProgressIndicator
43import androidx.compose.material3.CircularProgressIndicator
44+import androidx.compose.material3.DropdownMenu
42import androidx.compose.material3.DropdownMenuItem
45import androidx.compose.material3.DropdownMenuItem
43import androidx.compose.material3.ExposedDropdownMenuBox
46import androidx.compose.material3.ExposedDropdownMenuBox
44import androidx.compose.material3.ExposedDropdownMenuDefaults
47import androidx.compose.material3.ExposedDropdownMenuDefaults
@@ -59,6 +62,7 @@ import androidx.compose.material3.Switch
59import androidx.compose.material3.Text
62import androidx.compose.material3.Text
60import androidx.compose.material3.TextButton
63import androidx.compose.material3.TextButton
61import androidx.compose.material3.TopAppBar
64import androidx.compose.material3.TopAppBar
65+import androidx.compose.material3.TopAppBarDefaults
62import androidx.compose.runtime.Composable
66import androidx.compose.runtime.Composable
63import androidx.compose.runtime.getValue
67import androidx.compose.runtime.getValue
64import androidx.compose.runtime.mutableFloatStateOf
68import androidx.compose.runtime.mutableFloatStateOf
@@ -71,6 +75,7 @@ import androidx.compose.ui.Alignment
71import androidx.compose.ui.Modifier
75import androidx.compose.ui.Modifier
72import androidx.compose.ui.draw.clip
76import androidx.compose.ui.draw.clip
73import androidx.compose.ui.graphics.Color
77import androidx.compose.ui.graphics.Color
78+import androidx.compose.ui.input.nestedscroll.nestedScroll
74import androidx.compose.ui.platform.LocalContext
79import androidx.compose.ui.platform.LocalContext
75import androidx.compose.ui.platform.LocalDensity
80import androidx.compose.ui.platform.LocalDensity
76import androidx.compose.ui.res.pluralStringResource
81import androidx.compose.ui.res.pluralStringResource
@@ -92,6 +97,7 @@ import kotlinx.coroutines.launch
92import soy.iko.opencode.data.network.EventStreamClient
97import soy.iko.opencode.data.network.EventStreamClient
93import soy.iko.opencode.data.repo.CrashLogger
98import soy.iko.opencode.data.repo.CrashLogger
94import soy.iko.opencode.data.repo.SettingsStore
99import soy.iko.opencode.data.repo.SettingsStore
100+import soy.iko.opencode.data.repo.SwipeAction
95import soy.iko.opencode.data.repo.ThemeMode
101import soy.iko.opencode.data.repo.ThemeMode
96import soy.iko.opencode.di.AppContainer
102import soy.iko.opencode.di.AppContainer
97import soy.iko.opencode.R
103import soy.iko.opencode.R
@@ -150,6 +156,14 @@ fun SettingsScreen(
150 .collectAsStateWithLifecycle(initialValue = false)
156 .collectAsStateWithLifecycle(initialValue = false)
151 val appLockReLockSeconds by container.settingsStore.appLockReLockSeconds
157 val appLockReLockSeconds by container.settingsStore.appLockReLockSeconds
152 .collectAsStateWithLifecycle(initialValue = SettingsStore.DEFAULT_APP_LOCK_RELOCK_SECONDS)
158 .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")
153 val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
167 val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
154 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
168 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
155 // SSE connection state so the Settings screen can show a dropped stream (the
169 // SSE connection state so the Settings screen can show a dropped stream (the
@@ -237,6 +251,10 @@ fun SettingsScreen(
237 return "opencode-backup-$now.json"
251 return "opencode-backup-$now.json"
238 }
252 }
239
253
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()
240 Scaffold(
258 Scaffold(
241 topBar = {
259 topBar = {
242 TopAppBar(
260 TopAppBar(
@@ -246,6 +264,7 @@ fun SettingsScreen(
246 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
264 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
247 }
265 }
248 },
266 },
267+ scrollBehavior = scrollBehavior,
249 )
268 )
250 },
269 },
251 snackbarHost = { SnackbarHost(snackbar) },
270 snackbarHost = { SnackbarHost(snackbar) },
@@ -257,6 +276,10 @@ fun SettingsScreen(
257 .wrapContentWidth(Alignment.CenterHorizontally)
276 .wrapContentWidth(Alignment.CenterHorizontally)
258 .widthIn(max = 600.dp)
277 .widthIn(max = 600.dp)
259 .verticalScroll(rememberScrollState())
278 .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)
260 .padding(16.dp),
283 .padding(16.dp),
261 ) {
284 ) {
262 val s = settings
285 val s = settings
@@ -460,6 +483,261 @@ fun SettingsScreen(
460
483
461 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
484 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
462
485
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+
463 Text(
741 Text(
464 stringResource(R.string.connection),
742 stringResource(R.string.connection),
465 style = MaterialTheme.typography.titleSmall,
743 style = MaterialTheme.typography.titleSmall,
@@ -654,9 +932,18 @@ fun SettingsScreen(
654 Icon(Icons.Filled.BugReport, contentDescription = null, tint = MaterialTheme.colorScheme.error)
932 Icon(Icons.Filled.BugReport, contentDescription = null, tint = MaterialTheme.colorScheme.error)
655 Text(stringResource(R.string.diagnostics), modifier = Modifier.weight(1f).padding(start = 8.dp))
933 Text(stringResource(R.string.diagnostics), modifier = Modifier.weight(1f).padding(start = 8.dp))
656 // Badge with the crash count so the user can tell at a glance whether
934 // 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.
658 if (crashCount > 0) {
938 if (crashCount > 0) {
939+ BadgedBox(
940+ badge = {
659− Badge(count = crashCount)
941+ Badge {
942+ val overflow = stringResource(R.string.crash_count_overflow)
943+ Text(if (crashCount > 99) overflow else crashCount.toString())
944+ }
945+ },
946+ ) {}
660 Spacer(Modifier.size(8.dp))
947 Spacer(Modifier.size(8.dp))
661 }
948 }
662 Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
949 Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
@@ -931,26 +1218,6 @@ private data class SettingsValues(
931 val appLock: Boolean,
1218 val appLock: Boolean,
932)
1219)
933
1220
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−
954/** Dropdown selecting how long after backgrounding the app re-locks. Only shown when app lock
1221/** Dropdown selecting how long after backgrounding the app re-locks. Only shown when app lock
955 * is on. The grace period avoids a re-prompt on every quick app-switch (the most common reason
1222 * is on. The grace period avoids a re-prompt on every quick app-switch (the most common reason
956 * users disable biometric locks), while still re-locking immediately by default. */
1223 * users disable biometric locks), while still re-locking immediately by default. */
app/src/main/res/values-es/strings.xml+40
@@ -828,4 +828,44 @@
828 <string name="test_connection_desc">Probando conexión a %1$s</string>
828 <string name="test_connection_desc">Probando conexión a %1$s</string>
829 <string name="test_connection_success">Conectado (%1$dms)</string>
829 <string name="test_connection_success">Conectado (%1$dms)</string>
830 <string name="test_connection_failed">Error: %1$s</string>
830 <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>
831</resources>
871</resources>
app/src/main/res/values/strings.xml+61
@@ -833,4 +833,65 @@
833 <string name="test_connection_desc">Testing connection to %1$s</string>
833 <string name="test_connection_desc">Testing connection to %1$s</string>
834 <string name="test_connection_success">Connected (%1$dms)</string>
834 <string name="test_connection_success">Connected (%1$dms)</string>
835 <string name="test_connection_failed">Failed: %1$s</string>
835 <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>
836</resources>
897</resources>