* 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:
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 status: String?,
|
||||||
val preview: String? = null,
|
val preview: String? = null,
|
||||||
val started_at: Double? = 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.ActiveProfileResponse
|
||||||
import com.m57.hermescontrol.data.model.AgentPluginInstallBody
|
import com.m57.hermescontrol.data.model.AgentPluginInstallBody
|
||||||
import com.m57.hermescontrol.data.model.AuxiliaryModelsResponse
|
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.ConfigSchemaResponse
|
||||||
import com.m57.hermescontrol.data.model.ConfigUpdateRequest
|
import com.m57.hermescontrol.data.model.ConfigUpdateRequest
|
||||||
import com.m57.hermescontrol.data.model.CreateCronJobRequest
|
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.PluginsHubResponse
|
||||||
import com.m57.hermescontrol.data.model.ProfileSoulResponse
|
import com.m57.hermescontrol.data.model.ProfileSoulResponse
|
||||||
import com.m57.hermescontrol.data.model.ProfilesResponse
|
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.RawConfigResponse
|
||||||
import com.m57.hermescontrol.data.model.RecentUnlock
|
import com.m57.hermescontrol.data.model.RecentUnlock
|
||||||
import com.m57.hermescontrol.data.model.SaveSkillContentRequest
|
import com.m57.hermescontrol.data.model.SaveSkillContentRequest
|
||||||
import com.m57.hermescontrol.data.model.ScanStatus
|
import com.m57.hermescontrol.data.model.ScanStatus
|
||||||
import com.m57.hermescontrol.data.model.SessionListResponse
|
import com.m57.hermescontrol.data.model.SessionListResponse
|
||||||
import com.m57.hermescontrol.data.model.SessionMessagesResponse
|
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.SetActiveProfileRequest
|
||||||
import com.m57.hermescontrol.data.model.Skill
|
import com.m57.hermescontrol.data.model.Skill
|
||||||
import com.m57.hermescontrol.data.model.SkillContentResponse
|
import com.m57.hermescontrol.data.model.SkillContentResponse
|
||||||
@@ -95,6 +100,35 @@ interface HermesApiService {
|
|||||||
@Path("id") sessionId: String,
|
@Path("id") sessionId: String,
|
||||||
): Response<SessionMessagesResponse>
|
): 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")
|
@GET("api/system/stats")
|
||||||
suspend fun getSystemStats(): Response<SystemStatsResponse>
|
suspend fun getSystemStats(): Response<SystemStatsResponse>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
package com.m57.hermescontrol.ui.sessions
|
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.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
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.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
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.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.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.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
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.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -27,16 +58,25 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.Role
|
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.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.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.m57.hermescontrol.ChatScreen
|
import com.m57.hermescontrol.ChatScreen
|
||||||
import com.m57.hermescontrol.NavigationController
|
import com.m57.hermescontrol.NavigationController
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
import com.m57.hermescontrol.theme.LocalSpacing
|
import com.m57.hermescontrol.theme.LocalSpacing
|
||||||
import com.m57.hermescontrol.ui.common.EmptyState
|
import com.m57.hermescontrol.ui.common.EmptyState
|
||||||
import com.m57.hermescontrol.ui.common.ErrorState
|
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.LoadingState
|
||||||
import com.m57.hermescontrol.ui.common.NavIcon
|
import com.m57.hermescontrol.ui.common.NavIcon
|
||||||
import com.m57.hermescontrol.ui.common.SearchBar
|
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.listContentPadding
|
||||||
import com.m57.hermescontrol.ui.common.listItemSpacing
|
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
|
@Composable
|
||||||
fun SessionsScreen(
|
fun SessionsScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -55,8 +163,12 @@ fun SessionsScreen(
|
|||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val spacing = LocalSpacing.current
|
val spacing = LocalSpacing.current
|
||||||
|
val statusColors = LocalHermesStatusColors.current
|
||||||
|
val primaryContainer = MaterialTheme.colorScheme.primaryContainer
|
||||||
|
val onPrimaryContainer = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
|
||||||
var query by remember { mutableStateOf("") }
|
var query by remember { mutableStateOf("") }
|
||||||
|
var pruneDays by remember { mutableStateOf("7") }
|
||||||
|
|
||||||
val filteredSessions =
|
val filteredSessions =
|
||||||
remember(query, state.sessions) {
|
remember(query, state.sessions) {
|
||||||
@@ -66,8 +178,129 @@ fun SessionsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val hasSelection = state.selectedIds.isNotEmpty()
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.loadSessions()
|
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(
|
HermesScaffold(
|
||||||
@@ -83,8 +316,9 @@ fun SessionsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
state.errorMessage != null -> {
|
state.errorMessage != null -> {
|
||||||
|
val errorMsg = state.errorMessage
|
||||||
ErrorState(
|
ErrorState(
|
||||||
message = state.errorMessage ?: stringResource(R.string.error_unknown),
|
message = errorMsg ?: stringResource(R.string.error_unknown),
|
||||||
onRetry = { viewModel.loadSessions() },
|
onRetry = { viewModel.loadSessions() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -98,96 +332,159 @@ fun SessionsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
LazyColumn(
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
// ── Stats row ───────────────────────────────────────
|
||||||
contentPadding = listContentPadding,
|
Row(
|
||||||
verticalArrangement = listItemSpacing,
|
modifier =
|
||||||
) {
|
Modifier
|
||||||
item {
|
.fillMaxWidth()
|
||||||
SearchBar(
|
.padding(horizontal = spacing.md, vertical = spacing.sm),
|
||||||
query = query,
|
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||||
onQueryChange = { query = it },
|
) {
|
||||||
placeholder = "Search sessions...",
|
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),
|
||||||
)
|
)
|
||||||
}
|
StatCard(
|
||||||
items(filteredSessions, key = { it.id }) { session ->
|
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(
|
Card(
|
||||||
modifier =
|
modifier = Modifier.weight(1f),
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.testTag("session_card_${session.id}")
|
|
||||||
.clickable(role = Role.Button) {
|
|
||||||
NavigationController.pendingSessionId = session.id
|
|
||||||
NavigationController.navigateTo(ChatScreen)
|
|
||||||
},
|
|
||||||
colors =
|
colors =
|
||||||
CardDefaults.cardColors(
|
CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
),
|
),
|
||||||
|
onClick = { viewModel.showPruneDialog() },
|
||||||
) {
|
) {
|
||||||
Column(
|
Box(
|
||||||
modifier = Modifier.padding(spacing.md),
|
modifier = Modifier.padding(spacing.md),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
text =
|
Icon(
|
||||||
session.title?.takeIf { it.isNotBlank() }
|
imageVector = Icons.Filled.DeleteSweep,
|
||||||
?: stringResource(R.string.history_untitled),
|
contentDescription = null,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
tint = statusColors.warning,
|
||||||
fontWeight = FontWeight.Bold,
|
modifier = Modifier.size(20.dp),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(spacing.xs))
|
Spacer(modifier = Modifier.height(spacing.xs))
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
text =
|
text = stringResource(R.string.sessions_action_prune),
|
||||||
stringResource(
|
style = MaterialTheme.typography.labelSmall,
|
||||||
R.string.history_message_count,
|
color = statusColors.warning,
|
||||||
session.message_count ?: 0,
|
|
||||||
),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
)
|
||||||
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
|
// ── Search + bulk toggle ────────────────────────────
|
||||||
if (state.hasMore || state.isLoadingMore) {
|
Row(
|
||||||
item {
|
modifier =
|
||||||
Box(
|
Modifier
|
||||||
modifier =
|
.fillMaxWidth()
|
||||||
Modifier
|
.padding(horizontal = spacing.md),
|
||||||
.fillMaxWidth()
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
.padding(vertical = spacing.sm),
|
) {
|
||||||
contentAlignment = Alignment.Center,
|
SearchBar(
|
||||||
) {
|
query = query,
|
||||||
if (state.isLoadingMore) {
|
onQueryChange = { query = it },
|
||||||
CircularProgressIndicator(
|
placeholder = stringResource(R.string.sessions_search_placeholder),
|
||||||
modifier = Modifier.size(24.dp),
|
modifier = Modifier.weight(1f),
|
||||||
strokeWidth = 2.dp,
|
)
|
||||||
)
|
Spacer(modifier = Modifier.width(spacing.sm))
|
||||||
} else {
|
IconButton(onClick = { viewModel.toggleSelecting() }) {
|
||||||
Text(
|
Icon(
|
||||||
text = "Load more sessions...",
|
imageVector = if (state.isSelecting) Icons.Filled.Close else Icons.Filled.SelectAll,
|
||||||
style = MaterialTheme.typography.labelLarge,
|
contentDescription =
|
||||||
color = MaterialTheme.colorScheme.primary,
|
if (state.isSelecting) {
|
||||||
modifier =
|
stringResource(R.string.content_desc_exit_selection)
|
||||||
Modifier
|
} else {
|
||||||
.testTag("load_more_sessions")
|
stringResource(R.string.content_desc_enter_selection)
|
||||||
.clickable(role = Role.Button) {
|
},
|
||||||
viewModel.loadMore()
|
)
|
||||||
},
|
}
|
||||||
)
|
}
|
||||||
|
|
||||||
|
// ── 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.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
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.SessionInfo
|
||||||
|
import com.m57.hermescontrol.data.model.SessionRenameRequest
|
||||||
import com.m57.hermescontrol.data.remote.ApiClient
|
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.data.remote.safeApiCall
|
||||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -13,12 +17,30 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class SessionStats(
|
||||||
|
val total: Int = 0,
|
||||||
|
val active: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
data class SessionsUiState(
|
data class SessionsUiState(
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val isLoadingMore: Boolean = false,
|
val isLoadingMore: Boolean = false,
|
||||||
val sessions: List<SessionInfo> = emptyList(),
|
val sessions: List<SessionInfo> = emptyList(),
|
||||||
val total: Int = 0,
|
val total: Int = 0,
|
||||||
val errorMessage: String? = null,
|
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
|
val hasMore: Boolean get() = total > sessions.size
|
||||||
}
|
}
|
||||||
@@ -28,6 +50,7 @@ class SessionsViewModel : ViewModel() {
|
|||||||
val uiState: StateFlow<SessionsUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<SessionsUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
private var loadJob: Job? = null
|
private var loadJob: Job? = null
|
||||||
|
private var statsJob: Job? = null
|
||||||
|
|
||||||
/** Page size sent to the server — matches the default the gateway uses. */
|
/** Page size sent to the server — matches the default the gateway uses. */
|
||||||
private companion object {
|
private companion object {
|
||||||
@@ -56,6 +79,7 @@ class SessionsViewModel : ViewModel() {
|
|||||||
isLoadingMore = false,
|
isLoadingMore = false,
|
||||||
sessions = data?.sessions.orEmpty(),
|
sessions = data?.sessions.orEmpty(),
|
||||||
total = data?.total ?: 0,
|
total = data?.total ?: 0,
|
||||||
|
selectedIds = emptySet(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -87,7 +111,7 @@ class SessionsViewModel : ViewModel() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
when (result) {
|
when (result) {
|
||||||
is com.m57.hermescontrol.data.remote.NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
val data = result.data
|
val data = result.data
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
@@ -98,7 +122,7 @@ class SessionsViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoadingMore = false,
|
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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -293,6 +293,28 @@
|
|||||||
<string name="history_empty_desc">Previous chat sessions from Hermes will appear here.</string>
|
<string name="history_empty_desc">Previous chat sessions from Hermes will appear here.</string>
|
||||||
<string name="history_untitled">Untitled</string>
|
<string name="history_untitled">Untitled</string>
|
||||||
<string name="history_message_count">%1$d messages</string>
|
<string name="history_message_count">%1$d messages</string>
|
||||||
|
<string name="history_load_more">Load more sessions…</string>
|
||||||
|
|
||||||
|
<!-- Sessions Stats -->
|
||||||
|
<string name="sessions_search_placeholder">Search sessions…</string>
|
||||||
|
<string name="sessions_stat_total">Total</string>
|
||||||
|
<string name="sessions_stat_active">Active</string>
|
||||||
|
<string name="sessions_action_prune">Prune</string>
|
||||||
|
<string name="sessions_prune_title">Prune Old Sessions</string>
|
||||||
|
<string name="sessions_prune_desc">Delete sessions older than the specified number of days.</string>
|
||||||
|
<string name="sessions_prune_days_label">Days threshold</string>
|
||||||
|
<string name="sessions_prune_confirm">Prune</string>
|
||||||
|
<string name="sessions_delete_title">Delete Session?</string>
|
||||||
|
<string name="sessions_delete_message">Are you sure you want to delete \"%1$s\"? This can\'t be undone.</string>
|
||||||
|
<string name="sessions_bulk_delete_title">Delete Sessions?</string>
|
||||||
|
<string name="sessions_bulk_delete_message">Are you sure you want to delete %1$d session(s)? This can\'t be undone.</string>
|
||||||
|
<string name="sessions_action_select_all">Select All</string>
|
||||||
|
<string name="sessions_action_deselect_all">Deselect</string>
|
||||||
|
<string name="sessions_action_delete_n">Delete %1$d</string>
|
||||||
|
<string name="sessions_action_copy_prompt">Copy prompt</string>
|
||||||
|
<string name="sessions_rename_placeholder">New title…</string>
|
||||||
|
<string name="content_desc_enter_selection">Select sessions</string>
|
||||||
|
<string name="content_desc_exit_selection">Exit selection</string>
|
||||||
|
|
||||||
<!-- Toolsets Screen -->
|
<!-- Toolsets Screen -->
|
||||||
<string name="toolsets_empty_title">No toolsets reported</string>
|
<string name="toolsets_empty_title">No toolsets reported</string>
|
||||||
|
|||||||
Reference in new issue
Block a user