git.iko.soy/opencode-android
on master help sign in
Fix 10 bugs: notification cross-server routing, streaming diff/reasoning collapse, stale VCS badges, cache tombstone cross-profile leak, optimistic pin/archive, reconnecting flag on cancel, new-session retry, watchdog thread leak, unread cleanup on delete
commitef2f5798daa41c91c001fdb3cae1092eb2f5f840 authoriko <mail@iko.soy> dateFri 3 Jul 2026, 06:22 (3 months ago) parent9614d11
13 files changed+170−68
app/src/main/java/soy/iko/opencode/MainActivity.kt+5−2 app/src/main/java/soy/iko/opencode/data/repo/MessageCacheStore.kt+14−8 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+65−26 app/src/main/java/soy/iko/opencode/notification/NotificationActionReceiver.kt+21−8 app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+14−8 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+15−6 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+2−1 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+7−3 app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+5−2 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+6−1 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+6−1 app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+4−1 app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+6−1
app/src/main/java/soy/iko/opencode/MainActivity.kt+5−2
@@ -9,6 +9,7 @@ import android.os.Bundle
99import android.view.WindowManager
1010import androidx.core.content.IntentCompat
1111import soy.iko.opencode.data.network.NetworkConfig
12+import soy.iko.opencode.notification.NotificationActionReceiver
1213import androidx.activity.compose.setContent
1314import androidx.activity.enableEdgeToEdge
1415import androidx.activity.result.contract.ActivityResultContracts
@@ -187,7 +188,9 @@ class MainActivity : FragmentActivity() {
187188 // this flag (mirroring shareIntentHandled) the session would re-open unexpectedly.
188189 if (!openSessionHandled) {
189190 id?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
190− container.requestOpenSession(it)
191+ val profileId = intent?.getStringExtra(NotificationActionReceiver.EXTRA_PROFILE_ID)
192+ ?.takeIf { it.isNotBlank() }
193+ container.requestOpenSession(it, profileId)
191194 openSessionHandled = true
192195 }
193196 }
@@ -221,7 +224,7 @@ class MainActivity : FragmentActivity() {
221224 // the re-delivered intent re-dispatches it into the new container on restore; already
222225 // consumed → persist true so we don't re-open it unexpectedly on every later restore.
223226 val openConsumed = container.pendingOpenSession.value == null
224− val newConsumed = !container.pendingNewSession.value
227+ val newConsumed = container.pendingNewSession.value == 0
225228 val shareConsumed = container.pendingShare.value == null && container.pendingSharedMedia.value.isEmpty()
226229 outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled && shareConsumed)
227230 outState.putBoolean(KEY_OPEN_SESSION_HANDLED, openSessionHandled && openConsumed)
app/src/main/java/soy/iko/opencode/data/repo/MessageCacheStore.kt+14−8
@@ -60,17 +60,21 @@ open class MessageCacheStore private constructor(
6060 * remove(): both serialize on [lockFor], but their relative order is nondeterministic.
6161 * If the flush's save lands after remove, it re-creates the deleted session's cache
6262 * file, defeating the deletion (a privacy/disk-leak with no background cleanup). The
63− * tombstone lets a post-remove save detect the deletion and skip. Cleared by [load] so
64− * re-opening a session (which won't happen for a truly deleted UUID, but is safe)
65− * re-enables saving. Bounded by distinct deleted sessions, each a few bytes. */
63+ * tombstone lets a post-remove save detect the deletion and skip. Keyed by
64+ * "$profileId/$sessionId" — a bare sessionId would suppress a *different* server's
65+ * saves if two servers share a colliding session id (the very case the per-profile
66+ * namespacing at [profileDir] exists to defend against). Cleared by [load] so
67+ * re-opening a session re-enables saving. Bounded by distinct deleted sessions. */
6668 private val tombstones: MutableSet<String> =
6769 java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap())
6870
71+ private fun tombstoneKey(profileId: String, sessionId: String): String = "$profileId/$sessionId"
72+
6973 /** Load the cached messages for [sessionId] under [profileId], or empty if none / unreadable. */
7074 open suspend fun load(profileId: String, sessionId: String): List<MessageWithParts> = lockFor(sessionId).withLock {
7175 // Re-opening a session re-enables saving: clear any tombstone so a fresh conversation
7276 // write isn't suppressed by a prior deletion of the same (reused) id.
73− tombstones.remove(sessionId)
77+ tombstones.remove(tombstoneKey(profileId, sessionId))
7478 withContext(Dispatchers.IO) {
7579 val file = fileFor(profileId, sessionId)?.takeIf { it.exists() } ?: return@withContext emptyList()
7680 runCatchingCancellable { OpencodeJson.decodeFromString(serializer, file.readText()) }
@@ -83,7 +87,7 @@ open class MessageCacheStore private constructor(
8387 // Skip if this session was deleted since the observer started: a teardown flush racing
8488 // after remove() must not re-create the file. (Held under the same per-session lock as
8589 // remove, so the tombstone is visible to a save that lost the race.)
86− if (sessionId in tombstones) return@withLock
90+ if (tombstoneKey(profileId, sessionId) in tombstones) return@withLock
8791 withContext(Dispatchers.IO) {
8892 val file = fileFor(profileId, sessionId) ?: return@withContext
8993 runCatchingCancellable {
@@ -109,12 +113,14 @@ open class MessageCacheStore private constructor(
109113 * namespace: session ids are unique per server and deletion is terminal, so the caller
110114 * needn't know which profile owned it. The per-session lock is intentionally NOT evicted —
111115 * a concurrent teardown save() for this id must serialize against the same [Mutex] instance,
112− * and the map is bounded by the number of distinct sessions touched. */
113− open suspend fun remove(sessionId: String) {
116+ * and the map is bounded by the number of distinct sessions touched.
117+ * [profileId] namespaces the tombstone so a delete on one server doesn't suppress another
118+ * server's saves for a colliding session id. */
119+ open suspend fun remove(profileId: String, sessionId: String) {
114120 lockFor(sessionId).withLock {
115121 // Set the tombstone before deleting so a concurrent/loser save sees it and skips,
116122 // instead of re-creating the file we're about to delete.
117− tombstones.add(sessionId)
123+ tombstones.add(tombstoneKey(profileId, sessionId))
118124 withContext(Dispatchers.IO) {
119125 runCatchingCancellable {
120126 val name = idRegex.replace(sessionId, "_") + ".json"
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+65−26
@@ -154,21 +154,36 @@ open class AppContainer private constructor(
154154
155155 /**
156156 * Set from an external "New session" trigger (launcher shortcut / QS tile). The nav host
157− * consumes it once connected, creating a fresh session and opening it.
157+ * consumes it once connected, creating a fresh session and opening it. Uses a counter
158+ * (not a boolean) so re-tapping the shortcut while a prior request is still pending
159+ * increments the value and re-fires the LaunchedEffect — a boolean StateFlow wouldn't
160+ * emit when set to the same value, making the request unrecoverable by re-tapping.
158161 */
159− private val _pendingNewSession = MutableStateFlow(false)
160− open val pendingNewSession: StateFlow<Boolean> = _pendingNewSession.asStateFlow()
161− open fun requestNewSession() { _pendingNewSession.value = true }
162− open fun consumePendingNewSession(): Boolean = _pendingNewSession.compareAndSet(true, false)
162+ private val _pendingNewSession = MutableStateFlow(0)
163+ open val pendingNewSession: StateFlow<Int> = _pendingNewSession.asStateFlow()
164+ open fun requestNewSession() { _pendingNewSession.update { it + 1 } }
165+ open fun consumePendingNewSession(): Boolean {
166+ while (true) {
167+ val current = _pendingNewSession.value
168+ if (current == 0) return false
169+ if (_pendingNewSession.compareAndSet(current, current - 1)) return true
170+ }
171+ }
163172
164173 /**
165− * A session id to open from an external trigger (a notification tap or a deep link).
166− * The nav host consumes it once a connection is active.
174+ * A session id to open from an external trigger (a notification tap or a deep link),
175+ * paired with the originating server's profile id (if known) so a tap after the user
176+ * has switched servers routes back to the server that ran the session. The nav host
177+ * consumes it once a connection is active (switching to [profileId] first if needed).
167178 */
168− private val _pendingOpenSession = MutableStateFlow<String?>(null)
169− open val pendingOpenSession: StateFlow<String?> = _pendingOpenSession.asStateFlow()
170− open fun requestOpenSession(id: String) { _pendingOpenSession.value = id }
171− open fun consumePendingOpenSession(): String? {
179+ data class PendingOpenSession(val sessionId: String, val profileId: String?)
180+
181+ private val _pendingOpenSession = MutableStateFlow<PendingOpenSession?>(null)
182+ open val pendingOpenSession: StateFlow<PendingOpenSession?> = _pendingOpenSession.asStateFlow()
183+ open fun requestOpenSession(id: String, profileId: String? = null) {
184+ _pendingOpenSession.value = PendingOpenSession(id, profileId)
185+ }
186+ open fun consumePendingOpenSession(): PendingOpenSession? {
172187 val current = _pendingOpenSession.value ?: return null
173188 return if (_pendingOpenSession.compareAndSet(current, null)) current else null
174189 }
@@ -228,6 +243,13 @@ open class AppContainer private constructor(
228243 }
229244 }
230245
246+ /** Clear unread badge and dedup set for a deleted session so its stale count doesn't
247+ * linger in the session list badge or leak memory until the next connect/disconnect. */
248+ open fun clearUnread(id: String) {
249+ _unread.update { it - id }
250+ unreadMessageIds.remove(id)
251+ }
252+
231253 /** Restore a session's unread badge after a failed server switch reconnects.
232254 * Preserves the prior [count] so a session badged with "5 unread" before the
233255 * switch attempt still shows 5 (not 1) after the restore. No-op for the session
@@ -488,7 +510,7 @@ open class AppContainer private constructor(
488510 }
489511 }
490512 if (wasRunning && !isActivelyViewing(esid)) {
491− appScope.launch { notifySessionError(esid, conn.repository) }
513+ appScope.launch { notifySessionError(esid, conn.repository, conn.profile.id) }
492514 }
493515 return@collect
494516 }
@@ -649,12 +671,14 @@ open class AppContainer private constructor(
649671 appContext?.let { SessionNotifications.postPermission(it, permission, title, originConn.profile.id) }
650672 }
651673
652− /** Post an error notification for a failed background run. */
653− private suspend fun notifySessionError(sessionId: String, originRepo: SessionRepository) {
674+ /** Post an error notification for a failed background run. [originProfileId] is embedded
675+ * in the tap intent so the session opens under the server that ran it, not whichever
676+ * is active when the user taps — mirroring [notifySessionCompleted]. */
677+ private suspend fun notifySessionError(sessionId: String, originRepo: SessionRepository, originProfileId: String) {
654678 val title = resolveSessionTitle(sessionId, originRepo)
655679 // Re-check viewing just before posting — see notifySessionCompleted for the race.
656680 if (isActivelyViewing(sessionId)) return
657− appContext?.let { SessionNotifications.postError(it, sessionId, title) }
681+ appContext?.let { SessionNotifications.postError(it, sessionId, title, originProfileId) }
658682 }
659683
660684 /** Respond to a permission from a notification action (Allow once / Always / Reject),
@@ -769,16 +793,22 @@ open class AppContainer private constructor(
769793 // teardown by identity.)
770794 _reconnecting.value = true
771795 var conn: OpencodeConnection? = null
772− val ok = runCatchingCancellable {
773− conn = connectIfIdle(recent) ?: return@runCatchingCancellable false
774− conn!!.api.ping()
775− true
776− }.getOrDefault(false)
777− _reconnecting.value = false
778− when {
779− conn == null -> Unit // slot already taken by a manual connect — leave it be
780− ok -> _autoConnectDone.value = true
781− else -> disconnectIf(conn!!)
796+ try {
797+ val ok = runCatchingCancellable {
798+ conn = connectIfIdle(recent) ?: return@runCatchingCancellable false
799+ conn!!.api.ping()
800+ true
801+ }.getOrDefault(false)
802+ when {
803+ conn == null -> Unit // slot already taken by a manual connect — leave it be
804+ ok -> _autoConnectDone.value = true
805+ else -> disconnectIf(conn!!)
806+ }
807+ } finally {
808+ // Reset in a finally so a CancellationException (e.g. scope cancelled during
809+ // shutdown) can't leave the flag pinned true — runCatchingCancellable rethrows it,
810+ // skipping the line below the try block.
811+ _reconnecting.value = false
782812 }
783813 }
784814
@@ -830,6 +860,14 @@ open class AppContainer private constructor(
830860 open suspend fun connect(profile: ServerProfile): OpencodeConnection =
831861 connectionMutex.withLock { connectLocked(profile) }
832862
863+ /** Connect to the profile with [profileId], looking it up from [profileStore]. Returns
864+ * true on success, false if the profile wasn't found or connect threw. Used by the
865+ * notification body-tap path to route a session open back to the originating server. */
866+ open suspend fun connectByProfileId(profileId: String): Boolean {
867+ val profile = profileStore.profiles.first().find { it.id == profileId } ?: return false
868+ return runCatchingCancellable { connect(profile) }.isSuccess
869+ }
870+
833871 /** Connect to [profile] only if nothing is currently active, checking-and-connecting
834872 * atomically under [connectionMutex]. Returns null (touching nothing) when a connection is
835873 * already active, so autoConnect() can't clobber a manual connect() the user made during its
@@ -985,8 +1023,9 @@ open class AppContainer private constructor(
9851023 draftStore.remove(id)
9861024 attachmentDraftStore.remove(id)
9871025 sessionPrefsStore.forget(id)
988− messageCacheStore.remove(id)
1026+ messageCacheStore.remove(conn.profile.id, id)
9891027 outboxStore.removeForSession(id)
1028+ clearUnread(id)
9901029 onDeleted()
9911030 }
9921031 .onFailure { onError(it) }
app/src/main/java/soy/iko/opencode/notification/NotificationActionReceiver.kt+21−8
@@ -41,8 +41,14 @@ class NotificationActionReceiver : BroadcastReceiver() {
4141 } ?: return
4242 val pending = goAsync()
4343 val finished = AtomicBoolean(false)
44− val finishOnce = { if (finished.compareAndSet(false, true)) pending.finish() }
45− watchdog(finishOnce)
44+ var watchdogThread: Thread? = null
45+ val finishOnce = {
46+ if (finished.compareAndSet(false, true)) {
47+ watchdogThread?.interrupt()
48+ pending.finish()
49+ }
50+ }
51+ watchdogThread = watchdog(finishOnce)
4652 container.respondToPermissionFromNotification(sessionId, permissionId, response, profileId) { success ->
4753 // Only dismiss the notification once the tool was actually answered. If
4854 // there was no live connection (respond is a no-op) or the call failed,
@@ -66,8 +72,14 @@ class NotificationActionReceiver : BroadcastReceiver() {
6672 val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
6773 val pending = goAsync()
6874 val finished = AtomicBoolean(false)
69− val finishOnce = { if (finished.compareAndSet(false, true)) pending.finish() }
70− watchdog(finishOnce)
75+ var watchdogThread: Thread? = null
76+ val finishOnce = {
77+ if (finished.compareAndSet(false, true)) {
78+ watchdogThread?.interrupt()
79+ pending.finish()
80+ }
81+ }
82+ watchdogThread = watchdog(finishOnce)
7183 container.sendPromptFromNotification(sessionId, text, profileId) { enqueued ->
7284 // The reply is durably queued (and flushed now if online); clear the
7385 // "session ready" notification. If the enqueue failed, cancel it anyway and
@@ -77,7 +89,7 @@ class NotificationActionReceiver : BroadcastReceiver() {
7789 if (enqueued) {
7890 SessionNotifications.cancel(context, sessionId)
7991 } else {
80− SessionNotifications.postReplyFailed(context, sessionId)
92+ SessionNotifications.postReplyFailed(context, sessionId, profileId)
8193 }
8294 finishOnce()
8395 }
@@ -87,8 +99,10 @@ class NotificationActionReceiver : BroadcastReceiver() {
8799
88100 /** Schedule a deadline after which the receiver's [PendingResult] is finished even if
89101 * the network call hasn't resolved, so the system can't ANR the receiver. The
90− * underlying work continues on the app scope. */
91− private fun watchdog(finishOnce: () -> Unit) {
102+ * underlying work continues on the app scope. Returns the watchdog thread so the
103+ * caller can [interrupt][Thread.interrupt] it when work completes early, releasing
104+ * the sleeping thread immediately instead of leaving it for the full timeout. */
105+ private fun watchdog(finishOnce: () -> Unit): Thread =
92106 Thread {
93107 try {
94108 Thread.sleep(NetworkConfig.notificationReceiverTimeoutMs)
@@ -97,7 +111,6 @@ class NotificationActionReceiver : BroadcastReceiver() {
97111 }
98112 finishOnce()
99113 }.apply { isDaemon = true; name = "notif-receiver-watchdog"; start() }
100− }
101114
102115 companion object {
103116 const val ACTION_PERMISSION = "soy.iko.opencode.action.PERMISSION"
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+14−8
@@ -76,12 +76,18 @@ object SessionNotifications {
7676 * /, ?, # can't inject path segments or query parameters. */
7777 private fun safeId(sessionId: String): String = notifIdRegex.replace(sessionId, "")
7878
79− /** A PendingIntent that opens [sessionId] in the app when the notification body is tapped. */
80− private fun openSessionIntent(context: Context, sessionId: String, requestCode: Int): PendingIntent {
79+ /** A PendingIntent that opens [sessionId] in the app when the notification body is tapped.
80+ * [profileId] (the originating server) is embedded so a tap after the user has switched
81+ * servers routes back to the server that ran the session, not whichever is active —
82+ * mirroring the Reply and permission action intents. */
83+ private fun openSessionIntent(
84+ context: Context, sessionId: String, requestCode: Int, profileId: String?,
85+ ): PendingIntent {
8186 val openIntent = Intent(context, MainActivity::class.java).apply {
8287 action = Intent.ACTION_VIEW
8388 data = android.net.Uri.parse("opencode://session/${safeId(sessionId)}")
8489 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
90+ profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
8591 }
8692 return PendingIntent.getActivity(
8793 context, requestCode, openIntent,
@@ -127,7 +133,7 @@ object SessionNotifications {
127133 // literal doubled percent signs (e.g. a "50% done" title as "50%% done").
128134 .setContentText(context.getString(R.string.notif_completed_text, title))
129135 .setAutoCancel(true)
130− .setContentIntent(openSessionIntent(context, sessionId, notifId))
136+ .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
131137 .addAction(replyAction)
132138 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
133139 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
@@ -158,7 +164,7 @@ object SessionNotifications {
158164 .setContentText(detail)
159165 .setStyle(NotificationCompat.BigTextStyle().bigText(detail))
160166 .setAutoCancel(true)
161− .setContentIntent(openSessionIntent(context, sessionId, notifId))
167+ .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
162168 .setCategory(NotificationCompat.CATEGORY_CALL)
163169 .setPriority(NotificationCompat.PRIORITY_HIGH)
164170
@@ -189,7 +195,7 @@ object SessionNotifications {
189195
190196 /** Post a notification when a background run fails. */
191197 @SuppressLint("MissingPermission")
192− fun postError(context: Context, sessionId: String, title: String) {
198+ fun postError(context: Context, sessionId: String, title: String, profileId: String? = null) {
193199 if (!canPost(context)) return
194200 val notifId = notifId(NS_ERROR, sessionId)
195201 val notification = NotificationCompat.Builder(context, NotificationChannels.ERROR)
@@ -197,7 +203,7 @@ object SessionNotifications {
197203 .setContentTitle(context.getString(R.string.notif_error_title))
198204 .setContentText(context.getString(R.string.notif_error_text, title))
199205 .setAutoCancel(true)
200− .setContentIntent(openSessionIntent(context, sessionId, notifId))
206+ .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
201207 .setCategory(NotificationCompat.CATEGORY_ERROR)
202208 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
203209 .build()
@@ -210,7 +216,7 @@ object SessionNotifications {
210216 * stuck in the "sending…" spinner — a stuck field can't be resubmitted — and post an error
211217 * notification so the reply isn't silently lost and the user can reopen the session to retry. */
212218 @SuppressLint("MissingPermission")
213− fun postReplyFailed(context: Context, sessionId: String) {
219+ fun postReplyFailed(context: Context, sessionId: String, profileId: String? = null) {
214220 NotificationManagerCompat.from(context).cancel(notifId(NS_COMPLETED, sessionId))
215221 if (!canPost(context)) return
216222 val notifId = notifId(NS_ERROR, sessionId)
@@ -219,7 +225,7 @@ object SessionNotifications {
219225 .setContentTitle(context.getString(R.string.notif_reply_failed_title))
220226 .setContentText(context.getString(R.string.notif_reply_failed_text))
221227 .setAutoCancel(true)
222− .setContentIntent(openSessionIntent(context, sessionId, notifId))
228+ .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
223229 .setCategory(NotificationCompat.CATEGORY_ERROR)
224230 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
225231 .build()
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+15−6
@@ -100,11 +100,12 @@ fun OpencodeApp(container: AppContainer) {
100100 }
101101
102102 // "New session" from a launcher shortcut / QS tile: once connected, create a fresh
103− // session and open it. The pending flag is consumed (compareAndSet) only *after*
104− // createSession succeeds, so a transient failure leaves the request pending to retry
105− // (on the next connection change / trigger) instead of silently dropping the tap.
103+ // session and open it. The pending counter is consumed only *after* createSession
104+ // succeeds, so a transient failure leaves the request pending to retry (on the next
105+ // connection change or a re-tap that increments the counter) instead of silently
106+ // dropping the tap.
106107 LaunchedEffect(pendingNewSession, connection) {
107− if (!pendingNewSession) return@LaunchedEffect
108+ if (pendingNewSession == 0) return@LaunchedEffect
108109 val conn = connection ?: return@LaunchedEffect
109110 val session = runCatchingCancellable { conn.repository.createSession() }.getOrNull()
110111 ?: return@LaunchedEffect
@@ -126,8 +127,16 @@ fun OpencodeApp(container: AppContainer) {
126127
127128 // Open a session requested by a notification tap or deep link, once connected.
128129 LaunchedEffect(pendingOpenSession, connection, isTwoPane) {
129− val id = pendingOpenSession ?: return@LaunchedEffect
130+ val pending = pendingOpenSession ?: return@LaunchedEffect
130131 if (connection == null) return@LaunchedEffect
132+ // If the notification body-tap carries a profile id from a different server,
133+ // switch to it first so the session opens under the server that ran it, not
134+ // whichever is active. The effect re-runs when connection changes (keyed on it),
135+ // so on the second run the profile matches and the open proceeds.
136+ if (pending.profileId != null && connection?.profile?.id != pending.profileId) {
137+ if (!container.connectByProfileId(pending.profileId)) return@LaunchedEffect
138+ return@LaunchedEffect // re-run on the new connection
139+ }
131140 // In two-pane mode the detail pane (TwoPaneSessionChat, hosted on the SESSIONS
132141 // route) consumes the request and opens it in the detail pane. But it only
133142 // exists while the NavHost is on SESSIONS, so if the user is currently on
@@ -148,7 +157,7 @@ fun OpencodeApp(container: AppContainer) {
148157 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
149158 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
150159 }
151− navController.navigate(Routes.chat(id)) { launchSingleTop = true }
160+ navController.navigate(Routes.chat(pending.sessionId)) { launchSingleTop = true }
152161 }
153162
154163 NavHost(
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+2−1
@@ -1276,8 +1276,9 @@ class ChatViewModel(
12761276 .onSuccess {
12771277 container.draftStore.remove(sessionId)
12781278 container.attachmentDraftStore.remove(sessionId)
1279− container.messageCacheStore.remove(sessionId)
1279+ container.messageCacheStore.remove(conn.profile.id, sessionId)
12801280 container.outboxStore.removeForSession(sessionId)
1281+ container.clearUnread(sessionId)
12811282 _sessionDeleted.value = true
12821283 }
12831284 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+7−3
@@ -151,8 +151,10 @@ private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modi
151151 // Key the expanded state on the part's stable id so a new ReasoningPart doesn't
152152 // inherit the positional saveable key of a prior block. A content-prefix key would
153153 // change on nearly every token while streaming and snap the block back to collapsed;
154− // fall back to a content prefix only when the part has no id.
155− val saveableKey = keyId.ifBlank { text.take(64) }
154+ // when the part has no id, use a constant — the key(part.id, index) wrapper at the
155+ // call site already disambiguates sibling parts positionally, so a constant per
156+ // block type won't collide.
157+ val saveableKey = keyId.ifBlank { "reasoning" }
156158 var expanded by rememberSaveable(saveableKey) { mutableStateOf(false) }
157159 val expandedState = stringResource(R.string.state_expanded)
158160 val collapsedState = stringResource(R.string.state_collapsed)
@@ -310,6 +312,7 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
310312 expandedState = expandedState,
311313 collapsedState = collapsedState,
312314 onCopy = { copyToClipboard(context, "output", detail) },
315+ diffSaveKey = part.id,
313316 )
314317 }
315318 }
@@ -333,6 +336,7 @@ private fun CollapsibleDetail(
333336 expandedState: String = "",
334337 collapsedState: String = "",
335338 onCopy: (() -> Unit)? = null,
339+ diffSaveKey: String? = null,
336340) {
337341 Column(modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
338342 if (label != null) {
@@ -361,7 +365,7 @@ private fun CollapsibleDetail(
361365 if (!inputExpanded) return@Column
362366 }
363367 if (isDiff) {
364− DiffView(detail)
368+ DiffView(detail, saveKey = diffSaveKey)
365369 } else {
366370 // SelectionContainer so the user can select a portion of the output (e.g.
367371 // a single line of stdout) instead of only copy-all via the button below.
app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+5−2
@@ -123,7 +123,7 @@ private const val COLLAPSED_DIFF_LINES = 200
123123 * expands the view.
124124 */
125125@Composable
126−fun DiffView(diff: String, modifier: Modifier = Modifier) {
126+fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = null) {
127127 val lines = remember(diff) { parseDiff(diff) }
128128 // Derive the four row colors once per color scheme instead of allocating fresh Color
129129 // objects (the .copy() calls) on every recomposition — a diff re-parses/recomposes
@@ -135,7 +135,10 @@ fun DiffView(diff: String, modifier: Modifier = Modifier) {
135135 val removeText = scheme.error
136136 val context = LocalContext.current
137137 val hScrollState = rememberScrollState()
138− var expanded by rememberSaveable(diff) { mutableStateOf(false) }
138+ // Key on a stable id (the part id) when available so streaming updates that grow `diff`
139+ // don't reset expand state on every token. Fall back to `diff` for non-streaming callers
140+ // (e.g. FileViewScreen) where the content is static.
141+ var expanded by rememberSaveable(saveKey ?: diff) { mutableStateOf(false) }
139142
140143 Column(
141144 modifier = modifier
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+6−1
@@ -78,7 +78,12 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
7878 // called once from init with a null api and never retried.
7979 viewModelScope.launch {
8080 container.activeConnection.collectLatest { conn ->
81− if (conn == null) return@collectLatest
81+ if (conn == null) {
82+ // Cancel the in-flight VCS status load so it can't write stale badges
83+ // from the old server after the connection drops.
84+ statusJob?.cancel()
85+ return@collectLatest
86+ }
8287 loadStatus()
8388 // Re-open the current directory when a connection becomes available
8489 // so the initial open("") (which may have run before auto-reconnect
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+6−1
@@ -233,6 +233,9 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
233233 /** Pin or unpin [session] (pinned sessions sort to the top of the list). */
234234 fun togglePin(session: Session) {
235235 val pin = session.id !in _state.value.pinnedIds
236+ // Optimistically update the displayed state so rapid taps toggle correctly instead
237+ // of both reading the same stale set (pre-observer-round-trip) and double-pinning.
238+ _state.update { it.copy(pinnedIds = if (pin) it.pinnedIds + session.id else it.pinnedIds - session.id) }
236239 viewModelScope.launch {
237240 runCatchingCancellable { container.sessionPrefsStore.setPinned(session.id, pin) }
238241 .onFailure { _transientErrors.tryEmit(container.friendlyError(it)) }
@@ -242,6 +245,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
242245 /** Archive or unarchive [session] (archived sessions are hidden unless "show archived"). */
243246 fun toggleArchive(session: Session) {
244247 val archive = session.id !in _state.value.archivedIds
248+ _state.update { it.copy(archivedIds = if (archive) it.archivedIds + session.id else it.archivedIds - session.id) }
245249 viewModelScope.launch {
246250 runCatchingCancellable { container.sessionPrefsStore.setArchived(session.id, archive) }
247251 .onFailure { _transientErrors.tryEmit(container.friendlyError(it)) }
@@ -337,8 +341,9 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
337341 // the loop existed — killing live updates for good).
338342 runCatchingCancellable { container.draftStore.remove(id) }
339343 runCatchingCancellable { container.attachmentDraftStore.remove(id) }
340− runCatchingCancellable { container.messageCacheStore.remove(id) }
344+ runCatchingCancellable { container.messageCacheStore.remove(conn.profile.id, id) }
341345 runCatchingCancellable { container.sessionPrefsStore.forget(id) }
346+ container.clearUnread(id)
342347 _state.update { s ->
343348 s.copy(
344349 sessions = s.sessions.filterNot { it.id == id },
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+4−1
@@ -64,9 +64,12 @@ fun TwoPaneSessionChat(
6464 val sessionListState by sessionListVm.state.collectAsStateWithLifecycle()
6565
6666 // A notification tap / deep link requests a session: open it in the detail pane.
67+ // The connection switch (if the pending session is from a different server) is handled
68+ // by the LaunchedEffect in OpencodeApp before we get here, so by the time this runs
69+ // the active connection already matches.
6770 LaunchedEffect(pendingOpenSession) {
6871 pendingOpenSession?.let {
69− selected = it
72+ selected = it.sessionId
7073 container.consumePendingOpenSession()
7174 }
7275 }
app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+6−1
@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.StateFlow
99import kotlinx.coroutines.flow.asSharedFlow
1010import kotlinx.coroutines.flow.asStateFlow
1111import kotlinx.coroutines.flow.flowOf
12+import kotlinx.coroutines.flow.update
1213import soy.iko.opencode.data.model.Agent
1314import soy.iko.opencode.data.model.BusEvent
1415import soy.iko.opencode.data.model.Command
@@ -378,7 +379,7 @@ class FakeMessageCacheStore : MessageCacheStore() {
378379 if (messages.isEmpty()) store.remove(sessionId) else store[sessionId] = messages
379380 }
380381
381− override suspend fun remove(sessionId: String) { store.remove(sessionId) }
382+ override suspend fun remove(profileId: String, sessionId: String) { store.remove(sessionId) }
382383}
383384
384385// ---------------------------------------------------------------------------
@@ -423,6 +424,10 @@ class FakeAppContainer : AppContainer() {
423424 var probeWithCredentialsCalls: List<Triple<String, String, String>> = emptyList()
424425 private set
425426
427+ override fun clearUnread(id: String) {
428+ fakeUnread.update { it - id }
429+ }
430+
426431 override fun string(id: Int, vararg formatArgs: Any): String = "test-string-$id"
427432
428433 override fun friendlyError(t: Throwable): String = "test-error: ${t.message ?: t::class.simpleName}"