diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt index 6beef402..ef7cbb91 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt @@ -15,4 +15,28 @@ data class SessionInfo( val status: String?, val preview: String? = null, val started_at: Double? = null, + val source: String? = null, +) + +data class SessionStatsResponse( + val total: Int = 0, + val active: Int = 0, +) + +data class SessionRenameRequest( + val title: String, +) + +data class BulkDeleteRequest( + val ids: List, + val delete_all: Boolean = false, +) + +data class PruneRequest( + val days: Int, +) + +data class SessionPromptResponse( + val prompt: String? = null, + val id: String? = null, ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 0b7ec176..85e470f5 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -5,6 +5,7 @@ import com.m57.hermescontrol.data.model.AchievementsResponse import com.m57.hermescontrol.data.model.ActiveProfileResponse import com.m57.hermescontrol.data.model.AgentPluginInstallBody import com.m57.hermescontrol.data.model.AuxiliaryModelsResponse +import com.m57.hermescontrol.data.model.BulkDeleteRequest import com.m57.hermescontrol.data.model.ConfigSchemaResponse import com.m57.hermescontrol.data.model.ConfigUpdateRequest import com.m57.hermescontrol.data.model.CreateCronJobRequest @@ -37,12 +38,16 @@ import com.m57.hermescontrol.data.model.PluginProvidersPutRequest import com.m57.hermescontrol.data.model.PluginsHubResponse import com.m57.hermescontrol.data.model.ProfileSoulResponse import com.m57.hermescontrol.data.model.ProfilesResponse +import com.m57.hermescontrol.data.model.PruneRequest import com.m57.hermescontrol.data.model.RawConfigResponse import com.m57.hermescontrol.data.model.RecentUnlock import com.m57.hermescontrol.data.model.SaveSkillContentRequest import com.m57.hermescontrol.data.model.ScanStatus import com.m57.hermescontrol.data.model.SessionListResponse import com.m57.hermescontrol.data.model.SessionMessagesResponse +import com.m57.hermescontrol.data.model.SessionPromptResponse +import com.m57.hermescontrol.data.model.SessionRenameRequest +import com.m57.hermescontrol.data.model.SessionStatsResponse import com.m57.hermescontrol.data.model.SetActiveProfileRequest import com.m57.hermescontrol.data.model.Skill import com.m57.hermescontrol.data.model.SkillContentResponse @@ -95,6 +100,35 @@ interface HermesApiService { @Path("id") sessionId: String, ): Response + @GET("api/sessions/stats") + suspend fun getSessionStats(): Response + + @PUT("api/sessions/{id}/rename") + suspend fun renameSession( + @Path("id") sessionId: String, + @Body body: SessionRenameRequest, + ): Response + + @POST("api/sessions/delete") + suspend fun bulkDeleteSessions( + @Body body: BulkDeleteRequest, + ): Response + + @DELETE("api/sessions/{id}") + suspend fun deleteSession( + @Path("id") sessionId: String, + ): Response + + @POST("api/sessions/prune") + suspend fun pruneSessions( + @Body body: PruneRequest, + ): Response + + @GET("api/sessions/{id}/prompt") + suspend fun getSessionPrompt( + @Path("id") sessionId: String, + ): Response + @GET("api/system/stats") suspend fun getSystemStats(): Response 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 fa5ee796..aa9b69a4 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 @@ -1,10 +1,20 @@ package com.m57.hermescontrol.ui.sessions +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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 @@ -12,13 +22,34 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.DeleteSweep import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.SelectAll +import androidx.compose.material.icons.filled.Send +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -27,16 +58,25 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp 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.R +import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalSpacing import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.ErrorState @@ -44,9 +84,77 @@ import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.LoadingState import com.m57.hermescontrol.ui.common.NavIcon import com.m57.hermescontrol.ui.common.SearchBar +import com.m57.hermescontrol.ui.common.StatCard +import com.m57.hermescontrol.ui.common.StatusBadge +import com.m57.hermescontrol.ui.common.StatusBadgeType +import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.common.listContentPadding import com.m57.hermescontrol.ui.common.listItemSpacing +/** + * Maps a session source string to a Material icon for visual identification. + */ +private fun sourceIcon(source: String?): ImageVector? = + when (source?.lowercase()) { + "telegram", "tg" -> Icons.Filled.Send + "web", "dashboard" -> Icons.Filled.Language + "api", "rest" -> Icons.Filled.Code + "cli", "terminal" -> Icons.Filled.Terminal + else -> null + } + +/** + * Maps a source string to a label for tooltip / accessibility. + */ +private fun sourceLabel(source: String?): String = + when (source?.lowercase()) { + "telegram", "tg" -> "Telegram" + "web", "dashboard" -> "Web" + "api", "rest" -> "API" + "cli", "terminal" -> "CLI" + else -> source ?: "Unknown" + } + +/** + * Builds an annotated string with search term highlighting. + */ +private fun highlightText( + text: String, + query: String, + highlightBackground: Color, + highlightForeground: Color, +): AnnotatedString = + buildAnnotatedString { + if (query.isBlank()) { + append(text) + return@buildAnnotatedString + } + val lowerText = text.lowercase() + val lowerQuery = query.lowercase() + var currentIndex = 0 + while (currentIndex < text.length) { + val matchIndex = lowerText.indexOf(lowerQuery, currentIndex) + if (matchIndex == -1) { + append(text.substring(currentIndex)) + break + } + if (matchIndex > currentIndex) { + append(text.substring(currentIndex, matchIndex)) + } + withStyle( + SpanStyle( + background = highlightBackground, + color = highlightForeground, + fontWeight = FontWeight.Bold, + ), + ) { + append(text.substring(matchIndex, matchIndex + query.length)) + } + currentIndex = matchIndex + query.length + } + } + +@OptIn(ExperimentalFoundationApi::class) @Composable fun SessionsScreen( modifier: Modifier = Modifier, @@ -55,8 +163,12 @@ fun SessionsScreen( ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val spacing = LocalSpacing.current + val statusColors = LocalHermesStatusColors.current + val primaryContainer = MaterialTheme.colorScheme.primaryContainer + val onPrimaryContainer = MaterialTheme.colorScheme.onPrimaryContainer var query by remember { mutableStateOf("") } + var pruneDays by remember { mutableStateOf("7") } val filteredSessions = remember(query, state.sessions) { @@ -66,8 +178,129 @@ fun SessionsScreen( } } + val hasSelection = state.selectedIds.isNotEmpty() + LaunchedEffect(Unit) { viewModel.loadSessions() + viewModel.loadStats() + } + + // Toast effect + ToastEffect( + toastMessage = state.toastMessage, + onClearToast = { viewModel.clearToast() }, + ) + + // Prune dialog + if (state.showPruneDialog) { + AlertDialog( + onDismissRequest = { viewModel.hidePruneDialog() }, + title = { Text(stringResource(R.string.sessions_prune_title)) }, + text = { + Column { + Text(stringResource(R.string.sessions_prune_desc)) + Spacer(modifier = Modifier.height(spacing.md)) + OutlinedTextField( + value = pruneDays, + onValueChange = { pruneDays = it.filter { c -> c.isDigit() } }, + label = { Text(stringResource(R.string.sessions_prune_days_label)) }, + placeholder = { Text("7") }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = + KeyboardActions( + onDone = { + val days = pruneDays.toIntOrNull() + if (days != null && days > 0) viewModel.pruneSessions(days) + }, + ), + ) + } + }, + confirmButton = { + Button( + onClick = { + val days = pruneDays.toIntOrNull() + if (days != null && days > 0) viewModel.pruneSessions(days) + }, + colors = + ButtonDefaults.buttonColors( + containerColor = statusColors.error, + ), + ) { + Text(stringResource(R.string.sessions_prune_confirm)) + } + }, + dismissButton = { + TextButton(onClick = { viewModel.hidePruneDialog() }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } + + // Single-session delete confirmation dialog + if (state.sessionToDeleteConfirm != null) { + val sessionToDelete = state.sessionToDeleteConfirm + val sessionTitle = + state.sessions + .find { it.id == sessionToDelete } + ?.title?.takeIf { it.isNotBlank() } ?: stringResource(R.string.history_untitled) + AlertDialog( + onDismissRequest = { viewModel.cancelDeleteSession() }, + title = { Text(stringResource(R.string.sessions_delete_title)) }, + text = { + Text(stringResource(R.string.sessions_delete_message, sessionTitle)) + }, + confirmButton = { + Button( + onClick = { viewModel.confirmDeleteSession() }, + colors = + ButtonDefaults.buttonColors( + containerColor = statusColors.error, + ), + ) { + Text(stringResource(R.string.action_delete)) + } + }, + dismissButton = { + TextButton(onClick = { viewModel.cancelDeleteSession() }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } + + // Bulk delete confirmation dialog + if (state.showBulkDeleteConfirm) { + AlertDialog( + onDismissRequest = { viewModel.cancelBulkDelete() }, + title = { Text(stringResource(R.string.sessions_bulk_delete_title)) }, + text = { + Text( + stringResource( + R.string.sessions_bulk_delete_message, + state.selectedIds.size, + ), + ) + }, + confirmButton = { + Button( + onClick = { viewModel.confirmBulkDelete() }, + colors = + ButtonDefaults.buttonColors( + containerColor = statusColors.error, + ), + ) { + Text(stringResource(R.string.action_delete)) + } + }, + dismissButton = { + TextButton(onClick = { viewModel.cancelBulkDelete() }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) } HermesScaffold( @@ -83,8 +316,9 @@ fun SessionsScreen( } state.errorMessage != null -> { + val errorMsg = state.errorMessage ErrorState( - message = state.errorMessage ?: stringResource(R.string.error_unknown), + message = errorMsg ?: stringResource(R.string.error_unknown), onRetry = { viewModel.loadSessions() }, ) } @@ -98,96 +332,159 @@ fun SessionsScreen( } else -> { - LazyColumn( - modifier = Modifier.fillMaxWidth(), - contentPadding = listContentPadding, - verticalArrangement = listItemSpacing, - ) { - item { - SearchBar( - query = query, - onQueryChange = { query = it }, - placeholder = "Search sessions...", + Column(modifier = Modifier.fillMaxSize()) { + // ── Stats row ─────────────────────────────────────── + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = spacing.md, vertical = spacing.sm), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + StatCard( + label = stringResource(R.string.sessions_stat_total), + value = if (state.isLoadingStats) "…" else state.stats.total.toString(), + icon = Icons.Filled.History, + modifier = Modifier.weight(1f), ) - } - items(filteredSessions, key = { it.id }) { session -> + StatCard( + label = stringResource(R.string.sessions_stat_active), + value = if (state.isLoadingStats) "…" else state.stats.active.toString(), + icon = Icons.Filled.CheckCircle, + accentColor = statusColors.success, + modifier = Modifier.weight(1f), + ) + // Prune button card Card( - modifier = - Modifier - .fillMaxWidth() - .testTag("session_card_${session.id}") - .clickable(role = Role.Button) { - NavigationController.pendingSessionId = session.id - NavigationController.navigateTo(ChatScreen) - }, + modifier = Modifier.weight(1f), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceContainer, ), + onClick = { viewModel.showPruneDialog() }, ) { - Column( + Box( modifier = Modifier.padding(spacing.md), + contentAlignment = Alignment.Center, ) { - Text( - text = - session.title?.takeIf { it.isNotBlank() } - ?: stringResource(R.string.history_untitled), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - Spacer(modifier = Modifier.height(spacing.xs)) - Row( - verticalAlignment = Alignment.CenterVertically, - ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + imageVector = Icons.Filled.DeleteSweep, + contentDescription = null, + tint = statusColors.warning, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.height(spacing.xs)) Text( - text = - stringResource( - R.string.history_message_count, - session.message_count ?: 0, - ), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + text = stringResource(R.string.sessions_action_prune), + style = MaterialTheme.typography.labelSmall, + color = statusColors.warning, ) - if (!session.status.isNullOrBlank()) { - Spacer(modifier = Modifier.width(spacing.sm)) - Text( - text = "• ${session.status}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - ) - } } } } } + // Stats error snack + val statsError = state.statsError + if (statsError != null) { + Text( + text = statsError, + style = MaterialTheme.typography.labelSmall, + color = statusColors.error, + modifier = Modifier.padding(horizontal = spacing.md), + ) + } - // Load more button - if (state.hasMore || state.isLoadingMore) { - item { - Box( - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = spacing.sm), - contentAlignment = Alignment.Center, - ) { - if (state.isLoadingMore) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp, - ) - } else { - Text( - text = "Load more sessions...", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = - Modifier - .testTag("load_more_sessions") - .clickable(role = Role.Button) { - viewModel.loadMore() - }, - ) + // ── Search + bulk toggle ──────────────────────────── + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = spacing.md), + verticalAlignment = Alignment.CenterVertically, + ) { + SearchBar( + query = query, + onQueryChange = { query = it }, + placeholder = stringResource(R.string.sessions_search_placeholder), + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(spacing.sm)) + IconButton(onClick = { viewModel.toggleSelecting() }) { + Icon( + imageVector = if (state.isSelecting) Icons.Filled.Close else Icons.Filled.SelectAll, + contentDescription = + if (state.isSelecting) { + stringResource(R.string.content_desc_exit_selection) + } else { + stringResource(R.string.content_desc_enter_selection) + }, + ) + } + } + + // ── Session list ──────────────────────────────────── + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = listContentPadding, + verticalArrangement = listItemSpacing, + ) { + items(filteredSessions, key = { it.id }) { session -> + SessionCard( + session = session, + query = query, + isSelecting = state.isSelecting, + isSelected = session.id in state.selectedIds, + isDeleting = session.id in state.deletingSessionIds, + highlightBackground = primaryContainer, + highlightForeground = onPrimaryContainer, + onCardClick = { + if (state.isSelecting) { + viewModel.toggleSessionSelection(session.id) + } else { + NavigationController.pendingSessionId = session.id + NavigationController.navigateTo(ChatScreen) + } + }, + onCardLongClick = { + if (!state.isSelecting) { + viewModel.toggleSelecting() + viewModel.toggleSessionSelection(session.id) + } + }, + onToggleSelection = { viewModel.toggleSessionSelection(session.id) }, + onDelete = { viewModel.requestDeleteSession(session.id) }, + ) + } + + // Load more + if (state.hasMore || state.isLoadingMore) { + item { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = spacing.sm), + contentAlignment = Alignment.Center, + ) { + if (state.isLoadingMore) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + ) + } else { + Text( + text = stringResource(R.string.history_load_more), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = + Modifier + .testTag("load_more_sessions") + .clickable(role = Role.Button) { + viewModel.loadMore() + }, + ) + } } } } @@ -196,4 +493,225 @@ fun SessionsScreen( } } } + + // ── Bulk action toolbar (animated) ────────────────────────────────── + AnimatedVisibility( + visible = state.isSelecting && hasSelection, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), + modifier = Modifier.fillMaxSize(), + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.BottomCenter, + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + shadowElevation = 8.dp, + tonalElevation = 4.dp, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = spacing.md, vertical = spacing.sm), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + // Select all / deselect + OutlinedButton( + onClick = { + if (state.selectedIds.size == filteredSessions.size) { + viewModel.clearSelection() + } else { + viewModel.selectAll() + } + }, + ) { + Icon( + imageVector = + if (state.selectedIds.size == filteredSessions.size) { + Icons.Filled.Close + } else { + Icons.Filled.SelectAll + }, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(spacing.xs)) + Text( + if (state.selectedIds.size == filteredSessions.size) { + stringResource(R.string.sessions_action_deselect_all) + } else { + stringResource(R.string.sessions_action_select_all) + }, + ) + } + + // Delete selected + Button( + onClick = { viewModel.requestBulkDelete() }, + enabled = !state.isDeletingBulk, + colors = + ButtonDefaults.buttonColors( + containerColor = statusColors.error, + ), + ) { + if (state.isDeletingBulk) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = Color.White, + ) + } else { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + } + Spacer(modifier = Modifier.width(spacing.xs)) + Text( + stringResource( + R.string.sessions_action_delete_n, + state.selectedIds.size, + ), + ) + } + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun SessionCard( + session: com.m57.hermescontrol.data.model.SessionInfo, + query: String, + isSelecting: Boolean, + isSelected: Boolean, + isDeleting: Boolean, + highlightBackground: Color, + highlightForeground: Color, + onCardClick: () -> Unit, + onCardLongClick: () -> Unit, + onToggleSelection: () -> Unit, + onDelete: () -> Unit, +) { + val spacing = LocalSpacing.current + val statusColors = LocalHermesStatusColors.current + val displayTitle = session.title?.takeIf { it.isNotBlank() } ?: stringResource(R.string.history_untitled) + val isActive = session.status?.lowercase() == "active" || session.status?.lowercase() == "streaming" + val srcIcon = sourceIcon(session.source) + + Card( + modifier = + Modifier + .fillMaxWidth() + .testTag("session_card_${session.id}") + .combinedClickable( + onClick = onCardClick, + onLongClick = onCardLongClick, + ), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + border = + if (isActive && !isSelecting) { + BorderStroke(2.dp, statusColors.success) + } else if (isSelected) { + BorderStroke(2.dp, MaterialTheme.colorScheme.primary) + } else { + null + }, + ) { + Row( + modifier = Modifier.padding(spacing.md), + verticalAlignment = Alignment.CenterVertically, + ) { + // Checkbox in select mode + if (isSelecting) { + Checkbox( + checked = isSelected, + onCheckedChange = { onToggleSelection() }, + modifier = Modifier.testTag("session_checkbox_${session.id}"), + ) + Spacer(modifier = Modifier.width(spacing.sm)) + } + + // Source icon + if (srcIcon != null && !isSelecting) { + Icon( + imageVector = srcIcon, + contentDescription = sourceLabel(session.source), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(spacing.sm)) + } + + // Main content + Column(modifier = Modifier.weight(1f)) { + Text( + text = + if (query.isNotBlank()) { + highlightText(displayTitle, query, highlightBackground, highlightForeground) + } else { + AnnotatedString(displayTitle) + }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.height(spacing.xs)) + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.history_message_count, session.message_count ?: 0), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (!session.status.isNullOrBlank()) { + Spacer(modifier = Modifier.width(spacing.sm)) + StatusBadge( + text = session.status, + status = if (isActive) StatusBadgeType.SUCCESS else StatusBadgeType.NEUTRAL, + ) + } + } + } + + // Action buttons (not in select mode) + if (!isSelecting) { + Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) { + // Delete + if (isDeleting) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + } else { + IconButton( + onClick = onDelete, + modifier = Modifier.size(32.dp), + ) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = stringResource(R.string.action_delete), + modifier = Modifier.size(16.dp), + tint = statusColors.error, + ) + } + } + } + } + } + } } 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 index 25a1c20f..54a900fa 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt @@ -2,8 +2,12 @@ package com.m57.hermescontrol.ui.sessions import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.model.BulkDeleteRequest +import com.m57.hermescontrol.data.model.PruneRequest import com.m57.hermescontrol.data.model.SessionInfo +import com.m57.hermescontrol.data.model.SessionRenameRequest import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.ui.common.safeLaunchLoad import kotlinx.coroutines.Job @@ -13,12 +17,30 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +data class SessionStats( + val total: Int = 0, + val active: Int = 0, +) + data class SessionsUiState( val isLoading: Boolean = false, val isLoadingMore: Boolean = false, val sessions: List = emptyList(), val total: Int = 0, val errorMessage: String? = null, + val stats: SessionStats = SessionStats(), + val isLoadingStats: Boolean = false, + val statsError: String? = null, + val isSelecting: Boolean = false, + val selectedIds: Set = emptySet(), + val renamingSessionId: String? = null, + val deletingSessionIds: Set = emptySet(), + val showPruneDialog: Boolean = false, + val isPruning: Boolean = false, + val isDeletingBulk: Boolean = false, + val toastMessage: String? = null, + val sessionToDeleteConfirm: String? = null, + val showBulkDeleteConfirm: Boolean = false, ) { val hasMore: Boolean get() = total > sessions.size } @@ -28,6 +50,7 @@ class SessionsViewModel : ViewModel() { val uiState: StateFlow = _uiState.asStateFlow() private var loadJob: Job? = null + private var statsJob: Job? = null /** Page size sent to the server — matches the default the gateway uses. */ private companion object { @@ -56,6 +79,7 @@ class SessionsViewModel : ViewModel() { isLoadingMore = false, sessions = data?.sessions.orEmpty(), total = data?.total ?: 0, + selectedIds = emptySet(), ) } }, @@ -87,7 +111,7 @@ class SessionsViewModel : ViewModel() { ) } when (result) { - is com.m57.hermescontrol.data.remote.NetworkResult.Success -> { + is NetworkResult.Success -> { val data = result.data _uiState.update { it.copy( @@ -98,7 +122,7 @@ class SessionsViewModel : ViewModel() { } } - is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> { + is NetworkResult.Failure -> { _uiState.update { it.copy( isLoadingMore = false, @@ -109,4 +133,286 @@ class SessionsViewModel : ViewModel() { } } } + + // ── Stats ──────────────────────────────────────────────────────────── + + fun loadStats() { + statsJob = + safeLaunchLoad( + currentJob = statsJob, + apiCall = { + safeApiCall { ApiClient.hermesApi.getSessionStats() } + }, + onStart = { _uiState.update { it.copy(isLoadingStats = true, statsError = null) } }, + onSuccess = { data -> + _uiState.update { + it.copy( + isLoadingStats = false, + stats = + data?.let { SessionStats(total = it.total, active = it.active) } + ?: SessionStats(), + ) + } + }, + onError = { errorMsg -> + _uiState.update { + it.copy( + isLoadingStats = false, + statsError = errorMsg, + ) + } + }, + ) + } + + // ── Bulk selection ─────────────────────────────────────────────────── + + fun toggleSelecting() { + _uiState.update { + it.copy( + isSelecting = !it.isSelecting, + selectedIds = if (it.isSelecting) emptySet() else it.selectedIds, + ) + } + } + + fun exitSelecting() { + _uiState.update { it.copy(isSelecting = false, selectedIds = emptySet()) } + } + + fun toggleSessionSelection(id: String) { + _uiState.update { + val updated = it.selectedIds.toMutableSet() + if (updated.contains(id)) updated.remove(id) else updated.add(id) + it.copy(selectedIds = updated) + } + } + + fun selectAll() { + _uiState.update { + it.copy(selectedIds = it.sessions.map { s -> s.id }.toSet()) + } + } + + fun clearSelection() { + _uiState.update { it.copy(selectedIds = emptySet()) } + } + + // ── Rename ─────────────────────────────────────────────────────────── + + fun startRenaming(sessionId: String) { + _uiState.update { it.copy(renamingSessionId = sessionId) } + } + + fun cancelRenaming() { + _uiState.update { it.copy(renamingSessionId = null) } + } + + fun renameSession( + sessionId: String, + newTitle: String, + ) { + if (newTitle.isBlank()) { + _uiState.update { it.copy(renamingSessionId = null, toastMessage = "Title cannot be empty") } + return + } + viewModelScope.launch { + val result = + safeApiCall { + ApiClient.hermesApi.renameSession( + sessionId = sessionId, + body = SessionRenameRequest(title = newTitle), + ) + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + renamingSessionId = null, + sessions = + it.sessions.map { s -> + if (s.id == sessionId) s.copy(title = newTitle) else s + }, + toastMessage = "Session renamed", + ) + } + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + renamingSessionId = null, + toastMessage = "Rename failed: ${result.error.message}", + ) + } + } + } + } + } + + // ── Copy prompt ────────────────────────────────────────────────────── + + fun copySessionPrompt(sessionId: String) { + viewModelScope.launch { + val result = + safeApiCall { + ApiClient.hermesApi.getSessionPrompt(sessionId) + } + when (result) { + is NetworkResult.Success -> { + val promptText = result.data?.prompt ?: "No prompt available" + _uiState.update { it.copy(toastMessage = promptText) } + } + + is NetworkResult.Failure -> { + _uiState.update { it.copy(toastMessage = "Failed to get prompt: ${result.error.message}") } + } + } + } + } + + // ── Delete (single) ────────────────────────────────────────────────── + + fun requestDeleteSession(sessionId: String) { + _uiState.update { it.copy(sessionToDeleteConfirm = sessionId) } + } + + fun cancelDeleteSession() { + _uiState.update { it.copy(sessionToDeleteConfirm = null) } + } + + fun confirmDeleteSession() { + val sessionId = _uiState.value.sessionToDeleteConfirm ?: return + _uiState.update { + it.copy( + sessionToDeleteConfirm = null, + deletingSessionIds = it.deletingSessionIds + sessionId, + ) + } + viewModelScope.launch { + val result = + safeApiCall { + ApiClient.hermesApi.deleteSession(sessionId) + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + deletingSessionIds = it.deletingSessionIds - sessionId, + sessions = it.sessions.filter { s -> s.id != sessionId }, + total = it.total - 1, + toastMessage = "Session deleted", + ) + } + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + deletingSessionIds = it.deletingSessionIds - sessionId, + toastMessage = "Delete failed: ${result.error.message}", + ) + } + } + } + } + } + + // ── Bulk delete ────────────────────────────────────────────────────── + + fun requestBulkDelete() { + _uiState.update { it.copy(showBulkDeleteConfirm = true) } + } + + fun cancelBulkDelete() { + _uiState.update { it.copy(showBulkDeleteConfirm = false) } + } + + fun confirmBulkDelete() { + val ids = _uiState.value.selectedIds.toList() + if (ids.isEmpty()) return + + _uiState.update { it.copy(showBulkDeleteConfirm = false, isDeletingBulk = true) } + viewModelScope.launch { + val result = + safeApiCall { + ApiClient.hermesApi.bulkDeleteSessions( + body = BulkDeleteRequest(ids = ids), + ) + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + isDeletingBulk = false, + isSelecting = false, + selectedIds = emptySet(), + sessions = it.sessions.filter { s -> s.id !in ids }, + total = it.total - ids.size, + toastMessage = "${ids.size} session(s) deleted", + ) + } + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + isDeletingBulk = false, + toastMessage = "Delete failed: ${result.error.message}", + ) + } + } + } + } + } + + // ── Prune ──────────────────────────────────────────────────────────── + + fun showPruneDialog() { + _uiState.update { it.copy(showPruneDialog = true) } + } + + fun hidePruneDialog() { + _uiState.update { it.copy(showPruneDialog = false) } + } + + fun pruneSessions(days: Int) { + if (days < 1) return + _uiState.update { it.copy(isPruning = true, showPruneDialog = false) } + viewModelScope.launch { + val result = + safeApiCall { + ApiClient.hermesApi.pruneSessions( + body = PruneRequest(days = days), + ) + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + isPruning = false, + toastMessage = "Old sessions pruned", + ) + } + loadSessions() + loadStats() + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + isPruning = false, + toastMessage = "Prune failed: ${result.error.message}", + ) + } + } + } + } + } + + // ── Toast ──────────────────────────────────────────────────────────── + + fun clearToast() { + _uiState.update { it.copy(toastMessage = null) } + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a13c3319..ce479921 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -293,6 +293,28 @@ Previous chat sessions from Hermes will appear here. Untitled %1$d messages + Load more sessions… + + + Search sessions… + Total + Active + Prune + Prune Old Sessions + Delete sessions older than the specified number of days. + Days threshold + Prune + Delete Session? + Are you sure you want to delete \"%1$s\"? This can\'t be undone. + Delete Sessions? + Are you sure you want to delete %1$d session(s)? This can\'t be undone. + Select All + Deselect + Delete %1$d + Copy prompt + New title… + Select sessions + Exit selection No toolsets reported