fix(notification): open tapped chat at bottom (#984)

Co-authored-by: M57 <41645758+Hy4ri@users.noreply.github.com>
This commit is contained in:
AlexDev_andM57 authored and GitHub committed 2026-08-27 10:41:08 +03:00
1 parent 19065f02df
commit ce8fdc45bd
4 files changed
+65 -7

No files matched your search

@@ -96,6 +96,6 @@ class MainActivity : ComponentActivity() {
if (intent?.action != ACTION_OPEN_CHAT_FROM_NOTIFICATION) return if (intent?.action != ACTION_OPEN_CHAT_FROM_NOTIFICATION) return
val sessionId = intent.getStringExtra(NotificationReplyReceiver.EXTRA_SESSION_ID) val sessionId = intent.getStringExtra(NotificationReplyReceiver.EXTRA_SESSION_ID)
intent.removeExtra(NotificationReplyReceiver.EXTRA_SESSION_ID) intent.removeExtra(NotificationReplyReceiver.EXTRA_SESSION_ID)
sessionId?.takeIf { it.isNotBlank() }?.let(NavigationController::openChatSession) sessionId?.takeIf { it.isNotBlank() }?.let(NavigationController::openChatSessionFromNotification)
} }
} }
@@ -6,6 +6,12 @@ import androidx.compose.runtime.setValue
import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavKey
data class PendingChatNavigation(
val sessionId: String,
val scrollToBottom: Boolean,
val requestId: Long,
)
/** /**
* Central navigation controller with deduplication guard. * Central navigation controller with deduplication guard.
* *
@@ -19,8 +25,11 @@ import androidx.navigation3.runtime.NavKey
*/ */
object NavigationController { object NavigationController {
var backStack: NavBackStack<NavKey>? = null var backStack: NavBackStack<NavKey>? = null
var pendingSessionId: String? by mutableStateOf(null) var pendingChatNavigation: PendingChatNavigation? by mutableStateOf(null)
private set private set
val pendingSessionId: String? get() = pendingChatNavigation?.sessionId
private var nextChatNavigationRequestId = 0L
// Top-level primary screens (Chat, Skills, Cron, System, Settings) // Top-level primary screens (Chat, Skills, Cron, System, Settings)
private val primaryScreens: MutableSet<NavKey> = private val primaryScreens: MutableSet<NavKey> =
@@ -50,12 +59,31 @@ object NavigationController {
} }
fun openChatSession(sessionId: String) { 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 if (sessionId.isBlank()) return
pendingSessionId = sessionId pendingChatNavigation =
PendingChatNavigation(
sessionId = sessionId,
scrollToBottom = scrollToBottom,
requestId = ++nextChatNavigationRequestId,
)
navigateTo(ChatScreen) 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. */ /** Clear the stack and navigate to the given screen atomically. */
fun resetTo(screen: NavKey) { fun resetTo(screen: NavKey) {
@@ -44,13 +44,17 @@ fun ChatLifecycleEffects(
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
// Switch to session from notification/history // Switch to session from notification/history
val pendingSessionId = NavigationController.pendingSessionId val pendingNavigation = NavigationController.pendingChatNavigation
LaunchedEffect(sessionId, pendingSessionId, connectionStatus) { LaunchedEffect(sessionId, pendingNavigation, connectionStatus) {
if (connectionStatus != ConnectionStatus.CONNECTED) return@LaunchedEffect if (connectionStatus != ConnectionStatus.CONNECTED) return@LaunchedEffect
val target = NavigationController.consumePendingSessionId() ?: sessionId val request = NavigationController.consumePendingChatNavigation()
val target = request?.sessionId ?: sessionId
if (!target.isNullOrBlank()) { if (!target.isNullOrBlank()) {
viewModel.switchSession(target) viewModel.switchSession(target)
} }
if (request?.scrollToBottom == true) {
scrollController.jumpToBottom(animated = false)
}
} }
// Land instantly at the bottom on a session switch (issue #682). // Land instantly at the bottom on a session switch (issue #682).
@@ -146,6 +146,32 @@ class NavigationControllerTest {
assertNull(NavigationController.consumePendingSessionId()) assertNull(NavigationController.consumePendingSessionId())
} }
@Test
fun `notification chat request asks for bottom even when chat is already active`() {
val backStack = NavBackStack<NavKey>(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<NavKey>(HistoryScreen)
NavigationController.openChatSession("stored-session")
assertFalse(NavigationController.consumePendingChatNavigation()?.scrollToBottom == true)
}
// ── resetTo: atomic clear + navigate ────────────────────────────────── // ── resetTo: atomic clear + navigate ──────────────────────────────────
@Test @Test