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
ef2f579iko
app/src/main/java/soy/iko/opencode/MainActivity.kt+5−2
@@ -9,6 +9,7 @@ import android.os.Bundle
9import android.view.WindowManager
9import android.view.WindowManager
10import androidx.core.content.IntentCompat
10import androidx.core.content.IntentCompat
11import soy.iko.opencode.data.network.NetworkConfig
11import soy.iko.opencode.data.network.NetworkConfig
12+import soy.iko.opencode.notification.NotificationActionReceiver
12import androidx.activity.compose.setContent
13import androidx.activity.compose.setContent
13import androidx.activity.enableEdgeToEdge
14import androidx.activity.enableEdgeToEdge
14import androidx.activity.result.contract.ActivityResultContracts
15import androidx.activity.result.contract.ActivityResultContracts
@@ -187,7 +188,9 @@ class MainActivity : FragmentActivity() {
187 // this flag (mirroring shareIntentHandled) the session would re-open unexpectedly.
188 // this flag (mirroring shareIntentHandled) the session would re-open unexpectedly.
188 if (!openSessionHandled) {
189 if (!openSessionHandled) {
189 id?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
190 id?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
191+ val profileId = intent?.getStringExtra(NotificationActionReceiver.EXTRA_PROFILE_ID)
192+ ?.takeIf { it.isNotBlank() }
190− container.requestOpenSession(it)
193+ container.requestOpenSession(it, profileId)
191 openSessionHandled = true
194 openSessionHandled = true
192 }
195 }
193 }
196 }
@@ -221,7 +224,7 @@ class MainActivity : FragmentActivity() {
221 // the re-delivered intent re-dispatches it into the new container on restore; already
224 // the re-delivered intent re-dispatches it into the new container on restore; already
222 // consumed → persist true so we don't re-open it unexpectedly on every later restore.
225 // consumed → persist true so we don't re-open it unexpectedly on every later restore.
223 val openConsumed = container.pendingOpenSession.value == null
226 val openConsumed = container.pendingOpenSession.value == null
224− val newConsumed = !container.pendingNewSession.value
227+ val newConsumed = container.pendingNewSession.value == 0
225 val shareConsumed = container.pendingShare.value == null && container.pendingSharedMedia.value.isEmpty()
228 val shareConsumed = container.pendingShare.value == null && container.pendingSharedMedia.value.isEmpty()
226 outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled && shareConsumed)
229 outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled && shareConsumed)
227 outState.putBoolean(KEY_OPEN_SESSION_HANDLED, openSessionHandled && openConsumed)
230 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(
60 * remove(): both serialize on [lockFor], but their relative order is nondeterministic.
60 * remove(): both serialize on [lockFor], but their relative order is nondeterministic.
61 * If the flush's save lands after remove, it re-creates the deleted session's cache
61 * If the flush's save lands after remove, it re-creates the deleted session's cache
62 * file, defeating the deletion (a privacy/disk-leak with no background cleanup). The
62 * 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
63+ * tombstone lets a post-remove save detect the deletion and skip. Keyed by
64− * re-opening a session (which won't happen for a truly deleted UUID, but is safe)
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
65− * re-enables saving. Bounded by distinct deleted sessions, each a few bytes. */
67+ * re-opening a session re-enables saving. Bounded by distinct deleted sessions. */
66 private val tombstones: MutableSet<String> =
68 private val tombstones: MutableSet<String> =
67 java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap())
69 java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap())
68
70
71+ private fun tombstoneKey(profileId: String, sessionId: String): String = "$profileId/$sessionId"
72+
69 /** Load the cached messages for [sessionId] under [profileId], or empty if none / unreadable. */
73 /** Load the cached messages for [sessionId] under [profileId], or empty if none / unreadable. */
70 open suspend fun load(profileId: String, sessionId: String): List<MessageWithParts> = lockFor(sessionId).withLock {
74 open suspend fun load(profileId: String, sessionId: String): List<MessageWithParts> = lockFor(sessionId).withLock {
71 // Re-opening a session re-enables saving: clear any tombstone so a fresh conversation
75 // Re-opening a session re-enables saving: clear any tombstone so a fresh conversation
72 // write isn't suppressed by a prior deletion of the same (reused) id.
76 // write isn't suppressed by a prior deletion of the same (reused) id.
73− tombstones.remove(sessionId)
77+ tombstones.remove(tombstoneKey(profileId, sessionId))
74 withContext(Dispatchers.IO) {
78 withContext(Dispatchers.IO) {
75 val file = fileFor(profileId, sessionId)?.takeIf { it.exists() } ?: return@withContext emptyList()
79 val file = fileFor(profileId, sessionId)?.takeIf { it.exists() } ?: return@withContext emptyList()
76 runCatchingCancellable { OpencodeJson.decodeFromString(serializer, file.readText()) }
80 runCatchingCancellable { OpencodeJson.decodeFromString(serializer, file.readText()) }
@@ -83,7 +87,7 @@ open class MessageCacheStore private constructor(
83 // Skip if this session was deleted since the observer started: a teardown flush racing
87 // Skip if this session was deleted since the observer started: a teardown flush racing
84 // after remove() must not re-create the file. (Held under the same per-session lock as
88 // after remove() must not re-create the file. (Held under the same per-session lock as
85 // remove, so the tombstone is visible to a save that lost the race.)
89 // 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
87 withContext(Dispatchers.IO) {
91 withContext(Dispatchers.IO) {
88 val file = fileFor(profileId, sessionId) ?: return@withContext
92 val file = fileFor(profileId, sessionId) ?: return@withContext
89 runCatchingCancellable {
93 runCatchingCancellable {
@@ -109,12 +113,14 @@ open class MessageCacheStore private constructor(
109 * namespace: session ids are unique per server and deletion is terminal, so the caller
113 * namespace: session ids are unique per server and deletion is terminal, so the caller
110 * needn't know which profile owned it. The per-session lock is intentionally NOT evicted —
114 * needn't know which profile owned it. The per-session lock is intentionally NOT evicted —
111 * a concurrent teardown save() for this id must serialize against the same [Mutex] instance,
115 * 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. */
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. */
113− open suspend fun remove(sessionId: String) {
119+ open suspend fun remove(profileId: String, sessionId: String) {
114 lockFor(sessionId).withLock {
120 lockFor(sessionId).withLock {
115 // Set the tombstone before deleting so a concurrent/loser save sees it and skips,
121 // Set the tombstone before deleting so a concurrent/loser save sees it and skips,
116 // instead of re-creating the file we're about to delete.
122 // instead of re-creating the file we're about to delete.
117− tombstones.add(sessionId)
123+ tombstones.add(tombstoneKey(profileId, sessionId))
118 withContext(Dispatchers.IO) {
124 withContext(Dispatchers.IO) {
119 runCatchingCancellable {
125 runCatchingCancellable {
120 val name = idRegex.replace(sessionId, "_") + ".json"
126 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(
154
154
155 /**
155 /**
156 * Set from an external "New session" trigger (launcher shortcut / QS tile). The nav host
156 * 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.
158 */
161 */
159− private val _pendingNewSession = MutableStateFlow(false)
162+ private val _pendingNewSession = MutableStateFlow(0)
160− open val pendingNewSession: StateFlow<Boolean> = _pendingNewSession.asStateFlow()
163+ open val pendingNewSession: StateFlow<Int> = _pendingNewSession.asStateFlow()
161− open fun requestNewSession() { _pendingNewSession.value = true }
164+ open fun requestNewSession() { _pendingNewSession.update { it + 1 } }
162− open fun consumePendingNewSession(): Boolean = _pendingNewSession.compareAndSet(true, false)
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+ }
163
172
164 /**
173 /**
165− * A session id to open from an external trigger (a notification tap or a deep link).
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
166− * The nav host consumes it once a connection is active.
177+ * consumes it once a connection is active (switching to [profileId] first if needed).
167 */
178 */
179+ data class PendingOpenSession(val sessionId: String, val profileId: String?)
180+
168− private val _pendingOpenSession = MutableStateFlow<String?>(null)
181+ private val _pendingOpenSession = MutableStateFlow<PendingOpenSession?>(null)
169− open val pendingOpenSession: StateFlow<String?> = _pendingOpenSession.asStateFlow()
182+ open val pendingOpenSession: StateFlow<PendingOpenSession?> = _pendingOpenSession.asStateFlow()
170− open fun requestOpenSession(id: String) { _pendingOpenSession.value = id }
183+ open fun requestOpenSession(id: String, profileId: String? = null) {
184+ _pendingOpenSession.value = PendingOpenSession(id, profileId)
185+ }
171− open fun consumePendingOpenSession(): String? {
186+ open fun consumePendingOpenSession(): PendingOpenSession? {
172 val current = _pendingOpenSession.value ?: return null
187 val current = _pendingOpenSession.value ?: return null
173 return if (_pendingOpenSession.compareAndSet(current, null)) current else null
188 return if (_pendingOpenSession.compareAndSet(current, null)) current else null
174 }
189 }
@@ -228,6 +243,13 @@ open class AppContainer private constructor(
228 }
243 }
229 }
244 }
230
245
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+
231 /** Restore a session's unread badge after a failed server switch reconnects.
253 /** Restore a session's unread badge after a failed server switch reconnects.
232 * Preserves the prior [count] so a session badged with "5 unread" before the
254 * Preserves the prior [count] so a session badged with "5 unread" before the
233 * switch attempt still shows 5 (not 1) after the restore. No-op for the session
255 * switch attempt still shows 5 (not 1) after the restore. No-op for the session
@@ -488,7 +510,7 @@ open class AppContainer private constructor(
488 }
510 }
489 }
511 }
490 if (wasRunning && !isActivelyViewing(esid)) {
512 if (wasRunning && !isActivelyViewing(esid)) {
491− appScope.launch { notifySessionError(esid, conn.repository) }
513+ appScope.launch { notifySessionError(esid, conn.repository, conn.profile.id) }
492 }
514 }
493 return@collect
515 return@collect
494 }
516 }
@@ -649,12 +671,14 @@ open class AppContainer private constructor(
649 appContext?.let { SessionNotifications.postPermission(it, permission, title, originConn.profile.id) }
671 appContext?.let { SessionNotifications.postPermission(it, permission, title, originConn.profile.id) }
650 }
672 }
651
673
652− /** Post an error notification for a failed background run. */
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]. */
653− private suspend fun notifySessionError(sessionId: String, originRepo: SessionRepository) {
677+ private suspend fun notifySessionError(sessionId: String, originRepo: SessionRepository, originProfileId: String) {
654 val title = resolveSessionTitle(sessionId, originRepo)
678 val title = resolveSessionTitle(sessionId, originRepo)
655 // Re-check viewing just before posting — see notifySessionCompleted for the race.
679 // Re-check viewing just before posting — see notifySessionCompleted for the race.
656 if (isActivelyViewing(sessionId)) return
680 if (isActivelyViewing(sessionId)) return
657− appContext?.let { SessionNotifications.postError(it, sessionId, title) }
681+ appContext?.let { SessionNotifications.postError(it, sessionId, title, originProfileId) }
658 }
682 }
659
683
660 /** Respond to a permission from a notification action (Allow once / Always / Reject),
684 /** Respond to a permission from a notification action (Allow once / Always / Reject),
@@ -769,16 +793,22 @@ open class AppContainer private constructor(
769 // teardown by identity.)
793 // teardown by identity.)
770 _reconnecting.value = true
794 _reconnecting.value = true
771 var conn: OpencodeConnection? = null
795 var conn: OpencodeConnection? = null
796+ try {
772− val ok = runCatchingCancellable {
797+ val ok = runCatchingCancellable {
773− conn = connectIfIdle(recent) ?: return@runCatchingCancellable false
798+ conn = connectIfIdle(recent) ?: return@runCatchingCancellable false
774− conn!!.api.ping()
799+ conn!!.api.ping()
775− true
800+ true
776− }.getOrDefault(false)
801+ }.getOrDefault(false)
777− _reconnecting.value = false
778− when {
802+ when {
779− conn == null -> Unit // slot already taken by a manual connect — leave it be
803+ conn == null -> Unit // slot already taken by a manual connect — leave it be
780− ok -> _autoConnectDone.value = true
804+ ok -> _autoConnectDone.value = true
781− else -> disconnectIf(conn!!)
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
782 }
812 }
783 }
813 }
784
814
@@ -830,6 +860,14 @@ open class AppContainer private constructor(
830 open suspend fun connect(profile: ServerProfile): OpencodeConnection =
860 open suspend fun connect(profile: ServerProfile): OpencodeConnection =
831 connectionMutex.withLock { connectLocked(profile) }
861 connectionMutex.withLock { connectLocked(profile) }
832
862
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+
833 /** Connect to [profile] only if nothing is currently active, checking-and-connecting
871 /** Connect to [profile] only if nothing is currently active, checking-and-connecting
834 * atomically under [connectionMutex]. Returns null (touching nothing) when a connection is
872 * atomically under [connectionMutex]. Returns null (touching nothing) when a connection is
835 * already active, so autoConnect() can't clobber a manual connect() the user made during its
873 * already active, so autoConnect() can't clobber a manual connect() the user made during its
@@ -985,8 +1023,9 @@ open class AppContainer private constructor(
985 draftStore.remove(id)
1023 draftStore.remove(id)
986 attachmentDraftStore.remove(id)
1024 attachmentDraftStore.remove(id)
987 sessionPrefsStore.forget(id)
1025 sessionPrefsStore.forget(id)
988− messageCacheStore.remove(id)
1026+ messageCacheStore.remove(conn.profile.id, id)
989 outboxStore.removeForSession(id)
1027 outboxStore.removeForSession(id)
1028+ clearUnread(id)
990 onDeleted()
1029 onDeleted()
991 }
1030 }
992 .onFailure { onError(it) }
1031 .onFailure { onError(it) }
app/src/main/java/soy/iko/opencode/notification/NotificationActionReceiver.kt+21−8
@@ -41,8 +41,14 @@ class NotificationActionReceiver : BroadcastReceiver() {
41 } ?: return
41 } ?: return
42 val pending = goAsync()
42 val pending = goAsync()
43 val finished = AtomicBoolean(false)
43 val finished = AtomicBoolean(false)
44+ var watchdogThread: Thread? = null
45+ val finishOnce = {
44− val finishOnce = { if (finished.compareAndSet(false, true)) pending.finish() }
46+ if (finished.compareAndSet(false, true)) {
47+ watchdogThread?.interrupt()
48+ pending.finish()
49+ }
50+ }
45− watchdog(finishOnce)
51+ watchdogThread = watchdog(finishOnce)
46 container.respondToPermissionFromNotification(sessionId, permissionId, response, profileId) { success ->
52 container.respondToPermissionFromNotification(sessionId, permissionId, response, profileId) { success ->
47 // Only dismiss the notification once the tool was actually answered. If
53 // Only dismiss the notification once the tool was actually answered. If
48 // there was no live connection (respond is a no-op) or the call failed,
54 // there was no live connection (respond is a no-op) or the call failed,
@@ -66,8 +72,14 @@ class NotificationActionReceiver : BroadcastReceiver() {
66 val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
72 val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
67 val pending = goAsync()
73 val pending = goAsync()
68 val finished = AtomicBoolean(false)
74 val finished = AtomicBoolean(false)
75+ var watchdogThread: Thread? = null
76+ val finishOnce = {
69− val finishOnce = { if (finished.compareAndSet(false, true)) pending.finish() }
77+ if (finished.compareAndSet(false, true)) {
78+ watchdogThread?.interrupt()
79+ pending.finish()
80+ }
81+ }
70− watchdog(finishOnce)
82+ watchdogThread = watchdog(finishOnce)
71 container.sendPromptFromNotification(sessionId, text, profileId) { enqueued ->
83 container.sendPromptFromNotification(sessionId, text, profileId) { enqueued ->
72 // The reply is durably queued (and flushed now if online); clear the
84 // The reply is durably queued (and flushed now if online); clear the
73 // "session ready" notification. If the enqueue failed, cancel it anyway and
85 // "session ready" notification. If the enqueue failed, cancel it anyway and
@@ -77,7 +89,7 @@ class NotificationActionReceiver : BroadcastReceiver() {
77 if (enqueued) {
89 if (enqueued) {
78 SessionNotifications.cancel(context, sessionId)
90 SessionNotifications.cancel(context, sessionId)
79 } else {
91 } else {
80− SessionNotifications.postReplyFailed(context, sessionId)
92+ SessionNotifications.postReplyFailed(context, sessionId, profileId)
81 }
93 }
82 finishOnce()
94 finishOnce()
83 }
95 }
@@ -87,8 +99,10 @@ class NotificationActionReceiver : BroadcastReceiver() {
87
99
88 /** Schedule a deadline after which the receiver's [PendingResult] is finished even if
100 /** Schedule a deadline after which the receiver's [PendingResult] is finished even if
89 * the network call hasn't resolved, so the system can't ANR the receiver. The
101 * the network call hasn't resolved, so the system can't ANR the receiver. The
90− * underlying work continues on the app scope. */
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. */
91− private fun watchdog(finishOnce: () -> Unit) {
105+ private fun watchdog(finishOnce: () -> Unit): Thread =
92 Thread {
106 Thread {
93 try {
107 try {
94 Thread.sleep(NetworkConfig.notificationReceiverTimeoutMs)
108 Thread.sleep(NetworkConfig.notificationReceiverTimeoutMs)
@@ -97,7 +111,6 @@ class NotificationActionReceiver : BroadcastReceiver() {
97 }
111 }
98 finishOnce()
112 finishOnce()
99 }.apply { isDaemon = true; name = "notif-receiver-watchdog"; start() }
113 }.apply { isDaemon = true; name = "notif-receiver-watchdog"; start() }
100− }
101
114
102 companion object {
115 companion object {
103 const val ACTION_PERMISSION = "soy.iko.opencode.action.PERMISSION"
116 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 {
76 * /, ?, # can't inject path segments or query parameters. */
76 * /, ?, # can't inject path segments or query parameters. */
77 private fun safeId(sessionId: String): String = notifIdRegex.replace(sessionId, "")
77 private fun safeId(sessionId: String): String = notifIdRegex.replace(sessionId, "")
78
78
79− /** A PendingIntent that opens [sessionId] in the app when the notification body is tapped. */
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(
80− private fun openSessionIntent(context: Context, sessionId: String, requestCode: Int): PendingIntent {
84+ context: Context, sessionId: String, requestCode: Int, profileId: String?,
85+ ): PendingIntent {
81 val openIntent = Intent(context, MainActivity::class.java).apply {
86 val openIntent = Intent(context, MainActivity::class.java).apply {
82 action = Intent.ACTION_VIEW
87 action = Intent.ACTION_VIEW
83 data = android.net.Uri.parse("opencode://session/${safeId(sessionId)}")
88 data = android.net.Uri.parse("opencode://session/${safeId(sessionId)}")
84 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
89 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
90+ profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
85 }
91 }
86 return PendingIntent.getActivity(
92 return PendingIntent.getActivity(
87 context, requestCode, openIntent,
93 context, requestCode, openIntent,
@@ -127,7 +133,7 @@ object SessionNotifications {
127 // literal doubled percent signs (e.g. a "50% done" title as "50%% done").
133 // literal doubled percent signs (e.g. a "50% done" title as "50%% done").
128 .setContentText(context.getString(R.string.notif_completed_text, title))
134 .setContentText(context.getString(R.string.notif_completed_text, title))
129 .setAutoCancel(true)
135 .setAutoCancel(true)
130− .setContentIntent(openSessionIntent(context, sessionId, notifId))
136+ .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
131 .addAction(replyAction)
137 .addAction(replyAction)
132 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
138 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
133 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
139 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
@@ -158,7 +164,7 @@ object SessionNotifications {
158 .setContentText(detail)
164 .setContentText(detail)
159 .setStyle(NotificationCompat.BigTextStyle().bigText(detail))
165 .setStyle(NotificationCompat.BigTextStyle().bigText(detail))
160 .setAutoCancel(true)
166 .setAutoCancel(true)
161− .setContentIntent(openSessionIntent(context, sessionId, notifId))
167+ .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
162 .setCategory(NotificationCompat.CATEGORY_CALL)
168 .setCategory(NotificationCompat.CATEGORY_CALL)
163 .setPriority(NotificationCompat.PRIORITY_HIGH)
169 .setPriority(NotificationCompat.PRIORITY_HIGH)
164
170
@@ -189,7 +195,7 @@ object SessionNotifications {
189
195
190 /** Post a notification when a background run fails. */
196 /** Post a notification when a background run fails. */
191 @SuppressLint("MissingPermission")
197 @SuppressLint("MissingPermission")
192− fun postError(context: Context, sessionId: String, title: String) {
198+ fun postError(context: Context, sessionId: String, title: String, profileId: String? = null) {
193 if (!canPost(context)) return
199 if (!canPost(context)) return
194 val notifId = notifId(NS_ERROR, sessionId)
200 val notifId = notifId(NS_ERROR, sessionId)
195 val notification = NotificationCompat.Builder(context, NotificationChannels.ERROR)
201 val notification = NotificationCompat.Builder(context, NotificationChannels.ERROR)
@@ -197,7 +203,7 @@ object SessionNotifications {
197 .setContentTitle(context.getString(R.string.notif_error_title))
203 .setContentTitle(context.getString(R.string.notif_error_title))
198 .setContentText(context.getString(R.string.notif_error_text, title))
204 .setContentText(context.getString(R.string.notif_error_text, title))
199 .setAutoCancel(true)
205 .setAutoCancel(true)
200− .setContentIntent(openSessionIntent(context, sessionId, notifId))
206+ .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
201 .setCategory(NotificationCompat.CATEGORY_ERROR)
207 .setCategory(NotificationCompat.CATEGORY_ERROR)
202 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
208 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
203 .build()
209 .build()
@@ -210,7 +216,7 @@ object SessionNotifications {
210 * stuck in the "sending…" spinner — a stuck field can't be resubmitted — and post an error
216 * stuck in the "sending…" spinner — a stuck field can't be resubmitted — and post an error
211 * notification so the reply isn't silently lost and the user can reopen the session to retry. */
217 * notification so the reply isn't silently lost and the user can reopen the session to retry. */
212 @SuppressLint("MissingPermission")
218 @SuppressLint("MissingPermission")
213− fun postReplyFailed(context: Context, sessionId: String) {
219+ fun postReplyFailed(context: Context, sessionId: String, profileId: String? = null) {
214 NotificationManagerCompat.from(context).cancel(notifId(NS_COMPLETED, sessionId))
220 NotificationManagerCompat.from(context).cancel(notifId(NS_COMPLETED, sessionId))
215 if (!canPost(context)) return
221 if (!canPost(context)) return
216 val notifId = notifId(NS_ERROR, sessionId)
222 val notifId = notifId(NS_ERROR, sessionId)
@@ -219,7 +225,7 @@ object SessionNotifications {
219 .setContentTitle(context.getString(R.string.notif_reply_failed_title))
225 .setContentTitle(context.getString(R.string.notif_reply_failed_title))
220 .setContentText(context.getString(R.string.notif_reply_failed_text))
226 .setContentText(context.getString(R.string.notif_reply_failed_text))
221 .setAutoCancel(true)
227 .setAutoCancel(true)
222− .setContentIntent(openSessionIntent(context, sessionId, notifId))
228+ .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
223 .setCategory(NotificationCompat.CATEGORY_ERROR)
229 .setCategory(NotificationCompat.CATEGORY_ERROR)
224 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
230 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
225 .build()
231 .build()
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+15−6
@@ -100,11 +100,12 @@ fun OpencodeApp(container: AppContainer) {
100 }
100 }
101
101
102 // "New session" from a launcher shortcut / QS tile: once connected, create a fresh
102 // "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*
103+ // session and open it. The pending counter is consumed only *after* createSession
104− // createSession succeeds, so a transient failure leaves the request pending to retry
104+ // succeeds, so a transient failure leaves the request pending to retry (on the next
105− // (on the next connection change / trigger) instead of silently dropping the tap.
105+ // connection change or a re-tap that increments the counter) instead of silently
106+ // dropping the tap.
106 LaunchedEffect(pendingNewSession, connection) {
107 LaunchedEffect(pendingNewSession, connection) {
107− if (!pendingNewSession) return@LaunchedEffect
108+ if (pendingNewSession == 0) return@LaunchedEffect
108 val conn = connection ?: return@LaunchedEffect
109 val conn = connection ?: return@LaunchedEffect
109 val session = runCatchingCancellable { conn.repository.createSession() }.getOrNull()
110 val session = runCatchingCancellable { conn.repository.createSession() }.getOrNull()
110 ?: return@LaunchedEffect
111 ?: return@LaunchedEffect
@@ -126,8 +127,16 @@ fun OpencodeApp(container: AppContainer) {
126
127
127 // Open a session requested by a notification tap or deep link, once connected.
128 // Open a session requested by a notification tap or deep link, once connected.
128 LaunchedEffect(pendingOpenSession, connection, isTwoPane) {
129 LaunchedEffect(pendingOpenSession, connection, isTwoPane) {
129− val id = pendingOpenSession ?: return@LaunchedEffect
130+ val pending = pendingOpenSession ?: return@LaunchedEffect
130 if (connection == null) return@LaunchedEffect
131 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+ }
131 // In two-pane mode the detail pane (TwoPaneSessionChat, hosted on the SESSIONS
140 // In two-pane mode the detail pane (TwoPaneSessionChat, hosted on the SESSIONS
132 // route) consumes the request and opens it in the detail pane. But it only
141 // route) consumes the request and opens it in the detail pane. But it only
133 // exists while the NavHost is on SESSIONS, so if the user is currently on
142 // exists while the NavHost is on SESSIONS, so if the user is currently on
@@ -148,7 +157,7 @@ fun OpencodeApp(container: AppContainer) {
148 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
157 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
149 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
158 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
150 }
159 }
151− navController.navigate(Routes.chat(id)) { launchSingleTop = true }
160+ navController.navigate(Routes.chat(pending.sessionId)) { launchSingleTop = true }
152 }
161 }
153
162
154 NavHost(
163 NavHost(
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+2−1
@@ -1276,8 +1276,9 @@ class ChatViewModel(
1276 .onSuccess {
1276 .onSuccess {
1277 container.draftStore.remove(sessionId)
1277 container.draftStore.remove(sessionId)
1278 container.attachmentDraftStore.remove(sessionId)
1278 container.attachmentDraftStore.remove(sessionId)
1279− container.messageCacheStore.remove(sessionId)
1279+ container.messageCacheStore.remove(conn.profile.id, sessionId)
1280 container.outboxStore.removeForSession(sessionId)
1280 container.outboxStore.removeForSession(sessionId)
1281+ container.clearUnread(sessionId)
1281 _sessionDeleted.value = true
1282 _sessionDeleted.value = true
1282 }
1283 }
1283 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1284 .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
151 // Key the expanded state on the part's stable id so a new ReasoningPart doesn't
151 // Key the expanded state on the part's stable id so a new ReasoningPart doesn't
152 // inherit the positional saveable key of a prior block. A content-prefix key would
152 // inherit the positional saveable key of a prior block. A content-prefix key would
153 // change on nearly every token while streaming and snap the block back to collapsed;
153 // 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.
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.
155− val saveableKey = keyId.ifBlank { text.take(64) }
157+ val saveableKey = keyId.ifBlank { "reasoning" }
156 var expanded by rememberSaveable(saveableKey) { mutableStateOf(false) }
158 var expanded by rememberSaveable(saveableKey) { mutableStateOf(false) }
157 val expandedState = stringResource(R.string.state_expanded)
159 val expandedState = stringResource(R.string.state_expanded)
158 val collapsedState = stringResource(R.string.state_collapsed)
160 val collapsedState = stringResource(R.string.state_collapsed)
@@ -310,6 +312,7 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
310 expandedState = expandedState,
312 expandedState = expandedState,
311 collapsedState = collapsedState,
313 collapsedState = collapsedState,
312 onCopy = { copyToClipboard(context, "output", detail) },
314 onCopy = { copyToClipboard(context, "output", detail) },
315+ diffSaveKey = part.id,
313 )
316 )
314 }
317 }
315 }
318 }
@@ -333,6 +336,7 @@ private fun CollapsibleDetail(
333 expandedState: String = "",
336 expandedState: String = "",
334 collapsedState: String = "",
337 collapsedState: String = "",
335 onCopy: (() -> Unit)? = null,
338 onCopy: (() -> Unit)? = null,
339+ diffSaveKey: String? = null,
336) {
340) {
337 Column(modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
341 Column(modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
338 if (label != null) {
342 if (label != null) {
@@ -361,7 +365,7 @@ private fun CollapsibleDetail(
361 if (!inputExpanded) return@Column
365 if (!inputExpanded) return@Column
362 }
366 }
363 if (isDiff) {
367 if (isDiff) {
364− DiffView(detail)
368+ DiffView(detail, saveKey = diffSaveKey)
365 } else {
369 } else {
366 // SelectionContainer so the user can select a portion of the output (e.g.
370 // SelectionContainer so the user can select a portion of the output (e.g.
367 // a single line of stdout) instead of only copy-all via the button below.
371 // 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
123 * expands the view.
123 * expands the view.
124 */
124 */
125@Composable
125@Composable
126−fun DiffView(diff: String, modifier: Modifier = Modifier) {
126+fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = null) {
127 val lines = remember(diff) { parseDiff(diff) }
127 val lines = remember(diff) { parseDiff(diff) }
128 // Derive the four row colors once per color scheme instead of allocating fresh Color
128 // Derive the four row colors once per color scheme instead of allocating fresh Color
129 // objects (the .copy() calls) on every recomposition — a diff re-parses/recomposes
129 // objects (the .copy() calls) on every recomposition — a diff re-parses/recomposes
@@ -135,7 +135,10 @@ fun DiffView(diff: String, modifier: Modifier = Modifier) {
135 val removeText = scheme.error
135 val removeText = scheme.error
136 val context = LocalContext.current
136 val context = LocalContext.current
137 val hScrollState = rememberScrollState()
137 val hScrollState = rememberScrollState()
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.
138− var expanded by rememberSaveable(diff) { mutableStateOf(false) }
141+ var expanded by rememberSaveable(saveKey ?: diff) { mutableStateOf(false) }
139
142
140 Column(
143 Column(
141 modifier = modifier
144 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() {
78 // called once from init with a null api and never retried.
78 // called once from init with a null api and never retried.
79 viewModelScope.launch {
79 viewModelScope.launch {
80 container.activeConnection.collectLatest { conn ->
80 container.activeConnection.collectLatest { conn ->
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()
81− if (conn == null) return@collectLatest
85+ return@collectLatest
86+ }
82 loadStatus()
87 loadStatus()
83 // Re-open the current directory when a connection becomes available
88 // Re-open the current directory when a connection becomes available
84 // so the initial open("") (which may have run before auto-reconnect
89 // 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() {
233 /** Pin or unpin [session] (pinned sessions sort to the top of the list). */
233 /** Pin or unpin [session] (pinned sessions sort to the top of the list). */
234 fun togglePin(session: Session) {
234 fun togglePin(session: Session) {
235 val pin = session.id !in _state.value.pinnedIds
235 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) }
236 viewModelScope.launch {
239 viewModelScope.launch {
237 runCatchingCancellable { container.sessionPrefsStore.setPinned(session.id, pin) }
240 runCatchingCancellable { container.sessionPrefsStore.setPinned(session.id, pin) }
238 .onFailure { _transientErrors.tryEmit(container.friendlyError(it)) }
241 .onFailure { _transientErrors.tryEmit(container.friendlyError(it)) }
@@ -242,6 +245,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
242 /** Archive or unarchive [session] (archived sessions are hidden unless "show archived"). */
245 /** Archive or unarchive [session] (archived sessions are hidden unless "show archived"). */
243 fun toggleArchive(session: Session) {
246 fun toggleArchive(session: Session) {
244 val archive = session.id !in _state.value.archivedIds
247 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) }
245 viewModelScope.launch {
249 viewModelScope.launch {
246 runCatchingCancellable { container.sessionPrefsStore.setArchived(session.id, archive) }
250 runCatchingCancellable { container.sessionPrefsStore.setArchived(session.id, archive) }
247 .onFailure { _transientErrors.tryEmit(container.friendlyError(it)) }
251 .onFailure { _transientErrors.tryEmit(container.friendlyError(it)) }
@@ -337,8 +341,9 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
337 // the loop existed — killing live updates for good).
341 // the loop existed — killing live updates for good).
338 runCatchingCancellable { container.draftStore.remove(id) }
342 runCatchingCancellable { container.draftStore.remove(id) }
339 runCatchingCancellable { container.attachmentDraftStore.remove(id) }
343 runCatchingCancellable { container.attachmentDraftStore.remove(id) }
340− runCatchingCancellable { container.messageCacheStore.remove(id) }
344+ runCatchingCancellable { container.messageCacheStore.remove(conn.profile.id, id) }
341 runCatchingCancellable { container.sessionPrefsStore.forget(id) }
345 runCatchingCancellable { container.sessionPrefsStore.forget(id) }
346+ container.clearUnread(id)
342 _state.update { s ->
347 _state.update { s ->
343 s.copy(
348 s.copy(
344 sessions = s.sessions.filterNot { it.id == id },
349 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(
64 val sessionListState by sessionListVm.state.collectAsStateWithLifecycle()
64 val sessionListState by sessionListVm.state.collectAsStateWithLifecycle()
65
65
66 // A notification tap / deep link requests a session: open it in the detail pane.
66 // 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.
67 LaunchedEffect(pendingOpenSession) {
70 LaunchedEffect(pendingOpenSession) {
68 pendingOpenSession?.let {
71 pendingOpenSession?.let {
69− selected = it
72+ selected = it.sessionId
70 container.consumePendingOpenSession()
73 container.consumePendingOpenSession()
71 }
74 }
72 }
75 }
app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+6−1
@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.StateFlow
9import kotlinx.coroutines.flow.asSharedFlow
9import kotlinx.coroutines.flow.asSharedFlow
10import kotlinx.coroutines.flow.asStateFlow
10import kotlinx.coroutines.flow.asStateFlow
11import kotlinx.coroutines.flow.flowOf
11import kotlinx.coroutines.flow.flowOf
12+import kotlinx.coroutines.flow.update
12import soy.iko.opencode.data.model.Agent
13import soy.iko.opencode.data.model.Agent
13import soy.iko.opencode.data.model.BusEvent
14import soy.iko.opencode.data.model.BusEvent
14import soy.iko.opencode.data.model.Command
15import soy.iko.opencode.data.model.Command
@@ -378,7 +379,7 @@ class FakeMessageCacheStore : MessageCacheStore() {
378 if (messages.isEmpty()) store.remove(sessionId) else store[sessionId] = messages
379 if (messages.isEmpty()) store.remove(sessionId) else store[sessionId] = messages
379 }
380 }
380
381
381− override suspend fun remove(sessionId: String) { store.remove(sessionId) }
382+ override suspend fun remove(profileId: String, sessionId: String) { store.remove(sessionId) }
382}
383}
383
384
384// ---------------------------------------------------------------------------
385// ---------------------------------------------------------------------------
@@ -423,6 +424,10 @@ class FakeAppContainer : AppContainer() {
423 var probeWithCredentialsCalls: List<Triple<String, String, String>> = emptyList()
424 var probeWithCredentialsCalls: List<Triple<String, String, String>> = emptyList()
424 private set
425 private set
425
426
427+ override fun clearUnread(id: String) {
428+ fakeUnread.update { it - id }
429+ }
430+
426 override fun string(id: Int, vararg formatArgs: Any): String = "test-string-$id"
431 override fun string(id: Int, vararg formatArgs: Any): String = "test-string-$id"
427
432
428 override fun friendlyError(t: Throwable): String = "test-error: ${t.message ?: t::class.simpleName}"
433 override fun friendlyError(t: Throwable): String = "test-error: ${t.message ?: t::class.simpleName}"