diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationController.kt b/app/src/main/java/com/m57/hermescontrol/NavigationController.kt index 16ddc35f..466067a2 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationController.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationController.kt @@ -12,6 +12,10 @@ data class PendingChatNavigation( val requestId: Long, ) +data class PendingNewChatNavigation( + val requestId: Long, +) + /** * Central navigation controller with deduplication guard. * @@ -27,9 +31,12 @@ object NavigationController { var backStack: NavBackStack? = null var pendingChatNavigation: PendingChatNavigation? by mutableStateOf(null) private set + var pendingNewChatNavigation: PendingNewChatNavigation? by mutableStateOf(null) + private set val pendingSessionId: String? get() = pendingChatNavigation?.sessionId private var nextChatNavigationRequestId = 0L + private var nextNewChatNavigationRequestId = 0L /** * Top-level primary screens (all drawer-accessible screens). @@ -73,6 +80,14 @@ object NavigationController { queueChatNavigation(sessionId, scrollToBottom = true) } + fun openNewChat() { + pendingNewChatNavigation = + PendingNewChatNavigation( + requestId = ++nextNewChatNavigationRequestId, + ) + navigateTo(ChatScreen) + } + private fun queueChatNavigation( sessionId: String, scrollToBottom: Boolean, @@ -90,6 +105,9 @@ object NavigationController { fun consumePendingChatNavigation(): PendingChatNavigation? = pendingChatNavigation.also { pendingChatNavigation = null } + fun consumePendingNewChatNavigation(): PendingNewChatNavigation? = + pendingNewChatNavigation.also { pendingNewChatNavigation = null } + fun consumePendingSessionId(): String? = consumePendingChatNavigation()?.sessionId /** Clear the stack and navigate to the given screen atomically. */ 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 7af3e313..a45f6b12 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 @@ -45,9 +45,15 @@ fun ChatLifecycleEffects( // Switch to session from notification/history val pendingNavigation = NavigationController.pendingChatNavigation - LaunchedEffect(sessionId, pendingNavigation, connectionStatus) { + val pendingNewChatNavigation = NavigationController.pendingNewChatNavigation + LaunchedEffect(sessionId, pendingNavigation, pendingNewChatNavigation, connectionStatus) { if (connectionStatus != ConnectionStatus.CONNECTED) return@LaunchedEffect + val newChatRequest = NavigationController.consumePendingNewChatNavigation() val request = NavigationController.consumePendingChatNavigation() + if (newChatRequest != null) { + viewModel.createNewSession() + return@LaunchedEffect + } val target = request?.sessionId ?: sessionId if (!target.isNullOrBlank()) { viewModel.switchSession(target) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index edfc8465..6d45a04f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.CallSplit import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Code @@ -466,6 +467,15 @@ fun SessionsScreen( isRefreshing = state.isLoading, onRefresh = { viewModel.loadSessions() }, actions = { + IconButton( + onClick = { NavigationController.openNewChat() }, + modifier = Modifier.testTag("sessions_action_new_chat"), + ) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = stringResource(R.string.content_desc_new_chat), + ) + } if (state.hasHiddenSessions) { IconButton( onClick = { viewModel.toggleShowHidden() }, diff --git a/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt b/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt index 4013dd96..9579944e 100644 --- a/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt @@ -5,6 +5,7 @@ import androidx.navigation3.runtime.NavKey import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before @@ -21,12 +22,14 @@ class NavigationControllerTest { // Start fresh — no pinned back stack from a previous test NavigationController.backStack = null NavigationController.consumePendingSessionId() + NavigationController.consumePendingNewChatNavigation() } @After fun tearDown() { NavigationController.backStack = null NavigationController.consumePendingSessionId() + NavigationController.consumePendingNewChatNavigation() } // ── Dedup guard: navigateTo with same key ────────────────────────────── @@ -119,6 +122,18 @@ class NavigationControllerTest { assertNull(NavigationController.consumePendingSessionId()) } + @Test + fun `openNewChat navigates to chat and exposes a consumable request`() { + val backStack = NavBackStack(HistoryScreen) + NavigationController.backStack = backStack + + NavigationController.openNewChat() + + assertEquals(ChatScreen, backStack.lastOrNull()) + assertNotNull(NavigationController.consumePendingNewChatNavigation()) + assertNull(NavigationController.consumePendingNewChatNavigation()) + } + @Test fun `openChatSession ignores blank ids`() { val backStack = NavBackStack(HistoryScreen)