feat(web-ui): add pinned sessions and live monitor in Chat (#118)

* feat: add single-page live session monitor and chat pinning

* fix: restore full test green after main merge

* fix: use Array.from instead of Set spread for ts-node compatibility

[...new Set()] requires downlevelIteration which isn't enabled in
ts-node dev mode, causing sonic-boom crash on startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: ekko <fqsy1416@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Zhicheng Han
2026-04-22 02:09:58 +02:00
committed by GitHub
parent 83ad9642e2
commit 3f88553765
34 changed files with 2497 additions and 278 deletions
@@ -234,6 +234,7 @@ export const useChatStore = defineStore('chat', () => {
const streamStates = ref<Map<string, AbortController>>(new Map())
const isStreaming = computed(() => activeSessionId.value != null && streamStates.value.has(activeSessionId.value))
const isLoadingSessions = ref(false)
const sessionsLoaded = 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.
@@ -428,6 +429,7 @@ export const useChatStore = defineStore('chat', () => {
console.error('Failed to load sessions:', err)
} finally {
isLoadingSessions.value = false
sessionsLoaded.value = true
}
}
@@ -918,7 +920,9 @@ export const useChatStore = defineStore('chat', () => {
isRunActive,
isSessionLive,
isLoadingSessions,
sessionsLoaded,
isLoadingMessages,
newChat,
switchSession,
switchSessionModel,
@@ -66,6 +66,8 @@ export const useProfilesStore = defineStore('profiles', () => {
`hermes_session_msgs_v1_${profileName}_`,
`hermes_in_flight_v1_${profileName}_`,
`hermes_active_session_${profileName}`,
`hermes_session_pins_v1_${profileName}`,
`hermes_human_only_v1_${profileName}`,
]
const keysToRemove: string[] = []
for (let i = 0; i < localStorage.length; i++) {
@@ -0,0 +1,116 @@
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { useProfilesStore } from './profiles'
const PIN_KEY_PREFIX = 'hermes_session_pins_v1_'
const HUMAN_ONLY_KEY_PREFIX = 'hermes_human_only_v1_'
function currentProfileName(): string {
try {
return useProfilesStore().activeProfileName || localStorage.getItem('hermes_active_profile_name') || 'default'
} catch {
return localStorage.getItem('hermes_active_profile_name') || 'default'
}
}
function pinsKey(profileName: string): string {
return `${PIN_KEY_PREFIX}${profileName}`
}
function humanOnlyKey(profileName: string): string {
return `${HUMAN_ONLY_KEY_PREFIX}${profileName}`
}
function loadJson<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) as T : fallback
} catch {
return fallback
}
}
function saveJson(key: string, value: unknown) {
try {
localStorage.setItem(key, JSON.stringify(value))
} catch {
// ignore quota/storage errors — fall back to in-memory only
}
}
function sameIds(a: string[], b: string[]): boolean {
return a.length === b.length && a.every((value, index) => value === b[index])
}
export const useSessionBrowserPrefsStore = defineStore('session-browser-prefs', () => {
const profileName = ref(currentProfileName())
const pinnedIds = ref<string[]>(loadJson<string[]>(pinsKey(profileName.value), []))
const humanOnly = ref<boolean>(loadJson<boolean>(humanOnlyKey(profileName.value), true))
function reload() {
profileName.value = currentProfileName()
pinnedIds.value = loadJson<string[]>(pinsKey(profileName.value), [])
humanOnly.value = loadJson<boolean>(humanOnlyKey(profileName.value), true)
}
function persistPins() {
saveJson(pinsKey(profileName.value), pinnedIds.value)
}
function persistHumanOnly() {
saveJson(humanOnlyKey(profileName.value), humanOnly.value)
}
function isPinned(sessionId: string): boolean {
return pinnedIds.value.includes(sessionId)
}
function togglePinned(sessionId: string) {
if (isPinned(sessionId)) {
pinnedIds.value = pinnedIds.value.filter(id => id !== sessionId)
} else {
pinnedIds.value = [...pinnedIds.value, sessionId]
}
persistPins()
}
function removePinned(sessionId: string): boolean {
if (!isPinned(sessionId)) return false
pinnedIds.value = pinnedIds.value.filter(id => id !== sessionId)
persistPins()
return true
}
function setHumanOnly(value: boolean) {
if (humanOnly.value === value) return
humanOnly.value = value
persistHumanOnly()
}
function pruneMissingSessions(existingIds: string[]): boolean {
if (existingIds.length === 0) return false
const existing = new Set(existingIds)
const nextPinnedIds = pinnedIds.value.filter(id => existing.has(id))
if (sameIds(nextPinnedIds, pinnedIds.value)) return false
pinnedIds.value = nextPinnedIds
persistPins()
return true
}
watch(
() => useProfilesStore().activeProfileName,
() => reload(),
)
return {
profileName,
pinnedIds,
humanOnly,
reload,
isPinned,
togglePinned,
removePinned,
setHumanOnly,
pruneMissingSessions,
}
})