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:
@@ -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,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user