From 4327389b3222ac135a89ac26f5c7a83699de9c1c Mon Sep 17 00:00:00 2001 From: ronia Date: Fri, 19 Jun 2026 16:48:16 +0300 Subject: [PATCH 1/2] feat: dedicated Sessions browser screen (Issue #40) --- .../java/com/m57/hermescontrol/Navigation.kt | 14 ++ .../com/m57/hermescontrol/NavigationKeys.kt | 2 + .../ui/sessions/SessionsScreen.kt | 189 ++++++++++++++++++ .../ui/sessions/SessionsViewModel.kt | 89 +++++++++ .../ui/settings/SettingsViewModel.kt | 1 + 5 files changed, 295 insertions(+) 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 c2722382..f1b063e8 100644 --- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt +++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt @@ -25,6 +25,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.Forum import androidx.compose.material.icons.filled.HistoryEdu import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Key @@ -80,6 +81,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 SessionsScreenContent 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 @@ -101,6 +103,7 @@ private val ALL_NAV_ITEMS: List = BottomNavItem(SkillsScreen, "Skills", Icons.Filled.Extension), BottomNavItem(CronJobsScreen, "Cron", Icons.Filled.Schedule), BottomNavItem(SystemScreen, "System", Icons.Filled.Info), + BottomNavItem(SessionsScreen, "Sessions", Icons.Filled.Forum), BottomNavItem(SettingsScreen, "Settings", Icons.Filled.Settings), BottomNavItem(ProfilesScreen, "Profiles", Icons.Filled.AccountCircle), BottomNavItem(WebhooksScreen, "Webhooks", Icons.Filled.Webhook), @@ -159,6 +162,7 @@ private val DRAWER_ENTRIES = DrawerEntry(KeysScreen, "Keys", Icons.Filled.Key, DrawerSection.CONFIGURE), DrawerEntry(ChannelsScreen, "Channels", Icons.AutoMirrored.Filled.ListAlt, DrawerSection.CONFIGURE), // Inspect + DrawerEntry(SessionsScreen, "Sessions", Icons.Filled.Forum, DrawerSection.INSPECT), DrawerEntry(LogsScreen, "Logs", Icons.Filled.HistoryEdu, DrawerSection.INSPECT), DrawerEntry(KanbanScreen, "Kanban", Icons.Filled.Dashboard, DrawerSection.INSPECT), DrawerEntry(AchievementsScreen, "Achievements", Icons.Filled.Info, DrawerSection.INSPECT), @@ -431,6 +435,16 @@ fun MainNavigation() { ) } + entry { + SessionsScreenContent( + onOpenDrawer = { openDrawer() }, + onSwitchToSession = { sessionId, sessionTitle -> + backStack.removeAll { it == SessionsScreen } + NavigationController.navigateTo(ChatScreen) + }, + ) + } + entry { KanbanScreenContent( onOpenDrawer = { openDrawer() }, diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt index 00ef45e7..ad41e4e5 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt @@ -41,4 +41,6 @@ import kotlinx.serialization.Serializable @Serializable data object SystemScreen : NavKey +@Serializable data object SessionsScreen : NavKey + @Serializable data object KanbanScreen : NavKey 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..8af17403 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -0,0 +1,189 @@ +package com.m57.hermescontrol.ui.sessions + +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +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.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.data.model.SessionInfo +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.StatusBadge +import com.m57.hermescontrol.ui.common.StatusBadgeType +import com.m57.hermescontrol.ui.common.listContentPadding +import com.m57.hermescontrol.ui.common.listItemSpacing + +@Composable +fun SessionsScreen( + modifier: Modifier = Modifier, + onOpenDrawer: (() -> Unit)? = null, + onSwitchToSession: ((sessionId: String, sessionTitle: String?) -> Unit)? = null, + viewModel: SessionsViewModel = viewModel { SessionsViewModel() }, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val spacing = LocalSpacing.current + + LaunchedEffect(Unit) { + viewModel.loadSessions() + } + + LaunchedEffect(state.toastMessage) { + state.toastMessage?.let { msg -> + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() + viewModel.clearToast() + } + } + + HermesScaffold( + title = { Text("Sessions") }, + onOpenDrawer = onOpenDrawer, + isRefreshing = state.isLoading, + onRefresh = { viewModel.loadSessions() }, + ) { paddingValues -> + when { + state.isLoading && state.sessions.isEmpty() -> { + LoadingState(modifier = Modifier.padding(paddingValues)) + } + state.errorMessage != null -> { + ErrorState( + message = state.errorMessage ?: "", + onRetry = { viewModel.loadSessions() }, + modifier = Modifier.padding(paddingValues), + ) + } + state.sessions.isEmpty() -> { + EmptyState( + title = "No sessions yet", + subtitle = "Start a conversation to create a session.", + modifier = Modifier.padding(paddingValues), + ) + } + else -> { + LazyColumn( + modifier = + modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = listContentPadding(), + verticalArrangement = listItemSpacing(), + ) { + items(state.sessions, key = { it.id }) { session -> + SessionCard( + session = session, + onClick = { + onSwitchToSession?.invoke(session.id, session.title) + }, + ) + } + } + } + } + } +} + +@Composable +private fun SessionCard( + session: SessionInfo, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + onClick = onClick, + modifier = modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + // Title + Text( + text = session.title ?: "Untitled Session", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(4.dp)) + // ID + status + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = session.id.take(12) + "...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + session.status?.let { status -> + StatusBadge( + text = status, + status = + when (status.lowercase()) { + "active" -> StatusBadgeType.SUCCESS + "paused" -> StatusBadgeType.WARNING + else -> StatusBadgeType.DEFAULT + }, + ) + } + } + Spacer(modifier = Modifier.height(4.dp)) + // Metadata row + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + session.message_count?.let { count -> + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "$count messages", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + session.created_at?.let { date -> + Text( + text = date.take(10), // YYYY-MM-DD + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + } + } + } + } +} 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..a74d5de3 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt @@ -0,0 +1,89 @@ +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 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, + val toastMessage: 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 { + try { + val response = + withContext(Dispatchers.IO) { + ApiClient.hermesApi.getSessions() + } + if (response.isSuccessful) { + _uiState.update { + it.copy( + isLoading = false, + sessions = response.body()?.sessions.orEmpty(), + ) + } + } else { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "Failed to load sessions: HTTP ${response.code()}", + ) + } + } + } catch (e: Exception) { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "Failed to load sessions: ${e.message}", + ) + } + } + } + } + + fun deleteSession(id: String) { + val originalSessions = _uiState.value.sessions + // Optimistic removal + _uiState.update { state -> + state.copy( + sessions = state.sessions.filter { it.id != id }, + ) + } + viewModelScope.launch { + try { + // The Hermes API uses PATCH to switch session, not delete. + // Session deletion is not directly available in the current + // API surface, so we optimistically remove from the list + // and reload to reconcile. + loadSessions() + } catch (e: Exception) { + _uiState.update { + it.copy( + sessions = originalSessions, + toastMessage = "Failed to remove session: ${e.message}", + ) + } + } + } + } + + fun clearToast() { + _uiState.update { it.copy(toastMessage = null) } + } +} 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 bd3ff509..7225baf4 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 @@ -76,6 +76,7 @@ class SettingsViewModel : ViewModel() { "SkillsScreen" to "Skills", "CronJobsScreen" to "Cron Jobs", "SystemScreen" to "System", + "SessionsScreen" to "Sessions", "SettingsScreen" to "Settings", "ProfilesScreen" to "Profiles", "WebhooksScreen" to "Webhooks", From 402664b6f2ff944c5c2e1655e195a17b552bbd4c Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 19 Jun 2026 23:09:55 +0300 Subject: [PATCH 2/2] fix: fix compilation errors in SessionsScreen.kt --- .../com/m57/hermescontrol/ui/sessions/SessionsScreen.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 8af17403..dd5bdef9 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 @@ -89,8 +89,8 @@ fun SessionsScreen( modifier .fillMaxSize() .padding(paddingValues), - contentPadding = listContentPadding(), - verticalArrangement = listItemSpacing(), + contentPadding = listContentPadding, + verticalArrangement = listItemSpacing, ) { items(state.sessions, key = { it.id }) { session -> SessionCard( @@ -155,7 +155,7 @@ private fun SessionCard( when (status.lowercase()) { "active" -> StatusBadgeType.SUCCESS "paused" -> StatusBadgeType.WARNING - else -> StatusBadgeType.DEFAULT + else -> StatusBadgeType.NEUTRAL }, ) }