Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
yilsem committed 2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 files changed
+3442369

No files matched your search

@@ -0,0 +1,60 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { $sessions } from './session'
import { clearAllSessionStates, publishSessionState } from './session-states'
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
const setActiveWork = vi.fn()
const busy = (storedSessionId: string, isBusy: boolean) =>
({ busy: isBusy, needsInput: false, storedSessionId }) as ClientSessionState
const session = (id: string, title: null | string) => ({ id, title }) as (typeof $sessions.value)[number]
beforeAll(async () => {
desktopWindow.hermesDesktop = { setActiveWork } as unknown as Window['hermesDesktop']
// Subscribes at import time, so the bridge has to exist first.
await import('./active-work')
})
beforeEach(() => {
clearAllSessionStates()
$sessions.set([])
setActiveWork.mockClear()
})
describe('active work bridge', () => {
it('reports a busy session by title', () => {
$sessions.set([session('s1', 'Fix login'), session('s2', 'Idle chat')])
publishSessionState('runtime-1', busy('s1', true))
expect(setActiveWork).toHaveBeenLastCalledWith({ count: 1, titles: ['Fix login'] })
})
it('counts an untitled busy session without inventing a title', () => {
$sessions.set([session('s1', null)])
publishSessionState('runtime-1', busy('s1', true))
expect(setActiveWork).toHaveBeenLastCalledWith({ count: 1, titles: [] })
})
it('drops back to nothing when the turn ends', () => {
$sessions.set([session('s1', 'Fix login')])
publishSessionState('runtime-1', busy('s1', true))
publishSessionState('runtime-1', busy('s1', false))
expect(setActiveWork).toHaveBeenLastCalledWith({ count: 0, titles: [] })
})
it('does not re-send an unchanged summary', () => {
$sessions.set([session('s1', 'Fix login')])
publishSessionState('runtime-1', busy('s1', true))
setActiveWork.mockClear()
$sessions.set([session('s1', 'Fix login'), session('s2', 'Something else')])
expect(setActiveWork).not.toHaveBeenCalled()
})
})
+42
View File
@@ -0,0 +1,42 @@
/**
* Mirror of "which chats are mid-turn" to the main process.
*
* The renderer is the only side that knows a turn is in flight, and the main
* process is the only side that can intercept a quit. This module bridges the
* two: it publishes a small summary on every membership change, and
* `electron/quit-guard.ts` turns that into the confirmation dialog.
*
* Imported for its side effect from `main.tsx`, alongside `store/translucency`.
*/
import { computed } from 'nanostores'
import type { HermesActiveWork } from '@/global'
import { $sessions } from '@/store/session'
import { $workingSessionIds } from '@/store/session-states'
const $activeWork = computed([$workingSessionIds, $sessions], (workingIds, sessions): HermesActiveWork => {
const titleById = new Map(sessions.map(session => [session.id, session.title?.trim() ?? '']))
return {
count: workingIds.length,
titles: workingIds.map(id => titleById.get(id) ?? '').filter(Boolean)
}
})
if (typeof window !== 'undefined') {
// `$sessions` republishes on unrelated churn (previews, heartbeats), so only
// send when the summary itself moved — this crosses a process boundary.
let lastSent = ''
$activeWork.subscribe(work => {
const next = JSON.stringify(work)
if (next === lastSent) {
return
}
lastSent = next
window.hermesDesktop?.setActiveWork?.(work)
})
}
+100
View File
@@ -0,0 +1,100 @@
import { atom } from 'nanostores'
import { sessionTitle } from '@/lib/chat-runtime'
import type { PreviewServerRestart } from '@/store/preview'
import type { ActionStatusResponse, SessionInfo } from '@/types/hermes'
const HISTORY_LIMIT = 8
const COMPLETED_TTL_MS = 5 * 60 * 1000
export type RailTaskStatus = 'error' | 'running' | 'success'
export interface RailTask {
id: string
label: string
detail: string
status: RailTaskStatus
updatedAt: number
}
export interface DesktopActionTask {
status: ActionStatusResponse
updatedAt: number
}
export const $desktopActionTasks = atom<Record<string, DesktopActionTask>>({})
export function upsertDesktopActionTask(status: ActionStatusResponse): void {
$desktopActionTasks.set(prune({ ...$desktopActionTasks.get(), [status.name]: { status, updatedAt: Date.now() } }))
}
export function buildRailTasks(
workingSessionIds: readonly string[],
sessions: readonly SessionInfo[],
previewRestart: PreviewServerRestart | null,
actionTasks: Record<string, DesktopActionTask>
): RailTask[] {
const sessionsById = new Map(sessions.map(session => [session.id, session]))
const sessionTasks: RailTask[] = workingSessionIds.map((id, index) => {
const session = sessionsById.get(id)
return {
id: `session:${id}`,
label: session ? sessionTitle(session) : 'Session task',
detail: 'Agent task running',
status: 'running',
updatedAt: session?.last_active || Date.now() - index
}
})
const previewTasks: RailTask[] = previewRestart
? [
{
id: `preview:${previewRestart.taskId}`,
label: 'Preview restart',
detail: previewRestart.message || previewRestart.url,
status:
previewRestart.status === 'error' ? 'error' : previewRestart.status === 'running' ? 'running' : 'success',
updatedAt: Date.now()
}
]
: []
const actions: RailTask[] = Object.values(actionTasks).map(({ status, updatedAt }) => ({
id: `action:${status.name}`,
label: status.name,
detail: actionDetail(status),
status: actionStatus(status),
updatedAt
}))
return [...sessionTasks, ...previewTasks, ...actions].sort((left, right) => right.updatedAt - left.updatedAt)
}
function actionStatus(status: ActionStatusResponse): RailTaskStatus {
if (status.running) {
return 'running'
}
return status.exit_code === 0 ? 'success' : 'error'
}
function actionDetail(status: ActionStatusResponse): string {
if (status.running) {
return 'Running'
}
return status.exit_code === 0 ? 'Completed' : `Failed (${status.exit_code ?? 'unknown'})`
}
function prune(tasks: Record<string, DesktopActionTask>): Record<string, DesktopActionTask> {
const now = Date.now()
return Object.fromEntries(
Object.entries(tasks)
.filter(([, task]) => task.status.running || now - task.updatedAt <= COMPLETED_TTL_MS)
.sort(([, left], [, right]) => right.updatedAt - left.updatedAt)
.slice(0, HISTORY_LIMIT)
)
}
@@ -0,0 +1,231 @@
import { beforeEach, expect, test } from 'vitest'
import {
type AgentNoticePayload,
clearAgentNotice,
nativeNoticeInput,
noticeAccent,
noticeToToast,
showAgentNotice,
splitMeta,
stripGlyph,
usageFraction
} from './agent-notices'
import { $notifications, clearNotifications } from './notifications'
function usage(overrides: Partial<AgentNoticePayload> = {}): AgentNoticePayload {
return {
key: 'credits.usage',
kind: 'sticky',
level: 'info',
text: "• You've used $110.00 of your $220.00 cap",
...overrides
}
}
beforeEach(() => {
clearNotifications()
})
// ── noticeToToast: the whole mapping contract ────────────────────────────────
test('drops a notice with no text', () => {
expect(noticeToToast(undefined)).toBeNull()
expect(noticeToToast({ text: '' })).toBeNull()
expect(noticeToToast({ text: ' ' })).toBeNull()
})
test('level maps to toast kind (warn → warning)', () => {
expect(noticeToToast(usage({ level: 'info' }))?.kind).toBe('info')
expect(noticeToToast(usage({ level: 'warn' }))?.kind).toBe('warning')
expect(noticeToToast(usage({ level: 'error' }))?.kind).toBe('error')
expect(noticeToToast(usage({ level: 'success' }))?.kind).toBe('success')
})
test('unknown / missing level falls back to info', () => {
expect(noticeToToast({ text: 'x', level: 'bogus' })?.kind).toBe('info')
expect(noticeToToast({ text: 'x' })?.kind).toBe('info')
})
test('sticky notices never auto-dismiss', () => {
expect(noticeToToast(usage({ kind: 'sticky' }))?.durationMs).toBe(0)
})
test('ttl notice carries its ttl_ms as the duration', () => {
const toast = noticeToToast({
key: 'credits.restored',
kind: 'ttl',
level: 'success',
text: '✓ restored',
ttl_ms: 8000
})
expect(toast?.durationMs).toBe(8000)
})
test('ttl notice without a usable ttl_ms defers to notify()’s default', () => {
expect(noticeToToast({ text: 'x', kind: 'ttl' })?.durationMs).toBeUndefined()
expect(noticeToToast({ text: 'x', kind: 'ttl', ttl_ms: 0 })?.durationMs).toBeUndefined()
})
test('the notice key is the toast id, falling back to id', () => {
expect(noticeToToast(usage({ key: 'credits.usage' }))?.id).toBe('credits.usage')
expect(noticeToToast({ text: 'x', id: 'n1', key: undefined })?.id).toBe('n1')
})
test('the leading severity glyph is stripped from the toast message', () => {
// The toast renders a kind icon, so the message must not double up on a glyph.
expect(noticeToToast(usage())?.message).toBe("You've used $110.00 of your $220.00 cap")
expect(noticeToToast(usage({ level: 'error', text: '✕ Credit access paused' }))?.message).toBe('Credit access paused')
expect(noticeToToast(usage({ level: 'success', text: '✓ Credit access restored' }))?.message).toBe(
'Credit access restored'
)
})
test('the trailing "· detail" is split off as a secondary meta line, not inlined', () => {
// Detail-carrying notices split on the first ` · `.
const paused = noticeToToast({
key: 'credits.depleted',
level: 'error',
text: '✕ Credit access paused · run /topup to top up'
})
expect(paused?.message).toBe('Credit access paused')
expect(paused?.meta).toBe('run /topup to top up')
// grant_spent carries a `· detail` tail too.
const grant = noticeToToast({ key: 'credits.grant_spent', level: 'info', text: '• Grant spent · $12.00 top-up left' })
expect(grant?.message).toBe('Grant spent')
expect(grant?.meta).toBe('$12.00 top-up left')
// The usage line has no middot → whole line is the message, no meta.
const plain = noticeToToast(usage())
expect(plain?.message).toBe("You've used $110.00 of your $220.00 cap")
expect(plain?.meta).toBeUndefined()
})
test('splitMeta splits on the first space-middot-space only', () => {
expect(splitMeta('Credit access paused · run /topup to top up')).toEqual([
'Credit access paused',
'run /topup to top up'
])
expect(splitMeta('Grant spent · $12.00 top-up left')).toEqual(['Grant spent', '$12.00 top-up left'])
expect(splitMeta('Credit access restored')).toEqual(['Credit access restored', undefined])
// Interior middots after the first split stay in the meta.
expect(splitMeta('a · b · c')).toEqual(['a', 'b · c'])
})
test('stripGlyph removes only a single leading severity glyph', () => {
expect(stripGlyph('• Credits 50% used')).toBe('Credits 50% used')
expect(stripGlyph('⚠ warn')).toBe('warn')
expect(stripGlyph('✕ paused')).toBe('paused')
expect(stripGlyph('✓ ok')).toBe('ok')
// No leading glyph → unchanged; interior glyphs are preserved.
expect(stripGlyph('Credits 50% used')).toBe('Credits 50% used')
expect(stripGlyph('spent · $12.00 • top-up left')).toBe('spent · $12.00 • top-up left')
})
// ── noticeAccent: severity color ramp keyed off $used / $cap ─────────────────
test('usageFraction derives $used / $cap from the notice text', () => {
expect(usageFraction("You've used $15.00 of your $20.00 cap")).toBeCloseTo(0.75)
expect(usageFraction("You've used $198.00 of your $220.00 cap")).toBeCloseTo(0.9)
// Fewer than two amounts, or a zero cap → no fraction.
expect(usageFraction('Grant spent')).toBeNull()
expect(usageFraction("You've used $5.00 of your $0.00 cap")).toBeNull()
expect(usageFraction(undefined)).toBeNull()
})
test('usage accent stays muted below 75%, then ramps orange → red', () => {
expect(noticeAccent(usage({ text: "• You've used $10.00 of your $20.00 cap" }))).toBeUndefined() // 50%
expect(noticeAccent(usage({ text: "• You've used $14.80 of your $20.00 cap" }))).toBeUndefined() // 74%
expect(noticeAccent(usage({ level: 'warn', text: "⚠ You've used $15.00 of your $20.00 cap" }))).toBe(
'var(--ui-orange)'
) // 75%
expect(noticeAccent(usage({ level: 'warn', text: "⚠ You've used $17.80 of your $20.00 cap" }))).toBe(
'var(--ui-orange)'
) // 89%
expect(noticeAccent(usage({ level: 'warn', text: "⚠ You've used $18.00 of your $20.00 cap" }))).toBe('var(--ui-red)') // 90%
expect(noticeAccent(usage({ level: 'warn', text: "⚠ You've used $20.00 of your $20.00 cap" }))).toBe('var(--ui-red)') // 100%
})
test('terminal credit states carry their own accent; others stay default', () => {
expect(noticeAccent({ key: 'credits.depleted', text: '✕ paused' })).toBe('var(--ui-red)')
expect(noticeAccent({ key: 'credits.restored', text: '✓ restored' })).toBe('var(--ui-green)')
expect(noticeAccent({ key: 'credits.grant_spent', text: '• Grant spent' })).toBeUndefined()
expect(noticeAccent(undefined)).toBeUndefined()
})
test('noticeToToast attaches the band accent to the toast', () => {
expect(noticeToToast(usage({ level: 'warn', text: "⚠ You've used $15.00 of your $20.00 cap" }))?.accentColor).toBe(
'var(--ui-orange)'
)
expect(noticeToToast(usage({ text: "• You've used $10.00 of your $20.00 cap" }))?.accentColor).toBeUndefined()
})
// ── show / clear: rendered through the notifications store ────────────────────
test('showAgentNotice renders a toast; empty text is a no-op', () => {
showAgentNotice(usage())
expect($notifications.get()).toHaveLength(1)
expect($notifications.get()[0]?.id).toBe('credits.usage')
showAgentNotice({ text: '' })
expect($notifications.get()).toHaveLength(1)
})
test('re-emitting the same key replaces the toast instead of stacking (50→75→90)', () => {
showAgentNotice(usage({ level: 'info', text: "• You've used $10.00 of your $20.00 cap" }))
showAgentNotice(usage({ level: 'warn', text: "⚠ You've used $15.00 of your $20.00 cap" }))
showAgentNotice(usage({ level: 'warn', text: "⚠ You've used $18.00 of your $20.00 cap" }))
const toasts = $notifications.get().filter(item => item.id === 'credits.usage')
expect(toasts).toHaveLength(1)
expect(toasts[0]?.message).toBe("You've used $18.00 of your $20.00 cap")
expect(toasts[0]?.kind).toBe('warning')
})
test('clearAgentNotice dismisses only the matching key', () => {
showAgentNotice(usage())
showAgentNotice({ key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ paused' })
expect($notifications.get()).toHaveLength(2)
clearAgentNotice('credits.usage')
const ids = $notifications.get().map(item => item.id)
expect(ids).toContain('credits.depleted')
expect(ids).not.toContain('credits.usage')
clearAgentNotice(undefined)
expect($notifications.get()).toHaveLength(1)
})
// ── nativeNoticeInput: only the urgent credit pair breaks through the OS ──────
test('only credits.depleted and credits.restored map to a native notification', () => {
expect(nativeNoticeInput(usage({ key: 'credits.usage' }), 'Credits')).toBeNull()
expect(nativeNoticeInput(usage({ key: 'credits.grant_spent' }), 'Credits')).toBeNull()
expect(nativeNoticeInput({ text: 'x', key: undefined }, 'Credits')).toBeNull()
expect(nativeNoticeInput({ text: '', key: 'credits.depleted' }, 'Credits')).toBeNull()
})
test('the urgent pair maps to a global native input carrying the text as its body', () => {
const depleted = nativeNoticeInput(
{ key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ Credit access paused · run /topup to top up' },
'Credits'
)
expect(depleted).toEqual({
body: '✕ Credit access paused · run /topup to top up',
global: true,
kind: 'credits',
title: 'Credits'
})
const restored = nativeNoticeInput(
{ key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ Credit access restored', ttl_ms: 8000 },
'Credits'
)
expect(restored?.kind).toBe('credits')
expect(restored?.body).toBe('✓ Credit access restored')
})
+206
View File
@@ -0,0 +1,206 @@
import type { NativeNotificationInput } from '@/store/native-notifications'
import { dismissNotification, type NotificationInput, type NotificationKind, notify } from '@/store/notifications'
/**
* Wire shape of a `notification.show` payload — the driver-agnostic
* `AgentNotice` spine (`agent/credits_tracker.py`) as forwarded by
* `tui_gateway/server.py`. Snake_case to match the wire.
*
* The `text` carries its own leading severity glyph (• ⚠ ✕ ✓) from the Python
* policy — that's how the CLI/TUI render it (glyph in a status line, no separate
* icon). The desktop toast is different: every toast renders a kind icon, so we
* strip the leading glyph and let that icon carry severity (see `stripGlyph`),
* otherwise the toast shows two markers. The native OS notification keeps the
* glyph (it has no icon of ours).
*
* - `level` is severity: info | warn | error | success.
* - `kind` is lifetime: `sticky` (stays until an explicit clear) or `ttl`
* (self-expires after `ttl_ms`).
*/
export interface AgentNoticePayload {
text?: string
level?: string
kind?: string
ttl_ms?: null | number
key?: string
id?: string
}
const LEVEL_TO_TOAST_KIND: Record<string, NotificationKind> = {
error: 'error',
info: 'info',
success: 'success',
warn: 'warning'
}
// The severity glyphs the Python notice policy prefixes (`•` `⚠` `✕`/`✗` `✓`),
// optionally with a variation selector, plus trailing space. Stripped for the
// desktop toast because the toast already renders a kind icon.
const LEADING_GLYPH = /^[•⚠✕✗✓]\uFE0F?\s*/u
/** Drop a single leading severity glyph so the toast doesn't double up on it. */
export function stripGlyph(text: string): string {
return text.replace(LEADING_GLYPH, '')
}
/** A `$12.34` money token, as the Nous notice policy formats amounts. */
const MONEY = /\$(\d+(?:\.\d{2})?)/g
/**
* Used fraction of the cap derived from a "You've used $X of your $Y cap" notice
* — the first two money tokens are (used, cap). Returns a value in [0, 1], or
* `null` when the line has no usable pair.
*/
export function usageFraction(text: string | undefined): null | number {
const amounts = [...(text ?? '').matchAll(MONEY)].map(m => Number(m[1]))
if (amounts.length < 2 || !(amounts[1] > 0)) {
return null
}
return amounts[0] / amounts[1]
}
/**
* Accent color for a credit notice, as a range/severity ramp. The usage gauge
* keys off how much of the cap is spent ($used / $cap): it stays on the toast's
* default muted color under 75%, then escalates to orange, then red, as the cap
* nears. `credits.depleted` is red (paused) and `credits.restored` green.
*
* Returns a CSS color token or `undefined` (= keep the default muted color).
* These reuse the app's existing usage palette (`--ui-*`; see the
* `--context-usage-*` block in styles.css) — no new colors are introduced.
*/
export function noticeAccent(payload: AgentNoticePayload | undefined): string | undefined {
if (payload?.key === 'credits.depleted') {
return 'var(--ui-red)'
}
if (payload?.key === 'credits.restored') {
return 'var(--ui-green)'
}
const frac = usageFraction(payload?.text)
if (frac === null) {
return undefined
}
if (frac >= 0.9) {
return 'var(--ui-red)'
}
if (frac >= 0.75) {
return 'var(--ui-orange)'
}
return undefined
}
/**
* Map an agent notice to a toast input, or `null` when it carries no text.
*
* Pure and side-effect free so it can be unit-tested directly. The mapping is
* the whole contract:
* - `level` → toast kind (info/warn/error/success, warn→warning).
* - `sticky` → `durationMs: 0` (persists); `ttl` → `durationMs: ttl_ms`.
* - the notice `key` doubles as the toast `id`, so re-emitting the same key
* REPLACES the prior toast — the credits 50→75→90 line escalates in place
* instead of stacking, and a key-matched `notification.clear` can dismiss it.
*/
export function noticeToToast(payload: AgentNoticePayload | undefined): NotificationInput | null {
const text = payload?.text?.trim()
if (!text) {
return null
}
const isTtl = payload?.kind === 'ttl'
const ttl = typeof payload?.ttl_ms === 'number' && payload.ttl_ms > 0 ? payload.ttl_ms : undefined
// The Python notice text packs a trailing detail after a middot
// (`… used · $220.00 cap`, `Grant spent · $12.00 top-up left`). On one CLI/TUI
// status line that reads fine, but the toast follows the title-plus-description
// convention (Sonner/shadcn): the primary status is the message and the detail
// drops to a muted second line, instead of inlining a `·` separator.
const [primary, meta] = splitMeta(stripGlyph(text))
return {
// Icon + text tint by usage band (muted → orange → red); undefined keeps
// the default muted color.
accentColor: noticeAccent(payload),
// sticky → 0 (never auto-dismiss); ttl with a ttl_ms → that value; a ttl
// without a usable ttl_ms falls back to notify()'s per-kind default.
durationMs: isTtl ? ttl : 0,
id: payload?.key || payload?.id,
kind: LEVEL_TO_TOAST_KIND[payload?.level ?? 'info'] ?? 'info',
message: primary,
meta
}
}
/**
* Split a notice line into its primary status and a trailing detail on the first
* ` · ` (space-middot-space). No middot → the whole line is the primary and
* there's no meta.
*/
export function splitMeta(text: string): [primary: string, meta: string | undefined] {
const at = text.indexOf(' · ')
if (at === -1) {
return [text, undefined]
}
return [text.slice(0, at), text.slice(at + 3) || undefined]
}
/** Render a `notification.show` notice as a toast (no-op when it has no text). */
export function showAgentNotice(payload: AgentNoticePayload | undefined): void {
const toast = noticeToToast(payload)
if (toast) {
notify(toast)
}
}
/**
* Dismiss the toast a `notification.clear` targets. The clear only ever names a
* `key`, which we used as the toast id, so this is a key-matched dismissal.
*/
export function clearAgentNotice(key: string | undefined): void {
if (key) {
dismissNotification(key)
}
}
// Only these two credit notices are urgent enough to break through as a native
// OS notification (when Hermes is backgrounded). The escalating usage line
// (`credits.usage`) and the grant-spent notice stay in-app toasts only — they
// aren't worth interrupting the user's OS for.
const NATIVE_NOTICE_KEYS = new Set(['credits.depleted', 'credits.restored'])
/**
* Map a notice to a native OS notification input, or `null` when it isn't one of
* the urgent credit notices. Pure — the caller passes the localized `title` and
* decides whether to dispatch. `global: true` because credit state is
* account-wide, not tied to a chat session, so it should fire whenever the user
* is away regardless of which session (if any) is focused. The notice `text`
* already carries its glyph and is passed through as the raw body.
*/
export function nativeNoticeInput(
payload: AgentNoticePayload | undefined,
title: string
): NativeNotificationInput | null {
const text = payload?.text?.trim()
if (!text || !payload?.key || !NATIVE_NOTICE_KEYS.has(payload.key)) {
return null
}
return {
body: text,
global: true,
kind: 'credits',
title
}
}
+211
View File
@@ -0,0 +1,211 @@
import { atom } from 'nanostores'
import { notifyError } from '@/store/notifications'
/**
* Feature store for backend (agent) plugins — the native Hermes plugins plus
* portable Agent Plugins v1 packages the backend discovers on disk. Settings
* renders this next to the desktop (renderer) plugin inventory so every plugin
* the user has is discoverable and toggleable from one page, whatever process
* it runs in.
*
* Backed by the gateway's `plugins.manage` RPC — the same list/toggle
* primitives `hermes plugins` and the dashboard use, so all surfaces agree on
* what's installed and what's enabled. Works against every backend topology
* (local spawn, SSH, URL+token) because it rides the session's own transport.
*/
export interface AgentPluginRow {
name: string
/** Canonical registry key (e.g. `image_gen/fal`) — absent on legacy backends. */
key?: string
version: string
description: string
/** 'bundled' | 'user' | 'git' | 'project' | 'entrypoint' */
source: string
status: 'enabled' | 'disabled' | 'not enabled'
/** Agent Plugins v1 package (portable skills/MCP format) vs native Hermes. */
portable?: boolean
}
export type AgentPluginsStatus = 'idle' | 'loading' | 'ready' | 'error'
/** The recovering `requestGateway` from `useGatewayRequest`. */
export type GatewayRequest = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
export const $agentPlugins = atom<AgentPluginRow[]>([])
export const $agentPluginsStatus = atom<AgentPluginsStatus>('idle')
export const $agentPluginsError = atom<string | null>(null)
/** Best available address of the row whose toggle RPC is in flight. */
export const $agentPluginBusy = atom<string | null>(null)
// Rows the Plugins page actually lists (and search should surface): plugins
// the USER installed. Repo-bundled built-ins ship enabled-by-default and are
// configured from their own surfaces, so they're pure noise here. The prefix
// list is the fallback for older backends whose rows predate a reliable
// `source` field — same curation stance as desktop-slash-commands.ts.
const HIDDEN_KEY_PREFIXES = ['dashboard_auth/', 'model-providers/', 'platforms/']
export const isDesktopRelevantPlugin = (row: AgentPluginRow): boolean => {
if (row.source === 'bundled') {
return false
}
const key = row.key
return !key || !HIDDEN_KEY_PREFIXES.some(prefix => key.startsWith(prefix))
}
let inflight: Promise<void> | null = null
let inflightProfile: string | null = null
// Bumped per load so a slow response from a previous profile scope can't
// overwrite the newer scope's list (async results can land out of order).
let loadGeneration = 0
/** Scope a `plugins.manage` payload to a profile. Omitted (null) = the
* backend's launch profile — older backends ignore the extra param. */
const withProfile = (params: Record<string, unknown>, profile?: string | null) =>
profile ? { ...params, profile } : params
/** Fetch the backend plugin list, optionally scoped to another profile's
* HERMES_HOME. Always refetches (it's a cheap local disk scan on the
* backend); concurrent callers for the SAME profile share one in-flight
* request — a different profile starts fresh so a scope switch can't get a
* stale list. */
export function loadAgentPlugins(request: GatewayRequest, profile?: string | null): Promise<void> {
const scope = profile ?? null
if (inflight && inflightProfile === scope) {
return inflight
}
const generation = ++loadGeneration
inflightProfile = scope
inflight = (async () => {
if ($agentPluginsStatus.get() !== 'ready') {
$agentPluginsStatus.set('loading')
}
try {
const result = await request<{ plugins?: AgentPluginRow[] }>(
'plugins.manage',
withProfile({ action: 'list' }, scope)
)
if (generation !== loadGeneration) {
return
}
$agentPlugins.set(result?.plugins ?? [])
$agentPluginsStatus.set('ready')
$agentPluginsError.set(null)
} catch (e) {
if (generation !== loadGeneration) {
return
}
$agentPluginsError.set(e instanceof Error ? e.message : String(e))
$agentPluginsStatus.set('error')
} finally {
if (generation === loadGeneration) {
inflight = null
inflightProfile = null
}
}
})()
return inflight
}
/** Flip a backend plugin on/off and patch the row from the RPC's refreshed
* copy. Addressed by canonical key ONLY — bare names collide across category
* dirs (image_gen/fal vs video_gen/fal), which is exactly why the backend
* moved to key-addressed toggles. Rows without a key (pre-contract-v6
* backends) render read-only instead of falling back to the collision-prone
* name protocol; the backend-contract skew toast points the user at the
* update. Returns whether the toggle stuck. */
export async function toggleAgentPlugin(
request: GatewayRequest,
key: string,
enable: boolean,
failMessage: string,
profile?: string | null
): Promise<boolean> {
$agentPluginBusy.set(key)
try {
const result = await request<{ ok?: boolean; plugin?: AgentPluginRow | null }>(
'plugins.manage',
withProfile(
{
action: 'toggle',
key,
enable
},
profile
)
)
if (!result?.ok) {
throw new Error(failMessage)
}
const refreshed = result.plugin
if (refreshed) {
$agentPlugins.set($agentPlugins.get().map(row => (row.key === key ? { ...row, ...refreshed } : row)))
} else {
await loadAgentPlugins(request, profile)
}
return true
} catch (e) {
notifyError(e, failMessage)
return false
} finally {
$agentPluginBusy.set(null)
}
}
export interface AgentPluginInstallResult {
ok: boolean
pluginName?: string
warnings?: string[]
missingEnv?: string[]
error?: string
}
export async function installAgentPlugin(
request: GatewayRequest,
opts: { identifier: string; force?: boolean; enable?: boolean }
): Promise<AgentPluginInstallResult> {
try {
const result = await request<{
ok?: boolean
plugin_name?: string
warnings?: string[]
missing_env?: string[]
error?: string
}>('plugins.manage', {
action: 'install',
identifier: opts.identifier,
force: Boolean(opts.force),
enable: opts.enable ?? true
})
if (!result?.ok) {
return { ok: false, error: result?.error || 'Install failed' }
}
return {
ok: true,
pluginName: result.plugin_name,
warnings: result.warnings,
missingEnv: result.missing_env
}
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) }
}
}
+18
View File
@@ -0,0 +1,18 @@
// One window owns each cross-window ambient cue (turn-end sound, spoken reply)
// so N open full windows don't all fire it for the same backend event. The main
// process is the race-free owner (see electron/event-dedupe.ts). Off Electron —
// or when the bridge/claim fails — every window emits, preserving the
// single-window behavior rather than going silent.
export async function ownsAmbientCue(key: string): Promise<boolean> {
const claim = window.hermesDesktop?.claimAmbientCue
if (!claim) {
return true
}
try {
return await claim(key)
} catch {
return true
}
}
@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { deferred } from '../test/deferred'
import {
$approvalModes,
approvalModeForProfile,
reconcileApprovalModeForProfile,
setApprovalModeForProfile,
syncApprovalModeForProfile
} from './approval-mode'
describe('profile-scoped approval mode cache', () => {
beforeEach(() => $approvalModes.set({}))
it('labels an unread profile Smart by default and adopts backend truth', async () => {
expect(approvalModeForProfile('default')).toBe('smart')
const request = vi.fn(async () => ({ value: 'manual' }))
await syncApprovalModeForProfile(request, 'default')
expect(request).toHaveBeenCalledWith('config.get', { key: 'approvals.mode' })
expect(approvalModeForProfile('default')).toBe('manual')
})
it('keeps profile values isolated', async () => {
await syncApprovalModeForProfile(
vi.fn(async () => ({ value: 'manual' })),
'work'
)
await syncApprovalModeForProfile(
vi.fn(async () => ({ value: 'off' })),
'personal'
)
expect(approvalModeForProfile('work')).toBe('manual')
expect(approvalModeForProfile('personal')).toBe('off')
expect(approvalModeForProfile('default')).toBe('smart')
})
it('rolls consecutive failed writes back to the last authoritative value', async () => {
await syncApprovalModeForProfile(
vi.fn(async () => ({ value: 'smart' })),
'default'
)
const first = deferred<{ value: string }>()
const second = deferred<{ value: string }>()
const request = vi
.fn()
.mockImplementationOnce(() => first.promise)
.mockImplementationOnce(() => second.promise)
const staleWrite = setApprovalModeForProfile(request, 'default', 'manual')
const currentWrite = setApprovalModeForProfile(request, 'default', 'off')
expect(approvalModeForProfile('default')).toBe('off')
first.reject(new Error('old failure'))
await expect(staleWrite).rejects.toThrow('old failure')
expect(approvalModeForProfile('default')).toBe('off')
second.reject(new Error('current failure'))
await expect(currentWrite).rejects.toThrow('current failure')
expect(approvalModeForProfile('default')).toBe('smart')
})
it('lets a backend event supersede an optimistic write and its later failure', async () => {
const write = deferred<{ value: string }>()
const pending = setApprovalModeForProfile(
vi.fn(() => write.promise),
'work',
'off'
)
reconcileApprovalModeForProfile('work', 'smart')
expect(approvalModeForProfile('work')).toBe('smart')
write.reject(new Error('late failure'))
await expect(pending).rejects.toThrow('late failure')
expect(approvalModeForProfile('work')).toBe('smart')
})
it('ignores a stale initial read after a newer write succeeds', async () => {
const read = deferred<{ value: string }>()
const request = vi
.fn()
.mockImplementationOnce(() => read.promise)
.mockResolvedValueOnce({ value: 'off' })
const staleRead = syncApprovalModeForProfile(request, 'default')
await setApprovalModeForProfile(request, 'default', 'off')
read.resolve({ value: 'manual' })
await staleRead
expect(approvalModeForProfile('default')).toBe('off')
})
})
+97
View File
@@ -0,0 +1,97 @@
import { atom } from 'nanostores'
export type ApprovalMode = 'manual' | 'off' | 'smart'
export type ApprovalModeRequester = (method: string, params?: Record<string, unknown>) => Promise<unknown>
const APPROVAL_MODES = new Set<ApprovalMode>(['manual', 'smart', 'off'])
const revisions = new Map<string, number>()
const confirmedModes = new Map<string, ApprovalMode>()
export const $approvalModes = atom<Record<string, ApprovalMode>>({})
function profileKey(profile: string): string {
return profile.trim() || 'default'
}
function nextRevision(profile: string): number {
const revision = (revisions.get(profile) ?? 0) + 1
revisions.set(profile, revision)
return revision
}
function normalizeApprovalMode(value: unknown): ApprovalMode {
const normalized = String(value ?? '')
.trim()
.toLowerCase() as ApprovalMode
return APPROVAL_MODES.has(normalized) ? normalized : 'manual'
}
export function approvalModeForProfile(profile: string): ApprovalMode {
return $approvalModes.get()[profileKey(profile)] ?? 'smart'
}
function cacheApprovalMode(profile: string, mode: ApprovalMode): void {
const key = profileKey(profile)
$approvalModes.set({ ...$approvalModes.get(), [key]: mode })
}
export function reconcileApprovalModeForProfile(profile: string, value: unknown): ApprovalMode {
const key = profileKey(profile)
const mode = normalizeApprovalMode(value)
nextRevision(key)
confirmedModes.set(key, mode)
cacheApprovalMode(key, mode)
return mode
}
export async function syncApprovalModeForProfile(
requestGateway: ApprovalModeRequester,
profile: string
): Promise<ApprovalMode> {
const key = profileKey(profile)
const revision = nextRevision(key)
const result = (await requestGateway('config.get', { key: 'approvals.mode' })) as { value?: string }
const mode = normalizeApprovalMode(result?.value)
if (revisions.get(key) === revision) {
confirmedModes.set(key, mode)
cacheApprovalMode(key, mode)
}
return mode
}
export async function setApprovalModeForProfile(
requestGateway: ApprovalModeRequester,
profile: string,
mode: ApprovalMode
): Promise<ApprovalMode> {
const key = profileKey(profile)
const revision = nextRevision(key)
cacheApprovalMode(key, mode)
try {
const result = (await requestGateway('config.set', {
key: 'approvals.mode',
value: mode
})) as { value?: string }
const authoritative = normalizeApprovalMode(result?.value)
if (revisions.get(key) === revision) {
confirmedModes.set(key, authoritative)
cacheApprovalMode(key, authoritative)
}
return authoritative
} catch (error) {
if (revisions.get(key) === revision) {
cacheApprovalMode(key, confirmedModes.get(key) ?? 'smart')
}
throw error
}
}
+164
View File
@@ -0,0 +1,164 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { ArtifactDetection } from '@/lib/artifact-detect'
import {
$artifactVersionSelection,
artifactsForSession,
clearArtifactRegistry,
getArtifact,
openArtifact,
selectArtifactVersion,
upsertArtifact
} from './artifacts'
import { $rightRailActiveTabId } from './layout'
import { $previewTabs, closeRightRail, closeRightRailTab } from './preview'
import { $activeSessionId, $selectedStoredSessionId } from './session'
const HTML_DETECTION: ArtifactDetection = { kind: 'html', language: 'html', title: 'Pomodoro Timer' }
describe('artifacts store', () => {
beforeEach(() => {
$activeSessionId.set('session-1')
$selectedStoredSessionId.set(null)
window.localStorage.clear()
clearArtifactRegistry()
closeRightRail()
})
afterEach(() => {
$activeSessionId.set(null)
$selectedStoredSessionId.set(null)
clearArtifactRegistry()
window.localStorage.clear()
})
it('registers a new artifact with one version', () => {
const result = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')
expect(result?.versionAdded).toBe(true)
expect(artifactsForSession('session-1')).toHaveLength(1)
expect(getArtifact(result!.artifactId)?.versions).toHaveLength(1)
})
it('dedupes identical content by hash (streaming replays are no-ops)', () => {
const first = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')
const replay = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')
expect(replay?.versionAdded).toBe(false)
expect(replay?.artifactId).toBe(first?.artifactId)
expect(getArtifact(first!.artifactId)?.versions).toHaveLength(1)
})
it('appends a version when the same artifact regenerates with new content', () => {
const first = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')
const second = upsertArtifact('session-1', HTML_DETECTION, '<html>v2</html>')
expect(second?.versionAdded).toBe(true)
expect(second?.artifactId).toBe(first?.artifactId)
const record = getArtifact(first!.artifactId)
expect(record?.versions).toHaveLength(2)
expect(record?.versions.at(-1)?.content).toBe('<html>v2</html>')
expect(artifactsForSession('session-1')).toHaveLength(1)
})
it('keeps different titles as separate artifacts', () => {
upsertArtifact('session-1', HTML_DETECTION, '<html>timer</html>')
upsertArtifact('session-1', { ...HTML_DETECTION, title: 'Budget Dashboard' }, '<html>budget</html>')
expect(artifactsForSession('session-1')).toHaveLength(2)
})
it('scopes artifacts per session', () => {
upsertArtifact('session-1', HTML_DETECTION, '<html>a</html>')
upsertArtifact('session-2', HTML_DETECTION, '<html>b</html>')
expect(artifactsForSession('session-1')).toHaveLength(1)
expect(artifactsForSession('session-2')).toHaveLength(1)
})
it('rejects empty sessions and empty content', () => {
expect(upsertArtifact('', HTML_DETECTION, '<html>x</html>')).toBeNull()
expect(upsertArtifact('session-1', HTML_DETECTION, ' ')).toBeNull()
})
it('opens an artifact as a real rail tab that references the registry', () => {
const result = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')!
openArtifact(result.artifactId)
const tab = $previewTabs.get()[0]!
expect(tab.target).toMatchObject({ kind: 'artifact', label: 'Pomodoro Timer', url: result.artifactId })
expect($rightRailActiveTabId.get()).toBe(tab.id)
closeRightRailTab(tab.id)
expect($previewTabs.get()).toEqual([])
expect($rightRailActiveTabId.get()).toBeNull()
})
it('does not duplicate a tab when the same artifact opens twice', () => {
const result = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')!
openArtifact(result.artifactId)
openArtifact(result.artifactId)
expect($previewTabs.get()).toHaveLength(1)
})
it('keeps artifact tabs out of the persisted tab list', () => {
const result = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')!
openArtifact(result.artifactId)
expect(window.localStorage.getItem('hermes.desktop.previewTabs.v2')).toBe('[]')
})
it('tracks version selection and snaps back to latest', () => {
const result = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')!
upsertArtifact('session-1', HTML_DETECTION, '<html>v2</html>')
upsertArtifact('session-1', HTML_DETECTION, '<html>v3</html>')
selectArtifactVersion(result.artifactId, 0)
expect($artifactVersionSelection.get()[result.artifactId]).toBe(0)
// Selecting the newest version clears the pin (absent = newest).
selectArtifactVersion(result.artifactId, 2)
expect(result.artifactId in $artifactVersionSelection.get()).toBe(false)
// Out-of-range clamps.
selectArtifactVersion(result.artifactId, -5)
expect($artifactVersionSelection.get()[result.artifactId]).toBe(0)
})
it('opens at the newest version by default and at a pinned one on request', () => {
const result = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')!
upsertArtifact('session-1', HTML_DETECTION, '<html>v2</html>')
openArtifact(result.artifactId, 0)
expect($artifactVersionSelection.get()[result.artifactId]).toBe(0)
openArtifact(result.artifactId)
expect(result.artifactId in $artifactVersionSelection.get()).toBe(false)
})
it('clearing the registry closes the tabs pointing into it', () => {
const result = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')!
openArtifact(result.artifactId)
clearArtifactRegistry()
expect($previewTabs.get()).toEqual([])
expect(artifactsForSession('session-1')).toEqual([])
})
})
+227
View File
@@ -0,0 +1,227 @@
import { atom } from 'nanostores'
import { artifactContentHash, type ArtifactDetection, type ArtifactKind, artifactSlug } from '@/lib/artifact-detect'
import { closeArtifactPreviewTabs, openPreview, type PreviewTarget } from './preview'
/**
* ARTIFACT REGISTRY — substantial generated content (HTML pages, large SVGs,
* long code) produced in the transcript, promoted out of the message flow into
* versioned content the right rail can preview. The registry is authoritative
* for artifact content; a rail tab only ever holds a reference to it, so a new
* version shows up in an already-open tab.
*
* Identity: one artifact = one (session, slug) pair, where the slug derives
* from kind + language + title. When the model regenerates "the dashboard"
* three times in a session, that is ONE artifact with three versions, exactly
* like a document the user keeps refining — not three cards.
*
* Memory-only: the transcript is the durable copy. Cards re-register as they
* render, so a reload rebuilds the registry (and its version history) for free
* instead of parking megabytes of generated HTML in localStorage.
*/
export interface ArtifactVersion {
content: string
createdAt: number
hash: string
}
export interface ArtifactRecord {
createdAt: number
id: string
kind: ArtifactKind
language: string
sessionId: string
slug: string
title: string
updatedAt: number
/** Oldest → newest. The last entry is the current version. */
versions: ArtifactVersion[]
}
export type ArtifactRegistry = Record<string, ArtifactRecord[]>
const MAX_ARTIFACTS_PER_SESSION = 24
const MAX_VERSIONS_PER_ARTIFACT = 20
const MAX_SESSIONS = 40
function pruneRegistry(registry: ArtifactRegistry): ArtifactRegistry {
const entries = Object.entries(registry)
.map(([sessionId, records]) => {
const trimmed = [...records]
.sort((a, b) => b.updatedAt - a.updatedAt)
.slice(0, MAX_ARTIFACTS_PER_SESSION)
.sort((a, b) => a.createdAt - b.createdAt)
return [sessionId, trimmed] as const
})
.filter(([, records]) => records.length > 0)
.sort(([, a], [, b]) => {
const latest = (records: readonly ArtifactRecord[]) => Math.max(...records.map(record => record.updatedAt))
return latest(b) - latest(a)
})
.slice(0, MAX_SESSIONS)
return Object.fromEntries(entries)
}
export const $artifactRegistry = atom<ArtifactRegistry>({})
/** Per-artifact selected version index; absent = newest. */
export const $artifactVersionSelection = atom<Record<string, number>>({})
/** Lookup against a registry value, for components that already subscribe to
* the atom and need the record to change identity when it does. */
export function findArtifact(registry: ArtifactRegistry, artifactId: string): ArtifactRecord | null {
for (const records of Object.values(registry)) {
const found = records.find(record => record.id === artifactId)
if (found) {
return found
}
}
return null
}
export function getArtifact(artifactId: string): ArtifactRecord | null {
return findArtifact($artifactRegistry.get(), artifactId)
}
export function artifactsForSession(sessionId: string | null | undefined): ArtifactRecord[] {
const id = sessionId?.trim()
if (!id) {
return []
}
return $artifactRegistry.get()[id] ?? []
}
interface UpsertResult {
artifactId: string
record: ArtifactRecord
/** True when this call appended a NEW version (vs. deduped/no-op). */
versionAdded: boolean
}
/**
* Register (or version) an artifact for a session. Same slug + same content
* hash is a no-op (streaming remounts and transcript re-renders call this
* repeatedly); same slug + new content appends a version.
*/
export function upsertArtifact(
sessionId: string | null | undefined,
detection: ArtifactDetection,
content: string
): UpsertResult | null {
const id = sessionId?.trim()
const trimmed = content.trim()
if (!id || !trimmed) {
return null
}
const slug = artifactSlug(detection)
const hash = artifactContentHash(trimmed)
const registry = $artifactRegistry.get()
const records = registry[id] ?? []
const existing = records.find(record => record.slug === slug)
const now = Date.now()
if (existing) {
const known = existing.versions.some(version => version.hash === hash)
if (known) {
return { artifactId: existing.id, record: existing, versionAdded: false }
}
const versions = [...existing.versions, { content: trimmed, createdAt: now, hash }].slice(
-MAX_VERSIONS_PER_ARTIFACT
)
const next: ArtifactRecord = {
...existing,
// A regenerated artifact may carry a sharper title (html <title> arrives
// late in the stream); prefer the newest non-generic one.
title: detection.title || existing.title,
updatedAt: now,
versions
}
$artifactRegistry.set(
pruneRegistry({
...registry,
[id]: records.map(record => (record.id === existing.id ? next : record))
})
)
return { artifactId: existing.id, record: next, versionAdded: true }
}
const record: ArtifactRecord = {
createdAt: now,
id: `${id}:${slug}`,
kind: detection.kind,
language: detection.language,
sessionId: id,
slug,
title: detection.title,
updatedAt: now,
versions: [{ content: trimmed, createdAt: now, hash }]
}
$artifactRegistry.set(pruneRegistry({ ...registry, [id]: [...records, record] }))
return { artifactId: record.id, record, versionAdded: true }
}
/** A rail tab for an artifact references the registry by id rather than
* carrying content, so an open tab follows the artifact as it gains versions. */
export function artifactPreviewTarget(record: ArtifactRecord): PreviewTarget {
return { kind: 'artifact', label: record.title, source: record.id, url: record.id }
}
/** Open an artifact in the right rail at `versionIndex` (default: newest).
* User-initiated only (card click) — never called from streaming, per the
* no-hijack rule. */
export function openArtifact(artifactId: string, versionIndex?: number) {
const record = getArtifact(artifactId)
if (!record) {
return
}
selectArtifactVersion(artifactId, versionIndex ?? record.versions.length - 1)
openPreview(artifactPreviewTarget(record))
}
export function selectArtifactVersion(artifactId: string, versionIndex: number) {
const record = getArtifact(artifactId)
if (!record) {
return
}
const clamped = Math.max(0, Math.min(record.versions.length - 1, versionIndex))
const selection = $artifactVersionSelection.get()
if (clamped === record.versions.length - 1) {
if (artifactId in selection) {
const { [artifactId]: _dropped, ...rest } = selection
$artifactVersionSelection.set(rest)
}
return
}
$artifactVersionSelection.set({ ...selection, [artifactId]: clamped })
}
export function clearArtifactRegistry() {
$artifactRegistry.set({})
$artifactVersionSelection.set({})
closeArtifactPreviewTabs()
}
+14
View File
@@ -0,0 +1,14 @@
import { atom } from 'nanostores'
import { persistBoolean, storedBoolean } from '@/lib/storage'
const KEY = 'hermes.desktop.backdrop.v1'
/** Whether the faint statue image renders behind the chat transcript. */
export const $backdrop = atom(storedBoolean(KEY, false))
$backdrop.subscribe(on => persistBoolean(KEY, on))
export function setBackdrop(on: boolean) {
$backdrop.set(on)
}
@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { $backgroundResume } from './background-delegation'
import { $activeSessionId, $busy } from './session'
import { $subagentsBySession, type SubagentProgress, type SubagentStreamEntry } from './subagents'
const sub = (over: Partial<SubagentProgress> = {}): SubagentProgress => ({
id: over.id ?? 'deleg:1',
parentId: null,
goal: 'do the thing',
status: 'running',
taskCount: 1,
taskIndex: 0,
startedAt: 0,
updatedAt: 0,
filesRead: [],
filesWritten: [],
stream: [],
...over
})
const stream = (text: string): SubagentStreamEntry => ({ at: 0, kind: 'progress', text })
describe('$backgroundResume', () => {
beforeEach(() => {
$busy.set(false)
$activeSessionId.set('s1')
$subagentsBySession.set({})
})
it('counts running/queued children for the active session while idle', () => {
$subagentsBySession.set({ s1: [sub({ id: 'a' }), sub({ id: 'b', status: 'queued' })] })
expect($backgroundResume.get()?.count).toBe(2)
})
it('surfaces the primary child latest stream line as live activity', () => {
$subagentsBySession.set({ s1: [sub({ id: 'a', stream: [stream('Searching the web…')] })] })
expect($backgroundResume.get()?.activity).toBe('Searching the web…')
})
it('activity is null when no stream line has arrived (UI uses generic copy)', () => {
$subagentsBySession.set({ s1: [sub({ id: 'a' })] })
expect($backgroundResume.get()?.activity).toBeNull()
})
it('is null while a turn is busy (the turn owns the main loader)', () => {
$subagentsBySession.set({ s1: [sub({ id: 'a' })] })
$busy.set(true)
expect($backgroundResume.get()).toBeNull()
})
it('is null when only terminal children or other sessions have work', () => {
$subagentsBySession.set({
s1: [sub({ id: 'a', status: 'completed' }), sub({ id: 'b', status: 'failed' })],
s2: [sub({ id: 'c' })]
})
expect($backgroundResume.get()).toBeNull()
})
it('is null when there is no active session', () => {
$subagentsBySession.set({ s1: [sub({ id: 'a' })] })
$activeSessionId.set(null)
expect($backgroundResume.get()).toBeNull()
})
})
@@ -0,0 +1,48 @@
import { computed } from 'nanostores'
import { $activeSessionId, $busy } from './session'
import { $subagentsBySession, type SubagentProgress } from './subagents'
export interface BackgroundResume {
/** Latest live activity from the primary child (its newest stream line), or
* null when nothing readable has arrived yet — the UI then falls back to the
* generic "will resume" copy. */
activity: string | null
/** Running/queued background children for the active session. */
count: number
}
const RUNNING = (s: SubagentProgress) => s.status === 'running' || s.status === 'queued'
/**
* "Parked" background-delegation signal for the active session.
*
* A top-level `delegate_task` always runs in the background: the parent turn
* ends (`$busy` -> false) while the subagent keeps running, and its result
* re-enters the conversation as a fresh turn when it finishes. During that
* window the app is genuinely idle but work is still happening elsewhere, so we
* surface a calm, shimmering status line (its latest activity, or a generic
* "will resume" fallback) instead of a spinner that reads as "stuck."
*
* Null while `$busy`: an active turn already owns the main loader, and subagents
* spawned inside a running turn (synchronous orchestrator children) are part of
* that turn, not parked background work the user is waiting on.
*/
export const $backgroundResume = computed(
[$subagentsBySession, $activeSessionId, $busy],
(bySession, sid, busy): BackgroundResume | null => {
if (busy || !sid) {
return null
}
const running = (bySession[sid] ?? []).filter(RUNNING)
if (running.length === 0) {
return null
}
const activity = (running[0]!.stream.at(-1)?.text ?? '').trim() || null
return { activity, count: running.length }
}
)
@@ -0,0 +1,86 @@
import type { BillingBlock } from '@hermes/shared'
import { beforeEach, expect, test, vi } from 'vitest'
vi.mock('@/lib/external-link', () => ({ openExternalLink: vi.fn() }))
import { openExternalLink } from '@/lib/external-link'
import {
$billingBlock,
$billingSettingsRequest,
billingCtaLabel,
clearBillingBlock,
requestBillingSettings,
runBillingRecovery,
setBillingBlock
} from './billing-block'
function makeBlock(overrides: Partial<BillingBlock> = {}): BillingBlock {
return {
billing_url: 'https://platform.openai.com/settings/organization/billing',
is_nous: false,
message: 'You are out of credits.',
model: 'gpt-5',
provider: 'openai',
provider_label: 'OpenAI',
...overrides
}
}
beforeEach(() => {
$billingBlock.set(null)
$billingSettingsRequest.set(0)
vi.clearAllMocks()
})
test('setBillingBlock stores the block against its session', () => {
setBillingBlock('s1', makeBlock())
expect($billingBlock.get()?.sessionId).toBe('s1')
expect($billingBlock.get()?.block.provider).toBe('openai')
})
test('clearBillingBlock scoped to a session leaves a different session block intact', () => {
setBillingBlock('s1', makeBlock())
clearBillingBlock('s2')
expect($billingBlock.get()).not.toBeNull()
clearBillingBlock('s1')
expect($billingBlock.get()).toBeNull()
})
test('clearBillingBlock with no arg clears any active block', () => {
setBillingBlock('s1', makeBlock())
clearBillingBlock()
expect($billingBlock.get()).toBeNull()
})
test('runBillingRecovery routes Nous to in-app Settings, never an external link', () => {
runBillingRecovery(makeBlock({ is_nous: true, provider: 'nous', provider_label: 'Nous Portal' }))
expect($billingSettingsRequest.get()).toBe(1)
expect(openExternalLink).not.toHaveBeenCalled()
})
test('runBillingRecovery deep-links a third-party provider to its billing page', () => {
const block = makeBlock({ billing_url: 'https://openrouter.ai/settings/credits', provider: 'openrouter' })
runBillingRecovery(block)
expect(openExternalLink).toHaveBeenCalledWith('https://openrouter.ai/settings/credits')
expect($billingSettingsRequest.get()).toBe(0)
})
test('runBillingRecovery falls back to in-app settings when a provider has no URL', () => {
runBillingRecovery(makeBlock({ billing_url: null, provider: 'custom' }))
expect(openExternalLink).not.toHaveBeenCalled()
expect($billingSettingsRequest.get()).toBe(1)
})
test('requestBillingSettings increments the intent counter', () => {
requestBillingSettings()
requestBillingSettings()
expect($billingSettingsRequest.get()).toBe(2)
})
test('billingCtaLabel picks the right verb per route', () => {
const copy = { addCredits: 'Add credits', openBilling: 'Open billing' }
expect(billingCtaLabel(makeBlock({ is_nous: true }), copy)).toBe('Open billing')
expect(billingCtaLabel(makeBlock({ is_nous: false }), copy)).toBe('Add credits')
})
+76
View File
@@ -0,0 +1,76 @@
import type { BillingBlock } from '@hermes/shared'
import { atom } from 'nanostores'
import { openExternalLink } from '@/lib/external-link'
/**
* The active inference billing wall, if any. Set from the gateway
* `message.complete` / `error` event when a turn fails with
* `FailoverReason.billing` (see `agent/billing_links.py`). One global slot: a
* credit wall on the active session's provider is the whole app's problem, and
* the newest block wins. Cleared when a new turn starts or the user dismisses.
*/
export interface ActiveBillingBlock {
block: BillingBlock
sessionId: string
at: number
}
export const $billingBlock = atom<ActiveBillingBlock | null>(null)
/**
* Navigation intent counter. A toast fired outside React (or any surface
* without router context) bumps this to ask the shell — which owns
* `useNavigate` — to open Settings → Billing in-app. See `contrib/wiring.tsx`.
*/
export const $billingSettingsRequest = atom(0)
export function setBillingBlock(sessionId: string, block: BillingBlock): void {
$billingBlock.set({ at: Date.now(), block, sessionId })
}
export function clearBillingBlock(sessionId?: string): void {
const current = $billingBlock.get()
if (!current) {
return
}
// A scoped clear (new turn on session X) must not wipe a block raised by a
// different session's provider.
if (sessionId && current.sessionId !== sessionId) {
return
}
$billingBlock.set(null)
}
export function requestBillingSettings(): void {
$billingSettingsRequest.set($billingSettingsRequest.get() + 1)
}
/**
* The single recovery action for a billing wall, shared by the toast and the
* in-chat banner so both behave identically: Nous routes to the in-app
* Settings → Billing surface; a third-party provider deep-links to its own
* billing page (falling back to the in-app surface only if we have no URL).
*/
export function runBillingRecovery(block: BillingBlock): void {
if (block.is_nous) {
requestBillingSettings()
return
}
if (block.billing_url) {
openExternalLink(block.billing_url)
return
}
requestBillingSettings()
}
export function billingCtaLabel(block: BillingBlock, copy: { addCredits: string; openBilling: string }): string {
return block.is_nous ? copy.openBilling : copy.addCredits
}
+116
View File
@@ -0,0 +1,116 @@
import { atom } from 'nanostores'
import type { DesktopBootProgress } from '@/global'
import { translateNow } from '@/i18n'
export interface DesktopBootState extends DesktopBootProgress {
visible: boolean
}
const INITIAL_BOOT_STATE: DesktopBootState = {
error: null,
fakeMode: false,
message: translateNow('boot.steps.startingHermesDesktop'),
phase: 'renderer.init',
progress: 2,
running: true,
timestamp: Date.now(),
visible: true
}
export const $desktopBoot = atom<DesktopBootState>(INITIAL_BOOT_STATE)
function clampProgress(value: number) {
if (!Number.isFinite(value)) {
return 0
}
return Math.max(0, Math.min(100, Math.round(value)))
}
export function applyDesktopBootProgress(progress: DesktopBootProgress) {
const current = $desktopBoot.get()
const nextProgress = clampProgress(progress.progress)
const mergedProgress = progress.running ? Math.max(current.progress, nextProgress) : nextProgress
// Don't let a late progress event (error: null) clobber a previously-set
// boot failure — failDesktopBoot is terminal for this boot cycle.
const error = progress.error ?? (current.running ? null : current.error)
$desktopBoot.set({
...current,
...progress,
error,
progress: mergedProgress,
visible: progress.running || mergedProgress < 100 || Boolean(error)
})
}
export function setDesktopBootStep(step: {
phase: string
message: string
progress: number
running?: boolean
fakeMode?: boolean
error?: string | null
}) {
const current = $desktopBoot.get()
applyDesktopBootProgress({
error: step.error ?? null,
fakeMode: step.fakeMode ?? current.fakeMode,
message: step.message,
phase: step.phase,
progress: step.progress,
running: step.running ?? true,
timestamp: Date.now()
})
}
/**
* Re-arm the boot overlay for an automatic bounded retry of a failed REMOTE
* boot (#82679). Unlike setDesktopBootStep — whose null `error` intentionally
* cannot clear a latched failure — this explicitly lifts the error so the
* overlay shows the retry status instead of the terminal failure surface
* while the retry is in flight. failDesktopBoot() re-latches when the
* bounded retries are exhausted.
*/
export function resumeDesktopBootForRetry(message: string) {
const current = $desktopBoot.get()
$desktopBoot.set({
...current,
error: null,
message,
phase: 'renderer.boot.retry',
running: true,
timestamp: Date.now(),
visible: true
})
}
export function completeDesktopBoot(message = translateNow('boot.ready')) {
const current = $desktopBoot.get()
$desktopBoot.set({
...current,
error: null,
message,
phase: 'renderer.ready',
progress: 100,
running: false,
timestamp: Date.now(),
visible: false
})
}
export function failDesktopBoot(message: string) {
const current = $desktopBoot.get()
$desktopBoot.set({
...current,
error: message,
message: translateNow('boot.desktopBootFailedWithMessage', message),
phase: 'renderer.error',
progress: clampProgress(current.progress),
running: false,
timestamp: Date.now(),
visible: true
})
}
@@ -0,0 +1,88 @@
/**
* `$botChatSessionIds` is the store a surface must SUBSCRIBE to when it asks
* `isBotChatSession`.
*
* The composer originally subscribed to `$sessionStates` while reading this
* set. It looked fine because `$sessionStates` churns constantly — busy flags,
* stream ticks — so the stale answer was overwritten within a frame or two.
* The assertion that matters is the one that made that wrong: this set moves on
* its own, with no `$sessionStates` write anywhere near it, so a subscriber to
* the other store can miss the transition entirely.
*/
import { beforeEach, describe, expect, it } from 'vitest'
const { $botChatSessionIds, $sessionStates, $sessionTiles, isBotChatSession, setSessionTileWorkspaceScope } =
await import('./session-states')
const botScope = { workspaceMode: 'bots' as const, workspaceOwnerKey: 'bot:alpha' }
beforeEach(() => {
$botChatSessionIds.set(new Set())
// isBotChatSession takes a LIVE session id and files the answer under the
// stored one, so a binding has to exist for the lookup to resolve at all.
$sessionTiles.set([{ runtimeId: 'runtime-1', storedSessionId: 'stored-1' } as never])
})
describe('the bot-chat set moves independently of session state', () => {
it('notifies its own listeners without touching $sessionStates', () => {
const seen: number[] = []
const statesBefore = $sessionStates.get()
const stop = $botChatSessionIds.listen(ids => seen.push(ids.size))
try {
setSessionTileWorkspaceScope('stored-1', botScope)
} finally {
stop()
}
expect(seen).toEqual([1])
// The store a stale subscriber would have been waiting on never moved.
expect($sessionStates.get()).toBe(statesBefore)
})
it('answers for the live id behind a marked stored one, and stops when cleared', () => {
setSessionTileWorkspaceScope('stored-1', botScope)
expect(isBotChatSession('runtime-1')).toBe(true)
expect(isBotChatSession('stored-1')).toBe(true)
setSessionTileWorkspaceScope('stored-1', { workspaceMode: 'sessions', workspaceOwnerKey: '' })
expect(isBotChatSession('runtime-1')).toBe(false)
})
it('is silent for an id it never marked, and for no id at all', () => {
expect(isBotChatSession('never-seen')).toBe(false)
expect(isBotChatSession(null)).toBe(false)
expect(isBotChatSession(undefined)).toBe(false)
})
it('resolves a marked chat only once the binding that names it exists', () => {
// The reason a subscriber cannot watch the scope set alone: with the set
// already marked, the answer still flips when the BINDING arrives.
$sessionTiles.set([])
setSessionTileWorkspaceScope('stored-1', botScope)
expect(isBotChatSession('runtime-1')).toBe(false)
$sessionTiles.set([{ runtimeId: 'runtime-1', storedSessionId: 'stored-1' } as never])
expect(isBotChatSession('runtime-1')).toBe(true)
})
it('does not re-notify when the same scope is recorded twice', () => {
setSessionTileWorkspaceScope('stored-1', botScope)
const seen: number[] = []
const stop = $botChatSessionIds.listen(ids => seen.push(ids.size))
try {
setSessionTileWorkspaceScope('stored-1', botScope)
} finally {
stop()
}
expect(seen).toEqual([])
})
})
+215
View File
@@ -0,0 +1,215 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
$clarifyRequest,
$clarifyRequests,
type ClarifyRequest,
clearClarifyRequest,
hasClarifyRequest,
normalizeChoices,
normalizeQuestions,
setClarifyRequest,
skipClarifyRequest
} from './clarify'
import { $gateway } from './gateway'
import { $activeSessionId } from './session'
function clarify(sessionId: string | null, requestId: string): ClarifyRequest {
return {
requestId,
question: `question-${requestId}`,
choices: null,
multiSelect: false,
sessionId
}
}
describe('clarify store', () => {
beforeEach(() => {
$clarifyRequests.set({})
$activeSessionId.set(null)
})
afterEach(() => {
$clarifyRequests.set({})
$activeSessionId.set(null)
})
it('keeps clarify requests from concurrent sessions independent', () => {
setClarifyRequest(clarify('session-a', 'req-a'))
setClarifyRequest(clarify('session-b', 'req-b'))
expect($clarifyRequests.get()['session-a']?.requestId).toBe('req-a')
expect($clarifyRequests.get()['session-b']?.requestId).toBe('req-b')
})
it('exposes only the active session via the focus-scoped view', () => {
setClarifyRequest(clarify('session-a', 'req-a'))
setClarifyRequest(clarify('session-b', 'req-b'))
$activeSessionId.set('session-a')
expect($clarifyRequest.get()?.requestId).toBe('req-a')
$activeSessionId.set('session-b')
expect($clarifyRequest.get()?.requestId).toBe('req-b')
$activeSessionId.set('session-c')
expect($clarifyRequest.get()).toBeNull()
})
it('clears only the targeted session, leaving the other pending', () => {
setClarifyRequest(clarify('session-a', 'req-a'))
setClarifyRequest(clarify('session-b', 'req-b'))
clearClarifyRequest('req-a', 'session-a')
expect($clarifyRequests.get()['session-a']).toBeUndefined()
expect($clarifyRequests.get()['session-b']?.requestId).toBe('req-b')
})
it('ignores a stale clear whose request id no longer matches', () => {
setClarifyRequest(clarify('session-a', 'req-a2'))
clearClarifyRequest('req-a1', 'session-a')
expect($clarifyRequests.get()['session-a']?.requestId).toBe('req-a2')
})
it('clears by request id across sessions when no session hint is given', () => {
setClarifyRequest(clarify('session-a', 'shared'))
setClarifyRequest(clarify('session-b', 'other'))
clearClarifyRequest('shared')
expect($clarifyRequests.get()['session-a']).toBeUndefined()
expect($clarifyRequests.get()['session-b']?.requestId).toBe('other')
})
})
describe('skipClarifyRequest', () => {
const request = vi.fn(async () => ({ ok: true }))
beforeEach(() => {
$clarifyRequests.set({})
request.mockClear()
$gateway.set({ request } as unknown as ReturnType<typeof $gateway.get>)
})
afterEach(() => {
$clarifyRequests.set({})
$gateway.set(null)
})
it('answers the session\u2019s clarify with an empty answer and drops it', async () => {
setClarifyRequest(clarify('session-a', 'req-a'))
setClarifyRequest(clarify('session-b', 'req-b'))
await expect(skipClarifyRequest('session-a')).resolves.toBe(true)
expect(request).toHaveBeenCalledWith('clarify.respond', { request_id: 'req-a', answer: '' })
expect(hasClarifyRequest('session-a')).toBe(false)
// A background session's question is untouched — only the one being typed
// over is skipped.
expect(hasClarifyRequest('session-b')).toBe(true)
})
it('is a no-op when the session has no clarify parked', async () => {
await expect(skipClarifyRequest('session-a')).resolves.toBe(false)
expect(request).not.toHaveBeenCalled()
})
it('still reports the skip when the respond RPC fails', async () => {
setClarifyRequest(clarify('session-a', 'req-a'))
request.mockRejectedValueOnce(new Error('socket closed'))
await expect(skipClarifyRequest('session-a')).resolves.toBe(true)
expect(hasClarifyRequest('session-a')).toBe(false)
})
})
describe('normalizeChoices', () => {
it('returns empty array for null/undefined', () => {
expect(normalizeChoices(null)).toEqual([])
expect(normalizeChoices(undefined)).toEqual([])
})
it('returns empty array for non-array input', () => {
expect(normalizeChoices('hello')).toEqual([])
expect(normalizeChoices(42)).toEqual([])
expect(normalizeChoices({})).toEqual([])
})
it('filters out non-string items', () => {
expect(normalizeChoices(['a', 42, 'b', null, 'c'])).toEqual(['a', 'b', 'c'])
})
it('drops blank and whitespace-only strings', () => {
expect(normalizeChoices(['a', '', 'b', ' ', 'c'])).toEqual(['a', 'b', 'c'])
})
it('drops strings with newlines', () => {
expect(normalizeChoices(['a', 'b\nc', 'd'])).toEqual(['a', 'd'])
})
it('drops strings over 200 chars', () => {
const long = 'x'.repeat(201)
const ok = 'y'.repeat(200)
expect(normalizeChoices(['a', long, ok])).toEqual(['a', ok])
})
it('drops empty items and keeps valid ones', () => {
expect(normalizeChoices(['valid', ' ', '', 'also valid'])).toEqual(['valid', 'also valid'])
})
it('returns empty array when nothing survives', () => {
expect(normalizeChoices(['', ' ', null, undefined])).toEqual([])
expect(normalizeChoices([])).toEqual([])
})
})
describe('normalizeQuestions', () => {
it('returns empty array for non-array input', () => {
expect(normalizeQuestions(null)).toEqual([])
expect(normalizeQuestions('x')).toEqual([])
expect(normalizeQuestions({})).toEqual([])
})
it('normalizes a valid batch and keys by qid', () => {
const result = normalizeQuestions([
{ choices: ['a', 'b'], qid: 'q0', question: 'One?' },
{ qid: 'q1', question: 'Two?' }
])
expect(result).toEqual([
{ choices: ['a', 'b'], multiSelect: false, qid: 'q0', question: 'One?' },
{ choices: null, multiSelect: false, qid: 'q1', question: 'Two?' }
])
})
it('drops entries missing qid or question text', () => {
const result = normalizeQuestions([
{ qid: '', question: 'no qid' },
{ qid: 'q1', question: ' ' },
'not-an-object',
{ qid: 'q2', question: 'kept' }
])
expect(result.map(q => q.qid)).toEqual(['q2'])
})
it('degrades all-blank choices to open-ended per question', () => {
const result = normalizeQuestions([{ choices: ['', ' '], qid: 'q0', question: 'Q?' }])
expect(result[0]?.choices).toBeNull()
})
it('only honors multi_select when choices survive', () => {
const result = normalizeQuestions([
{ choices: ['a', 'b'], multi_select: true, qid: 'q0', question: 'A?' },
{ multi_select: true, qid: 'q1', question: 'B?' }
])
expect(result[0]?.multiSelect).toBe(true)
expect(result[1]?.multiSelect).toBe(false)
})
})
+212
View File
@@ -0,0 +1,212 @@
import { atom, computed } from 'nanostores'
import { $gateway } from './gateway'
import { $activeSessionId } from './session'
export interface ClarifyQuestion {
/** Server-generated wire id (q0..qN) — clarify.respond keys answers by it. */
qid: string
question: string
choices: string[] | null
multiSelect: boolean
}
export interface ClarifyRequest {
requestId: string
question: string
choices: string[] | null
multiSelect: boolean
/** Local receipt time (Unix seconds), used to reject stale resume cleanup. */
receivedAt?: number
sessionId: string | null
/** Batch (multi-question) clarify: present instead of question/choices. */
questions?: ClarifyQuestion[]
/** Answers already locked server-side (reconnect replay): qid → answer. */
lockedAnswers?: Record<string, string>
}
/**
* The backend labels the agent's recommended option by appending this to the
* first choice (`tools/clarify_tool.py::mark_recommended`). The renderer never
* writes it — it only styles it, and discounts it when measuring a choice so a
* long option isn't dropped for length the label added.
*/
export const RECOMMENDED_LABEL = '(Recommended)'
export const bareChoice = (choice: string): string =>
choice.endsWith(RECOMMENDED_LABEL) ? choice.slice(0, -RECOMMENDED_LABEL.length).trim() : choice
/**
* Validate and normalize a choices array.
*
* Keeps non-blank, newline-free strings of length ≤ 200; drops everything else
* and returns an empty array when nothing usable survives — the caller then
* falls back to a free-text answer instead of dead buttons.
*/
export function normalizeChoices(choices: unknown): string[] {
if (!Array.isArray(choices)) {
return []
}
return choices.filter(
(c): c is string => typeof c === 'string' && c.trim().length > 0 && bareChoice(c).length <= 200 && !c.includes('\n')
)
}
/**
* Structured warning for a clarify payload that arrived with choices but had
* them all normalized away — keeps the remaining #69122 "no selectable choices"
* triggers diagnosable in the field without dead constant fields.
*/
export function warnDroppedChoices(source: 'gateway' | 'tool_args', question: string, rawChoices: unknown): void {
console.warn('[clarify] choices dropped after normalization', {
choices_count: Array.isArray(rawChoices) ? rawChoices.length : 0,
question_length: question.length,
source
})
}
/**
* Validate and normalize a batch clarify payload's `questions` array.
*
* Keeps entries with a non-blank string `qid` and `question`; per-question
* choices go through `normalizeChoices` (all-blank → open-ended) and
* multi_select is only honored alongside surviving choices. Returns an empty
* array when nothing usable remains — the caller treats that as "not a
* batch" instead of rendering an unanswerable form.
*/
export function normalizeQuestions(questions: unknown): ClarifyQuestion[] {
if (!Array.isArray(questions)) {
return []
}
const normalized: ClarifyQuestion[] = []
for (const entry of questions) {
if (typeof entry !== 'object' || entry === null) {
continue
}
const row = entry as Record<string, unknown>
const qid = typeof row.qid === 'string' ? row.qid.trim() : ''
const question = typeof row.question === 'string' ? row.question.trim() : ''
if (!qid || !question) {
continue
}
const choices = normalizeChoices(row.choices)
normalized.push({
choices: choices.length > 0 ? choices : null,
multiSelect: row.multi_select === true && choices.length > 0,
qid,
question
})
}
return normalized
}
// Pending clarify requests keyed by the runtime session id that raised them.
// Storing per-session (instead of one shared slot) lets a *background* session
// park its clarify request while the user is looking at a different chat, then
// resolve it once they switch over — without a second concurrent clarify
// clobbering the first. A request with no session id lands under the empty key.
const keyFor = (sessionId: string | null | undefined): string => sessionId ?? ''
export const $clarifyRequests = atom<Record<string, ClarifyRequest>>({})
// The clarify request for the currently-viewed session. The inline ClarifyTool
// only ever mounts inside the active session's transcript, so it reads this
// focus-scoped view rather than reaching into the whole map.
export const $clarifyRequest = computed(
[$clarifyRequests, $activeSessionId],
(requests, activeId) => requests[keyFor(activeId)] ?? null
)
/** The clarify request for one specific session — the tile counterpart of the
* active-session `$clarifyRequest` view (same map, fixed key). */
export const sessionClarifyRequest = (sessionId: string | null) =>
computed($clarifyRequests, requests => requests[keyFor(sessionId)] ?? null)
export function setClarifyRequest(request: ClarifyRequest): void {
$clarifyRequests.set({ ...$clarifyRequests.get(), [keyFor(request.sessionId)]: request })
}
export function clearClarifyRequest(requestId?: string, sessionId?: string | null): void {
const requests = $clarifyRequests.get()
// Targeted clear when the caller knows the session (the common path from the
// inline ClarifyTool answering its own request).
if (sessionId !== undefined) {
const key = keyFor(sessionId)
const current = requests[key]
if (!current || (requestId && current.requestId !== requestId)) {
return
}
const next = { ...requests }
delete next[key]
$clarifyRequests.set(next)
return
}
// Fallback with no session hint: drop every entry matching the request id
// (or clear all when none is given).
const next: Record<string, ClarifyRequest> = {}
let changed = false
for (const [key, value] of Object.entries(requests)) {
if (requestId && value.requestId !== requestId) {
next[key] = value
} else {
changed = true
}
}
if (changed) {
$clarifyRequests.set(next)
}
}
/** Whether `sessionId` has a clarify parked on it right now (imperative read —
* the composer checks this on Enter, not on every render). */
export const hasClarifyRequest = (sessionId: string | null | undefined): boolean =>
Boolean($clarifyRequests.get()[keyFor(sessionId)])
/**
* Answer `sessionId`'s pending clarify with an empty answer (a skip) and drop it
* locally, resolving to whether there was one to skip.
*
* The composer uses this when the user types a real message instead of picking
* an option: a clarify blocks the agent inside its tool batch, so leaving it
* unanswered would park the follow-up until the server-side clarify timeout
* (default 5 min) — the message looks sent and nothing happens. Skipping lets
* the tool return and the turn carry on with the user's actual words.
*
* An empty answer is the same thing the card's own Skip button sends, and
* `clarify.respond` is `allow_expired`, so racing the timeout is harmless.
*/
export async function skipClarifyRequest(sessionId: string | null | undefined): Promise<boolean> {
const request = $clarifyRequests.get()[keyFor(sessionId)]
if (!request) {
return false
}
// Clear first: the answer is already decided, and an in-flight RPC must not
// leave a live card the user can answer a second time.
clearClarifyRequest(request.requestId, request.sessionId)
try {
await $gateway.get()?.request('clarify.respond', { request_id: request.requestId, answer: '' })
} catch {
// The tool times out on its own; a failed skip must never swallow the
// message the user is actually sending.
}
return true
}
@@ -0,0 +1,287 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { HermesRepoStatus } from '@/global'
import {
$repoStatus,
$repoStatusByCwd,
$repoStatusLoading,
_resetCodingStatusForTests,
refreshAllRepoStatuses,
refreshRepoStatus,
registerRepoStatusCwd,
repoChangeKindForPath,
repoStatusForCwd
} from './coding-status'
import { $currentCwd, $selectedStoredSessionId } from './session'
const sampleStatus: HermesRepoStatus = {
branch: 'feature/login',
defaultBranch: 'main',
detached: false,
ahead: 1,
behind: 0,
staged: 1,
unstaged: 2,
untracked: 0,
conflicted: 0,
changed: 3,
added: 12,
removed: 4,
files: []
}
const otherStatus: HermesRepoStatus = {
...sampleStatus,
branch: 'bb/other-worktree',
added: 3,
removed: 1,
changed: 1,
staged: 0,
unstaged: 1
}
function stubProbe(impl: (cwd: string) => Promise<HermesRepoStatus | null>) {
;(window as unknown as { hermesDesktop?: unknown }).hermesDesktop = { git: { repoStatus: impl } }
}
describe('refreshRepoStatus', () => {
beforeEach(() => {
vi.useFakeTimers()
_resetCodingStatusForTests()
$currentCwd.set('')
$selectedStoredSessionId.set(null)
// Drain the cwd/session subscribe side-effects the sets above kick off.
vi.advanceTimersByTime(200)
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
_resetCodingStatusForTests()
})
afterEach(() => {
_resetCodingStatusForTests()
vi.clearAllTimers()
vi.useRealTimers()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
it('populates the per-cwd cache and the primary computed for that cwd', async () => {
stubProbe(async () => sampleStatus)
$currentCwd.set('/repo')
await refreshRepoStatus('/repo')
expect(repoStatusForCwd('/repo').get()).toEqual(sampleStatus)
expect($repoStatus.get()).toEqual(sampleStatus)
})
it('falls back to the active session cwd when none is passed', async () => {
const probe = vi.fn(async () => sampleStatus)
stubProbe(probe)
$currentCwd.set('/active/repo')
await refreshRepoStatus()
expect(probe).toHaveBeenCalledWith('/active/repo')
expect($repoStatus.get()).toEqual(sampleStatus)
})
it('leaves the cache alone (primary reads null) when there is no cwd', async () => {
stubProbe(async () => sampleStatus)
$currentCwd.set('/repo')
await refreshRepoStatus('/repo')
expect($repoStatus.get()).toEqual(sampleStatus)
// Blank target is a no-op: other worktrees' cached truth must stay put.
await refreshRepoStatus(' ')
expect(repoStatusForCwd('/repo').get()).toEqual(sampleStatus)
$currentCwd.set('')
expect($repoStatus.get()).toBeNull()
})
it('clears every cached status when the probe is unavailable (remote backend)', async () => {
$currentCwd.set('/repo')
$repoStatusByCwd.set({ '/repo': sampleStatus, '/other': otherStatus })
await refreshRepoStatus('/repo')
expect($repoStatusByCwd.get()).toEqual({})
expect($repoStatus.get()).toBeNull()
})
it('clears only the failing cwd when the probe throws', async () => {
stubProbe(async cwd => {
if (cwd === '/bad') {
throw new Error('not a repo')
}
return sampleStatus
})
$currentCwd.set('/bad')
$repoStatusByCwd.set({ '/bad': otherStatus, '/good': sampleStatus })
await refreshRepoStatus('/bad')
expect(repoStatusForCwd('/bad').get()).toBeNull()
expect(repoStatusForCwd('/good').get()).toEqual(sampleStatus)
expect($repoStatus.get()).toBeNull()
})
it('never publishes an old worktree status onto the primary after the active cwd moves', async () => {
let resolveOld!: (status: HermesRepoStatus | null) => void
stubProbe(
() =>
new Promise(resolve => {
resolveOld = resolve
})
)
// Explicit probe (not the debounced cwd edge) so the hang is fully under
// our control — same race window the coding rail used to hit after a
// session switch mid-probe.
const inflight = refreshRepoStatus('/repo-a')
$currentCwd.set('/repo-a')
await Promise.resolve()
$currentCwd.set('/repo-b')
expect($repoStatus.get()).toBeNull()
resolveOld(sampleStatus)
await inflight
// Primary follows the NEW cwd (empty). The old worktree may still cache the
// late result under its own key — that's what lets a tile rail paint
// instantly — but it must not leak onto the main rail via $repoStatus.
expect($repoStatus.get()).toBeNull()
expect(repoStatusForCwd('/repo-a').get()).toEqual(sampleStatus)
})
it('keeps independent statuses for two live worktrees', async () => {
stubProbe(async cwd => (cwd === '/repo-a' ? sampleStatus : otherStatus))
await refreshRepoStatus('/repo-a')
await refreshRepoStatus('/repo-b')
expect(repoStatusForCwd('/repo-a').get()).toEqual(sampleStatus)
expect(repoStatusForCwd('/repo-b').get()).toEqual(otherStatus)
$currentCwd.set('/repo-a')
expect($repoStatus.get()).toEqual(sampleStatus)
$currentCwd.set('/repo-b')
expect($repoStatus.get()).toEqual(otherStatus)
})
it('runs one probe at a time and coalesces overlap into one trailing refresh per drain', async () => {
const resolvers: Array<(status: HermesRepoStatus | null) => void> = []
const calls: string[] = []
let active = 0
let maxActive = 0
stubProbe(
cwd =>
new Promise(resolve => {
calls.push(cwd)
active++
maxActive = Math.max(maxActive, active)
resolvers.push(status => {
active--
resolve(status)
})
})
)
$currentCwd.set('/repo-c')
const first = refreshRepoStatus('/repo-a')
const second = refreshRepoStatus('/repo-b')
const third = refreshRepoStatus('/repo-c')
expect(calls).toEqual(['/repo-a'])
expect(maxActive).toBe(1)
expect($repoStatusLoading.get()).toBe(true)
resolvers.shift()?.(sampleStatus)
await Promise.resolve()
await Promise.resolve()
expect(maxActive).toBe(1)
expect(calls.length).toBe(2)
resolvers.shift()?.(otherStatus)
await Promise.resolve()
await Promise.resolve()
resolvers.shift()?.(sampleStatus)
await Promise.all([first, second, third])
expect(maxActive).toBe(1)
expect(calls).toEqual(['/repo-a', '/repo-b', '/repo-c'])
expect(repoStatusForCwd('/repo-a').get()).toEqual(sampleStatus)
expect(repoStatusForCwd('/repo-b').get()).toEqual(otherStatus)
expect(repoStatusForCwd('/repo-c').get()).toEqual(sampleStatus)
expect($repoStatus.get()).toEqual(sampleStatus)
expect($repoStatusLoading.get()).toBe(false)
})
it('refreshes when the stored session id changes even if the cwd is unchanged', async () => {
const probe = vi.fn(async () => sampleStatus)
stubProbe(probe)
$currentCwd.set('/repo')
$selectedStoredSessionId.set('session-a')
// The cwd subscription fires on the set above; drain the debounced refresh.
vi.advanceTimersByTime(200)
await vi.runAllTicks()
probe.mockClear()
// Switch to a different session in the SAME repo dir. The cwd atom value is
// identical, so its subscription would not re-fire — but the stored-session
// id did change, which must still trigger a probe so the branch label
// tracks the new session's checked-out branch.
$selectedStoredSessionId.set('session-b')
vi.advanceTimersByTime(200)
await vi.runAllTicks()
expect(probe).toHaveBeenCalledWith('/repo')
})
it('registerRepoStatusCwd keeps that worktree in unscoped refreshes', async () => {
const probe = vi.fn(async cwd => (cwd === '/tile' ? otherStatus : sampleStatus))
stubProbe(probe)
$currentCwd.set('/main')
const release = registerRepoStatusCwd('/tile')
// Drain the register kick + cwd edge so the assert only covers the
// unscoped fan-out.
vi.advanceTimersByTime(200)
await refreshAllRepoStatuses()
probe.mockClear()
// Unscoped fan-out: every registered worktree + primary, not main only.
await refreshAllRepoStatuses()
const probed = [...new Set(probe.mock.calls.map(call => call[0]))].sort()
expect(probed).toEqual(['/main', '/tile'])
expect(repoStatusForCwd('/tile').get()).toEqual(otherStatus)
expect(repoStatusForCwd('/main').get()).toEqual(sampleStatus)
release?.()
})
})
describe('repoChangeKindForPath', () => {
it('does not notify a row when only another path changes', () => {
$currentCwd.set('/repo')
$repoStatusByCwd.set({ '/repo': { ...sampleStatus, files: [] } })
const row = repoChangeKindForPath('/repo/a.ts')
const listener = vi.fn()
const unsubscribe = row.subscribe(listener)
$repoStatusByCwd.set({
'/repo': {
...sampleStatus,
files: [{ path: 'b.ts', untracked: true } as HermesRepoStatus['files'][number]]
}
})
expect(listener).toHaveBeenCalledTimes(1)
$repoStatusByCwd.set({
'/repo': {
...sampleStatus,
files: [{ path: 'a.ts', untracked: true } as HermesRepoStatus['files'][number]]
}
})
expect(listener).toHaveBeenCalledTimes(2)
expect(listener.mock.calls.at(-1)?.[0]).toBe('added')
unsubscribe()
})
})
+526
View File
@@ -0,0 +1,526 @@
import { atom, computed, type ReadableAtom } from 'nanostores'
import type { HermesGitWorktree, HermesRepoStatus } from '@/global'
import { desktopGit } from '@/lib/desktop-git'
import {
$projectScope,
$projectTree,
$worktreeDialog,
$worktreeRefreshToken,
ALL_PROJECTS,
projectRootCwd
} from './projects'
import {
$busy,
$currentCwd,
$selectedStoredSessionId,
$workspaceCwdOwner,
workspaceCwdBelongsToSelectedSession
} from './session'
import { $focusedRuntimeId, $sessionStates } from './session-states'
import { $workspaceChangeTick } from './workspace-events'
// Live working-tree status for every git surface on screen — the data backbone
// of the composer coding rail. Keyed PER CWD: the main pane and each session
// tile can sit in different worktrees, so a single global status would paint
// the primary repo's branch/± onto every rail (the "tile shows main's diff"
// bug). It's the same "cheaply re-read git truth at the right moments" model
// as the sidebar worktree probe: a single bounded `git status --porcelain=v2`
// per on-screen worktree per refresh, driven by structural edges (cwd change,
// turn settle, window focus, worktree mutation), never per-token and never
// touching the conversation/system-prompt cache.
const REPO_STATUS_REFRESH_DEBOUNCE_MS = 100
const normalizeCwd = (cwd?: null | string): null | string => cwd?.trim() || null
const EMPTY_WORKTREES: HermesGitWorktree[] = []
// Status + worktrees per normalized cwd. Entries outlive their surface (the
// map stays bounded by the worktrees touched this run) so re-opening a tile
// paints its last-known status instantly while the fresh probe runs.
export const $repoStatusByCwd = atom<Record<string, HermesRepoStatus | null>>({})
export const $repoWorktreesByCwd = atom<Record<string, HermesGitWorktree[]>>({})
// The PRIMARY (main pane) view — the active session's slice of the per-cwd
// truth. Existing consumers (keybind gate, base-branch picker, file tree) keep
// reading these; only surfaces that can live in ANOTHER worktree (tile rails)
// need the per-cwd accessors below. During a conversation switch `$currentCwd`
// can still name the previous conversation's path, so ownership hides only this
// primary slice; the per-cwd cache stays available to any tile that genuinely
// owns that worktree (#71254).
export const $repoStatus: ReadableAtom<HermesRepoStatus | null> = computed(
[$repoStatusByCwd, $currentCwd, $selectedStoredSessionId, $workspaceCwdOwner],
(byCwd, cwd) => (workspaceCwdBelongsToSelectedSession() ? (byCwd[normalizeCwd(cwd) ?? ''] ?? null) : null)
)
export const $repoStatusLoading = atom(false)
// The repo's real worktrees (for the coding rail's "jump to a worktree" menu).
// Refreshed on the same edges as the status probe; empty off a repo.
export const $repoWorktrees: ReadableAtom<HermesGitWorktree[]> = computed(
[$repoWorktreesByCwd, $currentCwd, $selectedStoredSessionId, $workspaceCwdOwner],
(byCwd, cwd) =>
workspaceCwdBelongsToSelectedSession() ? (byCwd[normalizeCwd(cwd) ?? ''] ?? EMPTY_WORKTREES) : EMPTY_WORKTREES
)
// Reference-stable per-cwd slices, so any number of rails can each subscribe
// to their own worktree's status without re-deriving atoms per render.
const statusAtomByCwd = new Map<string, ReadableAtom<HermesRepoStatus | null>>()
const worktreesAtomByCwd = new Map<string, ReadableAtom<HermesGitWorktree[]>>()
const $noRepoStatus = atom<HermesRepoStatus | null>(null)
const $noWorktrees = atom<HermesGitWorktree[]>(EMPTY_WORKTREES)
/** Reactive status for one repo cwd (a tile's worktree). Stable per cwd. */
export function repoStatusForCwd(cwd?: null | string): ReadableAtom<HermesRepoStatus | null> {
const key = normalizeCwd(cwd)
if (!key) {
return $noRepoStatus
}
let $slice = statusAtomByCwd.get(key)
if (!$slice) {
$slice = computed($repoStatusByCwd, byCwd => byCwd[key] ?? null)
statusAtomByCwd.set(key, $slice)
}
return $slice
}
/**
* Is this path a git repo? This function reads the probe cache, and probes on
* demand when the cache has no entry for the path. Use it to validate any repo
* that was picked out of candidate FOLDERS: a path in a project row is not
* evidence that git can branch from it. On a remote gateway the probe is
* backend-routed (`desktopGit()` returns the REST mirror), so a VPS path is
* judged by the VPS's git — not by this machine's filesystem (#81724).
*/
export async function isGitRepoPath(cwd: string): Promise<boolean> {
const key = normalizeCwd(cwd)
if (!key) {
return false
}
if (key in $repoStatusByCwd.get()) {
return $repoStatusByCwd.get()[key] !== null
}
if (!desktopGit()?.repoStatus) {
return false
}
await refreshRepoStatus(key)
return ($repoStatusByCwd.get()[key] ?? null) !== null
}
/** Reactive worktree list for one repo cwd. Stable per cwd. */
export function repoWorktreesForCwd(cwd?: null | string): ReadableAtom<HermesGitWorktree[]> {
const key = normalizeCwd(cwd)
if (!key) {
return $noWorktrees
}
let $slice = worktreesAtomByCwd.get(key)
if (!$slice) {
$slice = computed($repoWorktreesByCwd, byCwd => byCwd[key] ?? EMPTY_WORKTREES)
worktreesAtomByCwd.set(key, $slice)
}
return $slice
}
export type RepoChangeKind = 'added' | 'conflicted' | 'modified'
// Absolute file path → its git change kind, for VS Code-style file-tree tinting.
// Reuses the same bounded $repoStatus probe (capped file list); git reports
// repo-root-relative paths, so we join them onto the active cwd. Deletions never
// appear — the file is gone from disk, so there's no tree row to tint.
export const $repoChangeByPath = computed([$repoStatus, $currentCwd], (status, cwd) => {
const map = new Map<string, RepoChangeKind>()
const root = (cwd || '').replace(/[/\\]+$/, '')
if (!status || !root) {
return map
}
for (const file of status.files) {
const kind: RepoChangeKind = file.conflicted ? 'conflicted' : file.untracked ? 'added' : 'modified'
map.set(`${root}/${file.path}`, kind)
}
return map
})
/**
* Per-row Git decoration subscription. A visible file row reads one scalar, so
* a fresh repo-status map only re-renders that row when its own kind changed.
*/
export function repoChangeKindForPath(path: string): ReadableAtom<RepoChangeKind | undefined> {
return computed($repoChangeByPath, changes => changes.get(path))
}
// Cwds whose rails are on screen right now (refcounted — two tiles in one
// worktree register it twice). Every refresh edge re-probes each registered
// cwd plus the primary workspace, so a tile's rail moves when ITS agent
// touches the tree — not only when main's does.
const registeredCwds = new Map<string, number>()
/** Keep `cwd` in the refresh set while its rail is mounted. Returns a release
* (undefined for a blank cwd), kicking off an immediate probe on register. */
export function registerRepoStatusCwd(cwd?: null | string): (() => void) | undefined {
const key = normalizeCwd(cwd)
if (!key) {
return undefined
}
registeredCwds.set(key, (registeredCwds.get(key) ?? 0) + 1)
scheduleRepoStatusRefresh(key)
let released = false
return () => {
if (released) {
return
}
released = true
const count = registeredCwds.get(key) ?? 0
if (count <= 1) {
registeredCwds.delete(key)
} else {
registeredCwds.set(key, count - 1)
}
}
}
function setRepoStatusEntry(target: string, status: HermesRepoStatus | null): void {
const byCwd = $repoStatusByCwd.get()
// Skip the no-op null→null write so a repeated failed probe doesn't churn
// every rail's computed slice.
if (target in byCwd && byCwd[target] === status) {
return
}
$repoStatusByCwd.set({ ...byCwd, [target]: status })
}
function setRepoWorktreesEntry(target: string, worktrees: HermesGitWorktree[]): void {
const byCwd = $repoWorktreesByCwd.get()
if (target in byCwd && byCwd[target] === worktrees) {
return
}
$repoWorktreesByCwd.set({ ...byCwd, [target]: worktrees })
}
interface RepoStatusRefreshRequest {
probe: (cwd: string) => Promise<HermesRepoStatus | null>
seq: number
target: string
}
// Coalesce overlapping probes: many triggers can fire around a turn boundary
// (busy flip + worktree token + focus), and several worktrees can need a
// re-probe at once. Keep ONE probe in flight and at most one trailing request
// per cwd so a slow `git status` cannot multiply into an unbounded subprocess
// pile-up.
const seqByCwd = new Map<string, number>()
const pendingByCwd = new Map<string, RepoStatusRefreshRequest>()
let repoStatusRefreshInFlight: Promise<void> | null = null
let repoStatusRefreshTimer: ReturnType<typeof setTimeout> | null = null
const scheduledCwds = new Set<string>()
let scheduledAllTargets = false
// A result only lands while it is still the newest request for ITS cwd. The
// debounce below deliberately delays the next probe; without this live check,
// an old probe can land during that gap and briefly make a session look like
// it is on a stale branch.
const isCurrentSeq = (target: string, seq: number): boolean => seqByCwd.get(target) === seq
async function loadWorktrees(target: string, seq: number): Promise<void> {
const list = desktopGit()?.worktreeList
if (!list) {
setRepoWorktreesEntry(target, EMPTY_WORKTREES)
return
}
try {
const worktrees = await list(target)
if (isCurrentSeq(target, seq)) {
setRepoWorktreesEntry(target, worktrees)
}
} catch {
if (isCurrentSeq(target, seq)) {
setRepoWorktreesEntry(target, EMPTY_WORKTREES)
}
}
}
/**
* Re-probe the working tree for one cwd. Best-effort: a non-repo, a remote
* backend, or a missing probe clears that cwd's entry so its rail hides
* rather than showing stale data.
*/
async function runRepoStatusRefresh({ probe, seq, target }: RepoStatusRefreshRequest): Promise<void> {
try {
const status = await probe(target)
// Drop the result if a newer refresh for this cwd started while we were
// probing — the newer probe owns the entry.
if (!isCurrentSeq(target, seq)) {
return
}
setRepoStatusEntry(target, status)
// Worktrees only matter inside a repo; clear them otherwise.
if (status) {
void loadWorktrees(target, seq)
} else {
setRepoWorktreesEntry(target, EMPTY_WORKTREES)
}
} catch {
if (isCurrentSeq(target, seq)) {
setRepoStatusEntry(target, null)
setRepoWorktreesEntry(target, EMPTY_WORKTREES)
}
}
}
async function drainRepoStatusRefreshes(): Promise<void> {
while (pendingByCwd.size > 0) {
const [target, request] = pendingByCwd.entries().next().value as [string, RepoStatusRefreshRequest]
pendingByCwd.delete(target)
await runRepoStatusRefresh(request)
}
// This reset is synchronous with the final empty-queue check. A refresh
// arriving before this continuation runs is drained above; one arriving
// afterward sees no in-flight promise and starts a new drain.
repoStatusRefreshInFlight = null
$repoStatusLoading.set(false)
}
export function refreshRepoStatus(cwd?: null | string): Promise<void> {
const target = normalizeCwd(cwd ?? $currentCwd.get())
const probe = desktopGit()?.repoStatus
if (!probe) {
// Remote backend / no bridge: there is no local git truth at all — wipe
// every entry so no rail shows stale local status, and invalidate any
// in-flight probe results.
pendingByCwd.clear()
seqByCwd.clear()
$repoStatusByCwd.set({})
$repoWorktreesByCwd.set({})
$repoStatusLoading.set(false)
return repoStatusRefreshInFlight || Promise.resolve()
}
if (!target) {
// No cwd (a detached fresh chat). The primary computed already reads null
// for a blank cwd, and other worktrees' entries stay valid.
return repoStatusRefreshInFlight || Promise.resolve()
}
const seq = (seqByCwd.get(target) ?? 0) + 1
seqByCwd.set(target, seq)
pendingByCwd.set(target, { probe, seq, target })
$repoStatusLoading.set(true)
if (!repoStatusRefreshInFlight) {
repoStatusRefreshInFlight = drainRepoStatusRefreshes()
}
return repoStatusRefreshInFlight
}
/** Registered (on-screen) worktrees + the primary workspace. */
function refreshTargets(): Set<string> {
const targets = new Set(registeredCwds.keys())
const primary = normalizeCwd($currentCwd.get())
if (primary) {
targets.add(primary)
}
return targets
}
/** Re-probe every on-screen worktree (and the primary). Awaits the drain. */
export async function refreshAllRepoStatuses(): Promise<void> {
const targets = refreshTargets()
if (targets.size === 0) {
return
}
// Queue every target, then await the single in-flight drain once.
let last: Promise<void> = Promise.resolve()
for (const target of targets) {
last = refreshRepoStatus(target)
}
await last
}
// `cwd` scopes the refresh to one worktree; omit it to re-probe every
// on-screen worktree (turn settle, window focus — the tree may have changed
// under any of them).
function scheduleRepoStatusRefresh(cwd?: null | string): void {
if (cwd === undefined) {
scheduledAllTargets = true
} else {
const key = normalizeCwd(cwd)
if (key) {
scheduledCwds.add(key)
}
}
if (repoStatusRefreshTimer) {
clearTimeout(repoStatusRefreshTimer)
}
repoStatusRefreshTimer = setTimeout(() => {
repoStatusRefreshTimer = null
const targets = new Set(scheduledCwds)
if (scheduledAllTargets) {
for (const target of refreshTargets()) {
targets.add(target)
}
}
scheduledCwds.clear()
scheduledAllTargets = false
for (const target of targets) {
void refreshRepoStatus(target)
}
}, REPO_STATUS_REFRESH_DEBOUNCE_MS)
}
// ── Triggers ─────────────────────────────────────────────────────────────────
// Wired once at module load (mirrors projects.ts's module-scope subscriptions).
// Each is a structural edge where a working tree may have changed under us.
// The active session's cwd changed (session switch / new chat) → the primary
// computed re-keys instantly (a keyboard action in the switch-to-probe gap can
// only ever see the NEW repo's last-known facts, never the previous
// worktree's), then the debounced probe refreshes that repo's truth.
$currentCwd.subscribe(cwd => scheduleRepoStatusRefresh(cwd))
// Switching sessions can land on the same cwd but a different checked-out
// branch (the agent ran `git checkout` in another session's terminal). The cwd
// subscription above won't fire when the path is identical, so the branch label
// would stay stale until a window focus or turn-settle triggers a refresh.
// Treat the stored-session id as a structural edge in its own right.
$selectedStoredSessionId.subscribe(() => scheduleRepoStatusRefresh())
// A worktree add/remove (desktop op, or the agent's out-of-band git in a settled
// turn / a window refocus — both already bump this token) → re-probe.
$worktreeRefreshToken.subscribe(() => scheduleRepoStatusRefresh())
// A file-mutating tool finished (event-driven, not polled) → re-probe so the
// rails' branch/+/- move exactly when an agent touches a tree. Unscoped on
// purpose: the tool may belong to a background tile's session, so every
// on-screen worktree re-probes, not just the primary.
$workspaceChangeTick.subscribe(() => scheduleRepoStatusRefresh())
// A turn settling is the backstop for changes no tool diff announced (e.g. a
// raw `git` in the terminal): one final refresh when the agent goes idle.
let prevBusy = $busy.get()
$busy.subscribe(busy => {
if (prevBusy && !busy) {
scheduleRepoStatusRefresh()
}
prevBusy = busy
})
// Window focus: external changes while we were away (an outside terminal).
if (typeof window !== 'undefined') {
window.addEventListener('focus', () => scheduleRepoStatusRefresh())
}
/** Test-only: drop in-flight / pending / registered state so cases don't leak. */
export function _resetCodingStatusForTests(): void {
if (repoStatusRefreshTimer) {
clearTimeout(repoStatusRefreshTimer)
repoStatusRefreshTimer = null
}
scheduledCwds.clear()
scheduledAllTargets = false
pendingByCwd.clear()
seqByCwd.clear()
registeredCwds.clear()
repoStatusRefreshInFlight = null
$repoStatusByCwd.set({})
$repoWorktreesByCwd.set({})
$repoStatusLoading.set(false)
}
// ── New-worktree target resolution ───────────────────────────────────────────
// This code lives here and not in projects.ts. To pick the target, it must read
// both the project state and the git truth, and coding-status already depends
// on projects. A dependency in the other direction is a cycle.
// The repo that a new worktree is cut from: the cwd of the focused surface, or
// the root of the project the user entered. Both are things the user points at.
// There is no "use some other project's repo" step, because that branches
// somewhere the user never selected.
//
// A project root is not always a repo, so existence alone is not proof. Each
// candidate is validated against the probe cache. This function is the only
// authority on the target, so the hotkey no longer tests `$repoStatus` first,
// and ⌘⇧B now works from a detached session inside a project. '' means that no
// repo is in reach. That is a no-op and not an error, because a worktree only
// exists inside a repo.
export async function resolveWorktreeRepoPath(): Promise<string> {
const runtimeId = $focusedRuntimeId.get()
const scope = $projectScope.get()
const candidates = [
runtimeId ? ($sessionStates.get()[runtimeId]?.cwd ?? '') : '',
scope === ALL_PROJECTS ? '' : projectRootCwd($projectTree.get().find(node => node.id === scope))
]
for (const candidate of candidates) {
const path = candidate.trim()
if (path && (await isGitRepoPath(path))) {
return path
}
}
return ''
}
export async function openWorktreeDialog(options?: { base?: string; repoPath?: string }): Promise<void> {
const repoPath = options?.repoPath?.trim() || (await resolveWorktreeRepoPath())
if (repoPath) {
$worktreeDialog.set({ base: options?.base, repoPath })
}
}
+59
View File
@@ -0,0 +1,59 @@
import { atom } from 'nanostores'
import { releaseTypingFocus } from '@/components/ui/keyboard-first'
/** Whether the global command palette (Cmd/Ctrl+K) is currently open. */
export const $commandPaletteOpen = atom(false)
/** Optional nested page to open when the palette next opens (e.g. `pets`). */
export const $commandPalettePage = atom<string | null>(null)
/** Optional search text to prefill when the palette next opens (type-to-search
* from a surface: the keystroke that opened the palette must not be lost). */
export const $commandPaletteSeed = atom<string | null>(null)
export function openCommandPalette(): void {
$commandPaletteOpen.set(true)
}
/** Open the palette directly on a nested page (`theme`, `pets`, …). */
export function openCommandPalettePage(page: string, seed?: string): void {
$commandPalettePage.set(page)
if (seed) {
$commandPaletteSeed.set(seed)
}
$commandPaletteOpen.set(true)
}
// Closing hands the keyboard back to the composer — a hotkey-opened overlay
// owes typing focus to whatever the user was writing, not to its trigger.
// Skipped when the palette action itself moved focus (navigating to a route,
// opening a dialog): those surfaces claim focus after this runs.
function setOpen(open: boolean): void {
const wasOpen = $commandPaletteOpen.get()
$commandPaletteOpen.set(open)
if (!open) {
$commandPalettePage.set(null)
$commandPaletteSeed.set(null)
if (wasOpen) {
releaseTypingFocus()
}
}
}
export function closeCommandPalette(): void {
setOpen(false)
}
export function setCommandPaletteOpen(open: boolean): void {
setOpen(open)
}
export function toggleCommandPalette(): void {
setOpen(!$commandPaletteOpen.get())
}
+41
View File
@@ -0,0 +1,41 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { $compactingSessions, sessionCompacting, setSessionCompacting } from './compaction'
describe('compaction store', () => {
beforeEach(() => $compactingSessions.set({}))
afterEach(() => $compactingSessions.set({}))
it('tracks compaction per session independently', () => {
setSessionCompacting('session-a', true)
setSessionCompacting('session-b', true)
expect($compactingSessions.get()).toEqual({ 'session-a': true, 'session-b': true })
})
it('scopes the view to the session asked for, not whichever is active', () => {
setSessionCompacting('session-a', true)
expect(sessionCompacting('session-a').get()).toBe(true)
expect(sessionCompacting('session-b').get()).toBe(false)
})
it('clears a session without disturbing the others', () => {
setSessionCompacting('session-a', true)
setSessionCompacting('session-b', true)
setSessionCompacting('session-a', false)
expect($compactingSessions.get()).toEqual({ 'session-b': true })
})
it('is a no-op when clearing an unknown session', () => {
setSessionCompacting('session-a', true)
const before = $compactingSessions.get()
setSessionCompacting('session-missing', false)
expect($compactingSessions.get()).toBe(before)
})
})
+45
View File
@@ -0,0 +1,45 @@
import { atom, computed } from 'nanostores'
// Per-session flag while auto-compaction runs mid-turn. Without it the
// transcript looks like it reset; per-session so a background chat can't
// clobber the foreground view.
const keyFor = (sessionId: string | null | undefined): string => sessionId ?? ''
export const $compactingSessions = atom<Record<string, true>>({})
/** Is `sessionId` compacting? Per-session because a transcript may be a tile,
* and a tile must never wear the primary chat's compaction state. */
export function sessionCompacting(sessionId: null | string) {
return computed($compactingSessions, sessions => keyFor(sessionId) in sessions)
}
export function setSessionCompacting(sessionId: string | null | undefined, active: boolean): void {
const key = keyFor(sessionId)
const sessions = $compactingSessions.get()
if (active) {
if (key in sessions) {
return
}
$compactingSessions.set({ ...sessions, [key]: true })
return
}
if (!(key in sessions)) {
return
}
const next = { ...sessions }
delete next[key]
$compactingSessions.set(next)
}
/** Clear compaction only when the gateway proves the turn resumed or ended. */
export function reconcileSessionCompacting(
sessionId: string | null | undefined,
_evidence: 'resumed' | 'terminal'
): void {
setSessionCompacting(sessionId, false)
}
@@ -0,0 +1,32 @@
import { atom } from 'nanostores'
import { persistString, storedString } from '@/lib/storage'
const STORAGE_KEY = 'hermes.desktop.completionSoundVariantId'
export const DEFAULT_COMPLETION_SOUND_VARIANT_ID = 1
// Range mirrors COMPLETION_SOUND_VARIANTS in lib/completion-sound.ts. Validating
// by range (not membership) keeps this store free of a dependency on the lib,
// which imports the atom back — a membership check would close that cycle.
const VARIANT_COUNT = 14
export function resolveCompletionSoundVariantId(variantId: number): number {
return Number.isInteger(variantId) && variantId >= 1 && variantId <= VARIANT_COUNT
? variantId
: DEFAULT_COMPLETION_SOUND_VARIANT_ID
}
function load(): number {
const stored = storedString(STORAGE_KEY)
return stored ? resolveCompletionSoundVariantId(Number.parseInt(stored, 10)) : DEFAULT_COMPLETION_SOUND_VARIANT_ID
}
export const $completionSoundVariantId = atom(load())
$completionSoundVariantId.subscribe(id => persistString(STORAGE_KEY, String(id)))
export function setCompletionSoundVariantId(variantId: number) {
$completionSoundVariantId.set(resolveCompletionSoundVariantId(variantId))
}
@@ -0,0 +1,69 @@
import { atom } from 'nanostores'
/**
* Composer micro actions — the floating pill strip at the top of the composer's
* overlay lane.
*
* A badge is a label and a `run`; what it does is entirely the registrar's
* business. Core ships none — the strip is a seam, filled by contributions to
* the `composer.microActions` area (see `composer/contrib.ts`).
*
* Session-scoped like every other stack feed: keyed by RUNTIME session id, and
* written immutably per key so `useSessionSlice` bails out on other sessions'
* churn.
*/
export interface ComposerAction {
/** Stable within a session; also the render key. */
id: string
label: string
/** Codicon name rendered before the label. */
icon?: string
disabled?: boolean
/** What the badge does. Anything: submit a prompt, open a pane, call the
* gateway. Receives the session it was registered for. */
run: (sessionId: string) => Promise<void> | void
}
export const $composerActionsBySession = atom<Record<string, ComposerAction[]>>({})
/**
* Two badge sets that would render identically.
*
* `run` is excluded on purpose: it's a fresh closure on every resolve, so
* including it would make every comparison false and defeat the bail-out
* below. The invariant that buys: a provider must not change what `run` DOES
* without also changing a visible field.
*/
const same = (a: readonly ComposerAction[], b: readonly ComposerAction[]) =>
a.length === b.length &&
a.every((x, i) => {
const y = b[i]!
return x.id === y.id && x.label === y.label && x.icon === y.icon && x.disabled === y.disabled
})
/**
* Publish this session's badge set.
*
* A no-op resolve must not write: providers are re-resolved on every composer
* render, and an equal-but-fresh array would re-render the stack on every
* keystroke. An unchanged set keeps its previous reference, so the store stays
* quiet.
*/
export function setComposerActions(sid: string, actions: ComposerAction[]) {
const current = $composerActionsBySession.get()
if (!sid || (current[sid] ? same(current[sid], actions) : actions.length === 0)) {
return
}
const next = { ...current }
if (actions.length > 0) {
next[sid] = actions
} else {
delete next[sid]
}
$composerActionsBySession.set(next)
}
@@ -0,0 +1,138 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
$perSessionBrowse,
browseBackward,
browseForward,
deriveUserHistory,
isBrowsingHistory,
resetBrowseState
} from './composer-input-history'
const SESSION_A = 'session-a'
const SESSION_B = 'session-b'
// Newest-first user text ring, what the caller passes to browse*.
const HISTORY = ['third', 'second', 'first']
const MSG = (role: string, text: string) => ({ id: '', role, text })
beforeEach(() => {
$perSessionBrowse.set({})
})
describe('deriveUserHistory', () => {
it('returns user messages newest-first with empty/whitespace skipped', () => {
const messages = [MSG('user', ' '), MSG('assistant', 'hi'), MSG('user', 'first'), MSG('user', 'second')]
expect(deriveUserHistory(messages, m => m.text)).toEqual(['second', 'first'])
})
})
describe('browseBackward', () => {
it('returns null when history is empty', () => {
expect(browseBackward(SESSION_A, '', [])).toBeNull()
})
it('returns the most recent entry on first press and saves the draft', () => {
const result = browseBackward(SESSION_A, 'unsent draft', HISTORY)
expect(result).toBe('third')
expect($perSessionBrowse.get()[SESSION_A]!.draftSnapshot).toBe('unsent draft')
})
it('moves to older entries on subsequent presses and stops at the oldest', () => {
expect(browseBackward(SESSION_A, '', HISTORY)).toBe('third')
expect(browseBackward(SESSION_A, '', HISTORY)).toBe('second')
expect(browseBackward(SESSION_A, '', HISTORY)).toBe('first')
expect(browseBackward(SESSION_A, '', HISTORY)).toBeNull()
})
it('uses caller-provided history, not a mirrored ring', () => {
// The store never owns the ring — the caller passes it every press.
// If the ring changes between presses (e.g. a new message was sent),
// the next press sees the updated ring and the cursor continues
// from where it was within it.
expect(browseBackward(SESSION_A, '', ['youngest', 'older'])).toBe('youngest')
// Caller added a new message; ring is now [brand-new, youngest, older].
// Cursor was at 0, next press advances to 1 -> "youngest".
expect(browseBackward(SESSION_A, '', ['brand-new', 'youngest', 'older'])).toBe('youngest')
// One more press -> "older".
expect(browseBackward(SESSION_A, '', ['brand-new', 'youngest', 'older'])).toBe('older')
})
})
describe('browseForward', () => {
it('returns null when not browsing', () => {
expect(browseForward(SESSION_A, HISTORY)).toBeNull()
})
it('moves toward the present', () => {
browseBackward(SESSION_A, 'draft', HISTORY) // cursor 0 -> 'third'
browseBackward(SESSION_A, '', HISTORY) // cursor 1 -> 'second'
expect(browseForward(SESSION_A, HISTORY)).toEqual({
text: 'third',
returnedToPresent: false
})
})
it('restores the saved draft and resets when reaching the present', () => {
browseBackward(SESSION_A, 'my original draft', HISTORY)
const result = browseForward(SESSION_A, HISTORY)
expect(result).toEqual({ text: 'my original draft', returnedToPresent: true })
expect(isBrowsingHistory(SESSION_A)).toBe(false)
})
})
describe('per-session isolation', () => {
it('tracks cursor and draft independently per session', () => {
browseBackward(SESSION_A, 'draft-a', HISTORY)
browseBackward(SESSION_A, '', HISTORY) // older
browseBackward(SESSION_B, 'draft-b', HISTORY)
const a = $perSessionBrowse.get()[SESSION_A]!
const b = $perSessionBrowse.get()[SESSION_B]!
expect(a.cursor).toBe(1)
expect(a.draftSnapshot).toBe('draft-a')
expect(b.cursor).toBe(0)
expect(b.draftSnapshot).toBe('draft-b')
})
})
describe('resetBrowseState', () => {
it('clears cursor and draft snapshot', () => {
browseBackward(SESSION_A, 'draft', HISTORY)
resetBrowseState(SESSION_A)
const s = $perSessionBrowse.get()[SESSION_A]!
expect(s.cursor).toBe(-1)
expect(s.draftSnapshot).toBe('')
})
})
describe('session switch behavior', () => {
it('resets the previous session cursor and lets the new session derive its own ring', () => {
// Session A: user browsed into the past
browseBackward(SESSION_A, '', HISTORY)
expect(isBrowsingHistory(SESSION_A)).toBe(true)
// Caller switches to session B; resets A's browse state
resetBrowseState(SESSION_A)
// Session B's ring is derived from B's messages, not A's
const sessionBMessages = [MSG('user', 'hello-b'), MSG('user', 'world-b')]
const sessionBHistory = deriveUserHistory(sessionBMessages, m => m.text)
expect(browseBackward(SESSION_B, '', sessionBHistory)).toBe('world-b')
expect(browseBackward(SESSION_B, '', sessionBHistory)).toBe('hello-b')
expect(isBrowsingHistory(SESSION_A)).toBe(false)
})
})
@@ -0,0 +1,164 @@
import { atom } from 'nanostores'
/**
* Per-session input history browse state.
*
* The user-text ring is **derived from the live session messages** on each
* keypress — it is not mirrored anywhere. This keeps a single source of truth
* and avoids the entire class of seeding/dedup bugs that come from trying to
* keep a parallel ring in sync with submit/queue/voice paths.
*
* We only persist the cursor and the saved draft:
* - `cursor` — index into the derived user-text ring (0 = newest, larger = older).
* `-1` means "not browsing".
* - `draftSnapshot` — the composer text at the moment the user started
* browsing, so ArrowDown back to the "present" restores it.
*/
export interface SessionBrowseState {
cursor: number
draftSnapshot: string
}
const $perSessionBrowse = atom<Record<string, SessionBrowseState>>({})
function ensure(sessionId: string): SessionBrowseState {
const all = { ...$perSessionBrowse.get() }
let s = all[sessionId]
if (!s) {
s = { cursor: -1, draftSnapshot: '' }
all[sessionId] = s
$perSessionBrowse.set(all)
}
return s
}
function persist() {
$perSessionBrowse.set({ ...$perSessionBrowse.get() })
}
function valid(sessionId: string | null | undefined): sessionId is string {
return typeof sessionId === 'string' && sessionId.length > 0
}
/**
* Derive the user-text ring (newest first) from session messages.
* The caller is responsible for providing already-session-scoped messages.
*/
export function deriveUserHistory<T extends { role: string }>(
messages: readonly T[],
getText: (m: T) => string
): string[] {
const out: string[] = []
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]!
if (m.role !== 'user') {
continue
}
const t = getText(m).trim()
if (t) {
out.push(t)
}
}
return out
}
/**
* Start browsing backward, or step to the next older entry.
* Returns the text to place in the composer, or null if already at the oldest
* entry (or the ring is empty).
*/
export function browseBackward(
sessionId: string | null | undefined,
currentDraft: string,
history: readonly string[]
): string | null {
if (!valid(sessionId) || history.length === 0) {
return null
}
const s = ensure(sessionId)
if (s.cursor === -1) {
s.draftSnapshot = currentDraft
s.cursor = 0
} else if (s.cursor < history.length - 1) {
s.cursor += 1
} else {
return null
}
persist()
return history[s.cursor]!
}
/**
* Browse forward toward the present. When reaching the "newest" entry the
* saved draft is restored and the cursor resets.
*/
export function browseForward(
sessionId: string | null | undefined,
history: readonly string[]
): { text: string; returnedToPresent: boolean } | null {
if (!valid(sessionId)) {
return null
}
const s = ensure(sessionId)
if (s.cursor === -1) {
return null
}
if (s.cursor > 0) {
s.cursor -= 1
persist()
return { text: history[s.cursor]!, returnedToPresent: false }
}
// At newest; moving forward restores the saved draft.
const text = s.draftSnapshot
s.cursor = -1
s.draftSnapshot = ''
persist()
return { text, returnedToPresent: true }
}
/** Clear browse state for a session (e.g. on session switch or new submit). */
export function resetBrowseState(sessionId: string | null | undefined) {
if (!valid(sessionId)) {
return
}
const all = { ...$perSessionBrowse.get() }
const existing = all[sessionId]
if (!existing) {
return
}
all[sessionId] = { cursor: -1, draftSnapshot: '' }
$perSessionBrowse.set(all)
}
/** True if the user is currently browsing history for this session. */
export function isBrowsingHistory(sessionId: string | null | undefined): boolean {
if (!valid(sessionId)) {
return false
}
const s = $perSessionBrowse.get()[sessionId]
return s ? s.cursor >= 0 : false
}
export { $perSessionBrowse }
@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const GESTURES_KEY = 'hermes.desktop.composerPopout.gesturesEnabled'
const LEGACY_ENABLED_KEY = 'hermes.desktop.composerPopout.enabled'
const ZONES_KEY = 'hermes.desktop.composerPopout.zones.v1'
const loadStore = () => import('./composer-popout')
describe('composer pop-out preference', () => {
beforeEach(() => {
window.localStorage.clear()
vi.resetModules()
})
it('docks every floating zone, preserves positions, and persists the lock', async () => {
const first = await loadStore()
first.setComposerPopoutPosition('left', { bottom: 100, right: 100 })
first.setComposerPoppedOut('left', true)
first.setComposerPopoutPosition('right', { bottom: 200, right: 200 })
first.setComposerPoppedOut('right', true)
first.setComposerPopoutGesturesEnabled(false)
expect(first.$composerPopoutGesturesEnabled.get()).toBe(false)
expect(first.getComposerPopoutZone('left')).toEqual({
poppedOut: false,
position: { bottom: 100, right: 100 }
})
expect(first.getComposerPopoutZone('right')).toEqual({
poppedOut: false,
position: { bottom: 200, right: 200 }
})
expect(window.localStorage.getItem(GESTURES_KEY)).toBe('false')
expect(window.localStorage.getItem(LEGACY_ENABLED_KEY)).toBe('false')
vi.resetModules()
const reloaded = await loadStore()
expect(reloaded.$composerPopoutGesturesEnabled.get()).toBe(false)
expect(reloaded.getComposerPopoutZone('left').poppedOut).toBe(false)
expect(reloaded.getComposerPopoutZone('right').poppedOut).toBe(false)
})
it('normalizes stale floating zones when the persisted preference is disabled', async () => {
window.localStorage.setItem(GESTURES_KEY, 'false')
window.localStorage.setItem(
ZONES_KEY,
JSON.stringify({ stale: { poppedOut: true, position: { bottom: 48, right: 64 } } })
)
const store = await loadStore()
expect(store.getComposerPopoutZone('stale')).toEqual({
poppedOut: false,
position: { bottom: 48, right: 64 }
})
})
it('keeps pop-out gestures enabled by default', async () => {
const store = await loadStore()
expect(store.$composerPopoutGesturesEnabled.get()).toBe(true)
})
})
@@ -0,0 +1,130 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
$composerPopoutZones,
clampPopoutPosition,
getComposerPopoutZone,
POPOUT_WIDTH_REM,
type PopoutBounds,
pruneComposerPopoutZones,
setComposerPopoutPosition,
setComposerPoppedOut
} from './composer-popout'
// jsdom's window is 1024x768; every expectation below is relative to that.
const VW = 1024
const VH = 768
const BOX = { height: 56, width: 320 }
// A split layout: chat on the right half, under a 40px header.
const RIGHT_HALF: PopoutBounds = { bottom: VH, left: VW / 2, right: VW, top: 40 }
// The same drag intent, viewed from the left half.
const LEFT_HALF: PopoutBounds = { bottom: VH, left: 0, right: VW / 2, top: 40 }
const LEFT_ZONE = 'g-left'
const RIGHT_ZONE = 'g-right'
describe('clampPopoutPosition', () => {
it('keeps the whole box inside its surface, not just its corner', () => {
const { bottom, right } = clampPopoutPosition({ bottom: 24, right: 24 }, BOX, RIGHT_HALF)
// Insets are viewport-relative, so convert back to an absolute rect.
const boxRight = VW - right
const boxLeft = boxRight - BOX.width
const boxBottom = VH - bottom
const boxTop = boxBottom - BOX.height
expect(boxLeft).toBeGreaterThanOrEqual(RIGHT_HALF.left)
expect(boxRight).toBeLessThanOrEqual(RIGHT_HALF.right)
expect(boxTop).toBeGreaterThanOrEqual(RIGHT_HALF.top)
expect(boxBottom).toBeLessThanOrEqual(RIGHT_HALF.bottom)
})
it('pulls an out-of-bounds intent back into the surface', () => {
// Dragged far left of this surface (a position that belongs to the other
// half of the split) — it must land inside, not off the edge.
const { right } = clampPopoutPosition({ bottom: 24, right: 900 }, BOX, RIGHT_HALF)
expect(VW - right - BOX.width).toBeGreaterThanOrEqual(RIGHT_HALF.left)
})
it('is pure — the same intent yields a different placement per surface', () => {
const intent = { bottom: 24, right: 24 }
const onRight = clampPopoutPosition(intent, BOX, RIGHT_HALF)
const onLeft = clampPopoutPosition(intent, BOX, LEFT_HALF)
// Right half honors the intent (it fits); left half has to push the box
// inward. Two surfaces, two placements, one unchanged intent — this is what
// lets keep-alive tabs share a zone's position without overwriting it.
expect(onRight).not.toEqual(onLeft)
expect(clampPopoutPosition(intent, BOX, RIGHT_HALF)).toEqual(onRight)
})
it('falls back to the full window when the surface has no measured area', () => {
const { bottom, right } = clampPopoutPosition({ bottom: 24, right: 24 }, BOX, undefined)
expect(bottom).toBe(24)
expect(right).toBe(24)
})
it('assumes the compact float width when the box is unmeasured', () => {
// Pre-layout (peel-off, restore) there is no rect yet; the fallback must
// still leave the box grabbable rather than clamping it to a zero-width slot.
const { right } = clampPopoutPosition({ bottom: 24, right: 5000 }, undefined, RIGHT_HALF)
expect(VW - right - POPOUT_WIDTH_REM * 16).toBeGreaterThanOrEqual(RIGHT_HALF.left)
})
})
describe('pop-out state is scoped to a layout zone', () => {
beforeEach(() => {
$composerPopoutZones.set({})
})
it('floats one zone without touching its neighbor', () => {
setComposerPoppedOut(LEFT_ZONE, true)
// Tabs in the left zone float; the right split stays docked. This is the
// whole point of keying by zone: popping out on the left must not fling a
// composer out of every other pane.
expect(getComposerPopoutZone(LEFT_ZONE).poppedOut).toBe(true)
expect(getComposerPopoutZone(RIGHT_ZONE).poppedOut).toBe(false)
})
it('keeps each zone position independent', () => {
setComposerPopoutPosition(LEFT_ZONE, { bottom: 100, right: 100 })
setComposerPopoutPosition(RIGHT_ZONE, { bottom: 24, right: 24 })
expect(getComposerPopoutZone(LEFT_ZONE).position).toEqual({ bottom: 100, right: 100 })
expect(getComposerPopoutZone(RIGHT_ZONE).position).toEqual({ bottom: 24, right: 24 })
})
it('reports the default for a zone nobody has touched', () => {
// Every tab in a fresh zone reads the same default, so a new split starts
// docked at the default corner rather than inheriting a neighbor's drag.
expect(getComposerPopoutZone('g-unseen')).toEqual({ poppedOut: false, position: { bottom: 24, right: 24 } })
})
it('does not let one zone float seed the zones split after it', () => {
setComposerPoppedOut(LEFT_ZONE, true)
setComposerPopoutPosition(LEFT_ZONE, { bottom: 200, right: 200 })
// A zone created later is its own thing — it must not inherit the float
// state of whichever zone happened to be touched first.
expect(getComposerPopoutZone('g-split-later')).toEqual({
poppedOut: false,
position: { bottom: 24, right: 24 }
})
})
it('drops state for zones that no longer exist', () => {
setComposerPoppedOut(LEFT_ZONE, true)
setComposerPoppedOut(RIGHT_ZONE, true)
pruneComposerPopoutZones([LEFT_ZONE])
expect(Object.keys($composerPopoutZones.get())).toEqual([LEFT_ZONE])
})
})
+287
View File
@@ -0,0 +1,287 @@
import { atom, computed, type ReadableAtom } from 'nanostores'
import { persistBoolean, persistString, storedBoolean, storedString } from '@/lib/storage'
const POPOUT_STORAGE_KEY = 'hermes.desktop.composerPopout.zones.v1'
const POPOUT_GESTURES_ENABLED_STORAGE_KEY = 'hermes.desktop.composerPopout.gesturesEnabled'
// Pre-zone keys: one flag + one position for the whole window. Read at load to
// seed the first zone the user touches (see `legacySeed`), never written again.
const LEGACY_ENABLED_KEY = 'hermes.desktop.composerPopout.enabled'
const LEGACY_POSITION_KEY = 'hermes.desktop.composerPopout.position'
const gesturesEnabledAtLoad = storedBoolean(POPOUT_GESTURES_ENABLED_STORAGE_KEY, true)
/** Where the floating composer's bottom-right corner sits, measured as an inset
* from the viewport's bottom/right edges. Anchoring to the bottom-right keeps
* the box visually pinned to its default corner as the window resizes and as
* the box grows upward while typing (the corner stays put, height climbs). */
export interface PopoutPosition {
bottom: number
right: number
}
/** One layout zone's pop-out state. */
export interface PopoutZoneState {
poppedOut: boolean
/** The user's intended placement for this zone, UNCLAMPED — every surface in
* the zone renders `clampPopoutPosition` of it against its own rect. */
position: PopoutPosition
}
// Floating composer width (rem). Shared by the inline style that sets
// --composer-popout-width and the peel-off drag math.
export const POPOUT_WIDTH_REM = 19.5
// Default pop-out placement: tucked into the bottom-right of the thread, clear
// of the window chrome. Matches the brief's "default to the right bottom".
const DEFAULT_POSITION: PopoutPosition = { bottom: 24, right: 24 }
const DEFAULT_ZONE: PopoutZoneState = { poppedOut: false, position: DEFAULT_POSITION }
const isPosition = (value: unknown): value is PopoutPosition => {
const r = value as null | Partial<PopoutPosition>
return typeof r?.bottom === 'number' && typeof r?.right === 'number'
}
/** The pre-zone position, if the user had one. */
function legacyPosition(): PopoutPosition {
try {
const parsed = JSON.parse(storedString(LEGACY_POSITION_KEY) || 'null') as unknown
return isPosition(parsed) ? { bottom: parsed.bottom, right: parsed.right } : DEFAULT_POSITION
} catch {
return DEFAULT_POSITION
}
}
function load(): Record<string, PopoutZoneState> {
const out: Record<string, PopoutZoneState> = {}
try {
const parsed = JSON.parse(storedString(POPOUT_STORAGE_KEY) || 'null') as unknown
for (const [id, value] of Object.entries((parsed as Record<string, unknown>) ?? {})) {
const zone = value as null | Partial<PopoutZoneState>
if (typeof zone?.poppedOut === 'boolean' && isPosition(zone.position)) {
out[id] = { poppedOut: gesturesEnabledAtLoad && zone.poppedOut, position: { ...zone.position } }
}
}
} catch {
// Treat unparseable persisted state as missing.
}
return out
}
/** Every zone's pop-out state, keyed by layout-tree group id.
*
* A GROUP is one stack of tabs, so this is the scope the user experiences:
* tabs in the same zone share a float (switch tabs, the box stays where you
* put it), while a split zone beside them keeps its own — popping out on the
* left doesn't fling a composer out of the right. */
export const $composerPopoutZones = atom<Record<string, PopoutZoneState>>(load())
export const $composerPopoutGesturesEnabled = atom(gesturesEnabledAtLoad)
/** Write-through to storage. Called explicitly — NOT on every store change: a
* drag updates the position once per frame, and serializing every zone to
* localStorage at 60Hz is exactly the IO the drag path was built to avoid. */
const persistZones = () => persistString(POPOUT_STORAGE_KEY, JSON.stringify($composerPopoutZones.get()))
/** Whether the user had a float before zones existed. Seeds a zone's first read
* so an upgrade doesn't silently dock someone who left their composer floating
* — but ONLY until they touch any zone. Once real per-zone state exists, that
* is the truth, and a zone split later starts docked like any other. */
let legacySeed: PopoutZoneState | null =
gesturesEnabledAtLoad && storedString(LEGACY_ENABLED_KEY) === 'true'
? { poppedOut: true, position: legacyPosition() }
: null
const zoneState = (zones: Record<string, PopoutZoneState>, groupId: string): PopoutZoneState =>
zones[groupId] ?? legacySeed ?? DEFAULT_ZONE
// Cached per-zone derived atoms keep useStore subscriptions referentially stable
// (and keep one zone's drag from re-rendering the composers in another).
const zoneCache = new Map<string, ReadableAtom<PopoutZoneState>>()
export function $composerPopoutZone(groupId: string): ReadableAtom<PopoutZoneState> {
let cached = zoneCache.get(groupId)
if (!cached) {
cached = computed($composerPopoutZones, zones => zoneState(zones, groupId))
zoneCache.set(groupId, cached)
}
return cached
}
export const getComposerPopoutZone = (groupId: string): PopoutZoneState =>
zoneState($composerPopoutZones.get(), groupId)
export interface PopoutSize {
height: number
width: number
}
/** Viewport-space rect the floating composer is confined to. Defaults to the
* whole window; pass the thread area so the box can't slide under a pinned
* sidebar or behind the header. */
export interface PopoutBounds {
bottom: number
left: number
right: number
top: number
}
interface SetPositionOptions {
/** Thread-area rect to confine the box to; falls back to the full window. */
area?: PopoutBounds
persist?: boolean
/** Measured box size; falls back to the compact width + a min height so the
* box stays grabbable even when the caller can't measure it. */
size?: PopoutSize
}
// Keep at least this much between the box and every edge of its bounds, so the
// floating composer can never be dragged (or restored) out of reach.
const EDGE_MARGIN = 8
// Height floor used when the real box height is unknown (init / load / peel-off).
export const POPOUT_ESTIMATED_HEIGHT = 56
const MIN_VISIBLE_HEIGHT = POPOUT_ESTIMATED_HEIGHT
const clampRange = (value: number, lo: number, hi: number) => Math.min(Math.max(value, lo), Math.max(lo, hi))
const rootFontSize = () => parseFloat(getComputedStyle(document.documentElement).fontSize) || 16
/** The chat surface a composer belongs to — the element whose rect bounds its
* floating box. Resolved from the composer's OWN surface root, so each mounted
* chat surface (primary, session tile, background tab) gets its own area
* instead of a document-wide first match. */
export const popoutBoundsElement = (composer: Element | null): Element | null =>
(composer?.closest('[data-chat-surface]') ?? document).querySelector('[data-slot="composer-bounds"]')
/** The thread area's viewport rect (excludes a pinned sidebar + the header), or
* undefined before it mounts — callers then fall back to the full window. */
export function readPopoutBounds(composer: Element | null): PopoutBounds | undefined {
const el = popoutBoundsElement(composer)
if (!el) {
return undefined
}
const { bottom, height, left, right, top, width } = el.getBoundingClientRect()
// Pre-layout (mount before first layout) the rect is empty — fall back to the
// window rather than clamping the box into a collapsed area.
return width > 0 && height > 0 ? { bottom, left, right, top } : undefined
}
/**
* Bound the bottom/right inset so the WHOLE box stays inside `area` (the chat
* surface's region, or the window by default) — the corner anchor alone would
* let the box's width/height push it past the opposite edges.
*
* PURE and per-surface: a zone stores the user's INTENT, and each mounted
* composer in it runs that through here against its own rect to get the
* placement it actually renders. Tabs in a zone are keep-alive mounted, so
* clamping into the store instead would have every tab overwrite the others
* with a value bounded by ITS geometry.
*/
export function clampPopoutPosition(
{ bottom, right }: PopoutPosition,
size?: PopoutSize,
area?: PopoutBounds
): PopoutPosition {
const width = size?.width || POPOUT_WIDTH_REM * rootFontSize()
const height = size?.height || MIN_VISIBLE_HEIGHT
const { innerHeight: vh, innerWidth: vw } = window
const a = area ?? { bottom: vh, left: 0, right: vw, top: 0 }
return {
bottom: clampRange(bottom, vh - a.bottom + EDGE_MARGIN, vh - a.top - height - EDGE_MARGIN),
right: clampRange(right, vw - a.right + EDGE_MARGIN, vw - a.left - width - EDGE_MARGIN)
}
}
function patchZone(groupId: string, patch: Partial<PopoutZoneState>) {
const zones = $composerPopoutZones.get()
const stored = zones[groupId]
const next = { ...zoneState(zones, groupId), ...patch }
if (
stored?.poppedOut === next.poppedOut &&
stored.position.bottom === next.position.bottom &&
stored.position.right === next.position.right
) {
return
}
// The user has now expressed per-zone intent; the pre-zone value stops
// standing in for zones they haven't touched.
legacySeed = null
$composerPopoutZones.set({ ...zones, [groupId]: next })
}
export function setComposerPoppedOut(groupId: string, value: boolean) {
patchZone(groupId, { poppedOut: value })
persistZones()
}
export function setComposerPopoutGesturesEnabled(value: boolean) {
$composerPopoutGesturesEnabled.set(value)
persistBoolean(POPOUT_GESTURES_ENABLED_STORAGE_KEY, value)
if (value) {
return
}
// Turning the feature off is an immediate lock-to-dock action for every
// layout zone, not merely a promise to ignore the next gesture. Keep each
// zone's resting position so opting back in restores where the user put it.
const zones = $composerPopoutZones.get()
const docked = Object.fromEntries(
Object.entries(zones).map(([groupId, zone]) => [groupId, { ...zone, poppedOut: false }])
)
legacySeed = null
$composerPopoutZones.set(docked)
persistZones()
// Neutralize the pre-zone singleton migration seed too, otherwise a reload
// with no materialized zones could revive an old floating composer.
persistBoolean(LEGACY_ENABLED_KEY, false)
}
/** Move this zone's box. Used per-frame during a drag, so it only writes the
* in-memory store by default; pass `persist` for the resting position on
* release. Returns the clamped position so callers can sync their live ref. */
export function setComposerPopoutPosition(
groupId: string,
position: PopoutPosition,
{ area, persist, size }: SetPositionOptions = {}
): PopoutPosition {
const next = clampPopoutPosition(position, size, area)
patchZone(groupId, { position: next })
if (persist) {
persistZones()
}
return next
}
/** Drop state for zones that no longer exist, so a long-lived install doesn't
* accumulate an entry per zone the user ever split and closed. */
export function pruneComposerPopoutZones(liveGroupIds: Iterable<string>) {
const live = new Set(liveGroupIds)
const zones = $composerPopoutZones.get()
const next = Object.fromEntries(Object.entries(zones).filter(([id]) => live.has(id)))
if (Object.keys(next).length !== Object.keys(zones).length) {
$composerPopoutZones.set(next)
persistZones()
}
}
@@ -0,0 +1,262 @@
import { beforeEach, describe, expect, it } from 'vitest'
import type { ComposerAttachment } from './composer'
import {
$parkedQueueSessions,
$queuedPromptsBySession,
clearQueuedPrompts,
dequeueQueuedPrompt,
enqueueQueuedPrompt,
getQueuedPrompts,
isQueueParked,
migrateQueuedPrompts,
parkQueuedPrompts,
promoteQueuedPrompt,
removeQueuedPrompt,
shouldAutoDrain,
unparkQueuedPrompts,
updateQueuedPrompt,
updateQueuedPromptText
} from './composer-queue'
const SESSION_KEY = 'session-abc'
const QUEUE_STORAGE_KEY = 'hermes.desktop.composerQueue.v1'
function attachment(id: string, kind: ComposerAttachment['kind'] = 'file'): ComposerAttachment {
return {
id,
kind,
label: id,
refText: `@file:${id}`
}
}
describe('composer queue store', () => {
beforeEach(() => {
window.localStorage.removeItem(QUEUE_STORAGE_KEY)
$queuedPromptsBySession.set({})
})
it('queues prompts in FIFO order', () => {
enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'first' })
enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'second' })
expect(dequeueQueuedPrompt(SESSION_KEY)?.text).toBe('first')
expect(dequeueQueuedPrompt(SESSION_KEY)?.text).toBe('second')
expect(dequeueQueuedPrompt(SESSION_KEY)).toBeNull()
})
it('clones attachments when queueing', () => {
const source = [attachment('a-1')]
const queued = enqueueQueuedPrompt(SESSION_KEY, { attachments: source, text: 'check clones' })
expect(queued).not.toBeNull()
expect(getQueuedPrompts(SESSION_KEY)[0]?.attachments[0]).toEqual(source[0])
expect(getQueuedPrompts(SESSION_KEY)[0]?.attachments[0]).not.toBe(source[0])
})
it('updates and removes queued entries by id', () => {
const first = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'draft one' })
const second = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'draft two' })
expect(first).not.toBeNull()
expect(second).not.toBeNull()
expect(updateQueuedPromptText(SESSION_KEY, first!.id, 'draft one edited')).toBe(true)
expect(getQueuedPrompts(SESSION_KEY).map(entry => entry.text)).toEqual(['draft one edited', 'draft two'])
expect(removeQueuedPrompt(SESSION_KEY, first!.id)).toBe(true)
expect(getQueuedPrompts(SESSION_KEY).map(entry => entry.text)).toEqual(['draft two'])
})
it('promotes a queued entry to the front', () => {
const first = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'first' })
const second = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'second' })
const third = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'third' })
expect(first).not.toBeNull()
expect(second).not.toBeNull()
expect(third).not.toBeNull()
expect(promoteQueuedPrompt(SESSION_KEY, third!.id)).toBe(true)
expect(getQueuedPrompts(SESSION_KEY).map(entry => entry.text)).toEqual(['third', 'first', 'second'])
expect(promoteQueuedPrompt(SESSION_KEY, third!.id)).toBe(false)
})
it('updates queued text and attachment snapshot', () => {
const first = enqueueQueuedPrompt(SESSION_KEY, { attachments: [attachment('f-1')], text: 'draft one' })
const editedAttachments = [attachment('f-2'), attachment('f-3', 'image')]
expect(first).not.toBeNull()
expect(
updateQueuedPrompt(SESSION_KEY, first!.id, {
attachments: editedAttachments,
text: 'edited text'
})
).toBe(true)
const queue = getQueuedPrompts(SESSION_KEY)
expect(queue[0]?.text).toBe('edited text')
expect(queue[0]?.attachments).toEqual(editedAttachments)
expect(queue[0]?.attachments[0]).not.toBe(editedAttachments[0])
})
it('clears queue state for a session', () => {
enqueueQueuedPrompt(SESSION_KEY, { attachments: [attachment('img-1', 'image')], text: 'queued' })
clearQueuedPrompts(SESSION_KEY)
expect(getQueuedPrompts(SESSION_KEY)).toEqual([])
expect($queuedPromptsBySession.get()[SESSION_KEY]).toBeUndefined()
expect(window.localStorage.getItem(QUEUE_STORAGE_KEY)).toBeNull()
})
it('persists queue entries into local storage', () => {
enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'persist me' })
const raw = window.localStorage.getItem(QUEUE_STORAGE_KEY)
expect(raw).toBeTruthy()
const parsed = JSON.parse(String(raw)) as Record<string, { text: string }[]>
expect(parsed[SESSION_KEY]?.[0]?.text).toBe('persist me')
})
})
describe('migrateQueuedPrompts', () => {
beforeEach(() => {
window.localStorage.removeItem(QUEUE_STORAGE_KEY)
$queuedPromptsBySession.set({})
})
it('moves entries from a dead runtime key onto the live one', () => {
enqueueQueuedPrompt('rt-old', { attachments: [], text: 'stranded' })
expect(migrateQueuedPrompts('rt-old', 'rt-new')).toBe(true)
expect(getQueuedPrompts('rt-old')).toEqual([])
expect(getQueuedPrompts('rt-new').map(e => e.text)).toEqual(['stranded'])
// The dead key is dropped from the store entirely.
expect($queuedPromptsBySession.get()['rt-old']).toBeUndefined()
})
it('appends after existing target entries (FIFO preserved)', () => {
enqueueQueuedPrompt('rt-new', { attachments: [], text: 'already here' })
enqueueQueuedPrompt('rt-old', { attachments: [], text: 'migrated' })
migrateQueuedPrompts('rt-old', 'rt-new')
expect(getQueuedPrompts('rt-new').map(e => e.text)).toEqual(['already here', 'migrated'])
})
it('is a no-op when source is empty or keys match', () => {
expect(migrateQueuedPrompts('rt-old', 'rt-new')).toBe(false)
expect(migrateQueuedPrompts('rt-x', 'rt-x')).toBe(false)
})
it('must not be used across sessions without a same-lineage guard (documents the leak)', () => {
// ChatView used to call migrateQueuedPrompts(selectedA, routeKeyB) during the
// route-ahead/store-lag window of a session switch. That re-homes A's queue
// onto B so the idle ChatBar on B auto-drains it into the wrong conversation.
// The guard lives in shouldMigrateComposerScope — this test locks the
// underlying hazard so a future caller cannot treat migrate as free.
enqueueQueuedPrompt('root-a', { attachments: [], text: 'belongs to A' })
expect(migrateQueuedPrompts('root-a', 'root-b')).toBe(true)
expect(getQueuedPrompts('root-a')).toEqual([])
expect(getQueuedPrompts('root-b').map(e => e.text)).toEqual(['belongs to A'])
})
})
describe('shouldAutoDrain', () => {
it('drains whenever idle with a non-empty queue', () => {
expect(shouldAutoDrain({ isBusy: false, queueLength: 1 })).toBe(true)
})
it('drains on mount/reconnect with no observed busy edge', () => {
// The whole point of dropping the edge: a remount resets the busy ref, so an
// edge-gated drain would strand the entry. Idle + non-empty must still fire.
expect(shouldAutoDrain({ isBusy: false, queueLength: 2 })).toBe(true)
})
it('does not drain mid-turn', () => {
expect(shouldAutoDrain({ isBusy: true, queueLength: 1 })).toBe(false)
})
it('does not drain an empty queue', () => {
expect(shouldAutoDrain({ isBusy: false, queueLength: 0 })).toBe(false)
})
it('does not drain a parked queue, even when idle', () => {
// The Stop/Esc settle edge: busy just flipped false but the user asked to
// HALT — the park must hold the head back until they resume.
expect(shouldAutoDrain({ isBusy: false, parked: true, queueLength: 1 })).toBe(false)
})
it('drains again once the park is lifted', () => {
expect(shouldAutoDrain({ isBusy: false, parked: false, queueLength: 1 })).toBe(true)
})
})
describe('parked queue sessions', () => {
beforeEach(() => {
window.localStorage.removeItem(QUEUE_STORAGE_KEY)
$queuedPromptsBySession.set({})
$parkedQueueSessions.set({})
})
it('parks only sessions with queued entries', () => {
expect(parkQueuedPrompts(SESSION_KEY)).toBe(false)
expect(isQueueParked(SESSION_KEY)).toBe(false)
enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'held back' })
expect(parkQueuedPrompts(SESSION_KEY)).toBe(true)
expect(isQueueParked(SESSION_KEY)).toBe(true)
})
it('unparks explicitly', () => {
enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'held back' })
parkQueuedPrompts(SESSION_KEY)
unparkQueuedPrompts(SESSION_KEY)
expect(isQueueParked(SESSION_KEY)).toBe(false)
})
it('queueing a fresh prompt lifts the park', () => {
enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'held back' })
parkQueuedPrompts(SESSION_KEY)
enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'new intent' })
expect(isQueueParked(SESSION_KEY)).toBe(false)
})
it('emptying the queue drops the park', () => {
const entry = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'held back' })
parkQueuedPrompts(SESSION_KEY)
removeQueuedPrompt(SESSION_KEY, entry!.id)
expect(isQueueParked(SESSION_KEY)).toBe(false)
})
it('a park travels with migrated entries', () => {
// A backend bounce right after Stop re-keys the queue; shedding the park
// there would auto-send the exact prompts the user just halted.
enqueueQueuedPrompt('rt-old', { attachments: [], text: 'held back' })
parkQueuedPrompts('rt-old')
migrateQueuedPrompts('rt-old', 'rt-new')
expect(isQueueParked('rt-old')).toBe(false)
expect(isQueueParked('rt-new')).toBe(true)
})
it('migration without a park does not invent one', () => {
enqueueQueuedPrompt('rt-old', { attachments: [], text: 'flowing' })
migrateQueuedPrompts('rt-old', 'rt-new')
expect(isQueueParked('rt-new')).toBe(false)
})
})
+369
View File
@@ -0,0 +1,369 @@
import { atom } from 'nanostores'
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import type { ComposerAttachment } from './composer'
export interface QueuedPromptEntry {
id: string
text: string
/** What the queue panel and the sent bubble show, when it differs from the
* text the agent receives. A queued `/skill` invocation carries the whole
* expanded skill body as `text` — the UI shows the invocation instead. */
displayText?: string
attachments: ComposerAttachment[]
queuedAt: number
}
/** Whether a queued entry can ride a mid-turn redirect: text-only, non-empty,
* not a slash command — the same gate `steerDraft` applies to the live draft
* (attachments can't ride a redirect; slash commands execute, not steer). */
export const isSteerableEntry = (entry: Pick<QueuedPromptEntry, 'attachments' | 'text'>): boolean => {
const text = entry.text.trim()
return Boolean(text) && entry.attachments.length === 0 && !SLASH_COMMAND_RE.test(text)
}
type QueueState = Record<string, QueuedPromptEntry[]>
const STORAGE_KEY = 'hermes.desktop.composerQueue.v1'
const load = (): QueueState => {
if (typeof window === 'undefined') {
return {}
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
const parsed = raw ? JSON.parse(raw) : null
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as QueueState) : {}
} catch {
return {}
}
}
const save = (state: QueueState) => {
if (typeof window === 'undefined') {
return
}
try {
if (Object.keys(state).length === 0) {
window.localStorage.removeItem(STORAGE_KEY)
} else {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
}
} catch {
// best-effort: storage may be unavailable, queue still works in-memory
}
}
export const $queuedPromptsBySession = atom<QueueState>(load())
/**
* Sessions whose queue the user explicitly halted (Stop button / Esc). A parked
* queue is skipped by both auto-drain paths until the user acts on it again —
* resume, send-now, a manual drain, queueing a fresh prompt, or emptying the
* queue all unpark. Deliberately in-memory only: a fresh app process starts
* unparked, so restored-entry semantics stay a separate concern.
*/
export const $parkedQueueSessions = atom<Record<string, true>>({})
const setParked = (sid: string, parked: boolean) => {
const current = $parkedQueueSessions.get()
if (Boolean(current[sid]) === parked) {
return
}
const next = { ...current }
if (parked) {
next[sid] = true
} else {
delete next[sid]
}
$parkedQueueSessions.set(next)
}
const writeSession = (sid: string, queue: QueuedPromptEntry[]) => {
const current = $queuedPromptsBySession.get()
const next = { ...current }
if (queue.length === 0) {
delete next[sid]
// An empty queue has nothing to hold back — drop the park so it can't
// linger as stale state and silently gate entries queued much later.
setParked(sid, false)
} else {
next[sid] = queue
}
$queuedPromptsBySession.set(next)
save(next)
}
const sidOf = (key: string | null | undefined): null | string => {
const trimmed = key?.trim()
return trimmed ? trimmed : null
}
const queueFor = (sid: string) => $queuedPromptsBySession.get()[sid] ?? []
const nextId = () => `queued-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a }))
export const getQueuedPrompts = (key: string | null | undefined): QueuedPromptEntry[] => {
const sid = sidOf(key)
return sid ? queueFor(sid) : []
}
export const enqueueQueuedPrompt = (
key: string | null | undefined,
payload: { text: string; attachments: ComposerAttachment[]; displayText?: string }
): null | QueuedPromptEntry => {
const sid = sidOf(key)
if (!sid) {
return null
}
const entry: QueuedPromptEntry = {
id: nextId(),
text: payload.text,
...(payload.displayText ? { displayText: payload.displayText } : {}),
attachments: cloneAttachments(payload.attachments),
queuedAt: Date.now()
}
writeSession(sid, [...queueFor(sid), entry])
// Queueing a new prompt is fresh intent to keep the conversation moving —
// a park from an earlier Stop must not hold this (or the entries ahead of
// it) back.
setParked(sid, false)
return entry
}
export const dequeueQueuedPrompt = (key: string | null | undefined): null | QueuedPromptEntry => {
const sid = sidOf(key)
if (!sid) {
return null
}
const [head, ...rest] = queueFor(sid)
if (!head) {
return null
}
writeSession(sid, rest)
return head
}
export const removeQueuedPrompt = (key: string | null | undefined, id: string): boolean => {
const sid = sidOf(key)
if (!sid) {
return false
}
const queue = queueFor(sid)
const next = queue.filter(e => e.id !== id)
if (next.length === queue.length) {
return false
}
writeSession(sid, next)
return true
}
export const promoteQueuedPrompt = (key: string | null | undefined, id: string): boolean => {
const sid = sidOf(key)
if (!sid) {
return false
}
const queue = queueFor(sid)
const index = queue.findIndex(e => e.id === id)
if (index <= 0) {
return false
}
const entry = queue[index]!
writeSession(sid, [entry, ...queue.slice(0, index), ...queue.slice(index + 1)])
return true
}
export const updateQueuedPrompt = (
key: string | null | undefined,
id: string,
update: { text: string; attachments?: ComposerAttachment[] }
): boolean => {
const sid = sidOf(key)
if (!sid) {
return false
}
const queue = queueFor(sid)
let changed = false
const next = queue.map(entry => {
if (entry.id !== id) {
return entry
}
const attachments = update.attachments ? cloneAttachments(update.attachments) : entry.attachments
if (entry.text === update.text && !update.attachments) {
return entry
}
changed = true
// The user rewrote the text, so any display projection it carried (a
// `/skill` invocation standing in for the expanded body) no longer
// describes it — what they typed is now what sends.
const { displayText: _dropped, ...rest } = entry
return { ...rest, text: update.text, attachments }
})
if (!changed) {
return false
}
writeSession(sid, next)
return true
}
export const updateQueuedPromptText = (key: string | null | undefined, id: string, text: string): boolean =>
updateQueuedPrompt(key, id, { text })
export const clearQueuedPrompts = (key: string | null | undefined) => {
const sid = sidOf(key)
if (!sid || !(sid in $queuedPromptsBySession.get())) {
return
}
writeSession(sid, [])
}
/**
* Move pending entries from a dead session key onto a live one, preserving FIFO
* (existing target entries first, migrated entries appended). A backend bounce /
* resume can mint a fresh runtime session id for the *same* conversation; the
* entries enqueued under the old id would otherwise be stranded under a key
* nothing reads anymore. No-op unless both keys resolve and differ.
*/
export const migrateQueuedPrompts = (fromKey: string | null | undefined, toKey: string | null | undefined): boolean => {
const from = sidOf(fromKey)
const to = sidOf(toKey)
if (!from || !to || from === to) {
return false
}
const pending = queueFor(from)
if (pending.length === 0) {
return false
}
const next = { ...$queuedPromptsBySession.get() }
delete next[from]
next[to] = [...queueFor(to), ...pending]
$queuedPromptsBySession.set(next)
save(next)
// The park is a property of the entries the user halted — it re-homes with
// them. Without this, a backend bounce right after Stop would shed the park
// and auto-send the exact prompts the user just held back.
if ($parkedQueueSessions.get()[from]) {
setParked(from, false)
setParked(to, true)
}
return true
}
/**
* Park a session's queue after an explicit user halt (Stop / Esc): entries stay
* visible in the panel but neither auto-drain path sends them. No-op for a
* session with nothing queued — parking exists to hold back queued turns, and
* a park with no queue would only linger as a stale gate.
*/
export const parkQueuedPrompts = (key: string | null | undefined): boolean => {
const sid = sidOf(key)
if (!sid || queueFor(sid).length === 0) {
return false
}
setParked(sid, true)
return true
}
/** Lift a park (user resumed the queue). Safe to call for any session. */
export const unparkQueuedPrompts = (key: string | null | undefined): void => {
const sid = sidOf(key)
if (sid) {
setParked(sid, false)
}
}
export const isQueueParked = (key: string | null | undefined): boolean => {
const sid = sidOf(key)
return sid ? Boolean($parkedQueueSessions.get()[sid]) : false
}
/** Inputs to {@link shouldAutoDrain}. */
export interface AutoDrainInput {
isBusy: boolean
/** The user explicitly halted this session's queue (Stop / Esc). */
parked?: boolean
queueLength: number
}
/**
* Decide whether the composer should auto-drain the next queued prompt.
*
* Edge-independent on purpose: the queue must advance whenever the session is
* idle and has pending entries, NOT only on an observed busy true → false edge.
* A backend bounce / websocket reconnect remounts the composer and resets the
* busy ref to the current value, swallowing the settle edge — an edge-gated
* drain would then strand the entry forever. The caller's drain lock
* (`drainingQueueRef`) serializes sends so being edge-free can't double-submit.
*
* `parked` is the one deliberate exception: an explicit Stop/Esc is the user
* saying HALT, and immediately firing the next queued prompt contradicts the
* instruction they just gave. Parked entries stay in the panel until the user
* resumes, sends, edits, or deletes them. Interrupts that exist to reach the
* queue faster (send-now-while-busy) never park, so they keep draining through
* this same gate.
*/
export const shouldAutoDrain = ({ isBusy, parked, queueLength }: AutoDrainInput): boolean =>
!isBusy && !parked && queueLength > 0
/** Auto-drain attempts for one entry before we stop retrying and toast. The
* entry stays queued for a manual send; a remount/reconnect resets the count. */
export const MAX_AUTO_DRAIN_ATTEMPTS = 4
@@ -0,0 +1,369 @@
import { JsonRpcGatewayError } from '@hermes/shared'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
$backgroundStatusBySession,
dismissBackgroundProcess,
isSessionGoneForBackgroundPolling,
reconcileBackgroundProcesses,
refreshBackgroundProcesses,
resetBackgroundPollingGuard,
stopBackgroundProcess
} from './composer-status'
import { $gateway } from './gateway'
import { markSessionGone } from './runtime-gone'
vi.mock('./notifications', () => ({ notifyError: vi.fn() }))
import { notifyError } from './notifications'
const SID = 'sess-1'
const running = (id: string, command = `cmd ${id}`) => ({ command, session_id: id, status: 'running' })
const exited = (id: string, exit_code = 0, command = `cmd ${id}`) => ({
command,
exit_code,
session_id: id,
status: 'exited'
})
const items = () => $backgroundStatusBySession.get()[SID] ?? []
describe('reconcileBackgroundProcesses', () => {
beforeEach(() => {
// Fake timers so the success self-clear (a real setTimeout) is deterministic
// and never leaks a pending timer between tests.
vi.useFakeTimers()
$backgroundStatusBySession.set({})
})
afterEach(() => {
vi.clearAllTimers()
vi.useRealTimers()
})
it('maps registry entries to status items', () => {
reconcileBackgroundProcesses(SID, [running('a'), exited('b', 0), exited('c', 1)])
expect(items().map(i => [i.id, i.state])).toEqual([
['a', 'running'],
['b', 'done'],
['c', 'failed']
])
expect(items()[2]!.exitCode).toBe(1)
})
it('keeps row order stable when a process flips state or the snapshot reorders', () => {
reconcileBackgroundProcesses(SID, [running('a'), running('b')])
// Snapshot arrives reordered AND `a` has exited — rows must not move.
reconcileBackgroundProcesses(SID, [running('b'), exited('a', 0)])
expect(items().map(i => [i.id, i.state])).toEqual([
['a', 'done'],
['b', 'running']
])
})
it('appends new processes after existing rows', () => {
reconcileBackgroundProcesses(SID, [running('a')])
reconcileBackgroundProcesses(SID, [running('b'), running('a')])
expect(items().map(i => i.id)).toEqual(['a', 'b'])
})
it('preserves object identity for unchanged rows (memo stability)', () => {
reconcileBackgroundProcesses(SID, [running('a'), running('b')])
const [a1] = items()
reconcileBackgroundProcesses(SID, [running('a'), exited('b', 0)])
const [a2, b2] = items()
expect(a2).toBe(a1)
expect(b2!.state).toBe('done')
})
it('is a no-op store write when nothing changed', () => {
reconcileBackgroundProcesses(SID, [running('a')])
const before = $backgroundStatusBySession.get()
reconcileBackgroundProcesses(SID, [running('a')])
expect($backgroundStatusBySession.get()).toBe(before)
})
it('never resurrects a dismissed process while the registry still reports it', () => {
reconcileBackgroundProcesses(SID, [exited('a', 0), running('b')])
dismissBackgroundProcess(SID, 'a')
reconcileBackgroundProcesses(SID, [exited('a', 0), running('b')])
expect(items().map(i => i.id)).toEqual(['b'])
})
it('forgets a dismissal once the registry prunes the process', () => {
reconcileBackgroundProcesses(SID, [exited('a', 0)])
dismissBackgroundProcess(SID, 'a')
// Registry pruned it…
reconcileBackgroundProcesses(SID, [])
// …so a future process reusing the id (new spawn) shows again.
reconcileBackgroundProcesses(SID, [running('a')])
expect(items().map(i => i.id)).toEqual(['a'])
})
it('drops the session key entirely when the last row goes away', () => {
reconcileBackgroundProcesses(SID, [running('a')])
reconcileBackgroundProcesses(SID, [])
expect($backgroundStatusBySession.get()).toEqual({})
})
// The self-clear path calls dismissBackgroundProcess, which records the id in
// the module-level dismissed set; use a fresh session per test so that record
// can't bleed into another test's reconcile.
const itemsOf = (sid: string) => $backgroundStatusBySession.get()[sid] ?? []
it('self-clears a finished success after a short linger', () => {
reconcileBackgroundProcesses('sess-clear', [exited('a', 0)])
expect(itemsOf('sess-clear').map(i => i.id)).toEqual(['a'])
vi.advanceTimersByTime(5_000)
expect(itemsOf('sess-clear')).toEqual([])
})
it('self-clears a failed task too, but only after a longer linger', () => {
reconcileBackgroundProcesses('sess-fail', [exited('a', 1)])
// Still visible after the success window — the failure gets a longer one so
// its exit code stays readable.
vi.advanceTimersByTime(5_000)
expect(itemsOf('sess-fail').map(i => [i.id, i.state])).toEqual([['a', 'failed']])
vi.advanceTimersByTime(10_000)
expect(itemsOf('sess-fail')).toEqual([])
})
it('never self-clears a still-running task', () => {
reconcileBackgroundProcesses('sess-run', [running('a')])
vi.advanceTimersByTime(60_000)
expect(itemsOf('sess-run').map(i => i.id)).toEqual(['a'])
})
it('arms the self-clear only once a task finishes', () => {
reconcileBackgroundProcesses('sess-arm', [running('a')])
vi.advanceTimersByTime(60_000)
// Still running after a minute — nothing scheduled yet.
expect(itemsOf('sess-arm').map(i => i.id)).toEqual(['a'])
reconcileBackgroundProcesses('sess-arm', [exited('a', 0)])
vi.advanceTimersByTime(5_000)
expect(itemsOf('sess-arm')).toEqual([])
})
})
// ── Dead-session polling guard (#94219 fallout) ──────────────────────────────
// The status stack polls `process.list` every 5s while a background row is on
// screen. `process.list` is session-scoped: against a runtime id the gateway no
// longer holds it returns 4001 "session not found". That failure was swallowed
// as "transient socket loss", so the poll re-sent the SAME dead id every 5s for
// the life of the window — 18,614 rejections against a single runtime id in one
// day on a real machine, and the log line the user reads as "session not found".
//
// A gone session is terminal, not transient: stop polling it.
describe('refreshBackgroundProcesses dead-session guard', () => {
beforeEach(() => {
$backgroundStatusBySession.set({})
resetBackgroundPollingGuard()
})
afterEach(() => {
$gateway.set(null as never)
resetBackgroundPollingGuard()
})
it('classifies a 4001 session-not-found as gone, and a timeout as transient', () => {
expect(isSessionGoneForBackgroundPolling(new Error('session not found'))).toBe(true)
expect(isSessionGoneForBackgroundPolling(new Error('4001 session not found'))).toBe(true)
expect(isSessionGoneForBackgroundPolling(new Error('Session Not Found'))).toBe(true)
// Transient failures must NOT latch the guard — the session may still be alive.
expect(isSessionGoneForBackgroundPolling(new Error('request timed out after 30s: process.list'))).toBe(false)
expect(isSessionGoneForBackgroundPolling(new Error('not connected'))).toBe(false)
})
it('stops re-polling a session after the gateway reports it gone', async () => {
const request = vi.fn(async () => {
throw new Error('session not found')
})
$gateway.set({ request } as never)
// First poll discovers the session is gone.
await refreshBackgroundProcesses(SID)
expect(request).toHaveBeenCalledTimes(1)
// Every subsequent tick must be suppressed. Before the fix these all went
// to the wire and each produced another gateway-side 4001.
await refreshBackgroundProcesses(SID)
await refreshBackgroundProcesses(SID)
await refreshBackgroundProcesses(SID)
expect(request).toHaveBeenCalledTimes(1)
})
it('keeps polling after a transient failure', async () => {
const request = vi.fn(async () => {
throw new Error('request timed out after 30s: process.list')
})
$gateway.set({ request } as never)
await refreshBackgroundProcesses(SID)
await refreshBackgroundProcesses(SID)
// A timeout is not proof of death — the poll must retry.
expect(request).toHaveBeenCalledTimes(2)
})
it('does not suppress a different, healthy session', async () => {
const request = vi.fn(async (_method: string, params?: Record<string, unknown>) => {
if (params?.session_id === SID) {
throw new Error('session not found')
}
return { processes: [] }
})
$gateway.set({ request } as never)
await refreshBackgroundProcesses(SID)
await refreshBackgroundProcesses(SID)
await refreshBackgroundProcesses('sess-healthy')
await refreshBackgroundProcesses('sess-healthy')
const targets = request.mock.calls.map(c => (c[1] as { session_id?: string } | undefined)?.session_id)
expect(targets.filter(t => t === SID)).toHaveLength(1)
expect(targets.filter(t => t === 'sess-healthy')).toHaveLength(2)
})
it('resumes polling a session that comes back (guard cleared on rebind)', async () => {
const request = vi.fn(async () => {
throw new Error('session not found')
})
$gateway.set({ request } as never)
await refreshBackgroundProcesses(SID)
await refreshBackgroundProcesses(SID)
expect(request).toHaveBeenCalledTimes(1)
// A fresh runtime bound to this session id clears the guard.
resetBackgroundPollingGuard(SID)
await refreshBackgroundProcesses(SID)
expect(request).toHaveBeenCalledTimes(2)
})
it('dismisses a stale process row when Stop is clicked after the runtime is gone', async () => {
reconcileBackgroundProcesses(SID, [running('stale')])
markSessionGone(SID)
$gateway.set({ request: vi.fn() } as never)
await stopBackgroundProcess(SID, 'stale')
expect(items()).toEqual([])
})
it('dismisses a stale process row while the gateway is disconnected', async () => {
reconcileBackgroundProcesses(SID, [running('disconnected')])
markSessionGone(SID)
$gateway.set(null as never)
await stopBackgroundProcess(SID, 'disconnected')
expect(items()).toEqual([])
})
it('keeps the row and reports failure when the gateway is disconnected', async () => {
reconcileBackgroundProcesses(SID, [running('unreachable')])
$gateway.set(null as never)
vi.mocked(notifyError).mockClear()
await stopBackgroundProcess(SID, 'unreachable')
expect(items()).toEqual([expect.objectContaining({ id: 'unreachable', state: 'running' })])
expect(notifyError).toHaveBeenCalledWith(expect.any(Error), 'Could not stop the process')
})
it('dismisses and latches when Stop discovers the runtime is gone', async () => {
const request = vi.fn(async () => {
throw new Error('session not found')
})
reconcileBackgroundProcesses(SID, [running('rejected')])
$gateway.set({ request } as never)
await stopBackgroundProcess(SID, 'rejected')
await stopBackgroundProcess(SID, 'rejected')
expect(items()).toEqual([])
expect(request).toHaveBeenCalledTimes(1)
})
})
// ── Review-thread hardenings on the guard (#94950) ───────────────────────────
describe('refreshBackgroundProcesses dead-session guard hardenings', () => {
beforeEach(() => {
$backgroundStatusBySession.set({})
resetBackgroundPollingGuard()
})
afterEach(() => {
$gateway.set(null as never)
resetBackgroundPollingGuard()
})
it('matches the structured 4001 code, not a message substring, when a code is present', () => {
// Structured gateway rejection: the code decides, both directions.
expect(isSessionGoneForBackgroundPolling(new JsonRpcGatewayError('session not found', { code: 4001 }))).toBe(true)
expect(isSessionGoneForBackgroundPolling(new JsonRpcGatewayError('gone', { code: 4001 }))).toBe(true)
// An unrelated coded error whose text merely MENTIONS the phrase must not
// latch — that would freeze the status stack on a healthy session.
expect(
isSessionGoneForBackgroundPolling(
new JsonRpcGatewayError('tool failed: upstream said session not found', { code: 5007 })
)
).toBe(false)
// Codeless errors keep the message fallback (legacy frames).
expect(isSessionGoneForBackgroundPolling(new JsonRpcGatewayError('session not found'))).toBe(true)
})
it('a full guard reset (runtime re-mint) resumes polling every latched session', async () => {
const request = vi.fn(async () => {
throw new JsonRpcGatewayError('session not found', { code: 4001 })
})
$gateway.set({ request } as never)
await refreshBackgroundProcesses(SID)
await refreshBackgroundProcesses('sess-2')
await refreshBackgroundProcesses(SID)
await refreshBackgroundProcesses('sess-2')
expect(request).toHaveBeenCalledTimes(2)
// Gateway reconnect re-mints runtimes: the no-arg reset (wired at the
// reconnect seams) must clear every latched id, not just one.
resetBackgroundPollingGuard()
await refreshBackgroundProcesses(SID)
await refreshBackgroundProcesses('sess-2')
expect(request).toHaveBeenCalledTimes(4)
})
})
+519
View File
@@ -0,0 +1,519 @@
import { atom, computed } from 'nanostores'
import { translateNow } from '@/i18n'
import { stableArray } from '@/lib/stable-array'
import { type TodoItem, type TodoStatus, todoTree } from '@/lib/todos'
import { $gateway } from './gateway'
import { $goalsBySession, type GoalStatus } from './goals'
import { dispatchNativeNotification } from './native-notifications'
import { notifyError } from './notifications'
import { isSessionGone, isSessionGoneForBackgroundPolling, markSessionGone, noteRuntimeAlive } from './runtime-gone'
import { $sessions, lineageAliases } from './session'
import { ambientRequestFor } from './session-gone-latch'
import { $sessionStates, requestForOwnedSession } from './session-states'
import { $subagentsBySession, type SubagentProgress } from './subagents'
import { $todosBySession } from './todos'
export {
isSessionGone,
isSessionGoneForBackgroundPolling,
markSessionGone,
resetBackgroundPollingGuard
} from './runtime-gone'
/** Composer status stack feed — merged todos, subagents, background per session. */
export type StatusItemState = 'done' | 'failed' | 'running'
export type StatusItemType = 'background' | 'goal' | 'subagent' | 'todo'
export interface ComposerStatusItem {
/** background: non-zero exit shown inline when failed. */
exitCode?: number
/** subagent: active tool label shown on the right. */
currentTool?: string
/** todo: nesting depth (0 = top-level) for indented subtask rows. */
depth?: number
/** goal: active | paused | waiting | done. */
goalStatus?: GoalStatus
id: string
/** background process: captured stdout/stderr tail for the inline viewer. */
output?: string
/** subagent: its own stored session id — row click opens that session window
* (livestreamed by the gateway's child-session mirror). */
sessionId?: string
state: StatusItemState
title: string
/** todo: the full four-state status driving the row's checkmark glyph. */
todoStatus?: TodoStatus
type: StatusItemType
}
// Writable source for background work, synced from the gateway's process
// registry (`terminal(background=true)` spawns) via `process.list`.
export const $backgroundStatusBySession = atom<Record<string, ComposerStatusItem[]>>({})
// Stored session ids that have at least one RUNNING background process. The
// sidebar row reads this for a hollow dot — distinct from the filled dot of an
// active LLM turn — so the user can tell at a glance "this session has
// something chugging along in the background" even when the turn is idle.
//
// $backgroundStatusBySession is keyed by RUNTIME session id (gateway events
// and process.list both speak that); the sidebar row knows only the STORED id.
// $sessionStates bridges the two: runtime id → state.storedSessionId, then
// lineageAliases covers whichever tip of that conversation a surface holds.
// Perf: recomputes on every $sessionStates change (message deltas, tens/sec),
// but the background-running set rarely moves. `stableArray` keeps the prior
// reference when unchanged so rows reading this don't re-render per token.
let backgroundRunningIds: readonly string[] = []
export const $backgroundRunningSessionIds = computed(
[$backgroundStatusBySession, $sessionStates, $sessions],
(bg, states, sessions) => {
const ids = new Set<string>()
for (const [runtimeId, items] of Object.entries(bg)) {
if (!items.some(i => i.state === 'running')) {
continue
}
// Same fresh-chat fallback as the working/attention projections: before a
// conversation is persisted its runtime id is the id surfaces key on.
for (const alias of lineageAliases(states[runtimeId]?.storedSessionId ?? runtimeId, sessions)) {
ids.add(alias)
}
}
return (backgroundRunningIds = stableArray(backgroundRunningIds, [...ids]))
}
)
// Rows the user X-ed away. The registry keeps finished processes around for a
// while, so without this every refresh would resurrect a dismissed row.
const dismissedBySession = new Map<string, Set<string>>()
// Finished tasks self-clear so the stack only ever holds running work. Success
// goes quick; failure lingers longer so its exit code stays readable (the output
// also lives in the transcript). A manual X still drops either at once.
const SUCCESS_LINGER_MS = 4_000
const FAILURE_LINGER_MS = 12_000
const autoClearTimers = new Map<string, Map<string, ReturnType<typeof setTimeout>>>()
function scheduleAutoDismiss(sid: string, id: string, delayMs: number) {
let timers = autoClearTimers.get(sid)
if (timers?.has(id)) {
return
}
if (!timers) {
timers = new Map()
autoClearTimers.set(sid, timers)
}
timers.set(
id,
setTimeout(() => {
autoClearTimers.get(sid)?.delete(id)
dismissBackgroundProcess(sid, id)
}, delayMs)
)
}
function cancelAutoDismiss(sid: string, id: string) {
const timers = autoClearTimers.get(sid)
if (!timers) {
return
}
const timer = timers.get(id)
if (timer !== undefined) {
clearTimeout(timer)
timers.delete(id)
}
}
function cancelAllAutoDismiss(sid: string) {
const timers = autoClearTimers.get(sid)
if (!timers) {
return
}
for (const timer of timers.values()) {
clearTimeout(timer)
}
autoClearTimers.delete(sid)
}
const subToItem = (s: SubagentProgress): ComposerStatusItem => ({
currentTool: s.currentTool,
id: s.id,
sessionId: s.sessionId,
state: 'running',
title: s.goal,
type: 'subagent'
})
const todoToItem = (t: TodoItem, depth: number): ComposerStatusItem => ({
depth,
id: `todo:${t.id}`,
state: t.status === 'in_progress' ? 'running' : 'done',
title: t.content,
todoStatus: t.status,
type: 'todo'
})
const goalToItem = (goal: { detail?: string; status: GoalStatus; title: string }): ComposerStatusItem => ({
currentTool: goal.detail,
goalStatus: goal.status,
id: 'goal:standing',
state: goal.status === 'active' || goal.status === 'waiting' ? 'running' : 'done',
title: goal.title,
type: 'goal'
})
// The single thing the stack reads: a typed, merged item list per session.
//
// Identity contract: this computed's inputs churn constantly during a turn (a
// subagent tick, a 5s background poll, a todo update — in ANY session), but
// the merged output for most sessions is unchanged. Rebuilding fresh arrays
// and item objects every time handed every mounted composer stack a new
// reference per recompute — cross-session churn × open tiles. Stabilize both
// levels: an unchanged session keeps its previous array (and item objects),
// and a fully-unchanged map keeps its previous reference so `computed` skips
// the notify entirely ("preserve reference identity on no-ops").
const sameStatusItem = (a: ComposerStatusItem, b: ComposerStatusItem) =>
a.id === b.id &&
a.type === b.type &&
a.state === b.state &&
a.title === b.title &&
a.output === b.output &&
a.exitCode === b.exitCode &&
a.currentTool === b.currentTool &&
a.goalStatus === b.goalStatus &&
a.todoStatus === b.todoStatus &&
a.depth === b.depth &&
a.sessionId === b.sessionId
const stabilizeItems = (prev: ComposerStatusItem[] | undefined, next: ComposerStatusItem[]): ComposerStatusItem[] => {
if (!prev) {
return next
}
const merged = next.map((item, i) => (prev[i] && sameStatusItem(prev[i], item) ? prev[i] : item))
return merged.length === prev.length && merged.every((item, i) => item === prev[i]) ? prev : merged
}
let prevStatusItems: Record<string, ComposerStatusItem[]> = {}
export const $statusItemsBySession = computed(
[$goalsBySession, $subagentsBySession, $backgroundStatusBySession, $todosBySession],
(goals, subs, background, todos) => {
const out: Record<string, ComposerStatusItem[]> = {}
const push = (sid: string, items: ComposerStatusItem[]) => {
if (items.length > 0) {
out[sid] = out[sid] ? [...out[sid], ...items] : items
}
}
for (const [sid, list] of Object.entries(todos)) {
push(
sid,
todoTree(list).map(([t, depth]) => todoToItem(t, depth))
)
}
for (const [sid, goal] of Object.entries(goals)) {
push(sid, [goalToItem(goal)])
}
for (const [sid, list] of Object.entries(subs)) {
push(sid, list.filter(s => s.status === 'running' || s.status === 'queued').map(subToItem))
}
for (const [sid, list] of Object.entries(background)) {
push(sid, list)
}
let unchanged = Object.keys(prevStatusItems).length === Object.keys(out).length
for (const sid of Object.keys(out)) {
out[sid] = stabilizeItems(prevStatusItems[sid], out[sid]!)
unchanged &&= out[sid] === prevStatusItems[sid]
}
return (prevStatusItems = unchanged ? prevStatusItems : out)
}
)
// Fixed render order for the groups in the stack (top → bottom, above queue).
const TYPE_ORDER: readonly StatusItemType[] = ['goal', 'todo', 'subagent', 'background']
export interface StatusGroup {
items: ComposerStatusItem[]
type: StatusItemType
}
export function groupStatusItems(items: readonly ComposerStatusItem[]): StatusGroup[] {
const byType = new Map<StatusItemType, ComposerStatusItem[]>()
for (const item of items) {
const list = byType.get(item.type)
if (list) {
list.push(item)
} else {
byType.set(item.type, [item])
}
}
return TYPE_ORDER.filter(type => byType.has(type)).map(type => ({ items: byType.get(type)!, type }))
}
const writeBackground = (sid: string, items: ComposerStatusItem[]) => {
const current = $backgroundStatusBySession.get()
const next = { ...current }
if (items.length > 0) {
next[sid] = items
} else {
delete next[sid]
}
$backgroundStatusBySession.set(next)
}
// `tui_gateway` process.list entry (tools/process_registry.list_sessions + output_tail).
interface GatewayProcessEntry {
command?: string
exit_code?: number
output_tail?: string
session_id?: string
status?: string
}
const toBackgroundItem = (proc: GatewayProcessEntry): ComposerStatusItem => {
const exited = proc.status === 'exited'
const exitCode = typeof proc.exit_code === 'number' ? proc.exit_code : undefined
return {
exitCode,
id: proc.session_id ?? '',
output: proc.output_tail || undefined,
state: exited ? (exitCode ? 'failed' : 'done') : 'running',
title: (proc.command ?? '').split('\n')[0]!.trim() || 'background process',
type: 'background'
}
}
const sameItem = (a: ComposerStatusItem, b: ComposerStatusItem) =>
a.state === b.state && a.title === b.title && a.output === b.output && a.exitCode === b.exitCode
/**
* Layout-stable sync of the registry snapshot into the store: existing rows
* keep their position (status flips happen in place, never reorder), new
* processes append, dismissed ids stay gone, and unchanged rows keep their
* object identity so memoised rows skip re-rendering.
*/
export function reconcileBackgroundProcesses(sid: string, procs: GatewayProcessEntry[]) {
const dismissed = dismissedBySession.get(sid)
const fresh = new Map(
procs
.filter(proc => proc.session_id && !dismissed?.has(proc.session_id))
.map(proc => [proc.session_id!, toBackgroundItem(proc)])
)
const prev = $backgroundStatusBySession.get()[sid] ?? []
// running → exited since the last snapshot = a background process just finished.
const prevState = new Map(prev.map(item => [item.id, item.state]))
for (const [id, item] of fresh) {
if (item.state !== 'running' && prevState.get(id) === 'running') {
dispatchNativeNotification({
body: item.title,
kind: 'backgroundDone',
sessionId: sid,
title: translateNow(
item.state === 'failed'
? 'notifications.native.backgroundFailedTitle'
: 'notifications.native.backgroundDoneTitle'
)
})
}
}
const kept = prev.flatMap(old => {
const next = fresh.get(old.id)
fresh.delete(old.id)
return next ? [sameItem(old, next) ? old : next] : []
})
const next = [...kept, ...fresh.values()]
// Dismissals only need remembering while the registry still reports the id.
if (dismissed) {
const reported = new Set(procs.map(proc => proc.session_id))
for (const id of dismissed) {
if (!reported.has(id)) {
dismissed.delete(id)
}
}
}
// Arm the self-clear on every finished task (failures linger longer); cancel
// it for anything running again or gone from the snapshot.
const finishedDelay = new Map(
next
.filter(item => item.state !== 'running')
.map(item => [item.id, item.state === 'failed' ? FAILURE_LINGER_MS : SUCCESS_LINGER_MS])
)
for (const [id, delay] of finishedDelay) {
scheduleAutoDismiss(sid, id, delay)
}
for (const id of [...(autoClearTimers.get(sid)?.keys() ?? [])]) {
if (!finishedDelay.has(id)) {
cancelAutoDismiss(sid, id)
}
}
if (next.length === prev.length && next.every((item, i) => item === prev[i])) {
return
}
writeBackground(sid, next)
}
/** Pull the session's live process snapshot from the gateway. */
export async function refreshBackgroundProcesses(sid: string): Promise<void> {
const gateway = $gateway.get()
if (!sid || !gateway || isSessionGone(sid)) {
return
}
try {
const result = await requestForOwnedSession<{ processes?: GatewayProcessEntry[] }>(
sid,
ambientRequestFor(gateway),
'process.list',
{ session_id: sid }
)
reconcileBackgroundProcesses(sid, result?.processes ?? [])
// The binding answered, so it is healthy: refund the stored session's
// recovery budget (a heal that stuck must not count against the next one).
noteRuntimeAlive(sid)
} catch (error) {
// A gone session never comes back under this runtime id: stop polling it,
// or the 5s timer hammers the gateway with 4001s for the window's lifetime.
if (isSessionGoneForBackgroundPolling(error)) {
markSessionGone(sid)
return
}
// Transient socket loss — the next trigger (event or poll) retries.
}
}
/** X on a finished row: drop it now and keep it dropped across refreshes. */
export function dismissBackgroundProcess(sid: string, id: string) {
cancelAutoDismiss(sid, id)
const dismissed = dismissedBySession.get(sid) ?? new Set<string>()
dismissed.add(id)
dismissedBySession.set(sid, dismissed)
const list = $backgroundStatusBySession.get()[sid] ?? []
writeBackground(
sid,
list.filter(item => item.id !== id)
)
}
/** X on a running row: kill the process for real, THEN drop the row. Only drop
* on a confirmed kill — dismissing unconditionally (the old behavior) hid the
* row while the process lived on, stranding rogue tasks. On failure the row
* stays so the user can retry / see it didn't die. */
export async function stopBackgroundProcess(sid: string, id: string): Promise<void> {
const gateway = $gateway.get()
if (isSessionGone(sid)) {
// The backend has already declared this runtime gone, so there is no
// authoritative process left to kill through this session. Remove the
// stale local row instead of leaving the Stop button permanently inert.
dismissBackgroundProcess(sid, id)
return
}
if (!gateway) {
notifyError(new Error('Gateway is not connected'), 'Could not stop the process')
return
}
try {
await requestForOwnedSession(sid, ambientRequestFor(gateway), 'process.kill', { process_id: id, session_id: sid })
dismissBackgroundProcess(sid, id)
} catch (err) {
if (isSessionGoneForBackgroundPolling(err)) {
dismissBackgroundProcess(sid, id)
markSessionGone(sid)
return
}
notifyError(err, 'Could not stop the process')
}
}
/**
* Rewind cleanup: a restore/edit discards the turns that spawned these
* processes, so they belong to an abandoned timeline. Kill the live ones and
* drop every row. Ids are marked dismissed so an in-flight `process.list` poll
* (kill is async) can't resurrect them; reconcile garbage-collects those once
* the registry stops reporting them.
*/
export function resetSessionBackground(sid: string) {
if (!sid) {
return
}
cancelAllAutoDismiss(sid)
const gateway = $gateway.get()
const list = $backgroundStatusBySession.get()[sid] ?? []
const dismissed = dismissedBySession.get(sid) ?? new Set<string>()
for (const item of list) {
dismissed.add(item.id)
if (item.state === 'running') {
if (gateway && !isSessionGone(sid)) {
void requestForOwnedSession(sid, ambientRequestFor(gateway), 'process.kill', {
process_id: item.id,
session_id: sid
}).catch(error => {
if (isSessionGoneForBackgroundPolling(error)) {
markSessionGone(sid)
}
})
}
}
}
dismissedBySession.set(sid, dismissed)
writeBackground(sid, [])
}
@@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest'
import {
$composerSuggestionsBySession,
type ComposerSuggestion,
markSuggestionInvoked,
offerSuggestions,
suggestionKey
} from './composer-suggestions'
const suggestion = (id: string, provider = 'test'): ComposerSuggestion => ({
doneLabel: 'done',
doneTip: 'done',
id,
invoke: async () => {},
label: id,
provider,
tip: 'because',
workingLabel: 'working',
workingTip: 'working'
})
const pillsFor = (sessionId: string) => ($composerSuggestionsBySession.get()[sessionId] ?? []).map(s => s.id)
describe('composer suggestion bus', () => {
it('publishes event offerings per session and withdraws on empty offer', () => {
offerSuggestions('s1', 'test', [suggestion('a')])
expect(pillsFor('s1')).toEqual(['a'])
expect(pillsFor('s2')).toEqual([])
offerSuggestions('s1', 'test', [])
expect(pillsFor('s1')).toEqual([])
})
it('caps merged suggestions at two', () => {
offerSuggestions('s3', 'test', [suggestion('a'), suggestion('b'), suggestion('c')])
expect(pillsFor('s3')).toHaveLength(2)
offerSuggestions('s3', 'test', [])
})
it('dedupes by provider-namespaced key across providers', () => {
offerSuggestions('s4', 'p1', [suggestion('same', 'p1')])
offerSuggestions('s4', 'p2', [suggestion('same', 'p2')])
// Different providers, same id — distinct keys, both allowed.
expect(pillsFor('s4')).toEqual(['same', 'same'])
offerSuggestions('s4', 'p1', [])
offerSuggestions('s4', 'p2', [])
})
it('quiets a suggestion after it is repeatedly withdrawn uninvoked', () => {
// Three offer/withdraw cycles = three strikes.
for (let i = 0; i < 3; i += 1) {
offerSuggestions('s5', 'test', [suggestion('naggy')])
offerSuggestions('s5', 'test', [])
}
offerSuggestions('s5', 'test', [suggestion('naggy')])
expect(pillsFor('s5')).toEqual([])
offerSuggestions('s5', 'test', [])
})
it('an invoked suggestion never accrues strikes', () => {
for (let i = 0; i < 3; i += 1) {
offerSuggestions('s6', 'test', [suggestion('used')])
markSuggestionInvoked('s6', suggestionKey(suggestion('used')))
offerSuggestions('s6', 'test', [])
}
offerSuggestions('s6', 'test', [suggestion('used')])
expect(pillsFor('s6')).toEqual(['used'])
offerSuggestions('s6', 'test', [])
})
it('replaces an offer whose rendered copy changed under the same key', () => {
offerSuggestions('s7', 'test', [{ ...suggestion('linear'), tip: 'because you mentioned “linear”' }])
offerSuggestions('s7', 'test', [{ ...suggestion('linear'), tip: 'because you pasted linear.app' }])
// Same key, new trigger — the strip must paint the new reason, not the
// first one it ever saw.
expect(($composerSuggestionsBySession.get().s7 ?? []).map(s => s.tip)).toEqual(['because you pasted linear.app'])
offerSuggestions('s7', 'test', [])
})
it('re-offering the same key swaps in the fresh invoke closure', async () => {
const calls: string[] = []
const offer = (tag: string) =>
offerSuggestions('s8', 'test', [
{ ...suggestion('linear'), invoke: async () => void calls.push(tag), label: `Add linear ${tag}` }
])
offer('first')
offer('second')
await ($composerSuggestionsBySession.get().s8 ?? [])[0]!.invoke({ cancelled: () => false, sessionId: 's8' })
// A pinned first object means the pill runs work built for a draft the
// user has since changed.
expect(calls).toEqual(['second'])
offerSuggestions('s8', 'test', [])
})
it('keeps the array reference when nothing the pill paints changed', () => {
offerSuggestions('s9', 'test', [suggestion('linear')])
const first = $composerSuggestionsBySession.get().s9
offerSuggestions('s9', 'test', [suggestion('linear')])
expect($composerSuggestionsBySession.get().s9).toBe(first)
offerSuggestions('s9', 'test', [])
})
})
@@ -0,0 +1,306 @@
import { atom } from 'nanostores'
/**
* The composer suggestion bus — a generic, session-scoped feed for the pill
* strip above the composer (rendered by `composer/suggestion-pills.tsx`).
*
* Providers, not sources baked into the store: anything can offer a pill —
* draft keywords (the MCP directory was the first), session state, tool
* results, connection events. Two provider shapes:
*
* - **Draft providers** react to what the user is typing. Registered with
* `registerDraftProvider`, they run inside the existing debounced draft
* sampler (600ms, change-gated) and return suggestions for that session's
* draft. Pure-ish: given a draft, they decide; the bus handles debounce,
* session scoping, reference identity, and the cap.
* - **Event providers** push and withdraw suggestions directly via
* `offerSuggestion` / `withdrawSuggestion` from wherever their signal
* lives (a store listener, a gateway event handler). The bus applies the
* same session scoping and cap.
*
* The UX contract every suggestion signs (see PR #85036's pills):
* session-scoped, capped at MAX_SUGGESTIONS with draft suggestions ranked
* after event ones, self-limiting (a suggestion exists only while its
* trigger condition holds — providers withdraw it, there is NO dismiss
* affordance), and one-click: `invoke` runs the whole action with the pill
* narrating idle → working → done. No suggestion may block or shift the
* composer.
*/
export interface ComposerSuggestion {
/** Stable per-suggestion identity, unique within its provider
* (e.g. the server name, the skill name). */
id: string
/** Provider that offered it; `${provider}:${id}` is the bus-wide key. */
provider: string
/** Pill label, already localized ("Add Atlassian"). */
label: string
/** Tooltip explaining WHY this is being suggested. */
tip: string
/** Brand identity for the glyph slot; falls back to `icon`. */
brand?: string
/** Codicon name when there is no brand glyph (default: lightbulb). */
icon?: string
/** Runs the whole action; the pill shows `workingLabel` while it's
* in flight and `doneLabel` on success. Reject to return to idle
* (provider surfaces its own error toast). */
invoke: (context: { cancelled: () => boolean; sessionId: string | null }) => Promise<void>
/** Label while `invoke` runs ("Connecting Atlassian…"). */
workingLabel: string
/** Tooltip while working; clicking a working pill requests cancel. */
workingTip: string
/** Label after `invoke` resolves ("Added Atlassian"). */
doneLabel: string
/** Tooltip once done. */
doneTip: string
}
export const MAX_SUGGESTIONS = 2
/** Bus-wide key: provider-namespaced so two providers can't collide. */
export const suggestionKey = (suggestion: Pick<ComposerSuggestion, 'id' | 'provider'>): string =>
`${suggestion.provider}:${suggestion.id}`
/** Suggestions keyed by RUNTIME session id, exactly like
* `$composerActionsBySession`: drafts are per-session state, so suggestions
* derived from them are too. */
export const $composerSuggestionsBySession = atom<Record<string, ComposerSuggestion[]>>({})
const keyFor = (sessionId: string | null | undefined): string => sessionId ?? ''
// Everything the pill actually paints. Compared field-by-field rather than by
// key alone: a provider rebuilds its suggestion objects on every sample, so
// key equality is true constantly, and treating that as "no change" pins the
// FIRST object forever — the strip then paints a stale tip and, worse, calls a
// stale `invoke` closure. Comparing the rendered copy keeps the cheap bail-out
// for the common case (same draft, same match) while letting a genuinely
// changed offer through.
const RENDERED: readonly (keyof ComposerSuggestion)[] = [
'brand',
'doneLabel',
'doneTip',
'icon',
'label',
'tip',
'workingLabel',
'workingTip'
]
const sameSuggestions = (a: readonly ComposerSuggestion[], b: readonly ComposerSuggestion[]) =>
a.length === b.length &&
a.every((x, i) => {
const y = b[i]!
return suggestionKey(x) === suggestionKey(y) && RENDERED.every(field => x[field] === y[field])
})
function write(sessionId: string | null | undefined, suggestions: ComposerSuggestion[]): void {
const key = keyFor(sessionId)
const current = $composerSuggestionsBySession.get()
const existing = current[key] ?? []
// Unchanged sets keep their reference so the strip doesn't re-render.
if (sameSuggestions(existing, suggestions)) {
return
}
const next = { ...current }
if (suggestions.length > 0) {
next[key] = suggestions
} else {
delete next[key]
}
$composerSuggestionsBySession.set(next)
}
// ---------------------------------------------------------------------------
// Providers
// ---------------------------------------------------------------------------
export interface DraftProviderContext {
sessionId: string | null
/** The draft text at sample time. */
text: string
}
export type DraftProvider = (context: DraftProviderContext) => Promise<ComposerSuggestion[]>
const draftProviders = new Map<string, DraftProvider>()
/** Register a provider that derives suggestions from the draft. Runs inside
* the composer's debounced sampler; results replace that provider's previous
* offerings for the session. Returns an unregister fn (HMR hygiene). */
export function registerDraftProvider(name: string, provider: DraftProvider): () => void {
draftProviders.set(name, provider)
return () => {
draftProviders.delete(name)
}
}
// Event-provider offerings, merged with draft results on every write.
// Keyed session → provider → suggestions.
const eventOfferings = new Map<string, Map<string, ComposerSuggestion[]>>()
/** Offer suggestions from an event provider (session state, tool results,
* connection events…). Replaces that provider's previous offerings for the
* session; providers withdraw by offering []. */
export function offerSuggestions(
sessionId: string | null | undefined,
provider: string,
suggestions: ComposerSuggestion[]
): void {
const key = keyFor(sessionId)
let providers = eventOfferings.get(key)
if (!providers) {
providers = new Map()
eventOfferings.set(key, providers)
}
if (suggestions.length > 0) {
providers.set(provider, suggestions)
} else {
providers.delete(provider)
}
publish(sessionId ?? null)
}
// Last draft-provider results per session, merged with event offerings.
const draftOfferings = new Map<string, ComposerSuggestion[]>()
// ---------------------------------------------------------------------------
// Declined ledger (VS Code's hardest-won recommendation lesson: never keep
// re-firing at someone who has seen the offer and not taken it)
// ---------------------------------------------------------------------------
// Times a suggestion key was WITHDRAWN without ever being invoked, per
// session. A pill the user watched appear and let die N times is a declined
// offer — stop making it for the rest of the session. Session-scoped and
// in-memory on purpose: a fresh session is a fresh chance, and acting on a
// pill clears its count.
const IGNORED_LIMIT = 3
const ignoredCounts = new Map<string, Map<string, number>>()
const shown = new Map<string, Set<string>>()
/** The pill strip marks a suggestion invoked so its withdrawal isn't
* miscounted as the user ignoring it. */
export function markSuggestionInvoked(sessionId: string | null | undefined, key: string): void {
ignoredCounts.get(keyFor(sessionId))?.delete(key)
shown.get(keyFor(sessionId))?.delete(key)
}
const quieted = (sessionKey: string, key: string): boolean =>
(ignoredCounts.get(sessionKey)?.get(key) ?? 0) >= IGNORED_LIMIT
// Suggestions visible in the last publish that vanish uninvoked get a strike.
function recordWithdrawals(sessionKey: string, next: readonly ComposerSuggestion[]): void {
const previous = shown.get(sessionKey)
if (previous) {
const surviving = new Set(next.map(suggestionKey))
for (const key of previous) {
if (!surviving.has(key)) {
const counts = ignoredCounts.get(sessionKey) ?? new Map<string, number>()
counts.set(key, (counts.get(key) ?? 0) + 1)
ignoredCounts.set(sessionKey, counts)
}
}
}
shown.set(sessionKey, new Set(next.map(suggestionKey)))
}
/** Event offerings first (they carry session/tool state, stronger signal
* than draft keywords), then draft matches, capped. */
function publish(sessionId: string | null): void {
const key = keyFor(sessionId)
const event = [...(eventOfferings.get(key)?.values() ?? [])].flat()
const draft = draftOfferings.get(key) ?? []
const seen = new Set<string>()
const merged: ComposerSuggestion[] = []
for (const suggestion of [...event, ...draft]) {
const k = suggestionKey(suggestion)
if (!seen.has(k) && !quieted(key, k)) {
seen.add(k)
merged.push(suggestion)
}
if (merged.length >= MAX_SUGGESTIONS) {
break
}
}
recordWithdrawals(key, merged)
write(sessionId, merged)
}
// ---------------------------------------------------------------------------
// Draft sampling (the composer's runtime subscription feeds this)
// ---------------------------------------------------------------------------
const SAMPLE_DEBOUNCE_MS = 600
// Per-session debounce/generation so a tile composer's sampling never stomps
// the primary's (each session settles independently).
const sampleTimers = new Map<string, number>()
const sampleGenerations = new Map<string, number>()
/**
* Feed a session's draft snapshot to the draft providers. Called from that
* composer's runtime subscription on every change, but internally debounced
* and change-gated: the store only writes when the session's merged set
* actually differs, so typing within a line costs nothing downstream.
*/
export function sampleComposerDraft(sessionId: string | null | undefined, text: string): void {
const key = keyFor(sessionId)
window.clearTimeout(sampleTimers.get(key))
const generation = (sampleGenerations.get(key) ?? 0) + 1
sampleGenerations.set(key, generation)
// Too short to mean anything — clear draft offerings without running providers.
if (text.trim().length < 3) {
draftOfferings.delete(key)
publish(sessionId ?? null)
return
}
sampleTimers.set(
key,
window.setTimeout(() => {
void Promise.all(
[...draftProviders.values()].map(provider =>
provider({ sessionId: sessionId ?? null, text }).catch((): ComposerSuggestion[] => [])
)
).then(results => {
// A newer sample for THIS session superseded this one mid-flight.
if (generation !== sampleGenerations.get(key)) {
return
}
draftOfferings.set(key, results.flat())
publish(sessionId ?? null)
})
}, SAMPLE_DEBOUNCE_MS)
)
}
/** Drop a session's suggestions outright (composer unmount / session leave).
* Draft offerings die with the draft; event offerings persist — their
* providers own that lifecycle and withdraw on their own signal. */
export function clearDraftSuggestions(sessionId: string | null | undefined): void {
const key = keyFor(sessionId)
window.clearTimeout(sampleTimers.get(key))
draftOfferings.delete(key)
publish(sessionId ?? null)
}
+294
View File
@@ -0,0 +1,294 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
$composerAttachments,
$voiceConversationStartRequest,
addComposerAttachment,
clearSessionDraft,
type ComposerAttachment,
createComposerAttachmentOccurrenceId,
createComposerAttachmentScope,
migrateSessionDraft,
removeComposerAttachment,
requestVoiceConversationStart,
SESSION_DRAFTS_STORAGE_KEY,
stashSessionDraft,
takeSessionDraft,
takeVoiceConversationStart,
updateComposerAttachment
} from './composer'
describe('voice conversation start requests', () => {
it('latches each request until the main composer consumes it once', () => {
requestVoiceConversationStart()
const first = $voiceConversationStartRequest.get()
expect(takeVoiceConversationStart(first)).toBe(true)
expect(takeVoiceConversationStart(first)).toBe(false)
requestVoiceConversationStart()
expect(takeVoiceConversationStart($voiceConversationStartRequest.get())).toBe(true)
})
})
function attachment(overrides: Partial<ComposerAttachment> & Pick<ComposerAttachment, 'id'>): ComposerAttachment {
return { kind: 'file', label: 'doc.pdf', ...overrides }
}
describe('updateComposerAttachment', () => {
afterEach(() => {
$composerAttachments.set([])
})
it('replaces an existing attachment in place', () => {
addComposerAttachment(attachment({ id: 'file:a', uploadState: 'uploading' }))
const updated = updateComposerAttachment(attachment({ id: 'file:a', attachedSessionId: 'sess-1' }))
expect(updated).toBe(true)
const current = $composerAttachments.get()
expect(current).toHaveLength(1)
expect(current[0]?.attachedSessionId).toBe('sess-1')
expect(current[0]?.uploadState).toBeUndefined()
})
it('does NOT resurrect an attachment the user removed mid-upload', () => {
// Drop → eager upload starts → user removes the chip → upload resolves.
// The late success must not re-add the removed attachment.
addComposerAttachment(attachment({ id: 'file:a', uploadState: 'uploading' }))
removeComposerAttachment('file:a')
const updated = updateComposerAttachment(attachment({ id: 'file:a', attachedSessionId: 'sess-1' }))
expect(updated).toBe(false)
expect($composerAttachments.get()).toHaveLength(0)
})
it('updates only the exact attachment occurrence captured before an async operation', () => {
const scope = createComposerAttachmentScope()
const first = attachment({
id: 'image:a',
kind: 'image',
occurrenceId: createComposerAttachmentOccurrenceId(),
path: '/tmp/a.png'
})
const replacement = attachment({
id: 'image:a',
kind: 'image',
occurrenceId: createComposerAttachmentOccurrenceId(),
path: '/tmp/a.png'
})
scope.add(first)
scope.remove(first.id)
scope.add(replacement)
expect(scope.updateIfCurrent(first, { thumbnailUrl: 'data:image/png;base64,stale' })).toBe(false)
expect(scope.$attachments.get()).toEqual([replacement])
expect(scope.updateIfCurrent(replacement, { thumbnailUrl: 'data:image/png;base64,current' })).toBe(true)
expect(scope.$attachments.get()[0]?.thumbnailUrl).toBe('data:image/png;base64,current')
})
it('recognizes the same attachment occurrence after a session-draft clone', () => {
const scope = createComposerAttachmentScope()
const original = attachment({
id: 'image:draft',
kind: 'image',
occurrenceId: createComposerAttachmentOccurrenceId(),
path: '/tmp/draft.png'
})
stashSessionDraft('session-a', '', [original])
const restored = takeSessionDraft('session-a').attachments[0]!
scope.add(restored)
expect(restored).not.toBe(original)
expect(scope.updateIfCurrent(original, { thumbnailUrl: 'data:image/png;base64,current' })).toBe(true)
expect(scope.$attachments.get()[0]?.thumbnailUrl).toBe('data:image/png;base64,current')
clearSessionDraft('session-a')
})
it('merges concurrent staging fields without discarding an existing thumbnail', () => {
const scope = createComposerAttachmentScope()
const original = attachment({
id: 'image:staging',
kind: 'image',
occurrenceId: createComposerAttachmentOccurrenceId(),
path: 'C:\\Users\\alice\\Pictures\\photo.png'
})
scope.add(original)
expect(scope.updateIfCurrent(original, { thumbnailUrl: 'data:image/png;base64,current' })).toBe(true)
expect(
scope.updateIfCurrent(original, {
attachedSessionId: 'session-1',
path: '/root/.hermes/attachments/photo.png',
uploadState: undefined
})
).toBe(true)
expect(scope.$attachments.get()[0]).toMatchObject({
attachedSessionId: 'session-1',
path: '/root/.hermes/attachments/photo.png',
thumbnailUrl: 'data:image/png;base64,current'
})
})
it('removes submitted occurrences while preserving unrelated attachments', () => {
const scope = createComposerAttachmentScope()
const submitted = attachment({
id: 'image:submitted',
kind: 'image',
occurrenceId: 'occurrence-submitted'
})
const other = attachment({ id: 'file:other', occurrenceId: 'occurrence-other' })
scope.add(submitted)
scope.add(other)
scope.removeOccurrences([submitted])
expect(scope.$attachments.get()).toEqual([other])
})
it('preserves a same-id replacement of a submitted occurrence', () => {
const scope = createComposerAttachmentScope()
const submitted = attachment({
id: 'image:submitted',
kind: 'image',
occurrenceId: 'occurrence-submitted'
})
const replacement = attachment({
...submitted,
occurrenceId: 'occurrence-replacement'
})
scope.add(replacement)
scope.removeOccurrences([submitted])
expect(scope.$attachments.get()).toEqual([replacement])
})
it('preserves a newer same-id legacy attachment and still emits on successful cleanup', () => {
const scope = createComposerAttachmentScope()
const submitted = attachment({ id: 'url:https://example.com', kind: 'url', label: 'old' })
const replacement = attachment({ id: submitted.id, kind: 'url', label: 'new' })
const listener = vi.fn()
const unlisten = scope.$attachments.listen(listener)
scope.add(submitted)
scope.remove(submitted.id)
scope.add(replacement)
listener.mockClear()
scope.removeOccurrences([submitted])
expect(scope.$attachments.get()).toEqual([replacement])
expect(listener).toHaveBeenCalledTimes(1)
unlisten()
})
it('removes the exact submitted legacy attachment', () => {
const scope = createComposerAttachmentScope()
const submitted = attachment({ id: 'url:https://example.com', kind: 'url' })
scope.add(submitted)
scope.removeOccurrences([submitted])
expect(scope.$attachments.get()).toEqual([])
})
})
describe('session drafts', () => {
afterEach(() => {
for (const scope of ['session-a', 'session-b', null]) {
clearSessionDraft(scope)
}
window.localStorage.clear()
})
it('keeps drafts isolated per session scope', () => {
stashSessionDraft('session-a', 'draft a', [])
stashSessionDraft('session-b', 'draft b', [attachment({ id: 'image:b', kind: 'image' })])
expect(takeSessionDraft('session-a')).toEqual({ attachments: [], text: 'draft a' })
expect(takeSessionDraft('session-b').text).toBe('draft b')
expect(takeSessionDraft('session-b').attachments.map(a => a.id)).toEqual(['image:b'])
})
it('scopes the unsaved new-session draft separately from real sessions', () => {
stashSessionDraft(null, 'new chat draft', [])
stashSessionDraft('session-a', 'session draft', [])
expect(takeSessionDraft(null).text).toBe('new chat draft')
expect(takeSessionDraft(undefined).text).toBe('new chat draft')
expect(takeSessionDraft('session-a').text).toBe('session draft')
})
it('persists draft text (not attachments) to localStorage', () => {
stashSessionDraft('session-a', 'survives reload', [attachment({ id: 'file:a' })])
const persisted = JSON.parse(window.localStorage.getItem(SESSION_DRAFTS_STORAGE_KEY) ?? '{}') as Record<
string,
string
>
expect(persisted['session-a']).toBe('survives reload')
})
it('evicts empty drafts instead of leaving stale entries behind', () => {
stashSessionDraft('session-a', 'saved', [])
stashSessionDraft('session-a', ' ', [])
expect(takeSessionDraft('session-a')).toEqual({ attachments: [], text: '' })
})
it('clears a stashed draft after an accepted submit', () => {
stashSessionDraft('session-a', 'sent prompt', [attachment({ id: 'file:a' })])
clearSessionDraft('session-a')
expect(takeSessionDraft('session-a')).toEqual({ attachments: [], text: '' })
})
it('returns clones so callers cannot mutate the stash', () => {
stashSessionDraft('session-a', 'draft', [attachment({ id: 'file:a' })])
const taken = takeSessionDraft('session-a')
taken.attachments[0]!.label = 'mutated'
expect(takeSessionDraft('session-a').attachments[0]?.label).toBe('doc.pdf')
})
it('migrates a tip-keyed draft onto the post-compression tip', () => {
const tipBefore = '20260720_062637_ad96b3'
const tipAfter = '20260720_071049_a28905'
stashSessionDraft(tipBefore, 'half typed while thinking', [])
expect(migrateSessionDraft(tipBefore, tipAfter)).toBe(true)
expect(takeSessionDraft(tipAfter).text).toBe('half typed while thinking')
expect(takeSessionDraft(tipBefore).text).toBe('')
clearSessionDraft(tipAfter)
})
it('does not overwrite a non-empty destination draft during migration', () => {
stashSessionDraft('from', 'old tip draft', [])
stashSessionDraft('to', 'already typed on new tip', [])
expect(migrateSessionDraft('from', 'to')).toBe(false)
expect(takeSessionDraft('to').text).toBe('already typed on new tip')
expect(takeSessionDraft('from').text).toBe('old tip draft')
clearSessionDraft('from')
clearSessionDraft('to')
})
})
+598
View File
@@ -0,0 +1,598 @@
import { atom } from 'nanostores'
import { deriveDraftTitle } from '@/lib/draft-title'
import { triggerHaptic } from '@/lib/haptics'
export interface ComposerAttachment {
id: string
/** Renderer-lifetime identity for one attachment occurrence. Unlike `id`,
* which is content/path-derived, this survives draft cloning but changes
* when the user removes and re-adds the same attachment. */
occurrenceId?: string
kind: 'file' | 'folder' | 'image' | 'review' | 'terminal' | 'url'
label: string
detail?: string
refText?: string
/** Legacy/on-demand full source. New local image chips omit this and read
* `path` only when the lightbox opens, avoiding retained multi-MB base64. */
previewUrl?: string
/** Downscaled data URL for the attachment card and optimistic bubble only. */
thumbnailUrl?: string
path?: string
attachedSessionId?: string
/** Set while the file/image bytes are being staged into the session
* workspace (remote upload or local stage), and 'error' if that failed.
* Drives the spinner / error state on the composer attachment card. */
uploadState?: 'uploading' | 'error'
}
export type ComposerAttachmentPatch = Partial<Omit<ComposerAttachment, 'id' | 'occurrenceId'>>
export const $composerDraft = atom('')
export const $composerAttachments = atom<ComposerAttachment[]>([])
export const $composerTerminalSelections = atom<Record<string, string>>({})
// Latched because opening a fresh session may remount the main composer before
// it can start voice. Session-tile composers deliberately never consume this.
export const $voiceConversationStartRequest = atom(0)
let nextVoiceStartRequest = 0
let handledVoiceStartRequest = 0
export const createComposerAttachmentOccurrenceId = (): string => crypto.randomUUID()
export const requestVoiceConversationStart = (): void => $voiceConversationStartRequest.set(++nextVoiceStartRequest)
export const takeVoiceConversationStart = (current: number): boolean => {
if (current <= handledVoiceStartRequest) {
return false
}
handledVoiceStartRequest = current
return true
}
// ---------------------------------------------------------------------------
// Composer scopes — one live attachment set PER MOUNTED COMPOSER. The main
// chat's scope wraps the module-level atom above (all existing readers keep
// working); each session tile creates its own so two composers on screen
// never share chips. Draft text needs no scope: it lives in each ChatBar's
// DOM + draftRef and stashes per session key already.
// ---------------------------------------------------------------------------
export interface ComposerAttachmentScope {
$attachments: ReturnType<typeof atom<ComposerAttachment[]>>
add(attachment: ComposerAttachment): void
clear(): void
remove(id: string): ComposerAttachment | null
removeOccurrences(attachments: readonly ComposerAttachment[]): void
setUploadState(id: string, uploadState?: ComposerAttachment['uploadState']): void
update(attachment: ComposerAttachment): boolean
updateIfCurrent(expected: ComposerAttachment, patch: ComposerAttachmentPatch): boolean
}
function attachmentOccurrenceIndex(attachments: ComposerAttachment[], expected: ComposerAttachment): number {
return attachments.findIndex(item =>
expected.occurrenceId === undefined
? item === expected
: item.id === expected.id && item.occurrenceId === expected.occurrenceId
)
}
export function createComposerAttachmentScope($attachments = atom<ComposerAttachment[]>([])): ComposerAttachmentScope {
return {
$attachments,
add(attachment) {
const previous = $attachments.get()
const next = upsertAttachment(previous, attachment)
$attachments.set(next)
if (next.length > previous.length && attachment.kind !== 'url') {
triggerHaptic('selection')
}
},
clear() {
$attachments.set([])
},
remove(id) {
const current = $attachments.get()
const removed = current.find(attachment => attachment.id === id) || null
$attachments.set(current.filter(attachment => attachment.id !== id))
return removed
},
removeOccurrences(attachments) {
const current = $attachments.get()
const submittedOccurrences = new Set(
attachments
.filter(attachment => attachment.occurrenceId !== undefined)
.map(attachment => `${attachment.id}\u0000${attachment.occurrenceId}`)
)
const submittedLegacy = new Set(attachments.filter(attachment => attachment.occurrenceId === undefined))
const next = current.filter(attachment =>
attachment.occurrenceId === undefined
? !submittedLegacy.has(attachment)
: !submittedOccurrences.has(`${attachment.id}\u0000${attachment.occurrenceId}`)
)
// Preserve clear()'s notification semantics even when no captured
// occurrence remains. Some composer consumers settle local state on the
// successful-submit store emission.
$attachments.set(next)
},
setUploadState(id, uploadState) {
const current = $attachments.get()
const index = current.findIndex(attachment => attachment.id === id)
if (index < 0) {
return
}
const next = [...current]
next[index] = { ...next[index]!, uploadState }
$attachments.set(next)
},
update(attachment) {
const current = $attachments.get()
const index = current.findIndex(item => item.id === attachment.id)
if (index < 0) {
return false
}
const next = [...current]
next[index] = attachment
$attachments.set(next)
return true
},
updateIfCurrent(expected, patch) {
const current = $attachments.get()
const index = attachmentOccurrenceIndex(current, expected)
if (index < 0) {
return false
}
const next = [...current]
next[index] = { ...next[index]!, ...patch }
$attachments.set(next)
return true
}
}
}
/** The main chat's scope — the module-level atom, so every existing
* `$composerAttachments` reader/writer IS this scope. */
export const mainComposerScope = createComposerAttachmentScope($composerAttachments)
// Per-thread draft stash for the decoupled composer. Session lifecycle never
// touches this — only ChatBar's scope swap reads/writes it. Text mirrors to
// localStorage; attachments are memory-only (blobs, upload state).
export const SESSION_DRAFTS_STORAGE_KEY = 'hermes:composer-drafts:v3'
const NEW_SESSION_DRAFT_KEY = '__new__'
const MAX_PERSISTED_DRAFTS = 50
const EMPTY_SESSION_DRAFT: SessionDraft = { attachments: [], text: '' }
export interface SessionDraft {
attachments: ComposerAttachment[]
text: string
}
const draftKey = (scope: string | null | undefined) => scope?.trim() || NEW_SESSION_DRAFT_KEY
const cloneDraft = (draft: SessionDraft): SessionDraft => ({
attachments: draft.attachments.map(attachment => ({ ...attachment })),
text: draft.text
})
function loadPersistedDraftTexts(): [string, SessionDraft][] {
try {
const raw = window.localStorage.getItem(SESSION_DRAFTS_STORAGE_KEY)
if (!raw) {
return []
}
return Object.entries(JSON.parse(raw) as Record<string, string>).map(([key, text]) => [
key,
{ attachments: [], text }
])
} catch {
return []
}
}
const draftsBySession = new Map<string, SessionDraft>(loadPersistedDraftTexts())
/**
* Patch one asynchronous attachment occurrence wherever the main composer owns
* it. During a session switch the occurrence moves from the live atom into the
* per-session in-memory draft stash; a preview may finish on either side of
* that handoff. Updating both stores is safe because occurrence ids are unique,
* and merging into the latest object preserves concurrent staging metadata.
*/
export function patchMainComposerAttachmentOccurrence(
expected: ComposerAttachment,
patch: ComposerAttachmentPatch
): boolean {
let updated = mainComposerScope.updateIfCurrent(expected, patch)
for (const [key, draft] of draftsBySession) {
const index = attachmentOccurrenceIndex(draft.attachments, expected)
if (index < 0) {
continue
}
const attachments = [...draft.attachments]
attachments[index] = { ...attachments[index]!, ...patch }
draftsBySession.set(key, { ...draft, attachments })
updated = true
}
return updated
}
/**
* What each unsent draft would be called, keyed the same way its text is.
*
* A draft has no session to carry a title, so the tab showing it reads this
* instead of the "New session" placeholder. Written from `stashSessionDraft`,
* the one funnel every composer's text already flows through — the debounce
* that persists a draft is the same beat that renames its tab, so typing costs
* nothing extra. Only tabs showing a draft subscribe, and each selects its own
* key, so a rename repaints one label rather than the strip.
*
* Seeded from the persisted texts: a draft left open across a restart comes
* back already named.
*/
export const $draftTitles = atom<Record<string, string>>(
Object.fromEntries(
[...draftsBySession].map(([key, draft]) => [key, deriveDraftTitle(draft.text)]).filter(([, title]) => title)
)
)
/** Read one draft's title out of the map — for a `useStoreSelector`, so a tab
* repaints on its OWN rename rather than on every draft's. */
export const draftTitleIn = (titles: Record<string, string>, scope: string | null | undefined): string =>
titles[draftKey(scope)] ?? ''
export const draftTitleFor = (scope: string | null | undefined): string => draftTitleIn($draftTitles.get(), scope)
function publishDraftTitle(key: string, title: string): void {
const current = $draftTitles.get()
if ((current[key] ?? '') === title) {
return
}
const next = { ...current }
if (title) {
next[key] = title
} else {
delete next[key]
}
$draftTitles.set(next)
}
/**
* Re-read the persisted drafts written by ANOTHER window into this one's map.
*
* Drafts are per-renderer state backed by shared localStorage, and the map
* above is read exactly once at module load. Two windows on the same session
* (HUD mode ⇄ the app window) therefore diverge the moment either one types:
* whichever window mounted first keeps its stale copy forever, so text typed
* in the HUD is simply gone when you return to the app.
*
* Merge, don't clobber — the local map may hold attachments (never persisted)
* that the incoming text-only snapshot can't know about.
*/
export function reloadPersistedDrafts(): void {
const incoming = new Map(loadPersistedDraftTexts())
for (const [key, draft] of incoming) {
const local = draftsBySession.get(key)
draftsBySession.set(key, local?.attachments.length ? { ...local, text: draft.text } : draft)
publishDraftTitle(key, deriveDraftTitle(draft.text))
}
// A key that vanished from storage was cleared (sent) in the other window.
for (const key of [...draftsBySession.keys()]) {
if (!incoming.has(key)) {
draftsBySession.delete(key)
publishDraftTitle(key, '')
}
}
}
// localStorage `storage` events fire across Electron BrowserWindows of the
// same origin, so the other window's write is the sync signal.
if (typeof window !== 'undefined') {
window.addEventListener('storage', event => {
if (event.key === SESSION_DRAFTS_STORAGE_KEY) {
reloadPersistedDrafts()
}
})
}
/**
* Push a composer's live text into the shared stash (`flush`), or repaint it
* from the stash (`reload`).
*
* Both halves of the HUD handoff need this. The stash is the only draft state
* two windows share, but a mounted composer only consults it when its session
* scope changes — so entering HUD mode has to flush the app window's in-editor
* text down to the stash before the HUD boots and reads it, and leaving has to
* repaint the app's editor from whatever the HUD left behind (usually empty,
* because the HUD sent it).
*
* Dispatched synchronously, unlike the focus bus: the flush must complete
* before the HUD window is created.
*/
const DRAFT_SYNC_EVENT = 'hermes:composer-draft-sync'
export type ComposerDraftSyncMode = 'flush' | 'reload'
interface ComposerDraftSyncDetail {
mode: ComposerDraftSyncMode
target: string
}
export function requestComposerDraftSync(mode: ComposerDraftSyncMode, target = 'main'): void {
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent<ComposerDraftSyncDetail>(DRAFT_SYNC_EVENT, { detail: { mode, target } }))
}
}
export function onComposerDraftSyncRequest(handler: (detail: ComposerDraftSyncDetail) => void): () => void {
if (typeof window === 'undefined') {
return () => undefined
}
const listener = (event: Event) => handler((event as CustomEvent<ComposerDraftSyncDetail>).detail)
window.addEventListener(DRAFT_SYNC_EVENT, listener)
return () => window.removeEventListener(DRAFT_SYNC_EVENT, listener)
}
function persistDraftTexts() {
try {
const entries = [...draftsBySession]
.filter(([, draft]) => draft.text)
.slice(-MAX_PERSISTED_DRAFTS)
.map(([key, draft]) => [key, draft.text] as const)
if (entries.length === 0) {
window.localStorage.removeItem(SESSION_DRAFTS_STORAGE_KEY)
} else {
window.localStorage.setItem(SESSION_DRAFTS_STORAGE_KEY, JSON.stringify(Object.fromEntries(entries)))
}
} catch {
// Best-effort only — quota/private-mode must never break typing.
}
}
export function stashSessionDraft(scope: string | null | undefined, text: string, attachments: ComposerAttachment[]) {
const key = draftKey(scope)
// Delete-then-set keeps MRU order for MAX_PERSISTED_DRAFTS eviction.
draftsBySession.delete(key)
if (text.trim() || attachments.length > 0) {
draftsBySession.set(key, cloneDraft({ attachments, text }))
}
persistDraftTexts()
publishDraftTitle(key, deriveDraftTitle(text))
}
export function takeSessionDraft(scope: string | null | undefined): SessionDraft {
const stashed = draftsBySession.get(draftKey(scope))
return stashed ? cloneDraft(stashed) : EMPTY_SESSION_DRAFT
}
export const clearSessionDraft = (scope: string | null | undefined) => stashSessionDraft(scope, '', [])
/**
* Move a stashed composer draft from one session key onto another.
*
* Auto-compression rotates the live stored tip id (root → continuation) while
* the user may still be typing. Drafts keyed on the obsolete tip would otherwise
* vanish from the composer when selection follows the new tip. No-op unless both
* keys resolve, differ, and the source has content. Does not overwrite a
* non-empty destination draft.
*/
export function migrateSessionDraft(fromKey: string | null | undefined, toKey: string | null | undefined): boolean {
const from = draftKey(fromKey)
const to = draftKey(toKey)
if (!fromKey || !toKey || from === to) {
return false
}
const source = draftsBySession.get(from)
if (!source || (!source.text.trim() && source.attachments.length === 0)) {
return false
}
const dest = draftsBySession.get(to)
if (dest && (dest.text.trim() || dest.attachments.length > 0)) {
return false
}
stashSessionDraft(toKey, source.text, source.attachments)
clearSessionDraft(fromKey)
return true
}
export function setComposerDraft(value: string) {
$composerDraft.set(value)
}
export function appendComposerDraft(value: string) {
const text = value.trim()
if (!text) {
return
}
const current = $composerDraft.get()
const separator = current && !current.endsWith('\n') ? '\n\n' : ''
$composerDraft.set(`${current}${separator}${text}`)
}
export function appendComposerInline(value: string) {
const text = value.trim()
if (!text) {
return
}
const current = $composerDraft.get().trimEnd()
const separator = current ? ' ' : ''
$composerDraft.set(`${current}${separator}${text}`)
}
export function clearComposerDraft() {
$composerDraft.set('')
}
// Main-scope conveniences — the names the app has always used.
export const addComposerAttachment = (attachment: ComposerAttachment) => mainComposerScope.add(attachment)
export const removeComposerAttachment = (id: string) => mainComposerScope.remove(id)
/** Replace an existing attachment in place by id. No-op (returns false) when the
* id is gone — e.g. the user removed the chip while an eager upload was still in
* flight, so a late success must NOT resurrect it. Use this instead of
* addComposerAttachment for async results that may land after a removal. */
export const updateComposerAttachment = (attachment: ComposerAttachment) => mainComposerScope.update(attachment)
export const clearComposerAttachments = () => mainComposerScope.clear()
/** Update only the upload state of an existing attachment (no-op if it's gone,
* e.g. the user removed it mid-upload). Pass `undefined` to clear it. */
export const setComposerAttachmentUploadState = (id: string, uploadState?: ComposerAttachment['uploadState']) =>
mainComposerScope.setUploadState(id, uploadState)
const TERMINAL_REF_RE = /@terminal:(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
function unquoteRefValue(raw: string) {
const head = raw[0]
const tail = raw[raw.length - 1]
const quoted = (head === '`' && tail === '`') || (head === '"' && tail === '"') || (head === "'" && tail === "'")
return (quoted ? raw.slice(1, -1) : raw).replace(/[,.;!?]+$/, '').trim()
}
function terminalLabelsFromDraft(draft: string) {
const labels: string[] = []
const seen = new Set<string>()
for (const match of draft.matchAll(TERMINAL_REF_RE)) {
const label = unquoteRefValue(match[1] || '')
if (!label || seen.has(label)) {
continue
}
seen.add(label)
labels.push(label)
}
return labels
}
export function setComposerTerminalSelection(label: string, text: string) {
const nextLabel = label.trim()
const nextText = text.trim()
if (!nextLabel || !nextText) {
return
}
const current = $composerTerminalSelections.get()
if (current[nextLabel] === nextText) {
return
}
$composerTerminalSelections.set({
...current,
[nextLabel]: nextText
})
}
export function reconcileComposerTerminalSelections(draft: string) {
const current = $composerTerminalSelections.get()
const labels = new Set(terminalLabelsFromDraft(draft))
let changed = false
const next: Record<string, string> = {}
for (const [label, text] of Object.entries(current)) {
if (!labels.has(label)) {
changed = true
continue
}
next[label] = text
}
if (changed) {
$composerTerminalSelections.set(next)
}
}
export function terminalContextBlocksFromDraft(draft: string) {
const labels = terminalLabelsFromDraft(draft)
if (labels.length === 0) {
return []
}
const selections = $composerTerminalSelections.get()
return labels.flatMap(label => {
const text = selections[label]?.trim()
if (!text) {
return []
}
return `\`\`\`terminal\n${text}\n\`\`\``
})
}
export function clearComposerTerminalSelections() {
if (Object.keys($composerTerminalSelections.get()).length === 0) {
return
}
$composerTerminalSelections.set({})
}
function upsertAttachment(attachments: ComposerAttachment[], attachment: ComposerAttachment) {
const index = attachments.findIndex(item => item.id === attachment.id)
if (index < 0) {
return [...attachments, attachment]
}
const next = [...attachments]
next[index] = attachment
return next
}
+40
View File
@@ -0,0 +1,40 @@
import { atom } from 'nanostores'
export interface ConfirmRequest {
title: string
description?: string
confirmLabel?: string
cancelLabel?: string
destructive?: boolean
}
export interface PendingConfirm extends ConfirmRequest {
resolve: (confirmed: boolean) => void
}
export const $confirmRequest = atom<null | PendingConfirm>(null)
// Imperative front door to ConfirmDialog, for handlers that want the answer
// inline the way window.confirm gave it — `if (!ok) return`. A surface that
// wants the busy → done beat or an inline error should mount <ConfirmDialog>
// itself and hand it the async onConfirm.
export function confirm(request: ConfirmRequest): Promise<boolean> {
// One modal at a time: a second ask supersedes the first, which answers no.
settleConfirm(false)
return new Promise<boolean>(resolve => {
$confirmRequest.set({ ...request, resolve })
})
}
/** Answer the open request, if there still is one. Idempotent. */
export function settleConfirm(confirmed: boolean): void {
const pending = $confirmRequest.get()
if (!pending) {
return
}
$confirmRequest.set(null)
pending.resolve(confirmed)
}
@@ -0,0 +1,14 @@
import { atom } from 'nanostores'
import type { DesktopConnectionsRegistry } from '@/global'
/** Null only for the legacy profile-only Desktop topology. Once Electron has
* published a registry, profile names are source-local and are not owners. */
export const $connectionsRegistry = atom<DesktopConnectionsRegistry | null>(null)
export function hasRegistryTopology(): boolean {
// The bridge exists before its asynchronous cache load. Treat that window
// (and a failed list IPC) as registry topology so owner routing fails closed;
// only an older Desktop without the registry capability is truly legacy.
return $connectionsRegistry.get() !== null || Boolean(window.hermesDesktop?.connections?.list)
}
+831
View File
@@ -0,0 +1,831 @@
import { atom } from 'nanostores'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DesktopConnectionsRegistry } from '@/global'
import { deferred } from '../test/deferred'
const $activeGatewayProfile = atom('default')
const $newChatProfile = atom<null | string>(null)
const $showAllProfiles = atom(false)
const $connection = atom<null | {
connectionId?: string
mode?: 'local' | 'remote'
profile?: string
registryScoped?: boolean
}>(null)
// The runtime session id minted by the CURRENT backend — the binding a switch
// must sever before the next source is published (#93937).
const $activeSessionId = atom<null | string>(null)
const $gatewaySwitching = atom(false)
interface ActivationOptions {
beforeActivate?: () => boolean
signal?: AbortSignal
}
const ensureGatewayAgent = vi.fn(
async (_connectionId: null | string, _profile: string, _options?: ActivationOptions): Promise<void> => undefined
)
const openGatewayAgent = vi.fn(async (_connectionId: string, _profile: string): Promise<void> => undefined)
const refreshActiveProfile = vi.fn(async () => undefined)
const requestFreshSession = vi.fn()
const beforeConnectionSwitch = vi.fn()
const wipeSessionListsForGatewaySwitch = vi.fn(() => $activeSessionId.set(null))
// Test double for the store's commit point with the real one's contract
// (barrier → machine-context reset → wipe, synchronously; the barrier is
// owned by the latest token); the real implementation is covered by
// gateway-switch.test.ts.
let latestSwitchToken = 0
const beginGatewaySwitch = vi.fn(() => {
$gatewaySwitching.set(true)
beforeConnectionSwitch()
wipeSessionListsForGatewaySwitch()
return ++latestSwitchToken
})
const endGatewaySwitch = vi.fn((token?: number) => {
if (token === undefined || token === latestSwitchToken) {
$gatewaySwitching.set(false)
}
})
const recoverActiveSourceAfterFailedGatewaySwitch = vi.fn()
vi.mock('@/store/session', () => ({ $connection }))
vi.mock('@/store/gateway-switch', () => ({
$gatewaySwitching,
beginGatewaySwitch,
endGatewaySwitch,
recoverActiveSourceAfterFailedGatewaySwitch,
wipeSessionListsForGatewaySwitch
}))
vi.mock('@/store/profile', () => ({
$activeGatewayProfile,
$newChatProfile,
$showAllProfiles,
captureNewChatSource: vi.fn(),
ensureGatewayAgent,
normalizeProfileKey: (name: null | string | undefined) => (name ?? '').trim() || 'default',
openGatewayAgent,
refreshActiveProfile,
requestFreshSession
}))
const {
$activeConnectionId,
$connectionsRegistry,
$pendingConnectionId,
initializeConnectionsRegistry,
refreshConnectionsRegistry,
_resetConnectionsForTests,
selectConnection,
setConnectionsRegistry
} = await import('./connections')
const registry: DesktopConnectionsRegistry = {
connections: [
{ id: 'local', kind: 'local', label: 'This device', tokenPreview: null, tokenSet: false },
{ id: 'homelab', kind: 'remote', label: 'Homelab', tokenPreview: '...abc', tokenSet: true },
{ id: 'work-vps', kind: 'remote', label: 'Work VPS', tokenPreview: '...xyz', tokenSet: true }
],
primary: 'local',
secureTokenStorage: true,
version: 2
}
const list = vi.fn(async () => registry)
const setLastUsed = vi.fn(async (id: string) => ({ ok: true, registry: { ...registry, lastUsed: id } }))
beforeEach(() => {
localStorage.clear()
_resetConnectionsForTests()
$connectionsRegistry.set(null)
$connection.set(null)
$activeGatewayProfile.set('default')
$newChatProfile.set(null)
$showAllProfiles.set(false)
ensureGatewayAgent.mockReset()
// Mirrors the real door: the commit hook runs right before the activation
// publishes, and a declined hook publishes nothing.
ensureGatewayAgent.mockImplementation(async (connectionId, profile, options) => {
if (options?.beforeActivate && !options.beforeActivate()) {
return
}
$connection.set({
connectionId: connectionId ?? undefined,
mode: connectionId === 'local' ? 'local' : 'remote',
profile,
registryScoped: true
})
})
openGatewayAgent.mockReset()
openGatewayAgent.mockResolvedValue(undefined)
refreshActiveProfile.mockClear()
requestFreshSession.mockClear()
beforeConnectionSwitch.mockClear()
beginGatewaySwitch.mockClear()
endGatewaySwitch.mockClear()
recoverActiveSourceAfterFailedGatewaySwitch.mockClear()
wipeSessionListsForGatewaySwitch.mockClear()
$activeSessionId.set(null)
$gatewaySwitching.set(false)
list.mockClear()
setLastUsed.mockClear()
vi.stubGlobal('window', { hermesDesktop: { connections: { list, setLastUsed } }, localStorage })
})
afterEach(() => vi.unstubAllGlobals())
describe('connection registry cache', () => {
it('loads only Electron local registry state', async () => {
await refreshConnectionsRegistry()
expect(list).toHaveBeenCalledTimes(1)
expect($connectionsRegistry.get()).toEqual(registry)
expect($activeConnectionId.get()).toBeNull()
})
it('restores the last-used source once when that launch mode is enabled', async () => {
list.mockResolvedValueOnce({ ...registry, lastUsed: 'homelab', launchMode: 'last-used' })
$connection.set({ connectionId: 'local', mode: 'local' })
await initializeConnectionsRegistry()
await initializeConnectionsRegistry()
expect(ensureGatewayAgent).toHaveBeenCalledTimes(1)
expect(ensureGatewayAgent).toHaveBeenCalledWith('homelab', 'default', expect.anything())
expect(setLastUsed).toHaveBeenCalledWith('homelab')
})
it('preserves the established Primary-source launch behavior by default', async () => {
list.mockResolvedValueOnce({ ...registry, lastUsed: 'homelab', launchMode: 'primary' })
$connection.set({ connectionId: 'local', mode: 'local' })
await initializeConnectionsRegistry()
expect(ensureGatewayAgent).not.toHaveBeenCalled()
})
it('restores a remote registry primary through its exact connection id', async () => {
list.mockResolvedValueOnce({ ...registry, primary: 'homelab', launchMode: 'primary' })
$connection.set({ connectionId: 'local', mode: 'local' })
await initializeConnectionsRegistry()
expect(ensureGatewayAgent).toHaveBeenCalledTimes(1)
expect(ensureGatewayAgent).toHaveBeenCalledWith('homelab', 'default', expect.anything())
expect(setLastUsed).toHaveBeenCalledWith('homelab')
})
it('boot restore yields to a source the user already picked while boot was settling', async () => {
// Primary is local, launch mode is primary. The user clicks a fleet-rail
// square on the homelab gateway before the boot-time restore runs. The
// restore must not "return" the window to the primary over that choice.
list.mockResolvedValue({ ...registry, primary: 'local', launchMode: 'primary' })
setConnectionsRegistry(registry)
$connection.set({ connectionId: 'local', mode: 'local' })
await selectConnection('homelab', { profile: 'omer' })
expect($activeConnectionId.get()).toBe('homelab')
ensureGatewayAgent.mockClear()
await initializeConnectionsRegistry()
expect(ensureGatewayAgent).not.toHaveBeenCalled()
expect($activeConnectionId.get()).toBe('homelab')
})
it('uses only the resolved descriptor identity for the active gateway', () => {
setConnectionsRegistry({ ...registry, primary: 'homelab' })
$connection.set({ connectionId: 'work-vps', mode: 'remote' })
expect($activeConnectionId.get()).toBe('work-vps')
$connection.set({ mode: 'remote' })
expect($activeConnectionId.get()).toBeNull()
$connection.set({ connectionId: 'work-vps', mode: 'remote' })
expect($activeConnectionId.get()).toBe('work-vps')
})
})
describe('selectConnection', () => {
it('dials a secondary source and starts a fresh source-scoped draft', async () => {
setConnectionsRegistry(registry)
$connection.set({ connectionId: 'local', mode: 'local' })
await selectConnection('homelab')
expect(openGatewayAgent).toHaveBeenCalledWith('homelab', 'default')
expect(ensureGatewayAgent).toHaveBeenCalledWith('homelab', 'default', expect.anything())
expect(beforeConnectionSwitch).toHaveBeenCalledTimes(1)
expect(requestFreshSession).toHaveBeenCalledTimes(1)
expect(wipeSessionListsForGatewaySwitch).toHaveBeenCalledTimes(1)
expect($newChatProfile.get()).toBe('default')
expect(refreshActiveProfile).toHaveBeenCalledTimes(1)
expect(setLastUsed).toHaveBeenCalledWith('homelab')
})
it('does not reset or dial when the active source/profile is selected again', async () => {
setConnectionsRegistry(registry)
$connection.set({ connectionId: 'local', mode: 'local' })
await selectConnection('local')
expect(ensureGatewayAgent).not.toHaveBeenCalled()
expect(requestFreshSession).not.toHaveBeenCalled()
})
it('uses an explicit local id when This device is not primary', async () => {
setConnectionsRegistry({ ...registry, primary: 'homelab' })
$connection.set({ connectionId: 'homelab', mode: 'remote' })
await selectConnection('local')
expect(ensureGatewayAgent).toHaveBeenCalledWith('local', 'default', expect.anything())
})
it('lets a later source choice win while an earlier dial is still pending', async () => {
let releaseDials!: () => void
const dialGate = new Promise<void>(resolve => {
releaseDials = resolve
})
setConnectionsRegistry(registry)
$connection.set({ connectionId: 'local', mode: 'local' })
openGatewayAgent.mockImplementation(async () => {
await dialGate
})
const openHomelab = selectConnection('homelab')
await Promise.resolve()
const stayLocal = selectConnection('local')
releaseDials()
await Promise.all([openHomelab, stayLocal])
expect(openGatewayAgent.mock.calls).toEqual([
['homelab', 'default'],
['local', 'default']
])
// The superseded dial never activates: the user doesn't flip through
// homelab on the way back to local, and only the winner commits.
expect(ensureGatewayAgent.mock.calls.map(call => [call[0], call[1]])).toEqual([['local', 'default']])
expect(beginGatewaySwitch).toHaveBeenCalledTimes(1)
expect(wipeSessionListsForGatewaySwitch).toHaveBeenCalledTimes(1)
// Only the latest intent repaints the profile list.
expect(refreshActiveProfile).toHaveBeenCalledTimes(1)
expect($connection.get()?.connectionId).toBe('local')
expect($gatewaySwitching.get()).toBe(false)
})
it('lands on an explicit profile instead of the one last used on that source', async () => {
setConnectionsRegistry(registry)
$connection.set({ connectionId: 'local', mode: 'local' })
// Remember "scout" on homelab by activating it once…
await selectConnection('homelab', { profile: 'scout' })
expect(ensureGatewayAgent).toHaveBeenLastCalledWith('homelab', 'scout', expect.anything())
// …then pick a different square on the same source: the click wins over
// whatever was last used there, and the fresh draft targets that profile.
await selectConnection('local')
await selectConnection('homelab', { profile: 'omer' })
expect(ensureGatewayAgent).toHaveBeenLastCalledWith('homelab', 'omer', expect.anything())
expect($newChatProfile.get()).toBe('omer')
})
it('restores the last profile used on each source', async () => {
setConnectionsRegistry(registry)
$connection.set({ connectionId: 'local', mode: 'local', profile: 'research', registryScoped: true })
$activeGatewayProfile.set('research')
$connection.set({ connectionId: 'homelab', mode: 'remote', registryScoped: true })
$activeGatewayProfile.set('default')
await selectConnection('local')
expect(ensureGatewayAgent).toHaveBeenCalledWith('local', 'research', expect.anything())
})
it('does not remember a migrated v1 routing alias as a backend profile', async () => {
setConnectionsRegistry(registry)
$connection.set({ connectionId: 'homelab', mode: 'remote' })
$activeGatewayProfile.set('legacy-homelab-alias')
$connection.set({ connectionId: 'local', mode: 'local', registryScoped: true })
await selectConnection('homelab')
expect(ensureGatewayAgent).toHaveBeenCalledWith('homelab', 'default', expect.anything())
})
it('does not remember a stale startup profile under the resolved source', async () => {
setConnectionsRegistry(registry)
$connection.set({ connectionId: 'local', mode: 'local', profile: 'default', registryScoped: true })
$activeGatewayProfile.set('work-agent')
$connection.set({ connectionId: 'homelab', mode: 'remote', profile: 'default', registryScoped: true })
$activeGatewayProfile.set('default')
await selectConnection('local')
expect(ensureGatewayAgent).toHaveBeenCalledWith('local', 'default', expect.anything())
})
it('keeps the current source usable when a dial fails: nothing is severed before the target is reachable', async () => {
setConnectionsRegistry(registry)
$connection.set({ connectionId: 'local', mode: 'local' })
$activeSessionId.set('a93bb39d')
openGatewayAgent.mockRejectedValueOnce(new Error('offline'))
await expect(selectConnection('homelab')).rejects.toThrow('offline')
// The dial failed in phase 1 — the switch never committed, so the open
// transcript, its runtime binding and the session lists are all intact.
expect(ensureGatewayAgent).not.toHaveBeenCalled()
expect(beginGatewaySwitch).not.toHaveBeenCalled()
expect(beforeConnectionSwitch).not.toHaveBeenCalled()
expect(wipeSessionListsForGatewaySwitch).not.toHaveBeenCalled()
expect($activeSessionId.get()).toBe('a93bb39d')
expect($gatewaySwitching.get()).toBe(false)
expect(requestFreshSession).not.toHaveBeenCalled()
expect($newChatProfile.get()).toBeNull()
expect($pendingConnectionId.get()).toBeNull()
expect(setLastUsed).not.toHaveBeenCalled()
expect($connection.get()?.connectionId).toBe('local')
})
it('an activation that does not land after the wipe lowers the barrier and repaints the still-active source', async () => {
setConnectionsRegistry(registry)
$connection.set({ connectionId: 'local', mode: 'local' })
// The dial opened the socket but the activation was declined (source
// edited/removed mid-switch): the commit hook ran (wipe done), yet
// $connection never moves to homelab.
ensureGatewayAgent.mockImplementationOnce(async (_connectionId, _profile, options) => {
options?.beforeActivate?.()
})
await expect(selectConnection('homelab')).rejects.toThrow('did not become active')
expect(beginGatewaySwitch).toHaveBeenCalledTimes(1)
expect(endGatewaySwitch).toHaveBeenCalledTimes(1)
expect($gatewaySwitching.get()).toBe(false)
// The lists were wiped for a commit that never happened; the source that
// is still active gets repainted and the user lands on a fresh draft there.
expect(recoverActiveSourceAfterFailedGatewaySwitch).toHaveBeenCalledTimes(1)
expect(requestFreshSession).toHaveBeenCalledTimes(1)
expect(setLastUsed).not.toHaveBeenCalled()
expect($newChatProfile.get()).toBeNull()
expect($pendingConnectionId.get()).toBeNull()
expect($connection.get()?.connectionId).toBe('local')
})
it("#93937: severs the previous source's runtime session binding BEFORE the new source is published, behind the barrier", async () => {
setConnectionsRegistry(registry)
$connection.set({ connectionId: 'local', mode: 'local' })
// Runtime id minted by the local backend; only local has ever heard of it.
$activeSessionId.set('a93bb39d')
// Phase 1 (the dial) must not touch the current workspace at all.
openGatewayAgent.mockImplementationOnce(async () => {
expect($activeSessionId.get()).toBe('a93bb39d')
expect($gatewaySwitching.get()).toBe(false)
expect(wipeSessionListsForGatewaySwitch).not.toHaveBeenCalled()
})
// Every publication of the new source, with what a session-scoped effect
// would read at that instant.
const published: Array<{ activeSessionId: null | string; connectionId?: string; switching: boolean }> = []
const off = $connection.listen(next => {
published.push({
activeSessionId: $activeSessionId.get(),
connectionId: next?.connectionId,
switching: $gatewaySwitching.get()
})
})
await selectConnection('homelab')
off()
// The old runtime id was already gone when homelab became visible, and
// the barrier was up — nothing could pair 'a93bb39d' with the new backend.
expect(published).toEqual([{ activeSessionId: null, connectionId: 'homelab', switching: true }])
// dial → commit (barrier + reset + wipe, inside the activation's commit
// hook) → publish, in that order.
expect(openGatewayAgent).toHaveBeenCalledWith('homelab', 'default')
expect(openGatewayAgent.mock.invocationCallOrder[0]).toBeLessThan(ensureGatewayAgent.mock.invocationCallOrder[0])
expect(beginGatewaySwitch).toHaveBeenCalledTimes(1)
expect(beforeConnectionSwitch).toHaveBeenCalledTimes(1)
expect(endGatewaySwitch).toHaveBeenCalledTimes(1)
expect($gatewaySwitching.get()).toBe(false)
expect($activeSessionId.get()).toBeNull()
})
it('a click that supersedes a QUEUED commit wins: the superseded switch neither wipes nor activates', async () => {
// Both activations sit behind an in-flight profile/agent switch (the
// profile store's mutex); their commit hooks only run once it settles.
const mutex = deferred()
setConnectionsRegistry(registry)
$connection.set({ connectionId: 'local', mode: 'local' })
$activeSessionId.set('a93bb39d')
ensureGatewayAgent.mockImplementation(async (connectionId, _profile, options) => {
await mutex.promise
if (options?.beforeActivate && !options.beforeActivate()) {
return
}
$connection.set({
connectionId: connectionId ?? undefined,
mode: 'remote',
profile: 'default',
registryScoped: true
})
})
const published: string[] = []
const off = $connection.listen(next => published.push(`${next?.connectionId}:active=${$activeSessionId.get()}`))
const first = selectConnection('homelab')
await vi.waitFor(() => expect(ensureGatewayAgent).toHaveBeenCalledTimes(1))
const second = selectConnection('work-vps')
await vi.waitFor(() => expect(ensureGatewayAgent).toHaveBeenCalledTimes(2))
// Nothing is severed while both are still queued.
expect(beginGatewaySwitch).not.toHaveBeenCalled()
expect($activeSessionId.get()).toBe('a93bb39d')
mutex.resolve()
await Promise.all([first, second])
off()
// homelab stepped aside at its turn: no wipe, no publication, no error
// UI; work-vps wiped once and is the only source ever published.
expect(ensureGatewayAgent.mock.calls.map(call => call[0])).toEqual(['homelab', 'work-vps'])
expect(beginGatewaySwitch).toHaveBeenCalledTimes(1)
expect(published).toEqual(['work-vps:active=null'])
expect($connection.get()?.connectionId).toBe('work-vps')
expect(recoverActiveSourceAfterFailedGatewaySwitch).not.toHaveBeenCalled()
expect(requestFreshSession).toHaveBeenCalledTimes(1)
expect(setLastUsed).toHaveBeenCalledTimes(1)
expect(setLastUsed).toHaveBeenCalledWith('work-vps')
expect($gatewaySwitching.get()).toBe(false)
expect($pendingConnectionId.get()).toBeNull()
})
it('does not spend the activation timeout while waiting for the serialized commit turn', async () => {
vi.useFakeTimers()
try {
const mutex = deferred()
setConnectionsRegistry(registry)
$connection.set({ connectionId: 'local', mode: 'local' })
ensureGatewayAgent.mockImplementationOnce(async (connectionId, _profile, options) => {
await mutex.promise
if (options?.beforeActivate && !options.beforeActivate()) {
return
}
$connection.set({
connectionId: connectionId ?? undefined,
mode: 'remote',
profile: 'default',
registryScoped: true
})
})
const attempt = selectConnection('homelab')
await vi.waitFor(() => expect(ensureGatewayAgent).toHaveBeenCalledTimes(1))
// Queue ownership belongs to the shared mutex, not the actual activation
// attempt, so waiting here must not consume its 20-second commit budget.
await vi.advanceTimersByTimeAsync(20_000)
expect(beginGatewaySwitch).not.toHaveBeenCalled()
mutex.resolve()
await attempt
expect($connection.get()?.connectionId).toBe('homelab')
expect(beginGatewaySwitch).toHaveBeenCalledTimes(1)
expect($gatewaySwitching.get()).toBe(false)
} finally {
vi.useRealTimers()
}
})
it('a dial that never answers times out: nothing severed, the click fails visibly, and the source can be retried', async () => {
vi.useFakeTimers()
try {
setConnectionsRegistry(registry)
$connection.set({ connectionId: 'local', mode: 'local' })
$activeSessionId.set('a93bb39d')
openGatewayAgent.mockImplementationOnce(() => new Promise<void>(() => undefined))
const outcome = selectConnection('homelab').then(
() => 'resolved',
(error: Error) => error.message
)
await vi.advanceTimersByTimeAsync(20_000)
expect(await outcome).toMatch(/Timed out connecting to "Homelab"/)
expect(ensureGatewayAgent).not.toHaveBeenCalled()
expect(beginGatewaySwitch).not.toHaveBeenCalled()
expect($activeSessionId.get()).toBe('a93bb39d')
expect($gatewaySwitching.get()).toBe(false)
expect($pendingConnectionId.get()).toBeNull()
// The stalled click does not poison the source: a retry is a real switch,
// not a duplicate of the pending one.
await selectConnection('homelab')
expect(ensureGatewayAgent).toHaveBeenCalledWith('homelab', 'default', expect.anything())
expect($connection.get()?.connectionId).toBe('homelab')
} finally {
vi.useRealTimers()
}
})
it('an activation that published the new source but never settled counts as committed', async () => {
vi.useFakeTimers()
try {
setConnectionsRegistry(registry)
$connection.set({ connectionId: 'local', mode: 'local' })
// The socket activates and publishes synchronously; only the trailing
// descriptor resync (an IPC) stalls.
ensureGatewayAgent.mockImplementationOnce((connectionId, _profile, options) => {
options?.beforeActivate?.()
$connection.set({
connectionId: connectionId ?? undefined,
mode: 'remote',
profile: 'default',
registryScoped: true
})
return new Promise<void>(() => undefined)
})
const attempt = selectConnection('homelab')
await vi.advanceTimersByTimeAsync(20_000)
await attempt
expect($connection.get()?.connectionId).toBe('homelab')
expect($gatewaySwitching.get()).toBe(false)
expect(setLastUsed).toHaveBeenCalledWith('homelab')
expect(requestFreshSession).toHaveBeenCalledTimes(1)
expect(recoverActiveSourceAfterFailedGatewaySwitch).not.toHaveBeenCalled()
expect($pendingConnectionId.get()).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('a timed-out activation that already published cannot republish after a newer source wins', async () => {
vi.useFakeTimers()
try {
let releaseDescriptor: () => void = () => undefined
setConnectionsRegistry(registry)
// Seed A's remembered profile through the real source/profile observer,
// then restore the currently active local source.
$activeGatewayProfile.set('research')
$connection.set({ connectionId: 'homelab', mode: 'remote', profile: 'research', registryScoped: true })
$activeGatewayProfile.set('default')
$connection.set({ connectionId: 'local', mode: 'local', profile: 'default', registryScoped: true })
ensureGatewayAgent
.mockImplementationOnce((connectionId, profile, options) => {
options?.beforeActivate?.()
// Low-level activation publishes synchronously. The trailing descriptor
// promise remains alive beyond selectConnection's commit timeout.
$activeGatewayProfile.set(profile)
$connection.set({
connectionId: connectionId ?? undefined,
mode: 'remote',
profile,
registryScoped: true
})
return new Promise<void>(resolve => {
releaseDescriptor = () => {
// Mirrors ensureGatewayAgent's publication seam: a revoked owner
// observes its signal and must not publish its late descriptor.
if (!options?.signal?.aborted) {
$activeGatewayProfile.set(profile)
$connection.set({
connectionId: connectionId ?? undefined,
mode: 'remote',
profile,
registryScoped: true
})
}
resolve()
}
})
})
.mockImplementationOnce(async (connectionId, profile, options) => {
if (options?.beforeActivate && !options.beforeActivate()) {
return
}
$activeGatewayProfile.set(profile)
$connection.set({
connectionId: connectionId ?? undefined,
mode: 'remote',
profile,
registryScoped: true
})
})
const timedOutOwner = selectConnection('homelab')
await vi.advanceTimersByTimeAsync(20_000)
await timedOutOwner
// Fail open: A really did become active before its trailing descriptor
// work timed out, so the commit remains successful.
expect($activeGatewayProfile.get()).toBe('research')
expect($connection.get()?.connectionId).toBe('homelab')
await selectConnection('work-vps')
expect($activeGatewayProfile.get()).toBe('default')
expect($connection.get()?.connectionId).toBe('work-vps')
releaseDescriptor()
await vi.advanceTimersByTimeAsync(0)
expect($activeGatewayProfile.get()).toBe('default')
expect($connection.get()?.connectionId).toBe('work-vps')
} finally {
vi.useRealTimers()
}
})
it('an activation that stalls AFTER the wipe times out: barrier down, still-active source repainted', async () => {
vi.useFakeTimers()
try {
setConnectionsRegistry(registry)
$connection.set({ connectionId: 'local', mode: 'local' })
ensureGatewayAgent.mockImplementationOnce((_connectionId, _profile, options) => {
options?.beforeActivate?.()
return new Promise<void>(() => undefined)
})
const outcome = selectConnection('homelab').then(
() => 'resolved',
(error: Error) => error.message
)
await vi.advanceTimersByTimeAsync(20_000)
expect(await outcome).toMatch(/Timed out activating "Homelab"/)
expect(beginGatewaySwitch).toHaveBeenCalledTimes(1)
expect($gatewaySwitching.get()).toBe(false)
expect(recoverActiveSourceAfterFailedGatewaySwitch).toHaveBeenCalledTimes(1)
expect(requestFreshSession).toHaveBeenCalledTimes(1)
expect(setLastUsed).not.toHaveBeenCalled()
expect($connection.get()?.connectionId).toBe('local')
expect($pendingConnectionId.get()).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('a timed-out activation cannot publish the target after it eventually settles', async () => {
vi.useFakeTimers()
try {
const activation = deferred()
setConnectionsRegistry(registry)
$connection.set({ connectionId: 'local', mode: 'local' })
ensureGatewayAgent.mockImplementationOnce(async (connectionId, _profile, options) => {
options?.beforeActivate?.()
await activation.promise
// Mirrors the real activation door: cancellation ownership is checked
// immediately before publishing after the async activation work.
if (options?.signal?.aborted) {
return
}
$connection.set({
connectionId: connectionId ?? undefined,
mode: 'remote',
profile: 'default',
registryScoped: true
})
})
const outcome = selectConnection('homelab').then(
() => 'resolved',
(error: Error) => error.message
)
await vi.advanceTimersByTimeAsync(20_000)
expect(await outcome).toMatch(/Timed out activating "Homelab"/)
expect($connection.get()?.connectionId).toBe('local')
expect($gatewaySwitching.get()).toBe(false)
activation.resolve()
await vi.advanceTimersByTimeAsync(0)
expect($connection.get()?.connectionId).toBe('local')
expect(beginGatewaySwitch).toHaveBeenCalledTimes(1)
expect(endGatewaySwitch).toHaveBeenCalledTimes(1)
expect($gatewaySwitching.get()).toBe(false)
expect($pendingConnectionId.get()).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('waits for the primary descriptor before restoring a source at boot', async () => {
// The sidebar mounts while the primary gateway is still booting. Dialing
// the preferred source before its descriptor is published can create a
// second SSH backend for the exact same registered connection.
list.mockResolvedValueOnce({ ...registry, lastUsed: 'homelab', launchMode: 'last-used' })
$showAllProfiles.set(true)
const restoring = initializeConnectionsRegistry()
await new Promise(resolve => setTimeout(resolve, 0))
expect(openGatewayAgent).not.toHaveBeenCalled()
expect(ensureGatewayAgent).not.toHaveBeenCalled()
$connection.set({ connectionId: 'homelab', mode: 'remote' })
await restoring
expect(openGatewayAgent).not.toHaveBeenCalled()
expect(ensureGatewayAgent).not.toHaveBeenCalled()
expect(setLastUsed).toHaveBeenCalledWith('homelab')
expect($showAllProfiles.get()).toBe(true)
})
it('boot restore proceeds after the descriptor wait deadline (bounded wait)', async () => {
// A primary that never publishes (spawn failure, dead SSH target) must
// not strand the registry restore forever: after the deadline the restore
// runs exactly as it did before the wait existed.
vi.useFakeTimers()
try {
list.mockResolvedValueOnce({ ...registry, lastUsed: 'homelab', launchMode: 'last-used' })
const restoring = initializeConnectionsRegistry()
await vi.advanceTimersByTimeAsync(1_000)
expect(ensureGatewayAgent).not.toHaveBeenCalled()
// Descriptor never arrives; deadline elapses.
await vi.advanceTimersByTimeAsync(60_000)
await restoring
expect(ensureGatewayAgent).toHaveBeenCalledWith('homelab', 'default', expect.anything())
} finally {
vi.useRealTimers()
}
})
it('a user-initiated source switch still collapses "All profiles"', async () => {
setConnectionsRegistry(registry)
$connection.set({ connectionId: 'local', mode: 'local' })
$showAllProfiles.set(true)
await selectConnection('homelab')
expect(ensureGatewayAgent).toHaveBeenCalledWith('homelab', 'default', expect.anything())
expect($showAllProfiles.get()).toBe(false)
})
it('never re-homes a live connection the registry cannot name', async () => {
// A window connected through the legacy v1 route carries an unqualified
// descriptor, so resolvedConnectionId — and therefore $activeConnectionId
// — is null. Restoring the registry primary over it would re-home a
// working remote onto local a few seconds after boot.
list.mockResolvedValueOnce({ ...registry, launchMode: 'primary', primary: 'local' })
$connection.set({ mode: 'remote', profile: 'default', registryScoped: false })
await initializeConnectionsRegistry()
expect(ensureGatewayAgent).not.toHaveBeenCalled()
expect(wipeSessionListsForGatewaySwitch).not.toHaveBeenCalled()
expect($connection.get()?.mode).toBe('remote')
})
})
+459
View File
@@ -0,0 +1,459 @@
import { atom, computed } from 'nanostores'
import type { DesktopConnectionsRegistry } from '@/global'
import { persistStringRecord, storedStringRecord } from '@/lib/storage'
import { BACKEND_BOOT_WAIT_TIMEOUT_MS, isTimeoutError, withTimeout } from '@/lib/with-timeout'
import { $connectionsRegistry } from '@/store/connection-registry-state'
import {
beginGatewaySwitch,
endGatewaySwitch,
type GatewaySwitchToken,
recoverActiveSourceAfterFailedGatewaySwitch
} from '@/store/gateway-switch'
import {
$activeGatewayProfile,
$newChatProfile,
$showAllProfiles,
captureNewChatSource,
ensureGatewayAgent,
normalizeProfileKey,
openGatewayAgent,
refreshActiveProfile,
requestFreshSession
} from '@/store/profile'
import { $connection } from '@/store/session'
const LAST_PROFILE_STORAGE_KEY = 'hermes.desktop.lastProfileByConnection'
// Every await of a source switch is bounded. A wedged spawn, ticket mint,
// handshake or IPC (the #93454 class) must surface as a failed click — not a
// spinner that also swallows every later click on the same source, and never
// a barrier left up or a wipe left unpainted.
const SWITCH_DIAL_TIMEOUT_MS = 20_000
const SWITCH_COMMIT_TIMEOUT_MS = 20_000
const SWITCH_REMEMBER_TIMEOUT_MS = 5_000
// Matches the primary spawn budget: a healthy cold boot publishes well within
// this; anything longer means the primary is not coming and the registry
// restore should stop waiting for it. Shared constant so the boot-class
// budgets can't drift apart (see with-timeout.ts).
const BOOT_DESCRIPTOR_WAIT_TIMEOUT_MS = BACKEND_BOOT_WAIT_TIMEOUT_MS
export { $connectionsRegistry } from '@/store/connection-registry-state'
// Use only the resolved descriptor identity Electron publishes. `primary`
// means the registry default, not necessarily the source this window is using;
// guessing it here would paint the wrong source as active for an unmatched v1
// route or while a legacy main is still resolving the descriptor.
export const $activeConnectionId = computed($connection, connection => connection?.connectionId ?? null)
export const $hasMultipleConnections = computed(
$connectionsRegistry,
registry => (registry?.connections.length ?? 0) > 1
)
const $lastProfileByConnection = atom<Record<string, string>>(storedStringRecord(LAST_PROFILE_STORAGE_KEY))
let pendingTarget: null | string = null
let restoreAttempted = false
let switchRevision = 0
export const $pendingConnectionId = atom<null | string>(null)
$lastProfileByConnection.subscribe(value => persistStringRecord(LAST_PROFILE_STORAGE_KEY, value))
const $activeConnectionProfile = computed(
[$activeConnectionId, $activeGatewayProfile, $connection],
(connectionId, profile, connection) => ({
connectionId,
descriptorProfile: normalizeProfileKey(connection?.profile),
profile: normalizeProfileKey(profile),
registryScoped: connection?.registryScoped === true
})
)
// Remember one profile per source, so switching machines is a re-home rather
// than a reset to `default`. The map is local UI preference only; Electron
// remains the authority for the connection registry and all secrets.
$activeConnectionProfile.subscribe(({ connectionId, descriptorProfile, profile, registryScoped }) => {
// A migrated v1 per-profile remote may expose a client-side alias such as
// "work" while the registered source's actual profile is "default". Only
// remember a source/profile pair after Electron confirms that exact v2
// descriptor. This also rejects the brief startup window where the profile
// atom still carries the previous app run's alias.
if (
!connectionId ||
!registryScoped ||
descriptorProfile !== profile ||
$lastProfileByConnection.get()[connectionId] === profile
) {
return
}
$lastProfileByConnection.set({ ...$lastProfileByConnection.get(), [connectionId]: profile })
})
/** @internal Reset module-owned preferences and switch coordination for tests. */
export function _resetConnectionsForTests(): void {
$lastProfileByConnection.set({})
pendingTarget = null
restoreAttempted = false
switchRevision = 0
$pendingConnectionId.set(null)
}
export function setConnectionsRegistry(registry: DesktopConnectionsRegistry): void {
$connectionsRegistry.set(registry)
}
/** Refresh the renderer cache from Electron's local registry. No backend is contacted. */
export async function refreshConnectionsRegistry(): Promise<DesktopConnectionsRegistry | null> {
const bridge = window.hermesDesktop?.connections
if (!bridge) {
return null
}
const registry = await bridge.list()
setConnectionsRegistry(registry)
return registry
}
async function rememberConnection(connectionId: string): Promise<void> {
const setLastUsed = window.hermesDesktop?.connections?.setLastUsed
if (!setLastUsed) {
return
}
try {
const result = await withTimeout(
setLastUsed(connectionId),
SWITCH_REMEMBER_TIMEOUT_MS,
'Timed out remembering the last-used connection'
)
setConnectionsRegistry(result.registry)
} catch {
// The source is already usable. A read-only/full userData directory (or
// a stalled IPC) must not turn a successful backend switch into a false
// connection failure.
}
}
/**
* The sidebar registry initializes in parallel with the primary gateway boot.
* Wait for main's resolved descriptor before deciding whether the preferred
* source needs a secondary dial. Otherwise a remote primary can be opened a
* second time through the registry while the identical primary SSH backend is
* still publishing its connection identity.
*
* Bounded: a primary that never publishes (spawn failure, dead SSH target)
* must not strand the registry restore forever — after the deadline the
* restore proceeds exactly as it did before this wait existed. The listener
* is always torn down so a late descriptor can't leak a dangling resolver.
*/
function waitForInitialConnection(): Promise<void> {
if ($connection.get()) {
return Promise.resolve()
}
let unlisten: (() => void) | undefined
const published = new Promise<void>(resolve => {
unlisten = $connection.listen(connection => {
if (!connection) {
return
}
unlisten?.()
resolve()
})
})
return withTimeout(
published,
BOOT_DESCRIPTOR_WAIT_TIMEOUT_MS,
'Timed out waiting for the primary connection descriptor'
).catch(error => {
unlisten?.()
if (!isTimeoutError(error)) {
throw error
}
})
}
/**
* Load the registry once for Sessions and restore the last successfully used
* source. Later registry refreshes stay side-effect free, so editing Settings
* in another window never changes the active workspace.
*/
export async function initializeConnectionsRegistry(): Promise<DesktopConnectionsRegistry | null> {
const registry = await refreshConnectionsRegistry()
if (!registry || restoreAttempted) {
return registry
}
restoreAttempted = true
await waitForInitialConnection()
// The user got there first: a source they picked while boot was settling
// (statusbar switcher, fleet profile rail) is not drift to "restore" over.
// The launch preference only decides where a window lands when nobody has
// said otherwise yet.
if (switchRevision > 0 || pendingTarget !== null) {
return registry
}
// Residual drift: a window can be live on a source the registry cannot name
// (a v1-configured remote that reconciliation has not repaired yet, e.g. a
// read-only userData that rejected the healed write). $activeConnectionId is
// null there, so the preferred-id guard below would miss and "restore" the
// registry primary over a connection that is already up and painting —
// re-homing the user onto a different backend seconds after boot. The
// registry has no claim on a source it does not know; leave the live one be.
if ($connection.get() && $activeConnectionId.get() === null) {
return registry
}
const lastUsed = registry.connections.some(connection => connection.id === registry.lastUsed)
? registry.lastUsed
: registry.primary
const preferredId = registry.launchMode === 'last-used' ? lastUsed : registry.primary
if (!preferredId) {
return registry
}
if ($activeConnectionId.get() === preferredId) {
await rememberConnection(preferredId)
} else {
await selectConnection(preferredId)
}
return $connectionsRegistry.get() ?? registry
}
/**
* Re-home Sessions to one registered source, restoring that source's last
* profile. Only the selected source is dialed; merely rendering the switcher
* never probes or opens remote gateways.
*
* Two phases, same commit contract as a Settings → Gateway apply (softSwitch):
* 1. Dial the target WITHOUT activating it. The previous source stays fully
* bound and painted, so a dead target fails with nothing lost.
* 2. Commit: beginGatewaySwitch() — barrier up, machine-context reset,
* session bindings wiped — then activate the already-open socket. The
* wipe runs inside the activation's serialized section, synchronously
* before the publication, so no route/session effect can observe the new
* source while $activeSessionId still names the previous backend's
* runtime. Activating first and wiping after (across an IPC round-trip)
* is exactly how that id leaked to the new backend and came back as
* "session not found" (#93937).
*
* Overlap and stalls: clicks can supersede a switch at any point. A switch
* superseded while queued behind another activation declines its commit —
* no wipe, no activation — so the winner's wipe is always the one that
* precedes the final publication, and the barrier is owned by the latest
* switch. Every await is bounded; a commit that stalls after the wipe lowers
* the barrier and repaints the source that is still active.
*/
export interface SelectConnectionOptions {
/** Land on this profile of the target source instead of the one last used
* there. The fleet profile rail passes the exact square the user clicked. */
profile?: null | string
}
export async function selectConnection(connectionId: string, options: SelectConnectionOptions = {}): Promise<void> {
const registry = $connectionsRegistry.get()
const targetConnection = registry?.connections.find(connection => connection.id === connectionId)
if (!registry || !targetConnection) {
return
}
// A user-initiated source switch collapses "All profiles" browse mode: the
// picker is a concrete-source action. The silent boot-time restore (below,
// from initializeConnectionsRegistry) is not — it must leave the persisted
// browse-mode preference alone so it survives restart (#93197).
const restoreOnBoot = pendingTarget === null && $activeConnectionId.get() === null
const currentConnectionId = $activeConnectionId.get()
const currentProfile = normalizeProfileKey($activeGatewayProfile.get())
const explicitProfile = String(options.profile ?? '').trim()
const targetProfile = normalizeProfileKey(
explicitProfile || ($lastProfileByConnection.get()[connectionId] ?? 'default')
)
const targetKey = `${connectionId}::${targetProfile}`
const targetIsActive = () => {
const active = $connection.get()
return active?.connectionId === connectionId && normalizeProfileKey(active.profile) === targetProfile
}
if (pendingTarget === targetKey) {
return
}
const switching =
pendingTarget !== null ||
$showAllProfiles.get() ||
currentConnectionId !== connectionId ||
currentProfile !== targetProfile
if (!switching) {
await rememberConnection(connectionId)
return
}
if (pendingTarget === null && currentConnectionId === connectionId && currentProfile === targetProfile) {
$showAllProfiles.set(false)
$newChatProfile.set(targetProfile)
// A connection switch is a new-chat intent on THAT source: keep the
// registry identity with the profile so the next create names local::x /
// <source>::x exactly, never a bare profile string.
captureNewChatSource()
requestFreshSession()
await rememberConnection(connectionId)
return
}
const revision = ++switchRevision
pendingTarget = targetKey
$pendingConnectionId.set(connectionId)
// Set by the commit hook once THIS switch has wiped — i.e. it owns the
// barrier and, if the commit then fails, owes the still-active source a
// repaint. Null while queued, or if it stepped aside before its turn.
let token = null as GatewaySwitchToken | null
try {
// Phase 1 — open the target's socket; the active route is untouched.
// Always use the explicit registry route. `local` must mean This device,
// and a registry primary can differ from a legacy per-profile override.
await withTimeout(
openGatewayAgent(connectionId, targetProfile),
SWITCH_DIAL_TIMEOUT_MS,
`Timed out connecting to "${targetConnection.label}".`
)
// A newer click owns the switch from here on. The superseded dial never
// activates, so the user doesn't flip through it on the way to the source
// they picked last; its socket stays warm for that click or idles out.
if (revision !== switchRevision) {
return
}
// Phase 2 — commit. The hook runs inside the activation's serialized
// section, right before the socket is activated: sever the previous
// backend's bindings, then publish, with nothing in between. A click that
// superseded this switch while it was queued makes the hook decline —
// neither wipe nor activation — so the user never flips through it.
const activationController = new AbortController()
let markActivationStarted: () => void = () => undefined
const activationStarted = new Promise<void>(resolve => {
markActivationStarted = resolve
})
try {
try {
const activation = ensureGatewayAgent(connectionId, targetProfile, {
signal: activationController.signal,
beforeActivate: () => {
if (revision !== switchRevision) {
return false
}
token = beginGatewaySwitch()
markActivationStarted()
return true
}
})
const timedActivation = activationStarted.then(() =>
withTimeout(
activation,
SWITCH_COMMIT_TIMEOUT_MS,
`Timed out activating "${targetConnection.label}".`,
error => {
// withTimeout does not cancel its input. Every timed-out owner
// loses future activation/publication rights, even when low-level
// activation already published the target and the commit remains
// fail-open. The shared activation signal suppresses any trailing
// descriptor/profile publication when stale work later settles.
activationController.abort(error)
}
)
)
// Queue time belongs to the profile-store mutex. Start the bounded
// commit window only once beforeActivate grants this request its turn.
await Promise.race([activation, timedActivation])
} catch (error) {
// The socket is activated and its descriptor published synchronously;
// only best-effort descriptor resync trails it. A commit that timed out
// AFTER the new source became active has landed, so keep it fail-open;
// the timeout signal still revokes all trailing publication rights.
if (!isTimeoutError(error) || !targetIsActive()) {
throw error
}
}
if (revision !== switchRevision) {
return
}
if (!targetIsActive()) {
throw new Error(`Connection "${targetConnection.label}" did not become active.`)
}
} finally {
// Lower the barrier the moment the commit settles — before the
// bookkeeping awaits below — but only if this switch still owns it.
if (token !== null) {
endGatewaySwitch(token)
}
}
// A newer click owns the final refresh. Serialized gateway activation
// already makes the latest source win; this guard also prevents an older
// request from repainting its profile list after that newer activation.
if (revision === switchRevision) {
await rememberConnection(connectionId)
if (!restoreOnBoot) {
$showAllProfiles.set(false)
}
$newChatProfile.set(targetProfile)
captureNewChatSource()
requestFreshSession()
await refreshActiveProfile()
}
} catch (error) {
if (revision === switchRevision) {
if (token !== null) {
// This switch wiped for a commit that never landed. The previous
// source is still the active one, and nothing reactive re-pulls its
// lists (no scope moved): repaint it and land on a fresh draft there,
// matching what a failed Settings apply leaves behind.
recoverActiveSourceAfterFailedGatewaySwitch(token)
requestFreshSession()
}
throw error
}
} finally {
if (revision === switchRevision) {
pendingTarget = null
$pendingConnectionId.set(null)
}
}
}
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import type { HermesConnection } from '@/global'
import { getCronModelImpactScope, syncCronModelImpactConnection } from './cron-model-impact-scope'
function connection(baseUrl: string, wsUrl: string, overrides: Partial<HermesConnection> = {}): HermesConnection {
return {
baseUrl,
isFullscreen: false,
mode: 'remote',
nativeOverlayWidth: 0,
token: 'secret-not-part-of-identity',
wsUrl,
logs: [],
windowButtonPosition: null,
...overrides
}
}
describe('cron model impact backend identity', () => {
it('survives disconnects and reminted websocket tickets but invalidates for another backend', () => {
syncCronModelImpactConnection(connection('https://one.example', 'wss://one.example?ticket=first'))
const first = getCronModelImpactScope()
syncCronModelImpactConnection(null)
expect(getCronModelImpactScope()).toEqual(first)
syncCronModelImpactConnection(connection('https://one.example', 'wss://one.example?ticket=second'))
expect(getCronModelImpactScope()).toEqual(first)
syncCronModelImpactConnection(connection('https://two.example', 'wss://two.example?ticket=third'))
expect(getCronModelImpactScope().generation).toBe(first.generation + 1)
})
it('treats reminted SSH tunnel ports as the same remote backend', () => {
const ssh = {
remoteKind: 'ssh' as const,
remoteIdentity: 'operator@remote-box',
remoteHost: 'remote-box'
}
syncCronModelImpactConnection(connection('http://127.0.0.1:41001', 'ws://127.0.0.1:41001', ssh))
const first = getCronModelImpactScope()
syncCronModelImpactConnection(connection('http://127.0.0.1:52002', 'ws://127.0.0.1:52002', ssh))
expect(getCronModelImpactScope()).toEqual(first)
})
})
@@ -0,0 +1,63 @@
import type { HermesConnection } from '@/global'
let generation = 0
let connectionIdentity = ''
const invalidationListeners = new Set<() => void>()
export interface CronModelImpactScopeSnapshot {
connection: string
generation: number
}
export function getCronModelImpactScope(): CronModelImpactScopeSnapshot {
return { connection: connectionIdentity, generation }
}
export function beginCronModelImpactAssignment(): CronModelImpactScopeSnapshot {
generation += 1
return getCronModelImpactScope()
}
export function invalidateCronModelImpactScopeState(): void {
generation += 1
invalidationListeners.forEach(listener => listener())
}
export function onCronModelImpactScopeInvalidated(listener: () => void): () => void {
invalidationListeners.add(listener)
return () => invalidationListeners.delete(listener)
}
function identityForConnection(connection: HermesConnection | null): string {
if (!connection) {
return ''
}
const backendIdentity =
connection.remoteKind === 'ssh'
? connection.remoteIdentity || connection.remoteHost || ''
: connection.remoteIdentity || connection.baseUrl
return [connection.mode ?? '', connection.remoteKind ?? '', backendIdentity, connection.profile ?? ''].join('\u0000')
}
/** Keep pending responses and action closures bound to the backend that issued
* them without storing or comparing connection secrets. */
export function syncCronModelImpactConnection(connection: HermesConnection | null): void {
// A null descriptor is an ordinary reconnect state, not evidence that the
// user selected another backend. Retain the last durable identity so the
// reconnect can prove whether the backend actually changed.
if (!connection) {
return
}
const next = identityForConnection(connection)
if (connectionIdentity && connectionIdentity !== next) {
invalidateCronModelImpactScopeState()
}
connectionIdentity = next
}
@@ -0,0 +1,290 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { $cronReviewRequest } from '@/store/cron'
import { $notifications, clearNotifications, dismissNotification } from '@/store/notifications'
import type { ModelAssignmentResponse } from '@/types/hermes'
const setModelAssignment = vi.fn()
const getApiRequestProfile = vi.fn<() => string | null>(() => 'default')
vi.mock('@/hermes', () => ({
setModelAssignment: (...args: unknown[]) => setModelAssignment(...args),
getApiRequestProfile: () => getApiRequestProfile()
}))
import {
CRON_MODEL_IMPACT_NOTIFICATION_ID,
invalidateCronModelImpactScope,
setMainModelAssignment
} from '@/store/cron-model-impact'
import { deferred } from '../test/deferred'
async function waitForConfirmToast() {
return vi.waitFor(() => {
const toast = $notifications.get().find(item => item.id.startsWith('model-warning-confirm-'))
expect(toast).toBeDefined()
return toast!
})
}
function response(impact: ModelAssignmentResponse['cron_model_impact']): ModelAssignmentResponse {
return {
ok: true,
scope: 'main',
provider: 'nous',
model: 'new/model',
cron_model_impact: impact
}
}
function positive(name = 'Morning summary'): ModelAssignmentResponse['cron_model_impact'] {
return {
available: true,
guard_enabled: true,
affected_count: 1,
truncated: false,
jobs: [{ id: 'job-1', name, drifted_axes: ['provider', 'model'] }]
}
}
beforeEach(() => {
setModelAssignment.mockReset()
getApiRequestProfile.mockReset()
getApiRequestProfile.mockReturnValue('default')
clearNotifications()
invalidateCronModelImpactScope({ clearNotification: false })
})
describe('setMainModelAssignment', () => {
it('shows one consumer warning and routes via a read-only review action', async () => {
setModelAssignment.mockResolvedValue(response(positive()))
const requestCount = $cronReviewRequest.get()
await setMainModelAssignment({ provider: 'nous', model: 'new/model' })
expect(setModelAssignment).toHaveBeenCalledWith({
scope: 'main',
provider: 'nous',
model: 'new/model'
})
const notification = $notifications.get().find(item => item.id === CRON_MODEL_IMPACT_NOTIFICATION_ID)
expect(notification?.kind).toBe('warning')
expect(notification?.title).toBe('Scheduled jobs need review')
expect(notification?.message).toContain('1 scheduled job will be skipped')
expect(notification?.detail).toContain('Morning summary')
expect(notification?.action?.label).toBe('Review scheduled jobs')
notification?.action?.onClick()
expect($cronReviewRequest.get()).toBe(requestCount + 1)
expect(setModelAssignment).toHaveBeenCalledTimes(1)
})
it('ignores malformed untrusted impact data', async () => {
setModelAssignment.mockResolvedValue(
response({
available: true,
guard_enabled: true,
affected_count: 2,
truncated: false,
jobs: [{ id: 'job-1', name: 'One', drifted_axes: ['provider'] }]
})
)
await setMainModelAssignment({ provider: 'nous', model: 'new/model' })
expect($notifications.get()).toEqual([])
})
it('keeps an existing warning for an older backend but clears it on explicit zero impact', async () => {
setModelAssignment.mockResolvedValueOnce(response(positive()))
await setMainModelAssignment({ provider: 'nous', model: 'one' })
expect($notifications.get()).toHaveLength(1)
setModelAssignment.mockResolvedValueOnce(response(undefined))
await setMainModelAssignment({ provider: 'nous', model: 'two' })
expect($notifications.get()).toHaveLength(1)
const retainedAction = $notifications.get()[0].action
const reviewCount = $cronReviewRequest.get()
retainedAction?.onClick()
expect($cronReviewRequest.get()).toBe(reviewCount + 1)
setModelAssignment.mockResolvedValueOnce(
response({
available: true,
guard_enabled: true,
affected_count: 0,
truncated: false,
jobs: []
})
)
await setMainModelAssignment({ provider: 'nous', model: 'three' })
expect($notifications.get()).toEqual([])
})
it('prompts and retries with confirm_expensive_model when the user accepts', async () => {
setModelAssignment.mockResolvedValueOnce(response(positive()))
await setMainModelAssignment({ provider: 'nous', model: 'one' })
const confirmResponse = {
ok: false,
scope: 'main',
provider: 'openrouter',
model: 'openai/gpt-5.5-pro',
confirm_required: true,
confirm_message: 'Confirm this expensive model.'
} satisfies ModelAssignmentResponse
setModelAssignment.mockResolvedValueOnce(confirmResponse)
setModelAssignment.mockResolvedValueOnce(response(positive('Confirmed job')))
const pending = setMainModelAssignment({ provider: 'openrouter', model: 'openai/gpt-5.5-pro' })
const confirm = await waitForConfirmToast()
expect(confirm.kind).toBe('warning')
expect(confirm.message).toBe('Confirm this expensive model.')
expect(confirm.action?.label).toBe('Confirm')
confirm.action?.onClick()
await pending
expect(setModelAssignment).toHaveBeenLastCalledWith(
expect.objectContaining({
provider: 'openrouter',
model: 'openai/gpt-5.5-pro',
confirm_expensive_model: true
})
)
expect($notifications.get().some(item => item.detail?.includes('Confirmed job'))).toBe(true)
})
it('declines without retrying when the user dismisses the guard prompt', async () => {
setModelAssignment.mockResolvedValueOnce(response(positive()))
await setMainModelAssignment({ provider: 'nous', model: 'one' })
setModelAssignment.mockResolvedValueOnce({
ok: false,
scope: 'main',
provider: 'openrouter',
model: 'openai/gpt-5.5-pro',
confirm_required: true,
confirm_message: 'Confirm this expensive model.'
} satisfies ModelAssignmentResponse)
const pending = setMainModelAssignment({ provider: 'openrouter', model: 'openai/gpt-5.5-pro' })
const confirm = await waitForConfirmToast()
dismissNotification(confirm.id)
await expect(pending).rejects.toThrow('Model change cancelled')
expect(setModelAssignment.mock.calls).toHaveLength(2)
const impact = $notifications.get().find(item => item.id === CRON_MODEL_IMPACT_NOTIFICATION_ID)
const reviewCount = $cronReviewRequest.get()
impact?.action?.onClick()
expect($cronReviewRequest.get()).toBe(reviewCount + 1)
})
it('fails closed without a prompt when skipConfirmPrompt is set', async () => {
setModelAssignment.mockResolvedValueOnce({
ok: false,
scope: 'main',
provider: 'openrouter',
model: 'openai/gpt-5.5-pro',
confirm_required: true,
confirm_message: 'Confirm this expensive model.'
} satisfies ModelAssignmentResponse)
await expect(
setMainModelAssignment({ provider: 'openrouter', model: 'openai/gpt-5.5-pro' }, undefined, {
skipConfirmPrompt: true
})
).rejects.toThrow('Confirm this expensive model.')
expect(setModelAssignment).toHaveBeenCalledTimes(1)
expect($notifications.get()).toEqual([])
})
it('does not recurse when the backend still demands confirm after ack', async () => {
const confirmResponse = {
ok: false,
scope: 'main',
provider: 'openrouter',
model: 'openai/gpt-5.5-pro',
confirm_required: true,
confirm_message: 'Confirm this expensive model.'
} satisfies ModelAssignmentResponse
setModelAssignment.mockResolvedValueOnce(confirmResponse)
setModelAssignment.mockResolvedValueOnce(confirmResponse)
const pending = setMainModelAssignment({ provider: 'openrouter', model: 'openai/gpt-5.5-pro' })
const confirm = await waitForConfirmToast()
confirm.action?.onClick()
await expect(pending).rejects.toThrow('Confirm this expensive model.')
expect(setModelAssignment).toHaveBeenCalledTimes(2)
})
it('publishes only the latest same-profile assignment when responses reverse', async () => {
const first = deferred<ModelAssignmentResponse>()
const second = deferred<ModelAssignmentResponse>()
setModelAssignment.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
const firstCall = setMainModelAssignment({ provider: 'nous', model: 'first' })
const secondCall = setMainModelAssignment({ provider: 'nous', model: 'second' })
second.resolve(response(positive('Second job')))
await secondCall
first.resolve(response(positive('Stale first job')))
await firstCall
const notification = $notifications.get()[0]
expect(notification.detail).toContain('Second job')
expect(notification.detail).not.toContain('Stale first job')
})
it('invalidates pending responses and action closures on profile or connection changes', async () => {
const pending = deferred<ModelAssignmentResponse>()
setModelAssignment.mockReturnValueOnce(pending.promise)
const call = setMainModelAssignment({ provider: 'nous', model: 'pending' })
invalidateCronModelImpactScope()
pending.resolve(response(positive('Stale job')))
await call
expect($notifications.get()).toEqual([])
setModelAssignment.mockResolvedValueOnce(response(positive('Current job')))
await setMainModelAssignment({ provider: 'nous', model: 'current' })
const action = $notifications.get()[0].action
const requestCount = $cronReviewRequest.get()
getApiRequestProfile.mockReturnValue('other')
invalidateCronModelImpactScope({ clearNotification: false })
action?.onClick()
expect($cronReviewRequest.get()).toBe(requestCount)
})
it('clears an obsolete warning when the drift guard is disabled', async () => {
setModelAssignment.mockResolvedValueOnce(response(positive()))
await setMainModelAssignment({ provider: 'nous', model: 'one' })
expect($notifications.get()).toHaveLength(1)
setModelAssignment.mockResolvedValueOnce(
response({ available: true, guard_enabled: false, affected_count: 0, truncated: false, jobs: [] })
)
await setMainModelAssignment({ provider: 'nous', model: 'two' })
expect($notifications.get()).toEqual([])
})
it('does not let a dismissed notification mutate cron configuration', async () => {
setModelAssignment.mockResolvedValue(response(positive()))
await setMainModelAssignment({ provider: 'nous', model: 'new/model' })
dismissNotification(CRON_MODEL_IMPACT_NOTIFICATION_ID)
expect($notifications.get()).toEqual([])
expect(setModelAssignment).toHaveBeenCalledTimes(1)
})
})
+262
View File
@@ -0,0 +1,262 @@
import { getApiRequestProfile, setModelAssignment } from '@/hermes'
import { translateNow } from '@/i18n'
import { requestCronReview } from '@/store/cron'
import {
beginCronModelImpactAssignment,
getCronModelImpactScope,
invalidateCronModelImpactScopeState,
onCronModelImpactScopeInvalidated
} from '@/store/cron-model-impact-scope'
import { dismissNotification, notify } from '@/store/notifications'
import type {
CronModelDriftAxis,
CronModelImpact,
CronModelImpactJob,
ModelAssignmentRequest,
ModelAssignmentResponse
} from '@/types/hermes'
export const CRON_MODEL_IMPACT_NOTIFICATION_ID = 'cron-model-impact'
const MAX_JOBS = 50
const MAX_ID_CODE_POINTS = 256
const MAX_NAME_CODE_POINTS = 120
const ALLOWED_AXES = new Set<CronModelDriftAxis>(['provider', 'model'])
function profileIdentity(): string {
return getApiRequestProfile()?.trim() || 'default'
}
function codePointLength(value: string): number {
return [...value].length
}
function hasControlCharacters(value: string): boolean {
return /\p{C}/u.test(value)
}
function validJob(value: unknown): value is CronModelImpactJob {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return false
}
const job = value as Partial<CronModelImpactJob>
if (
typeof job.id !== 'string' ||
job.id.trim() !== job.id ||
!job.id ||
codePointLength(job.id) > MAX_ID_CODE_POINTS ||
hasControlCharacters(job.id) ||
typeof job.name !== 'string' ||
job.name.trim() !== job.name ||
!job.name ||
codePointLength(job.name) > MAX_NAME_CODE_POINTS ||
hasControlCharacters(job.name) ||
!Array.isArray(job.drifted_axes) ||
job.drifted_axes.length < 1 ||
job.drifted_axes.length > 2 ||
new Set(job.drifted_axes).size !== job.drifted_axes.length ||
!job.drifted_axes.every(axis => ALLOWED_AXES.has(axis))
) {
return false
}
return true
}
export function parseCronModelImpact(value: unknown): CronModelImpact | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null
}
const impact = value as Partial<CronModelImpact>
if (
typeof impact.available !== 'boolean' ||
typeof impact.guard_enabled !== 'boolean' ||
!Number.isSafeInteger(impact.affected_count) ||
(impact.affected_count ?? -1) < 0 ||
typeof impact.truncated !== 'boolean' ||
!Array.isArray(impact.jobs) ||
impact.jobs.length > MAX_JOBS ||
!impact.jobs.every(validJob)
) {
return null
}
const count = impact.affected_count as number
const ids = impact.jobs.map(job => job.id)
if (
new Set(ids).size !== ids.length ||
(!impact.truncated && count !== impact.jobs.length) ||
(impact.truncated && (impact.jobs.length !== MAX_JOBS || count <= impact.jobs.length))
) {
return null
}
return impact as CronModelImpact
}
function currentResponseScope(profile: string, connection: string, generation: number): boolean {
const scope = getCronModelImpactScope()
return profileIdentity() === profile && scope.connection === connection && scope.generation === generation
}
function currentActionScope(profile: string, connection: string): boolean {
return profileIdentity() === profile && getCronModelImpactScope().connection === connection
}
function detailFor(impact: CronModelImpact): string {
const visible = impact.jobs.slice(0, 3).map(job => job.name)
const remaining = impact.affected_count - visible.length
return remaining > 0 ? translateNow('cron.modelImpact.detailMore', visible.join(', '), remaining) : visible.join(', ')
}
function publishImpact(impact: CronModelImpact, profile: string, connection: string, generation: number): void {
if (!impact.available) {
return
}
if (!impact.guard_enabled || impact.affected_count === 0) {
dismissNotification(CRON_MODEL_IMPACT_NOTIFICATION_ID)
return
}
notify({
id: CRON_MODEL_IMPACT_NOTIFICATION_ID,
kind: 'warning',
title: translateNow('cron.modelImpact.title'),
message: translateNow('cron.modelImpact.message', impact.affected_count),
detail: detailFor(impact),
action: {
label: translateNow('cron.modelImpact.review'),
onClick: () => {
if (currentActionScope(profile, connection)) {
requestCronReview()
}
}
}
})
}
export async function setMainModelAssignment(
request: Omit<ModelAssignmentRequest, 'scope'>,
scopeProfile?: null | string,
options?: { skipConfirmPrompt?: boolean }
): Promise<ModelAssignmentResponse> {
const { connection, generation } = beginCronModelImpactAssignment()
const profile = profileIdentity()
// Only pass the extra arg when a scope override exists, so unscoped callers
// keep the exact legacy call shape.
const assign = (body: Omit<ModelAssignmentRequest, 'scope'>) =>
scopeProfile == null
? setModelAssignment({ ...body, scope: 'main' })
: setModelAssignment({ ...body, scope: 'main' }, scopeProfile)
let result = await assign(request)
// Backend demands an explicit ack before persisting a model that trips a
// selection guard (expensive / data-training tiers like *-contributor).
// Settings used to throw confirm_message as a red error, so Apply could
// never persist. Prompt, then retry with confirm_expensive_model.
if (result.confirm_required) {
if (request.confirm_expensive_model || options?.skipConfirmPrompt) {
// Already acked, or headless onboarding (nothing mounted to click).
// Fail closed instead of recursing / dangling a prompt.
throw new Error(result.confirm_message?.trim() || translateNow('cron.modelImpact.saveFailed'))
}
const accepted = await confirmModelWarning(result.confirm_message?.trim() ?? '')
if (!accepted) {
throw new Error(translateNow('cron.modelImpact.declined'))
}
result = await assign({ ...request, confirm_expensive_model: true })
if (result.confirm_required || result.ok !== true) {
throw new Error(result.confirm_message?.trim() || translateNow('cron.modelImpact.saveFailed'))
}
} else if (result.ok !== true) {
throw new Error(result.confirm_message?.trim() || translateNow('cron.modelImpact.saveFailed'))
}
// A scoped assignment targets ANOTHER profile's backend: its cron impact
// belongs to that profile, and the review action would open the ACTIVE
// profile's cron view — skip the warning rather than mis-route it.
if (scopeProfile != null) {
return result
}
if (!currentResponseScope(profile, connection, generation)) {
return result
}
// Missing means an older backend. It is not evidence that an existing impact
// has gone away, so leave the current warning untouched.
if (result.cron_model_impact !== undefined) {
const impact = parseCronModelImpact(result.cron_model_impact)
if (impact) {
publishImpact(impact, profile, connection, generation)
}
}
return result
}
export function invalidateCronModelImpactScope(options: { clearNotification?: boolean } = {}): void {
if (options.clearNotification === false) {
beginCronModelImpactAssignment()
return
}
invalidateCronModelImpactScopeState()
}
// Scope changes originating outside this module (profile/backend switches)
// clear any warning that belongs to the old runtime.
onCronModelImpactScopeInvalidated(() => dismissNotification(CRON_MODEL_IMPACT_NOTIFICATION_ID))
/**
* Selection-guard warning as a confirm toast. Resolves true on Confirm, false
* on dismiss. The desktop has no blocking confirm API; this is the same
* notify-with-action pattern the in-session model picker uses.
*/
function confirmModelWarning(message: string): Promise<boolean> {
const id = `model-warning-confirm-${Date.now()}`
return new Promise(resolve => {
let settled = false
const finish = (value: boolean) => {
if (settled) {
return
}
settled = true
dismissNotification(id)
resolve(value)
}
notify({
id,
kind: 'warning',
title: translateNow('cron.modelImpact.confirmTitle'),
message: message || translateNow('cron.modelImpact.confirmDetail'),
detail: translateNow('cron.modelImpact.confirmDetail'),
action: {
label: translateNow('cron.modelImpact.confirmAction'),
onClick: () => finish(true)
},
onDismiss: () => finish(false)
})
})
}
+39
View File
@@ -0,0 +1,39 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { $cronJobs, beginCronJobsRequest, commitCronJobsRequest, setCronJobs, updateCronJobs } from './cron'
const oldJob = { id: 'old' } as never
const newJob = { id: 'new' } as never
describe('cron jobs request fencing', () => {
beforeEach(() => {
setCronJobs([])
})
it('rejects an older refresh after a newer refresh commits', () => {
const older = beginCronJobsRequest('all')
const newer = beginCronJobsRequest('all')
expect(commitCronJobsRequest(newer, [newJob])).toBe(true)
expect(commitCronJobsRequest(older, [oldJob])).toBe(false)
expect($cronJobs.get()).toEqual([newJob])
})
it('rejects a refresh from the previous profile scope', () => {
const work = beginCronJobsRequest('work')
beginCronJobsRequest('personal')
expect(commitCronJobsRequest(work, [oldJob])).toBe(false)
expect($cronJobs.get()).toEqual([])
})
it('rejects an in-flight poll after a local mutation', () => {
const poll = beginCronJobsRequest('all')
updateCronJobs(() => [newJob])
expect(commitCronJobsRequest(poll, [oldJob])).toBe(false)
expect($cronJobs.get()).toEqual([newJob])
})
})
+94
View File
@@ -0,0 +1,94 @@
import { atom } from 'nanostores'
import type { CronJob } from '@/types/hermes'
// Cron *jobs* (not run sessions) power the sidebar "Cron jobs" section. Listing
// the job — schedule, state, live next-run countdown — makes the job the
// first-class entity; its runs (sessions) resolve under it in the cron detail.
export const $cronJobs = atom<CronJob[]>([])
export interface CronJobsRequest {
generation: number
scope: string
}
export interface CronJobsScopeToken {
generation: number
scope: string
}
let cronJobsRequestGeneration = 0
let cronJobsRequestScope = ''
let cronJobsScopeGeneration = 0
function activateCronJobsScope(scope: string): void {
if (scope === cronJobsRequestScope) {
return
}
cronJobsRequestScope = scope
cronJobsRequestGeneration += 1
cronJobsScopeGeneration += 1
}
export function beginCronJobsRequest(scope: string): CronJobsRequest {
activateCronJobsScope(scope)
cronJobsRequestGeneration += 1
return { generation: cronJobsRequestGeneration, scope }
}
export function beginCronJobsAction(scope: string): CronJobsScopeToken {
activateCronJobsScope(scope)
return { generation: cronJobsScopeGeneration, scope }
}
export function isCronJobsScopeCurrent(token: CronJobsScopeToken): boolean {
return token.scope === cronJobsRequestScope && token.generation === cronJobsScopeGeneration
}
export function isCronJobsRequestCurrent(request: CronJobsRequest): boolean {
return request.scope === cronJobsRequestScope && request.generation === cronJobsRequestGeneration
}
export function invalidateCronJobsRequests(): void {
cronJobsRequestGeneration += 1
cronJobsScopeGeneration += 1
}
export function commitCronJobsRequest(request: CronJobsRequest, jobs: CronJob[]): boolean {
if (!isCronJobsRequestCurrent(request)) {
return false
}
// Consume the token so neither a duplicate completion nor any older request
// can publish after this authoritative snapshot.
cronJobsRequestGeneration += 1
$cronJobs.set(jobs)
return true
}
export const setCronJobs = (jobs: CronJob[]) => {
cronJobsRequestGeneration += 1
$cronJobs.set(jobs)
}
// In-place edit so the cron overlay's mutations (create/edit/delete/pause/…)
// land in the same atom the sidebar renders — no stale list until the next poll.
export const updateCronJobs = (fn: (jobs: CronJob[]) => CronJob[]) => {
cronJobsRequestGeneration += 1
$cronJobs.set(fn($cronJobs.get()))
}
// One-shot focus target: clicking "Manage" on a job sets this, then opens the
// cron overlay, which reads it once to select + scroll to that job. Cleared
// after consumption so re-opening cron normally doesn't re-focus a stale job.
export const $cronFocusJobId = atom<null | string>(null)
export const setCronFocusJobId = (id: null | string) => $cronFocusJobId.set(id)
// Shell-owned one-shot intent for stores without router context. Do not set a
// focus id here: the cron overlay's first fetch may not have loaded that row.
export const $cronReviewRequest = atom(0)
export const requestCronReview = () => $cronReviewRequest.set($cronReviewRequest.get() + 1)
@@ -0,0 +1,63 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
$dataUrlReadMaxMb,
clampDataUrlReadMaxMb,
DATA_URL_READ_DEFAULT_MAX_MB,
refreshDataUrlReadMaxMb,
setDataUrlReadMaxMb
} from './data-url-read-max'
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
const initialHermesDesktop = desktopWindow.hermesDesktop
const get = vi.fn(async () => ({ defaultMaxMb: 16, maxBytes: 16 * 1024 * 1024, maxMb: 16 }))
const set = vi.fn(async (maxMb: number) => ({
defaultMaxMb: 16,
maxBytes: maxMb * 1024 * 1024,
maxMb
}))
beforeEach(() => {
desktopWindow.hermesDesktop = { dataUrlReadMax: { get, set } } as unknown as Window['hermesDesktop']
$dataUrlReadMaxMb.set(DATA_URL_READ_DEFAULT_MAX_MB)
get.mockClear()
set.mockClear()
})
afterEach(() => {
desktopWindow.hermesDesktop = initialHermesDesktop
})
describe('clampDataUrlReadMaxMb', () => {
it('defaults invalid input and clamps only the safety range', () => {
expect(clampDataUrlReadMaxMb(undefined)).toBe(16)
expect(clampDataUrlReadMaxMb(0)).toBe(1)
expect(clampDataUrlReadMaxMb(100)).toBe(100)
expect(clampDataUrlReadMaxMb(99999)).toBe(4096)
expect(clampDataUrlReadMaxMb(32.4)).toBe(32)
})
})
describe('data-url-read-max store', () => {
it('loads the main-process value into the atom', async () => {
get.mockResolvedValueOnce({ defaultMaxMb: 16, maxBytes: 32 * 1024 * 1024, maxMb: 32 })
await expect(refreshDataUrlReadMaxMb()).resolves.toBe(32)
expect($dataUrlReadMaxMb.get()).toBe(32)
})
it('writes through the bridge and updates the atom', async () => {
await expect(setDataUrlReadMaxMb(48)).resolves.toBe(48)
expect(set).toHaveBeenCalledWith(48)
expect($dataUrlReadMaxMb.get()).toBe(48)
})
it('keeps the last known-good value when the bridge write fails', async () => {
set.mockRejectedValueOnce(new Error('boom'))
await expect(setDataUrlReadMaxMb(48)).resolves.toBe(16)
expect($dataUrlReadMaxMb.get()).toBe(16)
})
})
@@ -0,0 +1,69 @@
/**
* Max size for local files Desktop loads as data URLs (composer attach, image
* previews, etc.). Main owns the real cap + its JSON under userData — this
* atom only mirrors it for Settings → Chat. See electron/main.ts
* (`hermes:data-url-read-max:*`). The default and the clamp are shared with
* main via apps/shared so the two ends cannot drift.
*/
import {
clampDataUrlReadMaxMb,
DATA_URL_READ_DEFAULT_MAX_MB,
DATA_URL_READ_MAX_MAX_MB,
DATA_URL_READ_MIN_MAX_MB
} from '@hermes/shared'
import { atom } from 'nanostores'
import { notifyError } from '@/store/notifications'
export { clampDataUrlReadMaxMb, DATA_URL_READ_DEFAULT_MAX_MB, DATA_URL_READ_MAX_MAX_MB, DATA_URL_READ_MIN_MAX_MB }
export const $dataUrlReadMaxMb = atom<number>(DATA_URL_READ_DEFAULT_MAX_MB)
export async function refreshDataUrlReadMaxMb(): Promise<number> {
const api = window.hermesDesktop?.dataUrlReadMax
if (!api) {
return $dataUrlReadMaxMb.get()
}
try {
const result = await api.get()
const maxMb = clampDataUrlReadMaxMb(result.maxMb)
$dataUrlReadMaxMb.set(maxMb)
return maxMb
} catch {
return $dataUrlReadMaxMb.get()
}
}
export async function setDataUrlReadMaxMb(maxMb: number): Promise<number> {
const next = clampDataUrlReadMaxMb(maxMb)
const api = window.hermesDesktop?.dataUrlReadMax
if (!api) {
$dataUrlReadMaxMb.set(next)
return next
}
try {
const result = await api.set(next)
const applied = clampDataUrlReadMaxMb(result.maxMb)
$dataUrlReadMaxMb.set(applied)
return applied
} catch (error) {
// Leave the atom at the last known-good value and surface the failure —
// an optimistic set here would show a cap that was never persisted and
// silently reverts on restart.
notifyError(error, 'Could not save the max attachment size')
return $dataUrlReadMaxMb.get()
}
}
if (typeof window !== 'undefined' && window.hermesDesktop?.dataUrlReadMax) {
void refreshDataUrlReadMaxMb()
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Disable F12 DevTools shortcut — a device-local preference.
*
* When enabled, F12 is blocked in the main process (before-input-event).
* Ctrl+Shift+I (or Cmd+Opt+I on Mac) still works regardless.
* Mirrors the toggle to the main process via IPC.
*/
import { atom } from 'nanostores'
import { persistBoolean, storedBoolean } from '@/lib/storage'
const KEY = 'hermes.desktop.disableF12.v1'
export const $disableF12 = atom<boolean>(typeof window === 'undefined' ? false : storedBoolean(KEY, false))
export function setDisableF12(on: boolean): void {
$disableF12.set(on)
}
if (typeof window !== 'undefined') {
$disableF12.subscribe(on => {
persistBoolean(KEY, on)
window.hermesDesktop?.setDisableF12?.(on)
})
}
@@ -0,0 +1,20 @@
/**
* `display.timestamps` — one config key for message timestamps everywhere.
*
* The same config.yaml key that puts [HH:MM] stamps on classic-CLI labels
* gates the desktop transcript's per-message / per-tool-run timeline
* timestamps (#41531, #65272, #68052). Off by default, matching the CLI
* default in hermes_cli/config_defaults.py.
*
* Display-only: reading or toggling it never mutates model context, so it is
* prompt-cache safe. Hover tooltips with the exact time (#70450) are NOT
* gated — they add no visual noise until the user asks for them.
*/
import { atom } from 'nanostores'
export const $displayTimestamps = atom<boolean>(false)
export function setDisplayTimestampsFromConfig(value: unknown): void {
$displayTimestamps.set(value === true || value === 'true' || value === 1)
}
@@ -0,0 +1,51 @@
import { atom } from 'nanostores'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const $gateway = atom<unknown>(null)
const request = vi.fn(async (_method: string, _params?: Record<string, unknown>) => undefined)
vi.mock('@/store/gateway', () => ({
$gateway,
activeGateway: () => ({ request })
}))
const { mirrorDisplayToggle } = await import('./display-toggles')
const STORAGE_KEY = 'hermes.desktop.test-toggle.v1'
const $enabled = atom(true)
mirrorDisplayToggle('display.test_toggle', STORAGE_KEY, $enabled)
const sets = () => request.mock.calls.filter(([method]) => method === 'config.set').map(([, params]) => params)
beforeEach(() => {
localStorage.clear()
request.mockClear()
$enabled.set(true)
request.mockClear()
})
describe('display toggle mirror', () => {
it('sends the user answer to the gateway when it changes', () => {
$enabled.set(false)
expect(sets()).toEqual([{ key: 'display.test_toggle', value: 'false' }])
})
it('re-sends a touched setting to a gateway that has never seen it', () => {
localStorage.setItem(STORAGE_KEY, 'false')
$enabled.set(false)
request.mockClear()
$gateway.set({})
expect(sets()).toEqual([{ key: 'display.test_toggle', value: 'false' }])
})
it('leaves an untouched setting alone, so a hand-edited config.yaml wins', () => {
$gateway.set({})
expect(sets()).toEqual([])
})
})
+68
View File
@@ -0,0 +1,68 @@
/**
* Appearance switches the BACKEND also has to know about.
*
* Most of what Settings → Appearance controls is the renderer's own business.
* A few switches aren't: they decide whether a tool is in the model's schema at
* all (`react_to_message`, `tip`, `tour`), and that decision is made where the
* agent runs. So the value has to travel to whichever gateway the app is
* actually talking to — local, SSH, plain URL, or cloud — which is why this is
* `config.set` on the live connection and not an env var on some process.
*
* Two moments matter, and shipping only the first is why the reactions toggle
* has never worked end to end:
*
* - **On change**, because that is the user answering.
* - **On connect**, because a gateway the app has never spoken to holds no
* answer at all — and for a switch that defaults ON, silence reads as consent
* to exactly the thing the user turned off.
*
* The connect push is deliberately narrow: only settings the user has actually
* touched (a stored value exists) are re-sent. Otherwise a fresh install would
* broadcast its defaults over a `config.yaml` somebody hand-edited on the
* server, which is a worse failure than the one being fixed.
*/
import type { ReadableAtom } from 'nanostores'
import { readKey } from '@/lib/storage'
import { $gateway, activeGateway } from '@/store/gateway'
interface Mirror {
configKey: string
read: () => boolean
storageKey: string
}
const mirrors: Mirror[] = []
function push(mirror: Mirror): void {
void activeGateway()
?.request('config.set', { key: mirror.configKey, value: mirror.read() ? 'true' : 'false' })
.catch(() => {
// Not connected, or a gateway too old to know the key. The next toggle
// and the next connection both try again.
})
}
/** Keep `display.<configKey>` on the live gateway in step with a renderer atom. */
export function mirrorDisplayToggle(configKey: string, storageKey: string, $enabled: ReadableAtom<boolean>): void {
if (typeof window === 'undefined') {
return
}
const mirror: Mirror = { configKey, read: () => $enabled.get(), storageKey }
mirrors.push(mirror)
// listen, not subscribe: fire on CHANGE only. Module init must not write.
$enabled.listen(() => push(mirror))
}
if (typeof window !== 'undefined') {
$gateway.listen(() => {
for (const mirror of mirrors) {
if (readKey(mirror.storageKey) !== null) {
push(mirror)
}
}
})
}
+37
View File
@@ -0,0 +1,37 @@
import { type Codec, Codecs, persistentAtom } from '@/lib/persisted'
// Privacy gate for inline embeds. Loading an embed reaches out to a third party
// (IP, referrer, cookies), so by default we render a placeholder until the user
// consents — per embed ("Load once") or per service ("Always allow YouTube").
// Mirrors the tool-approval model, but purely client-side (the renderer is what
// makes the request) so it never touches the gateway/config.yaml.
export type EmbedMode = 'always' | 'ask' | 'off'
const MODE_KEY = 'hermes.desktop.embed-mode'
const ALLOWED_KEY = 'hermes.desktop.embed-allowed'
const modeCodec: Codec<EmbedMode> = {
decode: raw => (raw === 'always' || raw === 'off' ? raw : 'ask'),
encode: value => value
}
/** Global default: ask (placeholder), always (auto-load), off (plain link). */
export const $embedMode = persistentAtom<EmbedMode>(MODE_KEY, 'ask', modeCodec)
/** Providers granted a standing "always allow" (e.g. `youtube`, `twitter`). */
export const $embedAllowed = persistentAtom<string[]>(ALLOWED_KEY, [], Codecs.stringArray)
export function allowProvider(provider: string) {
const current = $embedAllowed.get()
if (!current.includes(provider)) {
$embedAllowed.set([...current, provider])
}
}
export function setEmbedMode(mode: EmbedMode) {
$embedMode.set(mode)
}
export function clearEmbedAllowed() {
$embedAllowed.set([])
}
@@ -0,0 +1,58 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $notifications, clearNotifications } from '@/store/notifications'
vi.mock('@/lib/media', () => ({
downloadGatewayMediaFile: vi.fn()
}))
const media = await import('@/lib/media')
const downloadGatewayMediaFile = vi.mocked(media.downloadGatewayMediaFile)
const { downloadRemoteFile, shouldOfferRemoteFileDownload } = await import('./file-actions')
describe('shouldOfferRemoteFileDownload', () => {
it('is only for files on a remote backend', () => {
expect(shouldOfferRemoteFileDownload(false, true)).toBe(true)
expect(shouldOfferRemoteFileDownload(true, true)).toBe(false)
expect(shouldOfferRemoteFileDownload(false, false)).toBe(false)
expect(shouldOfferRemoteFileDownload(true, false)).toBe(false)
})
})
describe('downloadRemoteFile', () => {
beforeEach(() => {
clearNotifications()
downloadGatewayMediaFile.mockReset()
})
afterEach(() => {
clearNotifications()
})
it('saves a remote gateway file through the native download bridge', async () => {
downloadGatewayMediaFile.mockResolvedValue({ path: '/Users/me/Downloads/notes.md', saved: true })
await downloadRemoteFile('/home/linux/project/notes.md')
expect(downloadGatewayMediaFile).toHaveBeenCalledWith('/home/linux/project/notes.md')
expect($notifications.get()[0]?.message).toBe('Saved')
})
it('stays quiet when the save dialog is canceled', async () => {
downloadGatewayMediaFile.mockResolvedValue({ canceled: true, saved: false })
await downloadRemoteFile('/home/linux/project/notes.md')
expect($notifications.get()).toEqual([])
})
it('toasts when the gateway download fails', async () => {
downloadGatewayMediaFile.mockRejectedValue(new Error('Desktop file download bridge is unavailable'))
await downloadRemoteFile('/home/linux/project/notes.md')
expect($notifications.get()[0]?.kind).toBe('error')
expect($notifications.get()[0]?.title).toBe('Download failed')
})
})
+118
View File
@@ -0,0 +1,118 @@
import { atom } from 'nanostores'
import { translateNow } from '@/i18n'
import {
copyTextToClipboard,
isDesktopFsRemoteMode,
renameDesktopPath,
revealDesktopPath,
trashDesktopPath
} from '@/lib/desktop-fs'
import { downloadGatewayMediaFile } from '@/lib/media'
import { notify, notifyError } from '@/store/notifications'
import { notifyWorkspaceChanged } from '@/store/workspace-events'
// Shared file-row actions for BOTH trees (the file browser + the review/git
// tree): reveal, copy path, download (remote), rename, delete. Rename/delete
// route through a single dialog set (driven by this atom, rendered once by
// `FileActionDialogs`) instead of one dialog per row. After a successful
// mutation we bump the workspace tick so every git-/fs-mirroring surface
// refreshes.
export interface FileActionTarget {
isDirectory: boolean
/** Display name (basename) shown in dialogs. */
name: string
/** Absolute path on disk. */
path: string
}
// Delete routes through a single confirm dialog (rendered once). Rename is
// INLINE (VS Code style — an input in the row), driven by `$renamingPath`.
export type FileActionDialog = { kind: 'delete' } & FileActionTarget
export const $fileActionDialog = atom<FileActionDialog | null>(null)
export function requestFileDelete(target: FileActionTarget): void {
$fileActionDialog.set({ kind: 'delete', ...target })
}
export function closeFileActionDialog(): void {
$fileActionDialog.set(null)
}
// Absolute path of the row currently being renamed inline, or null. A row whose
// path matches renders an edit input in place of its label; F2 / Enter (on a
// focused row) and the context-menu "Rename" all set this.
export const $renamingPath = atom<null | string>(null)
export function beginInlineRename(path: string): void {
$renamingPath.set(path)
}
export function cancelInlineRename(): void {
$renamingPath.set(null)
}
// ── Direct (no-dialog) actions ───────────────────────────────────────────────
export async function revealFile(path: string): Promise<void> {
try {
await revealDesktopPath(path)
} catch (error) {
notifyError(error, translateNow('errors.genericFailure'))
}
}
export async function copyFilePath(path: string): Promise<void> {
try {
await copyTextToClipboard(path)
notify({ durationMs: 1500, kind: 'info', message: translateNow('fileMenu.pathCopied') })
} catch (error) {
notifyError(error, translateNow('common.copyFailed'))
}
}
/** Remote Files panel can list gateway files but Reveal/Rename/Delete are local-only.
* Download is the local-copy affordance. Folders stay out — `/api/fs/download`
* streams a single file. */
export function shouldOfferRemoteFileDownload(isDirectory: boolean, remote = isDesktopFsRemoteMode()): boolean {
return remote && !isDirectory
}
export async function downloadRemoteFile(path: string): Promise<void> {
try {
const result = await downloadGatewayMediaFile(path)
if (result.canceled || !result.saved) {
return
}
notify({ durationMs: 1500, kind: 'info', message: translateNow('fileMenu.downloadSaved') })
} catch (error) {
notifyError(error, translateNow('fileMenu.downloadFailed'))
}
}
/** Strip a `relativeTo` prefix to produce a repo/cwd-relative path. */
export function toRelativePath(path: string, relativeTo: string): string {
const base = relativeTo.replace(/[\\/]+$/, '')
if (path === base) {
return path
}
return path.startsWith(`${base}/`) || path.startsWith(`${base}\\`) ? path.slice(base.length + 1) : path
}
// ── Dialog-confirmed mutations (called by FileActionDialogs) ──────────────────
export async function executeFileRename(path: string, newName: string): Promise<void> {
await renameDesktopPath(path, newName)
notifyWorkspaceChanged()
}
export async function executeFileDelete(path: string): Promise<void> {
await trashDesktopPath(path)
notifyWorkspaceChanged()
}
+226
View File
@@ -0,0 +1,226 @@
import { atom } from 'nanostores'
import { captureFindScope, currentFindScope, performScopedFind, releaseFindScope } from '@/lib/find-in-page-scope'
export interface FindInPageState {
active: boolean
query: string
matchOrdinal: number
matchCount: number
}
const EMPTY: FindInPageState = { active: false, query: '', matchOrdinal: 0, matchCount: 0 }
export const $findInPage = atom<FindInPageState>({ ...EMPTY })
/**
* Open the find bar and capture the CURRENT VIEW as the search scope.
*
* Capturing once at open time (rather than re-resolving on every keystroke)
* means a mid-search route change can't silently re-home the highlights onto
* a different session — the FindBar's `useLocation` cleanup closes the bar
* before the route flips, so the scope the user actually sees in the input
* is the only one ever searched. See apps/desktop/src/components/find-bar.tsx
* for the route-change close logic; see lib/find-in-page-scope.ts for the
* "current view" predicate (#81726).
*/
export function openFindBar(): void {
$findInPage.set({ ...EMPTY, active: true })
captureFindScope()
}
export function closeFindBar(): void {
// Already closed: don't re-issue clear. Escape is a shared gesture (the
// switcher and dialogs claim it too), so a stray second close must not
// re-strip highlights from a bar that has already been torn down.
if (!$findInPage.get().active) {
return
}
$findInPage.set({ ...EMPTY })
// Strip highlights and the scope marker from the DOM we previously wrapped.
releaseFindScope()
}
export async function setFindQuery(query: string): Promise<void> {
const prev = $findInPage.get()
// Never search for a closed bar. The component clears its debounce on
// close, but a timer that already fired (or any late caller) must not
// re-wrap matches after the user pressed Escape.
if (!prev.active) {
return
}
if (!query) {
$findInPage.set({ ...prev, query: '', matchOrdinal: 0, matchCount: 0 })
const scope = currentFindScope()
if (scope) {
// Re-run the scoped walker with an empty query — same code path,
// strips highlights + zeroes the counter without special-casing.
performScopedFind(scope, '', { forward: true, findNext: false })
}
return
}
const scope = currentFindScope()
if (!scope) {
// No chat surface to search (e.g. settings page, command center). The
// bar still accepts a query for parity with the bridge-driven path, but
// matches will be zero — there's nothing on screen that IS a "view".
$findInPage.set({ ...prev, query, matchOrdinal: 0, matchCount: 0 })
return
}
const result = performScopedFind(scope, query, { forward: true, findNext: false })
$findInPage.set({ ...prev, query, matchOrdinal: result.activeOrdinal, matchCount: result.count })
}
export function findNext(): void {
step(true)
}
export function findPrevious(): void {
step(false)
}
function step(forward: boolean): void {
const { query } = $findInPage.get()
if (!query) {
return
}
const scope = currentFindScope()
if (!scope) {
return
}
const result = performScopedFind(scope, query, { forward, findNext: true })
$findInPage.set({ ...$findInPage.get(), matchOrdinal: result.activeOrdinal, matchCount: result.count })
}
/** Called by the preload bridge when `found-in-page` fires on webContents.
* Retained for the multi-window case (a secondary session window still uses
* the Electron bridge — see electron/find-in-page.ts); the renderer-side
* walker for the primary window never fires this. */
export function updateFindResults(activeMatch: number, count: number): void {
const prev = $findInPage.get()
$findInPage.set({ ...prev, matchOrdinal: activeMatch, matchCount: count })
}
// The found-in-page subscription is process-wide, not per-mount: the FindBar
// lives in the global overlay set and the shell remounts it on a connection
// re-home (soft switch) while route changes keep it alive. Refcount the single
// bridge listener so a remount cannot stack duplicate subscriptions — every
// stacked listener would re-dispatch the same result and, worse, outlive its
// component.
let listenerRefs = 0
let detachListener: (() => void) | undefined
/**
* Subscribe to `found-in-page` results. Returns a release fn; the underlying
* bridge listener is installed on the first subscriber and removed when the
* last one releases. Safe to call from an effect with a `[]` dep list.
*
* Kept for secondary-window renderers that still drive search via the
* Electron bridge. The primary window's renderer-side walker calls
* `updateFindResults` synchronously and never wires this listener.
*/
export function initFindInPageListener(): () => void {
listenerRefs += 1
if (listenerRefs === 1) {
detachListener = window.hermesDesktop?.onFoundInPage?.(result => {
updateFindResults(result.activeMatchOrdinal, result.count)
})
}
let released = false
return () => {
// Guard double-release: React can invoke a cleanup once, but a caller
// holding the fn shouldn't be able to drive the refcount negative.
if (released) {
return
}
released = true
listenerRefs -= 1
if (listenerRefs === 0) {
detachListener?.()
detachListener = undefined
}
}
}
/** Test seam: number of live bridge subscriptions (0 or 1 in practice). */
export function findInPageListenerCount(): number {
return listenerRefs
}
/**
* Test seam: force-detach the bridge listener and zero the refcount.
* Production code never calls this — tests use it so one case's leaked
* subscription can't bleed into the next.
*/
export function resetFindInPageListenerForTest(): void {
detachListener?.()
detachListener = undefined
listenerRefs = 0
}
// Same refcount pattern as `initFindInPageListener`, but for the
// "main-process Ctrl/Cmd+F forwarded to renderer" channel. On Pop!_OS /
// GNOME-based Linux distros the GTK compositor grabs Ctrl+F before the
// renderer's keydown listener can fire — the main process intercepts the
// chord via `before-input-event` and emits this IPC, so the renderer can
// still open the FindBar (#81727).
let openFindBarRefs = 0
let detachOpenFindBar: (() => void) | undefined
export function initOpenFindBarListener(): () => void {
openFindBarRefs += 1
if (openFindBarRefs === 1) {
detachOpenFindBar = window.hermesDesktop?.onOpenFindBarRequested?.(() => {
openFindBar()
})
}
let released = false
return () => {
if (released) {
return
}
released = true
openFindBarRefs -= 1
if (openFindBarRefs === 0) {
detachOpenFindBar?.()
detachOpenFindBar = undefined
}
}
}
/** Test seam: number of live "open find bar" subscriptions. */
export function openFindBarListenerCount(): number {
return openFindBarRefs
}
/** Test seam: detach the open-find-bar bridge listener and zero the refcount. */
export function resetOpenFindBarListenerForTest(): void {
detachOpenFindBar?.()
detachOpenFindBar = undefined
openFindBarRefs = 0
}
+55
View File
@@ -0,0 +1,55 @@
import { atom } from 'nanostores'
import type { DesktopAgentRoster } from '@/global'
// The union agent roster — every profile on every registered gateway — as
// Electron enumerates it over REST/SSH (`hermes:agents:roster`). Bot Mode and
// the Capabilities scope selector already read it; the Sessions profile rail
// is its third consumer. Fetched on demand only (mount / focus / registry
// change), never on a timer: the multi-connection docs rule out periodic
// fleet polling from the sidebar.
export const $fleetRoster = atom<DesktopAgentRoster | null>(null)
const FLEET_ROSTER_STALE_MS = 60_000
let fetchedAt = 0
let inflight: null | Promise<void> = null
export async function refreshFleetRoster(options: { force?: boolean } = {}): Promise<void> {
const bridge = window.hermesDesktop?.getAgentRoster
if (!bridge) {
return
}
if (!options.force && $fleetRoster.get() && Date.now() - fetchedAt < FLEET_ROSTER_STALE_MS) {
return
}
if (inflight) {
return inflight
}
inflight = bridge()
.then(roster => {
$fleetRoster.set(roster)
fetchedAt = Date.now()
})
.catch((error: unknown) => {
// A failed enumeration keeps the last roster: the rail should not lose
// a machine's squares because one refresh hit a sleeping box.
console.warn('[fleet-roster] enumeration failed; keeping the previous roster', error)
})
.finally(() => {
inflight = null
})
return inflight
}
/** @internal */
export function _resetFleetRosterForTests(): void {
$fleetRoster.set(null)
fetchedAt = 0
inflight = null
}
@@ -0,0 +1,185 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Regression suite for #89622: clicking a profile in the rail did nothing.
// The live-work pruner (pruneSecondaryGateways) disposed the switch target's
// secondary entry while its socket was still dialing — the target is not yet
// the active key, has no live sessions, and holds no request lease, so every
// prune recompute during the ~3s cold backend spawn saw it as idle garbage.
// These tests pin the activation lease that spares a mid-dial entry, its
// release once the activation settles, and its bounded self-heal.
const secondaryGateways: Array<{
close: ReturnType<typeof vi.fn>
connect: ReturnType<typeof vi.fn>
connectionState: string
request: ReturnType<typeof vi.fn>
}> = []
let connectGate: Promise<void> | null = null
vi.mock('@/hermes', () => ({
HermesGateway: class {
connectionState = 'closed'
connect = vi.fn(async () => {
if (this.connectionState === 'connecting') {
return
}
this.connectionState = 'connecting'
if (connectGate) {
await connectGate
}
this.connectionState = 'open'
})
request = vi.fn(async (method: string, params: Record<string, unknown>) => ({ method, params }))
close = vi.fn()
onEvent = vi.fn(() => () => {})
onState = vi.fn(() => () => {})
constructor() {
secondaryGateways.push(this)
}
},
setApiRequestConnection: vi.fn()
}))
vi.mock('@/store/session', () => ({ setConnection: vi.fn(), setGatewayState: vi.fn() }))
vi.mock('@/store/notify-baseline', () => ({ markNativeNotifyBaseline: vi.fn() }))
const {
closeSecondaryGateways,
configureGatewayRegistry,
ensureGatewayForAgent,
ensureGatewayForProfile,
pruneSecondaryGateways,
setPrimaryGateway
} = await import('./gateway')
function installDesktop(): void {
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
getConnection: vi.fn(async (profile: null | string) =>
profile ? { port: 5151, profile, token: 'secondary-token' } : { port: 4242, token: 'primary-token' }
),
getConnectionFor: vi.fn(async ({ profile }: { connectionId: string; profile: string }) => ({
port: 6161,
profile,
token: 'registry-token'
})),
getGatewayWsUrlFor: vi.fn(async () => 'ws://127.0.0.1:6161/ws'),
touchBackend: vi.fn(async () => undefined)
}
}
function makePrimary() {
return {
connectionState: 'open',
request: vi.fn(async (method: string, params: Record<string, unknown>) => ({ method, params }))
}
}
beforeEach(() => {
secondaryGateways.length = 0
connectGate = null
configureGatewayRegistry({ onEvent: vi.fn() })
closeSecondaryGateways()
installDesktop()
setPrimaryGateway(makePrimary() as never, 'default')
})
afterEach(() => {
closeSecondaryGateways()
vi.clearAllMocks()
vi.useRealTimers()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
// Flush microtasks until the dial registers its secondary (or bail). The dial
// path's hop count is an implementation detail — #95343's withTimeout wrapper
// added an await hop and broke the previous exactly-two-Promise.resolve flush.
async function flushUntilSecondaryRegistered(max = 20): Promise<void> {
for (let i = 0; i < max && secondaryGateways.length === 0; i++) {
await Promise.resolve()
}
}
describe('activation lease vs. the live-work pruner (#89622)', () => {
it('a prune during the switch dial does not dispose the target and the switch lands', async () => {
let releaseConnect: () => void = () => undefined
connectGate = new Promise<void>(resolve => {
releaseConnect = resolve
})
const switching = ensureGatewayForProfile('bot')
// Let the dial start (createSecondary + connect() now pending on the gate).
await flushUntilSecondaryRegistered()
expect(secondaryGateways).toHaveLength(1)
// The exact recompute that killed the switch: no live sessions, target not
// active yet, empty keep-set. The mid-dial entry must survive it.
pruneSecondaryGateways(new Set())
expect(secondaryGateways[0].close).not.toHaveBeenCalled()
releaseConnect()
await switching
// The switch landed on the target's own (still-live) socket.
expect(secondaryGateways[0].connectionState).toBe('open')
})
it('the agent door leases its entry against the pruner too', async () => {
let releaseConnect: () => void = () => undefined
connectGate = new Promise<void>(resolve => {
releaseConnect = resolve
})
const switching = ensureGatewayForAgent('homelab', 'research')
await Promise.resolve()
await Promise.resolve()
expect(secondaryGateways).toHaveLength(1)
pruneSecondaryGateways(new Set())
expect(secondaryGateways[0].close).not.toHaveBeenCalled()
releaseConnect()
await expect(switching).resolves.toBe(true)
})
it('the lease is released once the switch settles — a later prune reclaims the idle entry', async () => {
await ensureGatewayForProfile('bot')
// Move away so 'bot' is neither active nor kept, then prune: with the
// lease released, the idle entry must be disposed exactly as before.
await ensureGatewayForProfile('default')
pruneSecondaryGateways(new Set())
expect(secondaryGateways[0].close).toHaveBeenCalled()
})
it('an orphaned lease expires — the pruner is not permanently locked out', async () => {
vi.useFakeTimers()
let releaseConnect: () => void = () => undefined
connectGate = new Promise<void>(resolve => {
releaseConnect = resolve
})
// Dial starts and never settles (wedged bridge call).
void ensureGatewayForProfile('bot')
await flushUntilSecondaryRegistered()
expect(secondaryGateways).toHaveLength(1)
// Inside the lease window: spared.
pruneSecondaryGateways(new Set())
expect(secondaryGateways[0].close).not.toHaveBeenCalled()
// Past the lease window: reclaimed. (Lease is wall-clock bounded so a
// leaked lease cannot pin a dead entry forever.)
vi.setSystemTime(Date.now() + 31_000)
pruneSecondaryGateways(new Set())
expect(secondaryGateways[0].close).toHaveBeenCalled()
releaseConnect()
})
})
@@ -0,0 +1,163 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Registry-agent scoping regression (multi-source roster): the active key can
// name a `conn:<id>::<profile>` scope whose secondaries entry gets evicted by
// a teardown path (closeSecondaryGateways during a soft gateway switch,
// pruneSecondaryGateways). activeGateway() used to silently fall back to the
// PRIMARY gateway for a missing NAMED scope — every send/session op then hit
// the wrong machine with no error. These tests pin the invariant: a missing
// named scope never resolves to the primary; every eviction site explicitly
// re-points the active key at the primary so `activeKey` always resolves.
const gatewayMocks = vi.hoisted(() => ({
connect: vi.fn(async (_wsUrl: string): Promise<void> => undefined),
setConnection: vi.fn(),
setGatewayState: vi.fn()
}))
vi.mock('@/hermes', () => ({
setApiRequestConnection: vi.fn(),
HermesGateway: class {
connectionState = 'closed'
connect = async (wsUrl: string): Promise<void> => {
await gatewayMocks.connect(wsUrl)
this.connectionState = 'open'
}
close = (): void => {
this.connectionState = 'closed'
}
onEvent = vi.fn(() => () => {})
onState = vi.fn(() => () => {})
}
}))
vi.mock('@/store/session', () => ({
setConnection: gatewayMocks.setConnection,
setGatewayState: gatewayMocks.setGatewayState
}))
vi.mock('@/store/notify-baseline', () => ({ markNativeNotifyBaseline: vi.fn() }))
const {
$gateway,
activeGateway,
closeSecondaryGateways,
configureGatewayRegistry,
ensureGatewayForAgent,
ensureGatewayForProfile,
isActivePrimary,
pruneSecondaryGateways,
setPrimaryGateway
} = await import('./gateway')
interface DesktopStub {
getConnection: ReturnType<typeof vi.fn>
getConnectionFor: ReturnType<typeof vi.fn>
}
function installDesktop(stub: DesktopStub): void {
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = stub
}
function makePrimary(): { connectionState: string } {
return { connectionState: 'open' }
}
const agentConn = {
authMode: 'token',
baseUrl: 'https://homelab.invalid',
mode: 'remote',
profile: 'research',
token: 'fake-test-token',
wsUrl: 'wss://homelab.invalid/api/ws?token=fake-test-token'
}
function installAgentDesktop(): DesktopStub {
const stub: DesktopStub = {
getConnection: vi.fn(async () => agentConn),
getConnectionFor: vi.fn(async () => agentConn)
}
installDesktop(stub)
return stub
}
beforeEach(() => {
configureGatewayRegistry({ onEvent: vi.fn() })
})
afterEach(() => {
closeSecondaryGateways()
vi.clearAllMocks()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
describe('registry-agent scope eviction (activeGateway must never silently hit the primary)', () => {
it('activates the agent socket, not the primary, for a registry scope', async () => {
const primary = makePrimary()
setPrimaryGateway(primary as never, 'default')
installAgentDesktop()
await ensureGatewayForAgent('homelab', 'research')
expect(isActivePrimary()).toBe(false)
expect(activeGateway()).not.toBe(primary)
expect($gateway.get()).not.toBe(primary)
expect(gatewayMocks.connect).toHaveBeenCalledWith(agentConn.wsUrl)
})
it('keeps the primary active when a fresh registry activation cannot resolve', async () => {
const primary = makePrimary()
setPrimaryGateway(primary as never, 'default')
await ensureGatewayForProfile('default')
const publishedPrimary = $gateway.get()
installDesktop({
getConnection: vi.fn(async () => agentConn),
getConnectionFor: vi.fn(async () => {
throw new Error('source unreachable')
})
})
await expect(ensureGatewayForAgent('offline', 'research')).resolves.toBe(false)
expect(isActivePrimary()).toBe(true)
expect(activeGateway()).toBe(primary)
expect($gateway.get()).toBe(publishedPrimary)
})
it('closeSecondaryGateways re-points the active key at the primary instead of dangling', async () => {
const primary = makePrimary()
setPrimaryGateway(primary as never, 'default')
installAgentDesktop()
await ensureGatewayForAgent('homelab', 'research')
expect(isActivePrimary()).toBe(false)
// The soft gateway switch path (use-gateway-boot) tears every secondary
// down without knowing which one was active.
closeSecondaryGateways()
// Regression: activeKey used to keep naming the evicted scope while
// activeGateway() fell back to the primary — wrong machine, no error.
// Now the teardown restores the primary EXPLICITLY (atoms follow).
expect(isActivePrimary()).toBe(true)
expect(activeGateway()).toBe(primary)
expect($gateway.get()).toBe(primary)
})
it('pruneSecondaryGateways never evicts the active scope and keeps the invariant', async () => {
const primary = makePrimary()
setPrimaryGateway(primary as never, 'default')
installAgentDesktop()
await ensureGatewayForAgent('homelab', 'research')
const agentGateway = activeGateway()
// Empty keep-set: everything non-active is evictable — the active agent
// scope must survive, and the active pointer must still resolve to it.
pruneSecondaryGateways(new Set())
expect(isActivePrimary()).toBe(false)
expect(activeGateway()).toBe(agentGateway)
expect($gateway.get()).toBe(agentGateway)
})
})
@@ -0,0 +1,534 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Connection lifecycle for registry-scoped secondary gateways:
//
// 1. Removing a connection must dispose its secondaries — remote/cloud
// sources have no local process whose death would drop the socket, so
// without an explicit dispose the WebSocket stays open and streams ghost
// events until page reload.
// 2. A materially edited connection re-dials so fresh sockets target the
// NEW endpoint.
// 3. When the Electron main reports the connection no longer exists
// (`No connection with id`), the reconnect loop fail-stops and evicts
// the entry instead of retrying forever.
const gatewayMocks = vi.hoisted(() => {
const instances: { close: ReturnType<typeof vi.fn>; connectionState: string }[] = []
return {
connect: vi.fn(async (_wsUrl: string): Promise<void> => undefined),
instances
}
})
const reconnectStateMocks = vi.hoisted(() => ({
reconcileBusyStatesOnReconnect: vi.fn(),
resetTileRuntimeBindings: vi.fn()
}))
vi.mock('@/hermes', () => ({
setApiRequestConnection: vi.fn(),
HermesGateway: class {
connectionState = 'closed'
close = vi.fn(() => {
this.connectionState = 'closed'
})
connect = async (wsUrl: string): Promise<void> => {
await gatewayMocks.connect(wsUrl)
this.connectionState = 'open'
}
onEvent = vi.fn(() => () => {})
onState = vi.fn(() => () => {})
constructor() {
gatewayMocks.instances.push(this as never)
}
}
}))
vi.mock('@/store/session', () => ({
setConnection: vi.fn(),
setGatewayState: vi.fn()
}))
vi.mock('@/store/notify-baseline', () => ({ markNativeNotifyBaseline: vi.fn() }))
vi.mock('@/store/session-states', () => reconnectStateMocks)
const {
activeGateway,
closeLegacySecondaryGateways,
closeSecondaryGateways,
configureGatewayRegistry,
disposeSecondariesForConnection,
ensureActiveGatewayOpen,
ensureGatewayForAgent,
ensureGatewayForProfile,
openGatewayForAgent,
openGatewayForProfile,
pruneSecondaryGateways,
reconnectSecondaryGateways,
retainGatewayForAgent,
retireLocalProfileGateways,
setPrimaryGateway
} = await import('./gateway')
function installDesktop(stub: Record<string, unknown>): void {
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = stub
}
function descriptorFor(connectionId: string, profile: string) {
return {
authMode: 'token',
baseUrl: `https://${connectionId}.invalid`,
mode: 'remote',
profile,
token: 'fake-test-token',
wsUrl: `wss://${connectionId}.invalid/api/ws?profile=${profile}`
}
}
beforeEach(() => {
configureGatewayRegistry({ onEvent: vi.fn() } as never)
setPrimaryGateway({ connectionState: 'open' } as never, 'default')
})
afterEach(() => {
closeSecondaryGateways()
gatewayMocks.instances.length = 0
vi.clearAllMocks()
vi.useRealTimers()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
describe('disposeSecondariesForConnection', () => {
it('keeps the previous source socket alive when another source becomes foreground', async () => {
const getConnectionFor = vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) =>
descriptorFor(connectionId, profile)
)
installDesktop({ getConnectionFor })
await ensureGatewayForAgent('homelab', 'default')
const homelab = gatewayMocks.instances[0]
await ensureGatewayForAgent('office', 'default')
expect(gatewayMocks.instances).toHaveLength(2)
expect(homelab.close).not.toHaveBeenCalled()
expect(homelab.connectionState).toBe('open')
// Returning to the first source reuses its live socket. A source switch is
// only a foreground routing change; it must never interrupt backend work.
await ensureGatewayForAgent('homelab', 'default')
expect(gatewayMocks.instances).toHaveLength(2)
expect(activeGateway()).toBe(homelab)
})
it('closes and evicts every secondary scoped to the removed connection', async () => {
const getConnectionFor = vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) =>
descriptorFor(connectionId, profile)
)
installDesktop({ getConnectionFor })
await ensureGatewayForAgent('homelab', 'default')
await ensureGatewayForAgent('homelab', 'work')
await ensureGatewayForAgent('office', 'default')
expect(gatewayMocks.instances).toHaveLength(3)
disposeSecondariesForConnection('homelab')
// Both homelab sockets closed; the office socket untouched.
expect(gatewayMocks.instances[0].close).toHaveBeenCalledOnce()
expect(gatewayMocks.instances[1].close).toHaveBeenCalledOnce()
expect(gatewayMocks.instances[2].close).not.toHaveBeenCalled()
// No redial for a removal.
expect(getConnectionFor).toHaveBeenCalledTimes(3)
})
it('re-dials disposed secondaries when redial is requested (material edit)', async () => {
const getConnectionFor = vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) =>
descriptorFor(connectionId, profile)
)
installDesktop({ getConnectionFor })
await ensureGatewayForAgent('homelab', 'default')
expect(gatewayMocks.connect).toHaveBeenCalledTimes(1)
disposeSecondariesForConnection('homelab', { redial: true })
// The redial runs async through the normal open path — flush it.
await vi.waitFor(() => {
expect(gatewayMocks.connect).toHaveBeenCalledTimes(2)
})
// Old socket closed, fresh descriptor fetched (would carry the new URL).
expect(gatewayMocks.instances[0].close).toHaveBeenCalledOnce()
expect(getConnectionFor).toHaveBeenCalledTimes(2)
})
it('defers edit redials until request and foreground owners release the old sockets', async () => {
const foregroundScopes = new Set<string>()
const getConnectionFor = vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) =>
descriptorFor(connectionId, profile)
)
configureGatewayRegistry({ foregroundScopes: () => foregroundScopes, onEvent: vi.fn() } as never)
installDesktop({ getConnectionFor })
await ensureGatewayForAgent('homelab', 'default')
await ensureGatewayForAgent('office', 'default')
const release = await retainGatewayForAgent('homelab', 'default')
await openGatewayForAgent('homelab', 'work')
foregroundScopes.add('conn:homelab::work')
const retainedSocket = gatewayMocks.instances[0]
const foregroundSocket = gatewayMocks.instances[2]
disposeSecondariesForConnection('homelab', { redial: true })
// An edit may need a new endpoint, but it cannot sever an in-flight turn
// or a mounted runtime's owner socket. No replacement is dialed yet.
expect(retainedSocket.close).not.toHaveBeenCalled()
expect(foregroundSocket.close).not.toHaveBeenCalled()
expect(gatewayMocks.connect).toHaveBeenCalledTimes(3)
release()
await vi.waitFor(() => expect(gatewayMocks.connect).toHaveBeenCalledTimes(4))
expect(retainedSocket.close).toHaveBeenCalledOnce()
expect(foregroundSocket.close).not.toHaveBeenCalled()
foregroundScopes.clear()
pruneSecondaryGateways(new Set(['conn:homelab::default']))
await vi.waitFor(() => expect(gatewayMocks.connect).toHaveBeenCalledTimes(5))
expect(foregroundSocket.close).toHaveBeenCalledOnce()
})
it('is a no-op for blank or unknown connection ids', async () => {
installDesktop({
getConnectionFor: vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) =>
descriptorFor(connectionId, profile)
)
})
await ensureGatewayForAgent('homelab', 'default')
disposeSecondariesForConnection('')
disposeSecondariesForConnection('ghost')
expect(gatewayMocks.instances[0].close).not.toHaveBeenCalled()
})
})
describe('legacy secondary teardown', () => {
it('closes v1 profile sockets without detaching registered sources', async () => {
const getConnection = vi.fn(async (profile: string) => descriptorFor('legacy-local', profile))
const getConnectionFor = vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) =>
descriptorFor(connectionId, profile)
)
installDesktop({ getConnection, getConnectionFor })
await openGatewayForProfile('writer')
await ensureGatewayForAgent('homelab', 'default')
const legacy = gatewayMocks.instances[0]
const registered = gatewayMocks.instances[1]
closeLegacySecondaryGateways()
expect(legacy.close).toHaveBeenCalledOnce()
expect(registered.close).not.toHaveBeenCalled()
expect(activeGateway()).toBe(registered)
})
})
describe('secondary reconnect runtime scope', () => {
it('invalidates stale runtime bindings before a direct secondary reopen publishes open', async () => {
installDesktop({
getConnectionFor: vi.fn(async ({ connectionId, profile }) => descriptorFor(connectionId, profile))
})
await openGatewayForAgent('homelab', 'writer')
const firstSocket = gatewayMocks.instances[0]
pruneSecondaryGateways(new Set())
expect(firstSocket.close).toHaveBeenCalledOnce()
let finishReconnect!: () => void
const reconnect = new Promise<void>(resolve => {
finishReconnect = resolve
})
gatewayMocks.connect.mockImplementationOnce(() => reconnect)
// A real user action after the renderer/main-process pool reaped an idle
// profile creates a fresh Secondary entry and opens it directly. Runtime
// bindings from the previous backend generation must be gone before the
// new socket can publish `open`, or the request can immediately reuse a
// process-local id the respawned backend never minted.
const reopening = openGatewayForAgent('homelab', 'writer')
await vi.waitFor(() => expect(gatewayMocks.connect).toHaveBeenCalledTimes(2))
expect(reconnectStateMocks.resetTileRuntimeBindings).toHaveBeenCalledWith({
connectionId: 'homelab',
profile: 'writer'
})
expect(reconnectStateMocks.resetTileRuntimeBindings.mock.invocationCallOrder[0]).toBeLessThan(
gatewayMocks.connect.mock.invocationCallOrder[1]
)
finishReconnect()
await reopening
})
it('rebinds only Bot runtimes owned by the reconnected profile route', async () => {
installDesktop({
getConnectionFor: vi.fn(async ({ connectionId, profile }) => descriptorFor(connectionId, profile))
})
await ensureGatewayForAgent('homelab', 'writer')
const socket = gatewayMocks.instances[0]
socket.connectionState = 'closed'
reconnectSecondaryGateways()
await vi.waitFor(() =>
expect(reconnectStateMocks.resetTileRuntimeBindings).toHaveBeenCalledWith({
connectionId: 'homelab',
profile: 'writer'
})
)
expect(reconnectStateMocks.reconcileBusyStatesOnReconnect).toHaveBeenCalledWith('conn:homelab::writer')
})
})
describe('retireLocalProfileGateways', () => {
it('retires both local profile scopes without touching the same-named remote agent', async () => {
const getConnection = vi.fn(async (profile: string) => descriptorFor('legacy-local', profile))
const getConnectionFor = vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) =>
descriptorFor(connectionId, profile)
)
installDesktop({ getConnection, getConnectionFor })
await openGatewayForProfile('selena')
await ensureGatewayForAgent('local', 'selena')
await ensureGatewayForAgent('homelab', 'selena')
expect(gatewayMocks.instances).toHaveLength(3)
const connectionCallsBeforeRetire = getConnection.mock.calls.length + getConnectionFor.mock.calls.length
retireLocalProfileGateways('selena')
expect(gatewayMocks.instances[0].close).toHaveBeenCalledOnce()
expect(gatewayMocks.instances[1].close).toHaveBeenCalledOnce()
expect(gatewayMocks.instances[2].close).not.toHaveBeenCalled()
// A wake/reconnect sweep cannot redial either retired local scope. The
// homelab entry remains open and therefore also needs no extra dial.
reconnectSecondaryGateways()
await Promise.resolve()
expect(getConnection.mock.calls.length + getConnectionFor.mock.calls.length).toBe(connectionCallsBeforeRetire)
})
it('allows an explicit later access to create a fresh profile secondary', async () => {
const getConnection = vi.fn(async (profile: string) => descriptorFor('legacy-local', profile))
installDesktop({ getConnection })
await openGatewayForProfile('selena')
retireLocalProfileGateways('selena')
await openGatewayForProfile('selena')
expect(gatewayMocks.instances).toHaveLength(2)
expect(gatewayMocks.instances[0].close).toHaveBeenCalledOnce()
expect(gatewayMocks.instances[1].close).not.toHaveBeenCalled()
})
})
describe('reconnectSecondaryGateways', () => {
it('force-redials an open secondary whose transport may be half-open after wake', async () => {
const getConnectionFor = vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) =>
descriptorFor(connectionId, profile)
)
installDesktop({ getConnectionFor })
await ensureGatewayForAgent('homelab', 'default')
expect(gatewayMocks.connect).toHaveBeenCalledTimes(1)
expect(gatewayMocks.instances[0].connectionState).toBe('open')
reconnectSecondaryGateways({ forceOpenSockets: true })
await vi.waitFor(() => {
expect(gatewayMocks.connect).toHaveBeenCalledTimes(2)
})
expect(gatewayMocks.instances[0].close).toHaveBeenCalledOnce()
expect(getConnectionFor).toHaveBeenCalledTimes(2)
expect(gatewayMocks.instances[0].connectionState).toBe('open')
})
})
describe('reconnect fail-stop on a removed connection', () => {
it('evicts the entry instead of retrying when the registry no longer knows the id', async () => {
const getConnectionFor = vi
.fn()
.mockResolvedValueOnce(descriptorFor('homelab', 'default'))
.mockRejectedValue(new Error('No connection with id "homelab".'))
installDesktop({ getConnectionFor })
await ensureGatewayForAgent('homelab', 'default')
expect(gatewayMocks.instances).toHaveLength(1)
// Simulate the socket dropping after the connection was removed.
const socket = gatewayMocks.instances[0] as unknown as { connectionState: string }
socket.connectionState = 'closed'
// ensureActiveGatewayOpen drives reconnectSecondary for the active scope.
const result = await ensureActiveGatewayOpen()
expect(result).toBeNull()
// Fail-stop: the entry was disposed + evicted, so a second drive finds
// nothing to retry (no further getConnectionFor calls).
const callsAfterFailStop = getConnectionFor.mock.calls.length
await ensureActiveGatewayOpen()
expect(getConnectionFor.mock.calls.length).toBe(callsAfterFailStop)
})
it('keeps retrying on ordinary transport failures', async () => {
const getConnectionFor = vi
.fn()
.mockResolvedValueOnce(descriptorFor('homelab', 'default'))
.mockRejectedValueOnce(new Error('ECONNREFUSED'))
.mockResolvedValue(descriptorFor('homelab', 'default'))
installDesktop({ getConnectionFor })
await ensureGatewayForAgent('homelab', 'default')
const socket = gatewayMocks.instances[0] as unknown as { connectionState: string }
socket.connectionState = 'closed'
// First drive fails with a transport error → entry survives.
await ensureActiveGatewayOpen()
// Second drive succeeds against the surviving entry.
const reopened = await ensureActiveGatewayOpen()
expect(reopened).not.toBeNull()
})
it('evicts a LOCAL profile entry when the deletion guard reports the profile gone (#88769)', async () => {
// A stale rail badge clicked after deletion drives reconnects against
// Electron's spawn guard, which rejects every attempt. That rejection is
// permanent — the loop must fail-stop, not hammer the guard on backoff.
// sharedPrimaryRoute probes getConnection too, so resolve enough calls to
// get the socket open before the guard starts rejecting.
let connectionCalls = 0
const getConnection = vi.fn(async () => {
connectionCalls += 1
if (connectionCalls <= 3) {
return descriptorFor('legacy-local', 'selena')
}
throw new Error('Profile "selena" no longer exists.')
})
installDesktop({ getConnection })
await openGatewayForProfile('selena')
await ensureGatewayForProfile('selena')
expect(gatewayMocks.instances).toHaveLength(1)
connectionCalls = 99
const socket = gatewayMocks.instances[0] as unknown as { connectionState: string }
socket.connectionState = 'closed'
// Drive the reconnect: the guard rejection must dispose + evict.
const result = await ensureActiveGatewayOpen()
expect(result).toBeNull()
const callsAfterFailStop = getConnection.mock.calls.length
await ensureActiveGatewayOpen()
expect(getConnection.mock.calls.length).toBe(callsAfterFailStop)
})
it('fail-stops on the mid-delete guard rejection too', async () => {
let connectionCalls = 0
const getConnection = vi.fn(async () => {
connectionCalls += 1
if (connectionCalls <= 3) {
return descriptorFor('legacy-local', 'selena')
}
throw new Error('Profile "selena" is being deleted.')
})
installDesktop({ getConnection })
await openGatewayForProfile('selena')
await ensureGatewayForProfile('selena')
connectionCalls = 99
const socket = gatewayMocks.instances[0] as unknown as { connectionState: string }
socket.connectionState = 'closed'
await ensureActiveGatewayOpen()
const callsAfterFailStop = getConnection.mock.calls.length
await ensureActiveGatewayOpen()
expect(getConnection.mock.calls.length).toBe(callsAfterFailStop)
})
it('waits out an in-flight secondary activation instead of failing instantly (#88880)', async () => {
// A remote secondary whose activation is ALREADY in flight from another
// path (wake sweep, agent activation) used to make ensureActiveGatewayOpen
// return null immediately: reconnectSecondary early-returns on
// `reconnecting`, the socket is still closed, and the caller surfaced
// "Hermes gateway is not connected" on the Sessions + action. The drive
// must ride out the in-flight activation and hand back the opened socket.
let releaseDial: (() => void) | undefined
const dialGate = new Promise<void>(resolve => {
releaseDial = resolve
})
const getConnectionFor = vi.fn(async () => descriptorFor('homelab', 'default'))
installDesktop({ getConnectionFor })
gatewayMocks.connect
.mockImplementationOnce(async () => undefined) // initial open
.mockImplementationOnce(async () => {
await dialGate // the sweep-driven reconnect dial hangs until released
})
await ensureGatewayForAgent('homelab', 'default')
const socket = gatewayMocks.instances[0] as unknown as { connectionState: string }
socket.connectionState = 'closed'
// Another path (the wake sweep) starts the reconnect first — the drive
// below meets an entry that is already `reconnecting`.
reconnectSecondaryGateways()
await Promise.resolve()
const driving = ensureActiveGatewayOpen()
await new Promise(resolve => setTimeout(resolve, 300))
releaseDial?.()
const result = await driving
expect(result).not.toBeNull()
expect((result as unknown as { connectionState: string }).connectionState).toBe('open')
})
})
@@ -0,0 +1,210 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Cross-connection event/prune scoping: every registered source exposes a
// 'default' profile (the roster force-unshifts it), so any consumer keyed by
// the bare profile name conflates two connected gateways' activity. These
// tests pin the composite (connectionId, profile) keying:
// - pruneSecondaryGateways must NOT keep a registry-scoped socket alive off
// another source's same-named profile (and vice versa) — registry entries
// match only their composite backendScopeKey scope.
// - the session-states scope ledger (recordSessionEventScope /
// liveSessionScopes) turns registry-tagged live work into those composite
// keep-set entries, and ignores untagged local/primary events.
const gatewayMocks = vi.hoisted(() => ({
closed: [] as string[],
setConnection: vi.fn()
}))
vi.mock('@/hermes', () => ({
setApiRequestConnection: vi.fn(),
HermesGateway: class {
connectionState = 'closed'
wsUrl = ''
connect = async (wsUrl: string): Promise<void> => {
this.wsUrl = wsUrl
this.connectionState = 'open'
}
close = (): void => {
gatewayMocks.closed.push(this.wsUrl)
this.connectionState = 'closed'
}
onEvent = vi.fn(() => () => {})
onState = vi.fn(() => () => {})
}
}))
vi.mock('@/store/session', () => ({
setConnection: gatewayMocks.setConnection,
setGatewayState: vi.fn()
}))
vi.mock('@/store/notify-baseline', () => ({ markNativeNotifyBaseline: vi.fn() }))
const {
activeGatewayConnectionId,
closeLegacySecondaryGateways,
closeSecondaryGateways,
configureGatewayRegistry,
ensureGatewayForAgent,
ensureGatewayForProfile,
openGatewayForAgent,
pruneSecondaryGateways,
setPrimaryGateway,
setPrimaryGatewayConnectionId
} = await import('./gateway')
const { setApiRequestConnection } = await import('@/hermes')
function installDesktop(): void {
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
getConnection: vi.fn(async () => ({
authMode: 'token',
profile: 'default',
token: 't',
wsUrl: 'wss://local.invalid/api/ws?token=t'
})),
getConnectionFor: vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) => ({
authMode: 'token',
connectionId,
profile,
token: 't',
wsUrl: `wss://${connectionId}.invalid/api/ws?profile=${profile}`
})),
getGatewayWsUrlFor: vi.fn(
async ({ connectionId, profile }: { connectionId: string; profile: string }) =>
`wss://${connectionId}.invalid/api/ws?profile=${profile}`
),
touchBackend: vi.fn(async () => undefined)
}
}
beforeEach(() => {
installDesktop()
configureGatewayRegistry({ onEvent: vi.fn() })
setPrimaryGateway({ connectionState: 'open' } as never, 'default')
gatewayMocks.closed = []
})
afterEach(() => {
closeSecondaryGateways()
vi.clearAllMocks()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
describe('primary gateway registry scope', () => {
it('publishes a registered primary connection id for ambient API/WebSocket helpers', () => {
setPrimaryGateway({ connectionState: 'open' } as never, 'default')
setPrimaryGatewayConnectionId(' homelab-ssh ')
expect(activeGatewayConnectionId()).toBe('homelab-ssh')
expect(setApiRequestConnection).toHaveBeenLastCalledWith('homelab-ssh')
})
it('clears primary connection scope when the primary becomes legacy/local again', () => {
setPrimaryGateway({ connectionState: 'open' } as never, 'default')
setPrimaryGatewayConnectionId('homelab-ssh')
setPrimaryGateway({ connectionState: 'open' } as never, 'default')
expect(activeGatewayConnectionId()).toBeNull()
expect(setApiRequestConnection).toHaveBeenLastCalledWith(null)
})
it('ignores primary connection-id writes while a secondary registry scope is active (#95628 hardening)', async () => {
setPrimaryGateway({ connectionState: 'open' } as never, 'default')
setPrimaryGatewayConnectionId('primary-vps')
// Foreground Gateway B's composite scope (connectionId 'homelab').
await expect(ensureGatewayForAgent('homelab', 'default')).resolves.toBe(true)
// Presentation-layer write while the secondary is foregrounded: the id
// describes the secondary, not the primary. It must be dropped — accepting
// it relabels the primary socket and poisons ambient routing.
setPrimaryGatewayConnectionId('homelab')
// Back on the primary route, its registry identity is intact.
await ensureGatewayForProfile('default')
expect(activeGatewayConnectionId()).toBe('primary-vps')
expect(setApiRequestConnection).toHaveBeenLastCalledWith('primary-vps')
})
})
describe('pruneSecondaryGateways with registry-scoped entries', () => {
it('keeps the previous source socket open when Sessions switches backends', async () => {
await ensureGatewayForAgent('work', 'default')
await ensureGatewayForAgent('homelab', 'default')
// Source switching only changes the foreground route. Retaining the first
// socket lets its live turn continue and keeps receiving completion events.
expect(gatewayMocks.closed).toEqual([])
})
it("does not keep a registry socket alive off another source's same-named profile", async () => {
// Gateway B's 'default' — the roster row (connectionId 'homelab', profile
// 'default'). The keep-set carries the bare 'default' profile because the
// LOCAL source has live work; that must not pin homelab's socket.
await openGatewayForAgent('homelab', 'default')
pruneSecondaryGateways(new Set(['default']))
expect(gatewayMocks.closed).toEqual(['wss://homelab.invalid/api/ws?profile=default'])
})
it('a switch-phase dial (activationLease) survives a live-work recompute until its activation lands', async () => {
// Phase one of the Sessions-switcher source switch: the target is opened
// but not yet active and has no live work of its own. Another source's
// streaming turn recomputes the keep-set mid-dial — that must not dispose
// the socket the switch is about to activate (#89622 via #93937).
await openGatewayForAgent('homelab', 'default', { activationLease: true })
pruneSecondaryGateways(new Set(['default']))
expect(gatewayMocks.closed).toEqual([])
})
it('keeps a registry socket whose composite scope has live work', async () => {
await openGatewayForAgent('homelab', 'default')
pruneSecondaryGateways(new Set(['conn:homelab::default']))
expect(gatewayMocks.closed).toEqual([])
})
it('still keeps a local (profile-keyed) secondary via its bare profile name', async () => {
await openGatewayForAgent(null, 'research')
pruneSecondaryGateways(new Set(['research']))
expect(gatewayMocks.closed).toEqual([])
pruneSecondaryGateways(new Set())
expect(gatewayMocks.closed).toHaveLength(1)
})
it('does not let a remote tile keep-set pin a local same-named secondary', async () => {
// Chrome is on another profile so 'default' is a real secondary, not the
// spared active key. A homelab bot tile keep-set must keep only the
// composite scope — the local 'default' socket still idles out.
setPrimaryGateway({ connectionState: 'open' } as never, 'research')
await openGatewayForAgent(null, 'default')
await openGatewayForAgent('homelab', 'default')
pruneSecondaryGateways(new Set(['conn:homelab::default']))
expect(gatewayMocks.closed).toEqual(['wss://local.invalid/api/ws?token=t'])
})
it('does not classify an explicit local registry source as legacy', async () => {
await openGatewayForAgent(null, 'writer')
await openGatewayForAgent('local', 'writer')
expect(gatewayMocks.closed).toEqual([])
closeLegacySecondaryGateways()
// The bare profile socket follows the v1 mode configuration and is
// retired. The explicit `local` registry socket is a v2 source and must
// survive the mode apply just like a registered remote source.
expect(gatewayMocks.closed).toEqual(['wss://local.invalid/api/ws?token=t'])
})
})
@@ -0,0 +1,177 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// #93892 regression: the registry disposed the secondary socket an idle Bot
// Chat tile was bound to — the live-work pruner for a pre-dialed (retained)
// entry, and the refcount-0 request lease for one the tile's own resume
// created. openGatewayForAgent marks the entry `retained`, but `retained` is
// a one-way latch every hover pre-warm and profile switch also sets, so no
// dispose path can honor it without leaking every socket ever warmed.
// Instead the registry's `foregroundScopes` hook names the scopes FOREGROUND
// surfaces are bound to (foregroundSessionScopes): a mounted tile pins its
// owner socket for exactly as long as it is mounted, on every dispose path.
const gatewayMocks = vi.hoisted(() => ({
closed: [] as string[]
}))
vi.mock('@/hermes', async importActual => ({
...(await importActual<Record<string, unknown>>()),
setApiRequestConnection: vi.fn(),
HermesGateway: class {
connectionState = 'closed'
wsUrl = ''
connect = async (wsUrl: string): Promise<void> => {
this.wsUrl = wsUrl
this.connectionState = 'open'
}
close = (): void => {
gatewayMocks.closed.push(this.wsUrl)
this.connectionState = 'closed'
}
request = async (): Promise<unknown> => ({})
onEvent = vi.fn(() => () => {})
onState = vi.fn(() => () => {})
}
}))
vi.mock('@/store/notify-baseline', () => ({ markNativeNotifyBaseline: vi.fn() }))
const {
closeSecondaryGateways,
configureGatewayRegistry,
openGatewayForAgent,
pruneSecondaryGateways,
requestGatewayForAgent,
setPrimaryGateway
} = await import('./gateway')
const { $sessionTiles, foregroundSessionScopes, liveSessionScopes } = await import('./session-states')
function installDesktop(): void {
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
getConnection: vi.fn(async () => ({
authMode: 'token',
profile: 'default',
token: 't',
wsUrl: 'wss://local.invalid/api/ws?token=t'
})),
getConnectionFor: vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) => ({
authMode: 'token',
connectionId,
profile,
token: 't',
wsUrl: `wss://${connectionId}.invalid/api/ws?profile=${profile}`
})),
getGatewayWsUrlFor: vi.fn(
async ({ connectionId, profile }: { connectionId: string; profile: string }) =>
`wss://${connectionId}.invalid/api/ws?profile=${profile}`
),
touchBackend: vi.fn(async () => undefined)
}
}
// What use-gateway-boot's recomputeKeptGateways feeds the pruner for a
// window with NO busy / needs-input work anywhere — the idle bot chat case.
// Foreground pins are NOT in here: the registry reads them itself.
const idleKeepSet = () => liveSessionScopes()
const BOT_TILE = {
ownerRoute: { connectionId: 'local', mode: 'local' as const, profile: 'bot' },
runtimeId: 'rt-bot',
storedSessionId: 'stored-bot'
}
beforeEach(() => {
installDesktop()
// Wired exactly as use-gateway-boot wires it.
configureGatewayRegistry({ foregroundScopes: foregroundSessionScopes, onEvent: vi.fn() })
setPrimaryGateway({ connectionState: 'open' } as never, 'default')
gatewayMocks.closed = []
$sessionTiles.set([])
})
afterEach(() => {
closeSecondaryGateways()
$sessionTiles.set([])
vi.clearAllMocks()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
describe('foreground tile retention vs. the live-work pruner (#93892)', () => {
it('keeps an idle Bot Chat tile’s owner socket across prune recomputes', async () => {
// The BOTS workspace dials the bot's own backend without activating it
// (keepAllProfilesScope) and opens the canonical chat as a tile on that
// route. The global active profile stays primary/default.
await openGatewayForAgent('local', 'bot')
$sessionTiles.set([BOT_TILE])
// Idle: no working / needs-input session anywhere. Before the fix this
// recompute closed the socket → backend reaped the runtime → reclaim →
// unbind → resume → … forever.
pruneSecondaryGateways(idleKeepSet())
pruneSecondaryGateways(idleKeepSet())
expect(gatewayMocks.closed).toEqual([])
})
it('keeps the socket while the tile is still resuming (no runtime bound yet)', async () => {
await openGatewayForAgent('local', 'bot')
$sessionTiles.set([{ ...BOT_TILE, runtimeId: undefined }])
pruneSecondaryGateways(idleKeepSet())
expect(gatewayMocks.closed).toEqual([])
})
it('releases the socket once the tile is closed — the pin never latches', async () => {
await openGatewayForAgent('local', 'bot')
$sessionTiles.set([BOT_TILE])
pruneSecondaryGateways(idleKeepSet())
expect(gatewayMocks.closed).toEqual([])
$sessionTiles.set([])
pruneSecondaryGateways(idleKeepSet())
expect(gatewayMocks.closed).toEqual(['wss://local.invalid/api/ws?profile=bot'])
})
it('keeps the socket a tile’s OWN resume dialed (no pre-dial, refcount-0 lease path)', async () => {
// Relaunch with a persisted Bot Chat tab: nothing pre-dials the bot, so
// the tile's session.resume goes out through requestGatewayForAgent's
// per-request lease on a fresh, NON-retained entry. Before the fix the
// lease's `finally` disposed that socket the instant the resume returned
// — the runtime it had just minted was orphaned on the spot.
$sessionTiles.set([{ ...BOT_TILE, runtimeId: undefined }])
await requestGatewayForAgent('local', 'bot', 'session.resume', { session_id: 'stored-bot' })
expect(gatewayMocks.closed).toEqual([])
// …and the pruner agrees with the lease.
pruneSecondaryGateways(idleKeepSet())
expect(gatewayMocks.closed).toEqual([])
// Tile gone: the next lease release disposes as it always did.
$sessionTiles.set([])
await requestGatewayForAgent('local', 'bot', 'session.usage', { session_id: 'stored-bot' })
expect(gatewayMocks.closed).toEqual(['wss://local.invalid/api/ws?profile=bot'])
})
it('still prunes a merely pre-warmed (retained, no surface) socket — `retained` is not a pin', async () => {
// A roster hover warms the bot's socket through the same door and sets
// the same `retained` flag; with no tile bound to it, it is idle garbage.
await openGatewayForAgent('local', 'bot')
pruneSecondaryGateways(idleKeepSet())
expect(gatewayMocks.closed).toEqual(['wss://local.invalid/api/ws?profile=bot'])
})
it('does not let a tile on one source pin another source’s same-named profile', async () => {
await openGatewayForAgent('homelab', 'bot')
$sessionTiles.set([BOT_TILE])
pruneSecondaryGateways(idleKeepSet())
expect(gatewayMocks.closed).toEqual(['wss://homelab.invalid/api/ws?profile=bot'])
})
})
@@ -0,0 +1,680 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const secondaryGateways: Array<{
close: ReturnType<typeof vi.fn>
connect: ReturnType<typeof vi.fn>
connectionState: string
request: ReturnType<typeof vi.fn>
}> = []
let connectGate: Promise<void> | null = null
vi.mock('@/hermes', () => ({
HermesGateway: class {
connectionState = 'closed'
connect = vi.fn(async () => {
if (this.connectionState === 'connecting') {
return
}
this.connectionState = 'connecting'
if (connectGate) {
await connectGate
}
this.connectionState = 'open'
})
request = vi.fn(async (method: string, params: Record<string, unknown>) => {
if (this.connectionState !== 'open') {
throw new Error('gateway is not connected')
}
return { method, params }
})
close = vi.fn()
onEvent = vi.fn(() => () => {})
onState = vi.fn(() => () => {})
constructor() {
secondaryGateways.push(this)
}
},
setApiRequestConnection: vi.fn()
}))
vi.mock('@/store/session', () => ({ setConnection: vi.fn(), setGatewayState: vi.fn() }))
vi.mock('@/store/notify-baseline', () => ({ markNativeNotifyBaseline: vi.fn() }))
const {
$gateway,
closeSecondaryGateways,
configureGatewayRegistry,
ensureGatewayForAgent,
ensureGatewayForProfile,
gatewayActivationEpoch,
disposeSecondariesForConnection,
openGatewayForAgent,
pruneSecondaryGateways,
requestGatewayForAgent,
requestGatewayForProfile,
retainGatewayForAgent,
setPrimaryGateway,
setPrimaryGatewayConnection
} = await import('./gateway')
function installDesktop(getConnection: ReturnType<typeof vi.fn>): void {
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
getConnection,
touchBackend: vi.fn(async () => undefined)
}
}
function makePrimary() {
return {
connectionState: 'open',
request: vi.fn(async (method: string, params: Record<string, unknown>) => ({ method, params }))
}
}
beforeEach(async () => {
secondaryGateways.length = 0
connectGate = null
configureGatewayRegistry({ onEvent: vi.fn() })
closeSecondaryGateways()
})
afterEach(() => {
closeSecondaryGateways()
vi.clearAllMocks()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
describe('requestGatewayForProfile', () => {
it('requests through a pooled profile gateway without changing the active gateway', async () => {
const primary = makePrimary()
setPrimaryGateway(primary as never, 'default')
installDesktop(
vi.fn(async (profile: null | string) =>
profile ? { port: 5151, profile, token: 'secondary-token' } : { port: 4242, token: 'primary-token' }
)
)
await ensureGatewayForProfile('default')
const result = await requestGatewayForProfile('worker', 'profiles.list', { include_sessions: true })
expect(result).toEqual({ method: 'profiles.list', params: { include_sessions: true } })
expect(secondaryGateways).toHaveLength(1)
expect(secondaryGateways[0].request).toHaveBeenCalledWith('profiles.list', { include_sessions: true })
expect(secondaryGateways[0].close).toHaveBeenCalledOnce()
expect($gateway.get()).toBe(primary)
})
it('uses the primary socket and adds profile scope for a shared global remote route', async () => {
const primary = makePrimary()
setPrimaryGateway(primary as never, 'default')
installDesktop(
vi.fn(async (profile: null | string) => ({
port: 4242,
...(profile ? { profile, sharedPrimary: true } : {}),
token: 'primary-token'
}))
)
await ensureGatewayForProfile('default')
const result = await requestGatewayForProfile('venture', 'session.list', { limit: 20, profile: 'wrong' })
expect(result).toEqual({ method: 'session.list', params: { limit: 20, profile: 'venture' } })
expect(primary.request).toHaveBeenCalledWith('session.list', { limit: 20, profile: 'venture' })
expect(secondaryGateways).toHaveLength(0)
expect($gateway.get()).toBe(primary)
})
it('serializes concurrent requests while a secondary gateway is connecting', async () => {
let releaseConnect: () => void = () => undefined
connectGate = new Promise<void>(resolve => {
releaseConnect = resolve
})
const primary = makePrimary()
setPrimaryGateway(primary as never, 'default')
installDesktop(
vi.fn(async (profile: null | string) =>
profile ? { port: 5151, profile, token: 'secondary-token' } : { port: 4242, token: 'primary-token' }
)
)
await ensureGatewayForProfile('default')
const first = requestGatewayForProfile('worker', 'profiles.list')
await vi.waitFor(() => expect(secondaryGateways[0]?.connect).toHaveBeenCalledOnce())
const second = requestGatewayForProfile('worker', 'profiles.list')
const guardedSecond = second.catch(error => error)
await Promise.resolve()
releaseConnect()
const [firstResult, secondResult] = await Promise.all([first, guardedSecond])
expect(firstResult).toEqual({ method: 'profiles.list', params: {} })
expect(secondResult).toEqual({ method: 'profiles.list', params: {} })
expect(secondaryGateways[0].connect).toHaveBeenCalledOnce()
expect(secondaryGateways[0].request).toHaveBeenCalledTimes(2)
expect(secondaryGateways[0].close).toHaveBeenCalledOnce()
expect($gateway.get()).toBe(primary)
})
it('prevents pruning while a background request is connecting or in flight', async () => {
let releaseConnect: () => void = () => undefined
connectGate = new Promise<void>(resolve => {
releaseConnect = resolve
})
const primary = makePrimary()
setPrimaryGateway(primary as never, 'default')
installDesktop(
vi.fn(async (profile: null | string) =>
profile ? { port: 5151, profile, token: 'secondary-token' } : { port: 4242, token: 'primary-token' }
)
)
await ensureGatewayForProfile('default')
const request = requestGatewayForProfile('worker', 'profiles.list')
await vi.waitFor(() => expect(secondaryGateways[0]?.connect).toHaveBeenCalledOnce())
pruneSecondaryGateways(new Set())
releaseConnect()
await expect(request).resolves.toEqual({ method: 'profiles.list', params: {} })
expect(secondaryGateways[0].close).toHaveBeenCalledOnce()
pruneSecondaryGateways(new Set())
expect(secondaryGateways[0].close).toHaveBeenCalledOnce()
})
})
describe('requestGatewayForAgent', () => {
it('reuses the active primary socket when its registry connection owns the session', async () => {
const primary = makePrimary()
const getConnectionFor = vi.fn(async ({ connectionId, profile }) => ({
connectionId,
port: 5151,
profile,
token: 'secondary-token'
}))
setPrimaryGateway(primary as never, 'default')
setPrimaryGatewayConnection({ connectionId: 'remote-primary' })
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
getConnection: vi.fn(),
getConnectionFor,
getGatewayWsUrlFor: vi.fn(async () => ({ ok: true as const, wsUrl: 'wss://remote.invalid/api/ws' })),
touchBackend: vi.fn(async () => undefined)
}
await ensureGatewayForProfile('default')
await openGatewayForAgent('remote-primary', 'default')
await ensureGatewayForAgent('remote-primary', 'default')
const result = await requestGatewayForAgent('remote-primary', 'default', 'session.resume', {
session_id: 'stored-session'
})
expect(result).toEqual({
method: 'session.resume',
params: { session_id: 'stored-session' }
})
expect(primary.request).toHaveBeenCalledWith('session.resume', { session_id: 'stored-session' })
expect(getConnectionFor).not.toHaveBeenCalled()
expect(secondaryGateways).toHaveLength(0)
expect($gateway.get()).toBe(primary)
})
it('keeps another profile on the same registry source isolated from the primary', async () => {
const primary = makePrimary()
const getConnectionFor = vi.fn(async ({ connectionId, profile }) => ({
connectionId,
port: 5151,
profile,
token: 'secondary-token'
}))
setPrimaryGateway(primary as never, 'default')
setPrimaryGatewayConnection({ connectionId: 'remote-primary' })
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
getConnection: vi.fn(),
getConnectionFor,
getGatewayWsUrlFor: vi.fn(async () => ({ ok: true as const, wsUrl: 'wss://remote.invalid/api/ws' })),
touchBackend: vi.fn(async () => undefined)
}
await requestGatewayForAgent('remote-primary', 'research', 'session.resume', {
session_id: 'research-session'
})
expect(getConnectionFor).toHaveBeenCalledWith({ connectionId: 'remote-primary', profile: 'research' })
expect(primary.request).not.toHaveBeenCalled()
expect(secondaryGateways).toHaveLength(1)
})
it('leases separate registry sockets for duplicate profile names without changing the active gateway', async () => {
const primary = makePrimary()
const getConnection = vi.fn(async (profile: null | string) => ({ port: 4242, profile, token: 'legacy-token' }))
const getConnectionFor = vi.fn(async ({ connectionId, profile }) => ({
connectionId,
port: connectionId === 'source-a' ? 5151 : 5252,
profile,
token: `${connectionId}-token`
}))
const getGatewayWsUrlFor = vi.fn(async ({ connectionId, profile }) => ({
ok: true as const,
wsUrl: `ws://${connectionId}/${profile}`
}))
setPrimaryGateway(primary as never, 'default')
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
getConnection,
getConnectionFor,
getGatewayWsUrlFor,
touchBackend: vi.fn(async () => undefined)
}
await ensureGatewayForProfile('default')
const [fromA, fromB] = await Promise.all([
requestGatewayForAgent('source-a', 'research', 'session.list', { limit: 1 }),
requestGatewayForAgent('source-b', 'research', 'session.list', { limit: 2 })
])
expect(fromA).toEqual({ method: 'session.list', params: { limit: 1 } })
expect(fromB).toEqual({ method: 'session.list', params: { limit: 2 } })
expect(getConnectionFor).toHaveBeenCalledWith({ connectionId: 'source-a', profile: 'research' })
expect(getConnectionFor).toHaveBeenCalledWith({ connectionId: 'source-b', profile: 'research' })
expect(getGatewayWsUrlFor).toHaveBeenCalledWith({ connectionId: 'source-a', profile: 'research' })
expect(getGatewayWsUrlFor).toHaveBeenCalledWith({ connectionId: 'source-b', profile: 'research' })
expect(getConnection).not.toHaveBeenCalled()
expect(secondaryGateways).toHaveLength(2)
expect(secondaryGateways[0].close).toHaveBeenCalledOnce()
expect(secondaryGateways[1].close).toHaveBeenCalledOnce()
expect($gateway.get()).toBe(primary)
})
it('routes an explicit local registry descriptor through getConnectionFor', async () => {
const primary = makePrimary()
const getConnection = vi.fn(async (profile: null | string) => ({
mode: 'remote',
profile,
wsUrl: 'wss://legacy-remote.invalid/api/ws?token=legacy'
}))
const getConnectionFor = vi.fn(async ({ connectionId, profile }) => ({
connectionId,
mode: 'local',
port: 5151,
profile
}))
setPrimaryGateway(primary as never, 'default')
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
getConnection,
getConnectionFor,
getGatewayWsUrlFor: vi.fn(async ({ connectionId, profile }) => ({
ok: true as const,
wsUrl: `ws://${connectionId}/${profile}`
})),
touchBackend: vi.fn(async () => undefined)
}
await ensureGatewayForProfile('default')
await expect(requestGatewayForAgent('local', 'worker', 'profiles.list')).resolves.toEqual({
method: 'profiles.list',
params: {}
})
expect(getConnectionFor).toHaveBeenCalledWith({ connectionId: 'local', profile: 'worker' })
expect(getConnection).not.toHaveBeenCalled()
expect($gateway.get()).toBe(primary)
})
it('evicts registry sockets when their source is edited or removed', async () => {
const primary = makePrimary()
const getConnectionFor = vi.fn(async ({ connectionId, profile }) => ({ connectionId, port: 5151, profile }))
const onActiveConnectionInvalidated = vi.fn()
setPrimaryGateway(primary as never, 'default')
configureGatewayRegistry({ onActiveConnectionInvalidated, onEvent: vi.fn() })
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
getConnection: vi.fn(),
getConnectionFor,
getGatewayWsUrlFor: vi.fn(async ({ connectionId, profile }) => ({
ok: true as const,
wsUrl: `ws://${connectionId}/${profile}`
})),
touchBackend: vi.fn(async () => undefined)
}
await openGatewayForAgent('source-a', 'research')
await requestGatewayForAgent('source-a', 'research', 'session.list')
disposeSecondariesForConnection('source-a')
expect(secondaryGateways[0].close).toHaveBeenCalledOnce()
expect(onActiveConnectionInvalidated).not.toHaveBeenCalled()
await requestGatewayForAgent('source-a', 'research', 'session.list')
expect(secondaryGateways).toHaveLength(2)
expect(getConnectionFor).toHaveBeenCalledTimes(2)
})
it('invalidates an active pinned source with its window profile and a route epoch', async () => {
const primary = makePrimary()
const onActiveConnectionChanged = vi.fn()
const onActiveConnectionInvalidated = vi.fn()
setPrimaryGateway(primary as never, 'pinned')
configureGatewayRegistry({ onActiveConnectionChanged, onActiveConnectionInvalidated, onEvent: vi.fn() })
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
getConnection: vi.fn(),
getConnectionFor: vi.fn(async ({ connectionId, profile }) => ({ connectionId, port: 5151, profile })),
getGatewayWsUrlFor: vi.fn(async ({ connectionId, profile }) => ({
ok: true as const,
wsUrl: `ws://${connectionId}/${profile}`
})),
touchBackend: vi.fn(async () => undefined)
}
await ensureGatewayForAgent('source-a', 'pinned')
disposeSecondariesForConnection('source-a')
const invalidationEpoch = gatewayActivationEpoch()
expect(onActiveConnectionInvalidated).toHaveBeenCalledWith('pinned', invalidationEpoch)
expect($gateway.get()).toBe(primary)
// Same profile, different source advances the guard as soon as activation
// starts, before its deferred socket connect can finish.
let releaseConnect: () => void = () => undefined
connectGate = new Promise<void>(resolve => {
releaseConnect = resolve
})
const sourceBActivation = ensureGatewayForAgent('source-b', 'pinned')
await vi.waitFor(() => expect(secondaryGateways[1]?.connect).toHaveBeenCalledOnce())
expect(gatewayActivationEpoch()).toBeGreaterThan(invalidationEpoch)
releaseConnect()
await sourceBActivation
expect(onActiveConnectionChanged).toHaveBeenLastCalledWith(expect.objectContaining({ connectionId: 'source-b' }))
})
it('does not activate or publish a source invalidated while its dial is pending', async () => {
const primary = makePrimary()
const onActiveConnectionChanged = vi.fn()
setPrimaryGateway(primary as never, 'default')
configureGatewayRegistry({ onActiveConnectionChanged, onEvent: vi.fn() })
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
getConnection: vi.fn(async profile => ({ port: 4242, profile })),
getConnectionFor: vi.fn(async ({ connectionId, profile }) => ({ connectionId, port: 5151, profile })),
getGatewayWsUrlFor: vi.fn(async ({ connectionId, profile }) => ({
ok: true as const,
wsUrl: `ws://${connectionId}/${profile}`
})),
touchBackend: vi.fn(async () => undefined)
}
await ensureGatewayForProfile('default')
let releaseConnect: () => void = () => undefined
connectGate = new Promise<void>(resolve => {
releaseConnect = resolve
})
const pendingActivation = ensureGatewayForAgent('source-b', 'default')
await vi.waitFor(() => expect(secondaryGateways[0]?.connect).toHaveBeenCalledOnce())
disposeSecondariesForConnection('source-b')
releaseConnect()
await pendingActivation
expect(secondaryGateways[0].close).toHaveBeenCalled()
expect(onActiveConnectionChanged).not.toHaveBeenCalled()
expect($gateway.get()).toBe(primary)
})
it('does not activate or publish a source whose activation owner aborted while dialing', async () => {
const primary = makePrimary()
const onActiveConnectionChanged = vi.fn()
const controller = new AbortController()
setPrimaryGateway(primary as never, 'default')
configureGatewayRegistry({ onActiveConnectionChanged, onEvent: vi.fn() })
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
getConnection: vi.fn(async profile => ({ port: 4242, profile })),
getConnectionFor: vi.fn(async ({ connectionId, profile }) => ({ connectionId, port: 5151, profile })),
getGatewayWsUrlFor: vi.fn(async ({ connectionId, profile }) => ({
ok: true as const,
wsUrl: `ws://${connectionId}/${profile}`
})),
touchBackend: vi.fn(async () => undefined)
}
await ensureGatewayForProfile('default')
let releaseConnect: () => void = () => undefined
connectGate = new Promise<void>(resolve => {
releaseConnect = resolve
})
const abandoned = ensureGatewayForAgent('source-b', 'default', { signal: controller.signal })
await vi.waitFor(() => expect(secondaryGateways[0]?.connect).toHaveBeenCalledOnce())
controller.abort()
releaseConnect()
expect(await abandoned).toBe(false)
expect(onActiveConnectionChanged).not.toHaveBeenCalled()
expect($gateway.get()).toBe(primary)
})
})
describe('retainGatewayForAgent (#93602)', () => {
function installRegistryDesktop() {
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
getConnection: vi.fn(async (profile: null | string) => ({ port: 4242, profile, token: 't' })),
getConnectionFor: vi.fn(async ({ connectionId, profile }) => ({ connectionId, port: 5151, profile })),
getGatewayWsUrlFor: vi.fn(async ({ connectionId, profile }) => ({
ok: true as const,
wsUrl: `ws://${connectionId}/${profile}`
})),
touchBackend: vi.fn(async () => undefined)
}
}
it('holds the registry socket across multiple leased requests — no mid-turn disposal', async () => {
const primary = makePrimary()
setPrimaryGateway(primary as never, 'default')
installRegistryDesktop()
await ensureGatewayForProfile('default')
const release = await retainGatewayForAgent('mini', 'helper')
// The whole member-turn RPC sequence: each call takes and releases its own
// per-request lease. Without the retain, refcount 0 between calls disposes
// the socket and the runtime session with it.
await requestGatewayForAgent('mini', 'helper', 'session.create', { title: 'g' })
await requestGatewayForAgent('mini', 'helper', 'image.attach_bytes', { session_id: 'rt-1' })
await requestGatewayForAgent('mini', 'helper', 'prompt.submit', { session_id: 'rt-1', text: 'hi' })
expect(secondaryGateways).toHaveLength(1)
expect(secondaryGateways[0].close).not.toHaveBeenCalled()
expect(secondaryGateways[0].request).toHaveBeenCalledTimes(3)
release()
expect(secondaryGateways[0].close).toHaveBeenCalledOnce()
})
it('release is idempotent — a double release never underflows into a foreign disposal', async () => {
const primary = makePrimary()
setPrimaryGateway(primary as never, 'default')
installRegistryDesktop()
await ensureGatewayForProfile('default')
const release = await retainGatewayForAgent('mini', 'helper')
release()
release()
expect(secondaryGateways[0].close).toHaveBeenCalledOnce()
// A fresh retain still works after the double release.
const again = await retainGatewayForAgent('mini', 'helper')
await requestGatewayForAgent('mini', 'helper', 'prompt.submit', { session_id: 'rt-2', text: 'hi' })
expect(secondaryGateways[1].close).not.toHaveBeenCalled()
again()
expect(secondaryGateways[1].close).toHaveBeenCalledOnce()
})
it('without the retain, the leased socket closes after each request (the #93602 race)', async () => {
const primary = makePrimary()
setPrimaryGateway(primary as never, 'default')
installRegistryDesktop()
await ensureGatewayForProfile('default')
await requestGatewayForAgent('mini', 'helper', 'session.create', { title: 'g' })
// Refcount hit 0 → disposed: this is the socket close that reaps the
// runtime session server-side and makes the later prompt.submit 4001.
expect(secondaryGateways[0].close).toHaveBeenCalledOnce()
})
it('plain-profile retain leases the pooled profile socket and releases it', async () => {
const primary = makePrimary()
setPrimaryGateway(primary as never, 'default')
installDesktop(
vi.fn(async (profile: null | string) =>
profile ? { port: 5151, profile, token: 'secondary-token' } : { port: 4242, token: 'primary-token' }
)
)
await ensureGatewayForProfile('default')
const release = await retainGatewayForAgent(null, 'worker')
await requestGatewayForProfile('worker', 'session.create', { title: 'g' })
await requestGatewayForProfile('worker', 'prompt.submit', { session_id: 'rt-1', text: 'hi' })
expect(secondaryGateways).toHaveLength(1)
expect(secondaryGateways[0].close).not.toHaveBeenCalled()
release()
expect(secondaryGateways[0].close).toHaveBeenCalledOnce()
})
})
describe('attached shared-remote group turns (#96493)', () => {
function installAttachedSharedRemote() {
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
getConnection: vi.fn(async (profile: null | string) => ({ port: 4242, profile, token: 't' })),
getConnectionFor: vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) => ({
connectionId,
port: 9119,
profile,
sharedRemote: true
})),
getGatewayWsUrlFor: vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) => ({
ok: true as const,
wsUrl: `ws://${connectionId}/${profile}`
})),
touchBackend: vi.fn(async () => undefined)
}
}
it('reuses the primary socket for a named profile on the attached shared remote', async () => {
const primary = makePrimary()
setPrimaryGateway(primary as never, 'default')
setPrimaryGatewayConnection({ connectionId: 'homelab' })
installAttachedSharedRemote()
await ensureGatewayForProfile('default')
const release = await retainGatewayForAgent('homelab', 'voter')
await requestGatewayForAgent('homelab', 'voter', 'session.create', { title: 'Group: room' })
await requestGatewayForAgent('homelab', 'voter', 'prompt.submit', { session_id: 'rt-1', text: 'hi' })
expect(secondaryGateways).toHaveLength(0)
expect(primary.request).toHaveBeenCalledTimes(2)
expect(primary.request).toHaveBeenNthCalledWith(1, 'session.create', {
title: 'Group: room',
profile: 'voter'
})
expect(primary.request).toHaveBeenNthCalledWith(2, 'prompt.submit', {
session_id: 'rt-1',
text: 'hi',
profile: 'voter'
})
release()
expect(secondaryGateways).toHaveLength(0)
})
it('still dials a secondary when the attached source is not a shared remote', async () => {
const primary = makePrimary()
setPrimaryGateway(primary as never, 'default')
setPrimaryGatewayConnection({ connectionId: 'homelab' })
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
getConnection: vi.fn(async (profile: null | string) => ({ port: 4242, profile, token: 't' })),
getConnectionFor: vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) => ({
connectionId,
port: 5151,
profile,
sharedRemote: false
})),
getGatewayWsUrlFor: vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) => ({
ok: true as const,
wsUrl: `ws://${connectionId}/${profile}`
})),
touchBackend: vi.fn(async () => undefined)
}
await ensureGatewayForProfile('default')
await requestGatewayForAgent('homelab', 'voter', 'session.create', { title: 'g' })
expect(secondaryGateways).toHaveLength(1)
expect(primary.request).not.toHaveBeenCalled()
})
it('reuses the primary when the shared-remote probe fails instead of dialing a ghost secondary', async () => {
const primary = makePrimary()
setPrimaryGateway(primary as never, 'default')
setPrimaryGatewayConnection({ connectionId: 'homelab' })
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
getConnection: vi.fn(async (profile: null | string) => ({ port: 4242, profile, token: 't' })),
getConnectionFor: vi.fn(async () => {
throw new Error('Timed out connecting to profile "voter"')
}),
getGatewayWsUrlFor: vi.fn(async () => ({ ok: true as const, wsUrl: 'ws://homelab/voter' })),
touchBackend: vi.fn(async () => undefined)
}
await ensureGatewayForProfile('default')
await requestGatewayForAgent('homelab', 'voter', 'session.create', { title: 'g' })
expect(secondaryGateways).toHaveLength(0)
expect(primary.request).toHaveBeenCalledOnce()
})
it('openGatewayForAgent and ensureGatewayForAgent do not dial a secondary', async () => {
const primary = makePrimary()
setPrimaryGateway(primary as never, 'default')
setPrimaryGatewayConnection({ connectionId: 'homelab' })
installAttachedSharedRemote()
await ensureGatewayForProfile('default')
await openGatewayForAgent('homelab', 'voter')
expect(await ensureGatewayForAgent('homelab', 'voter')).toBe(true)
expect(secondaryGateways).toHaveLength(0)
})
it('ensureGatewayForAgent is false when the attached primary socket is closed', async () => {
const primary = { connectionState: 'closed', request: vi.fn() }
setPrimaryGateway(primary as never, 'default')
setPrimaryGatewayConnection({ connectionId: 'homelab' })
installAttachedSharedRemote()
expect(await ensureGatewayForAgent('homelab', 'voter')).toBe(false)
expect(secondaryGateways).toHaveLength(0)
})
})
@@ -0,0 +1,59 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { reconnectGateway, registerGatewayReconnect } from './gateway-reconnect'
const disposers: Array<() => void> = []
afterEach(() => {
while (disposers.length > 0) {
disposers.pop()?.()
}
})
describe('gateway reconnect controller', () => {
it('coalesces repeated requests onto one active reconnect', async () => {
let finish: (() => void) | undefined
const handler = vi
.fn()
.mockImplementationOnce(
() =>
new Promise<void>(resolve => {
finish = resolve
})
)
.mockResolvedValueOnce(undefined)
disposers.push(registerGatewayReconnect(handler))
const first = reconnectGateway()
const second = reconnectGateway()
expect(second).toBe(first)
await Promise.resolve()
expect(handler).toHaveBeenCalledTimes(1)
finish?.()
await first
await reconnectGateway()
expect(handler).toHaveBeenCalledTimes(2)
})
it('only lets the current registration remove itself', async () => {
const stale = vi.fn()
const current = vi.fn()
const disposeStale = registerGatewayReconnect(stale)
disposers.push(registerGatewayReconnect(current))
disposeStale()
await reconnectGateway()
expect(stale).not.toHaveBeenCalled()
expect(current).toHaveBeenCalledOnce()
})
it('rejects when the gateway boot owner is not mounted', async () => {
await expect(reconnectGateway()).rejects.toThrow('Gateway reconnect is unavailable')
})
})
@@ -0,0 +1,34 @@
type GatewayReconnectHandler = () => Promise<void> | void
let activeHandler: GatewayReconnectHandler | null = null
let inFlight: Promise<void> | null = null
export function registerGatewayReconnect(handler: GatewayReconnectHandler): () => void {
activeHandler = handler
return () => {
if (activeHandler === handler) {
activeHandler = null
}
}
}
export function reconnectGateway(): Promise<void> {
if (inFlight) {
return inFlight
}
const handler = activeHandler
if (!handler) {
return Promise.reject(new Error('Gateway reconnect is unavailable'))
}
inFlight = Promise.resolve()
.then(handler)
.finally(() => {
inFlight = null
})
return inFlight
}
@@ -0,0 +1,169 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Bot-relay socket retention (#93594): the desktop bot relay RPCs every
// registered connection on its drain loop through requestGatewayForAgent's
// per-request lease. With nothing else holding the entry, the refcount hit 0
// after every tick and the pooled socket was disposed — a fresh WebSocket
// dial + teardown per registered connection every 4 seconds, flooding the
// gateway logs with connect/disconnect pairs. retainGatewayForRelay pins the
// route's pooled entry for the relay's active lifetime; releasing (stopBotRelay
// / plugin dispose) restores dispose-at-refcount-0. Local routes are exempt so
// the idle reaper can still reclaim spawned local backends.
const gatewayMocks = vi.hoisted(() => ({
constructions: 0,
connect: vi.fn(async (_wsUrl: string): Promise<void> => undefined),
setConnection: vi.fn(),
setGatewayState: vi.fn()
}))
vi.mock('@/hermes', () => ({
setApiRequestConnection: vi.fn(),
HermesGateway: class {
connectionState = 'closed'
constructor() {
gatewayMocks.constructions += 1
}
connect = async (wsUrl: string): Promise<void> => {
await gatewayMocks.connect(wsUrl)
this.connectionState = 'open'
}
close = (): void => {
this.connectionState = 'closed'
}
request = async (): Promise<unknown> => ({})
onEvent = vi.fn(() => () => {})
onState = vi.fn(() => () => {})
}
}))
vi.mock('@/store/session', () => ({
setConnection: gatewayMocks.setConnection,
setGatewayState: gatewayMocks.setGatewayState
}))
vi.mock('@/store/notify-baseline', () => ({ markNativeNotifyBaseline: vi.fn() }))
const {
closeSecondaryGateways,
configureGatewayRegistry,
pruneSecondaryGateways,
requestGatewayForAgent,
retainGatewayForRelay,
setPrimaryGateway
} = await import('./gateway')
const agentConn = {
authMode: 'token',
baseUrl: 'https://homelab.invalid',
mode: 'remote',
profile: 'research',
token: 'fake-test-token',
wsUrl: 'wss://homelab.invalid/api/ws?token=fake-test-token'
}
function installDesktop(): void {
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
getConnection: vi.fn(async () => agentConn),
getConnectionFor: vi.fn(async () => agentConn)
}
}
beforeEach(() => {
configureGatewayRegistry({ onEvent: vi.fn() })
setPrimaryGateway({ connectionState: 'open' } as never, 'default')
installDesktop()
gatewayMocks.constructions = 0
})
afterEach(() => {
closeSecondaryGateways()
vi.clearAllMocks()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
describe('bot-relay gateway retention (#93594)', () => {
it('without retention every drain tick dials a fresh socket (the churn this fix removes)', async () => {
// Baseline: three request-leased RPCs against an otherwise-unheld route.
for (let tick = 0; tick < 3; tick += 1) {
await requestGatewayForAgent('homelab', 'research', 'bot_relay.outbox.drain', {})
}
// Refcount hits 0 after each call → dispose → next tick constructs anew.
expect(gatewayMocks.constructions).toBe(3)
expect(gatewayMocks.connect).toHaveBeenCalledTimes(3)
})
it('a retained relay route holds ONE persistent socket across multiple drain ticks', async () => {
const release = retainGatewayForRelay('homelab', 'research')
for (let tick = 0; tick < 5; tick += 1) {
await requestGatewayForAgent('homelab', 'research', 'bot_relay.outbox.drain', {})
}
expect(gatewayMocks.constructions).toBe(1)
expect(gatewayMocks.connect).toHaveBeenCalledTimes(1)
release()
})
it('release (stopBotRelay) drops retention: the socket is disposed and the next tick redials', async () => {
const release = retainGatewayForRelay('homelab', 'research')
await requestGatewayForAgent('homelab', 'research', 'bot_relay.outbox.drain', {})
expect(gatewayMocks.constructions).toBe(1)
release()
// With retention gone (and no in-flight lease), the entry was disposed —
// a later drain tick constructs a fresh gateway again.
await requestGatewayForAgent('homelab', 'research', 'bot_relay.outbox.drain', {})
expect(gatewayMocks.constructions).toBe(2)
})
it('release is once-only and counted: double-release cannot strip a second retainer', async () => {
const first = retainGatewayForRelay('homelab', 'research')
const second = retainGatewayForRelay('homelab', 'research')
first()
first() // once-only: must not decrement again
await requestGatewayForAgent('homelab', 'research', 'bot_relay.outbox.drain', {})
await requestGatewayForAgent('homelab', 'research', 'bot_relay.outbox.drain', {})
expect(gatewayMocks.constructions).toBe(1)
second()
})
it('the live-work pruner does not evict a relay-retained route between ticks', async () => {
const release = retainGatewayForRelay('homelab', 'research')
await requestGatewayForAgent('homelab', 'research', 'bot_relay.outbox.drain', {})
// Empty keep-set: everything idle is evictable — except the relay pin.
pruneSecondaryGateways(new Set())
await requestGatewayForAgent('homelab', 'research', 'bot_relay.outbox.drain', {})
expect(gatewayMocks.constructions).toBe(1)
release()
// After release the same prune reclaims it.
pruneSecondaryGateways(new Set())
await requestGatewayForAgent('homelab', 'research', 'bot_relay.outbox.drain', {})
expect(gatewayMocks.constructions).toBe(2)
})
it('local routes are exempt: retention is a no-op so the idle reaper can reclaim spawned backends', () => {
// Pinning a local route would keep the secondaries touch-loop pinging its
// Electron-spawned backend forever, defeating the idle reaper
// (gateway.ts local-backend lifecycle). Both the null/legacy and explicit
// `local` source ids must decline the pin.
const releaseNull = retainGatewayForRelay(null, 'research')
const releaseLocal = retainGatewayForRelay('local', 'research')
// No entry was created or pinned — nothing dials, nothing retains.
expect(gatewayMocks.constructions).toBe(0)
releaseNull()
releaseLocal()
})
})
@@ -0,0 +1,162 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Regression for issue #92265: a transient first-dial WebSocket failure
// (e.g. ECONNRESET before the socket reaches `open`) must not let Desktop
// publish the closed gateway as the active route. entry.connection is set
// BEFORE the dial completes in openSecondary(), so checking only its
// truthiness previously let a failed activation still "succeed" and
// publish -- the next chat RPC then failed with "Hermes gateway is not
// connected" even though the UI had already switched to that route.
const gatewayMocks = vi.hoisted(() => ({
connect: vi.fn(async (_wsUrl: string): Promise<void> => undefined),
setConnection: vi.fn(),
setGatewayState: vi.fn()
}))
vi.mock('@/hermes', () => ({
setApiRequestConnection: vi.fn(),
HermesGateway: class {
connectionState = 'closed'
connect = async (wsUrl: string): Promise<void> => {
// Unlike gateway-agent-scope.test.ts's always-succeeds mock, this
// one propagates gatewayMocks.connect's outcome -- letting tests
// below simulate a rejected first dial without flipping
// connectionState to 'open'.
await gatewayMocks.connect(wsUrl)
this.connectionState = 'open'
}
close = (): void => {
this.connectionState = 'closed'
}
onEvent = vi.fn(() => () => {})
onState = vi.fn(() => () => {})
}
}))
vi.mock('@/store/session', () => ({
setConnection: gatewayMocks.setConnection,
setGatewayState: gatewayMocks.setGatewayState
}))
vi.mock('@/store/notify-baseline', () => ({ markNativeNotifyBaseline: vi.fn() }))
const {
$gateway,
activeGateway,
closeSecondaryGateways,
configureGatewayRegistry,
ensureGatewayForAgent,
ensureGatewayForProfile,
isActivePrimary,
setPrimaryGateway
} = await import('./gateway')
interface DesktopStub {
getConnection: ReturnType<typeof vi.fn>
getConnectionFor: ReturnType<typeof vi.fn>
}
function installDesktop(stub: DesktopStub): void {
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = stub
}
function makePrimary(): { connectionState: string } {
return { connectionState: 'open' }
}
const agentConn = {
authMode: 'token',
baseUrl: 'https://homelab.invalid',
mode: 'remote',
profile: 'research',
token: 'fake-test-token',
wsUrl: 'wss://homelab.invalid/api/ws?token=fake-test-token'
}
function installAgentDesktop(): DesktopStub {
const stub: DesktopStub = {
getConnection: vi.fn(async () => agentConn),
getConnectionFor: vi.fn(async () => agentConn)
}
installDesktop(stub)
return stub
}
beforeEach(() => {
configureGatewayRegistry({ onEvent: vi.fn() })
})
afterEach(() => {
closeSecondaryGateways()
vi.clearAllMocks()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
describe('secondary activation requires an open socket, not just a connection descriptor (issue #92265)', () => {
it('ensureGatewayForAgent: a transient first-dial failure does not activate or publish the closed gateway', async () => {
const primary = makePrimary()
setPrimaryGateway(primary as never, 'default')
await ensureGatewayForProfile('default')
const publishedPrimary = $gateway.get()
installAgentDesktop()
gatewayMocks.connect.mockRejectedValueOnce(new Error('ECONNRESET'))
const activated = await ensureGatewayForAgent('homelab', 'research')
expect(activated).toBe(false)
// The exact reported symptom: the UI must NOT have switched away from
// the primary onto the closed secondary.
expect(isActivePrimary()).toBe(true)
expect(activeGateway()).toBe(primary)
expect($gateway.get()).toBe(publishedPrimary)
})
it('ensureGatewayForAgent: a successful dial still activates and publishes normally', async () => {
const primary = makePrimary()
setPrimaryGateway(primary as never, 'default')
installAgentDesktop()
const activated = await ensureGatewayForAgent('homelab', 'research')
expect(activated).toBe(true)
expect(isActivePrimary()).toBe(false)
expect(activeGateway()).not.toBe(primary)
expect($gateway.get()).not.toBe(primary)
})
it('ensureGatewayForProfile: a transient first-dial failure does not publish the closed gateway', async () => {
const primary = makePrimary()
setPrimaryGateway(primary as never, 'default')
await ensureGatewayForProfile('default')
const publishedPrimary = $gateway.get()
installDesktop({
getConnection: vi.fn(async () => agentConn),
getConnectionFor: vi.fn(async () => agentConn)
})
gatewayMocks.connect.mockRejectedValueOnce(new Error('ECONNRESET'))
// Post-#81165 the profile door RE-THROWS on a failed dial (so callers can
// surface it); the #92265 invariant under test is unchanged — the closed
// secondary must never be published as the active route.
await expect(ensureGatewayForProfile('research')).rejects.toThrow('ECONNRESET')
// Must still be on the primary -- the closed secondary was never
// published as the active route.
expect(isActivePrimary()).toBe(true)
expect($gateway.get()).toBe(publishedPrimary)
})
it('ensureGatewayForProfile: a successful dial still activates and publishes normally', async () => {
const primary = makePrimary()
setPrimaryGateway(primary as never, 'default')
installAgentDesktop()
await ensureGatewayForProfile('research')
expect(isActivePrimary()).toBe(false)
expect($gateway.get()).not.toBe(primary)
})
})
@@ -0,0 +1,143 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// The global-remote share (backend routing case 3): every profile is served
// by the PRIMARY backend over one host, and getConnection() explicitly tags
// the shared descriptor with `sharedPrimary`. Dialing a second WebSocket at it
// used to fail over SSH (per-backend tunnel/ticket) and poison the active
// gateway with a closed socket — "Hermes gateway is not connected" for every
// profile except the primary. Pooled backends (own-remote override, local
// named profile) also carry `profile` for WS URL minting, so `profile` alone
// cannot identify the shared-primary route. These tests pin the fix: only a
// `sharedPrimary` descriptor activates the primary socket; a pooled descriptor
// that also carries `profile` must still dial its own socket.
const gatewayMocks = vi.hoisted(() => ({
connect: vi.fn(async (_wsUrl: string): Promise<void> => {
throw new Error('dialed a socket for a shared-primary profile')
}),
setConnection: vi.fn()
}))
vi.mock('@/hermes', () => ({
setApiRequestConnection: vi.fn(),
HermesGateway: class {
connectionState = 'closed'
connect = async (wsUrl: string): Promise<void> => {
await gatewayMocks.connect(wsUrl)
this.connectionState = 'open'
}
close = vi.fn()
onEvent = vi.fn(() => () => {})
onState = vi.fn(() => () => {})
}
}))
vi.mock('@/store/session', () => ({
setConnection: gatewayMocks.setConnection,
setGatewayState: vi.fn()
}))
vi.mock('@/store/notify-baseline', () => ({ markNativeNotifyBaseline: vi.fn() }))
const { $gateway, closeSecondaryGateways, configureGatewayRegistry, ensureGatewayForProfile, setPrimaryGateway } =
await import('./gateway')
type DesktopStub = { getConnection: ReturnType<typeof vi.fn> }
function installDesktop(stub: DesktopStub): void {
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = stub
}
function makePrimary(): { connectionState: string } {
// Only connectionState is consulted by setActive/isOpen for these paths.
return { connectionState: 'open' }
}
beforeEach(() => {
configureGatewayRegistry({
onEvent: vi.fn(),
primaryProfile: 'default'
} as never)
})
afterEach(() => {
closeSecondaryGateways()
vi.clearAllMocks()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
describe('ensureGatewayForProfile under a shared global remote', () => {
it('activates the primary socket for an explicitly shared-primary descriptor', async () => {
const primary = makePrimary()
setPrimaryGateway(primary as never, 'default')
installDesktop({
// Shared descriptor: primary connection tagged with the profile scope
// AND the explicit sharedPrimary marker.
getConnection: vi.fn(async () => ({ port: 4242, profile: 'venture', sharedPrimary: true, token: 't' }))
})
await ensureGatewayForProfile('venture')
expect(gatewayMocks.connect).not.toHaveBeenCalled()
expect($gateway.get()).toBe(primary)
})
it('dials the exact WebSocket URL for a pooled profile descriptor that carries profile', async () => {
const primary = makePrimary()
const remoteWsUrl = 'wss://remote.invalid/api/ws?token=fake-test-token'
setPrimaryGateway(primary as never, 'default')
installDesktop({
// Pooled descriptor: carries `profile` for WS URL minting but is NOT
// shared-primary (no marker) — it must dial its own socket, not reuse
// the primary. This is the local named / own-remote profile case.
getConnection: vi.fn(async () => ({
authMode: 'token',
baseUrl: 'https://remote.invalid',
mode: 'remote',
profile: 'worker',
token: 'fake-test-token',
wsUrl: remoteWsUrl
}))
})
gatewayMocks.connect.mockResolvedValueOnce(undefined)
await ensureGatewayForProfile('worker')
expect(gatewayMocks.connect).toHaveBeenCalledOnce()
expect(gatewayMocks.connect).toHaveBeenCalledWith(remoteWsUrl)
expect($gateway.get()).not.toBe(primary)
})
it('rejects the failed dial without publishing an activation, then activates once the backend returns', async () => {
const connection = {
authMode: 'token',
baseUrl: 'https://worker.invalid',
mode: 'remote',
profile: 'worker',
token: 'fake-test-token',
wsUrl: 'wss://worker.invalid/api/ws?token=fake-test-token'
}
const getConnection = vi.fn(async () => connection)
setPrimaryGateway(makePrimary() as never, 'default')
installDesktop({ getConnection })
gatewayMocks.connect.mockRejectedValueOnce(new Error('temporarily offline')).mockResolvedValueOnce(undefined)
// #81094: a failed dial must REJECT instead of silently activating a
// closed socket that would route messages to the primary backend.
await expect(ensureGatewayForProfile('worker')).rejects.toThrow('temporarily offline')
// No activation was published for the dead dial — $connection keeps the
// primary's descriptor (set by setPrimaryGateway), never the unreachable
// secondary's. (Also the #92265 shape: publish requires an OPEN socket.)
expect(gatewayMocks.setConnection).not.toHaveBeenCalled()
// Once the backend is reachable again, retrying the switch activates and
// publishes the live connection descriptor.
await ensureGatewayForProfile('worker')
expect(gatewayMocks.setConnection).toHaveBeenCalledTimes(1)
expect(gatewayMocks.setConnection).toHaveBeenLastCalledWith(connection)
})
})
@@ -0,0 +1,349 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $sessionsLimit, resetSessionsLimit, SIDEBAR_SESSIONS_PAGE_SIZE } from '@/store/layout'
import {
$activeSessionId,
$cronSessions,
$freshDraftReady,
$messagingSessions,
$sessionProfilesTruncated,
$sessions,
$sessionsLoading,
setActiveSessionId,
setCronSessions,
setFreshDraftReady,
setMessagingSessions,
setSessionProfilesTruncated,
setSessions,
setSessionsLoading
} from '@/store/session'
import { $stalledSessionIds } from '@/store/session-states'
import {
$gatewaySwitching,
beginGatewaySwitch,
endGatewaySwitch,
recoverActiveSourceAfterFailedGatewaySwitch,
registerGatewaySwitchLifecycle,
wipeSessionListsForGatewaySwitch
} from './gateway-switch'
vi.mock('@/lib/query-client', () => ({
invalidateProfileScopedQueries: vi.fn()
}))
vi.mock(import('@/store/profile'), async importOriginal => {
const actual = await importOriginal()
return {
...actual,
invalidateProfileListFetches: vi.fn()
}
})
const { invalidateProfileListFetches } = await import('@/store/profile')
describe('wipeSessionListsForGatewaySwitch', () => {
beforeEach(() => {
$gatewaySwitching.set(false)
setSessions([{ id: 's1', title: 'old', profile: 'default' } as never])
setSessionProfilesTruncated({ default: true })
setCronSessions([{ id: 'c1', title: 'cron', profile: 'default' } as never])
setMessagingSessions([{ id: 'm1', title: 'tg', profile: 'default' } as never])
$stalledSessionIds.set(['s1'])
setSessionsLoading(false)
setFreshDraftReady(false)
$sessionsLimit.set(SIDEBAR_SESSIONS_PAGE_SIZE * 3)
})
afterEach(() => {
resetSessionsLimit()
setSessions([])
setCronSessions([])
setMessagingSessions([])
$stalledSessionIds.set([])
setSessionsLoading(true)
$gatewaySwitching.set(false)
})
it('clears lists and arms loading so sidebar skeletons retrigger', () => {
wipeSessionListsForGatewaySwitch()
expect($sessions.get()).toEqual([])
expect($sessionProfilesTruncated.get()).toEqual({})
expect($cronSessions.get()).toEqual([])
expect($messagingSessions.get()).toEqual([])
expect($stalledSessionIds.get()).toEqual([])
expect($sessionsLoading.get()).toBe(true)
expect($sessionsLimit.get()).toBe(SIDEBAR_SESSIONS_PAGE_SIZE)
expect($freshDraftReady.get()).toBe(true)
})
it('strands in-flight profile-list fetches so the old backend cannot repaint the rail (#85731)', () => {
// The soft re-home moves /api/profiles routing to the NEW backend; a
// response still in flight from the previous one must be invalidated
// here, in the same wipe every connection/mode apply funnels through.
wipeSessionListsForGatewaySwitch()
expect(invalidateProfileListFetches).toHaveBeenCalled()
})
})
describe('beginGatewaySwitch / endGatewaySwitch — the shared switch commit point (#93937)', () => {
beforeEach(() => {
$gatewaySwitching.set(false)
setSessions([{ id: 's1', title: 'old', profile: 'default' } as never])
setActiveSessionId('a93bb39d')
setSessionsLoading(false)
})
afterEach(() => {
setSessions([])
setActiveSessionId(null)
setSessionsLoading(true)
$gatewaySwitching.set(false)
})
it('raises the barrier, runs the registered machine-context reset, then wipes — synchronously, in that order', () => {
const seen: string[] = []
const off = registerGatewaySwitchLifecycle({
beforeConnectionSwitch: () => {
// The reset runs behind the barrier and BEFORE the wipe: it may still
// read the outgoing session (to fresh-draft it), never a half-wiped one.
seen.push(
`switching=${$gatewaySwitching.get()} active=${$activeSessionId.get()} rows=${$sessions.get().length}`
)
},
refreshSessions: async () => undefined
})
beginGatewaySwitch()
expect(seen).toEqual(['switching=true active=a93bb39d rows=1'])
expect($gatewaySwitching.get()).toBe(true)
// The previous backend's runtime binding is gone before anything can dial.
expect($activeSessionId.get()).toBeNull()
expect($sessions.get()).toEqual([])
expect($sessionsLoading.get()).toBe(true)
endGatewaySwitch()
expect($gatewaySwitching.get()).toBe(false)
off()
})
it('tears down its barrier when the registered lifecycle throws before the wipe', () => {
const failure = new Error('machine-context reset failed')
const off = registerGatewaySwitchLifecycle({
beforeConnectionSwitch: () => {
throw failure
},
refreshSessions: vi.fn(async () => undefined)
})
expect(() => beginGatewaySwitch()).toThrow(failure)
expect($gatewaySwitching.get()).toBe(false)
// No wipe started, so the still-active source remains intact and needs no
// repaint. A later switch can acquire and release barrier ownership.
expect($activeSessionId.get()).toBe('a93bb39d')
expect($sessions.get()).toHaveLength(1)
off()
const next = beginGatewaySwitch()
expect($gatewaySwitching.get()).toBe(true)
endGatewaySwitch(next)
expect($gatewaySwitching.get()).toBe(false)
})
it('does not disarm a newer switch while a partial-wipe recovery refresh is pending', async () => {
const failure = new Error('profile fetch invalidation failed')
let finishRefresh: () => void = () => undefined
let refreshCompleted = false
const refreshPending = new Promise<void>(resolve => {
finishRefresh = resolve
})
const refreshSessions = vi.fn(async () => {
await refreshPending
refreshCompleted = true
})
const off = registerGatewaySwitchLifecycle({ beforeConnectionSwitch: () => undefined, refreshSessions })
vi.mocked(invalidateProfileListFetches).mockImplementationOnce(() => {
throw failure
})
expect(() => beginGatewaySwitch()).toThrow(failure)
expect($gatewaySwitching.get()).toBe(false)
await vi.waitFor(() => expect(refreshSessions).toHaveBeenCalledTimes(1))
// A newer switch takes ownership while the old source repaint is still in
// flight. Completing the old repaint must not lower the newer skeleton.
const next = beginGatewaySwitch()
expect($gatewaySwitching.get()).toBe(true)
expect($sessionsLoading.get()).toBe(true)
finishRefresh()
await vi.waitFor(() => expect(refreshCompleted).toBe(true))
await Promise.resolve()
expect($sessionsLoading.get()).toBe(true)
expect($gatewaySwitching.get()).toBe(true)
endGatewaySwitch(next)
expect($gatewaySwitching.get()).toBe(false)
off()
})
it('does not refresh through a newer route when recovery is superseded before it starts', async () => {
const failure = new Error('profile fetch invalidation failed')
const refreshSessions = vi.fn(async () => undefined)
const off = registerGatewaySwitchLifecycle({ beforeConnectionSwitch: () => undefined, refreshSessions })
vi.mocked(invalidateProfileListFetches).mockImplementationOnce(() => {
throw failure
})
expect(() => beginGatewaySwitch()).toThrow(failure)
// Recovery is queued on a microtask. A newer switch that starts first owns
// the active route, so the stale recovery must not issue a request through it.
const next = beginGatewaySwitch()
await Promise.resolve()
await Promise.resolve()
expect(refreshSessions).not.toHaveBeenCalled()
expect($sessionsLoading.get()).toBe(true)
endGatewaySwitch(next)
off()
})
it('the barrier belongs to the LATEST switch: an older switch ending mid-commit of a newer one is a no-op', () => {
const older = beginGatewaySwitch()
const newer = beginGatewaySwitch()
endGatewaySwitch(older)
expect($gatewaySwitching.get()).toBe(true)
endGatewaySwitch(newer)
expect($gatewaySwitching.get()).toBe(false)
// Host teardown forces it down regardless of ownership.
beginGatewaySwitch()
endGatewaySwitch()
expect($gatewaySwitching.get()).toBe(false)
})
it('repeated overlapping commits safely re-run the lifecycle and wipe without losing barrier ownership', () => {
const beforeConnectionSwitch = vi.fn()
const off = registerGatewaySwitchLifecycle({
beforeConnectionSwitch,
refreshSessions: async () => undefined
})
const older = beginGatewaySwitch()
// Simulate stale gateway-bound state racing back before the newer commit.
setSessions([{ id: 'late', title: 'late old-source row', profile: 'default' } as never])
setActiveSessionId('late-runtime')
const newer = beginGatewaySwitch()
expect(beforeConnectionSwitch).toHaveBeenCalledTimes(2)
expect($sessions.get()).toEqual([])
expect($activeSessionId.get()).toBeNull()
endGatewaySwitch(older)
expect($gatewaySwitching.get()).toBe(true)
endGatewaySwitch(newer)
expect($gatewaySwitching.get()).toBe(false)
off()
})
it('still severs the bindings when no lifecycle is registered (windows that never mount the boot hook)', () => {
beginGatewaySwitch()
expect($gatewaySwitching.get()).toBe(true)
expect($activeSessionId.get()).toBeNull()
expect($sessions.get()).toEqual([])
endGatewaySwitch()
expect($gatewaySwitching.get()).toBe(false)
})
it('an unregistered lifecycle no longer runs, and a stale unregister cannot evict a newer one', () => {
const first = vi.fn()
const second = vi.fn()
const offFirst = registerGatewaySwitchLifecycle({
beforeConnectionSwitch: first,
refreshSessions: async () => undefined
})
const offSecond = registerGatewaySwitchLifecycle({
beforeConnectionSwitch: second,
refreshSessions: async () => undefined
})
// Stale unregister from the older host: the newer registration stays.
offFirst()
beginGatewaySwitch()
endGatewaySwitch()
expect(first).not.toHaveBeenCalled()
expect(second).toHaveBeenCalledTimes(1)
offSecond()
beginGatewaySwitch()
endGatewaySwitch()
expect(second).toHaveBeenCalledTimes(1)
})
it('recoverActiveSourceAfterFailedGatewaySwitch re-pulls the still-active source and disarms the skeleton', async () => {
const refreshSessions = vi.fn(async () => undefined)
const off = registerGatewaySwitchLifecycle({ beforeConnectionSwitch: () => undefined, refreshSessions })
const token = beginGatewaySwitch()
expect($sessionsLoading.get()).toBe(true)
recoverActiveSourceAfterFailedGatewaySwitch(token)
endGatewaySwitch(token)
await vi.waitFor(() => expect($sessionsLoading.get()).toBe(false))
expect(refreshSessions).toHaveBeenCalledTimes(1)
off()
})
it('a failing repaint (or none registered) still disarms the skeleton', async () => {
const off = registerGatewaySwitchLifecycle({
beforeConnectionSwitch: () => undefined,
refreshSessions: async () => {
throw new Error('backend busy')
}
})
setSessionsLoading(true)
const failingToken = beginGatewaySwitch()
recoverActiveSourceAfterFailedGatewaySwitch(failingToken)
endGatewaySwitch(failingToken)
await vi.waitFor(() => expect($sessionsLoading.get()).toBe(false))
off()
setSessionsLoading(true)
const debug = vi.spyOn(console, 'debug').mockImplementation(() => undefined)
const missingLifecycleToken = beginGatewaySwitch()
recoverActiveSourceAfterFailedGatewaySwitch(missingLifecycleToken)
endGatewaySwitch(missingLifecycleToken)
await vi.waitFor(() => expect($sessionsLoading.get()).toBe(false))
expect(debug).toHaveBeenCalledWith(
'[gateway-switch] cannot repaint the active source because no switch lifecycle is registered'
)
debug.mockRestore()
})
})
+232
View File
@@ -0,0 +1,232 @@
import { atom } from 'nanostores'
import { resetLiveRuntimeTracking } from '@/app/contrib/hooks/use-background-sync'
import { resetSidebarBatchCapability } from '@/hermes'
import { invalidateProfileScopedQueries } from '@/lib/query-client'
import { clearArtifactRegistry } from '@/store/artifacts'
import { invalidateCronJobsRequests, setCronJobs } from '@/store/cron'
import { resetSessionsLimit } from '@/store/layout'
import { resetLiveSync } from '@/store/live-sync'
import { invalidateProfileListFetches } from '@/store/profile'
import {
$unreadFinishedSessionIds,
setActiveSessionId,
setCronSessions,
setFreshDraftReady,
setMessages,
setMessagingPlatformTotals,
setMessagingSessions,
setMessagingTruncated,
setSelectedStoredSessionId,
setSessionProfilesTruncated,
setSessionProfilesUsage,
setSessions,
setSessionsLoading
} from '@/store/session'
import { resetSessionPinMirror } from '@/store/session-pin-sync'
import { clearAllSessionStates } from '@/store/session-states'
import { clearTranscriptTails } from '@/store/transcript-tail-cache'
// True while a connection switch is mid-flight — a Settings → Gateway apply
// (wipe → re-dial, use-gateway-boot softSwitch) or a Sessions-switcher source
// change (store/connections selectConnection). Lets the boot hook suppress the
// backend-exit toast, keeps the cold-boot CONNECTING overlay from resurrecting
// when startHermes re-emits boot progress, and tells the resume path that a
// "session not found" mid-switch means "retry once things settle", not "gone".
export const $gatewaySwitching = atom(false)
/**
* Renderer-side cleanup a connection switch must run before the next gateway
* is activated or published: fresh-draft the open transcript, drop the overlay
* return route, reset the project tree, close terminals — everything bound to
* the OUTGOING backend that lives outside the session store. Registered by
* useGatewayBoot (whose host owns those React callbacks) so store-driven
* switches run the exact same reset as a Settings → Gateway apply.
*/
export interface GatewaySwitchLifecycle {
beforeConnectionSwitch: () => void
/** Re-pull the session lists from whichever backend is active NOW. */
refreshSessions: (shouldPublish?: () => boolean) => Promise<void>
}
let switchLifecycle: GatewaySwitchLifecycle | null = null
/** Ownership handle returned by beginGatewaySwitch; see endGatewaySwitch. */
export type GatewaySwitchToken = number
let latestSwitchToken = 0
/** True only while token owns the latest connection-switch lifecycle. */
export function isCurrentGatewaySwitch(token: GatewaySwitchToken): boolean {
return token === latestSwitchToken
}
export function registerGatewaySwitchLifecycle(lifecycle: GatewaySwitchLifecycle): () => void {
switchLifecycle = lifecycle
return () => {
if (switchLifecycle === lifecycle) {
switchLifecycle = null
}
}
}
/**
* Commit point of every connection switch: raise the barrier and sever every
* binding to the outgoing backend in ONE synchronous step. Both switch doors —
* Settings apply (softSwitch) and the Sessions switcher (selectConnection) —
* must call this BEFORE the next gateway is activated or its descriptor
* published. The sidebar door used to activate first and wipe afterwards
* (across an IPC round-trip), so route/session effects saw the new source while
* $activeSessionId still named the previous backend's runtime and sent that id
* to a backend that had never minted it — "session not found" (#93937).
*/
export function beginGatewaySwitch(): GatewaySwitchToken {
const token = ++latestSwitchToken
let wipeStarted = false
$gatewaySwitching.set(true)
try {
switchLifecycle?.beforeConnectionSwitch()
wipeStarted = true
wipeSessionListsForGatewaySwitch()
return token
} catch (error) {
// No caller received this token, so begin owns cleanup. Token-aware teardown
// preserves a newer recursively-started switch, if lifecycle code began one.
const stillOwnsSwitch = isCurrentGatewaySwitch(token)
endGatewaySwitch(token)
// A synchronous wipe has no rollback: once it starts, some outgoing-source
// stores may already be empty. Repaint the still-active source best-effort.
// A lifecycle failure happens before the wipe and leaves lists untouched.
// If a nested switch superseded this one, its owner is responsible instead.
if (wipeStarted && stillOwnsSwitch) {
try {
recoverActiveSourceAfterFailedGatewaySwitch(token)
} catch {
// Recovery must never replace the original commit failure.
}
}
throw error
}
}
/**
* Lower the barrier once the switch that owns it has committed (or failed).
* Switches overlap — a click can supersede one that is mid-commit — and the
* barrier belongs to the LATEST one: an older switch ending is a no-op while a
* newer one is still in flight. No token = force down (host teardown).
*/
export function endGatewaySwitch(token?: GatewaySwitchToken): void {
if (token !== undefined && !isCurrentGatewaySwitch(token)) {
return
}
$gatewaySwitching.set(false)
}
/**
* A commit that fails AFTER beginGatewaySwitch leaves the still-active source
* with its lists wiped and the sidebar skeleton armed, and nothing reactive
* re-pulls them (no source/profile scope moved). Repaint it explicitly so the
* sidebar doesn't sit on the skeleton; the fetch is best-effort. Recovery
* retains the failed switch's token across the async refresh so it cannot
* request through, or disarm loading for, a newer route.
*/
export function recoverActiveSourceAfterFailedGatewaySwitch(token: GatewaySwitchToken): void {
const lifecycle = switchLifecycle
if (!lifecycle) {
console.debug('[gateway-switch] cannot repaint the active source because no switch lifecycle is registered')
if (isCurrentGatewaySwitch(token)) {
setSessionsLoading(false)
}
return
}
void Promise.resolve()
.then(() =>
isCurrentGatewaySwitch(token) ? lifecycle.refreshSessions(() => isCurrentGatewaySwitch(token)) : undefined
)
.catch(() => undefined)
.finally(() => {
if (isCurrentGatewaySwitch(token)) {
setSessionsLoading(false)
}
})
}
/**
* Clear gateway-bound session UI so sidebar skeletons retrigger.
*
* Sessions live in nanostores (not React Query) — refreshSessions merges into
* the existing list, so without an explicit wipe a soft switch would keep
* painting the previous gateway's rows. RQ caches (settings/config/skills) are
* invalidated separately; the live session list is this path.
*
* Does NOT call requestFreshSession() — that navigates to NEW_CHAT and would
* close route overlays (Settings). Clear chat state in place; leave the URL
* alone so the user stays where they were (e.g. mid-Gateway settings).
*/
export function wipeSessionListsForGatewaySwitch(): void {
// The next backend is a different runtime — don't carry the old one's
// "batched sidebar endpoint missing" capability verdict across the switch.
resetSidebarBatchCapability()
// Strand any in-flight /api/profiles fetch from the PREVIOUS backend. The
// rail's $profiles cache is deliberately NOT wiped (an empty list flickers
// the rail away), but a late response from the old backend must not
// overwrite what the new backend reports — that stale write is how a
// remote/Cloud connection apply made the profile rail vanish (#85731).
invalidateProfileListFetches()
// Pins are mirrored per-backend. The next gateway has its own state.db and
// has never seen them, so drop the "already pushed" bookkeeping and let the
// next reconcile re-assert the whole set against the new backend.
resetSessionPinMirror()
setSessions([])
setSessionProfilesTruncated({})
setSessionProfilesUsage({})
setCronSessions([])
invalidateCronJobsRequests()
setCronJobs([])
setMessagingSessions([])
setMessagingPlatformTotals({})
setMessagingTruncated(false)
// Clearing $sessionStates automatically clears $workingSessionIds and
// $attentionSessionIds (computed) and $stalledSessionIds (owned beside it).
// $unreadFinishedSessionIds is separate, so wipe it explicitly. Only the
// transient paint layer is wiped: the persisted markers/watermarks in
// session-unread.ts are keyed by durable session id and repaint the rows
// that are still unread once the next gateway's lists load — so a profile
// round-trip doesn't swallow green dots.
clearAllSessionStates()
resetLiveRuntimeTracking()
resetLiveSync()
$unreadFinishedSessionIds.set([])
setSessionsLoading(true)
resetSessionsLimit()
setActiveSessionId(null)
setSelectedStoredSessionId(null)
setMessages([])
setFreshDraftReady(true)
// Artifacts are keyed by sessions on the previous backend, so both the
// registry and any rail tab pointing into it go with them.
clearArtifactRegistry()
// Cached transcript tails belong to the PREVIOUS backend's sessions; a
// different backend can recycle stored ids, and painting another machine's
// conversation under a same-named id is worse than a loader. Wipe them.
clearTranscriptTails()
// Narrowed: account/marketplace/onboarding caches are global, not gateway-
// scoped, so a mode swap must not refetch them.
invalidateProfileScopedQueries()
}
+358
View File
@@ -0,0 +1,358 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Connection lifecycle for registry-scoped secondary gateways:
//
// 1. Removing a connection must dispose its secondaries — remote/cloud
// sources have no local process whose death would drop the socket, so
// without an explicit dispose the WebSocket stays open and streams ghost
// events until page reload.
// 2. A materially edited connection re-dials so fresh sockets target the
// NEW endpoint.
// 3. When the Electron main reports the connection no longer exists
// (`No connection with id`), the reconnect loop fail-stops and evicts
// the entry instead of retrying forever.
const gatewayMocks = vi.hoisted(() => {
const instances: { close: ReturnType<typeof vi.fn>; connectionState: string }[] = []
return {
connect: vi.fn(async (_wsUrl: string): Promise<void> => undefined),
instances
}
})
vi.mock('@/hermes', () => ({
setApiRequestConnection: vi.fn(),
HermesGateway: class {
connectionState = 'closed'
close = vi.fn(() => {
this.connectionState = 'closed'
})
connect = async (wsUrl: string): Promise<void> => {
await gatewayMocks.connect(wsUrl)
this.connectionState = 'open'
}
onEvent = vi.fn(() => () => {})
onState = vi.fn(() => () => {})
constructor() {
gatewayMocks.instances.push(this as never)
}
}
}))
vi.mock('@/store/session', () => ({
setConnection: vi.fn(),
setGatewayState: vi.fn()
}))
vi.mock('@/store/notify-baseline', () => ({ markNativeNotifyBaseline: vi.fn() }))
const {
activeGateway,
closeSecondaryGateways,
configureGatewayRegistry,
ensureGatewayForProfile,
openGatewayForAgent,
pruneSecondaryGateways,
setPrimaryGateway
} = await import('./gateway')
function installDesktop(stub: Record<string, unknown>): void {
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = stub
}
beforeEach(() => {
configureGatewayRegistry({ onEvent: vi.fn() } as never)
setPrimaryGateway({ connectionState: 'open' } as never, 'default')
})
afterEach(() => {
closeSecondaryGateways()
gatewayMocks.instances.length = 0
vi.clearAllMocks()
vi.useRealTimers()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
describe('ensureGatewayForProfile — secondary connect failure surfaces (#81094)', () => {
it('rethrows the dial failure instead of activating a closed socket', async () => {
const getConnection = vi.fn(async ({ profile }: { profile: string }) => ({
authMode: 'token',
baseUrl: `https://${profile}.invalid`,
mode: 'local',
profile,
token: 'fake-test-token',
wsUrl: `wss://${profile}.invalid/ws`
}))
installDesktop({ getConnection })
// First activation succeeds so the entry exists.
await ensureGatewayForProfile('work')
const live = activeGateway()
expect(live).toBeTruthy()
// The socket then dies (backend restart): state flips to closed, so the
// next activation must re-dial instead of reusing the dead socket.
;(live as unknown as { connectionState: string }).connectionState = 'closed'
gatewayMocks.connect.mockRejectedValue(new Error('backend unreachable'))
await expect(ensureGatewayForProfile('work')).rejects.toThrow('backend unreachable')
// The failed switch must NOT fall through to setActive() with a closed
// socket: the active gateway is still the previously-live one, never the
// dead entry that just failed to dial.
const stillActive = activeGateway()
expect(stillActive).toBe(live)
expect(gatewayMocks.instances).toHaveLength(1)
})
it('releases the activation lease when the first dial is rejected so pruning disposes it', async () => {
const getConnection = vi.fn(async ({ profile }: { profile: string }) => ({
authMode: 'token',
baseUrl: `https://${profile}.invalid`,
mode: 'local',
profile,
token: 'fake-test-token',
wsUrl: `wss://${profile}.invalid/ws`
}))
installDesktop({ getConnection })
gatewayMocks.connect.mockRejectedValue(new Error('backend unreachable'))
await expect(ensureGatewayForProfile('work')).rejects.toThrow('backend unreachable')
pruneSecondaryGateways(new Set())
expect(gatewayMocks.instances[0].close).toHaveBeenCalledTimes(1)
})
it('keeps the reconnect schedule armed so transient failures still self-heal', async () => {
vi.useFakeTimers()
let failFirst = true
const getConnection = vi.fn(async ({ profile }: { profile: string }) => ({
authMode: 'token',
baseUrl: `https://${profile}.invalid`,
mode: 'local',
profile,
token: 'fake-test-token',
wsUrl: `wss://${profile}.invalid/ws`
}))
installDesktop({ getConnection })
gatewayMocks.connect.mockImplementation(async () => {
if (failFirst) {
throw new Error('backend unreachable')
}
})
await expect(ensureGatewayForProfile('work')).rejects.toThrow('backend unreachable')
// The catch kept the reconnect schedule: exactly one backoff timer is armed
// for the failed entry (transient failures still self-heal).
expect(vi.getTimerCount()).toBe(1)
// Backoff fires → reconnect dials again → succeeds → socket opens.
failFirst = false
await vi.runAllTimersAsync()
expect(gatewayMocks.instances[0].connectionState).toBe('open')
})
it('activates the secondary when connect succeeds', async () => {
const getConnection = vi.fn(async ({ profile }: { profile: string }) => ({
authMode: 'token',
baseUrl: `https://${profile}.invalid`,
mode: 'local',
profile,
token: 'fake-test-token',
wsUrl: `wss://${profile}.invalid/ws`
}))
installDesktop({ getConnection })
await ensureGatewayForProfile('work')
expect(activeGateway()).toBe(gatewayMocks.instances[0])
})
})
describe('connection-scoped dial failure identity (#95421)', () => {
it('logs the route scope while preserving the original dial error', async () => {
const dialError = new Error('backend unreachable')
const getConnectionFor = vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) => ({
authMode: 'token',
connectionId,
profile,
wsUrl: `wss://${connectionId}.invalid/ws`
}))
installDesktop({ getConnectionFor })
gatewayMocks.connect.mockRejectedValue(dialError)
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
await expect(openGatewayForAgent('work', 'default')).rejects.toBe(dialError)
await expect(openGatewayForAgent('homelab', 'default')).rejects.toBe(dialError)
const messages = errorSpy.mock.calls.map(([message]) => String(message))
expect(messages).toHaveLength(2)
expect(messages).toEqual(
expect.arrayContaining([
expect.stringContaining('scope="conn:work::default"'),
expect.stringContaining('scope="conn:homelab::default"')
])
)
expect(messages.every(message => message.includes('profile="default"'))).toBe(true)
expect(new Set(messages).size).toBe(2)
expect(messages.join(' ')).not.toContain('wss://')
for (const [, error] of errorSpy.mock.calls) {
expect(error).toBe(dialError)
}
} finally {
errorSpy.mockRestore()
}
})
})
describe('profile switch mid-WS-handshake (#92434 close-candidate pin)', () => {
// Reported shape: Bot ↔ Default switching killed the socket until an app
// restart. The activation-epoch guard (applyActive) + open-socket-publish
// rule mean a switch-back that lands while the outgoing switch's handshake
// is still pending must win the route, and the late-completing dial must
// neither steal the foreground nor leave its socket permanently broken.
it('a switch-back during a pending handshake wins; the late dial neither steals the route nor breaks the socket', async () => {
const getConnection = vi.fn(async ({ profile }: { profile: string }) => ({
authMode: 'token',
baseUrl: `https://${profile}.invalid`,
mode: 'local',
profile,
token: 'fake-test-token',
wsUrl: `wss://${profile}.invalid/ws`
}))
installDesktop({ getConnection })
let releaseDial: () => void = () => undefined
gatewayMocks.connect.mockImplementation(
() =>
new Promise<void>(resolve => {
releaseDial = resolve
})
)
// 1. Default → Bot: the secondary's WS handshake starts and stays pending.
const botActivation = ensureGatewayForProfile('bot')
await vi.waitFor(() => expect(gatewayMocks.connect).toHaveBeenCalledTimes(1))
// 2. The user switches back to Default while that handshake is mid-flight.
await ensureGatewayForProfile('default')
const primary = activeGateway()
expect(primary).toBeTruthy()
// 3. The Bot handshake completes AFTER the switch-back.
releaseDial()
await botActivation
// The stale activation must not steal the foreground route (epoch guard).
expect(activeGateway()).toBe(primary)
// 4. No permanent break: switching to Bot again activates the (already
// open) socket — no app restart, no duplicate socket/serve.
await ensureGatewayForProfile('bot')
expect(activeGateway()).toBe(gatewayMocks.instances[0])
expect(gatewayMocks.instances[0].connectionState).toBe('open')
expect(gatewayMocks.instances).toHaveLength(1)
})
})
describe('secondary connection timeout (#93454)', () => {
it("rejects instead of hanging forever when openSecondary's getConnection() wedges", async () => {
// Repro: desktop.getConnection is an IPC round-trip into the main process
// with no timeout of its own. A wedged main-process round-trip (e.g. a
// stuck revalidation) hangs this await forever, latching
// entry.connectPromise so every routed action against this secondary
// (SSH terminal, messaging DELETE, session send, …) never settles either.
vi.useFakeTimers()
let callCount = 0
const getConnection = vi.fn(({ profile }: { profile: string }) => {
callCount += 1
// First call is sharedPrimaryRoute's probe — resolves fast, not the
// shared primary. Every call after (openSecondary's actual dial) wedges.
if (callCount === 1) {
return Promise.resolve({ sharedPrimary: false })
}
return new Promise(() => undefined)
})
installDesktop({ getConnection })
const pending = expect(ensureGatewayForProfile('work')).rejects.toThrow('Timed out connecting to profile "work"')
// Advance past the internal reconnect-attempt timeout (20s) — the stalled
// await must reject instead of hanging forever.
await vi.advanceTimersByTimeAsync(20_000)
await pending
})
it('does not let a wedged shared-primary-route probe block the secondary dial forever', async () => {
// Same unbounded-IPC hazard as above, but for sharedPrimaryRoute's own
// getConnection() probe, which runs BEFORE openSecondary on every route —
// a wedge there must resolve to "not the shared primary" and fall through
// to the ordinary secondary dial instead of hanging the whole route
// decision forever.
vi.useFakeTimers()
let callCount = 0
const getConnection = vi.fn(({ profile }: { profile: string }) => {
callCount += 1
if (callCount === 1) {
return new Promise(() => undefined)
}
return Promise.resolve({
authMode: 'token',
baseUrl: `https://${profile}.invalid`,
mode: 'local',
profile,
token: 'fake-test-token',
wsUrl: `wss://${profile}.invalid/ws`
})
})
installDesktop({ getConnection })
// The #92434 pin above leaves gatewayMocks.connect latched on a
// never-resolving mockImplementation (vi.clearAllMocks() clears calls, not
// implementations). Restore the default resolving dial for this test.
gatewayMocks.connect.mockImplementation(async () => undefined)
const pending = ensureGatewayForProfile('work')
await vi.advanceTimersByTimeAsync(20_000)
await pending
// The probe's own bound (not just openSecondary's) is what let this
// resolve after a single 20s timeout instead of two stacked ones.
expect(callCount).toBe(2)
expect(activeGateway()).toBe(gatewayMocks.instances[0])
})
})
File diff suppressed because it is too large. Load diff
+162
View File
@@ -0,0 +1,162 @@
import { JsonRpcGatewayError } from '@hermes/shared'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { $gateway } from './gateway'
import { $goalsBySession, applyGoalStatusText, clearSessionGoal, refreshSessionGoal } from './goals'
import { resetBackgroundPollingGuard } from './runtime-gone'
describe('goal store', () => {
afterEach(() => {
vi.useRealTimers()
$goalsBySession.set({})
$gateway.set(null as never)
resetBackgroundPollingGuard()
})
it('stores active goals from /goal output', () => {
applyGoalStatusText('s1', '⊙ Goal set (20-turn budget): ship the feature')
expect($goalsBySession.get().s1).toMatchObject({
status: 'active',
title: 'ship the feature'
})
})
it('keeps the current title for continuation and pause messages', () => {
applyGoalStatusText('s1', '⊙ Goal set (20-turn budget): ship the feature')
applyGoalStatusText('s1', '↻ Continuing toward goal (1/20): next step is tests')
expect($goalsBySession.get().s1).toMatchObject({
detail: 'Continuing toward goal (1/20): next step is tests',
status: 'active',
title: 'ship the feature'
})
applyGoalStatusText('s1', '⏸ Goal paused — 20/20 turns used. Use /goal resume to keep going.')
expect($goalsBySession.get().s1).toMatchObject({
status: 'paused',
title: 'ship the feature'
})
})
it('lingers done goals before clearing them', () => {
vi.useFakeTimers()
applyGoalStatusText('s1', '⊙ Goal set (20-turn budget): ship the feature')
applyGoalStatusText('s1', '✓ Goal achieved: tests pass')
expect($goalsBySession.get().s1).toMatchObject({ status: 'done' })
vi.advanceTimersByTime(7_999)
expect($goalsBySession.get().s1).toBeTruthy()
vi.advanceTimersByTime(1)
expect($goalsBySession.get().s1).toBeUndefined()
})
it('clears on no-goal output', () => {
applyGoalStatusText('s1', '⊙ Goal set (20-turn budget): ship another feature')
applyGoalStatusText('s1', 'No active goal. Set one with /goal <text>.')
expect($goalsBySession.get().s1).toBeUndefined()
})
it('clears immediately on /goal clear output', () => {
applyGoalStatusText('s1', '⊙ Goal set (20-turn budget): ship another feature')
applyGoalStatusText('s1', '⏸ Goal paused — 20/20 turns used. Use /goal resume to keep going.')
applyGoalStatusText('s1', '✓ Goal cleared.')
expect($goalsBySession.get().s1).toBeUndefined()
})
it('cancels pending done clears when replacing a goal', () => {
vi.useFakeTimers()
applyGoalStatusText('s1', '⊙ Goal set: first')
applyGoalStatusText('s1', '✓ Goal achieved: first done')
applyGoalStatusText('s1', '⊙ Goal set: second')
vi.advanceTimersByTime(8_000)
expect($goalsBySession.get().s1).toMatchObject({ status: 'active', title: 'second' })
clearSessionGoal('s1')
})
it('does not resurrect a done goal on hydration', () => {
// `/goal status` reports "✓ Goal done" forever after completion (the DB
// keeps terminal state). Hydrating that on session open must not bring
// the completed chip back — Bot Mode's single endless session would show
// it for the rest of time.
applyGoalStatusText('s1', '✓ Goal done (12/20 turns): ship the feature', { hydrate: true })
expect($goalsBySession.get().s1).toBeUndefined()
})
it('hydration drops a lingering done chip already on screen', () => {
vi.useFakeTimers()
applyGoalStatusText('s1', '✓ Goal achieved: tests pass')
applyGoalStatusText('s1', '✓ Goal done (12/20 turns): tests pass', { hydrate: true })
expect($goalsBySession.get().s1).toBeUndefined()
})
it('hydration still surfaces non-terminal goals', () => {
applyGoalStatusText('s1', '⊙ Goal (active, 3/20 turns): ship the feature', { hydrate: true })
expect($goalsBySession.get().s1).toMatchObject({ status: 'active', title: 'ship the feature' })
applyGoalStatusText('s2', '⏸ Goal (paused, 20/20 turns): other work', { hydrate: true })
expect($goalsBySession.get().s2).toMatchObject({ status: 'paused', title: 'other work' })
})
it('does not retry goal hydration for a runtime rejected as session-not-found', async () => {
const request = vi.fn(async () => {
throw new JsonRpcGatewayError('session not found', { code: 4001 })
})
$gateway.set({ request } as never)
await refreshSessionGoal('dead-runtime')
await refreshSessionGoal('dead-runtime')
expect(request).toHaveBeenCalledTimes(1)
})
})
describe('refreshSessionGoal dead-session guard', () => {
afterEach(() => {
$gateway.set(null as never)
resetBackgroundPollingGuard()
})
it('stops re-asking a runtime the gateway no longer holds', async () => {
const request = vi.fn(async () => {
throw new Error('session not found')
})
$gateway.set({ request } as never)
await refreshSessionGoal('dead-1')
await refreshSessionGoal('dead-1')
await refreshSessionGoal('dead-1')
expect(request).toHaveBeenCalledTimes(1)
})
it('does not latch on a transient failure', async () => {
const request = vi.fn(async () => {
throw new Error('request timed out after 30s: slash.exec')
})
$gateway.set({ request } as never)
await refreshSessionGoal('s1')
await refreshSessionGoal('s1')
expect(request).toHaveBeenCalledTimes(2)
})
})
+189
View File
@@ -0,0 +1,189 @@
import { atom } from 'nanostores'
import { keyedTimeouts } from '@/lib/keyed-timeouts'
import { $gateway } from './gateway'
import { isSessionGone, isSessionGoneForBackgroundPolling, markSessionGone } from './runtime-gone'
import { ambientRequestFor } from './session-gone-latch'
import { requestForOwnedSession } from './session-states'
export type GoalStatus = 'active' | 'done' | 'paused' | 'waiting'
export interface SessionGoal {
detail?: string
status: GoalStatus
title: string
updatedAt: number
}
export const $goalsBySession = atom<Record<string, SessionGoal>>({})
const DONE_LINGER_MS = 8_000
const clearTimers = keyedTimeouts()
export function setSessionGoal(sid: string, goal: SessionGoal) {
if (!sid) {
return
}
clearTimers.cancel(sid)
$goalsBySession.set({ ...$goalsBySession.get(), [sid]: goal })
if (goal.status === 'done') {
clearTimers.schedule(sid, DONE_LINGER_MS, () => clearSessionGoal(sid))
}
}
export function clearSessionGoal(sid: string) {
clearTimers.cancel(sid)
const map = $goalsBySession.get()
if (!(sid in map)) {
return
}
const { [sid]: _drop, ...rest } = map
$goalsBySession.set(rest)
}
const clean = (value: string): string => value.replace(/\r/g, '').trim()
const firstLine = (value: string): string => clean(value).split('\n')[0]?.trim() ?? ''
function goalTitleFromLine(line: string, pattern: RegExp): string {
return (line.match(pattern)?.[1] ?? '').trim()
}
function nextGoalFromText(text: string, previous?: SessionGoal): SessionGoal | null | undefined {
const body = clean(text)
const line = firstLine(body)
if (!line) {
return undefined
}
if (
/^No active goal\b/i.test(line) ||
/^No goal (?:set|to resume)\b/i.test(line) ||
/^✓ Goal cleared\b/i.test(line)
) {
return null
}
const now = Date.now()
const fromSet = goalTitleFromLine(line, /^⊙ Goal set(?:\s*\([^)]*\))?:\s*(.+)$/)
const fromActive = goalTitleFromLine(line, /^⊙ Goal\s*\([^)]*active[^)]*\):\s*(.+)$/)
const fromResume = goalTitleFromLine(line, /^▶ Goal resumed:\s*(.+)$/)
if (fromSet || fromActive || fromResume) {
return { status: 'active', title: fromSet || fromActive || fromResume, updatedAt: now }
}
const fromWaiting = goalTitleFromLine(line, /^⏳ Goal\s*\([^)]*(?:parked|active)[^)]*\):\s*(.+)$/)
if (fromWaiting) {
return { status: 'waiting', title: fromWaiting, updatedAt: now }
}
const fromPaused = goalTitleFromLine(line, /^⏸ Goal(?:\s*\([^)]*\)| paused)?:\s*(.+)$/)
if (fromPaused) {
return { status: 'paused', title: fromPaused, updatedAt: now }
}
const fromDone = goalTitleFromLine(line, /^✓ Goal done\s*\([^)]*\):\s*(.+)$/)
if (fromDone) {
return { status: 'done', title: fromDone, updatedAt: now }
}
if (/^↻ Continuing toward goal\b/i.test(line)) {
return {
detail: line.replace(/^↻\s*/, ''),
status: 'active',
title: previous?.title || 'Standing goal',
updatedAt: now
}
}
if (/^⏳ Goal parked\b/i.test(line)) {
return {
detail: line.replace(/^⏳\s*/, ''),
status: 'waiting',
title: previous?.title || 'Standing goal',
updatedAt: now
}
}
if (/^⏸ Goal paused\b/i.test(line)) {
return {
detail: line.replace(/^⏸\s*/, ''),
status: 'paused',
title: previous?.title || 'Standing goal',
updatedAt: now
}
}
if (/^✓ Goal achieved\b/i.test(line)) {
return {
detail: line.replace(/^✓\s*/, ''),
status: 'done',
title: previous?.title || 'Standing goal',
updatedAt: now
}
}
return undefined
}
export function applyGoalStatusText(sid: string, text: string, opts?: { hydrate?: boolean }) {
if (!sid) {
return
}
const next = nextGoalFromText(text, $goalsBySession.get()[sid])
if (next === null) {
clearSessionGoal(sid)
} else if (next) {
// A done goal is terminal state in the backend DB — it stays "done"
// forever (only /goal clear or a new goal replaces it). The 8s linger is
// for the LIVE completion moment; re-hydrating "✓ Goal done" on every
// mount would resurrect the chip indefinitely. Bot Mode is the worst
// case: one endless session means the completed layover would never go
// away. On hydration, a terminal goal is the same as no goal.
if (opts?.hydrate && next.status === 'done') {
clearSessionGoal(sid)
return
}
setSessionGoal(sid, next)
}
}
export async function refreshSessionGoal(sid: string): Promise<void> {
const gateway = $gateway.get()
if (!sid || !gateway || isSessionGone(sid)) {
return
}
try {
const result = await requestForOwnedSession<{ output?: string }>(sid, ambientRequestFor(gateway), 'slash.exec', {
command: 'goal status',
session_id: sid
})
applyGoalStatusText(sid, result?.output ?? '', { hydrate: true })
} catch (error) {
if (isSessionGoneForBackgroundPolling(error)) {
markSessionGone(sid)
return
}
// Best-effort: older gateways or a transport blip simply won't hydrate it.
}
}
+17
View File
@@ -0,0 +1,17 @@
import { atom } from 'nanostores'
import { persistBoolean, storedBoolean } from '@/lib/storage'
const HAPTICS_MUTED_STORAGE_KEY = 'hermes.desktop.hapticsMuted'
export const $hapticsMuted = atom(storedBoolean(HAPTICS_MUTED_STORAGE_KEY, false))
$hapticsMuted.subscribe(muted => persistBoolean(HAPTICS_MUTED_STORAGE_KEY, muted))
export function setHapticsMuted(muted: boolean) {
$hapticsMuted.set(muted)
}
export function toggleHapticsMuted() {
$hapticsMuted.set(!$hapticsMuted.get())
}
@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// A failed hub action (non-zero subprocess exit) must REJECT so callers toast
// it — the Aug 2026 report shape was a scan-gated/failed `hermes skills
// install` exiting non-zero with no toast, no list change, and no error
// anywhere in the UI: the user read it as "install did nothing".
const getActionStatus = vi.fn()
const installSkillFromHub = vi.fn()
vi.mock('@/hermes', () => ({
getActionStatus: (...args: unknown[]) => getActionStatus(...args),
installSkillFromHub: (...args: unknown[]) => installSkillFromHub(...args),
uninstallSkillFromHub: vi.fn(),
updateSkillsFromHub: vi.fn()
}))
vi.mock('@/lib/query-client', () => ({
queryClient: { invalidateQueries: vi.fn() }
}))
vi.mock('@/lib/slash-completion-cache', () => ({
invalidateSlashCompletions: vi.fn()
}))
vi.mock('@/store/activity', () => ({
upsertDesktopActionTask: vi.fn()
}))
// hub-actions subscribes to the active gateway profile at module scope (its
// per-profile state wipe); a minimal stub keeps this test off the real store.
vi.mock('@/store/profile', () => ({
$activeGatewayProfile: { subscribe: vi.fn() },
normalizeProfileKey: (value: unknown) => String(value ?? '') || 'default'
}))
import { $hubActions, $hubInstalledOverride, installHubSkill } from './hub-actions'
describe('installHubSkill failure surfacing', () => {
beforeEach(() => {
getActionStatus.mockReset()
installSkillFromHub.mockReset()
$hubActions.set({})
$hubInstalledOverride.set({})
})
it('rejects with the action log tail when the subprocess exits non-zero', async () => {
installSkillFromHub.mockResolvedValue({ name: 'skills-install-ascii-art-dd7bccf1' })
getActionStatus.mockResolvedValue({
name: 'skills-install-ascii-art-dd7bccf1',
running: false,
exit_code: 1,
pid: 4242,
lines: ['Resolving ascii-art...', 'Error: security scan blocked install']
})
await expect(installHubSkill('ascii-art')).rejects.toThrow('security scan blocked install')
// A failed install must not paint the row as installed.
expect($hubInstalledOverride.get()['ascii-art']).toBeUndefined()
// ...but the row's running flag still settles to false for the button.
expect($hubActions.get()['ascii-art']?.running).toBe(false)
})
it('rejects with the exit code when the log carries no detail', async () => {
installSkillFromHub.mockResolvedValue({ name: 'skills-install-x-00000000' })
getActionStatus.mockResolvedValue({
name: 'skills-install-x-00000000',
running: false,
exit_code: 7,
pid: 4242,
lines: []
})
await expect(installHubSkill('x')).rejects.toThrow('exited with code 7')
})
it('resolves and flips the row on a clean exit', async () => {
installSkillFromHub.mockResolvedValue({ name: 'skills-install-ok-11111111' })
getActionStatus.mockResolvedValue({
name: 'skills-install-ok-11111111',
running: false,
exit_code: 0,
pid: 4242,
lines: ['Installed ok']
})
await expect(installHubSkill('ok')).resolves.toBeUndefined()
expect($hubInstalledOverride.get()['ok']).toBe(true)
})
})
+176
View File
@@ -0,0 +1,176 @@
import { atom, map } from 'nanostores'
import {
getActionStatus,
installSkillFromHub,
type ProfileScope,
uninstallSkillFromHub,
updateSkillsFromHub
} from '@/hermes'
import { queryClient } from '@/lib/query-client'
import { invalidateSlashCompletions } from '@/lib/slash-completion-cache'
import { upsertDesktopActionTask } from '@/store/activity'
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
const POLL_MS = 1200
// Shared with hub.tsx's sources useQuery so a finished action refreshes the
// installed map.
export const HUB_SOURCES_KEY = ['skill-hub-sources'] as const
// The Capabilities Skills-list query key (see app/skills/index.tsx) — kept in
// sync here so a hub (un)install updates the Skills tab, not just the hub.
const SKILLS_LIST_KEY = ['skills-list'] as const
// The built-in optional-skills catalog rows in the Skills tab: an install
// flips one of them to an installed (toggle) row, so the catalog's
// installed-flags must refetch alongside the skills list.
export const OFFICIAL_SKILLS_KEY = ['official-skills'] as const
// Non-identifier key for the fleet-wide "Update installed" action.
export const UPDATE_ALL_KEY = '__update_all__'
export type HubActionKind = 'install' | 'uninstall' | 'update'
export interface HubAction {
kind: HubActionKind
running: boolean
lines: string[]
}
// Per-item action status, keyed by skill identifier (or UPDATE_ALL_KEY). Each
// row drives its own button off ITS entry — one install never touches another.
export const $hubActions = map<Record<string, HubAction | undefined>>({})
// Optimistic installed overrides so a row flips to its resolved state the instant
// its own action finishes, instead of waiting on (and racing) the sources
// refetch. install/update → true, uninstall → false; sources reconciles after.
export const $hubInstalledOverride = map<Record<string, boolean | undefined>>({})
// The key whose log the bottom pane currently tails (the latest-started action).
export const $hubActiveLog = atom<null | string>(null)
// Hub action state is per-profile: a profile switch must drop every in-flight
// entry, optimistic override, and active log so profile A's install/uninstall
// state can never render (or be polled) in profile B. Cleared at the source so
// it holds regardless of whether the Hub view is mounted. The epoch bumps on
// every switch; a runHubAction() started before the switch captures it and bails
// before any store write once it no longer matches (so an A action finishing
// after the clear can't repopulate B).
let _hubProfile: null | string = null
let _hubEpoch = 0
$activeGatewayProfile.subscribe(value => {
const key = normalizeProfileKey(value)
if (_hubProfile !== null && _hubProfile !== key) {
_hubEpoch += 1
$hubActions.set({})
$hubInstalledOverride.set({})
$hubActiveLog.set(null)
}
_hubProfile = key
})
// One self-contained task: spawn → tail its own action log into the store →
// mark resolved. Concurrency-safe: state is per-key, so parallel installs never
// stomp each other, and the sources query is invalidated once at the end.
// `profile` is the Capabilities profile-scope override — the action (and its
// status polling) runs against THAT profile's backend; undefined keeps the
// app-wide active profile (unchanged behavior).
async function runHubAction(
key: string,
kind: HubActionKind,
spawn: () => Promise<{ name: string }>,
profile?: ProfileScope
): Promise<void> {
const epoch = _hubEpoch
const switched = () => _hubEpoch !== epoch
$hubActions.setKey(key, { kind, running: true, lines: [] })
$hubActiveLog.set(key)
try {
const started = await spawn()
let exitCode: number | null = null
for (;;) {
const status = await getActionStatus(started.name, 200, profile)
// Profile switched mid-flight: the store was cleared for the new profile,
// so drop this A-profile result instead of writing it back into B.
if (switched()) {
return
}
upsertDesktopActionTask(status)
$hubActions.setKey(key, { kind, running: status.running, lines: status.lines })
if (!status.running) {
exitCode = status.exit_code
break
}
await new Promise(resolve => setTimeout(resolve, POLL_MS))
}
// Only flip the row on a clean exit — a failed install/uninstall must not
// render as installed/removed.
if (key !== UPDATE_ALL_KEY && exitCode === 0) {
$hubInstalledOverride.setKey(key, kind !== 'uninstall')
}
// Refresh the hub's installed map AND the Capabilities Skills list — a hub
// (un)install adds/removes a skill, so its count/rows must update too.
void queryClient.invalidateQueries({ queryKey: HUB_SOURCES_KEY })
void queryClient.invalidateQueries({ queryKey: SKILLS_LIST_KEY })
void queryClient.invalidateQueries({ queryKey: OFFICIAL_SKILLS_KEY })
// …and the composer's `/` list, which caches the command catalog for an
// hour and would otherwise keep offering the skill we just removed.
invalidateSlashCompletions()
// A non-zero exit is a real failure — throw so the caller's catch toasts
// it. Before this, a failed subprocess (scan gate, network, bad
// identifier) just stopped silently: no flip, no toast, and the user read
// the unchanged skills list as "install did nothing" (Aug 2026 report).
// The last log lines carry the subprocess's actual error.
if (exitCode !== null && exitCode !== 0) {
const detail = ($hubActions.get()[key]?.lines ?? []).slice(-3).join('\n').trim()
throw new Error(detail || `Action exited with code ${exitCode}`)
}
} catch (err) {
// A profile switch points the next poll at the new backend, which 404s the
// old action name — that's an abandonment, not a failure, so swallow it
// instead of letting the caller toast a phantom error. Real (same-profile)
// failures still propagate.
if (switched()) {
return
}
throw err
} finally {
// Skip the running=false write after a switch — it would re-add the key the
// profile-switch clear just dropped.
const current = $hubActions.get()[key]
if (current && !switched()) {
$hubActions.setKey(key, { ...current, running: false })
}
}
}
export function installHubSkill(identifier: string, profile?: ProfileScope): Promise<void> {
return runHubAction(identifier, 'install', () => installSkillFromHub(identifier, profile), profile)
}
export function uninstallHubSkill(identifier: string, name: string, profile?: ProfileScope): Promise<void> {
return runHubAction(identifier, 'uninstall', () => uninstallSkillFromHub(name, profile), profile)
}
export function updateHubSkills(profile?: ProfileScope): Promise<void> {
return runHubAction(UPDATE_ALL_KEY, 'update', () => updateSkillsFromHub(profile), profile)
}
export function closeHubLog(): void {
$hubActiveLog.set(null)
}
+91
View File
@@ -0,0 +1,91 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $activeGatewayProfile } from '@/store/profile'
import { $sessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
import { $hudActive, $hudSession, openHud, resetHudLayout } from './hud'
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
const initialHermesDesktop = desktopWindow.hermesDesktop
const open = vi.fn().mockResolvedValue({ ok: true })
const resetLayout = vi.fn().mockResolvedValue({ ok: true })
function installBridge() {
desktopWindow.hermesDesktop = {
hud: { open, resetLayout }
} as unknown as Window['hermesDesktop']
}
function session(overrides: Partial<SessionInfo>): SessionInfo {
return { id: 's', title: '', created_at: '', updated_at: '', ...overrides } as SessionInfo
}
beforeEach(() => {
open.mockClear()
resetLayout.mockClear()
installBridge()
$hudActive.set(false)
$hudSession.set(null)
$sessions.set([])
$activeGatewayProfile.set('default')
})
afterEach(() => {
if (initialHermesDesktop) {
desktopWindow.hermesDesktop = initialHermesDesktop
} else {
delete desktopWindow.hermesDesktop
}
})
describe('resetHudLayout', () => {
it('uses the native HUD recovery capability', () => {
resetHudLayout()
expect(resetLayout).toHaveBeenCalledOnce()
})
})
describe('openHud profile targeting (#82285)', () => {
it('carries the session-stamped profile when the target belongs to another profile', () => {
$sessions.set([session({ id: 'abc', profile: 'work' })])
$activeGatewayProfile.set('default')
openHud('abc')
expect(open).toHaveBeenCalledWith({ sessionId: 'abc', profile: 'work' })
})
it('falls back to the active gateway profile for an unstamped session', () => {
$sessions.set([session({ id: 'abc', profile: '' })])
$activeGatewayProfile.set('work')
openHud('abc')
expect(open).toHaveBeenCalledWith({ sessionId: 'abc', profile: 'work' })
})
it('uses the active gateway profile when opening without a session', () => {
$activeGatewayProfile.set('research')
openHud()
expect(open).toHaveBeenCalledWith({ sessionId: null, profile: 'research' })
})
it('normalizes to default for single-profile users', () => {
openHud()
expect(open).toHaveBeenCalledWith({ sessionId: null, profile: 'default' })
})
it('uses the active profile when the target session is not in the cache', () => {
$activeGatewayProfile.set('work')
openHud('unknown-session')
expect(open).toHaveBeenCalledWith({ sessionId: 'unknown-session', profile: 'work' })
})
})
+117
View File
@@ -0,0 +1,117 @@
/**
* HUD mode — the chrome-free floating chat.
*
* A transparent, frameless, always-on-top window showing nothing but the REAL
* composer with the reply scrolling above it, so Hermes can be driven while
* the user works in another app (Figma, a browser).
*
* It is NOT a puppet window. Unlike the pet overlay / quick entry, the HUD is
* a full app renderer with its own gateway — the same thing `openWindow()`
* spawns, just reshaped. That is the whole design: the HUD renders `ChatView`
* in its `hud` variant, so the composer IS the app's composer (attachments,
* slash commands, queue, voice, model pill) rather than a lookalike that
* drifts. This module owns only the mode flag and the window lifecycle.
*/
import { atom } from 'nanostores'
import { requestComposerDraftSync } from '@/store/composer'
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
import { $sessions, rememberedSessionProfile } from '@/store/session'
import { isHudWindow } from '@/store/windows'
/** Whether a HUD window is currently up. In the HUD's own renderer this is
* always true (it IS the HUD); in the main window it tracks the child so the
* titlebar toggle reads correctly.
*
* Deliberately NOT persisted. The HUD is a live window main owns, so it can
* never outlive the app — a remembered `true` from the last run just makes the
* first toggle a no-op ("the button does nothing after a restart"). Main
* broadcasts the truth on every change, which is the only authority there is. */
export const $hudActive = atom(isHudWindow())
/** True only in the HUD window itself — the renderer flag that swaps the app
* shell for the slim floating layout. Constant for the window's life, so it
* never invalidates a render path mid-session. */
export const $hudMode = atom(isHudWindow())
/** Which conversation the HUD is showing, as far as this window knows. Lets the
* toggle tell "switch the HUD to this tab" apart from "dismiss the HUD". */
export const $hudSession = atom<null | string>(null)
/** True when the shell exposes HUD mode (desktop only). */
export const canUseHud = (): boolean =>
typeof window !== 'undefined' && typeof window.hermesDesktop?.hud?.open === 'function'
export function openHud(sessionId?: null | string): void {
const api = window.hermesDesktop?.hud
if (!api) {
return
}
// Push whatever is half-typed here into the shared draft stash BEFORE the
// HUD window exists, so its composer boots with the text rather than racing
// a cross-window storage event that lands after it has already painted.
requestComposerDraftSync('flush')
// Which backend the HUD must boot against. The HUD is a full renderer that
// adopts the PRIMARY backend's profile by default, so handing it a session
// from a non-primary profile without saying so resolves the id against the
// wrong backend — the lookup misses and the HUD falls back to the default
// profile's last session (#82285). Same ladder the remembered-navigation key
// uses: the session's stamped owner wins, and a fresh/unstamped/uncached
// target inherits the profile the user is looking at.
const profile = normalizeProfileKey(
rememberedSessionProfile($sessions.get(), sessionId ?? null, $activeGatewayProfile.get())
)
$hudActive.set(true)
$hudSession.set(sessionId ?? null)
void api.open({ sessionId: sessionId ?? null, profile })
}
/** Leave HUD mode. Callable from either window — main closes the child, the
* HUD closes itself; both restore the app window. */
export function closeHud(): void {
const api = window.hermesDesktop?.hud
if (!api) {
return
}
$hudActive.set(false)
$hudSession.set(null)
void api.close()
}
export const toggleHud = (sessionId?: null | string) => ($hudActive.get() ? closeHud() : openHud(sessionId))
/** Restore the HUD's persisted geometry to its display-aware default. */
export function resetHudLayout(): void {
void window.hermesDesktop?.hud?.resetLayout?.()
}
/** Tell main which session this HUD is on. Main holds it (the HUD's renderer
* doesn't outlive the window) and hands it back in the close broadcast so the
* app window knows what to re-home onto. */
export const reportHudSession = (sessionId: null | string): void => window.hermesDesktop?.hud?.setSession?.(sessionId)
/**
* Track the HUD window's real state so the titlebar toggle can't go stale when
* the HUD is closed from its own side (⌘W, its exit button), and hand the
* app window the session the HUD ended on. Returns a disposer; no-ops outside
* Electron.
*/
export function watchHudState(onClosed?: (sessionId: null | string) => void): () => void {
const off = window.hermesDesktop?.hud?.onChanged?.(({ open, sessionId }) => {
$hudActive.set(open)
$hudSession.set(open ? sessionId : null)
if (!open) {
onClosed?.(sessionId)
}
})
return off ?? (() => {})
}
+14
View File
@@ -0,0 +1,14 @@
import { atom } from 'nanostores'
import { persistBoolean, storedBoolean } from '@/lib/storage'
const KEY = 'hermes.desktop.intro-splash.v1'
/** Whether the wordmark + tagline splash renders on an empty chat. */
export const $introSplash = atom(storedBoolean(KEY, true))
$introSplash.subscribe(on => persistBoolean(KEY, on))
export function setIntroSplash(on: boolean) {
$introSplash.set(on)
}
+33
View File
@@ -0,0 +1,33 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { storedBoolean } from '@/lib/storage'
import { $keepAwake, setKeepAwake } from './keep-awake'
const KEY = 'hermes.desktop.keepAwake.v1'
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
const initialHermesDesktop = desktopWindow.hermesDesktop
const setKeepAwakeBridge = vi.fn()
beforeEach(() => {
desktopWindow.hermesDesktop = { setKeepAwake: setKeepAwakeBridge } as unknown as Window['hermesDesktop']
setKeepAwake(false)
setKeepAwakeBridge.mockClear()
})
afterEach(() => {
desktopWindow.hermesDesktop = initialHermesDesktop
})
describe('keep-awake store', () => {
it('persists the pref and mirrors it to the main process', () => {
setKeepAwake(true)
expect($keepAwake.get()).toBe(true)
expect(storedBoolean(KEY, false)).toBe(true)
expect(setKeepAwakeBridge).toHaveBeenLastCalledWith(true)
setKeepAwake(false)
expect(storedBoolean(KEY, true)).toBe(false)
expect(setKeepAwakeBridge).toHaveBeenLastCalledWith(false)
})
})
+29
View File
@@ -0,0 +1,29 @@
/**
* Keep-awake — stop the machine sleeping during long, unattended runs.
*
* A device-local preference (each computer keeps its own), off by default. This
* atom backs the Settings → Advanced toggle and mirrors changes to the main
* process, which owns the real power-save blocker AND its own persisted copy —
* so a cold launch restores the blocker without the renderer visiting Settings
* (see electron/main.ts + electron/power-save.ts). Linux/web without the bridge
* just no-op.
*/
import { atom } from 'nanostores'
import { persistBoolean, storedBoolean } from '@/lib/storage'
const KEY = 'hermes.desktop.keepAwake.v1'
export const $keepAwake = atom<boolean>(typeof window === 'undefined' ? false : storedBoolean(KEY, false))
export function setKeepAwake(on: boolean): void {
$keepAwake.set(on)
}
if (typeof window !== 'undefined') {
$keepAwake.subscribe(on => {
persistBoolean(KEY, on)
window.hermesDesktop?.setKeepAwake?.(on)
})
}
+143
View File
@@ -0,0 +1,143 @@
import { atom, computed } from 'nanostores'
import { $registryVersion } from '@/contrib/registry'
import { allKeybindActions, defaultBindings, keybindAction, type KeybindBindings } from '@/lib/keybinds/actions'
import { canonicalizeCombo } from '@/lib/keybinds/combo'
import { arraysEqual, persistString, storedString } from '@/lib/storage'
const STORAGE_KEY = 'hermes.desktop.keybinds'
// The user's raw stored overrides. Kept verbatim so an action CONTRIBUTED
// after module init (plugins register late) still resolves its saved rebind —
// `bindingsFor` consults this before falling back to shipped defaults.
function readStoredOverrides(): Record<string, string[]> {
const raw = storedString(STORAGE_KEY)
if (!raw) {
return {}
}
try {
const parsed = JSON.parse(raw) as Record<string, unknown>
const out: Record<string, string[]> = {}
for (const [id, value] of Object.entries(parsed)) {
if (Array.isArray(value)) {
out[id] = value.filter((combo): combo is string => typeof combo === 'string')
}
}
return out
} catch {
// Corrupt storage falls back to defaults.
return {}
}
}
const storedOverrides = readStoredOverrides()
// Defaults overlaid with the user's stored overrides. Unknown / stale action
// ids are dropped; actions added in a later release pick up their shipped
// default; late-registered contributed actions resolve via `bindingsFor`.
function loadBindings(): KeybindBindings {
const base = defaultBindings()
for (const id of Object.keys(base)) {
if (storedOverrides[id]) {
base[id] = storedOverrides[id]
}
}
return base
}
// Persist only the actions whose combos differ from their shipped default, so
// changing a default never gets shadowed by a stored snapshot.
function persistBindings(bindings: KeybindBindings): void {
const defaults = defaultBindings()
const diff: KeybindBindings = {}
for (const action of allKeybindActions()) {
const current = bindings[action.id] ?? []
if (!arraysEqual(current, defaults[action.id] ?? [])) {
diff[action.id] = current
}
}
persistString(STORAGE_KEY, JSON.stringify(diff))
}
export const $bindings = atom<KeybindBindings>(loadBindings())
$bindings.subscribe(persistBindings)
/** Live combos for an action: explicit binding → stored override → default. */
export function bindingsFor(id: string, bindings: KeybindBindings = $bindings.get()): string[] {
return bindings[id] ?? storedOverrides[id] ?? [...(keybindAction(id)?.defaults ?? [])]
}
// Reverse lookup combo → actionId for dispatch. First action wins on conflict;
// the panel/edit overlay surface conflicts so users can resolve them. Keys go
// through `canonicalizeCombo` so a `ctrl+…` binding resolves everywhere.
// Recomputes on registry mutations so contributed actions dispatch live.
export const $comboIndex = computed([$bindings, $registryVersion], bindings => {
const index = new Map<string, string>()
for (const action of allKeybindActions()) {
for (const combo of bindingsFor(action.id, bindings)) {
const key = canonicalizeCombo(combo)
if (!index.has(key)) {
index.set(key, action.id)
}
}
}
return index
})
export function setBinding(actionId: string, combos: string[]): void {
if (!keybindAction(actionId)) {
return
}
$bindings.set({ ...$bindings.get(), [actionId]: [...combos] })
}
export function resetBinding(actionId: string): void {
const action = keybindAction(actionId)
if (!action) {
return
}
$bindings.set({ ...$bindings.get(), [actionId]: [...action.defaults] })
}
export function resetAllBindings(): void {
$bindings.set(defaultBindings())
}
// Other actions that already use `combo` (excluding `actionId` itself).
export function conflictsFor(actionId: string, combo: string): string[] {
const bindings = $bindings.get()
return allKeybindActions()
.map(action => action.id)
.filter(id => id !== actionId && bindingsFor(id, bindings).includes(combo))
}
// ── Capture ─────────────────────────────────────────────────────────────────
// `$capture` is the action currently listening for its next keypress (a panel
// row armed for rebinding). Session-only — never persisted.
export const $capture = atom<string | null>(null)
export function beginCapture(actionId: string): void {
$capture.set(actionId)
}
export function endCapture(): void {
$capture.set(null)
}
@@ -0,0 +1,166 @@
import { beforeEach, describe, expect, it } from 'vitest'
import type { HermesConnection } from '@/global'
import { readKey } from '@/lib/storage'
import { $pinnedSessionIds, $sidebarSessionOrderIds, $sidebarSessionOrderManual, pinSession } from './layout'
import { getRememberedSessionId, setConnection, setRememberedSessionId } from './session'
// Two Desktop windows share one renderer origin (and therefore one
// localStorage area) while each can be connected to a DIFFERENT gateway.
// Session/pin lists reconcile against each gateway's own state.db, so any of
// these lists persisted under a single global key bleeds between windows on
// different backends (#77318). These tests pin the scope contract: the
// gateway-bound sidebar lists must be keyed per connection.
const localConn = {
baseUrl: 'http://127.0.0.1:8000',
mode: 'local',
profile: 'default'
} as unknown as HermesConnection
const remoteA = {
baseUrl: 'https://vps-a.example:8443',
mode: 'remote',
profile: 'default'
} as unknown as HermesConnection
const remoteB = {
baseUrl: 'https://vps-b.example:8443',
mode: 'remote',
profile: 'default'
} as unknown as HermesConnection
beforeEach(() => {
window.localStorage.clear()
// Return to the local scope and empty every list under it.
setConnection(localConn)
$pinnedSessionIds.set([])
$sidebarSessionOrderIds.set([])
$sidebarSessionOrderManual.set(false)
})
describe('connection-scoped sidebar lists (#77318)', () => {
it('does not leak the local pin set into a remote connection', () => {
pinSession('local-1')
pinSession('local-2')
setConnection(remoteA)
// The remote gateway has its own state.db; its pin list starts from its
// own scope (empty here), never from the local window's pins.
expect($pinnedSessionIds.get()).toEqual([])
})
it('keeps pin sets isolated between two different remote gateways', () => {
setConnection(remoteA)
pinSession('a-1')
setConnection(remoteB)
expect($pinnedSessionIds.get()).toEqual([])
pinSession('b-1')
// Each scope round-trips its own set.
setConnection(remoteA)
expect($pinnedSessionIds.get()).toEqual(['a-1'])
setConnection(remoteB)
expect($pinnedSessionIds.get()).toEqual(['b-1'])
})
it('restores the local pin set when returning to the local connection', () => {
pinSession('local-1')
setConnection(remoteA)
pinSession('a-1')
setConnection(localConn)
expect($pinnedSessionIds.get()).toEqual(['local-1'])
})
it('writes remote pins to a connection-scoped key, leaving the legacy key intact', () => {
pinSession('local-1')
setConnection(remoteA)
pinSession('a-1')
// The bare key still belongs to the local connection.
expect(readKey('hermes.desktop.pinnedSessions')).toBe(JSON.stringify(['local-1']))
// The remote pin landed under its own gateway scope, not the shared key
// and not a per-profile fragment.
const scoped = readKey(`hermes.desktop.pinnedSessions.remote.${encodeURIComponent('https://vps-a.example:8443')}`)
expect(scoped).toBe(JSON.stringify(['a-1']))
})
it('never adopts legacy globally-keyed pins into a remote scope', () => {
// Pre-fix installs persisted every window's pins under the one global
// key. Ownership of those rows is unknowable, so a remote scope must
// start empty (the gateway's own `pinned` rows re-adopt what belongs
// there) rather than inherit the mixed set wholesale.
pinSession('mixed-1')
pinSession('mixed-2')
setConnection(remoteA)
expect($pinnedSessionIds.get()).toEqual([])
})
it('a null connection (reconnect blip) keeps the current scope', () => {
setConnection(remoteA)
pinSession('a-1')
// Disconnects publish null; that is a connectivity state, not a switch —
// the remote window must not repaint the local scope's lists.
setConnection(null)
expect($pinnedSessionIds.get()).toEqual(['a-1'])
})
it('keeps pins across a profile switch on the same gateway, but not session order', () => {
setConnection(remoteA)
pinSession('a-1')
$sidebarSessionOrderIds.set(['s1'])
$sidebarSessionOrderManual.set(true)
setConnection({ ...remoteA, profile: 'k9' } as unknown as HermesConnection)
expect($pinnedSessionIds.get()).toEqual(['a-1'])
expect($sidebarSessionOrderIds.get()).toEqual([])
expect($sidebarSessionOrderManual.get()).toBe(false)
})
it('scopes the manual session order and its flag per connection', () => {
$sidebarSessionOrderIds.set(['s1', 's2'])
$sidebarSessionOrderManual.set(true)
setConnection(remoteA)
expect($sidebarSessionOrderIds.get()).toEqual([])
expect($sidebarSessionOrderManual.get()).toBe(false)
$sidebarSessionOrderIds.set(['r1'])
$sidebarSessionOrderManual.set(true)
setConnection(localConn)
expect($sidebarSessionOrderIds.get()).toEqual(['s1', 's2'])
expect($sidebarSessionOrderManual.get()).toBe(true)
setConnection(remoteA)
expect($sidebarSessionOrderIds.get()).toEqual(['r1'])
})
it('scopes remembered session navigation per connection, not just per profile', () => {
setRememberedSessionId('local-session', 'default')
setConnection(remoteA)
// Same profile name on another gateway is a different backend: restoring
// the local window's session id here would navigate to a session this
// gateway has never seen.
expect(getRememberedSessionId('default')).toBeNull()
setRememberedSessionId('remote-session', 'default')
expect(getRememberedSessionId('default')).toBe('remote-session')
setConnection(localConn)
expect(getRememberedSessionId('default')).toBe('local-session')
})
})
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { filterVisibleProjects } from './layout'
const auto = (id: string) => ({ id, isAuto: true as const })
const explicit = (id: string) => ({ id, isAuto: false as const })
const home = { id: '__home__' }
describe('filterVisibleProjects', () => {
it('drops dismissed autos and keeps explicit + home + undismissed', () => {
const tree = [explicit('p_shop'), auto('/www/otl-theme'), auto('/www/keep'), home]
expect(filterVisibleProjects(tree, ['/www/otl-theme', '/www/other']).map(p => p.id)).toEqual([
'p_shop',
'/www/keep',
'__home__'
])
})
it('ignores dismiss ids that hit an explicit project', () => {
// List is auto-only by construction; still don't let a stale id hide a real row.
const tree = [explicit('p_real'), auto('/www/gone')]
expect(filterVisibleProjects(tree, ['p_real', '/www/gone']).map(p => p.id)).toEqual(['p_real'])
})
it('passes the list through when nothing is dismissed', () => {
const tree = [auto('/www/a'), explicit('p_b')]
expect(filterVisibleProjects(tree, [])).toBe(tree)
})
})
@@ -0,0 +1,49 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { $pinnedSessionIds, setPinnedSessionOrder } from './layout'
beforeEach(() => {
$pinnedSessionIds.set([])
})
describe('setPinnedSessionOrder', () => {
it('applies a full reorder', () => {
$pinnedSessionIds.set(['a', 'b', 'c'])
setPinnedSessionOrder(['c', 'b', 'a'])
expect($pinnedSessionIds.get()).toEqual(['c', 'b', 'a'])
})
it('reorders a subset in place, leaving unresolved pins alone', () => {
// The dragged list only contains pins whose session actually loaded. A pin
// whose row hasn't arrived is absent from the drag but must keep its slot
// — requiring the two lists to match length discarded the whole reorder.
$pinnedSessionIds.set(['loaded1', 'unresolved', 'loaded2'])
setPinnedSessionOrder(['loaded2', 'loaded1'])
expect($pinnedSessionIds.get()).toEqual(['loaded2', 'unresolved', 'loaded1'])
})
it('ignores ids that are not pinned', () => {
$pinnedSessionIds.set(['a', 'b'])
setPinnedSessionOrder(['b', 'stranger', 'a'])
expect($pinnedSessionIds.get()).toEqual(['b', 'a'])
})
it('is a no-op when the drag names nothing pinned', () => {
const before = ['a', 'b']
$pinnedSessionIds.set(before)
setPinnedSessionOrder(['ghost'])
expect($pinnedSessionIds.get()).toBe(before)
})
it('keeps the same array reference when the order is unchanged', () => {
const before = ['a', 'b']
$pinnedSessionIds.set(before)
setPinnedSessionOrder(['a', 'b'])
expect($pinnedSessionIds.get()).toBe(before)
})
})
@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
$sidebarGrouping,
$sidebarOrdering,
$sidebarRowMeta,
$sidebarViewCustomized,
resetSidebarView,
setSidebarGrouping,
setSidebarOrdering,
toggleSidebarRowMeta,
toggleSidebarStatusFilter
} from './layout'
import { $showAllProfiles } from './profile'
beforeEach(() => {
$showAllProfiles.set(false)
resetSidebarView()
})
describe('the sidebar as it ships', () => {
it('groups by date, sorts by recency, and pins the timestamp and preview', () => {
expect($sidebarGrouping.get()).toBe('date')
expect($sidebarOrdering.get()).toBe('updated')
expect($sidebarRowMeta.get()).toEqual(['preview', 'updated'])
})
it('offers no reset until something actually moves off the defaults', () => {
expect($sidebarViewCustomized.get()).toBe(false)
toggleSidebarRowMeta('tokens')
expect($sidebarViewCustomized.get()).toBe(true)
})
it('is what reset puts back — every knob, not just the filters', () => {
setSidebarGrouping('project')
setSidebarOrdering('cost')
toggleSidebarRowMeta('updated')
toggleSidebarRowMeta('cost')
toggleSidebarStatusFilter('working')
resetSidebarView()
expect($sidebarGrouping.get()).toBe('date')
expect($sidebarOrdering.get()).toBe('updated')
expect($sidebarRowMeta.get()).toEqual(['preview', 'updated'])
expect($sidebarViewCustomized.get()).toBe(false)
})
it('ships by date in the all-profiles scope too, and resets back to it', () => {
$showAllProfiles.set(true)
setSidebarGrouping('profile')
resetSidebarView()
expect($sidebarGrouping.get()).toBe('date')
expect($sidebarViewCustomized.get()).toBe(false)
})
it('resets the scope the user is not looking at, so flipping the rail cannot restore it', () => {
setSidebarGrouping('status')
$showAllProfiles.set(true)
setSidebarGrouping('profile')
resetSidebarView()
$showAllProfiles.set(false)
expect($sidebarGrouping.get()).toBe('date')
})
it('turns all-profiles on when the user groups by profile, since that is the ask', () => {
setSidebarGrouping('profile')
expect($showAllProfiles.get()).toBe(true)
expect($sidebarGrouping.get()).toBe('profile')
})
})
+801
View File
@@ -0,0 +1,801 @@
import { atom, computed, type ReadableAtom, type WritableAtom } from 'nanostores'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '@/app/layout-constants'
import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell'
import { isPaneVisible, revealTreePane } from '@/components/pane-shell/tree/store'
import { matchesQuery } from '@/hooks/use-media-query'
import { connectionScopedAtom } from '@/lib/connection-scoped'
import { type Codec, Codecs, persistentAtom } from '@/lib/persisted'
import { arraysEqual, insertUniqueId, readKey } from '@/lib/storage'
import { $paneStates, ensurePaneRegistered, setPaneOpen, setPaneWidthOverride, togglePane } from './panes'
import { $showAllProfiles, setShowAllProfiles } from './profile'
import type { PullRequestBucket } from './pull-requests'
import type { SessionStatusBucket } from './session-dot-state'
export const SIDEBAR_DEFAULT_WIDTH = 237
export const SIDEBAR_MAX_WIDTH = 360
// Open at the same width as the sessions sidebar so the two rails match, but
// allow shrinking well below that (~30% under the old 14rem floor) for users who
// want a narrow tree.
export const FILE_BROWSER_DEFAULT_WIDTH = `${SIDEBAR_DEFAULT_WIDTH}px`
export const FILE_BROWSER_MIN_WIDTH = '10rem'
export const FILE_BROWSER_MAX_WIDTH = '20rem'
export const SIDEBAR_SESSIONS_PAGE_SIZE = 50
// How deep the list reaches once a filter is on. A filter that only searches
// the loaded page answers the wrong question — "6 merged PRs" really meant "6
// among the last 50 rows" — so narrowing the view widens the window it reads.
export const SIDEBAR_FILTERED_PAGE_SIZE = 300
const SIDEBAR_PINNED_STORAGE_KEY = 'hermes.desktop.pinnedSessions'
const SIDEBAR_AGENTS_GROUPED_STORAGE_KEY = 'hermes.desktop.agentsGroupedByWorkspace'
const SIDEBAR_CRON_OPEN_STORAGE_KEY = 'hermes.desktop.sidebarCronOpen'
const SIDEBAR_MESSAGING_OPEN_STORAGE_KEY = 'hermes.desktop.sidebarMessagingOpen'
const SIDEBAR_SESSION_ORDER_STORAGE_KEY = 'hermes.desktop.sessionOrder'
const SIDEBAR_SESSION_ORDER_MANUAL_STORAGE_KEY = 'hermes.desktop.sessionOrder.manual'
const SIDEBAR_GROUPING_STORAGE_KEY = 'hermes.desktop.sidebarGrouping'
const SIDEBAR_ALL_PROFILES_GROUPING_STORAGE_KEY = 'hermes.desktop.sidebarGrouping.allProfiles'
const SIDEBAR_ALL_PROFILES_AGENTS_GROUPED_STORAGE_KEY = 'hermes.desktop.sidebarAgentsGrouped.allProfiles'
const SIDEBAR_SORT_KEY_STORAGE_KEY = 'hermes.desktop.sidebarSortKey'
const SIDEBAR_ROW_META_STORAGE_KEY = 'hermes.desktop.sidebarRowMeta'
const SIDEBAR_CARD_ROWS_STORAGE_KEY = 'hermes.desktop.sidebarCardRows'
const SIDEBAR_STATUS_FILTER_STORAGE_KEY = 'hermes.desktop.sidebarStatusFilter'
const SIDEBAR_SHOW_ARCHIVED_STORAGE_KEY = 'hermes.desktop.sidebarShowArchived'
const SIDEBAR_PROJECT_FILTER_STORAGE_KEY = 'hermes.desktop.sidebarProjectFilter'
const SIDEBAR_PROFILE_FILTER_STORAGE_KEY = 'hermes.desktop.sidebarProfileFilter'
const SIDEBAR_PR_FILTER_STORAGE_KEY = 'hermes.desktop.sidebarPrFilter'
const SIDEBAR_WORKSPACE_ORDER_STORAGE_KEY = 'hermes.desktop.workspaceOrder'
const SIDEBAR_WORKSPACE_PARENT_ORDER_STORAGE_KEY = 'hermes.desktop.workspaceParentOrder'
const SIDEBAR_PROJECT_ORDER_STORAGE_KEY = 'hermes.desktop.projectOrder'
const SIDEBAR_WORKSPACE_COLLAPSED_STORAGE_KEY = 'hermes.desktop.workspaceCollapsed'
const SIDEBAR_WORKSPACE_NODE_OPEN_STORAGE_KEY = 'hermes.desktop.workspaceNodeOpen'
const SIDEBAR_DISMISSED_AUTO_PROJECTS_STORAGE_KEY = 'hermes.desktop.dismissedAutoProjects'
const SIDEBAR_DISMISSED_WORKTREES_STORAGE_KEY = 'hermes.desktop.dismissedWorktrees'
const PANES_FLIPPED_STORAGE_KEY = 'hermes.desktop.panesFlipped'
const RIGHT_RAIL_ACTIVE_TAB_STORAGE_KEY = 'hermes.desktop.rightRailActiveTab'
export const CHAT_SIDEBAR_PANE_ID = 'chat-sidebar'
export const FILE_BROWSER_PANE_ID = 'file-browser'
/** The file tree's id in the LAYOUT TREE — distinct from the pane-state id
* above, which keys its open/width record. Toggles need both. */
export const FILES_PANE_ID = 'files'
/** Every rail tab is a preview of something, namespaced by what backs it: a
* path on disk, a live URL, or an id into the in-memory artifact registry. */
export type RightRailTabId = `artifact:${string}` | `file:${string}` | `url:${string}`
ensurePaneRegistered(CHAT_SIDEBAR_PANE_ID, { open: true })
ensurePaneRegistered(FILE_BROWSER_PANE_ID, { open: false })
export const $sidebarOpen: ReadableAtom<boolean> = computed(
$paneStates,
states => states[CHAT_SIDEBAR_PANE_ID]?.open ?? true
)
export const $fileBrowserOpen: ReadableAtom<boolean> = computed(
$paneStates,
states => states[FILE_BROWSER_PANE_ID]?.open ?? false
)
// Persisted so a relaunch reopens the same rail tab. Null when the rail has no
// tabs; a restored id with no matching tab is reconciled in the preview store.
export const $rightRailActiveTabId = persistentAtom<RightRailTabId | null>(RIGHT_RAIL_ACTIVE_TAB_STORAGE_KEY, null, {
decode: raw => (raw ? (raw as RightRailTabId) : null),
encode: tabId => tabId ?? ''
})
export const $sidebarWidth: ReadableAtom<number> = computed($paneStates, states => {
const override = states[CHAT_SIDEBAR_PANE_ID]?.widthOverride
return typeof override === 'number' ? override : SIDEBAR_DEFAULT_WIDTH
})
// Pins and the manual session order are CONNECTION-scoped, not global: they
// are lists of session ids owned by one gateway's state.db, and multiple
// windows in this app can be connected to different gateways while sharing
// one localStorage area. A global key here is how one gateway's pins bleed
// into another window's sidebar (#77318). The local connection keeps the
// bare legacy key; remote connections get their own namespaced keys.
//
// Pins omit the profile from that key: `sessions.pinned` is gateway-wide,
// and a per-profile localStorage copy is how an unpin in profile A comes
// back when the window rescopes to B (stale ids flush as pinned=true).
export const $pinnedSessionIds = connectionScopedAtom(SIDEBAR_PINNED_STORAGE_KEY, [] as string[], Codecs.stringArray, {
includeProfile: false
})
export const $sidebarSessionOrderIds = connectionScopedAtom(
SIDEBAR_SESSION_ORDER_STORAGE_KEY,
[] as string[],
Codecs.stringArray
)
export const $sidebarSessionOrderManual = connectionScopedAtom(
SIDEBAR_SESSION_ORDER_MANUAL_STORAGE_KEY,
false,
Codecs.bool
)
export const $sidebarWorkspaceOrderIds = persistentAtom(
SIDEBAR_WORKSPACE_ORDER_STORAGE_KEY,
[] as string[],
Codecs.stringArray
)
// Order of the top-level repo "parent" groups in the worktree tree (worktrees
// within a parent reuse $sidebarWorkspaceOrderIds).
export const $sidebarWorkspaceParentOrderIds = persistentAtom(
SIDEBAR_WORKSPACE_PARENT_ORDER_STORAGE_KEY,
[] as string[],
Codecs.stringArray
)
// Manual drag-order of projects in the overview. Empty = the deterministic
// default sort (active first, explicit before auto, by recency); once the user
// drags a project their order wins (orderByIds surfaces new projects on top).
export const $sidebarProjectOrderIds = persistentAtom(
SIDEBAR_PROJECT_ORDER_STORAGE_KEY,
[] as string[],
Codecs.stringArray
)
// Explicit open/collapse state for sidebar workspace nodes AND review file-tree
// folders, keyed by stable node id (repo root / worktree path / `review:<path>`).
// A stored value is the user's EXPLICIT choice (true = open, false = collapsed);
// an absent id falls back to the caller's `defaultOpen`.
//
// We store the RESOLVED boolean, NOT an XOR against the default (the old
// `workspaceCollapsed` set did the latter). The XOR was buggy for any node
// whose default *flips*: a worktree lane defaults collapsed while empty and
// open once it holds a session, so an explicit expand of an empty lane silently
// re-read as a "collapse" the moment the lane gained a row — collapsing the very
// lane the user had just opened to work in. An absolute value survives that flip.
export const $sidebarWorkspaceNodeOpen = persistentAtom<Record<string, boolean>>(
SIDEBAR_WORKSPACE_NODE_OPEN_STORAGE_KEY,
migrateWorkspaceCollapsedIds(),
Codecs.json<Record<string, boolean>>(raw => {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return {}
}
return Object.fromEntries(
Object.entries(raw).filter((entry): entry is [string, boolean] => typeof entry[1] === 'boolean')
)
})
)
// One-time migration off the old XOR `workspaceCollapsed` string[]. Every id in
// it was a deviation from a DEFAULT-OPEN node (repos + file-tree folders, whose
// default never flips), so it maps cleanly to `collapsed` (false). The rare
// empty-worktree-lane "expand" record maps to `false` too, which just returns
// that lane to its default-collapsed state — self-healing, not a regression.
function migrateWorkspaceCollapsedIds(): Record<string, boolean> {
if (readKey(SIDEBAR_WORKSPACE_NODE_OPEN_STORAGE_KEY) !== null) {
return {}
}
const raw = readKey(SIDEBAR_WORKSPACE_COLLAPSED_STORAGE_KEY)
if (raw === null) {
return {}
}
try {
const ids = JSON.parse(raw) as unknown
if (!Array.isArray(ids)) {
return {}
}
return Object.fromEntries(
ids.filter((id): id is string => typeof id === 'string' && id.length > 0).map(id => [id, false])
)
} catch {
return {}
}
}
// Auto-derived (git-repo) projects the user has dismissed ("deleted") from the
// overview. Keyed by repo-root path; persisted so they stay hidden. Explicit
// projects are deleted for real instead — this only declutters the auto tier.
export const $dismissedAutoProjectIds = persistentAtom(
SIDEBAR_DISMISSED_AUTO_PROJECTS_STORAGE_KEY,
[] as string[],
Codecs.stringArray
)
// Worktree rows removed from the UI after a `git worktree remove`. The on-disk
// dir is gone but historical sessions still reference its path, so we hide the
// row by id (worktree path) to keep "remove" feeling real.
export const $dismissedWorktreeIds = persistentAtom(
SIDEBAR_DISMISSED_WORKTREES_STORAGE_KEY,
[] as string[],
Codecs.stringArray
)
export const $sidebarPinsOpen = atom(true)
export const $sidebarRecentsOpen = atom(true)
// Cron-job sessions live in their own section below recents, collapsed by
// default (it only renders at all when cron sessions exist) so the
// scheduler's `[IMPORTANT: …]` first-message previews don't spam recents.
export const $sidebarCronOpen = persistentAtom(SIDEBAR_CRON_OPEN_STORAGE_KEY, false, Codecs.bool)
// Messaging platform sections collapse by default (they can be numerous and
// tall). We persist the ids the user has *explicitly expanded*, so the default
// stays collapsed unless they've opened a platform before.
export const $sidebarMessagingOpenIds = persistentAtom(
SIDEBAR_MESSAGING_OPEN_STORAGE_KEY,
[] as string[],
Codecs.stringArray
)
// The Project-grouping flag, per scope like the grouping atoms below it: one
// global bool here meant picking Project inside a workspace also flipped the
// all-profiles view into the project tree (and leaving it there wiped the
// workspace's choice) — the "sidebar forgets my grouping every time I switch
// workspaces" bug. The flat key keeps its historical name so an existing
// choice survives the update.
const $sidebarFlatAgentsGrouped = persistentAtom(SIDEBAR_AGENTS_GROUPED_STORAGE_KEY, false, Codecs.bool)
const $sidebarAllProfilesAgentsGrouped = persistentAtom(
SIDEBAR_ALL_PROFILES_AGENTS_GROUPED_STORAGE_KEY,
false,
Codecs.bool
)
/** Whether the CURRENT scope shows the project tree (reads the scope's own
* flag, so each workspace and the all-profiles view remember it separately). */
export const $sidebarAgentsGrouped: ReadableAtom<boolean> = computed(
[$showAllProfiles, $sidebarFlatAgentsGrouped, $sidebarAllProfilesAgentsGrouped],
(showAll, flat, allProfiles) => (showAll ? allProfiles : flat)
)
/** How the recents list is divided. `date` is the sidebar's long-standing
* default (Today / Yesterday / Last week dividers). `profile` only means
* anything while the sidebar is showing every profile at once. */
export type SidebarGrouping = 'date' | 'profile' | 'project' | 'status'
/** What ranks rows within whatever grouping is active. */
export type SidebarOrdering = 'cost' | 'created' | 'manual' | 'status' | 'tokens' | 'updated'
/** The sort keys the menu offers; `manual` is entered by dragging, not picked. */
export type SidebarSortKey = Exclude<SidebarOrdering, 'manual'>
/** Optional per-row metadata the user can switch on. `preview` is card-only:
* the one-line row has nowhere to put a second line. */
export type SidebarRowMeta = 'cost' | 'pr' | 'preview' | 'profile' | 'tokens' | 'updated'
function oneOf<T extends string>(values: readonly T[], fallback: T): Codec<T> {
return {
decode: raw => (values.includes(raw as T) ? (raw as T) : fallback),
encode: value => value
}
}
function listOf<T extends string>(values: readonly T[]): Codec<T[]> {
return {
decode: raw => Codecs.stringArray.decode(raw).filter((item): item is T => values.includes(item as T)),
encode: value => Codecs.stringArray.encode(value)
}
}
const ROW_META: readonly SidebarRowMeta[] = ['cost', 'pr', 'preview', 'profile', 'tokens', 'updated']
const STATUS_FILTERS: readonly SessionStatusBucket[] = ['needs-input', 'working', 'unread', 'draft', 'idle']
const PR_FILTERS: readonly PullRequestBucket[] = ['open', 'draft', 'merged', 'closed', 'none']
export const SIDEBAR_SORT_KEYS: readonly SidebarSortKey[] = ['updated', 'created', 'status', 'tokens', 'cost']
// `project` deliberately does NOT live here. Entering a project from ⌘K, the
// projects store, or a repo scan flips $sidebarAgentsGrouped directly, so that
// atom stays the single authority for the project view and this one only holds
// the grouping to fall back to when the user leaves it.
const $sidebarFlatGrouping = persistentAtom<SidebarGrouping>(
SIDEBAR_GROUPING_STORAGE_KEY,
'date',
oneOf(['date', 'status'], 'date')
)
// All-profiles keeps its own grouping: `profile` only means anything there, and
// a shared atom would either drag it into a scope where it means nothing or
// reset the choice every time the user flips the rail. Both scopes still ship
// by day — grouping by owner is something you go and pick.
const $sidebarAllProfilesGrouping = persistentAtom<SidebarGrouping>(
SIDEBAR_ALL_PROFILES_GROUPING_STORAGE_KEY,
'date',
oneOf(['date', 'profile', 'status'], 'date')
)
// The sidebar as it ships. Declared once so the atoms below, "Reset to
// defaults" and the "has this view been customized?" check can't drift apart —
// they used to inline the same literals in three places.
const SIDEBAR_DEFAULT_GROUPING: SidebarGrouping = 'date'
const SIDEBAR_DEFAULT_ORDERING: SidebarOrdering = 'updated'
const SIDEBAR_DEFAULT_ROW_META: SidebarRowMeta[] = ['preview', 'updated']
const $sidebarSortKey = persistentAtom<SidebarSortKey>(
SIDEBAR_SORT_KEY_STORAGE_KEY,
'updated',
oneOf(SIDEBAR_SORT_KEYS, 'updated')
)
export const $sidebarRowMeta = persistentAtom<SidebarRowMeta[]>(
SIDEBAR_ROW_META_STORAGE_KEY,
SIDEBAR_DEFAULT_ROW_META,
listOf(ROW_META)
)
/** Inbox style: render the flat list's session rows as three-line cards
* (project · age / title / model · size) instead of the one-line row. A
* RENDER variant, deliberately not a grouping — it composes with whichever
* grouping is active. Off by default; dense tree surfaces never use it. */
export const $sidebarCardRows = persistentAtom(SIDEBAR_CARD_ROWS_STORAGE_KEY, false, Codecs.bool)
export function setSidebarCardRows(on: boolean) {
$sidebarCardRows.set(on)
}
/** Order-insensitive: the menu appends in click order, so ['tokens','updated']
* and ['updated','tokens'] are the same view. */
function sameRowMeta(a: SidebarRowMeta[], b: SidebarRowMeta[]): boolean {
return a.length === b.length && a.every(id => b.includes(id))
}
// Archived sessions are a separate backend query (`archived: 'only'`), so this
// flag both filters the list and drives the fetch.
export const $sidebarShowArchived = persistentAtom(SIDEBAR_SHOW_ARCHIVED_STORAGE_KEY, false, Codecs.bool)
export const $sidebarStatusFilter = persistentAtom<SessionStatusBucket[]>(
SIDEBAR_STATUS_FILTER_STORAGE_KEY,
[],
listOf(STATUS_FILTERS)
)
// Project ids as `liveSessionProjectId` reports them: an explicit project's id,
// or a repo root path for an auto-promoted one.
export const $sidebarProjectFilter = persistentAtom(
SIDEBAR_PROJECT_FILTER_STORAGE_KEY,
[] as string[],
Codecs.stringArray
)
// Profile names, as `normalizeProfileKey` reports them. Only bites while the
// sidebar is showing every profile — scoped to one, the scope is the filter.
export const $sidebarProfileFilter = persistentAtom(
SIDEBAR_PROFILE_FILTER_STORAGE_KEY,
[] as string[],
Codecs.stringArray
)
// Whether a session's branch has a PR, and in what state. Fetched per repo via
// `gh` (see store/pull-requests), so this is empty on backends without a local
// checkout — the menu hides the submenu rather than offering a dead filter.
export const $sidebarPrFilter = persistentAtom<PullRequestBucket[]>(
SIDEBAR_PR_FILTER_STORAGE_KEY,
[],
listOf(PR_FILTERS)
)
export const $sidebarGrouping: ReadableAtom<SidebarGrouping> = computed(
[$sidebarAgentsGrouped, $sidebarFlatGrouping, $sidebarAllProfilesGrouping, $showAllProfiles],
(grouped, flat, allProfiles, showAll) => (grouped ? 'project' : showAll ? allProfiles : flat)
)
// A hand-dragged order outranks any sort key — dragging IS how you pick manual,
// so the menu reflects that rather than offering a fourth way to say it.
export const $sidebarOrdering: ReadableAtom<SidebarOrdering> = computed(
[$sidebarSessionOrderManual, $sidebarSortKey],
(manual, key) => (manual ? 'manual' : key)
)
export const $sidebarFiltersActive: ReadableAtom<boolean> = computed(
[$sidebarStatusFilter, $sidebarProjectFilter, $sidebarProfileFilter, $sidebarPrFilter, $sidebarShowArchived],
(statuses, projects, profiles, prs, archived) =>
statuses.length > 0 || projects.length > 0 || profiles.length > 0 || prs.length > 0 || archived
)
/** Anything at all moved off the shipped view — what makes a reset worth
* offering. Broader than `$sidebarFiltersActive`, which only knows about what
* hides rows, not about how they're grouped, sorted or labelled. */
export const $sidebarViewCustomized: ReadableAtom<boolean> = computed(
[$sidebarGrouping, $sidebarOrdering, $sidebarRowMeta, $sidebarCardRows, $sidebarFiltersActive],
(grouping, ordering, rowMeta, cardRows, filtersActive) =>
grouping !== SIDEBAR_DEFAULT_GROUPING ||
ordering !== SIDEBAR_DEFAULT_ORDERING ||
!sameRowMeta(rowMeta, SIDEBAR_DEFAULT_ROW_META) ||
cardRows ||
filtersActive
)
// When true, the sessions sidebar moves to the right and the file browser +
// preview rail move to the left — a mirror of the default layout.
export const $panesFlipped = persistentAtom(PANES_FLIPPED_STORAGE_KEY, false, Codecs.bool)
export const $isSidebarResizing = atom(false)
export const $sessionsLimit = atom(SIDEBAR_SESSIONS_PAGE_SIZE)
// Live date/status divider ids (`list-group:yesterday`, …) currently in the
// recents list. Not persisted — the open/closed choice lives on
// `$sidebarWorkspaceNodeOpen`; this just names what's on screen so "Collapse
// all" can fold every labelled bucket, including ones never toggled.
export const $sidebarListGroupIds = atom<string[]>([])
// Date/status dividers share `$sidebarWorkspaceNodeOpen` under this prefix so
// they don't collide with repo paths.
export function listGroupNodeId(key: string): string {
return `list-group:${key}`
}
// Resolve a node's open state against its default (absent = follow default).
export function workspaceNodeOpen(id: string, defaultOpen = true): boolean {
return $sidebarWorkspaceNodeOpen.get()[id] ?? defaultOpen
}
// Force a node open/collapsed. Stable across a default flip — used by "+ new
// session" to reveal the lane it targets and keep it open once it's populated.
export function setWorkspaceNodeOpen(id: string, open: boolean): void {
const current = $sidebarWorkspaceNodeOpen.get()
if (current[id] === open) {
return
}
$sidebarWorkspaceNodeOpen.set({ ...current, [id]: open })
}
// Toggle a repo/worktree/file-tree node relative to its current resolved state.
export function toggleWorkspaceNodeCollapsed(id: string, defaultOpen = true): void {
setWorkspaceNodeOpen(id, !workspaceNodeOpen(id, defaultOpen))
}
// Fold a whole level shut (or open) in one write — the sidebar's "Collapse all"
// over the project rows. Their lanes keep their own state underneath, so
// re-opening a project shows it as the user left it.
export function setWorkspaceNodesOpen(ids: readonly string[], open: boolean): void {
$sidebarWorkspaceNodeOpen.set({
...$sidebarWorkspaceNodeOpen.get(),
...Object.fromEntries(ids.map(id => [id, open]))
})
}
// Dismiss ("delete") an auto-derived project from the overview.
export function dismissAutoProject(id: string): void {
const current = $dismissedAutoProjectIds.get()
if (!current.includes(id)) {
$dismissedAutoProjectIds.set([...current, id])
}
}
// Auto projects dismissed from the overview stay out of every surface that
// lists projects (sidebar + ⌘K). Explicit rows never match.
export function filterVisibleProjects<T extends { id: string; isAuto?: boolean }>(
projects: readonly T[],
dismissedIds: readonly string[] = $dismissedAutoProjectIds.get()
): T[] {
if (!dismissedIds.length) {
return projects as T[]
}
const dismissed = new Set(dismissedIds)
return projects.filter(project => !(project.isAuto && dismissed.has(project.id)))
}
// Hide a worktree row after it's been removed via git.
export function dismissWorktree(id: string): void {
const current = $dismissedWorktreeIds.get()
if (!current.includes(id)) {
$dismissedWorktreeIds.set([...current, id])
}
}
// A hidden worktree becomes visible again as soon as the user explicitly starts
// or opens work there (for example, selecting an already-checked-out branch).
export function restoreWorktree(id: string): void {
const current = $dismissedWorktreeIds.get()
if (current.includes(id)) {
$dismissedWorktreeIds.set(current.filter(worktreeId => worktreeId !== id))
}
}
export function setSidebarWidth(width: number) {
const bounded = Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_DEFAULT_WIDTH, width))
setPaneWidthOverride(CHAT_SIDEBAR_PANE_ID, bounded)
}
// Below the collapse breakpoint a collapsible rail leaves the grid and lives as
// a hover/pin overlay, so open/toggle must route through the reveal event — the
// docked `open` flag renders a 0px track invisibly. Centralised here so every
// caller (titlebar, keybinds, session-search, reveal-file) inherits it instead
// of re-deriving the narrow branch. Returns true when it handled the intent.
function revealNarrowPane(id: string, mode: 'close' | 'open' | 'toggle'): boolean {
if (typeof window === 'undefined' || !matchesQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)) {
return false
}
window.dispatchEvent(new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id, mode } }))
return true
}
export function setSidebarOpen(open: boolean) {
setPaneOpen(CHAT_SIDEBAR_PANE_ID, open)
revealNarrowPane(CHAT_SIDEBAR_PANE_ID, open ? 'open' : 'close')
}
export function toggleSidebarOpen() {
if (!revealNarrowPane(CHAT_SIDEBAR_PANE_ID, 'toggle')) {
togglePane(CHAT_SIDEBAR_PANE_ID)
}
}
export function toggleFileBrowserOpen() {
if (revealNarrowPane(FILE_BROWSER_PANE_ID, 'toggle')) {
return
}
// Ask the TREE, not the pane's boolean. `$fileBrowserOpen` stays true while
// the tree pane sits behind a sibling tab in the shared right column (the
// preview rail, the diff) or inside a minimized zone, so ⌘J spent its press
// re-asserting a value it already held and read as a dead key. Only fold the
// side when the tree is genuinely the thing on screen; otherwise bring it
// forward through the reveal path, which fronts and un-minimizes.
if (!isPaneVisible(FILES_PANE_ID) && $fileBrowserOpen.get()) {
revealTreePane(FILES_PANE_ID)
return
}
togglePane(FILE_BROWSER_PANE_ID)
}
export function setFileBrowserOpen(open: boolean) {
setPaneOpen(FILE_BROWSER_PANE_ID, open)
revealNarrowPane(FILE_BROWSER_PANE_ID, open ? 'open' : 'close')
}
// "Reveal this file in the file-browser tree" — an absolute path the tree
// subscribes to, expanding ancestor folders and selecting/scrolling to it. Reset
// to null by the tree once consumed.
export const $revealInTreeRequest = atom<null | string>(null)
export function revealFileInTree(path: string): void {
setFileBrowserOpen(true)
$revealInTreeRequest.set(path)
}
// Hotkey → focus the sessions search field. Opens the sidebar first, then lets
// the field (which only mounts when the sidebar is open) subscribe + focus.
export const SESSION_SEARCH_FOCUS_EVENT = 'hermes:focus-session-search'
export function requestSessionSearchFocus() {
setSidebarOpen(true)
if (typeof window !== 'undefined') {
window.setTimeout(() => window.dispatchEvent(new CustomEvent(SESSION_SEARCH_FOCUS_EVENT)), 0)
}
}
export function togglePanesFlipped() {
$panesFlipped.set(!$panesFlipped.get())
}
export function selectRightRailTab(id: RightRailTabId | null) {
$rightRailActiveTabId.set(id)
}
export function setSidebarPinsOpen(open: boolean) {
$sidebarPinsOpen.set(open)
}
export function setSidebarRecentsOpen(open: boolean) {
$sidebarRecentsOpen.set(open)
}
export function setSidebarCronOpen(open: boolean) {
$sidebarCronOpen.set(open)
}
export function toggleSidebarMessagingOpen(sourceId: string) {
const current = $sidebarMessagingOpenIds.get()
$sidebarMessagingOpenIds.set(
current.includes(sourceId) ? current.filter(id => id !== sourceId) : [...current, sourceId]
)
}
export function setSidebarAgentsGrouped(grouped: boolean) {
// Write the flag the current scope reads — see $sidebarAgentsGrouped.
;($showAllProfiles.get() ? $sidebarAllProfilesAgentsGrouped : $sidebarFlatAgentsGrouped).set(grouped)
}
export function setSidebarGrouping(grouping: SidebarGrouping) {
// Grouping by owner is a request to see every owner, so it turns the
// all-profiles view on rather than drawing one group around one profile —
// and every write that follows must target that scope, not the one we were
// in when the click landed. (The flat scope's atom can't hold 'profile'.)
if (grouping === 'profile') {
setShowAllProfiles(true)
$sidebarAllProfilesAgentsGrouped.set(false)
$sidebarAllProfilesGrouping.set(grouping)
return
}
setSidebarAgentsGrouped(grouping === 'project')
if (grouping === 'project') {
return
}
if ($showAllProfiles.get()) {
$sidebarAllProfilesGrouping.set(grouping)
return
}
$sidebarFlatGrouping.set(grouping)
}
export function setSidebarOrdering(ordering: SidebarOrdering) {
if (ordering === 'manual') {
setSidebarSessionOrderManual(true)
return
}
// Picking a sort key is the only way back out of a hand-dragged order, so it
// has to drop the saved sequence as well as the flag.
setSidebarSessionOrderManual(false)
setSidebarSessionOrderIds([])
$sidebarSortKey.set(ordering)
}
export function setSidebarShowArchived(show: boolean) {
$sidebarShowArchived.set(show)
}
function toggleIn<T extends string>($atom: WritableAtom<T[]>, value: T) {
const current = $atom.get()
$atom.set(current.includes(value) ? current.filter(item => item !== value) : [...current, value])
}
export function toggleSidebarRowMeta(meta: SidebarRowMeta) {
toggleIn($sidebarRowMeta, meta)
}
export function toggleSidebarStatusFilter(status: SessionStatusBucket) {
toggleIn($sidebarStatusFilter, status)
}
export function toggleSidebarProjectFilter(projectId: string) {
toggleIn($sidebarProjectFilter, projectId)
}
export function toggleSidebarProfileFilter(profile: string) {
toggleIn($sidebarProfileFilter, profile)
}
export function toggleSidebarPrFilter(bucket: PullRequestBucket) {
toggleIn($sidebarPrFilter, bucket)
}
function clearSidebarFilters() {
$sidebarStatusFilter.set([])
$sidebarProjectFilter.set([])
$sidebarProfileFilter.set([])
$sidebarPrFilter.set([])
$sidebarShowArchived.set(false)
}
/** Every knob the filter menu owns, back to the sidebar as it ships. Ordering
* goes through its setter so a hand-dragged sequence is dropped along with it. */
export function resetSidebarView() {
setSidebarGrouping(SIDEBAR_DEFAULT_GROUPING)
// Both scopes, not just the one on screen: each keeps its own grouping (and
// its own Project flag), so a reset that left the other customized would
// hand it back on the next flip.
$sidebarFlatGrouping.set(SIDEBAR_DEFAULT_GROUPING)
$sidebarAllProfilesGrouping.set(SIDEBAR_DEFAULT_GROUPING)
$sidebarFlatAgentsGrouped.set(false)
$sidebarAllProfilesAgentsGrouped.set(false)
setSidebarOrdering(SIDEBAR_DEFAULT_ORDERING)
$sidebarRowMeta.set(SIDEBAR_DEFAULT_ROW_META)
$sidebarCardRows.set(false)
clearSidebarFilters()
}
/** Whether anything on screen needs PR data — the gate on shelling out to
* `gh`. Nobody pays for a network call per repo to render a view that would
* not show a PR anywhere. */
export const $sidebarPrDataWanted: ReadableAtom<boolean> = computed(
[$sidebarRowMeta, $sidebarPrFilter],
(rowMeta, prFilter) => rowMeta.includes('pr') || prFilter.length > 0
)
// Write an order list only when it actually changed, so an identical drag
// result keeps the same array reference and subscribers don't churn.
function setOrderIds($atom: WritableAtom<string[]>, ids: string[]) {
if (!arraysEqual($atom.get(), ids)) {
$atom.set(ids)
}
}
export function setSidebarSessionOrderIds(ids: string[]) {
setOrderIds($sidebarSessionOrderIds, ids)
}
export function setSidebarSessionOrderManual(manual: boolean) {
if ($sidebarSessionOrderManual.get() !== manual) {
$sidebarSessionOrderManual.set(manual)
}
}
export function setSidebarWorkspaceOrderIds(ids: string[]) {
setOrderIds($sidebarWorkspaceOrderIds, ids)
}
export function setSidebarWorkspaceParentOrderIds(ids: string[]) {
setOrderIds($sidebarWorkspaceParentOrderIds, ids)
}
export function setSidebarProjectOrderIds(ids: string[]) {
setOrderIds($sidebarProjectOrderIds, ids)
}
export function setSidebarResizing(resizing: boolean) {
$isSidebarResizing.set(resizing)
}
export function pinSession(sessionId: string, index?: number) {
const prev = $pinnedSessionIds.get()
setOrderIds($pinnedSessionIds, insertUniqueId(prev, sessionId, index ?? prev.filter(id => id !== sessionId).length))
}
export function unpinSession(sessionId: string) {
setOrderIds(
$pinnedSessionIds,
$pinnedSessionIds.get().filter(id => id !== sessionId)
)
}
// Apply a new pinned order from a drag. The dragged list only holds the pins
// that currently RESOLVE to a loaded row, so this is a permutation of a subset:
// re-slot the ids it names into the positions they already occupied, leaving
// any pin it doesn't mention (row not loaded yet) exactly where it was.
// Requiring both lists to be the same length instead let one unresolved pin
// silently discard the whole reorder.
export function setPinnedSessionOrder(ids: string[]) {
const prev = $pinnedSessionIds.get()
const pinned = new Set(prev)
const moving = ids.filter(id => pinned.has(id))
if (!moving.length) {
return
}
const movingSet = new Set(moving)
const next = [...prev]
let cursor = 0
prev.forEach((id, index) => {
if (movingSet.has(id)) {
next[index] = moving[cursor++]
}
})
setOrderIds($pinnedSessionIds, next)
}
export function bumpSessionsLimit(step: number = SIDEBAR_SESSIONS_PAGE_SIZE) {
const safeStep = Math.max(1, Math.floor(step))
$sessionsLimit.set($sessionsLimit.get() + safeStep)
}
/** Raise the window to at least `floor`, never shrinking it. Returns true when
* it moved, so the caller knows a refetch is worth it. */
export function raiseSessionsLimit(floor: number): boolean {
if ($sessionsLimit.get() >= floor) {
return false
}
$sessionsLimit.set(floor)
return true
}
export function resetSessionsLimit() {
if ($sessionsLimit.get() !== SIDEBAR_SESSIONS_PAGE_SIZE) {
$sessionsLimit.set(SIDEBAR_SESSIONS_PAGE_SIZE)
}
}
+65
View File
@@ -0,0 +1,65 @@
import { atom } from 'nanostores'
// Event-driven "backend data changed" signals — the workspace-events twin for
// gateway-side state. The change watcher in tui_gateway broadcasts
// `pet.changed` / `cron.changed` / `sessions.changed` when its on-disk
// signatures move (see server._broadcast_watched_changes), the gateway-event
// lifecycle handler routes them here, and the surfaces that used to poll (cron
// sidebar/page, messaging lists, pet sprite, live session statuses) subscribe
// to these ticks and refresh — so they move exactly when the backend acts and
// stay idle otherwise.
//
// `$changeEventsAvailable` is the compat gate: gateway.ready advertises
// `change_events: true` on backends that broadcast. Consumers keep their old
// polls as SLOW backstops when it's true and at the legacy cadence when it's
// false (an older backend), so nothing goes dark mid-upgrade.
export const $changeEventsAvailable = atom(false)
export const $cronChangeTick = atom(0)
export const $sessionsChangeTick = atom(0)
export const $platformsChangeTick = atom(0)
export const $pairingChangeTick = atom(0)
/** `pet.info.meta`-shaped payload carried on `pet.changed` — lets the pet skip
* the heavy sprite refetch when the broadcast already says enabled=false. */
export interface PetChangeMeta {
enabled: boolean
slug?: string
displayName?: string
scale?: number
spritesheetRevision?: string
}
export const $petChange = atom<{ meta?: PetChangeMeta; tick: number }>({ tick: 0 })
export function setChangeEventsAvailable(available: boolean): void {
$changeEventsAvailable.set(available)
}
export function notifyPetChanged(meta?: PetChangeMeta): void {
$petChange.set({ meta, tick: $petChange.get().tick + 1 })
}
export function notifyCronChanged(): void {
$cronChangeTick.set($cronChangeTick.get() + 1)
}
export function notifySessionsChanged(): void {
$sessionsChangeTick.set($sessionsChangeTick.get() + 1)
}
export function notifyPlatformsChanged(): void {
$platformsChangeTick.set($platformsChangeTick.get() + 1)
}
export function notifyPairingChanged(): void {
$pairingChangeTick.set($pairingChangeTick.get() + 1)
}
/** Reset on gateway wipe/reconnect — a new backend re-advertises capability on
* its own gateway.ready, and stale ticks must not fire refreshes into stores
* the wipe just cleared. */
export function resetLiveSync(): void {
$changeEventsAvailable.set(false)
}
@@ -0,0 +1,40 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
describe('$localModelsEnabled', () => {
beforeEach(() => {
vi.resetModules()
})
it('reads true when the preload bridge reports the --local launch flag', async () => {
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { localModelsEnabled: true }
})
const { $localModelsEnabled } = await import('./local-models-flag')
expect($localModelsEnabled.get()).toBe(true)
})
it('defaults to false when the bridge omits the flag (older preload, web)', async () => {
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: {}
})
const { $localModelsEnabled } = await import('./local-models-flag')
expect($localModelsEnabled.get()).toBe(false)
})
it('defaults to false with no bridge at all', async () => {
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: undefined
})
const { $localModelsEnabled } = await import('./local-models-flag')
expect($localModelsEnabled.get()).toBe(false)
})
})
@@ -0,0 +1,16 @@
import { atom } from 'nanostores'
/**
* Launch-flag gate for every local-models surface in the GUI.
*
* Local models ship on main behind `--local` (either `hermes desktop --local`
* or the flag on Hermes.exe itself). The flag is strict: without it the GUI
* shows no local-models surface at all, even on a machine where local models
* are configured and running — the backend routes stay live, only the
* desktop's presentation is gated. Read once from the preload bridge at
* module load; a launch flag can't change mid-session, so nothing rewrites
* it outside tests.
*/
export const $localModelsEnabled = atom<boolean>(
typeof window !== 'undefined' && window.hermesDesktop?.localModelsEnabled === true
)
@@ -0,0 +1,169 @@
import { atom } from 'nanostores'
import { getLocalModelsJobs, getLocalModelsStatus } from '@/hermes'
import { translateNow } from '@/i18n'
import { notify, notifyError } from '@/store/notifications'
import type { LocalRuntimeJob } from '@/types/hermes'
// App-level tracker for local-runtime jobs (runtime installs, model
// downloads). The AUTHORITY is the backend job registry — this store is a
// cache of it (desktop guide: server truth is cached, not owned). Living at
// the store layer, not in the settings pane, is what makes a download
// survive the pane unmounting: anything can start a job, the poller follows
// it to completion, and completion/failure notify app-wide exactly once.
export const $localRuntimeJobs = atom<readonly LocalRuntimeJob[]>([])
const POLL_ACTIVE_MS = 700
let timer: null | number = null
let polling = false
// Jobs we've already toasted for, so a poll race can't double-notify.
const settledNotified = new Set<string>()
function jobsEqual(a: readonly LocalRuntimeJob[], b: readonly LocalRuntimeJob[]) {
if (a.length !== b.length) {
return false
}
return a.every((job, i) => {
const other = b[i]
return (
job.job_id === other.job_id &&
job.status === other.status &&
job.phase === other.phase &&
job.done_bytes === other.done_bytes
)
})
}
function notifySettled(previous: readonly LocalRuntimeJob[], next: readonly LocalRuntimeJob[]) {
const wasRunning = new Set(previous.filter(j => j.status === 'running').map(j => j.job_id))
for (const job of next) {
if (job.status === 'running' || !wasRunning.has(job.job_id) || settledNotified.has(job.job_id)) {
continue
}
settledNotified.add(job.job_id)
if (job.status === 'done') {
notify({
durationMs: 6_000,
kind: 'success',
title: translateNow('settings.localModels.title'),
message:
job.kind === 'model-download'
? translateNow('settings.localModels.downloadDoneToast', job.target)
: job.kind === 'model-activate'
? translateNow('settings.localModels.activateDoneToast', job.target)
: job.kind === 'quickstart'
? translateNow('settings.localModels.quickstartDoneToast', job.target)
: translateNow('settings.localModels.installDoneToast')
})
} else {
notifyError(
new Error(job.error ?? job.detail ?? 'failed'),
job.kind === 'model-download'
? translateNow('settings.localModels.downloadFailed', job.target)
: job.kind === 'model-activate'
? translateNow('settings.localModels.activateFailed', job.target)
: job.kind === 'quickstart'
? translateNow('settings.localModels.quickstartFailed')
: translateNow('settings.localModels.installFailed')
)
}
}
}
async function poll() {
try {
const { jobs } = await getLocalModelsJobs()
const previous = $localRuntimeJobs.get()
if (!jobsEqual(previous, jobs)) {
notifySettled(previous, jobs)
$localRuntimeJobs.set(jobs)
}
} catch {
// Backend unreachable — keep the last snapshot; the next poll retries.
}
const anyRunning = $localRuntimeJobs.get().some(j => j.status === 'running')
if (anyRunning) {
timer = window.setTimeout(() => void poll(), POLL_ACTIVE_MS)
} else {
polling = false
timer = null
}
}
// Idempotent kick: start (or keep) the poll loop while work is in flight.
// Call after starting a job AND on app boot (to rediscover work started
// before a reload).
export function watchLocalRuntimeJobs() {
if (polling) {
return
}
polling = true
if (timer !== null) {
window.clearTimeout(timer)
}
void poll()
}
// Selector: the running download job for a catalog model id, if any.
export function runningDownloadFor(jobs: readonly LocalRuntimeJob[], modelId: string): LocalRuntimeJob | null {
return jobs.find(j => j.kind === 'model-download' && j.status === 'running' && j.model_id === modelId) ?? null
}
// Selector: every model on its way to the library right now — plain
// downloads plus quickstart runs while they are still fetching bytes
// (later quickstart phases mean the model is staged and activating).
// The model picker renders these as disabled progress rows.
const DOWNLOAD_PHASES = new Set(['starting', 'installing-runtime', 'downloading'])
export function runningModelDownloads(jobs: readonly LocalRuntimeJob[]): LocalRuntimeJob[] {
return jobs.filter(
j =>
j.status === 'running' &&
(j.kind === 'model-download' || (j.kind === 'quickstart' && DOWNLOAD_PHASES.has(j.phase)))
)
}
export function runningRuntimeInstall(jobs: readonly LocalRuntimeJob[]): LocalRuntimeJob | null {
return jobs.find(j => j.kind === 'runtime-install' && j.status === 'running') ?? null
}
// One engine-update toast per app session: checked at boot (after the
// gateway is ready), only when the user runs the local engine. The
// download itself is always a button click in Local Models — this is a
// pointer, not an installer.
let updateNotified = false
export async function checkLocalRuntimeUpdate() {
if (updateNotified) {
return
}
try {
const status = await getLocalModelsStatus()
if (status.enabled && status.update_available) {
updateNotified = true
notify({
durationMs: 10_000,
kind: 'info',
title: translateNow('settings.localModels.title'),
message: translateNow('settings.localModels.updateToast', status.configured_tag)
})
}
} catch {
// Backend without the endpoint (older runtime) or transient failure —
// silently skip; the pane still shows the update row when opened.
}
}
@@ -0,0 +1,208 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { DesktopManagedConnectionUpdateResult } from '@/global'
const {
$managedUpdates,
_resetManagedUpdatesForTests,
isManagedUpdateBusyMessage,
managedUpdatesSupported,
runManagedUpdate
} = await import('./managed-updates')
const updateManaged = vi.fn<(id: string) => Promise<DesktopManagedConnectionUpdateResult>>()
function managedResult(over: Partial<DesktopManagedConnectionUpdateResult> = {}): DesktopManagedConnectionUpdateResult {
return {
connectionId: 'linux-ssh',
correlationId: '0c44e2da-993e-4d96-ab45-2d0f73365d61',
exitCode: 0,
ok: true,
outcome: 'updated',
receipt: {
correlationId: '0c44e2da-993e-4d96-ab45-2d0f73365d61',
outcome: 'success',
postVersion: '1.1.0',
preVersion: '1.0.0'
},
restoreOk: true,
scopes: [{ profile: 'default', restored: true }],
updateOk: true,
...over
}
}
beforeEach(() => {
_resetManagedUpdatesForTests()
updateManaged.mockReset().mockResolvedValue(managedResult())
;(window as { hermesDesktop?: unknown }).hermesDesktop = {
connections: { updateManaged }
}
})
describe('managedUpdatesSupported', () => {
it('is false on an older Electron main without the transactional bridge', () => {
;(window as { hermesDesktop?: unknown }).hermesDesktop = { connections: {} }
expect(managedUpdatesSupported()).toBe(false)
})
it('is true when the preload bridge exposes updateManaged', () => {
expect(managedUpdatesSupported()).toBe(true)
})
})
describe('runManagedUpdate', () => {
it('routes through the managed drain/update/restore bridge and lands on updated with the receipt', async () => {
const pending = runManagedUpdate('linux-ssh')
expect($managedUpdates.get()['linux-ssh']).toMatchObject({ status: 'updating' })
const state = await pending
expect(updateManaged).toHaveBeenCalledWith('linux-ssh')
expect(state).toMatchObject({ alreadyRunning: false, connectionId: 'linux-ssh', status: 'updated' })
expect(state.receipt).toMatchObject({
correlationId: '0c44e2da-993e-4d96-ab45-2d0f73365d61',
outcome: 'success',
postVersion: '1.1.0'
})
expect($managedUpdates.get()['linux-ssh']).toMatchObject({ status: 'updated' })
})
it('joins a repeat click to the in-flight promise instead of double-dispatching', async () => {
let resolve!: (value: DesktopManagedConnectionUpdateResult) => void
updateManaged.mockReturnValue(
new Promise<DesktopManagedConnectionUpdateResult>(next => {
resolve = next
})
)
const first = runManagedUpdate('linux-ssh')
const second = runManagedUpdate('linux-ssh')
expect(second).toBe(first)
expect(updateManaged).toHaveBeenCalledTimes(1)
resolve(managedResult())
await expect(first).resolves.toMatchObject({ status: 'updated' })
})
it('keeps updated-but-restore-failed truthful as partial', async () => {
updateManaged.mockResolvedValue(
managedResult({
error: 'work profile did not come back',
ok: false,
outcome: 'restore-failed',
restoreOk: false,
scopes: [
{ profile: 'default', restored: true },
{ error: 'ssh dial timed out', profile: 'work', restored: false }
]
})
)
const state = await runManagedUpdate('linux-ssh')
expect(state).toMatchObject({
message: 'work profile did not come back',
status: 'partial'
})
expect(state.scopes).toHaveLength(2)
})
it('surfaces the managed-update-in-progress refusal as busy, not a scary failure', async () => {
updateManaged.mockResolvedValue(
managedResult({
message: 'A managed update is already in progress.',
ok: false,
outcome: 'refused',
receipt: null,
scopes: [],
updateOk: false
})
)
const state = await runManagedUpdate('linux-ssh')
expect(state).toMatchObject({
alreadyRunning: true,
message: 'A managed update is already in progress.',
status: 'refused'
})
})
it('maps a thrown managed-update-in-progress IPC envelope to the same busy state', async () => {
const error: Error & { code?: string } = new Error(
"Error invoking remote method 'hermes:connections:update-managed': " +
'SSH connection "linux-ssh" is paused while its managed update is in progress.'
)
error.code = 'managed-update-in-progress'
updateManaged.mockRejectedValue(error)
const state = await runManagedUpdate('linux-ssh')
expect(state).toMatchObject({ alreadyRunning: true, status: 'refused' })
})
it('keeps a non-busy refusal refused with its exact message', async () => {
updateManaged.mockResolvedValue(
managedResult({
message: 'Only registered Desktop-managed SSH connections can use this update lifecycle.',
ok: false,
outcome: 'refused',
receipt: null,
updateOk: false
})
)
const state = await runManagedUpdate('remote-a')
expect(state).toMatchObject({
alreadyRunning: false,
message: 'Only registered Desktop-managed SSH connections can use this update lifecycle.',
status: 'refused'
})
})
it('lands a failed update on failed with the stop reason as fallback message', async () => {
updateManaged.mockResolvedValue(
managedResult({
error: undefined,
exitCode: 1,
message: undefined,
ok: false,
outcome: 'update-failed',
receipt: {
correlationId: 'run-x',
outcome: 'failed',
stopReason: 'launcher exited 1'
},
restoreOk: true,
updateOk: false
})
)
const state = await runManagedUpdate('linux-ssh')
expect(state).toMatchObject({ message: 'launcher exited 1', status: 'failed' })
})
it('fails closed when the bridge is missing instead of pretending to update', async () => {
;(window as { hermesDesktop?: unknown }).hermesDesktop = { connections: {} }
const state = await runManagedUpdate('linux-ssh')
expect(state).toMatchObject({ status: 'failed' })
expect(updateManaged).not.toHaveBeenCalled()
})
})
describe('isManagedUpdateBusyMessage', () => {
it('recognizes every busy-gate phrasing and rejects unrelated failures', () => {
expect(isManagedUpdateBusyMessage('A managed update is already in progress.')).toBe(true)
expect(isManagedUpdateBusyMessage('code managed-update-in-progress')).toBe(true)
expect(isManagedUpdateBusyMessage('ssh dial timed out')).toBe(false)
expect(isManagedUpdateBusyMessage(null)).toBe(false)
})
})
+157
View File
@@ -0,0 +1,157 @@
import { atom } from 'nanostores'
import type { DesktopManagedConnectionUpdateResult, DesktopManagedUpdateReceipt } from '@/global'
/** Renderer-side lifecycle of one managed SSH update (#93042 renderer unit,
* slimmed to what #95942's engine actually exposes today: a single
* `connections.updateManaged(id)` promise that resolves with a correlated
* result + receipt. There is no streaming progress channel, so the in-flight
* phase is one `updating` state and every finer-grained outcome (partial
* restore, refusal, per-profile recovery) comes from the terminal result). */
export type ManagedUpdateStatus = 'failed' | 'idle' | 'partial' | 'refused' | 'updated' | 'updating'
export interface ManagedUpdateState {
/** True when the refusal was the busy gate: another managed update already
* owns this connection (the `managed-update-in-progress` envelope). */
alreadyRunning: boolean
connectionId: string
finishedAt: number | null
message: string | null
receipt: DesktopManagedUpdateReceipt | null
scopes: DesktopManagedConnectionUpdateResult['scopes']
status: ManagedUpdateStatus
}
export const $managedUpdates = atom<Record<string, ManagedUpdateState>>({})
const inFlight = new Map<string, Promise<ManagedUpdateState>>()
// The main process signals the busy gate two ways: a `refused` result whose
// message says an update is already in progress, and (from sibling IPC paths
// it pauses) a thrown error carrying code `managed-update-in-progress`.
const IN_PROGRESS_PATTERN = /managed[\s-]update[\s\w]*\bin[\s-]progress|managed-update-in-progress|already in progress/i
function managedUpdater(): ((id: string) => Promise<DesktopManagedConnectionUpdateResult>) | null {
if (typeof window === 'undefined') {
return null
}
return window.hermesDesktop?.connections?.updateManaged ?? null
}
/** Whether this Electron main exposes the transactional SSH update bridge.
* Older mains without it hide the section entirely (fail closed, no fake
* button that could mutate a live serve process). */
export function managedUpdatesSupported(): boolean {
return Boolean(managedUpdater())
}
export function isManagedUpdateBusyMessage(message: string | null | undefined): boolean {
return Boolean(message && IN_PROGRESS_PATTERN.test(message))
}
function publish(state: ManagedUpdateState): void {
$managedUpdates.set({ ...$managedUpdates.get(), [state.connectionId]: state })
}
function blankState(connectionId: string): ManagedUpdateState {
return {
alreadyRunning: false,
connectionId,
finishedAt: null,
message: null,
receipt: null,
scopes: [],
status: 'idle'
}
}
function terminalState(connectionId: string, result: DesktopManagedConnectionUpdateResult): ManagedUpdateState {
const message = result.message || result.error || result.receipt?.stopReason || null
const alreadyRunning = result.outcome === 'refused' && isManagedUpdateBusyMessage(message)
const status: ManagedUpdateStatus = result.ok
? 'updated'
: result.outcome === 'refused'
? 'refused'
: result.updateOk && !result.restoreOk
? 'partial'
: 'failed'
return {
alreadyRunning,
connectionId,
finishedAt: Date.now(),
message,
receipt: result.receipt ?? null,
scopes: result.scopes ?? [],
status
}
}
/** Run one managed update. A repeat click while it is in flight joins the
* existing promise — the main process gate would refuse a second start, and
* the UI must not turn that self-collision into a scary failure row. */
export function runManagedUpdate(connectionId: string): Promise<ManagedUpdateState> {
const existing = inFlight.get(connectionId)
if (existing) {
return existing
}
const updateManaged = managedUpdater()
if (!updateManaged) {
const state: ManagedUpdateState = {
...blankState(connectionId),
finishedAt: Date.now(),
status: 'failed'
}
publish(state)
return Promise.resolve(state)
}
publish({ ...blankState(connectionId), status: 'updating' })
const run = (async (): Promise<ManagedUpdateState> => {
try {
const result = await updateManaged(connectionId)
const state = terminalState(connectionId, result)
publish(state)
return state
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
const alreadyRunning =
(error as { code?: unknown } | null)?.code === 'managed-update-in-progress' ||
isManagedUpdateBusyMessage(message)
const state: ManagedUpdateState = {
...blankState(connectionId),
alreadyRunning,
finishedAt: Date.now(),
message,
status: alreadyRunning ? 'refused' : 'failed'
}
publish(state)
return state
} finally {
inFlight.delete(connectionId)
}
})()
inFlight.set(connectionId, run)
return run
}
/** @internal */
export function _resetManagedUpdatesForTests(): void {
inFlight.clear()
$managedUpdates.set({})
}
@@ -0,0 +1,31 @@
import { atom } from 'nanostores'
import { translateNow } from '@/i18n'
import { MCP_DEEPLINK_ERROR_KEYS, type McpInstallRequest, parseMcpInstallDeepLink } from '@/lib/mcp-deeplink'
import { notify } from './notifications'
/**
* Pending `hermes://mcp/install` request awaiting the user's explicit
* confirmation. Set by the deep-link listener, consumed by
* `McpInstallDeepLinkDialog`; null means no dialog. Nothing is written to
* config until the user confirms in the dialog.
*/
export const $mcpInstallRequest = atom<McpInstallRequest | null>(null)
/** Validate a deep link's params into a pending install, or toast a rejection. */
export function requestMcpInstallFromDeepLink(params: Record<string, string | undefined>): void {
const result = parseMcpInstallDeepLink(params)
if (!result.ok) {
notify({
kind: 'error',
title: translateNow('settings.mcp.deepLinkErrorTitle'),
message: translateNow(`settings.mcp.${MCP_DEEPLINK_ERROR_KEYS[result.error]}`)
})
return
}
$mcpInstallRequest.set(result.request)
}
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it, vi } from 'vitest'
// The store wires itself to gateway/profile atoms and the REST layer at import
// time paths; mock the seams (same shape as updates.test.ts) so this test only
// exercises the pure transition state machine.
vi.mock('@/hermes', () => ({
getHermesConfigRecord: vi.fn(),
testMcpServer: vi.fn()
}))
vi.mock('@/i18n', () => ({
translateNow: (key: string) => key
}))
vi.mock('@/store/notifications', () => ({
notify: vi.fn()
}))
vi.mock('@/store/profile', () => ({
$activeGatewayProfile: { get: () => 'default', listen: () => () => {} },
normalizeProfileKey: (name: string | null | undefined) => (name ?? '').trim() || 'default'
}))
vi.mock('@/store/session', () => ({
$gatewayState: { get: () => 'closed', subscribe: () => () => {} }
}))
const { shouldNotifyOnTransition } = await import('./mcp-health')
type Status = 'error' | 'needs-auth' | 'ok'
describe('shouldNotifyOnTransition', () => {
// The full previous × next decision table: notify only on a TRANSITION into
// a bad state. Rechecks of an already-bad server stay quiet; ok never nudges.
it.each<[previous: Status | null, next: Status, notify: boolean]>([
// First observation of the session (previous unknown).
[null, 'ok', false],
[null, 'needs-auth', true],
[null, 'error', true],
// Healthy server stays healthy / breaks.
['ok', 'ok', false],
['ok', 'needs-auth', true],
['ok', 'error', true],
// Already-broken server: rechecks must NOT re-notify…
['needs-auth', 'needs-auth', false],
['error', 'error', false],
// …but flipping from one bad state to the other is a new transition.
['needs-auth', 'error', true],
['error', 'needs-auth', true],
// Recovery is silent.
['needs-auth', 'ok', false],
['error', 'ok', false]
])('previous=%s next=%s → notify=%s', (previous, next, expected) => {
expect(shouldNotifyOnTransition(previous, next)).toBe(expected)
})
})
Loaded 100 of 11340 files, more files were not shown because too many files have changed in this diff. Show more