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)
e4dceb6iko
app/src/main/java/soy/iko/opencode/MainActivity.kt+23−12
@@ -10,7 +10,10 @@ import androidx.activity.compose.setContent
10import androidx.activity.enableEdgeToEdge
10import androidx.activity.enableEdgeToEdge
11import androidx.activity.result.contract.ActivityResultContracts
11import androidx.activity.result.contract.ActivityResultContracts
12import androidx.compose.foundation.isSystemInDarkTheme
12import androidx.compose.foundation.isSystemInDarkTheme
13+import androidx.compose.runtime.LaunchedEffect
13import androidx.compose.runtime.getValue
14import androidx.compose.runtime.getValue
15+import androidx.compose.runtime.remember
16+import kotlinx.coroutines.flow.combine
14import androidx.core.content.ContextCompat
17import androidx.core.content.ContextCompat
15import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
18import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
16import androidx.lifecycle.compose.collectAsStateWithLifecycle
19import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -27,25 +30,33 @@ class MainActivity : ComponentActivity() {
27 private var shareIntentHandled = false
30 private var shareIntentHandled = false
28
31
29 override fun onCreate(savedInstanceState: Bundle?) {
32 override fun onCreate(savedInstanceState: Bundle?) {
30− installSplashScreen()
33+ val splash = installSplashScreen()
31 super.onCreate(savedInstanceState)
34 super.onCreate(savedInstanceState)
32 enableEdgeToEdge()
35 enableEdgeToEdge()
33 savedInstanceState?.let { shareIntentHandled = it.getBoolean(KEY_SHARE_HANDLED, false) }
36 savedInstanceState?.let { shareIntentHandled = it.getBoolean(KEY_SHARE_HANDLED, false) }
34 val container = (application as OpencodeApp).container
37 val container = (application as OpencodeApp).container
35 handleIntent(intent)
38 handleIntent(intent)
36 if (savedInstanceState == null) maybeRequestNotificationPermission()
39 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 }
37 setContent {
45 setContent {
46+ val theme by remember(container) {
38− val themeMode by container.settingsStore.themeMode
47+ container.settingsStore.themeMode
48+ .combine(container.settingsStore.dynamicColor, ::Pair)
39− .collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
49+ }.collectAsStateWithLifecycle(initialValue = null)
40− val dynamicColor by container.settingsStore.dynamicColor
50+ LaunchedEffect(theme) { if (theme != null) themeLoaded = true }
41− .collectAsStateWithLifecycle(initialValue = true)
51+ theme?.let { (themeMode, dynamicColor) ->
42− val dark = when (themeMode) {
52+ val dark = when (themeMode) {
43− ThemeMode.SYSTEM -> isSystemInDarkTheme()
53+ ThemeMode.SYSTEM -> isSystemInDarkTheme()
44− ThemeMode.LIGHT -> false
54+ ThemeMode.LIGHT -> false
45− ThemeMode.DARK -> true
55+ ThemeMode.DARK -> true
46− }
56+ }
47− OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor) {
57+ OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor) {
48− OpencodeAppUi(container = container)
58+ OpencodeAppUi(container = container)
59+ }
49 }
60 }
50 }
61 }
51 }
62 }
app/src/main/java/soy/iko/opencode/data/model/Permission.kt+6−2
@@ -4,8 +4,8 @@ import androidx.compose.runtime.Immutable
4import kotlinx.serialization.Serializable
4import kotlinx.serialization.Serializable
5import kotlinx.serialization.json.JsonElement
5import kotlinx.serialization.json.JsonElement
6import kotlinx.serialization.json.JsonObject
6import kotlinx.serialization.json.JsonObject
7+import kotlinx.serialization.json.JsonArray
7import kotlinx.serialization.json.JsonPrimitive
8import kotlinx.serialization.json.JsonPrimitive
8−import kotlinx.serialization.json.jsonArray
9
9
10/**
10/**
11 * A permission request from `permission.updated`. The tool detail lives in [type] +
11 * A permission request from `permission.updated`. The tool detail lives in [type] +
@@ -29,7 +29,11 @@ data class Permission(
29 get() = when (val p = pattern) {
29 get() = when (val p = pattern) {
30 null -> null
30 null -> null
31 is JsonPrimitive -> p.content
31 is JsonPrimitive -> p.content
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.
32− else -> p.jsonArray.mapNotNull { (it as? JsonPrimitive)?.content }.joinToString(", ").ifEmpty { null }
35+ is JsonArray -> p.mapNotNull { (it as? JsonPrimitive)?.content }.joinToString(", ").ifEmpty { null }
36+ else -> null
33 }
37 }
34}
38}
35
39
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+33
@@ -7,8 +7,10 @@ import android.util.Log
7import androidx.core.content.pm.PackageInfoCompat
7import androidx.core.content.pm.PackageInfoCompat
8import kotlinx.coroutines.CoroutineScope
8import kotlinx.coroutines.CoroutineScope
9import kotlinx.coroutines.Dispatchers
9import kotlinx.coroutines.Dispatchers
10+import kotlinx.coroutines.Job
10import kotlinx.coroutines.SupervisorJob
11import kotlinx.coroutines.SupervisorJob
11import kotlinx.coroutines.cancel
12import kotlinx.coroutines.cancel
13+import kotlinx.coroutines.delay
12import kotlinx.coroutines.flow.MutableStateFlow
14import kotlinx.coroutines.flow.MutableStateFlow
13import kotlinx.coroutines.flow.StateFlow
15import kotlinx.coroutines.flow.StateFlow
14import kotlinx.coroutines.flow.asStateFlow
16import kotlinx.coroutines.flow.asStateFlow
@@ -87,6 +89,37 @@ class CrashLogger private constructor(private val appContext: Context) {
87 }
89 }
88 }
90 }
89
91
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+
90 fun clearAll() {
123 fun clearAll() {
91 scope.launch {
124 scope.launch {
92 crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }?.forEach { it.delete() }
125 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(
100 if (state == EventStreamClient.ConnectionState.Connected) {
100 if (state == EventStreamClient.ConnectionState.Connected) {
101 if (hasConnectedBefore) {
101 if (hasConnectedBefore) {
102 val generation = ++seedGeneration
102 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() }
103 val fresh = runCatchingCancellable { api.listMessages(sessionId) }
107 val fresh = runCatchingCancellable { api.listMessages(sessionId) }
104 .onFailure { Log.w("SessionRepository", "Re-seed message load failed for $sessionId; relying on SSE: ${safeExceptionSummary(it)}") }
108 .onFailure { Log.w("SessionRepository", "Re-seed message load failed for $sessionId; relying on SSE: ${safeExceptionSummary(it)}") }
105 .getOrDefault(emptyList())
109 .getOrDefault(emptyList())
@@ -159,6 +163,18 @@ internal class MessageStore {
159 // messageId -> (info + parts), insertion-ordered.
163 // messageId -> (info + parts), insertion-ordered.
160 private val messages = LinkedHashMap<String, MessageWithParts>()
164 private val messages = LinkedHashMap<String, MessageWithParts>()
161
165
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+
162 /** Monotonic counter for synthetic UnknownMessage keys, avoiding nanoTime collisions. */
178 /** Monotonic counter for synthetic UnknownMessage keys, avoiding nanoTime collisions. */
163 private val unknownCounter = java.util.concurrent.atomic.AtomicLong(0)
179 private val unknownCounter = java.util.concurrent.atomic.AtomicLong(0)
164
180
@@ -194,7 +210,13 @@ internal class MessageStore {
194 // append any streamed-only parts, and adopt the authoritative REST info.
210 // append any streamed-only parts, and adopt the authoritative REST info.
195 val streamedById = existing.parts.associateBy { it.id }
211 val streamedById = existing.parts.associateBy { it.id }
196 val snapshotIds = m.parts.mapTo(mutableSetOf()) { it.id }
212 val snapshotIds = m.parts.mapTo(mutableSetOf()) { it.id }
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.
197− val ordered = m.parts.map { p -> streamedById[p.id] ?: p }.toMutableList()
217+ val ordered = m.parts.map { p ->
218+ if (p.id in streamedSincePivot) streamedById[p.id] ?: p else p
219+ }.toMutableList()
198 for (p in existing.parts) {
220 for (p in existing.parts) {
199 if (p.id !in snapshotIds) ordered.add(p)
221 if (p.id !in snapshotIds) ordered.add(p)
200 }
222 }
@@ -207,6 +229,9 @@ internal class MessageStore {
207 }
229 }
208 }
230 }
209 if (evictOldMessages()) changed = true
231 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()
210 return changed
235 return changed
211 }
236 }
212
237
@@ -238,10 +263,16 @@ internal class MessageStore {
238 val part = event.properties.part
263 val part = event.properties.part
239 val messageId = part.messageID ?: event.properties.messageID
264 val messageId = part.messageID ?: event.properties.messageID
240 val partSession = part.sessionID ?: event.properties.sessionID
265 val partSession = part.sessionID ?: event.properties.sessionID
266+ when {
267+ messageId == null -> false
241− if (messageId == null || (partSession != null && partSession != sessionId)) {
268+ partSession != null -> if (partSession == sessionId) upsertPart(messageId, part) else false
242− false
269+ // No session id anywhere on the event: a null session is NOT a wildcard
243− } else {
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.
244− upsertPart(messageId, part)
275+ else -> if (messages.containsKey(messageId)) upsertPart(messageId, part) else false
245 }
276 }
246 }
277 }
247
278
@@ -285,6 +316,9 @@ internal class MessageStore {
285 info = UnknownMessage(id = messageId, sessionID = part.sessionID ?: ""),
316 info = UnknownMessage(id = messageId, sessionID = part.sessionID ?: ""),
286 parts = newParts,
317 parts = newParts,
287 )
318 )
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)
288 if (current == null) evictOldMessages()
322 if (current == null) evictOldMessages()
289 return true
323 return true
290 }
324 }
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+40−5
@@ -144,6 +144,7 @@ open class AppContainer private constructor(
144 _currentSession.value = id
144 _currentSession.value = id
145 if (id != null) {
145 if (id != null) {
146 _unread.update { it - id }
146 _unread.update { it - id }
147+ unreadMessageIds.remove(id)
147 appContext?.let { SessionNotifications.cancel(it, id) }
148 appContext?.let { SessionNotifications.cancel(it, id) }
148 }
149 }
149 }
150 }
@@ -249,17 +250,34 @@ open class AppContainer private constructor(
249 // session isn't currently being viewed and was actively streaming.
250 // session isn't currently being viewed and was actively streaming.
250 if (event is SessionIdle) {
251 if (event is SessionIdle) {
251 val idleSid = event.properties.sessionID ?: return@collect
252 val idleSid = event.properties.sessionID ?: return@collect
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) {
252− if (idleSid != _currentSession.value && activeRuns.remove(idleSid)) {
259+ activeRuns.remove(idleSid).also { removed ->
253− _anyRunActive.value = activeRuns.isNotEmpty()
260+ if (removed) _anyRunActive.value = activeRuns.isNotEmpty()
261+ }
262+ }
263+ if (wasRunning && idleSid != _currentSession.value) {
254 notifySessionCompleted(idleSid)
264 notifySessionCompleted(idleSid)
255 }
265 }
256 return@collect
266 return@collect
257 }
267 }
258 val sid = sessionOf(event) ?: return@collect
268 val sid = sessionOf(event) ?: return@collect
259 if (sid != _currentSession.value) {
269 if (sid != _currentSession.value) {
260− // Increment the unread count for this session so the badge can
270+ // Increment the unread count once per distinct *message*, not per
261− // show "N unread" instead of just a dot.
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)) {
262− _unread.update { it + (sid to (it[sid] ?: 0) + 1) }
279+ _unread.update { it + (sid to (it[sid] ?: 0) + 1) }
280+ }
263 }
281 }
264 // Track sessions actively streaming so we know which idle events
282 // Track sessions actively streaming so we know which idle events
265 // represent a finished run worth notifying about.
283 // represent a finished run worth notifying about.
@@ -282,6 +300,11 @@ open class AppContainer private constructor(
282 /** Session ids currently streaming an assistant run (best-effort, in-process). */
300 /** Session ids currently streaming an assistant run (best-effort, in-process). */
283 private val activeRuns: MutableSet<String> = java.util.Collections.synchronizedSet(mutableSetOf())
301 private val activeRuns: MutableSet<String> = java.util.Collections.synchronizedSet(mutableSetOf())
284
302
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+
285 /** Upper bound on [activeRuns] to prevent unbounded growth if SessionIdle never arrives. */
308 /** Upper bound on [activeRuns] to prevent unbounded growth if SessionIdle never arrives. */
286 private val activeRunsLimit = NetworkConfig.activeRunsLimit
309 private val activeRunsLimit = NetworkConfig.activeRunsLimit
287
310
@@ -355,6 +378,7 @@ open class AppContainer private constructor(
355 activeRuns.clear()
378 activeRuns.clear()
356 _anyRunActive.value = false
379 _anyRunActive.value = false
357 _unread.value = emptyMap()
380 _unread.value = emptyMap()
381+ unreadMessageIds.clear()
358 val now = System.currentTimeMillis()
382 val now = System.currentTimeMillis()
359 val resolved = profileStore.resolve(profile)
383 val resolved = profileStore.resolve(profile)
360 val needsSave = (now - resolved.lastUsed) > LAST_USED_SAVE_THRESHOLD_MS
384 val needsSave = (now - resolved.lastUsed) > LAST_USED_SAVE_THRESHOLD_MS
@@ -455,3 +479,14 @@ internal fun sessionOfEvent(event: BusEvent): String? = when (event) {
455 is MessagePartUpdated -> event.properties.part.sessionID ?: event.properties.sessionID
479 is MessagePartUpdated -> event.properties.part.sessionID ?: event.properties.sessionID
456 else -> null
480 else -> null
457}
481}
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 {
33 val digest = runCatching { MessageDigest.getInstance("SHA-256") }
33 val digest = runCatching { MessageDigest.getInstance("SHA-256") }
34 .getOrNull()?.digest(sessionId.toByteArray()) ?: return NOTIF_ID_PREFIX
34 .getOrNull()?.digest(sessionId.toByteArray()) ?: return NOTIF_ID_PREFIX
35 // Take the first 4 bytes, mask to 31 bits (always positive) to fit an Int id.
35 // Take the first 4 bytes, mask to 31 bits (always positive) to fit an Int id.
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.
36− val hash = ((digest[0].toInt() and 0xFF) shl 24 or
40+ val hash = (((digest[0].toInt() and 0xFF) shl 24) or
37− (digest[1].toInt() and 0xFF) shl 16 or
41+ ((digest[1].toInt() and 0xFF) shl 16) or
38− (digest[2].toInt() and 0xFF) shl 8 or
42+ ((digest[2].toInt() and 0xFF) shl 8) or
39 (digest[3].toInt() and 0xFF)).and(0x7FFFFFFF)
43 (digest[3].toInt() and 0xFF)).and(0x7FFFFFFF)
40 // Offset by NOTIF_ID_PREFIX, staying within the positive Int range.
44 // Offset by NOTIF_ID_PREFIX, staying within the positive Int range.
41 // Using modulo ensures the prefix is preserved without Int overflow.
45 // 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
92import kotlinx.coroutines.Dispatchers
92import kotlinx.coroutines.Dispatchers
93import kotlinx.coroutines.launch
93import kotlinx.coroutines.launch
94import kotlinx.coroutines.withContext
94import kotlinx.coroutines.withContext
95+import soy.iko.opencode.data.model.Part
96+import soy.iko.opencode.data.model.ReasoningPart
95import soy.iko.opencode.data.model.TextPart
97import 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
96import soy.iko.opencode.data.network.EventStreamClient
102import soy.iko.opencode.data.network.EventStreamClient
97import soy.iko.opencode.data.network.NetworkConfig
103import soy.iko.opencode.data.network.NetworkConfig
98import soy.iko.opencode.di.AppContainer
104import soy.iko.opencode.di.AppContainer
@@ -398,13 +404,15 @@ fun ChatScreen(
398
404
399 LaunchedEffect(Unit) {
405 LaunchedEffect(Unit) {
400 snapshotFlow {
406 snapshotFlow {
401− // Track list size + the streaming text length of the last message so we
407+ // Track list size + the streaming length of the last part so we auto-scroll
402− // auto-scroll as tokens arrive, plus the pinned flag. A small data class
408+ // as content arrives, plus the pinned flag. A small data class with primitive
403− // with primitive fields avoids the per-frame boxing that Triple<Int,Int,Boolean>
409+ // fields avoids the per-frame boxing that Triple<Int,Int,Boolean> pays
404− // pays (snapshotFlow re-evaluates this lambda every snapshot), and reading
410+ // (snapshotFlow re-evaluates this lambda every snapshot), and reading only the
405− // the last TextPart's length is O(1) vs. the prior sumOf over every part.
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).
406− val lastText = messages.lastOrNull()?.parts?.lastOrNull() as? TextPart
414+ val lastLen = streamingContentLength(messages.lastOrNull()?.parts?.lastOrNull())
407− AutoScrollSignal(messages.size, lastText?.text?.length ?: 0, isPinnedToBottom)
415+ AutoScrollSignal(messages.size, lastLen, isPinnedToBottom)
408 }.collect { signal ->
416 }.collect { signal ->
409 if (signal.size > 0 && signal.pinned) {
417 if (signal.size > 0 && signal.pinned) {
410 // Scroll to the effective last index, including the trailing
418 // Scroll to the effective last index, including the trailing
@@ -859,3 +867,18 @@ private fun EmptyConversation(
859 * equality is structural so `snapshotFlow` emits only when something relevant changes.
867 * equality is structural so `snapshotFlow` emits only when something relevant changes.
860 */
868 */
861private data class AutoScrollSignal(val size: Int, val lastTextLength: Int, val pinned: Boolean)
869private 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(
476 _running.value = false
476 _running.value = false
477 }.onSuccess {
477 }.onSuccess {
478 suppressDraftPersist.set(false)
478 suppressDraftPersist.set(false)
479− // Send succeeded — now it's safe to persist the empty draft.
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.
480− container.draftStore.set(sessionId, "")
483+ if (_draft.value.isBlank()) container.draftStore.set(sessionId, "")
481 // Don't reset _running here: the agent continues streaming via SSE.
484 // Don't reset _running here: the agent continues streaming via SSE.
482 // _running is cleared on SessionIdle/SessionError (see event collector)
485 // _running is cleared on SessionIdle/SessionError (see event collector)
483 // or when the SSE stream drops (see connection state watcher below).
486 // or when the SSE stream drops (see connection state watcher below).
@@ -506,6 +509,10 @@ class ChatViewModel(
506 fun queueFollowUp(text: String) {
509 fun queueFollowUp(text: String) {
507 val trimmed = text.trim()
510 val trimmed = text.trim()
508 _queuedFollowUp.value = trimmed.takeIf { it.isNotEmpty() }
511 _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 = ""
509 }
516 }
510
517
511 /** Transient flag set by [refreshMessages] so the top-bar refresh icon can show
518 /** 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(
52 onRetry: () -> Unit,
52 onRetry: () -> Unit,
53 onDismiss: () -> Unit,
53 onDismiss: () -> Unit,
54) {
54) {
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.
55− var pendingCommand by rememberSaveable { mutableStateOf<Command?>(null) }
59+ var pendingCommandName by rememberSaveable { mutableStateOf<String?>(null) }
60+ val pendingCommand = pendingCommandName?.let { name -> commands.firstOrNull { it.name == name } }
56 ModalBottomSheet(onDismissRequest = onDismiss) {
61 ModalBottomSheet(onDismissRequest = onDismiss) {
57 Text(
62 Text(
58 stringResource(R.string.commands),
63 stringResource(R.string.commands),
@@ -122,7 +127,7 @@ fun CommandPickerSheet(
122 .fillMaxWidth()
127 .fillMaxWidth()
123 .clickable(role = Role.Button) {
128 .clickable(role = Role.Button) {
124 if (isDestructiveCommand(cmd.name)) {
129 if (isDestructiveCommand(cmd.name)) {
125− pendingCommand = cmd
130+ pendingCommandName = cmd.name
126 } else {
131 } else {
127 onSelect(cmd)
132 onSelect(cmd)
128 onDismiss()
133 onDismiss()
@@ -159,18 +164,18 @@ fun CommandPickerSheet(
159 // so a tap doesn't irrevocably run e.g. /clear or /compact without consent.
164 // so a tap doesn't irrevocably run e.g. /clear or /compact without consent.
160 pendingCommand?.let { cmd ->
165 pendingCommand?.let { cmd ->
161 AlertDialog(
166 AlertDialog(
162− onDismissRequest = { pendingCommand = null },
167+ onDismissRequest = { pendingCommandName = null },
163 title = { Text(stringResource(R.string.run_command_title, cmd.name)) },
168 title = { Text(stringResource(R.string.run_command_title, cmd.name)) },
164 text = { Text(stringResource(R.string.run_command_text, "/${cmd.name}")) },
169 text = { Text(stringResource(R.string.run_command_text, "/${cmd.name}")) },
165 confirmButton = {
170 confirmButton = {
166 TextButton(onClick = {
171 TextButton(onClick = {
167− pendingCommand = null
172+ pendingCommandName = null
168 onSelect(cmd)
173 onSelect(cmd)
169 onDismiss()
174 onDismiss()
170 }) { Text(stringResource(R.string.run)) }
175 }) { Text(stringResource(R.string.run)) }
171 },
176 },
172 dismissButton = {
177 dismissButton = {
173− TextButton(onClick = { pendingCommand = null }) { Text(stringResource(R.string.cancel)) }
178+ TextButton(onClick = { pendingCommandName = null }) { Text(stringResource(R.string.cancel)) }
174 },
179 },
175 )
180 )
176 }
181 }
app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+5−1
@@ -59,7 +59,11 @@ fun parseDiff(diff: String): List<DiffLine> {
59 for (raw in diff.lineSequence()) {
59 for (raw in diff.lineSequence()) {
60 when {
60 when {
61 raw.startsWith("@@") -> result.add(DiffLine.Hunk(raw))
61 raw.startsWith("@@") -> result.add(DiffLine.Hunk(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.
62− raw.startsWith("---") || raw.startsWith("+++") -> result.add(DiffLine.FileHeader(raw))
66+ raw.startsWith("--- ") || raw.startsWith("+++ ") -> result.add(DiffLine.FileHeader(raw))
63 raw.startsWith("+") -> result.add(DiffLine.Add(raw.removePrefix("+")))
67 raw.startsWith("+") -> result.add(DiffLine.Add(raw.removePrefix("+")))
64 raw.startsWith("-") -> result.add(DiffLine.Remove(raw.removePrefix("-")))
68 raw.startsWith("-") -> result.add(DiffLine.Remove(raw.removePrefix("-")))
65 raw.startsWith(" ") -> result.add(DiffLine.Context(raw.removePrefix(" ")))
69 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() {
52 val undoEvents: SharedFlow<String> = _undoEvents.asSharedFlow()
52 val undoEvents: SharedFlow<String> = _undoEvents.asSharedFlow()
53
53
54 /** Profile awaiting the undo window to expire before its REST delete fires. */
54 /** 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>()
56
59
57 fun connect(profile: ServerProfile, onConnected: () -> Unit) {
60 fun connect(profile: ServerProfile, onConnected: () -> Unit) {
58 if (_connecting.value != null) return
61 if (_connecting.value != null) return
@@ -92,7 +95,7 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
92 * hidden by the optimistic removal, and the undo re-shows it without reconnecting.
95 * hidden by the optimistic removal, and the undo re-shows it without reconnecting.
93 */
96 */
94 fun delete(profile: ServerProfile) {
97 fun delete(profile: ServerProfile) {
95− pendingDelete = profile
98+ pendingDeletes[profile.id] = profile
96 if (container.activeConnection.value?.profile?.id == profile.id) {
99 if (container.activeConnection.value?.profile?.id == profile.id) {
97 // disconnect() is suspend; run it on the VM scope so the optimistic removal
100 // disconnect() is suspend; run it on the VM scope so the optimistic removal
98 // and undo emission aren't blocked on the connection close completing.
101 // and undo emission aren't blocked on the connection close completing.
@@ -101,9 +104,7 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
101 _undoEvents.tryEmit(profile.id)
104 _undoEvents.tryEmit(profile.id)
102 viewModelScope.launch {
105 viewModelScope.launch {
103 delay(NetworkConfig.undoServerDeleteDelayMs)
106 delay(NetworkConfig.undoServerDeleteDelayMs)
104− val toDelete = pendingDelete
107+ if (pendingDeletes.remove(profile.id) != null) {
105− if (toDelete != null && toDelete.id == profile.id) {
106− pendingDelete = null
107 runCatchingCancellable { container.profileStore.delete(profile.id) }
108 runCatchingCancellable { container.profileStore.delete(profile.id) }
108 .onFailure { _errorEvents.tryEmit(ConnectError(container.friendlyError(it), null)) }
109 .onFailure { _errorEvents.tryEmit(ConnectError(container.friendlyError(it), null)) }
109 }
110 }
@@ -112,9 +113,6 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
112
113
113 /** Cancel a pending delete (Undo snackbar action). */
114 /** Cancel a pending delete (Undo snackbar action). */
114 fun undoDelete(profileId: String) {
115 fun undoDelete(profileId: String) {
115− val pending = pendingDelete
116+ pendingDeletes.remove(profileId)
116− if (pending != null && pending.id == profileId) {
117− pendingDelete = null
118− }
119 }
117 }
120}
118}
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+10−9
@@ -136,7 +136,10 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
136 val undoEvents: SharedFlow<String> = _undoEvents.asSharedFlow()
136 val undoEvents: SharedFlow<String> = _undoEvents.asSharedFlow()
137
137
138 /** Session awaiting the undo window to expire before its REST delete fires. */
138 /** 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>()
140
143
141 private var previewJob: Job? = null
144 private var previewJob: Job? = null
142 private var refreshJob: Job? = null
145 private var refreshJob: Job? = null
@@ -391,14 +394,13 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
391 s.copy(sessions = s.sessions.filterNot { it.id == session.id })
394 s.copy(sessions = s.sessions.filterNot { it.id == session.id })
392 }
395 }
393 // Hold the pending delete so the snackbar can undo it.
396 // Hold the pending delete so the snackbar can undo it.
394− pendingDelete = session
397+ pendingDeletes[session.id] = session
395 _undoEvents.tryEmit(session.id)
398 _undoEvents.tryEmit(session.id)
396 viewModelScope.launch {
399 viewModelScope.launch {
397 delay(NetworkConfig.undoDeleteDelayMs)
400 delay(NetworkConfig.undoDeleteDelayMs)
398− // If undo was called, pendingDelete was cleared — don't delete.
401+ // If undo ran, this id was already removed — remove() returning null means
399− val toDelete = pendingDelete
402+ // "don't delete". Removing by id also can't be clobbered by a later delete.
400− if (toDelete != null && toDelete.id == session.id) {
403+ if (pendingDeletes.remove(session.id) != null) {
401− pendingDelete = null
402 val conn = container.activeConnection.value ?: return@launch
404 val conn = container.activeConnection.value ?: return@launch
403 runCatchingCancellable { conn.repository.deleteSession(session.id) }
405 runCatchingCancellable { conn.repository.deleteSession(session.id) }
404 .onSuccess {
406 .onSuccess {
@@ -416,9 +418,8 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
416
418
417 /** Cancel a pending delete (Undo snackbar action) and restore the session. */
419 /** Cancel a pending delete (Undo snackbar action) and restore the session. */
418 fun undoDelete(sessionId: String) {
420 fun undoDelete(sessionId: String) {
419− val pending = pendingDelete
421+ val pending = pendingDeletes.remove(sessionId)
420− if (pending != null && pending.id == sessionId) {
422+ if (pending != null) {
421− pendingDelete = null
422 _state.update { s ->
423 _state.update { s ->
423 s.copy(sessions = (s.sessions + pending).sortedByMode(s.sortMode))
424 s.copy(sessions = (s.sessions + pending).sortedByMode(s.sortMode))
424 }
425 }
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+18−43
@@ -89,14 +89,9 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
89 val snackbar = remember { SnackbarHostState() }
89 val snackbar = remember { SnackbarHostState() }
90 val undoLabel = stringResource(R.string.undo)
90 val undoLabel = stringResource(R.string.undo)
91 val reportDeletedLabel = stringResource(R.string.report_deleted)
91 val reportDeletedLabel = stringResource(R.string.report_deleted)
92− // Track report names whose deletion has been deferred for an undo window, so a
92+ // Deferred report deletions are owned by the CrashLogger (its process-lived scope),
93− // swipe-triggered delete can be cancelled before the file is actually removed.
93+ // so they survive both rotation and navigating away from this screen. The screen only
94− // rememberSaveable (not remember) so the list survives rotation: otherwise a
94+ // shows the Undo snackbar and asks the logger to cancel a pending delete on undo.
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>() }
100 val shareLabel = stringResource(R.string.share)
95 val shareLabel = stringResource(R.string.share)
101 val shareSubject = stringResource(R.string.crash_report_share_subject)
96 val shareSubject = stringResource(R.string.crash_report_share_subject)
102 val timeTick = rememberRelativeTimeTick()
97 val timeTick = rememberRelativeTimeTick()
@@ -309,27 +304,27 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
309 pendingReportDelete = null
304 pendingReportDelete = null
310 viewing = null
305 viewing = null
311 // Defer the actual delete so the Undo snackbar can cancel it. The
306 // Defer the actual delete so the Undo snackbar can cancel it. The
312− // report stays in the list until the window expires; the undo
307+ // deletion is owned by the CrashLogger's own scope, so it commits even
313− // action removes the name from the deferred list.
308+ // if the user navigates away during the undo window (the screen's
314− deferredDeletes.add(name)
309+ // shareScope would otherwise cancel it, silently dropping the delete).
315− // The deferred delete is scheduled by a LaunchedEffect keyed on
310+ logger.scheduleDelete(name, NetworkConfig.undoReportDeleteDelayMs)
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.
321 shareScope.launch {
311 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+ }
322 val result = snackbar.showSnackbar(
320 val result = snackbar.showSnackbar(
323 message = reportDeletedLabel,
321 message = reportDeletedLabel,
324 actionLabel = undoLabel,
322 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,
330 )
324 )
325+ dismisser.cancel()
331 if (result == SnackbarResult.ActionPerformed) {
326 if (result == SnackbarResult.ActionPerformed) {
332− deferredDeletes.remove(name)
327+ logger.cancelScheduledDelete(name)
333 }
328 }
334 }
329 }
335 }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
330 }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
@@ -340,26 +335,6 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
340 )
335 )
341 }
336 }
342
337
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−
363 if (showClearAll) {
338 if (showClearAll) {
364 AlertDialog(
339 AlertDialog(
365 onDismissRequest = { showClearAll = false },
340 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:
74 // Combine the three settings into a single nullable state so the appearance section
74 // Combine the three settings into a single nullable state so the appearance section
75 // renders only after the persisted values have loaded, avoiding a brief flash of
75 // renders only after the persisted values have loaded, avoiding a brief flash of
76 // hardcoded defaults (SYSTEM/light/dynamic-on) on cold start.
76 // hardcoded defaults (SYSTEM/light/dynamic-on) on cold start.
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) {
77− val settings by combine(
81+ combine(
78− container.settingsStore.themeMode,
82+ container.settingsStore.themeMode,
79− container.settingsStore.dynamicColor,
83+ container.settingsStore.dynamicColor,
80− container.settingsStore.sendOnEnter,
84+ container.settingsStore.sendOnEnter,
81− ) { theme, dyn, enter -> SettingsValues(theme, dyn, enter) as SettingsValues? }
85+ ) { theme, dyn, enter -> SettingsValues(theme, dyn, enter) as SettingsValues? }
86+ }
82− .collectAsStateWithLifecycle(initialValue = null)
87+ val settings by settingsFlow.collectAsStateWithLifecycle(initialValue = null)
83 val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
88 val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
84 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
89 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
85 // SSE connection state so the Settings screen can show a dropped stream (the
90 // SSE connection state so the Settings screen can show a dropped stream (the