hermes-agent/apps/desktop/src/store/panes.ts
Brooklyn Nicholson 3fffecbdaf feat(desktop): add timeline rail for long chat threads
Adds a compact right-edge prompt timeline for long desktop chat sessions, with hover previews, click-to-jump, active/hover row states, and pane hover-reveal suppression so the rail can live at the hard edge without opening side panels.
2026-06-22 18:34:07 -05:00

147 lines
4.1 KiB
TypeScript

import { atom, computed, type ReadableAtom } from 'nanostores'
export interface PaneStateSnapshot {
open: boolean
widthOverride?: number
}
export interface PaneRegisterDefaults {
open: boolean
widthOverride?: number
}
const STORAGE_KEY = 'hermes.desktop.paneStates.v1'
function isSnapshot(value: unknown): value is PaneStateSnapshot {
if (!value || typeof value !== 'object') {
return false
}
const r = value as Record<string, unknown>
if (typeof r.open !== 'boolean') {
return false
}
return r.widthOverride === undefined || (typeof r.widthOverride === 'number' && Number.isFinite(r.widthOverride))
}
function load(): Record<string, PaneStateSnapshot> {
if (typeof window === 'undefined') {
return {}
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw) as unknown
if (parsed && typeof parsed === 'object') {
const out: Record<string, PaneStateSnapshot> = {}
for (const [id, value] of Object.entries(parsed as Record<string, unknown>)) {
if (isSnapshot(value)) {
out[id] = { open: value.open, widthOverride: value.widthOverride }
}
}
return out
}
}
} catch {
// Treat unparseable persisted state as missing.
}
return {}
}
// widthOverride is in-memory only — phase 2 can add per-pane persistWidth opt-in.
function persist(states: Record<string, PaneStateSnapshot>) {
if (typeof window === 'undefined') {
return
}
const minimal: Record<string, { open: boolean }> = {}
for (const [id, s] of Object.entries(states)) {
minimal[id] = { open: s.open }
}
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(minimal))
} catch {
// Storage failures are nonfatal.
}
}
export const $paneStates = atom<Record<string, PaneStateSnapshot>>(load())
export const $paneHoverRevealSuppressed = atom(false)
$paneStates.subscribe(persist)
// Cached per-pane derived atoms keep useStore subscriptions referentially stable.
function memoized<T>(
cache: Map<string, ReadableAtom<T>>,
id: string,
selector: (s: PaneStateSnapshot | undefined) => T
) {
let cached = cache.get(id)
if (!cached) {
cached = computed($paneStates, states => selector(states[id]))
cache.set(id, cached)
}
return cached
}
const openCache = new Map<string, ReadableAtom<boolean>>()
const stateCache = new Map<string, ReadableAtom<PaneStateSnapshot | undefined>>()
const widthCache = new Map<string, ReadableAtom<number | undefined>>()
export const $paneOpen = (id: string) => memoized(openCache, id, s => s?.open ?? false)
export const $paneState = (id: string) => memoized(stateCache, id, s => s)
export const $paneWidthOverride = (id: string) => memoized(widthCache, id, s => s?.widthOverride)
export function ensurePaneRegistered(id: string, defaults: PaneRegisterDefaults) {
const current = $paneStates.get()
if (current[id] !== undefined) {
return
}
$paneStates.set({ ...current, [id]: { open: defaults.open, widthOverride: defaults.widthOverride } })
}
export function setPaneOpen(id: string, open: boolean) {
const current = $paneStates.get()
const existing = current[id]
if (existing?.open === open) {
return
}
$paneStates.set({ ...current, [id]: { open, widthOverride: existing?.widthOverride } })
}
export function togglePane(id: string) {
const current = $paneStates.get()
const existing = current[id]
$paneStates.set({ ...current, [id]: { open: !(existing?.open ?? false), widthOverride: existing?.widthOverride } })
}
export function setPaneWidthOverride(id: string, width: number | undefined) {
const current = $paneStates.get()
const existing = current[id] ?? { open: false }
if (existing.widthOverride === width) {
return
}
$paneStates.set({ ...current, [id]: { open: existing.open, widthOverride: width } })
}
export const clearPaneWidthOverride = (id: string) => setPaneWidthOverride(id, undefined)
export const getPaneStateSnapshot = (id: string) => $paneStates.get()[id]
export const setPaneHoverRevealSuppressed = (suppressed: boolean) => $paneHoverRevealSuppressed.set(suppressed)