diff --git a/app/src/main/java/com/m57/hermescontrol/MainActivity.kt b/app/src/main/java/com/m57/hermescontrol/MainActivity.kt index 883f3301..60456818 100644 --- a/app/src/main/java/com/m57/hermescontrol/MainActivity.kt +++ b/app/src/main/java/com/m57/hermescontrol/MainActivity.kt @@ -96,6 +96,6 @@ class MainActivity : ComponentActivity() { if (intent?.action != ACTION_OPEN_CHAT_FROM_NOTIFICATION) return val sessionId = intent.getStringExtra(NotificationReplyReceiver.EXTRA_SESSION_ID) intent.removeExtra(NotificationReplyReceiver.EXTRA_SESSION_ID) - sessionId?.takeIf { it.isNotBlank() }?.let(NavigationController::openChatSession) + sessionId?.takeIf { it.isNotBlank() }?.let(NavigationController::openChatSessionFromNotification) } } diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationController.kt b/app/src/main/java/com/m57/hermescontrol/NavigationController.kt index 139c42d0..8659dcbc 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationController.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationController.kt @@ -6,6 +6,12 @@ import androidx.compose.runtime.setValue import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey +data class PendingChatNavigation( + val sessionId: String, + val scrollToBottom: Boolean, + val requestId: Long, +) + /** * Central navigation controller with deduplication guard. * @@ -19,8 +25,11 @@ import androidx.navigation3.runtime.NavKey */ object NavigationController { var backStack: NavBackStack? = null - var pendingSessionId: String? by mutableStateOf(null) + var pendingChatNavigation: PendingChatNavigation? by mutableStateOf(null) private set + val pendingSessionId: String? get() = pendingChatNavigation?.sessionId + + private var nextChatNavigationRequestId = 0L // Top-level primary screens (Chat, Skills, Cron, System, Settings) private val primaryScreens: MutableSet = @@ -50,12 +59,31 @@ object NavigationController { } fun openChatSession(sessionId: String) { + queueChatNavigation(sessionId, scrollToBottom = false) + } + + fun openChatSessionFromNotification(sessionId: String) { + queueChatNavigation(sessionId, scrollToBottom = true) + } + + private fun queueChatNavigation( + sessionId: String, + scrollToBottom: Boolean, + ) { if (sessionId.isBlank()) return - pendingSessionId = sessionId + pendingChatNavigation = + PendingChatNavigation( + sessionId = sessionId, + scrollToBottom = scrollToBottom, + requestId = ++nextChatNavigationRequestId, + ) navigateTo(ChatScreen) } - fun consumePendingSessionId(): String? = pendingSessionId.also { pendingSessionId = null } + fun consumePendingChatNavigation(): PendingChatNavigation? = + pendingChatNavigation.also { pendingChatNavigation = null } + + fun consumePendingSessionId(): String? = consumePendingChatNavigation()?.sessionId /** Clear the stack and navigate to the given screen atomically. */ fun resetTo(screen: NavKey) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt index f2909116..c869afb4 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt @@ -44,13 +44,17 @@ fun ChatLifecycleEffects( val lifecycleOwner = LocalLifecycleOwner.current // Switch to session from notification/history - val pendingSessionId = NavigationController.pendingSessionId - LaunchedEffect(sessionId, pendingSessionId, connectionStatus) { + val pendingNavigation = NavigationController.pendingChatNavigation + LaunchedEffect(sessionId, pendingNavigation, connectionStatus) { if (connectionStatus != ConnectionStatus.CONNECTED) return@LaunchedEffect - val target = NavigationController.consumePendingSessionId() ?: sessionId + val request = NavigationController.consumePendingChatNavigation() + val target = request?.sessionId ?: sessionId if (!target.isNullOrBlank()) { viewModel.switchSession(target) } + if (request?.scrollToBottom == true) { + scrollController.jumpToBottom(animated = false) + } } // Land instantly at the bottom on a session switch (issue #682). diff --git a/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt b/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt index 5881c85f..b92167a2 100644 --- a/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt @@ -146,6 +146,32 @@ class NavigationControllerTest { assertNull(NavigationController.consumePendingSessionId()) } + @Test + fun `notification chat request asks for bottom even when chat is already active`() { + val backStack = NavBackStack(ChatScreen) + NavigationController.backStack = backStack + + NavigationController.openChatSessionFromNotification("stored-session") + val first = NavigationController.consumePendingChatNavigation() + NavigationController.openChatSessionFromNotification("stored-session") + val second = NavigationController.consumePendingChatNavigation() + + assertEquals(1, backStack.size) + assertEquals("stored-session", first?.sessionId) + assertTrue(first?.scrollToBottom == true) + assertTrue(second?.scrollToBottom == true) + assertTrue("repeated taps must be distinct events", first?.requestId != second?.requestId) + } + + @Test + fun `history chat request does not force bottom`() { + NavigationController.backStack = NavBackStack(HistoryScreen) + + NavigationController.openChatSession("stored-session") + + assertFalse(NavigationController.consumePendingChatNavigation()?.scrollToBottom == true) + } + // ── resetTo: atomic clear + navigate ────────────────────────────────── @Test