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:
ekko
2026-05-06 16:15:42 +08:00
committed by GitHub
parent d13423b9dd
commit 266f6e1a59
14 changed files with 342 additions and 49 deletions
@@ -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>