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
ffa5e31iko
app/detekt-baseline.xml+13
@@ -2,13 +2,25 @@
2<SmellBaseline>
2<SmellBaseline>
3 <ManuallySuppressedIssues/>
3 <ManuallySuppressedIssues/>
4 <CurrentIssues>
4 <CurrentIssues>
5+ <ID>ComplexCondition:ChatScreen.kt$searchActive &amp;&amp; searchQuery.isNotEmpty() &amp;&amp; searchMessages.isEmpty() &amp;&amp; messages.isNotEmpty()</ID>
5 <ID>ComplexCondition:PartRenderer.kt$part.isImage &amp;&amp; imageContext != null &amp;&amp; (part.source != null || !part.url.isNullOrBlank())</ID>
6 <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>
6 <ID>CyclomaticComplexMethod:AppContainer.kt$AppContainer$@OptIn(ExperimentalCoroutinesApi::class) private fun observeMessageActivity()</ID>
8 <ID>CyclomaticComplexMethod:AppContainer.kt$AppContainer$@OptIn(ExperimentalCoroutinesApi::class) private fun observeMessageActivity()</ID>
7 <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>
9 <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>
8 <ID>CyclomaticComplexMethod:ChatViewModel.kt$ChatViewModel$private fun send(text: String, includeAttachments: Boolean, idempotencyKey: String? = null): Boolean</ID>
11 <ID>CyclomaticComplexMethod:ChatViewModel.kt$ChatViewModel$private fun send(text: String, includeAttachments: Boolean, idempotencyKey: String? = null): Boolean</ID>
9 <ID>CyclomaticComplexMethod:CodeHighlighter.kt$private fun findNumberEnd(line: String, start: Int): Int</ID>
12 <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>
10 <ID>CyclomaticComplexMethod:EventStreamClient.kt$EventStreamClient$private fun stream(): Flow&lt;BusEvent&gt;</ID>
15 <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>
11 <ID>CyclomaticComplexMethod:OpencodeApp.kt$@Composable fun OpencodeApp(container: AppContainer)</ID>
19 <ID>CyclomaticComplexMethod:OpencodeApp.kt$@Composable fun OpencodeApp(container: AppContainer)</ID>
20+ <ID>CyclomaticComplexMethod:ServerListScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ServerListScreen( container: AppContainer, onConnected: () -&gt; Unit, onAddProfile: () -&gt; Unit, onEditProfile: (String) -&gt; Unit, onDuplicateProfile: (String) -&gt; Unit, )</ID>
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>
12 <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>
24 <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>
13 <ID>LargeClass:AppContainer.kt$AppContainer</ID>
25 <ID>LargeClass:AppContainer.kt$AppContainer</ID>
14 <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
26 <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
@@ -17,5 +29,6 @@
17 <ID>TooManyFunctions:ChatViewModel.kt$ChatViewModel : ViewModel</ID>
29 <ID>TooManyFunctions:ChatViewModel.kt$ChatViewModel : ViewModel</ID>
18 <ID>TooManyFunctions:CrashLogger.kt$CrashLogger</ID>
30 <ID>TooManyFunctions:CrashLogger.kt$CrashLogger</ID>
19 <ID>TooManyFunctions:OpencodeApiClient.kt$OpencodeApiClient</ID>
31 <ID>TooManyFunctions:OpencodeApiClient.kt$OpencodeApiClient</ID>
32+ <ID>TooManyFunctions:SessionNotifications.kt$SessionNotifications</ID>
20 </CurrentIssues>
33 </CurrentIssues>
21</SmellBaseline>
34</SmellBaseline>
app/src/main/AndroidManifest.xml+13
@@ -58,6 +58,19 @@
58 <category android:name="android.intent.category.BROWSABLE" />
58 <category android:name="android.intent.category.BROWSABLE" />
59 <data android:scheme="opencode" android:host="session" />
59 <data android:scheme="opencode" android:host="session" />
60 </intent-filter>
60 </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>
61 </activity>
74 </activity>
62
75
63 <service
76 <service
app/src/main/java/soy/iko/opencode/MainActivity.kt+48−14
@@ -46,6 +46,7 @@ class MainActivity : FragmentActivity() {
46 private var shareIntentHandled = false
46 private var shareIntentHandled = false
47 private var openSessionHandled = false
47 private var openSessionHandled = false
48 private var newSessionHandled = false
48 private var newSessionHandled = false
49+ private var openFileHandled = false
49 // Cold-start prompts (crash report, notification rationale) rendered as Compose M3
50 // Cold-start prompts (crash report, notification rationale) rendered as Compose M3
50 // dialogs. Hoisted as Activity-level mutableState so the non-composable trigger logic
51 // dialogs. Hoisted as Activity-level mutableState so the non-composable trigger logic
51 // (onCreate/onResume) can flip them and the Compose tree (inside OpencodeTheme) reads
52 // (onCreate/onResume) can flip them and the Compose tree (inside OpencodeTheme) reads
@@ -72,6 +73,7 @@ class MainActivity : FragmentActivity() {
72 shareIntentHandled = it.getBoolean(KEY_SHARE_HANDLED, false)
73 shareIntentHandled = it.getBoolean(KEY_SHARE_HANDLED, false)
73 openSessionHandled = it.getBoolean(KEY_OPEN_SESSION_HANDLED, false)
74 openSessionHandled = it.getBoolean(KEY_OPEN_SESSION_HANDLED, false)
74 newSessionHandled = it.getBoolean(KEY_NEW_SESSION_HANDLED, false)
75 newSessionHandled = it.getBoolean(KEY_NEW_SESSION_HANDLED, false)
76+ openFileHandled = it.getBoolean(KEY_OPEN_FILE_HANDLED, false)
75 }
77 }
76 val container = (application as OpencodeApp).container
78 val container = (application as OpencodeApp).container
77 handleIntent(intent)
79 handleIntent(intent)
@@ -183,6 +185,7 @@ class MainActivity : FragmentActivity() {
183 shareIntentHandled = false
185 shareIntentHandled = false
184 openSessionHandled = false
186 openSessionHandled = false
185 newSessionHandled = false
187 newSessionHandled = false
188+ openFileHandled = false
186 handleIntent(intent)
189 handleIntent(intent)
187 }
190 }
188
191
@@ -247,21 +250,45 @@ class MainActivity : FragmentActivity() {
247 }
250 }
248 }
251 }
249 }
252 }
250− // Deep link: opencode://session/{sessionId} (or the EXTRA_SESSION_ID extra).
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).
251− val deepLinkId = intent?.takeIf { it.action == Intent.ACTION_VIEW }
259+ val data = intent?.takeIf { it.action == Intent.ACTION_VIEW }?.data
252− ?.data
260+ when (data?.host) {
253− ?.takeIf { it.host == "session" }
261+ "session" -> {
254− ?.lastPathSegment
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)
255− val id = deepLinkId ?: intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() }
266+ ?.takeIf { it.isNotBlank() }
256− // Validate: session ids are opaque server-generated identifiers. Reject anything
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
257− // with path separators or other traversal/control characters so a malicious deep
274+ // slashes. Decoded + validated against traversal/control characters.
258− // link can't inject path components into the REST URL path.
275+ val rawPath = data.path?.removePrefix("/file/")?.let { android.net.Uri.decode(it) }
259− // Guard against re-firing the retained ACTION_VIEW intent after process-death
276+ if (!openFileHandled) {
260− // restore: onCreate calls handleIntent again with the original intent, so without
277+ rawPath?.takeIf { it.isNotBlank() && VALID_FILE_PATH.containsMatchIn(it) && !it.contains("..") }
261− // this flag (mirroring shareIntentHandled) the session would re-open unexpectedly.
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.
262 if (!openSessionHandled) {
289 if (!openSessionHandled) {
263− id?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
290+ intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
264− val profileId = intent?.getStringExtra(NotificationActionReceiver.EXTRA_PROFILE_ID)
291+ val profileId = intent.getStringExtra(NotificationActionReceiver.EXTRA_PROFILE_ID)
265 ?.takeIf { it.isNotBlank() }
292 ?.takeIf { it.isNotBlank() }
266 container.requestOpenSession(it, profileId)
293 container.requestOpenSession(it, profileId)
267 openSessionHandled = true
294 openSessionHandled = true
@@ -279,9 +306,14 @@ class MainActivity : FragmentActivity() {
279 /** Session ids are alphanumeric with dashes/underscores. Reject path traversal. */
306 /** Session ids are alphanumeric with dashes/underscores. Reject path traversal. */
280 private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
307 private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
281
308
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+
282 private const val KEY_SHARE_HANDLED = "shareIntentHandled"
313 private const val KEY_SHARE_HANDLED = "shareIntentHandled"
283 private const val KEY_OPEN_SESSION_HANDLED = "openSessionHandled"
314 private const val KEY_OPEN_SESSION_HANDLED = "openSessionHandled"
284 private const val KEY_NEW_SESSION_HANDLED = "newSessionHandled"
315 private const val KEY_NEW_SESSION_HANDLED = "newSessionHandled"
316+ private const val KEY_OPEN_FILE_HANDLED = "openFileHandled"
285 private const val KEY_NOTIF_PERM_REQUESTED = "notificationPermissionRequested"
317 private const val KEY_NOTIF_PERM_REQUESTED = "notificationPermissionRequested"
286 }
318 }
287
319
@@ -299,9 +331,11 @@ class MainActivity : FragmentActivity() {
299 val openConsumed = container.pendingOpenSession.value == null
331 val openConsumed = container.pendingOpenSession.value == null
300 val newConsumed = container.pendingNewSession.value == 0
332 val newConsumed = container.pendingNewSession.value == 0
301 val shareConsumed = container.pendingShare.value == null && container.pendingSharedMedia.value.isEmpty()
333 val shareConsumed = container.pendingShare.value == null && container.pendingSharedMedia.value.isEmpty()
334+ val fileConsumed = container.pendingOpenFile.value == null
302 outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled && shareConsumed)
335 outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled && shareConsumed)
303 outState.putBoolean(KEY_OPEN_SESSION_HANDLED, openSessionHandled && openConsumed)
336 outState.putBoolean(KEY_OPEN_SESSION_HANDLED, openSessionHandled && openConsumed)
304 outState.putBoolean(KEY_NEW_SESSION_HANDLED, newSessionHandled && newConsumed)
337 outState.putBoolean(KEY_NEW_SESSION_HANDLED, newSessionHandled && newConsumed)
338+ outState.putBoolean(KEY_OPEN_FILE_HANDLED, openFileHandled && fileConsumed)
305 outState.putBoolean(KEY_NOTIF_PERM_REQUESTED, notificationPermissionRequested)
339 outState.putBoolean(KEY_NOTIF_PERM_REQUESTED, notificationPermissionRequested)
306 }
340 }
307
341
app/src/main/java/soy/iko/opencode/data/model/Files.kt+6
@@ -10,6 +10,12 @@ data class FileNode(
10 val name: String = "",
10 val name: String = "",
11 val path: String = "",
11 val path: String = "",
12 val type: String? = null,
12 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,
13) {
19) {
14 val isDirectory: Boolean get() = type == "directory"
20 val isDirectory: Boolean get() = type == "directory"
15}
21}
app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.kt+34−2
@@ -25,6 +25,9 @@ class SettingsStore(context: Context) {
25 private val appLockReLockSecondsKey = intPreferencesKey("app_lock_relock_seconds")
25 private val appLockReLockSecondsKey = intPreferencesKey("app_lock_relock_seconds")
26 private val chatTextScaleKey = floatPreferencesKey("chat_text_scale")
26 private val chatTextScaleKey = floatPreferencesKey("chat_text_scale")
27 private val codeWrapKey = booleanPreferencesKey("code_wrap")
27 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")
28
31
29 companion object {
32 companion object {
30 /** Bounds for the chat text-size multiplier so the UI can't be scaled into
33 /** Bounds for the chat text-size multiplier so the UI can't be scaled into
@@ -86,6 +89,22 @@ class SettingsStore(context: Context) {
86 prefs[codeWrapKey] ?: false
89 prefs[codeWrapKey] ?: false
87 }
90 }
88
91
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+
89 suspend fun setThemeMode(mode: ThemeMode) {
108 suspend fun setThemeMode(mode: ThemeMode) {
90 appContext.settingsDataStore.edit { it[themeKey] = mode.name }
109 appContext.settingsDataStore.edit { it[themeKey] = mode.name }
91 }
110 }
@@ -113,8 +132,21 @@ class SettingsStore(context: Context) {
113 it[chatTextScaleKey] = scale.coerceIn(MIN_CHAT_TEXT_SCALE, MAX_CHAT_TEXT_SCALE)
132 it[chatTextScaleKey] = scale.coerceIn(MIN_CHAT_TEXT_SCALE, MAX_CHAT_TEXT_SCALE)
114 }
133 }
115 }
134 }
116−
117 suspend fun setCodeWrap(enabled: Boolean) {
135 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 }
119 }
151 }
120}
152}
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+52
@@ -205,6 +205,19 @@ open class AppContainer private constructor(
205 return if (_pendingOpenSession.compareAndSet(current, null)) current else null
205 return if (_pendingOpenSession.compareAndSet(current, null)) current else null
206 }
206 }
207
207
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+
208 /**
221 /**
209 * The session the user is currently viewing (or null when not in a chat). Drives the
222 * The session the user is currently viewing (or null when not in a chat). Drives the
210 * unread tracker: messages arriving for any *other* session mark it unread, and
223 * unread tracker: messages arriving for any *other* session mark it unread, and
@@ -911,6 +924,45 @@ open class AppContainer private constructor(
911 }
924 }
912 }
925 }
913
926
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+
914 /** Extract the session id an event pertains to, for message-activity events. */
966 /** Extract the session id an event pertains to, for message-activity events. */
915 private fun sessionOf(event: BusEvent): String? = sessionOfEvent(event)
967 private fun sessionOf(event: BusEvent): String? = sessionOfEvent(event)
916
968
app/src/main/java/soy/iko/opencode/notification/NotificationActionReceiver.kt+21
@@ -122,6 +122,26 @@ class NotificationActionReceiver : BroadcastReceiver() {
122 finishOnce()
122 finishOnce()
123 }
123 }
124 }
124 }
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+ }
125 }
145 }
126 }
146 }
127
147
@@ -145,6 +165,7 @@ class NotificationActionReceiver : BroadcastReceiver() {
145 const val ACTION_REPLY = "soy.iko.opencode.action.REPLY"
165 const val ACTION_REPLY = "soy.iko.opencode.action.REPLY"
146 const val ACTION_STOP_RUN = "soy.iko.opencode.action.STOP_RUN"
166 const val ACTION_STOP_RUN = "soy.iko.opencode.action.STOP_RUN"
147 const val ACTION_MARK_READ = "soy.iko.opencode.action.MARK_READ"
167 const val ACTION_MARK_READ = "soy.iko.opencode.action.MARK_READ"
168+ const val ACTION_RETRY_LAST = "soy.iko.opencode.action.RETRY_LAST"
148 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
169 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
149 const val EXTRA_PERMISSION_ID = "soy.iko.opencode.extra.PERMISSION_ID"
170 const val EXTRA_PERMISSION_ID = "soy.iko.opencode.extra.PERMISSION_ID"
150 const val EXTRA_PROFILE_ID = "soy.iko.opencode.extra.PROFILE_ID"
171 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 {
22 const val COMPLETED = "session_completed"
22 const val COMPLETED = "session_completed"
23 const val PERMISSION = "permission_request"
23 const val PERMISSION = "permission_request"
24 const val ERROR = "session_error"
24 const val ERROR = "session_error"
25+ const val UNREAD = "unread_badge"
25
26
26 fun create(context: Context) {
27 fun create(context: Context) {
27 val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return
28 val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return
@@ -48,5 +49,17 @@ object NotificationChannels {
48 description = context.getString(R.string.notif_channel_error_desc)
49 description = context.getString(R.string.notif_channel_error_desc)
49 },
50 },
50 )
51 )
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+ )
51 }
64 }
52}
65}
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+3
@@ -86,6 +86,9 @@ class RunForegroundService : Service() {
86 .setShowWhen(true)
86 .setShowWhen(true)
87 .setPriority(NotificationCompat.PRIORITY_LOW)
87 .setPriority(NotificationCompat.PRIORITY_LOW)
88 .setCategory(NotificationCompat.CATEGORY_PROGRESS)
88 .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)
89 // Add a Stop action so the user can cancel the run without opening the app — the
92 // Add a Stop action so the user can cancel the run without opening the app — the
90 // core of the "kick it off and walk away" flow. Only when a specific session is
93 // core of the "kick it off and walk away" flow. Only when a specific session is
91 // known (multiple concurrent runs can't be targeted individually).
94 // 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 {
35
35
36 private const val NOTIF_ID_PREFIX = 4000
36 private const val NOTIF_ID_PREFIX = 4000
37 private const val TAG = "SessionNotifications"
37 private const val TAG = "SessionNotifications"
38+ private const val UNREAD_BADGE_ID = NOTIF_ID_PREFIX + 9999
38
39
39 // Distinct namespaces so a session can have a completion, a permission, and an error
40 // Distinct namespaces so a session can have a completion, a permission, and an error
40 // notification outstanding at once without their ids colliding.
41 // notification outstanding at once without their ids colliding.
@@ -253,7 +254,7 @@ object SessionNotifications {
253 fun postError(context: Context, sessionId: String, title: String, profileId: String? = null) {
254 fun postError(context: Context, sessionId: String, title: String, profileId: String? = null) {
254 if (!canPost(context)) return
255 if (!canPost(context)) return
255 val notifId = notifId(NS_ERROR, sessionId)
256 val notifId = notifId(NS_ERROR, sessionId)
256− val notification = NotificationCompat.Builder(context, NotificationChannels.ERROR)
257+ val builder = NotificationCompat.Builder(context, NotificationChannels.ERROR)
257 .setSmallIcon(R.drawable.ic_stat_notify)
258 .setSmallIcon(R.drawable.ic_stat_notify)
258 .setContentTitle(context.getString(R.string.notif_error_title))
259 .setContentTitle(context.getString(R.string.notif_error_title))
259 .setContentText(context.getString(R.string.notif_error_text, title))
260 .setContentText(context.getString(R.string.notif_error_text, title))
@@ -271,7 +272,19 @@ object SessionNotifications {
271 .setContentTitle(context.getString(R.string.notif_error_public))
272 .setContentTitle(context.getString(R.string.notif_error_public))
272 .build(),
273 .build(),
273 )
274 )
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()
275 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
288 runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
276 .onFailure { Log.w(TAG, "Failed to post error notification", it) }
289 .onFailure { Log.w(TAG, "Failed to post error notification", it) }
277 runCatching { postGroupSummary(context, NotificationChannels.ERROR, GROUP_ERROR, SUMMARY_ERROR_ID) }
290 runCatching { postGroupSummary(context, NotificationChannels.ERROR, GROUP_ERROR, SUMMARY_ERROR_ID) }
@@ -332,6 +345,35 @@ object SessionNotifications {
332 maybeCancelSummary(context, GROUP_COMPLETED, SUMMARY_COMPLETED_ID)
345 maybeCancelSummary(context, GROUP_COMPLETED, SUMMARY_COMPLETED_ID)
333 }
346 }
334
347
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+
335 /** Cancel a session's permission notification (on reply, or when the user opens it). */
377 /** Cancel a session's permission notification (on reply, or when the user opens it). */
336 fun cancelPermission(context: Context, sessionId: String) {
378 fun cancelPermission(context: Context, sessionId: String) {
337 NotificationManagerCompat.from(context).cancel(notifId(NS_PERMISSION, sessionId))
379 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
38import soy.iko.opencode.ui.chat.ChatScreen
38import soy.iko.opencode.ui.chat.ChatScreen
39import soy.iko.opencode.ui.file.FileBrowserScreen
39import soy.iko.opencode.ui.file.FileBrowserScreen
40import soy.iko.opencode.ui.file.FileViewScreen
40import soy.iko.opencode.ui.file.FileViewScreen
41+import soy.iko.opencode.ui.file.TwoPaneFileBrowser
41import soy.iko.opencode.ui.server.ServerEditScreen
42import soy.iko.opencode.ui.server.ServerEditScreen
42import soy.iko.opencode.ui.server.ServerListScreen
43import soy.iko.opencode.ui.server.ServerListScreen
43import soy.iko.opencode.ui.search.GlobalSearchScreen
44import soy.iko.opencode.ui.search.GlobalSearchScreen
@@ -62,6 +63,7 @@ fun OpencodeApp(container: AppContainer) {
62 val pendingSharedMedia by container.pendingSharedMedia.collectAsStateWithLifecycle()
63 val pendingSharedMedia by container.pendingSharedMedia.collectAsStateWithLifecycle()
63 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
64 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
64 val pendingNewSession by container.pendingNewSession.collectAsStateWithLifecycle()
65 val pendingNewSession by container.pendingNewSession.collectAsStateWithLifecycle()
66+ val pendingOpenFile by container.pendingOpenFile.collectAsStateWithLifecycle()
65 val pendingDiagnostics by container.pendingDiagnostics.collectAsStateWithLifecycle()
67 val pendingDiagnostics by container.pendingDiagnostics.collectAsStateWithLifecycle()
66 val connection by container.activeConnection.collectAsStateWithLifecycle()
68 val connection by container.activeConnection.collectAsStateWithLifecycle()
67 // Chat presentation preferences, provided to the whole nav graph so the markdown/code
69 // Chat presentation preferences, provided to the whole nav graph so the markdown/code
@@ -215,6 +217,19 @@ fun OpencodeApp(container: AppContainer) {
215 navController.navigate(Routes.chat(pending.sessionId)) { launchSingleTop = true }
217 navController.navigate(Routes.chat(pending.sessionId)) { launchSingleTop = true }
216 }
218 }
217
219
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+
218 val navHost = @Composable {
233 val navHost = @Composable {
219 NavHost(
234 NavHost(
220 modifier = Modifier.fillMaxSize(),
235 modifier = Modifier.fillMaxSize(),
@@ -348,11 +363,18 @@ fun OpencodeApp(container: AppContainer) {
348 }
363 }
349
364
350 composable(Routes.FILES) {
365 composable(Routes.FILES) {
366+ if (isTwoPane) {
367+ TwoPaneFileBrowser(
368+ container = container,
369+ onBack = { navController.popBackStack() },
370+ )
371+ } else {
351− FileBrowserScreen(
372+ FileBrowserScreen(
352− container = container,
373+ container = container,
353− onOpenFile = { path, line -> navController.navigate(Routes.fileView(path, line)) },
374+ onOpenFile = { path, line -> navController.navigate(Routes.fileView(path, line)) },
354− onBack = { navController.popBackStack() },
375+ onBack = { navController.popBackStack() },
355− )
376+ )
377+ }
356 }
378 }
357
379
358 composable(
380 composable(
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+236−23
@@ -18,6 +18,7 @@ import androidx.compose.animation.core.rememberInfiniteTransition
18import androidx.compose.animation.core.tween
18import androidx.compose.animation.core.tween
19import androidx.compose.foundation.background
19import androidx.compose.foundation.background
20import androidx.compose.foundation.clickable
20import androidx.compose.foundation.clickable
21+import androidx.compose.foundation.content.contentReceiver
21import androidx.compose.foundation.layout.Arrangement
22import androidx.compose.foundation.layout.Arrangement
22import androidx.compose.foundation.layout.Box
23import androidx.compose.foundation.layout.Box
23import androidx.compose.foundation.layout.Column
24import androidx.compose.foundation.layout.Column
@@ -48,8 +49,10 @@ import androidx.compose.material.icons.filled.Add
48import androidx.compose.material.icons.filled.AutoAwesome
49import androidx.compose.material.icons.filled.AutoAwesome
49import androidx.compose.material.icons.filled.Delete
50import androidx.compose.material.icons.filled.Delete
50import androidx.compose.material.icons.filled.Description
51import androidx.compose.material.icons.filled.Description
52+import androidx.compose.material.icons.filled.Close
51import androidx.compose.material.icons.filled.Edit
53import androidx.compose.material.icons.filled.Edit
52import androidx.compose.material.icons.filled.Expand
54import androidx.compose.material.icons.filled.Expand
55+import androidx.compose.material.icons.filled.Search
53import androidx.compose.material.icons.filled.KeyboardArrowDown
56import androidx.compose.material.icons.filled.KeyboardArrowDown
54import androidx.compose.material.icons.filled.KeyboardArrowUp
57import androidx.compose.material.icons.filled.KeyboardArrowUp
55import androidx.compose.material.icons.filled.Mic
58import androidx.compose.material.icons.filled.Mic
@@ -262,6 +265,9 @@ fun ChatScreen(
262 var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
265 var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
263 var showShellDialog by rememberSaveable { mutableStateOf(false) }
266 var showShellDialog by rememberSaveable { mutableStateOf(false) }
264 var showPalette by rememberSaveable { mutableStateOf(false) }
267 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("") }
265 // Summarize and init (generate AGENTS.md) are irreversible, so a single tap is gated behind
271 // Summarize and init (generate AGENTS.md) are irreversible, so a single tap is gated behind
266 // a confirmation dialog (from both the overflow menu and the command palette).
272 // a confirmation dialog (from both the overflow menu and the command palette).
267 var showSummarizeConfirm by rememberSaveable { mutableStateOf(false) }
273 var showSummarizeConfirm by rememberSaveable { mutableStateOf(false) }
@@ -347,6 +353,7 @@ fun ChatScreen(
347 val noVoiceMsg = stringResource(R.string.no_voice_app)
353 val noVoiceMsg = stringResource(R.string.no_voice_app)
348 val voicePrompt = stringResource(R.string.voice_prompt)
354 val voicePrompt = stringResource(R.string.voice_prompt)
349 val linkCopiedMsg = stringResource(R.string.link_copied)
355 val linkCopiedMsg = stringResource(R.string.link_copied)
356+ val ttsUnavailableMsg = stringResource(R.string.tts_unavailable)
350
357
351 // Count of picks currently being read + base64-encoded off the main thread, so the
358 // Count of picks currently being read + base64-encoded off the main thread, so the
352 // composer can show a staging placeholder immediately (chips only materialize once done).
359 // composer can show a staging placeholder immediately (chips only materialize once done).
@@ -643,6 +650,18 @@ fun ChatScreen(
643 enabled = !commandsLoading || commands.isNotEmpty(),
650 enabled = !commandsLoading || commands.isNotEmpty(),
644 onClick = { showOverflowMenu = false; showCommandPicker = true },
651 onClick = { showOverflowMenu = false; showCommandPicker = true },
645 )
652 )
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+ )
646 androidx.compose.material3.HorizontalDivider()
665 androidx.compose.material3.HorizontalDivider()
647 // Session sharing: create a public link, copy an existing one, or revoke it.
666 // Session sharing: create a public link, copy an existing one, or revoke it.
648 if (shareUrl == null) {
667 if (shareUrl == null) {
@@ -767,6 +786,7 @@ fun ChatScreen(
767 onPickFile = { filePicker.launch("*/*") },
786 onPickFile = { filePicker.launch("*/*") },
768 onCamera = { launchCamera() },
787 onCamera = { launchCamera() },
769 onVoice = { launchVoice() },
788 onVoice = { launchVoice() },
789+ onPasteImage = { stageUris(it) },
770 )
790 )
771 }
791 }
772 },
792 },
@@ -795,8 +815,17 @@ fun ChatScreen(
795 // raw messages list, so scroll targets must be in listItems space).
815 // raw messages list, so scroll targets must be in listItems space).
796 val todayLabel = stringResource(R.string.today)
816 val todayLabel = stringResource(R.string.today)
797 val yesterdayLabel = stringResource(R.string.yesterday)
817 val yesterdayLabel = stringResource(R.string.yesterday)
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+ }
798− val listItems = remember(messages, todayLabel, yesterdayLabel) {
827+ val listItems = remember(searchMessages, todayLabel, yesterdayLabel) {
799− buildMessageListItems(messages, todayLabel, yesterdayLabel)
828+ buildMessageListItems(searchMessages, todayLabel, yesterdayLabel)
800 }
829 }
801 // listItems is a plain (non-snapshot) local, rebuilt each recomposition. The
830 // listItems is a plain (non-snapshot) local, rebuilt each recomposition. The
802 // derivedStateOf and snapshotFlow lambdas below are created once (keyless
831 // derivedStateOf and snapshotFlow lambdas below are created once (keyless
@@ -808,9 +837,10 @@ fun ChatScreen(
808 // whose value tracks the latest list.
837 // whose value tracks the latest list.
809 val currentListItems by rememberUpdatedState(listItems)
838 val currentListItems by rememberUpdatedState(listItems)
810
839
811− // New-content signal for the jump-to-latest FAB: set when content grows while the user
840+ // New-content signal for the jump-to-latest FAB: counts how many new items arrived
812− // is scrolled away from the bottom, cleared once they return (see the collector below).
841+ // while the user was scrolled away from the bottom, cleared once they return. A count
813− var hasNewContent by remember { mutableStateOf(false) }
842+ // (vs. a bare dot) tells the user how much they missed at a glance.
843+ var newContentCount by remember { mutableIntStateOf(0) }
814
844
815 val isPinnedToBottom by remember {
845 val isPinnedToBottom by remember {
816 derivedStateOf {
846 derivedStateOf {
@@ -873,7 +903,7 @@ fun ChatScreen(
873 }.collect { signal ->
903 }.collect { signal ->
874 if (signal.pinned) {
904 if (signal.pinned) {
875 // Back at the bottom: clear the new-content badge.
905 // Back at the bottom: clear the new-content badge.
876− hasNewContent = false
906+ newContentCount = 0
877 if (signal.size > 0) {
907 if (signal.size > 0) {
878 // Scroll to the effective last index, including the trailing
908 // Scroll to the effective last index, including the trailing
879 // "__typing" row when a run is active so the working indicator
909 // "__typing" row when a run is active so the working indicator
@@ -883,8 +913,11 @@ fun ChatScreen(
883 listState.scrollToItem(target)
913 listState.scrollToItem(target)
884 }
914 }
885 } else if (signal.size > prevSize || signal.lastTextLength > prevLen) {
915 } else if (signal.size > prevSize || signal.lastTextLength > prevLen) {
886− // Content arrived while scrolled up — badge the jump-to-latest FAB.
916+ // Content arrived while scrolled up — increment the jump-to-latest badge.
887− hasNewContent = true
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
888 }
921 }
889 prevSize = signal.size
922 prevSize = signal.size
890 prevLen = signal.lastTextLength
923 prevLen = signal.lastTextLength
@@ -1159,13 +1192,26 @@ fun ChatScreen(
1159 }
1192 }
1160 }
1193 }
1161 },
1194 },
1195+ onSpeak = { text ->
1162− onSpeak = { text -> tts.toggle(message.info.id, 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+ },
1163 isSpeaking = message.info.id == speakingMessageId,
1204 isSpeaking = message.info.id == speakingMessageId,
1164 onQuote = { text ->
1205 onQuote = { text ->
1165 vm.quoteReply(text)
1206 vm.quoteReply(text)
1166 runCatching { inputFocusRequester.requestFocus() }
1207 runCatching { inputFocusRequester.requestFocus() }
1167 },
1208 },
1168 onBranch = { text -> vm.branchFrom(text) },
1209 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,
1169 sendStatus = optimisticStatuses[message.info.id]?.let { failed ->
1215 sendStatus = optimisticStatuses[message.info.id]?.let { failed ->
1170 if (failed) soy.iko.opencode.ui.chat.MessageSendStatus.FAILED
1216 if (failed) soy.iko.opencode.ui.chat.MessageSendStatus.FAILED
1171 else soy.iko.opencode.ui.chat.MessageSendStatus.SENDING
1217 else soy.iko.opencode.ui.chat.MessageSendStatus.SENDING
@@ -1234,6 +1280,17 @@ fun ChatScreen(
1234 color = MaterialTheme.colorScheme.onSurfaceVariant,
1280 color = MaterialTheme.colorScheme.onSurfaceVariant,
1235 modifier = Modifier.padding(start = 4.dp),
1281 modifier = Modifier.padding(start = 4.dp),
1236 )
1282 )
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+ }
1237 }
1294 }
1238 }
1295 }
1239 }
1296 }
@@ -1252,16 +1309,18 @@ fun ChatScreen(
1252 // they know there's something new to jump to (cleared once back at bottom).
1309 // they know there's something new to jump to (cleared once back at bottom).
1253 BadgedBox(
1310 BadgedBox(
1254 badge = {
1311 badge = {
1255− if (hasNewContent) {
1312+ if (newContentCount > 0) {
1256 val newContentLabel = stringResource(R.string.new_messages)
1313 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+ }
1258 }
1317 }
1259 },
1318 },
1260 modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
1319 modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
1261 ) {
1320 ) {
1262 ExtendedFloatingActionButton(
1321 ExtendedFloatingActionButton(
1263 onClick = {
1322 onClick = {
1264− hasNewContent = false
1323+ newContentCount = 0
1265 if (listItems.isNotEmpty()) {
1324 if (listItems.isNotEmpty()) {
1266 // Scroll to the effective last index, including the
1325 // Scroll to the effective last index, including the
1267 // trailing "__typing" row when a run is active.
1326 // trailing "__typing" row when a run is active.
@@ -1300,6 +1359,61 @@ fun ChatScreen(
1300 )
1359 )
1301 }
1360 }
1302 }
1361 }
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+ }
1303 }
1417 }
1304 }
1418 }
1305 }
1419 }
@@ -1462,8 +1576,48 @@ fun ChatScreen(
1462 if (showPalette) {
1576 if (showPalette) {
1463 CommandPalette(actions = paletteActions, onDismiss = { showPalette = false })
1577 CommandPalette(actions = paletteActions, onDismiss = { showPalette = false })
1464 }
1578 }
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+ }
1465}
1601}
1466
1602
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)
1467@Composable
1621@Composable
1468private fun ChatInputBar(
1622private fun ChatInputBar(
1469 value: String,
1623 value: String,
@@ -1485,6 +1639,7 @@ private fun ChatInputBar(
1485 onPickFile: () -> Unit,
1639 onPickFile: () -> Unit,
1486 onCamera: () -> Unit,
1640 onCamera: () -> Unit,
1487 onVoice: () -> Unit,
1641 onVoice: () -> Unit,
1642+ onPasteImage: (List<Uri>) -> Unit,
1488) {
1643) {
1489 // Sendable when there's text OR at least one attachment (an image-only prompt is valid).
1644 // Sendable when there's text OR at least one attachment (an image-only prompt is valid).
1490 val hasContent = value.isNotBlank() || attachments.isNotEmpty()
1645 val hasContent = value.isNotBlank() || attachments.isNotEmpty()
@@ -1495,13 +1650,25 @@ private fun ChatInputBar(
1495 // A queued follow-up replaces the Stop button with a "queued" chip so the
1650 // A queued follow-up replaces the Stop button with a "queued" chip so the
1496 // user sees their message will be sent when the run finishes, and can cancel.
1651 // user sees their message will be sent when the run finishes, and can cancel.
1497 if (queuedFollowUp != null) {
1652 if (queuedFollowUp != null) {
1653+ val editLabel = stringResource(R.string.edit)
1498 Row(
1654 Row(
1499 modifier = Modifier
1655 modifier = Modifier
1500 .fillMaxWidth()
1656 .fillMaxWidth()
1501 .padding(start = 12.dp, top = 6.dp, end = 4.dp),
1657 .padding(start = 12.dp, top = 6.dp, end = 4.dp),
1502 verticalAlignment = Alignment.CenterVertically,
1658 verticalAlignment = Alignment.CenterVertically,
1503 ) {
1659 ) {
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(
1504− Column(modifier = Modifier.weight(1f)) {
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+ ) {
1505 Text(
1672 Text(
1506 stringResource(R.string.queued),
1673 stringResource(R.string.queued),
1507 style = MaterialTheme.typography.labelSmall,
1674 style = MaterialTheme.typography.labelSmall,
@@ -1569,6 +1736,31 @@ private fun ChatInputBar(
1569 .weight(1f)
1736 .weight(1f)
1570 .focusRequester(focusRequester)
1737 .focusRequester(focusRequester)
1571 .testTag("chat_input")
1738 .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+ )
1572 .onPreviewKeyEvent { event ->
1764 .onPreviewKeyEvent { event ->
1573 if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
1765 if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
1574 // With "Send on Enter" on, Enter sends (Shift+Enter newlines).
1766 // With "Send on Enter" on, Enter sends (Shift+Enter newlines).
@@ -1643,8 +1835,19 @@ private fun ChatInputBar(
1643 value = value,
1835 value = value,
1644 onValueChange = onValueChange,
1836 onValueChange = onValueChange,
1645 onDismiss = { showFullScreenEditor = false },
1837 onDismiss = { showFullScreenEditor = false },
1838+ onSend = {
1646− onSend = { showFullScreenEditor = false; 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+ },
1647 canSend = enabled && hasContent,
1846 canSend = enabled && hasContent,
1847+ running = running,
1848+ attachments = attachments,
1849+ onRemoveAttachment = onRemoveAttachment,
1850+ staging = staging,
1648 )
1851 )
1649 }
1852 }
1650 }
1853 }
@@ -1660,6 +1863,10 @@ private fun FullScreenEditor(
1660 onDismiss: () -> Unit,
1863 onDismiss: () -> Unit,
1661 onSend: () -> Unit,
1864 onSend: () -> Unit,
1662 canSend: Boolean,
1865 canSend: Boolean,
1866+ running: Boolean = false,
1867+ attachments: List<PendingAttachment> = emptyList(),
1868+ onRemoveAttachment: (String) -> Unit = {},
1869+ staging: Boolean = false,
1663) {
1870) {
1664 androidx.compose.ui.window.Dialog(
1871 androidx.compose.ui.window.Dialog(
1665 onDismissRequest = onDismiss,
1872 onDismissRequest = onDismiss,
@@ -1683,20 +1890,26 @@ private fun FullScreenEditor(
1683 horizontalArrangement = Arrangement.End,
1890 horizontalArrangement = Arrangement.End,
1684 ) {
1891 ) {
1685 androidx.compose.material3.Button(onClick = onSend, enabled = canSend) {
1892 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))
1687 }
1894 }
1688 }
1895 }
1689 }
1896 }
1690 },
1897 },
1691 ) { padding ->
1898 ) { padding ->
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)
1692− OutlinedTextField(
1904+ OutlinedTextField(
1693− value = value,
1905+ value = value,
1694− onValueChange = { v -> onValueChange(v.take(NetworkConfig.maxDraftLengthChars)) },
1906+ onValueChange = { v -> onValueChange(v.take(NetworkConfig.maxDraftLengthChars)) },
1695− modifier = Modifier.fillMaxSize().padding(padding),
1907+ modifier = Modifier.fillMaxSize().weight(1f),
1696− placeholder = { Text(stringResource(R.string.message_placeholder)) },
1908+ placeholder = { Text(stringResource(R.string.message_placeholder)) },
1697− maxLines = Int.MAX_VALUE,
1909+ maxLines = Int.MAX_VALUE,
1698− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Default, capitalization = KeyboardCapitalization.Sentences),
1910+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Default, capitalization = KeyboardCapitalization.Sentences),
1699− )
1911+ )
1912+ }
1700 }
1913 }
1701 }
1914 }
1702}
1915}
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+30
@@ -1280,6 +1280,36 @@ class ChatViewModel(
1280 }
1280 }
1281 }
1281 }
1282
1282
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+
1283 /** Load a previously-sent user prompt back into the composer to edit and re-send.
1313 /** Load a previously-sent user prompt back into the composer to edit and re-send.
1284 * Reverts the conversation to just before [messageId] (hiding it and everything after)
1314 * Reverts the conversation to just before [messageId] (hiding it and everything after)
1285 * and prefills the draft with [text]. The next Send continues from that checkpoint,
1315 * 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 @@
1package soy.iko.opencode.ui.chat
1package soy.iko.opencode.ui.chat
2
2
3import soy.iko.opencode.data.model.AssistantMessage
3import soy.iko.opencode.data.model.AssistantMessage
4+import soy.iko.opencode.data.model.FilePart
4import soy.iko.opencode.data.model.MessageWithParts
5import soy.iko.opencode.data.model.MessageWithParts
5import soy.iko.opencode.data.model.ReasoningPart
6import soy.iko.opencode.data.model.ReasoningPart
6import soy.iko.opencode.data.model.TextPart
7import soy.iko.opencode.data.model.TextPart
@@ -9,6 +10,10 @@ import soy.iko.opencode.data.model.ToolError
9import soy.iko.opencode.data.model.ToolPart
10import soy.iko.opencode.data.model.ToolPart
10import soy.iko.opencode.data.model.ToolRunning
11import soy.iko.opencode.data.model.ToolRunning
11import soy.iko.opencode.data.model.UserMessage
12import 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
12
17
13/**
18/**
14 * Render a conversation as Markdown for sharing/exporting. Each message becomes a
19 * Render a conversation as Markdown for sharing/exporting. Each message becomes a
@@ -40,17 +45,34 @@ fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?):
40 is ReasoningPart -> part.text.takeIf { it.isNotBlank() }
45 is ReasoningPart -> part.text.takeIf { it.isNotBlank() }
41 ?.let { text -> text.trim().lines().joinToString("\n") { "> _${escapeMarkdown(it)}_" } }
46 ?.let { text -> text.trim().lines().joinToString("\n") { "> _${escapeMarkdown(it)}_" } }
42 is ToolPart -> formatToolCall(part)
47 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+ }
43 else -> null
56 else -> null
44 }
57 }
45 }
58 }
46 .joinToString("\n\n")
59 .joinToString("\n\n")
47 .trim()
60 .trim()
48 if (body.isEmpty()) continue
61 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")
50 }
67 }
51 return sb.toString().trimEnd()
68 return sb.toString().trimEnd()
52}
69}
53
70
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+
54/** Append a metadata header (message counts + timestamp range) so a shared transcript has
76/** Append a metadata header (message counts + timestamp range) so a shared transcript has
55 * context for support/debugging. Skipped when there are no messages. */
77 * context for support/debugging. Skipped when there are no messages. */
56private fun appendMetadataHeader(sb: StringBuilder, messages: List<MessageWithParts>) {
78private 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
22import androidx.compose.material.icons.filled.Error
22import androidx.compose.material.icons.filled.Error
23import androidx.compose.material.icons.filled.FormatQuote
23import androidx.compose.material.icons.filled.FormatQuote
24import androidx.compose.material.icons.filled.MoreVert
24import androidx.compose.material.icons.filled.MoreVert
25+import androidx.compose.material.icons.filled.Refresh
25import androidx.compose.material.icons.filled.Restore
26import androidx.compose.material.icons.filled.Restore
26import androidx.compose.material.icons.filled.SmartToy
27import androidx.compose.material.icons.filled.SmartToy
27import androidx.compose.material.icons.filled.StopCircle
28import androidx.compose.material.icons.filled.StopCircle
@@ -100,6 +101,7 @@ fun MessageBubble(
100 isSpeaking: Boolean = false,
101 isSpeaking: Boolean = false,
101 onQuote: ((String) -> Unit)? = null,
102 onQuote: ((String) -> Unit)? = null,
102 onBranch: ((String) -> Unit)? = null,
103 onBranch: ((String) -> Unit)? = null,
104+ onRegenerate: (() -> Unit)? = null,
103 sendStatus: MessageSendStatus? = null,
105 sendStatus: MessageSendStatus? = null,
104 isEdited: Boolean = false,
106 isEdited: Boolean = false,
105 onRetry: (() -> Unit)? = null,
107 onRetry: (() -> Unit)? = null,
@@ -108,7 +110,7 @@ fun MessageBubble(
108 when (message.info) {
110 when (message.info) {
109 is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile, onRevert, onEdit, onQuote, onBranch, sendStatus, isEdited, onRetry, onDismiss)
111 is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile, onRevert, onEdit, onQuote, onBranch, sendStatus, isEdited, onRetry, onDismiss)
110 is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier, onOpenFile)
112 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)
112 }
114 }
113}
115}
114
116
@@ -510,6 +512,7 @@ private fun AssistantBlock(
510 isSpeaking: Boolean = false,
512 isSpeaking: Boolean = false,
511 onQuote: ((String) -> Unit)? = null,
513 onQuote: ((String) -> Unit)? = null,
512 onBranch: ((String) -> Unit)? = null,
514 onBranch: ((String) -> Unit)? = null,
515+ onRegenerate: (() -> Unit)? = null,
513 isEdited: Boolean = false,
516 isEdited: Boolean = false,
514) {
517) {
515 Column(
518 Column(
@@ -639,6 +642,7 @@ private fun AssistantBlock(
639 onRevert = onRevert,
642 onRevert = onRevert,
640 onQuote = onQuote,
643 onQuote = onQuote,
641 onBranch = onBranch,
644 onBranch = onBranch,
645+ onRegenerate = onRegenerate,
642 )
646 )
643 }
647 }
644 MessageLongPressMenu(
648 MessageLongPressMenu(
@@ -668,11 +672,28 @@ private fun AssistantActions(
668 onRevert: (() -> Unit)?,
672 onRevert: (() -> Unit)?,
669 onQuote: ((String) -> Unit)? = null,
673 onQuote: ((String) -> Unit)? = null,
670 onBranch: ((String) -> Unit)? = null,
674 onBranch: ((String) -> Unit)? = null,
675+ onRegenerate: (() -> Unit)? = null,
671) {
676) {
672 val context = LocalContext.current
677 val context = LocalContext.current
673 val haptics = LocalHapticFeedback.current
678 val haptics = LocalHapticFeedback.current
674 val copyLabel = stringResource(R.string.copy)
679 val copyLabel = stringResource(R.string.copy)
675 Row(verticalAlignment = Alignment.CenterVertically) {
680 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+ }
676 // Read-aloud toggle: speaks the assistant text via TextToSpeech, showing a Stop
697 // Read-aloud toggle: speaks the assistant text via TextToSpeech, showing a Stop
677 // icon while this message is the one being spoken.
698 // icon while this message is the one being spoken.
678 if (onSpeak != null && textToCopy != null) {
699 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
214 color = MaterialTheme.colorScheme.onSurfaceVariant,
214 color = MaterialTheme.colorScheme.onSurfaceVariant,
215 modifier = Modifier.padding(start = 6.dp),
215 modifier = Modifier.padding(start = 6.dp),
216 )
216 )
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+ }
217 }
231 }
218 val expandMotion = rememberVisibilityTransitions()
232 val expandMotion = rememberVisibilityTransitions()
219 AnimatedVisibility(
233 AnimatedVisibility(
@@ -243,7 +257,9 @@ private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modi
243 copyToClipboard(context, context.getString(R.string.clip_label_reasoning), text)
257 copyToClipboard(context, context.getString(R.string.clip_label_reasoning), text)
244 },
258 },
245 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
259 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
260+ modifier = Modifier
261+ .defaultMinSize(minHeight = 48.dp)
246− modifier = Modifier.semantics(mergeDescendants = true) {},
262+ .semantics(mergeDescendants = true) {},
247 ) {
263 ) {
248 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
264 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
249 Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
265 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) {
308 // rather than an empty state (the defaults are "").
324 // rather than an empty state (the defaults are "").
309 expandedState = stringResource(R.string.state_expanded),
325 expandedState = stringResource(R.string.state_expanded),
310 collapsedState = stringResource(R.string.state_collapsed),
326 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+ },
311 )
334 )
312 }
335 }
313 }
336 }
@@ -427,7 +450,9 @@ private fun CollapsibleDetail(
427 TextButton(
450 TextButton(
428 onClick = onToggleExpand,
451 onClick = onToggleExpand,
429 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
452 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
453+ modifier = Modifier
454+ .defaultMinSize(minHeight = 48.dp)
430− modifier = Modifier.semantics { stateDescription = if (expanded) expandedState else collapsedState },
455+ .semantics { stateDescription = if (expanded) expandedState else collapsedState },
431 ) {
456 ) {
432 Icon(
457 Icon(
433 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
458 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
@@ -449,7 +474,9 @@ private fun CollapsibleDetail(
449 TextButton(
474 TextButton(
450 onClick = onCopy,
475 onClick = onCopy,
451 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
476 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
477+ modifier = Modifier
478+ .defaultMinSize(minHeight = 48.dp)
452− modifier = Modifier.semantics(mergeDescendants = true) {},
479+ .semantics(mergeDescendants = true) {},
453 ) {
480 ) {
454 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
481 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
455 Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
482 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 {
62 mainHandler.post { if (_speakingId.value == utteranceId) _speakingId.value = null }
62 mainHandler.post { if (_speakingId.value == utteranceId) _speakingId.value = null }
63 }
63 }
64
64
65+ /**
65− /** Speak [text] for message [id], or stop if [id] is already the one being spoken. */
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+ */
66− fun toggle(id: String, text: String) {
72+ fun toggle(id: String, text: String): Boolean {
67− if (_speakingId.value == id) { stop(); return }
73+ if (_speakingId.value == id) { stop(); return true }
68− if (!ready || text.isBlank()) return
74+ if (!ready || text.isBlank()) return false
69 // TextToSpeech.speak() silently returns ERROR (enqueuing nothing, firing no callback)
75 // TextToSpeech.speak() silently returns ERROR (enqueuing nothing, firing no callback)
70 // when a single input exceeds getMaxSpeechInputLength(), which would leave the Stop
76 // when a single input exceeds getMaxSpeechInputLength(), which would leave the Stop
71 // button stuck with no audio. Chunk long text and enqueue the parts back-to-back.
77 // button stuck with no audio. Chunk long text and enqueue the parts back-to-back.
72 val chunks = chunkForTts(text)
78 val chunks = chunkForTts(text)
73− if (chunks.isEmpty()) return
79+ if (chunks.isEmpty()) return false
74 chunks.forEachIndexed { index, chunk ->
80 chunks.forEachIndexed { index, chunk ->
75 val queueMode = if (index == 0) TextToSpeech.QUEUE_FLUSH else TextToSpeech.QUEUE_ADD
81 val queueMode = if (index == 0) TextToSpeech.QUEUE_FLUSH else TextToSpeech.QUEUE_ADD
76 // Only the final chunk carries the tracked [id]; intermediate chunks get a
82 // Only the final chunk carries the tracked [id]; intermediate chunks get a
@@ -85,10 +91,11 @@ class TtsController(context: Context) : RememberObserver {
85 if (result != TextToSpeech.SUCCESS) {
91 if (result != TextToSpeech.SUCCESS) {
86 if (index == 0) _speakingId.value = null
92 if (index == 0) _speakingId.value = null
87 else stop()
93 else stop()
88− return
94+ return false
89 }
95 }
90 }
96 }
91 _speakingId.value = id
97 _speakingId.value = id
98+ return true
92 }
99 }
93
100
94 /**
101 /**
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
6import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Arrangement
7import androidx.compose.foundation.layout.BoxScope
7import androidx.compose.foundation.layout.BoxScope
8import androidx.compose.foundation.layout.Row
8import androidx.compose.foundation.layout.Row
9+import androidx.compose.foundation.layout.defaultMinSize
9import androidx.compose.foundation.layout.fillMaxWidth
10import androidx.compose.foundation.layout.fillMaxWidth
10import androidx.compose.foundation.layout.padding
11import androidx.compose.foundation.layout.padding
11import androidx.compose.foundation.layout.size
12import androidx.compose.foundation.layout.size
@@ -133,7 +134,11 @@ fun ConnectionBanner(
133 // already reconnecting), when offline (retry can't help), and when no
134 // already reconnecting), when offline (retry can't help), and when no
134 // callback is wired (callers that don't have a reconnect path).
135 // callback is wired (callers that don't have a reconnect path).
135 if (isServerFailure && !isOffline && onRetry != null) {
136 if (isServerFailure && !isOffline && onRetry != null) {
137+ TextButton(
138+ onClick = onRetry,
136− TextButton(onClick = onRetry, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp)) {
139+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
140+ modifier = Modifier.defaultMinSize(minHeight = 48.dp),
141+ ) {
137 Text(stringResource(R.string.retry_now), color = onContainer)
142 Text(stringResource(R.string.retry_now), color = onContainer)
138 }
143 }
139 }
144 }
app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+2
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Row
8import androidx.compose.foundation.layout.Spacer
8import androidx.compose.foundation.layout.Spacer
9import androidx.compose.foundation.layout.fillMaxWidth
9import androidx.compose.foundation.layout.fillMaxWidth
10import androidx.compose.foundation.layout.height
10import androidx.compose.foundation.layout.height
11+import androidx.compose.foundation.layout.defaultMinSize
11import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.width
13import androidx.compose.foundation.layout.width
13import androidx.compose.foundation.rememberScrollState
14import androidx.compose.foundation.rememberScrollState
@@ -216,6 +217,7 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
216 TextButton(
217 TextButton(
217 onClick = { expanded = !expanded },
218 onClick = { expanded = !expanded },
218 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
219 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
220+ modifier = Modifier.defaultMinSize(minHeight = 48.dp),
219 ) {
221 ) {
220 Text(
222 Text(
221 if (expanded) stringResource(R.string.show_less)
223 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
5import android.content.Context
5import android.content.Context
6import android.os.Build
6import android.os.Build
7import android.widget.Toast
7import 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
8import androidx.compose.foundation.background
13import androidx.compose.foundation.background
9import androidx.compose.foundation.horizontalScroll
14import androidx.compose.foundation.horizontalScroll
10import androidx.compose.foundation.layout.Arrangement
15import androidx.compose.foundation.layout.Arrangement
11import androidx.compose.foundation.layout.Box
16import androidx.compose.foundation.layout.Box
12import androidx.compose.foundation.layout.Column
17import androidx.compose.foundation.layout.Column
13import androidx.compose.foundation.layout.Row
18import androidx.compose.foundation.layout.Row
19+import androidx.compose.foundation.layout.defaultMinSize
14import androidx.compose.foundation.layout.fillMaxWidth
20import androidx.compose.foundation.layout.fillMaxWidth
15import androidx.compose.foundation.layout.padding
21import androidx.compose.foundation.layout.padding
16import androidx.compose.foundation.layout.size
22import androidx.compose.foundation.layout.size
17import androidx.compose.foundation.rememberScrollState
23import androidx.compose.foundation.rememberScrollState
18import androidx.compose.foundation.text.selection.SelectionContainer
24import androidx.compose.foundation.text.selection.SelectionContainer
19import androidx.compose.material.icons.Icons
25import androidx.compose.material.icons.Icons
20−import androidx.compose.material.icons.filled.ContentCopy
21−import androidx.compose.material.icons.filled.Check
22import androidx.compose.material.icons.automirrored.filled.WrapText
26import 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
23import androidx.compose.material3.Icon
31import androidx.compose.material3.Icon
24import androidx.compose.material3.IconButton
32import androidx.compose.material3.IconButton
25import androidx.compose.material3.MaterialTheme
33import androidx.compose.material3.MaterialTheme
26import androidx.compose.material3.Surface
34import androidx.compose.material3.Surface
27import androidx.compose.material3.Text
35import androidx.compose.material3.Text
36+import androidx.compose.material3.TextButton
28import androidx.compose.material3.Typography
37import androidx.compose.material3.Typography
29import androidx.compose.runtime.Composable
38import androidx.compose.runtime.Composable
30import androidx.compose.runtime.LaunchedEffect
39import androidx.compose.runtime.LaunchedEffect
@@ -32,12 +41,15 @@ import androidx.compose.runtime.getValue
32import androidx.compose.runtime.mutableStateOf
41import androidx.compose.runtime.mutableStateOf
33import androidx.compose.runtime.remember
42import androidx.compose.runtime.remember
34import androidx.compose.runtime.rememberUpdatedState
43import androidx.compose.runtime.rememberUpdatedState
44+import androidx.compose.runtime.saveable.rememberSaveable
35import androidx.compose.runtime.setValue
45import androidx.compose.runtime.setValue
36import androidx.compose.runtime.snapshotFlow
46import androidx.compose.runtime.snapshotFlow
37import androidx.compose.ui.Alignment
47import androidx.compose.ui.Alignment
38import androidx.compose.ui.Modifier
48import androidx.compose.ui.Modifier
39import androidx.compose.ui.draw.clip
49import androidx.compose.ui.draw.clip
40import androidx.compose.ui.platform.LocalContext
50import androidx.compose.ui.platform.LocalContext
51+import androidx.compose.ui.res.pluralStringResource
52+import androidx.compose.ui.res.stringResource
41import androidx.compose.ui.text.AnnotatedString
53import androidx.compose.ui.text.AnnotatedString
42import androidx.compose.ui.text.TextStyle
54import androidx.compose.ui.text.TextStyle
43import androidx.compose.ui.text.font.FontFamily
55import androidx.compose.ui.text.font.FontFamily
@@ -157,6 +169,10 @@ private fun MarkdownBody(
157 modifier = modifier,
169 modifier = modifier,
158 components = components,
170 components = components,
159 )
171 )
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()
160 } else {
176 } else {
161 SelectionContainer {
177 SelectionContainer {
162 Markdown(
178 Markdown(
@@ -174,6 +190,10 @@ private fun MarkdownBody(
174 * copy button. Self-contained so it doesn't depend on the library's internal code-block
190 * copy button. Self-contained so it doesn't depend on the library's internal code-block
175 * composables. The wrap toggle starts from the user's [LocalCodeWrap] preference but can
191 * composables. The wrap toggle starts from the user's [LocalCodeWrap] preference but can
176 * be flipped per-block.
192 * 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.
177 */
197 */
178@Composable
198@Composable
179private fun CodeWithCopy(model: MarkdownComponentModel) {
199private fun CodeWithCopy(model: MarkdownComponentModel) {
@@ -189,15 +209,32 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
189 val code = remember(raw, isFenced) { extractCodeText(raw, isFenced) }
209 val code = remember(raw, isFenced) { extractCodeText(raw, isFenced) }
190 val language = remember(raw, isFenced) { extractFenceLanguage(raw, isFenced) }
210 val language = remember(raw, isFenced) { extractFenceLanguage(raw, isFenced) }
191 val codeStyle = model.typography.code.copy(fontFamily = FontFamily.Monospace)
211 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+ }
192 // Heuristic syntax highlighting, reusing the same highlighter the file viewer uses.
228 // Heuristic syntax highlighting, reusing the same highlighter the file viewer uses.
193 // Resolved from the fence's info string (e.g. "kotlin"), not a filename. Memoized so a
229 // Resolved from the fence's info string (e.g. "kotlin"), not a filename. Memoized so a
194 // scroll-induced recomposition doesn't re-tokenize; during streaming the MarkdownBody
230 // scroll-induced recomposition doesn't re-tokenize; during streaming the MarkdownBody
195 // throttle coalesces re-highlights. Falls back to plain text for unknown/missing tags
231 // 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.
197 val palette = rememberHighlightPalette()
234 val palette = rememberHighlightPalette()
198 val syntax = remember(language) { language?.let { syntaxForLanguageTag(it) } }
235 val syntax = remember(language) { language?.let { syntaxForLanguageTag(it) } }
199− val highlighted = remember(code, syntax, palette) {
236+ val highlighted = remember(displayCode, syntax, palette) {
200− if (syntax != null) highlightCode(code, syntax, palette) else AnnotatedString(code)
237+ if (syntax != null) highlightCode(displayCode, syntax, palette) else AnnotatedString(displayCode)
201 }
238 }
202 // Default per-block wrap from the global preference; re-seed if the preference changes
239 // Default per-block wrap from the global preference; re-seed if the preference changes
203 // while this block is composed (rememberSaveable would over-persist across blocks).
240 // while this block is composed (rememberSaveable would over-persist across blocks).
@@ -231,6 +268,18 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
231 color = MaterialTheme.colorScheme.onSurfaceVariant,
268 color = MaterialTheme.colorScheme.onSurfaceVariant,
232 modifier = Modifier.weight(1f),
269 modifier = Modifier.weight(1f),
233 )
270 )
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+ }
234 val wrapLabel = context.getString(if (wrap) R.string.code_no_wrap else R.string.code_wrap)
283 val wrapLabel = context.getString(if (wrap) R.string.code_no_wrap else R.string.code_wrap)
235 IconButton(onClick = { wrap = !wrap }) {
284 IconButton(onClick = { wrap = !wrap }) {
236 Icon(
285 Icon(
@@ -273,9 +322,45 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
273 },
322 },
274 )
323 )
275 }
324 }
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+ }
276 }
337 }
277}
338}
278
339
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+
279/**
364/**
280 * Extract the fence language tag (e.g. "kotlin" from ```kotlin) so the code block can
365 * Extract the fence language tag (e.g. "kotlin" from ```kotlin) so the code block can
281 * label itself. Returns null for indented blocks or fences with no info string. Extracted
366 * 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
30import androidx.compose.material.icons.filled.Image
30import androidx.compose.material.icons.filled.Image
31import androidx.compose.material.icons.filled.Code
31import androidx.compose.material.icons.filled.Code
32import androidx.compose.material.icons.filled.Article
32import androidx.compose.material.icons.filled.Article
33+import androidx.compose.material.icons.filled.KeyboardArrowDown
34+import androidx.compose.material.icons.filled.KeyboardArrowUp
33import androidx.compose.material.icons.filled.Link
35import androidx.compose.material.icons.filled.Link
34import androidx.compose.material.icons.filled.MoreVert
36import androidx.compose.material.icons.filled.MoreVert
35import androidx.compose.material.icons.filled.Search
37import androidx.compose.material.icons.filled.Search
38+import androidx.compose.material.icons.filled.SortByAlpha
36import androidx.compose.material3.CircularProgressIndicator
39import androidx.compose.material3.CircularProgressIndicator
37import androidx.compose.material3.DropdownMenu
40import androidx.compose.material3.DropdownMenu
38import androidx.compose.material3.DropdownMenuItem
41import androidx.compose.material3.DropdownMenuItem
@@ -80,6 +83,7 @@ import androidx.compose.ui.res.pluralStringResource
80import androidx.compose.ui.res.stringResource
83import androidx.compose.ui.res.stringResource
81import androidx.lifecycle.compose.collectAsStateWithLifecycle
84import androidx.lifecycle.compose.collectAsStateWithLifecycle
82import androidx.lifecycle.viewmodel.compose.viewModel
85import androidx.lifecycle.viewmodel.compose.viewModel
86+import soy.iko.opencode.data.model.FileNode
83import soy.iko.opencode.data.model.FileStatusEntry
87import soy.iko.opencode.data.model.FileStatusEntry
84import soy.iko.opencode.data.model.FindMatch
88import soy.iko.opencode.data.model.FindMatch
85import soy.iko.opencode.data.model.SymbolResult
89import soy.iko.opencode.data.model.SymbolResult
@@ -90,6 +94,7 @@ import soy.iko.opencode.ui.components.AppTopBar
90import soy.iko.opencode.ui.components.ConnectionBannerFor
94import soy.iko.opencode.ui.components.ConnectionBannerFor
91import soy.iko.opencode.ui.components.EmptyState
95import soy.iko.opencode.ui.components.EmptyState
92import soy.iko.opencode.ui.components.copyToClipboard
96import soy.iko.opencode.ui.components.copyToClipboard
97+import soy.iko.opencode.ui.components.relativeTime
93import soy.iko.opencode.ui.vmFactory
98import soy.iko.opencode.ui.vmFactory
94import soy.iko.opencode.util.runCatchingCancellable
99import soy.iko.opencode.util.runCatchingCancellable
95
100
@@ -514,14 +519,29 @@ private fun DirectoryListing(
514 // hides them by default (the common case for browsing a repo) and lets the user reveal
519 // hides them by default (the common case for browsing a repo) and lets the user reveal
515 // .env / .git / .opencode on demand. Default off so the listing isn't cluttered.
520 // .env / .git / .opencode on demand. Default off so the listing isn't cluttered.
516 var showHidden by rememberSaveable { mutableStateOf(false) }
521 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) }
517 val visible = remember(state.entries, showHidden) {
527 val visible = remember(state.entries, showHidden) {
518 if (showHidden) state.entries else state.entries.filterNot { it.name.startsWith(".") }
528 if (showHidden) state.entries else state.entries.filterNot { it.name.startsWith(".") }
519 }
529 }
520− val sorted = remember(visible, foldersFirst) {
530+ val sorted = remember(visible, foldersFirst, sortKey, sortDesc) {
521− if (!foldersFirst) visible
531+ // Null size/mtime coerce to values that sink the unknown entries to the bottom in the
522− else {
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) {
523 val (dirs, files) = visible.partition { it.isDirectory }
539 val (dirs, files) = visible.partition { it.isDirectory }
540+ // Directories always sort by name (size/date on a folder is rarely meaningful);
541+ // the chosen key applies to the files within.
524− dirs.sortedBy { it.name.lowercase() } + files.sortedBy { it.name.lowercase() }
542+ dirs.sortedBy { it.name.lowercase() } + files.sortedWith(comparator)
543+ } else {
544+ visible.sortedWith(comparator)
525 }
545 }
526 }
546 }
527 LazyColumn(modifier = Modifier.fillMaxSize()) {
547 LazyColumn(modifier = Modifier.fillMaxSize()) {
@@ -537,6 +557,46 @@ private fun DirectoryListing(
537 horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
557 horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
538 verticalAlignment = Alignment.CenterVertically,
558 verticalAlignment = Alignment.CenterVertically,
539 ) {
559 ) {
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+ }
540 androidx.compose.material3.FilterChip(
600 androidx.compose.material3.FilterChip(
541 selected = showHidden,
601 selected = showHidden,
542 onClick = { showHidden = !showHidden },
602 onClick = { showHidden = !showHidden },
@@ -562,6 +622,8 @@ private fun DirectoryListing(
562 onCopyPath = {
622 onCopyPath = {
563 copyToClipboard(context, context.getString(R.string.clip_label_path), node.path)
623 copyToClipboard(context, context.getString(R.string.clip_label_path), node.path)
564 },
624 },
625+ size = node.size,
626+ mtime = node.mtime,
565 status = state.statusMap[node.path],
627 status = state.statusMap[node.path],
566 modifier = Modifier.animateItem(),
628 modifier = Modifier.animateItem(),
567 )
629 )
@@ -585,6 +647,8 @@ private fun FileRow(
585 onClick: () -> Unit,
647 onClick: () -> Unit,
586 status: FileStatusEntry? = null,
648 status: FileStatusEntry? = null,
587 sublabel: String? = null,
649 sublabel: String? = null,
650+ size: Long? = null,
651+ mtime: Long? = null,
588 onCopyPath: (() -> Unit)? = null,
652 onCopyPath: (() -> Unit)? = null,
589 modifier: Modifier = Modifier,
653 modifier: Modifier = Modifier,
590) {
654) {
@@ -634,9 +698,19 @@ private fun FileRow(
634 maxLines = 1,
698 maxLines = 1,
635 overflow = TextOverflow.Ellipsis,
699 overflow = TextOverflow.Ellipsis,
636 )
700 )
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 {
637− if (sublabel != null) {
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()) {
638 Text(
712 Text(
639− sublabel,
713+ detailParts.joinToString(" · "),
640 style = MaterialTheme.typography.labelSmall,
714 style = MaterialTheme.typography.labelSmall,
641 color = MaterialTheme.colorScheme.onSurfaceVariant,
715 color = MaterialTheme.colorScheme.onSurfaceVariant,
642 maxLines = 1,
716 maxLines = 1,
@@ -692,6 +766,25 @@ private fun StatusBadge(status: FileStatusEntry) {
692 }
766 }
693}
767}
694
768
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+
695/** Pick an icon for a file by its extension so the directory listing is scannable instead of a
788/** Pick an icon for a file by its extension so the directory listing is scannable instead of a
696 * wall of identical `Description` glyphs: images, source code, and Markdown/article files each
789 * wall of identical `Description` glyphs: images, source code, and Markdown/article files each
697 * get a distinct glyph; unknown extensions fall back to the generic document icon. */
790 * 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
40import androidx.compose.material.icons.filled.MoreVert
40import androidx.compose.material.icons.filled.MoreVert
41import androidx.compose.material.icons.filled.Refresh
41import androidx.compose.material.icons.filled.Refresh
42import androidx.compose.material.icons.filled.Search
42import androidx.compose.material.icons.filled.Search
43+import androidx.compose.material.icons.filled.OpenInNew
43import androidx.compose.material.icons.filled.Share
44import androidx.compose.material.icons.filled.Share
44import androidx.compose.material3.AlertDialog
45import androidx.compose.material3.AlertDialog
45import androidx.compose.material3.CircularProgressIndicator
46import androidx.compose.material3.CircularProgressIndicator
@@ -239,6 +240,30 @@ fun FileViewScreen(
239 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
240 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
240 .onFailure { scope.launch { snackbar.showSnackbar(context.getString(R.string.no_share_app)) } }
241 .onFailure { scope.launch { snackbar.showSnackbar(context.getString(R.string.no_share_app)) } }
241 },
242 },
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+ },
242 )
267 )
243 },
268 },
244 snackbarHost = { SnackbarHost(snackbar) },
269 snackbarHost = { SnackbarHost(snackbar) },
@@ -420,6 +445,7 @@ private fun FileViewTopBar(
420 onCopy: () -> Unit,
445 onCopy: () -> Unit,
421 onCopyPath: () -> Unit,
446 onCopyPath: () -> Unit,
422 onShare: () -> Unit,
447 onShare: () -> Unit,
448+ onOpenExternally: () -> Unit,
423) {
449) {
424 TopAppBar(
450 TopAppBar(
425 title = {
451 title = {
@@ -510,6 +536,11 @@ private fun FileViewTopBar(
510 onClick = { onShare(); menuExpanded = false },
536 onClick = { onShare(); menuExpanded = false },
511 leadingIcon = { Icon(Icons.Filled.Share, contentDescription = null) },
537 leadingIcon = { Icon(Icons.Filled.Share, contentDescription = null) },
512 )
538 )
539+ DropdownMenuItem(
540+ text = { Text(stringResource(R.string.open_externally)) },
541+ onClick = { onOpenExternally(); menuExpanded = false },
542+ leadingIcon = { Icon(Icons.Filled.OpenInNew, contentDescription = null) },
543+ )
513 }
544 }
514 }
545 }
515 },
546 },
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
70import soy.iko.opencode.data.network.NetworkConfig
70import soy.iko.opencode.data.network.NetworkConfig
71import soy.iko.opencode.di.AppContainer
71import soy.iko.opencode.di.AppContainer
72import soy.iko.opencode.R
72import soy.iko.opencode.R
73+import soy.iko.opencode.ui.components.autofillModifier
74+import soy.iko.opencode.ui.components.AutofillHint
73import soy.iko.opencode.ui.vmFactory
75import soy.iko.opencode.ui.vmFactory
74
76
75@OptIn(ExperimentalMaterial3Api::class)
77@OptIn(ExperimentalMaterial3Api::class)
@@ -194,7 +196,12 @@ private fun ServerEditForm(
194 // fresh add form (via baseUrlFocus above).
196 // fresh add form (via baseUrlFocus above).
195 OutlinedTextField(
197 OutlinedTextField(
196 value = state.baseUrl,
198 value = state.baseUrl,
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.
197− onValueChange = { v -> vm.update { it.copy(baseUrl = v) } },
203+ if (!vm.applyQrPayload(v)) vm.update { it.copy(baseUrl = v) }
204+ },
198 label = { Text(stringResource(R.string.base_url)) },
205 label = { Text(stringResource(R.string.base_url)) },
199 placeholder = { Text(stringResource(R.string.base_url_hint)) },
206 placeholder = { Text(stringResource(R.string.base_url_hint)) },
200 singleLine = true,
207 singleLine = true,
@@ -221,7 +228,10 @@ private fun ServerEditForm(
221 },
228 },
222 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
229 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
223 keyboardActions = KeyboardActions(onDone = { keyboard?.hide() }),
230 keyboardActions = KeyboardActions(onDone = { keyboard?.hide() }),
231+ modifier = Modifier
232+ .fillMaxWidth()
224− modifier = Modifier.fillMaxWidth().focusRequester(baseUrlFocus).testTag("server_url"),
233+ .focusRequester(baseUrlFocus)
234+ .testTag("server_url"),
225 )
235 )
226 // Auto-expand LAN discovery only on a fresh form (no URL yet) so tapping a found
236 // Auto-expand LAN discovery only on a fresh form (no URL yet) so tapping a found
227 // server is the primary path; on edit the populated URL keeps it (and its multicast)
237 // server is the primary path; on edit the populated URL keeps it (and its multicast)
@@ -334,7 +344,11 @@ private fun AuthFields(
334 singleLine = true,
344 singleLine = true,
335 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
345 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
336 keyboardActions = KeyboardActions(onNext = { passwordFocus.requestFocus() }),
346 keyboardActions = KeyboardActions(onNext = { passwordFocus.requestFocus() }),
347+ modifier = Modifier
348+ .fillMaxWidth()
337− modifier = Modifier.fillMaxWidth().focusRequester(usernameFocus).testTag("server_username"),
349+ .focusRequester(usernameFocus)
350+ .then(autofillModifier(AutofillHint.Username) { v -> onUpdate { it.copy(username = v) } })
351+ .testTag("server_username"),
338 )
352 )
339 OutlinedTextField(
353 OutlinedTextField(
340 value = state.password,
354 value = state.password,
@@ -352,7 +366,11 @@ private fun AuthFields(
352 },
366 },
353 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
367 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
354 keyboardActions = KeyboardActions(onDone = { onImeDone() }),
368 keyboardActions = KeyboardActions(onDone = { onImeDone() }),
369+ modifier = Modifier
370+ .fillMaxWidth()
355− modifier = Modifier.fillMaxWidth().focusRequester(passwordFocus).testTag("server_password"),
371+ .focusRequester(passwordFocus)
372+ .then(autofillModifier(AutofillHint.Password) { v -> onUpdate { it.copy(password = v) } })
373+ .testTag("server_password"),
356 )
374 )
357 OutlinedButton(
375 OutlinedButton(
358 onClick = onTestCredentials,
376 onClick = onTestCredentials,
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+22
@@ -248,6 +248,28 @@ class ServerEditViewModel(
248 _state.update { transform(it).copy(credentialsResult = null, error = null) }
248 _state.update { transform(it).copy(credentialsResult = null, error = null) }
249 }
249 }
250
250
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+
251 /**
273 /**
252 * Primary action: save the profile and connect to it. When the server needs auth and the
274 * Primary action: save the profile and connect to it. When the server needs auth and the
253 * user hasn't supplied credentials yet, a connectivity probe reveals the username/password
275 * 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
15import androidx.compose.foundation.lazy.items
15import androidx.compose.foundation.lazy.items
16import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.Icons
17import androidx.compose.material.icons.filled.Add
17import androidx.compose.material.icons.filled.Add
18+import androidx.compose.material.icons.filled.Check
18import androidx.compose.material.icons.filled.CheckCircle
19import androidx.compose.material.icons.filled.CheckCircle
19import androidx.compose.material.icons.filled.Close
20import androidx.compose.material.icons.filled.Close
21+import androidx.compose.material.icons.filled.Sort
20import androidx.compose.material.icons.filled.Error
22import androidx.compose.material.icons.filled.Error
21import androidx.compose.material.icons.filled.Delete
23import androidx.compose.material.icons.filled.Delete
22import androidx.compose.material.icons.filled.Dns
24import androidx.compose.material.icons.filled.Dns
@@ -196,6 +198,31 @@ fun ServerListScreen(
196 TopAppBar(
198 TopAppBar(
197 title = { Text(stringResource(R.string.servers_title)) },
199 title = { Text(stringResource(R.string.servers_title)) },
198 actions = {
200 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+ }
199 // Import a server by decoding a QR code from a saved image (e.g. a screenshot
226 // Import a server by decoding a QR code from a saved image (e.g. a screenshot
200 // of another device's Share-as-QR dialog). Camera scanning would need extra
227 // of another device's Share-as-QR dialog). Camera scanning would need extra
201 // permissions; an image picker reuses the system gallery and covers the
228 // 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
26 * failed to connect so the snackbar can offer a Retry action. */
26 * failed to connect so the snackbar can offer a Retry action. */
27data class ConnectError(val message: String, val profile: ServerProfile?)
27data class ConnectError(val message: String, val profile: ServerProfile?)
28
28
29+/** Sort order for the server list. RECENT (by lastUsed, the default) or NAME (A→Z). */
30+enum class ServerSortMode { RECENT, NAME }
31+
29class ServerListViewModel(private val container: AppContainer) : ViewModel() {
32class ServerListViewModel(private val container: AppContainer) : ViewModel() {
30
33
31 /** Ids optimistically hidden while their deferred delete's undo window is open, so a
34 /** 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() {
33 * undo window. Mirrors [SessionListViewModel]'s optimistic-removal pattern. */
36 * undo window. Mirrors [SessionListViewModel]'s optimistic-removal pattern. */
34 private val _hiddenIds = MutableStateFlow<Set<String>>(emptySet())
37 private val _hiddenIds = MutableStateFlow<Set<String>>(emptySet())
35
38
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+
36 val profiles: StateFlow<List<ServerProfile>> =
48 val profiles: StateFlow<List<ServerProfile>> =
37− combine(container.profileStore.profiles, _hiddenIds) { profiles, hidden ->
49+ combine(container.profileStore.profiles, _hiddenIds, _sortMode) { profiles, hidden, sort ->
38− profiles.filterNot { it.id in hidden }
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+ }
39 }.stateIn(
58 }.stateIn(
40 scope = viewModelScope,
59 scope = viewModelScope,
41 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
60 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
3import androidx.compose.foundation.background
3import androidx.compose.foundation.background
4import androidx.activity.compose.BackHandler
4import androidx.activity.compose.BackHandler
5import androidx.compose.foundation.clickable
5import androidx.compose.foundation.clickable
6+import androidx.compose.foundation.combinedClickable
6import androidx.compose.foundation.layout.Arrangement
7import androidx.compose.foundation.layout.Arrangement
7import androidx.compose.foundation.layout.Box
8import androidx.compose.foundation.layout.Box
8import androidx.compose.foundation.layout.Column
9import androidx.compose.foundation.layout.Column
@@ -34,6 +35,7 @@ import androidx.compose.material.icons.filled.CheckCircle
34import androidx.compose.material.icons.filled.Close
35import androidx.compose.material.icons.filled.Close
35import androidx.compose.material.icons.filled.Delete
36import androidx.compose.material.icons.filled.Delete
36import androidx.compose.material.icons.filled.DoneAll
37import androidx.compose.material.icons.filled.DoneAll
38+import androidx.compose.material.icons.filled.MarkChatRead
37import androidx.compose.material.icons.filled.Folder
39import androidx.compose.material.icons.filled.Folder
38import androidx.compose.material.icons.filled.History
40import androidx.compose.material.icons.filled.History
39import androidx.compose.material.icons.filled.Link
41import androidx.compose.material.icons.filled.Link
@@ -91,6 +93,7 @@ import androidx.compose.ui.text.input.ImeAction
91import androidx.compose.ui.text.input.TextFieldValue
93import androidx.compose.ui.text.input.TextFieldValue
92import androidx.compose.ui.text.style.TextOverflow
94import androidx.compose.ui.text.style.TextOverflow
93import androidx.compose.ui.unit.dp
95import androidx.compose.ui.unit.dp
96+import androidx.compose.ui.res.pluralStringResource
94import androidx.compose.ui.res.stringResource
97import androidx.compose.ui.res.stringResource
95import androidx.compose.ui.platform.LocalContext
98import androidx.compose.ui.platform.LocalContext
96import androidx.lifecycle.compose.collectAsStateWithLifecycle
99import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -169,6 +172,14 @@ fun SessionListScreen(
169 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
172 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
170 var pendingRenameId by rememberSaveable { mutableStateOf<String?>(null) }
173 var pendingRenameId by rememberSaveable { mutableStateOf<String?>(null) }
171 var showDisconnectConfirm by rememberSaveable { mutableStateOf(false) }
174 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+ }
172 val pendingDelete = pendingDeleteId?.let { id -> state.sessions.firstOrNull { it.id == id } }
183 val pendingDelete = pendingDeleteId?.let { id -> state.sessions.firstOrNull { it.id == id } }
173 val pendingRename = pendingRenameId?.let { id -> state.sessions.firstOrNull { it.id == id } }
184 val pendingRename = pendingRenameId?.let { id -> state.sessions.firstOrNull { it.id == id } }
174
185
@@ -178,6 +189,13 @@ fun SessionListScreen(
178 val platformContext = LocalContext.current.applicationContext
189 val platformContext = LocalContext.current.applicationContext
179 SyncShortcutsAndWidget(platformContext, state)
190 SyncShortcutsAndWidget(platformContext, state)
180
191
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+
181 // Close the open dropdown on back press instead of navigating away.
199 // Close the open dropdown on back press instead of navigating away.
182 BackHandler(enabled = showServerMenu) { showServerMenu = false }
200 BackHandler(enabled = showServerMenu) { showServerMenu = false }
183 BackHandler(enabled = showSortMenu) { showSortMenu = false }
201 BackHandler(enabled = showSortMenu) { showSortMenu = false }
@@ -239,6 +257,43 @@ fun SessionListScreen(
239
257
240 Scaffold(
258 Scaffold(
241 topBar = {
259 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 {
242 TopAppBar(
297 TopAppBar(
243 title = {
298 title = {
244 ServerSwitcherMenu(
299 ServerSwitcherMenu(
@@ -318,30 +373,35 @@ fun SessionListScreen(
318 }
373 }
319 },
374 },
320 )
375 )
376+ }
321 },
377 },
322 snackbarHost = { SnackbarHost(snackbar) },
378 snackbarHost = { SnackbarHost(snackbar) },
323 floatingActionButton = {
379 floatingActionButton = {
380+ if (!inSelection) {
324− ExtendedFloatingActionButton(
381+ ExtendedFloatingActionButton(
325− onClick = openNewSession,
382+ onClick = openNewSession,
326− icon = {
383+ icon = {
327− if (creating) {
384+ if (creating) {
328− CircularProgressIndicator(
385+ CircularProgressIndicator(
329− Modifier.size(24.dp),
386+ Modifier.size(24.dp),
330− strokeWidth = 2.dp,
387+ strokeWidth = 2.dp,
331− color = MaterialTheme.colorScheme.onPrimaryContainer,
388+ color = MaterialTheme.colorScheme.onPrimaryContainer,
332− )
389+ )
333− } else {
390+ } else {
334− Icon(Icons.Filled.Add, contentDescription = null)
391+ Icon(Icons.Filled.Add, contentDescription = null)
335− }
392+ }
336− },
393+ },
337− text = { Text(stringResource(R.string.new_session)) },
394+ text = { Text(stringResource(R.string.new_session)) },
338− // Disable while a creation is in flight so a double-tap can't spawn two
395+ // 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;
396+ // sessions. The container guard in createSession is the real protection;
340− // this is the visual signal.
397+ // this is the visual signal.
341− expanded = !creating,
398+ expanded = !creating,
342− )
399+ )
400+ }
343 },
401 },
344 ) { padding ->
402 ) { padding ->
403+ // Exit selection mode on the system back gesture instead of navigating away.
404+ androidx.activity.compose.BackHandler(enabled = inSelection) { selectedIds = emptySet() }
345 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
405 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
346 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
406 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
347 ConnectionBanner(
407 ConnectionBanner(
@@ -364,6 +424,10 @@ fun SessionListScreen(
364 onDelete = { pendingDeleteId = it },
424 onDelete = { pendingDeleteId = it },
365 onPin = { vm.togglePin(it) },
425 onPin = { vm.togglePin(it) },
366 onArchive = { vm.toggleArchive(it) },
426 onArchive = { vm.toggleArchive(it) },
427+ onFilterByDirectory = vm::setDirectoryFilter,
428+ inSelection = inSelection,
429+ selectedIds = selectedIds,
430+ onToggleSelect = ::toggleSelected,
367 )
431 )
368 }
432 }
369 }
433 }
@@ -408,6 +472,25 @@ fun SessionListScreen(
408 )
472 )
409 }
473 }
410
474
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+
411 if (showNewSessionDialog) {
494 if (showNewSessionDialog) {
412 // Distinct session directories, most-recent first (state.sessions is sorted), so the
495 // Distinct session directories, most-recent first (state.sessions is sorted), so the
413 // picker can offer directories the user has recently worked in even if the server's
496 // 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(
447 onDelete: (String) -> Unit,
530 onDelete: (String) -> Unit,
448 onPin: (Session) -> Unit,
531 onPin: (Session) -> Unit,
449 onArchive: (Session) -> Unit,
532 onArchive: (Session) -> Unit,
533+ onFilterByDirectory: (String) -> Unit,
534+ inSelection: Boolean,
535+ selectedIds: Set<String>,
536+ onToggleSelect: (String) -> Unit,
450) {
537) {
451 when {
538 when {
452 state.loading -> {
539 state.loading -> {
@@ -657,7 +744,8 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
657 session = session,
744 session = session,
658 preview = state.previews[session.id],
745 preview = state.previews[session.id],
659 unreadCount = unread[session.id] ?: 0,
746 unreadCount = unread[session.id] ?: 0,
747+ isSelected = if (inSelection) session.id in selectedIds
660− isSelected = session.id == selectedSessionId,
748+ else session.id == selectedSessionId,
661 isPinned = isPinned,
749 isPinned = isPinned,
662 isArchived = isArchived,
750 isArchived = isArchived,
663 onClick = onCardClick,
751 onClick = onCardClick,
@@ -665,6 +753,11 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
665 onDelete = onCardDelete,
753 onDelete = onCardDelete,
666 onPin = onCardPin,
754 onPin = onCardPin,
667 onArchive = onCardArchive,
755 onArchive = onCardArchive,
756+ onFilterByDirectory = onFilterByDirectory,
757+ isDirectoryFiltered = state.directoryFilter != null &&
758+ session.directory?.trimEnd('/') == state.directoryFilter,
759+ inSelection = inSelection,
760+ onToggleSelect = { onToggleSelect(session.id) },
668 modifier = Modifier.testTag("session_card"),
761 modifier = Modifier.testTag("session_card"),
669 )
762 )
670 }
763 }
@@ -800,7 +893,7 @@ private fun SessionTitle(text: String, unread: Boolean) {
800 )
893 )
801}
894}
802
895
803−@OptIn(ExperimentalMaterial3Api::class)
896+@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
804@Composable
897@Composable
805private fun SessionCard(
898private fun SessionCard(
806 session: Session,
899 session: Session,
@@ -815,10 +908,15 @@ private fun SessionCard(
815 isSelected: Boolean = false,
908 isSelected: Boolean = false,
816 isPinned: Boolean = false,
909 isPinned: Boolean = false,
817 isArchived: Boolean = false,
910 isArchived: Boolean = false,
911+ onFilterByDirectory: ((String) -> Unit)? = null,
912+ isDirectoryFiltered: Boolean = false,
913+ inSelection: Boolean = false,
914+ onToggleSelect: (() -> Unit)? = null,
818) {
915) {
819 // In two-pane mode the selected row is highlighted (border + container tint) so
916 // In two-pane mode the selected row is highlighted (border + container tint) so
820 // the user can tell which conversation is open in the detail pane at a glance —
917 // 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.
822 val containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer
920 val containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer
823 else MaterialTheme.colorScheme.surface
921 else MaterialTheme.colorScheme.surface
824 val borderColor = if (isSelected) MaterialTheme.colorScheme.primary
922 val borderColor = if (isSelected) MaterialTheme.colorScheme.primary
@@ -832,7 +930,16 @@ private fun SessionCard(
832 // Merge the row's text into one semantics node so TalkBack reads it as a single
930 // Merge the row's text into one semantics node so TalkBack reads it as a single
833 // item; the overflow menu button stays its own actionable node.
931 // item; the overflow menu button stays its own actionable node.
834 .semantics(mergeDescendants = true) {}
932 .semantics(mergeDescendants = true) {}
933+ .combinedClickable(
835− .clickable(role = Role.Button) { onClick() },
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+ ),
836 colors = androidx.compose.material3.CardDefaults.cardColors(containerColor = containerColor),
943 colors = androidx.compose.material3.CardDefaults.cardColors(containerColor = containerColor),
837 border = androidx.compose.foundation.BorderStroke(1.dp, borderColor),
944 border = androidx.compose.foundation.BorderStroke(1.dp, borderColor),
838 ) {
945 ) {
@@ -841,11 +948,21 @@ private fun SessionCard(
841 modifier = Modifier.fillMaxWidth(),
948 modifier = Modifier.fillMaxWidth(),
842 verticalAlignment = Alignment.CenterVertically,
949 verticalAlignment = Alignment.CenterVertically,
843 ) {
950 ) {
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) {
844 // Leading monogram avatar so a long list is scannable at a glance instead of a
960 // Leading monogram avatar so a long list is scannable at a glance instead of a
845 // wall of identical text rows. Color is derived (stably) from the session id so
961 // wall of identical text rows. Color is derived (stably) from the session id so
846 // the same session always gets the same tint, while staying within tonal theme
962 // the same session always gets the same tint, while staying within tonal theme
847 // roles to match the Tokyo Night palette.
963 // roles to match the Tokyo Night palette.
848 SessionAvatar(session)
964 SessionAvatar(session)
965+ }
849 Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
966 Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
850 Row(verticalAlignment = Alignment.CenterVertically) {
967 Row(verticalAlignment = Alignment.CenterVertically) {
851 if (unreadCount > 0) {
968 if (unreadCount > 0) {
@@ -928,22 +1045,40 @@ private fun SessionCard(
928 // otherwise-similar sessions apart at a glance.
1045 // otherwise-similar sessions apart at a glance.
929 session.displayDirectory?.let { dir ->
1046 session.displayDirectory?.let { dir ->
930 val dirDesc = stringResource(R.string.session_directory_desc, session.directory.orEmpty())
1047 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+ )
931 Row(
1054 Row(
932 verticalAlignment = Alignment.CenterVertically,
1055 verticalAlignment = Alignment.CenterVertically,
933 modifier = Modifier
1056 modifier = Modifier
934 .padding(top = 2.dp)
1057 .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+ )
935 .semantics(mergeDescendants = true) { contentDescription = dirDesc },
1068 .semantics(mergeDescendants = true) { contentDescription = dirDesc },
936 ) {
1069 ) {
937 Icon(
1070 Icon(
938 Icons.Filled.Folder,
1071 Icons.Filled.Folder,
939 contentDescription = null,
1072 contentDescription = null,
940 modifier = Modifier.size(12.dp),
1073 modifier = Modifier.size(12.dp),
1074+ tint = if (isDirectoryFiltered) MaterialTheme.colorScheme.primary
941− tint = MaterialTheme.colorScheme.onSurfaceVariant,
1075+ else MaterialTheme.colorScheme.onSurfaceVariant,
942 )
1076 )
943 Text(
1077 Text(
944 dir,
1078 dir,
945 style = MaterialTheme.typography.labelSmall,
1079 style = MaterialTheme.typography.labelSmall,
1080+ color = if (isDirectoryFiltered) MaterialTheme.colorScheme.primary
946− color = MaterialTheme.colorScheme.onSurfaceVariant,
1081+ else MaterialTheme.colorScheme.onSurfaceVariant,
947 maxLines = 1,
1082 maxLines = 1,
948 overflow = TextOverflow.Ellipsis,
1083 overflow = TextOverflow.Ellipsis,
949 modifier = Modifier.padding(start = 4.dp),
1084 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(
77 val pinnedIds: Set<String> = emptySet(),
77 val pinnedIds: Set<String> = emptySet(),
78 val archivedIds: Set<String> = emptySet(),
78 val archivedIds: Set<String> = emptySet(),
79 val showArchived: Boolean = false,
79 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,
80) {
83) {
81 /** Sessions to display: archived ones hidden (unless [showArchived]), filtered by the
84 /** 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. */
83 val filtered: List<Session>
87 val filtered: List<Session>
84 get() {
88 get() {
85 val q = query.trim()
89 val q = query.trim()
86 // Fast path: nothing to filter or reorder — return the same instance so
90 // Fast path: nothing to filter or reorder — return the same instance so
87 // downstream can skip a recomposition.
91 // downstream can skip a recomposition.
88 val noArchivedHidden = showArchived || archivedIds.isEmpty()
92 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('/')
90 fun visible(session: Session): Boolean {
95 fun visible(session: Session): Boolean {
91 if (!showArchived && session.id in archivedIds) return false
96 if (!showArchived && session.id in archivedIds) return false
97+ if (dirFilter != null && session.directory?.trimEnd('/') != dirFilter) return false
92 if (q.isEmpty()) return true
98 if (q.isEmpty()) return true
93 return session.displayTitle.contains(q, ignoreCase = true) ||
99 return session.displayTitle.contains(q, ignoreCase = true) ||
94 (previews[session.id]?.contains(q, ignoreCase = true) == true)
100 (previews[session.id]?.contains(q, ignoreCase = true) == true)
@@ -253,6 +259,35 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
253 viewModelScope.launch {
259 viewModelScope.launch {
254 container.sessionPrefsStore.archived.collect { ids -> _state.update { it.copy(archivedIds = ids) } }
260 container.sessionPrefsStore.archived.collect { ids -> _state.update { it.copy(archivedIds = ids) } }
255 }
261 }
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+ }
256 }
291 }
257
292
258 /** Pin or unpin [session] (pinned sessions sort to the top of the list). */
293 /** Pin or unpin [session] (pinned sessions sort to the top of the list). */
@@ -305,6 +340,9 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
305 /** Toggle whether archived sessions are shown in the list. */
340 /** Toggle whether archived sessions are shown in the list. */
306 fun setShowArchived(show: Boolean) {
341 fun setShowArchived(show: Boolean) {
307 _state.update { it.copy(showArchived = show) }
342 _state.update { it.copy(showArchived = show) }
343+ viewModelScope.launch {
344+ runCatchingCancellable { container.settingsStore.setSessionShowArchived(show) }
345+ }
308 }
346 }
309
347
310 /**
348 /**
@@ -457,6 +495,16 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
457 _state.update { it.copy(query = query) }
495 _state.update { it.copy(query = query) }
458 }
496 }
459
497
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+
460 /** Force the SSE stream to reconnect (recovery path for a Failed banner) and
508 /** Force the SSE stream to reconnect (recovery path for a Failed banner) and
461 * re-fetch the session list so the UI reflects the current server state. */
509 * re-fetch the session list so the UI reflects the current server state. */
462 fun retryConnection() {
510 fun retryConnection() {
@@ -716,7 +764,48 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
716 }
764 }
717 }
765 }
718
766
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+
719− fun setSortMode(mode: SessionSortMode) {
808+ fun setSortMode(mode: SessionSortMode, persist: Boolean = true) {
720 if (mode == _state.value.sortMode) return
809 if (mode == _state.value.sortMode) return
721 // Reset to the new mode's natural direction (RECENT=desc, TITLE=asc) so switching
810 // Reset to the new mode's natural direction (RECENT=desc, TITLE=asc) so switching
722 // modes doesn't inherit a surprising reversed order from the previous mode.
811 // modes doesn't inherit a surprising reversed order from the previous mode.
@@ -724,6 +813,15 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
724 _state.update { s ->
813 _state.update { s ->
725 s.copy(sortMode = mode, sortDescending = descending, sessions = s.sessions.sortedByMode(mode, descending))
814 s.copy(sortMode = mode, sortDescending = descending, sessions = s.sessions.sortedByMode(mode, descending))
726 }
815 }
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+ }
727 }
825 }
728
826
729 /** Flip the ascending/descending direction of the current sort mode. */
827 /** Flip the ascending/descending direction of the current sort mode. */
@@ -732,6 +830,9 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
732 val descending = !s.sortDescending
830 val descending = !s.sortDescending
733 s.copy(sortDescending = descending, sessions = s.sessions.sortedByMode(s.sortMode, descending))
831 s.copy(sortDescending = descending, sessions = s.sessions.sortedByMode(s.sortMode, descending))
734 }
832 }
833+ viewModelScope.launch {
834+ runCatchingCancellable { container.settingsStore.setSessionSortDescending(_state.value.sortDescending) }
835+ }
735 }
836 }
736
837
737 fun renameSession(session: Session, newTitle: String) {
838 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
21import androidx.compose.material.icons.filled.BugReport
21import androidx.compose.material.icons.filled.BugReport
22import androidx.compose.material.icons.filled.Close
22import androidx.compose.material.icons.filled.Close
23import androidx.compose.material.icons.filled.ContentCopy
23import androidx.compose.material.icons.filled.ContentCopy
24+import androidx.activity.compose.rememberLauncherForActivityResult
24import androidx.compose.material.icons.filled.Delete
25import androidx.compose.material.icons.filled.Delete
25import androidx.compose.material.icons.filled.DeleteSweep
26import androidx.compose.material.icons.filled.DeleteSweep
27+import androidx.compose.material.icons.filled.IosShare
26import androidx.compose.material.icons.filled.MoreVert
28import androidx.compose.material.icons.filled.MoreVert
27import androidx.compose.material.icons.filled.Search
29import androidx.compose.material.icons.filled.Search
28import androidx.compose.material.icons.filled.Share
30import androidx.compose.material.icons.filled.Share
@@ -117,6 +119,26 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
117 val shareSubject = stringResource(R.string.crash_report_share_subject)
119 val shareSubject = stringResource(R.string.crash_report_share_subject)
118 val timeTick = rememberRelativeTimeTick()
120 val timeTick = rememberRelativeTimeTick()
119
121
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+
120 // Report-delete undo events, shown via a single collectLatest collector rather than a
142 // Report-delete undo events, shown via a single collectLatest collector rather than a
121 // per-confirm launch: a serialized showSnackbar would queue each report's undo behind the
143 // per-confirm launch: a serialized showSnackbar would queue each report's undo behind the
122 // previous one's full window. collectLatest instead cancels the current snackbar and shows
144 // previous one's full window. collectLatest instead cancels the current snackbar and shows
@@ -186,6 +208,13 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
186 onBack = onBack,
208 onBack = onBack,
187 actions = {
209 actions = {
188 if (reports.isNotEmpty()) {
210 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+ }
189 IconButton(onClick = {
218 IconButton(onClick = {
190 showClearAll = true
219 showClearAll = true
191 }) {
220 }) {
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+29
@@ -28,8 +28,10 @@ import androidx.compose.material.icons.Icons
28import androidx.compose.material.icons.automirrored.filled.ArrowBack
28import androidx.compose.material.icons.automirrored.filled.ArrowBack
29import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
29import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
30import androidx.compose.material.icons.filled.BugReport
30import androidx.compose.material.icons.filled.BugReport
31+import androidx.compose.material.icons.filled.Code
31import androidx.compose.material.icons.filled.Dns
32import androidx.compose.material.icons.filled.Dns
32import androidx.compose.material.icons.filled.Download
33import androidx.compose.material.icons.filled.Download
34+import androidx.compose.material.icons.filled.Feedback
33import androidx.compose.material.icons.filled.Hub
35import androidx.compose.material.icons.filled.Hub
34import androidx.compose.material.icons.filled.Notifications
36import androidx.compose.material.icons.filled.Notifications
35import androidx.compose.material.icons.filled.QueryStats
37import androidx.compose.material.icons.filled.QueryStats
@@ -609,6 +611,33 @@ fun SettingsScreen(
609 }
611 }
610 Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
612 Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
611 }
613 }
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+ )
612 }
641 }
613 }
642 }
614
643
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 @@
262 <string name="notif_channel_permission_desc">Se muestra cuando un agente en ejecución necesita tu aprobación para continuar.</string>
262 <string name="notif_channel_permission_desc">Se muestra cuando un agente en ejecución necesita tu aprobación para continuar.</string>
263 <string name="notif_channel_error">Errores de sesión</string>
263 <string name="notif_channel_error">Errores de sesión</string>
264 <string name="notif_channel_error_desc">Se muestra cuando falla la ejecución de una sesión en segundo plano.</string>
264 <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>
265 <string name="notif_permission_title">Se necesita aprobación en «%1$s»</string>
271 <string name="notif_permission_title">Se necesita aprobación en «%1$s»</string>
266 <string name="notif_permission_fallback">Una herramienta quiere ejecutarse.</string>
272 <string name="notif_permission_fallback">Una herramienta quiere ejecutarse.</string>
267 <string name="notif_error_title">La ejecución falló</string>
273 <string name="notif_error_title">La ejecución falló</string>
@@ -571,6 +577,7 @@
571 <string name="clip_label_diff">Diferencias</string>
577 <string name="clip_label_diff">Diferencias</string>
572 <string name="clip_label_reasoning">Razonamiento</string>
578 <string name="clip_label_reasoning">Razonamiento</string>
573 <string name="clip_label_output">Salida</string>
579 <string name="clip_label_output">Salida</string>
580+ <string name="clip_label_input">Entrada</string>
574 <string name="clip_label_path">Ruta</string>
581 <string name="clip_label_path">Ruta</string>
575 <string name="clip_label_message">Mensaje</string>
582 <string name="clip_label_message">Mensaje</string>
576 <string name="clip_label_share">Enlace para compartir</string>
583 <string name="clip_label_share">Enlace para compartir</string>
@@ -619,4 +626,49 @@
619 <item quantity="one">%1$d minuto</item>
626 <item quantity="one">%1$d minuto</item>
620 <item quantity="other">%1$d minutos</item>
627 <item quantity="other">%1$d minutos</item>
621 </plurals>
628 </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>
622</resources>
674</resources>
app/src/main/res/values/strings.xml+52
@@ -271,6 +271,12 @@
271 <string name="notif_channel_permission_desc">Shown when a running agent needs your approval to continue.</string>
271 <string name="notif_channel_permission_desc">Shown when a running agent needs your approval to continue.</string>
272 <string name="notif_channel_error">Session errors</string>
272 <string name="notif_channel_error">Session errors</string>
273 <string name="notif_channel_error_desc">Shown when a background session run fails.</string>
273 <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>
274 <string name="notif_permission_title">Approval needed in “%1$s”</string>
280 <string name="notif_permission_title">Approval needed in “%1$s”</string>
275 <string name="notif_permission_fallback">A tool wants to run.</string>
281 <string name="notif_permission_fallback">A tool wants to run.</string>
276 <string name="notif_error_title">Run failed</string>
282 <string name="notif_error_title">Run failed</string>
@@ -593,6 +599,7 @@
593 <string name="clip_label_diff">Diff</string>
599 <string name="clip_label_diff">Diff</string>
594 <string name="clip_label_reasoning">Reasoning</string>
600 <string name="clip_label_reasoning">Reasoning</string>
595 <string name="clip_label_output">Output</string>
601 <string name="clip_label_output">Output</string>
602+ <string name="clip_label_input">Input</string>
596 <string name="clip_label_path">Path</string>
603 <string name="clip_label_path">Path</string>
597 <string name="clip_label_message">Message</string>
604 <string name="clip_label_message">Message</string>
598 <string name="clip_label_share">Share link</string>
605 <string name="clip_label_share">Share link</string>
@@ -619,4 +626,49 @@
619 <string name="crash_last_run_dismiss">Dismiss</string>
626 <string name="crash_last_run_dismiss">Dismiss</string>
620 <string name="connecting_to">Connecting to %1$s…</string>
627 <string name="connecting_to">Connecting to %1$s…</string>
621 <string name="scroll_to_top">Scroll to top</string>
628 <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>
622</resources>
674</resources>
app/src/main/res/xml/file_paths.xml+3
@@ -3,4 +3,7 @@
3 <!-- Camera captures are written to cache/captures and shared with the camera app via
3 <!-- Camera captures are written to cache/captures and shared with the camera app via
4 the app's FileProvider (see AndroidManifest). -->
4 the app's FileProvider (see AndroidManifest). -->
5 <cache-path name="captures" path="captures/" />
5 <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/" />
6</paths>
9</paths>