feat: make navigation use native links (#973)
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
@@ -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)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -273,6 +273,8 @@ export default {
|
||||
monitorRoleUser: 'ユーザー',
|
||||
monitorRoleAssistant: 'アシスタント',
|
||||
copySessionLink: 'セッションリンクをコピー',
|
||||
openSessionInNewTab: 'Open in new tab',
|
||||
sessionLinkCopied: 'Session link copied',
|
||||
copySessionId: 'セッション ID をコピー',
|
||||
export: 'エクスポート',
|
||||
exportFull: 'フルエクスポート (JSON)',
|
||||
|
||||
@@ -273,6 +273,8 @@ export default {
|
||||
monitorRoleUser: '사용자',
|
||||
monitorRoleAssistant: '어시스턴트',
|
||||
copySessionLink: '세션 링크 복사',
|
||||
openSessionInNewTab: 'Open in new tab',
|
||||
sessionLinkCopied: 'Session link copied',
|
||||
copySessionId: '세션 ID 복사',
|
||||
export: '내보내기',
|
||||
exportFull: '전체 내보내기 (JSON)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -289,6 +289,8 @@ export default {
|
||||
monitorRoleUser: '用户',
|
||||
monitorRoleAssistant: '助手',
|
||||
copySessionLink: '复制会话链接',
|
||||
openSessionInNewTab: 'Open in new tab',
|
||||
sessionLinkCopied: 'Session link copied',
|
||||
copySessionId: '复制会话 ID',
|
||||
export: '导出',
|
||||
exportFull: '全量导出 (JSON)',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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({})
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
Reference in New Issue
Block a user