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
This commit is contained in:
M57 authored and GitHub committed 2026-06-20 22:27:54 +03:00
1 parent 34d339e6c9
commit 64a047f095
8 files changed
+250 -79

No files matched your search

@@ -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> =
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<HistoryScreen> {
HistoryScreenContent(
onOpenDrawer = { openDrawer() },
)
}
entry<SettingsScreen> {
SettingsScreenContent(
onBack = { NavigationController.goBack() },
@@ -16,6 +16,7 @@ import androidx.navigation3.runtime.NavKey
*/
object NavigationController {
var backStack: NavBackStack<NavKey>? = null
var pendingSessionId: String? = null
// Bottom-nav primary screens — dynamic, updated by Navigation.kt via
// updatePrimaryScreens() when the user customises the bottom nav bar.
@@ -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
@@ -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(
@@ -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,
)
}
}
}
}
}
}
}
}
}
}
@@ -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<SessionInfo> = emptyList(),
val errorMessage: String? = null,
)
class SessionsViewModel : ViewModel() {
private val _uiState = MutableStateFlow(SessionsUiState())
val uiState: StateFlow<SessionsUiState> = _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}",
)
}
}
}
}
}
}
@@ -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",