git.iko.soy/opencode-android
on master help sign in
Fix 6 P0 bugs (permission crash on non-array pattern, notification-id hash entropy loss, command-picker rotation crash, draft wipe during send, stale parts after reconnect, cross-session part leak) and 10 P1/P2 fixes (stuck run indicator, inflated unread badge, overlapping delete clobber on 3 screens, crash-report deferred-delete survival, cold-start theme flash, settings Flow re-creation, diff header misclassification, auto-scroll on reasoning/tool streaming, follow-up input clear)
commite4dceb6ec095de11e496ee7ff75a927fdd83da91 authoriko <mail@iko.soy> dateWed 1 Jul 2026, 15:37 (3 months ago) parent1e779bb
14 files changed+248−109
app/src/main/java/soy/iko/opencode/MainActivity.kt+23−12 app/src/main/java/soy/iko/opencode/data/model/Permission.kt+6−2 app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+33 app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+39−5 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+40−5 app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+7−3 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+30−7 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+9−2 app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+10−5 app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+5−1 app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+7−9 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+10−9 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+18−43 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+11−6
app/src/main/java/soy/iko/opencode/MainActivity.kt+23−12
@@ -10,7 +10,10 @@ import androidx.activity.compose.setContent
1010import androidx.activity.enableEdgeToEdge
1111import androidx.activity.result.contract.ActivityResultContracts
1212import androidx.compose.foundation.isSystemInDarkTheme
13+import androidx.compose.runtime.LaunchedEffect
1314import androidx.compose.runtime.getValue
15+import androidx.compose.runtime.remember
16+import kotlinx.coroutines.flow.combine
1417import androidx.core.content.ContextCompat
1518import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
1619import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -27,25 +30,33 @@ class MainActivity : ComponentActivity() {
2730 private var shareIntentHandled = false
2831
2932 override fun onCreate(savedInstanceState: Bundle?) {
30− installSplashScreen()
33+ val splash = installSplashScreen()
3134 super.onCreate(savedInstanceState)
3235 enableEdgeToEdge()
3336 savedInstanceState?.let { shareIntentHandled = it.getBoolean(KEY_SHARE_HANDLED, false) }
3437 val container = (application as OpencodeApp).container
3538 handleIntent(intent)
3639 if (savedInstanceState == null) maybeRequestNotificationPermission()
40+ // Hold the splash until the persisted theme settings load, so we never paint a
41+ // frame with the default (dynamicColor=true / SYSTEM) that then snaps to the
42+ // user's real choice — the flash the SettingsScreen's null-gate already avoids.
43+ var themeLoaded = false
44+ splash.setKeepOnScreenCondition { !themeLoaded }
3745 setContent {
38− val themeMode by container.settingsStore.themeMode
39− .collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
40− val dynamicColor by container.settingsStore.dynamicColor
41− .collectAsStateWithLifecycle(initialValue = true)
42− val dark = when (themeMode) {
43− ThemeMode.SYSTEM -> isSystemInDarkTheme()
44− ThemeMode.LIGHT -> false
45− ThemeMode.DARK -> true
46− }
47− OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor) {
48− OpencodeAppUi(container = container)
46+ val theme by remember(container) {
47+ container.settingsStore.themeMode
48+ .combine(container.settingsStore.dynamicColor, ::Pair)
49+ }.collectAsStateWithLifecycle(initialValue = null)
50+ LaunchedEffect(theme) { if (theme != null) themeLoaded = true }
51+ theme?.let { (themeMode, dynamicColor) ->
52+ val dark = when (themeMode) {
53+ ThemeMode.SYSTEM -> isSystemInDarkTheme()
54+ ThemeMode.LIGHT -> false
55+ ThemeMode.DARK -> true
56+ }
57+ OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor) {
58+ OpencodeAppUi(container = container)
59+ }
4960 }
5061 }
5162 }
app/src/main/java/soy/iko/opencode/data/model/Permission.kt+6−2
@@ -4,8 +4,8 @@ import androidx.compose.runtime.Immutable
44import kotlinx.serialization.Serializable
55import kotlinx.serialization.json.JsonElement
66import kotlinx.serialization.json.JsonObject
7+import kotlinx.serialization.json.JsonArray
78import kotlinx.serialization.json.JsonPrimitive
8−import kotlinx.serialization.json.jsonArray
99
1010/**
1111 * A permission request from `permission.updated`. The tool detail lives in [type] +
@@ -29,7 +29,11 @@ data class Permission(
2929 get() = when (val p = pattern) {
3030 null -> null
3131 is JsonPrimitive -> p.content
32− else -> p.jsonArray.mapNotNull { (it as? JsonPrimitive)?.content }.joinToString(", ").ifEmpty { null }
32+ // Only a JSON array is flattened; any other shape (e.g. an object) would make
33+ // the previous unguarded `p.jsonArray` throw during composition, so treat it
34+ // as "no displayable pattern" instead.
35+ is JsonArray -> p.mapNotNull { (it as? JsonPrimitive)?.content }.joinToString(", ").ifEmpty { null }
36+ else -> null
3337 }
3438}
3539
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+33
@@ -7,8 +7,10 @@ import android.util.Log
77import androidx.core.content.pm.PackageInfoCompat
88import kotlinx.coroutines.CoroutineScope
99import kotlinx.coroutines.Dispatchers
10+import kotlinx.coroutines.Job
1011import kotlinx.coroutines.SupervisorJob
1112import kotlinx.coroutines.cancel
13+import kotlinx.coroutines.delay
1214import kotlinx.coroutines.flow.MutableStateFlow
1315import kotlinx.coroutines.flow.StateFlow
1416import kotlinx.coroutines.flow.asStateFlow
@@ -87,6 +89,37 @@ class CrashLogger private constructor(private val appContext: Context) {
8789 }
8890 }
8991
92+ /** Deferred deletions keyed by file name, so an Undo can cancel one before it fires. */
93+ private val pendingDeletes = java.util.concurrent.ConcurrentHashMap<String, Job>()
94+
95+ /**
96+ * Schedule [fileName] for deletion after [delayMs], cancellable via
97+ * [cancelScheduledDelete]. Runs on the logger's own long-lived scope — not the
98+ * Diagnostics screen's composition scope — so navigating away during the undo window
99+ * still commits the delete instead of silently dropping it. A repeated schedule for
100+ * the same name replaces the prior timer.
101+ */
102+ fun scheduleDelete(fileName: String, delayMs: Long) {
103+ val job = scope.launch {
104+ delay(delayMs)
105+ val file = File(crashDir, fileName).canonicalFile
106+ if (file.path.startsWith(crashDir.canonicalPath + File.separator)) {
107+ file.delete()
108+ }
109+ pendingDeletes.remove(fileName)
110+ refresh()
111+ }
112+ pendingDeletes.put(fileName, job)?.cancel()
113+ }
114+
115+ /** Cancel a pending deferred delete (the Undo action). Returns true if it was still
116+ * pending (undo succeeded), false if the delete had already fired. */
117+ fun cancelScheduledDelete(fileName: String): Boolean {
118+ val job = pendingDeletes.remove(fileName) ?: return false
119+ job.cancel()
120+ return true
121+ }
122+
90123 fun clearAll() {
91124 scope.launch {
92125 crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }?.forEach { it.delete() }
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+39−5
@@ -100,6 +100,10 @@ open class SessionRepository(
100100 if (state == EventStreamClient.ConnectionState.Connected) {
101101 if (hasConnectedBefore) {
102102 val generation = ++seedGeneration
103+ // Pivot before the fetch: parts already in memory predate this
104+ // snapshot and must yield to it, while parts that stream in during
105+ // the fetch (added to streamedSincePivot via reduce) outrank it.
106+ lock.withLock { store.beginReseed() }
103107 val fresh = runCatchingCancellable { api.listMessages(sessionId) }
104108 .onFailure { Log.w("SessionRepository", "Re-seed message load failed for $sessionId; relying on SSE: ${safeExceptionSummary(it)}") }
105109 .getOrDefault(emptyList())
@@ -159,6 +163,18 @@ internal class MessageStore {
159163 // messageId -> (info + parts), insertion-ordered.
160164 private val messages = LinkedHashMap<String, MessageWithParts>()
161165
166+ /** Part ids upserted via [reduce] since the last reseed pivot ([beginReseed]) or seed.
167+ * On a reconnect re-seed these are the parts that streamed in *during* the REST fetch,
168+ * so they're newer than the snapshot and must win over it; every other part is taken
169+ * from the authoritative REST snapshot. Without this distinction the merge kept every
170+ * overlapping in-memory part, so a part that changed server-side during the disconnect
171+ * (e.g. a tool going running -> completed) stayed stale until app restart. */
172+ private val streamedSincePivot = mutableSetOf<String>()
173+
174+ /** Mark the point a reconnect re-seed's REST fetch begins: parts streamed from here on
175+ * are newer than the fetched snapshot. Call under the same lock as [seed]/[reduce]. */
176+ fun beginReseed() { streamedSincePivot.clear() }
177+
162178 /** Monotonic counter for synthetic UnknownMessage keys, avoiding nanoTime collisions. */
163179 private val unknownCounter = java.util.concurrent.atomic.AtomicLong(0)
164180
@@ -194,7 +210,13 @@ internal class MessageStore {
194210 // append any streamed-only parts, and adopt the authoritative REST info.
195211 val streamedById = existing.parts.associateBy { it.id }
196212 val snapshotIds = m.parts.mapTo(mutableSetOf()) { it.id }
197− val ordered = m.parts.map { p -> streamedById[p.id] ?: p }.toMutableList()
213+ // Prefer the in-memory version of an overlapping part ONLY if it streamed
214+ // in since the reseed pivot (i.e. during this fetch), so it's newer than
215+ // the snapshot. Otherwise the REST snapshot is authoritative and wins,
216+ // discarding an in-memory part that went stale during a disconnect.
217+ val ordered = m.parts.map { p ->
218+ if (p.id in streamedSincePivot) streamedById[p.id] ?: p else p
219+ }.toMutableList()
198220 for (p in existing.parts) {
199221 if (p.id !in snapshotIds) ordered.add(p)
200222 }
@@ -207,6 +229,9 @@ internal class MessageStore {
207229 }
208230 }
209231 if (evictOldMessages()) changed = true
232+ // Reset the pivot: everything just merged is now the baseline, so only parts that
233+ // stream in *after* this seed can outrank the next reconnect's snapshot.
234+ streamedSincePivot.clear()
210235 return changed
211236 }
212237
@@ -238,10 +263,16 @@ internal class MessageStore {
238263 val part = event.properties.part
239264 val messageId = part.messageID ?: event.properties.messageID
240265 val partSession = part.sessionID ?: event.properties.sessionID
241− if (messageId == null || (partSession != null && partSession != sessionId)) {
242− false
243− } else {
244− upsertPart(messageId, part)
266+ when {
267+ messageId == null -> false
268+ partSession != null -> if (partSession == sessionId) upsertPart(messageId, part) else false
269+ // No session id anywhere on the event: a null session is NOT a wildcard
270+ // (mirroring isIdle/isError). Every open session's store sees the shared
271+ // event stream, so accepting it unconditionally would leak the part into
272+ // unrelated conversations (e.g. both panes in two-pane mode). Attribute it
273+ // only if this store already holds the parent message — MessageUpdated,
274+ // which always carries a session id, will have created it here first.
275+ else -> if (messages.containsKey(messageId)) upsertPart(messageId, part) else false
245276 }
246277 }
247278
@@ -285,6 +316,9 @@ internal class MessageStore {
285316 info = UnknownMessage(id = messageId, sessionID = part.sessionID ?: ""),
286317 parts = newParts,
287318 )
319+ // Record that this part streamed in, so a re-seed after a reconnect knows it's
320+ // newer than the REST snapshot and keeps it (see seed()/beginReseed()).
321+ streamedSincePivot.add(part.id)
288322 if (current == null) evictOldMessages()
289323 return true
290324 }
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+40−5
@@ -144,6 +144,7 @@ open class AppContainer private constructor(
144144 _currentSession.value = id
145145 if (id != null) {
146146 _unread.update { it - id }
147+ unreadMessageIds.remove(id)
147148 appContext?.let { SessionNotifications.cancel(it, id) }
148149 }
149150 }
@@ -249,17 +250,34 @@ open class AppContainer private constructor(
249250 // session isn't currently being viewed and was actively streaming.
250251 if (event is SessionIdle) {
251252 val idleSid = event.properties.sessionID ?: return@collect
252− if (idleSid != _currentSession.value && activeRuns.remove(idleSid)) {
253− _anyRunActive.value = activeRuns.isNotEmpty()
253+ // Clear the run state unconditionally — even for the session on
254+ // screen. Gating the removal on `idleSid != currentSession` (as
255+ // this once did) leaks the id in activeRuns and pins anyRunActive
256+ // true forever whenever a run finishes while being viewed. Only
257+ // the completion *notification* is suppressed for the viewed one.
258+ val wasRunning = synchronized(activeRuns) {
259+ activeRuns.remove(idleSid).also { removed ->
260+ if (removed) _anyRunActive.value = activeRuns.isNotEmpty()
261+ }
262+ }
263+ if (wasRunning && idleSid != _currentSession.value) {
254264 notifySessionCompleted(idleSid)
255265 }
256266 return@collect
257267 }
258268 val sid = sessionOf(event) ?: return@collect
259269 if (sid != _currentSession.value) {
260− // Increment the unread count for this session so the badge can
261− // show "N unread" instead of just a dot.
262− _unread.update { it + (sid to (it[sid] ?: 0) + 1) }
270+ // Increment the unread count once per distinct *message*, not per
271+ // event: a single reply emits many message.updated / message.part.
272+ // updated events (one per streamed token, plus cost/token refreshes),
273+ // which would otherwise inflate the badge into the dozens/hundreds.
274+ val messageId = messageIdOfEvent(event)
275+ val counted = unreadMessageIds.getOrPut(sid) {
276+ java.util.Collections.synchronizedSet(mutableSetOf())
277+ }
278+ if (messageId == null || counted.add(messageId)) {
279+ _unread.update { it + (sid to (it[sid] ?: 0) + 1) }
280+ }
263281 }
264282 // Track sessions actively streaming so we know which idle events
265283 // represent a finished run worth notifying about.
@@ -282,6 +300,11 @@ open class AppContainer private constructor(
282300 /** Session ids currently streaming an assistant run (best-effort, in-process). */
283301 private val activeRuns: MutableSet<String> = java.util.Collections.synchronizedSet(mutableSetOf())
284302
303+ /** Per non-viewed session, the set of message ids already reflected in its unread
304+ * count. De-duplicates the many streaming events that share one message id so the
305+ * badge counts messages, not deltas. Cleared when a session is viewed or on connect. */
306+ private val unreadMessageIds = java.util.concurrent.ConcurrentHashMap<String, MutableSet<String>>()
307+
285308 /** Upper bound on [activeRuns] to prevent unbounded growth if SessionIdle never arrives. */
286309 private val activeRunsLimit = NetworkConfig.activeRunsLimit
287310
@@ -355,6 +378,7 @@ open class AppContainer private constructor(
355378 activeRuns.clear()
356379 _anyRunActive.value = false
357380 _unread.value = emptyMap()
381+ unreadMessageIds.clear()
358382 val now = System.currentTimeMillis()
359383 val resolved = profileStore.resolve(profile)
360384 val needsSave = (now - resolved.lastUsed) > LAST_USED_SAVE_THRESHOLD_MS
@@ -455,3 +479,14 @@ internal fun sessionOfEvent(event: BusEvent): String? = when (event) {
455479 is MessagePartUpdated -> event.properties.part.sessionID ?: event.properties.sessionID
456480 else -> null
457481}
482+
483+/**
484+ * The message id a message-activity event pertains to, used to de-duplicate the unread
485+ * badge so a reply's many streaming events count once. Null for events that carry no
486+ * message id (those fall back to being counted individually).
487+ */
488+internal fun messageIdOfEvent(event: BusEvent): String? = when (event) {
489+ is MessageUpdated -> event.properties.info.id
490+ is MessagePartUpdated -> event.properties.messageID ?: event.properties.part.messageID
491+ else -> null
492+}
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+7−3
@@ -33,9 +33,13 @@ object SessionNotifications {
3333 val digest = runCatching { MessageDigest.getInstance("SHA-256") }
3434 .getOrNull()?.digest(sessionId.toByteArray()) ?: return NOTIF_ID_PREFIX
3535 // Take the first 4 bytes, mask to 31 bits (always positive) to fit an Int id.
36− val hash = ((digest[0].toInt() and 0xFF) shl 24 or
37− (digest[1].toInt() and 0xFF) shl 16 or
38− (digest[2].toInt() and 0xFF) shl 8 or
36+ // Kotlin's shl/or/and are equal-precedence infix functions evaluated strictly
37+ // left-to-right, so the shifts MUST be parenthesized individually — otherwise
38+ // `a shl 24 or b shl 16` parses as `((a shl 24) or b) shl 16`, shifting the first
39+ // byte out of the Int entirely and collapsing the id's entropy.
40+ val hash = (((digest[0].toInt() and 0xFF) shl 24) or
41+ ((digest[1].toInt() and 0xFF) shl 16) or
42+ ((digest[2].toInt() and 0xFF) shl 8) or
3943 (digest[3].toInt() and 0xFF)).and(0x7FFFFFFF)
4044 // Offset by NOTIF_ID_PREFIX, staying within the positive Int range.
4145 // Using modulo ensures the prefix is preserved without Int overflow.
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+30−7
@@ -92,7 +92,13 @@ import androidx.lifecycle.viewmodel.compose.viewModel
9292import kotlinx.coroutines.Dispatchers
9393import kotlinx.coroutines.launch
9494import kotlinx.coroutines.withContext
95+import soy.iko.opencode.data.model.Part
96+import soy.iko.opencode.data.model.ReasoningPart
9597import soy.iko.opencode.data.model.TextPart
98+import soy.iko.opencode.data.model.ToolCompleted
99+import soy.iko.opencode.data.model.ToolError
100+import soy.iko.opencode.data.model.ToolPart
101+import soy.iko.opencode.data.model.ToolRunning
96102import soy.iko.opencode.data.network.EventStreamClient
97103import soy.iko.opencode.data.network.NetworkConfig
98104import soy.iko.opencode.di.AppContainer
@@ -398,13 +404,15 @@ fun ChatScreen(
398404
399405 LaunchedEffect(Unit) {
400406 snapshotFlow {
401− // Track list size + the streaming text length of the last message so we
402− // auto-scroll as tokens arrive, plus the pinned flag. A small data class
403− // with primitive fields avoids the per-frame boxing that Triple<Int,Int,Boolean>
404− // pays (snapshotFlow re-evaluates this lambda every snapshot), and reading
405− // the last TextPart's length is O(1) vs. the prior sumOf over every part.
406− val lastText = messages.lastOrNull()?.parts?.lastOrNull() as? TextPart
407− AutoScrollSignal(messages.size, lastText?.text?.length ?: 0, isPinnedToBottom)
407+ // Track list size + the streaming length of the last part so we auto-scroll
408+ // as content arrives, plus the pinned flag. A small data class with primitive
409+ // fields avoids the per-frame boxing that Triple<Int,Int,Boolean> pays
410+ // (snapshotFlow re-evaluates this lambda every snapshot), and reading only the
411+ // last part's length is O(1) vs. a sumOf over every part. Covering reasoning
412+ // and tool output — not just TextPart — keeps a pinned view following a long
413+ // reasoning/tool block while it streams (previously it stalled at 0).
414+ val lastLen = streamingContentLength(messages.lastOrNull()?.parts?.lastOrNull())
415+ AutoScrollSignal(messages.size, lastLen, isPinnedToBottom)
408416 }.collect { signal ->
409417 if (signal.size > 0 && signal.pinned) {
410418 // Scroll to the effective last index, including the trailing
@@ -859,3 +867,18 @@ private fun EmptyConversation(
859867 * equality is structural so `snapshotFlow` emits only when something relevant changes.
860868 */
861869private data class AutoScrollSignal(val size: Int, val lastTextLength: Int, val pinned: Boolean)
870+
871+/** Length of the streaming content carried by [part], driving auto-scroll as it grows.
872+ * Covers text, reasoning, and tool output so a pinned view keeps following non-text
873+ * streaming, not just plain assistant text. O(1). */
874+private fun streamingContentLength(part: Part?): Int = when (part) {
875+ is TextPart -> part.text.length
876+ is ReasoningPart -> part.text.length
877+ is ToolPart -> when (val st = part.state) {
878+ is ToolCompleted -> st.output?.length ?: 0
879+ is ToolError -> st.error?.length ?: 0
880+ is ToolRunning -> st.title?.length ?: 0
881+ else -> 0
882+ }
883+ else -> 0
884+}
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+9−2
@@ -476,8 +476,11 @@ class ChatViewModel(
476476 _running.value = false
477477 }.onSuccess {
478478 suppressDraftPersist.set(false)
479− // Send succeeded — now it's safe to persist the empty draft.
480− container.draftStore.set(sessionId, "")
479+ // Send succeeded — persist the cleared draft, but only if the user hasn't
480+ // typed a new one while the send was in flight. Otherwise clearing the
481+ // store echoes back through the draft observer and wipes the in-progress
482+ // text (data loss). Mirrors the guard on the failure path above.
483+ if (_draft.value.isBlank()) container.draftStore.set(sessionId, "")
481484 // Don't reset _running here: the agent continues streaming via SSE.
482485 // _running is cleared on SessionIdle/SessionError (see event collector)
483486 // or when the SSE stream drops (see connection state watcher below).
@@ -506,6 +509,10 @@ class ChatViewModel(
506509 fun queueFollowUp(text: String) {
507510 val trimmed = text.trim()
508511 _queuedFollowUp.value = trimmed.takeIf { it.isNotEmpty() }
512+ // The text now lives in the queued chip, so clear the input field just as a
513+ // normal send would. No-op for the cancel case (blank text), which must leave
514+ // whatever the user has since typed untouched.
515+ if (trimmed.isNotEmpty()) _draft.value = ""
509516 }
510517
511518 /** Transient flag set by [refreshMessages] so the top-bar refresh icon can show
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+10−5
@@ -52,7 +52,12 @@ fun CommandPickerSheet(
5252 onRetry: () -> Unit,
5353 onDismiss: () -> Unit,
5454) {
55− var pendingCommand by rememberSaveable { mutableStateOf<Command?>(null) }
55+ // Persist only the command *name* (a savable String), not the Command itself: a
56+ // kotlinx @Serializable Command is neither Parcelable nor java.io.Serializable, so
57+ // routing it through rememberSaveable crashes on rotation while the confirm dialog
58+ // is open. Re-resolve the full Command from the current list for rendering.
59+ var pendingCommandName by rememberSaveable { mutableStateOf<String?>(null) }
60+ val pendingCommand = pendingCommandName?.let { name -> commands.firstOrNull { it.name == name } }
5661 ModalBottomSheet(onDismissRequest = onDismiss) {
5762 Text(
5863 stringResource(R.string.commands),
@@ -122,7 +127,7 @@ fun CommandPickerSheet(
122127 .fillMaxWidth()
123128 .clickable(role = Role.Button) {
124129 if (isDestructiveCommand(cmd.name)) {
125− pendingCommand = cmd
130+ pendingCommandName = cmd.name
126131 } else {
127132 onSelect(cmd)
128133 onDismiss()
@@ -159,18 +164,18 @@ fun CommandPickerSheet(
159164 // so a tap doesn't irrevocably run e.g. /clear or /compact without consent.
160165 pendingCommand?.let { cmd ->
161166 AlertDialog(
162− onDismissRequest = { pendingCommand = null },
167+ onDismissRequest = { pendingCommandName = null },
163168 title = { Text(stringResource(R.string.run_command_title, cmd.name)) },
164169 text = { Text(stringResource(R.string.run_command_text, "/${cmd.name}")) },
165170 confirmButton = {
166171 TextButton(onClick = {
167− pendingCommand = null
172+ pendingCommandName = null
168173 onSelect(cmd)
169174 onDismiss()
170175 }) { Text(stringResource(R.string.run)) }
171176 },
172177 dismissButton = {
173− TextButton(onClick = { pendingCommand = null }) { Text(stringResource(R.string.cancel)) }
178+ TextButton(onClick = { pendingCommandName = null }) { Text(stringResource(R.string.cancel)) }
174179 },
175180 )
176181 }
app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+5−1
@@ -59,7 +59,11 @@ fun parseDiff(diff: String): List<DiffLine> {
5959 for (raw in diff.lineSequence()) {
6060 when {
6161 raw.startsWith("@@") -> result.add(DiffLine.Hunk(raw))
62− raw.startsWith("---") || raw.startsWith("+++") -> result.add(DiffLine.FileHeader(raw))
62+ // Unified-diff file headers are always "--- <path>" / "+++ <path>" (a space
63+ // follows the marker). Requiring that space avoids misclassifying removed or
64+ // added *content* lines like "--foo"/"++bar" — which appear on the wire as
65+ // "---foo"/"+++bar" — as headers; those fall through to the +/- branches below.
66+ raw.startsWith("--- ") || raw.startsWith("+++ ") -> result.add(DiffLine.FileHeader(raw))
6367 raw.startsWith("+") -> result.add(DiffLine.Add(raw.removePrefix("+")))
6468 raw.startsWith("-") -> result.add(DiffLine.Remove(raw.removePrefix("-")))
6569 raw.startsWith(" ") -> result.add(DiffLine.Context(raw.removePrefix(" ")))
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+7−9
@@ -52,7 +52,10 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
5252 val undoEvents: SharedFlow<String> = _undoEvents.asSharedFlow()
5353
5454 /** Profile awaiting the undo window to expire before its REST delete fires. */
55− private var pendingDelete: ServerProfile? = null
55+ // Keyed by profile id, not a single field: several deletes can overlap within the
56+ // undo window, and a single field would let the second overwrite the first — leaving
57+ // the first profile in the store forever while it's hidden from the list.
58+ private val pendingDeletes = mutableMapOf<String, ServerProfile>()
5659
5760 fun connect(profile: ServerProfile, onConnected: () -> Unit) {
5861 if (_connecting.value != null) return
@@ -92,7 +95,7 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
9295 * hidden by the optimistic removal, and the undo re-shows it without reconnecting.
9396 */
9497 fun delete(profile: ServerProfile) {
95− pendingDelete = profile
98+ pendingDeletes[profile.id] = profile
9699 if (container.activeConnection.value?.profile?.id == profile.id) {
97100 // disconnect() is suspend; run it on the VM scope so the optimistic removal
98101 // and undo emission aren't blocked on the connection close completing.
@@ -101,9 +104,7 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
101104 _undoEvents.tryEmit(profile.id)
102105 viewModelScope.launch {
103106 delay(NetworkConfig.undoServerDeleteDelayMs)
104− val toDelete = pendingDelete
105− if (toDelete != null && toDelete.id == profile.id) {
106− pendingDelete = null
107+ if (pendingDeletes.remove(profile.id) != null) {
107108 runCatchingCancellable { container.profileStore.delete(profile.id) }
108109 .onFailure { _errorEvents.tryEmit(ConnectError(container.friendlyError(it), null)) }
109110 }
@@ -112,9 +113,6 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
112113
113114 /** Cancel a pending delete (Undo snackbar action). */
114115 fun undoDelete(profileId: String) {
115− val pending = pendingDelete
116− if (pending != null && pending.id == profileId) {
117− pendingDelete = null
118− }
116+ pendingDeletes.remove(profileId)
119117 }
120118}
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+10−9
@@ -136,7 +136,10 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
136136 val undoEvents: SharedFlow<String> = _undoEvents.asSharedFlow()
137137
138138 /** Session awaiting the undo window to expire before its REST delete fires. */
139− private var pendingDelete: Session? = null
139+ // Keyed by session id, not a single field: the undo window lets several deletes be
140+ // in flight at once, and a single field would let a second delete overwrite the
141+ // first — dropping the first's server-side delete while it stays removed from the UI.
142+ private val pendingDeletes = mutableMapOf<String, Session>()
140143
141144 private var previewJob: Job? = null
142145 private var refreshJob: Job? = null
@@ -391,14 +394,13 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
391394 s.copy(sessions = s.sessions.filterNot { it.id == session.id })
392395 }
393396 // Hold the pending delete so the snackbar can undo it.
394− pendingDelete = session
397+ pendingDeletes[session.id] = session
395398 _undoEvents.tryEmit(session.id)
396399 viewModelScope.launch {
397400 delay(NetworkConfig.undoDeleteDelayMs)
398− // If undo was called, pendingDelete was cleared — don't delete.
399− val toDelete = pendingDelete
400− if (toDelete != null && toDelete.id == session.id) {
401− pendingDelete = null
401+ // If undo ran, this id was already removed — remove() returning null means
402+ // "don't delete". Removing by id also can't be clobbered by a later delete.
403+ if (pendingDeletes.remove(session.id) != null) {
402404 val conn = container.activeConnection.value ?: return@launch
403405 runCatchingCancellable { conn.repository.deleteSession(session.id) }
404406 .onSuccess {
@@ -416,9 +418,8 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
416418
417419 /** Cancel a pending delete (Undo snackbar action) and restore the session. */
418420 fun undoDelete(sessionId: String) {
419− val pending = pendingDelete
420− if (pending != null && pending.id == sessionId) {
421− pendingDelete = null
421+ val pending = pendingDeletes.remove(sessionId)
422+ if (pending != null) {
422423 _state.update { s ->
423424 s.copy(sessions = (s.sessions + pending).sortedByMode(s.sortMode))
424425 }
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+18−43
@@ -89,14 +89,9 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
8989 val snackbar = remember { SnackbarHostState() }
9090 val undoLabel = stringResource(R.string.undo)
9191 val reportDeletedLabel = stringResource(R.string.report_deleted)
92− // Track report names whose deletion has been deferred for an undo window, so a
93− // swipe-triggered delete can be cancelled before the file is actually removed.
94− // rememberSaveable (not remember) so the list survives rotation: otherwise a
95− // rotation mid-undo-window resets it to empty, the deferred-delete coroutine is
96− // cancelled by dispose, and a report the user swiped to delete silently survives
97− // (or in edge cases is deleted without an undo path). Uses a SnapshotStateList
98− // (saveable) instead of a plain Set so it round-trips through the state registry.
99− val deferredDeletes = rememberSaveable { androidx.compose.runtime.mutableStateListOf<String>() }
92+ // Deferred report deletions are owned by the CrashLogger (its process-lived scope),
93+ // so they survive both rotation and navigating away from this screen. The screen only
94+ // shows the Undo snackbar and asks the logger to cancel a pending delete on undo.
10095 val shareLabel = stringResource(R.string.share)
10196 val shareSubject = stringResource(R.string.crash_report_share_subject)
10297 val timeTick = rememberRelativeTimeTick()
@@ -309,27 +304,27 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
309304 pendingReportDelete = null
310305 viewing = null
311306 // Defer the actual delete so the Undo snackbar can cancel it. The
312− // report stays in the list until the window expires; the undo
313− // action removes the name from the deferred list.
314− deferredDeletes.add(name)
315− // The deferred delete is scheduled by a LaunchedEffect keyed on
316− // deferredDeletes (below), which survives rotation because
317− // deferredDeletes is rememberSaveable. Only the snackbar runs in
318− // shareScope here — if a rotation cancels it, the undo window is
319− // lost but the report is still deleted after the delay (the safer
320− // failure mode) rather than silently surviving.
307+ // deletion is owned by the CrashLogger's own scope, so it commits even
308+ // if the user navigates away during the undo window (the screen's
309+ // shareScope would otherwise cancel it, silently dropping the delete).
310+ logger.scheduleDelete(name, NetworkConfig.undoReportDeleteDelayMs)
321311 shareScope.launch {
312+ // Indefinite + a matching timed dismiss keeps the Undo button
313+ // visible for exactly the undo window and no longer: a fixed
314+ // SnackbarDuration.Long (~10s) outlasted the 5s window, leaving
315+ // the button on screen but dead for its second half.
316+ val dismisser = launch {
317+ delay(NetworkConfig.undoReportDeleteDelayMs)
318+ snackbar.currentSnackbarData?.dismiss()
319+ }
322320 val result = snackbar.showSnackbar(
323321 message = reportDeletedLabel,
324322 actionLabel = undoLabel,
325− // Long (~10s) outlasts the undoReportDeleteDelayMs window
326− // (5s) so the Undo button remains available for the whole
327− // undo period; Short (~1.5s) vanished before the delete
328− // fired, leaving no way to cancel during the last 3.5s.
329− duration = SnackbarDuration.Long,
323+ duration = SnackbarDuration.Indefinite,
330324 )
325+ dismisser.cancel()
331326 if (result == SnackbarResult.ActionPerformed) {
332− deferredDeletes.remove(name)
327+ logger.cancelScheduledDelete(name)
333328 }
334329 }
335330 }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
@@ -340,26 +335,6 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
340335 )
341336 }
342337
343− // Schedule the deferred delete for every entry in the deferred list. Keyed on the
344− // list so a rotation (which restores the list via rememberSaveable) re-schedules
345− // any pending deletes that were cancelled when shareScope was disposed. Each
346− // entry gets its own delay; undo removes the name from the list, which re-keys
347− // this effect and cancels the in-flight delays for remaining entries (they're
348− // re-launched, but the removed name is no longer present so its delete is
349− // skipped). collectLatest would cancel sibling deletes, so we launch per-name.
350− LaunchedEffect(deferredDeletes.toList()) {
351− val pending = deferredDeletes.toList()
352− pending.forEach { name ->
353− shareScope.launch {
354− delay(NetworkConfig.undoReportDeleteDelayMs)
355− if (name in deferredDeletes) {
356− deferredDeletes.remove(name)
357− logger.deleteReport(name)
358− }
359− }
360− }
361− }
362−
363338 if (showClearAll) {
364339 AlertDialog(
365340 onDismissRequest = { showClearAll = false },
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+11−6
@@ -74,12 +74,17 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
7474 // Combine the three settings into a single nullable state so the appearance section
7575 // renders only after the persisted values have loaded, avoiding a brief flash of
7676 // hardcoded defaults (SYSTEM/light/dynamic-on) on cold start.
77− val settings by combine(
78− container.settingsStore.themeMode,
79− container.settingsStore.dynamicColor,
80− container.settingsStore.sendOnEnter,
81− ) { theme, dyn, enter -> SettingsValues(theme, dyn, enter) as SettingsValues? }
82− .collectAsStateWithLifecycle(initialValue = null)
77+ // Wrapped in remember so the combined Flow isn't re-created on every recomposition
78+ // (which would cancel and relaunch the DataStore collection each time — see the
79+ // connectionStateFlow below, remembered for the same reason).
80+ val settingsFlow = remember(container) {
81+ combine(
82+ container.settingsStore.themeMode,
83+ container.settingsStore.dynamicColor,
84+ container.settingsStore.sendOnEnter,
85+ ) { theme, dyn, enter -> SettingsValues(theme, dyn, enter) as SettingsValues? }
86+ }
87+ val settings by settingsFlow.collectAsStateWithLifecycle(initialValue = null)
8388 val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
8489 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
8590 // SSE connection state so the Settings screen can show a dropped stream (the