fix(sessions): rename via PATCH /api/sessions/{id} + move actions to hold menu (issue #785) (#838)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-07 14:20:21 +03:00
1 parent f34bfa7cf8
commit 9d0baa78fd
4 files changed
+179 -62

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 = { onToggleSelection = { viewModel.toggleSessionSelection(session.id) },
if (!state.isSelecting) { onSelect = {
viewModel.toggleSelecting() viewModel.toggleSelecting()
viewModel.toggleSessionSelection(session.id) viewModel.toggleSessionSelection(session.id)
}
}, },
onToggleSelection = { 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,6 +927,7 @@ private fun SessionCard(
null null
}, },
) { ) {
Box {
Row( Row(
modifier = Modifier.padding(spacing.md), modifier = Modifier.padding(spacing.md),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -949,31 +997,26 @@ private fun SessionCard(
} }
} }
} }
}
// Action buttons (not in select mode) // Per-session action menu (hold the card to open)
if (!isSelecting) { SessionActionMenu(
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) { expanded = menuExpanded,
// Delete onDismiss = { menuExpanded = false },
if (isDeleting) { isDeleting = isDeleting,
CircularProgressIndicator( onSelect = {
modifier = Modifier.size(16.dp), menuExpanded = false
strokeWidth = 2.dp, onSelect()
},
onRename = {
menuExpanded = false
onRename()
},
onDelete = {
menuExpanded = false
onDelete()
},
) )
} 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,
)
}
}
}
}
} }
} }
} }
@@ -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,6 +1069,7 @@ private fun SearchResultCard(
null null
}, },
) { ) {
Box {
Row( Row(
modifier = Modifier.padding(spacing.sm), modifier = Modifier.padding(spacing.sm),
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.Top,
@@ -1107,30 +1153,71 @@ private fun SearchResultCard(
} }
} }
} }
}
// Action buttons (not in select mode) // Per-session action menu (hold the card to open)
if (!isSelecting) { SessionActionMenu(
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) { expanded = menuExpanded,
if (isDeleting) { onDismiss = { menuExpanded = false },
CircularProgressIndicator( isDeleting = isDeleting,
modifier = Modifier.size(16.dp), onSelect = {
strokeWidth = 2.dp, menuExpanded = false
onSelect()
},
onRename = {
menuExpanded = false
onRename()
},
onDelete = {
menuExpanded = false
onDelete()
},
) )
} 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,
)
}
}
}
}
} }
} }
} }
/**
* 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}",
) )
} }
+4
View File
@@ -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>