git.iko.soy/opencode-android
on master help sign in
Batch of UI/UX improvements: add 48dp touch targets, a reasoning word-count hint, and code-block collapse to the chat, add regenerate and find-in-conversation and a streaming caret and an editable queued follow-up and composer image paste and a typing-row stop and a jump-to-latest badge and keyboard-shortcuts dialog, add session multi-select with bulk actions and a launcher unread badge and persisted sort/show-archived and a tappable directory filter, add two-pane files with open-externally and size/date columns and sort-by-size/date, add autofill hints and QR/JSON paste to the server editor and recent/name sort to the server list, add diagnostics export-all and source/issue about links to settings, add notification retry-last and a foreground progress bar and an unread-badge channel, and add opencode://file and opencode://new deep links
commitffa5e31c97b5e05a20d11ae89298cba78c33e130 authoriko <mail@iko.soy> dateSat 4 Jul 2026, 01:24 (3 months ago) parent7496a78
36 files changed+1527−103
app/detekt-baseline.xml+13 app/src/main/AndroidManifest.xml+13 app/src/main/java/soy/iko/opencode/MainActivity.kt+48−14 app/src/main/java/soy/iko/opencode/data/model/Files.kt+6 app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.kt+34−2 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+52 app/src/main/java/soy/iko/opencode/notification/NotificationActionReceiver.kt+21 app/src/main/java/soy/iko/opencode/notification/NotificationChannels.kt+13 app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+3 app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+44−2 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+27−5 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+236−23 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+30 app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.kt+23−1 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+22−1 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+30−3 app/src/main/java/soy/iko/opencode/ui/chat/TtsController.kt+13−6 app/src/main/java/soy/iko/opencode/ui/components/Autofill.kt+49 app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+6−1 app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+2 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+90−5 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+99−6 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+31 app/src/main/java/soy/iko/opencode/ui/file/TwoPaneFileBrowser.kt+92 app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+22−4 app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+22 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+27 app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+21−2 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+160−25 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+104−3 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+29 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+29 app/src/main/res/drawable/ic_action_refresh.xml+9 app/src/main/res/values-es/strings.xml+52 app/src/main/res/values/strings.xml+52 app/src/main/res/xml/file_paths.xml+3
app/detekt-baseline.xml+13
@@ -2,13 +2,25 @@
22<SmellBaseline>
33 <ManuallySuppressedIssues/>
44 <CurrentIssues>
5+ <ID>ComplexCondition:ChatScreen.kt$searchActive &amp;&amp; searchQuery.isNotEmpty() &amp;&amp; searchMessages.isEmpty() &amp;&amp; messages.isNotEmpty()</ID>
56 <ID>ComplexCondition:PartRenderer.kt$part.isImage &amp;&amp; imageContext != null &amp;&amp; (part.source != null || !part.url.isNullOrBlank())</ID>
7+ <ID>ComplexCondition:SessionListViewModel.kt$SessionListState$q.isEmpty() &amp;&amp; pinnedIds.isEmpty() &amp;&amp; noArchivedHidden &amp;&amp; directoryFilter == null</ID>
68 <ID>CyclomaticComplexMethod:AppContainer.kt$AppContainer$@OptIn(ExperimentalCoroutinesApi::class) private fun observeMessageActivity()</ID>
79 <ID>CyclomaticComplexMethod:ChatScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( container: AppContainer, sessionId: String, onBack: () -&gt; Unit, onOpenFile: ((String) -&gt; Unit)? = null, onOpenSession: ((String) -&gt; Unit)? = null, )</ID>
10+ <ID>CyclomaticComplexMethod:ChatScreen.kt$@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable private fun ChatInputBar( value: String, onValueChange: (String) -&gt; Unit, running: Boolean, aborting: Boolean, enabled: Boolean, sendOnEnter: Boolean, onSend: () -&gt; Unit, onAbort: () -&gt; Unit, queuedFollowUp: String?, onQueueFollowUp: (String) -&gt; Unit, onCancelQueue: () -&gt; Unit, focusRequester: androidx.compose.ui.focus.FocusRequester, attachments: List&lt;PendingAttachment&gt;, staging: Boolean, onRemoveAttachment: (String) -&gt; Unit, onPickPhoto: () -&gt; Unit, onPickFile: () -&gt; Unit, onCamera: () -&gt; Unit, onVoice: () -&gt; Unit, onPasteImage: (List&lt;Uri&gt;) -&gt; Unit, )</ID>
811 <ID>CyclomaticComplexMethod:ChatViewModel.kt$ChatViewModel$private fun send(text: String, includeAttachments: Boolean, idempotencyKey: String? = null): Boolean</ID>
912 <ID>CyclomaticComplexMethod:CodeHighlighter.kt$private fun findNumberEnd(line: String, start: Int): Int</ID>
13+ <ID>CyclomaticComplexMethod:ConversationExport.kt$fun buildConversationMarkdown(messages: List&lt;MessageWithParts&gt;, title: String?): String</ID>
14+ <ID>CyclomaticComplexMethod:DiagnosticsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun DiagnosticsScreen(onBack: () -&gt; Unit)</ID>
1015 <ID>CyclomaticComplexMethod:EventStreamClient.kt$EventStreamClient$private fun stream(): Flow&lt;BusEvent&gt;</ID>
16+ <ID>CyclomaticComplexMethod:FileBrowserScreen.kt$@OptIn(ExperimentalFoundationApi::class) @Composable private fun FileRow( icon: Boolean, label: String, onClick: () -&gt; Unit, status: FileStatusEntry? = null, sublabel: String? = null, size: Long? = null, mtime: Long? = null, onCopyPath: (() -&gt; Unit)? = null, modifier: Modifier = Modifier, )</ID>
17+ <ID>CyclomaticComplexMethod:FileViewScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun FileViewScreen( container: AppContainer, path: String, onBack: () -&gt; Unit, initialLine: Int? = null, )</ID>
18+ <ID>CyclomaticComplexMethod:MainActivity.kt$MainActivity$private fun handleIntent(intent: Intent?)</ID>
1119 <ID>CyclomaticComplexMethod:OpencodeApp.kt$@Composable fun OpencodeApp(container: AppContainer)</ID>
20+ <ID>CyclomaticComplexMethod:ServerListScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ServerListScreen( container: AppContainer, onConnected: () -&gt; Unit, onAddProfile: () -&gt; Unit, onEditProfile: (String) -&gt; Unit, onDuplicateProfile: (String) -&gt; Unit, )</ID>
21+ <ID>CyclomaticComplexMethod:SessionListScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun SessionListScreen( container: AppContainer, onOpenSession: (String) -&gt; Unit, onDisconnect: () -&gt; Unit, onOpenFiles: () -&gt; Unit, onOpenSettings: () -&gt; Unit, onAddServer: () -&gt; Unit, onOpenSearch: () -&gt; Unit = {}, // Incremented by a host (the two-pane empty-detail pane) to open the new-session // directory picker, so that pane shares this screen's dialog instead of bypassing it. externalNewSessionTrigger: Int = 0, selectedSessionId: String? = null, )</ID>
22+ <ID>CyclomaticComplexMethod:SessionListScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun androidx.compose.foundation.layout.BoxScope.SessionListBody( state: SessionListState, unread: Map&lt;String, Int&gt;, refreshing: Boolean, haptics: androidx.compose.ui.hapticfeedback.HapticFeedback, selectedSessionId: String?, onRefresh: () -&gt; Unit, onQueryChange: (String) -&gt; Unit, onOpenSession: (String) -&gt; Unit, onCreateSession: () -&gt; Unit, onRename: (String) -&gt; Unit, onDelete: (String) -&gt; Unit, onPin: (Session) -&gt; Unit, onArchive: (Session) -&gt; Unit, onFilterByDirectory: (String) -&gt; Unit, inSelection: Boolean, selectedIds: Set&lt;String&gt;, onToggleSelect: (String) -&gt; Unit, )</ID>
23+ <ID>CyclomaticComplexMethod:SessionListScreen.kt$@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable private fun SessionCard( session: Session, preview: String?, unreadCount: Int, onClick: () -&gt; Unit, onRename: () -&gt; Unit, onDelete: () -&gt; Unit, onPin: () -&gt; Unit, onArchive: () -&gt; Unit, modifier: Modifier = Modifier, isSelected: Boolean = false, isPinned: Boolean = false, isArchived: Boolean = false, onFilterByDirectory: ((String) -&gt; Unit)? = null, isDirectoryFiltered: Boolean = false, inSelection: Boolean = false, onToggleSelect: (() -&gt; Unit)? = null, )</ID>
1224 <ID>CyclomaticComplexMethod:SettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalCoroutinesApi::class) @Composable fun SettingsScreen( container: AppContainer, onBack: () -&gt; Unit, onManageServers: () -&gt; Unit, onOpenDiagnostics: () -&gt; Unit, onOpenUsage: () -&gt; Unit = {}, onOpenMcp: () -&gt; Unit = {}, )</ID>
1325 <ID>LargeClass:AppContainer.kt$AppContainer</ID>
1426 <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
@@ -17,5 +29,6 @@
1729 <ID>TooManyFunctions:ChatViewModel.kt$ChatViewModel : ViewModel</ID>
1830 <ID>TooManyFunctions:CrashLogger.kt$CrashLogger</ID>
1931 <ID>TooManyFunctions:OpencodeApiClient.kt$OpencodeApiClient</ID>
32+ <ID>TooManyFunctions:SessionNotifications.kt$SessionNotifications</ID>
2033 </CurrentIssues>
2134</SmellBaseline>
app/src/main/AndroidManifest.xml+13
@@ -58,6 +58,19 @@
5858 <category android:name="android.intent.category.BROWSABLE" />
5959 <data android:scheme="opencode" android:host="session" />
6060 </intent-filter>
61+ <!-- Deep links: opencode://file/{path} and opencode://new -->
62+ <intent-filter>
63+ <action android:name="android.intent.action.VIEW" />
64+ <category android:name="android.intent.category.DEFAULT" />
65+ <category android:name="android.intent.category.BROWSABLE" />
66+ <data android:scheme="opencode" android:host="file" />
67+ </intent-filter>
68+ <intent-filter>
69+ <action android:name="android.intent.action.VIEW" />
70+ <category android:name="android.intent.category.DEFAULT" />
71+ <category android:name="android.intent.category.BROWSABLE" />
72+ <data android:scheme="opencode" android:host="new" />
73+ </intent-filter>
6174 </activity>
6275
6376 <service
app/src/main/java/soy/iko/opencode/MainActivity.kt+48−14
@@ -46,6 +46,7 @@ class MainActivity : FragmentActivity() {
4646 private var shareIntentHandled = false
4747 private var openSessionHandled = false
4848 private var newSessionHandled = false
49+ private var openFileHandled = false
4950 // Cold-start prompts (crash report, notification rationale) rendered as Compose M3
5051 // dialogs. Hoisted as Activity-level mutableState so the non-composable trigger logic
5152 // (onCreate/onResume) can flip them and the Compose tree (inside OpencodeTheme) reads
@@ -72,6 +73,7 @@ class MainActivity : FragmentActivity() {
7273 shareIntentHandled = it.getBoolean(KEY_SHARE_HANDLED, false)
7374 openSessionHandled = it.getBoolean(KEY_OPEN_SESSION_HANDLED, false)
7475 newSessionHandled = it.getBoolean(KEY_NEW_SESSION_HANDLED, false)
76+ openFileHandled = it.getBoolean(KEY_OPEN_FILE_HANDLED, false)
7577 }
7678 val container = (application as OpencodeApp).container
7779 handleIntent(intent)
@@ -183,6 +185,7 @@ class MainActivity : FragmentActivity() {
183185 shareIntentHandled = false
184186 openSessionHandled = false
185187 newSessionHandled = false
188+ openFileHandled = false
186189 handleIntent(intent)
187190 }
188191
@@ -247,21 +250,45 @@ class MainActivity : FragmentActivity() {
247250 }
248251 }
249252 }
250− // Deep link: opencode://session/{sessionId} (or the EXTRA_SESSION_ID extra).
251− val deepLinkId = intent?.takeIf { it.action == Intent.ACTION_VIEW }
252− ?.data
253− ?.takeIf { it.host == "session" }
254− ?.lastPathSegment
255− val id = deepLinkId ?: intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() }
256− // Validate: session ids are opaque server-generated identifiers. Reject anything
257− // with path separators or other traversal/control characters so a malicious deep
258− // link can't inject path components into the REST URL path.
259− // Guard against re-firing the retained ACTION_VIEW intent after process-death
260− // restore: onCreate calls handleIntent again with the original intent, so without
261− // this flag (mirroring shareIntentHandled) the session would re-open unexpectedly.
253+ // Deep links under the opencode:// scheme. The manifest exposes several hosts:
254+ // session/{id} → open (and switch to) that conversation
255+ // file/{path} → open the file viewer at that workspace path
256+ // new → start a fresh session
257+ // The file path is percent-encoded by the link source; decode it here and validate the
258+ // segment to keep path-traversal out of the REST path (mirroring the session guard).
259+ val data = intent?.takeIf { it.action == Intent.ACTION_VIEW }?.data
260+ when (data?.host) {
261+ "session" -> {
262+ val seg = data.lastPathSegment
263+ if (!openSessionHandled) {
264+ seg?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
265+ val profileId = intent.getStringExtra(NotificationActionReceiver.EXTRA_PROFILE_ID)
266+ ?.takeIf { it.isNotBlank() }
267+ container.requestOpenSession(it, profileId)
268+ openSessionHandled = true
269+ }
270+ }
271+ }
272+ "file" -> {
273+ // Reconstruct the path from the path segments after "file", preserving internal
274+ // slashes. Decoded + validated against traversal/control characters.
275+ val rawPath = data.path?.removePrefix("/file/")?.let { android.net.Uri.decode(it) }
276+ if (!openFileHandled) {
277+ rawPath?.takeIf { it.isNotBlank() && VALID_FILE_PATH.containsMatchIn(it) && !it.contains("..") }
278+ ?.let { container.requestOpenFile(it); openFileHandled = true }
279+ }
280+ }
281+ "new" -> {
282+ if (!newSessionHandled) {
283+ container.requestNewSession()
284+ newSessionHandled = true
285+ }
286+ }
287+ }
288+ // Fall back to the EXTRA_SESSION_ID extra (notifications / widget) when no deep link matched.
262289 if (!openSessionHandled) {
263− id?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
264− val profileId = intent?.getStringExtra(NotificationActionReceiver.EXTRA_PROFILE_ID)
290+ intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
291+ val profileId = intent.getStringExtra(NotificationActionReceiver.EXTRA_PROFILE_ID)
265292 ?.takeIf { it.isNotBlank() }
266293 container.requestOpenSession(it, profileId)
267294 openSessionHandled = true
@@ -279,9 +306,14 @@ class MainActivity : FragmentActivity() {
279306 /** Session ids are alphanumeric with dashes/underscores. Reject path traversal. */
280307 private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
281308
309+ /** A file path must be made of path-ish characters and not carry traversal. Used to
310+ * sanity-check the file deep link before handing it to the viewer. */
311+ private val VALID_FILE_PATH = Regex("[A-Za-z0-9_\\-./ ]+")
312+
282313 private const val KEY_SHARE_HANDLED = "shareIntentHandled"
283314 private const val KEY_OPEN_SESSION_HANDLED = "openSessionHandled"
284315 private const val KEY_NEW_SESSION_HANDLED = "newSessionHandled"
316+ private const val KEY_OPEN_FILE_HANDLED = "openFileHandled"
285317 private const val KEY_NOTIF_PERM_REQUESTED = "notificationPermissionRequested"
286318 }
287319
@@ -299,9 +331,11 @@ class MainActivity : FragmentActivity() {
299331 val openConsumed = container.pendingOpenSession.value == null
300332 val newConsumed = container.pendingNewSession.value == 0
301333 val shareConsumed = container.pendingShare.value == null && container.pendingSharedMedia.value.isEmpty()
334+ val fileConsumed = container.pendingOpenFile.value == null
302335 outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled && shareConsumed)
303336 outState.putBoolean(KEY_OPEN_SESSION_HANDLED, openSessionHandled && openConsumed)
304337 outState.putBoolean(KEY_NEW_SESSION_HANDLED, newSessionHandled && newConsumed)
338+ outState.putBoolean(KEY_OPEN_FILE_HANDLED, openFileHandled && fileConsumed)
305339 outState.putBoolean(KEY_NOTIF_PERM_REQUESTED, notificationPermissionRequested)
306340 }
307341
app/src/main/java/soy/iko/opencode/data/model/Files.kt+6
@@ -10,6 +10,12 @@ data class FileNode(
1010 val name: String = "",
1111 val path: String = "",
1212 val type: String? = null,
13+ // Size in bytes and last-modified epoch-ms. The server doesn't currently emit these, so
14+ // they default to null and the browser renders nothing extra; optional + defaulted keeps
15+ // the client forward-compatible (resilient decoding) so the moment the server adds them
16+ // the listing shows size/date with no client change.
17+ val size: Long? = null,
18+ val mtime: Long? = null,
1319) {
1420 val isDirectory: Boolean get() = type == "directory"
1521}
app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.kt+34−2
@@ -25,6 +25,9 @@ class SettingsStore(context: Context) {
2525 private val appLockReLockSecondsKey = intPreferencesKey("app_lock_relock_seconds")
2626 private val chatTextScaleKey = floatPreferencesKey("chat_text_scale")
2727 private val codeWrapKey = booleanPreferencesKey("code_wrap")
28+ private val sessionSortModeKey = stringPreferencesKey("session_sort_mode")
29+ private val sessionSortDescendingKey = booleanPreferencesKey("session_sort_descending")
30+ private val sessionShowArchivedKey = booleanPreferencesKey("session_show_archived")
2831
2932 companion object {
3033 /** Bounds for the chat text-size multiplier so the UI can't be scaled into
@@ -86,6 +89,22 @@ class SettingsStore(context: Context) {
8689 prefs[codeWrapKey] ?: false
8790 }
8891
92+ /** Persisted session-list sort mode so a chosen ordering survives process death (not just
93+ * rotation). Stored as the enum name; an unknown value falls back to RECENT. */
94+ val sessionSortMode: Flow<String> = appContext.settingsDataStore.data.map { prefs ->
95+ prefs[sessionSortModeKey] ?: "RECENT"
96+ }
97+
98+ /** Persisted session-list sort direction. Defaults to descending (newest first). */
99+ val sessionSortDescending: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
100+ prefs[sessionSortDescendingKey] ?: true
101+ }
102+
103+ /** Persisted "show archived sessions" toggle so the choice survives process death. */
104+ val sessionShowArchived: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
105+ prefs[sessionShowArchivedKey] ?: false
106+ }
107+
89108 suspend fun setThemeMode(mode: ThemeMode) {
90109 appContext.settingsDataStore.edit { it[themeKey] = mode.name }
91110 }
@@ -113,8 +132,21 @@ class SettingsStore(context: Context) {
113132 it[chatTextScaleKey] = scale.coerceIn(MIN_CHAT_TEXT_SCALE, MAX_CHAT_TEXT_SCALE)
114133 }
115134 }
116−
117135 suspend fun setCodeWrap(enabled: Boolean) {
118− appContext.settingsDataStore.edit { it[codeWrapKey] = enabled }
136+ appContext.settingsDataStore.edit {
137+ it[codeWrapKey] = enabled
138+ }
139+ }
140+
141+ suspend fun setSessionSortMode(mode: String) {
142+ appContext.settingsDataStore.edit { it[sessionSortModeKey] = mode }
143+ }
144+
145+ suspend fun setSessionSortDescending(descending: Boolean) {
146+ appContext.settingsDataStore.edit { it[sessionSortDescendingKey] = descending }
147+ }
148+
149+ suspend fun setSessionShowArchived(show: Boolean) {
150+ appContext.settingsDataStore.edit { it[sessionShowArchivedKey] = show }
119151 }
120152}
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+52
@@ -205,6 +205,19 @@ open class AppContainer private constructor(
205205 return if (_pendingOpenSession.compareAndSet(current, null)) current else null
206206 }
207207
208+ /**
209+ * A file path requested to be opened (via deep link `opencode://file/{path}` or future
210+ * entry points), consumed by the NavHost to navigate to the file viewer. Mirrors the
211+ * session open-signal pattern: a StateFlow the NavHost observes, drained atomically.
212+ */
213+ private val _pendingOpenFile = MutableStateFlow<String?>(null)
214+ open val pendingOpenFile: StateFlow<String?> = _pendingOpenFile.asStateFlow()
215+ open fun requestOpenFile(path: String) { _pendingOpenFile.value = path }
216+ open fun consumePendingOpenFile(): String? {
217+ val current = _pendingOpenFile.value ?: return null
218+ return if (_pendingOpenFile.compareAndSet(current, null)) current else null
219+ }
220+
208221 /**
209222 * The session the user is currently viewing (or null when not in a chat). Drives the
210223 * unread tracker: messages arriving for any *other* session mark it unread, and
@@ -911,6 +924,45 @@ open class AppContainer private constructor(
911924 }
912925 }
913926
927+ /**
928+ * Retry the last user prompt for [sessionId] from an error notification's "Retry last"
929+ * action. Fetches the session's messages, re-sends the most recent user prompt, and
930+ * cancels the error notification on success. Routes to the originating profile's
931+ * connection (falling back to the active one) so the send reaches the server that owns
932+ * the session. A no-op (reported as false) when there's no connection or no prior prompt.
933+ */
934+ open fun retryLastFromNotification(sessionId: String, originProfileId: String?, onDone: (Boolean) -> Unit) {
935+ appScope.launch {
936+ var success = false
937+ try {
938+ val conn = activeConnection.value?.takeIf {
939+ originProfileId == null || it.profile.id == originProfileId
940+ } ?: activeConnection.value
941+ if (conn != null) {
942+ success = runCatchingCancellable {
943+ val messages = conn.api.listMessages(sessionId)
944+ val lastPrompt = messages
945+ .lastOrNull { it.info is soy.iko.opencode.data.model.UserMessage }
946+ ?.parts
947+ ?.filterIsInstance<soy.iko.opencode.data.model.TextPart>()
948+ ?.joinToString("\n\n") { it.text }
949+ ?.takeIf { it.isNotBlank() }
950+ if (lastPrompt != null) {
951+ // model=null lets the server reuse the session's current model; we
952+ // don't know the originally-selected model from a notification context.
953+ conn.repository.sendPrompt(sessionId, lastPrompt, model = null)
954+ true
955+ } else {
956+ false
957+ }
958+ }.isSuccess
959+ }
960+ } finally {
961+ onDone(success)
962+ }
963+ }
964+ }
965+
914966 /** Extract the session id an event pertains to, for message-activity events. */
915967 private fun sessionOf(event: BusEvent): String? = sessionOfEvent(event)
916968
app/src/main/java/soy/iko/opencode/notification/NotificationActionReceiver.kt+21
@@ -122,6 +122,26 @@ class NotificationActionReceiver : BroadcastReceiver() {
122122 finishOnce()
123123 }
124124 }
125+ ACTION_RETRY_LAST -> {
126+ // "Retry last" from an error notification: re-send the most recent user prompt
127+ // for this session without opening the app. Cancels the error notification on
128+ // success so the user sees the retry took effect.
129+ val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
130+ val pending = goAsync()
131+ val finished = AtomicBoolean(false)
132+ var watchdogThread: Thread? = null
133+ val finishOnce = {
134+ if (finished.compareAndSet(false, true)) {
135+ watchdogThread?.interrupt()
136+ pending.finish()
137+ }
138+ }
139+ watchdogThread = watchdog(finishOnce)
140+ container.retryLastFromNotification(sessionId, profileId) { success ->
141+ if (success) SessionNotifications.cancel(context, sessionId)
142+ finishOnce()
143+ }
144+ }
125145 }
126146 }
127147
@@ -145,6 +165,7 @@ class NotificationActionReceiver : BroadcastReceiver() {
145165 const val ACTION_REPLY = "soy.iko.opencode.action.REPLY"
146166 const val ACTION_STOP_RUN = "soy.iko.opencode.action.STOP_RUN"
147167 const val ACTION_MARK_READ = "soy.iko.opencode.action.MARK_READ"
168+ const val ACTION_RETRY_LAST = "soy.iko.opencode.action.RETRY_LAST"
148169 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
149170 const val EXTRA_PERMISSION_ID = "soy.iko.opencode.extra.PERMISSION_ID"
150171 const val EXTRA_PROFILE_ID = "soy.iko.opencode.extra.PROFILE_ID"
app/src/main/java/soy/iko/opencode/notification/NotificationChannels.kt+13
@@ -22,6 +22,7 @@ object NotificationChannels {
2222 const val COMPLETED = "session_completed"
2323 const val PERMISSION = "permission_request"
2424 const val ERROR = "session_error"
25+ const val UNREAD = "unread_badge"
2526
2627 fun create(context: Context) {
2728 val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return
@@ -48,5 +49,17 @@ object NotificationChannels {
4849 description = context.getString(R.string.notif_channel_error_desc)
4950 },
5051 )
52+ // A silent, badge-only channel that carries the launcher icon's unread count (the only
53+ // way to badge the launcher icon on Android is via a notification on a badge-enabled
54+ // channel). Low importance + no sound/vibration so it never alerts; setShowBadge(true)
55+ // so its number renders on the launcher icon.
56+ nm.createNotificationChannel(
57+ NotificationChannel(UNREAD, context.getString(R.string.notif_channel_unread), NotificationManager.IMPORTANCE_MIN).apply {
58+ description = context.getString(R.string.notif_channel_unread_desc)
59+ setShowBadge(true)
60+ setSound(null, null)
61+ enableVibration(false)
62+ },
63+ )
5164 }
5265}
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+3
@@ -86,6 +86,9 @@ class RunForegroundService : Service() {
8686 .setShowWhen(true)
8787 .setPriority(NotificationCompat.PRIORITY_LOW)
8888 .setCategory(NotificationCompat.CATEGORY_PROGRESS)
89+ // An indeterminate progress bar so the notification reads as actively "in progress"
90+ // at a glance (alongside the chronometer), matching the conventional run affordance.
91+ .setProgress(0, 0, true)
8992 // Add a Stop action so the user can cancel the run without opening the app — the
9093 // core of the "kick it off and walk away" flow. Only when a specific session is
9194 // known (multiple concurrent runs can't be targeted individually).
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+44−2
@@ -35,6 +35,7 @@ object SessionNotifications {
3535
3636 private const val NOTIF_ID_PREFIX = 4000
3737 private const val TAG = "SessionNotifications"
38+ private const val UNREAD_BADGE_ID = NOTIF_ID_PREFIX + 9999
3839
3940 // Distinct namespaces so a session can have a completion, a permission, and an error
4041 // notification outstanding at once without their ids colliding.
@@ -253,7 +254,7 @@ object SessionNotifications {
253254 fun postError(context: Context, sessionId: String, title: String, profileId: String? = null) {
254255 if (!canPost(context)) return
255256 val notifId = notifId(NS_ERROR, sessionId)
256− val notification = NotificationCompat.Builder(context, NotificationChannels.ERROR)
257+ val builder = NotificationCompat.Builder(context, NotificationChannels.ERROR)
257258 .setSmallIcon(R.drawable.ic_stat_notify)
258259 .setContentTitle(context.getString(R.string.notif_error_title))
259260 .setContentText(context.getString(R.string.notif_error_text, title))
@@ -271,7 +272,19 @@ object SessionNotifications {
271272 .setContentTitle(context.getString(R.string.notif_error_public))
272273 .build(),
273274 )
274− .build()
275+ // A "Retry last" action lets the user re-run the failed prompt straight from the shade
276+ // without opening the app — a failed run is exactly when a one-tap retry is most useful.
277+ val retryIntent = Intent(context, NotificationActionReceiver::class.java).apply {
278+ action = NotificationActionReceiver.ACTION_RETRY_LAST
279+ putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
280+ profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
281+ }
282+ val retryPending = PendingIntent.getBroadcast(
283+ context, notifId + 10, retryIntent,
284+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
285+ )
286+ builder.addAction(R.drawable.ic_action_refresh, context.getString(R.string.retry_last), retryPending)
287+ val notification = builder.build()
275288 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
276289 .onFailure { Log.w(TAG, "Failed to post error notification", it) }
277290 runCatching { postGroupSummary(context, NotificationChannels.ERROR, GROUP_ERROR, SUMMARY_ERROR_ID) }
@@ -332,6 +345,35 @@ object SessionNotifications {
332345 maybeCancelSummary(context, GROUP_COMPLETED, SUMMARY_COMPLETED_ID)
333346 }
334347
348+ /**
349+ * Reflect the total number of unread sessions as a launcher-icon badge. Android only badges
350+ * the launcher icon via a notification on a badge-enabled channel, so this posts a silent,
351+ * min-importance notification with [.setNumber] on the [NotificationChannels.UNREAD] channel
352+ * when [totalUnread] > 0, and cancels it when it drops to 0. Skipped entirely without
353+ * POST_NOTIFICATIONS permission (Android 13+) — the badge just won't show, no error.
354+ */
355+ @SuppressLint("MissingPermission")
356+ fun updateUnreadBadge(context: Context, totalUnread: Int) {
357+ val nm = NotificationManagerCompat.from(context)
358+ if (totalUnread <= 0) {
359+ nm.cancel(UNREAD_BADGE_ID)
360+ return
361+ }
362+ if (!canPost(context)) return
363+ val notification = NotificationCompat.Builder(context, NotificationChannels.UNREAD)
364+ .setSmallIcon(R.drawable.ic_stat_notify)
365+ .setContentTitle(context.getString(R.string.app_name))
366+ .setContentText(context.resources.getQuantityString(R.plurals.unread_sessions, totalUnread, totalUnread))
367+ .setNumber(totalUnread.coerceAtMost(999))
368+ .setOnlyAlertOnce(true)
369+ .setSilent(true)
370+ .setShowWhen(false)
371+ .setPriority(NotificationCompat.PRIORITY_MIN)
372+ .build()
373+ runCatching { nm.notify(UNREAD_BADGE_ID, notification) }
374+ .onFailure { Log.w(TAG, "Failed to post unread badge notification", it) }
375+ }
376+
335377 /** Cancel a session's permission notification (on reply, or when the user opens it). */
336378 fun cancelPermission(context: Context, sessionId: String) {
337379 NotificationManagerCompat.from(context).cancel(notifId(NS_PERMISSION, sessionId))
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+27−5
@@ -38,6 +38,7 @@ import soy.iko.opencode.data.network.NetworkConfig
3838import soy.iko.opencode.ui.chat.ChatScreen
3939import soy.iko.opencode.ui.file.FileBrowserScreen
4040import soy.iko.opencode.ui.file.FileViewScreen
41+import soy.iko.opencode.ui.file.TwoPaneFileBrowser
4142import soy.iko.opencode.ui.server.ServerEditScreen
4243import soy.iko.opencode.ui.server.ServerListScreen
4344import soy.iko.opencode.ui.search.GlobalSearchScreen
@@ -62,6 +63,7 @@ fun OpencodeApp(container: AppContainer) {
6263 val pendingSharedMedia by container.pendingSharedMedia.collectAsStateWithLifecycle()
6364 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
6465 val pendingNewSession by container.pendingNewSession.collectAsStateWithLifecycle()
66+ val pendingOpenFile by container.pendingOpenFile.collectAsStateWithLifecycle()
6567 val pendingDiagnostics by container.pendingDiagnostics.collectAsStateWithLifecycle()
6668 val connection by container.activeConnection.collectAsStateWithLifecycle()
6769 // Chat presentation preferences, provided to the whole nav graph so the markdown/code
@@ -215,6 +217,19 @@ fun OpencodeApp(container: AppContainer) {
215217 navController.navigate(Routes.chat(pending.sessionId)) { launchSingleTop = true }
216218 }
217219
220+ // Deep-linked file open (opencode://file/{path}): navigate to the file viewer once a
221+ // connection exists (the viewer fetches content from the server). Path comes pre-validated
222+ // by MainActivity's deep-link guard.
223+ LaunchedEffect(pendingOpenFile, connection) {
224+ val path = pendingOpenFile ?: return@LaunchedEffect
225+ if (connection == null) return@LaunchedEffect
226+ container.consumePendingOpenFile()
227+ if (!navController.popBackStack(Routes.FILES, inclusive = false)) {
228+ navController.navigate(Routes.FILES) { launchSingleTop = true }
229+ }
230+ navController.navigate(Routes.fileView(path)) { launchSingleTop = true }
231+ }
232+
218233 val navHost = @Composable {
219234 NavHost(
220235 modifier = Modifier.fillMaxSize(),
@@ -348,11 +363,18 @@ fun OpencodeApp(container: AppContainer) {
348363 }
349364
350365 composable(Routes.FILES) {
351− FileBrowserScreen(
352− container = container,
353− onOpenFile = { path, line -> navController.navigate(Routes.fileView(path, line)) },
354− onBack = { navController.popBackStack() },
355− )
366+ if (isTwoPane) {
367+ TwoPaneFileBrowser(
368+ container = container,
369+ onBack = { navController.popBackStack() },
370+ )
371+ } else {
372+ FileBrowserScreen(
373+ container = container,
374+ onOpenFile = { path, line -> navController.navigate(Routes.fileView(path, line)) },
375+ onBack = { navController.popBackStack() },
376+ )
377+ }
356378 }
357379
358380 composable(
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+236−23
@@ -18,6 +18,7 @@ import androidx.compose.animation.core.rememberInfiniteTransition
1818import androidx.compose.animation.core.tween
1919import androidx.compose.foundation.background
2020import androidx.compose.foundation.clickable
21+import androidx.compose.foundation.content.contentReceiver
2122import androidx.compose.foundation.layout.Arrangement
2223import androidx.compose.foundation.layout.Box
2324import androidx.compose.foundation.layout.Column
@@ -48,8 +49,10 @@ import androidx.compose.material.icons.filled.Add
4849import androidx.compose.material.icons.filled.AutoAwesome
4950import androidx.compose.material.icons.filled.Delete
5051import androidx.compose.material.icons.filled.Description
52+import androidx.compose.material.icons.filled.Close
5153import androidx.compose.material.icons.filled.Edit
5254import androidx.compose.material.icons.filled.Expand
55+import androidx.compose.material.icons.filled.Search
5356import androidx.compose.material.icons.filled.KeyboardArrowDown
5457import androidx.compose.material.icons.filled.KeyboardArrowUp
5558import androidx.compose.material.icons.filled.Mic
@@ -262,6 +265,9 @@ fun ChatScreen(
262265 var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
263266 var showShellDialog by rememberSaveable { mutableStateOf(false) }
264267 var showPalette by rememberSaveable { mutableStateOf(false) }
268+ var showShortcutsDialog by rememberSaveable { mutableStateOf(false) }
269+ var searchActive by rememberSaveable { mutableStateOf(false) }
270+ var chatSearch by rememberSaveable { mutableStateOf("") }
265271 // Summarize and init (generate AGENTS.md) are irreversible, so a single tap is gated behind
266272 // a confirmation dialog (from both the overflow menu and the command palette).
267273 var showSummarizeConfirm by rememberSaveable { mutableStateOf(false) }
@@ -347,6 +353,7 @@ fun ChatScreen(
347353 val noVoiceMsg = stringResource(R.string.no_voice_app)
348354 val voicePrompt = stringResource(R.string.voice_prompt)
349355 val linkCopiedMsg = stringResource(R.string.link_copied)
356+ val ttsUnavailableMsg = stringResource(R.string.tts_unavailable)
350357
351358 // Count of picks currently being read + base64-encoded off the main thread, so the
352359 // composer can show a staging placeholder immediately (chips only materialize once done).
@@ -643,6 +650,18 @@ fun ChatScreen(
643650 enabled = !commandsLoading || commands.isNotEmpty(),
644651 onClick = { showOverflowMenu = false; showCommandPicker = true },
645652 )
653+ DropdownMenuItem(
654+ text = { Text(stringResource(R.string.keyboard_shortcuts)) },
655+ onClick = { showOverflowMenu = false; showShortcutsDialog = true },
656+ )
657+ DropdownMenuItem(
658+ text = { Text(stringResource(R.string.find_in_conversation)) },
659+ onClick = {
660+ showOverflowMenu = false
661+ searchActive = true
662+ runCatching { inputFocusRequester.requestFocus() }
663+ },
664+ )
646665 androidx.compose.material3.HorizontalDivider()
647666 // Session sharing: create a public link, copy an existing one, or revoke it.
648667 if (shareUrl == null) {
@@ -767,6 +786,7 @@ fun ChatScreen(
767786 onPickFile = { filePicker.launch("*/*") },
768787 onCamera = { launchCamera() },
769788 onVoice = { launchVoice() },
789+ onPasteImage = { stageUris(it) },
770790 )
771791 }
772792 },
@@ -795,8 +815,17 @@ fun ChatScreen(
795815 // raw messages list, so scroll targets must be in listItems space).
796816 val todayLabel = stringResource(R.string.today)
797817 val yesterdayLabel = stringResource(R.string.yesterday)
798− val listItems = remember(messages, todayLabel, yesterdayLabel) {
799− buildMessageListItems(messages, todayLabel, yesterdayLabel)
818+ // In-conversation search: when active, narrow to messages whose text contains the query.
819+ // Computed on the filtered set so day separators still align with the visible messages.
820+ val searchQuery = chatSearch.trim()
821+ val searchMessages = remember(messages, searchQuery) {
822+ if (searchQuery.isEmpty()) messages
823+ else messages.filter { m ->
824+ m.parts.any { p -> (p as? TextPart)?.text?.contains(searchQuery, ignoreCase = true) == true }
825+ }
826+ }
827+ val listItems = remember(searchMessages, todayLabel, yesterdayLabel) {
828+ buildMessageListItems(searchMessages, todayLabel, yesterdayLabel)
800829 }
801830 // listItems is a plain (non-snapshot) local, rebuilt each recomposition. The
802831 // derivedStateOf and snapshotFlow lambdas below are created once (keyless
@@ -808,9 +837,10 @@ fun ChatScreen(
808837 // whose value tracks the latest list.
809838 val currentListItems by rememberUpdatedState(listItems)
810839
811− // New-content signal for the jump-to-latest FAB: set when content grows while the user
812− // is scrolled away from the bottom, cleared once they return (see the collector below).
813− var hasNewContent by remember { mutableStateOf(false) }
840+ // New-content signal for the jump-to-latest FAB: counts how many new items arrived
841+ // while the user was scrolled away from the bottom, cleared once they return. A count
842+ // (vs. a bare dot) tells the user how much they missed at a glance.
843+ var newContentCount by remember { mutableIntStateOf(0) }
814844
815845 val isPinnedToBottom by remember {
816846 derivedStateOf {
@@ -873,7 +903,7 @@ fun ChatScreen(
873903 }.collect { signal ->
874904 if (signal.pinned) {
875905 // Back at the bottom: clear the new-content badge.
876− hasNewContent = false
906+ newContentCount = 0
877907 if (signal.size > 0) {
878908 // Scroll to the effective last index, including the trailing
879909 // "__typing" row when a run is active so the working indicator
@@ -883,8 +913,11 @@ fun ChatScreen(
883913 listState.scrollToItem(target)
884914 }
885915 } else if (signal.size > prevSize || signal.lastTextLength > prevLen) {
886− // Content arrived while scrolled up — badge the jump-to-latest FAB.
887− hasNewContent = true
916+ // Content arrived while scrolled up — increment the jump-to-latest badge.
917+ // Only count whole new items (not per-token growth) so the number reflects
918+ // new messages, not a token count.
919+ if (signal.size > prevSize) newContentCount += signal.size - prevSize
920+ else if (newContentCount == 0) newContentCount = 1
888921 }
889922 prevSize = signal.size
890923 prevLen = signal.lastTextLength
@@ -1159,13 +1192,26 @@ fun ChatScreen(
11591192 }
11601193 }
11611194 },
1162− onSpeak = { text -> tts.toggle(message.info.id, text) },
1195+ onSpeak = { text ->
1196+ if (!tts.toggle(message.info.id, text)) {
1197+ // TTS engine unavailable (init failed / not yet ready
1198+ // / blank text): surface feedback so the button doesn't
1199+ // appear dead. Throttled so a rapid double-tap (stop
1200+ // then immediate re-tap before ready) doesn't spam.
1201+ scope.launch { snackbar.showSnackbar(ttsUnavailableMsg) }
1202+ }
1203+ },
11631204 isSpeaking = message.info.id == speakingMessageId,
11641205 onQuote = { text ->
11651206 vm.quoteReply(text)
11661207 runCatching { inputFocusRequester.requestFocus() }
11671208 },
11681209 onBranch = { text -> vm.branchFrom(text) },
1210+ // Regenerate is only meaningful for a finished assistant reply
1211+ // (not the actively-streaming one), so gate it on !running.
1212+ onRegenerate = if (!running) {
1213+ { vm.regenerate(message.info.id) }
1214+ } else null,
11691215 sendStatus = optimisticStatuses[message.info.id]?.let { failed ->
11701216 if (failed) soy.iko.opencode.ui.chat.MessageSendStatus.FAILED
11711217 else soy.iko.opencode.ui.chat.MessageSendStatus.SENDING
@@ -1234,6 +1280,17 @@ fun ChatScreen(
12341280 color = MaterialTheme.colorScheme.onSurfaceVariant,
12351281 modifier = Modifier.padding(start = 4.dp),
12361282 )
1283+ // A trailing Stop on the working row so the run can be aborted
1284+ // without scrolling back to the composer (which may be off-screen
1285+ // while the user reads the streaming output up-thread).
1286+ Spacer(Modifier.weight(1f))
1287+ val stopLabel = stringResource(R.string.stop)
1288+ IconButton(
1289+ onClick = { showStopConfirm = true },
1290+ modifier = Modifier.semantics { contentDescription = stopLabel },
1291+ ) {
1292+ Icon(Icons.Filled.Stop, contentDescription = null)
1293+ }
12371294 }
12381295 }
12391296 }
@@ -1252,16 +1309,18 @@ fun ChatScreen(
12521309 // they know there's something new to jump to (cleared once back at bottom).
12531310 BadgedBox(
12541311 badge = {
1255− if (hasNewContent) {
1312+ if (newContentCount > 0) {
12561313 val newContentLabel = stringResource(R.string.new_messages)
1257− Badge(modifier = Modifier.semantics { contentDescription = newContentLabel })
1314+ Badge(modifier = Modifier.semantics { contentDescription = newContentLabel }) {
1315+ Text(if (newContentCount > 99) "99+" else newContentCount.toString())
1316+ }
12581317 }
12591318 },
12601319 modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
12611320 ) {
12621321 ExtendedFloatingActionButton(
12631322 onClick = {
1264− hasNewContent = false
1323+ newContentCount = 0
12651324 if (listItems.isNotEmpty()) {
12661325 // Scroll to the effective last index, including the
12671326 // trailing "__typing" row when a run is active.
@@ -1300,6 +1359,61 @@ fun ChatScreen(
13001359 )
13011360 }
13021361 }
1362+ // In-conversation search bar. Overlays the list so typing filters messages in
1363+ // place; the match count + a no-matches note give immediate feedback.
1364+ val searchMotion = rememberVisibilityTransitions()
1365+ AnimatedVisibility(
1366+ visible = searchActive,
1367+ enter = searchMotion.enter,
1368+ exit = searchMotion.exit,
1369+ modifier = Modifier.align(Alignment.TopCenter),
1370+ ) {
1371+ Surface(
1372+ tonalElevation = 3.dp,
1373+ modifier = Modifier
1374+ .padding(horizontal = 12.dp, vertical = 4.dp)
1375+ .fillMaxWidth(),
1376+ ) {
1377+ Row(
1378+ modifier = Modifier.padding(start = 8.dp, end = 4.dp),
1379+ verticalAlignment = Alignment.CenterVertically,
1380+ ) {
1381+ OutlinedTextField(
1382+ value = chatSearch,
1383+ onValueChange = { chatSearch = it },
1384+ modifier = Modifier.weight(1f),
1385+ placeholder = { Text(stringResource(R.string.search_in_conversation)) },
1386+ singleLine = true,
1387+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
1388+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
1389+ )
1390+ Text(
1391+ if (searchQuery.isNotEmpty()) {
1392+ stringResource(R.string.search_match_count, searchMessages.size, messages.size)
1393+ } else "",
1394+ style = MaterialTheme.typography.labelSmall,
1395+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1396+ modifier = Modifier.padding(horizontal = 4.dp),
1397+ )
1398+ IconButton(onClick = {
1399+ searchActive = false
1400+ chatSearch = ""
1401+ }) {
1402+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
1403+ }
1404+ }
1405+ }
1406+ }
1407+ // When a search yields no matches (but the conversation isn't empty), say so
1408+ // instead of showing a blank list.
1409+ if (searchActive && searchQuery.isNotEmpty() && searchMessages.isEmpty() && messages.isNotEmpty()) {
1410+ Text(
1411+ stringResource(R.string.search_no_matches),
1412+ style = MaterialTheme.typography.bodyMedium,
1413+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1414+ modifier = Modifier.align(Alignment.Center),
1415+ )
1416+ }
13031417 }
13041418 }
13051419 }
@@ -1462,8 +1576,48 @@ fun ChatScreen(
14621576 if (showPalette) {
14631577 CommandPalette(actions = paletteActions, onDismiss = { showPalette = false })
14641578 }
1579+
1580+ if (showShortcutsDialog) {
1581+ // Surfaces the hardware-keyboard shortcuts (Ctrl+K palette, Escape stop, Enter send)
1582+ // that are otherwise wired but undiscoverable. A simple key/value list; Enter behavior
1583+ // depends on the "Send on Enter" setting, noted inline.
1584+ AlertDialog(
1585+ onDismissRequest = { showShortcutsDialog = false },
1586+ title = { Text(stringResource(R.string.keyboard_shortcuts_title)) },
1587+ text = {
1588+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
1589+ ShortcutRow("Ctrl + K", stringResource(R.string.shortcut_open_palette))
1590+ ShortcutRow("Esc", stringResource(R.string.shortcut_stop_run))
1591+ ShortcutRow("Esc", stringResource(R.string.shortcut_close))
1592+ ShortcutRow("Enter", stringResource(R.string.shortcut_send))
1593+ ShortcutRow("Shift + Enter / Ctrl + Enter", stringResource(R.string.shortcut_newline))
1594+ }
1595+ },
1596+ confirmButton = {
1597+ TextButton(onClick = { showShortcutsDialog = false }) { Text(stringResource(R.string.action_continue)) }
1598+ },
1599+ )
1600+ }
14651601}
14661602
1603+@Composable
1604+private fun ShortcutRow(keys: String, desc: String) {
1605+ Row(verticalAlignment = Alignment.CenterVertically) {
1606+ Text(
1607+ keys,
1608+ style = MaterialTheme.typography.labelLarge,
1609+ fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
1610+ modifier = Modifier.widthIn(min = 160.dp),
1611+ )
1612+ Text(
1613+ desc,
1614+ style = MaterialTheme.typography.bodyMedium,
1615+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1616+ )
1617+ }
1618+}
1619+
1620+@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
14671621@Composable
14681622private fun ChatInputBar(
14691623 value: String,
@@ -1485,6 +1639,7 @@ private fun ChatInputBar(
14851639 onPickFile: () -> Unit,
14861640 onCamera: () -> Unit,
14871641 onVoice: () -> Unit,
1642+ onPasteImage: (List<Uri>) -> Unit,
14881643) {
14891644 // Sendable when there's text OR at least one attachment (an image-only prompt is valid).
14901645 val hasContent = value.isNotBlank() || attachments.isNotEmpty()
@@ -1495,13 +1650,25 @@ private fun ChatInputBar(
14951650 // A queued follow-up replaces the Stop button with a "queued" chip so the
14961651 // user sees their message will be sent when the run finishes, and can cancel.
14971652 if (queuedFollowUp != null) {
1653+ val editLabel = stringResource(R.string.edit)
14981654 Row(
14991655 modifier = Modifier
15001656 .fillMaxWidth()
15011657 .padding(start = 12.dp, top = 6.dp, end = 4.dp),
15021658 verticalAlignment = Alignment.CenterVertically,
15031659 ) {
1504− Column(modifier = Modifier.weight(1f)) {
1660+ // Tapping the chip reloads the queued text into the draft (and clears the
1661+ // queue) so a typo spotted while queuing can be fixed without retyping.
1662+ Column(
1663+ modifier = Modifier
1664+ .weight(1f)
1665+ .clip(MaterialTheme.shapes.small)
1666+ .clickable(role = Role.Button, onClickLabel = editLabel) {
1667+ onValueChange(queuedFollowUp)
1668+ onCancelQueue()
1669+ }
1670+ .padding(vertical = 4.dp),
1671+ ) {
15051672 Text(
15061673 stringResource(R.string.queued),
15071674 style = MaterialTheme.typography.labelSmall,
@@ -1569,6 +1736,31 @@ private fun ChatInputBar(
15691736 .weight(1f)
15701737 .focusRequester(focusRequester)
15711738 .testTag("chat_input")
1739+ // Intercept image content pasted/dropped into the field (keyboard paste
1740+ // of a copied screenshot, drag from Files, etc.) and route it through the
1741+ // same staging path as the attach menu. Non-image content (text) falls
1742+ // through unchanged so normal typing/paste works.
1743+ .contentReceiver(
1744+ remember {
1745+ androidx.compose.foundation.content.ReceiveContentListener { transferable ->
1746+ val clipData = transferable.clipEntry?.clipData
1747+ if (clipData != null) {
1748+ val imageUris = (0 until clipData.itemCount).mapNotNull { i ->
1749+ val item = clipData.getItemAt(i)
1750+ item.uri?.takeIf { uri ->
1751+ item.text == null && uri.toString().let { s ->
1752+ s.startsWith("content:") || s.startsWith("file:")
1753+ }
1754+ }
1755+ }
1756+ if (imageUris.isNotEmpty()) {
1757+ onPasteImage(imageUris)
1758+ }
1759+ }
1760+ transferable
1761+ }
1762+ },
1763+ )
15721764 .onPreviewKeyEvent { event ->
15731765 if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
15741766 // With "Send on Enter" on, Enter sends (Shift+Enter newlines).
@@ -1643,8 +1835,19 @@ private fun ChatInputBar(
16431835 value = value,
16441836 onValueChange = onValueChange,
16451837 onDismiss = { showFullScreenEditor = false },
1646− onSend = { showFullScreenEditor = false; onSend() },
1838+ onSend = {
1839+ showFullScreenEditor = false
1840+ // Mirror the inline composer's behavior: while a run is active a send
1841+ // can't go immediately, so queue the follow-up instead of silently
1842+ // dropping it (vm.send() returns false during a run, which previously
1843+ // lost the typed message with no feedback).
1844+ if (running) onQueueFollowUp(value) else onSend()
1845+ },
16471846 canSend = enabled && hasContent,
1847+ running = running,
1848+ attachments = attachments,
1849+ onRemoveAttachment = onRemoveAttachment,
1850+ staging = staging,
16481851 )
16491852 }
16501853 }
@@ -1660,6 +1863,10 @@ private fun FullScreenEditor(
16601863 onDismiss: () -> Unit,
16611864 onSend: () -> Unit,
16621865 canSend: Boolean,
1866+ running: Boolean = false,
1867+ attachments: List<PendingAttachment> = emptyList(),
1868+ onRemoveAttachment: (String) -> Unit = {},
1869+ staging: Boolean = false,
16631870) {
16641871 androidx.compose.ui.window.Dialog(
16651872 onDismissRequest = onDismiss,
@@ -1683,20 +1890,26 @@ private fun FullScreenEditor(
16831890 horizontalArrangement = Arrangement.End,
16841891 ) {
16851892 androidx.compose.material3.Button(onClick = onSend, enabled = canSend) {
1686− Text(stringResource(R.string.send))
1893+ Text(stringResource(if (running) R.string.queue else R.string.send))
16871894 }
16881895 }
16891896 }
16901897 },
16911898 ) { padding ->
1692− OutlinedTextField(
1693− value = value,
1694− onValueChange = { v -> onValueChange(v.take(NetworkConfig.maxDraftLengthChars)) },
1695− modifier = Modifier.fillMaxSize().padding(padding),
1696− placeholder = { Text(stringResource(R.string.message_placeholder)) },
1697− maxLines = Int.MAX_VALUE,
1698− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Default, capitalization = KeyboardCapitalization.Sentences),
1699− )
1899+ Column(modifier = Modifier.fillMaxSize().padding(padding)) {
1900+ // Show staged attachments in the full-screen editor too, so a user who staged
1901+ // images then expanded for a long prompt can still see and remove them without
1902+ // collapsing first (mirrors the inline composer's AttachmentStrip).
1903+ AttachmentStrip(attachments, onRemove = onRemoveAttachment, staging = staging)
1904+ OutlinedTextField(
1905+ value = value,
1906+ onValueChange = { v -> onValueChange(v.take(NetworkConfig.maxDraftLengthChars)) },
1907+ modifier = Modifier.fillMaxSize().weight(1f),
1908+ placeholder = { Text(stringResource(R.string.message_placeholder)) },
1909+ maxLines = Int.MAX_VALUE,
1910+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Default, capitalization = KeyboardCapitalization.Sentences),
1911+ )
1912+ }
17001913 }
17011914 }
17021915}
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+30
@@ -1280,6 +1280,36 @@ class ChatViewModel(
12801280 }
12811281 }
12821282
1283+ /**
1284+ * Regenerate an assistant reply: revert the conversation to that assistant message (removing
1285+ * it and everything after), then re-send the user prompt that immediately preceded it. A
1286+ * one-tap "try again" mirroring ChatGPT/Claude, instead of the two-step revert + re-Send.
1287+ *
1288+ * Captures the preceding user text *before* the revert (the messages list updates
1289+ * asynchronously via SSE once the revert lands). No-op if there's no preceding user prompt
1290+ * or the assistant message isn't found.
1291+ */
1292+ fun regenerate(assistantMessageId: String) {
1293+ val conn = connection ?: return
1294+ val msgs = messages.value
1295+ val idx = msgs.indexOfFirst { it.info.id == assistantMessageId }
1296+ if (idx < 0) return
1297+ val precedingUserText = (idx - 1 downTo 0)
1298+ .asSequence()
1299+ .map { msgs[it] }
1300+ .firstOrNull { it.info is UserMessage }
1301+ ?.parts
1302+ ?.filterIsInstance<TextPart>()
1303+ ?.joinToString("\n\n") { it.text }
1304+ ?.takeIf { it.isNotBlank() }
1305+ ?: return
1306+ viewModelScope.launch {
1307+ runCatchingCancellable { conn.api.revert(sessionId, assistantMessageId) }
1308+ .onSuccess { send(precedingUserText, includeAttachments = false) }
1309+ .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1310+ }
1311+ }
1312+
12831313 /** Load a previously-sent user prompt back into the composer to edit and re-send.
12841314 * Reverts the conversation to just before [messageId] (hiding it and everything after)
12851315 * and prefills the draft with [text]. The next Send continues from that checkpoint,
app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.kt+23−1
@@ -1,6 +1,7 @@
11package soy.iko.opencode.ui.chat
22
33import soy.iko.opencode.data.model.AssistantMessage
4+import soy.iko.opencode.data.model.FilePart
45import soy.iko.opencode.data.model.MessageWithParts
56import soy.iko.opencode.data.model.ReasoningPart
67import soy.iko.opencode.data.model.TextPart
@@ -9,6 +10,10 @@ import soy.iko.opencode.data.model.ToolError
910import soy.iko.opencode.data.model.ToolPart
1011import soy.iko.opencode.data.model.ToolRunning
1112import soy.iko.opencode.data.model.UserMessage
13+import soy.iko.opencode.data.model.sourcePath
14+import java.text.SimpleDateFormat
15+import java.util.Date
16+import java.util.Locale
1217
1318/**
1419 * Render a conversation as Markdown for sharing/exporting. Each message becomes a
@@ -40,17 +45,34 @@ fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?):
4045 is ReasoningPart -> part.text.takeIf { it.isNotBlank() }
4146 ?.let { text -> text.trim().lines().joinToString("\n") { "> _${escapeMarkdown(it)}_" } }
4247 is ToolPart -> formatToolCall(part)
48+ is FilePart -> {
49+ // Image/file attachments aren't portable in a text export; emit a
50+ // labeled placeholder so the transcript records that one was present
51+ // (useful for debugging) instead of silently dropping it.
52+ val name = part.sourcePath ?: part.url ?: part.filename ?: "attachment"
53+ if (part.mime?.startsWith("image/") == true) "_[image: ${escapeMarkdown(name)}]_"
54+ else "_[file: ${escapeMarkdown(name)}]_"
55+ }
4356 else -> null
4457 }
4558 }
4659 .joinToString("\n\n")
4760 .trim()
4861 if (body.isEmpty()) continue
49− sb.append(heading).append("\n\n").append(body).append("\n\n")
62+ sb.append(heading).append("\n\n")
63+ // Per-message timestamp (ISO, local zone) so an exported transcript is useful for
64+ // support/debugging — without it only the metadata header's global range has timing.
65+ message.info.time?.created?.let { ts -> sb.append("_").append(formatExportTimestamp(ts)).append("_\n\n") }
66+ sb.append(body).append("\n\n")
5067 }
5168 return sb.toString().trimEnd()
5269}
5370
71+/** Format an epoch-millis as a locale-stable ISO-ish timestamp for the export. Thread-confined
72+ * to the exporting coroutine, so a shared SimpleDateFormat (not thread-safe) is fine. */
73+private fun formatExportTimestamp(epochMillis: Long): String =
74+ SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date(epochMillis))
75+
5476/** Append a metadata header (message counts + timestamp range) so a shared transcript has
5577 * context for support/debugging. Skipped when there are no messages. */
5678private fun appendMetadataHeader(sb: StringBuilder, messages: List<MessageWithParts>) {
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+22−1
@@ -22,6 +22,7 @@ import androidx.compose.material.icons.filled.Edit
2222import androidx.compose.material.icons.filled.Error
2323import androidx.compose.material.icons.filled.FormatQuote
2424import androidx.compose.material.icons.filled.MoreVert
25+import androidx.compose.material.icons.filled.Refresh
2526import androidx.compose.material.icons.filled.Restore
2627import androidx.compose.material.icons.filled.SmartToy
2728import androidx.compose.material.icons.filled.StopCircle
@@ -100,6 +101,7 @@ fun MessageBubble(
100101 isSpeaking: Boolean = false,
101102 onQuote: ((String) -> Unit)? = null,
102103 onBranch: ((String) -> Unit)? = null,
104+ onRegenerate: (() -> Unit)? = null,
103105 sendStatus: MessageSendStatus? = null,
104106 isEdited: Boolean = false,
105107 onRetry: (() -> Unit)? = null,
@@ -108,7 +110,7 @@ fun MessageBubble(
108110 when (message.info) {
109111 is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile, onRevert, onEdit, onQuote, onBranch, sendStatus, isEdited, onRetry, onDismiss)
110112 is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier, onOpenFile)
111− else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel, onOpenFile, onRevert, onSpeak, isSpeaking, onQuote, onBranch, isEdited)
113+ else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel, onOpenFile, onRevert, onSpeak, isSpeaking, onQuote, onBranch, onRegenerate, isEdited)
112114 }
113115}
114116
@@ -510,6 +512,7 @@ private fun AssistantBlock(
510512 isSpeaking: Boolean = false,
511513 onQuote: ((String) -> Unit)? = null,
512514 onBranch: ((String) -> Unit)? = null,
515+ onRegenerate: (() -> Unit)? = null,
513516 isEdited: Boolean = false,
514517) {
515518 Column(
@@ -639,6 +642,7 @@ private fun AssistantBlock(
639642 onRevert = onRevert,
640643 onQuote = onQuote,
641644 onBranch = onBranch,
645+ onRegenerate = onRegenerate,
642646 )
643647 }
644648 MessageLongPressMenu(
@@ -668,11 +672,28 @@ private fun AssistantActions(
668672 onRevert: (() -> Unit)?,
669673 onQuote: ((String) -> Unit)? = null,
670674 onBranch: ((String) -> Unit)? = null,
675+ onRegenerate: (() -> Unit)? = null,
671676) {
672677 val context = LocalContext.current
673678 val haptics = LocalHapticFeedback.current
674679 val copyLabel = stringResource(R.string.copy)
675680 Row(verticalAlignment = Alignment.CenterVertically) {
681+ // Regenerate: re-run the preceding user prompt to get a fresh reply. Only meaningful
682+ // for a completed, non-streaming message, so the caller gates it on !isRunning.
683+ if (onRegenerate != null) {
684+ val regenerateLabel = stringResource(R.string.regenerate)
685+ IconButton(onClick = {
686+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
687+ onRegenerate()
688+ }) {
689+ Icon(
690+ Icons.Filled.Refresh,
691+ contentDescription = regenerateLabel,
692+ modifier = Modifier.size(18.dp),
693+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
694+ )
695+ }
696+ }
676697 // Read-aloud toggle: speaks the assistant text via TextToSpeech, showing a Stop
677698 // icon while this message is the one being spoken.
678699 if (onSpeak != null && textToCopy != null) {
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+30−3
@@ -214,6 +214,20 @@ private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modi
214214 color = MaterialTheme.colorScheme.onSurfaceVariant,
215215 modifier = Modifier.padding(start = 6.dp),
216216 )
217+ // When collapsed, show a muted word count so the user has a signal of how much
218+ // content is hidden before deciding to expand. Skipped while streaming (the
219+ // content is growing and the label already says "Thinking…").
220+ if (!streaming && !expanded) {
221+ val wordCount = remember(text) {
222+ text.trim().split(Regex("\\s+")).count { it.isNotEmpty() }
223+ }
224+ Text(
225+ "($wordCount)",
226+ style = MaterialTheme.typography.labelSmall,
227+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
228+ modifier = Modifier.padding(start = 4.dp),
229+ )
230+ }
217231 }
218232 val expandMotion = rememberVisibilityTransitions()
219233 AnimatedVisibility(
@@ -243,7 +257,9 @@ private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modi
243257 copyToClipboard(context, context.getString(R.string.clip_label_reasoning), text)
244258 },
245259 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
246− modifier = Modifier.semantics(mergeDescendants = true) {},
260+ modifier = Modifier
261+ .defaultMinSize(minHeight = 48.dp)
262+ .semantics(mergeDescendants = true) {},
247263 ) {
248264 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
249265 Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
@@ -308,6 +324,13 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
308324 // rather than an empty state (the defaults are "").
309325 expandedState = stringResource(R.string.state_expanded),
310326 collapsedState = stringResource(R.string.state_collapsed),
327+ // Mirror the output block's copy affordance so a tool's arguments (e.g. a
328+ // bash command or file-write content) can be copied for reuse without manual
329+ // text selection.
330+ onCopy = {
331+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
332+ copyToClipboard(context, context.getString(R.string.clip_label_input), pretty)
333+ },
311334 )
312335 }
313336 }
@@ -427,7 +450,9 @@ private fun CollapsibleDetail(
427450 TextButton(
428451 onClick = onToggleExpand,
429452 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
430− modifier = Modifier.semantics { stateDescription = if (expanded) expandedState else collapsedState },
453+ modifier = Modifier
454+ .defaultMinSize(minHeight = 48.dp)
455+ .semantics { stateDescription = if (expanded) expandedState else collapsedState },
431456 ) {
432457 Icon(
433458 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
@@ -449,7 +474,9 @@ private fun CollapsibleDetail(
449474 TextButton(
450475 onClick = onCopy,
451476 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
452− modifier = Modifier.semantics(mergeDescendants = true) {},
477+ modifier = Modifier
478+ .defaultMinSize(minHeight = 48.dp)
479+ .semantics(mergeDescendants = true) {},
453480 ) {
454481 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
455482 Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
app/src/main/java/soy/iko/opencode/ui/chat/TtsController.kt+13−6
@@ -62,15 +62,21 @@ class TtsController(context: Context) : RememberObserver {
6262 mainHandler.post { if (_speakingId.value == utteranceId) _speakingId.value = null }
6363 }
6464
65− /** Speak [text] for message [id], or stop if [id] is already the one being spoken. */
66− fun toggle(id: String, text: String) {
67− if (_speakingId.value == id) { stop(); return }
68− if (!ready || text.isBlank()) return
65+ /**
66+ * Speak [text] for message [id], or stop if [id] is already the one being spoken.
67+ *
68+ * @return true if playback was (de)queued or stopped; false if the request was a no-op
69+ * because the engine isn't ready (init failed or still pending) or the text is blank.
70+ * Callers use this to surface feedback instead of the button appearing dead.
71+ */
72+ fun toggle(id: String, text: String): Boolean {
73+ if (_speakingId.value == id) { stop(); return true }
74+ if (!ready || text.isBlank()) return false
6975 // TextToSpeech.speak() silently returns ERROR (enqueuing nothing, firing no callback)
7076 // when a single input exceeds getMaxSpeechInputLength(), which would leave the Stop
7177 // button stuck with no audio. Chunk long text and enqueue the parts back-to-back.
7278 val chunks = chunkForTts(text)
73− if (chunks.isEmpty()) return
79+ if (chunks.isEmpty()) return false
7480 chunks.forEachIndexed { index, chunk ->
7581 val queueMode = if (index == 0) TextToSpeech.QUEUE_FLUSH else TextToSpeech.QUEUE_ADD
7682 // Only the final chunk carries the tracked [id]; intermediate chunks get a
@@ -85,10 +91,11 @@ class TtsController(context: Context) : RememberObserver {
8591 if (result != TextToSpeech.SUCCESS) {
8692 if (index == 0) _speakingId.value = null
8793 else stop()
88− return
94+ return false
8995 }
9096 }
9197 _speakingId.value = id
98+ return true
9299 }
93100
94101 /**
app/src/main/java/soy/iko/opencode/ui/components/Autofill.ktnew file+49
@@ -0,0 +1,49 @@
1+@file:Suppress("MatchingDeclarationName")
2+
3+package soy.iko.opencode.ui.components
4+
5+import androidx.compose.runtime.Composable
6+import androidx.compose.runtime.getValue
7+import androidx.compose.runtime.remember
8+import androidx.compose.runtime.rememberUpdatedState
9+import androidx.compose.ui.ExperimentalComposeUiApi
10+import androidx.compose.ui.Modifier
11+import androidx.compose.ui.autofill.AutofillNode
12+import androidx.compose.ui.autofill.AutofillType
13+import androidx.compose.ui.layout.boundsInWindow
14+import androidx.compose.ui.layout.onGloballyPositioned
15+import androidx.compose.ui.platform.LocalAutofillTree
16+
17+/**
18+ * A stable, non-experimental hint for which autofill category a field represents, so callers
19+ * don't have to opt into the experimental [AutofillType] API (or reference `androidx.compose.ui
20+ * .autofill` types) at every text-field call site.
21+ */
22+enum class AutofillHint { Username, Password, Email }
23+
24+/**
25+ * Registers an autofill node for [hint] with the Compose autofill tree so password managers and
26+ * the system credential service can populate the field. Returns a [Modifier] that reports the
27+ * field's window bounds so the platform's autofill UI anchors to it.
28+ *
29+ * Compose's autofill API is still experimental (`androidx.compose.ui.autofill`); encapsulating
30+ * it here keeps the opt-in local and gives every auth field the same hint wiring. Without it,
31+ * Compose text fields carry no autofill hints and password managers can't fill them.
32+ */
33+@OptIn(ExperimentalComposeUiApi::class)
34+@Composable
35+fun autofillModifier(hint: AutofillHint, onFill: (String) -> Unit): Modifier {
36+ val currentOnFill by rememberUpdatedState(onFill)
37+ val types = remember(hint) {
38+ listOf(
39+ when (hint) {
40+ AutofillHint.Username -> AutofillType.Username
41+ AutofillHint.Password -> AutofillType.Password
42+ AutofillHint.Email -> AutofillType.EmailAddress
43+ },
44+ )
45+ }
46+ val node = remember(types) { AutofillNode(autofillTypes = types, onFill = { currentOnFill(it) }) }
47+ LocalAutofillTree.current += node
48+ return Modifier.onGloballyPositioned { coords -> node.boundingBox = coords.boundsInWindow() }
49+}
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+6−1
@@ -6,6 +6,7 @@ import androidx.compose.animation.fadeOut
66import androidx.compose.foundation.layout.Arrangement
77import androidx.compose.foundation.layout.BoxScope
88import androidx.compose.foundation.layout.Row
9+import androidx.compose.foundation.layout.defaultMinSize
910import androidx.compose.foundation.layout.fillMaxWidth
1011import androidx.compose.foundation.layout.padding
1112import androidx.compose.foundation.layout.size
@@ -133,7 +134,11 @@ fun ConnectionBanner(
133134 // already reconnecting), when offline (retry can't help), and when no
134135 // callback is wired (callers that don't have a reconnect path).
135136 if (isServerFailure && !isOffline && onRetry != null) {
136− TextButton(onClick = onRetry, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp)) {
137+ TextButton(
138+ onClick = onRetry,
139+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
140+ modifier = Modifier.defaultMinSize(minHeight = 48.dp),
141+ ) {
137142 Text(stringResource(R.string.retry_now), color = onContainer)
138143 }
139144 }
app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+2
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Row
88import androidx.compose.foundation.layout.Spacer
99import androidx.compose.foundation.layout.fillMaxWidth
1010import androidx.compose.foundation.layout.height
11+import androidx.compose.foundation.layout.defaultMinSize
1112import androidx.compose.foundation.layout.padding
1213import androidx.compose.foundation.layout.width
1314import androidx.compose.foundation.rememberScrollState
@@ -216,6 +217,7 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
216217 TextButton(
217218 onClick = { expanded = !expanded },
218219 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
220+ modifier = Modifier.defaultMinSize(minHeight = 48.dp),
219221 ) {
220222 Text(
221223 if (expanded) stringResource(R.string.show_less)
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+90−5
@@ -5,26 +5,35 @@ import android.content.ClipboardManager
55import android.content.Context
66import android.os.Build
77import android.widget.Toast
8+import androidx.compose.animation.core.RepeatMode
9+import androidx.compose.animation.core.animateFloat
10+import androidx.compose.animation.core.infiniteRepeatable
11+import androidx.compose.animation.core.rememberInfiniteTransition
12+import androidx.compose.animation.core.tween
813import androidx.compose.foundation.background
914import androidx.compose.foundation.horizontalScroll
1015import androidx.compose.foundation.layout.Arrangement
1116import androidx.compose.foundation.layout.Box
1217import androidx.compose.foundation.layout.Column
1318import androidx.compose.foundation.layout.Row
19+import androidx.compose.foundation.layout.defaultMinSize
1420import androidx.compose.foundation.layout.fillMaxWidth
1521import androidx.compose.foundation.layout.padding
1622import androidx.compose.foundation.layout.size
1723import androidx.compose.foundation.rememberScrollState
1824import androidx.compose.foundation.text.selection.SelectionContainer
1925import androidx.compose.material.icons.Icons
20−import androidx.compose.material.icons.filled.ContentCopy
21−import androidx.compose.material.icons.filled.Check
2226import androidx.compose.material.icons.automirrored.filled.WrapText
27+import androidx.compose.material.icons.filled.Check
28+import androidx.compose.material.icons.filled.ContentCopy
29+import androidx.compose.material.icons.filled.ExpandLess
30+import androidx.compose.material.icons.filled.ExpandMore
2331import androidx.compose.material3.Icon
2432import androidx.compose.material3.IconButton
2533import androidx.compose.material3.MaterialTheme
2634import androidx.compose.material3.Surface
2735import androidx.compose.material3.Text
36+import androidx.compose.material3.TextButton
2837import androidx.compose.material3.Typography
2938import androidx.compose.runtime.Composable
3039import androidx.compose.runtime.LaunchedEffect
@@ -32,12 +41,15 @@ import androidx.compose.runtime.getValue
3241import androidx.compose.runtime.mutableStateOf
3342import androidx.compose.runtime.remember
3443import androidx.compose.runtime.rememberUpdatedState
44+import androidx.compose.runtime.saveable.rememberSaveable
3545import androidx.compose.runtime.setValue
3646import androidx.compose.runtime.snapshotFlow
3747import androidx.compose.ui.Alignment
3848import androidx.compose.ui.Modifier
3949import androidx.compose.ui.draw.clip
4050import androidx.compose.ui.platform.LocalContext
51+import androidx.compose.ui.res.pluralStringResource
52+import androidx.compose.ui.res.stringResource
4153import androidx.compose.ui.text.AnnotatedString
4254import androidx.compose.ui.text.TextStyle
4355import androidx.compose.ui.text.font.FontFamily
@@ -157,6 +169,10 @@ private fun MarkdownBody(
157169 modifier = modifier,
158170 components = components,
159171 )
172+ // A blinking caret at the end of a streaming reply gives a "live" typewriter feel.
173+ // Skipped under reduced motion (where blinking is discouraged). It's a tiny separate
174+ // element so the markdown AST isn't re-parsed to toggle it.
175+ if (!LocalReducedMotion.current) StreamingCaret()
160176 } else {
161177 SelectionContainer {
162178 Markdown(
@@ -174,6 +190,10 @@ private fun MarkdownBody(
174190 * copy button. Self-contained so it doesn't depend on the library's internal code-block
175191 * composables. The wrap toggle starts from the user's [LocalCodeWrap] preference but can
176192 * be flipped per-block.
193+ *
194+ * To avoid composing thousands of [Text] nodes at once (which can ANR/OOM on a long
195+ * response), code blocks over [COLLAPSED_CODE_LINES] lines render only the head until the
196+ * user expands — mirroring the collapse already applied to tool output and diffs.
177197 */
178198@Composable
179199private fun CodeWithCopy(model: MarkdownComponentModel) {
@@ -189,15 +209,32 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
189209 val code = remember(raw, isFenced) { extractCodeText(raw, isFenced) }
190210 val language = remember(raw, isFenced) { extractFenceLanguage(raw, isFenced) }
191211 val codeStyle = model.typography.code.copy(fontFamily = FontFamily.Monospace)
212+ // A long code fence can carry hundreds of lines; computing lineCount walks the string
213+ // once, memoized so a recomposition that doesn't change `code` (e.g. a wrap/expand flip)
214+ // doesn't re-walk it.
215+ val lineCount = remember(code) { code.count { it == '\n' } + 1 }
216+ val canCollapse = lineCount > COLLAPSED_CODE_LINES
217+ // Key expand state on the content so a growing (streaming) block re-seeds it. Collapsing
218+ // primarily benefits completed messages; a streaming block that crosses the cap may snap
219+ // to collapsed on the token that crosses it, which is acceptable (the head stays visible).
220+ var expanded by rememberSaveable(code) { mutableStateOf(false) }
221+ val displayCode = if (canCollapse && !expanded) {
222+ remember(code, expanded) {
223+ code.lineSequence().take(COLLAPSED_CODE_LINES).joinToString("\n")
224+ }
225+ } else {
226+ code
227+ }
192228 // Heuristic syntax highlighting, reusing the same highlighter the file viewer uses.
193229 // Resolved from the fence's info string (e.g. "kotlin"), not a filename. Memoized so a
194230 // scroll-induced recomposition doesn't re-tokenize; during streaming the MarkdownBody
195231 // throttle coalesces re-highlights. Falls back to plain text for unknown/missing tags
196− // (NONE) so un-tagged fences render unchanged.
232+ // (NONE) so un-tagged fences render unchanged. Re-derived when displayCode changes so a
233+ // collapsed head and the full expanded block are both highlighted correctly.
197234 val palette = rememberHighlightPalette()
198235 val syntax = remember(language) { language?.let { syntaxForLanguageTag(it) } }
199− val highlighted = remember(code, syntax, palette) {
200− if (syntax != null) highlightCode(code, syntax, palette) else AnnotatedString(code)
236+ val highlighted = remember(displayCode, syntax, palette) {
237+ if (syntax != null) highlightCode(displayCode, syntax, palette) else AnnotatedString(displayCode)
201238 }
202239 // Default per-block wrap from the global preference; re-seed if the preference changes
203240 // while this block is composed (rememberSaveable would over-persist across blocks).
@@ -231,6 +268,18 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
231268 color = MaterialTheme.colorScheme.onSurfaceVariant,
232269 modifier = Modifier.weight(1f),
233270 )
271+ if (canCollapse) {
272+ val moreLines = lineCount - COLLAPSED_CODE_LINES
273+ IconButton(onClick = { expanded = !expanded }) {
274+ Icon(
275+ if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
276+ contentDescription = if (expanded) context.getString(R.string.show_less)
277+ else context.resources.getQuantityString(R.plurals.show_more_lines, moreLines, moreLines),
278+ modifier = Modifier.size(18.dp),
279+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
280+ )
281+ }
282+ }
234283 val wrapLabel = context.getString(if (wrap) R.string.code_no_wrap else R.string.code_wrap)
235284 IconButton(onClick = { wrap = !wrap }) {
236285 Icon(
@@ -273,9 +322,45 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
273322 },
274323 )
275324 }
325+ if (canCollapse && !expanded) {
326+ val hidden = lineCount - COLLAPSED_CODE_LINES
327+ TextButton(
328+ onClick = { expanded = true },
329+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 12.dp),
330+ modifier = Modifier
331+ .defaultMinSize(minHeight = 48.dp)
332+ .padding(bottom = 4.dp),
333+ ) {
334+ Text(pluralStringResource(R.plurals.show_more_lines, hidden, hidden))
335+ }
336+ }
276337 }
277338}
278339
340+/** Max lines a code fence renders before collapsing to a head with a "show more" affordance. */
341+private const val COLLAPSED_CODE_LINES = 200
342+
343+/**
344+ * A small blinking caret shown at the tail of a streaming assistant reply for a "live"
345+ * typewriter feel. Respects reduced motion (callers gate it). The blink is a gentle 1s alpha
346+ * pulse; the caret sits inline at the leading edge so it reads as the next character arriving.
347+ */
348+@Composable
349+private fun StreamingCaret() {
350+ val transition = rememberInfiniteTransition(label = "caret")
351+ val alpha by transition.animateFloat(
352+ initialValue = 0.2f,
353+ targetValue = 1f,
354+ animationSpec = infiniteRepeatable(tween(550), RepeatMode.Reverse),
355+ label = "caretAlpha",
356+ )
357+ Text(
358+ "▋",
359+ style = MaterialTheme.typography.bodyLarge,
360+ color = MaterialTheme.colorScheme.primary.copy(alpha = alpha),
361+ )
362+}
363+
279364/**
280365 * Extract the fence language tag (e.g. "kotlin" from ```kotlin) so the code block can
281366 * label itself. Returns null for indented blocks or fences with no info string. Extracted
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+99−6
@@ -30,9 +30,12 @@ import androidx.compose.material.icons.filled.Home
3030import androidx.compose.material.icons.filled.Image
3131import androidx.compose.material.icons.filled.Code
3232import androidx.compose.material.icons.filled.Article
33+import androidx.compose.material.icons.filled.KeyboardArrowDown
34+import androidx.compose.material.icons.filled.KeyboardArrowUp
3335import androidx.compose.material.icons.filled.Link
3436import androidx.compose.material.icons.filled.MoreVert
3537import androidx.compose.material.icons.filled.Search
38+import androidx.compose.material.icons.filled.SortByAlpha
3639import androidx.compose.material3.CircularProgressIndicator
3740import androidx.compose.material3.DropdownMenu
3841import androidx.compose.material3.DropdownMenuItem
@@ -80,6 +83,7 @@ import androidx.compose.ui.res.pluralStringResource
8083import androidx.compose.ui.res.stringResource
8184import androidx.lifecycle.compose.collectAsStateWithLifecycle
8285import androidx.lifecycle.viewmodel.compose.viewModel
86+import soy.iko.opencode.data.model.FileNode
8387import soy.iko.opencode.data.model.FileStatusEntry
8488import soy.iko.opencode.data.model.FindMatch
8589import soy.iko.opencode.data.model.SymbolResult
@@ -90,6 +94,7 @@ import soy.iko.opencode.ui.components.AppTopBar
9094import soy.iko.opencode.ui.components.ConnectionBannerFor
9195import soy.iko.opencode.ui.components.EmptyState
9296import soy.iko.opencode.ui.components.copyToClipboard
97+import soy.iko.opencode.ui.components.relativeTime
9398import soy.iko.opencode.ui.vmFactory
9499import soy.iko.opencode.util.runCatchingCancellable
95100
@@ -514,14 +519,29 @@ private fun DirectoryListing(
514519 // hides them by default (the common case for browsing a repo) and lets the user reveal
515520 // .env / .git / .opencode on demand. Default off so the listing isn't cluttered.
516521 var showHidden by rememberSaveable { mutableStateOf(false) }
522+ // Sort key for files within the listing. Name is the default (and the only axis the server
523+ // reliably supports); Size/Modified appear only when the server emits size/mtime (otherwise
524+ // those sorts are no-ops on null fields), gated behind a menu so they don't clutter the bar.
525+ var sortKey by rememberSaveable { mutableStateOf(FileSortKey.NAME) }
526+ var sortDesc by rememberSaveable { mutableStateOf(false) }
517527 val visible = remember(state.entries, showHidden) {
518528 if (showHidden) state.entries else state.entries.filterNot { it.name.startsWith(".") }
519529 }
520− val sorted = remember(visible, foldersFirst) {
521− if (!foldersFirst) visible
522− else {
530+ val sorted = remember(visible, foldersFirst, sortKey, sortDesc) {
531+ // Null size/mtime coerce to values that sink the unknown entries to the bottom in the
532+ // default (ascending) view, so a partially-populated listing keeps sized/dated files on top.
533+ val comparator = when (sortKey) {
534+ FileSortKey.NAME -> compareBy<FileNode> { it.name.lowercase() }
535+ FileSortKey.SIZE -> compareBy<FileNode> { it.size ?: Long.MAX_VALUE }
536+ FileSortKey.MODIFIED -> compareBy<FileNode> { it.mtime ?: 0L }
537+ }.let { if (sortDesc) it.reversed() else it }
538+ if (foldersFirst) {
523539 val (dirs, files) = visible.partition { it.isDirectory }
524− dirs.sortedBy { it.name.lowercase() } + files.sortedBy { it.name.lowercase() }
540+ // Directories always sort by name (size/date on a folder is rarely meaningful);
541+ // the chosen key applies to the files within.
542+ dirs.sortedBy { it.name.lowercase() } + files.sortedWith(comparator)
543+ } else {
544+ visible.sortedWith(comparator)
525545 }
526546 }
527547 LazyColumn(modifier = Modifier.fillMaxSize()) {
@@ -537,6 +557,46 @@ private fun DirectoryListing(
537557 horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
538558 verticalAlignment = Alignment.CenterVertically,
539559 ) {
560+ var showSortMenu by remember { mutableStateOf(false) }
561+ androidx.compose.material3.FilterChip(
562+ selected = sortKey != FileSortKey.NAME || sortDesc,
563+ onClick = { showSortMenu = true },
564+ leadingIcon = {
565+ Icon(Icons.Filled.SortByAlpha, contentDescription = null, modifier = Modifier.size(16.dp))
566+ },
567+ label = { Text(stringResource(R.string.sort_by), style = MaterialTheme.typography.labelSmall) },
568+ )
569+ DropdownMenu(expanded = showSortMenu, onDismissRequest = { showSortMenu = false }) {
570+ val labels = remember {
571+ mapOf(
572+ FileSortKey.NAME to R.string.sort_name,
573+ FileSortKey.SIZE to R.string.sort_size,
574+ FileSortKey.MODIFIED to R.string.sort_modified,
575+ )
576+ }
577+ FileSortKey.entries.forEach { key ->
578+ DropdownMenuItem(
579+ text = {
580+ Row(verticalAlignment = Alignment.CenterVertically) {
581+ Text(stringResource(labels[key]!!), modifier = Modifier.weight(1f))
582+ if (sortKey == key) {
583+ Icon(
584+ if (sortDesc) Icons.Filled.KeyboardArrowDown else Icons.Filled.KeyboardArrowUp,
585+ contentDescription = null,
586+ modifier = Modifier.size(18.dp),
587+ )
588+ }
589+ }
590+ },
591+ onClick = {
592+ // Tapping the active key flips the direction; tapping a different
593+ // key switches to it (ascending by default).
594+ if (sortKey == key) sortDesc = !sortDesc else { sortKey = key; sortDesc = false }
595+ showSortMenu = false
596+ },
597+ )
598+ }
599+ }
540600 androidx.compose.material3.FilterChip(
541601 selected = showHidden,
542602 onClick = { showHidden = !showHidden },
@@ -562,6 +622,8 @@ private fun DirectoryListing(
562622 onCopyPath = {
563623 copyToClipboard(context, context.getString(R.string.clip_label_path), node.path)
564624 },
625+ size = node.size,
626+ mtime = node.mtime,
565627 status = state.statusMap[node.path],
566628 modifier = Modifier.animateItem(),
567629 )
@@ -585,6 +647,8 @@ private fun FileRow(
585647 onClick: () -> Unit,
586648 status: FileStatusEntry? = null,
587649 sublabel: String? = null,
650+ size: Long? = null,
651+ mtime: Long? = null,
588652 onCopyPath: (() -> Unit)? = null,
589653 modifier: Modifier = Modifier,
590654) {
@@ -634,9 +698,19 @@ private fun FileRow(
634698 maxLines = 1,
635699 overflow = TextOverflow.Ellipsis,
636700 )
637− if (sublabel != null) {
701+ // Secondary line: a caller-supplied sublabel (directory in search results) and/or
702+ // the file's size + last-modified time when the server provides them. Combines the
703+ // fragments so a single muted line carries all metadata instead of stacking rows.
704+ val detailParts = buildList {
705+ if (sublabel != null) add(sublabel)
706+ if (!icon) {
707+ size?.let { add(formatFileSize(it)) }
708+ mtime?.let { add(relativeTime(it)) }
709+ }
710+ }
711+ if (detailParts.isNotEmpty()) {
638712 Text(
639− sublabel,
713+ detailParts.joinToString(" · "),
640714 style = MaterialTheme.typography.labelSmall,
641715 color = MaterialTheme.colorScheme.onSurfaceVariant,
642716 maxLines = 1,
@@ -692,6 +766,25 @@ private fun StatusBadge(status: FileStatusEntry) {
692766 }
693767}
694768
769+/** Sort axis for the directory listing. Name is always available; Size/Modified depend on the
770+ * server emitting size/mtime (otherwise they sort nulls to the bottom). */
771+private enum class FileSortKey { NAME, SIZE, MODIFIED }
772+
773+/** Format a byte count as a compact human-readable size (e.g. 1.2 KB, 3.4 MB). Uses 1024-based
774+ * units with one decimal place, matching how file managers render sizes. */
775+private fun formatFileSize(bytes: Long): String {
776+ if (bytes < 1024) return "$bytes B"
777+ val units = arrayOf("KB", "MB", "GB", "TB")
778+ var value = bytes.toDouble() / 1024.0
779+ var unit = 0
780+ while (value >= 1024 && unit < units.lastIndex) {
781+ value /= 1024.0
782+ unit++
783+ }
784+ val formatted = if (value >= 100) "%.0f".format(value) else "%.1f".format(value)
785+ return "$formatted ${units[unit]}"
786+}
787+
695788/** Pick an icon for a file by its extension so the directory listing is scannable instead of a
696789 * wall of identical `Description` glyphs: images, source code, and Markdown/article files each
697790 * get a distinct glyph; unknown extensions fall back to the generic document icon. */
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+31
@@ -40,6 +40,7 @@ import androidx.compose.material.icons.filled.Link
4040import androidx.compose.material.icons.filled.MoreVert
4141import androidx.compose.material.icons.filled.Refresh
4242import androidx.compose.material.icons.filled.Search
43+import androidx.compose.material.icons.filled.OpenInNew
4344import androidx.compose.material.icons.filled.Share
4445import androidx.compose.material3.AlertDialog
4546import androidx.compose.material3.CircularProgressIndicator
@@ -239,6 +240,30 @@ fun FileViewScreen(
239240 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
240241 .onFailure { scope.launch { snackbar.showSnackbar(context.getString(R.string.no_share_app)) } }
241242 },
243+ onOpenExternally = {
244+ // Write the viewed content to a cache file and hand a content:// URI to an
245+ // external app via ACTION_VIEW, so a file the in-app viewer can only show as
246+ // text (or a large file beyond the render cap) can be opened in a real editor.
247+ scope.launch {
248+ val ok = runCatchingCancellable {
249+ withContext(Dispatchers.IO) {
250+ val dir = java.io.File(context.cacheDir, "external").apply { mkdirs() }
251+ val file = java.io.File(dir, filename.ifBlank { "file.txt" })
252+ file.writeText(rawText)
253+ val authority = context.packageName + ".fileprovider"
254+ val uri = androidx.core.content.FileProvider.getUriForFile(context, authority, file)
255+ val view = Intent(Intent.ACTION_VIEW).apply {
256+ setDataAndType(uri, android.webkit.MimeTypeMap.getSingleton()
257+ .getMimeTypeFromExtension(filename.substringAfterLast('.', ""))
258+ ?: "text/plain")
259+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
260+ }
261+ context.startActivity(view)
262+ }
263+ }.isSuccess
264+ if (!ok) scope.launch { snackbar.showSnackbar(context.getString(R.string.open_externally_failed)) }
265+ }
266+ },
242267 )
243268 },
244269 snackbarHost = { SnackbarHost(snackbar) },
@@ -420,6 +445,7 @@ private fun FileViewTopBar(
420445 onCopy: () -> Unit,
421446 onCopyPath: () -> Unit,
422447 onShare: () -> Unit,
448+ onOpenExternally: () -> Unit,
423449) {
424450 TopAppBar(
425451 title = {
@@ -510,6 +536,11 @@ private fun FileViewTopBar(
510536 onClick = { onShare(); menuExpanded = false },
511537 leadingIcon = { Icon(Icons.Filled.Share, contentDescription = null) },
512538 )
539+ DropdownMenuItem(
540+ text = { Text(stringResource(R.string.open_externally)) },
541+ onClick = { onOpenExternally(); menuExpanded = false },
542+ leadingIcon = { Icon(Icons.Filled.OpenInNew, contentDescription = null) },
543+ )
513544 }
514545 }
515546 },
app/src/main/java/soy/iko/opencode/ui/file/TwoPaneFileBrowser.ktnew file+92
@@ -0,0 +1,92 @@
1+package soy.iko.opencode.ui.file
2+
3+import androidx.activity.compose.BackHandler
4+import androidx.compose.foundation.layout.Box
5+import androidx.compose.foundation.layout.Row
6+import androidx.compose.foundation.layout.fillMaxHeight
7+import androidx.compose.foundation.layout.fillMaxSize
8+import androidx.compose.foundation.layout.widthIn
9+import androidx.compose.material.icons.Icons
10+import androidx.compose.material.icons.filled.Description
11+import androidx.compose.material3.VerticalDivider
12+import androidx.compose.runtime.Composable
13+import androidx.compose.runtime.LaunchedEffect
14+import androidx.compose.runtime.getValue
15+import androidx.compose.runtime.key
16+import androidx.compose.runtime.mutableStateOf
17+import androidx.compose.runtime.remember
18+import androidx.compose.runtime.saveable.rememberSaveable
19+import androidx.compose.runtime.setValue
20+import androidx.compose.ui.Modifier
21+import androidx.compose.ui.res.stringResource
22+import androidx.compose.ui.unit.dp
23+import androidx.lifecycle.compose.collectAsStateWithLifecycle
24+import soy.iko.opencode.R
25+import soy.iko.opencode.data.network.NetworkConfig
26+import soy.iko.opencode.di.AppContainer
27+import soy.iko.opencode.ui.components.EmptyState
28+
29+/**
30+ * Master–detail layout for the file browser on wide screens (≥ 840dp): the directory listing
31+ * on the left, the selected file's viewer on the right. Mirrors [TwoPaneSessionChat]: each
32+ * side hosts an existing screen (which carries its own Scaffold + app bar), so the panes are
33+ * self-contained. Selecting a file updates the detail pane rather than pushing a destination;
34+ * the viewer's onBack clears the selection.
35+ */
36+@Composable
37+fun TwoPaneFileBrowser(
38+ container: AppContainer,
39+ onBack: () -> Unit,
40+ modifier: Modifier = Modifier,
41+) {
42+ var selected by rememberSaveable { mutableStateOf<String?>(null) }
43+ // A deep-linked file open (opencode://file/{path}) targets the detail pane here.
44+ val pendingOpenFile by container.pendingOpenFile.collectAsStateWithLifecycle()
45+ LaunchedEffect(pendingOpenFile) {
46+ pendingOpenFile?.let {
47+ selected = it
48+ container.consumePendingOpenFile()
49+ }
50+ }
51+
52+ Row(modifier = modifier.fillMaxSize()) {
53+ Box(
54+ modifier = Modifier
55+ .weight(NetworkConfig.twoPaneLeftWeight)
56+ .widthIn(max = 460.dp),
57+ ) {
58+ FileBrowserScreen(
59+ container = container,
60+ // Selecting a file opens it in the detail pane (not the back stack).
61+ onOpenFile = { path, _ -> selected = path },
62+ onBack = onBack,
63+ )
64+ }
65+
66+ VerticalDivider(modifier = Modifier.fillMaxHeight())
67+
68+ Box(modifier = Modifier.weight(NetworkConfig.twoPaneRightWeight).fillMaxSize()) {
69+ val path = selected
70+ if (path == null) {
71+ EmptyState(
72+ icon = Icons.Filled.Description,
73+ title = stringResource(R.string.empty_file_detail_title),
74+ description = stringResource(R.string.empty_file_detail_desc),
75+ modifier = Modifier.fillMaxSize(),
76+ )
77+ } else {
78+ BackHandler { selected = null }
79+ // key() on the path so switching files in two-pane mode gives FileViewScreen a
80+ // fresh composition (its find/wrap state and scroll are per-file, like the chat
81+ // pane's per-session keying).
82+ key(path) {
83+ FileViewScreen(
84+ container = container,
85+ path = path,
86+ onBack = { selected = null },
87+ )
88+ }
89+ }
90+ }
91+ }
92+}
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+22−4
@@ -70,6 +70,8 @@ import soy.iko.opencode.data.network.NsdDiscovery
7070import soy.iko.opencode.data.network.NetworkConfig
7171import soy.iko.opencode.di.AppContainer
7272import soy.iko.opencode.R
73+import soy.iko.opencode.ui.components.autofillModifier
74+import soy.iko.opencode.ui.components.AutofillHint
7375import soy.iko.opencode.ui.vmFactory
7476
7577@OptIn(ExperimentalMaterial3Api::class)
@@ -194,7 +196,12 @@ private fun ServerEditForm(
194196 // fresh add form (via baseUrlFocus above).
195197 OutlinedTextField(
196198 value = state.baseUrl,
197− onValueChange = { v -> vm.update { it.copy(baseUrl = v) } },
199+ onValueChange = { v ->
200+ // A paste of a full QR/JSON config into the URL field populates the whole form
201+ // (so a user can paste a shared server config instead of scanning an image).
202+ // Falls through to a normal URL edit when it isn't a recognized payload.
203+ if (!vm.applyQrPayload(v)) vm.update { it.copy(baseUrl = v) }
204+ },
198205 label = { Text(stringResource(R.string.base_url)) },
199206 placeholder = { Text(stringResource(R.string.base_url_hint)) },
200207 singleLine = true,
@@ -221,7 +228,10 @@ private fun ServerEditForm(
221228 },
222229 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
223230 keyboardActions = KeyboardActions(onDone = { keyboard?.hide() }),
224− modifier = Modifier.fillMaxWidth().focusRequester(baseUrlFocus).testTag("server_url"),
231+ modifier = Modifier
232+ .fillMaxWidth()
233+ .focusRequester(baseUrlFocus)
234+ .testTag("server_url"),
225235 )
226236 // Auto-expand LAN discovery only on a fresh form (no URL yet) so tapping a found
227237 // server is the primary path; on edit the populated URL keeps it (and its multicast)
@@ -334,7 +344,11 @@ private fun AuthFields(
334344 singleLine = true,
335345 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
336346 keyboardActions = KeyboardActions(onNext = { passwordFocus.requestFocus() }),
337− modifier = Modifier.fillMaxWidth().focusRequester(usernameFocus).testTag("server_username"),
347+ modifier = Modifier
348+ .fillMaxWidth()
349+ .focusRequester(usernameFocus)
350+ .then(autofillModifier(AutofillHint.Username) { v -> onUpdate { it.copy(username = v) } })
351+ .testTag("server_username"),
338352 )
339353 OutlinedTextField(
340354 value = state.password,
@@ -352,7 +366,11 @@ private fun AuthFields(
352366 },
353367 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
354368 keyboardActions = KeyboardActions(onDone = { onImeDone() }),
355− modifier = Modifier.fillMaxWidth().focusRequester(passwordFocus).testTag("server_password"),
369+ modifier = Modifier
370+ .fillMaxWidth()
371+ .focusRequester(passwordFocus)
372+ .then(autofillModifier(AutofillHint.Password) { v -> onUpdate { it.copy(password = v) } })
373+ .testTag("server_password"),
356374 )
357375 OutlinedButton(
358376 onClick = onTestCredentials,
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+22
@@ -248,6 +248,28 @@ class ServerEditViewModel(
248248 _state.update { transform(it).copy(credentialsResult = null, error = null) }
249249 }
250250
251+ /**
252+ * Populate the form from a pasted/decoded QR payload (a JSON blob the Share-as-QR flow
253+ * produces) or an `opencode://server/...` link. Returns true when [text] was recognized and
254+ * applied so the caller can surface a "fields filled" confirmation. Lets a user transfer a
255+ * server config by pasting instead of scanning an image.
256+ */
257+ fun applyQrPayload(text: String): Boolean {
258+ val qr = soy.iko.opencode.ui.components.parseQrPayload(text.trim()) ?: return false
259+ _state.update {
260+ it.copy(
261+ baseUrl = qr.baseUrl,
262+ username = qr.username.orEmpty(),
263+ password = qr.password.orEmpty(),
264+ requireHttps = qr.requireHttps,
265+ certPin = qr.certPin.orEmpty(),
266+ credentialsResult = null,
267+ error = null,
268+ )
269+ }
270+ return true
271+ }
272+
251273 /**
252274 * Primary action: save the profile and connect to it. When the server needs auth and the
253275 * user hasn't supplied credentials yet, a connectivity probe reveals the username/password
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+27
@@ -15,8 +15,10 @@ import androidx.compose.foundation.lazy.LazyColumn
1515import androidx.compose.foundation.lazy.items
1616import androidx.compose.material.icons.Icons
1717import androidx.compose.material.icons.filled.Add
18+import androidx.compose.material.icons.filled.Check
1819import androidx.compose.material.icons.filled.CheckCircle
1920import androidx.compose.material.icons.filled.Close
21+import androidx.compose.material.icons.filled.Sort
2022import androidx.compose.material.icons.filled.Error
2123import androidx.compose.material.icons.filled.Delete
2224import androidx.compose.material.icons.filled.Dns
@@ -196,6 +198,31 @@ fun ServerListScreen(
196198 TopAppBar(
197199 title = { Text(stringResource(R.string.servers_title)) },
198200 actions = {
201+ // Sort menu (recent / name). Only earns its space once there are a few
202+ // servers to order; with one or two it's faster to scan by eye.
203+ if (profiles.size > 1) {
204+ val sortMode by vm.sortMode.collectAsStateWithLifecycle()
205+ var sortMenu by remember { mutableStateOf(false) }
206+ IconButton(onClick = { sortMenu = true }) {
207+ Icon(Icons.Filled.Sort, contentDescription = stringResource(R.string.sort_by))
208+ }
209+ DropdownMenu(expanded = sortMenu, onDismissRequest = { sortMenu = false }) {
210+ DropdownMenuItem(
211+ text = { Text(stringResource(R.string.sort_recent)) },
212+ trailingIcon = {
213+ if (sortMode == ServerSortMode.RECENT) Icon(Icons.Filled.Check, contentDescription = null, modifier = Modifier.size(18.dp))
214+ },
215+ onClick = { vm.setSortMode(ServerSortMode.RECENT); sortMenu = false },
216+ )
217+ DropdownMenuItem(
218+ text = { Text(stringResource(R.string.sort_name)) },
219+ trailingIcon = {
220+ if (sortMode == ServerSortMode.NAME) Icon(Icons.Filled.Check, contentDescription = null, modifier = Modifier.size(18.dp))
221+ },
222+ onClick = { vm.setSortMode(ServerSortMode.NAME); sortMenu = false },
223+ )
224+ }
225+ }
199226 // Import a server by decoding a QR code from a saved image (e.g. a screenshot
200227 // of another device's Share-as-QR dialog). Camera scanning would need extra
201228 // permissions; an image picker reuses the system gallery and covers the
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+21−2
@@ -26,6 +26,9 @@ import kotlinx.coroutines.withTimeoutOrNull
2626 * failed to connect so the snackbar can offer a Retry action. */
2727data class ConnectError(val message: String, val profile: ServerProfile?)
2828
29+/** Sort order for the server list. RECENT (by lastUsed, the default) or NAME (A→Z). */
30+enum class ServerSortMode { RECENT, NAME }
31+
2932class ServerListViewModel(private val container: AppContainer) : ViewModel() {
3033
3134 /** Ids optimistically hidden while their deferred delete's undo window is open, so a
@@ -33,9 +36,25 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
3336 * undo window. Mirrors [SessionListViewModel]'s optimistic-removal pattern. */
3437 private val _hiddenIds = MutableStateFlow<Set<String>>(emptySet())
3538
39+ /** Sort order for the list. A simple recent/name toggle (no persistence — the list is
40+ * short enough that re-picking is cheap, matching the screen's other ephemeral state). */
41+ private val _sortMode = MutableStateFlow(ServerSortMode.RECENT)
42+ val sortMode: StateFlow<ServerSortMode> = _sortMode.asStateFlow()
43+
44+ fun setSortMode(mode: ServerSortMode) {
45+ if (_sortMode.value != mode) _sortMode.value = mode
46+ }
47+
3648 val profiles: StateFlow<List<ServerProfile>> =
37− combine(container.profileStore.profiles, _hiddenIds) { profiles, hidden ->
38− profiles.filterNot { it.id in hidden }
49+ combine(container.profileStore.profiles, _hiddenIds, _sortMode) { profiles, hidden, sort ->
50+ val visible = profiles.filterNot { it.id in hidden }
51+ when (sort) {
52+ ServerSortMode.RECENT -> visible.sortedByDescending { it.lastUsed }
53+ ServerSortMode.NAME -> visible.sortedWith(
54+ compareBy<ServerProfile, String>(String.CASE_INSENSITIVE_ORDER) { it.displayLabel }
55+ .thenByDescending { it.lastUsed },
56+ )
57+ }
3958 }.stateIn(
4059 scope = viewModelScope,
4160 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+160−25
@@ -3,6 +3,7 @@ package soy.iko.opencode.ui.session
33import androidx.compose.foundation.background
44import androidx.activity.compose.BackHandler
55import androidx.compose.foundation.clickable
6+import androidx.compose.foundation.combinedClickable
67import androidx.compose.foundation.layout.Arrangement
78import androidx.compose.foundation.layout.Box
89import androidx.compose.foundation.layout.Column
@@ -34,6 +35,7 @@ import androidx.compose.material.icons.filled.CheckCircle
3435import androidx.compose.material.icons.filled.Close
3536import androidx.compose.material.icons.filled.Delete
3637import androidx.compose.material.icons.filled.DoneAll
38+import androidx.compose.material.icons.filled.MarkChatRead
3739import androidx.compose.material.icons.filled.Folder
3840import androidx.compose.material.icons.filled.History
3941import androidx.compose.material.icons.filled.Link
@@ -91,6 +93,7 @@ import androidx.compose.ui.text.input.ImeAction
9193import androidx.compose.ui.text.input.TextFieldValue
9294import androidx.compose.ui.text.style.TextOverflow
9395import androidx.compose.ui.unit.dp
96+import androidx.compose.ui.res.pluralStringResource
9497import androidx.compose.ui.res.stringResource
9598import androidx.compose.ui.platform.LocalContext
9699import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -169,6 +172,14 @@ fun SessionListScreen(
169172 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
170173 var pendingRenameId by rememberSaveable { mutableStateOf<String?>(null) }
171174 var showDisconnectConfirm by rememberSaveable { mutableStateOf(false) }
175+ // Multi-select: a set of selected session ids. Non-empty ⇒ selection mode (contextual top
176+ // bar, tap-to-toggle instead of tap-to-open, FAB hidden). Long-press a card to enter it.
177+ var selectedIds by rememberSaveable { mutableStateOf(emptySet<String>()) }
178+ val inSelection = selectedIds.isNotEmpty()
179+ var showBulkDelete by rememberSaveable { mutableStateOf(false) }
180+ fun toggleSelected(id: String) {
181+ selectedIds = if (id in selectedIds) selectedIds - id else selectedIds + id
182+ }
172183 val pendingDelete = pendingDeleteId?.let { id -> state.sessions.firstOrNull { it.id == id } }
173184 val pendingRename = pendingRenameId?.let { id -> state.sessions.firstOrNull { it.id == id } }
174185
@@ -178,6 +189,13 @@ fun SessionListScreen(
178189 val platformContext = LocalContext.current.applicationContext
179190 SyncShortcutsAndWidget(platformContext, state)
180191
192+ // Reflect total unread sessions as a launcher-icon badge (silent notification on a
193+ // badge-enabled channel). Skips itself when the session list isn't loading a server yet.
194+ val totalUnread = remember(unread) { unread.values.sum() }
195+ LaunchedEffect(totalUnread) {
196+ soy.iko.opencode.notification.SessionNotifications.updateUnreadBadge(platformContext, totalUnread)
197+ }
198+
181199 // Close the open dropdown on back press instead of navigating away.
182200 BackHandler(enabled = showServerMenu) { showServerMenu = false }
183201 BackHandler(enabled = showSortMenu) { showSortMenu = false }
@@ -239,6 +257,43 @@ fun SessionListScreen(
239257
240258 Scaffold(
241259 topBar = {
260+ // Contextual action bar while sessions are selected (long-press to enter): count as
261+ // the title, with select-all and bulk archive / mark-read / delete actions. Mirrors
262+ // the standard Android selection-mode pattern; exits (clearing the selection) on the
263+ // back/close action or the system back gesture (BackHandler below).
264+ if (inSelection) {
265+ TopAppBar(
266+ title = { Text(pluralStringResource(R.plurals.selected_count, selectedIds.size, selectedIds.size)) },
267+ navigationIcon = {
268+ IconButton(onClick = { selectedIds = emptySet() }) {
269+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.cancel))
270+ }
271+ },
272+ actions = {
273+ val visibleSessions = state.filtered
274+ IconButton(onClick = {
275+ selectedIds = visibleSessions.map { it.id }.toSet()
276+ }) {
277+ Icon(Icons.Filled.DoneAll, contentDescription = stringResource(R.string.select_all))
278+ }
279+ IconButton(onClick = {
280+ vm.bulkMarkRead(selectedIds)
281+ selectedIds = emptySet()
282+ }) {
283+ Icon(Icons.Filled.MarkChatRead, contentDescription = stringResource(R.string.bulk_mark_read))
284+ }
285+ IconButton(onClick = {
286+ vm.bulkArchive(selectedIds, archive = true)
287+ selectedIds = emptySet()
288+ }) {
289+ Icon(Icons.Filled.Archive, contentDescription = stringResource(R.string.bulk_archive))
290+ }
291+ IconButton(onClick = { showBulkDelete = true }) {
292+ Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.bulk_delete))
293+ }
294+ },
295+ )
296+ } else {
242297 TopAppBar(
243298 title = {
244299 ServerSwitcherMenu(
@@ -318,30 +373,35 @@ fun SessionListScreen(
318373 }
319374 },
320375 )
376+ }
321377 },
322378 snackbarHost = { SnackbarHost(snackbar) },
323379 floatingActionButton = {
324− ExtendedFloatingActionButton(
325− onClick = openNewSession,
326− icon = {
327− if (creating) {
328− CircularProgressIndicator(
329− Modifier.size(24.dp),
330− strokeWidth = 2.dp,
331− color = MaterialTheme.colorScheme.onPrimaryContainer,
332− )
333− } else {
334− Icon(Icons.Filled.Add, contentDescription = null)
335− }
336− },
337− text = { Text(stringResource(R.string.new_session)) },
338− // Disable while a creation is in flight so a double-tap can't spawn two
339− // sessions. The container guard in createSession is the real protection;
340− // this is the visual signal.
341− expanded = !creating,
342− )
380+ if (!inSelection) {
381+ ExtendedFloatingActionButton(
382+ onClick = openNewSession,
383+ icon = {
384+ if (creating) {
385+ CircularProgressIndicator(
386+ Modifier.size(24.dp),
387+ strokeWidth = 2.dp,
388+ color = MaterialTheme.colorScheme.onPrimaryContainer,
389+ )
390+ } else {
391+ Icon(Icons.Filled.Add, contentDescription = null)
392+ }
393+ },
394+ text = { Text(stringResource(R.string.new_session)) },
395+ // Disable while a creation is in flight so a double-tap can't spawn two
396+ // sessions. The container guard in createSession is the real protection;
397+ // this is the visual signal.
398+ expanded = !creating,
399+ )
400+ }
343401 },
344402 ) { padding ->
403+ // Exit selection mode on the system back gesture instead of navigating away.
404+ androidx.activity.compose.BackHandler(enabled = inSelection) { selectedIds = emptySet() }
345405 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
346406 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
347407 ConnectionBanner(
@@ -364,6 +424,10 @@ fun SessionListScreen(
364424 onDelete = { pendingDeleteId = it },
365425 onPin = { vm.togglePin(it) },
366426 onArchive = { vm.toggleArchive(it) },
427+ onFilterByDirectory = vm::setDirectoryFilter,
428+ inSelection = inSelection,
429+ selectedIds = selectedIds,
430+ onToggleSelect = ::toggleSelected,
367431 )
368432 }
369433 }
@@ -408,6 +472,25 @@ fun SessionListScreen(
408472 )
409473 }
410474
475+ if (showBulkDelete) {
476+ val count = selectedIds.size
477+ AlertDialog(
478+ onDismissRequest = { showBulkDelete = false },
479+ title = { Text(stringResource(R.string.delete)) },
480+ text = { Text(pluralStringResource(R.plurals.selected_count, count, count)) },
481+ confirmButton = {
482+ TextButton(onClick = {
483+ showBulkDelete = false
484+ vm.bulkDelete(selectedIds)
485+ selectedIds = emptySet()
486+ }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
487+ },
488+ dismissButton = {
489+ TextButton(onClick = { showBulkDelete = false }) { Text(stringResource(R.string.cancel)) }
490+ },
491+ )
492+ }
493+
411494 if (showNewSessionDialog) {
412495 // Distinct session directories, most-recent first (state.sessions is sorted), so the
413496 // picker can offer directories the user has recently worked in even if the server's
@@ -447,6 +530,10 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
447530 onDelete: (String) -> Unit,
448531 onPin: (Session) -> Unit,
449532 onArchive: (Session) -> Unit,
533+ onFilterByDirectory: (String) -> Unit,
534+ inSelection: Boolean,
535+ selectedIds: Set<String>,
536+ onToggleSelect: (String) -> Unit,
450537) {
451538 when {
452539 state.loading -> {
@@ -657,7 +744,8 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
657744 session = session,
658745 preview = state.previews[session.id],
659746 unreadCount = unread[session.id] ?: 0,
660− isSelected = session.id == selectedSessionId,
747+ isSelected = if (inSelection) session.id in selectedIds
748+ else session.id == selectedSessionId,
661749 isPinned = isPinned,
662750 isArchived = isArchived,
663751 onClick = onCardClick,
@@ -665,6 +753,11 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
665753 onDelete = onCardDelete,
666754 onPin = onCardPin,
667755 onArchive = onCardArchive,
756+ onFilterByDirectory = onFilterByDirectory,
757+ isDirectoryFiltered = state.directoryFilter != null &&
758+ session.directory?.trimEnd('/') == state.directoryFilter,
759+ inSelection = inSelection,
760+ onToggleSelect = { onToggleSelect(session.id) },
668761 modifier = Modifier.testTag("session_card"),
669762 )
670763 }
@@ -800,7 +893,7 @@ private fun SessionTitle(text: String, unread: Boolean) {
800893 )
801894}
802895
803−@OptIn(ExperimentalMaterial3Api::class)
896+@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
804897@Composable
805898private fun SessionCard(
806899 session: Session,
@@ -815,10 +908,15 @@ private fun SessionCard(
815908 isSelected: Boolean = false,
816909 isPinned: Boolean = false,
817910 isArchived: Boolean = false,
911+ onFilterByDirectory: ((String) -> Unit)? = null,
912+ isDirectoryFiltered: Boolean = false,
913+ inSelection: Boolean = false,
914+ onToggleSelect: (() -> Unit)? = null,
818915) {
819916 // In two-pane mode the selected row is highlighted (border + container tint) so
820917 // the user can tell which conversation is open in the detail pane at a glance —
821− // without it every row looks identical and the open session is a guess.
918+ // without it every row looks identical and the open session is a guess. In multi-select
919+ // mode a checked row is tinted the same way.
822920 val containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer
823921 else MaterialTheme.colorScheme.surface
824922 val borderColor = if (isSelected) MaterialTheme.colorScheme.primary
@@ -832,7 +930,16 @@ private fun SessionCard(
832930 // Merge the row's text into one semantics node so TalkBack reads it as a single
833931 // item; the overflow menu button stays its own actionable node.
834932 .semantics(mergeDescendants = true) {}
835− .clickable(role = Role.Button) { onClick() },
933+ .combinedClickable(
934+ role = Role.Button,
935+ // In selection mode a tap toggles the row's checked state; otherwise it opens
936+ // the conversation. A long-press enters selection mode on that row.
937+ onClick = {
938+ if (inSelection && onToggleSelect != null) onToggleSelect() else onClick()
939+ },
940+ onLongClick = onToggleSelect?.let { { it() } },
941+ onLongClickLabel = onToggleSelect?.let { stringResource(R.string.select) },
942+ ),
836943 colors = androidx.compose.material3.CardDefaults.cardColors(containerColor = containerColor),
837944 border = androidx.compose.foundation.BorderStroke(1.dp, borderColor),
838945 ) {
@@ -841,11 +948,21 @@ private fun SessionCard(
841948 modifier = Modifier.fillMaxWidth(),
842949 verticalAlignment = Alignment.CenterVertically,
843950 ) {
951+ // In multi-select mode a checkbox leads the row so the checked state is obvious,
952+ // and the monogram is hidden (it competes for the leading slot).
953+ if (inSelection) {
954+ androidx.compose.material3.Checkbox(
955+ checked = isSelected,
956+ onCheckedChange = { onToggleSelect?.invoke() },
957+ )
958+ }
959+ if (!inSelection) {
844960 // Leading monogram avatar so a long list is scannable at a glance instead of a
845961 // wall of identical text rows. Color is derived (stably) from the session id so
846962 // the same session always gets the same tint, while staying within tonal theme
847963 // roles to match the Tokyo Night palette.
848964 SessionAvatar(session)
965+ }
849966 Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
850967 Row(verticalAlignment = Alignment.CenterVertically) {
851968 if (unreadCount > 0) {
@@ -928,22 +1045,40 @@ private fun SessionCard(
9281045 // otherwise-similar sessions apart at a glance.
9291046 session.displayDirectory?.let { dir ->
9301047 val dirDesc = stringResource(R.string.session_directory_desc, session.directory.orEmpty())
1048+ // Tapping the directory chip filters the list to this project (and taps
1049+ // again to clear), so a user working across sessions in one repo can scope
1050+ // the list without typing. Highlighted while the filter is active.
1051+ val filterLabel = stringResource(
1052+ if (isDirectoryFiltered) R.string.clear_project_filter else R.string.filter_by_project,
1053+ )
9311054 Row(
9321055 verticalAlignment = Alignment.CenterVertically,
9331056 modifier = Modifier
9341057 .padding(top = 2.dp)
1058+ .clip(MaterialTheme.shapes.small)
1059+ .then(
1060+ if (onFilterByDirectory != null) {
1061+ Modifier.clickable(role = Role.Button, onClickLabel = filterLabel) {
1062+ onFilterByDirectory(session.directory ?: dir)
1063+ }
1064+ } else {
1065+ Modifier
1066+ },
1067+ )
9351068 .semantics(mergeDescendants = true) { contentDescription = dirDesc },
9361069 ) {
9371070 Icon(
9381071 Icons.Filled.Folder,
9391072 contentDescription = null,
9401073 modifier = Modifier.size(12.dp),
941− tint = MaterialTheme.colorScheme.onSurfaceVariant,
1074+ tint = if (isDirectoryFiltered) MaterialTheme.colorScheme.primary
1075+ else MaterialTheme.colorScheme.onSurfaceVariant,
9421076 )
9431077 Text(
9441078 dir,
9451079 style = MaterialTheme.typography.labelSmall,
946− color = MaterialTheme.colorScheme.onSurfaceVariant,
1080+ color = if (isDirectoryFiltered) MaterialTheme.colorScheme.primary
1081+ else MaterialTheme.colorScheme.onSurfaceVariant,
9471082 maxLines = 1,
9481083 overflow = TextOverflow.Ellipsis,
9491084 modifier = Modifier.padding(start = 4.dp),
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+104−3
@@ -77,18 +77,24 @@ data class SessionListState(
7777 val pinnedIds: Set<String> = emptySet(),
7878 val archivedIds: Set<String> = emptySet(),
7979 val showArchived: Boolean = false,
80+ // When non-null, only sessions whose working directory matches are shown. Tapped into via
81+ // a session card's directory chip ("filter by this project"); null clears it.
82+ val directoryFilter: String? = null,
8083) {
8184 /** Sessions to display: archived ones hidden (unless [showArchived]), filtered by the
82− * search query (title or preview text), with pinned sessions floated to the top. */
85+ * search query (title or preview text) and the optional [directoryFilter], with pinned
86+ * sessions floated to the top. */
8387 val filtered: List<Session>
8488 get() {
8589 val q = query.trim()
8690 // Fast path: nothing to filter or reorder — return the same instance so
8791 // downstream can skip a recomposition.
8892 val noArchivedHidden = showArchived || archivedIds.isEmpty()
89− if (q.isEmpty() && pinnedIds.isEmpty() && noArchivedHidden) return sessions
93+ if (q.isEmpty() && pinnedIds.isEmpty() && noArchivedHidden && directoryFilter == null) return sessions
94+ val dirFilter = directoryFilter?.trimEnd('/')
9095 fun visible(session: Session): Boolean {
9196 if (!showArchived && session.id in archivedIds) return false
97+ if (dirFilter != null && session.directory?.trimEnd('/') != dirFilter) return false
9298 if (q.isEmpty()) return true
9399 return session.displayTitle.contains(q, ignoreCase = true) ||
94100 (previews[session.id]?.contains(q, ignoreCase = true) == true)
@@ -253,6 +259,35 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
253259 viewModelScope.launch {
254260 container.sessionPrefsStore.archived.collect { ids -> _state.update { it.copy(archivedIds = ids) } }
255261 }
262+ // Persisted list-display prefs (sort + show-archived) so a user's chosen ordering
263+ // survives process death, not just rotation. The initial seed restores them before
264+ // the first list flush; subsequent emissions keep the VM in sync if the store changes
265+ // elsewhere (it won't, but the collector is the canonical single-source-of-truth read).
266+ // settingsStore is best-effort (it may be unavailable in tests), so a missing store just
267+ // skips persistence — the in-memory defaults still drive the list.
268+ viewModelScope.launch {
269+ val store = runCatching { container.settingsStore }.getOrNull() ?: return@launch
270+ store.sessionSortMode.collect { name ->
271+ val mode = runCatching { SessionSortMode.valueOf(name) }.getOrDefault(SessionSortMode.RECENT)
272+ if (_state.value.sortMode != mode) setSortMode(mode, persist = false)
273+ }
274+ }
275+ viewModelScope.launch {
276+ val store = runCatching { container.settingsStore }.getOrNull() ?: return@launch
277+ store.sessionSortDescending.collect { desc ->
278+ if (_state.value.sortDescending != desc) {
279+ _state.update {
280+ it.copy(sortDescending = desc, sessions = it.sessions.sortedByMode(it.sortMode, desc))
281+ }
282+ }
283+ }
284+ }
285+ viewModelScope.launch {
286+ val store = runCatching { container.settingsStore }.getOrNull() ?: return@launch
287+ store.sessionShowArchived.collect { show ->
288+ if (_state.value.showArchived != show) _state.update { it.copy(showArchived = show) }
289+ }
290+ }
256291 }
257292
258293 /** Pin or unpin [session] (pinned sessions sort to the top of the list). */
@@ -305,6 +340,9 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
305340 /** Toggle whether archived sessions are shown in the list. */
306341 fun setShowArchived(show: Boolean) {
307342 _state.update { it.copy(showArchived = show) }
343+ viewModelScope.launch {
344+ runCatchingCancellable { container.settingsStore.setSessionShowArchived(show) }
345+ }
308346 }
309347
310348 /**
@@ -457,6 +495,16 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
457495 _state.update { it.copy(query = query) }
458496 }
459497
498+ /** Restrict the list to sessions in [directory] (null clears it). Toggled by a session
499+ * card's directory chip so the user can scope the list to one project. */
500+ fun setDirectoryFilter(directory: String?) {
501+ val normalized = directory?.takeIf { it.isNotBlank() }?.trimEnd('/')
502+ _state.update {
503+ val toggleOff = normalized != null && it.directoryFilter == normalized
504+ it.copy(directoryFilter = if (toggleOff) null else normalized)
505+ }
506+ }
507+
460508 /** Force the SSE stream to reconnect (recovery path for a Failed banner) and
461509 * re-fetch the session list so the UI reflects the current server state. */
462510 fun retryConnection() {
@@ -716,7 +764,48 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
716764 }
717765 }
718766
719− fun setSortMode(mode: SessionSortMode) {
767+ /** Bulk-archive (or unarchive) the given sessions. Quietly applies state + persists; the
768+ * caller surfaces a single confirmation (bulk actions batch, so per-item snackbars would
769+ * spam). Reversible by toggling "show archived" and un-archiving. */
770+ fun bulkArchive(ids: Set<String>, archive: Boolean) {
771+ ids.forEach { applyArchive(it, archive) }
772+ }
773+
774+ /** Mark all of the given sessions as read (clears their unread badge). */
775+ fun bulkMarkRead(ids: Set<String>) {
776+ ids.forEach { container.markSessionRead(it) }
777+ }
778+
779+ /**
780+ * Permanently delete the given sessions. Unlike a single swipe-delete this is NOT undoable
781+ * (the batch undo would need to cancel N deferred deletes); the caller confirms via a dialog
782+ * first, mirroring other destructive bulk actions. Each delete is scheduled on the
783+ * container's process-lived scope so it commits even if the user navigates away.
784+ */
785+ fun bulkDelete(ids: Set<String>) {
786+ val toDelete = _state.value.sessions.filter { it.id in ids }
787+ if (toDelete.isEmpty()) return
788+ _state.update { s -> s.copy(sessions = s.sessions.filterNot { it.id in ids }) }
789+ toDelete.forEach { session ->
790+ pendingDeletes[session.id] = session
791+ pendingSessionUpdates.remove(session.id)
792+ container.scheduleSessionDelete(
793+ id = session.id,
794+ delayMs = NetworkConfig.undoDeleteDelayMs,
795+ onDeleted = {
796+ viewModelScope.launch { pendingDeletes.remove(session.id); refresh() }
797+ },
798+ onError = { err ->
799+ viewModelScope.launch {
800+ pendingDeletes.remove(session.id)
801+ _transientErrors.tryEmit(container.friendlyError(err))
802+ }
803+ },
804+ )
805+ }
806+ }
807+
808+ fun setSortMode(mode: SessionSortMode, persist: Boolean = true) {
720809 if (mode == _state.value.sortMode) return
721810 // Reset to the new mode's natural direction (RECENT=desc, TITLE=asc) so switching
722811 // modes doesn't inherit a surprising reversed order from the previous mode.
@@ -724,6 +813,15 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
724813 _state.update { s ->
725814 s.copy(sortMode = mode, sortDescending = descending, sessions = s.sessions.sortedByMode(mode, descending))
726815 }
816+ if (persist) {
817+ viewModelScope.launch {
818+ runCatchingCancellable {
819+ val store = container.settingsStore
820+ store.setSessionSortMode(mode.name)
821+ store.setSessionSortDescending(descending)
822+ }
823+ }
824+ }
727825 }
728826
729827 /** Flip the ascending/descending direction of the current sort mode. */
@@ -732,6 +830,9 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
732830 val descending = !s.sortDescending
733831 s.copy(sortDescending = descending, sessions = s.sessions.sortedByMode(s.sortMode, descending))
734832 }
833+ viewModelScope.launch {
834+ runCatchingCancellable { container.settingsStore.setSessionSortDescending(_state.value.sortDescending) }
835+ }
735836 }
736837
737838 fun renameSession(session: Session, newTitle: String) {
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+29
@@ -21,8 +21,10 @@ import androidx.compose.material.icons.Icons
2121import androidx.compose.material.icons.filled.BugReport
2222import androidx.compose.material.icons.filled.Close
2323import androidx.compose.material.icons.filled.ContentCopy
24+import androidx.activity.compose.rememberLauncherForActivityResult
2425import androidx.compose.material.icons.filled.Delete
2526import androidx.compose.material.icons.filled.DeleteSweep
27+import androidx.compose.material.icons.filled.IosShare
2628import androidx.compose.material.icons.filled.MoreVert
2729import androidx.compose.material.icons.filled.Search
2830import androidx.compose.material.icons.filled.Share
@@ -117,6 +119,26 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
117119 val shareSubject = stringResource(R.string.crash_report_share_subject)
118120 val timeTick = rememberRelativeTimeTick()
119121
122+ // "Export all": let the user save every crash report into a single text file they pick via
123+ // SAF (CreateDocument). Concatenating avoids needing a zip dependency; each report is
124+ // delimited so a single file stays readable and shareable for support/debugging.
125+ val exportAllLauncher = rememberLauncherForActivityResult(
126+ androidx.activity.result.contract.ActivityResultContracts.CreateDocument("text/plain"),
127+ ) { uri ->
128+ if (uri == null) return@rememberLauncherForActivityResult
129+ shareScope.launch {
130+ val ok = runCatchingCancellable {
131+ withContext(Dispatchers.IO) {
132+ val all = reports.joinToString("\n\n${"=".repeat(60)}\n\n") { report ->
133+ "### ${report.fileName}\n\n" + logger.readReport(report.fileName).orEmpty()
134+ }
135+ context.contentResolver.openOutputStream(uri)?.use { it.write(all.toByteArray()) }
136+ }
137+ }.isSuccess
138+ if (ok) showToast(context, context.getString(R.string.export_all))
139+ }
140+ }
141+
120142 // Report-delete undo events, shown via a single collectLatest collector rather than a
121143 // per-confirm launch: a serialized showSnackbar would queue each report's undo behind the
122144 // previous one's full window. collectLatest instead cancels the current snackbar and shows
@@ -186,6 +208,13 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
186208 onBack = onBack,
187209 actions = {
188210 if (reports.isNotEmpty()) {
211+ IconButton(onClick = {
212+ runCatching {
213+ exportAllLauncher.launch("opencode-crash-reports.txt")
214+ }
215+ }) {
216+ Icon(Icons.Filled.IosShare, contentDescription = stringResource(R.string.export_all))
217+ }
189218 IconButton(onClick = {
190219 showClearAll = true
191220 }) {
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+29
@@ -28,8 +28,10 @@ import androidx.compose.material.icons.Icons
2828import androidx.compose.material.icons.automirrored.filled.ArrowBack
2929import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
3030import androidx.compose.material.icons.filled.BugReport
31+import androidx.compose.material.icons.filled.Code
3132import androidx.compose.material.icons.filled.Dns
3233import androidx.compose.material.icons.filled.Download
34+import androidx.compose.material.icons.filled.Feedback
3335import androidx.compose.material.icons.filled.Hub
3436import androidx.compose.material.icons.filled.Notifications
3537import androidx.compose.material.icons.filled.QueryStats
@@ -609,6 +611,33 @@ fun SettingsScreen(
609611 }
610612 Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
611613 }
614+ // Discoverability links from the About section: source code and issue tracker.
615+ // Opens in the browser via ACTION_VIEW; if nothing can handle it, the chooser just
616+ // shows no options (a no-op), which is acceptable for these optional links.
617+ NavRow(
618+ icon = Icons.Filled.Code,
619+ label = stringResource(R.string.source_code),
620+ enabled = true,
621+ onClick = {
622+ runCatching {
623+ context.startActivity(
624+ Intent(Intent.ACTION_VIEW, android.net.Uri.parse(context.getString(R.string.url_source_code))),
625+ )
626+ }
627+ },
628+ )
629+ NavRow(
630+ icon = Icons.Filled.Feedback,
631+ label = stringResource(R.string.report_issue),
632+ enabled = true,
633+ onClick = {
634+ runCatching {
635+ context.startActivity(
636+ Intent(Intent.ACTION_VIEW, android.net.Uri.parse(context.getString(R.string.url_report_issue))),
637+ )
638+ }
639+ },
640+ )
612641 }
613642 }
614643
app/src/main/res/drawable/ic_action_refresh.xmlnew file+9
@@ -0,0 +1,9 @@
1+<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+ android:width="24dp"
3+ android:height="24dp"
4+ android:viewportWidth="24"
5+ android:viewportHeight="24">
6+ <path
7+ android:fillColor="#FFFFFFFF"
8+ android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z" />
9+</vector>
app/src/main/res/values-es/strings.xml+52
@@ -262,6 +262,12 @@
262262 <string name="notif_channel_permission_desc">Se muestra cuando un agente en ejecución necesita tu aprobación para continuar.</string>
263263 <string name="notif_channel_error">Errores de sesión</string>
264264 <string name="notif_channel_error_desc">Se muestra cuando falla la ejecución de una sesión en segundo plano.</string>
265+ <string name="notif_channel_unread">Insignia de no leídos</string>
266+ <string name="notif_channel_unread_desc">Muestra silenciosamente en el icono del launcher el recuento de sesiones no leídas.</string>
267+ <plurals name="unread_sessions">
268+ <item quantity="one">%d sesión sin leer</item>
269+ <item quantity="other">%d sesiones sin leer</item>
270+ </plurals>
265271 <string name="notif_permission_title">Se necesita aprobación en «%1$s»</string>
266272 <string name="notif_permission_fallback">Una herramienta quiere ejecutarse.</string>
267273 <string name="notif_error_title">La ejecución falló</string>
@@ -571,6 +577,7 @@
571577 <string name="clip_label_diff">Diferencias</string>
572578 <string name="clip_label_reasoning">Razonamiento</string>
573579 <string name="clip_label_output">Salida</string>
580+ <string name="clip_label_input">Entrada</string>
574581 <string name="clip_label_path">Ruta</string>
575582 <string name="clip_label_message">Mensaje</string>
576583 <string name="clip_label_share">Enlace para compartir</string>
@@ -619,4 +626,49 @@
619626 <item quantity="one">%1$d minuto</item>
620627 <item quantity="other">%1$d minutos</item>
621628 </plurals>
629+
630+ <!-- UI/UX batch 3 -->
631+ <string name="queue">Encolar</string>
632+ <string name="tts_unavailable">La lectura en voz alta no está disponible</string>
633+ <string name="regenerate">Regenerar</string>
634+ <string name="find_in_conversation">Buscar en la conversación</string>
635+ <string name="search_in_conversation">Buscar en la conversación</string>
636+ <string name="search_match_count">%1$d de %2$d</string>
637+ <string name="keyboard_shortcuts">Atajos de teclado</string>
638+ <string name="keyboard_shortcuts_title">Atajos de teclado</string>
639+ <string name="shortcut_open_palette">Abrir paleta de comandos</string>
640+ <string name="shortcut_stop_run">Detener la ejecución activa</string>
641+ <string name="shortcut_send">Enviar mensaje</string>
642+ <string name="shortcut_newline">Salto de línea</string>
643+ <string name="shortcut_close">Cerrar diálogo / paleta</string>
644+ <string name="open_externally">Abrir externamente</string>
645+ <string name="open_externally_failed">No hay ninguna app para abrir este archivo</string>
646+ <string name="sort_by">Ordenar por</string>
647+ <string name="sort_size">Tamaño</string>
648+ <string name="sort_modified">Modificación</string>
649+ <string name="show_file_details">Mostrar detalles del archivo</string>
650+ <string name="export_all">Exportar todo</string>
651+ <string name="source_code">Código fuente</string>
652+ <string name="open_source_licenses">Licencias de código abierto</string>
653+ <string name="report_issue">Reportar un problema</string>
654+ <string name="privacy_policy">Privacidad</string>
655+ <string name="url_source_code" translatable="false">https://github.com/anomalyco/opencode-android</string>
656+ <string name="url_report_issue" translatable="false">https://github.com/anomalyco/opencode-android/issues</string>
657+ <string name="retry_last">Reintentar último</string>
658+ <string name="select_all">Seleccionar todo</string>
659+ <string name="select">Seleccionar</string>
660+ <plurals name="selected_count">
661+ <item quantity="one">%d seleccionado</item>
662+ <item quantity="other">%d seleccionados</item>
663+ </plurals>
664+ <string name="bulk_archive">Archivar</string>
665+ <string name="bulk_delete">Eliminar</string>
666+ <string name="bulk_mark_read">Marcar leído</string>
667+ <string name="deep_link_invalid">Enlace no admitido</string>
668+ <string name="file_details_size">%1$s</string>
669+ <string name="file_details_size_date">%1$s · %2$s</string>
670+ <string name="filter_by_project">Filtrar por este proyecto</string>
671+ <string name="clear_project_filter">Quitar filtro de proyecto</string>
672+ <string name="empty_file_detail_title">Ningún archivo seleccionado</string>
673+ <string name="empty_file_detail_desc">Elige un archivo de la lista para verlo aquí.</string>
622674</resources>
app/src/main/res/values/strings.xml+52
@@ -271,6 +271,12 @@
271271 <string name="notif_channel_permission_desc">Shown when a running agent needs your approval to continue.</string>
272272 <string name="notif_channel_error">Session errors</string>
273273 <string name="notif_channel_error_desc">Shown when a background session run fails.</string>
274+ <string name="notif_channel_unread">Unread badge</string>
275+ <string name="notif_channel_unread_desc">Silently badges the launcher icon with the unread session count.</string>
276+ <plurals name="unread_sessions">
277+ <item quantity="one">%d unread session</item>
278+ <item quantity="other">%d unread sessions</item>
279+ </plurals>
274280 <string name="notif_permission_title">Approval needed in “%1$s”</string>
275281 <string name="notif_permission_fallback">A tool wants to run.</string>
276282 <string name="notif_error_title">Run failed</string>
@@ -593,6 +599,7 @@
593599 <string name="clip_label_diff">Diff</string>
594600 <string name="clip_label_reasoning">Reasoning</string>
595601 <string name="clip_label_output">Output</string>
602+ <string name="clip_label_input">Input</string>
596603 <string name="clip_label_path">Path</string>
597604 <string name="clip_label_message">Message</string>
598605 <string name="clip_label_share">Share link</string>
@@ -619,4 +626,49 @@
619626 <string name="crash_last_run_dismiss">Dismiss</string>
620627 <string name="connecting_to">Connecting to %1$s…</string>
621628 <string name="scroll_to_top">Scroll to top</string>
629+
630+ <!-- UI/UX batch 3 -->
631+ <string name="queue">Queue</string>
632+ <string name="tts_unavailable">Text-to-speech is unavailable</string>
633+ <string name="regenerate">Regenerate</string>
634+ <string name="find_in_conversation">Find in conversation</string>
635+ <string name="search_in_conversation">Search in conversation</string>
636+ <string name="search_match_count">%1$d of %2$d</string>
637+ <string name="keyboard_shortcuts">Keyboard shortcuts</string>
638+ <string name="keyboard_shortcuts_title">Keyboard shortcuts</string>
639+ <string name="shortcut_open_palette">Open command palette</string>
640+ <string name="shortcut_stop_run">Stop the active run</string>
641+ <string name="shortcut_send">Send message</string>
642+ <string name="shortcut_newline">New line</string>
643+ <string name="shortcut_close">Close dialog / palette</string>
644+ <string name="open_externally">Open externally</string>
645+ <string name="open_externally_failed">No app available to open this file</string>
646+ <string name="sort_by">Sort by</string>
647+ <string name="sort_size">Size</string>
648+ <string name="sort_modified">Modified</string>
649+ <string name="show_file_details">Show file details</string>
650+ <string name="export_all">Export all</string>
651+ <string name="source_code">Source code</string>
652+ <string name="open_source_licenses">Open-source licenses</string>
653+ <string name="report_issue">Report an issue</string>
654+ <string name="privacy_policy">Privacy</string>
655+ <!-- Non-translatable external links. -->
656+ <string name="url_source_code" translatable="false">https://github.com/anomalyco/opencode-android</string>
657+ <string name="url_report_issue" translatable="false">https://github.com/anomalyco/opencode-android/issues</string>
658+ <string name="retry_last">Retry last</string>
659+ <string name="select_all">Select all</string>
660+ <string name="select">Select</string> <plurals name="selected_count">
661+ <item quantity="one">%d selected</item>
662+ <item quantity="other">%d selected</item>
663+ </plurals>
664+ <string name="bulk_archive">Archive</string>
665+ <string name="bulk_delete">Delete</string>
666+ <string name="bulk_mark_read">Mark read</string>
667+ <string name="deep_link_invalid">Unsupported link</string>
668+ <string name="file_details_size">%1$s</string>
669+ <string name="file_details_size_date">%1$s · %2$s</string>
670+ <string name="filter_by_project">Filter by this project</string>
671+ <string name="clear_project_filter">Clear project filter</string>
672+ <string name="empty_file_detail_title">No file selected</string>
673+ <string name="empty_file_detail_desc">Pick a file from the list to view it here.</string>
622674</resources>
app/src/main/res/xml/file_paths.xml+3
@@ -3,4 +3,7 @@
33 <!-- Camera captures are written to cache/captures and shared with the camera app via
44 the app's FileProvider (see AndroidManifest). -->
55 <cache-path name="captures" path="captures/" />
6+ <!-- "Open externally" writes a viewed file's content to cache/external and hands a
7+ content:// URI to another app via ACTION_VIEW (see FileViewScreen). -->
8+ <cache-path name="external" path="external/" />
69</paths>