git.iko.soy/opencode-android
on master help sign in
Fix 18 bugs: reconnect cache wipe on failed re-seed, file deep-link leading slash, foreground notification stale fields/thread-safety, unrevert/revert retry hazards, nullable BusEvent info props + consumers, search back-handler, go-to-line recomposition race, and 10 smaller correctness fixes (wrap-toggle keying, ErrorKind redirect branch, SHA-256 Stop request code, runCatchingCancellable in probe finally, CrashLogger undo race, evicted-message late parts, DraftStore shutdown drain, RecentSessionsStore atomic fallback)
commit8b41e1cc2a2033d2541aa39e69ff6847aab02fe7 authoriko <mail@iko.soy> dateTue 7 Jul 2026, 05:59 (3 months ago) parente66c7ed
14 files changed+239−56
app/src/main/java/soy/iko/opencode/MainActivity.kt+12−3 app/src/main/java/soy/iko/opencode/data/model/BusEvent.kt+2−2 app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+22−6 app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+21 app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+13−2 app/src/main/java/soy/iko/opencode/data/repo/ErrorKind.kt+4−1 app/src/main/java/soy/iko/opencode/data/repo/RecentSessionsStore.kt+9−3 app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+58−14 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+9−5 app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+59−13 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+11−1 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+4−3 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+14−2 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+1−1
app/src/main/java/soy/iko/opencode/MainActivity.kt+12−3
@@ -369,9 +369,18 @@ class MainActivity : FragmentActivity() {
369369 }
370370 }
371371 "file" -> {
372− // Reconstruct the path from the path segments after "file", preserving internal
373− // slashes. Decoded + validated against traversal/control characters.
374− val rawPath = data.path?.removePrefix("/file/")?.let { android.net.Uri.decode(it) }
372+ // Reconstruct the path from the URI. `file` is the URI *host* (authority), so
373+ // data.path is the path AFTER the authority — e.g. `opencode://file/foo/bar.txt`
374+ // yields data.path = "/foo/bar.txt", which begins with a leading slash that the
375+ // server's file endpoints don't expect (the in-app browser uses server-returned
376+ // FileNode.path values with no leading slash, e.g. "foo/bar.txt"). Strip the
377+ // single leading slash, then percent-decode and validate against traversal/control
378+ // characters. (Using pathSegments would also work but over-decodes `+` and other
379+ // characters that aren't percent-encoded in real workspace paths.)
380+ val rawPath = data.path?.let { p ->
381+ val stripped = if (p.startsWith("/")) p.substring(1) else p
382+ android.net.Uri.decode(stripped)
383+ }
375384 if (!openFileHandled) {
376385 rawPath?.takeIf { isValidFilePath(it) }
377386 ?.let { container.requestOpenFile(it); openFileHandled = true } ?: run {
app/src/main/java/soy/iko/opencode/data/model/BusEvent.kt+2−2
@@ -22,7 +22,7 @@ data object ServerConnected : BusEvent
2222@SerialName("message.updated")
2323data class MessageUpdated(val properties: Props) : BusEvent {
2424 @Serializable
25− data class Props(val info: MessageInfo)
25+ data class Props(val info: MessageInfo? = null)
2626}
2727
2828@Serializable
@@ -78,7 +78,7 @@ data class SessionError(val properties: Props) : BusEvent {
7878@SerialName("session.updated")
7979data class SessionUpdated(val properties: Props) : BusEvent {
8080 @Serializable
81− data class Props(val info: Session)
81+ data class Props(val info: Session? = null, val sessionID: String? = null)
8282}
8383
8484@Serializable
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+22−6
@@ -216,18 +216,34 @@ open class OpencodeApiClient private constructor(
216216 }
217217
218218 /** Revert the session to just before [messageId] (optionally part [partId]); the
219− * server hides everything after the checkpoint. Returns the updated session. Naturally
220− * idempotent (reverting to the same point twice yields the same state). */
221− open suspend fun revert(sessionId: String, messageId: String, partId: String? = null): Session = withRetry {
219+ * server hides everything after the checkpoint. Returns the updated session.
220+ *
221+ * NOT retried (single attempt), mirroring [abort]: a lost response followed by a retry
222+ * would POST revert again, and if a new run started in the gap the retry would hide
223+ * that new run's messages too (until the user noticed and called [unrevert]). There's
224+ * no Idempotency-Key the server could dedupe on, so a single attempt is the safe
225+ * choice — the SSE stream reflects the actual revert state regardless. */
226+ open suspend fun revert(sessionId: String, messageId: String, partId: String? = null): Session = withRetry(maxAttempts = 1) {
222227 client!!.post("session/${encode(sessionId)}/revert") {
223228 contentType(ContentType.Application.Json)
224229 setBody(RevertRequest(messageID = messageId, partID = partId))
225230 }.body()
226231 }
227232
228− /** Undo the active revert checkpoint, restoring the hidden messages. */
229− open suspend fun unrevert(sessionId: String): Session = withRetry {
230− client!!.post("session/${encode(sessionId)}/unrevert").body()
233+ /** Undo the active revert checkpoint, restoring the hidden messages.
234+ *
235+ * Mint an Idempotency-Key *before* withRetry so a retried POST (first request reached
236+ * the server but its response was lost) doesn't trigger a second unrevert on a session
237+ * whose checkpoint was already cleared — the second attempt would hit a session with no
238+ * active revert and the server could return a non-retryable 4xx, surfacing a spurious
239+ * error for an operation that actually succeeded. The key lets the server dedupe. */
240+ open suspend fun unrevert(sessionId: String): Session {
241+ val idempotencyKey = java.util.UUID.randomUUID().toString()
242+ return withRetry {
243+ client!!.post("session/${encode(sessionId)}/unrevert") {
244+ header("Idempotency-Key", idempotencyKey)
245+ }.body()
246+ }
231247 }
232248
233249 /** Create a public share link for the session. Returns the session with [Session.share] set.
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+21
@@ -130,6 +130,15 @@ class CrashLogger private constructor(private val appContext: Context) {
130130 /** Deferred deletions keyed by file name, so an Undo can cancel one before it fires. */
131131 private val pendingDeletes = java.util.concurrent.ConcurrentHashMap<String, Job>()
132132
133+ /** Files whose scheduled delete has already claimed (passed its delay and removed its
134+ * map entry) and is about to delete or has just deleted. [cancelScheduledDelete]
135+ * consults this to honor its contract: once a delete has fired, undo returns false
136+ * even if a reschedule installed a newer job in the map in the gap between the claim
137+ * and the cancel. Without this, the cancel would remove the newer job and report
138+ * "undo succeeded" while the older job's delete proceeds — contradicting the user
139+ * who tapped Undo and the Diagnostics screen that then shows the file gone. */
140+ private val claimedDeletes = java.util.concurrent.ConcurrentHashMap<String, Boolean>()
141+
133142 /**
134143 * Schedule [fileName] for deletion after [delayMs], cancellable via
135144 * [cancelScheduledDelete]. Runs on the logger's own long-lived scope — not the
@@ -149,11 +158,20 @@ class CrashLogger private constructor(private val appContext: Context) {
149158 // remove(name, thisJob) also no-ops for a stale job after a reschedule.
150159 val claimed = coroutineContext[Job]?.let { pendingDeletes.remove(fileName, it) } == true
151160 if (!claimed) return@launch
161+ // Mark this delete as fired so a cancel that arrives AFTER the claim (but before
162+ // the file.delete() completes) honestly reports "already fired" instead of
163+ // "undo succeeded". A reschedule that installed a newer job in the map in that
164+ // gap would otherwise make cancelScheduledDelete remove the newer job and return
165+ // true, while this coroutine still deletes — violating the documented contract.
166+ claimedDeletes[fileName] = true
152167 val file = File(crashDir, fileName).canonicalFile
153168 if (file.path.startsWith(crashDir.canonicalPath + File.separator)) {
154169 file.delete()
155170 }
156171 refresh()
172+ // Clear the fired marker after the delete + refresh so a future scheduleDelete
173+ // for the same name (e.g. a re-created report) starts with a clean slate.
174+ claimedDeletes.remove(fileName)
157175 }
158176 pendingDeletes.put(fileName, job)?.cancel()
159177 }
@@ -161,6 +179,9 @@ class CrashLogger private constructor(private val appContext: Context) {
161179 /** Cancel a pending deferred delete (the Undo action). Returns true if it was still
162180 * pending (undo succeeded), false if the delete had already fired. */
163181 fun cancelScheduledDelete(fileName: String): Boolean {
182+ // If the delete has already claimed (passed its delay), the undo is too late —
183+ // report false even if a reschedule installed a newer job that's still in the map.
184+ if (claimedDeletes.containsKey(fileName)) return false
164185 val job = pendingDeletes.remove(fileName) ?: return false
165186 job.cancel()
166187 return true
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+13−2
@@ -278,7 +278,18 @@ open class DraftStore private constructor(
278278 open fun shutdown() {
279279 flushExecutor.shutdown()
280280 // Await pending flushDraft writes so drafts aren't lost if the process exits
281− // immediately after shutdown (e.g. ANR-triggered process kill).
282− runCatching { flushExecutor.awaitTermination(2, java.util.concurrent.TimeUnit.SECONDS) }
281+ // immediately after shutdown (e.g. ANR-triggered process kill). Loop until the
282+ // executor is truly idle (or 5s elapses) rather than a single 2s wait: a slow disk
283+ // with a deep queue of keystroke-triggered flushes can exceed 2s, and dropping the
284+ // tail would lose the user's most recent edits. The JVM shutdown hook's own deadline
285+ // is the hard limit; this loop just gives the executor the best chance to drain.
286+ var waitedMs = 0L
287+ while (waitedMs < 5000L) {
288+ if (flushExecutor.isTerminated) break
289+ runCatching {
290+ flushExecutor.awaitTermination(500, java.util.concurrent.TimeUnit.MILLISECONDS)
291+ }
292+ waitedMs += 500L
293+ }
283294 }
284295}
app/src/main/java/soy/iko/opencode/data/repo/ErrorKind.kt+4−1
@@ -51,8 +51,11 @@ fun classifyError(t: Throwable): ErrorKind {
5151 is SocketTimeoutException, is HttpRequestTimeoutException ->
5252 return ErrorKind.TIMEOUT
5353 is ServerResponseException -> return ErrorKind.SERVER
54− is ClientRequestException -> return ErrorKind.CLIENT
54+ // RedirectResponseException extends ClientRequestException, so it must be checked
55+ // first: a 3xx response is semantically distinct from a 4xx, and matching the
56+ // parent class first would make this branch unreachable dead code.
5557 is RedirectResponseException -> return ErrorKind.CLIENT
58+ is ClientRequestException -> return ErrorKind.CLIENT
5659 is IOException -> return ErrorKind.NETWORK
5760 else -> {
5861 // Any other ResponseException (unrecognized status family).
app/src/main/java/soy/iko/opencode/data/repo/RecentSessionsStore.kt+9−3
@@ -80,11 +80,17 @@ object RecentSessionsStore {
8080 tmp2.writeText(encoded)
8181 if (!tmp2.renameTo(target)) {
8282 // Truly last resort: some OEM filesystems reject even a create-rename.
83− // Only here do we accept a non-atomic overwrite, with a final cleanup.
84− target.delete()
85− tmp2.renameTo(target)
83+ // Write directly to target, accepting a brief non-atomic window (a
84+ // concurrent reader could see a partially-written file). The previous
85+ // delete-then-rename here was worse: between the delete and the rename
86+ // a reader saw NO file (empty recents), and if the rename failed too,
87+ // target was left missing and tmp2 orphaned. writeText at least keeps
88+ // either the old or the new content visible, never a gap. Clean up
89+ // both temps afterwards.
90+ target.writeText(encoded)
8691 }
8792 tmp.delete()
93+ tmp2.delete()
8894 }
8995 }
9096 }
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+58−14
@@ -221,28 +221,37 @@ open class SessionRepository(
221221 store.beginReseed()
222222 ++seedGeneration
223223 }
224− val fresh = runCatchingCancellable { api.listMessages(sessionId) }
224+ val freshResult = runCatchingCancellable { api.listMessages(sessionId) }
225225 .onFailure { Log.w("SessionRepository", "Re-seed message load failed for $sessionId; relying on SSE: ${safeExceptionSummary(it)}") }
226− .getOrDefault(emptyList())
226+ val fresh = freshResult.getOrNull()
227227 var seedChanged = false
228228 lock.withLock {
229229 if (generation == seedGeneration) {
230− // Prune cache-seeded ghosts here too, not only in the initial
231− // REST seed. If the SSE stream connected→dropped→reconnected
232− // before the initial listMessages returned, seedGeneration was
233− // already >0 by the time the initial seed ran, so its prune was
234− // skipped (the `seedGeneration == 0` guard below). Without this
235− // prune, messages deleted server-side while the app was closed
236− // would survive as ghosts until a future app restart where the
237− // timing happened not to recur.
238− val pruned = store.pruneStaleCacheSeeded(cacheSeededIds, fresh)
230+ // Only prune when the fetch SUCCEEDED. A failed fetch yields
231+ // fresh == null; treating null as an empty REST snapshot would
232+ // make pruneStaleCacheSeeded remove EVERY cache-seeded message
233+ // (none are in the empty retain-set), wiping the whole cached
234+ // conversation on a transient network error — the cache is the
235+ // only thing keeping the session readable until SSE repopulates.
236+ var pruned = false
237+ if (fresh != null) {
238+ // Prune cache-seeded ghosts here too, not only in the initial
239+ // REST seed. If the SSE stream connected→dropped→reconnected
240+ // before the initial listMessages returned, seedGeneration was
241+ // already >0 by the time the initial seed ran, so its prune was
242+ // skipped (the `seedGeneration == 0` guard below). Without this
243+ // prune, messages deleted server-side while the app was closed
244+ // would survive as ghosts until a future app restart where the
245+ // timing happened not to recur.
246+ pruned = store.pruneStaleCacheSeeded(cacheSeededIds, fresh)
247+ }
239248 // Merge without pruning: SSE events arriving during the
240249 // REST fetch may have added messages not yet indexed by
241250 // the REST endpoint. Pruning would silently delete them.
242251 // Stale messages from deletions during the disconnect
243252 // gap are eventually removed via MessageRemoved events
244253 // or on the next app restart.
245− val seeded = store.seed(fresh, prune = false)
254+ val seeded = if (fresh != null) store.seed(fresh, prune = false) else false
246255 seedChanged = pruned || seeded
247256 }
248257 }
@@ -349,7 +358,7 @@ open class SessionRepository(
349358 (part.sessionID ?: event.properties.sessionID) == sessionId
350359 }
351360 is MessageUpdated -> {
352− val info = event.properties.info
361+ val info = event.properties.info ?: return false
353362 info.sessionID == sessionId && info is AssistantMessage && !info.isComplete && info.error == null
354363 }
355364 else -> false
@@ -441,6 +450,10 @@ internal class MessageStore {
441450 messages.keys.retainAll(incomingKeys)
442451 if (messages.size != before) changed = true
443452 }
453+ // An authoritative seed supersedes any eviction record: a message re-introduced by
454+ // this seed is back in memory, so a later MessagePartUpdated should update it, not
455+ // be dropped as "late for an evicted message". Clearing keeps the set bounded too.
456+ evictedMessageIds.clear()
444457
445458 for (m in initial) {
446459 val key = syntheticKeyFor(m)
@@ -621,7 +634,7 @@ internal class MessageStore {
621634 }
622635
623636 private fun handleMessageUpdated(sessionId: String, event: MessageUpdated): Boolean {
624− val info = event.properties.info
637+ val info = event.properties.info ?: return false
625638 if (info.sessionID != sessionId) return false
626639 // An UnknownMessage with an empty id (e.g. from an unrecognized server role with no id
627640 // field) would collide with other such messages in the map. It carries no id, only
@@ -640,6 +653,10 @@ internal class MessageStore {
640653 // A server re-sending byte-identical info is a no-op: skip it so we don't publish a
641654 // redundant snapshot (mirrors upsertPart's identical-part guard).
642655 if (existing != null && existing.info == info) return false
656+ // A MessageUpdated re-introduces the message into the store (it was either evicted
657+ // by evictOldMessages or genuinely new). Drop any eviction record so a following
658+ // MessagePartUpdated updates this holder instead of being dropped as "late".
659+ info.id.takeIf { it.isNotEmpty() }?.let { evictedMessageIds.remove(it) }
643660 if (existing == null) {
644661 messages[key] = Holder(info, LinkedHashMap())
645662 } else {
@@ -685,6 +702,10 @@ internal class MessageStore {
685702 eventSession == null && !messages.containsKey(id) -> return false
686703 }
687704 val removed = messages.remove(id) ?: return false
705+ // The message is gone from the store; drop any eviction record for it so the set
706+ // doesn't retain ids of long-deleted messages. (A re-seed would also clear this,
707+ // but a quiet session that never reconnects could otherwise accumulate entries.)
708+ removed.info.id.takeIf { it.isNotEmpty() }?.let { evictedMessageIds.remove(it) }
688709 // Derive the same key handleMessageUpdated would have stored this holder under. For an
689710 // UnknownMessage with an empty id, that's the synthetic "unknown-c<created>" /
690711 // "unknown-h<hash>" key (not the raw empty id) — removing the raw id here would leave
@@ -712,6 +733,13 @@ internal class MessageStore {
712733 current.parts[part.id] = part
713734 current.invalidate()
714735 } else {
736+ // A late part update for a message that was evicted by evictOldMessages() (the
737+ // store caps at maxMessages) would otherwise re-insert the message with only
738+ // this single part — a partial, contextless bubble at the end of the list. The
739+ // evicted message's other parts are gone from memory, so re-inserting can't
740+ // restore them; ignore the late part instead. (A later MessageUpdated for the
741+ // same id — which always carries full info — re-creates the holder correctly.)
742+ if (evictedMessageIds.contains(messageId)) return false
715743 val parts = LinkedHashMap<String, Part>()
716744 parts[part.id] = part
717745 messages[messageId] = Holder(
@@ -739,6 +767,15 @@ internal class MessageStore {
739767 return true
740768 }
741769
770+ /** Message ids evicted by [evictOldMessages] (capped at [maxMessages]). A late
771+ * [MessagePartUpdated] for one of these would otherwise re-insert the message under
772+ * [upsertPart] with only the late part — a partial, contextless bubble at the end of
773+ * the list. Membership here tells upsertPart to drop the late part instead. Bounded by
774+ * the same cap (entries are removed when the message is re-seeded via [seed] or removed
775+ * via [handleMessageRemoved]); a long-lived session that cycles past maxMessages many
776+ * times never grows this beyond the most recent eviction batch. */
777+ private val evictedMessageIds = mutableSetOf<String>()
778+
742779 /** Evict the oldest messages when the store exceeds [maxMessages], keeping memory bounded.
743780 * Returns true if any messages were evicted. */
744781 private fun evictOldMessages(): Boolean {
@@ -746,6 +783,13 @@ internal class MessageStore {
746783 while (messages.size > maxMessages) {
747784 val oldestKey = messages.keys.iterator().next()
748785 val removed = messages.remove(oldestKey)
786+ // Record the evicted message's id so a late MessagePartUpdated for it is dropped
787+ // instead of re-inserting a single-part bubble (see upsertPart). Also clear any
788+ // prior eviction record for this id from an older cycle (the message re-entered
789+ // via seed/MessageUpdated and has now been evicted again).
790+ removed?.info?.id?.takeIf { it.isNotEmpty() }?.let { id ->
791+ evictedMessageIds.add(id)
792+ }
749793 // Prune the evicted message's pivot bookkeeping so these sets don't grow
750794 // unbounded over a long-running session that never reconnects (they're only
751795 // otherwise cleared on reseed/seed).
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+9−5
@@ -870,7 +870,7 @@ open class AppContainer private constructor(
870870 // to clear it — pinning anyRunActive true and the foreground notification indefinitely.
871871 // Mirrors SessionRepository.isRunActivity's guard exactly.
872872 if (event is MessageUpdated) {
873− val info = event.properties.info
873+ val info = event.properties.info ?: return false
874874 return info is AssistantMessage && !info.isComplete && info.error == null
875875 }
876876 // A step-finish part is the trailing completion marker for a run (it carries the
@@ -1473,7 +1473,10 @@ open class AppContainer private constructor(
14731473 val status = responseStatusCode(e)
14741474 if (status == 401 || status == 403) false else throw e
14751475 } finally {
1476− runCatching { client.close() }
1476+ // runCatchingCancellable (not plain runCatching): this is a suspend finally, and
1477+ // plain runCatching swallows CancellationException — if the caller's scope is
1478+ // cancelled while client.close() is in flight, the cancellation must propagate.
1479+ runCatchingCancellable { client.close() }
14771480 }
14781481 }
14791482
@@ -1493,7 +1496,8 @@ open class AppContainer private constructor(
14931496 ProbeResult.Unreachable(friendlyErrorFor(e, profile.baseUrl))
14941497 }
14951498 } finally {
1496− runCatching { client.close() }
1499+ // runCatchingCancellable: see probeWithCredentials for the rationale.
1500+ runCatchingCancellable { client.close() }
14971501 }
14981502 }
14991503
@@ -1541,7 +1545,7 @@ open class AppContainer private constructor(
15411545 * [Context] (the surrounding [AppContainer] needs one).
15421546 */
15431547internal fun sessionOfEvent(event: BusEvent): String? = when (event) {
1544− is MessageUpdated -> event.properties.info.sessionID
1548+ is MessageUpdated -> event.properties.info?.sessionID
15451549 is MessagePartUpdated -> event.properties.part.sessionID ?: event.properties.sessionID
15461550 else -> null
15471551}
@@ -1552,7 +1556,7 @@ internal fun sessionOfEvent(event: BusEvent): String? = when (event) {
15521556 * message id (those fall back to being counted individually).
15531557 */
15541558internal fun messageIdOfEvent(event: BusEvent): String? = when (event) {
1555− is MessageUpdated -> event.properties.info.id
1559+ is MessageUpdated -> event.properties.info?.id
15561560 is MessagePartUpdated -> event.properties.messageID ?: event.properties.part.messageID
15571561 else -> null
15581562}
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+59−13
@@ -16,6 +16,7 @@ import androidx.core.app.NotificationManagerCompat
1616import androidx.core.app.ServiceCompat
1717import soy.iko.opencode.MainActivity
1818import soy.iko.opencode.R
19+import java.security.MessageDigest
1920
2021/**
2122 * A foreground service kept alive while an opencode agent is actively running. Holding a
@@ -37,7 +38,12 @@ class RunForegroundService : Service() {
3738 // recreated service instance and leave the chronometer pinned to a previous run's start
3839 // if onDestroy hadn't run yet when the next run's first intent arrived. As an instance
3940 // field it's reset by construction on every new Service instance.
40− private var runStartMillis: Long = 0L
41+ //
42+ // @Volatile: onStartCommand/onDestroy write these on the main thread, but updateProgress
43+ // (reached via the companion start() from AppContainer.observeRunForegroundService, which
44+ // runs on Dispatchers.IO) reads them. Without @Volatile there's no happens-before edge,
45+ // so a 64-bit Long (runStartMillis) can be torn and the booleans/strings can be stale.
46+ @Volatile private var runStartMillis: Long = 0L
4147 // Whether *this* service instance has successfully called startForeground. Tracked so
4248 // progress-driven re-starts (which re-enter onStartCommand) don't re-call startForeground
4349 // and re-bind the 5s obligation each time — that would let a backgrounded re-start throw
@@ -45,14 +51,14 @@ class RunForegroundService : Service() {
4551 // foreground. After the first successful startForeground, progress updates refresh the
4652 // notification via NotificationManager.notify directly (see [updateProgress]), which
4753 // never throws the background-start exception.
48− private var isInForeground: Boolean = false
54+ @Volatile private var isInForeground: Boolean = false
4955 // The most recent session title/id/profile/progress handed to onStartCommand, retained so
5056 // [updateProgress] can rebuild the notification with a new progress string without a fresh
5157 // startForegroundService intent (which can throw when backgrounded).
52− private var lastTitle: String? = null
53− private var lastSessionId: String? = null
54− private var lastProfileId: String? = null
55− private var lastProgress: String? = null
58+ @Volatile private var lastTitle: String? = null
59+ @Volatile private var lastSessionId: String? = null
60+ @Volatile private var lastProfileId: String? = null
61+ @Volatile private var lastProgress: String? = null
5662
5763 @android.annotation.SuppressLint("MissingPermission")
5864 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -113,19 +119,33 @@ class RunForegroundService : Service() {
113119 * re-entering [onStartCommand] (and therefore without calling [Context.startForegroundService],
114120 * which can throw [android.app.ForegroundServiceStartNotAllowedException] on Android 12+ when
115121 * the app is backgrounded — even if the service is already in the foreground). Called directly
116− * by the companion [updateProgress] when a progress refresh is needed while the service is
117− * already running; falls back to [start] (which goes through startForegroundService) when the
118− * service isn't alive yet, so the first progress update still lands. */
122+ * by the companion [start] when a progress refresh is needed while the service is already
123+ * running; falls back to [start] (which goes through startForegroundService) when the
124+ * service isn't alive yet, so the first progress update still lands.
125+ *
126+ * Forwards the full set of run fields (title/sessionId/profileId/progress), not just the
127+ * progress string, so a late-resolved session title or a second run starting after the
128+ * first updates the notification text AND the Stop action's target session/profile.
129+ * Without this, the Stop action would keep targeting the originally-captured session even
130+ * after a profile switch (mirroring the SessionNotifications abort-routing guard). */
119131 @SuppressLint("MissingPermission")
120− private fun updateProgress(progress: String?) {
132+ private fun updateProgress(
133+ progress: String?,
134+ sessionTitle: String? = lastTitle,
135+ sessionId: String? = lastSessionId,
136+ profileId: String? = lastProfileId,
137+ ) {
121138 // Guard against the companion start() → instance race: start() reads @Volatile instance
122139 // on a background dispatcher and calls updateProgress, but onDestroy (on the main thread)
123140 // can null instance and call stopForeground between the read and this notify(). isInForeground
124141 // is cleared in onDestroy, so checking it here covers the stopForeground-already-called case
125142 // and avoids touching NotificationManager during teardown.
126143 if (!isInForeground) return
144+ lastTitle = sessionTitle
145+ lastSessionId = sessionId
146+ lastProfileId = profileId
127147 lastProgress = progress
128− val notification = buildNotification(lastTitle, lastSessionId, lastProfileId, progress)
148+ val notification = buildNotification(sessionTitle, sessionId, profileId, progress)
129149 runCatching {
130150 NotificationManagerCompat.from(this).notify(NOTIF_ID, notification)
131151 }.onFailure { Log.w(TAG, "Failed to update foreground notification progress", it) }
@@ -181,8 +201,15 @@ class RunForegroundService : Service() {
181201 putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
182202 profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
183203 }
204+ // Derive the request code from a SHA-256 of the session id (truncated to 31 bits),
205+ // NOT String.hashCode(): two distinct session ids can share a 32-bit hashCode, and
206+ // with FLAG_UPDATE_CURRENT the second registration overwrites the first's extras —
207+ // so a hash collision between two concurrent runs would make tapping Stop on one
208+ // session's notification broadcast the OTHER session's id. Mirrors SessionNotifications.
209+ // notifId's collision-resistant derivation.
210+ val requestCode = stopRequestCode(sessionId)
184211 val stopPending = PendingIntent.getBroadcast(
185− this, sessionId.hashCode(), stopIntent,
212+ this, requestCode, stopIntent,
186213 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
187214 )
188215 builder.addAction(
@@ -263,6 +290,21 @@ class RunForegroundService : Service() {
263290 private const val TAG = "RunForegroundService"
264291 private const val NOTIF_ID = 1
265292 private const val NOTIF_TIMEOUT_ID = 2
293+
294+ /** Derive a stable, collision-resistant PendingIntent request code from a session id.
295+ * String.hashCode() can collide for distinct session ids; a SHA-256 truncated to 31
296+ * bits makes accidental collisions astronomically unlikely, so two concurrent runs'
297+ * Stop actions can't overwrite each other's PendingIntent extras. Mirrors
298+ * [SessionNotifications.notifId]'s derivation. */
299+ private fun stopRequestCode(sessionId: String): Int {
300+ val digest = runCatching { MessageDigest.getInstance("SHA-256") }
301+ .getOrNull()?.digest(sessionId.toByteArray()) ?: return sessionId.hashCode()
302+ val hash = (((digest[0].toInt() and 0xFF) shl 24) or
303+ ((digest[1].toInt() and 0xFF) shl 16) or
304+ ((digest[2].toInt() and 0xFF) shl 8) or
305+ (digest[3].toInt() and 0xFF)).and(0x7FFFFFFF)
306+ return hash
307+ }
266308 // Brand accent for the foreground notifications' small-icon tint circle. Resolved
267309 // from resources (R.color.notif_brand) so it follows the app theme (light/dark)
268310 // instead of a hardcoded constant. Lazily computed on first use from the app context
@@ -304,7 +346,11 @@ class RunForegroundService : Service() {
304346 // notify never throws, so progress refreshes always land while the service runs.
305347 val live = instance
306348 if (live != null) {
307− live.updateProgress(progress)
349+ // Forward the full set of run fields (not just progress) so a late-resolved
350+ // session title or a second run updates the notification text AND the Stop
351+ // action's target session/profile — otherwise the Stop action would keep
352+ // targeting the originally-captured session even after a profile switch.
353+ live.updateProgress(progress, sessionTitle, sessionId, profileId)
308354 return
309355 }
310356 // startForegroundService can throw ForegroundServiceStartNotAllowedException
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+11−1
@@ -547,7 +547,17 @@ fun ChatScreen(
547547 }
548548 }
549549
550− BackHandler(enabled = running && !showModelPicker && !showAgentPicker && !showCommandPicker) { showExitConfirm = true }
550+ // The in-conversation search bar is an AnimatedVisibility overlay (not a Dialog/Dropdown),
551+ // so it has no built-in back handling. Close it first on back press, before the exit
552+ // confirmation or navigation-away can fire — standard Android "back closes the topmost
553+ // overlay" behavior. Declared before the exit-confirm BackHandler so it takes precedence
554+ // when both could be active (search open + a run in progress).
555+ BackHandler(enabled = searchActive) {
556+ searchActive = false
557+ chatSearch = ""
558+ }
559+
560+ BackHandler(enabled = running && !showModelPicker && !showAgentPicker && !showCommandPicker && !searchActive) { showExitConfirm = true }
551561
552562 Scaffold(
553563 // Hardware-keyboard shortcuts (tablets / DeX / Chromebooks): Ctrl+K opens the command
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+4−3
@@ -957,14 +957,15 @@ class ChatViewModel(
957957 if (event.properties.sessionID == sessionId) enqueuePermission(event.properties)
958958 is PermissionReplied ->
959959 if (event.properties.sessionID == sessionId) event.properties.permissionID?.let { resolvePermission(it) }
960− is SessionUpdated ->
961− if (event.properties.info.id == sessionId) {
962− val info = event.properties.info
960+ is SessionUpdated -> {
961+ val info = event.properties.info
962+ if (info != null && info.id == sessionId) {
963963 _sessionTitle.value = info.displayTitle
964964 _reverted.value = info.isReverted
965965 _revertDiff.value = info.revert?.diff?.takeIf { it.isNotBlank() }
966966 _shareUrl.value = info.share?.url?.takeIf { it.isNotBlank() }
967967 }
968+ }
968969 is SessionDeleted ->
969970 if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
970971 _sessionDeleted.value = true
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+14−2
@@ -163,8 +163,11 @@ fun FileViewScreen(
163163 var matchPos by rememberSaveable(path) { mutableIntStateOf(0) }
164164 // Seed the per-file wrap toggle from the user's global code-wrap preference so the viewer
165165 // respects it by default; they can still flip it per-file (persisted across rotation).
166+ // Key on BOTH path and codeWrap so a change to the global preference while the file is
167+ // open re-seeds the toggle — without the codeWrap key, rememberSaveable(path) would keep
168+ // the old value and ignore the new preference until the file were closed and reopened.
166169 val codeWrap = LocalCodeWrap.current
167− var wrap by rememberSaveable(path) { mutableStateOf(codeWrap) }
170+ var wrap by rememberSaveable(path, codeWrap) { mutableStateOf(codeWrap) }
168171 val listState = rememberLazyListState()
169172 val rawText = state.content?.content.orEmpty()
170173 // Directory portion of the path, shown under the filename in the top bar so a user who
@@ -279,7 +282,16 @@ fun FileViewScreen(
279282 // scroll lands on the right item once recomposition expands the LazyColumn.
280283 val target = (clamped - 1).coerceIn(0, allLines.size - 1)
281284 highlightLine.value = null
282− scope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
285+ scope.launch {
286+ // When the render limit was just expanded above, the LazyColumn hasn't recomposed
287+ // yet (Dispatchers.Main.immediate runs this body before recomposition), so
288+ // animateScrollToItem would clamp to the last currently-rendered item. A short
289+ // delay lets the state change propagate and the LazyColumn measure the expanded
290+ // item count before the scroll runs. Mirrors rememberJumpToLineHighlight's
291+ // identical race fix. The delay is harmless when no expansion was needed.
292+ kotlinx.coroutines.delay(1)
293+ runCatchingCancellable { listState.animateScrollToItem(target) }
294+ }
283295 }
284296 Scaffold(
285297 topBar = {
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+1−1
@@ -444,7 +444,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
444444 conn.events.events.collect { event ->
445445 when (event) {
446446 is SessionUpdated -> {
447− val session = event.properties.info
447+ val session = event.properties.info ?: return@collect
448448 // Ignore updates for a session the user just deleted (still
449449 // within its undo window): the server hasn't deleted it yet
450450 // and can legitimately emit updates, but buffering them here