hermes-agent/apps/desktop/src/store/profile.ts
Brooklyn Nicholson 9dbd3c57d7 feat(desktop): drag sessions into chat as @session links + spawn loader
Drag a sidebar session into the composer to drop an @session:<profile>/<id>
chip the agent resolves via session_search. New READ shape dumps a whole
session by id (head+tail when large); a `profile` param reads another
profile's DB read-only, and a cross-profile locate scan resolves bare ids
when the model drops the owning profile from the link.

Also: ASCII "waking up <profile>" overlay during lazy gateway swaps,
global haptic rate-limit to kill the reconnect-storm "clickity" buzz, and
reauth toasts surfaced once per disconnect instead of every backoff tick.
2026-06-04 19:41:51 -05:00

273 lines
11 KiB
TypeScript

import { atom, computed } from 'nanostores'
import { getProfiles, setApiRequestProfile } from '@/hermes'
import { resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
import { queryClient } from '@/lib/query-client'
import { arraysEqual, persistBoolean, persistStringArray, storedBoolean, storedStringArray } from '@/lib/storage'
import { $gateway } from '@/store/gateway'
import { setConnection } from '@/store/session'
import type { ProfileInfo } from '@/types/hermes'
// Canonical key for a profile: trimmed, empty → "default". Used everywhere we
// compare a session's owning profile against the live gateway's profile.
export function normalizeProfileKey(name: string | null | undefined): string {
const value = (name ?? '').trim()
return value || 'default'
}
// The profile the running local backend is actually scoped to (mirrors
// /api/profiles/active `current`). "default" is the root ~/.hermes. This is the
// display source of truth for the statusbar pill; the desktop's *stored*
// preference (which may be unset) lives in the Electron main process.
export const $activeProfile = atom<string>('default')
// Cached profile list for the picker. Refreshed lazily; the dropdown also
// re-fetches on open so a profile created elsewhere shows up.
export const $profiles = atom<ProfileInfo[]>([])
export function setActiveProfile(name: string): void {
$activeProfile.set(name || 'default')
}
// ── Rail order ─────────────────────────────────────────────────────────────
// User-defined order for the named (non-default) profile squares in the rail.
// Names absent from the list fall back to alphabetical, appended at the tail —
// so a freshly created profile lands at the end until the user drags it.
const PROFILE_ORDER_STORAGE_KEY = 'hermes.desktop.profileOrder'
export const $profileOrder = atom<string[]>(storedStringArray(PROFILE_ORDER_STORAGE_KEY))
$profileOrder.subscribe(value => persistStringArray(PROFILE_ORDER_STORAGE_KEY, [...value]))
export function setProfileOrder(names: string[]): void {
if (!arraysEqual($profileOrder.get(), names)) {
$profileOrder.set(names)
}
}
// Sort items by the stored order; unordered names alphabetise at the tail.
export function sortByProfileOrder<T extends { name: string }>(items: T[], order: string[]): T[] {
const rank = new Map(order.map((name, index) => [name, index]))
return [...items].sort((a, b) => {
const ra = rank.get(a.name)
const rb = rank.get(b.name)
if (ra != null && rb != null) {
return ra - rb
}
return ra != null ? -1 : rb != null ? 1 : a.name.localeCompare(b.name)
})
}
interface ActiveProfileResponse {
active: string
current: string
}
// Pull the running backend's current profile + the available profile list.
// Best-effort: failures (backend not up yet) leave the prior values intact.
export async function refreshActiveProfile(): Promise<void> {
try {
const res = await window.hermesDesktop.api<ActiveProfileResponse>({ path: '/api/profiles/active' })
setActiveProfile(res.current || 'default')
} catch {
// Backend may not be ready; keep the last known value.
}
try {
const { profiles } = await getProfiles()
$profiles.set(profiles)
} catch {
// Leave the cached list in place.
}
}
// Persist the choice and relaunch the backend under the new HERMES_HOME. The
// main process reloads the window, so this normally never returns to the caller
// (the renderer is torn down). We optimistically reflect the selection first so
// the pill updates instantly if the reload is delayed.
export async function switchProfile(name: string): Promise<void> {
if (!name || name === $activeProfile.get()) {
return
}
setActiveProfile(name)
await window.hermesDesktop.profile.set(name)
}
// ── Swap-minimal gateway routing ──────────────────────────────────────────
// One live gateway at a time. When the user opens/sends a session whose profile
// differs from the gateway's current profile, we lazily reconnect the single
// gateway to that profile's backend (spawned on demand by the Electron pool).
// A single-profile user never triggers a swap, so their path is unchanged.
// The profile the live gateway WebSocket is currently connected to. Initialized
// to the primary (window) backend's profile on boot.
export const $activeGatewayProfile = atom<string>('default')
// Profile for the NEXT new chat (chosen via the new-chat picker). null = primary
// / default, so single-profile users are unaffected.
export const $newChatProfile = atom<string | null>(null)
// Bumped whenever the profile context actually changes (switch or create). The
// chat controller subscribes and drops to a fresh new-session draft, so the
// session you were in doesn't stay sticky across a profile switch.
export const $freshSessionRequest = atom(0)
function requestFreshSession(): void {
$freshSessionRequest.set($freshSessionRequest.get() + 1)
}
// Route profile-scoped REST settings (config/env/skills/tools/model/…) to the
// profile the live gateway is currently on, and drop cached settings from the
// previous profile so pages refetch against the right backend. Fires once
// immediately (no real change → no invalidation), so single-profile users just
// get "default" (→ the primary backend) with no extra fetches.
let _lastRoutedProfile: string | null = null
$activeGatewayProfile.subscribe(value => {
const key = normalizeProfileKey(value)
setApiRequestProfile(key)
if (_lastRoutedProfile !== null && _lastRoutedProfile !== key) {
// Profile-scoped settings + the unified session list are now stale.
void queryClient.invalidateQueries()
}
_lastRoutedProfile = key
})
// Target profile while a gateway swap is mid-flight (spawning/reconnecting that
// profile's backend), else null. Drives the chat's "waking up <profile>" loader
// so a lazy spawn doesn't read as a hang. Single-profile users never swap.
export const $gatewaySwapTarget = atom<string | null>(null)
let gatewaySwitch: Promise<void> | null = null
// Reconnect the single live gateway to `profile`'s backend if it isn't already
// there. A null/empty target means "no explicit profile" → keep the gateway on
// whatever profile it's currently on (so a plain new chat stays put and a
// single-profile user never swaps). No-op fast path when already on target.
export async function ensureGatewayProfile(profile: string | null | undefined): Promise<void> {
if (profile == null || !String(profile).trim()) {
// "No explicit profile" = use the current gateway. But if an explicit swap
// (e.g. the user just picked a profile in the switcher) is still in flight,
// let it settle first so a new chat doesn't race session.create against a
// half-open socket and land on the wrong backend.
if (gatewaySwitch) {
await gatewaySwitch.catch(() => undefined)
}
return
}
const target = normalizeProfileKey(profile)
if (normalizeProfileKey($activeGatewayProfile.get()) === target) {
return
}
// Serialize concurrent swaps so two rapid session switches don't fight over
// the single socket.
if (gatewaySwitch) {
await gatewaySwitch.catch(() => undefined)
if (normalizeProfileKey($activeGatewayProfile.get()) === target) {
return
}
}
$gatewaySwapTarget.set(target)
gatewaySwitch = (async () => {
const desktop = window.hermesDesktop
const gateway = $gateway.get()
if (!desktop || !gateway) {
return
}
// getConnection lazily spawns/reuses the profile's pool backend (or returns
// the primary when target is the primary's profile).
const conn = await desktop.getConnection(target)
setConnection(conn)
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
// The single socket is still OPEN to the *previous* profile's backend, and
// gateway.connect() no-ops on an already-open socket. Drop it first so the
// reconnect actually re-points at the target profile's backend — otherwise
// the swap silently stays on the old backend (session.create writes to the
// wrong profile's DB). close() nulls the socket without emitting a 'closed'
// state, so it doesn't trip the boot auto-reconnect.
gateway.close()
await gateway.connect(wsUrl)
$activeGatewayProfile.set(target)
void desktop.touchBackend?.(target).catch(() => undefined)
})()
try {
await gatewaySwitch
} finally {
gatewaySwitch = null
$gatewaySwapTarget.set(null)
}
}
// ── Sidebar profile scope (the "workspace switcher" model) ─────────────────
// Mirrors how Slack/VS Code/Linear do multi-context: you're "in" one profile at
// a time and the sidebar shows only that profile's sessions (clean rows, no
// per-row tags). The lone exception is an explicit "All profiles" mode that
// fans every profile's sessions into one grouped, browsable list.
export const ALL_PROFILES = '__all__'
const SHOW_ALL_PROFILES_STORAGE_KEY = 'hermes.desktop.showAllProfiles'
// Opt-in unified view. When false, scope follows the live gateway profile, so
// single-profile users (who never see the switcher) are completely unaffected.
export const $showAllProfiles = atom<boolean>(storedBoolean(SHOW_ALL_PROFILES_STORAGE_KEY, false))
$showAllProfiles.subscribe(value => persistBoolean(SHOW_ALL_PROFILES_STORAGE_KEY, value))
// The profile context the sidebar is currently showing: a concrete profile key,
// or ALL_PROFILES for the unified grouped view. Concrete scope is tied to the
// gateway so opening/selecting a profile (which swaps the gateway) moves the
// whole sidebar with it — a real context switch, not a separate filter to keep
// in sync.
export const $profileScope = computed([$showAllProfiles, $activeGatewayProfile], (showAll, gateway) =>
showAll ? ALL_PROFILES : normalizeProfileKey(gateway)
)
// Switch the active context to `name`: leave "All profiles" mode, point new
// chats at it, and swap the single live gateway onto its backend (which moves
// $activeGatewayProfile → name, so $profileScope follows).
export function selectProfile(name: string): void {
const target = normalizeProfileKey(name)
// Switching profiles (or coming back from the all-profiles browse view) starts
// fresh; re-tapping the profile you're already in leaves your session be.
const switching = $showAllProfiles.get() || target !== normalizeProfileKey($activeGatewayProfile.get())
$showAllProfiles.set(false)
$newChatProfile.set(target)
if (switching) {
requestFreshSession()
}
void ensureGatewayProfile(target)
}
export function setShowAllProfiles(value: boolean): void {
$showAllProfiles.set(value)
}
// Keepalive ping for the active pool backend so the main-process idle reaper
// (which can't see the direct renderer↔backend WS) spares it. No-op for the
// primary/default backend, which is never pooled.
export function touchActiveGatewayBackend(): void {
// Always ping: the main process no-ops for non-pool (primary) backends, so we
// don't need to know which profile is primary from here.
const target = normalizeProfileKey($activeGatewayProfile.get())
void window.hermesDesktop?.touchBackend?.(target).catch(() => undefined)
}