Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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 ?? (() => {})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user