@@ -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) }