import { atom } from 'nanostores' import type { ContextSuggestion } from '@/app/types' import type { HermesConnection } from '@/global' import type { ChatMessage } from '@/lib/chat-messages' import { persistString, storedString } from '@/lib/storage' import type { SessionInfo, UsageStats } from '@/types/hermes' type Updater = T | ((current: T) => T) const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd' export const getRememberedWorkspaceCwd = (): string => storedString(WORKSPACE_CWD_KEY)?.trim() || '' interface AppAtom { get: () => T set: (value: T) => void } function updateAtom(store: AppAtom, next: Updater) { store.set(typeof next === 'function' ? (next as (current: T) => T)(store.get()) : next) } /** Durable id for pinning. Auto-compression rotates a conversation's session * id (root -> continuation tip), so pins keyed on the live id evaporate. The * lineage root is stable across every compression, so we pin on that. */ export const sessionPinId = (session: Pick): string => session._lineage_root_id ?? session.id /** Merge a fresh server session page into the in-memory list, keeping any * row the server omitted that we still want visible — both still-"working" * sessions and pinned sessions. * * Two reasons the server drops a row we must keep: * * 1. A brand-new session's first user message isn't flushed to the SessionDB * until its turn is persisted, so `listSessions(min_messages=1)` skips * sessions that are mid-first-response. Because every `message.complete` * triggers a full refresh, a hard replace makes concurrent new chats vanish * the instant any one of them finishes. * 2. The sidebar lists only the most-recent page (`SIDEBAR_SESSIONS_PAGE_SIZE`) * ordered by activity. A pinned conversation that hasn't been touched in a * while falls off that page, so a hard replace silently evicts it from the * in-memory list — and because the Pinned section resolves pins against * that list, the pin "disappears until you refresh". * * `keepIds` carries both the working set and the pinned set. Pins are stored * on the durable lineage-root id (see {@link sessionPinId}), while the loaded * row surfaces under its live compression tip, so we match a survivor by * either its live `id` or its `_lineage_root_id`. Optimistic deletes/archives * drop the row from `previous` (and unpin it), so a removed session can't be * resurrected here. */ export function mergeSessionPage( previous: SessionInfo[], incoming: SessionInfo[], keepIds: Iterable ): SessionInfo[] { const keep = keepIds instanceof Set ? keepIds : new Set(keepIds) if (keep.size === 0) { return incoming } const incomingIds = new Set(incoming.map(session => session.id)) const survivors = previous.filter( session => !incomingIds.has(session.id) && (keep.has(session.id) || (session._lineage_root_id != null && keep.has(session._lineage_root_id))) ) return survivors.length ? [...survivors, ...incoming] : incoming } export const $connection = atom(null) export const $gatewayState = atom('idle') export const $sessions = atom([]) export const $sessionsTotal = atom(0) // Cron-job sessions (source === 'cron') are fetched as their own list so the // scheduler's always-newest sessions never crowd recents out of the page // budget. Powers the collapsed "Cron jobs" sidebar section. export const $cronSessions = atom([]) // Listable conversation count per profile (children excluded), keyed by profile // name. Lets the sidebar scope its "Load more" footer to the active profile so a // huge default profile doesn't keep "Load more" visible while browsing a small // one. Empty for single-profile users (fall back to $sessionsTotal). export const $sessionProfileTotals = atom>({}) export const $sessionsLoading = atom(true) export const $workingSessionIds = atom([]) export const $activeSessionId = atom(null) export const $selectedStoredSessionId = atom(null) export const $messages = atom([]) export const $freshDraftReady = atom(false) export const $busy = atom(false) export const $awaitingResponse = atom(false) export const $currentModel = atom('') export const $currentProvider = atom('') export const $currentReasoningEffort = atom('') export const $currentServiceTier = atom('') export const $currentFastMode = atom(false) // Effective approval-bypass state mirrored from the gateway (session.info). // Persistence lives in the backend config (approvals.mode), so this is a plain // reflection of the truth the gateway reports rather than its own store. export const $yoloActive = atom(false) export const $currentCwd = atom(getRememberedWorkspaceCwd()) export const $currentBranch = atom('') export const $currentUsage = atom({ calls: 0, input: 0, output: 0, total: 0 }) export const $sessionStartedAt = atom(null) export const $turnStartedAt = atom(null) export const $introPersonality = atom('') export const $currentPersonality = atom('') export const $availablePersonalities = atom([]) export const $introSeed = atom(0) export const $contextSuggestions = atom([]) export const $modelPickerOpen = atom(false) export const setConnection = (next: Updater) => updateAtom($connection, next) export const setGatewayState = (next: Updater) => updateAtom($gatewayState, next) export const setSessions = (next: Updater) => updateAtom($sessions, next) export const setSessionsTotal = (next: Updater) => updateAtom($sessionsTotal, next) export const setCronSessions = (next: Updater) => updateAtom($cronSessions, next) export const setSessionProfileTotals = (next: Updater>) => updateAtom($sessionProfileTotals, next) export const setSessionsLoading = (next: Updater) => updateAtom($sessionsLoading, next) export const setWorkingSessionIds = (next: Updater) => updateAtom($workingSessionIds, next) export const setActiveSessionId = (next: Updater) => updateAtom($activeSessionId, next) export const setSelectedStoredSessionId = (next: Updater) => updateAtom($selectedStoredSessionId, next) export const setMessages = (next: Updater) => updateAtom($messages, next) export const setFreshDraftReady = (next: Updater) => updateAtom($freshDraftReady, next) export const setBusy = (next: Updater) => updateAtom($busy, next) export const setAwaitingResponse = (next: Updater) => updateAtom($awaitingResponse, next) export const setCurrentModel = (next: Updater) => updateAtom($currentModel, next) export const setCurrentProvider = (next: Updater) => updateAtom($currentProvider, next) export const setCurrentReasoningEffort = (next: Updater) => updateAtom($currentReasoningEffort, next) export const setCurrentServiceTier = (next: Updater) => updateAtom($currentServiceTier, next) export const setCurrentFastMode = (next: Updater) => updateAtom($currentFastMode, next) export const setYoloActive = (next: Updater) => updateAtom($yoloActive, next) export const setCurrentCwd = (next: Updater) => { updateAtom($currentCwd, next) // Keep localStorage in sync with the atom: a real folder is remembered, an // empty cwd clears the key (|| null → removeItem). persistString(WORKSPACE_CWD_KEY, $currentCwd.get().trim() || null) } export const setCurrentBranch = (next: Updater) => updateAtom($currentBranch, next) export const setCurrentUsage = (next: Updater) => updateAtom($currentUsage, next) export const setSessionStartedAt = (next: Updater) => updateAtom($sessionStartedAt, next) export const setTurnStartedAt = (next: Updater) => updateAtom($turnStartedAt, next) export const setIntroPersonality = (next: Updater) => updateAtom($introPersonality, next) export const setCurrentPersonality = (next: Updater) => updateAtom($currentPersonality, next) export const setAvailablePersonalities = (next: Updater) => updateAtom($availablePersonalities, next) export const setIntroSeed = (next: Updater) => updateAtom($introSeed, next) export const setContextSuggestions = (next: Updater) => updateAtom($contextSuggestions, next) export const setModelPickerOpen = (next: Updater) => updateAtom($modelPickerOpen, next) // Watchdog tracking — when does a "working" session count as stuck? // Long-running tool calls (LLM inference, long shell commands, web fetches) // can take a few minutes legitimately. We allow 8 minutes of complete // silence on the stream before clearing the working flag; in practice this // catches gateway hangs and dropped streams without false-positive-clearing // real long turns. const SESSION_WATCHDOG_TIMEOUT_MS = 8 * 60 * 1000 const sessionWatchdogTimers = new Map>() function armSessionWatchdog(sessionId: string) { const existing = sessionWatchdogTimers.get(sessionId) if (existing) { clearTimeout(existing) } const timer = setTimeout(() => { sessionWatchdogTimers.delete(sessionId) // Re-check the latest state at fire-time. If the user already navigated // away or the session genuinely finished, the timer is a no-op. if ($workingSessionIds.get().includes(sessionId)) { setWorkingSessionIds(current => current.filter(id => id !== sessionId)) } }, SESSION_WATCHDOG_TIMEOUT_MS) sessionWatchdogTimers.set(sessionId, timer) } function clearSessionWatchdog(sessionId: string) { const existing = sessionWatchdogTimers.get(sessionId) if (existing) { clearTimeout(existing) sessionWatchdogTimers.delete(sessionId) } } /** Call when a streaming event for a session lands. Refreshes the watchdog * so the session keeps its "working" status as long as data keeps coming. */ export function noteSessionActivity(sessionId: string | null | undefined) { if (!sessionId || !$workingSessionIds.get().includes(sessionId)) { return } armSessionWatchdog(sessionId) } // Toggle an id's membership in a string-set atom, no-op when unchanged (keeps // the same array reference so subscribers don't churn). const toggleMembership = (set: (next: Updater) => void, id: string, on: boolean) => set(current => { const present = current.includes(id) if (on) { return present ? current : [...current, id] } return present ? current.filter(x => x !== id) : current }) // Stored session ids with a blocking prompt (clarify) waiting on the user. // Separate from $workingSessionIds: a session can be "working" (turn running) // AND need input. The sidebar row reads this for a persistent indicator that, // unlike a toast, survives window blur / alt-tab. export const $attentionSessionIds = atom([]) export const setAttentionSessionIds = (next: Updater) => updateAtom($attentionSessionIds, next) export function setSessionAttention(sessionId: string | null | undefined, needsInput: boolean) { if (sessionId) { toggleMembership(setAttentionSessionIds, sessionId, needsInput) } } export function setSessionWorking(sessionId: string | null | undefined, working: boolean) { if (!sessionId) { return } toggleMembership(setWorkingSessionIds, sessionId, working) // Bookend the watchdog: arm on enter, disarm on leave. A later // noteSessionActivity() from a streaming event refreshes the timer. if (working) { armSessionWatchdog(sessionId) } else { clearSessionWatchdog(sessionId) } }