git.iko.soy/opencode-android
on master help sign in
Implement P0/P1 UI/UX improvements: skeletons, lazy diffview, foldable posture detection, per-session mute, default model/agent prefs, compact spacing, IME scroll, save/share image, search-more, permission timeout, diff colors, haptics, a11y fixes, and pagination
7ce40ediko
app/build.gradle.kts+1
@@ -113,6 +113,7 @@ android {
113dependencies {
113dependencies {
114 implementation(libs.androidx.core.ktx)
114 implementation(libs.androidx.core.ktx)
115 implementation(libs.androidx.core.splashscreen)
115 implementation(libs.androidx.core.splashscreen)
116+ implementation(libs.androidx.window)
116 implementation(libs.androidx.activity.compose)
117 implementation(libs.androidx.activity.compose)
117 implementation(libs.androidx.lifecycle.viewmodel.compose)
118 implementation(libs.androidx.lifecycle.viewmodel.compose)
118 implementation(libs.androidx.lifecycle.runtime.compose)
119 implementation(libs.androidx.lifecycle.runtime.compose)
app/detekt-baseline.xml+4−2
@@ -10,17 +10,19 @@
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>
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>
11 <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>
12 <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:CodeHighlighter.kt$fun highlightLine(line: String, syntax: FileSyntax, palette: HighlightPalette, state: BlockCommentState): AnnotatedString</ID>
13 <ID>CyclomaticComplexMethod:ConversationExport.kt$fun buildConversationMarkdown(messages: List&lt;MessageWithParts&gt;, title: String?): String</ID>
14 <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>
15 <ID>CyclomaticComplexMethod:DiagnosticsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun DiagnosticsScreen(onBack: () -&gt; Unit)</ID>
15 <ID>CyclomaticComplexMethod:EventStreamClient.kt$EventStreamClient$private fun stream(): Flow&lt;BusEvent&gt;</ID>
16 <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: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: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>
19 <ID>CyclomaticComplexMethod:MainActivity.kt$MainActivity$private fun handleIntent(intent: Intent?)</ID>
20+ <ID>CyclomaticComplexMethod:ModelPickerSheet.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ModelPickerSheet( options: List&lt;ModelOption&gt;, selected: ModelOption?, loading: Boolean, error: Boolean, onSelect: (ModelOption) -&gt; Unit, onRetry: () -&gt; Unit, onDismiss: () -&gt; Unit, preferredModelId: String = "", onSetPreferredModel: (String) -&gt; Unit = {}, )</ID>
19 <ID>CyclomaticComplexMethod:OpencodeApp.kt$@Composable fun OpencodeApp(container: AppContainer)</ID>
21 <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>
22 <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>
23 <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>
24+ <ID>CyclomaticComplexMethod:SessionListScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun androidx.compose.foundation.layout.BoxScope.SessionListBody( state: SessionListState, unread: Map&lt;String, Int&gt;, mutedSessions: Set&lt;String&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, onMarkUnread: (String) -&gt; Unit, onToggleMute: (String) -&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>
25+ <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, onMarkUnread: () -&gt; Unit, onToggleMute: () -&gt; Unit, isMuted: Boolean, 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>
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>
26 <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>
25 <ID>LargeClass:AppContainer.kt$AppContainer</ID>
27 <ID>LargeClass:AppContainer.kt$AppContainer</ID>
26 <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
28 <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
app/src/main/AndroidManifest.xml+1
@@ -29,6 +29,7 @@
29 android:launchMode="singleTop"
29 android:launchMode="singleTop"
30 android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|uiMode|navigation"
30 android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|uiMode|navigation"
31 android:enableOnBackInvokedCallback="true"
31 android:enableOnBackInvokedCallback="true"
32+ android:windowSoftInputMode="adjustResize"
32 tools:targetApi="33"
33 tools:targetApi="33"
33 android:theme="@style/Theme.OpencodeAndroid.Splash">
34 android:theme="@style/Theme.OpencodeAndroid.Splash">
34 <intent-filter>
35 <intent-filter>
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+22−2
@@ -57,6 +57,12 @@ object NetworkConfig {
57
57
58 /** Max sessions to fetch previews for in one batch. */
58 /** Max sessions to fetch previews for in one batch. */
59 const val maxPreviewSessions = 50
59 const val maxPreviewSessions = 50
60+ /** Initial render window for the session list — the number of rows composed before the
61+ * user scrolls to reveal more. Grows by [sessionListPageStep] as the user nears the
62+ * bottom, bounding the sort/filter work on a huge list while keeping scrolling seamless. */
63+ const val sessionListInitialPage = 60
64+ /** How many additional rows to reveal when the session list render window is exhausted. */
65+ const val sessionListPageStep = 60
60 /** Max concurrent preview fetches (prevents flooding the server with parallel requests). */
66 /** Max concurrent preview fetches (prevents flooding the server with parallel requests). */
61 const val maxConcurrentPreviews = 8
67 const val maxConcurrentPreviews = 8
62 /** Max characters of a session's last message to keep as a list preview. */
68 /** Max characters of a session's last message to keep as a list preview. */
@@ -247,8 +253,11 @@ object NetworkConfig {
247 const val twoPaneRightWeight = 0.62f
253 const val twoPaneRightWeight = 0.62f
248
254
249 /** Minimum window width (dp) at which a NavigationRail is shown alongside the
255 /** Minimum window width (dp) at which a NavigationRail is shown alongside the
250− * two-pane layout for top-level destination discoverability on large screens. */
256+ * two-pane layout for top-level destination discoverability on large screens. Sits below
257+ * [twoPaneWidthThresholdDp] (M3's WindowWidthSizeClass.Medium breakpoint ~600dp) so a
258+ * tablet or landscape phone gets the rail while staying single-pane until 840dp, where the
259+ * two-pane master/detail split takes over. */
251− const val navigationRailThresholdDp = 840
260+ const val navigationRailThresholdDp = 600
252
261
253 /** Maximum width (dp) for the chat message list on large screens. On a tablet or
262 /** Maximum width (dp) for the chat message list on large screens. On a tablet or
254 * unfolded foldable, full-width message bubbles stretch edge-to-edge, hurting
263 * unfolded foldable, full-width message bubbles stretch edge-to-edge, hurting
@@ -301,6 +310,17 @@ object NetworkConfig {
301 /** Blink period (ms) of the streaming caret at the tail of a live assistant reply. */
310 /** Blink period (ms) of the streaming caret at the tail of a live assistant reply. */
302 const val streamingCaretPeriodMs = 550
311 const val streamingCaretPeriodMs = 550
303
312
313+ /** Auto-reject a tool-permission prompt after this many ms if the user hasn't responded,
314+ * so a forgotten prompt doesn't block the run indefinitely. The foreground service keeps
315+ * the process alive, so without a timeout a prompt that the user walked away from holds
316+ * the run open forever. The timeout is long (10 minutes) so a deliberate pause to read
317+ * the prompt doesn't trip it; a reminder toast-style line appears in the dialog after
318+ * [permissionReminderThresholdMs] to surface that the run is waiting. Set to 0 to disable. */
319+ const val permissionAutoRejectMs = 10L * 60 * 1000
320+
321+ /** Show a "still waiting" reminder in the permission dialog after this many ms. */
322+ const val permissionReminderThresholdMs = 60L * 1000
323+
304 /** Duration (ms) the copy button shows its checkmark confirmation before reverting. */
324 /** Duration (ms) the copy button shows its checkmark confirmation before reverting. */
305 const val copyFeedbackMs = 1200
325 const val copyFeedbackMs = 1200
306
326
app/src/main/java/soy/iko/opencode/data/repo/SessionPrefsStore.kt+10−1
@@ -28,6 +28,7 @@ open class SessionPrefsStore private constructor(
28
28
29 private val pinnedKey = stringSetPreferencesKey("pinned")
29 private val pinnedKey = stringSetPreferencesKey("pinned")
30 private val archivedKey = stringSetPreferencesKey("archived")
30 private val archivedKey = stringSetPreferencesKey("archived")
31+ private val mutedKey = stringSetPreferencesKey("muted")
31
32
32 open val pinned: Flow<Set<String>>
33 open val pinned: Flow<Set<String>>
33 get() = appContext?.sessionPrefsDataStore?.data?.map { it[pinnedKey] ?: emptySet() }
34 get() = appContext?.sessionPrefsDataStore?.data?.map { it[pinnedKey] ?: emptySet() }
@@ -37,16 +38,24 @@ open class SessionPrefsStore private constructor(
37 get() = appContext?.sessionPrefsDataStore?.data?.map { it[archivedKey] ?: emptySet() }
38 get() = appContext?.sessionPrefsDataStore?.data?.map { it[archivedKey] ?: emptySet() }
38 ?: flowOf(emptySet())
39 ?: flowOf(emptySet())
39
40
41+ /** Session ids the user has muted (suppress unread badge + completion notification). */
42+ open val muted: Flow<Set<String>>
43+ get() = appContext?.sessionPrefsDataStore?.data?.map { it[mutedKey] ?: emptySet() }
44+ ?: flowOf(emptySet())
45+
40 open suspend fun setPinned(sessionId: String, pinned: Boolean) = toggle(pinnedKey, sessionId, pinned)
46 open suspend fun setPinned(sessionId: String, pinned: Boolean) = toggle(pinnedKey, sessionId, pinned)
41
47
42 open suspend fun setArchived(sessionId: String, archived: Boolean) = toggle(archivedKey, sessionId, archived)
48 open suspend fun setArchived(sessionId: String, archived: Boolean) = toggle(archivedKey, sessionId, archived)
43
49
50+ open suspend fun setMuted(sessionId: String, muted: Boolean) = toggle(mutedKey, sessionId, muted)
51+
44− /** Drop a session from both sets — call on deletion so ids don't accumulate forever. */
52+ /** Drop a session from all sets — call on deletion so ids don't accumulate forever. */
45 open suspend fun forget(sessionId: String) {
53 open suspend fun forget(sessionId: String) {
46 val ctx = appContext ?: return
54 val ctx = appContext ?: return
47 ctx.sessionPrefsDataStore.edit { prefs ->
55 ctx.sessionPrefsDataStore.edit { prefs ->
48 prefs[pinnedKey] = (prefs[pinnedKey] ?: emptySet()) - sessionId
56 prefs[pinnedKey] = (prefs[pinnedKey] ?: emptySet()) - sessionId
49 prefs[archivedKey] = (prefs[archivedKey] ?: emptySet()) - sessionId
57 prefs[archivedKey] = (prefs[archivedKey] ?: emptySet()) - sessionId
58+ prefs[mutedKey] = (prefs[mutedKey] ?: emptySet()) - sessionId
50 }
59 }
51 }
60 }
52
61
app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.kt+41−2
@@ -28,6 +28,9 @@ class SettingsStore(context: Context) {
28 private val sessionSortModeKey = stringPreferencesKey("session_sort_mode")
28 private val sessionSortModeKey = stringPreferencesKey("session_sort_mode")
29 private val sessionSortDescendingKey = booleanPreferencesKey("session_sort_descending")
29 private val sessionSortDescendingKey = booleanPreferencesKey("session_sort_descending")
30 private val sessionShowArchivedKey = booleanPreferencesKey("session_show_archived")
30 private val sessionShowArchivedKey = booleanPreferencesKey("session_show_archived")
31+ private val preferredModelIdKey = stringPreferencesKey("preferred_model_id")
32+ private val preferredAgentNameKey = stringPreferencesKey("preferred_agent_name")
33+ private val compactMessageSpacingKey = booleanPreferencesKey("compact_message_spacing")
31
34
32 companion object {
35 companion object {
33 /** Bounds for the chat text-size multiplier so the UI can't be scaled into
36 /** Bounds for the chat text-size multiplier so the UI can't be scaled into
@@ -105,6 +108,23 @@ class SettingsStore(context: Context) {
105 prefs[sessionShowArchivedKey] ?: false
108 prefs[sessionShowArchivedKey] ?: false
106 }
109 }
107
110
111+ /** Preferred default model id for new sessions. Empty means "use the server default".
112+ * A user who always picks a specific model doesn't have to pick it every session. */
113+ val preferredModelId: Flow<String> = appContext.settingsDataStore.data.map { prefs ->
114+ prefs[preferredModelIdKey] ?: ""
115+ }
116+
117+ /** Preferred default agent name for new sessions. Empty means "use the server default". */
118+ val preferredAgentName: Flow<String> = appContext.settingsDataStore.data.map { prefs ->
119+ prefs[preferredAgentNameKey] ?: ""
120+ }
121+
122+ /** When true, chat message bubbles use a tighter vertical spacing (8dp vs the default 16dp)
123+ * so power users can fit more conversation on screen. Defaults to false (the design default). */
124+ val compactMessageSpacing: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
125+ prefs[compactMessageSpacingKey] ?: false
126+ }
127+
108 suspend fun setThemeMode(mode: ThemeMode) {
128 suspend fun setThemeMode(mode: ThemeMode) {
109 appContext.settingsDataStore.edit { it[themeKey] = mode.name }
129 appContext.settingsDataStore.edit { it[themeKey] = mode.name }
110 }
130 }
@@ -145,8 +165,27 @@ class SettingsStore(context: Context) {
145 suspend fun setSessionSortDescending(descending: Boolean) {
165 suspend fun setSessionSortDescending(descending: Boolean) {
146 appContext.settingsDataStore.edit { it[sessionSortDescendingKey] = descending }
166 appContext.settingsDataStore.edit { it[sessionSortDescendingKey] = descending }
147 }
167 }
148−
149 suspend fun setSessionShowArchived(show: Boolean) {
168 suspend fun setSessionShowArchived(show: Boolean) {
150− appContext.settingsDataStore.edit { it[sessionShowArchivedKey] = show }
169+ appContext.settingsDataStore.edit {
170+ it[sessionShowArchivedKey] = show
171+ }
172+ }
173+
174+ suspend fun setPreferredModelId(modelId: String) {
175+ appContext.settingsDataStore.edit {
176+ if (modelId.isEmpty()) it.remove(preferredModelIdKey) else it[preferredModelIdKey] = modelId
177+ }
178+ }
179+
180+ suspend fun setPreferredAgentName(agentName: String) {
181+ appContext.settingsDataStore.edit {
182+ if (agentName.isEmpty()) it.remove(preferredAgentNameKey) else it[preferredAgentNameKey] = agentName
183+ }
184+ }
185+
186+ suspend fun setCompactMessageSpacing(enabled: Boolean) {
187+ appContext.settingsDataStore.edit {
188+ it[compactMessageSpacingKey] = enabled
189+ }
151 }
190 }
152}
191}
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+67−15
@@ -233,6 +233,13 @@ open class AppContainer private constructor(
233 private val _unread = MutableStateFlow<Map<String, Int>>(emptyMap())
233 private val _unread = MutableStateFlow<Map<String, Int>>(emptyMap())
234 open val unread: StateFlow<Map<String, Int>> = _unread.asStateFlow()
234 open val unread: StateFlow<Map<String, Int>> = _unread.asStateFlow()
235
235
236+ /** Session ids the user has muted. A muted session doesn't badge unread (the count is
237+ * still tracked internally so unmuting can restore it, but the badge and completion
238+ * notification are suppressed). Persisted via SessionPrefsStore so the choice survives
239+ * process death. */
240+ private val _mutedSessions = MutableStateFlow<Set<String>>(emptySet())
241+ open val mutedSessions: StateFlow<Set<String>> = _mutedSessions.asStateFlow()
242+
236 /** True when any assistant run is actively streaming across all sessions. Drives the
243 /** True when any assistant run is actively streaming across all sessions. Drives the
237 * disconnect confirmation on the session list (disconnecting mid-run kills it). */
244 * disconnect confirmation on the session list (disconnecting mid-run kills it). */
238 private val _anyRunActive = MutableStateFlow(false)
245 private val _anyRunActive = MutableStateFlow(false)
@@ -289,6 +296,34 @@ open class AppContainer private constructor(
289 /** Mark a session as read (clear its unread badge) — the "Mark read" notification action. */
296 /** Mark a session as read (clear its unread badge) — the "Mark read" notification action. */
290 open fun markSessionRead(id: String) = clearUnread(id)
297 open fun markSessionRead(id: String) = clearUnread(id)
291
298
299+ /** Mark a session as unread with a count of 1 (or restore a prior count). Used by the
300+ * session list's "Mark as unread" overflow action so a user can re-flag a conversation
301+ * they want to revisit. No-op when the session is the one currently being viewed — a
302+ * viewed session clears its own unread badge via [setCurrentSession], so marking it
303+ * unread would race and immediately clear. Muted sessions are still marked (the count is
304+ * tracked so unmuting restores it) but the list UI hides the badge while muted. */
305+ open fun markSessionUnread(id: String, count: Int = 1) {
306+ if (id == _currentSession.value) return
307+ _unread.update { it + (id to count) }
308+ }
309+
310+ /** Toggle a session's mute state. A muted session suppresses the unread badge and the
311+ * completion notification so a noisy conversation doesn't keep interrupting. Persisted
312+ * via SessionPrefsStore so it survives process death. */
313+ fun toggleSessionMute(id: String) {
314+ val muted = id in _mutedSessions.value
315+ val next = if (muted) _mutedSessions.value - id else _mutedSessions.value + id
316+ _mutedSessions.value = next
317+ appContext?.let {
318+ kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch {
319+ runCatchingCancellable { sessionPrefsStore.setMuted(id, !muted) }
320+ }
321+ }
322+ }
323+
324+ /** True when [id] is muted (unread badge and completion notification suppressed). */
325+ fun isSessionMuted(id: String): Boolean = id in _mutedSessions.value
326+
292 /** Clear every session's unread badge at once — the session list's "Mark all read" action. */
327 /** Clear every session's unread badge at once — the session list's "Mark all read" action. */
293 open fun clearAllUnread() {
328 open fun clearAllUnread() {
294 _unread.update { emptyMap() }
329 _unread.update { emptyMap() }
@@ -678,21 +713,28 @@ open class AppContainer private constructor(
678 // A stale earlier check would race a badge — and a dedup entry — back
713 // A stale earlier check would race a badge — and a dedup entry — back
679 // in for the session that's actually on screen.
714 // in for the session that's actually on screen.
680 if (sid != _currentSession.value) {
715 if (sid != _currentSession.value) {
716+ // Skip the unread badge for muted sessions — the count is still tracked
717+ // in unreadMessageIds (so unmuting can't re-count the same messages),
718+ // but the visible badge is suppressed so a muted conversation doesn't
719+ // interrupt. The completion notification is also gated on isSessionMuted
720+ // (see notifySessionCompleted).
721+ if (sid !in _mutedSessions.value) {
681− // Increment the unread count once per distinct *message*, not per
722+ // Increment the unread count once per distinct *message*, not per
682− // event: a single reply emits many message.updated / message.part.
723+ // event: a single reply emits many message.updated / message.part.
683− // updated events (one per streamed token, plus cost/token refreshes),
724+ // updated events (one per streamed token, plus cost/token refreshes),
684− // which would otherwise inflate the badge into the dozens/hundreds.
725+ // which would otherwise inflate the badge into the dozens/hundreds.
685− val messageId = messageIdOfEvent(event)
726+ val messageId = messageIdOfEvent(event)
686− val counted = unreadMessageIds.getOrPut(sid) {
727+ val counted = unreadMessageIds.getOrPut(sid) {
687− java.util.Collections.synchronizedSet(mutableSetOf())
728+ java.util.Collections.synchronizedSet(mutableSetOf())
688− }
729+ }
689− if (messageId == null || counted.add(messageId)) {
730+ if (messageId == null || counted.add(messageId)) {
690− _unread.update { current ->
731+ _unread.update { current ->
691− // Guard again inside the atomic update lambda so a retry
732+ // Guard again inside the atomic update lambda so a retry
692− // (or an open that landed just now) can't reintroduce the
733+ // (or an open that landed just now) can't reintroduce the
693− // badge for the session on screen.
734+ // badge for the session on screen.
694− if (sid == _currentSession.value) current
735+ if (sid == _currentSession.value) current
695− else current + (sid to (current[sid] ?: 0) + 1)
736+ else current + (sid to (current[sid] ?: 0) + 1)
737+ }
696 }
738 }
697 }
739 }
698 }
740 }
@@ -789,6 +831,9 @@ open class AppContainer private constructor(
789 // prior notification — posting a fresh one here would leave a "session ready" heads-up
831 // prior notification — posting a fresh one here would leave a "session ready" heads-up
790 // lingering while they're already looking at the finished run.
832 // lingering while they're already looking at the finished run.
791 if (isActivelyViewing(sessionId)) return
833 if (isActivelyViewing(sessionId)) return
834+ // Suppress the completion notification for muted sessions — the whole point of muting
835+ // is that a noisy conversation doesn't interrupt.
836+ if (sessionId in _mutedSessions.value) return
792 appContext?.let { SessionNotifications.postCompleted(it, sessionId, title, originProfileId) }
837 appContext?.let { SessionNotifications.postCompleted(it, sessionId, title, originProfileId) }
793 }
838 }
794
839
@@ -1350,6 +1395,13 @@ open class AppContainer private constructor(
1350 observeMessageActivity()
1395 observeMessageActivity()
1351 observeRunReconcileOnReconnect()
1396 observeRunReconcileOnReconnect()
1352 observeRunForegroundService()
1397 observeRunForegroundService()
1398+ // Load the persisted muted-sessions set so isSessionMuted() reflects the choice
1399+ // immediately on cold start (before any session list composition).
1400+ appScope.launch {
1401+ runCatchingCancellable {
1402+ sessionPrefsStore.muted.collect { ids -> _mutedSessions.value = ids }
1403+ }
1404+ }
1353 // Flush right after load() populates the queue. observeOutbox()'s combine is keyed
1405 // Flush right after load() populates the queue. observeOutbox()'s combine is keyed
1354 // on activeConnection/isOnline/_outboxFlushTrigger — NOT on outboxStore.messages — so
1406 // on activeConnection/isOnline/_outboxFlushTrigger — NOT on outboxStore.messages — so
1355 // if connect() sets activeConnection before load() finishes reading a (possibly
1407 // if connect() sets activeConnection before load() finishes reading a (possibly
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+33−1
@@ -17,10 +17,12 @@ import androidx.compose.runtime.CompositionLocalProvider
17import androidx.compose.runtime.LaunchedEffect
17import androidx.compose.runtime.LaunchedEffect
18import androidx.compose.runtime.getValue
18import androidx.compose.runtime.getValue
19import androidx.compose.runtime.mutableStateOf
19import androidx.compose.runtime.mutableStateOf
20+import androidx.compose.runtime.remember
20import androidx.compose.runtime.rememberCoroutineScope
21import androidx.compose.runtime.rememberCoroutineScope
21import androidx.compose.runtime.saveable.rememberSaveable
22import androidx.compose.runtime.saveable.rememberSaveable
22import androidx.compose.runtime.setValue
23import androidx.compose.runtime.setValue
23import androidx.compose.ui.Modifier
24import androidx.compose.ui.Modifier
25+import androidx.compose.ui.platform.LocalContext
24import androidx.compose.ui.platform.LocalDensity
26import androidx.compose.ui.platform.LocalDensity
25import androidx.compose.ui.unit.dp
27import androidx.compose.ui.unit.dp
26import androidx.compose.ui.unit.LayoutDirection
28import androidx.compose.ui.unit.LayoutDirection
@@ -172,8 +174,29 @@ fun OpencodeApp(container: AppContainer) {
172 val slideDir = if (LocalLayoutDirection.current == LayoutDirection.Rtl) -1 else 1
174 val slideDir = if (LocalLayoutDirection.current == LayoutDirection.Rtl) -1 else 1
173 // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
175 // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
174 // the open conversation side by side instead of a single-pane back stack.
176 // the open conversation side by side instead of a single-pane back stack.
177+ // Posture-aware: a foldable in HALF_OPENED (tabletop/book) mode has a hinge across the
178+ // content area — splitting there would place the list/detail boundary on the hinge, which
179+ // is hard to read and interact with. Detect a FoldingFeature and suppress two-pane in
180+ // those postures so the app falls back to a single-pane layout that respects the fold.
181+ val windowContext = LocalContext.current
182+ var foldState by remember { mutableStateOf<androidx.window.layout.FoldingFeature?>(null) }
183+ androidx.compose.runtime.LaunchedEffect(windowContext) {
184+ val activity = windowContext.findActivity()
185+ if (activity != null) {
186+ androidx.window.layout.WindowInfoTracker.getOrCreate(windowContext)
187+ .windowLayoutInfo(activity)
188+ .collect { info ->
189+ foldState = info.displayFeatures
190+ .filterIsInstance<androidx.window.layout.FoldingFeature>()
191+ .firstOrNull()
192+ }
193+ }
194+ }
195+ val isHalfOpened = foldState?.state == androidx.window.layout.FoldingFeature.State.HALF_OPENED
175 BoxWithConstraints {
196 BoxWithConstraints {
176− val isTwoPane = maxWidth >= NetworkConfig.twoPaneWidthThresholdDp.dp && connection != null
197+ val isTwoPane = maxWidth >= NetworkConfig.twoPaneWidthThresholdDp.dp &&
198+ connection != null &&
199+ !isHalfOpened
177 // On large screens, surface a navigation rail alongside the content so top-level
200 // On large screens, surface a navigation rail alongside the content so top-level
178 // destinations (Sessions, Files, Search, …) are one tap away instead of buried in a
201 // destinations (Sessions, Files, Search, …) are one tap away instead of buried in a
179 // screen's overflow menu. Mirrors the standard large-screen M3 layout.
202 // screen's overflow menu. Mirrors the standard large-screen M3 layout.
@@ -486,3 +509,12 @@ fun OpencodeApp(container: AppContainer) {
486 }
509 }
487 }
510 }
488}
511}
512+
513+/** Walk the Context wrapper chain to find the hosting Activity, so WindowInfoTracker can
514+ * observe the fold state. Returns null when the composable isn't hosted in an Activity
515+ * (e.g. a preview), in which case fold-awareness is skipped. */
516+private tailrec fun android.content.Context.findActivity(): android.app.Activity? = when (this) {
517+ is android.app.Activity -> this
518+ is android.content.ContextWrapper -> baseContext.findActivity()
519+ else -> null
520+}
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+107−64
@@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.fillMaxSize
31import androidx.compose.foundation.layout.fillMaxWidth
31import androidx.compose.foundation.layout.fillMaxWidth
32import androidx.compose.foundation.layout.height
32import androidx.compose.foundation.layout.height
33import androidx.compose.foundation.layout.imePadding
33import androidx.compose.foundation.layout.imePadding
34+import androidx.compose.foundation.layout.ime
34import androidx.compose.foundation.layout.navigationBars
35import androidx.compose.foundation.layout.navigationBars
35import androidx.compose.foundation.layout.padding
36import androidx.compose.foundation.layout.padding
36import androidx.compose.foundation.layout.size
37import androidx.compose.foundation.layout.size
@@ -132,10 +133,12 @@ import androidx.compose.ui.platform.LocalDensity
132import androidx.compose.ui.platform.LocalHapticFeedback
133import androidx.compose.ui.platform.LocalHapticFeedback
133import androidx.compose.ui.platform.LocalView
134import androidx.compose.ui.platform.LocalView
134import androidx.compose.ui.platform.testTag
135import androidx.compose.ui.platform.testTag
136+import androidx.compose.ui.res.pluralStringResource
135import androidx.compose.ui.res.stringResource
137import androidx.compose.ui.res.stringResource
136import androidx.compose.ui.semantics.Role
138import androidx.compose.ui.semantics.Role
137import androidx.compose.ui.semantics.contentDescription
139import androidx.compose.ui.semantics.contentDescription
138import androidx.compose.ui.semantics.heading
140import androidx.compose.ui.semantics.heading
141+import androidx.compose.ui.semantics.invisibleToUser
139import androidx.compose.ui.semantics.semantics
142import androidx.compose.ui.semantics.semantics
140import androidx.compose.ui.text.input.ImeAction
143import androidx.compose.ui.text.input.ImeAction
141import androidx.compose.ui.text.style.TextOverflow
144import androidx.compose.ui.text.style.TextOverflow
@@ -218,6 +221,8 @@ fun ChatScreen(
218 val shareUrl by vm.shareUrl.collectAsStateWithLifecycle()
221 val shareUrl by vm.shareUrl.collectAsStateWithLifecycle()
219 val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
222 val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
220 val sendOnEnter by container.settingsStore.sendOnEnter.collectAsStateWithLifecycle(initialValue = true)
223 val sendOnEnter by container.settingsStore.sendOnEnter.collectAsStateWithLifecycle(initialValue = true)
224+ val preferredModelId by container.settingsStore.preferredModelId.collectAsStateWithLifecycle(initialValue = "")
225+ val compactSpacing by container.settingsStore.compactMessageSpacing.collectAsStateWithLifecycle(initialValue = false)
221 val isOnline by container.isOnline.collectAsStateWithLifecycle()
226 val isOnline by container.isOnline.collectAsStateWithLifecycle()
222 val haptics = LocalHapticFeedback.current
227 val haptics = LocalHapticFeedback.current
223 val scope = rememberCoroutineScope()
228 val scope = rememberCoroutineScope()
@@ -952,6 +957,28 @@ fun ChatScreen(
952 if (index >= 0) runCatchingCancellable { listState.animateScrollToItem(index) }
957 if (index >= 0) runCatchingCancellable { listState.animateScrollToItem(index) }
953 }
958 }
954
959
960+ // Scroll to bottom when the IME opens and the user was already pinned to bottom, so the
961+ // latest reply stays visible as the composer rises. Without this the IME can obscure the
962+ // last message even though the user was reading it a moment ago. WindowInsets.ime is a
963+ // @Composable property, so we read it inside a snapshotFlow registered in the composable
964+ // scope; the flow re-emits whenever the IME inset changes (open/close transitions).
965+ val density = androidx.compose.ui.platform.LocalDensity.current
966+ var wasImeOpen by remember { mutableStateOf(false) }
967+ val imeBottomPx = WindowInsets.ime.getBottom(density)
968+ androidx.compose.runtime.LaunchedEffect(imeBottomPx, listItems.isNotEmpty(), isPinnedToBottom) {
969+ if (listItems.isEmpty()) return@LaunchedEffect
970+ val imeOpen = imeBottomPx > 0
971+ // Scroll to bottom only on the open transition (not close) and only when the user
972+ // was already pinned to bottom — otherwise we'd yank them out of a mid-history read.
973+ val shouldScroll = imeOpen && !wasImeOpen && isPinnedToBottom
974+ if (shouldScroll) {
975+ runCatchingCancellable {
976+ listState.animateScrollToItem(if (running) listItems.size else listItems.lastIndex)
977+ }
978+ }
979+ wasImeOpen = imeOpen
980+ }
981+
955 LaunchedEffect(Unit) {
982 LaunchedEffect(Unit) {
956 var prevSize = 0
983 var prevSize = 0
957 var prevLen = 0
984 var prevLen = 0
@@ -1062,57 +1089,10 @@ fun ChatScreen(
1062 // Reserve top space when the banner is visible so it doesn't overlap the
1089 // Reserve top space when the banner is visible so it doesn't overlap the
1063 // first message / state content. (topPad computed above.)
1090 // first message / state content. (topPad computed above.)
1064 if (loading && messages.isEmpty()) {
1091 if (loading && messages.isEmpty()) {
1065− val loadingLabel = stringResource(R.string.loading)
1066 // Skeleton bubbles pre-structure the layout so the perceived load is faster
1092 // Skeleton bubbles pre-structure the layout so the perceived load is faster
1067 // than a bare centered spinner and the first real messages pop into an
1093 // than a bare centered spinner and the first real messages pop into an
1068− // already-shaped list instead of a blank-then-pop. Alpha pulses (gated by
1094+ // already-shaped list instead of a blank-then-pop.
1069− // reduced-motion) to convey ongoing work; the column carries the a11y label.
1095+ ChatSkeleton(topPad = topPad)
1070− val reducedMotion = LocalReducedMotion.current
1071− val transition = rememberInfiniteTransition(label = "skeleton")
1072− val pulse by transition.animateFloat(
1073− initialValue = 0.35f,
1074− targetValue = 0.7f,
1075− animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse),
1076− label = "skeletonAlpha",
1077− )
1078− val skeletonAlpha = if (reducedMotion) 0.5f else pulse
1079− val skeletonColor = MaterialTheme.colorScheme.surfaceVariant
1080− @Composable
1081− fun bar(widthFraction: Float) {
1082− Box(
1083− Modifier
1084− .fillMaxWidth(widthFraction)
1085− .height(14.dp)
1086− .clip(RoundedCornerShape(4.dp))
1087− .background(skeletonColor)
1088− .alpha(skeletonAlpha),
1089− )
1090− }
1091− Column(
1092− modifier = Modifier
1093− .align(Alignment.TopCenter)
1094− .padding(top = topPad)
1095− .padding(16.dp)
1096− .fillMaxWidth()
1097− .semantics { contentDescription = loadingLabel },
1098− verticalArrangement = Arrangement.spacedBy(14.dp),
1099− ) {
1100− repeat(4) { i ->
1101− val left = i % 2 == 0
1102− Row(
1103− modifier = Modifier.fillMaxWidth(),
1104− horizontalArrangement = if (left) Arrangement.Start else Arrangement.End,
1105− ) {
1106− Column(
1107− modifier = Modifier.fillMaxWidth(if (left) 0.7f else 0.55f),
1108− verticalArrangement = Arrangement.spacedBy(6.dp),
1109− ) {
1110− bar(1f)
1111− bar(0.8f)
1112− }
1113− }
1114− }
1115− }
1116 } else if (loadError && messages.isEmpty()) {
1096 } else if (loadError && messages.isEmpty()) {
1117 // A failed load with nothing to show. Distinct from the empty
1097 // A failed load with nothing to show. Distinct from the empty
1118 // conversation state below — the conversation isn't empty, it
1098 // conversation state below — the conversation isn't empty, it
@@ -1132,18 +1112,11 @@ fun ChatScreen(
1132 }
1112 }
1133 }
1113 }
1134 } else if (messages.isEmpty() && running) {
1114 } else if (messages.isEmpty() && running) {
1135− // A run just started but no parts have arrived yet — the empty
1115+ // A run just started but no parts have arrived yet — the empty list with
1136− // list with only the trailing "working…" row looks broken, so
1116+ // only the trailing "working…" row looks broken. Reuse the skeleton bubbles
1137− // surface a clear starting state until the first part streams in.
1117+ // from the initial-load branch so the perceived start latency is lower and
1138− val workingText = stringResource(R.string.working)
1118+ // the first real assistant part pops into an already-shaped list.
1139− Column(
1119+ ChatSkeleton(topPad = topPad)
1140− modifier = Modifier.align(Alignment.Center).padding(top = topPad).padding(24.dp),
1141− horizontalAlignment = Alignment.CenterHorizontally,
1142− ) {
1143− CircularProgressIndicator(Modifier.size(28.dp), strokeWidth = 3.dp)
1144− Spacer(Modifier.size(12.dp))
1145− Text(workingText, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
1146− }
1147 } else if (messages.isEmpty()) {
1120 } else if (messages.isEmpty()) {
1148 EmptyConversation(
1121 EmptyConversation(
1149 onSuggestion = {
1122 onSuggestion = {
@@ -1178,7 +1151,9 @@ fun ChatScreen(
1178 end = 16.dp,
1151 end = 16.dp,
1179 bottom = 16.dp + NetworkConfig.chatListFabInsetDp.dp,
1152 bottom = 16.dp + NetworkConfig.chatListFabInsetDp.dp,
1180 ),
1153 ),
1154+ // Compact spacing (8dp) vs the default 16dp — a power-user toggle to fit
1155+ // more conversation on screen without scrolling as much.
1181− verticalArrangement = Arrangement.spacedBy(16.dp),
1156+ verticalArrangement = Arrangement.spacedBy(if (compactSpacing) 8.dp else 16.dp),
1182 ) {
1157 ) {
1183 items(
1158 items(
1184 items = listItems,
1159 items = listItems,
@@ -1442,9 +1417,18 @@ fun ChatScreen(
1442 BadgedBox(
1417 BadgedBox(
1443 badge = {
1418 badge = {
1444 if (newContentCount > 0) {
1419 if (newContentCount > 0) {
1420+ // Merge the count into the badge announcement so TalkBack reads
1421+ // "5 new messages" instead of the bare label and a separate number.
1445− val newContentLabel = stringResource(R.string.new_messages)
1422+ val newContentLabel = pluralStringResource(
1423+ R.plurals.plurals_new_messages,
1424+ newContentCount,
1425+ newContentCount,
1426+ )
1446 Badge(modifier = Modifier.semantics { contentDescription = newContentLabel }) {
1427 Badge(modifier = Modifier.semantics { contentDescription = newContentLabel }) {
1428+ Text(
1447− Text(if (newContentCount > 99) "99+" else newContentCount.toString())
1429+ if (newContentCount > 99) "99+" else newContentCount.toString(),
1430+ modifier = Modifier.semantics { invisibleToUser() },
1431+ )
1448 }
1432 }
1449 }
1433 }
1450 },
1434 },
@@ -1706,6 +1690,10 @@ fun ChatScreen(
1706 onSelect = { vm.selectModel(it) },
1690 onSelect = { vm.selectModel(it) },
1707 onRetry = { vm.reloadModels() },
1691 onRetry = { vm.reloadModels() },
1708 onDismiss = { showModelPicker = false },
1692 onDismiss = { showModelPicker = false },
1693+ preferredModelId = preferredModelId,
1694+ onSetPreferredModel = { id ->
1695+ scope.launch { runCatchingCancellable { container.settingsStore.setPreferredModelId(id) } }
1696+ },
1709 )
1697 )
1710 }
1698 }
1711
1699
@@ -2694,3 +2682,58 @@ private fun DateSeparator(label: String) {
2694 }
2682 }
2695 }
2683 }
2696}
2684}
2685+
2686+/** Skeleton placeholder bubbles for the chat loading and run-started states. Alpha pulses
2687+ * (gated by reduced-motion) to convey ongoing work; the column carries the a11y label so
2688+ * TalkBack announces "Loading" instead of reading the skeleton bars. Rendered as a
2689+ * [BoxScope] extension so it can align itself to the top center of its host box. */
2690+@Composable
2691+private fun androidx.compose.foundation.layout.BoxScope.ChatSkeleton(topPad: androidx.compose.ui.unit.Dp) {
2692+ val loadingLabel = stringResource(R.string.loading)
2693+ val reducedMotion = LocalReducedMotion.current
2694+ val transition = rememberInfiniteTransition(label = "skeleton")
2695+ val pulse by transition.animateFloat(
2696+ initialValue = 0.35f,
2697+ targetValue = 0.7f,
2698+ animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse),
2699+ label = "skeletonAlpha",
2700+ )
2701+ val skeletonAlpha = if (reducedMotion) 0.5f else pulse
2702+ val skeletonColor = MaterialTheme.colorScheme.surfaceVariant
2703+ @Composable
2704+ fun bar(widthFraction: Float) {
2705+ Box(
2706+ Modifier
2707+ .fillMaxWidth(widthFraction)
2708+ .height(14.dp)
2709+ .clip(RoundedCornerShape(4.dp))
2710+ .background(skeletonColor)
2711+ .alpha(skeletonAlpha),
2712+ )
2713+ }
2714+ Column(
2715+ modifier = Modifier
2716+ .align(Alignment.TopCenter)
2717+ .padding(top = topPad)
2718+ .padding(16.dp)
2719+ .fillMaxWidth()
2720+ .semantics { contentDescription = loadingLabel },
2721+ verticalArrangement = Arrangement.spacedBy(14.dp),
2722+ ) {
2723+ repeat(4) { i ->
2724+ val left = i % 2 == 0
2725+ Row(
2726+ modifier = Modifier.fillMaxWidth(),
2727+ horizontalArrangement = if (left) Arrangement.Start else Arrangement.End,
2728+ ) {
2729+ Column(
2730+ modifier = Modifier.fillMaxWidth(if (left) 0.7f else 0.55f),
2731+ verticalArrangement = Arrangement.spacedBy(6.dp),
2732+ ) {
2733+ bar(1f)
2734+ bar(0.8f)
2735+ }
2736+ }
2737+ }
2738+ }
2739+}
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+25−6
@@ -686,7 +686,7 @@ class ChatViewModel(
686 loading: MutableStateFlow<Boolean>,
686 loading: MutableStateFlow<Boolean>,
687 error: MutableStateFlow<Boolean>,
687 error: MutableStateFlow<Boolean>,
688 fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> T,
688 fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> T,
689− onSuccess: (T) -> Unit,
689+ onSuccess: suspend (T) -> Unit,
690 onNull: () -> Unit,
690 onNull: () -> Unit,
691 reloadTrigger: StateFlow<Int>,
691 reloadTrigger: StateFlow<Int>,
692 ) {
692 ) {
@@ -701,7 +701,7 @@ class ChatViewModel(
701 // ClientRequestException whose message embeds the full request URL
701 // ClientRequestException whose message embeds the full request URL
702 // (may contain auth/paths). Log only a scrubbed summary.
702 // (may contain auth/paths). Log only a scrubbed summary.
703 .onFailure { Log.w("ChatViewModel", "Failed to load $tag: ${safeExceptionSummary(it)}"); error.value = true }
703 .onFailure { Log.w("ChatViewModel", "Failed to load $tag: ${safeExceptionSummary(it)}"); error.value = true }
704− .getOrNull()?.let(onSuccess)
704+ .getOrNull()?.let { onSuccess(it) }
705 loading.value = false
705 loading.value = false
706 }
706 }
707 }
707 }
@@ -758,6 +758,16 @@ class ChatViewModel(
758 // this collector does, including transient SSE drops and the cold-start
758 // this collector does, including transient SSE drops and the cold-start
759 // restore path — would drop a legitimately queued/recovered follow-up.
759 // restore path — would drop a legitimately queued/recovered follow-up.
760 _selectedAgent.value = null
760 _selectedAgent.value = null
761+ // Apply the user's persisted preferred-agent for this new session/load, so a
762+ // user who always runs a specific agent doesn't have to pick it every session.
763+ // No-op when the preference is empty (the server default applies). Read here
764+ // (not in the agent-catalog onSuccess) because the catalog loads once per
765+ // connection while this reset fires per-session-open; setting it once on
766+ // connect is enough and avoids re-applying it on every catalog refresh.
767+ runCatchingCancellable {
768+ val pref = container.settingsStore.preferredAgentName.first()
769+ if (pref.isNotEmpty()) _selectedAgent.value = pref
770+ }
761 _sessionTitle.value = null
771 _sessionTitle.value = null
762 // Resolve the app-bar title via REST for THIS connection. Launched from the same
772 // Resolve the app-bar title via REST for THIS connection. Launched from the same
763 // block that just nulled _sessionTitle (and cancelled the previous fetch) so it can
773 // block that just nulled _sessionTitle (and cancelled the previous fetch) so it can
@@ -864,12 +874,21 @@ class ChatViewModel(
864 onSuccess = { resp ->
874 onSuccess = { resp ->
865 val options = resp.toOptions()
875 val options = resp.toOptions()
866 _models.value = options
876 _models.value = options
867− // Only (re)apply the server default when there's no valid current
877+ // Only (re)apply the default when there's no valid current selection. On
868− // selection. On re-fetch/reconnect/Retry the user's chosen model must
878+ // re-fetch/reconnect/Retry the user's chosen model must survive — keep it as
869− // survive — keep it as long as it's still present in the refreshed list.
879+ // long as it's still present in the refreshed list. When there's no current
880+ // selection, prefer the user's persisted default-model preference (if it's in
881+ // the list) over the server default, so a user who always picks a specific
882+ // model doesn't have to pick it every new session.
870 val current = _selectedModel.value
883 val current = _selectedModel.value
871 if (current == null || options.none { it.ref == current.ref }) {
884 if (current == null || options.none { it.ref == current.ref }) {
885+ val prefId = runCatchingCancellable {
886+ container.settingsStore.preferredModelId.first()
887+ }.getOrDefault("")
888+ val preferredOption = if (prefId.isNotEmpty()) {
889+ options.firstOrNull { it.modelID == prefId }
890+ } else null
872− _selectedModel.value = resp.defaultOption(options)
891+ _selectedModel.value = preferredOption ?: resp.defaultOption(options)
873 }
892 }
874 },
893 },
875 onNull = { _models.value = emptyList(); _selectedModel.value = null },
894 onNull = { _models.value = emptyList(); _selectedModel.value = null },
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+73−61
@@ -44,6 +44,7 @@ import androidx.compose.runtime.key
44import androidx.compose.runtime.mutableStateOf
44import androidx.compose.runtime.mutableStateOf
45import androidx.compose.runtime.remember
45import androidx.compose.runtime.remember
46import androidx.compose.runtime.setValue
46import androidx.compose.runtime.setValue
47+import androidx.compose.animation.togetherWith
47import androidx.compose.ui.Alignment
48import androidx.compose.ui.Alignment
48import androidx.compose.ui.Modifier
49import androidx.compose.ui.Modifier
49import androidx.compose.ui.draw.clip
50import androidx.compose.ui.draw.clip
@@ -443,79 +444,90 @@ private fun UserBubble(
443 verticalAlignment = Alignment.CenterVertically,
444 verticalAlignment = Alignment.CenterVertically,
444 ) {
445 ) {
445 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
446 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
446− when (sendStatus) {
447+ // Crossfade the send-status slot so SENDING → FAILED → null transitions
448+ // don't snap; a subtle fade reads as a polish detail rather than a pop.
449+ androidx.compose.animation.AnimatedContent(
450+ targetState = sendStatus,
451+ transitionSpec = {
452+ androidx.compose.animation.fadeIn() togetherWith
453+ androidx.compose.animation.fadeOut()
454+ },
455+ label = "send_status",
456+ ) { status ->
457+ when (status) {
447− MessageSendStatus.SENDING -> {
458+ MessageSendStatus.SENDING -> {
448− CircularProgressIndicator(
459+ CircularProgressIndicator(
449− Modifier.size(12.dp),
460+ Modifier.size(12.dp),
450− strokeWidth = 1.5.dp,
461+ strokeWidth = 1.5.dp,
451− color = MaterialTheme.colorScheme.onSurfaceVariant,
452− )
453− Text(
454− stringResource(R.string.message_sending),
455− style = MaterialTheme.typography.labelSmall,
456− color = MaterialTheme.colorScheme.onSurfaceVariant,
462+ color = MaterialTheme.colorScheme.onSurfaceVariant,
457− )
458− }
459− MessageSendStatus.FAILED -> {
460− // Tap-to-retry: the whole failed label is tappable to re-send (far more
461− // discoverable than relying on the transient snackbar). A dismiss (×) button
462− // removes the abandoned message entirely, since its text will never match a
463− // real server message and would otherwise linger forever.
464− val retryModifier = if (onRetry != null) {
465− Modifier.clickable(
466− interactionSource = remember { MutableInteractionSource() },
467− indication = ripple(),
468− role = Role.Button,
469− onClick = {
470− haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
471− onRetry()
472− },
473− ).padding(vertical = 2.dp)
474− } else {
475− Modifier.padding(vertical = 2.dp)
476− }
477− Row(
478− verticalAlignment = Alignment.CenterVertically,
479− modifier = retryModifier.semantics(mergeDescendants = true) {
480− contentDescription = context.getString(R.string.message_send_failed) +
481− ". " + context.getString(R.string.tap_to_retry)
482− },
483− ) {
484− Icon(
485− Icons.Filled.Error,
486− contentDescription = null,
487− modifier = Modifier.size(12.dp),
488− tint = MaterialTheme.colorScheme.error,
489 )
463 )
490 Text(
464 Text(
491− stringResource(R.string.message_send_failed),
465+ stringResource(R.string.message_sending),
492 style = MaterialTheme.typography.labelSmall,
466 style = MaterialTheme.typography.labelSmall,
493− color = MaterialTheme.colorScheme.error,
467+ color = MaterialTheme.colorScheme.onSurfaceVariant,
494− modifier = Modifier.padding(start = 4.dp),
495 )
468 )
496 }
469 }
497− if (onDismiss != null) {
470+ MessageSendStatus.FAILED -> {
498− // No size override on the IconButton: it keeps the default 48dp
471+ // Tap-to-retry: the whole failed label is tappable to re-send (far more
499− // touch target (only the inner Icon is shrunk to 14dp). An earlier
472+ // discoverable than relying on the transient snackbar). A dismiss (×) button
500− // `.size(20.dp)` here clamped the target to 20dp — well under the
473+ // removes the abandoned message entirely, since its text will never match a
501− // M3 minimum — which made the discard action hard to hit. Matches
474+ // real server message and would otherwise linger forever.
502− // the pattern used by every other inline IconButton in this file.
475+ val retryModifier = if (onRetry != null) {
503− IconButton(
476+ Modifier.clickable(
477+ interactionSource = remember { MutableInteractionSource() },
478+ indication = ripple(),
479+ role = Role.Button,
504− onClick = {
480+ onClick = {
505− haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
481+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
506− onDismiss()
482+ onRetry()
483+ },
484+ ).padding(vertical = 2.dp)
485+ } else {
486+ Modifier.padding(vertical = 2.dp)
487+ }
488+ Row(
489+ verticalAlignment = Alignment.CenterVertically,
490+ modifier = retryModifier.semantics(mergeDescendants = true) {
491+ contentDescription = context.getString(R.string.message_send_failed) +
492+ ". " + context.getString(R.string.tap_to_retry)
507 },
493 },
508 ) {
494 ) {
509 Icon(
495 Icon(
510− Icons.Filled.Close,
496+ Icons.Filled.Error,
511− contentDescription = stringResource(R.string.outbox_discard),
497+ contentDescription = null,
512− modifier = Modifier.size(14.dp),
498+ modifier = Modifier.size(12.dp),
513− tint = MaterialTheme.colorScheme.onSurfaceVariant,
499+ tint = MaterialTheme.colorScheme.error,
514 )
500 )
501+ Text(
502+ stringResource(R.string.message_send_failed),
503+ style = MaterialTheme.typography.labelSmall,
504+ color = MaterialTheme.colorScheme.error,
505+ modifier = Modifier.padding(start = 4.dp),
506+ )
507+ }
508+ if (onDismiss != null) {
509+ // No size override on the IconButton: it keeps the default 48dp
510+ // touch target (only the inner Icon is shrunk to 14dp). An earlier
511+ // `.size(20.dp)` here clamped the target to 20dp — well under the
512+ // M3 minimum — which made the discard action hard to hit. Matches
513+ // the pattern used by every other inline IconButton in this file.
514+ IconButton(
515+ onClick = {
516+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
517+ onDismiss()
518+ },
519+ ) {
520+ Icon(
521+ Icons.Filled.Close,
522+ contentDescription = stringResource(R.string.outbox_discard),
523+ modifier = Modifier.size(14.dp),
524+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
525+ )
526+ }
515 }
527 }
516 }
528 }
529+ null -> {}
517 }
530 }
518− null -> {}
519 }
531 }
520 if (isEdited && sendStatus == null) {
532 if (isEdited && sendStatus == null) {
521 Text(
533 Text(
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+35
@@ -92,6 +92,8 @@ fun ModelPickerSheet(
92 onSelect: (ModelOption) -> Unit,
92 onSelect: (ModelOption) -> Unit,
93 onRetry: () -> Unit,
93 onRetry: () -> Unit,
94 onDismiss: () -> Unit,
94 onDismiss: () -> Unit,
95+ preferredModelId: String = "",
96+ onSetPreferredModel: (String) -> Unit = {},
95) {
97) {
96 // Hoist SheetState + skipPartiallyExpanded so the sheet opens full-height and selection
98 // Hoist SheetState + skipPartiallyExpanded so the sheet opens full-height and selection
97 // animates out (see AgentPickerSheet for the full rationale).
99 // animates out (see AgentPickerSheet for the full rationale).
@@ -253,6 +255,39 @@ fun ModelPickerSheet(
253 }
255 }
254 }
256 }
255 }
257 }
258+ // Footer: a "set as default" toggle for the currently-selected model, so a user
259+ // who always picks a specific model doesn't have to pick it every new session.
260+ // Only shown when there's a selection; tapping clears the preference if it's
261+ // already the default (toggle behavior).
262+ if (selected != null) {
263+ val isDefault = preferredModelId == selected.modelID
264+ Row(
265+ modifier = Modifier
266+ .fillMaxWidth()
267+ .padding(horizontal = 24.dp, vertical = 8.dp)
268+ .navigationBarsPadding(),
269+ verticalAlignment = Alignment.CenterVertically,
270+ ) {
271+ Text(
272+ stringResource(
273+ if (isDefault) R.string.default_model_current else R.string.set_as_default_model,
274+ selected.modelLabel,
275+ ),
276+ style = MaterialTheme.typography.bodySmall,
277+ color = MaterialTheme.colorScheme.onSurfaceVariant,
278+ modifier = Modifier.weight(1f),
279+ )
280+ TextButton(
281+ onClick = {
282+ onSetPreferredModel(if (isDefault) "" else selected.modelID)
283+ },
284+ ) {
285+ Text(
286+ stringResource(if (isDefault) R.string.clear_default else R.string.set_as_default),
287+ )
288+ }
289+ }
290+ }
256 }
291 }
257 }
292 }
258 }
293 }
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+35
@@ -19,6 +19,7 @@ import androidx.compose.material3.OutlinedButton
19import androidx.compose.material3.Text
19import androidx.compose.material3.Text
20import androidx.compose.material3.TextButton
20import androidx.compose.material3.TextButton
21import androidx.compose.runtime.Composable
21import androidx.compose.runtime.Composable
22+import androidx.compose.runtime.LaunchedEffect
22import androidx.compose.runtime.mutableStateOf
23import androidx.compose.runtime.mutableStateOf
23import androidx.compose.runtime.remember
24import androidx.compose.runtime.remember
24import androidx.compose.runtime.getValue
25import androidx.compose.runtime.getValue
@@ -28,14 +29,21 @@ import androidx.compose.ui.platform.LocalHapticFeedback
28import androidx.compose.ui.res.stringResource
29import androidx.compose.ui.res.stringResource
29import androidx.compose.ui.text.font.FontFamily
30import androidx.compose.ui.text.font.FontFamily
30import androidx.compose.ui.unit.dp
31import androidx.compose.ui.unit.dp
32+import kotlinx.coroutines.delay
31import soy.iko.opencode.data.model.Permission
33import soy.iko.opencode.data.model.Permission
32import soy.iko.opencode.data.model.PermissionResponse
34import soy.iko.opencode.data.model.PermissionResponse
35+import soy.iko.opencode.data.network.NetworkConfig
33import soy.iko.opencode.R
36import soy.iko.opencode.R
34
37
35/**
38/**
36 * Modal asking the user to approve a tool the agent wants to run. The agent run is
39 * Modal asking the user to approve a tool the agent wants to run. The agent run is
37 * paused until one of these responses is sent. Non-dismissable by tap-outside so the
40 * paused until one of these responses is sent. Non-dismissable by tap-outside so the
38 * decision is explicit.
41 * decision is explicit.
42+ *
43+ * After [NetworkConfig.permissionAutoRejectMs] the prompt auto-rejects so a forgotten
44+ * prompt doesn't hold the run open indefinitely (the foreground service keeps the process
45+ * alive, so without a timeout a walked-away user blocks the run forever). A "still waiting"
46+ * reminder appears after [NetworkConfig.permissionReminderThresholdMs].
39 */
47 */
40@Composable
48@Composable
41fun PermissionDialog(
49fun PermissionDialog(
@@ -58,12 +66,29 @@ fun PermissionDialog(
58 // remember resets to false on every fresh composition, so a re-queued permission is
66 // remember resets to false on every fresh composition, so a re-queued permission is
59 // always answerable.
67 // always answerable.
60 var responded by remember(permission.id) { mutableStateOf(false) }
68 var responded by remember(permission.id) { mutableStateOf(false) }
69+ // Elapsed ms since the dialog opened for this permission, used to surface a "still
70+ // waiting" reminder after the threshold. Caps at the auto-reject deadline.
71+ var elapsedMs by remember(permission.id) { mutableStateOf(0L) }
61 val respond: (PermissionResponse) -> Unit = { response ->
72 val respond: (PermissionResponse) -> Unit = { response ->
62 if (!responded) {
73 if (!responded) {
63 responded = true
74 responded = true
64 onRespond(response)
75 onRespond(response)
65 }
76 }
66 }
77 }
78+ // Auto-reject after the configured timeout. A forgotten prompt otherwise blocks the run
79+ // indefinitely (the foreground service holds the process alive). LaunchedEffect keyed on
80+ // permission.id so a fresh prompt resets the timer. Disabled when the timeout is 0.
81+ val autoRejectMs = NetworkConfig.permissionAutoRejectMs
82+ LaunchedEffect(permission.id, autoRejectMs) {
83+ if (autoRejectMs <= 0L) return@LaunchedEffect
84+ val tickMs = 1000L
85+ while (elapsedMs < autoRejectMs) {
86+ delay(tickMs)
87+ elapsedMs = (elapsedMs + tickMs).coerceAtMost(autoRejectMs)
88+ }
89+ respond(PermissionResponse.REJECT)
90+ }
91+ val showReminder = elapsedMs >= NetworkConfig.permissionReminderThresholdMs
67 AlertDialog(
92 AlertDialog(
68 // Back press routes here (dismissOnBackPress defaults true) and is treated as an
93 // Back press routes here (dismissOnBackPress defaults true) and is treated as an
69 // explicit reject — the safe default. Tap-outside is disabled below so it can't
94 // explicit reject — the safe default. Tap-outside is disabled below so it can't
@@ -116,6 +141,16 @@ fun PermissionDialog(
116 style = MaterialTheme.typography.bodySmall,
141 style = MaterialTheme.typography.bodySmall,
117 color = MaterialTheme.colorScheme.onSurfaceVariant,
142 color = MaterialTheme.colorScheme.onSurfaceVariant,
118 )
143 )
144+ // After the reminder threshold, surface that the run is waiting so the user
145+ // understands their inaction is holding up the agent. A nudge, not a force.
146+ if (showReminder && autoRejectMs > 0L) {
147+ val remainingMin = ((autoRejectMs - elapsedMs) / 60_000L).coerceAtLeast(0L)
148+ Text(
149+ stringResource(R.string.permission_waiting, remainingMin),
150+ style = MaterialTheme.typography.labelSmall,
151+ color = MaterialTheme.colorScheme.tertiary,
152+ )
153+ }
119 }
154 }
120 },
155 },
121 confirmButton = {
156 confirmButton = {
app/src/main/java/soy/iko/opencode/ui/components/CodeHighlighter.kt+177
@@ -228,6 +228,183 @@ fun highlightLine(line: String, syntax: FileSyntax, palette: HighlightPalette):
228 return builder.toAnnotatedString()
228 return builder.toAnnotatedString()
229}
229}
230
230
231+/**
232+ * State carried across lines while highlighting a multi-line file, so block comments
233+ * (C-family slash-star, Ruby =begin/=end) and triple-quoted strings (Python/Kotlin)
234+ * are colored correctly on every line they span, not just the opening line.
235+ *
236+ * The per-line [highlightLine] carries no state and is still the right call for chat
237+ * code fences (which render a whole snippet in one Text). The file viewer renders
238+ * line-by-line and should use the stateful [highlightLine] overload instead so a
239+ * C-family file with a 50-line block comment doesn't lose highlighting on lines 2..49.
240+ */
241+class BlockCommentState {
242+ /** True when the previous line ended inside an unclosed C-family block comment. */
243+ internal var inBlockComment: Boolean = false
244+ /** True when the previous line ended inside an unclosed triple-quoted string. */
245+ internal var inTripleString: Boolean = false
246+}
247+
248+/** Highlight a single line, carrying [state] across calls so multi-line block comments and
249+ * triple-quoted strings stay colored on every line they span. Mutates [state] in place.
250+ * Use this from line-by-line renderers (the file viewer); use [highlightLine] / [highlightCode]
251+ * for self-contained snippets (chat code fences) where cross-line state isn't worth tracking. */
252+fun highlightLine(line: String, syntax: FileSyntax, palette: HighlightPalette, state: BlockCommentState): AnnotatedString {
253+ val lang = syntax.lang
254+ if (lang == Language.NONE) return AnnotatedString(line)
255+ val keywords = syntax.keywords
256+ val builder = AnnotatedString.Builder(line.length + 8)
257+ val baseStyle = SpanStyle(color = palette.base, fontFamily = FontFamily.Monospace)
258+ val mono = FontFamily.Monospace
259+ val commentStyle = SpanStyle(color = palette.comment, fontStyle = FontStyle.Italic, fontFamily = mono)
260+ val stringStyle = SpanStyle(color = palette.string, fontFamily = mono)
261+
262+ // If we're continuing inside an open block comment, scan for the closing `*/` first;
263+ // everything before it is comment-colored. If none exists on this line, the whole line
264+ // is comment-colored and we stay in the block.
265+ if (state.inBlockComment) {
266+ val closeIdx = line.indexOf("*/")
267+ if (closeIdx < 0) {
268+ builder.withStyle(commentStyle) { append(line) }
269+ return builder.toAnnotatedString()
270+ }
271+ val end = closeIdx + 2
272+ builder.withStyle(commentStyle) { append(line.substring(0, end)) }
273+ state.inBlockComment = false
274+ // Continue highlighting the remainder of the line normally.
275+ var i = end
276+ val n = line.length
277+ while (i < n) {
278+ val c = line[i]
279+ // An opening `/*` inside the remainder re-enters the block.
280+ if (isCBlockCommentOpen(c, line, i, n, lang)) {
281+ val close = findBlockCommentEnd(line, i + 2)
282+ if (close < 0) {
283+ builder.withStyle(commentStyle) { append(line.substring(i)) }
284+ state.inBlockComment = true
285+ return builder.toAnnotatedString()
286+ }
287+ builder.withStyle(commentStyle) { append(line.substring(i, close)) }
288+ i = close
289+ continue
290+ }
291+ val emitted = emitToken(builder, line, i, lang, keywords, palette, baseStyle, mono)
292+ if (emitted.consumed) {
293+ i = emitted.nextIndex
294+ } else {
295+ builder.withStyle(baseStyle) { append(c) }
296+ i++
297+ }
298+ }
299+ return builder.toAnnotatedString()
300+ }
301+
302+ // If we're continuing inside a triple-quoted string, scan for the closing `"""`.
303+ if (state.inTripleString) {
304+ val closeIdx = line.indexOf("\"\"\"")
305+ if (closeIdx < 0) {
306+ builder.withStyle(stringStyle) { append(line) }
307+ return builder.toAnnotatedString()
308+ }
309+ val end = closeIdx + 3
310+ builder.withStyle(stringStyle) { append(line.substring(0, end)) }
311+ state.inTripleString = false
312+ var i = end
313+ val n = line.length
314+ while (i < n) {
315+ val c = line[i]
316+ val emitted = emitToken(builder, line, i, lang, keywords, palette, baseStyle, mono)
317+ if (emitted.consumed) {
318+ i = emitted.nextIndex
319+ } else {
320+ builder.withStyle(baseStyle) { append(c) }
321+ i++
322+ }
323+ }
324+ return builder.toAnnotatedString()
325+ }
326+
327+ // Not currently in a block — run the normal per-line scanner, but intercept `/*` and `"""`
328+ // openings so we can flip state and color the rest of the line accordingly.
329+ var i = 0
330+ val n = line.length
331+ while (i < n) {
332+ val c = line[i]
333+ // Block comment open: C-family `/* ... */`. (Ruby's `=begin`/`=end` is handled below.)
334+ if (isCBlockCommentOpen(c, line, i, n, lang)) {
335+ val close = findBlockCommentEnd(line, i + 2)
336+ if (close < 0) {
337+ builder.withStyle(commentStyle) { append(line.substring(i)) }
338+ state.inBlockComment = true
339+ return builder.toAnnotatedString()
340+ }
341+ builder.withStyle(commentStyle) { append(line.substring(i, close)) }
342+ i = close
343+ continue
344+ }
345+ // Triple-quoted string open: Python/Kotlin `""" ... """`. Only track for languages
346+ // that actually have triple-quoted strings; in plain C/JS a `"` run is just adjacent
347+ // string literals.
348+ if (isTripleStringOpen(c, line, i, n, lang)) {
349+ val close = line.indexOf("\"\"\"", i + 3)
350+ if (close < 0) {
351+ builder.withStyle(stringStyle) { append(line.substring(i)) }
352+ state.inTripleString = true
353+ return builder.toAnnotatedString()
354+ }
355+ val end = close + 3
356+ builder.withStyle(stringStyle) { append(line.substring(i, end)) }
357+ i = end
358+ continue
359+ }
360+ // Ruby `=begin` … `=end` block comment (must be at the start of the line per Ruby
361+ // syntax; we check the leading-run here).
362+ if (isRubyBlockCommentOpen(c, line, i, lang)) {
363+ val close = line.indexOf("=end", i + 6)
364+ if (close < 0) {
365+ builder.withStyle(commentStyle) { append(line.substring(i)) }
366+ state.inBlockComment = true
367+ return builder.toAnnotatedString()
368+ }
369+ val end = close + 4
370+ builder.withStyle(commentStyle) { append(line.substring(i, end)) }
371+ i = end
372+ continue
373+ }
374+ val emitted = emitToken(builder, line, i, lang, keywords, palette, baseStyle, mono)
375+ if (emitted.consumed) {
376+ i = emitted.nextIndex
377+ } else {
378+ builder.withStyle(baseStyle) { append(c) }
379+ i++
380+ }
381+ }
382+ return builder.toAnnotatedString()
383+}
384+
385+/** Index just past the closing block-comment delimiter for a block comment opened at [from],
386+ * or -1 if the line ends before the block closes. */
387+private fun findBlockCommentEnd(line: String, from: Int): Int {
388+ val idx = line.indexOf("*/", from)
389+ return if (idx < 0) -1 else idx + 2
390+}
391+
392+/** True when [line] at [i] opens a C-family block comment (`/` followed by `*`). */
393+private fun isCBlockCommentOpen(c: Char, line: String, i: Int, n: Int, lang: Language): Boolean =
394+ c == '/' && i + 1 < n && line[i + 1] == '*' && lang == Language.C_FAMILY
395+
396+/** True when [line] at [i] opens a triple-quoted string (three double-quotes), for languages
397+ * that support it (Python, Kotlin/C-family). */
398+private fun isTripleStringOpen(c: Char, line: String, i: Int, n: Int, lang: Language): Boolean =
399+ c == '"' && i + 2 < n && line[i + 1] == '"' && line[i + 2] == '"' &&
400+ (lang == Language.PYTHON || lang == Language.C_FAMILY)
401+
402+/** True when [line] at [i] opens a Ruby `=begin` block comment. Must be at the start of the
403+ * line or preceded by whitespace per Ruby syntax. */
404+private fun isRubyBlockCommentOpen(c: Char, line: String, i: Int, lang: Language): Boolean =
405+ lang == Language.RUBY && c == '=' && line.startsWith("=begin", i) &&
406+ (i == 0 || line[i - 1] == ' ' || line[i - 1] == '\t')
407+
231/** Result of attempting to emit one token starting at [start]: the next scan index and
408/** Result of attempting to emit one token starting at [start]: the next scan index and
232 * whether a token was consumed (vs. falling through to the default plain-text emit). */
409 * whether a token was consumed (vs. falling through to the default plain-text emit). */
233private data class EmitResult(val nextIndex: Int, val consumed: Boolean)
410private data class EmitResult(val nextIndex: Int, val consumed: Boolean)
app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+231−100
@@ -13,6 +13,9 @@ import androidx.compose.foundation.layout.height
13import androidx.compose.foundation.layout.defaultMinSize
13import androidx.compose.foundation.layout.defaultMinSize
14import androidx.compose.foundation.layout.padding
14import androidx.compose.foundation.layout.padding
15import androidx.compose.foundation.layout.width
15import androidx.compose.foundation.layout.width
16+import androidx.compose.foundation.lazy.LazyColumn
17+import androidx.compose.foundation.lazy.itemsIndexed
18+import androidx.compose.foundation.lazy.rememberLazyListState
16import androidx.compose.foundation.rememberScrollState
19import androidx.compose.foundation.rememberScrollState
17import androidx.compose.material.icons.Icons
20import androidx.compose.material.icons.Icons
18import androidx.compose.material.icons.filled.ContentCopy
21import androidx.compose.material.icons.filled.ContentCopy
@@ -45,6 +48,7 @@ import androidx.compose.ui.res.pluralStringResource
45import androidx.compose.ui.res.stringResource
48import androidx.compose.ui.res.stringResource
46import soy.iko.opencode.R
49import soy.iko.opencode.R
47import soy.iko.opencode.data.network.NetworkConfig
50import soy.iko.opencode.data.network.NetworkConfig
51+import soy.iko.opencode.ui.theme.diffColors
48
52
49/** A single line of a parsed unified diff. */
53/** A single line of a parsed unified diff. */
50sealed interface DiffLine {
54sealed interface DiffLine {
@@ -162,14 +166,15 @@ private fun syntaxForHeader(headerText: String): FileSyntax? {
162@Composable
166@Composable
163fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = null) {
167fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = null) {
164 val lines = remember(diff) { parseDiff(diff) }
168 val lines = remember(diff) { parseDiff(diff) }
165− // Derive the four row colors once per color scheme instead of allocating fresh Color
169+ // Use dedicated diff add/remove colors (a fixed green/red pair tuned to the theme) so the
166− // objects (the .copy() calls) on every recomposition — a diff re-parses/recomposes
170+ // diff semantics stay consistent under Material You — where the primary role may not be
167− // per token while its output streams under the collapse limit.
171+ // green-tinted, an "added" line would otherwise read as a primary-tinted highlight.
168 val scheme = MaterialTheme.colorScheme
172 val scheme = MaterialTheme.colorScheme
173+ val diffColors = diffColors()
169− val addColor = remember(scheme) { scheme.primary.copy(alpha = 0.15f) }
174+ val addColor = remember(scheme) { diffColors.addBg }
170− val removeColor = remember(scheme) { scheme.error.copy(alpha = 0.15f) }
175+ val removeColor = remember(scheme) { diffColors.removeBg }
171− val addText = scheme.primary
176+ val addText = diffColors.addText
172− val removeText = scheme.error
177+ val removeText = diffColors.removeText
173 val context = LocalContext.current
178 val context = LocalContext.current
174 val hScrollState = rememberScrollState()
179 val hScrollState = rememberScrollState()
175 // Key on a stable id (the part id) when available so streaming updates that grow `diff`
180 // Key on a stable id (the part id) when available so streaming updates that grow `diff`
@@ -189,110 +194,236 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
189 .clip(MaterialTheme.shapes.small)
194 .clip(MaterialTheme.shapes.small)
190 .background(MaterialTheme.colorScheme.surfaceVariant),
195 .background(MaterialTheme.colorScheme.surfaceVariant),
191 ) {
196 ) {
192− Row(
197+ DiffHeader(
193− modifier = Modifier.fillMaxWidth().padding(top = 2.dp, start = 4.dp, end = 4.dp),
198+ lineCount = lines.size,
194− verticalAlignment = Alignment.CenterVertically,
199+ expanded = expanded,
195− ) {
196− // Expand/collapse toggle in the header (matching CodeWithCopy's pattern) so the
197− // user doesn't have to scroll to the bottom of a collapsed diff to expand it.
198− if (lines.size > NetworkConfig.collapsedDiffLineThreshold) {
199− val moreLines = lines.size - NetworkConfig.collapsedDiffLineThreshold
200− IconButton(onClick = { expanded = !expanded }) {
200+ onToggle = { expanded = !expanded },
201− Icon(
202− if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
203− contentDescription = if (expanded) stringResource(R.string.show_less)
204− else context.resources.getQuantityString(
205− R.plurals.show_more_lines, moreLines, moreLines,
206− ),
207− tint = MaterialTheme.colorScheme.onSurfaceVariant,
208− )
209− }
210− }
211− Spacer(Modifier.weight(1f))
212− IconButton(onClick = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) }) {
201+ onCopy = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) },
213− Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
202+ )
214− }
215− }
216 val visibleLines = if (lines.size <= NetworkConfig.collapsedDiffLineThreshold || expanded) lines
203 val visibleLines = if (lines.size <= NetworkConfig.collapsedDiffLineThreshold || expanded) lines
217 else lines.subList(0, NetworkConfig.collapsedDiffLineThreshold)
204 else lines.subList(0, NetworkConfig.collapsedDiffLineThreshold)
218 // One horizontalScroll on the container instead of one per row: each modifier
205 // One horizontalScroll on the container instead of one per row: each modifier
219 // adds a layout node + clip + offset pass, so a 200-line collapsed diff was
206 // adds a layout node + clip + offset pass, so a 200-line collapsed diff was
220 // paying for 200 of them. The shared scroll state still synchronizes all rows.
207 // paying for 200 of them. The shared scroll state still synchronizes all rows.
221− // oldLine/newLine track the current line numbers derived from each @@ hunk header
208+ val rows = remember(visibleLines, addColor, removeColor, addText, removeText, scheme) {
222− // so the gutter can show absolute line numbers (a big readability win for review).
209+ buildDiffRows(
223− var oldLine = 0
210+ visibleLines,
224− var newLine = 0
211+ addColor, removeColor, addText, removeText,
225− val hunkRegex = Regex("""@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@""")
212+ scheme.tertiary, scheme.onSurface, scheme.onSurfaceVariant,
213+ )
214+ }
226 Column(modifier = Modifier.horizontalScroll(hScrollState)) {
215 Column(modifier = Modifier.horizontalScroll(hScrollState)) {
227− visibleLines.forEach { line ->
216+ rows.forEach { row -> RenderDiffRow(row, syntax, palette) }
228− when (line) {
217+ }
229− is DiffLine.Hunk -> {
218+ DiffExpandFooter(lines = lines, expanded = expanded, onToggle = { expanded = !expanded })
230− val m = hunkRegex.find(line.text)
231− if (m != null) {
232− oldLine = m.groupValues[1].toInt()
233− newLine = m.groupValues[2].toInt()
234− }
219+ }
235− Text(
220+}
236− line.text,
221+
237− style = MaterialTheme.typography.bodySmall,
222+/**
238− fontFamily = FontFamily.Monospace,
223+ * Lazy variant of [DiffView] for callers that need the diff to scroll on its own (e.g. the
239− color = MaterialTheme.colorScheme.tertiary,
224+ * file viewer) WITHOUT an enclosing verticalScroll. A 5000-line diff in [DiffView] would
240− modifier = Modifier.padding(horizontal = 10.dp, vertical = 2.dp),
225+ * compose every row eagerly; this variant uses a [LazyColumn] so only the visible rows
241− )
226+ * compose. The collapse threshold still applies for the *initial* view, but expanding
242− }
227+ * switches to lazy rendering so a huge expanded diff doesn't OOM.
243− is DiffLine.FileHeader -> {
228+ */
244− // Render the display path (e.g. `src/Foo.kt`) as a bold header rather
229+@Composable
245− // than the raw `+++ b/src/Foo.kt`, so file boundaries are scannable in
230+fun LazyDiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = null) {
246− // a long multi-file diff. The raw line is still copied with the diff.
231+ val lines = remember(diff) { parseDiff(diff) }
247− val displayPath = extractDisplayPath(line.text)
232+ val scheme = MaterialTheme.colorScheme
248− val isHeader = displayPath != line.text
233+ val diffColors = diffColors()
249− Text(
234+ val addColor = remember(scheme) { diffColors.addBg }
250− displayPath,
235+ val removeColor = remember(scheme) { diffColors.removeBg }
251− style = MaterialTheme.typography.labelMedium,
236+ val addText = diffColors.addText
252− fontFamily = FontFamily.Monospace,
237+ val removeText = diffColors.removeText
253− fontWeight = if (isHeader) FontWeight.SemiBold else FontWeight.Normal,
238+ val context = LocalContext.current
254− color = MaterialTheme.colorScheme.onSurface,
239+ val hScrollState = rememberScrollState()
255− modifier = Modifier
240+ var expanded by rememberSaveable(saveKey ?: diff) { mutableStateOf(false) }
256− .fillMaxWidth()
241+ val syntax = remember(lines) {
257− .padding(horizontal = 10.dp, vertical = 3.dp)
242+ lines.firstOrNull { it is DiffLine.FileHeader }?.let { syntaxForHeader(it.text) }
258− .semantics { heading() },
243+ }
259− )
244+ val palette = rememberHighlightPalette()
260− }
245+ val lazyListState = androidx.compose.foundation.lazy.rememberLazyListState()
261− is DiffLine.Meta -> Text(
246+
262− line.text,
247+ Column(
263− style = MaterialTheme.typography.bodySmall,
248+ modifier = modifier
264− fontFamily = FontFamily.Monospace,
249+ .fillMaxWidth()
265− color = MaterialTheme.colorScheme.onSurfaceVariant,
250+ .clip(MaterialTheme.shapes.small)
266− modifier = Modifier.padding(vertical = 1.dp),
251+ .background(MaterialTheme.colorScheme.surfaceVariant),
267− )
252+ ) {
268− is DiffLine.Add -> {
253+ DiffHeader(
269− val n = newLine++
254+ lineCount = lines.size,
270− DiffRow(line.text, "+", addColor, addText, oldLine = null, newLine = n, syntax = syntax, palette = palette)
255+ expanded = expanded,
271− }
256+ onToggle = { expanded = !expanded },
272− is DiffLine.Remove -> {
257+ onCopy = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) },
273− val o = oldLine++
258+ )
274− DiffRow(line.text, "-", removeColor, removeText, oldLine = o, newLine = null, syntax = syntax, palette = palette)
259+ val visibleLines = if (lines.size <= NetworkConfig.collapsedDiffLineThreshold || expanded) lines
275− }
260+ else lines.subList(0, NetworkConfig.collapsedDiffLineThreshold)
276− is DiffLine.Context -> {
261+ val rows = remember(visibleLines, addColor, removeColor, addText, removeText, scheme) {
277− val o = oldLine++; val n = newLine++
262+ buildDiffRows(
278− DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface, oldLine = o, newLine = n, syntax = syntax, palette = palette)
263+ visibleLines,
279− }
264+ addColor, removeColor, addText, removeText,
280− }
265+ scheme.tertiary, scheme.onSurface, scheme.onSurfaceVariant,
266+ )
267+ }
268+ // One horizontalScroll wrapping the LazyColumn synchronizes horizontal panning across
269+ // visible rows (the same trick DiffView uses on its Column). LazyColumn handles the
270+ // vertical scrolling.
271+ LazyColumn(
272+ state = lazyListState,
273+ modifier = Modifier.horizontalScroll(hScrollState),
274+ ) {
275+ itemsIndexed(
276+ rows,
277+ key = { i, row -> "${row.kind::class.simpleName}:$i:${row.text.hashCode()}" },
278+ ) { _, row ->
279+ RenderDiffRow(row, syntax, palette)
281 }
280 }
282 }
281 }
282+ DiffExpandFooter(lines = lines, expanded = expanded, onToggle = { expanded = !expanded })
283+ }
284+}
285+
286+/** Header row with the expand/collapse toggle (when applicable) and a copy action. */
287+@Composable
288+private fun DiffHeader(lineCount: Int, expanded: Boolean, onToggle: () -> Unit, onCopy: () -> Unit) {
289+ val context = LocalContext.current
290+ Row(
291+ modifier = Modifier.fillMaxWidth().padding(top = 2.dp, start = 4.dp, end = 4.dp),
292+ verticalAlignment = Alignment.CenterVertically,
293+ ) {
283− if (lines.size > NetworkConfig.collapsedDiffLineThreshold) {
294+ if (lineCount > NetworkConfig.collapsedDiffLineThreshold) {
284− val hidden = lines.size - NetworkConfig.collapsedDiffLineThreshold
295+ val moreLines = lineCount - NetworkConfig.collapsedDiffLineThreshold
285− TextButton(
296+ IconButton(onClick = onToggle) {
286− onClick = { expanded = !expanded },
297+ Icon(
287− contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
298+ if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
288− modifier = Modifier.defaultMinSize(minHeight = 48.dp),
289− ) {
290− Text(
291− if (expanded) stringResource(R.string.show_less)
299+ contentDescription = if (expanded) stringResource(R.string.show_less)
300+ else context.resources.getQuantityString(
292− else pluralStringResource(R.plurals.show_more_lines, hidden, hidden),
301+ R.plurals.show_more_lines, moreLines, moreLines,
302+ ),
303+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
293 )
304 )
294 }
305 }
295 }
306 }
307+ Spacer(Modifier.weight(1f))
308+ IconButton(onClick = onCopy) {
309+ Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
310+ }
311+ }
312+}
313+
314+/** Footer "show more/less" button when the diff was collapsed. */
315+@Composable
316+private fun DiffExpandFooter(lines: List<DiffLine>, expanded: Boolean, onToggle: () -> Unit) {
317+ if (lines.size > NetworkConfig.collapsedDiffLineThreshold) {
318+ val hidden = lines.size - NetworkConfig.collapsedDiffLineThreshold
319+ TextButton(
320+ onClick = onToggle,
321+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
322+ modifier = Modifier.defaultMinSize(minHeight = 48.dp),
323+ ) {
324+ Text(
325+ if (expanded) stringResource(R.string.show_less)
326+ else pluralStringResource(R.plurals.show_more_lines, hidden, hidden),
327+ )
328+ }
329+ }
330+}
331+
332+/** A pre-resolved diff row carrying its kind, text, line numbers, and resolved colors. This
333+ * lets both [DiffView] and [LazyDiffView] share the rendering path without re-deriving the
334+ * per-row state (line numbers advance as a side effect of walking the list). */
335+private data class DiffRowItem(
336+ val kind: DiffLine,
337+ val text: String,
338+ val bg: Color,
339+ val textColor: Color,
340+ val oldLine: Int?,
341+ val newLine: Int?,
342+)
343+
344+/** Walk [visibleLines] advancing the old/new line counters per hunk, producing a list of
345+ * [DiffRowItem]s that the renderers can iterate without per-row state. Theme colors for the
346+ * non-add/remove rows (tertiary for hunk headers, onSurface/onSurfaceVariant for context
347+ * and meta) are passed in by the @Composable caller so the row-builder stays a plain
348+ * function that doesn't read MaterialTheme directly. */
349+private fun buildDiffRows(
350+ visibleLines: List<DiffLine>,
351+ addColor: Color,
352+ removeColor: Color,
353+ addText: Color,
354+ removeText: Color,
355+ tertiary: Color,
356+ onSurface: Color,
357+ onSurfaceVariant: Color,
358+): List<DiffRowItem> {
359+ var oldLine = 0
360+ var newLine = 0
361+ val hunkRegex = Regex("""@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@""")
362+ return visibleLines.map { line ->
363+ when (line) {
364+ is DiffLine.Hunk -> {
365+ val m = hunkRegex.find(line.text)
366+ if (m != null) {
367+ oldLine = m.groupValues[1].toInt()
368+ newLine = m.groupValues[2].toInt()
369+ }
370+ DiffRowItem(line, line.text, Color.Transparent, tertiary, null, null)
371+ }
372+ is DiffLine.FileHeader -> DiffRowItem(line, line.text, Color.Transparent, onSurface, null, null)
373+ is DiffLine.Meta -> DiffRowItem(line, line.text, Color.Transparent, onSurfaceVariant, null, null)
374+ is DiffLine.Add -> {
375+ val n = newLine++
376+ DiffRowItem(line, line.text, addColor, addText, null, n)
377+ }
378+ is DiffLine.Remove -> {
379+ val o = oldLine++
380+ DiffRowItem(line, line.text, removeColor, removeText, o, null)
381+ }
382+ is DiffLine.Context -> {
383+ val o = oldLine++; val n = newLine++
384+ DiffRowItem(line, line.text, Color.Transparent, onSurface, o, n)
385+ }
386+ }
387+ }
388+}
389+
390+/** Render a single [DiffRowItem]. Hunk and FileHeader rows get their own styling; Add/Remove/
391+ * Context delegate to [DiffRow]. */
392+@Composable
393+private fun RenderDiffRow(row: DiffRowItem, syntax: FileSyntax?, palette: HighlightPalette) {
394+ when (row.kind) {
395+ is DiffLine.Hunk -> Text(
396+ row.text,
397+ style = MaterialTheme.typography.bodySmall,
398+ fontFamily = FontFamily.Monospace,
399+ color = MaterialTheme.colorScheme.tertiary,
400+ modifier = Modifier.padding(horizontal = 10.dp, vertical = 2.dp),
401+ )
402+ is DiffLine.FileHeader -> {
403+ val displayPath = extractDisplayPath(row.text)
404+ val isHeader = displayPath != row.text
405+ Text(
406+ displayPath,
407+ style = MaterialTheme.typography.labelMedium,
408+ fontFamily = FontFamily.Monospace,
409+ fontWeight = if (isHeader) FontWeight.SemiBold else FontWeight.Normal,
410+ color = MaterialTheme.colorScheme.onSurface,
411+ modifier = Modifier
412+ .fillMaxWidth()
413+ .padding(horizontal = 10.dp, vertical = 3.dp)
414+ .semantics { heading() },
415+ )
416+ }
417+ is DiffLine.Meta -> Text(
418+ row.text,
419+ style = MaterialTheme.typography.bodySmall,
420+ fontFamily = FontFamily.Monospace,
421+ color = MaterialTheme.colorScheme.onSurfaceVariant,
422+ modifier = Modifier.padding(vertical = 1.dp),
423+ )
424+ is DiffLine.Add -> DiffRow(row.text, "+", row.bg, row.textColor, oldLine = row.oldLine, newLine = row.newLine, syntax = syntax, palette = palette)
425+ is DiffLine.Remove -> DiffRow(row.text, "-", row.bg, row.textColor, oldLine = row.oldLine, newLine = row.newLine, syntax = syntax, palette = palette)
426+ is DiffLine.Context -> DiffRow(row.text, " ", row.bg, row.textColor, oldLine = row.oldLine, newLine = row.newLine, syntax = syntax, palette = palette)
296 }
427 }
297}
428}
298
429
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+144−1
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.WindowInsets
12import androidx.compose.foundation.layout.fillMaxSize
12import androidx.compose.foundation.layout.fillMaxSize
13import androidx.compose.foundation.layout.fillMaxWidth
13import androidx.compose.foundation.layout.fillMaxWidth
14import androidx.compose.foundation.layout.heightIn
14import androidx.compose.foundation.layout.heightIn
15+import androidx.compose.foundation.layout.navigationBars
15import androidx.compose.foundation.layout.padding
16import androidx.compose.foundation.layout.padding
16import androidx.compose.foundation.layout.statusBars
17import androidx.compose.foundation.layout.statusBars
17import androidx.compose.foundation.layout.windowInsetsPadding
18import androidx.compose.foundation.layout.windowInsetsPadding
@@ -39,7 +40,9 @@ import androidx.compose.ui.platform.LocalContext
39import androidx.compose.ui.res.stringResource
40import androidx.compose.ui.res.stringResource
40import androidx.compose.ui.semantics.Role
41import androidx.compose.ui.semantics.Role
41import androidx.compose.ui.semantics.contentDescription
42import androidx.compose.ui.semantics.contentDescription
43+import androidx.compose.ui.semantics.invisibleToUser
42import androidx.compose.ui.semantics.semantics
44import androidx.compose.ui.semantics.semantics
45+import androidx.compose.ui.text.style.TextOverflow
43import androidx.compose.ui.unit.dp
46import androidx.compose.ui.unit.dp
44import androidx.compose.ui.window.Dialog
47import androidx.compose.ui.window.Dialog
45import androidx.compose.ui.window.DialogProperties
48import androidx.compose.ui.window.DialogProperties
@@ -53,12 +56,14 @@ import kotlinx.coroutines.withContext
53import androidx.compose.material.icons.Icons
56import androidx.compose.material.icons.Icons
54import androidx.compose.material.icons.filled.BrokenImage
57import androidx.compose.material.icons.filled.BrokenImage
55import androidx.compose.material.icons.filled.Close
58import androidx.compose.material.icons.filled.Close
59+import androidx.compose.material.icons.filled.MoreVert
56import soy.iko.opencode.R
60import soy.iko.opencode.R
57import soy.iko.opencode.data.model.FilePart
61import soy.iko.opencode.data.model.FilePart
58import soy.iko.opencode.data.model.ServerProfile
62import soy.iko.opencode.data.model.ServerProfile
59import soy.iko.opencode.data.model.sourcePath
63import soy.iko.opencode.data.model.sourcePath
60import soy.iko.opencode.data.network.HttpClientFactory
64import soy.iko.opencode.data.network.HttpClientFactory
61import soy.iko.opencode.data.network.NetworkConfig
65import soy.iko.opencode.data.network.NetworkConfig
66+import soy.iko.opencode.util.runCatchingCancellable
62
67
63/**
68/**
64 * Carries the bits needed to load an image off the opencode server: the base URL (for
69 * Carries the bits needed to load an image off the opencode server: the base URL (for
@@ -243,7 +248,7 @@ fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modi
243 * Fullscreen zoomable image viewer opened by tapping an inline [RemoteImage]. Supports
248 * Fullscreen zoomable image viewer opened by tapping an inline [RemoteImage]. Supports
244 * pinch-to-zoom and pan via [detectTransformGestures], double-tap to toggle zoom, and
249 * pinch-to-zoom and pan via [detectTransformGestures], double-tap to toggle zoom, and
245 * re-centers when zoom returns to 1×. Uses a non-interactive Dialog window so it overlays
250 * re-centers when zoom returns to 1×. Uses a non-interactive Dialog window so it overlays
246− * the whole screen.
251+ * the whole screen. Offers Save-to-gallery and Share actions via the top bar.
247 */
252 */
248@Composable
253@Composable
249private fun FullscreenImageViewer(
254private fun FullscreenImageViewer(
@@ -257,6 +262,7 @@ private fun FullscreenImageViewer(
257 var offsetX by remember { mutableFloatStateOf(0f) }
262 var offsetX by remember { mutableFloatStateOf(0f) }
258 var offsetY by remember { mutableFloatStateOf(0f) }
263 var offsetY by remember { mutableFloatStateOf(0f) }
259 val scope = rememberCoroutineScope()
264 val scope = rememberCoroutineScope()
265+ val context = LocalContext.current
260 // Double-tap zoom tween, collapsed to an instant snap under reduced motion. Resolved in
266 // Double-tap zoom tween, collapsed to an instant snap under reduced motion. Resolved in
261 // the composable body (not inside the pointerInput coroutine) since it reads the
267 // the composable body (not inside the pointerInput coroutine) since it reads the
262 // composition's LocalReducedMotion.
268 // composition's LocalReducedMotion.
@@ -267,6 +273,11 @@ private fun FullscreenImageViewer(
267 // a continuous motion rather than a snap.
273 // a continuous motion rather than a snap.
268 var dragOffsetY by remember { mutableFloatStateOf(0f) }
274 var dragOffsetY by remember { mutableFloatStateOf(0f) }
269 val dismissThreshold = NetworkConfig.imageViewerSwipeDismissThreshold
275 val dismissThreshold = NetworkConfig.imageViewerSwipeDismissThreshold
276+ var showOverflow by remember { mutableStateOf(false) }
277+ val saveLabel = stringResource(R.string.save_image)
278+ val shareLabel = stringResource(R.string.share_image)
279+ val savedLabel = stringResource(R.string.image_saved)
280+ val saveFailedLabel = stringResource(R.string.image_save_failed)
270 Dialog(
281 Dialog(
271 onDismissRequest = onDismiss,
282 onDismissRequest = onDismiss,
272 properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
283 properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
@@ -365,6 +376,71 @@ private fun FullscreenImageViewer(
365 tint = androidx.compose.ui.graphics.Color.White,
376 tint = androidx.compose.ui.graphics.Color.White,
366 )
377 )
367 }
378 }
379+ // Overflow with Save-to-gallery and Share. Save decodes the request's bytes via
380+ // Coil and writes them to MediaStore (Pictures/opencode); Share fires ACTION_SEND
381+ // with a content Uri. Both run off the main thread on the coroutine scope.
382+ Box(
383+ modifier = Modifier
384+ .align(Alignment.TopStart)
385+ .windowInsetsPadding(WindowInsets.statusBars)
386+ .padding(start = 8.dp),
387+ ) {
388+ androidx.compose.material3.IconButton(onClick = { showOverflow = true }) {
389+ Icon(
390+ Icons.Filled.MoreVert,
391+ contentDescription = stringResource(R.string.more_options),
392+ tint = androidx.compose.ui.graphics.Color.White,
393+ )
394+ }
395+ androidx.compose.material3.DropdownMenu(
396+ expanded = showOverflow,
397+ onDismissRequest = { showOverflow = false },
398+ ) {
399+ androidx.compose.material3.DropdownMenuItem(
400+ text = { Text(saveLabel) },
401+ onClick = {
402+ showOverflow = false
403+ scope.launch {
404+ val ok = runCatchingCancellable { saveImageToGallery(context, request, contentDescription) }.isSuccess
405+ showToast(context, if (ok) savedLabel else saveFailedLabel)
406+ }
407+ },
408+ )
409+ androidx.compose.material3.DropdownMenuItem(
410+ text = { Text(shareLabel) },
411+ onClick = {
412+ showOverflow = false
413+ scope.launch {
414+ runCatchingCancellable { shareImage(context, request, contentDescription) }
415+ .onFailure { showToast(context, saveFailedLabel) }
416+ }
417+ },
418+ )
419+ }
420+ }
421+ // Show the alt text / filename as a caption at the bottom of the viewer so a user
422+ // can tell what the image is (e.g. an agent-named diagram) without leaving fullscreen.
423+ // The contentDescription is already used by the image itself for TalkBack; the caption
424+ // is hidden from a11y here to avoid double-announcing.
425+ if (contentDescription.isNotBlank() && contentDescription != stringResource(R.string.image)) {
426+ Box(
427+ modifier = Modifier
428+ .align(Alignment.BottomCenter)
429+ .fillMaxWidth()
430+ .background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.5f))
431+ .windowInsetsPadding(WindowInsets.navigationBars)
432+ .padding(horizontal = 16.dp, vertical = 8.dp),
433+ ) {
434+ Text(
435+ contentDescription,
436+ color = androidx.compose.ui.graphics.Color.White,
437+ style = MaterialTheme.typography.bodySmall,
438+ maxLines = 2,
439+ overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
440+ modifier = Modifier.semantics { invisibleToUser() },
441+ )
442+ }
443+ }
368 }
444 }
369 }
445 }
370}
446}
@@ -415,3 +491,70 @@ private fun BrokenImageIcon() {
415 tint = MaterialTheme.colorScheme.onSurfaceVariant,
491 tint = MaterialTheme.colorScheme.onSurfaceVariant,
416 )
492 )
417}
493}
494+
495+/** Decode the image behind [request] via Coil and write it to the system MediaStore
496+ * (Pictures/opencode) so it appears in the gallery. Returns true on success. Runs on a
497+ * background dispatcher; callers should use runCatchingCancellable. */
498+private suspend fun saveImageToGallery(
499+ context: android.content.Context,
500+ request: ImageRequest,
501+ displayName: String,
502+): Boolean {
503+ val bitmap = decodeRequestBitmap(context, request) ?: return false
504+ val resolver = context.contentResolver
505+ val name = (displayName.ifBlank { "opencode-image" }).take(100)
506+ val timestamp = System.currentTimeMillis() / 1000
507+ val values = android.content.ContentValues().apply {
508+ put(android.provider.MediaStore.Images.Media.DISPLAY_NAME, "$name-${timestamp}.png")
509+ put(android.provider.MediaStore.Images.Media.MIME_TYPE, "image/png")
510+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
511+ put(android.provider.MediaStore.Images.Media.RELATIVE_PATH, "Pictures/opencode")
512+ put(android.provider.MediaStore.Images.Media.IS_PENDING, 1)
513+ }
514+ }
515+ val uri = resolver.insert(android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
516+ ?: return false
517+ return runCatching {
518+ resolver.openOutputStream(uri)?.use { out ->
519+ bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, out)
520+ } ?: error("no output stream")
521+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
522+ values.clear()
523+ values.put(android.provider.MediaStore.Images.Media.IS_PENDING, 0)
524+ resolver.update(uri, values, null, null)
525+ }
526+ true
527+ }.getOrDefault(false)
528+}
529+
530+/** Decode the image behind [request] to a Bitmap via Coil's ImageLoader. Returns null on
531+ * failure. Suspending so callers run it on a background dispatcher. */
532+private suspend fun decodeRequestBitmap(context: android.content.Context, request: ImageRequest): android.graphics.Bitmap? {
533+ return soy.iko.opencode.util.runCatchingCancellableSuspend {
534+ val loader = coil.Coil.imageLoader(context)
535+ val result = loader.execute(request)
536+ (result.drawable as? android.graphics.drawable.BitmapDrawable)?.bitmap
537+ }.getOrNull()
538+}
539+
540+/** Share the image behind [request] via ACTION_SEND. Writes a temp file to the cache dir and
541+ * grants the chooser read access via FileProvider. */
542+private suspend fun shareImage(
543+ context: android.content.Context,
544+ request: ImageRequest,
545+ displayName: String,
546+) {
547+ val bitmap = decodeRequestBitmap(context, request) ?: error("decode failed")
548+ val name = (displayName.ifBlank { "opencode-image" }).take(80)
549+ val file = java.io.File(context.cacheDir, "share/$name-${System.currentTimeMillis()}.png")
550+ file.parentFile?.mkdirs()
551+ file.outputStream().use { bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) }
552+ val authority = "${context.packageName}.fileprovider"
553+ val uri = androidx.core.content.FileProvider.getUriForFile(context, authority, file)
554+ val intent = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
555+ type = "image/png"
556+ putExtra(android.content.Intent.EXTRA_STREAM, uri)
557+ addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
558+ }
559+ context.startActivity(android.content.Intent.createChooser(intent, displayName))
560+}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+26−5
@@ -280,13 +280,20 @@ private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Mo
280 // Scroll to the deepest segment when the path changes so the current directory stays in
280 // Scroll to the deepest segment when the path changes so the current directory stays in
281 // view instead of hiding off the right edge behind the earlier segments on a deep path.
281 // view instead of hiding off the right edge behind the earlier segments on a deep path.
282 LaunchedEffect(path) { scrollState.animateScrollTo(scrollState.maxValue) }
282 LaunchedEffect(path) { scrollState.animateScrollTo(scrollState.maxValue) }
283+ // Haptic on breadcrumb navigation for consistency with the session cards / message actions,
284+ // which all fire TextHandleMove on tap.
285+ val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
286+ val onSegmentClick: (String) -> Unit = { target ->
287+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
288+ onNavigate(target)
289+ }
283 Row(
290 Row(
284 modifier = modifier
291 modifier = modifier
285 .horizontalScroll(scrollState)
292 .horizontalScroll(scrollState)
286 .padding(horizontal = 16.dp, vertical = 8.dp),
293 .padding(horizontal = 16.dp, vertical = 8.dp),
287 verticalAlignment = Alignment.CenterVertically,
294 verticalAlignment = Alignment.CenterVertically,
288 ) {
295 ) {
289− IconButton(onClick = { onNavigate("") }) {
296+ IconButton(onClick = { onSegmentClick("") }) {
290 Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(24.dp))
297 Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(24.dp))
291 }
298 }
292 var acc = ""
299 var acc = ""
@@ -312,7 +319,7 @@ private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Mo
312 overflow = TextOverflow.Ellipsis,
319 overflow = TextOverflow.Ellipsis,
313 modifier = Modifier
320 modifier = Modifier
314 .widthIn(max = segMaxWidth)
321 .widthIn(max = segMaxWidth)
315− .clickable(role = Role.Button) { onNavigate(target) }
322+ .clickable(role = Role.Button) { onSegmentClick(target) }
316 .defaultMinSize(minHeight = 48.dp)
323 .defaultMinSize(minHeight = 48.dp)
317 .padding(horizontal = 6.dp, vertical = 14.dp),
324 .padding(horizontal = 6.dp, vertical = 14.dp),
318 )
325 )
@@ -374,6 +381,7 @@ private fun searchEmptyMessage(searching: Boolean, queryBlank: Boolean, hint: In
374/** Content (ripgrep) search results: file + line number, with the matched line highlighted. */
381/** Content (ripgrep) search results: file + line number, with the matched line highlighted. */
375@Composable
382@Composable
376private fun TextResults(results: List<FindMatch>, emptyMessage: String?, onOpen: (String, Int?) -> Unit) {
383private fun TextResults(results: List<FindMatch>, emptyMessage: String?, onOpen: (String, Int?) -> Unit) {
384+ val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
377 if (results.isEmpty()) {
385 if (results.isEmpty()) {
378 if (emptyMessage != null) {
386 if (emptyMessage != null) {
379 EmptyFileState(
387 EmptyFileState(
@@ -403,7 +411,10 @@ private fun TextResults(results: List<FindMatch>, emptyMessage: String?, onOpen:
403 modifier = Modifier
411 modifier = Modifier
404 .fillMaxWidth()
412 .fillMaxWidth()
405 .defaultMinSize(minHeight = 48.dp)
413 .defaultMinSize(minHeight = 48.dp)
414+ .clickable(role = Role.Button) {
415+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
406− .clickable(role = Role.Button) { onOpen(match.filePath, match.lineNumber) }
416+ onOpen(match.filePath, match.lineNumber)
417+ }
407 .padding(horizontal = 16.dp, vertical = 10.dp),
418 .padding(horizontal = 16.dp, vertical = 10.dp),
408 ) {
419 ) {
409 Row(verticalAlignment = Alignment.CenterVertically) {
420 Row(verticalAlignment = Alignment.CenterVertically) {
@@ -448,6 +459,7 @@ private fun TextResults(results: List<FindMatch>, emptyMessage: String?, onOpen:
448/** Workspace symbol results: symbol name + kind, and the file:line it's defined at. */
459/** Workspace symbol results: symbol name + kind, and the file:line it's defined at. */
449@Composable
460@Composable
450private fun SymbolResults(results: List<SymbolResult>, emptyMessage: String?, onOpen: (String, Int?) -> Unit) {
461private fun SymbolResults(results: List<SymbolResult>, emptyMessage: String?, onOpen: (String, Int?) -> Unit) {
462+ val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
451 if (results.isEmpty()) {
463 if (results.isEmpty()) {
452 if (emptyMessage != null) {
464 if (emptyMessage != null) {
453 EmptyFileState(
465 EmptyFileState(
@@ -475,7 +487,10 @@ private fun SymbolResults(results: List<SymbolResult>, emptyMessage: String?, on
475 modifier = Modifier
487 modifier = Modifier
476 .fillMaxWidth()
488 .fillMaxWidth()
477 .defaultMinSize(minHeight = 48.dp)
489 .defaultMinSize(minHeight = 48.dp)
490+ .clickable(role = Role.Button) {
491+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
478− .clickable(role = Role.Button) { onOpen(symbol.filePath, symbol.displayLine) }
492+ onOpen(symbol.filePath, symbol.displayLine)
493+ }
479 .padding(horizontal = 16.dp, vertical = 10.dp),
494 .padding(horizontal = 16.dp, vertical = 10.dp),
480 ) {
495 ) {
481 Row(verticalAlignment = Alignment.CenterVertically) {
496 Row(verticalAlignment = Alignment.CenterVertically) {
@@ -688,6 +703,12 @@ private fun FileRow(
688 modifier: Modifier = Modifier,
703 modifier: Modifier = Modifier,
689) {
704) {
690 val copyPathLabel = stringResource(R.string.copy_path)
705 val copyPathLabel = stringResource(R.string.copy_path)
706+ // Haptic on row open for consistency with the breadcrumb taps and other screens.
707+ val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
708+ val onRowClick: () -> Unit = {
709+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
710+ onClick()
711+ }
691 // Long-press → "Copy path" dropdown. Rendered only when [onCopyPath] is supplied, so the
712 // Long-press → "Copy path" dropdown. Rendered only when [onCopyPath] is supplied, so the
692 // parent ".." row (which passes none) stays a plain tap target. Anchored within the row.
713 // parent ".." row (which passes none) stays a plain tap target. Anchored within the row.
693 // rememberSaveable so an open row menu survives a rotation instead of closing.
714 // rememberSaveable so an open row menu survives a rotation instead of closing.
@@ -716,7 +737,7 @@ private fun FileRow(
716 .defaultMinSize(minHeight = 48.dp)
737 .defaultMinSize(minHeight = 48.dp)
717 .combinedClickable(
738 .combinedClickable(
718 role = Role.Button,
739 role = Role.Button,
719− onClick = onClick,
740+ onClick = onRowClick,
720 onLongClick = onCopyPath?.let { { menu = true } },
741 onLongClick = onCopyPath?.let { { menu = true } },
721 onLongClickLabel = onCopyPath?.let { copyPathLabel },
742 onLongClickLabel = onCopyPath?.let { copyPathLabel },
722 )
743 )
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+97−7
@@ -2,6 +2,10 @@ package soy.iko.opencode.ui.file
2
2
3import android.content.Intent
3import android.content.Intent
4import androidx.compose.animation.AnimatedContent
4import androidx.compose.animation.AnimatedContent
5+import androidx.compose.animation.core.RepeatMode
6+import androidx.compose.animation.core.animateFloat
7+import androidx.compose.animation.core.infiniteRepeatable
8+import androidx.compose.animation.core.rememberInfiniteTransition
5import androidx.compose.animation.core.tween
9import androidx.compose.animation.core.tween
6import androidx.compose.animation.fadeIn
10import androidx.compose.animation.fadeIn
7import androidx.compose.animation.fadeOut
11import androidx.compose.animation.fadeOut
@@ -17,6 +21,7 @@ import androidx.compose.foundation.layout.Row
17import androidx.compose.foundation.layout.Spacer
21import androidx.compose.foundation.layout.Spacer
18import androidx.compose.foundation.layout.fillMaxSize
22import androidx.compose.foundation.layout.fillMaxSize
19import androidx.compose.foundation.layout.fillMaxWidth
23import androidx.compose.foundation.layout.fillMaxWidth
24+import androidx.compose.foundation.layout.height
20import androidx.compose.foundation.layout.imePadding
25import androidx.compose.foundation.layout.imePadding
21import androidx.compose.foundation.layout.padding
26import androidx.compose.foundation.layout.padding
22import androidx.compose.foundation.layout.size
27import androidx.compose.foundation.layout.size
@@ -29,7 +34,6 @@ import androidx.compose.foundation.rememberScrollState
29import androidx.compose.foundation.text.KeyboardActions
34import androidx.compose.foundation.text.KeyboardActions
30import androidx.compose.foundation.text.KeyboardOptions
35import androidx.compose.foundation.text.KeyboardOptions
31import androidx.compose.foundation.text.selection.SelectionContainer
36import androidx.compose.foundation.text.selection.SelectionContainer
32−import androidx.compose.foundation.verticalScroll
33import androidx.compose.material.icons.Icons
37import androidx.compose.material.icons.Icons
34import androidx.compose.material.icons.automirrored.filled.ArrowBack
38import androidx.compose.material.icons.automirrored.filled.ArrowBack
35import androidx.compose.material.icons.automirrored.filled.WrapText
39import androidx.compose.material.icons.automirrored.filled.WrapText
@@ -72,6 +76,8 @@ import androidx.compose.runtime.saveable.rememberSaveable
72import androidx.compose.runtime.setValue
76import androidx.compose.runtime.setValue
73import androidx.compose.ui.Alignment
77import androidx.compose.ui.Alignment
74import androidx.compose.ui.Modifier
78import androidx.compose.ui.Modifier
79+import androidx.compose.ui.draw.alpha
80+import androidx.compose.ui.draw.clip
75import androidx.compose.ui.layout.onSizeChanged
81import androidx.compose.ui.layout.onSizeChanged
76import androidx.compose.ui.platform.LocalContext
82import androidx.compose.ui.platform.LocalContext
77import androidx.compose.ui.platform.LocalDensity
83import androidx.compose.ui.platform.LocalDensity
@@ -596,13 +602,56 @@ private fun BoxScope.FileViewStateContent(
596 onToggleCaseSensitive: () -> Unit,
602 onToggleCaseSensitive: () -> Unit,
597) {
603) {
598 when {
604 when {
599− // Full-screen spinner only on the initial load (no content yet). A reload keeps the
605+ // Full-screen skeleton only on the initial load (no content yet). A reload keeps the
600 // existing content on screen; the top-bar reload button shows the in-flight progress.
606 // existing content on screen; the top-bar reload button shows the in-flight progress.
607+ // Skeleton "code line" rows pre-structure the layout so a large file's load feels
608+ // faster than a bare spinner and the real content cross-fades into an already-shaped
609+ // gutter+text area.
601 state.loading && state.content == null -> {
610 state.loading && state.content == null -> {
602 val loadingLabel = stringResource(R.string.loading)
611 val loadingLabel = stringResource(R.string.loading)
603− CircularProgressIndicator(
612+ val reducedMotion = soy.iko.opencode.ui.components.LocalReducedMotion.current
604− Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
613+ val transition = rememberInfiniteTransition(label = "file_skeleton")
614+ val pulse by transition.animateFloat(
615+ initialValue = 0.35f,
616+ targetValue = 0.7f,
617+ animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse),
618+ label = "fileSkeletonAlpha",
605 )
619 )
620+ val skeletonAlpha = if (reducedMotion) 0.5f else pulse
621+ val skeletonColor = MaterialTheme.colorScheme.surfaceVariant
622+ Column(
623+ modifier = Modifier
624+ .align(Alignment.TopCenter)
625+ .padding(top = 24.dp)
626+ .padding(horizontal = 16.dp)
627+ .fillMaxWidth()
628+ .semantics { contentDescription = loadingLabel },
629+ verticalArrangement = Arrangement.spacedBy(8.dp),
630+ ) {
631+ repeat(10) { i ->
632+ Row(
633+ modifier = Modifier.fillMaxWidth(),
634+ horizontalArrangement = Arrangement.spacedBy(8.dp),
635+ ) {
636+ Box(
637+ Modifier
638+ .width(36.dp)
639+ .height(14.dp)
640+ .clip(MaterialTheme.shapes.small)
641+ .background(skeletonColor)
642+ .alpha(skeletonAlpha),
643+ )
644+ Box(
645+ Modifier
646+ .fillMaxWidth(if (i % 3 == 0) 0.9f else 0.7f)
647+ .height(14.dp)
648+ .clip(MaterialTheme.shapes.small)
649+ .background(skeletonColor)
650+ .alpha(skeletonAlpha),
651+ )
652+ }
653+ }
654+ }
606 }
655 }
607 state.error != null -> Column(
656 state.error != null -> Column(
608 modifier = Modifier.align(Alignment.Center).padding(24.dp),
657 modifier = Modifier.align(Alignment.Center).padding(24.dp),
@@ -735,12 +784,13 @@ private fun BoxScope.FileViewContentBody(
735 }
784 }
736 }
785 }
737 if (showDiff && content?.diff != null && content.diff.isNotBlank()) {
786 if (showDiff && content?.diff != null && content.diff.isNotBlank()) {
738− DiffView(
787+ // Use LazyDiffView so a 5000-line diff composes only the visible rows. The previous
788+ // DiffView + verticalScroll composed every row eagerly, which OOM'd on large diffs.
789+ soy.iko.opencode.ui.components.LazyDiffView(
739 diff = content.diff,
790 diff = content.diff,
740 modifier = Modifier
791 modifier = Modifier
741 .fillMaxSize()
792 .fillMaxSize()
742 .padding(top = topInset)
793 .padding(top = topInset)
743− .verticalScroll(rememberScrollState())
744 .padding(8.dp),
794 .padding(8.dp),
745 )
795 )
746 } else {
796 } else {
@@ -876,6 +926,32 @@ private fun FileTextContent(
876 val palette = rememberHighlightPalette()
926 val palette = rememberHighlightPalette()
877 // Resolve the file's language once instead of re-parsing the extension for every line.
927 // Resolve the file's language once instead of re-parsing the extension for every line.
878 val syntax = remember(filename) { syntaxFor(filename) }
928 val syntax = remember(filename) { syntaxFor(filename) }
929+ // Pre-compute per-line block-comment/triple-string state snapshots so a LazyColumn row
930+ // can highlight its line correctly without re-walking every preceding line on each
931+ // recomposition. Each snapshot is two booleans; for a 5000-line file that's ~10KB,
932+ // trivial next to the line strings themselves. highlightLine(line, syntax, palette, state)
933+ // mutates the passed state in place, so we give each line its own snapshot derived from
934+ // the prior line's end state — this is what makes a 50-line block comment color correctly
935+ // on every line, not just the opener.
936+ val lineStates = remember(lines, syntax) {
937+ if (syntax.lang == soy.iko.opencode.ui.components.Language.NONE) {
938+ emptyList()
939+ } else {
940+ val states = ArrayList<soy.iko.opencode.ui.components.BlockCommentState>(lines.size)
941+ var current = soy.iko.opencode.ui.components.BlockCommentState()
942+ for (line in lines) {
943+ val snapshot = soy.iko.opencode.ui.components.BlockCommentState().also {
944+ it.inBlockComment = current.inBlockComment
945+ it.inTripleString = current.inTripleString
946+ }
947+ states.add(snapshot)
948+ // Advance the running state by highlighting into a throwaway builder — the
949+ // side effect on `current` is what we need. We discard the AnnotatedString.
950+ soy.iko.opencode.ui.components.highlightLine(line, syntax, palette, current)
951+ }
952+ states
953+ }
954+ }
879 val q = findQuery.trim()
955 val q = findQuery.trim()
880 val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
956 val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
881 MaterialTheme(typography = scaled) {
957 MaterialTheme(typography = scaled) {
@@ -937,7 +1013,21 @@ private fun FileTextContent(
937 // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
1013 // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
938 // file, or palette actually changes — not on every recomposition (e.g. every
1014 // file, or palette actually changes — not on every recomposition (e.g. every
939 // keystroke into find-in-file, which would otherwise re-highlight all visible lines).
1015 // keystroke into find-in-file, which would otherwise re-highlight all visible lines).
1016+ // Uses the stateful highlightLine so multi-line block comments and triple-quoted
1017+ // strings color correctly on every line they span (the per-line snapshot in
1018+ // lineStates carries the in-block/in-trString flag for this line's position).
940− val highlighted = remember(line, syntax, palette) { highlightLine(line, syntax, palette) }
1019+ val highlighted = remember(line, syntax, palette, index) {
1020+ val state = lineStates.getOrNull(index)
1021+ if (state != null) {
1022+ val s = soy.iko.opencode.ui.components.BlockCommentState().also {
1023+ it.inBlockComment = state.inBlockComment
1024+ it.inTripleString = state.inTripleString
1025+ }
1026+ soy.iko.opencode.ui.components.highlightLine(line, syntax, palette, s)
1027+ } else {
1028+ soy.iko.opencode.ui.components.highlightLine(line, syntax, palette)
1029+ }
1030+ }
941 Text(
1031 Text(
942 highlighted,
1032 highlighted,
943 modifier = Modifier.padding(start = 8.dp),
1033 modifier = Modifier.padding(start = 8.dp),
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+19−6
@@ -168,12 +168,25 @@ fun GlobalSearchScreen(
168 }
168 }
169 if (state.truncated) {
169 if (state.truncated) {
170 item(key = "__truncated") {
170 item(key = "__truncated") {
171− Text(
171+ // Surface that the search was capped AND offer a one-tap "search
172+ // more" so users with >50 sessions can page through older matches
173+ // instead of being stuck at the cap.
174+ Column(
175+ modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
176+ ) {
177+ Text(
172− stringResource(R.string.search_truncated),
178+ stringResource(R.string.search_truncated),
173− style = MaterialTheme.typography.labelSmall,
179+ style = MaterialTheme.typography.labelSmall,
174− color = MaterialTheme.colorScheme.onSurfaceVariant,
180+ color = MaterialTheme.colorScheme.onSurfaceVariant,
175− modifier = Modifier.padding(vertical = 4.dp),
181+ )
182+ androidx.compose.material3.TextButton(
183+ onClick = { vm.searchMore() },
184+ enabled = !state.searching,
185+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
176− )
186+ ) {
187+ Text(stringResource(R.string.search_more))
188+ }
189+ }
177 }
190 }
178 }
191 }
179 items(state.results, key = { it.session.id }) { hit ->
192 items(state.results, key = { it.session.id }) { hit ->
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchViewModel.kt+22−6
@@ -52,8 +52,9 @@ data class GlobalSearchState(
52 /** True once a search has run for the current query (distinguishes "no results" from
52 /** True once a search has run for the current query (distinguishes "no results" from
53 * "nothing searched yet" so the UI shows the right empty state). */
53 * "nothing searched yet" so the UI shows the right empty state). */
54 val hasSearched: Boolean = false,
54 val hasSearched: Boolean = false,
55− /** True when there were more sessions than [NetworkConfig.maxSearchSessions], so the
55+ /** True when there were more sessions than the current cap, so the search only covered
56− * search only covered the most recent ones. */
56+ * the most recent ones. The cap starts at [NetworkConfig.maxSearchSessions] and grows
57+ * via [searchMore] so the user can page through older sessions. */
57 val truncated: Boolean = false,
58 val truncated: Boolean = false,
58 /** How many sessions have been scanned so far in the current pass, for progress feedback
59 /** How many sessions have been scanned so far in the current pass, for progress feedback
59 * (a global search downloads each session's history, so a 50-session pass can take a
60 * (a global search downloads each session's history, so a 50-session pass can take a
@@ -61,6 +62,8 @@ data class GlobalSearchState(
61 val searchedCount: Int = 0,
62 val searchedCount: Int = 0,
62 /** Total sessions being scanned in the current pass ([searchedCount] / [totalCount]). */
63 /** Total sessions being scanned in the current pass ([searchedCount] / [totalCount]). */
63 val totalCount: Int = 0,
64 val totalCount: Int = 0,
65+ /** The cap applied to this pass. Grows when the user taps "Search more". */
66+ val sessionCap: Int = NetworkConfig.maxSearchSessions,
64)
67)
65
68
66/**
69/**
@@ -109,11 +112,24 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
109 sessionsCache = null
112 sessionsCache = null
110 messageCache.clear()
113 messageCache.clear()
111 searchJob?.cancel()
114 searchJob?.cancel()
112− _state.update { it.copy(searching = true, error = null) }
115+ _state.update { it.copy(searching = true, error = null, sessionCap = NetworkConfig.maxSearchSessions) }
113 searchJob = viewModelScope.launch { runSearch(trimmed) }
116 searchJob = viewModelScope.launch { runSearch(trimmed) }
114 }
117 }
115
118
119+ /** Expand the search to cover more sessions (the next [NetworkConfig.maxSearchSessions] batch)
120+ * and re-run. No-op when the prior pass wasn't truncated (there's nothing more to search).
121+ * The message cache is preserved so already-downloaded sessions aren't re-fetched. */
122+ fun searchMore() {
123+ if (!_state.value.truncated) return
124+ val trimmed = _state.value.query.trim()
125+ if (trimmed.length < NetworkConfig.minSearchQueryLength) return
126+ val newCap = _state.value.sessionCap + NetworkConfig.maxSearchSessions
127+ searchJob?.cancel()
128+ _state.update { it.copy(searching = true, error = null, sessionCap = newCap) }
129+ searchJob = viewModelScope.launch { runSearch(trimmed, newCap) }
130+ }
131+
116− private suspend fun runSearch(query: String) {
132+ private suspend fun runSearch(query: String, cap: Int = _state.value.sessionCap) {
117 val conn = container.activeConnection.value
133 val conn = container.activeConnection.value
118 if (conn == null) {
134 if (conn == null) {
119 _state.update { it.copy(searching = false, error = container.string(R.string.not_connected)) }
135 _state.update { it.copy(searching = false, error = container.string(R.string.not_connected)) }
@@ -125,9 +141,9 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
125 _state.update { s -> s.copy(searching = false, error = container.friendlyError(it)) }
141 _state.update { s -> s.copy(searching = false, error = container.friendlyError(it)) }
126 return
142 return
127 }.also { sessionsCache = it }
143 }.also { sessionsCache = it }
128− val toSearch = sessions.take(NetworkConfig.maxSearchSessions)
144+ val toSearch = sessions.take(cap)
129 val truncated = sessions.size > toSearch.size
145 val truncated = sessions.size > toSearch.size
130− _state.update { it.copy(totalCount = toSearch.size, searchedCount = 0) }
146+ _state.update { it.copy(totalCount = toSearch.size, searchedCount = 0, truncated = truncated) }
131 val hits = java.util.Collections.synchronizedMap(HashMap<String, SearchHit>())
147 val hits = java.util.Collections.synchronizedMap(HashMap<String, SearchHit>())
132 val semaphore = Semaphore(NetworkConfig.maxConcurrentPreviews)
148 val semaphore = Semaphore(NetworkConfig.maxConcurrentPreviews)
133 val searched = java.util.concurrent.atomic.AtomicInteger(0)
149 val searched = java.util.concurrent.atomic.AtomicInteger(0)
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+9
@@ -56,6 +56,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
56import androidx.compose.ui.platform.testTag
56import androidx.compose.ui.platform.testTag
57import androidx.compose.ui.semantics.Role
57import androidx.compose.ui.semantics.Role
58import androidx.compose.ui.semantics.contentDescription
58import androidx.compose.ui.semantics.contentDescription
59+import androidx.compose.ui.semantics.role
59import androidx.compose.ui.semantics.semantics
60import androidx.compose.ui.semantics.semantics
60import androidx.compose.ui.semantics.stateDescription
61import androidx.compose.ui.semantics.stateDescription
61import androidx.compose.ui.text.input.ImeAction
62import androidx.compose.ui.text.input.ImeAction
@@ -215,9 +216,17 @@ private fun ServerEditForm(
215 val suggestion = suggestUrlScheme(state.baseUrl)
216 val suggestion = suggestUrlScheme(state.baseUrl)
216 if (suggestion != null) {
217 if (suggestion != null) {
217 val labelRes = if (suggestion.startsWith("https://")) R.string.suggest_scheme_https else R.string.suggest_scheme
218 val labelRes = if (suggestion.startsWith("https://")) R.string.suggest_scheme_https else R.string.suggest_scheme
219+ // Mark the supporting-text fix as a Button so TalkBack announces it as an
220+ // actionable control. Without an explicit role, a TextButton inside
221+ // supportingText can be missed by screen-reader users who don't scan
222+ // the error slot visually.
218 TextButton(
223 TextButton(
219 onClick = { vm.update { it.copy(baseUrl = suggestion) } },
224 onClick = { vm.update { it.copy(baseUrl = suggestion) } },
220 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
225 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
226+ modifier = Modifier.semantics {
227+ role = Role.Button
228+ contentDescription = state.baseUrl
229+ },
221 ) {
230 ) {
222 Text(stringResource(labelRes))
231 Text(stringResource(labelRes))
223 }
232 }
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+13−4
@@ -308,11 +308,20 @@ fun ServerListScreen(
308 }
308 }
309 if (filtered.isEmpty()) {
309 if (filtered.isEmpty()) {
310 item(key = "__no_match") {
310 item(key = "__no_match") {
311− EmptyState(
311+ Column(
312− icon = Icons.Filled.Search,
313− title = stringResource(R.string.no_server_matches),
314 modifier = Modifier.fillMaxWidth().padding(24.dp),
312 modifier = Modifier.fillMaxWidth().padding(24.dp),
313+ horizontalAlignment = Alignment.CenterHorizontally,
315− )
314+ ) {
315+ EmptyState(
316+ icon = Icons.Filled.Search,
317+ title = stringResource(R.string.no_server_matches),
318+ )
319+ if (serverQuery.isNotEmpty()) {
320+ TextButton(onClick = { serverQuery = "" }) {
321+ Text(stringResource(R.string.clear_search))
322+ }
323+ }
324+ }
316 }
325 }
317 }
326 }
318 items(filtered, key = { it.id }) { profile ->
327 items(filtered, key = { it.id }) { profile ->
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+68−2
@@ -38,6 +38,7 @@ import androidx.compose.material.icons.filled.Close
38import androidx.compose.material.icons.filled.Delete
38import androidx.compose.material.icons.filled.Delete
39import androidx.compose.material.icons.filled.DoneAll
39import androidx.compose.material.icons.filled.DoneAll
40import androidx.compose.material.icons.filled.MarkChatRead
40import androidx.compose.material.icons.filled.MarkChatRead
41+import androidx.compose.material.icons.filled.NotificationsOff
41import androidx.compose.material.icons.filled.Folder
42import androidx.compose.material.icons.filled.Folder
42import androidx.compose.material.icons.filled.History
43import androidx.compose.material.icons.filled.History
43import androidx.compose.material.icons.filled.Link
44import androidx.compose.material.icons.filled.Link
@@ -102,6 +103,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
102import androidx.lifecycle.viewmodel.compose.viewModel
103import androidx.lifecycle.viewmodel.compose.viewModel
103import kotlinx.coroutines.coroutineScope
104import kotlinx.coroutines.coroutineScope
104import kotlinx.coroutines.flow.collectLatest
105import kotlinx.coroutines.flow.collectLatest
106+import kotlinx.coroutines.flow.distinctUntilChanged
105import kotlinx.coroutines.flow.merge
107import kotlinx.coroutines.flow.merge
106import kotlinx.coroutines.delay
108import kotlinx.coroutines.delay
107import kotlinx.coroutines.launch
109import kotlinx.coroutines.launch
@@ -145,6 +147,7 @@ fun SessionListScreen(
145 val switchingId by vm.switchingId.collectAsStateWithLifecycle()
147 val switchingId by vm.switchingId.collectAsStateWithLifecycle()
146 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
148 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
147 val unread by vm.unread.collectAsStateWithLifecycle()
149 val unread by vm.unread.collectAsStateWithLifecycle()
150+ val mutedSessions by container.mutedSessions.collectAsStateWithLifecycle()
148 val creating by vm.creating.collectAsStateWithLifecycle()
151 val creating by vm.creating.collectAsStateWithLifecycle()
149 val directoryOptions by vm.directoryOptions.collectAsStateWithLifecycle()
152 val directoryOptions by vm.directoryOptions.collectAsStateWithLifecycle()
150 val anyRunActive by container.anyRunActive.collectAsStateWithLifecycle()
153 val anyRunActive by container.anyRunActive.collectAsStateWithLifecycle()
@@ -435,6 +438,7 @@ fun SessionListScreen(
435 SessionListBody(
438 SessionListBody(
436 state = state,
439 state = state,
437 unread = unread,
440 unread = unread,
441+ mutedSessions = mutedSessions,
438 refreshing = refreshing,
442 refreshing = refreshing,
439 haptics = haptics,
443 haptics = haptics,
440 selectedSessionId = selectedSessionId,
444 selectedSessionId = selectedSessionId,
@@ -446,6 +450,8 @@ fun SessionListScreen(
446 onDelete = { pendingDeleteId = it },
450 onDelete = { pendingDeleteId = it },
447 onPin = { vm.togglePin(it) },
451 onPin = { vm.togglePin(it) },
448 onArchive = { vm.toggleArchive(it) },
452 onArchive = { vm.toggleArchive(it) },
453+ onMarkUnread = { vm.markUnread(it) },
454+ onToggleMute = { container.toggleSessionMute(it) },
449 onFilterByDirectory = vm::setDirectoryFilter,
455 onFilterByDirectory = vm::setDirectoryFilter,
450 inSelection = inSelection,
456 inSelection = inSelection,
451 selectedIds = selectedIds,
457 selectedIds = selectedIds,
@@ -545,6 +551,7 @@ fun SessionListScreen(
545private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
551private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
546 state: SessionListState,
552 state: SessionListState,
547 unread: Map<String, Int>,
553 unread: Map<String, Int>,
554+ mutedSessions: Set<String>,
548 refreshing: Boolean,
555 refreshing: Boolean,
549 haptics: androidx.compose.ui.hapticfeedback.HapticFeedback,
556 haptics: androidx.compose.ui.hapticfeedback.HapticFeedback,
550 selectedSessionId: String?,
557 selectedSessionId: String?,
@@ -556,6 +563,8 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
556 onDelete: (String) -> Unit,
563 onDelete: (String) -> Unit,
557 onPin: (Session) -> Unit,
564 onPin: (Session) -> Unit,
558 onArchive: (Session) -> Unit,
565 onArchive: (Session) -> Unit,
566+ onMarkUnread: (String) -> Unit,
567+ onToggleMute: (String) -> Unit,
559 onFilterByDirectory: (String) -> Unit,
568 onFilterByDirectory: (String) -> Unit,
560 inSelection: Boolean,
569 inSelection: Boolean,
561 selectedIds: Set<String>,
570 selectedIds: Set<String>,
@@ -674,17 +683,41 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
674 // Order child (sub-)sessions immediately under their parent, tracking depth
683 // Order child (sub-)sessions immediately under their parent, tracking depth
675 // so the UI can indent them into a tree.
684 // so the UI can indent them into a tree.
676 val nodes = remember(sessions) { buildSessionTree(sessions) }
685 val nodes = remember(sessions) { buildSessionTree(sessions) }
686+ // Windowed render: compose only the first `renderCap` rows, growing as the
687+ // user scrolls near the bottom. Bounds the LazyColumn's key+arrange work on a
688+ // huge list and keeps the tree-build + filter cost proportional to what's seen.
689+ var renderCap by remember {
690+ mutableStateOf(NetworkConfig.sessionListInitialPage)
691+ }
692+ val visibleNodes = remember(nodes, renderCap) {
693+ if (nodes.size <= renderCap) nodes else nodes.take(renderCap)
694+ }
677 PullToRefreshBox(
695 PullToRefreshBox(
678 isRefreshing = refreshing,
696 isRefreshing = refreshing,
679 onRefresh = onRefresh,
697 onRefresh = onRefresh,
680 modifier = Modifier.fillMaxSize(),
698 modifier = Modifier.fillMaxSize(),
681 ) {
699 ) {
700+ val listState = androidx.compose.foundation.lazy.rememberLazyListState()
701+ // Grow the window when the user scrolls within a page of the cap so the
702+ // "infinite scroll" feels seamless (no spinner — the next batch is already
703+ // in the list by the time they reach the bottom).
704+ androidx.compose.runtime.LaunchedEffect(listState, nodes.size) {
705+ androidx.compose.runtime.snapshotFlow {
706+ val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
707+ lastVisible >= renderCap - NetworkConfig.sessionListPageStep / 2
708+ }.distinctUntilChanged().collect { nearEnd ->
709+ if (nearEnd && renderCap < nodes.size) {
710+ renderCap = (renderCap + NetworkConfig.sessionListPageStep).coerceAtMost(nodes.size)
711+ }
712+ }
713+ }
682 LazyColumn(
714 LazyColumn(
715+ state = listState,
683 modifier = Modifier.fillMaxSize(),
716 modifier = Modifier.fillMaxSize(),
684 contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 96.dp),
717 contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 96.dp),
685 verticalArrangement = Arrangement.spacedBy(12.dp),
718 verticalArrangement = Arrangement.spacedBy(12.dp),
686 ) {
719 ) {
687− items(nodes, key = { it.session.id }) { node ->
720+ items(visibleNodes, key = { it.session.id }) { node ->
688 val session = node.session
721 val session = node.session
689 // Swipe end-to-start reveals a delete affordance and opens the
722 // Swipe end-to-start reveals a delete affordance and opens the
690 // same confirmation dialog as the trash icon. We never commit
723 // same confirmation dialog as the trash icon. We never commit
@@ -726,6 +759,9 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
726 val onCardDelete = remember(session.id) { { onDelete(session.id) } }
759 val onCardDelete = remember(session.id) { { onDelete(session.id) } }
727 val onCardPin = remember(session.id) { { onPin(session) } }
760 val onCardPin = remember(session.id) { { onPin(session) } }
728 val onCardArchive = remember(session.id) { { onArchive(session) } }
761 val onCardArchive = remember(session.id) { { onArchive(session) } }
762+ val onCardMarkUnread = remember(session.id) { { onMarkUnread(session.id) } }
763+ val onCardToggleMute = remember(session.id) { { onToggleMute(session.id) } }
764+ val isMuted = session.id in mutedSessions
729 val isPinned = session.id in state.pinnedIds
765 val isPinned = session.id in state.pinnedIds
730 val isArchived = session.id in state.archivedIds
766 val isArchived = session.id in state.archivedIds
731 SwipeToDismissBox(
767 SwipeToDismissBox(
@@ -786,11 +822,14 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
786 else session.id == selectedSessionId,
822 else session.id == selectedSessionId,
787 isPinned = isPinned,
823 isPinned = isPinned,
788 isArchived = isArchived,
824 isArchived = isArchived,
825+ isMuted = isMuted,
789 onClick = onCardClick,
826 onClick = onCardClick,
790 onRename = onCardRename,
827 onRename = onCardRename,
791 onDelete = onCardDelete,
828 onDelete = onCardDelete,
792 onPin = onCardPin,
829 onPin = onCardPin,
793 onArchive = onCardArchive,
830 onArchive = onCardArchive,
831+ onMarkUnread = onCardMarkUnread,
832+ onToggleMute = onCardToggleMute,
794 onFilterByDirectory = onFilterByDirectory,
833 onFilterByDirectory = onFilterByDirectory,
795 isDirectoryFiltered = state.directoryFilter != null &&
834 isDirectoryFiltered = state.directoryFilter != null &&
796 session.directory?.trimEnd('/') == state.directoryFilter,
835 session.directory?.trimEnd('/') == state.directoryFilter,
@@ -942,6 +981,9 @@ private fun SessionCard(
942 onDelete: () -> Unit,
981 onDelete: () -> Unit,
943 onPin: () -> Unit,
982 onPin: () -> Unit,
944 onArchive: () -> Unit,
983 onArchive: () -> Unit,
984+ onMarkUnread: () -> Unit,
985+ onToggleMute: () -> Unit,
986+ isMuted: Boolean,
945 modifier: Modifier = Modifier,
987 modifier: Modifier = Modifier,
946 isSelected: Boolean = false,
988 isSelected: Boolean = false,
947 isPinned: Boolean = false,
989 isPinned: Boolean = false,
@@ -1003,11 +1045,13 @@ private fun SessionCard(
1003 }
1045 }
1004 Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
1046 Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
1005 Row(verticalAlignment = Alignment.CenterVertically) {
1047 Row(verticalAlignment = Alignment.CenterVertically) {
1006− if (unreadCount > 0) {
1048+ if (unreadCount > 0 && !isMuted) {
1007 val unreadLabel = stringResource(R.string.unread_count, unreadCount)
1049 val unreadLabel = stringResource(R.string.unread_count, unreadCount)
1008 // Count badge: shows the number of unread messages so the user
1050 // Count badge: shows the number of unread messages so the user
1009 // can tell a single reply from a burst. Falls back to a dot for
1051 // can tell a single reply from a burst. Falls back to a dot for
1010 // a count of 1 (the common "one reply" case) to avoid clutter.
1052 // a count of 1 (the common "one reply" case) to avoid clutter.
1053+ // Suppressed when the session is muted (the count is still tracked
1054+ // internally so unmuting restores the badge).
1011 if (unreadCount == 1) {
1055 if (unreadCount == 1) {
1012 Box(
1056 Box(
1013 modifier = Modifier
1057 modifier = Modifier
@@ -1052,6 +1096,14 @@ private fun SessionCard(
1052 tint = MaterialTheme.colorScheme.onSurfaceVariant,
1096 tint = MaterialTheme.colorScheme.onSurfaceVariant,
1053 )
1097 )
1054 }
1098 }
1099+ if (isMuted) {
1100+ Icon(
1101+ Icons.Filled.NotificationsOff,
1102+ contentDescription = stringResource(R.string.session_muted_badge),
1103+ modifier = Modifier.padding(end = 4.dp).size(14.dp),
1104+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
1105+ )
1106+ }
1055 // Server-side state badges (shared / reverted). These are surfaced in
1107 // Server-side state badges (shared / reverted). These are surfaced in
1056 // the chat screen but weren't in the list, so the list never reflected
1108 // the chat screen but weren't in the list, so the list never reflected
1057 // them. Muted so they read as status, not as unread emphasis.
1109 // them. Muted so they read as status, not as unread emphasis.
@@ -1144,6 +1196,8 @@ private fun SessionCard(
1144 val moreLabel = stringResource(R.string.more_options_for, session.displayTitle)
1196 val moreLabel = stringResource(R.string.more_options_for, session.displayTitle)
1145 val pinLabel = stringResource(if (isPinned) R.string.session_unpin else R.string.session_pin)
1197 val pinLabel = stringResource(if (isPinned) R.string.session_unpin else R.string.session_pin)
1146 val archiveLabel = stringResource(if (isArchived) R.string.session_unarchive else R.string.session_archive)
1198 val archiveLabel = stringResource(if (isArchived) R.string.session_unarchive else R.string.session_archive)
1199+ val markUnreadLabel = stringResource(R.string.session_mark_unread)
1200+ val muteLabel = stringResource(if (isMuted) R.string.session_unmute else R.string.session_mute)
1147 Box {
1201 Box {
1148 IconButton(onClick = { showRowMenu = true }) {
1202 IconButton(onClick = { showRowMenu = true }) {
1149 Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
1203 Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
@@ -1160,6 +1214,18 @@ private fun SessionCard(
1160 text = { Text(archiveLabel) },
1214 text = { Text(archiveLabel) },
1161 onClick = { showRowMenu = false; onArchive() },
1215 onClick = { showRowMenu = false; onArchive() },
1162 )
1216 )
1217+ // Only offer "Mark as unread" when the session isn't already badged —
1218+ // re-marking an unread session is a no-op that would just clutter the menu.
1219+ if (unreadCount == 0) {
1220+ DropdownMenuItem(
1221+ text = { Text(markUnreadLabel) },
1222+ onClick = { showRowMenu = false; onMarkUnread() },
1223+ )
1224+ }
1225+ DropdownMenuItem(
1226+ text = { Text(muteLabel) },
1227+ onClick = { showRowMenu = false; onToggleMute() },
1228+ )
1163 DropdownMenuItem(
1229 DropdownMenuItem(
1164 text = { Text(renameLabel) },
1230 text = { Text(renameLabel) },
1165 onClick = { showRowMenu = false; onRename() },
1231 onClick = { showRowMenu = false; onRename() },
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+5
@@ -793,6 +793,11 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
793 ids.forEach { container.markSessionRead(it) }
793 ids.forEach { container.markSessionRead(it) }
794 }
794 }
795
795
796+ /** Mark a single session as unread (re-badges it in the list). */
797+ fun markUnread(id: String) {
798+ container.markSessionUnread(id)
799+ }
800+
796 /**
801 /**
797 * Permanently delete the given sessions. Unlike a single swipe-delete this is NOT undoable
802 * Permanently delete the given sessions. Unlike a single swipe-delete this is NOT undoable
798 * (the batch undo would need to cancel N deferred deletes); the caller confirms via a dialog
803 * (the batch undo would need to cancel N deferred deletes); the caller confirms via a dialog
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+78−29
@@ -45,6 +45,7 @@ import androidx.compose.material3.ExposedDropdownMenuDefaults
45import androidx.compose.material3.HorizontalDivider
45import androidx.compose.material3.HorizontalDivider
46import androidx.compose.material3.Icon
46import androidx.compose.material3.Icon
47import androidx.compose.material3.IconButton
47import androidx.compose.material3.IconButton
48+import androidx.compose.material3.LinearProgressIndicator
48import androidx.compose.material3.MaterialTheme
49import androidx.compose.material3.MaterialTheme
49import androidx.compose.material3.MenuAnchorType
50import androidx.compose.material3.MenuAnchorType
50import androidx.compose.material3.OutlinedTextField
51import androidx.compose.material3.OutlinedTextField
@@ -145,6 +146,8 @@ fun SettingsScreen(
145 val chatTextScale by container.settingsStore.chatTextScale
146 val chatTextScale by container.settingsStore.chatTextScale
146 .collectAsStateWithLifecycle(initialValue = SettingsStore.DEFAULT_CHAT_TEXT_SCALE)
147 .collectAsStateWithLifecycle(initialValue = SettingsStore.DEFAULT_CHAT_TEXT_SCALE)
147 val codeWrap by container.settingsStore.codeWrap.collectAsStateWithLifecycle(initialValue = false)
148 val codeWrap by container.settingsStore.codeWrap.collectAsStateWithLifecycle(initialValue = false)
149+ val compactSpacing by container.settingsStore.compactMessageSpacing
150+ .collectAsStateWithLifecycle(initialValue = false)
148 val appLockReLockSeconds by container.settingsStore.appLockReLockSeconds
151 val appLockReLockSeconds by container.settingsStore.appLockReLockSeconds
149 .collectAsStateWithLifecycle(initialValue = SettingsStore.DEFAULT_APP_LOCK_RELOCK_SECONDS)
152 .collectAsStateWithLifecycle(initialValue = SettingsStore.DEFAULT_APP_LOCK_RELOCK_SECONDS)
150 val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
153 val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
@@ -185,6 +188,11 @@ fun SettingsScreen(
185 // import reads a chosen file and applies it. Feedback goes through the snackbar host so it's
188 // import reads a chosen file and applies it. Feedback goes through the snackbar host so it's
186 // accessible to TalkBack and consistent with the rest of the app (Toast isn't).
189 // accessible to TalkBack and consistent with the rest of the app (Toast isn't).
187 var includePasswords by rememberSaveable { mutableStateOf(false) }
190 var includePasswords by rememberSaveable { mutableStateOf(false) }
191+ // True while an export or import is in flight. Used to disable both NavRow triggers so a
192+ // double-tap (or a slow device) can't launch a second file picker or start a second
193+ // operation while the first is still reading/writing. Mirrors the busy-flag pattern used
194+ // by other slow actions in the app.
195+ var backupBusy by remember { mutableStateOf(false) }
188 // Import overwrites all profiles/settings with no undo, so a picked file is staged here
196 // Import overwrites all profiles/settings with no undo, so a picked file is staged here
189 // and only applied once the user confirms the replace dialog below. Export with passwords
197 // and only applied once the user confirms the replace dialog below. Export with passwords
190 // included warns first (plaintext credentials leave the app).
198 // included warns first (plaintext credentials leave the app).
@@ -201,12 +209,17 @@ fun SettingsScreen(
201 ) { uri ->
209 ) { uri ->
202 if (uri == null) return@rememberLauncherForActivityResult
210 if (uri == null) return@rememberLauncherForActivityResult
203 scope.launch {
211 scope.launch {
212+ backupBusy = true
213+ try {
204− val ok = runCatchingCancellable {
214+ val ok = runCatchingCancellable {
205− val text = container.backupManager.export(includePasswords)
215+ val text = container.backupManager.export(includePasswords)
206− context.contentResolver.openOutputStream(uri)?.use { it.write(text.toByteArray()) }
216+ context.contentResolver.openOutputStream(uri)?.use { it.write(text.toByteArray()) }
207− ?: error("no output stream")
217+ ?: error("no output stream")
208− }.isSuccess
218+ }.isSuccess
209− snackbar.showSnackbar(if (ok) exportedMsg else exportFailedMsg)
219+ snackbar.showSnackbar(if (ok) exportedMsg else exportFailedMsg)
220+ } finally {
221+ backupBusy = false
222+ }
210 }
223 }
211 }
224 }
212 val importLauncher = rememberLauncherForActivityResult(
225 val importLauncher = rememberLauncherForActivityResult(
@@ -420,6 +433,31 @@ fun SettingsScreen(
420 Switch(checked = codeWrap, onCheckedChange = null)
433 Switch(checked = codeWrap, onCheckedChange = null)
421 }
434 }
422
435
436+ // Compact message spacing: a power-user toggle to fit more conversation on screen.
437+ Spacer(Modifier.size(4.dp))
438+ Row(
439+ modifier = Modifier
440+ .fillMaxWidth()
441+ .defaultMinSize(minHeight = 48.dp)
442+ .toggleable(
443+ value = compactSpacing,
444+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setCompactMessageSpacing(it) } } },
445+ role = Role.Switch,
446+ )
447+ .padding(vertical = 8.dp),
448+ verticalAlignment = Alignment.CenterVertically,
449+ ) {
450+ Column(modifier = Modifier.weight(1f)) {
451+ Text(stringResource(R.string.compact_message_spacing), style = MaterialTheme.typography.bodyLarge)
452+ Text(
453+ stringResource(R.string.compact_message_spacing_desc),
454+ style = MaterialTheme.typography.bodySmall,
455+ color = MaterialTheme.colorScheme.onSurfaceVariant,
456+ )
457+ }
458+ Switch(checked = compactSpacing, onCheckedChange = null)
459+ }
460+
423 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
461 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
424
462
425 Text(
463 Text(
@@ -559,7 +597,7 @@ fun SettingsScreen(
559 NavRow(
597 NavRow(
560 icon = Icons.Filled.Upload,
598 icon = Icons.Filled.Upload,
561 label = stringResource(R.string.export_backup),
599 label = stringResource(R.string.export_backup),
562− enabled = true,
600+ enabled = !backupBusy,
563 // Warn before writing plaintext credentials out of the app; confirm then launches
601 // Warn before writing plaintext credentials out of the app; confirm then launches
564 // the file picker. Without passwords, export straight away.
602 // the file picker. Without passwords, export straight away.
565 onClick = {
603 onClick = {
@@ -570,13 +608,19 @@ fun SettingsScreen(
570 NavRow(
608 NavRow(
571 icon = Icons.Filled.Download,
609 icon = Icons.Filled.Download,
572 label = stringResource(R.string.import_backup),
610 label = stringResource(R.string.import_backup),
573− enabled = true,
611+ enabled = !backupBusy,
574 onClick = {
612 onClick = {
575 runCatching {
613 runCatching {
576 importLauncher.launch(arrayOf("application/json", "text/plain", "application/octet-stream"))
614 importLauncher.launch(arrayOf("application/json", "text/plain", "application/octet-stream"))
577 }
615 }
578 },
616 },
579 )
617 )
618+ // Surface an inline progress indicator while a backup write/read is in flight so
619+ // the user gets immediate feedback that the (potentially slow on a big file)
620+ // operation is running, and isn't tempted to tap again.
621+ if (backupBusy) {
622+ LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(top = 4.dp))
623+ }
580
624
581 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
625 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
582
626
@@ -657,28 +701,33 @@ fun SettingsScreen(
657 TextButton(onClick = {
701 TextButton(onClick = {
658 pendingImportUri = null
702 pendingImportUri = null
659 scope.launch {
703 scope.launch {
704+ backupBusy = true
705+ try {
660− val ok = runCatchingCancellable {
706+ val ok = runCatchingCancellable {
661− val text = context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() }
707+ val text = context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() }
662− ?: error("no input stream")
708+ ?: error("no input stream")
663− container.backupManager.import(text)
709+ container.backupManager.import(text)
664− }.isSuccess
710+ }.isSuccess
665− if (ok) {
711+ if (ok) {
666− val result = snackbar.showSnackbar(
712+ val result = snackbar.showSnackbar(
667− message = importedMsg,
713+ message = importedMsg,
668− actionLabel = restartLabel,
714+ actionLabel = restartLabel,
669− )
715+ )
670− if (result == SnackbarResult.ActionPerformed) {
716+ if (result == SnackbarResult.ActionPerformed) {
671− // Shut down the container (cancels the app scope, closes the
717+ // Shut down the container (cancels the app scope, closes the
672− // active connection, flushes stores) before the hard kill so
718+ // active connection, flushes stores) before the hard kill so
673− // in-flight work is cleaned up rather than abruptly severed.
719+ // in-flight work is cleaned up rather than abruptly severed.
674− // The JVM shutdown hook in OpencodeApp would catch this, but
720+ // The JVM shutdown hook in OpencodeApp would catch this, but
675− // calling shutdown explicitly is cleaner and avoids relying on
721+ // calling shutdown explicitly is cleaner and avoids relying on
676− // the hook's ordering under a kill.
722+ // the hook's ordering under a kill.
677− runCatchingCancellable { container.shutdown() }
723+ runCatchingCancellable { container.shutdown() }
678− android.os.Process.killProcess(android.os.Process.myPid())
724+ android.os.Process.killProcess(android.os.Process.myPid())
725+ }
726+ } else {
727+ snackbar.showSnackbar(importFailedMsg)
679 }
728 }
680− } else {
729+ } finally {
681− snackbar.showSnackbar(importFailedMsg)
730+ backupBusy = false
682 }
731 }
683 }
732 }
684 }) { Text(stringResource(R.string.import_confirm_replace), color = MaterialTheme.colorScheme.error) }
733 }) { Text(stringResource(R.string.import_confirm_replace), color = MaterialTheme.colorScheme.error) }
app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+25
@@ -187,6 +187,31 @@ val AmoledPaletteSwatches: List<Color> = listOf(
187 AmoledColors.background,
187 AmoledColors.background,
188)
188)
189
189
190+// Fixed diff add/remove colors per theme (Tokyo Night green/red), exposed so DiffView can
191+// render consistent diff semantics regardless of the active palette. Under Material You the
192+// primary/error roles can land on hues that don't read as "added" or "removed" (a yellow
193+// primary makes an "added" line look like a warning) — a dedicated diff pair preserves the
194+// green/red convention every diff viewer uses while staying tuned to the theme's lightness.
195+private val DarkDiffAdd = Color(0xFF9ECE6A)
196+private val DarkDiffAddBg = Color(0xFF1F3A1F)
197+private val DarkDiffRemove = Color(0xFFF7768E)
198+private val DarkDiffRemoveBg = Color(0xFF3A1F24)
199+private val LightDiffAdd = Color(0xFF485E30)
200+private val LightDiffAddBg = Color(0xFFE6F2D0)
201+private val LightDiffRemove = Color(0xFF9B1C2E)
202+private val LightDiffRemoveBg = Color(0xFFF6D9DD)
203+
204+/** A (text, background) pair for added lines in a unified diff. Theme-aware so the green
205+ * reads correctly in light/dark/AMOLED instead of relying on the dynamic-color primary. */
206+data class DiffColors(val addText: Color, val addBg: Color, val removeText: Color, val removeBg: Color)
207+
208+@Composable
209+fun diffColors(): DiffColors {
210+ val dark = androidx.compose.foundation.isSystemInDarkTheme()
211+ return if (dark) DiffColors(DarkDiffAdd, DarkDiffAddBg, DarkDiffRemove, DarkDiffRemoveBg)
212+ else DiffColors(LightDiffAdd, LightDiffAddBg, LightDiffRemove, LightDiffRemoveBg)
213+}
214+
190private tailrec fun Context.findActivity(): Activity? = when (this) {
215private tailrec fun Context.findActivity(): Activity? = when (this) {
191 is Activity -> this
216 is Activity -> this
192 is ContextWrapper -> baseContext.findActivity()
217 is ContextWrapper -> baseContext.findActivity()
app/src/main/java/soy/iko/opencode/util/Coroutines.kt+12
@@ -16,6 +16,18 @@ inline fun <T> runCatchingCancellable(block: () -> T): Result<T> = try {
16 Result.failure(e)
16 Result.failure(e)
17}
17}
18
18
19+/**
20+ * Suspend variant of [runCatchingCancellable] for blocks that themselves call suspend
21+ * functions. The non-suspend overload can't accept a suspend lambda.
22+ */
23+suspend inline fun <T> runCatchingCancellableSuspend(crossinline block: suspend () -> T): Result<T> = try {
24+ Result.success(block())
25+} catch (e: CancellationException) {
26+ throw e
27+} catch (e: Exception) {
28+ Result.failure(e)
29+}
30+
19/**
31/**
20 * A log-safe exception summary that avoids leaking the request URL (which
32 * A log-safe exception summary that avoids leaking the request URL (which
21 * [ClientRequestException] embeds in its message and may contain auth or paths).
33 * [ClientRequestException] embeds in its message and may contain auth or paths).
app/src/main/res/values-es/strings.xml+20
@@ -189,11 +189,19 @@
189 <string name="no_commands_match">Ningún comando coincide con "%1$s".</string>
189 <string name="no_commands_match">Ningún comando coincide con "%1$s".</string>
190 <string name="load_failed">Error al cargar. Toca reintentar.</string>
190 <string name="load_failed">Error al cargar. Toca reintentar.</string>
191 <string name="image_failed">Error al cargar la imagen</string>
191 <string name="image_failed">Error al cargar la imagen</string>
192+ <string name="save_image">Guardar en galería</string>
193+ <string name="share_image">Compartir imagen</string>
194+ <string name="image_saved">Imagen guardada en la galería</string>
195+ <string name="image_save_failed">No se pudo guardar la imagen</string>
192 <string name="search_models">Buscar modelos…</string>
196 <string name="search_models">Buscar modelos…</string>
193 <string name="search_agents">Buscar agentes…</string>
197 <string name="search_agents">Buscar agentes…</string>
194 <string name="search_commands">Buscar comandos…</string>
198 <string name="search_commands">Buscar comandos…</string>
195 <string name="no_models_match">Ningún modelo coincide con "%1$s".</string>
199 <string name="no_models_match">Ningún modelo coincide con "%1$s".</string>
196 <string name="no_agents_match">Ningún agente coincide con "%1$s".</string>
200 <string name="no_agents_match">Ningún agente coincide con "%1$s".</string>
201+ <string name="set_as_default_model">Modelo predeterminado: %1$s</string>
202+ <string name="default_model_current">Modelo predeterminado actual: %1$s</string>
203+ <string name="set_as_default">Establecer como predeterminado</string>
204+ <string name="clear_default">Quitar predeterminado</string>
197
205
198 <!-- File browser -->
206 <!-- File browser -->
199 <string name="search_files">Buscar archivos…</string>
207 <string name="search_files">Buscar archivos…</string>
@@ -389,6 +397,10 @@
389 <string name="session_archive">Archivar</string>
397 <string name="session_archive">Archivar</string>
390 <string name="session_unarchive">Desarchivar</string>
398 <string name="session_unarchive">Desarchivar</string>
391 <string name="session_pinned">Fijada</string>
399 <string name="session_pinned">Fijada</string>
400+ <string name="session_mark_unread">Marcar como no leída</string>
401+ <string name="session_mute">Silenciar notificaciones</string>
402+ <string name="session_unmute">Activar notificaciones</string>
403+ <string name="session_muted_badge">Silenciada</string>
392 <string name="show_archived">Mostrar archivadas</string>
404 <string name="show_archived">Mostrar archivadas</string>
393 <string name="hide_archived">Ocultar archivadas</string>
405 <string name="hide_archived">Ocultar archivadas</string>
394 <string name="search_all">Buscar en todas las sesiones</string>
406 <string name="search_all">Buscar en todas las sesiones</string>
@@ -396,6 +408,7 @@
396 <string name="search_all_hint">Buscar en todas las conversaciones…</string>
408 <string name="search_all_hint">Buscar en todas las conversaciones…</string>
397 <string name="search_no_matches">No hay mensajes coincidentes</string>
409 <string name="search_no_matches">No hay mensajes coincidentes</string>
398 <string name="search_truncated">Mostrando resultados solo de las sesiones más recientes.</string>
410 <string name="search_truncated">Mostrando resultados solo de las sesiones más recientes.</string>
411+ <string name="search_more">Buscar en más sesiones</string>
399 <string name="search_all_start">Busca en tus conversaciones</string>
412 <string name="search_all_start">Busca en tus conversaciones</string>
400 <string name="search_all_start_hint">Escribe una palabra o frase para encontrarla en todas las sesiones.</string>
413 <string name="search_all_start_hint">Escribe una palabra o frase para encontrarla en todas las sesiones.</string>
401 <plurals name="search_results_count">
414 <plurals name="search_results_count">
@@ -422,6 +435,8 @@
422 <string name="chat_text_size_desc">Escala el texto de mensajes y código</string>
435 <string name="chat_text_size_desc">Escala el texto de mensajes y código</string>
423 <string name="wrap_code_blocks">Ajustar bloques de código</string>
436 <string name="wrap_code_blocks">Ajustar bloques de código</string>
424 <string name="wrap_code_blocks_desc">Ajusta las líneas largas en vez de desplazarlas</string>
437 <string name="wrap_code_blocks_desc">Ajusta las líneas largas en vez de desplazarlas</string>
438+ <string name="compact_message_spacing">Espaciado compacto de mensajes</string>
439+ <string name="compact_message_spacing_desc">Menos espacio entre mensajes para mostrar más en pantalla.</string>
425 <string name="reset">Restablecer</string>
440 <string name="reset">Restablecer</string>
426 <string name="text_size_sample">El veloz murciélago hindú comía feliz cardillo.</string>
441 <string name="text_size_sample">El veloz murciélago hindú comía feliz cardillo.</string>
427 <string name="text_scale_value">%1$d%%</string>
442 <string name="text_scale_value">%1$d%%</string>
@@ -537,6 +552,10 @@
537 <!-- Chat -->
552 <!-- Chat -->
538 <string name="attachment_staging">Adjuntando…</string>
553 <string name="attachment_staging">Adjuntando…</string>
539 <string name="new_messages">Mensajes nuevos</string>
554 <string name="new_messages">Mensajes nuevos</string>
555+ <plurals name="plurals_new_messages">
556+ <item quantity="one">%1$d mensaje nuevo</item>
557+ <item quantity="other">%1$d mensajes nuevos</item>
558+ </plurals>
540
559
541 <!-- Session list -->
560 <!-- Session list -->
542 <string name="session_archived">Sesión archivada</string>
561 <string name="session_archived">Sesión archivada</string>
@@ -567,6 +586,7 @@
567
586
568 <!-- UI/UX batch: permisos, confirmaciones, indicaciones de búsqueda, accesibilidad -->
587 <!-- UI/UX batch: permisos, confirmaciones, indicaciones de búsqueda, accesibilidad -->
569 <string name="permission_progress">%1$d de %2$d</string>
588 <string name="permission_progress">%1$d de %2$d</string>
589+ <string name="permission_waiting">El agente está esperando. Se rechazará automáticamente en %1$d min si no eliges.</string>
570 <string name="action_continue">Continuar</string>
590 <string name="action_continue">Continuar</string>
571 <string name="summarize_confirm_title">¿Resumir la conversación?</string>
591 <string name="summarize_confirm_title">¿Resumir la conversación?</string>
572 <string name="summarize_confirm_text">Los mensajes anteriores se compactan en un resumen para liberar contexto. Esto no se puede deshacer aquí.</string>
592 <string name="summarize_confirm_text">Los mensajes anteriores se compactan en un resumen para liberar contexto. Esto no se puede deshacer aquí.</string>
app/src/main/res/values/strings.xml+20
@@ -198,11 +198,19 @@
198 <string name="no_commands_match">No commands match "%1$s".</string>
198 <string name="no_commands_match">No commands match "%1$s".</string>
199 <string name="load_failed">Failed to load. Tap retry.</string>
199 <string name="load_failed">Failed to load. Tap retry.</string>
200 <string name="image_failed">Failed to load image</string>
200 <string name="image_failed">Failed to load image</string>
201+ <string name="save_image">Save to gallery</string>
202+ <string name="share_image">Share image</string>
203+ <string name="image_saved">Image saved to gallery</string>
204+ <string name="image_save_failed">Couldn\'t save image</string>
201 <string name="search_models">Search models…</string>
205 <string name="search_models">Search models…</string>
202 <string name="search_agents">Search agents…</string>
206 <string name="search_agents">Search agents…</string>
203 <string name="search_commands">Search commands…</string>
207 <string name="search_commands">Search commands…</string>
204 <string name="no_models_match">No models match "%1$s".</string>
208 <string name="no_models_match">No models match "%1$s".</string>
205 <string name="no_agents_match">No agents match "%1$s".</string>
209 <string name="no_agents_match">No agents match "%1$s".</string>
210+ <string name="set_as_default_model">Default model: %1$s</string>
211+ <string name="default_model_current">Current default model: %1$s</string>
212+ <string name="set_as_default">Set as default</string>
213+ <string name="clear_default">Clear default</string>
206
214
207 <!-- File browser -->
215 <!-- File browser -->
208 <string name="search_files">Search files…</string>
216 <string name="search_files">Search files…</string>
@@ -370,6 +378,10 @@
370 <string name="session_archive">Archive</string>
378 <string name="session_archive">Archive</string>
371 <string name="session_unarchive">Unarchive</string>
379 <string name="session_unarchive">Unarchive</string>
372 <string name="session_pinned">Pinned</string>
380 <string name="session_pinned">Pinned</string>
381+ <string name="session_mark_unread">Mark as unread</string>
382+ <string name="session_mute">Mute notifications</string>
383+ <string name="session_unmute">Unmute notifications</string>
384+ <string name="session_muted_badge">Muted</string>
373 <string name="show_archived">Show archived</string>
385 <string name="show_archived">Show archived</string>
374 <string name="hide_archived">Hide archived</string>
386 <string name="hide_archived">Hide archived</string>
375
387
@@ -386,6 +398,7 @@
386 <string name="search_all_hint">Search across all conversations…</string>
398 <string name="search_all_hint">Search across all conversations…</string>
387 <string name="search_no_matches">No matching messages</string>
399 <string name="search_no_matches">No matching messages</string>
388 <string name="search_truncated">Showing results from the most recent sessions only.</string>
400 <string name="search_truncated">Showing results from the most recent sessions only.</string>
401+ <string name="search_more">Search more sessions</string>
389 <string name="search_all_start">Search your conversations</string>
402 <string name="search_all_start">Search your conversations</string>
390 <string name="search_all_start_hint">Type a word or phrase to find it across every session.</string>
403 <string name="search_all_start_hint">Type a word or phrase to find it across every session.</string>
391 <plurals name="search_results_count">
404 <plurals name="search_results_count">
@@ -444,6 +457,8 @@
444 <string name="chat_text_size_desc">Scale message and code text</string>
457 <string name="chat_text_size_desc">Scale message and code text</string>
445 <string name="wrap_code_blocks">Wrap code blocks</string>
458 <string name="wrap_code_blocks">Wrap code blocks</string>
446 <string name="wrap_code_blocks_desc">Soft-wrap long lines instead of scrolling</string>
459 <string name="wrap_code_blocks_desc">Soft-wrap long lines instead of scrolling</string>
460+ <string name="compact_message_spacing">Compact message spacing</string>
461+ <string name="compact_message_spacing_desc">Tighter spacing between messages to fit more on screen.</string>
447 <string name="reset">Reset</string>
462 <string name="reset">Reset</string>
448 <string name="text_size_sample">The quick brown fox jumps over the lazy dog.</string>
463 <string name="text_size_sample">The quick brown fox jumps over the lazy dog.</string>
449 <string name="text_scale_value">%1$d%%</string>
464 <string name="text_scale_value">%1$d%%</string>
@@ -560,6 +575,10 @@
560 <!-- Chat -->
575 <!-- Chat -->
561 <string name="attachment_staging">Attaching…</string>
576 <string name="attachment_staging">Attaching…</string>
562 <string name="new_messages">New messages</string>
577 <string name="new_messages">New messages</string>
578+ <plurals name="plurals_new_messages">
579+ <item quantity="one">%1$d new message</item>
580+ <item quantity="other">%1$d new messages</item>
581+ </plurals>
563
582
564 <!-- Session list -->
583 <!-- Session list -->
565 <string name="session_archived">Session archived</string>
584 <string name="session_archived">Session archived</string>
@@ -590,6 +609,7 @@
590
609
591 <!-- UI/UX batch: permissions, confirmations, search prompts, a11y -->
610 <!-- UI/UX batch: permissions, confirmations, search prompts, a11y -->
592 <string name="permission_progress">%1$d of %2$d</string>
611 <string name="permission_progress">%1$d of %2$d</string>
612+ <string name="permission_waiting">The agent is waiting. Auto-rejecting in %1$d min if you don\'t choose.</string>
593 <string name="action_continue">Continue</string>
613 <string name="action_continue">Continue</string>
594 <string name="summarize_confirm_title">Summarize conversation?</string>
614 <string name="summarize_confirm_title">Summarize conversation?</string>
595 <string name="summarize_confirm_text">Earlier messages are compacted into a summary to free up context. This can\'t be undone here.</string>
615 <string name="summarize_confirm_text">Earlier messages are compacted into a summary to free up context. This can\'t be undone here.</string>
gradle/libs.versions.toml+2
@@ -16,6 +16,7 @@ markdownRenderer = "0.27.0"
16zxingCore = "3.5.3"
16zxingCore = "3.5.3"
17coreKtx = "1.13.1"
17coreKtx = "1.13.1"
18splashscreen = "1.0.1"
18splashscreen = "1.0.1"
19+window = "1.3.0"
19junit = "4.13.2"
20junit = "4.13.2"
20detekt = "1.23.8"
21detekt = "1.23.8"
21androidxTestExt = "1.2.1"
22androidxTestExt = "1.2.1"
@@ -26,6 +27,7 @@ espresso = "3.6.1"
26[libraries]
27[libraries]
27androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
28androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
28androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" }
29androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" }
30+androidx-window = { group = "androidx.window", name = "window", version.ref = "window" }
29androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
31androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
30androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
32androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
31androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
33androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }