feat: make navigation use native links (#973)

This commit is contained in:
Maxim Kirilyuk
2026-05-24 14:13:42 +03:00
committed by GitHub
parent e743c81ad3
commit acdf18793c
20 changed files with 419 additions and 46 deletions
@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { RouteLocationRaw } from 'vue-router'
const props = withDefaults(defineProps<{
to: RouteLocationRaw
active?: boolean
exact?: boolean
title?: string
}>(), {
active: undefined,
exact: false,
})
</script>
<template>
<RouterLink v-slot="slotProps" :to="props.to" custom>
<a
class="route-link-item"
:class="{ active: props.active ?? (props.exact ? !!slotProps?.isExactActive : !!slotProps?.isActive) }"
:href="slotProps?.href || '#'"
:title="props.title"
:aria-current="(props.active ?? (props.exact ? !!slotProps?.isExactActive : !!slotProps?.isActive)) ? 'page' : undefined"
@click="slotProps?.navigate"
>
<slot />
</a>
</RouterLink>
</template>
@@ -60,6 +60,20 @@ const showSessions = ref(
let mobileQuery: MediaQueryList | null = null;
const isMobile = ref(false);
function sessionHref(sessionId: string) {
const profile = sessionProfile(sessionId);
return router.resolve({
name: "hermes.session",
params: { sessionId },
query: profile ? { profile } : undefined,
}).href;
}
function openSessionInNewTab(sessionId: string) {
if (typeof window === "undefined") return;
window.open(sessionHref(sessionId), "_blank", "noopener,noreferrer");
}
async function handleSessionClick(sessionId: string) {
const session = chatStore.sessions.find((item) => item.id === sessionId);
await router.push({
@@ -442,6 +456,7 @@ const contextMenuOptions = computed(() => {
},
],
})
options.push({ label: t("chat.openSessionInNewTab"), key: "open-link" })
options.push({ label: t("chat.copySessionLink"), key: "copy-link" })
options.push({ label: t("chat.copySessionId"), key: "copy-id" })
return options
@@ -478,6 +493,8 @@ async function handleContextMenuSelect(key: string) {
copySessionLink(contextSessionId.value);
} else if (key === "copy-id") {
copySessionId(contextSessionId.value);
} else if (key === "open-link") {
openSessionInNewTab(contextSessionId.value);
} else if (parseExportKey(key)) {
const { mode, ext } = parseExportKey(key)!;
const loadingMsg = mode === "compressed" ? message.loading(t("chat.exportCompressing"), { duration: 0 }) : null;
@@ -846,6 +863,7 @@ async function handleSessionModelCustomSubmit() {
:selectable="isBatchMode"
:selected="isSessionSelected(s.id)"
:show-profile="true"
:to="sessionHref(s.id)"
@select="handleSessionClick(s.id)"
@contextmenu="handleContextMenu($event, s.id)"
@delete="handleDeleteSession(s.id)"
@@ -867,6 +885,7 @@ async function handleSessionModelCustomSubmit() {
:selectable="isBatchMode"
:selected="isSessionSelected(s.id)"
:show-profile="true"
:to="sessionHref(s.id)"
@select="handleSessionClick(s.id)"
@contextmenu="handleContextMenu($event, s.id)"
@delete="handleDeleteSession(s.id)"
@@ -1714,6 +1733,7 @@ async function handleSessionModelCustomSubmit() {
border-radius: $radius-sm;
cursor: pointer;
text-align: left;
text-decoration: none;
color: $text-secondary;
transition: all $transition-fast;
margin-bottom: 2px;
@@ -17,6 +17,7 @@ const props = withDefaults(defineProps<{
selectable?: boolean
selected?: boolean
showProfile?: boolean
to?: string
}>(), {
showProfile: true,
})
@@ -77,11 +78,18 @@ function onTouchMove() {
}
}
function onClick() {
function isModifiedNavigation(event?: MouseEvent) {
return !!event && (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0)
}
function onClick(event?: MouseEvent) {
if (longPressTriggered.value) {
longPressTriggered.value = false
event?.preventDefault()
return
}
if (isModifiedNavigation(event)) return
if (props.to && !props.selectable) event?.preventDefault()
emit('select')
}
@@ -91,10 +99,13 @@ onUnmounted(() => {
</script>
<template>
<button
<component
:is="selectable || !to ? 'button' : 'a'"
class="session-item"
:class="{ active, 'batch-mode': selectable, 'missing-models': profileModelsMissing }"
:aria-current="active ? 'page' : undefined"
:href="!selectable ? to : undefined"
:type="selectable || !to ? 'button' : undefined"
@click="onClick"
@contextmenu="emit('contextmenu', $event)"
@touchstart="onTouchStart"
@@ -119,7 +130,7 @@ onUnmounted(() => {
</span>
<NTooltip v-if="profileModelsMissing" trigger="click" placement="top">
<template #trigger>
<button class="session-item-warning" type="button" @click.stop>
<button class="session-item-warning" type="button" @click.stop.prevent>
!
</button>
</template>
@@ -137,13 +148,13 @@ onUnmounted(() => {
</div>
<NPopconfirm v-if="canDelete && !selectable" @positive-click="emit('delete')">
<template #trigger>
<button class="session-item-delete" @click.stop>
<button class="session-item-delete" @click.stop.prevent>
<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>
</button>
</template>
{{ t('chat.deleteSession') }}
</NPopconfirm>
</button>
</component>
</template>
<style scoped>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, reactive, ref } from "vue";
import { computed, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { NButton, NModal, useMessage } from "naive-ui";
@@ -9,6 +9,8 @@ import ProfileSelector from "./ProfileSelector.vue";
import LanguageSwitch from "./LanguageSwitch.vue";
import ThemeSwitch from "./ThemeSwitch.vue";
import { useSessionSearch } from '@/composables/useSessionSearch'
import { usePersistentRecord } from '@/composables/usePersistentRecord'
import RouteLinkItem from '@/components/common/RouteLinkItem.vue'
import { changelog } from "@/data/changelog";
import { isStoredSuperAdmin } from "@/api/client";
@@ -25,9 +27,13 @@ const selectedKey = computed(() => {
return route.name as string;
});
const isSuperAdmin = computed(() => isStoredSuperAdmin());
function isNavActive(...names: string[]) {
return names.includes(selectedKey.value);
}
const logoPath = '/logo.png';
const collapsedGroups = reactive<Record<string, boolean>>({});
const { record: collapsedGroups, persist: persistCollapsedGroups } = usePersistentRecord('hermes.sidebar.collapsedGroups');
type SidebarGroupKey = "Conversation" | "Agent" | "Monitoring" | "System";
@@ -37,15 +43,13 @@ function groupLabel(key: SidebarGroupKey) {
function toggleGroup(key: string) {
collapsedGroups[key] = !collapsedGroups[key];
persistCollapsedGroups();
}
function isGroupCollapsed(key: string) {
return !!collapsedGroups[key];
}
function handleNav(key: string) {
router.push({ name: key });
}
async function handleUpdate() {
const ok = await appStore.doUpdate();
@@ -75,11 +79,11 @@ function openChangelog() {
<template>
<aside class="sidebar" :class="{ open: appStore.sidebarOpen, collapsed: appStore.sidebarCollapsed }">
<div class="sidebar-logo" @click="router.push('/hermes/chat')">
<RouteLinkItem class="sidebar-logo" :to="{ name: 'hermes.chat' }">
<img :src="logoPath" alt="Hermes" class="logo-img" />
<span class="logo-text">Hermes</span>
<!-- <video class="logo-dance" :src="isDark ? danceVideoDark : danceVideoLight" autoplay loop muted playsinline /> -->
</div>
</RouteLinkItem>
<button class="collapse-btn" @click="appStore.toggleSidebarCollapsed()" :title="appStore.sidebarCollapsed ? t('sidebar.expand') : t('sidebar.collapse')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -98,20 +102,20 @@ function openChangelog() {
</svg>
</div>
<div v-show="!isGroupCollapsed('conversation')" class="nav-group-items">
<button class="nav-item" :class="{ active: selectedKey === 'hermes.chat' }" @click="handleNav('hermes.chat')">
<RouteLinkItem class="nav-item" :to="{ name: 'hermes.chat' }" :active="isNavActive('hermes.chat', 'hermes.session')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
<span>{{ t("sidebar.chat") }}</span>
</button>
<button class="nav-item" :class="{ active: selectedKey === 'hermes.history' }" @click="handleNav('hermes.history')">
</RouteLinkItem>
<RouteLinkItem class="nav-item" :to="{ name: 'hermes.history' }" :active="isNavActive('hermes.history', 'hermes.historySession')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span>{{ t("sidebar.history") }}</span>
</button>
<button class="nav-item" :class="{ active: selectedKey === 'hermes.groupChat' }" @click="handleNav('hermes.groupChat')">
</RouteLinkItem>
<RouteLinkItem class="nav-item" :to="{ name: 'hermes.groupChat' }" :active="isNavActive('hermes.groupChat', 'hermes.groupChatRoom')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
@@ -119,7 +123,7 @@ function openChangelog() {
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
<span>{{ t("sidebar.groupChat") }}<span class="beta-tag">(beta)</span></span>
</button>
</RouteLinkItem>
<button class="nav-item" @click="openSessionSearch">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="7" />
@@ -143,7 +147,7 @@ function openChangelog() {
</svg>
</div>
<div v-show="!isGroupCollapsed('agent')" class="nav-group-items">
<button class="nav-item" :class="{ active: selectedKey === 'hermes.jobs' }" @click="handleNav('hermes.jobs')">
<RouteLinkItem class="nav-item" :to="{ name: 'hermes.jobs' }" :active="selectedKey === 'hermes.jobs'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
@@ -151,45 +155,45 @@ function openChangelog() {
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<span>{{ t("sidebar.jobs") }}</span>
</button>
<button class="nav-item" :class="{ active: selectedKey === 'hermes.kanban' }" @click="handleNav('hermes.kanban')">
</RouteLinkItem>
<RouteLinkItem class="nav-item" :to="{ name: 'hermes.kanban' }" :active="selectedKey === 'hermes.kanban'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="5" height="18" rx="1" />
<rect x="10" y="3" width="5" height="12" rx="1" />
<rect x="17" y="3" width="5" height="18" rx="1" />
</svg>
<span>{{ t("sidebar.kanban") }}</span>
</button>
<button class="nav-item" :class="{ active: selectedKey === 'hermes.channels' }" @click="handleNav('hermes.channels')">
</RouteLinkItem>
<RouteLinkItem class="nav-item" :to="{ name: 'hermes.channels' }" :active="selectedKey === 'hermes.channels'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
<span>{{ t("sidebar.channels") }}</span>
</button>
<button class="nav-item" :class="{ active: selectedKey === 'hermes.skills' }" @click="handleNav('hermes.skills')">
</RouteLinkItem>
<RouteLinkItem class="nav-item" :to="{ name: 'hermes.skills' }" :active="selectedKey === 'hermes.skills'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<polygon points="12 2 2 7 12 12 22 7 12 2" />
<polyline points="2 17 12 22 22 17" />
<polyline points="2 12 12 17 22 12" />
</svg>
<span>{{ t("sidebar.skills") }}</span>
</button>
<button class="nav-item" :class="{ active: selectedKey === 'hermes.plugins' }" @click="handleNav('hermes.plugins')">
</RouteLinkItem>
<RouteLinkItem class="nav-item" :to="{ name: 'hermes.plugins' }" :active="selectedKey === 'hermes.plugins'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l2.1-2.1a4 4 0 0 1-5.3 5.3l-7.8 7.8a2.1 2.1 0 0 1-3-3l7.8-7.8a4 4 0 0 1 5.3-5.3l-2.1 2.1z" />
<path d="M5 19l1-1" />
</svg>
<span>{{ t("sidebar.plugins") }}</span>
</button>
<button class="nav-item" :class="{ active: selectedKey === 'hermes.memory' }" @click="handleNav('hermes.memory')">
</RouteLinkItem>
<RouteLinkItem class="nav-item" :to="{ name: 'hermes.memory' }" :active="selectedKey === 'hermes.memory'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 18h6" />
<path d="M10 22h4" />
<path d="M12 2a7 7 0 0 0-4 12.7V17h8v-2.3A7 7 0 0 0 12 2z" />
</svg>
<span>{{ t("sidebar.memory") }}</span>
</button>
<button class="nav-item" :class="{ active: selectedKey === 'hermes.models' }" @click="handleNav('hermes.models')">
</RouteLinkItem>
<RouteLinkItem class="nav-item" :to="{ name: 'hermes.models' }" :active="selectedKey === 'hermes.models'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M12 1v4" />
@@ -202,7 +206,7 @@ function openChangelog() {
<path d="M16.95 7.05l2.83-2.83" />
</svg>
<span>{{ t("sidebar.models") }}</span>
</button>
</RouteLinkItem>
</div>
</div>
@@ -215,7 +219,7 @@ function openChangelog() {
</svg>
</div>
<div v-show="!isGroupCollapsed('monitoring')" class="nav-group-items">
<button class="nav-item" :class="{ active: selectedKey === 'hermes.logs' }" @click="handleNav('hermes.logs')">
<RouteLinkItem class="nav-item" :to="{ name: 'hermes.logs' }" :active="selectedKey === 'hermes.logs'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
@@ -224,28 +228,28 @@ function openChangelog() {
<polyline points="10 9 9 9 8 9" />
</svg>
<span>{{ t("sidebar.logs") }}</span>
</button>
<button class="nav-item" :class="{ active: selectedKey === 'hermes.usage' }" @click="handleNav('hermes.usage')">
</RouteLinkItem>
<RouteLinkItem class="nav-item" :to="{ name: 'hermes.usage' }" :active="selectedKey === 'hermes.usage'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="12" width="4" height="9" rx="1" />
<rect x="10" y="7" width="4" height="14" rx="1" />
<rect x="17" y="3" width="4" height="18" rx="1" />
</svg>
<span>{{ t("sidebar.usage") }}</span>
</button>
<button v-if="isSuperAdmin" class="nav-item" :class="{ active: selectedKey === 'hermes.performance' }" @click="handleNav('hermes.performance')">
</RouteLinkItem>
<RouteLinkItem v-if="isSuperAdmin" class="nav-item" :to="{ name: 'hermes.performance' }" :active="selectedKey === 'hermes.performance'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
<span>{{ t("sidebar.performance") }}</span>
</button>
<button class="nav-item" :class="{ active: selectedKey === 'hermes.skillsUsage' }" @click="handleNav('hermes.skillsUsage')">
</RouteLinkItem>
<RouteLinkItem class="nav-item" :to="{ name: 'hermes.skillsUsage' }" :active="selectedKey === 'hermes.skillsUsage'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.21 15.89A10 10 0 1 1 8.11 2.79" />
<path d="M22 12A10 10 0 0 0 12 2v10z" />
</svg>
<span>{{ t("sidebar.skillsUsage") }}</span>
</button>
</RouteLinkItem>
</div>
</div>
@@ -258,20 +262,20 @@ function openChangelog() {
</svg>
</div>
<div v-show="!isGroupCollapsed('system')" class="nav-group-items">
<button v-if="isSuperAdmin" class="nav-item" :class="{ active: selectedKey === 'hermes.profiles' }" @click="handleNav('hermes.profiles')">
<RouteLinkItem v-if="isSuperAdmin" class="nav-item" :to="{ name: 'hermes.profiles' }" :active="selectedKey === 'hermes.profiles'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<span>{{ t("sidebar.profiles") }}</span>
</button>
<button class="nav-item" :class="{ active: selectedKey === 'hermes.settings' }" @click="handleNav('hermes.settings')">
</RouteLinkItem>
<RouteLinkItem class="nav-item" :to="{ name: 'hermes.settings' }" :active="selectedKey === 'hermes.settings'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
<span>{{ t("sidebar.settings") }}</span>
</button>
</RouteLinkItem>
</div>
</div>
</nav>
@@ -480,6 +484,8 @@ function openChangelog() {
padding: 12px;
border: none;
background: none;
appearance: none;
text-decoration: none;
color: $text-secondary;
font-size: 14px;
border-radius: $radius-sm;
@@ -801,6 +807,8 @@ function openChangelog() {
height: 28px;
border: none;
background: none;
appearance: none;
text-decoration: none;
color: $text-muted;
border-radius: $radius-sm;
cursor: pointer;
@@ -0,0 +1,23 @@
import { reactive } from 'vue'
export function usePersistentRecord(key: string) {
let initial: Record<string, boolean> = {}
if (typeof window !== 'undefined') {
try {
const raw = window.localStorage.getItem(key)
if (raw) initial = JSON.parse(raw)
} catch {
initial = {}
}
}
const record = reactive<Record<string, boolean>>({ ...initial })
function persist() {
if (typeof window === 'undefined') return
window.localStorage.setItem(key, JSON.stringify({ ...record }))
}
return { record, persist }
}
+2
View File
@@ -273,6 +273,8 @@ export default {
monitorRoleUser: 'Benutzer',
monitorRoleAssistant: 'Assistent',
copySessionLink: 'Sitzungslink kopieren',
openSessionInNewTab: 'Open in new tab',
sessionLinkCopied: 'Session link copied',
copySessionId: 'Sitzungs-ID kopieren',
export: 'Exportieren',
exportFull: 'Vollständiger Export (JSON)',
+2
View File
@@ -289,6 +289,8 @@ export default {
monitorRoleUser: 'User',
monitorRoleAssistant: 'Assistant',
copySessionLink: 'Copy Session Link',
openSessionInNewTab: 'Open in new tab',
sessionLinkCopied: 'Session link copied',
copySessionId: 'Copy Session ID',
export: 'Export',
exportFull: 'Full Export (JSON)',
+2
View File
@@ -273,6 +273,8 @@ export default {
monitorRoleUser: 'Usuario',
monitorRoleAssistant: 'Asistente',
copySessionLink: 'Copiar enlace de sesión',
openSessionInNewTab: 'Open in new tab',
sessionLinkCopied: 'Session link copied',
copySessionId: 'Copiar ID de sesión',
export: 'Exportar',
exportFull: 'Exportación completa (JSON)',
+2
View File
@@ -273,6 +273,8 @@ export default {
monitorRoleUser: 'Utilisateur',
monitorRoleAssistant: 'Assistant',
copySessionLink: 'Copier le lien de session',
openSessionInNewTab: 'Open in new tab',
sessionLinkCopied: 'Session link copied',
copySessionId: "Copier l'ID de session",
export: 'Exporter',
exportFull: 'Export complet (JSON)',
+2
View File
@@ -273,6 +273,8 @@ export default {
monitorRoleUser: 'ユーザー',
monitorRoleAssistant: 'アシスタント',
copySessionLink: 'セッションリンクをコピー',
openSessionInNewTab: 'Open in new tab',
sessionLinkCopied: 'Session link copied',
copySessionId: 'セッション ID をコピー',
export: 'エクスポート',
exportFull: 'フルエクスポート (JSON)',
+2
View File
@@ -273,6 +273,8 @@ export default {
monitorRoleUser: '사용자',
monitorRoleAssistant: '어시스턴트',
copySessionLink: '세션 링크 복사',
openSessionInNewTab: 'Open in new tab',
sessionLinkCopied: 'Session link copied',
copySessionId: '세션 ID 복사',
export: '내보내기',
exportFull: '전체 내보내기 (JSON)',
+2
View File
@@ -273,6 +273,8 @@ export default {
monitorRoleUser: 'Usuário',
monitorRoleAssistant: 'Assistente',
copySessionLink: 'Copiar link da sessão',
openSessionInNewTab: 'Open in new tab',
sessionLinkCopied: 'Session link copied',
copySessionId: 'Copiar ID da sessão',
export: 'Exportar',
exportFull: 'Exportação completa (JSON)',
@@ -287,6 +287,8 @@ export default {
monitorRoleUser: '使用者',
monitorRoleAssistant: '助手',
copySessionLink: '複製工作階段連結',
openSessionInNewTab: 'Open in new tab',
sessionLinkCopied: 'Session link copied',
copySessionId: '複製工作階段 ID',
export: '匯出',
exportFull: '完整匯出 (JSON)',
+2
View File
@@ -289,6 +289,8 @@ export default {
monitorRoleUser: '用户',
monitorRoleAssistant: '助手',
copySessionLink: '复制会话链接',
openSessionInNewTab: 'Open in new tab',
sessionLinkCopied: 'Session link copied',
copySessionId: '复制会话 ID',
export: '导出',
exportFull: '全量导出 (JSON)',
+34
View File
@@ -0,0 +1,34 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent } from 'vue'
import RouteLinkItem from '@/components/common/RouteLinkItem.vue'
describe('RouteLinkItem', () => {
it('renders a real anchor with href from RouterLink custom slot', () => {
const wrapper = mount(RouteLinkItem, {
props: {
to: { name: 'hermes.session', params: { id: 's1' } },
active: true,
},
slots: {
default: 'Session S1',
},
global: {
components: {
RouterLink: defineComponent({
props: ['to', 'custom'],
template: '<slot href="/session/s1" :navigate="() => {}" :is-active="true" :is-exact-active="true" />',
}),
},
},
})
const link = wrapper.get('a')
expect(link.attributes('href')).toBe('/session/s1')
expect(link.classes()).toContain('route-link-item')
expect(link.classes()).toContain('active')
expect(link.attributes('aria-current')).toBe('page')
expect(link.text()).toContain('Session S1')
})
})
+138
View File
@@ -0,0 +1,138 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent } from 'vue'
import SessionListItem from '@/components/hermes/chat/SessionListItem.vue'
vi.mock('@/stores/hermes/app', () => ({
useAppStore: () => ({
profileModelGroups: [],
displayModelName: (model: string) => model,
}),
}))
vi.mock('@/stores/hermes/profiles', () => ({
useProfilesStore: () => ({ profiles: [] }),
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('@/shared/session-display', () => ({
formatTimestampMs: () => 'now',
}))
vi.mock('naive-ui', () => ({
NPopconfirm: defineComponent({
name: 'NPopconfirm',
emits: ['positive-click'],
template: '<span><slot name="trigger" /><slot /></span>',
}),
NCheckbox: defineComponent({
name: 'NCheckbox',
props: ['checked'],
emits: ['click'],
template: '<input type="checkbox" :checked="checked" @click="$emit(\'click\')" />',
}),
NTooltip: defineComponent({
name: 'NTooltip',
template: '<span><slot name="trigger" /><slot /></span>',
}),
}))
const session = {
id: 's1',
title: 'Session One',
model: 'gpt-test',
provider: 'openai',
createdAt: Date.now(),
profile: 'kira',
}
describe('SessionListItem', () => {
it('renders normal mode as a link to the session route', () => {
const wrapper = mount(SessionListItem, {
props: {
session,
active: false,
pinned: false,
canDelete: true,
to: '/session/s1',
},
global: {
stubs: {
ProfileAvatar: true,
},
},
})
const link = wrapper.get('a.session-item')
expect(link.attributes('href')).toBe('/session/s1')
expect(wrapper.find('button.session-item').exists()).toBe(false)
})
it('renders selectable mode as a button and does not expose row href', () => {
const wrapper = mount(SessionListItem, {
props: {
session,
active: false,
pinned: false,
canDelete: true,
selectable: true,
selected: false,
to: '/session/s1',
},
global: {
stubs: {
ProfileAvatar: true,
},
},
})
expect(wrapper.find('button.session-item').exists()).toBe(true)
expect(wrapper.find('a.session-item').exists()).toBe(false)
})
it('does not select the row when clicking nested action controls', async () => {
const wrapper = mount(SessionListItem, {
props: {
session,
active: false,
pinned: false,
canDelete: true,
to: '/session/s1',
},
global: {
stubs: {
ProfileAvatar: true,
},
},
})
await wrapper.get('button.session-item-delete').trigger('click')
expect(wrapper.emitted('select')).toBeUndefined()
})
it('does not hijack modified clicks on normal links', async () => {
const wrapper = mount(SessionListItem, {
props: {
session,
active: false,
pinned: false,
canDelete: true,
to: '/session/s1',
},
global: {
stubs: {
ProfileAvatar: true,
},
},
})
const link = wrapper.get('a.session-item')
link.element.addEventListener('click', event => event.preventDefault())
await link.trigger('click', { ctrlKey: true })
expect(wrapper.emitted('select')).toBeUndefined()
})
})
+24
View File
@@ -55,6 +55,30 @@ vi.mock('/logo.png', () => ({
default: 'logo.png',
}))
vi.mock('@/components/layout/ProfileSelector.vue', () => ({
default: { name: 'ProfileSelector', template: '<div />' },
}))
vi.mock('@/components/layout/ModelSelector.vue', () => ({
default: { name: 'ModelSelector', template: '<div />' },
}))
vi.mock('@/components/layout/LanguageSwitch.vue', () => ({
default: { name: 'LanguageSwitch', template: '<div />' },
}))
vi.mock('@/components/layout/ThemeSwitch.vue', () => ({
default: { name: 'ThemeSwitch', template: '<div />' },
}))
vi.mock('@/components/common/RouteLinkItem.vue', () => ({
default: {
name: 'RouteLinkItem',
props: ['to', 'active'],
template: '<a class="route-link-item" :class="{ active }" href="#"><slot /></a>',
},
}))
vi.mock('naive-ui', async () => {
const actual = await vi.importActual<any>('naive-ui')
return {
@@ -0,0 +1,28 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it } from 'vitest'
import { usePersistentRecord } from '@/composables/usePersistentRecord'
describe('usePersistentRecord', () => {
beforeEach(() => localStorage.clear())
it('loads saved record and persists updates', () => {
localStorage.setItem('hermes.sidebar.collapsedGroups', JSON.stringify({ agent: true }))
const state = usePersistentRecord('hermes.sidebar.collapsedGroups')
expect(state.record.agent).toBe(true)
state.record.system = true
state.persist()
expect(JSON.parse(localStorage.getItem('hermes.sidebar.collapsedGroups') || '{}')).toEqual({
agent: true,
system: true,
})
})
it('ignores invalid stored values', () => {
localStorage.setItem('hermes.sidebar.collapsedGroups', 'not-json')
const state = usePersistentRecord('hermes.sidebar.collapsedGroups')
expect({ ...state.record }).toEqual({})
})
})
+6 -2
View File
@@ -16,12 +16,16 @@ test('renders authenticated shell and navigates between key product routes', asy
const cronHistoryRequest = api.requests.find((request) => request.pathname === '/api/cron-history')
expect(cronHistoryRequest?.headers['x-hermes-profile']).toBe('research')
await page.locator('aside.sidebar').getByRole('button', { name: /^Models$/ }).click()
const modelsLink = page.locator('aside.sidebar').getByRole('link', { name: /^Models$/ })
await expect(modelsLink).toHaveAttribute('href', '#/hermes/models')
await modelsLink.click()
await expect(page).toHaveURL(/#\/hermes\/models$/)
await expect(page.getByRole('heading', { name: 'Models' })).toBeVisible()
await expect(page.getByText('test-model').first()).toBeVisible()
await page.locator('aside.sidebar').getByRole('button', { name: /^Settings$/ }).click()
const settingsLink = page.locator('aside.sidebar').getByRole('link', { name: /^Settings$/ })
await expect(settingsLink).toHaveAttribute('href', '#/hermes/settings')
await settingsLink.click()
await expect(page).toHaveURL(/#\/hermes\/settings$/)
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible()
expect(api.unexpectedRequests).toEqual([])
+37
View File
@@ -0,0 +1,37 @@
import { expect, test } from '@playwright/test'
import { authenticate, mockHermesApi, TEST_ACCESS_KEY } from './fixtures'
const sampleSession = {
id: 'session-native-1',
title: 'Native Link Session',
source: 'cli',
model: 'test-model',
provider: 'test-provider',
profile: 'research',
started_at: 1_700_000_000,
ended_at: null,
last_active: 1_700_000_100,
message_count: 2,
}
test('sidebar navigation exposes native links', async ({ page }) => {
await authenticate(page, TEST_ACCESS_KEY, 'research')
await mockHermesApi(page)
await page.goto('/#/hermes/chat')
const models = page.locator('aside.sidebar').getByRole('link', { name: /^Models$/ })
await expect(models).toHaveAttribute('href', '#/hermes/models')
const history = page.locator('aside.sidebar').getByRole('link', { name: /^History$/ })
await expect(history).toHaveAttribute('href', '#/hermes/history')
})
test('session rows expose native session links', async ({ page }) => {
await authenticate(page, TEST_ACCESS_KEY, 'research')
await mockHermesApi(page, { sessions: [sampleSession] })
await page.goto('/#/hermes/chat')
const sessionLink = page.locator('.session-items a.session-item').first()
await expect(sessionLink).toHaveAttribute('href', '#/hermes/session/session-native-1?profile=research')
await expect(sessionLink).toContainText('Native Link Session')
})