Merge pull request #44 from 0xnuu/pr/chat-resilience-sidebar
feat(chat): improve resilience and collapsible sidebar
This commit is contained in:
@@ -11,7 +11,15 @@ const chatStore = useChatStore()
|
|||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const showSessions = ref(true)
|
// 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
|
||||||
|
// only flips it to `false` AFTER the first render, causing a visible flash
|
||||||
|
// where the session list covers the chat content ("auto-fixes after a
|
||||||
|
// moment" — that was the race).
|
||||||
|
const showSessions = ref(
|
||||||
|
typeof window === 'undefined' || !window.matchMedia('(max-width: 768px)').matches,
|
||||||
|
)
|
||||||
let mobileQuery: MediaQueryList | null = null
|
let mobileQuery: MediaQueryList | null = null
|
||||||
|
|
||||||
function handleSessionClick(sessionId: string) {
|
function handleSessionClick(sessionId: string) {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ watch(
|
|||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
);
|
);
|
||||||
watch(
|
watch(
|
||||||
() => chatStore.isStreaming,
|
() => chatStore.isRunActive,
|
||||||
(v) => {
|
(v) => {
|
||||||
if (v) scrollToBottom();
|
if (v) scrollToBottom();
|
||||||
},
|
},
|
||||||
@@ -61,7 +61,7 @@ watch(currentToolCalls, scrollToBottom);
|
|||||||
</div>
|
</div>
|
||||||
<MessageItem v-for="msg in displayMessages" :key="msg.id" :message="msg" />
|
<MessageItem v-for="msg in displayMessages" :key="msg.id" :message="msg" />
|
||||||
<Transition name="fade">
|
<Transition name="fade">
|
||||||
<div v-if="chatStore.isStreaming" class="streaming-indicator">
|
<div v-if="chatStore.isRunActive" class="streaming-indicator">
|
||||||
<video
|
<video
|
||||||
:src="isDark ? thinkingVideoDark : thinkingVideoLight"
|
:src="isDark ? thinkingVideoDark : thinkingVideoLight"
|
||||||
autoplay
|
autoplay
|
||||||
|
|||||||
@@ -36,17 +36,43 @@ async function handleUpdate() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside class="sidebar" :class="{ open: appStore.sidebarOpen }">
|
<aside class="sidebar" :class="{ open: appStore.sidebarOpen, collapsed: appStore.sidebarCollapsed }">
|
||||||
<div class="sidebar-logo" @click="router.push('/hermes/chat')">
|
<div class="sidebar-logo">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="logo-main"
|
||||||
|
:title="appStore.sidebarCollapsed ? t('sidebar.expand') : 'Hermes'"
|
||||||
|
@click="router.push('/hermes/chat')"
|
||||||
|
>
|
||||||
<img src="/logo.png" alt="Hermes" class="logo-img" />
|
<img src="/logo.png" alt="Hermes" class="logo-img" />
|
||||||
<span class="logo-text">Hermes</span>
|
<span class="logo-text">Hermes</span>
|
||||||
<video class="logo-dance" :src="isDark ? danceVideoDark : danceVideoLight" autoplay loop muted playsinline />
|
</button>
|
||||||
|
<video
|
||||||
|
class="logo-dance"
|
||||||
|
:src="isDark ? danceVideoDark : danceVideoLight"
|
||||||
|
autoplay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
playsinline
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="sidebar-collapse-btn"
|
||||||
|
:title="appStore.sidebarCollapsed ? t('sidebar.expand') : t('sidebar.collapse')"
|
||||||
|
@click="appStore.toggleSidebarCollapsed"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline v-if="appStore.sidebarCollapsed" points="9 18 15 12 9 6" />
|
||||||
|
<polyline v-else points="15 18 9 12 15 6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
<button
|
<button
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
:class="{ active: selectedKey === 'hermes.chat' }"
|
:class="{ active: selectedKey === 'hermes.chat' }"
|
||||||
|
:title="t('sidebar.chat')"
|
||||||
@click="handleNav('hermes.chat')"
|
@click="handleNav('hermes.chat')"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -69,6 +95,7 @@ async function handleUpdate() {
|
|||||||
<button
|
<button
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
:class="{ active: selectedKey === 'hermes.jobs' }"
|
:class="{ active: selectedKey === 'hermes.jobs' }"
|
||||||
|
:title="t('sidebar.jobs')"
|
||||||
@click="handleNav('hermes.jobs')"
|
@click="handleNav('hermes.jobs')"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -92,6 +119,7 @@ async function handleUpdate() {
|
|||||||
<button
|
<button
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
:class="{ active: selectedKey === 'hermes.models' }"
|
:class="{ active: selectedKey === 'hermes.models' }"
|
||||||
|
:title="t('sidebar.models')"
|
||||||
@click="handleNav('hermes.models')"
|
@click="handleNav('hermes.models')"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -120,6 +148,7 @@ async function handleUpdate() {
|
|||||||
<button
|
<button
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
:class="{ active: selectedKey === 'hermes.channels' }"
|
:class="{ active: selectedKey === 'hermes.channels' }"
|
||||||
|
:title="t('sidebar.channels')"
|
||||||
@click="handleNav('hermes.channels')"
|
@click="handleNav('hermes.channels')"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -140,6 +169,7 @@ async function handleUpdate() {
|
|||||||
<button
|
<button
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
:class="{ active: selectedKey === 'hermes.skills' }"
|
:class="{ active: selectedKey === 'hermes.skills' }"
|
||||||
|
:title="t('sidebar.skills')"
|
||||||
@click="handleNav('hermes.skills')"
|
@click="handleNav('hermes.skills')"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -162,6 +192,7 @@ async function handleUpdate() {
|
|||||||
<button
|
<button
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
:class="{ active: selectedKey === 'hermes.memory' }"
|
:class="{ active: selectedKey === 'hermes.memory' }"
|
||||||
|
:title="t('sidebar.memory')"
|
||||||
@click="handleNav('hermes.memory')"
|
@click="handleNav('hermes.memory')"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -184,6 +215,7 @@ async function handleUpdate() {
|
|||||||
<button
|
<button
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
:class="{ active: selectedKey === 'hermes.logs' }"
|
:class="{ active: selectedKey === 'hermes.logs' }"
|
||||||
|
:title="t('sidebar.logs')"
|
||||||
@click="handleNav('hermes.logs')"
|
@click="handleNav('hermes.logs')"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -210,6 +242,7 @@ async function handleUpdate() {
|
|||||||
<button
|
<button
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
:class="{ active: selectedKey === 'hermes.usage' }"
|
:class="{ active: selectedKey === 'hermes.usage' }"
|
||||||
|
:title="t('sidebar.usage')"
|
||||||
@click="handleNav('hermes.usage')"
|
@click="handleNav('hermes.usage')"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -253,6 +286,7 @@ async function handleUpdate() {
|
|||||||
<button
|
<button
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
:class="{ active: selectedKey === 'hermes.terminal' }"
|
:class="{ active: selectedKey === 'hermes.terminal' }"
|
||||||
|
:title="t('sidebar.terminal')"
|
||||||
@click="handleNav('hermes.terminal')"
|
@click="handleNav('hermes.terminal')"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -274,6 +308,7 @@ async function handleUpdate() {
|
|||||||
<button
|
<button
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
:class="{ active: selectedKey === 'hermes.settings' }"
|
:class="{ active: selectedKey === 'hermes.settings' }"
|
||||||
|
:title="t('sidebar.settings')"
|
||||||
@click="handleNav('hermes.settings')"
|
@click="handleNav('hermes.settings')"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -355,11 +390,10 @@ async function handleUpdate() {
|
|||||||
.sidebar-logo {
|
.sidebar-logo {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 6px;
|
||||||
padding: 20px 12px;
|
padding: 20px 12px;
|
||||||
margin: 0 -12px;
|
margin: 0 -12px;
|
||||||
color: $text-primary;
|
color: $text-primary;
|
||||||
cursor: pointer;
|
|
||||||
background-color: $bg-card;
|
background-color: $bg-card;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
@@ -369,6 +403,20 @@ async function handleUpdate() {
|
|||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
|
.logo-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.logo-text {
|
.logo-text {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -377,7 +425,8 @@ async function handleUpdate() {
|
|||||||
|
|
||||||
.logo-dance {
|
.logo-dance {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 12px;
|
// Give the 36-wide collapse button + 12px sidebar padding breathing room.
|
||||||
|
right: 54px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
height: 100px;
|
height: 100px;
|
||||||
@@ -389,6 +438,53 @@ async function handleUpdate() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-collapse-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
// 36×36 meets the 44dp Material / 44pt Apple touch-target floor after
|
||||||
|
// typical zoom-out (e.g. Chrome mobile at 80% → ~29 physical px, still
|
||||||
|
// finger-friendly). The 14×14 SVG inside keeps the chevron visually small.
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
background: $bg-card;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
color: $text-secondary;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
transition: all $transition-fast;
|
||||||
|
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
|
// Invisible padding via ::before extends the hit box a further 6px on each
|
||||||
|
// side without affecting layout — makes the button easy to tap even with
|
||||||
|
// imprecise touch input.
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $text-primary;
|
||||||
|
border-color: $accent-muted;
|
||||||
|
background: $bg-card-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
transform: scale(0.94);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-nav {
|
.sidebar-nav {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -496,11 +592,78 @@ async function handleUpdate() {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Desktop-only collapsed ("icon rail") state. Mobile continues to use the
|
||||||
|
// slide-in open/close behaviour below.
|
||||||
|
@media (min-width: #{$breakpoint-mobile + 1px}) {
|
||||||
|
.sidebar.collapsed {
|
||||||
|
width: $sidebar-collapsed-width;
|
||||||
|
padding: 0 6px 16px;
|
||||||
|
|
||||||
|
.sidebar-logo {
|
||||||
|
padding: 16px 6px;
|
||||||
|
margin: 0 -6px;
|
||||||
|
gap: 4px;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.logo-main {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-text,
|
||||||
|
.logo-dance {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
gap: 0;
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.model-selector) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-row {
|
||||||
|
padding: 8px 0;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
:deep(.input-sm),
|
||||||
|
.status-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-info {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: $breakpoint-mobile) {
|
@media (max-width: $breakpoint-mobile) {
|
||||||
.logo-dance {
|
.logo-dance {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Desktop-only collapse toggle — mobile relies on the slide-in open state.
|
||||||
|
.sidebar-collapse-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.status-row {
|
.status-row {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ export default {
|
|||||||
settings: 'Settings',
|
settings: 'Settings',
|
||||||
connected: 'Connected',
|
connected: 'Connected',
|
||||||
disconnected: 'Disconnected',
|
disconnected: 'Disconnected',
|
||||||
|
collapse: 'Collapse menu',
|
||||||
|
expand: 'Expand menu',
|
||||||
updateTip: 'Run "hermes-web-ui update" in terminal to update',
|
updateTip: 'Run "hermes-web-ui update" in terminal to update',
|
||||||
updateVersion: 'Upgrade to v{version}',
|
updateVersion: 'Upgrade to v{version}',
|
||||||
updating: 'Updating...',
|
updating: 'Updating...',
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ export default {
|
|||||||
settings: '设置',
|
settings: '设置',
|
||||||
connected: '已连接',
|
connected: '已连接',
|
||||||
disconnected: '未连接',
|
disconnected: '未连接',
|
||||||
|
collapse: '收起菜单',
|
||||||
|
expand: '展开菜单',
|
||||||
updateTip: '在终端运行 "hermes-web-ui update" 即可更新',
|
updateTip: '在终端运行 "hermes-web-ui update" 即可更新',
|
||||||
updateVersion: '升级版本 v{version}',
|
updateVersion: '升级版本 v{version}',
|
||||||
updating: '正在更新...',
|
updating: '正在更新...',
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ import { checkHealth, fetchAvailableModels, updateDefaultModel, triggerUpdate, t
|
|||||||
|
|
||||||
const WEB_UI_VERSION = __APP_VERSION__
|
const WEB_UI_VERSION = __APP_VERSION__
|
||||||
|
|
||||||
|
const SIDEBAR_COLLAPSED_KEY = 'hermes_sidebar_collapsed'
|
||||||
|
|
||||||
export const useAppStore = defineStore('app', () => {
|
export const useAppStore = defineStore('app', () => {
|
||||||
const sidebarOpen = ref(false)
|
const sidebarOpen = ref(false)
|
||||||
|
// Desktop-only collapsed state (icon-rail mode). Persisted to localStorage.
|
||||||
|
const sidebarCollapsed = ref(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1')
|
||||||
|
|
||||||
const connected = ref(false)
|
const connected = ref(false)
|
||||||
const serverVersion = ref(WEB_UI_VERSION)
|
const serverVersion = ref(WEB_UI_VERSION)
|
||||||
@@ -90,10 +94,21 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
sidebarOpen.value = false
|
sidebarOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleSidebarCollapsed() {
|
||||||
|
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, sidebarCollapsed.value ? '1' : '0')
|
||||||
|
} catch {
|
||||||
|
// ignore quota errors — fallback to in-memory only
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sidebarOpen,
|
sidebarOpen,
|
||||||
|
sidebarCollapsed,
|
||||||
toggleSidebar,
|
toggleSidebar,
|
||||||
closeSidebar,
|
closeSidebar,
|
||||||
|
toggleSidebarCollapsed,
|
||||||
connected,
|
connected,
|
||||||
serverVersion,
|
serverVersion,
|
||||||
latestVersion,
|
latestVersion,
|
||||||
|
|||||||
@@ -159,23 +159,244 @@ function mapHermesSession(s: SessionSummary): Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useChatStore = defineStore('chat', () => {
|
// Cache keys for stale-while-revalidate loading of sessions / messages.
|
||||||
|
// Rendering from cache on boot avoids the multi-round-trip wait the user sees
|
||||||
|
// every time they open the page (esp. noticeable on mobile).
|
||||||
const STORAGE_KEY = 'hermes_active_session'
|
const STORAGE_KEY = 'hermes_active_session'
|
||||||
|
const SESSIONS_CACHE_KEY = 'hermes_sessions_cache_v1'
|
||||||
|
const MSGS_CACHE_KEY_PREFIX = 'hermes_session_msgs_v1_'
|
||||||
|
// tmux-like resume: persist active run info so a refresh/reopen mid-run can
|
||||||
|
// pick up the working indicator and poll fetchSession for new progress.
|
||||||
|
const IN_FLIGHT_KEY_PREFIX = 'hermes_in_flight_v1_'
|
||||||
|
const IN_FLIGHT_TTL_MS = 15 * 60 * 1000 // Give up after 15 minutes
|
||||||
|
const POLL_INTERVAL_MS = 2000
|
||||||
|
const POLL_STABLE_EXITS = 3 // 3 × 2s = 6s of no change → assume run finished
|
||||||
|
|
||||||
|
interface InFlightRun {
|
||||||
|
runId: string
|
||||||
|
startedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadJson<T>(key: string): T | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
return raw ? (JSON.parse(raw) as T) : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveJson(key: string, value: unknown) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, JSON.stringify(value))
|
||||||
|
} catch {
|
||||||
|
// quota exceeded or private mode — ignore, cache is best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItem(key: string) {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(key)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip the circular `file: File` reference from attachments before caching —
|
||||||
|
// File objects don't serialize and we only need name/type/size/url for display.
|
||||||
|
function sanitizeForCache(msgs: Message[]): Message[] {
|
||||||
|
return msgs.map(m => {
|
||||||
|
if (!m.attachments?.length) return m
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
attachments: m.attachments.map(a => ({ id: a.id, name: a.name, type: a.type, size: a.size, url: a.url })),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useChatStore = defineStore('chat', () => {
|
||||||
const sessions = ref<Session[]>([])
|
const sessions = ref<Session[]>([])
|
||||||
const activeSessionId = ref<string | null>(localStorage.getItem(STORAGE_KEY))
|
const activeSessionId = ref<string | null>(localStorage.getItem(STORAGE_KEY))
|
||||||
const streamStates = ref<Map<string, AbortController>>(new Map())
|
const streamStates = ref<Map<string, AbortController>>(new Map())
|
||||||
const isStreaming = computed(() => activeSessionId.value != null && streamStates.value.has(activeSessionId.value))
|
const isStreaming = computed(() => activeSessionId.value != null && streamStates.value.has(activeSessionId.value))
|
||||||
const isLoadingSessions = ref(false)
|
const isLoadingSessions = ref(false)
|
||||||
const isLoadingMessages = ref(false)
|
const isLoadingMessages = ref(false)
|
||||||
|
// tmux-like resume state: true when we recovered an in-flight run from
|
||||||
|
// localStorage after a refresh and are polling fetchSession for progress.
|
||||||
|
// UI shows the thinking indicator while this is set.
|
||||||
|
const resumingRuns = ref<Set<string>>(new Set())
|
||||||
|
const isRunActive = computed(() =>
|
||||||
|
isStreaming.value
|
||||||
|
|| (activeSessionId.value != null && resumingRuns.value.has(activeSessionId.value))
|
||||||
|
)
|
||||||
|
const pollTimers = new Map<string, ReturnType<typeof setInterval>>()
|
||||||
|
const pollSignatures = new Map<string, { sig: string, stableTicks: number }>()
|
||||||
|
|
||||||
const activeSession = ref<Session | null>(null)
|
const activeSession = ref<Session | null>(null)
|
||||||
const messages = computed<Message[]>(() => activeSession.value?.messages || [])
|
const messages = computed<Message[]>(() => activeSession.value?.messages || [])
|
||||||
|
|
||||||
|
// Hydrate from cache synchronously so the UI renders instantly on boot.
|
||||||
|
// Network revalidation happens in loadSessions() below.
|
||||||
|
const cachedSessions = loadJson<Session[]>(SESSIONS_CACHE_KEY)
|
||||||
|
if (cachedSessions?.length) {
|
||||||
|
sessions.value = cachedSessions
|
||||||
|
if (activeSessionId.value) {
|
||||||
|
const cachedActive = cachedSessions.find(s => s.id === activeSessionId.value) || null
|
||||||
|
if (cachedActive) {
|
||||||
|
const cachedMsgs = loadJson<Message[]>(MSGS_CACHE_KEY_PREFIX + activeSessionId.value)
|
||||||
|
if (cachedMsgs) cachedActive.messages = cachedMsgs
|
||||||
|
activeSession.value = cachedActive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistSessionsList() {
|
||||||
|
// Cache lightweight summaries only (messages are cached per-session).
|
||||||
|
saveJson(
|
||||||
|
SESSIONS_CACHE_KEY,
|
||||||
|
sessions.value.map(s => ({ ...s, messages: [] })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistActiveMessages() {
|
||||||
|
const sid = activeSessionId.value
|
||||||
|
if (!sid) return
|
||||||
|
const s = sessions.value.find(sess => sess.id === sid)
|
||||||
|
if (s) saveJson(MSGS_CACHE_KEY_PREFIX + sid, sanitizeForCache(s.messages))
|
||||||
|
}
|
||||||
|
|
||||||
|
function markInFlight(sid: string, runId: string) {
|
||||||
|
saveJson(IN_FLIGHT_KEY_PREFIX + sid, { runId, startedAt: Date.now() } as InFlightRun)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearInFlight(sid: string) {
|
||||||
|
removeItem(IN_FLIGHT_KEY_PREFIX + sid)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readInFlight(sid: string): InFlightRun | null {
|
||||||
|
const rec = loadJson<InFlightRun>(IN_FLIGHT_KEY_PREFIX + sid)
|
||||||
|
if (!rec) return null
|
||||||
|
if (Date.now() - rec.startedAt > IN_FLIGHT_TTL_MS) {
|
||||||
|
removeItem(IN_FLIGHT_KEY_PREFIX + sid)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling(sid: string) {
|
||||||
|
const t = pollTimers.get(sid)
|
||||||
|
if (t) {
|
||||||
|
clearInterval(t)
|
||||||
|
pollTimers.delete(sid)
|
||||||
|
}
|
||||||
|
pollSignatures.delete(sid)
|
||||||
|
resumingRuns.value = new Set([...resumingRuns.value].filter(x => x !== sid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll fetchSession while an in-flight run is recovering. Exits when the
|
||||||
|
// server's message signature is stable for POLL_STABLE_EXITS ticks (run
|
||||||
|
// presumed done), TTL elapses, or the user explicitly starts streaming.
|
||||||
|
function startPolling(sid: string) {
|
||||||
|
if (pollTimers.has(sid)) return
|
||||||
|
resumingRuns.value = new Set([...resumingRuns.value, sid])
|
||||||
|
const timer = setInterval(async () => {
|
||||||
|
// If a fresh SSE stream started for this session, polling is redundant.
|
||||||
|
if (streamStates.value.has(sid)) {
|
||||||
|
stopPolling(sid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const inFlight = readInFlight(sid)
|
||||||
|
if (!inFlight) {
|
||||||
|
stopPolling(sid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const detail = await fetchSession(sid)
|
||||||
|
if (!detail) return
|
||||||
|
const mapped = mapHermesMessages(detail.messages || [])
|
||||||
|
const target = sessions.value.find(s => s.id === sid)
|
||||||
|
if (!target) return
|
||||||
|
// Use the same "content-aware" comparison as switchSession: server
|
||||||
|
// is ahead iff it knows about at least as many user turns and its
|
||||||
|
// last assistant text is at least as long as ours.
|
||||||
|
const local = target.messages
|
||||||
|
const localLastAssistant = [...local].reverse().find(m => m.role === 'assistant')
|
||||||
|
const serverLastAssistant = [...mapped].reverse().find(m => m.role === 'assistant')
|
||||||
|
const localAssistantLen = localLastAssistant?.content?.length ?? 0
|
||||||
|
const serverAssistantLen = serverLastAssistant?.content?.length ?? 0
|
||||||
|
const localUsers = local.filter(m => m.role === 'user').length
|
||||||
|
const serverUsers = mapped.filter(m => m.role === 'user').length
|
||||||
|
const serverIsCaughtUp = serverUsers >= localUsers
|
||||||
|
// Same rationale as switchSession: strictly more user turns means
|
||||||
|
// server is ahead (new turn complete). Equal user turns + longer
|
||||||
|
// assistant means server caught up on the current turn.
|
||||||
|
const serverIsAhead =
|
||||||
|
serverUsers > localUsers
|
||||||
|
|| (serverUsers === localUsers && serverAssistantLen >= localAssistantLen)
|
||||||
|
if (serverIsAhead) {
|
||||||
|
target.messages = mapped
|
||||||
|
target.inputTokens = detail.input_tokens
|
||||||
|
target.outputTokens = detail.output_tokens
|
||||||
|
if (detail.title && !target.title) target.title = detail.title
|
||||||
|
if (sid === activeSessionId.value) persistActiveMessages()
|
||||||
|
}
|
||||||
|
// Stability detection ONLY matters when the server has at least as
|
||||||
|
// many user turns as we do. Otherwise the server is still catching
|
||||||
|
// up (e.g. the new turn we just sent hasn't been flushed server-side
|
||||||
|
// yet) and a "stable" signature is a false positive — the stability
|
||||||
|
// is the server NOT having our latest turn, not the run being done.
|
||||||
|
if (!serverIsCaughtUp) {
|
||||||
|
pollSignatures.delete(sid)
|
||||||
|
} else {
|
||||||
|
const last = mapped[mapped.length - 1]
|
||||||
|
const sig = `${mapped.length}|${last?.content?.slice(-40) || ''}|${last?.toolStatus || ''}`
|
||||||
|
const prev = pollSignatures.get(sid)
|
||||||
|
if (prev && prev.sig === sig) {
|
||||||
|
prev.stableTicks += 1
|
||||||
|
if (prev.stableTicks >= POLL_STABLE_EXITS) {
|
||||||
|
// Run is done on the server. Force-apply server view even if
|
||||||
|
// our "don't retreat" guard above skipped it — the server is
|
||||||
|
// now the authoritative source of truth.
|
||||||
|
target.messages = mapped
|
||||||
|
target.inputTokens = detail.input_tokens
|
||||||
|
target.outputTokens = detail.output_tokens
|
||||||
|
if (detail.title) target.title = detail.title
|
||||||
|
if (sid === activeSessionId.value) persistActiveMessages()
|
||||||
|
clearInFlight(sid)
|
||||||
|
stopPolling(sid)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pollSignatures.set(sid, { sig, stableTicks: 0 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// transient network error — ignore, next tick tries again
|
||||||
|
}
|
||||||
|
}, POLL_INTERVAL_MS)
|
||||||
|
pollTimers.set(sid, timer)
|
||||||
|
}
|
||||||
|
|
||||||
async function loadSessions() {
|
async function loadSessions() {
|
||||||
isLoadingSessions.value = true
|
isLoadingSessions.value = true
|
||||||
try {
|
try {
|
||||||
const list = await fetchSessions()
|
const list = await fetchSessions()
|
||||||
sessions.value = list.map(mapHermesSession)
|
const fresh = list.map(mapHermesSession)
|
||||||
|
const freshIds = new Set(fresh.map(s => s.id))
|
||||||
|
// Preserve already-loaded messages for sessions that are still present,
|
||||||
|
// so we don't blow away the active session's messages on refresh.
|
||||||
|
const msgsByIdBefore = new Map(sessions.value.map(s => [s.id, s.messages]))
|
||||||
|
for (const s of fresh) {
|
||||||
|
const prev = msgsByIdBefore.get(s.id)
|
||||||
|
if (prev && prev.length) s.messages = prev
|
||||||
|
}
|
||||||
|
// Preserve local-only sessions the server hasn't seen yet — e.g. a chat
|
||||||
|
// that was just created and whose first run is still in-flight. Without
|
||||||
|
// this, refreshing mid-run would wipe the session and fall back to
|
||||||
|
// sessions[0], which is exactly what the user reported.
|
||||||
|
const localOnly = sessions.value.filter(s => !freshIds.has(s.id))
|
||||||
|
sessions.value = [...localOnly, ...fresh]
|
||||||
|
persistSessionsList()
|
||||||
|
|
||||||
// Restore last active session, fallback to most recent
|
// Restore last active session, fallback to most recent
|
||||||
const savedId = activeSessionId.value
|
const savedId = activeSessionId.value
|
||||||
const targetId = savedId && sessions.value.some(s => s.id === savedId)
|
const targetId = savedId && sessions.value.some(s => s.id === savedId)
|
||||||
@@ -191,6 +412,30 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-pull active session from server and overwrite local messages. Used on
|
||||||
|
// SSE drop and on tab-visible events — mobile browsers kill EventSource
|
||||||
|
// while backgrounded, but the backend run usually completes anyway.
|
||||||
|
async function refreshActiveSession(): Promise<boolean> {
|
||||||
|
const sid = activeSessionId.value
|
||||||
|
if (!sid) return false
|
||||||
|
try {
|
||||||
|
const detail = await fetchSession(sid)
|
||||||
|
if (!detail) return false
|
||||||
|
const target = sessions.value.find(s => s.id === sid)
|
||||||
|
if (!target) return false
|
||||||
|
const mapped = mapHermesMessages(detail.messages || [])
|
||||||
|
target.messages = mapped
|
||||||
|
target.inputTokens = detail.input_tokens
|
||||||
|
target.outputTokens = detail.output_tokens
|
||||||
|
if (detail.title) target.title = detail.title
|
||||||
|
persistActiveMessages()
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to refresh active session:', err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function createSession(): Session {
|
function createSession(): Session {
|
||||||
const session: Session = {
|
const session: Session = {
|
||||||
@@ -202,6 +447,9 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
}
|
}
|
||||||
sessions.value.unshift(session)
|
sessions.value.unshift(session)
|
||||||
|
// Persist immediately so a refresh before run.completed can still find
|
||||||
|
// this session in the cache.
|
||||||
|
persistSessionsList()
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,32 +458,81 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
localStorage.setItem(STORAGE_KEY, sessionId)
|
localStorage.setItem(STORAGE_KEY, sessionId)
|
||||||
activeSession.value = sessions.value.find(s => s.id === sessionId) || null
|
activeSession.value = sessions.value.find(s => s.id === sessionId) || null
|
||||||
|
|
||||||
// If session has no messages loaded, fetch from API
|
if (!activeSession.value) return
|
||||||
if (activeSession.value && activeSession.value.messages.length === 0) {
|
|
||||||
isLoadingMessages.value = true
|
// Hydrate messages from localStorage cache first (instant render), then
|
||||||
|
// revalidate from server in the background. If no cache exists, show the
|
||||||
|
// loading state while we fetch.
|
||||||
|
const hasLocalMessages = activeSession.value.messages.length > 0
|
||||||
|
if (!hasLocalMessages) {
|
||||||
|
const cachedMsgs = loadJson<Message[]>(MSGS_CACHE_KEY_PREFIX + sessionId)
|
||||||
|
if (cachedMsgs?.length) {
|
||||||
|
activeSession.value.messages = cachedMsgs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsBlockingLoad = activeSession.value.messages.length === 0
|
||||||
|
if (needsBlockingLoad) isLoadingMessages.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const detail = await fetchSession(sessionId)
|
const detail = await fetchSession(sessionId)
|
||||||
if (detail && detail.messages) {
|
if (detail && detail.messages) {
|
||||||
const mapped = mapHermesMessages(detail.messages)
|
const mapped = mapHermesMessages(detail.messages)
|
||||||
|
// Pick whichever view has more information. Simple length comparison
|
||||||
|
// is wrong because mapHermesMessages folds tool_call-only assistant
|
||||||
|
// msgs and matches them with tool-result msgs — so post-fold `mapped`
|
||||||
|
// can be SHORTER than the raw SSE-built local array even when the
|
||||||
|
// server is strictly ahead. Instead, compare the last assistant
|
||||||
|
// message content: if the server's is at least as long, the server
|
||||||
|
// is up-to-date (and has the final complete text); otherwise keep
|
||||||
|
// local (in-flight window where server hasn't flushed the new turn).
|
||||||
|
const local = activeSession.value.messages
|
||||||
|
const localLastAssistant = [...local].reverse().find(m => m.role === 'assistant')
|
||||||
|
const serverLastAssistant = [...mapped].reverse().find(m => m.role === 'assistant')
|
||||||
|
const localAssistantLen = localLastAssistant?.content?.length ?? 0
|
||||||
|
const serverAssistantLen = serverLastAssistant?.content?.length ?? 0
|
||||||
|
const localUsers = local.filter(m => m.role === 'user').length
|
||||||
|
const serverUsers = mapped.filter(m => m.role === 'user').length
|
||||||
|
// Trust server when:
|
||||||
|
// - it has STRICTLY MORE user turns than we do (new turn landed),
|
||||||
|
// OR
|
||||||
|
// - same user-turn count AND server's last assistant is at least
|
||||||
|
// as long as ours (same turn, server caught up or further)
|
||||||
|
// Otherwise keep local (protects against the server-not-yet-flushed
|
||||||
|
// race during in-flight runs). Length comparison alone is wrong
|
||||||
|
// across different turns because each turn's last assistant is
|
||||||
|
// unrelated to the previous turn's.
|
||||||
|
const serverIsAhead =
|
||||||
|
serverUsers > localUsers
|
||||||
|
|| (serverUsers === localUsers && serverAssistantLen >= localAssistantLen)
|
||||||
|
if (serverIsAhead) {
|
||||||
activeSession.value.messages = mapped
|
activeSession.value.messages = mapped
|
||||||
|
}
|
||||||
activeSession.value.inputTokens = detail.input_tokens
|
activeSession.value.inputTokens = detail.input_tokens
|
||||||
activeSession.value.outputTokens = detail.output_tokens
|
activeSession.value.outputTokens = detail.output_tokens
|
||||||
// Update title: use Hermes title, or fallback to first user message
|
// Update title: use Hermes title, or fallback to first user message
|
||||||
if (detail.title) {
|
if (detail.title) {
|
||||||
activeSession.value.title = detail.title
|
activeSession.value.title = detail.title
|
||||||
} else {
|
} else if (!activeSession.value.title) {
|
||||||
const firstUser = mapped.find(m => m.role === 'user')
|
const firstUser = (activeSession.value.messages).find(m => m.role === 'user')
|
||||||
if (firstUser) {
|
if (firstUser) {
|
||||||
const t = firstUser.content.slice(0, 40)
|
const t = firstUser.content.slice(0, 40)
|
||||||
activeSession.value.title = t + (firstUser.content.length > 40 ? '...' : '')
|
activeSession.value.title = t + (firstUser.content.length > 40 ? '...' : '')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
persistActiveMessages()
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load session messages:', err)
|
console.error('Failed to load session messages:', err)
|
||||||
} finally {
|
} finally {
|
||||||
isLoadingMessages.value = false
|
isLoadingMessages.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tmux-like resume: if this session has a recent in-flight run and we're
|
||||||
|
// not currently streaming, start polling fetchSession to pick up progress
|
||||||
|
// that happened while we were gone. Exits automatically on stability.
|
||||||
|
if (readInFlight(sessionId) && !streamStates.value.has(sessionId)) {
|
||||||
|
startPolling(sessionId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,6 +559,8 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
async function deleteSession(sessionId: string) {
|
async function deleteSession(sessionId: string) {
|
||||||
await deleteSessionApi(sessionId)
|
await deleteSessionApi(sessionId)
|
||||||
sessions.value = sessions.value.filter(s => s.id !== sessionId)
|
sessions.value = sessions.value.filter(s => s.id !== sessionId)
|
||||||
|
removeItem(MSGS_CACHE_KEY_PREFIX + sessionId)
|
||||||
|
persistSessionsList()
|
||||||
if (activeSessionId.value === sessionId) {
|
if (activeSessionId.value === sessionId) {
|
||||||
if (sessions.value.length > 0) {
|
if (sessions.value.length > 0) {
|
||||||
await switchSession(sessions.value[0].id)
|
await switchSession(sessions.value[0].id)
|
||||||
@@ -326,6 +625,13 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
}
|
}
|
||||||
addMessage(sid, userMsg)
|
addMessage(sid, userMsg)
|
||||||
updateSessionTitle(sid)
|
updateSessionTitle(sid)
|
||||||
|
// Persist immediately so a refresh before the first SSE event (e.g. the
|
||||||
|
// user closes the tab right after sending) still has the user's message
|
||||||
|
// and session title in the cache.
|
||||||
|
if (sid === activeSessionId.value) {
|
||||||
|
persistActiveMessages()
|
||||||
|
persistSessionsList()
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build conversation history from past messages
|
// Build conversation history from past messages
|
||||||
@@ -362,9 +668,33 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tmux-like resume: persist run_id so refresh/reopen can pick up the
|
||||||
|
// working indicator and poll for progress.
|
||||||
|
markInFlight(sid, runId)
|
||||||
|
// If we were already polling (e.g. user re-sent while resume was still
|
||||||
|
// polling an earlier run), cancel that polling — the new SSE stream is
|
||||||
|
// the authoritative live source.
|
||||||
|
stopPolling(sid)
|
||||||
|
|
||||||
// Helper to clean up this session's stream state
|
// Helper to clean up this session's stream state
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
streamStates.value.delete(sid)
|
streamStates.value.delete(sid)
|
||||||
|
if (persistTimer) {
|
||||||
|
clearTimeout(persistTimer)
|
||||||
|
persistTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throttle in-flight cache writes so a refresh mid-stream still shows
|
||||||
|
// the partial reply. 800ms keeps quota pressure low while guaranteeing
|
||||||
|
// at most ~1s of unsaved delta on reload.
|
||||||
|
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const schedulePersist = () => {
|
||||||
|
if (sid !== activeSessionId.value || persistTimer) return
|
||||||
|
persistTimer = setTimeout(() => {
|
||||||
|
persistTimer = null
|
||||||
|
persistActiveMessages()
|
||||||
|
}, 800)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen to SSE events — all closures capture `sid`
|
// Listen to SSE events — all closures capture `sid`
|
||||||
@@ -390,6 +720,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
isStreaming: true,
|
isStreaming: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
schedulePersist()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,6 +739,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
toolPreview: evt.preview,
|
toolPreview: evt.preview,
|
||||||
toolStatus: 'running',
|
toolStatus: 'running',
|
||||||
})
|
})
|
||||||
|
schedulePersist()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,6 +752,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
const last = toolMsgs[toolMsgs.length - 1]
|
const last = toolMsgs[toolMsgs.length - 1]
|
||||||
updateMessage(sid, last.id, { toolStatus: 'done' })
|
updateMessage(sid, last.id, { toolStatus: 'done' })
|
||||||
}
|
}
|
||||||
|
schedulePersist()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,6 +764,15 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
}
|
}
|
||||||
cleanup()
|
cleanup()
|
||||||
updateSessionTitle(sid)
|
updateSessionTitle(sid)
|
||||||
|
// IMPORTANT ordering: persist the final cache BEFORE clearing
|
||||||
|
// the in-flight marker. If the browser is reloading right now
|
||||||
|
// and kills us between the two localStorage writes, we want
|
||||||
|
// the next page load to still see in-flight === true (so
|
||||||
|
// polling kicks in and recovers) rather than the other way
|
||||||
|
// around (cleared in-flight + stale streaming cache = UI stuck).
|
||||||
|
if (sid === activeSessionId.value) persistActiveMessages()
|
||||||
|
clearInFlight(sid)
|
||||||
|
stopPolling(sid)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,6 +799,9 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
cleanup()
|
cleanup()
|
||||||
|
if (sid === activeSessionId.value) persistActiveMessages()
|
||||||
|
clearInFlight(sid)
|
||||||
|
stopPolling(sid)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -472,24 +817,36 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
updateSessionTitle(sid)
|
updateSessionTitle(sid)
|
||||||
},
|
},
|
||||||
// onError
|
// onError
|
||||||
|
// Mobile browsers drop EventSource when the tab backgrounds / screen
|
||||||
|
// locks / network flips. The backend run usually completes anyway, so
|
||||||
|
// rather than injecting a stale "SSE connection error" bubble we mark
|
||||||
|
// streaming as done and silently re-sync from the server, which has
|
||||||
|
// the real final answer. If the server fetch itself fails, we leave
|
||||||
|
// whatever text we already streamed in place — no visible error.
|
||||||
(err) => {
|
(err) => {
|
||||||
|
console.warn('SSE connection dropped, resyncing from server:', err.message)
|
||||||
const msgs = getSessionMsgs(sid)
|
const msgs = getSessionMsgs(sid)
|
||||||
const last = msgs[msgs.length - 1]
|
const last = msgs[msgs.length - 1]
|
||||||
if (last?.isStreaming) {
|
if (last?.isStreaming) {
|
||||||
updateMessage(sid, last.id, {
|
updateMessage(sid, last.id, { isStreaming: false })
|
||||||
isStreaming: false,
|
|
||||||
content: `Error: ${err.message}`,
|
|
||||||
role: 'system',
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
addMessage(sid, {
|
|
||||||
id: uid(),
|
|
||||||
role: 'system',
|
|
||||||
content: `Error: ${err.message}`,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
// Any tool messages still marked 'running' will be replaced by the
|
||||||
|
// server's view after refresh; clear their spinner state now.
|
||||||
|
msgs.forEach((m, i) => {
|
||||||
|
if (m.role === 'tool' && m.toolStatus === 'running') {
|
||||||
|
msgs[i] = { ...m, toolStatus: 'done' }
|
||||||
|
}
|
||||||
|
})
|
||||||
cleanup()
|
cleanup()
|
||||||
|
if (sid === activeSessionId.value) {
|
||||||
|
void refreshActiveSession()
|
||||||
|
}
|
||||||
|
// The run might still be going on the server side (SSE drop doesn't
|
||||||
|
// abort it). If we still have an in-flight record, fall back to
|
||||||
|
// polling fetchSession to keep the user updated.
|
||||||
|
if (readInFlight(sid)) {
|
||||||
|
startPolling(sid)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -516,18 +873,48 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
updateMessage(sid, lastMsg.id, { isStreaming: false })
|
updateMessage(sid, lastMsg.id, { isStreaming: false })
|
||||||
}
|
}
|
||||||
streamStates.value.delete(sid)
|
streamStates.value.delete(sid)
|
||||||
|
clearInFlight(sid)
|
||||||
|
stopPolling(sid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load sessions on init
|
// Load sessions on init (cache has already hydrated the UI above).
|
||||||
loadSessions()
|
loadSessions()
|
||||||
|
|
||||||
|
// tmux-like resume on boot: if the last active session has a persisted
|
||||||
|
// in-flight run that's still fresh, show the working indicator immediately
|
||||||
|
// and start polling the server. loadSessions() above will call
|
||||||
|
// switchSession which also triggers this path, but doing it synchronously
|
||||||
|
// here means the UI shows "working" from the very first frame even while
|
||||||
|
// loadSessions is still in flight.
|
||||||
|
if (activeSessionId.value && readInFlight(activeSessionId.value)) {
|
||||||
|
startPolling(activeSessionId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the tab returns to the foreground, re-sync the active session from
|
||||||
|
// the server. Mobile browsers suspend tabs aggressively, and any in-flight
|
||||||
|
// run that completed while we were backgrounded won't have reached the
|
||||||
|
// in-memory state otherwise.
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'visible' && activeSessionId.value && !isStreaming.value) {
|
||||||
|
void refreshActiveSession()
|
||||||
|
// Resume polling too in case we put a run in-flight before going to
|
||||||
|
// background and the SSE got killed.
|
||||||
|
if (readInFlight(activeSessionId.value)) {
|
||||||
|
startPolling(activeSessionId.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessions,
|
sessions,
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
activeSession,
|
activeSession,
|
||||||
messages,
|
messages,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
|
isRunActive,
|
||||||
isLoadingSessions,
|
isLoadingSessions,
|
||||||
isLoadingMessages,
|
isLoadingMessages,
|
||||||
newChat,
|
newChat,
|
||||||
@@ -537,5 +924,6 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
sendMessage,
|
sendMessage,
|
||||||
stopStreaming,
|
stopStreaming,
|
||||||
loadSessions,
|
loadSessions,
|
||||||
|
refreshActiveSession,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
|
||||||
|
const mockSystemApi = vi.hoisted(() => ({
|
||||||
|
checkHealth: vi.fn(),
|
||||||
|
fetchAvailableModels: vi.fn(),
|
||||||
|
updateDefaultModel: vi.fn(),
|
||||||
|
triggerUpdate: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/hermes/system', () => mockSystemApi)
|
||||||
|
|
||||||
|
import { useAppStore } from '@/stores/hermes/app'
|
||||||
|
|
||||||
|
describe('App Store', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.clearAllMocks()
|
||||||
|
window.localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists desktop sidebar collapsed state to localStorage', () => {
|
||||||
|
const store = useAppStore()
|
||||||
|
|
||||||
|
expect(store.sidebarCollapsed).toBe(false)
|
||||||
|
|
||||||
|
store.toggleSidebarCollapsed()
|
||||||
|
expect(store.sidebarCollapsed).toBe(true)
|
||||||
|
expect(window.localStorage.getItem('hermes_sidebar_collapsed')).toBe('1')
|
||||||
|
|
||||||
|
store.toggleSidebarCollapsed()
|
||||||
|
expect(store.sidebarCollapsed).toBe(false)
|
||||||
|
expect(window.localStorage.getItem('hermes_sidebar_collapsed')).toBe('0')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
|
||||||
|
const mockChatApi = vi.hoisted(() => ({
|
||||||
|
startRun: vi.fn(),
|
||||||
|
streamRunEvents: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockSessionsApi = vi.hoisted(() => ({
|
||||||
|
fetchSessions: vi.fn(),
|
||||||
|
fetchSession: vi.fn(),
|
||||||
|
deleteSession: vi.fn(),
|
||||||
|
renameSession: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/hermes/chat', () => mockChatApi)
|
||||||
|
vi.mock('@/api/hermes/sessions', () => mockSessionsApi)
|
||||||
|
|
||||||
|
import { useChatStore } from '@/stores/hermes/chat'
|
||||||
|
|
||||||
|
function makeSummary(id: string, title = 'Session') {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
source: 'api_server',
|
||||||
|
model: 'gpt-4o',
|
||||||
|
title,
|
||||||
|
started_at: 1710000000,
|
||||||
|
ended_at: 1710000001,
|
||||||
|
message_count: 1,
|
||||||
|
tool_call_count: 0,
|
||||||
|
input_tokens: 10,
|
||||||
|
output_tokens: 20,
|
||||||
|
cache_read_tokens: 0,
|
||||||
|
cache_write_tokens: 0,
|
||||||
|
reasoning_tokens: 0,
|
||||||
|
billing_provider: 'openai',
|
||||||
|
estimated_cost_usd: 0,
|
||||||
|
actual_cost_usd: 0,
|
||||||
|
cost_status: 'estimated',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDetail(id: string, messages: Array<Record<string, any>>) {
|
||||||
|
return {
|
||||||
|
...makeSummary(id),
|
||||||
|
messages,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushPromises() {
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Chat Store', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.useRealTimers()
|
||||||
|
window.localStorage.clear()
|
||||||
|
mockSessionsApi.fetchSessions.mockResolvedValue([])
|
||||||
|
mockSessionsApi.fetchSession.mockResolvedValue(null)
|
||||||
|
mockSessionsApi.deleteSession.mockResolvedValue(true)
|
||||||
|
mockSessionsApi.renameSession.mockResolvedValue(true)
|
||||||
|
mockChatApi.startRun.mockResolvedValue({ run_id: 'run-1', status: 'queued' })
|
||||||
|
mockChatApi.streamRunEvents.mockImplementation(() => ({
|
||||||
|
abort: vi.fn(),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hydrates cached active session immediately and preserves local-only sessions after refresh', async () => {
|
||||||
|
const cachedSession = {
|
||||||
|
id: 'local-1',
|
||||||
|
title: 'Local Draft',
|
||||||
|
source: 'api_server',
|
||||||
|
messages: [],
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
}
|
||||||
|
const cachedMessages = [
|
||||||
|
{ id: 'm1', role: 'user', content: 'draft', timestamp: 1 },
|
||||||
|
]
|
||||||
|
|
||||||
|
window.localStorage.setItem('hermes_active_session', 'local-1')
|
||||||
|
window.localStorage.setItem('hermes_sessions_cache_v1', JSON.stringify([cachedSession]))
|
||||||
|
window.localStorage.setItem('hermes_session_msgs_v1_local-1', JSON.stringify(cachedMessages))
|
||||||
|
|
||||||
|
mockSessionsApi.fetchSessions.mockResolvedValue([makeSummary('remote-1', 'Remote Session')])
|
||||||
|
mockSessionsApi.fetchSession.mockResolvedValue(null)
|
||||||
|
|
||||||
|
const store = useChatStore()
|
||||||
|
|
||||||
|
expect(store.activeSessionId).toBe('local-1')
|
||||||
|
expect(store.messages.map(m => m.content)).toEqual(['draft'])
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(store.sessions.map(s => s.id)).toEqual(['local-1', 'remote-1'])
|
||||||
|
expect(store.activeSession?.id).toBe('local-1')
|
||||||
|
expect(store.messages.map(m => m.content)).toEqual(['draft'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists the user message immediately before any SSE delta arrives', async () => {
|
||||||
|
const store = useChatStore()
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
await store.sendMessage('hello world')
|
||||||
|
|
||||||
|
const sid = store.activeSessionId
|
||||||
|
expect(sid).toBeTruthy()
|
||||||
|
expect(window.localStorage.getItem('hermes_active_session')).toBe(sid)
|
||||||
|
|
||||||
|
const cachedMessages = JSON.parse(
|
||||||
|
window.localStorage.getItem(`hermes_session_msgs_v1_${sid}`) || '[]',
|
||||||
|
)
|
||||||
|
expect(cachedMessages).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
role: 'user',
|
||||||
|
content: 'hello world',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('silently refreshes from server on SSE error instead of appending a fake error bubble', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
|
||||||
|
window.localStorage.setItem('hermes_active_session', 'sess-1')
|
||||||
|
window.localStorage.setItem(
|
||||||
|
'hermes_sessions_cache_v1',
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
id: 'sess-1',
|
||||||
|
title: 'Recovered Chat',
|
||||||
|
source: 'api_server',
|
||||||
|
messages: [],
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
window.localStorage.setItem(
|
||||||
|
'hermes_session_msgs_v1_sess-1',
|
||||||
|
JSON.stringify([
|
||||||
|
{ id: 'old-user', role: 'user', content: 'old prompt', timestamp: 1 },
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
mockSessionsApi.fetchSessions.mockResolvedValue([makeSummary('sess-1', 'Recovered Chat')])
|
||||||
|
|
||||||
|
let fetchSessionCalls = 0
|
||||||
|
mockSessionsApi.fetchSession.mockImplementation(async () => {
|
||||||
|
fetchSessionCalls += 1
|
||||||
|
if (fetchSessionCalls === 1) return null
|
||||||
|
return makeDetail('sess-1', [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
session_id: 'sess-1',
|
||||||
|
role: 'user',
|
||||||
|
content: 'old prompt',
|
||||||
|
tool_call_id: null,
|
||||||
|
tool_calls: null,
|
||||||
|
tool_name: null,
|
||||||
|
timestamp: 1710000000,
|
||||||
|
token_count: null,
|
||||||
|
finish_reason: null,
|
||||||
|
reasoning: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
session_id: 'sess-1',
|
||||||
|
role: 'user',
|
||||||
|
content: 'check this',
|
||||||
|
tool_call_id: null,
|
||||||
|
tool_calls: null,
|
||||||
|
tool_name: null,
|
||||||
|
timestamp: 1710000001,
|
||||||
|
token_count: null,
|
||||||
|
finish_reason: null,
|
||||||
|
reasoning: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
session_id: 'sess-1',
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'final answer',
|
||||||
|
tool_call_id: null,
|
||||||
|
tool_calls: null,
|
||||||
|
tool_name: null,
|
||||||
|
timestamp: 1710000002,
|
||||||
|
token_count: null,
|
||||||
|
finish_reason: 'stop',
|
||||||
|
reasoning: null,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
mockChatApi.streamRunEvents.mockImplementation((
|
||||||
|
_runId: string,
|
||||||
|
_onEvent: (event: unknown) => void,
|
||||||
|
_onDone: () => void,
|
||||||
|
onError: (err: Error) => void,
|
||||||
|
) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
onError(new Error('SSE connection error'))
|
||||||
|
}, 0)
|
||||||
|
return { abort: vi.fn() }
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useChatStore()
|
||||||
|
await flushPromises()
|
||||||
|
await store.sendMessage('check this')
|
||||||
|
await vi.advanceTimersByTimeAsync(0)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(9000)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(store.messages.some(m => m.role === 'system' && m.content.includes('SSE connection error'))).toBe(false)
|
||||||
|
expect(store.messages.some(m => m.role === 'assistant' && m.content === 'final answer')).toBe(true)
|
||||||
|
expect(store.isRunActive).toBe(false)
|
||||||
|
expect(window.localStorage.getItem('hermes_in_flight_v1_sess-1')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { vi } from 'vitest'
|
import { vi } from 'vitest'
|
||||||
|
|
||||||
|
// Vite injects this at build time; unit tests need a stable fallback.
|
||||||
|
;(globalThis as any).__APP_VERSION__ = 'test'
|
||||||
// Client-only setup (window/localStorage only exist in jsdom)
|
// Client-only setup (window/localStorage only exist in jsdom)
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
// Mock window.matchMedia
|
// Mock window.matchMedia
|
||||||
|
|||||||
Reference in New Issue
Block a user