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
commit7ce40ed1f275d85d4251fe4a588890b227a00254 authoriko <mail@iko.soy> dateSat 4 Jul 2026, 11:07 (3 months ago) parent268623f
30 files changed+1422−314
app/build.gradle.kts+1 app/detekt-baseline.xml+4−2 app/src/main/AndroidManifest.xml+1 app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+22−2 app/src/main/java/soy/iko/opencode/data/repo/SessionPrefsStore.kt+10−1 app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.kt+41−2 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+67−15 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+33−1 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+107−64 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+25−6 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+73−61 app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+35 app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+35 app/src/main/java/soy/iko/opencode/ui/components/CodeHighlighter.kt+177 app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+231−100 app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+144−1 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+26−5 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+97−7 app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+19−6 app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchViewModel.kt+22−6 app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+9 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+13−4 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+68−2 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+5 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+78−29 app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+25 app/src/main/java/soy/iko/opencode/util/Coroutines.kt+12 app/src/main/res/values-es/strings.xml+20 app/src/main/res/values/strings.xml+20 gradle/libs.versions.toml+2
app/build.gradle.kts+1
@@ -113,6 +113,7 @@ android {
113113dependencies {
114114 implementation(libs.androidx.core.ktx)
115115 implementation(libs.androidx.core.splashscreen)
116+ implementation(libs.androidx.window)
116117 implementation(libs.androidx.activity.compose)
117118 implementation(libs.androidx.lifecycle.viewmodel.compose)
118119 implementation(libs.androidx.lifecycle.runtime.compose)
app/detekt-baseline.xml+4−2
@@ -10,17 +10,19 @@
1010 <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>
1111 <ID>CyclomaticComplexMethod:ChatViewModel.kt$ChatViewModel$private fun send(text: String, includeAttachments: Boolean, idempotencyKey: String? = null): Boolean</ID>
1212 <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>
1314 <ID>CyclomaticComplexMethod:ConversationExport.kt$fun buildConversationMarkdown(messages: List&lt;MessageWithParts&gt;, title: String?): String</ID>
1415 <ID>CyclomaticComplexMethod:DiagnosticsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun DiagnosticsScreen(onBack: () -&gt; Unit)</ID>
1516 <ID>CyclomaticComplexMethod:EventStreamClient.kt$EventStreamClient$private fun stream(): Flow&lt;BusEvent&gt;</ID>
1617 <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>
1718 <ID>CyclomaticComplexMethod:FileViewScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun FileViewScreen( container: AppContainer, path: String, onBack: () -&gt; Unit, initialLine: Int? = null, )</ID>
1819 <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>
1921 <ID>CyclomaticComplexMethod:OpencodeApp.kt$@Composable fun OpencodeApp(container: AppContainer)</ID>
2022 <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>
2123 <ID>CyclomaticComplexMethod:SessionListScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun SessionListScreen( container: AppContainer, onOpenSession: (String) -&gt; Unit, onDisconnect: () -&gt; Unit, onOpenFiles: () -&gt; Unit, onOpenSettings: () -&gt; Unit, onAddServer: () -&gt; Unit, onOpenSearch: () -&gt; Unit = {}, // Incremented by a host (the two-pane empty-detail pane) to open the new-session // directory picker, so that pane shares this screen's dialog instead of bypassing it. externalNewSessionTrigger: Int = 0, selectedSessionId: String? = null, )</ID>
22− <ID>CyclomaticComplexMethod:SessionListScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun androidx.compose.foundation.layout.BoxScope.SessionListBody( state: SessionListState, unread: Map&lt;String, Int&gt;, refreshing: Boolean, haptics: androidx.compose.ui.hapticfeedback.HapticFeedback, selectedSessionId: String?, onRefresh: () -&gt; Unit, onQueryChange: (String) -&gt; Unit, onOpenSession: (String) -&gt; Unit, onCreateSession: () -&gt; Unit, onRename: (String) -&gt; Unit, onDelete: (String) -&gt; Unit, onPin: (Session) -&gt; Unit, onArchive: (Session) -&gt; Unit, onFilterByDirectory: (String) -&gt; Unit, inSelection: Boolean, selectedIds: Set&lt;String&gt;, onToggleSelect: (String) -&gt; Unit, )</ID>
23− <ID>CyclomaticComplexMethod:SessionListScreen.kt$@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable private fun SessionCard( session: Session, preview: String?, unreadCount: Int, onClick: () -&gt; Unit, onRename: () -&gt; Unit, onDelete: () -&gt; Unit, onPin: () -&gt; Unit, onArchive: () -&gt; Unit, modifier: Modifier = Modifier, isSelected: Boolean = false, isPinned: Boolean = false, isArchived: Boolean = false, onFilterByDirectory: ((String) -&gt; Unit)? = null, isDirectoryFiltered: Boolean = false, inSelection: Boolean = false, onToggleSelect: (() -&gt; Unit)? = null, )</ID>
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>
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>
2426 <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>
2527 <ID>LargeClass:AppContainer.kt$AppContainer</ID>
2628 <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
app/src/main/AndroidManifest.xml+1
@@ -29,6 +29,7 @@
2929 android:launchMode="singleTop"
3030 android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|uiMode|navigation"
3131 android:enableOnBackInvokedCallback="true"
32+ android:windowSoftInputMode="adjustResize"
3233 tools:targetApi="33"
3334 android:theme="@style/Theme.OpencodeAndroid.Splash">
3435 <intent-filter>
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+22−2
@@ -57,6 +57,12 @@ object NetworkConfig {
5757
5858 /** Max sessions to fetch previews for in one batch. */
5959 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
6066 /** Max concurrent preview fetches (prevents flooding the server with parallel requests). */
6167 const val maxConcurrentPreviews = 8
6268 /** Max characters of a session's last message to keep as a list preview. */
@@ -247,8 +253,11 @@ object NetworkConfig {
247253 const val twoPaneRightWeight = 0.62f
248254
249255 /** Minimum window width (dp) at which a NavigationRail is shown alongside the
250− * two-pane layout for top-level destination discoverability on large screens. */
251− const val navigationRailThresholdDp = 840
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. */
260+ const val navigationRailThresholdDp = 600
252261
253262 /** Maximum width (dp) for the chat message list on large screens. On a tablet or
254263 * unfolded foldable, full-width message bubbles stretch edge-to-edge, hurting
@@ -301,6 +310,17 @@ object NetworkConfig {
301310 /** Blink period (ms) of the streaming caret at the tail of a live assistant reply. */
302311 const val streamingCaretPeriodMs = 550
303312
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+
304324 /** Duration (ms) the copy button shows its checkmark confirmation before reverting. */
305325 const val copyFeedbackMs = 1200
306326
app/src/main/java/soy/iko/opencode/data/repo/SessionPrefsStore.kt+10−1
@@ -28,6 +28,7 @@ open class SessionPrefsStore private constructor(
2828
2929 private val pinnedKey = stringSetPreferencesKey("pinned")
3030 private val archivedKey = stringSetPreferencesKey("archived")
31+ private val mutedKey = stringSetPreferencesKey("muted")
3132
3233 open val pinned: Flow<Set<String>>
3334 get() = appContext?.sessionPrefsDataStore?.data?.map { it[pinnedKey] ?: emptySet() }
@@ -37,16 +38,24 @@ open class SessionPrefsStore private constructor(
3738 get() = appContext?.sessionPrefsDataStore?.data?.map { it[archivedKey] ?: emptySet() }
3839 ?: flowOf(emptySet())
3940
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+
4046 open suspend fun setPinned(sessionId: String, pinned: Boolean) = toggle(pinnedKey, sessionId, pinned)
4147
4248 open suspend fun setArchived(sessionId: String, archived: Boolean) = toggle(archivedKey, sessionId, archived)
4349
44− /** Drop a session from both sets — call on deletion so ids don't accumulate forever. */
50+ open suspend fun setMuted(sessionId: String, muted: Boolean) = toggle(mutedKey, sessionId, muted)
51+
52+ /** Drop a session from all sets — call on deletion so ids don't accumulate forever. */
4553 open suspend fun forget(sessionId: String) {
4654 val ctx = appContext ?: return
4755 ctx.sessionPrefsDataStore.edit { prefs ->
4856 prefs[pinnedKey] = (prefs[pinnedKey] ?: emptySet()) - sessionId
4957 prefs[archivedKey] = (prefs[archivedKey] ?: emptySet()) - sessionId
58+ prefs[mutedKey] = (prefs[mutedKey] ?: emptySet()) - sessionId
5059 }
5160 }
5261
app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.kt+41−2
@@ -28,6 +28,9 @@ class SettingsStore(context: Context) {
2828 private val sessionSortModeKey = stringPreferencesKey("session_sort_mode")
2929 private val sessionSortDescendingKey = booleanPreferencesKey("session_sort_descending")
3030 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")
3134
3235 companion object {
3336 /** Bounds for the chat text-size multiplier so the UI can't be scaled into
@@ -105,6 +108,23 @@ class SettingsStore(context: Context) {
105108 prefs[sessionShowArchivedKey] ?: false
106109 }
107110
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+
108128 suspend fun setThemeMode(mode: ThemeMode) {
109129 appContext.settingsDataStore.edit { it[themeKey] = mode.name }
110130 }
@@ -145,8 +165,27 @@ class SettingsStore(context: Context) {
145165 suspend fun setSessionSortDescending(descending: Boolean) {
146166 appContext.settingsDataStore.edit { it[sessionSortDescendingKey] = descending }
147167 }
148−
149168 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+ }
151190 }
152191}
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+67−15
@@ -233,6 +233,13 @@ open class AppContainer private constructor(
233233 private val _unread = MutableStateFlow<Map<String, Int>>(emptyMap())
234234 open val unread: StateFlow<Map<String, Int>> = _unread.asStateFlow()
235235
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+
236243 /** True when any assistant run is actively streaming across all sessions. Drives the
237244 * disconnect confirmation on the session list (disconnecting mid-run kills it). */
238245 private val _anyRunActive = MutableStateFlow(false)
@@ -289,6 +296,34 @@ open class AppContainer private constructor(
289296 /** Mark a session as read (clear its unread badge) — the "Mark read" notification action. */
290297 open fun markSessionRead(id: String) = clearUnread(id)
291298
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+
292327 /** Clear every session's unread badge at once — the session list's "Mark all read" action. */
293328 open fun clearAllUnread() {
294329 _unread.update { emptyMap() }
@@ -678,21 +713,28 @@ open class AppContainer private constructor(
678713 // A stale earlier check would race a badge — and a dedup entry — back
679714 // in for the session that's actually on screen.
680715 if (sid != _currentSession.value) {
681− // Increment the unread count once per distinct *message*, not per
682− // event: a single reply emits many message.updated / message.part.
683− // updated events (one per streamed token, plus cost/token refreshes),
684− // which would otherwise inflate the badge into the dozens/hundreds.
685− val messageId = messageIdOfEvent(event)
686− val counted = unreadMessageIds.getOrPut(sid) {
687− java.util.Collections.synchronizedSet(mutableSetOf())
688− }
689− if (messageId == null || counted.add(messageId)) {
690− _unread.update { current ->
691− // Guard again inside the atomic update lambda so a retry
692− // (or an open that landed just now) can't reintroduce the
693− // badge for the session on screen.
694− if (sid == _currentSession.value) current
695− else current + (sid to (current[sid] ?: 0) + 1)
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) {
722+ // Increment the unread count once per distinct *message*, not per
723+ // event: a single reply emits many message.updated / message.part.
724+ // updated events (one per streamed token, plus cost/token refreshes),
725+ // which would otherwise inflate the badge into the dozens/hundreds.
726+ val messageId = messageIdOfEvent(event)
727+ val counted = unreadMessageIds.getOrPut(sid) {
728+ java.util.Collections.synchronizedSet(mutableSetOf())
729+ }
730+ if (messageId == null || counted.add(messageId)) {
731+ _unread.update { current ->
732+ // Guard again inside the atomic update lambda so a retry
733+ // (or an open that landed just now) can't reintroduce the
734+ // badge for the session on screen.
735+ if (sid == _currentSession.value) current
736+ else current + (sid to (current[sid] ?: 0) + 1)
737+ }
696738 }
697739 }
698740 }
@@ -789,6 +831,9 @@ open class AppContainer private constructor(
789831 // prior notification — posting a fresh one here would leave a "session ready" heads-up
790832 // lingering while they're already looking at the finished run.
791833 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
792837 appContext?.let { SessionNotifications.postCompleted(it, sessionId, title, originProfileId) }
793838 }
794839
@@ -1350,6 +1395,13 @@ open class AppContainer private constructor(
13501395 observeMessageActivity()
13511396 observeRunReconcileOnReconnect()
13521397 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+ }
13531405 // Flush right after load() populates the queue. observeOutbox()'s combine is keyed
13541406 // on activeConnection/isOnline/_outboxFlushTrigger — NOT on outboxStore.messages — so
13551407 // 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
1717import androidx.compose.runtime.LaunchedEffect
1818import androidx.compose.runtime.getValue
1919import androidx.compose.runtime.mutableStateOf
20+import androidx.compose.runtime.remember
2021import androidx.compose.runtime.rememberCoroutineScope
2122import androidx.compose.runtime.saveable.rememberSaveable
2223import androidx.compose.runtime.setValue
2324import androidx.compose.ui.Modifier
25+import androidx.compose.ui.platform.LocalContext
2426import androidx.compose.ui.platform.LocalDensity
2527import androidx.compose.ui.unit.dp
2628import androidx.compose.ui.unit.LayoutDirection
@@ -172,8 +174,29 @@ fun OpencodeApp(container: AppContainer) {
172174 val slideDir = if (LocalLayoutDirection.current == LayoutDirection.Rtl) -1 else 1
173175 // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
174176 // 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
175196 BoxWithConstraints {
176− val isTwoPane = maxWidth >= NetworkConfig.twoPaneWidthThresholdDp.dp && connection != null
197+ val isTwoPane = maxWidth >= NetworkConfig.twoPaneWidthThresholdDp.dp &&
198+ connection != null &&
199+ !isHalfOpened
177200 // On large screens, surface a navigation rail alongside the content so top-level
178201 // destinations (Sessions, Files, Search, …) are one tap away instead of buried in a
179202 // screen's overflow menu. Mirrors the standard large-screen M3 layout.
@@ -486,3 +509,12 @@ fun OpencodeApp(container: AppContainer) {
486509 }
487510 }
488511}
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
3131import androidx.compose.foundation.layout.fillMaxWidth
3232import androidx.compose.foundation.layout.height
3333import androidx.compose.foundation.layout.imePadding
34+import androidx.compose.foundation.layout.ime
3435import androidx.compose.foundation.layout.navigationBars
3536import androidx.compose.foundation.layout.padding
3637import androidx.compose.foundation.layout.size
@@ -132,10 +133,12 @@ import androidx.compose.ui.platform.LocalDensity
132133import androidx.compose.ui.platform.LocalHapticFeedback
133134import androidx.compose.ui.platform.LocalView
134135import androidx.compose.ui.platform.testTag
136+import androidx.compose.ui.res.pluralStringResource
135137import androidx.compose.ui.res.stringResource
136138import androidx.compose.ui.semantics.Role
137139import androidx.compose.ui.semantics.contentDescription
138140import androidx.compose.ui.semantics.heading
141+import androidx.compose.ui.semantics.invisibleToUser
139142import androidx.compose.ui.semantics.semantics
140143import androidx.compose.ui.text.input.ImeAction
141144import androidx.compose.ui.text.style.TextOverflow
@@ -218,6 +221,8 @@ fun ChatScreen(
218221 val shareUrl by vm.shareUrl.collectAsStateWithLifecycle()
219222 val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
220223 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)
221226 val isOnline by container.isOnline.collectAsStateWithLifecycle()
222227 val haptics = LocalHapticFeedback.current
223228 val scope = rememberCoroutineScope()
@@ -952,6 +957,28 @@ fun ChatScreen(
952957 if (index >= 0) runCatchingCancellable { listState.animateScrollToItem(index) }
953958 }
954959
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+
955982 LaunchedEffect(Unit) {
956983 var prevSize = 0
957984 var prevLen = 0
@@ -1062,57 +1089,10 @@ fun ChatScreen(
10621089 // Reserve top space when the banner is visible so it doesn't overlap the
10631090 // first message / state content. (topPad computed above.)
10641091 if (loading && messages.isEmpty()) {
1065− val loadingLabel = stringResource(R.string.loading)
10661092 // Skeleton bubbles pre-structure the layout so the perceived load is faster
10671093 // 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
1069− // reduced-motion) to convey ongoing work; the column carries the a11y label.
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− }
1094+ // already-shaped list instead of a blank-then-pop.
1095+ ChatSkeleton(topPad = topPad)
11161096 } else if (loadError && messages.isEmpty()) {
11171097 // A failed load with nothing to show. Distinct from the empty
11181098 // conversation state below — the conversation isn't empty, it
@@ -1132,18 +1112,11 @@ fun ChatScreen(
11321112 }
11331113 }
11341114 } else if (messages.isEmpty() && running) {
1135− // A run just started but no parts have arrived yet — the empty
1136− // list with only the trailing "working…" row looks broken, so
1137− // surface a clear starting state until the first part streams in.
1138− val workingText = stringResource(R.string.working)
1139− Column(
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− }
1115+ // A run just started but no parts have arrived yet — the empty list with
1116+ // only the trailing "working…" row looks broken. Reuse the skeleton bubbles
1117+ // from the initial-load branch so the perceived start latency is lower and
1118+ // the first real assistant part pops into an already-shaped list.
1119+ ChatSkeleton(topPad = topPad)
11471120 } else if (messages.isEmpty()) {
11481121 EmptyConversation(
11491122 onSuggestion = {
@@ -1178,7 +1151,9 @@ fun ChatScreen(
11781151 end = 16.dp,
11791152 bottom = 16.dp + NetworkConfig.chatListFabInsetDp.dp,
11801153 ),
1181− verticalArrangement = Arrangement.spacedBy(16.dp),
1154+ // Compact spacing (8dp) vs the default 16dp — a power-user toggle to fit
1155+ // more conversation on screen without scrolling as much.
1156+ verticalArrangement = Arrangement.spacedBy(if (compactSpacing) 8.dp else 16.dp),
11821157 ) {
11831158 items(
11841159 items = listItems,
@@ -1442,9 +1417,18 @@ fun ChatScreen(
14421417 BadgedBox(
14431418 badge = {
14441419 if (newContentCount > 0) {
1445− val newContentLabel = stringResource(R.string.new_messages)
1420+ // Merge the count into the badge announcement so TalkBack reads
1421+ // "5 new messages" instead of the bare label and a separate number.
1422+ val newContentLabel = pluralStringResource(
1423+ R.plurals.plurals_new_messages,
1424+ newContentCount,
1425+ newContentCount,
1426+ )
14461427 Badge(modifier = Modifier.semantics { contentDescription = newContentLabel }) {
1447− Text(if (newContentCount > 99) "99+" else newContentCount.toString())
1428+ Text(
1429+ if (newContentCount > 99) "99+" else newContentCount.toString(),
1430+ modifier = Modifier.semantics { invisibleToUser() },
1431+ )
14481432 }
14491433 }
14501434 },
@@ -1706,6 +1690,10 @@ fun ChatScreen(
17061690 onSelect = { vm.selectModel(it) },
17071691 onRetry = { vm.reloadModels() },
17081692 onDismiss = { showModelPicker = false },
1693+ preferredModelId = preferredModelId,
1694+ onSetPreferredModel = { id ->
1695+ scope.launch { runCatchingCancellable { container.settingsStore.setPreferredModelId(id) } }
1696+ },
17091697 )
17101698 }
17111699
@@ -2694,3 +2682,58 @@ private fun DateSeparator(label: String) {
26942682 }
26952683 }
26962684}
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(
686686 loading: MutableStateFlow<Boolean>,
687687 error: MutableStateFlow<Boolean>,
688688 fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> T,
689− onSuccess: (T) -> Unit,
689+ onSuccess: suspend (T) -> Unit,
690690 onNull: () -> Unit,
691691 reloadTrigger: StateFlow<Int>,
692692 ) {
@@ -701,7 +701,7 @@ class ChatViewModel(
701701 // ClientRequestException whose message embeds the full request URL
702702 // (may contain auth/paths). Log only a scrubbed summary.
703703 .onFailure { Log.w("ChatViewModel", "Failed to load $tag: ${safeExceptionSummary(it)}"); error.value = true }
704− .getOrNull()?.let(onSuccess)
704+ .getOrNull()?.let { onSuccess(it) }
705705 loading.value = false
706706 }
707707 }
@@ -758,6 +758,16 @@ class ChatViewModel(
758758 // this collector does, including transient SSE drops and the cold-start
759759 // restore path — would drop a legitimately queued/recovered follow-up.
760760 _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+ }
761771 _sessionTitle.value = null
762772 // Resolve the app-bar title via REST for THIS connection. Launched from the same
763773 // block that just nulled _sessionTitle (and cancelled the previous fetch) so it can
@@ -864,12 +874,21 @@ class ChatViewModel(
864874 onSuccess = { resp ->
865875 val options = resp.toOptions()
866876 _models.value = options
867− // Only (re)apply the server default when there's no valid current
868− // selection. On re-fetch/reconnect/Retry the user's chosen model must
869− // survive — keep it as long as it's still present in the refreshed list.
877+ // Only (re)apply the default when there's no valid current selection. On
878+ // re-fetch/reconnect/Retry the user's chosen model must survive — keep it as
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.
870883 val current = _selectedModel.value
871884 if (current == null || options.none { it.ref == current.ref }) {
872− _selectedModel.value = resp.defaultOption(options)
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
891+ _selectedModel.value = preferredOption ?: resp.defaultOption(options)
873892 }
874893 },
875894 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
4444import androidx.compose.runtime.mutableStateOf
4545import androidx.compose.runtime.remember
4646import androidx.compose.runtime.setValue
47+import androidx.compose.animation.togetherWith
4748import androidx.compose.ui.Alignment
4849import androidx.compose.ui.Modifier
4950import androidx.compose.ui.draw.clip
@@ -443,79 +444,90 @@ private fun UserBubble(
443444 verticalAlignment = Alignment.CenterVertically,
444445 ) {
445446 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
446− when (sendStatus) {
447− MessageSendStatus.SENDING -> {
448− CircularProgressIndicator(
449− Modifier.size(12.dp),
450− 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,
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,
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) {
458+ MessageSendStatus.SENDING -> {
459+ CircularProgressIndicator(
460+ Modifier.size(12.dp),
461+ strokeWidth = 1.5.dp,
462+ color = MaterialTheme.colorScheme.onSurfaceVariant,
489463 )
490464 Text(
491− stringResource(R.string.message_send_failed),
465+ stringResource(R.string.message_sending),
492466 style = MaterialTheme.typography.labelSmall,
493− color = MaterialTheme.colorScheme.error,
494− modifier = Modifier.padding(start = 4.dp),
467+ color = MaterialTheme.colorScheme.onSurfaceVariant,
495468 )
496469 }
497− if (onDismiss != null) {
498− // No size override on the IconButton: it keeps the default 48dp
499− // touch target (only the inner Icon is shrunk to 14dp). An earlier
500− // `.size(20.dp)` here clamped the target to 20dp — well under the
501− // M3 minimum — which made the discard action hard to hit. Matches
502− // the pattern used by every other inline IconButton in this file.
503− IconButton(
504− onClick = {
505− haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
506− onDismiss()
470+ MessageSendStatus.FAILED -> {
471+ // Tap-to-retry: the whole failed label is tappable to re-send (far more
472+ // discoverable than relying on the transient snackbar). A dismiss (×) button
473+ // removes the abandoned message entirely, since its text will never match a
474+ // real server message and would otherwise linger forever.
475+ val retryModifier = if (onRetry != null) {
476+ Modifier.clickable(
477+ interactionSource = remember { MutableInteractionSource() },
478+ indication = ripple(),
479+ role = Role.Button,
480+ onClick = {
481+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
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)
507493 },
508494 ) {
509495 Icon(
510− Icons.Filled.Close,
511− contentDescription = stringResource(R.string.outbox_discard),
512− modifier = Modifier.size(14.dp),
513− tint = MaterialTheme.colorScheme.onSurfaceVariant,
496+ Icons.Filled.Error,
497+ contentDescription = null,
498+ modifier = Modifier.size(12.dp),
499+ tint = MaterialTheme.colorScheme.error,
514500 )
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+ }
515527 }
516528 }
529+ null -> {}
517530 }
518− null -> {}
519531 }
520532 if (isEdited && sendStatus == null) {
521533 Text(
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+35
@@ -92,6 +92,8 @@ fun ModelPickerSheet(
9292 onSelect: (ModelOption) -> Unit,
9393 onRetry: () -> Unit,
9494 onDismiss: () -> Unit,
95+ preferredModelId: String = "",
96+ onSetPreferredModel: (String) -> Unit = {},
9597) {
9698 // Hoist SheetState + skipPartiallyExpanded so the sheet opens full-height and selection
9799 // animates out (see AgentPickerSheet for the full rationale).
@@ -253,6 +255,39 @@ fun ModelPickerSheet(
253255 }
254256 }
255257 }
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+ }
256291 }
257292 }
258293 }
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+35
@@ -19,6 +19,7 @@ import androidx.compose.material3.OutlinedButton
1919import androidx.compose.material3.Text
2020import androidx.compose.material3.TextButton
2121import androidx.compose.runtime.Composable
22+import androidx.compose.runtime.LaunchedEffect
2223import androidx.compose.runtime.mutableStateOf
2324import androidx.compose.runtime.remember
2425import androidx.compose.runtime.getValue
@@ -28,14 +29,21 @@ import androidx.compose.ui.platform.LocalHapticFeedback
2829import androidx.compose.ui.res.stringResource
2930import androidx.compose.ui.text.font.FontFamily
3031import androidx.compose.ui.unit.dp
32+import kotlinx.coroutines.delay
3133import soy.iko.opencode.data.model.Permission
3234import soy.iko.opencode.data.model.PermissionResponse
35+import soy.iko.opencode.data.network.NetworkConfig
3336import soy.iko.opencode.R
3437
3538/**
3639 * Modal asking the user to approve a tool the agent wants to run. The agent run is
3740 * paused until one of these responses is sent. Non-dismissable by tap-outside so the
3841 * 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].
3947 */
4048@Composable
4149fun PermissionDialog(
@@ -58,12 +66,29 @@ fun PermissionDialog(
5866 // remember resets to false on every fresh composition, so a re-queued permission is
5967 // always answerable.
6068 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) }
6172 val respond: (PermissionResponse) -> Unit = { response ->
6273 if (!responded) {
6374 responded = true
6475 onRespond(response)
6576 }
6677 }
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
6792 AlertDialog(
6893 // Back press routes here (dismissOnBackPress defaults true) and is treated as an
6994 // explicit reject — the safe default. Tap-outside is disabled below so it can't
@@ -116,6 +141,16 @@ fun PermissionDialog(
116141 style = MaterialTheme.typography.bodySmall,
117142 color = MaterialTheme.colorScheme.onSurfaceVariant,
118143 )
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+ }
119154 }
120155 },
121156 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):
228228 return builder.toAnnotatedString()
229229}
230230
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+
231408/** Result of attempting to emit one token starting at [start]: the next scan index and
232409 * whether a token was consumed (vs. falling through to the default plain-text emit). */
233410private 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
1313import androidx.compose.foundation.layout.defaultMinSize
1414import androidx.compose.foundation.layout.padding
1515import 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
1619import androidx.compose.foundation.rememberScrollState
1720import androidx.compose.material.icons.Icons
1821import androidx.compose.material.icons.filled.ContentCopy
@@ -45,6 +48,7 @@ import androidx.compose.ui.res.pluralStringResource
4548import androidx.compose.ui.res.stringResource
4649import soy.iko.opencode.R
4750import soy.iko.opencode.data.network.NetworkConfig
51+import soy.iko.opencode.ui.theme.diffColors
4852
4953/** A single line of a parsed unified diff. */
5054sealed interface DiffLine {
@@ -162,14 +166,15 @@ private fun syntaxForHeader(headerText: String): FileSyntax? {
162166@Composable
163167fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = null) {
164168 val lines = remember(diff) { parseDiff(diff) }
165− // Derive the four row colors once per color scheme instead of allocating fresh Color
166− // objects (the .copy() calls) on every recomposition — a diff re-parses/recomposes
167− // per token while its output streams under the collapse limit.
169+ // Use dedicated diff add/remove colors (a fixed green/red pair tuned to the theme) so the
170+ // diff semantics stay consistent under Material You — where the primary role may not be
171+ // green-tinted, an "added" line would otherwise read as a primary-tinted highlight.
168172 val scheme = MaterialTheme.colorScheme
169− val addColor = remember(scheme) { scheme.primary.copy(alpha = 0.15f) }
170− val removeColor = remember(scheme) { scheme.error.copy(alpha = 0.15f) }
171− val addText = scheme.primary
172− val removeText = scheme.error
173+ val diffColors = diffColors()
174+ val addColor = remember(scheme) { diffColors.addBg }
175+ val removeColor = remember(scheme) { diffColors.removeBg }
176+ val addText = diffColors.addText
177+ val removeText = diffColors.removeText
173178 val context = LocalContext.current
174179 val hScrollState = rememberScrollState()
175180 // 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
189194 .clip(MaterialTheme.shapes.small)
190195 .background(MaterialTheme.colorScheme.surfaceVariant),
191196 ) {
192− Row(
193− modifier = Modifier.fillMaxWidth().padding(top = 2.dp, start = 4.dp, end = 4.dp),
194− verticalAlignment = Alignment.CenterVertically,
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 }) {
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) }) {
213− Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
214− }
215− }
197+ DiffHeader(
198+ lineCount = lines.size,
199+ expanded = expanded,
200+ onToggle = { expanded = !expanded },
201+ onCopy = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) },
202+ )
216203 val visibleLines = if (lines.size <= NetworkConfig.collapsedDiffLineThreshold || expanded) lines
217204 else lines.subList(0, NetworkConfig.collapsedDiffLineThreshold)
218205 // One horizontalScroll on the container instead of one per row: each modifier
219206 // adds a layout node + clip + offset pass, so a 200-line collapsed diff was
220207 // 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
222− // so the gutter can show absolute line numbers (a big readability win for review).
223− var oldLine = 0
224− var newLine = 0
225− val hunkRegex = Regex("""@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@""")
208+ val rows = remember(visibleLines, addColor, removeColor, addText, removeText, scheme) {
209+ buildDiffRows(
210+ visibleLines,
211+ addColor, removeColor, addText, removeText,
212+ scheme.tertiary, scheme.onSurface, scheme.onSurfaceVariant,
213+ )
214+ }
226215 Column(modifier = Modifier.horizontalScroll(hScrollState)) {
227− visibleLines.forEach { line ->
228− when (line) {
229− is DiffLine.Hunk -> {
230− val m = hunkRegex.find(line.text)
231− if (m != null) {
232− oldLine = m.groupValues[1].toInt()
233− newLine = m.groupValues[2].toInt()
234− }
235− Text(
236− line.text,
237− style = MaterialTheme.typography.bodySmall,
238− fontFamily = FontFamily.Monospace,
239− color = MaterialTheme.colorScheme.tertiary,
240− modifier = Modifier.padding(horizontal = 10.dp, vertical = 2.dp),
241− )
242− }
243− is DiffLine.FileHeader -> {
244− // Render the display path (e.g. `src/Foo.kt`) as a bold header rather
245− // than the raw `+++ b/src/Foo.kt`, so file boundaries are scannable in
246− // a long multi-file diff. The raw line is still copied with the diff.
247− val displayPath = extractDisplayPath(line.text)
248− val isHeader = displayPath != line.text
249− Text(
250− displayPath,
251− style = MaterialTheme.typography.labelMedium,
252− fontFamily = FontFamily.Monospace,
253− fontWeight = if (isHeader) FontWeight.SemiBold else FontWeight.Normal,
254− color = MaterialTheme.colorScheme.onSurface,
255− modifier = Modifier
256− .fillMaxWidth()
257− .padding(horizontal = 10.dp, vertical = 3.dp)
258− .semantics { heading() },
259− )
260− }
261− is DiffLine.Meta -> Text(
262− line.text,
263− style = MaterialTheme.typography.bodySmall,
264− fontFamily = FontFamily.Monospace,
265− color = MaterialTheme.colorScheme.onSurfaceVariant,
266− modifier = Modifier.padding(vertical = 1.dp),
267− )
268− is DiffLine.Add -> {
269− val n = newLine++
270− DiffRow(line.text, "+", addColor, addText, oldLine = null, newLine = n, syntax = syntax, palette = palette)
271− }
272− is DiffLine.Remove -> {
273− val o = oldLine++
274− DiffRow(line.text, "-", removeColor, removeText, oldLine = o, newLine = null, syntax = syntax, palette = palette)
275− }
276− is DiffLine.Context -> {
277− val o = oldLine++; val n = newLine++
278− DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface, oldLine = o, newLine = n, syntax = syntax, palette = palette)
279− }
280− }
216+ rows.forEach { row -> RenderDiffRow(row, syntax, palette) }
217+ }
218+ DiffExpandFooter(lines = lines, expanded = expanded, onToggle = { expanded = !expanded })
219+ }
220+}
221+
222+/**
223+ * Lazy variant of [DiffView] for callers that need the diff to scroll on its own (e.g. the
224+ * file viewer) WITHOUT an enclosing verticalScroll. A 5000-line diff in [DiffView] would
225+ * compose every row eagerly; this variant uses a [LazyColumn] so only the visible rows
226+ * compose. The collapse threshold still applies for the *initial* view, but expanding
227+ * switches to lazy rendering so a huge expanded diff doesn't OOM.
228+ */
229+@Composable
230+fun LazyDiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = null) {
231+ val lines = remember(diff) { parseDiff(diff) }
232+ val scheme = MaterialTheme.colorScheme
233+ val diffColors = diffColors()
234+ val addColor = remember(scheme) { diffColors.addBg }
235+ val removeColor = remember(scheme) { diffColors.removeBg }
236+ val addText = diffColors.addText
237+ val removeText = diffColors.removeText
238+ val context = LocalContext.current
239+ val hScrollState = rememberScrollState()
240+ var expanded by rememberSaveable(saveKey ?: diff) { mutableStateOf(false) }
241+ val syntax = remember(lines) {
242+ lines.firstOrNull { it is DiffLine.FileHeader }?.let { syntaxForHeader(it.text) }
243+ }
244+ val palette = rememberHighlightPalette()
245+ val lazyListState = androidx.compose.foundation.lazy.rememberLazyListState()
246+
247+ Column(
248+ modifier = modifier
249+ .fillMaxWidth()
250+ .clip(MaterialTheme.shapes.small)
251+ .background(MaterialTheme.colorScheme.surfaceVariant),
252+ ) {
253+ DiffHeader(
254+ lineCount = lines.size,
255+ expanded = expanded,
256+ onToggle = { expanded = !expanded },
257+ onCopy = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) },
258+ )
259+ val visibleLines = if (lines.size <= NetworkConfig.collapsedDiffLineThreshold || expanded) lines
260+ else lines.subList(0, NetworkConfig.collapsedDiffLineThreshold)
261+ val rows = remember(visibleLines, addColor, removeColor, addText, removeText, scheme) {
262+ buildDiffRows(
263+ visibleLines,
264+ addColor, removeColor, addText, removeText,
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)
281280 }
282281 }
283− if (lines.size > NetworkConfig.collapsedDiffLineThreshold) {
284− val hidden = lines.size - NetworkConfig.collapsedDiffLineThreshold
285− TextButton(
286− onClick = { expanded = !expanded },
287− contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
288− modifier = Modifier.defaultMinSize(minHeight = 48.dp),
289− ) {
290− Text(
291− if (expanded) stringResource(R.string.show_less)
292− else pluralStringResource(R.plurals.show_more_lines, hidden, hidden),
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+ ) {
294+ if (lineCount > NetworkConfig.collapsedDiffLineThreshold) {
295+ val moreLines = lineCount - NetworkConfig.collapsedDiffLineThreshold
296+ IconButton(onClick = onToggle) {
297+ Icon(
298+ if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
299+ contentDescription = if (expanded) stringResource(R.string.show_less)
300+ else context.resources.getQuantityString(
301+ R.plurals.show_more_lines, moreLines, moreLines,
302+ ),
303+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
293304 )
294305 }
295306 }
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)
296427 }
297428}
298429
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+144−1
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.WindowInsets
1212import androidx.compose.foundation.layout.fillMaxSize
1313import androidx.compose.foundation.layout.fillMaxWidth
1414import androidx.compose.foundation.layout.heightIn
15+import androidx.compose.foundation.layout.navigationBars
1516import androidx.compose.foundation.layout.padding
1617import androidx.compose.foundation.layout.statusBars
1718import androidx.compose.foundation.layout.windowInsetsPadding
@@ -39,7 +40,9 @@ import androidx.compose.ui.platform.LocalContext
3940import androidx.compose.ui.res.stringResource
4041import androidx.compose.ui.semantics.Role
4142import androidx.compose.ui.semantics.contentDescription
43+import androidx.compose.ui.semantics.invisibleToUser
4244import androidx.compose.ui.semantics.semantics
45+import androidx.compose.ui.text.style.TextOverflow
4346import androidx.compose.ui.unit.dp
4447import androidx.compose.ui.window.Dialog
4548import androidx.compose.ui.window.DialogProperties
@@ -53,12 +56,14 @@ import kotlinx.coroutines.withContext
5356import androidx.compose.material.icons.Icons
5457import androidx.compose.material.icons.filled.BrokenImage
5558import androidx.compose.material.icons.filled.Close
59+import androidx.compose.material.icons.filled.MoreVert
5660import soy.iko.opencode.R
5761import soy.iko.opencode.data.model.FilePart
5862import soy.iko.opencode.data.model.ServerProfile
5963import soy.iko.opencode.data.model.sourcePath
6064import soy.iko.opencode.data.network.HttpClientFactory
6165import soy.iko.opencode.data.network.NetworkConfig
66+import soy.iko.opencode.util.runCatchingCancellable
6267
6368/**
6469 * 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
243248 * Fullscreen zoomable image viewer opened by tapping an inline [RemoteImage]. Supports
244249 * pinch-to-zoom and pan via [detectTransformGestures], double-tap to toggle zoom, and
245250 * 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.
247252 */
248253@Composable
249254private fun FullscreenImageViewer(
@@ -257,6 +262,7 @@ private fun FullscreenImageViewer(
257262 var offsetX by remember { mutableFloatStateOf(0f) }
258263 var offsetY by remember { mutableFloatStateOf(0f) }
259264 val scope = rememberCoroutineScope()
265+ val context = LocalContext.current
260266 // Double-tap zoom tween, collapsed to an instant snap under reduced motion. Resolved in
261267 // the composable body (not inside the pointerInput coroutine) since it reads the
262268 // composition's LocalReducedMotion.
@@ -267,6 +273,11 @@ private fun FullscreenImageViewer(
267273 // a continuous motion rather than a snap.
268274 var dragOffsetY by remember { mutableFloatStateOf(0f) }
269275 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)
270281 Dialog(
271282 onDismissRequest = onDismiss,
272283 properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
@@ -365,6 +376,71 @@ private fun FullscreenImageViewer(
365376 tint = androidx.compose.ui.graphics.Color.White,
366377 )
367378 }
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+ }
368444 }
369445 }
370446}
@@ -415,3 +491,70 @@ private fun BrokenImageIcon() {
415491 tint = MaterialTheme.colorScheme.onSurfaceVariant,
416492 )
417493}
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
280280 // Scroll to the deepest segment when the path changes so the current directory stays in
281281 // view instead of hiding off the right edge behind the earlier segments on a deep path.
282282 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+ }
283290 Row(
284291 modifier = modifier
285292 .horizontalScroll(scrollState)
286293 .padding(horizontal = 16.dp, vertical = 8.dp),
287294 verticalAlignment = Alignment.CenterVertically,
288295 ) {
289− IconButton(onClick = { onNavigate("") }) {
296+ IconButton(onClick = { onSegmentClick("") }) {
290297 Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(24.dp))
291298 }
292299 var acc = ""
@@ -312,7 +319,7 @@ private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Mo
312319 overflow = TextOverflow.Ellipsis,
313320 modifier = Modifier
314321 .widthIn(max = segMaxWidth)
315− .clickable(role = Role.Button) { onNavigate(target) }
322+ .clickable(role = Role.Button) { onSegmentClick(target) }
316323 .defaultMinSize(minHeight = 48.dp)
317324 .padding(horizontal = 6.dp, vertical = 14.dp),
318325 )
@@ -374,6 +381,7 @@ private fun searchEmptyMessage(searching: Boolean, queryBlank: Boolean, hint: In
374381/** Content (ripgrep) search results: file + line number, with the matched line highlighted. */
375382@Composable
376383private fun TextResults(results: List<FindMatch>, emptyMessage: String?, onOpen: (String, Int?) -> Unit) {
384+ val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
377385 if (results.isEmpty()) {
378386 if (emptyMessage != null) {
379387 EmptyFileState(
@@ -403,7 +411,10 @@ private fun TextResults(results: List<FindMatch>, emptyMessage: String?, onOpen:
403411 modifier = Modifier
404412 .fillMaxWidth()
405413 .defaultMinSize(minHeight = 48.dp)
406− .clickable(role = Role.Button) { onOpen(match.filePath, match.lineNumber) }
414+ .clickable(role = Role.Button) {
415+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
416+ onOpen(match.filePath, match.lineNumber)
417+ }
407418 .padding(horizontal = 16.dp, vertical = 10.dp),
408419 ) {
409420 Row(verticalAlignment = Alignment.CenterVertically) {
@@ -448,6 +459,7 @@ private fun TextResults(results: List<FindMatch>, emptyMessage: String?, onOpen:
448459/** Workspace symbol results: symbol name + kind, and the file:line it's defined at. */
449460@Composable
450461private fun SymbolResults(results: List<SymbolResult>, emptyMessage: String?, onOpen: (String, Int?) -> Unit) {
462+ val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
451463 if (results.isEmpty()) {
452464 if (emptyMessage != null) {
453465 EmptyFileState(
@@ -475,7 +487,10 @@ private fun SymbolResults(results: List<SymbolResult>, emptyMessage: String?, on
475487 modifier = Modifier
476488 .fillMaxWidth()
477489 .defaultMinSize(minHeight = 48.dp)
478− .clickable(role = Role.Button) { onOpen(symbol.filePath, symbol.displayLine) }
490+ .clickable(role = Role.Button) {
491+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
492+ onOpen(symbol.filePath, symbol.displayLine)
493+ }
479494 .padding(horizontal = 16.dp, vertical = 10.dp),
480495 ) {
481496 Row(verticalAlignment = Alignment.CenterVertically) {
@@ -688,6 +703,12 @@ private fun FileRow(
688703 modifier: Modifier = Modifier,
689704) {
690705 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+ }
691712 // Long-press → "Copy path" dropdown. Rendered only when [onCopyPath] is supplied, so the
692713 // parent ".." row (which passes none) stays a plain tap target. Anchored within the row.
693714 // rememberSaveable so an open row menu survives a rotation instead of closing.
@@ -716,7 +737,7 @@ private fun FileRow(
716737 .defaultMinSize(minHeight = 48.dp)
717738 .combinedClickable(
718739 role = Role.Button,
719− onClick = onClick,
740+ onClick = onRowClick,
720741 onLongClick = onCopyPath?.let { { menu = true } },
721742 onLongClickLabel = onCopyPath?.let { copyPathLabel },
722743 )
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+97−7
@@ -2,6 +2,10 @@ package soy.iko.opencode.ui.file
22
33import android.content.Intent
44import 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
59import androidx.compose.animation.core.tween
610import androidx.compose.animation.fadeIn
711import androidx.compose.animation.fadeOut
@@ -17,6 +21,7 @@ import androidx.compose.foundation.layout.Row
1721import androidx.compose.foundation.layout.Spacer
1822import androidx.compose.foundation.layout.fillMaxSize
1923import androidx.compose.foundation.layout.fillMaxWidth
24+import androidx.compose.foundation.layout.height
2025import androidx.compose.foundation.layout.imePadding
2126import androidx.compose.foundation.layout.padding
2227import androidx.compose.foundation.layout.size
@@ -29,7 +34,6 @@ import androidx.compose.foundation.rememberScrollState
2934import androidx.compose.foundation.text.KeyboardActions
3035import androidx.compose.foundation.text.KeyboardOptions
3136import androidx.compose.foundation.text.selection.SelectionContainer
32−import androidx.compose.foundation.verticalScroll
3337import androidx.compose.material.icons.Icons
3438import androidx.compose.material.icons.automirrored.filled.ArrowBack
3539import androidx.compose.material.icons.automirrored.filled.WrapText
@@ -72,6 +76,8 @@ import androidx.compose.runtime.saveable.rememberSaveable
7276import androidx.compose.runtime.setValue
7377import androidx.compose.ui.Alignment
7478import androidx.compose.ui.Modifier
79+import androidx.compose.ui.draw.alpha
80+import androidx.compose.ui.draw.clip
7581import androidx.compose.ui.layout.onSizeChanged
7682import androidx.compose.ui.platform.LocalContext
7783import androidx.compose.ui.platform.LocalDensity
@@ -596,13 +602,56 @@ private fun BoxScope.FileViewStateContent(
596602 onToggleCaseSensitive: () -> Unit,
597603) {
598604 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
600606 // 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.
601610 state.loading && state.content == null -> {
602611 val loadingLabel = stringResource(R.string.loading)
603− CircularProgressIndicator(
604− Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
612+ val reducedMotion = soy.iko.opencode.ui.components.LocalReducedMotion.current
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",
605619 )
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+ }
606655 }
607656 state.error != null -> Column(
608657 modifier = Modifier.align(Alignment.Center).padding(24.dp),
@@ -735,12 +784,13 @@ private fun BoxScope.FileViewContentBody(
735784 }
736785 }
737786 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(
739790 diff = content.diff,
740791 modifier = Modifier
741792 .fillMaxSize()
742793 .padding(top = topInset)
743− .verticalScroll(rememberScrollState())
744794 .padding(8.dp),
745795 )
746796 } else {
@@ -876,6 +926,32 @@ private fun FileTextContent(
876926 val palette = rememberHighlightPalette()
877927 // Resolve the file's language once instead of re-parsing the extension for every line.
878928 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+ }
879955 val q = findQuery.trim()
880956 val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
881957 MaterialTheme(typography = scaled) {
@@ -937,7 +1013,21 @@ private fun FileTextContent(
9371013 // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
9381014 // file, or palette actually changes — not on every recomposition (e.g. every
9391015 // keystroke into find-in-file, which would otherwise re-highlight all visible lines).
940− val highlighted = remember(line, syntax, palette) { highlightLine(line, syntax, palette) }
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).
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+ }
9411031 Text(
9421032 highlighted,
9431033 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(
168168 }
169169 if (state.truncated) {
170170 item(key = "__truncated") {
171− Text(
172− stringResource(R.string.search_truncated),
173− style = MaterialTheme.typography.labelSmall,
174− color = MaterialTheme.colorScheme.onSurfaceVariant,
175− modifier = Modifier.padding(vertical = 4.dp),
176− )
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(
178+ stringResource(R.string.search_truncated),
179+ style = MaterialTheme.typography.labelSmall,
180+ color = MaterialTheme.colorScheme.onSurfaceVariant,
181+ )
182+ androidx.compose.material3.TextButton(
183+ onClick = { vm.searchMore() },
184+ enabled = !state.searching,
185+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
186+ ) {
187+ Text(stringResource(R.string.search_more))
188+ }
189+ }
177190 }
178191 }
179192 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(
5252 /** True once a search has run for the current query (distinguishes "no results" from
5353 * "nothing searched yet" so the UI shows the right empty state). */
5454 val hasSearched: Boolean = false,
55− /** True when there were more sessions than [NetworkConfig.maxSearchSessions], so the
56− * search only covered the most recent ones. */
55+ /** True when there were more sessions than the current cap, so the search only covered
56+ * the most recent ones. The cap starts at [NetworkConfig.maxSearchSessions] and grows
57+ * via [searchMore] so the user can page through older sessions. */
5758 val truncated: Boolean = false,
5859 /** How many sessions have been scanned so far in the current pass, for progress feedback
5960 * (a global search downloads each session's history, so a 50-session pass can take a
@@ -61,6 +62,8 @@ data class GlobalSearchState(
6162 val searchedCount: Int = 0,
6263 /** Total sessions being scanned in the current pass ([searchedCount] / [totalCount]). */
6364 val totalCount: Int = 0,
65+ /** The cap applied to this pass. Grows when the user taps "Search more". */
66+ val sessionCap: Int = NetworkConfig.maxSearchSessions,
6467)
6568
6669/**
@@ -109,11 +112,24 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
109112 sessionsCache = null
110113 messageCache.clear()
111114 searchJob?.cancel()
112− _state.update { it.copy(searching = true, error = null) }
115+ _state.update { it.copy(searching = true, error = null, sessionCap = NetworkConfig.maxSearchSessions) }
113116 searchJob = viewModelScope.launch { runSearch(trimmed) }
114117 }
115118
116− private suspend fun runSearch(query: String) {
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+
132+ private suspend fun runSearch(query: String, cap: Int = _state.value.sessionCap) {
117133 val conn = container.activeConnection.value
118134 if (conn == null) {
119135 _state.update { it.copy(searching = false, error = container.string(R.string.not_connected)) }
@@ -125,9 +141,9 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
125141 _state.update { s -> s.copy(searching = false, error = container.friendlyError(it)) }
126142 return
127143 }.also { sessionsCache = it }
128− val toSearch = sessions.take(NetworkConfig.maxSearchSessions)
144+ val toSearch = sessions.take(cap)
129145 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) }
131147 val hits = java.util.Collections.synchronizedMap(HashMap<String, SearchHit>())
132148 val semaphore = Semaphore(NetworkConfig.maxConcurrentPreviews)
133149 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
5656import androidx.compose.ui.platform.testTag
5757import androidx.compose.ui.semantics.Role
5858import androidx.compose.ui.semantics.contentDescription
59+import androidx.compose.ui.semantics.role
5960import androidx.compose.ui.semantics.semantics
6061import androidx.compose.ui.semantics.stateDescription
6162import androidx.compose.ui.text.input.ImeAction
@@ -215,9 +216,17 @@ private fun ServerEditForm(
215216 val suggestion = suggestUrlScheme(state.baseUrl)
216217 if (suggestion != null) {
217218 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.
218223 TextButton(
219224 onClick = { vm.update { it.copy(baseUrl = suggestion) } },
220225 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
226+ modifier = Modifier.semantics {
227+ role = Role.Button
228+ contentDescription = state.baseUrl
229+ },
221230 ) {
222231 Text(stringResource(labelRes))
223232 }
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+13−4
@@ -308,11 +308,20 @@ fun ServerListScreen(
308308 }
309309 if (filtered.isEmpty()) {
310310 item(key = "__no_match") {
311− EmptyState(
312− icon = Icons.Filled.Search,
313− title = stringResource(R.string.no_server_matches),
311+ Column(
314312 modifier = Modifier.fillMaxWidth().padding(24.dp),
315− )
313+ horizontalAlignment = Alignment.CenterHorizontally,
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+ }
316325 }
317326 }
318327 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
3838import androidx.compose.material.icons.filled.Delete
3939import androidx.compose.material.icons.filled.DoneAll
4040import androidx.compose.material.icons.filled.MarkChatRead
41+import androidx.compose.material.icons.filled.NotificationsOff
4142import androidx.compose.material.icons.filled.Folder
4243import androidx.compose.material.icons.filled.History
4344import androidx.compose.material.icons.filled.Link
@@ -102,6 +103,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
102103import androidx.lifecycle.viewmodel.compose.viewModel
103104import kotlinx.coroutines.coroutineScope
104105import kotlinx.coroutines.flow.collectLatest
106+import kotlinx.coroutines.flow.distinctUntilChanged
105107import kotlinx.coroutines.flow.merge
106108import kotlinx.coroutines.delay
107109import kotlinx.coroutines.launch
@@ -145,6 +147,7 @@ fun SessionListScreen(
145147 val switchingId by vm.switchingId.collectAsStateWithLifecycle()
146148 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
147149 val unread by vm.unread.collectAsStateWithLifecycle()
150+ val mutedSessions by container.mutedSessions.collectAsStateWithLifecycle()
148151 val creating by vm.creating.collectAsStateWithLifecycle()
149152 val directoryOptions by vm.directoryOptions.collectAsStateWithLifecycle()
150153 val anyRunActive by container.anyRunActive.collectAsStateWithLifecycle()
@@ -435,6 +438,7 @@ fun SessionListScreen(
435438 SessionListBody(
436439 state = state,
437440 unread = unread,
441+ mutedSessions = mutedSessions,
438442 refreshing = refreshing,
439443 haptics = haptics,
440444 selectedSessionId = selectedSessionId,
@@ -446,6 +450,8 @@ fun SessionListScreen(
446450 onDelete = { pendingDeleteId = it },
447451 onPin = { vm.togglePin(it) },
448452 onArchive = { vm.toggleArchive(it) },
453+ onMarkUnread = { vm.markUnread(it) },
454+ onToggleMute = { container.toggleSessionMute(it) },
449455 onFilterByDirectory = vm::setDirectoryFilter,
450456 inSelection = inSelection,
451457 selectedIds = selectedIds,
@@ -545,6 +551,7 @@ fun SessionListScreen(
545551private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
546552 state: SessionListState,
547553 unread: Map<String, Int>,
554+ mutedSessions: Set<String>,
548555 refreshing: Boolean,
549556 haptics: androidx.compose.ui.hapticfeedback.HapticFeedback,
550557 selectedSessionId: String?,
@@ -556,6 +563,8 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
556563 onDelete: (String) -> Unit,
557564 onPin: (Session) -> Unit,
558565 onArchive: (Session) -> Unit,
566+ onMarkUnread: (String) -> Unit,
567+ onToggleMute: (String) -> Unit,
559568 onFilterByDirectory: (String) -> Unit,
560569 inSelection: Boolean,
561570 selectedIds: Set<String>,
@@ -674,17 +683,41 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
674683 // Order child (sub-)sessions immediately under their parent, tracking depth
675684 // so the UI can indent them into a tree.
676685 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+ }
677695 PullToRefreshBox(
678696 isRefreshing = refreshing,
679697 onRefresh = onRefresh,
680698 modifier = Modifier.fillMaxSize(),
681699 ) {
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+ }
682714 LazyColumn(
715+ state = listState,
683716 modifier = Modifier.fillMaxSize(),
684717 contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 96.dp),
685718 verticalArrangement = Arrangement.spacedBy(12.dp),
686719 ) {
687− items(nodes, key = { it.session.id }) { node ->
720+ items(visibleNodes, key = { it.session.id }) { node ->
688721 val session = node.session
689722 // Swipe end-to-start reveals a delete affordance and opens the
690723 // same confirmation dialog as the trash icon. We never commit
@@ -726,6 +759,9 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
726759 val onCardDelete = remember(session.id) { { onDelete(session.id) } }
727760 val onCardPin = remember(session.id) { { onPin(session) } }
728761 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
729765 val isPinned = session.id in state.pinnedIds
730766 val isArchived = session.id in state.archivedIds
731767 SwipeToDismissBox(
@@ -786,11 +822,14 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
786822 else session.id == selectedSessionId,
787823 isPinned = isPinned,
788824 isArchived = isArchived,
825+ isMuted = isMuted,
789826 onClick = onCardClick,
790827 onRename = onCardRename,
791828 onDelete = onCardDelete,
792829 onPin = onCardPin,
793830 onArchive = onCardArchive,
831+ onMarkUnread = onCardMarkUnread,
832+ onToggleMute = onCardToggleMute,
794833 onFilterByDirectory = onFilterByDirectory,
795834 isDirectoryFiltered = state.directoryFilter != null &&
796835 session.directory?.trimEnd('/') == state.directoryFilter,
@@ -942,6 +981,9 @@ private fun SessionCard(
942981 onDelete: () -> Unit,
943982 onPin: () -> Unit,
944983 onArchive: () -> Unit,
984+ onMarkUnread: () -> Unit,
985+ onToggleMute: () -> Unit,
986+ isMuted: Boolean,
945987 modifier: Modifier = Modifier,
946988 isSelected: Boolean = false,
947989 isPinned: Boolean = false,
@@ -1003,11 +1045,13 @@ private fun SessionCard(
10031045 }
10041046 Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
10051047 Row(verticalAlignment = Alignment.CenterVertically) {
1006− if (unreadCount > 0) {
1048+ if (unreadCount > 0 && !isMuted) {
10071049 val unreadLabel = stringResource(R.string.unread_count, unreadCount)
10081050 // Count badge: shows the number of unread messages so the user
10091051 // can tell a single reply from a burst. Falls back to a dot for
10101052 // 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).
10111055 if (unreadCount == 1) {
10121056 Box(
10131057 modifier = Modifier
@@ -1052,6 +1096,14 @@ private fun SessionCard(
10521096 tint = MaterialTheme.colorScheme.onSurfaceVariant,
10531097 )
10541098 }
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+ }
10551107 // Server-side state badges (shared / reverted). These are surfaced in
10561108 // the chat screen but weren't in the list, so the list never reflected
10571109 // them. Muted so they read as status, not as unread emphasis.
@@ -1144,6 +1196,8 @@ private fun SessionCard(
11441196 val moreLabel = stringResource(R.string.more_options_for, session.displayTitle)
11451197 val pinLabel = stringResource(if (isPinned) R.string.session_unpin else R.string.session_pin)
11461198 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)
11471201 Box {
11481202 IconButton(onClick = { showRowMenu = true }) {
11491203 Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
@@ -1160,6 +1214,18 @@ private fun SessionCard(
11601214 text = { Text(archiveLabel) },
11611215 onClick = { showRowMenu = false; onArchive() },
11621216 )
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+ )
11631229 DropdownMenuItem(
11641230 text = { Text(renameLabel) },
11651231 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() {
793793 ids.forEach { container.markSessionRead(it) }
794794 }
795795
796+ /** Mark a single session as unread (re-badges it in the list). */
797+ fun markUnread(id: String) {
798+ container.markSessionUnread(id)
799+ }
800+
796801 /**
797802 * Permanently delete the given sessions. Unlike a single swipe-delete this is NOT undoable
798803 * (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
4545import androidx.compose.material3.HorizontalDivider
4646import androidx.compose.material3.Icon
4747import androidx.compose.material3.IconButton
48+import androidx.compose.material3.LinearProgressIndicator
4849import androidx.compose.material3.MaterialTheme
4950import androidx.compose.material3.MenuAnchorType
5051import androidx.compose.material3.OutlinedTextField
@@ -145,6 +146,8 @@ fun SettingsScreen(
145146 val chatTextScale by container.settingsStore.chatTextScale
146147 .collectAsStateWithLifecycle(initialValue = SettingsStore.DEFAULT_CHAT_TEXT_SCALE)
147148 val codeWrap by container.settingsStore.codeWrap.collectAsStateWithLifecycle(initialValue = false)
149+ val compactSpacing by container.settingsStore.compactMessageSpacing
150+ .collectAsStateWithLifecycle(initialValue = false)
148151 val appLockReLockSeconds by container.settingsStore.appLockReLockSeconds
149152 .collectAsStateWithLifecycle(initialValue = SettingsStore.DEFAULT_APP_LOCK_RELOCK_SECONDS)
150153 val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
@@ -185,6 +188,11 @@ fun SettingsScreen(
185188 // import reads a chosen file and applies it. Feedback goes through the snackbar host so it's
186189 // accessible to TalkBack and consistent with the rest of the app (Toast isn't).
187190 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) }
188196 // Import overwrites all profiles/settings with no undo, so a picked file is staged here
189197 // and only applied once the user confirms the replace dialog below. Export with passwords
190198 // included warns first (plaintext credentials leave the app).
@@ -201,12 +209,17 @@ fun SettingsScreen(
201209 ) { uri ->
202210 if (uri == null) return@rememberLauncherForActivityResult
203211 scope.launch {
204− val ok = runCatchingCancellable {
205− val text = container.backupManager.export(includePasswords)
206− context.contentResolver.openOutputStream(uri)?.use { it.write(text.toByteArray()) }
207− ?: error("no output stream")
208− }.isSuccess
209− snackbar.showSnackbar(if (ok) exportedMsg else exportFailedMsg)
212+ backupBusy = true
213+ try {
214+ val ok = runCatchingCancellable {
215+ val text = container.backupManager.export(includePasswords)
216+ context.contentResolver.openOutputStream(uri)?.use { it.write(text.toByteArray()) }
217+ ?: error("no output stream")
218+ }.isSuccess
219+ snackbar.showSnackbar(if (ok) exportedMsg else exportFailedMsg)
220+ } finally {
221+ backupBusy = false
222+ }
210223 }
211224 }
212225 val importLauncher = rememberLauncherForActivityResult(
@@ -420,6 +433,31 @@ fun SettingsScreen(
420433 Switch(checked = codeWrap, onCheckedChange = null)
421434 }
422435
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+
423461 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
424462
425463 Text(
@@ -559,7 +597,7 @@ fun SettingsScreen(
559597 NavRow(
560598 icon = Icons.Filled.Upload,
561599 label = stringResource(R.string.export_backup),
562− enabled = true,
600+ enabled = !backupBusy,
563601 // Warn before writing plaintext credentials out of the app; confirm then launches
564602 // the file picker. Without passwords, export straight away.
565603 onClick = {
@@ -570,13 +608,19 @@ fun SettingsScreen(
570608 NavRow(
571609 icon = Icons.Filled.Download,
572610 label = stringResource(R.string.import_backup),
573− enabled = true,
611+ enabled = !backupBusy,
574612 onClick = {
575613 runCatching {
576614 importLauncher.launch(arrayOf("application/json", "text/plain", "application/octet-stream"))
577615 }
578616 },
579617 )
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+ }
580624
581625 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
582626
@@ -657,28 +701,33 @@ fun SettingsScreen(
657701 TextButton(onClick = {
658702 pendingImportUri = null
659703 scope.launch {
660− val ok = runCatchingCancellable {
661− val text = context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() }
662− ?: error("no input stream")
663− container.backupManager.import(text)
664− }.isSuccess
665− if (ok) {
666− val result = snackbar.showSnackbar(
667− message = importedMsg,
668− actionLabel = restartLabel,
669− )
670− if (result == SnackbarResult.ActionPerformed) {
671− // Shut down the container (cancels the app scope, closes the
672− // active connection, flushes stores) before the hard kill so
673− // in-flight work is cleaned up rather than abruptly severed.
674− // The JVM shutdown hook in OpencodeApp would catch this, but
675− // calling shutdown explicitly is cleaner and avoids relying on
676− // the hook's ordering under a kill.
677− runCatchingCancellable { container.shutdown() }
678− android.os.Process.killProcess(android.os.Process.myPid())
704+ backupBusy = true
705+ try {
706+ val ok = runCatchingCancellable {
707+ val text = context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() }
708+ ?: error("no input stream")
709+ container.backupManager.import(text)
710+ }.isSuccess
711+ if (ok) {
712+ val result = snackbar.showSnackbar(
713+ message = importedMsg,
714+ actionLabel = restartLabel,
715+ )
716+ if (result == SnackbarResult.ActionPerformed) {
717+ // Shut down the container (cancels the app scope, closes the
718+ // active connection, flushes stores) before the hard kill so
719+ // in-flight work is cleaned up rather than abruptly severed.
720+ // The JVM shutdown hook in OpencodeApp would catch this, but
721+ // calling shutdown explicitly is cleaner and avoids relying on
722+ // the hook's ordering under a kill.
723+ runCatchingCancellable { container.shutdown() }
724+ android.os.Process.killProcess(android.os.Process.myPid())
725+ }
726+ } else {
727+ snackbar.showSnackbar(importFailedMsg)
679728 }
680− } else {
681− snackbar.showSnackbar(importFailedMsg)
729+ } finally {
730+ backupBusy = false
682731 }
683732 }
684733 }) { 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(
187187 AmoledColors.background,
188188)
189189
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+
190215private tailrec fun Context.findActivity(): Activity? = when (this) {
191216 is Activity -> this
192217 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 {
1616 Result.failure(e)
1717}
1818
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+
1931/**
2032 * A log-safe exception summary that avoids leaking the request URL (which
2133 * [ClientRequestException] embeds in its message and may contain auth or paths).
app/src/main/res/values-es/strings.xml+20
@@ -189,11 +189,19 @@
189189 <string name="no_commands_match">Ningún comando coincide con "%1$s".</string>
190190 <string name="load_failed">Error al cargar. Toca reintentar.</string>
191191 <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>
192196 <string name="search_models">Buscar modelos…</string>
193197 <string name="search_agents">Buscar agentes…</string>
194198 <string name="search_commands">Buscar comandos…</string>
195199 <string name="no_models_match">Ningún modelo coincide con "%1$s".</string>
196200 <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>
197205
198206 <!-- File browser -->
199207 <string name="search_files">Buscar archivos…</string>
@@ -389,6 +397,10 @@
389397 <string name="session_archive">Archivar</string>
390398 <string name="session_unarchive">Desarchivar</string>
391399 <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>
392404 <string name="show_archived">Mostrar archivadas</string>
393405 <string name="hide_archived">Ocultar archivadas</string>
394406 <string name="search_all">Buscar en todas las sesiones</string>
@@ -396,6 +408,7 @@
396408 <string name="search_all_hint">Buscar en todas las conversaciones…</string>
397409 <string name="search_no_matches">No hay mensajes coincidentes</string>
398410 <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>
399412 <string name="search_all_start">Busca en tus conversaciones</string>
400413 <string name="search_all_start_hint">Escribe una palabra o frase para encontrarla en todas las sesiones.</string>
401414 <plurals name="search_results_count">
@@ -422,6 +435,8 @@
422435 <string name="chat_text_size_desc">Escala el texto de mensajes y código</string>
423436 <string name="wrap_code_blocks">Ajustar bloques de código</string>
424437 <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>
425440 <string name="reset">Restablecer</string>
426441 <string name="text_size_sample">El veloz murciélago hindú comía feliz cardillo.</string>
427442 <string name="text_scale_value">%1$d%%</string>
@@ -537,6 +552,10 @@
537552 <!-- Chat -->
538553 <string name="attachment_staging">Adjuntando…</string>
539554 <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>
540559
541560 <!-- Session list -->
542561 <string name="session_archived">Sesión archivada</string>
@@ -567,6 +586,7 @@
567586
568587 <!-- UI/UX batch: permisos, confirmaciones, indicaciones de búsqueda, accesibilidad -->
569588 <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>
570590 <string name="action_continue">Continuar</string>
571591 <string name="summarize_confirm_title">¿Resumir la conversación?</string>
572592 <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 @@
198198 <string name="no_commands_match">No commands match "%1$s".</string>
199199 <string name="load_failed">Failed to load. Tap retry.</string>
200200 <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>
201205 <string name="search_models">Search models…</string>
202206 <string name="search_agents">Search agents…</string>
203207 <string name="search_commands">Search commands…</string>
204208 <string name="no_models_match">No models match "%1$s".</string>
205209 <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>
206214
207215 <!-- File browser -->
208216 <string name="search_files">Search files…</string>
@@ -370,6 +378,10 @@
370378 <string name="session_archive">Archive</string>
371379 <string name="session_unarchive">Unarchive</string>
372380 <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>
373385 <string name="show_archived">Show archived</string>
374386 <string name="hide_archived">Hide archived</string>
375387
@@ -386,6 +398,7 @@
386398 <string name="search_all_hint">Search across all conversations…</string>
387399 <string name="search_no_matches">No matching messages</string>
388400 <string name="search_truncated">Showing results from the most recent sessions only.</string>
401+ <string name="search_more">Search more sessions</string>
389402 <string name="search_all_start">Search your conversations</string>
390403 <string name="search_all_start_hint">Type a word or phrase to find it across every session.</string>
391404 <plurals name="search_results_count">
@@ -444,6 +457,8 @@
444457 <string name="chat_text_size_desc">Scale message and code text</string>
445458 <string name="wrap_code_blocks">Wrap code blocks</string>
446459 <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>
447462 <string name="reset">Reset</string>
448463 <string name="text_size_sample">The quick brown fox jumps over the lazy dog.</string>
449464 <string name="text_scale_value">%1$d%%</string>
@@ -560,6 +575,10 @@
560575 <!-- Chat -->
561576 <string name="attachment_staging">Attaching…</string>
562577 <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>
563582
564583 <!-- Session list -->
565584 <string name="session_archived">Session archived</string>
@@ -590,6 +609,7 @@
590609
591610 <!-- UI/UX batch: permissions, confirmations, search prompts, a11y -->
592611 <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>
593613 <string name="action_continue">Continue</string>
594614 <string name="summarize_confirm_title">Summarize conversation?</string>
595615 <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"
1616zxingCore = "3.5.3"
1717coreKtx = "1.13.1"
1818splashscreen = "1.0.1"
19+window = "1.3.0"
1920junit = "4.13.2"
2021detekt = "1.23.8"
2122androidxTestExt = "1.2.1"
@@ -26,6 +27,7 @@ espresso = "3.6.1"
2627[libraries]
2728androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
2829androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" }
30+androidx-window = { group = "androidx.window", name = "window", version.ref = "window" }
2931androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
3032androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
3133androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }