diff --git a/packages/client/src/components/hermes/chat/ChatPanel.vue b/packages/client/src/components/hermes/chat/ChatPanel.vue index 3279070f..0c14274a 100644 --- a/packages/client/src/components/hermes/chat/ChatPanel.vue +++ b/packages/client/src/components/hermes/chat/ChatPanel.vue @@ -11,7 +11,15 @@ const chatStore = useChatStore() const message = useMessage() 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 function handleSessionClick(sessionId: string) { diff --git a/packages/client/src/components/hermes/chat/MessageList.vue b/packages/client/src/components/hermes/chat/MessageList.vue index b1311f8e..03a177e9 100644 --- a/packages/client/src/components/hermes/chat/MessageList.vue +++ b/packages/client/src/components/hermes/chat/MessageList.vue @@ -45,7 +45,7 @@ watch( scrollToBottom, ); watch( - () => chatStore.isStreaming, + () => chatStore.isRunActive, (v) => { if (v) scrollToBottom(); }, @@ -61,7 +61,7 @@ watch(currentToolCalls, scrollToBottom); - + { 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 serverVersion = ref(WEB_UI_VERSION) @@ -90,10 +94,21 @@ export const useAppStore = defineStore('app', () => { 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 { sidebarOpen, + sidebarCollapsed, toggleSidebar, closeSidebar, + toggleSidebarCollapsed, connected, serverVersion, latestVersion, diff --git a/packages/client/src/stores/hermes/chat.ts b/packages/client/src/stores/hermes/chat.ts index cedc73ed..170d41e6 100644 --- a/packages/client/src/stores/hermes/chat.ts +++ b/packages/client/src/stores/hermes/chat.ts @@ -159,23 +159,244 @@ function mapHermesSession(s: SessionSummary): Session { } } +// 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 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(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 STORAGE_KEY = 'hermes_active_session' const sessions = ref([]) const activeSessionId = ref(localStorage.getItem(STORAGE_KEY)) const streamStates = ref>(new Map()) const isStreaming = computed(() => activeSessionId.value != null && streamStates.value.has(activeSessionId.value)) const isLoadingSessions = 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>(new Set()) + const isRunActive = computed(() => + isStreaming.value + || (activeSessionId.value != null && resumingRuns.value.has(activeSessionId.value)) + ) + const pollTimers = new Map>() + const pollSignatures = new Map() const activeSession = ref(null) const messages = computed(() => activeSession.value?.messages || []) + // Hydrate from cache synchronously so the UI renders instantly on boot. + // Network revalidation happens in loadSessions() below. + const cachedSessions = loadJson(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(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(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() { isLoadingSessions.value = true try { 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 const savedId = activeSessionId.value 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 { + 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 { const session: Session = { @@ -202,6 +447,9 @@ export const useChatStore = defineStore('chat', () => { updatedAt: Date.now(), } sessions.value.unshift(session) + // Persist immediately so a refresh before run.completed can still find + // this session in the cache. + persistSessionsList() return session } @@ -210,32 +458,81 @@ export const useChatStore = defineStore('chat', () => { localStorage.setItem(STORAGE_KEY, sessionId) activeSession.value = sessions.value.find(s => s.id === sessionId) || null - // If session has no messages loaded, fetch from API - if (activeSession.value && activeSession.value.messages.length === 0) { - isLoadingMessages.value = true - try { - const detail = await fetchSession(sessionId) - if (detail && detail.messages) { - const mapped = mapHermesMessages(detail.messages) + if (!activeSession.value) return + + // 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(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 { + const detail = await fetchSession(sessionId) + if (detail && 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.inputTokens = detail.input_tokens - activeSession.value.outputTokens = detail.output_tokens - // Update title: use Hermes title, or fallback to first user message - if (detail.title) { - activeSession.value.title = detail.title - } else { - const firstUser = mapped.find(m => m.role === 'user') - if (firstUser) { - const t = firstUser.content.slice(0, 40) - activeSession.value.title = t + (firstUser.content.length > 40 ? '...' : '') - } + } + activeSession.value.inputTokens = detail.input_tokens + activeSession.value.outputTokens = detail.output_tokens + // Update title: use Hermes title, or fallback to first user message + if (detail.title) { + activeSession.value.title = detail.title + } else if (!activeSession.value.title) { + const firstUser = (activeSession.value.messages).find(m => m.role === 'user') + if (firstUser) { + const t = firstUser.content.slice(0, 40) + activeSession.value.title = t + (firstUser.content.length > 40 ? '...' : '') } } - } catch (err) { - console.error('Failed to load session messages:', err) - } finally { - isLoadingMessages.value = false + persistActiveMessages() } + } catch (err) { + console.error('Failed to load session messages:', err) + } finally { + 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) { await deleteSessionApi(sessionId) sessions.value = sessions.value.filter(s => s.id !== sessionId) + removeItem(MSGS_CACHE_KEY_PREFIX + sessionId) + persistSessionsList() if (activeSessionId.value === sessionId) { if (sessions.value.length > 0) { await switchSession(sessions.value[0].id) @@ -326,6 +625,13 @@ export const useChatStore = defineStore('chat', () => { } addMessage(sid, userMsg) 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 { // Build conversation history from past messages @@ -362,9 +668,33 @@ export const useChatStore = defineStore('chat', () => { 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 const cleanup = () => { 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 | null = null + const schedulePersist = () => { + if (sid !== activeSessionId.value || persistTimer) return + persistTimer = setTimeout(() => { + persistTimer = null + persistActiveMessages() + }, 800) } // Listen to SSE events — all closures capture `sid` @@ -390,6 +720,7 @@ export const useChatStore = defineStore('chat', () => { isStreaming: true, }) } + schedulePersist() break } @@ -408,6 +739,7 @@ export const useChatStore = defineStore('chat', () => { toolPreview: evt.preview, toolStatus: 'running', }) + schedulePersist() break } @@ -420,6 +752,7 @@ export const useChatStore = defineStore('chat', () => { const last = toolMsgs[toolMsgs.length - 1] updateMessage(sid, last.id, { toolStatus: 'done' }) } + schedulePersist() break } @@ -431,6 +764,15 @@ export const useChatStore = defineStore('chat', () => { } cleanup() 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 } @@ -457,6 +799,9 @@ export const useChatStore = defineStore('chat', () => { } }) cleanup() + if (sid === activeSessionId.value) persistActiveMessages() + clearInFlight(sid) + stopPolling(sid) break } } @@ -472,24 +817,36 @@ export const useChatStore = defineStore('chat', () => { updateSessionTitle(sid) }, // 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) => { + console.warn('SSE connection dropped, resyncing from server:', err.message) const msgs = getSessionMsgs(sid) const last = msgs[msgs.length - 1] if (last?.isStreaming) { - updateMessage(sid, last.id, { - isStreaming: false, - content: `Error: ${err.message}`, - role: 'system', - }) - } else { - addMessage(sid, { - id: uid(), - role: 'system', - content: `Error: ${err.message}`, - timestamp: Date.now(), - }) + updateMessage(sid, last.id, { isStreaming: false }) } + // 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() + 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 }) } streamStates.value.delete(sid) + clearInFlight(sid) + stopPolling(sid) } } - // Load sessions on init + // Load sessions on init (cache has already hydrated the UI above). 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 { sessions, activeSessionId, activeSession, messages, isStreaming, + isRunActive, isLoadingSessions, isLoadingMessages, newChat, @@ -537,5 +924,6 @@ export const useChatStore = defineStore('chat', () => { sendMessage, stopStreaming, loadSessions, + refreshActiveSession, } }) diff --git a/tests/client/app-store.test.ts b/tests/client/app-store.test.ts new file mode 100644 index 00000000..02856c57 --- /dev/null +++ b/tests/client/app-store.test.ts @@ -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') + }) +}) diff --git a/tests/client/chat-store.test.ts b/tests/client/chat-store.test.ts new file mode 100644 index 00000000..6bc3c46c --- /dev/null +++ b/tests/client/chat-store.test.ts @@ -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>) { + 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() + }) +}) diff --git a/tests/setup.ts b/tests/setup.ts index 90ab99c3..3915cba0 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,5 +1,7 @@ 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) if (typeof window !== 'undefined') { // Mock window.matchMedia