Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
f34bfa7cf8
commit
9d0baa78fd
4 files changed
+318
-201
No files matched your search
@@ -188,7 +188,9 @@ interface HermesApiService {
|
|||||||
@GET("api/sessions/stats")
|
@GET("api/sessions/stats")
|
||||||
suspend fun getSessionStats(): Response<SessionStatsResponse>
|
suspend fun getSessionStats(): Response<SessionStatsResponse>
|
||||||
|
|
||||||
@PUT("api/sessions/{id}/rename")
|
// Rename is PATCH /api/sessions/{id} with {title} — the backend never
|
||||||
|
// registered PUT .../rename (404; issue #785). Desktop uses the same PATCH.
|
||||||
|
@PATCH("api/sessions/{id}")
|
||||||
suspend fun renameSession(
|
suspend fun renameSession(
|
||||||
// Preserve slashes in session IDs — backend generates IDs containing '/' characters (issue #468).
|
// Preserve slashes in session IDs — backend generates IDs containing '/' characters (issue #468).
|
||||||
// Contract: The server-generated sessionId must only contain URL-safe characters (no ?, #, or spaces).
|
// Contract: The server-generated sessionId must only contain URL-safe characters (no ?, #, or spaces).
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import androidx.compose.material.icons.filled.Close
|
|||||||
import androidx.compose.material.icons.filled.Code
|
import androidx.compose.material.icons.filled.Code
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.DeleteSweep
|
import androidx.compose.material.icons.filled.DeleteSweep
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
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.Language
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
@@ -48,6 +49,8 @@ import androidx.compose.material3.Card
|
|||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -447,6 +450,36 @@ fun SessionsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Single-session rename dialog (issue #785) — opened from the card's
|
||||||
|
// hold menu; PATCH /api/sessions/{id} with {title}.
|
||||||
|
state.renamingSessionId?.let { sessionId ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { viewModel.closeRenameDialog() },
|
||||||
|
title = { Text(stringResource(R.string.sessions_rename_title)) },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.renameDraft,
|
||||||
|
onValueChange = { viewModel.updateRenameDraft(it) },
|
||||||
|
singleLine = true,
|
||||||
|
label = { Text(stringResource(R.string.sessions_rename_hint)) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = { viewModel.renameSession(sessionId, state.renameDraft) },
|
||||||
|
enabled = state.renameDraft.isNotBlank(),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.sessions_action_rename))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { viewModel.closeRenameDialog() }) {
|
||||||
|
Text(stringResource(R.string.action_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
HermesScaffold(
|
HermesScaffold(
|
||||||
title = { Text(stringResource(R.string.screen_history)) },
|
title = { Text(stringResource(R.string.screen_history)) },
|
||||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||||
@@ -558,17 +591,21 @@ fun SessionsScreen(
|
|||||||
NavigationController.openChatSession(session.id)
|
NavigationController.openChatSession(session.id)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onCardLongClick = {
|
|
||||||
if (!state.isSelecting) {
|
|
||||||
viewModel.toggleSelecting()
|
|
||||||
viewModel.toggleSessionSelection(session.id)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onToggleSelection = {
|
onToggleSelection = {
|
||||||
viewModel.toggleSessionSelection(
|
viewModel.toggleSessionSelection(
|
||||||
session.id,
|
session.id,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
onSelect = {
|
||||||
|
viewModel.toggleSelecting()
|
||||||
|
viewModel.toggleSessionSelection(session.id)
|
||||||
|
},
|
||||||
|
onRename = {
|
||||||
|
viewModel.openRenameDialog(
|
||||||
|
session.id,
|
||||||
|
session.title.orEmpty(),
|
||||||
|
)
|
||||||
|
},
|
||||||
onDelete = { viewModel.requestDeleteSession(session.id) },
|
onDelete = { viewModel.requestDeleteSession(session.id) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -693,13 +730,17 @@ fun SessionsScreen(
|
|||||||
NavigationController.openChatSession(session.id)
|
NavigationController.openChatSession(session.id)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onCardLongClick = {
|
|
||||||
if (!state.isSelecting) {
|
|
||||||
viewModel.toggleSelecting()
|
|
||||||
viewModel.toggleSessionSelection(session.id)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onToggleSelection = { viewModel.toggleSessionSelection(session.id) },
|
onToggleSelection = { viewModel.toggleSessionSelection(session.id) },
|
||||||
|
onSelect = {
|
||||||
|
viewModel.toggleSelecting()
|
||||||
|
viewModel.toggleSessionSelection(session.id)
|
||||||
|
},
|
||||||
|
onRename = {
|
||||||
|
viewModel.openRenameDialog(
|
||||||
|
session.id,
|
||||||
|
item.displayTitle,
|
||||||
|
)
|
||||||
|
},
|
||||||
onDelete = { viewModel.requestDeleteSession(session.id) },
|
onDelete = { viewModel.requestDeleteSession(session.id) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -848,14 +889,16 @@ private fun SessionCard(
|
|||||||
highlightBackground: Color,
|
highlightBackground: Color,
|
||||||
highlightForeground: Color,
|
highlightForeground: Color,
|
||||||
onCardClick: () -> Unit,
|
onCardClick: () -> Unit,
|
||||||
onCardLongClick: () -> Unit,
|
|
||||||
onToggleSelection: () -> Unit,
|
onToggleSelection: () -> Unit,
|
||||||
|
onSelect: () -> Unit,
|
||||||
|
onRename: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val spacing = LocalSpacing.current
|
val spacing = LocalSpacing.current
|
||||||
val statusColors = LocalHermesStatusColors.current
|
val statusColors = LocalHermesStatusColors.current
|
||||||
val isActive = session.status?.lowercase() == "active" || session.status?.lowercase() == "streaming"
|
val isActive = session.status?.lowercase() == "active" || session.status?.lowercase() == "streaming"
|
||||||
val srcIcon = sourceIcon(session.source)
|
val srcIcon = sourceIcon(session.source)
|
||||||
|
var menuExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -865,7 +908,11 @@ private fun SessionCard(
|
|||||||
.testTag("session_card_${session.id}")
|
.testTag("session_card_${session.id}")
|
||||||
.combinedClickable(
|
.combinedClickable(
|
||||||
onClick = onCardClick,
|
onClick = onCardClick,
|
||||||
onLongClick = onCardLongClick,
|
// 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 =
|
colors =
|
||||||
CardDefaults.cardColors(
|
CardDefaults.cardColors(
|
||||||
@@ -880,100 +927,96 @@ private fun SessionCard(
|
|||||||
null
|
null
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Row(
|
Box {
|
||||||
modifier = Modifier.padding(spacing.md),
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
modifier = Modifier.padding(spacing.md),
|
||||||
) {
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
// Checkbox in select mode
|
) {
|
||||||
if (isSelecting) {
|
// Checkbox in select mode
|
||||||
Checkbox(
|
if (isSelecting) {
|
||||||
checked = isSelected,
|
Checkbox(
|
||||||
onCheckedChange = { onToggleSelection() },
|
checked = isSelected,
|
||||||
modifier = Modifier.testTag("session_checkbox_${session.id}"),
|
onCheckedChange = { onToggleSelection() },
|
||||||
)
|
modifier = Modifier.testTag("session_checkbox_${session.id}"),
|
||||||
Spacer(modifier = Modifier.width(spacing.sm))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Branch tree stem
|
|
||||||
if (branchStem != null && !isSelecting) {
|
|
||||||
Text(
|
|
||||||
text = branchStem,
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.outline,
|
|
||||||
)
|
|
||||||
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))
|
||||||
Spacer(modifier = Modifier.width(spacing.sm))
|
|
||||||
StatusBadge(
|
|
||||||
text = session.status,
|
|
||||||
status = if (isActive) StatusBadgeType.SUCCESS else StatusBadgeType.NEUTRAL,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Action buttons (not in select mode)
|
// Branch tree stem
|
||||||
if (!isSelecting) {
|
if (branchStem != null && !isSelecting) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
Text(
|
||||||
// Delete
|
text = branchStem,
|
||||||
if (isDeleting) {
|
style = MaterialTheme.typography.labelLarge,
|
||||||
CircularProgressIndicator(
|
color = MaterialTheme.colorScheme.outline,
|
||||||
modifier = Modifier.size(16.dp),
|
)
|
||||||
strokeWidth = 2.dp,
|
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,
|
||||||
)
|
)
|
||||||
} else {
|
if (!session.status.isNullOrBlank()) {
|
||||||
IconButton(
|
Spacer(modifier = Modifier.width(spacing.sm))
|
||||||
onClick = onDelete,
|
StatusBadge(
|
||||||
modifier = Modifier.size(32.dp),
|
text = session.status,
|
||||||
) {
|
status = if (isActive) StatusBadgeType.SUCCESS else StatusBadgeType.NEUTRAL,
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Delete,
|
|
||||||
contentDescription = stringResource(R.string.action_delete),
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
tint = statusColors.error,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -994,15 +1037,16 @@ private fun SearchResultCard(
|
|||||||
highlightBackground: Color,
|
highlightBackground: Color,
|
||||||
highlightForeground: Color,
|
highlightForeground: Color,
|
||||||
onCardClick: () -> Unit,
|
onCardClick: () -> Unit,
|
||||||
onCardLongClick: () -> Unit,
|
|
||||||
onToggleSelection: () -> Unit,
|
onToggleSelection: () -> Unit,
|
||||||
|
onSelect: () -> Unit,
|
||||||
|
onRename: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val spacing = LocalSpacing.current
|
val spacing = LocalSpacing.current
|
||||||
val statusColors = LocalHermesStatusColors.current
|
|
||||||
val snippet = session.preview?.takeIf { it.isNotBlank() } ?: stringResource(R.string.history_untitled)
|
val snippet = session.preview?.takeIf { it.isNotBlank() } ?: stringResource(R.string.history_untitled)
|
||||||
val cleanSnippet = cleanSearchSnippet(snippet)
|
val cleanSnippet = cleanSearchSnippet(snippet)
|
||||||
val playedAt = formatPlayedAt(session.started_at)
|
val playedAt = formatPlayedAt(session.started_at)
|
||||||
|
var menuExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -1011,7 +1055,8 @@ private fun SearchResultCard(
|
|||||||
.testTag("session_card_${session.id}")
|
.testTag("session_card_${session.id}")
|
||||||
.combinedClickable(
|
.combinedClickable(
|
||||||
onClick = onCardClick,
|
onClick = onCardClick,
|
||||||
onLongClick = onCardLongClick,
|
// Long-press opens the per-session action menu (issue #785)
|
||||||
|
onLongClick = { if (!isSelecting) menuExpanded = true },
|
||||||
),
|
),
|
||||||
colors =
|
colors =
|
||||||
CardDefaults.cardColors(
|
CardDefaults.cardColors(
|
||||||
@@ -1024,113 +1069,155 @@ private fun SearchResultCard(
|
|||||||
null
|
null
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Row(
|
Box {
|
||||||
modifier = Modifier.padding(spacing.sm),
|
Row(
|
||||||
verticalAlignment = Alignment.Top,
|
modifier = Modifier.padding(spacing.sm),
|
||||||
) {
|
verticalAlignment = Alignment.Top,
|
||||||
// Checkbox in select mode
|
) {
|
||||||
if (isSelecting) {
|
// Checkbox in select mode
|
||||||
Checkbox(
|
if (isSelecting) {
|
||||||
checked = isSelected,
|
Checkbox(
|
||||||
onCheckedChange = { onToggleSelection() },
|
checked = isSelected,
|
||||||
modifier = Modifier.testTag("session_checkbox_${session.id}"),
|
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()) {
|
Spacer(modifier = Modifier.width(spacing.sm))
|
||||||
Text(
|
|
||||||
text = "· ${session.id.take(8)}",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(spacing.xs))
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
// Header row: "Match" label + source icon
|
||||||
// The matched snippet, highlighted — shown as the body, NOT as a title.
|
Row(
|
||||||
Text(
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
text = highlightText(cleanSnippet, query, highlightBackground, highlightForeground),
|
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
) {
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
val srcIcon = sourceIcon(session.source)
|
||||||
maxLines = 4,
|
if (srcIcon != null && !isSelecting) {
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Action buttons (not in select mode)
|
|
||||||
if (!isSelecting) {
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
|
||||||
if (isDeleting) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
IconButton(
|
|
||||||
onClick = onDelete,
|
|
||||||
modifier = Modifier.size(32.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Delete,
|
imageVector = srcIcon,
|
||||||
contentDescription = stringResource(R.string.action_delete),
|
contentDescription = sourceLabel(session.source),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
tint = statusColors.error,
|
)
|
||||||
|
}
|
||||||
|
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): Select / Rename / Delete. Opened by
|
||||||
|
* long-pressing a session card. Delete lives here instead of on the card,
|
||||||
|
* and Rename now lands on the working PATCH /api/sessions/{id} route.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SessionActionMenu(
|
||||||
|
expanded: Boolean,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
isDeleting: Boolean,
|
||||||
|
onSelect: () -> Unit,
|
||||||
|
onRename: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
val statusColors = LocalHermesStatusColors.current
|
||||||
|
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
|
||||||
|
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)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Delete,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = statusColors.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !isDeleting,
|
||||||
|
onClick = onDelete,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ data class SessionsUiState(
|
|||||||
val isSelecting: Boolean = false,
|
val isSelecting: Boolean = false,
|
||||||
val selectedIds: Set<String> = emptySet(),
|
val selectedIds: Set<String> = emptySet(),
|
||||||
val renamingSessionId: String? = null,
|
val renamingSessionId: String? = null,
|
||||||
|
val renameDraft: String = "",
|
||||||
val deletingSessionIds: Set<String> = emptySet(),
|
val deletingSessionIds: Set<String> = emptySet(),
|
||||||
val showPruneDialog: Boolean = false,
|
val showPruneDialog: Boolean = false,
|
||||||
val isPruning: Boolean = false,
|
val isPruning: Boolean = false,
|
||||||
@@ -283,12 +284,33 @@ class SessionsViewModel :
|
|||||||
|
|
||||||
// ── Rename ───────────────────────────────────────────────────────────
|
// ── Rename ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun openRenameDialog(
|
||||||
|
sessionId: String,
|
||||||
|
currentTitle: String,
|
||||||
|
) {
|
||||||
|
_uiState.update { it.copy(renamingSessionId = sessionId, renameDraft = currentTitle) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateRenameDraft(value: String) {
|
||||||
|
_uiState.update { it.copy(renameDraft = value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeRenameDialog() {
|
||||||
|
_uiState.update { it.copy(renamingSessionId = null, renameDraft = "") }
|
||||||
|
}
|
||||||
|
|
||||||
fun renameSession(
|
fun renameSession(
|
||||||
sessionId: String,
|
sessionId: String,
|
||||||
newTitle: String,
|
newTitle: String,
|
||||||
) {
|
) {
|
||||||
if (newTitle.isBlank()) {
|
if (newTitle.isBlank()) {
|
||||||
_uiState.update { it.copy(renamingSessionId = null, toastMessage = "Title cannot be empty") }
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
renamingSessionId = null,
|
||||||
|
renameDraft = "",
|
||||||
|
toastMessage = "Title cannot be empty",
|
||||||
|
)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -304,6 +326,7 @@ class SessionsViewModel :
|
|||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
renamingSessionId = null,
|
renamingSessionId = null,
|
||||||
|
renameDraft = "",
|
||||||
sessions =
|
sessions =
|
||||||
it.sessions.map { s ->
|
it.sessions.map { s ->
|
||||||
if (s.id == sessionId) s.copy(title = newTitle) else s
|
if (s.id == sessionId) s.copy(title = newTitle) else s
|
||||||
@@ -317,6 +340,7 @@ class SessionsViewModel :
|
|||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
renamingSessionId = null,
|
renamingSessionId = null,
|
||||||
|
renameDraft = "",
|
||||||
toastMessage = "Rename failed: ${result.error.message}",
|
toastMessage = "Rename failed: ${result.error.message}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -520,6 +520,10 @@
|
|||||||
<string name="sessions_action_select_all">Select All</string>
|
<string name="sessions_action_select_all">Select All</string>
|
||||||
<string name="sessions_action_deselect_all">Deselect</string>
|
<string name="sessions_action_deselect_all">Deselect</string>
|
||||||
<string name="sessions_action_delete_n">Delete %1$d</string>
|
<string name="sessions_action_delete_n">Delete %1$d</string>
|
||||||
|
<string name="sessions_action_select">Select</string>
|
||||||
|
<string name="sessions_action_rename">Rename</string>
|
||||||
|
<string name="sessions_rename_title">Rename Session</string>
|
||||||
|
<string name="sessions_rename_hint">Session title</string>
|
||||||
<string name="content_desc_enter_selection">Select sessions</string>
|
<string name="content_desc_enter_selection">Select sessions</string>
|
||||||
<string name="content_desc_exit_selection">Exit selection</string>
|
<string name="content_desc_exit_selection">Exit selection</string>
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user