feat(#439): enrich sessions tab with dashboard features (#460)

* feat(#439): enrich sessions tab with dashboard features

Add session stats (total/active counts), per-session source icons,
active session badges, inline rename, bulk selection with toolbar,
prune old sessions dialog with configurable days threshold,
session prompt copy, single-session delete, and search highlighting.

- SessionInfo model: add source field
- New models: SessionStatsResponse, SessionRenameRequest,
  BulkDeleteRequest, PruneRequest, SessionPromptResponse
- HermesApiService: add stats, rename, bulk delete, prune, prompt endpoints
- SessionsViewModel: stats loading, bulk select, rename, delete, prune
- SessionsScreen: stats row, source icons, active border, rename inline,
  bulk mode with animated toolbar, prune dialog, search highlight
- strings.xml: new resources for all sessions UI features

* fix(#439): resolve CI compile errors

- Add explicit icon imports for Code, Language, Terminal
- Add missing clickable import
- Make highlightText non-composable by passing color params
- Fix combinedClickable usage on SessionCard

* fix(#439): address review findings

- Remove unused background import
- Add dedicated DELETE /api/sessions/{id} endpoint
- Refactor deleteSession to use dedicated endpoint
- Add confirmation dialogs for both single-session and bulk delete
- Surface statsError in the UI when stats load fails
- Add string resources for delete confirmation dialogs

* fix(#439): fix delegated property smart cast errors

- Capture statsError, errorMessage, sessionToDeleteConfirm in local vals
  before null checks to avoid 'Smart cast impossible' on delegated props

* fix(#439): remove copy and rename buttons, keep delete only

* fix(#439): restore CheckCircle import

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-03 17:55:26 +03:00
1 parent 41fde55c11
commit e2349eca22
5 files changed
+981 -77

No files matched your search

@@ -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<String>,
val delete_all: Boolean = false,
)
data class PruneRequest(
val days: Int,
)
data class SessionPromptResponse(
val prompt: String? = null,
val id: String? = null,
)
@@ -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<SessionMessagesResponse>
@GET("api/sessions/stats")
suspend fun getSessionStats(): Response<SessionStatsResponse>
@PUT("api/sessions/{id}/rename")
suspend fun renameSession(
@Path("id") sessionId: String,
@Body body: SessionRenameRequest,
): Response<Unit>
@POST("api/sessions/delete")
suspend fun bulkDeleteSessions(
@Body body: BulkDeleteRequest,
): Response<Unit>
@DELETE("api/sessions/{id}")
suspend fun deleteSession(
@Path("id") sessionId: String,
): Response<Unit>
@POST("api/sessions/prune")
suspend fun pruneSessions(
@Body body: PruneRequest,
): Response<Unit>
@GET("api/sessions/{id}/prompt")
suspend fun getSessionPrompt(
@Path("id") sessionId: String,
): Response<SessionPromptResponse>
@GET("api/system/stats")
suspend fun getSystemStats(): Response<SystemStatsResponse>
@@ -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,
)
}
}
}
}
}
}
}
@@ -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<SessionInfo> = 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<String> = emptySet(),
val renamingSessionId: String? = null,
val deletingSessionIds: Set<String> = 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<SessionsUiState> = _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) }
}
}