From 64a047f09590e816211fd1dc9ad6126d4124c99d Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 20 Jun 2026 22:27:54 +0300 Subject: [PATCH] feat: move session history to a dedicated history screen (#175) * feat: move session history to a dedicated history screen Closes #159. * fix(ci): fix ktlint style issues and unresolved NavigationController reference --- .../java/com/m57/hermescontrol/Navigation.kt | 10 ++ .../m57/hermescontrol/NavigationController.kt | 1 + .../com/m57/hermescontrol/NavigationKeys.kt | 2 + .../m57/hermescontrol/ui/chat/ChatScreen.kt | 89 ++---------- .../ui/sessions/SessionsScreen.kt | 130 ++++++++++++++++++ .../ui/sessions/SessionsViewModel.kt | 55 ++++++++ .../ui/settings/SettingsViewModel.kt | 1 + .../m57/hermescontrol/E2eIntegrationTest.kt | 41 ++++++ 8 files changed, 250 insertions(+), 79 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt index c54bdae3..e3b2896a 100644 --- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt +++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt @@ -26,6 +26,7 @@ import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.Dashboard import androidx.compose.material.icons.filled.Devices import androidx.compose.material.icons.filled.Extension +import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.HistoryEdu import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Key @@ -82,6 +83,7 @@ import com.m57.hermescontrol.ui.model.ModelScreen as ModelScreenContent import com.m57.hermescontrol.ui.pairing.PairingScreen as PairingScreenContent import com.m57.hermescontrol.ui.plugins.PluginsScreen as PluginsScreenContent import com.m57.hermescontrol.ui.profiles.ProfilesScreen as ProfilesScreenContent +import com.m57.hermescontrol.ui.sessions.SessionsScreen as HistoryScreenContent import com.m57.hermescontrol.ui.settings.SettingsScreen as SettingsScreenContent import com.m57.hermescontrol.ui.skills.SkillsScreen as SkillsScreenContent import com.m57.hermescontrol.ui.system.SystemScreen as SystemScreenContent @@ -118,6 +120,7 @@ private val ALL_NAV_ITEMS: List = BottomNavItem(LogsScreen, "Logs", Icons.Filled.HistoryEdu), BottomNavItem(KanbanScreen, "Kanban", Icons.Filled.Dashboard), BottomNavItem(AchievementsScreen, "Achievements", Icons.Filled.Info), + BottomNavItem(HistoryScreen, "History", Icons.Filled.History), ) /** Lookup: data-object simple name → NavKey (used by bottom-nav config). */ @@ -150,6 +153,7 @@ private val DRAWER_ENTRIES = listOf( // Converse DrawerEntry(ChatScreen, "Chat", Icons.AutoMirrored.Filled.Chat, DrawerSection.CONVERSE), + DrawerEntry(HistoryScreen, "History", Icons.Filled.History, DrawerSection.CONVERSE), DrawerEntry(ProfilesScreen, "Profiles", Icons.Filled.AccountCircle, DrawerSection.CONVERSE), // Automate DrawerEntry(CronJobsScreen, "Cron", Icons.Filled.Schedule, DrawerSection.AUTOMATE), @@ -339,6 +343,12 @@ fun MainNavigation(sessionId: String? = null) { ) } + entry { + HistoryScreenContent( + onOpenDrawer = { openDrawer() }, + ) + } + entry { SettingsScreenContent( onBack = { NavigationController.goBack() }, diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationController.kt b/app/src/main/java/com/m57/hermescontrol/NavigationController.kt index 4505ce45..bf691fc8 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationController.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationController.kt @@ -16,6 +16,7 @@ import androidx.navigation3.runtime.NavKey */ object NavigationController { var backStack: NavBackStack? = null + var pendingSessionId: String? = null // Bottom-nav primary screens — dynamic, updated by Navigation.kt via // updatePrimaryScreens() when the user customises the bottom nav bar. diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt index 00ef45e7..4f18d40b 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt @@ -15,6 +15,8 @@ import kotlinx.serialization.Serializable @Serializable data object GatewayScreen : NavKey +@Serializable data object HistoryScreen : NavKey + @Serializable data object ProfilesScreen : NavKey @Serializable data object ToolsetsScreen : NavKey diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 9c7b13f1..a55eb597 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -47,9 +47,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.Search @@ -58,11 +56,9 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -101,6 +97,7 @@ import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.R import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.ws.ConnectionStatus @@ -132,11 +129,15 @@ fun ChatScreen( val context = LocalContext.current val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current - // Switch to the session from a notification tap when provided. - // Runs once per sessionId value (re-runs if a different notification taps in). - LaunchedEffect(sessionId) { - if (!sessionId.isNullOrBlank()) { - viewModel.switchSession(sessionId) + // Switch to the session from a notification tap or history screen when provided. + val pendingSessionId = NavigationController.pendingSessionId + LaunchedEffect(sessionId, pendingSessionId) { + val target = if (!sessionId.isNullOrBlank()) sessionId else pendingSessionId + if (!target.isNullOrBlank()) { + viewModel.switchSession(target) + if (target == pendingSessionId) { + NavigationController.pendingSessionId = null + } } } @@ -285,76 +286,6 @@ fun ChatScreen( ) } - // Session picker - Box { - IconButton(onClick = { - viewModel.loadSessions() - viewModel.toggleSessionPicker() - }) { - Icon( - imageVector = Icons.Filled.History, - contentDescription = "Sessions", - ) - } - - DropdownMenu( - expanded = state.showSessionPicker, - onDismissRequest = { viewModel.toggleSessionPicker() }, - ) { - DropdownMenuItem( - text = { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - Icons.Filled.Add, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("New Session") - } - }, - onClick = { - viewModel.createNewSession() - viewModel.toggleSessionPicker() - }, - ) - if (state.sessions.isNotEmpty()) { - HorizontalDivider() - } - state.sessions.forEach { session -> - DropdownMenuItem( - text = { - Column { - Text( - text = session.title, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - ) - Text( - text = "${session.messageCount} messages", - style = - MaterialTheme.typography.labelSmall.copy( - color = MaterialTheme.colorScheme.onSurfaceVariant, - ), - ) - } - }, - onClick = { viewModel.switchSession(session.id) }, - trailingIcon = { - if (session.id == state.currentSessionId) { - Icon( - Icons.Filled.ArrowDropDown, - contentDescription = "Current", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(18.dp), - ) - } - }, - ) - } - } - } - // Search toggle IconButton(onClick = { viewModel.toggleSearch() }) { Icon( 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 new file mode 100644 index 00000000..e5e6ad14 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -0,0 +1,130 @@ +package com.m57.hermescontrol.ui.sessions + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.History +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.ChatScreen +import com.m57.hermescontrol.NavigationController +import com.m57.hermescontrol.theme.LocalSpacing +import com.m57.hermescontrol.ui.common.EmptyState +import com.m57.hermescontrol.ui.common.ErrorState +import com.m57.hermescontrol.ui.common.HermesScaffold +import com.m57.hermescontrol.ui.common.LoadingState +import com.m57.hermescontrol.ui.common.listContentPadding +import com.m57.hermescontrol.ui.common.listItemSpacing + +@Composable +fun SessionsScreen( + modifier: Modifier = Modifier, + onOpenDrawer: (() -> Unit)? = null, + viewModel: SessionsViewModel = viewModel { SessionsViewModel() }, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val spacing = LocalSpacing.current + + LaunchedEffect(Unit) { + viewModel.loadSessions() + } + + HermesScaffold( + title = { Text("History") }, + onOpenDrawer = onOpenDrawer, + isRefreshing = state.isLoading, + onRefresh = { viewModel.loadSessions() }, + modifier = modifier, + ) { + when { + state.isLoading && state.sessions.isEmpty() -> { + LoadingState() + } + + state.errorMessage != null -> { + ErrorState( + message = state.errorMessage ?: "Unknown error", + onRetry = { viewModel.loadSessions() }, + ) + } + + state.sessions.isEmpty() -> { + EmptyState( + title = "No history", + subtitle = "Previous chat sessions from Hermes will appear here.", + icon = Icons.Filled.History, + ) + } + + else -> { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + contentPadding = listContentPadding, + verticalArrangement = listItemSpacing, + ) { + items(state.sessions) { session -> + Card( + modifier = + Modifier + .fillMaxWidth() + .clickable { + NavigationController.pendingSessionId = session.id + NavigationController.navigateTo(ChatScreen) + }, + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column( + modifier = Modifier.padding(spacing.md), + ) { + Text( + text = session.title ?: "Untitled", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.height(spacing.xs)) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "${session.message_count ?: 0} messages", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (!session.status.isNullOrBlank()) { + Spacer(modifier = Modifier.width(spacing.sm)) + Text( + text = "• ${session.status}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt new file mode 100644 index 00000000..0599013b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt @@ -0,0 +1,55 @@ +package com.m57.hermescontrol.ui.sessions + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.model.SessionInfo +import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.remote.NetworkResult +import com.m57.hermescontrol.data.remote.safeApiCall +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +data class SessionsUiState( + val isLoading: Boolean = false, + val sessions: List = emptyList(), + val errorMessage: String? = null, +) + +class SessionsViewModel : ViewModel() { + private val _uiState = MutableStateFlow(SessionsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun loadSessions() { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.getSessions() } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + isLoading = false, + sessions = result.data?.sessions.orEmpty(), + ) + } + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "Failed to load sessions: ${result.error.message}", + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt index 028f0b7d..b887c4a4 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt @@ -94,6 +94,7 @@ class SettingsViewModel : ViewModel() { "ProfilesScreen" to "Profiles", "WebhooksScreen" to "Webhooks", "GatewayScreen" to "Gateway", + "HistoryScreen" to "History", "ToolsetsScreen" to "Toolsets", "PluginsScreen" to "Plugins", "ConfigScreen" to "Config", diff --git a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt index 25564884..9c9a44d4 100644 --- a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt @@ -20,6 +20,8 @@ import com.m57.hermescontrol.data.model.PluginInfo import com.m57.hermescontrol.data.model.PluginsHubResponse import com.m57.hermescontrol.data.model.ProfileInfo import com.m57.hermescontrol.data.model.ProfilesResponse +import com.m57.hermescontrol.data.model.SessionInfo +import com.m57.hermescontrol.data.model.SessionListResponse import com.m57.hermescontrol.data.model.Skill import com.m57.hermescontrol.data.model.StatusResponse import com.m57.hermescontrol.data.model.SystemStatsResponse @@ -34,6 +36,7 @@ import com.m57.hermescontrol.ui.keys.KeysViewModel import com.m57.hermescontrol.ui.logs.LogsViewModel import com.m57.hermescontrol.ui.model.ModelViewModel import com.m57.hermescontrol.ui.plugins.PluginsViewModel +import com.m57.hermescontrol.ui.sessions.SessionsViewModel import com.m57.hermescontrol.ui.skills.SkillsViewModel import com.m57.hermescontrol.ui.system.SystemViewModel import io.mockk.* @@ -407,6 +410,44 @@ class E2eIntegrationTest { ) } + // Sessions History Screen: + @Test + fun testSessionsListing_success() = + runTest { + val session = SessionInfo("session-123", "Session 1", "2026-06-15T15:10:00Z", 5, "active") + coEvery { mockApiService.getSessions() } returns Response.success(SessionListResponse(listOf(session))) + + val viewModel = SessionsViewModel() + viewModel.loadSessions() + assertTrue(viewModel.uiState.value.isLoading) + + advanceUntilIdle() + + assertFalse(viewModel.uiState.value.isLoading) + assertEquals(1, viewModel.uiState.value.sessions.size) + assertEquals("session-123", viewModel.uiState.value.sessions[0].id) + assertEquals("Session 1", viewModel.uiState.value.sessions[0].title) + assertEquals(5, viewModel.uiState.value.sessions[0].message_count) + assertEquals("active", viewModel.uiState.value.sessions[0].status) + } + + @Test + fun testSessionsLoad_failure() = + runTest { + coEvery { mockApiService.getSessions() } returns createErrorResponse(500) + + val viewModel = SessionsViewModel() + viewModel.loadSessions() + advanceUntilIdle() + + assertFalse(viewModel.uiState.value.isLoading) + assertNotNull(viewModel.uiState.value.errorMessage) + assertTrue( + viewModel.uiState.value.errorMessage!! + .contains("HTTP 500"), + ) + } + @Test fun testCronJobsRefresh() = runTest {