refactor(sessions): decompose SessionsScreen into focused components

Extract SessionCard, SearchResultCard, SessionActionMenu,
SessionsStatsRow, SessionsBulkActionBar, SessionsDialogs, and AutomationGroupList.
This commit is contained in:
Hy4ri committed 2026-09-11 19:28:52 +03:00
1 parent 35dc778702
commit 3d711c5a51
8 files changed
+1098 -641

No files matched your search

@@ -116,6 +116,12 @@ 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.listItemSpacing
import com.m57.hermescontrol.ui.sessions.components.SearchResultCard
import com.m57.hermescontrol.ui.sessions.components.SessionCard
import com.m57.hermescontrol.ui.sessions.components.SessionsBulkActionBar
import com.m57.hermescontrol.ui.sessions.components.SessionsDialogs
import com.m57.hermescontrol.ui.sessions.components.SessionsStatsRow
import com.m57.hermescontrol.ui.sessions.components.automationGroups
/**
* Auto-load the next history page when the user scrolls to within this many
@@ -680,65 +686,14 @@ fun SessionsScreen(
else -> {
Column(modifier = Modifier.fillMaxSize()) {
// ── Stats row ───────────────────────────────────────
Row(
modifier =
Modifier
.fillMaxWidth()
.height(IntrinsicSize.Max)
.padding(horizontal = spacing.md, vertical = spacing.sm),
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
StatCard(
label = stringResource(R.string.sessions_stat_total),
value = formatCompactCount(state.total),
icon = Icons.Filled.History,
modifier = Modifier.weight(1f).fillMaxHeight(),
)
StatCard(
label = stringResource(R.string.sessions_stat_messages_loaded),
value = formatCompactCount(loadedMessageCount),
icon = Icons.Filled.Email,
modifier = Modifier.weight(1f).fillMaxHeight(),
)
// Prune button — compact card sized to its label, so the
// stat cards keep enough width for large counts.
if (state.section == HistorySection.CONVERSATIONS) {
Card(
modifier = Modifier.fillMaxHeight(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
onClick = { viewModel.showPruneDialog() },
) {
Box(
// NOTE: fillMaxHeight only — never fillMaxSize here.
// An unweighted Row child with fillMaxSize expands to the
// full row width and starves the weighted stat cards.
modifier = Modifier.fillMaxHeight().padding(horizontal = spacing.md),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(spacing.xs),
) {
Icon(
imageVector = Icons.Filled.DeleteSweep,
contentDescription = null,
tint = statusColors.warning,
modifier = Modifier.size(20.dp),
)
Text(
text = stringResource(R.string.sessions_action_prune),
style = MaterialTheme.typography.labelMedium,
color = statusColors.warning,
fontWeight = FontWeight.SemiBold,
)
}
}
}
}
}
SessionsStatsRow(
total = state.total,
loadedMessageCount = loadedMessageCount,
section = state.section,
spacing = spacing,
statusColors = statusColors,
onPruneClick = { viewModel.showPruneDialog() },
)
// ── Session list ────────────────────────────────────
val listState = rememberLazyListState()
@@ -822,66 +777,21 @@ fun SessionsScreen(
verticalArrangement = listItemSpacing,
) {
if (state.section == HistorySection.AUTOMATIONS) {
automationSessionGroups.forEach { group ->
item(key = "automation-group:${group.key}") {
val expanded = group.key in expandedAutomationGroups
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable {
expandedAutomationGroups =
if (expanded) {
expandedAutomationGroups - group.key
} else {
expandedAutomationGroups + group.key
}
}.padding(horizontal = spacing.sm, vertical = spacing.md),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector =
if (expanded) {
Icons.Filled.KeyboardArrowDown
} else {
Icons.AutoMirrored.Filled.KeyboardArrowRight
},
contentDescription = null,
)
Spacer(modifier = Modifier.width(spacing.sm))
Column {
Text(
text =
group.title
?: group.jobId?.let {
stringResource(
R.string.sessions_section_automation_job,
it,
)
} ?: stringResource(
R.string.sessions_section_automation_unknown,
),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Text(
text =
stringResource(
R.string.sessions_section_automation_runs,
group.sessions.size,
),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
automationGroups(
groups = automationSessionGroups,
expandedGroups = expandedAutomationGroups,
sessionItemsById = sessionItemsById,
spacing = spacing,
onToggleGroup = { groupKey ->
expandedAutomationGroups =
if (groupKey in expandedAutomationGroups) {
expandedAutomationGroups - groupKey
} else {
expandedAutomationGroups + groupKey
}
}
}
if (group.key in expandedAutomationGroups) {
items(group.sessions, key = { it.id }) { session ->
sessionItemsById[session.id]?.let { sessionCard(it) }
}
}
}
},
renderSessionCard = sessionCard,
)
} else {
items(sessionsToDisplay, key = { it.session.id }) { item ->
sessionCard(item)
@@ -927,533 +837,21 @@ fun SessionsScreen(
}
// ── Bulk action toolbar (animated) ──────────────────────────────────
AnimatedVisibility(
SessionsBulkActionBar(
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 (allVisibleSessionsSelected) {
viewModel.clearSelection()
} else {
viewModel.selectAll(visibleSessionIds)
}
},
) {
Icon(
imageVector =
if (allVisibleSessionsSelected) {
Icons.Filled.Close
} else {
Icons.Filled.SelectAll
},
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(spacing.xs))
Text(
if (allVisibleSessionsSelected) {
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 = statusColors.onError,
)
} 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,
displayTitle: String,
branchStem: String?,
isFork: Boolean = false,
forkDepth: Int = 0,
query: String,
isSelecting: Boolean,
isSelected: Boolean,
isDeleting: Boolean,
isPinned: Boolean,
isHidden: Boolean = false,
highlightBackground: Color,
highlightForeground: Color,
onCardClick: () -> Unit,
onToggleSelection: () -> Unit,
onSelect: () -> Unit,
onRename: () -> Unit,
onTogglePin: () -> Unit,
onToggleHide: () -> Unit,
onDelete: () -> Unit,
) {
val spacing = LocalSpacing.current
val statusColors = LocalHermesStatusColors.current
val isActive = session.status?.lowercase() == "active" || session.status?.lowercase() == "streaming"
val srcIcon = sourceIcon(session.source)
var menuExpanded by remember { mutableStateOf(false) }
Card(
modifier =
Modifier
.fillMaxWidth()
.testTag("session_card_${session.id}")
.combinedClickable(
onClick = onCardClick,
// Long-press opens the per-session action menu (issue #785):
// Select / Rename / Delete. The old long-press behavior
// (jump straight into selection mode) is now the menu's
// "Select" item.
onLongClick = { if (!isSelecting) menuExpanded = true },
),
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
},
) {
Box {
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))
}
// Fork marker: a fork icon + its nesting depth, so a chain reads
// 🍴1 → 🍴2 → 🍴3 and a branch-off pops out. No indentation — the
// number carries the structure so names never get squished.
if (isFork && !isSelecting) {
Icon(
imageVector = Icons.AutoMirrored.Filled.CallSplit,
contentDescription = stringResource(R.string.sessions_fork_indicator),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp),
)
if (forkDepth > 0) {
Spacer(modifier = Modifier.width(spacing.xs))
Text(
text = forkDepth.toString(),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
}
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)) {
Row(verticalAlignment = Alignment.CenterVertically) {
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,
modifier = Modifier.weight(1f),
)
if (isPinned) {
Spacer(modifier = Modifier.width(spacing.xs))
Icon(
imageVector = Icons.Filled.PushPin,
contentDescription = stringResource(R.string.sessions_pinned_indicator),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(14.dp),
)
}
}
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.hidden == true) {
Spacer(modifier = Modifier.width(spacing.sm))
StatusBadge(
text = stringResource(R.string.sessions_hidden_badge),
status = StatusBadgeType.NEUTRAL,
)
}
if (!session.status.isNullOrBlank()) {
Spacer(modifier = Modifier.width(spacing.sm))
StatusBadge(
text = session.status,
status = if (isActive) StatusBadgeType.SUCCESS else StatusBadgeType.NEUTRAL,
)
}
}
}
}
// Per-session action menu (hold the card to open)
SessionActionMenu(
expanded = menuExpanded,
onDismiss = { menuExpanded = false },
isDeleting = isDeleting,
isPinned = isPinned,
onTogglePin = {
menuExpanded = false
onTogglePin()
},
isHidden = isHidden,
onToggleHide = {
menuExpanded = false
onToggleHide()
},
onSelect = {
menuExpanded = false
onSelect()
},
onRename = {
menuExpanded = false
onRename()
},
onDelete = {
menuExpanded = false
onDelete()
},
)
}
}
}
/**
* Search-result card. The backend search payload has no session title, so this card is
* honest about it: it shows a "Match" label + the highlighted snippet as the body, plus
* source / model / played-at metadata chips. It never presents the snippet as a name.
*/
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
@Composable
private fun SearchResultCard(
session: com.m57.hermescontrol.data.model.SessionInfo,
query: String,
isSelecting: Boolean,
isSelected: Boolean,
isDeleting: Boolean,
highlightBackground: Color,
highlightForeground: Color,
onCardClick: () -> Unit,
onToggleSelection: () -> Unit,
onSelect: () -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit,
) {
val spacing = LocalSpacing.current
val snippet = session.preview?.takeIf { it.isNotBlank() } ?: stringResource(R.string.history_untitled)
val cleanSnippet = cleanSearchSnippet(snippet)
val playedAt = formatPlayedAt(session.started_at)
var menuExpanded by remember { mutableStateOf(false) }
Card(
modifier =
Modifier
.fillMaxWidth()
.testTag("session_card_${session.id}")
.combinedClickable(
onClick = onCardClick,
// Long-press opens the per-session action menu (issue #785)
onLongClick = { if (!isSelecting) menuExpanded = true },
),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
border =
if (isSelected) {
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
} else {
null
},
) {
Box {
Row(
modifier = Modifier.padding(spacing.sm),
verticalAlignment = Alignment.Top,
) {
// Checkbox in select mode
if (isSelecting) {
Checkbox(
checked = isSelected,
onCheckedChange = { onToggleSelection() },
modifier = Modifier.testTag("session_checkbox_${session.id}"),
)
Spacer(modifier = Modifier.width(spacing.sm))
}
Column(modifier = Modifier.weight(1f)) {
// Header row: "Match" label + source icon
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
) {
val srcIcon = sourceIcon(session.source)
if (srcIcon != null && !isSelecting) {
Icon(
imageVector = srcIcon,
contentDescription = sourceLabel(session.source),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp),
)
}
Text(
text = stringResource(R.string.sessions_search_match_label),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
letterSpacing = 0.5.sp,
)
if (!session.id.isNullOrBlank()) {
Text(
text = "· ${session.id.take(8)}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(modifier = Modifier.height(spacing.xs))
// The matched snippet, highlighted — shown as the body, NOT as a title.
Text(
text = highlightText(cleanSnippet, query, highlightBackground, highlightForeground),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(spacing.xs))
// Metadata chips row
FlowRow(
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
verticalArrangement = Arrangement.spacedBy(spacing.xs),
) {
session.source?.let { src ->
StatusBadge(
text = sourceLabel(src),
status = StatusBadgeType.NEUTRAL,
)
}
session.model?.let { mdl ->
StatusBadge(
text = stringResource(R.string.sessions_search_model_label) + ": " + mdl,
status = StatusBadgeType.INFO,
)
}
playedAt?.let { time ->
StatusBadge(
text = stringResource(R.string.sessions_search_played_at, time),
status = StatusBadgeType.NEUTRAL,
)
}
}
}
}
// Per-session action menu (hold the card to open)
SessionActionMenu(
expanded = menuExpanded,
onDismiss = { menuExpanded = false },
isDeleting = isDeleting,
onSelect = {
menuExpanded = false
onSelect()
},
onRename = {
menuExpanded = false
onRename()
},
onDelete = {
menuExpanded = false
onDelete()
},
)
}
}
}
/**
* Per-session hold menu (issue #785): Pin/Unpin / Select / Rename / Delete.
* Opened by long-pressing a session card. The pin item is only shown when
* [onTogglePin] is provided (history cards; search-result cards omit it).
*/
@Composable
private fun SessionActionMenu(
expanded: Boolean,
onDismiss: () -> Unit,
isDeleting: Boolean,
isPinned: Boolean = false,
onTogglePin: (() -> Unit)? = null,
isHidden: Boolean = false,
onToggleHide: (() -> Unit)? = null,
onSelect: () -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit,
) {
val statusColors = LocalHermesStatusColors.current
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
if (onTogglePin != null) {
DropdownMenuItem(
text = {
Text(
stringResource(
if (isPinned) {
R.string.sessions_action_unpin
} else {
R.string.sessions_action_pin
},
),
)
},
leadingIcon = { Icon(Icons.Filled.PushPin, contentDescription = null) },
onClick = onTogglePin,
)
}
if (onToggleHide != null) {
DropdownMenuItem(
text = {
Text(
stringResource(
if (isHidden) {
R.string.sessions_action_unhide
} else {
R.string.sessions_action_hide
},
),
)
},
leadingIcon = {
Icon(
imageVector =
if (isHidden) {
Icons.Filled.Visibility
} else {
Icons.Filled.VisibilityOff
},
contentDescription = null,
)
},
onClick = onToggleHide,
)
}
DropdownMenuItem(
text = { Text(stringResource(R.string.sessions_action_select)) },
leadingIcon = { Icon(Icons.Filled.CheckCircle, contentDescription = null) },
onClick = onSelect,
)
DropdownMenuItem(
text = { Text(stringResource(R.string.sessions_action_rename)) },
leadingIcon = { Icon(Icons.Filled.Edit, contentDescription = null) },
onClick = onRename,
)
DropdownMenuItem(
text = { Text(stringResource(R.string.action_delete)) },
leadingIcon = {
if (isDeleting) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
allVisibleSessionsSelected = allVisibleSessionsSelected,
selectedCount = state.selectedIds.size,
isDeletingBulk = state.isDeletingBulk,
spacing = spacing,
statusColors = statusColors,
onSelectAllToggle = {
if (allVisibleSessionsSelected) {
viewModel.clearSelection()
} else {
Icon(
imageVector = Icons.Filled.Delete,
contentDescription = null,
tint = statusColors.error,
)
viewModel.selectAll(visibleSessionIds)
}
},
enabled = !isDeleting,
onClick = onDelete,
onDeleteSelected = { viewModel.requestBulkDelete() },
)
}
}
@@ -0,0 +1,90 @@
package com.m57.hermescontrol.ui.sessions.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.SessionTreeItem
import com.m57.hermescontrol.theme.Spacing
import com.m57.hermescontrol.ui.sessions.AutomationSessionGroup
internal fun LazyListScope.automationGroups(
groups: List<AutomationSessionGroup>,
expandedGroups: Set<String>,
sessionItemsById: Map<String, SessionTreeItem>,
spacing: Spacing,
onToggleGroup: (String) -> Unit,
renderSessionCard: @Composable (SessionTreeItem) -> Unit,
) {
groups.forEach { group ->
item(key = "automation-group:${group.key}") {
val expanded = group.key in expandedGroups
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable { onToggleGroup(group.key) }
.padding(horizontal = spacing.sm, vertical = spacing.md),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector =
if (expanded) {
Icons.Filled.KeyboardArrowDown
} else {
Icons.AutoMirrored.Filled.KeyboardArrowRight
},
contentDescription = null,
)
Spacer(modifier = Modifier.width(spacing.sm))
Column {
Text(
text =
group.title
?: group.jobId?.let {
stringResource(
R.string.sessions_section_automation_job,
it,
)
} ?: stringResource(
R.string.sessions_section_automation_unknown,
),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Text(
text =
stringResource(
R.string.sessions_section_automation_runs,
group.sessions.size,
),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
if (group.key in expandedGroups) {
items(group.sessions, key = { it.id }) { session ->
sessionItemsById[session.id]?.let { renderSessionCard(it) }
}
}
}
}
@@ -0,0 +1,198 @@
package com.m57.hermescontrol.ui.sessions.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
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.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.SessionInfo
import com.m57.hermescontrol.theme.LocalSpacing
import com.m57.hermescontrol.ui.common.StatusBadge
import com.m57.hermescontrol.ui.common.StatusBadgeType
import com.m57.hermescontrol.ui.sessions.cleanSearchSnippet
import com.m57.hermescontrol.ui.sessions.formatPlayedAt
/**
* Search-result card. The backend search payload has no session title, so this card is
* honest about it: it shows a "Match" label + the highlighted snippet as the body, plus
* source / model / played-at metadata chips. It never presents the snippet as a name.
*/
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
@Composable
fun SearchResultCard(
session: SessionInfo,
query: String,
isSelecting: Boolean,
isSelected: Boolean,
isDeleting: Boolean,
highlightBackground: Color,
highlightForeground: Color,
onCardClick: () -> Unit,
onToggleSelection: () -> Unit,
onSelect: () -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit,
) {
val spacing = LocalSpacing.current
val snippet = session.preview?.takeIf { it.isNotBlank() } ?: stringResource(R.string.history_untitled)
val cleanSnippet = cleanSearchSnippet(snippet)
val playedAt = formatPlayedAt(session.started_at)
var menuExpanded by remember { mutableStateOf(false) }
Card(
modifier =
Modifier
.fillMaxWidth()
.testTag("session_card_${session.id}")
.combinedClickable(
onClick = onCardClick,
onLongClick = { if (!isSelecting) menuExpanded = true },
),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
border =
if (isSelected) {
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
} else {
null
},
) {
Box {
Row(
modifier = Modifier.padding(spacing.sm),
verticalAlignment = Alignment.Top,
) {
if (isSelecting) {
Checkbox(
checked = isSelected,
onCheckedChange = { onToggleSelection() },
modifier = Modifier.testTag("session_checkbox_${session.id}"),
)
Spacer(modifier = Modifier.width(spacing.sm))
}
Column(modifier = Modifier.weight(1f)) {
// Header row: "Match" label + source icon
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
) {
val srcIcon = sourceIcon(session.source)
if (srcIcon != null && !isSelecting) {
Icon(
imageVector = srcIcon,
contentDescription = sourceLabel(session.source),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp),
)
}
Text(
text = stringResource(R.string.sessions_search_match_label),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
letterSpacing = 0.5.sp,
)
if (!session.id.isNullOrBlank()) {
Text(
text = "· ${session.id.take(8)}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(modifier = Modifier.height(spacing.xs))
// The matched snippet, highlighted — shown as the body, NOT as a title.
Text(
text = highlightText(cleanSnippet, query, highlightBackground, highlightForeground),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(spacing.xs))
// Metadata chips row
FlowRow(
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
verticalArrangement = Arrangement.spacedBy(spacing.xs),
) {
session.source?.let { src ->
StatusBadge(
text = sourceLabel(src),
status = StatusBadgeType.NEUTRAL,
)
}
session.model?.let { mdl ->
StatusBadge(
text = stringResource(R.string.sessions_search_model_label) + ": " + mdl,
status = StatusBadgeType.INFO,
)
}
playedAt?.let { time ->
StatusBadge(
text = stringResource(R.string.sessions_search_played_at, time),
status = StatusBadgeType.NEUTRAL,
)
}
}
}
}
// Per-session action menu (hold the card to open)
SessionActionMenu(
expanded = menuExpanded,
onDismiss = { menuExpanded = false },
isDeleting = isDeleting,
onSelect = {
menuExpanded = false
onSelect()
},
onRename = {
menuExpanded = false
onRename()
},
onDelete = {
menuExpanded = false
onDelete()
},
)
}
}
}
@@ -0,0 +1,109 @@
package com.m57.hermescontrol.ui.sessions.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalHermesStatusColors
/**
* Per-session overflow/long-press menu.
*/
@Composable
fun SessionActionMenu(
expanded: Boolean,
onDismiss: () -> Unit,
isDeleting: Boolean,
isPinned: Boolean = false,
onTogglePin: (() -> Unit)? = null,
isHidden: Boolean = false,
onToggleHide: (() -> Unit)? = null,
onSelect: () -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit,
) {
val statusColors = LocalHermesStatusColors.current
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
if (onTogglePin != null) {
DropdownMenuItem(
text = {
Text(
stringResource(
if (isPinned) {
R.string.sessions_action_unpin
} else {
R.string.sessions_action_pin
},
),
)
},
leadingIcon = { Icon(Icons.Filled.PushPin, contentDescription = null) },
onClick = onTogglePin,
)
}
if (onToggleHide != null) {
DropdownMenuItem(
text = {
Text(
stringResource(
if (isHidden) {
R.string.sessions_action_unhide
} else {
R.string.sessions_action_hide
},
),
)
},
leadingIcon = {
Icon(
imageVector =
if (isHidden) {
Icons.Filled.Visibility
} else {
Icons.Filled.VisibilityOff
},
contentDescription = null,
)
},
onClick = onToggleHide,
)
}
DropdownMenuItem(
text = { Text(stringResource(R.string.sessions_action_select)) },
leadingIcon = { Icon(Icons.Filled.CheckCircle, contentDescription = null) },
onClick = onSelect,
)
DropdownMenuItem(
text = { Text(stringResource(R.string.sessions_action_rename)) },
leadingIcon = { Icon(Icons.Filled.Edit, contentDescription = null) },
onClick = onRename,
)
DropdownMenuItem(
text = {
Text(
text = stringResource(R.string.action_delete),
color = statusColors.error,
)
},
leadingIcon = {
Icon(
Icons.Filled.Delete,
contentDescription = null,
tint = statusColors.error,
)
},
enabled = !isDeleting,
onClick = onDelete,
)
}
}
@@ -0,0 +1,285 @@
package com.m57.hermescontrol.ui.sessions.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.CallSplit
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.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.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.SessionInfo
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.LocalSpacing
import com.m57.hermescontrol.ui.common.StatusBadge
import com.m57.hermescontrol.ui.common.StatusBadgeType
fun sourceIcon(source: String?): ImageVector? =
when (source?.lowercase()) {
"telegram", "tg" -> Icons.AutoMirrored.Filled.Send
"web", "dashboard" -> Icons.Filled.Language
"api", "rest" -> Icons.Filled.Code
"cli", "terminal" -> Icons.Filled.Terminal
else -> null
}
fun sourceLabel(source: String?): String =
when (source?.lowercase()) {
"telegram", "tg" -> "Telegram"
"web", "dashboard" -> "Web"
"api", "rest" -> "API"
"cli", "terminal" -> "CLI"
else -> source ?: "Unknown"
}
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 SessionCard(
session: SessionInfo,
displayTitle: String,
branchStem: String?,
isFork: Boolean = false,
forkDepth: Int = 0,
query: String,
isSelecting: Boolean,
isSelected: Boolean,
isDeleting: Boolean,
isPinned: Boolean,
isHidden: Boolean = false,
highlightBackground: Color,
highlightForeground: Color,
onCardClick: () -> Unit,
onToggleSelection: () -> Unit,
onSelect: () -> Unit,
onRename: () -> Unit,
onTogglePin: () -> Unit,
onToggleHide: () -> Unit,
onDelete: () -> Unit,
) {
val spacing = LocalSpacing.current
val statusColors = LocalHermesStatusColors.current
val isActive = session.status?.lowercase() == "active" || session.status?.lowercase() == "streaming"
val srcIcon = sourceIcon(session.source)
var menuExpanded by remember { mutableStateOf(false) }
Card(
modifier =
Modifier
.fillMaxWidth()
.testTag("session_card_${session.id}")
.combinedClickable(
onClick = onCardClick,
onLongClick = { if (!isSelecting) menuExpanded = true },
),
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
},
) {
Box {
Row(
modifier = Modifier.padding(spacing.md),
verticalAlignment = Alignment.CenterVertically,
) {
if (isSelecting) {
Checkbox(
checked = isSelected,
onCheckedChange = { onToggleSelection() },
modifier = Modifier.testTag("session_checkbox_${session.id}"),
)
Spacer(modifier = Modifier.width(spacing.sm))
}
if (isFork && !isSelecting) {
Icon(
imageVector = Icons.AutoMirrored.Filled.CallSplit,
contentDescription = stringResource(R.string.sessions_fork_indicator),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp),
)
if (forkDepth > 0) {
Spacer(modifier = Modifier.width(spacing.xs))
Text(
text = forkDepth.toString(),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
}
Spacer(modifier = Modifier.width(spacing.sm))
}
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))
}
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
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,
modifier = Modifier.weight(1f),
)
if (isPinned) {
Spacer(modifier = Modifier.width(spacing.xs))
Icon(
imageVector = Icons.Filled.PushPin,
contentDescription = stringResource(R.string.sessions_pinned_indicator),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(14.dp),
)
}
}
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.hidden == true) {
Spacer(modifier = Modifier.width(spacing.sm))
StatusBadge(
text = stringResource(R.string.sessions_hidden_badge),
status = StatusBadgeType.NEUTRAL,
)
}
if (!session.status.isNullOrBlank()) {
Spacer(modifier = Modifier.width(spacing.sm))
StatusBadge(
text = session.status,
status = if (isActive) StatusBadgeType.SUCCESS else StatusBadgeType.NEUTRAL,
)
}
}
}
}
SessionActionMenu(
expanded = menuExpanded,
onDismiss = { menuExpanded = false },
isDeleting = isDeleting,
isPinned = isPinned,
onTogglePin = {
menuExpanded = false
onTogglePin()
},
isHidden = isHidden,
onToggleHide = {
menuExpanded = false
onToggleHide()
},
onSelect = {
menuExpanded = false
onSelect()
},
onRename = {
menuExpanded = false
onRename()
},
onDelete = {
menuExpanded = false
onDelete()
},
)
}
}
}
@@ -0,0 +1,127 @@
package com.m57.hermescontrol.ui.sessions.components
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.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.HermesStatusColors
import com.m57.hermescontrol.theme.Spacing
@Composable
fun SessionsBulkActionBar(
visible: Boolean,
allVisibleSessionsSelected: Boolean,
selectedCount: Int,
isDeletingBulk: Boolean,
spacing: Spacing,
statusColors: HermesStatusColors,
onSelectAllToggle: () -> Unit,
onDeleteSelected: () -> Unit,
) {
AnimatedVisibility(
visible = visible,
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,
) {
OutlinedButton(onClick = onSelectAllToggle) {
Icon(
imageVector =
if (allVisibleSessionsSelected) {
Icons.Filled.Close
} else {
Icons.Filled.SelectAll
},
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(spacing.xs))
Text(
if (allVisibleSessionsSelected) {
stringResource(R.string.sessions_action_deselect_all)
} else {
stringResource(R.string.sessions_action_select_all)
},
)
}
Button(
onClick = onDeleteSelected,
enabled = !isDeletingBulk,
colors =
ButtonDefaults.buttonColors(
containerColor = statusColors.error,
),
) {
if (isDeletingBulk) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = statusColors.onError,
)
} 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,
selectedCount,
),
)
}
}
}
}
}
}
@@ -0,0 +1,152 @@
package com.m57.hermescontrol.ui.sessions.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.HermesStatusColors
import com.m57.hermescontrol.theme.LocalSpacing
import com.m57.hermescontrol.ui.common.ConfirmDialog
import com.m57.hermescontrol.ui.sessions.SessionsUiState
import com.m57.hermescontrol.ui.sessions.cleanSearchSnippet
@Composable
fun SessionsDialogs(
state: SessionsUiState,
pruneDays: String,
onPruneDaysChange: (String) -> Unit,
statusColors: HermesStatusColors,
onConfirmEmptyCleanup: () -> Unit,
onDismissEmptyCleanup: () -> Unit,
onConfirmPrune: (Int) -> Unit,
onDismissPrune: () -> Unit,
onConfirmDeleteSession: () -> Unit,
onDismissDeleteSession: () -> Unit,
onConfirmBulkDelete: () -> Unit,
onDismissBulkDelete: () -> Unit,
onRenameSession: (String, String) -> Unit,
onRenameDraftChange: (String) -> Unit,
onDismissRename: () -> Unit,
) {
val spacing = LocalSpacing.current
// Empty-session cleanup dialog
if (state.showEmptyCleanupDialog) {
ConfirmDialog(
title = stringResource(R.string.sessions_empty_cleanup_title),
message = stringResource(R.string.sessions_empty_cleanup_message, state.emptyCount),
onConfirm = onConfirmEmptyCleanup,
onDismiss = onDismissEmptyCleanup,
confirmText = stringResource(R.string.sessions_empty_cleanup_confirm),
)
}
// Prune dialog
if (state.showPruneDialog) {
ConfirmDialog(
title = stringResource(R.string.sessions_prune_title),
message = stringResource(R.string.sessions_prune_desc),
onConfirm = {
val days = pruneDays.toIntOrNull()
if (days != null && days > 0) onConfirmPrune(days)
},
onDismiss = onDismissPrune,
confirmText = stringResource(R.string.sessions_prune_confirm),
isDestructive = true,
extraContent = {
Spacer(modifier = Modifier.height(spacing.sm))
OutlinedTextField(
value = pruneDays,
onValueChange = onPruneDaysChange,
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) onConfirmPrune(days)
},
),
)
},
)
}
// 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() }
?: state.searchResults
.find { it.session_id == sessionToDelete }
?.snippet
?.let(::cleanSearchSnippet)
?.take(80)
?: stringResource(R.string.history_untitled)
ConfirmDialog(
title = stringResource(R.string.sessions_delete_title),
message = stringResource(R.string.sessions_delete_message, sessionTitle),
onConfirm = onConfirmDeleteSession,
onDismiss = onDismissDeleteSession,
confirmText = stringResource(R.string.action_delete),
isDestructive = true,
)
}
// Bulk delete confirmation dialog
if (state.showBulkDeleteConfirm) {
ConfirmDialog(
title = stringResource(R.string.sessions_bulk_delete_title),
message = stringResource(R.string.sessions_bulk_delete_message, state.selectedIds.size),
onConfirm = onConfirmBulkDelete,
onDismiss = onDismissBulkDelete,
confirmText = stringResource(R.string.action_delete),
isDestructive = true,
)
}
// Single-session rename dialog
state.renamingSessionId?.let { sessionId ->
AlertDialog(
onDismissRequest = onDismissRename,
title = { Text(stringResource(R.string.sessions_rename_title)) },
text = {
OutlinedTextField(
value = state.renameDraft,
onValueChange = onRenameDraftChange,
singleLine = true,
label = { Text(stringResource(R.string.sessions_rename_hint)) },
)
},
confirmButton = {
TextButton(
onClick = { onRenameSession(sessionId, state.renameDraft) },
enabled = state.renameDraft.isNotBlank(),
) {
Text(stringResource(R.string.sessions_action_rename))
}
},
dismissButton = {
TextButton(onClick = onDismissRename) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
}
@@ -0,0 +1,98 @@
package com.m57.hermescontrol.ui.sessions.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.History
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.HermesStatusColors
import com.m57.hermescontrol.theme.Spacing
import com.m57.hermescontrol.ui.common.StatCard
import com.m57.hermescontrol.ui.sessions.HistorySection
import com.m57.hermescontrol.ui.sessions.formatCompactCount
@Composable
fun SessionsStatsRow(
total: Int,
loadedMessageCount: Int,
section: HistorySection,
spacing: Spacing,
statusColors: HermesStatusColors,
onPruneClick: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.height(IntrinsicSize.Max)
.padding(horizontal = spacing.md, vertical = spacing.sm),
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
StatCard(
label = stringResource(R.string.sessions_stat_total),
value = formatCompactCount(total),
icon = Icons.Filled.History,
modifier = Modifier.weight(1f).fillMaxHeight(),
)
StatCard(
label = stringResource(R.string.sessions_stat_messages_loaded),
value = formatCompactCount(loadedMessageCount),
icon = Icons.Filled.Email,
modifier = Modifier.weight(1f).fillMaxHeight(),
)
if (section == HistorySection.CONVERSATIONS) {
Card(
modifier = Modifier.fillMaxHeight(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
onClick = onPruneClick,
) {
Box(
modifier = Modifier.fillMaxHeight().padding(horizontal = spacing.md),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(spacing.xs),
) {
Icon(
imageVector = Icons.Filled.DeleteSweep,
contentDescription = null,
tint = statusColors.warning,
modifier = Modifier.size(20.dp),
)
Text(
text = stringResource(R.string.sessions_action_prune),
style = MaterialTheme.typography.labelMedium,
color = statusColors.warning,
fontWeight = FontWeight.SemiBold,
)
}
}
}
}
}
}