fix(notification): resume stored session before reply (#805)

This commit is contained in:
AlexDev_ authored and GitHub committed 2026-08-04 19:54:49 +03:00
1 parent e1a04a9396
commit 356ccc98b5
13 files changed
+268 -39

No files matched your search

@@ -11,8 +11,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.notification.NotificationReplyReceiver
@@ -20,9 +18,6 @@ import com.m57.hermescontrol.theme.HermesControlTheme
import com.m57.hermescontrol.util.LocaleContextWrapper
class MainActivity : ComponentActivity() {
// Observable state so both onCreate and onNewIntent updates flow to Compose
private var notificationSessionId by mutableStateOf<String?>(null)
/**
* Apply the user-selected display language before any view is inflated.
* Reads the persisted code from [AuthManager]; an uninitialized store
@@ -38,8 +33,7 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Read notification tap sessionId (if any) from the intent that launched us
notificationSessionId = intent?.getStringExtra(NotificationReplyReceiver.EXTRA_SESSION_ID)
consumeNotificationIntent(intent)
enableEdgeToEdge()
setContent {
@@ -55,7 +49,7 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
MainNavigation(sessionId = notificationSessionId)
MainNavigation()
}
}
}
@@ -63,10 +57,13 @@ class MainActivity : ComponentActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// When a notification tap arrives while the app is already running
// (task exists in background), Android delivers the intent here instead
// of onCreate. Update the observable state so Compose recomposes
// and ChatScreen's LaunchedEffect switches to the correct session.
notificationSessionId = intent.getStringExtra(NotificationReplyReceiver.EXTRA_SESSION_ID)
setIntent(intent)
consumeNotificationIntent(intent)
}
private fun consumeNotificationIntent(intent: Intent?) {
val sessionId = intent?.getStringExtra(NotificationReplyReceiver.EXTRA_SESSION_ID)
intent?.removeExtra(NotificationReplyReceiver.EXTRA_SESSION_ID)
sessionId?.takeIf { it.isNotBlank() }?.let(NavigationController::openChatSession)
}
}
@@ -1,5 +1,8 @@
package com.m57.hermescontrol
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
@@ -16,7 +19,8 @@ import androidx.navigation3.runtime.NavKey
*/
object NavigationController {
var backStack: NavBackStack<NavKey>? = null
var pendingSessionId: String? = null
var pendingSessionId: String? by mutableStateOf(null)
private set
// Top-level primary screens (Chat, Skills, Cron, System, Settings)
private val primaryScreens: MutableSet<NavKey> =
@@ -45,6 +49,14 @@ object NavigationController {
stack.add(key)
}
fun openChatSession(sessionId: String) {
if (sessionId.isBlank()) return
pendingSessionId = sessionId
navigateTo(ChatScreen)
}
fun consumePendingSessionId(): String? = pendingSessionId.also { pendingSessionId = null }
/** Clear the stack and navigate to the given screen atomically. */
fun resetTo(screen: NavKey) {
val stack = backStack ?: return
@@ -16,6 +16,7 @@ import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.CookieManager
import com.m57.hermescontrol.data.remote.ServerEndpoint
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
import kotlinx.coroutines.CoroutineScope
@@ -310,6 +311,7 @@ object AuthManager {
) {
requirePrefs().edit().putString("token_$profileId", token).apply()
if (getSelectedProfileId() == profileId) {
if (token == null) ActiveSessionHolder.clear()
// B7 (Jul 08 2026, kanban t_470): sync in-memory cachedToken
// to prevent stale tokens during ticket refresh
synchronized(this) {
@@ -326,6 +328,9 @@ object AuthManager {
}
fun setSelectedProfileId(id: String?) {
if (getSelectedProfileId() != id?.takeIf { it.isNotBlank() }) {
ActiveSessionHolder.clear()
}
serverStore.update { it.copy(selectedProfileId = id) }
synchronized(this) {
tokenInitialized = false
@@ -17,12 +17,32 @@ import kotlinx.coroutines.flow.asStateFlow
*/
object ActiveSessionHolder {
private val _activeSessionId = MutableStateFlow<String?>(null)
private var activeStoredSessionId: String? = null
/** The currently active chat session id, or null if none is known yet. */
val activeSessionId: StateFlow<String?> = _activeSessionId.asStateFlow()
/** Update the active session id. Pass null to clear (e.g. on logout). */
fun set(sessionId: String?) {
@Synchronized
fun set(
sessionId: String?,
storedSessionId: String? = null,
) {
_activeSessionId.value = sessionId
activeStoredSessionId = storedSessionId
}
@Synchronized
fun resolveStoredSessionId(sessionId: String?): String? =
if (sessionId == _activeSessionId.value) activeStoredSessionId ?: sessionId else sessionId
@Synchronized
fun resolveRuntimeSessionId(sessionId: String): String? =
_activeSessionId.value.takeIf { activeStoredSessionId == sessionId }
@Synchronized
fun clear() {
_activeSessionId.value = null
activeStoredSessionId = null
}
}
@@ -7,6 +7,7 @@ import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -452,6 +453,7 @@ object HermesWsClient {
/** Cleanly close the WebSocket and stop auto-reconnect. */
fun disconnect(clearPendingMessages: Boolean = false) {
ActiveSessionHolder.clear()
synchronized(outboundLock) {
intentionalClose.set(true)
acceptQueuedMessages.set(!clearPendingMessages)
@@ -885,6 +887,7 @@ object HermesWsClient {
// reason is still inspected internally to detect auth failures.
Log.i(TAG, "WebSocket closed: $code")
connected.set(false)
ActiveSessionHolder.clear()
stopHealthTracking()
if (code == 4001 || code == 4401 ||
reason.contains("unauthorized", ignoreCase = true) ||
@@ -915,6 +918,7 @@ object HermesWsClient {
// detection.
Log.e(TAG, "WebSocket failure: ${t.javaClass.simpleName}", t)
connected.set(false)
ActiveSessionHolder.clear()
stopHealthTracking()
val code = response?.code ?: 0
if (code == 401 || t.message?.contains(
@@ -14,6 +14,7 @@ import androidx.core.app.RemoteInput
import com.m57.hermescontrol.MainActivity
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent
import kotlinx.coroutines.CoroutineScope
@@ -98,7 +99,10 @@ class ChatNotificationService : Service() {
.take(100)
.replace("\n", " ")
.ifBlank { getString(R.string.notif_new_message) }
showReplyNotification(preview, event.sessionId)
showReplyNotification(
preview,
ActiveSessionHolder.resolveStoredSessionId(event.sessionId),
)
// The wait is over — retire the foreground
// service. The reply notification above
// replaces the persistent "waiting" one,
@@ -8,7 +8,9 @@ import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.RemoteInput
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsMethods
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -20,6 +22,7 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
companion object {
const val KEY_TEXT_REPLY = "key_text_reply"
const val EXTRA_SESSION_ID = "extra_session_id"
private const val REPLY_TIMEOUT_MS = 5_000L
}
// Reusable scope for async reply processing — avoids creating a new
@@ -74,7 +77,15 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
return@withContext
}
HermesWsClient.sendMessage(sessionId, replyText)
val runtimeSessionId =
ActiveSessionHolder.resolveRuntimeSessionId(sessionId)
?: resumeSession(sessionId)
HermesWsClient
.request(
WsMethods.PROMPT_SUBMIT,
mapOf("session_id" to runtimeSessionId, "text" to replyText),
timeoutMs = REPLY_TIMEOUT_MS,
).await()
val entity =
com.m57.hermescontrol.data.local.ChatMessageEntity(
@@ -111,4 +122,19 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
}
}
}
private suspend fun resumeSession(storedSessionId: String): String {
val result =
HermesWsClient
.request(
WsMethods.SESSION_RESUME,
mapOf("session_id" to storedSessionId),
timeoutMs = REPLY_TIMEOUT_MS,
).await() as? Map<*, *>
val runtimeSessionId =
(result?.get("session_id") as? String)?.takeIf { it.isNotBlank() }
?: error("Resume returned no runtime session id")
ActiveSessionHolder.set(runtimeSessionId, storedSessionId)
return runtimeSessionId
}
}
@@ -845,7 +845,7 @@ class ChatViewModel(
// Mirror the active session id app-wide so session-scoped
// drawer screens (e.g. Processes, issue #532) can issue
// session-scoped RPCs. See ActiveSessionHolder.
ActiveSessionHolder.set(runtimeId)
ActiveSessionHolder.set(runtimeId, storageId)
_streamingState.update { StreamingState() }
addSystemMessage("Session created", persist = true)
loadSessions()
@@ -868,7 +868,7 @@ class ChatViewModel(
isLoading = false,
)
runtimeSessionId = runtimeId
ActiveSessionHolder.set(runtimeId)
ActiveSessionHolder.set(runtimeId, storageId)
sessionHasServerPresence = false
sessionGoneRecoveryInFlight = false
addSystemMessage("Session branched", persist = true)
@@ -938,7 +938,7 @@ class ChatViewModel(
)
}
// Mirror the active runtime session id app-wide (issue #532).
ActiveSessionHolder.set(runtimeSessionId ?: sessionId)
ActiveSessionHolder.set(runtimeSessionId ?: sessionId, sessionId)
addSystemMessage("Session resumed")
fetchContextUsage()
val generation = request?.generation ?: sessionGeneration
@@ -1848,7 +1848,7 @@ class ChatViewModel(
resumedGeneration = -1L
hydratedGeneration = -1L
runtimeSessionId = null
ActiveSessionHolder.set(sessionId)
ActiveSessionHolder.clear()
loadedMessageOffset = 0
isSyncingMessages = false
streamingController.resetStreaming()
@@ -10,7 +10,6 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
@@ -48,20 +47,13 @@ fun ChatLifecycleEffects(
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
// Publish the notification session ID to the ViewModel synchronously
SideEffect {
viewModel.initialSessionId = sessionId
}
// Switch to session from notification/history
LaunchedEffect(sessionId, NavigationController.pendingSessionId, connectionStatus) {
val pendingSessionId = NavigationController.pendingSessionId
LaunchedEffect(sessionId, pendingSessionId, connectionStatus) {
if (connectionStatus != ConnectionStatus.CONNECTED) return@LaunchedEffect
val target = if (!sessionId.isNullOrBlank()) sessionId else NavigationController.pendingSessionId
val target = NavigationController.consumePendingSessionId() ?: sessionId
if (!target.isNullOrBlank()) {
viewModel.switchSession(target)
if (target == NavigationController.pendingSessionId) {
NavigationController.pendingSessionId = null
}
}
}
@@ -555,8 +555,7 @@ fun SessionsScreen(
if (state.isSelecting) {
viewModel.toggleSessionSelection(session.id)
} else {
NavigationController.pendingSessionId = session.id
NavigationController.navigateTo(ChatScreen)
NavigationController.openChatSession(session.id)
}
},
onCardLongClick = {
@@ -691,8 +690,7 @@ fun SessionsScreen(
if (state.isSelecting) {
viewModel.toggleSessionSelection(session.id)
} else {
NavigationController.pendingSessionId = session.id
NavigationController.navigateTo(ChatScreen)
NavigationController.openChatSession(session.id)
}
},
onCardLongClick = {