diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt index 9641c574..feb8bb8c 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.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(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 { + 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..dd5bdef9 --- /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.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), + ) + } + } + } + } + } +} 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 9c07d5a5..d8f7360d 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 @@ -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",