feat: Add batch delete functionality for chat sessions (#480)
* feat: add batch delete functionality for chat sessions Backend: - Add batchRemove controller to handle bulk session deletion - Add POST /api/hermes/sessions/batch-delete endpoint - Support both local session store and CLI deletion - Return detailed results (deleted, failed, errors) Frontend: - Add batch selection mode with checkboxes in SessionListItem - Add batch selection toggle and select all button - Add batch delete button with confirmation - Update ChatPanel to manage selected session IDs - Add batchDeleteSessions API function i18n: - Add batch delete translations for all 8 languages - Simplify "Web UI/API Server Sessions" to "Sessions" Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: vertically align buttons in session list header Add inline-flex and center alignment to all buttons in session-list-actions to ensure proper vertical centering with the title text. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: ensure proper vertical alignment in session list header - Set fixed height of 22px for session-list-actions - Add min-height and height to all buttons - Add line-height to session-list-title for text baseline alignment - Add min-height: 0 to session-list-header to prevent flex stretch This ensures the title and all action buttons are perfectly vertically centered. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: call loadSessions after batch delete instead of looping deleteSession The previous implementation was calling chatStore.deleteSession(id) in a loop after batch delete API succeeded, which triggered individual delete API calls for each session - causing n API requests instead of 1. Now we simply call loadSessions() to refresh the session list from the server after successful batch deletion, ensuring: - Only 1 API request for batch delete - UI stays in sync with server state - No duplicate API calls Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: improve update mechanism reliability Major improvements to the update system: **Path Resolution:** - Remove unreliable dirname(process.execPath) assumption - Use npm from PATH environment variable - Dynamically get global prefix via `npm prefix -g` - Calculate CLI path based on actual global install location **Windows Support:** - Remove complex cmd.exe wrapper logic - Directly call npm.cmd (works on all Windows setups) - Simplified quote handling **Error Handling:** - Add fallback error message (err.stderr || err.message || String(err)) - Add default success message when output is empty - Wrap spawnRestart in try-finally to ensure cleanup **Timing:** - Increase timeout from 120s to 10min (slow network support) - Increase restart delay from 2s to 3s (safer margin) **Code Quality:** - Remove unused functions (getNodeBinDir, getWindowsShell, quoteForWindowsCommand) - Use constants instead of magic numbers (10 * 60 * 1000) - More maintainable and cross-platform compatible This fixes issues where updates would fail due to incorrect npm/CLI paths on systems with non-standard Node.js installations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { renameSession, setSessionWorkspace } from "@/api/hermes/sessions";
|
||||
import { renameSession, setSessionWorkspace, batchDeleteSessions } from "@/api/hermes/sessions";
|
||||
import { useChatStore, type Session } from "@/stores/hermes/chat";
|
||||
import { useSessionBrowserPrefsStore } from "@/stores/hermes/session-browser-prefs";
|
||||
import {
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
NInput,
|
||||
NModal,
|
||||
NTooltip,
|
||||
NPopconfirm,
|
||||
useMessage,
|
||||
} from "naive-ui";
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
@@ -31,6 +32,10 @@ const drawerActiveTab = ref<"terminal" | "files">("files");
|
||||
|
||||
const currentMode = ref<"chat" | "live">("chat");
|
||||
|
||||
// Batch selection mode
|
||||
const isBatchMode = ref(false);
|
||||
const selectedSessionIds = ref<Set<string>>(new Set());
|
||||
|
||||
// Initialize synchronously from the media query so first paint is correct.
|
||||
// On narrow viewports the session list is an absolute-positioned overlay
|
||||
// (z-index 10) on top of the chat area; if we default to `true`, onMounted
|
||||
@@ -218,6 +223,72 @@ function handleDeleteSession(id: string) {
|
||||
message.success(t("chat.sessionDeleted"));
|
||||
}
|
||||
|
||||
function toggleBatchMode() {
|
||||
isBatchMode.value = !isBatchMode.value;
|
||||
if (!isBatchMode.value) {
|
||||
selectedSessionIds.value.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSessionSelection(id: string) {
|
||||
if (selectedSessionIds.value.has(id)) {
|
||||
selectedSessionIds.value.delete(id);
|
||||
} else {
|
||||
selectedSessionIds.value.add(id);
|
||||
}
|
||||
selectedSessionIds.value = new Set(selectedSessionIds.value);
|
||||
}
|
||||
|
||||
function isSessionSelected(id: string): boolean {
|
||||
return selectedSessionIds.value.has(id);
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
if (selectedSessionIds.value.size === 0) return;
|
||||
|
||||
const ids = Array.from(selectedSessionIds.value);
|
||||
try {
|
||||
const result = await batchDeleteSessions(ids);
|
||||
if (result.deleted > 0) {
|
||||
// Remove from pinned sessions
|
||||
for (const id of ids) {
|
||||
sessionBrowserPrefsStore.removePinned(id);
|
||||
}
|
||||
|
||||
// Remove deleted sessions from local store (without calling API again)
|
||||
// Use loadSessions to refresh from server instead of manual filtering
|
||||
await chatStore.loadSessions();
|
||||
|
||||
message.success(t("chat.batchDeleteSuccess", { count: result.deleted }));
|
||||
if (result.failed > 0) {
|
||||
message.warning(t("chat.batchDeletePartial", { failed: result.failed }));
|
||||
}
|
||||
} else {
|
||||
message.error(t("chat.batchDeleteFailed"));
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error(t("chat.batchDeleteFailed"));
|
||||
} finally {
|
||||
isBatchMode.value = false;
|
||||
selectedSessionIds.value.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function selectAllSessions() {
|
||||
selectedSessionIds.value.clear();
|
||||
for (const session of chatStore.sessions) {
|
||||
if (session.id !== chatStore.activeSessionId) {
|
||||
selectedSessionIds.value.add(session.id);
|
||||
}
|
||||
}
|
||||
selectedSessionIds.value = new Set(selectedSessionIds.value);
|
||||
}
|
||||
|
||||
const selectedCount = computed(() => selectedSessionIds.value.size);
|
||||
const canSelectAll = computed(() => {
|
||||
return chatStore.sessions.some(s => s.id !== chatStore.activeSessionId);
|
||||
});
|
||||
|
||||
const contextSessionId = ref<string | null>(null);
|
||||
const contextSessionPinned = computed(() =>
|
||||
contextSessionId.value
|
||||
@@ -358,6 +429,92 @@ async function handleWorkspaceConfirm() {
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
<NButton
|
||||
v-if="!isBatchMode"
|
||||
quaternary
|
||||
size="tiny"
|
||||
@click="toggleBatchMode"
|
||||
:title="t('chat.toggleBatchMode')"
|
||||
>
|
||||
<template #icon>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M9 11l3 3L22 4" />
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||
</svg>
|
||||
</template>
|
||||
</NButton>
|
||||
<NButton
|
||||
v-if="isBatchMode"
|
||||
quaternary
|
||||
size="tiny"
|
||||
@click="selectAllSessions"
|
||||
:disabled="!canSelectAll"
|
||||
:title="t('chat.selectAll')"
|
||||
>
|
||||
<template #icon>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M9 11l3 3L22 4" />
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||
</svg>
|
||||
</template>
|
||||
</NButton>
|
||||
<NPopconfirm
|
||||
v-if="isBatchMode && selectedCount > 0"
|
||||
@positive-click="handleBatchDelete"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton quaternary size="tiny" type="error">
|
||||
<template #icon>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</template>
|
||||
</NButton>
|
||||
</template>
|
||||
{{ t('chat.confirmBatchDelete', { count: selectedCount }) }}
|
||||
</NPopconfirm>
|
||||
<NButton
|
||||
v-if="isBatchMode"
|
||||
quaternary
|
||||
size="tiny"
|
||||
@click="toggleBatchMode"
|
||||
>
|
||||
<template #icon>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</template>
|
||||
</NButton>
|
||||
<NButton quaternary size="tiny" @click="handleNewChat" circle>
|
||||
<template #icon>
|
||||
<svg
|
||||
@@ -408,9 +565,12 @@ async function handleWorkspaceConfirm() {
|
||||
chatStore.sessions.length > 1
|
||||
"
|
||||
:streaming="chatStore.isSessionLive(s.id)"
|
||||
:selectable="isBatchMode"
|
||||
:selected="isSessionSelected(s.id)"
|
||||
@select="handleSessionClick(s.id)"
|
||||
@contextmenu="handleContextMenu($event, s.id)"
|
||||
@delete="handleDeleteSession(s.id)"
|
||||
@toggle-select="toggleSessionSelection(s.id)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -443,9 +603,12 @@ async function handleWorkspaceConfirm() {
|
||||
chatStore.sessions.length > 1
|
||||
"
|
||||
:streaming="chatStore.isSessionLive(s.id)"
|
||||
:selectable="isBatchMode"
|
||||
:selected="isSessionSelected(s.id)"
|
||||
@select="handleSessionClick(s.id)"
|
||||
@contextmenu="handleContextMenu($event, s.id)"
|
||||
@delete="handleDeleteSession(s.id)"
|
||||
@toggle-select="toggleSessionSelection(s.id)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
@@ -685,12 +848,22 @@ async function handleWorkspaceConfirm() {
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
flex-shrink: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.session-list-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 22px;
|
||||
|
||||
.n-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 22px;
|
||||
min-height: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
.session-close-btn {
|
||||
@@ -701,6 +874,10 @@ async function handleWorkspaceConfirm() {
|
||||
color: $text-secondary;
|
||||
padding: 4px;
|
||||
border-radius: $radius-sm;
|
||||
height: 22px;
|
||||
min-height: 22px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background: rgba($accent-primary, 0.06);
|
||||
@@ -713,6 +890,7 @@ async function handleWorkspaceConfirm() {
|
||||
color: $text-muted;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.session-scope-note {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { NPopconfirm } from 'naive-ui'
|
||||
import { NPopconfirm, NCheckbox } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Session } from '@/stores/hermes/chat'
|
||||
import { formatTimestampMs } from '@/shared/session-display'
|
||||
@@ -10,12 +10,15 @@ const props = defineProps<{
|
||||
pinned: boolean
|
||||
canDelete: boolean
|
||||
streaming?: boolean
|
||||
selectable?: boolean
|
||||
selected?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: []
|
||||
contextmenu: [event: MouseEvent]
|
||||
delete: []
|
||||
'toggle-select': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -24,11 +27,14 @@ const { t } = useI18n()
|
||||
<template>
|
||||
<button
|
||||
class="session-item"
|
||||
:class="{ active }"
|
||||
:class="{ active, 'batch-mode': selectable }"
|
||||
:aria-current="active ? 'page' : undefined"
|
||||
@click="emit('select')"
|
||||
@contextmenu="emit('contextmenu', $event)"
|
||||
>
|
||||
<div v-if="selectable" class="session-item-checkbox">
|
||||
<NCheckbox :checked="selected" @click.stop="emit('toggle-select')" />
|
||||
</div>
|
||||
<div class="session-item-content">
|
||||
<span class="session-item-title-row">
|
||||
<span v-if="pinned" class="session-item-pin" aria-hidden="true">
|
||||
@@ -48,7 +54,7 @@ const { t } = useI18n()
|
||||
<span class="session-item-time">{{ formatTimestampMs(session.createdAt) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<NPopconfirm v-if="canDelete" @positive-click="emit('delete')">
|
||||
<NPopconfirm v-if="canDelete && !selectable" @positive-click="emit('delete')">
|
||||
<template #trigger>
|
||||
<button class="session-item-delete" @click.stop>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
|
||||
Reference in New Issue
Block a user