Merge pull request #42 from Hy4ri/feat/sessions-screen

feat: dedicated Sessions browser screen (Issue #40)
This commit is contained in:
M57 authored and GitHub committed 2026-06-19 23:14:40 +03:00
commit 7d50b5acd7
5 files changed
+295

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.Forum
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 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
@@ -103,6 +105,7 @@ private val ALL_NAV_ITEMS: List<BottomNavItem> =
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),
@@ -163,6 +166,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),
@@ -442,6 +446,16 @@ fun MainNavigation() {
)
}
entry<SessionsScreen> {
SessionsScreenContent(
onOpenDrawer = { openDrawer() },
onSwitchToSession = { sessionId, sessionTitle ->
backStack.removeAll { it == SessionsScreen }
NavigationController.navigateTo(ChatScreen)
},
)
}
entry<KanbanScreen> {
KanbanScreenContent(
onOpenDrawer = { openDrawer() },
@@ -41,4 +41,6 @@ import kotlinx.serialization.Serializable
@Serializable data object SystemScreen : NavKey
@Serializable data object SessionsScreen : NavKey
@Serializable data object KanbanScreen : NavKey
@@ -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.NEUTRAL
},
)
}
}
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),
)
}
}
}
}
}
}
@@ -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<SessionInfo> = emptyList(),
val errorMessage: String? = null,
val toastMessage: 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 {
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) }
}
}
@@ -78,6 +78,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",