feat(nav): root swipe-back on chat with double-back exit and subpage hierarchy (#1001)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-28 18:38:01 +03:00
1 parent f04d878358
commit 2332bdd5d5
7 files changed
+141 -101

No files matched your search

@@ -1,5 +1,7 @@
package com.m57.hermescontrol package com.m57.hermescontrol
import android.app.Activity
import android.widget.Toast
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -31,10 +33,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -191,7 +196,25 @@ fun MainNavigation(sessionId: String? = null) {
val openDrawer: () -> Unit = { scope.launch { drawerState.open() } } val openDrawer: () -> Unit = { scope.launch { drawerState.open() } }
val context = LocalContext.current
var lastBackPressTime by remember { mutableLongStateOf(0L) }
val backToastText = stringResource(R.string.press_back_again_to_exit)
// Double-back to exit on root ChatScreen
BackHandler(enabled = currentScreen == ChatScreen && backStack.size == 1) { BackHandler(enabled = currentScreen == ChatScreen && backStack.size == 1) {
val now = System.currentTimeMillis()
if (now - lastBackPressTime < 2000L) {
(context as? Activity)?.finish()
} else {
lastBackPressTime = now
Toast.makeText(context, backToastText, Toast.LENGTH_SHORT).show()
}
}
// Safety fallback: if a non-chat screen somehow sits alone on stack, swipe back returns to ChatScreen
BackHandler(
enabled = currentScreen != ChatScreen && currentScreen != LandingScreen && backStack.size == 1,
) {
NavigationController.goBack() NavigationController.goBack()
} }
@@ -31,30 +31,37 @@ object NavigationController {
private var nextChatNavigationRequestId = 0L private var nextChatNavigationRequestId = 0L
// Top-level primary screens (Chat, Skills, Cron, System, Settings) /**
private val primaryScreens: MutableSet<NavKey> = * Top-level primary screens (all drawer-accessible screens).
mutableSetOf( * Navigating to any of these clears the stack to `[ChatScreen, key]` (or `[ChatScreen]` for Chat),
ChatScreen, * ensuring swiping back returns to ChatScreen.
SkillsScreen, */
CronJobsScreen, fun isPrimaryScreen(key: NavKey): Boolean = key == ChatScreen || ScreenRegistry.ALL_SCREENS.any { it.key == key }
SystemScreen,
SettingsScreen,
)
/** Returns whether the given key is a primary top-level screen. */
fun isPrimaryScreen(key: NavKey): Boolean = key in primaryScreens
fun navigateTo(key: NavKey) { fun navigateTo(key: NavKey) {
val stack = backStack ?: return val stack = backStack ?: return
if (stack.lastOrNull() == key) return if (stack.lastOrNull() == key) return
if (key == ChatScreen) {
stack.clear()
stack.add(ChatScreen)
return
}
if (key == LandingScreen) {
stack.clear()
stack.add(LandingScreen)
return
}
if (isPrimaryScreen(key)) { if (isPrimaryScreen(key)) {
stack.clear() stack.clear()
stack.add(ChatScreen)
stack.add(key)
return
} }
// Drawer dismissal is handled by DrawerGestureController (issue #619):
// when a non-gesture sub-page composes, its HermesScaffold reconciles // Subscreen / drill-down navigation: append to current stack
// drawerGesturesEnabled=false and the controller closes the drawer
// itself via SideEffect. No synchronous closeDrawer callback here.
stack.add(key) stack.add(key)
} }
@@ -98,15 +105,13 @@ object NavigationController {
*/ */
fun goBack(fallback: NavKey = ChatScreen) { fun goBack(fallback: NavKey = ChatScreen) {
val stack = backStack ?: return val stack = backStack ?: return
if (stack.lastOrNull() == ChatScreen) {
resetTo(HistoryScreen)
return
}
if (stack.size > 1) { if (stack.size > 1) {
stack.removeLastOrNull() stack.removeLastOrNull()
} else if (stack.size == 1) { } else if (stack.size == 1) {
stack.clear() if (stack.lastOrNull() != fallback && stack.lastOrNull() != LandingScreen) {
stack.add(fallback) stack.clear()
stack.add(fallback)
}
} }
} }
} }
+1
View File
@@ -49,6 +49,7 @@
<string name="billing_topup">충전</string> <string name="billing_topup">충전</string>
<!-- Navigation & Drawer --> <!-- Navigation & Drawer -->
<string name="press_back_again_to_exit">종료하려면 뒤로가기를 한 번 더 누르세요</string>
<string name="nav_drawer_title">헤르메스</string> <string name="nav_drawer_title">헤르메스</string>
<string name="nav_drawer_subtitle">AI 에이전트 제어</string> <string name="nav_drawer_subtitle">AI 에이전트 제어</string>
<string name="nav_drawer_section_converse">대화</string> <string name="nav_drawer_section_converse">대화</string>
+1
View File
@@ -55,6 +55,7 @@
<string name="billing_credits_remaining">剩余额度:%1$s</string> <string name="billing_credits_remaining">剩余额度:%1$s</string>
<string name="billing_plan">套餐</string> <string name="billing_plan">套餐</string>
<string name="billing_topup">充值</string> <string name="billing_topup">充值</string>
<string name="press_back_again_to_exit">再次返回以退出应用</string>
<string name="nav_drawer_title">Hermes</string> <string name="nav_drawer_title">Hermes</string>
<string name="nav_drawer_subtitle">AI Agent 控制台</string> <string name="nav_drawer_subtitle">AI Agent 控制台</string>
<string name="nav_drawer_section_converse">对话</string> <string name="nav_drawer_section_converse">对话</string>
+1
View File
@@ -66,6 +66,7 @@
<string name="billing_topup">Top-up</string> <string name="billing_topup">Top-up</string>
<!-- Navigation & Drawer --> <!-- Navigation & Drawer -->
<string name="press_back_again_to_exit">Press back again to exit</string>
<string name="nav_drawer_title">Hermes</string> <string name="nav_drawer_title">Hermes</string>
<string name="nav_drawer_subtitle">AI Agent Control</string> <string name="nav_drawer_subtitle">AI Agent Control</string>
<string name="nav_drawer_section_converse">Converse</string> <string name="nav_drawer_section_converse">Converse</string>
@@ -718,17 +718,19 @@ class E2eIntegrationTest {
NavigationController.navigateTo(SkillsScreen) NavigationController.navigateTo(SkillsScreen)
assertEquals(SkillsScreen, backStack.lastOrNull()) assertEquals(SkillsScreen, backStack.lastOrNull())
assertEquals(1, backStack.size) assertEquals(2, backStack.size)
assertEquals(ChatScreen, backStack[0])
NavigationController.navigateTo(CronJobsScreen) NavigationController.navigateTo(CronJobsScreen)
assertEquals(CronJobsScreen, backStack.lastOrNull()) assertEquals(CronJobsScreen, backStack.lastOrNull())
assertEquals(1, backStack.size) assertEquals(2, backStack.size)
assertEquals(ChatScreen, backStack[0])
// Non-clearing drawer screen adds to stack // Drawer screen navigation always roots on ChatScreen
NavigationController.navigateTo(ProfilesScreen) NavigationController.navigateTo(ProfilesScreen)
assertEquals(ProfilesScreen, backStack.lastOrNull()) assertEquals(ProfilesScreen, backStack.lastOrNull())
assertEquals(2, backStack.size) assertEquals(2, backStack.size)
assertEquals(CronJobsScreen, backStack[0]) assertEquals(ChatScreen, backStack[0])
} }
// ── Tier 2: Boundary & Corner Cases (>=5 per feature) ──────────────── // ── Tier 2: Boundary & Corner Cases (>=5 per feature) ────────────────
@@ -12,15 +12,8 @@ import org.junit.Test
/** /**
* Unit tests for [NavigationController] — the central navigation guard that * Unit tests for [NavigationController] — the central navigation guard that
* prevents duplicate screen entries on the back stack. * prevents duplicate screen entries on the back stack and enforces
* * swipe-back-to-chat hierarchy.
* Issue #291 (Critical Test Coverage): Verifies the deduplication logic at
* line 45 (`if (stack.lastOrNull() == key) return`) and the primary-screen
* stack-clearing behavior at line 47-49.
*
* All tests are pure Kotlin with no Android dependencies — they only exercise
* [NavigationController]'s companion object methods against a real
* [NavBackStack] instance.
*/ */
class NavigationControllerTest { class NavigationControllerTest {
@Before @Before
@@ -47,7 +40,7 @@ class NavigationControllerTest {
@Test @Test
fun `navigateTo on same primary screen is a no-op`() { fun `navigateTo on same primary screen is a no-op`() {
val backStack = NavBackStack<NavKey>(SkillsScreen) val backStack = NavBackStack<NavKey>(ChatScreen, SkillsScreen)
NavigationController.backStack = backStack NavigationController.backStack = backStack
val sizeBefore = backStack.size val sizeBefore = backStack.size
@@ -58,69 +51,60 @@ class NavigationControllerTest {
} }
@Test @Test
fun `navigateTo on same non-primary screen is a no-op`() { fun `navigateTo on same subscreen is a no-op`() {
val backStack = NavBackStack<NavKey>(ProfilesScreen) val backStack = NavBackStack<NavKey>(ChatScreen, SettingsScreen, SettingsAppearance)
NavigationController.backStack = backStack NavigationController.backStack = backStack
// ProfilesScreen is NOT in the default primaryScreens so it stays on the stack NavigationController.navigateTo(SettingsAppearance)
NavigationController.navigateTo(ProfilesScreen)
assertEquals(1, backStack.size) assertEquals(3, backStack.size)
assertEquals(SettingsAppearance, backStack.lastOrNull())
}
// ── Primary-screen behaviour: resets stack to [ChatScreen, Target] ───────
@Test
fun `navigateTo on a different primary screen resets stack with ChatScreen as root`() {
val backStack = NavBackStack<NavKey>(ChatScreen)
NavigationController.backStack = backStack
NavigationController.navigateTo(SkillsScreen)
assertEquals(2, backStack.size)
assertEquals(ChatScreen, backStack.firstOrNull())
assertEquals(SkillsScreen, backStack.lastOrNull())
// Now navigate to another primary screen (e.g. ProfilesScreen)
NavigationController.navigateTo(ProfilesScreen)
assertEquals("primary screen navigation should reset stack to [ChatScreen, target]", 2, backStack.size)
assertEquals(ChatScreen, backStack.firstOrNull())
assertEquals(ProfilesScreen, backStack.lastOrNull()) assertEquals(ProfilesScreen, backStack.lastOrNull())
} }
// ── Primary-screen behaviour: stack clearing ──────────────────────────
@Test @Test
fun `navigateTo on a different primary screen clears the stack`() { fun `navigateTo ChatScreen clears stack to single ChatScreen root`() {
val backStack = NavBackStack<NavKey>(ChatScreen) val backStack = NavBackStack<NavKey>(ChatScreen, ProfilesScreen)
NavigationController.backStack = backStack NavigationController.backStack = backStack
// Navigate to a non-primary screen first (should add to stack)
NavigationController.navigateTo(LogsScreen)
assertEquals(2, backStack.size)
// Now navigate to a different primary screen — must clear
NavigationController.navigateTo(SkillsScreen)
assertEquals("primary screen navigation should clear the stack", 1, backStack.size)
assertEquals(SkillsScreen, backStack.lastOrNull())
}
@Test
fun `navigateTo on the current primary screen clears the stack`() {
val backStack = NavBackStack<NavKey>(ChatScreen)
NavigationController.backStack = backStack
// Add a non-primary screen
NavigationController.navigateTo(ProfilesScreen)
assertEquals(2, backStack.size)
// Navigate to ChatScreen — it's a primary screen, so the stack gets
// cleared before adding it. The dedup guard only fires when the key
// is already the LAST item (ChatScreen is at index 0, ProfilesScreen
// is last), not when it's merely present somewhere in the stack.
NavigationController.navigateTo(ChatScreen) NavigationController.navigateTo(ChatScreen)
// Primary screen navigation clears the stack assertEquals("chat navigation should result in single root entry", 1, backStack.size)
assertEquals("primary navigation clears stack", 1, backStack.size)
assertEquals(ChatScreen, backStack.lastOrNull()) assertEquals(ChatScreen, backStack.lastOrNull())
} }
// ── Non-primary screen behaviour: stack appending ───────────────────── // ── Subscreen behaviour: appends to stack ───────────────────────────────
@Test @Test
fun `navigateTo on a non-primary screen appends to the stack`() { fun `navigateTo on a subscreen appends to the current stack`() {
val backStack = NavBackStack<NavKey>(ChatScreen) val backStack = NavBackStack<NavKey>(ChatScreen, SettingsScreen)
NavigationController.backStack = backStack NavigationController.backStack = backStack
NavigationController.navigateTo(ProfilesScreen) NavigationController.navigateTo(SettingsAppearance)
assertEquals(2, backStack.size)
assertEquals(ProfilesScreen, backStack.lastOrNull())
NavigationController.navigateTo(KeysScreen)
assertEquals(3, backStack.size) assertEquals(3, backStack.size)
assertEquals(KeysScreen, backStack.lastOrNull()) assertEquals(SettingsAppearance, backStack.lastOrNull())
NavigationController.navigateTo(SettingsAbout)
assertEquals(4, backStack.size)
assertEquals(SettingsAbout, backStack.lastOrNull())
} }
@Test @Test
@@ -176,13 +160,9 @@ class NavigationControllerTest {
@Test @Test
fun `resetTo clears the stack and sets the target screen`() { fun `resetTo clears the stack and sets the target screen`() {
val backStack = NavBackStack<NavKey>(ChatScreen) val backStack = NavBackStack<NavKey>(ChatScreen, ProfilesScreen, KeysScreen)
NavigationController.backStack = backStack NavigationController.backStack = backStack
NavigationController.navigateTo(ProfilesScreen)
NavigationController.navigateTo(KeysScreen)
assertEquals(3, backStack.size)
NavigationController.resetTo(SettingsScreen) NavigationController.resetTo(SettingsScreen)
assertEquals(1, backStack.size) assertEquals(1, backStack.size)
@@ -197,14 +177,17 @@ class NavigationControllerTest {
assertNull(NavigationController.backStack) assertNull(NavigationController.backStack)
} }
// ── goBack: never leave the stack empty ─────────────────────────────── // ── goBack: pops stack, preserves chat root ───────────────────────────
@Test @Test
fun `goBack removes the top screen when stack has more than one`() { fun `goBack removes the top screen when stack has more than one`() {
val backStack = NavBackStack<NavKey>(ChatScreen) val backStack = NavBackStack<NavKey>(ChatScreen, SettingsScreen, SettingsAppearance)
NavigationController.backStack = backStack NavigationController.backStack = backStack
NavigationController.navigateTo(ProfilesScreen)
NavigationController.goBack()
assertEquals(2, backStack.size) assertEquals(2, backStack.size)
assertEquals(SettingsScreen, backStack.lastOrNull())
NavigationController.goBack() NavigationController.goBack()
@@ -213,18 +196,18 @@ class NavigationControllerTest {
} }
@Test @Test
fun `goBack from chat opens history`() { fun `goBack on root ChatScreen keeps ChatScreen`() {
val backStack = NavBackStack<NavKey>(ChatScreen) val backStack = NavBackStack<NavKey>(ChatScreen)
NavigationController.backStack = backStack NavigationController.backStack = backStack
NavigationController.goBack() NavigationController.goBack()
assertEquals(1, backStack.size) assertEquals(1, backStack.size)
assertEquals(HistoryScreen, backStack.lastOrNull()) assertEquals(ChatScreen, backStack.lastOrNull())
} }
@Test @Test
fun `goBack falls back to default screen when stack has one item`() { fun `goBack falls back to default screen when non-root stack has one item`() {
val backStack = NavBackStack<NavKey>(ProfilesScreen) val backStack = NavBackStack<NavKey>(ProfilesScreen)
NavigationController.backStack = backStack NavigationController.backStack = backStack
@@ -256,18 +239,42 @@ class NavigationControllerTest {
// ── primaryScreens ──────────────────────────────────────────────────── // ── primaryScreens ────────────────────────────────────────────────────
@Test @Test
fun `isPrimaryScreen returns true for default screens`() { fun `isPrimaryScreen returns true for all drawer screens`() {
assertTrue("ChatScreen should be primary by default", NavigationController.isPrimaryScreen(ChatScreen)) assertTrue("ChatScreen should be primary", NavigationController.isPrimaryScreen(ChatScreen))
assertTrue("SkillsScreen should be primary by default", NavigationController.isPrimaryScreen(SkillsScreen)) assertTrue("SkillsScreen should be primary", NavigationController.isPrimaryScreen(SkillsScreen))
assertTrue("CronJobsScreen should be primary by default", NavigationController.isPrimaryScreen(CronJobsScreen)) assertTrue("CronJobsScreen should be primary", NavigationController.isPrimaryScreen(CronJobsScreen))
assertTrue("SystemScreen should be primary by default", NavigationController.isPrimaryScreen(SystemScreen)) assertTrue("SystemScreen should be primary", NavigationController.isPrimaryScreen(SystemScreen))
assertTrue("SettingsScreen should be primary by default", NavigationController.isPrimaryScreen(SettingsScreen)) assertTrue("SettingsScreen should be primary", NavigationController.isPrimaryScreen(SettingsScreen))
assertTrue("ProfilesScreen should be primary", NavigationController.isPrimaryScreen(ProfilesScreen))
assertTrue("LogsScreen should be primary", NavigationController.isPrimaryScreen(LogsScreen))
assertTrue("ConfigScreen should be primary", NavigationController.isPrimaryScreen(ConfigScreen))
assertTrue("BotsScreen should be primary", NavigationController.isPrimaryScreen(BotsScreen))
assertTrue("HistoryScreen should be primary", NavigationController.isPrimaryScreen(HistoryScreen))
} }
@Test @Test
fun `isPrimaryScreen returns false for non-default screens`() { fun `isPrimaryScreen returns false for subscreen detail keys`() {
assertFalse("ProfilesScreen should NOT be primary", NavigationController.isPrimaryScreen(ProfilesScreen)) assertFalse(
assertFalse("LogsScreen should NOT be primary", NavigationController.isPrimaryScreen(LogsScreen)) "SettingsAppearance should NOT be primary",
assertFalse("ConfigScreen should NOT be primary", NavigationController.isPrimaryScreen(ConfigScreen)) NavigationController.isPrimaryScreen(SettingsAppearance),
)
assertFalse(
"SettingsConnection should NOT be primary",
NavigationController.isPrimaryScreen(SettingsConnection),
)
assertFalse("SettingsLanguage should NOT be primary", NavigationController.isPrimaryScreen(SettingsLanguage))
assertFalse("SettingsChat should NOT be primary", NavigationController.isPrimaryScreen(SettingsChat))
assertFalse("SettingsBehavior should NOT be primary", NavigationController.isPrimaryScreen(SettingsBehavior))
assertFalse("SettingsAbout should NOT be primary", NavigationController.isPrimaryScreen(SettingsAbout))
assertFalse(
"ToolsetDetailKey should NOT be primary",
NavigationController.isPrimaryScreen(ToolsetDetailKey("tool")),
)
assertFalse(
"MemoryProviderDetailKey should NOT be primary",
NavigationController.isPrimaryScreen(MemoryProviderDetailKey("mem")),
)
assertFalse("GroupChatKey should NOT be primary", NavigationController.isPrimaryScreen(GroupChatKey("room")))
assertFalse("AuthLoginScreen should NOT be primary", NavigationController.isPrimaryScreen(AuthLoginScreen))
} }
} }