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

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
@@ -0,0 +1,245 @@
import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { BrandMark } from '@/components/brand-mark'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { type Translations, useI18n } from '@/i18n'
import { useAiturkCopy } from '@/i18n/aiturk'
import { AlertTriangle, CheckCircle2, ExternalLink, Loader2, RefreshCw } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$desktopVersion,
$updateApply,
$updateChecking,
$updateStatus,
checkUpdates,
openUpdatesWindow,
refreshDesktopVersion,
startActiveUpdate
} from '@/store/updates'
import { ListRow, SectionHeading, SettingsContent } from './primitives'
import { UninstallSection } from './uninstall-section'
const RELEASE_NOTES_URL = 'https://gitea.twinpay.one/yilsem/aiturk-hermes-ide/releases'
const INSTALLER_URL = 'https://turkservis.online/ide'
function relativeTime(ms: number | undefined, a: Translations['settings']['about']) {
if (!ms) {
return a.never
}
const diff = Date.now() - ms
if (diff < 60_000) {
return a.justNow
}
if (diff < 3_600_000) {
return a.minAgo(Math.round(diff / 60_000))
}
if (diff < 86_400_000) {
return a.hoursAgo(Math.round(diff / 3_600_000))
}
return a.daysAgo(Math.round(diff / 86_400_000))
}
export function AboutSettings() {
const { t } = useI18n()
const copy = useAiturkCopy()
const a = t.settings.about
const version = useStore($desktopVersion)
const status = useStore($updateStatus)
const apply = useStore($updateApply)
const checking = useStore($updateChecking)
const [justChecked, setJustChecked] = useState(false)
// The version atom is loaded once at app boot, which makes About show a
// stale number after a self-update (the running binary is current, the
// displayed string is not). Re-read on mount so opening About always
// reflects the running build.
useEffect(() => {
void refreshDesktopVersion()
}, [])
const behind = status?.behind ?? 0
// behind is null when the exact count is unknowable (shallow clone): the
// backend flags that case via updateAvailable instead of a number.
const updateAvailable = behind > 0 || Boolean(status?.updateAvailable)
const supported = status?.supported !== false
const applying = apply.applying || apply.stage === 'restart'
const handleCheck = async () => {
setJustChecked(false)
const next = await checkUpdates()
setJustChecked(Boolean(next))
}
let statusLine: string
let statusTone: 'idle' | 'available' | 'error' = 'idle'
if (!supported) {
statusLine = status?.message ?? a.cantUpdate
statusTone = 'error'
} else if (status?.error) {
statusLine = a.cantReach
statusTone = 'error'
} else if (applying) {
statusLine = a.installing
statusTone = 'available'
} else if (updateAvailable) {
statusLine = behind > 0 ? a.updateReady(behind) : a.updateReadyUnknown
statusTone = 'available'
} else if (status) {
statusLine = a.onLatest
} else {
statusLine = a.tapCheck
}
return (
<SettingsContent>
<section className="mb-6 grid gap-2 rounded-lg border border-border p-4">
<h2 className="text-lg font-semibold">AITURK IDE</h2>
<p className="text-sm text-muted-foreground">{copy.about}</p>
<div className="flex flex-wrap gap-4 text-sm">
<a className="text-primary underline" href={INSTALLER_URL} rel="noreferrer" target="_blank">{copy.downloads}</a>
<a className="text-primary underline" href="https://gitea.twinpay.one/yilsem/aiturk-hermes-ide" rel="noreferrer" target="_blank">{copy.source}</a>
</div>
</section>
<div className="flex flex-col items-center gap-3 pt-6 pb-2 text-center">
<BrandMark className="size-16" />
<div>
<h2 className="text-lg font-semibold tracking-tight">{a.heading}</h2>
<p className="mt-1 text-xs text-muted-foreground">
{version?.appVersion ? a.version(version.appVersion) : a.versionUnavailable}
</p>
</div>
{(version?.bundleOutOfSync || version?.bundleSwapPending) && (
<div className="mx-auto w-full max-w-2xl rounded-xl border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-left text-sm">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-600 dark:text-amber-400" />
<div className="min-w-0">
{version?.bundleSwapPending ? (
// The updated app is already on disk — the updater swapped it
// under this running process — so a restart loads it. Saying
// "App build out of date" here would repeat the contradiction
// this banner is meant to resolve: the Updates card below
// already reports the runtime as current.
<>
<p className="font-medium">{a.bundleSwapPending}</p>
<p className="mt-1 text-xs text-muted-foreground">{a.bundleSwapPendingDesc}</p>
<Button
className="mt-2"
onClick={() => void window.hermesDesktop?.relaunchApp?.()}
size="sm"
variant="textStrong"
>
<RefreshCw className="size-3" />
{a.bundleSwapPendingAction}
</Button>
</>
) : (
<>
<p className="font-medium">{a.bundleOutOfSync}</p>
<p className="mt-1 text-xs text-muted-foreground">{a.bundleOutOfSyncDesc}</p>
<Button asChild className="mt-2" size="sm" variant="textStrong">
<a
href={INSTALLER_URL}
onClick={event => {
event.preventDefault()
void window.hermesDesktop?.openExternal?.(INSTALLER_URL)
}}
rel="noreferrer"
target="_blank"
>
<ExternalLink className="size-3" />
{a.bundleOutOfSyncAction}
</a>
</Button>
</>
)}
</div>
</div>
</div>
)}
</div>
<div className="mx-auto mt-4 w-full max-w-2xl">
<SectionHeading icon={RefreshCw} title={a.updates} />
<div
className={cn(
'rounded-xl border px-4 py-3 text-sm',
statusTone === 'available' && 'border-primary/30 bg-primary/5 text-foreground',
statusTone === 'error' && 'border-destructive/35 bg-destructive/5 text-destructive',
statusTone === 'idle' && 'border-border/70 bg-muted/20 text-foreground'
)}
>
<div className="flex items-start gap-2">
{statusTone === 'available' ? (
<Codicon className="mt-0.5 size-4 shrink-0 text-primary" name="cloud-download" size="1rem" />
) : statusTone === 'error' ? null : (
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
)}
<div className="min-w-0">
<p className="font-medium">{statusLine}</p>
<p className="mt-1 text-xs text-muted-foreground">
{a.lastChecked(relativeTime(status?.fetchedAt, a))}
{justChecked && !checking ? a.justNowSuffix : ''}
</p>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-4">
<Button
disabled={checking || applying || !supported}
onClick={() => void handleCheck()}
size="sm"
variant="textStrong"
>
{checking ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
{checking ? a.checking : a.checkNow}
</Button>
{updateAvailable && supported && !applying && (
<>
<Button onClick={() => startActiveUpdate()} size="sm">
{a.updateNow}
</Button>
<Button onClick={() => openUpdatesWindow('client')} size="sm" variant="textStrong">
{a.seeWhatsNew}
</Button>
</>
)}
<Button asChild className="ml-auto" size="sm" variant="text">
<a
href={RELEASE_NOTES_URL}
onClick={event => {
event.preventDefault()
void window.hermesDesktop?.openExternal?.(RELEASE_NOTES_URL)
}}
rel="noreferrer"
target="_blank"
>
<ExternalLink className="size-3" />
{a.releaseNotes}
</a>
</Button>
</div>
</div>
<ListRow
description={a.automaticUpdatesDesc}
hint={a.branchCommit(status?.branch ?? 'unknown', status?.currentSha?.slice(0, 7) ?? 'unknown')}
title={a.automaticUpdates}
/>
<UninstallSection />
</div>
</SettingsContent>
)
}
@@ -0,0 +1,952 @@
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useDebounced } from '@/app/hooks/use-debounced'
import { LanguageSwitcher } from '@/components/language-switcher'
import { Button } from '@/components/ui/button'
import { SegmentedControl } from '@/components/ui/segmented-control'
import type { DesktopMarketplaceSearchItem } from '@/global'
import { saveHermesConfig } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Download, Loader2, Palette, Trash2 } from '@/lib/icons'
import { selectableCardClass } from '@/lib/selectable-card'
import { normalize } from '@/lib/text'
import { cn } from '@/lib/utils'
import { $backdrop, setBackdrop } from '@/store/backdrop'
import { $composerPopoutGesturesEnabled, setComposerPopoutGesturesEnabled } from '@/store/composer-popout'
import { $embedAllowed, $embedMode, clearEmbedAllowed, type EmbedMode, setEmbedMode } from '@/store/embed-consent'
import { $introSplash, setIntroSplash } from '@/store/intro-splash'
import { notifyError } from '@/store/notifications'
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
import { $reactionsEnabled, setReactionsEnabled } from '@/store/reactions-enabled'
import { $reasoningCollapsedByDefault, setReasoningCollapsedByDefault } from '@/store/reasoning-disclosure'
import { $sessionListDensity, type SessionListDensity, setSessionListDensity } from '@/store/session-list-density'
import { $tabStripDefault, setTabStripDefault, type TabStripDefault } from '@/store/tabstrip-prefs'
import { $retiredTips, $tipsEnabled, resetTips, setTipsEnabled } from '@/store/tips'
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
import { $toursEnabled, setToursEnabled } from '@/store/tours'
import {
$translucency,
beginTranslucencyPeek,
endTranslucencyPeek,
GLASS_IS_WINDOWS,
GLASS_SCOPES,
GLASS_SUPPORTED,
glassMaterialForPicker,
glassMaterialsFor,
pulseTranslucencyPeek,
resetTranslucencyPeek,
setTranslucency,
setTranslucencyFade,
setTranslucencyMaterial,
setTranslucencyMode,
setTranslucencyScope,
TRANSLUCENCY_MAX,
TRANSLUCENCY_MIN,
TRANSLUCENCY_STEP,
TRANSLUCENCY_SUPPORTED
} from '@/store/translucency'
import { $vibeHeartsEnabled, setVibeHeartsEnabled } from '@/store/vibe-hearts-enabled'
import { $zoomPercent, setZoomPercent } from '@/store/zoom'
import { getBaseColors, useTheme } from '@/themes/context'
import { installVscodeThemeFromMarketplace } from '@/themes/install'
import type { DesktopTheme } from '@/themes/types'
import { $marketplaceInstalls, isUserTheme, removeUserTheme } from '@/themes/user-themes'
import { setHermesConfigCache, useHermesConfigRecord } from '../hooks/use-config-record'
import { MODE_OPTIONS } from './constants'
import { setNested } from './helpers'
import { PetSettings } from './pet-settings'
import { ListRow, SectionHeading, SettingsContent, ToggleRow } from './primitives'
import { APPEARANCE_SETTING_IDS } from './settings-search'
import { TerminalFontSetting } from './terminal-font-setting'
import { useDeepLinkHighlight } from './use-deep-link-highlight'
// display.resume_last_session lives in the backend config record (shared with
// config.yaml and the cold-start restore in use-desktop-integrations), not a
// renderer store. Saves write through the shared react-query cache so the
// restore gate sees the new value on the next launch.
function ResumeLastSessionSetting() {
const { t } = useI18n()
const a = t.settings.appearance
const configQuery = useHermesConfigRecord()
const config = configQuery.data
const checked = (config?.display as { resume_last_session?: unknown } | undefined)?.resume_last_session !== false
const update = (on: boolean) => {
if (!config) {
return
}
const next = setNested(config, 'display.resume_last_session', on)
setHermesConfigCache(next)
void saveHermesConfig(next)
.then(result => {
if (!result.ok) {
throw new Error(t.settings.config.autosaveFailed)
}
})
.catch(error => {
setHermesConfigCache(config)
notifyError(error, t.settings.config.autosaveFailed)
})
}
return (
<ToggleRow
checked={checked}
description={a.resumeLastSessionDesc}
disabled={!config}
label={a.resumeLastSessionTitle}
onChange={update}
/>
)
}
function ThemePreview({ name, mode }: { name: string; mode: 'light' | 'dark' }) {
// Preview in the *current* mode: the dark palette in Dark, and the light
// palette in Light — synthesizing one for dark-only themes — so every card
// tracks the Light/Dark toggle, exactly like the app itself does.
const c = getBaseColors(name, mode)
return (
<div
className="h-20 overflow-hidden rounded-xl border shadow-xs"
style={{ backgroundColor: c.background, borderColor: c.border }}
>
<div className="flex h-full">
<div
className="w-12 border-r"
style={{
backgroundColor: c.sidebarBackground ?? c.muted,
borderColor: c.sidebarBorder ?? c.border
}}
/>
<div className="flex flex-1 flex-col gap-2 p-3">
<div className="h-2.5 w-16 rounded-full" style={{ backgroundColor: c.foreground }} />
<div className="h-2 w-24 rounded-full" style={{ backgroundColor: c.mutedForeground }} />
<div className="mt-auto flex justify-end">
<div
className="h-5 w-16 rounded-full border"
style={{
backgroundColor: c.userBubble ?? c.muted,
borderColor: c.userBubbleBorder ?? c.border
}}
/>
</div>
</div>
</div>
</div>
)
}
// UI scale presets, as zoom percentages. 100 is Chromium's actual-size
// baseline; the shipped default is the 90% preset. Ids double as the percent
// values sent to the main process. A Cmd/Ctrl +/- step landing between
// presets highlights nothing, and the row description keeps showing the
// exact current percent.
const UI_SCALE_PRESETS = ['90', '100', '110', '125', '150', '175'] as const
const APPEARANCE_SEARCH_TARGETS = new Set<string>(Object.values(APPEARANCE_SETTING_IDS))
const appearanceSettingElementId = (id: string) => `setting-field-${id}`
type UiScalePreset = (typeof UI_SCALE_PRESETS)[number]
function matchUiScalePreset(percent: number): UiScalePreset | null {
return UI_SCALE_PRESETS.find(preset => Number(preset) === percent) ?? null
}
const compactNumber = new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 })
/**
* Live VS Code Marketplace theme search (the same backend as the Cmd-K "Install
* theme…" page). Renders below the local grid when there's a query: each row
* downloads + converts + installs via `installVscodeThemeFromMarketplace` and
* activates it. Extensions already imported locally are marked installed.
*/
function MarketplaceThemeResults({
query,
installs,
onInstalled
}: {
query: string
installs: ReadonlyMap<string, DesktopTheme>
onInstalled: (name: string) => void
}) {
const { t } = useI18n()
const copy = t.commandCenter.installTheme
const debounced = useDebounced(query.trim(), 300)
const [installingId, setInstallingId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const search = useQuery({
enabled: debounced.length > 0,
queryFn: () => window.hermesDesktop?.themes?.searchMarketplace(debounced) ?? Promise.resolve([]),
queryKey: ['marketplace-themes-settings', debounced],
staleTime: 5 * 60 * 1000
})
// Already installed → just re-activate it; never re-download what we have.
const select = (item: DesktopMarketplaceSearchItem) => {
const owned = installs.get(item.extensionId)
if (owned) {
triggerHaptic('crisp')
onInstalled(owned.name)
return
}
void install(item)
}
const install = async (item: DesktopMarketplaceSearchItem) => {
if (installingId) {
return
}
setInstallingId(item.extensionId)
setError(null)
try {
const theme = await installVscodeThemeFromMarketplace(item.extensionId)
triggerHaptic('crisp')
onInstalled(theme.name)
} catch (e) {
setError(e instanceof Error ? e.message : copy.error)
} finally {
setInstallingId(null)
}
}
if (!debounced) {
return null
}
const header = (
<p className="mb-2 mt-4 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
From the VS Code Marketplace
</p>
)
if (search.isLoading) {
return (
<>
{header}
<p className="flex items-center gap-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<Loader2 className="size-3.5 animate-spin" />
{copy.loading}
</p>
</>
)
}
if (search.isError) {
return (
<>
{header}
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-red)">{copy.error}</p>
</>
)
}
const results = search.data ?? []
if (results.length === 0) {
return (
<>
{header}
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">{copy.empty}</p>
</>
)
}
return (
<>
{header}
{error && <p className="mb-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-red)">{error}</p>}
<div className="grid gap-2 sm:grid-cols-2">
{results.map(item => {
const busy = installingId === item.extensionId
const done = installs.has(item.extensionId)
return (
<button
className={cn(
'flex items-center gap-2.5 px-2.5 py-2 text-left disabled:opacity-60',
selectableCardClass({ prominent: done })
)}
disabled={Boolean(installingId) && !busy}
key={item.extensionId}
onClick={() => select(item)}
type="button"
>
<Palette className="size-4 shrink-0 text-(--ui-text-tertiary)" />
<span className="min-w-0 flex-1">
<span className="block truncate text-[length:var(--conversation-text-font-size)] font-medium">
{item.displayName}
</span>
<span className="block truncate text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{item.publisher}
{item.installs > 0 ? ` · ${copy.installs(compactNumber.format(item.installs))}` : ''}
</span>
</span>
<span className="shrink-0 text-(--ui-text-tertiary)">
{busy ? (
<Loader2 className="size-4 animate-spin" />
) : done ? (
<Check className="size-4 text-(--ui-green)" />
) : (
<Download className="size-4" />
)}
</span>
</button>
)
})}
</div>
</>
)
}
// Keys a range input treats as a step, so the peek can flash the live window
// for keyboard adjustment the way a pointer drag holds it open.
const SLIDER_STEP_KEYS = new Set([
'ArrowDown',
'ArrowLeft',
'ArrowRight',
'ArrowUp',
'End',
'Home',
'PageDown',
'PageUp'
])
interface TranslucencySliderProps {
label: string
onChange: (value: number) => void
value: number
}
/**
* One 0100 lever, used up to twice: Clear's window opacity, and under Glass
* the tint plus an optional native fade.
*
* Peek while the hand is on it — the overlay (scrim + near-opaque card) ghosts
* so the window behind IS the live preview. The pointer pair covers
* mouse/touch drags; the keyboard path pulses per step instead, and blur ends
* any residual hold.
*/
function TranslucencySlider({ label, onChange, value }: TranslucencySliderProps) {
return (
<>
<input
aria-label={label}
className="h-1 w-40 cursor-pointer appearance-none rounded-full bg-(--ui-stroke-tertiary)"
max={TRANSLUCENCY_MAX}
min={TRANSLUCENCY_MIN}
onBlur={endTranslucencyPeek}
onChange={event => {
triggerHaptic('selection')
onChange(Number(event.target.value))
}}
onKeyDown={event => {
if (SLIDER_STEP_KEYS.has(event.key)) {
pulseTranslucencyPeek()
}
}}
onLostPointerCapture={endTranslucencyPeek}
onPointerDown={beginTranslucencyPeek}
onPointerUp={endTranslucencyPeek}
step={TRANSLUCENCY_STEP}
style={{ accentColor: 'var(--dt-primary)' }}
type="range"
value={value}
/>
<span className="w-9 text-right text-[length:var(--conversation-caption-font-size)] tabular-nums text-(--ui-text-tertiary)">
{value}%
</span>
</>
)
}
interface GlassRowProps {
children: React.ReactNode
label: string
}
/** A labelled control in the Glass sub-panel: tint, fade, frost, area. */
function GlassRow({ children, label }: GlassRowProps) {
return (
<div className="flex items-center gap-3">
<span className="w-12 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{label}
</span>
{children}
</div>
)
}
export function AppearanceSettings() {
const { t, isSavingLocale } = useI18n()
const { themeName, mode, resolvedMode, availableThemes, setTheme, setMode } = useTheme()
const toolViewMode = useStore($toolViewMode)
const reasoningCollapsedByDefault = useStore($reasoningCollapsedByDefault)
const sessionListDensity = useStore($sessionListDensity)
const tabStripDefault = useStore($tabStripDefault)
const zoomPercent = useStore($zoomPercent)
const embedMode = useStore($embedMode)
const embedAllowed = useStore($embedAllowed)
const composerPopoutGesturesEnabled = useStore($composerPopoutGesturesEnabled)
const translucency = useStore($translucency)
const glassMode = translucency.mode === 'glass' && GLASS_SUPPORTED
const reactionsEnabled = useStore($reactionsEnabled)
const tipsEnabled = useStore($tipsEnabled)
const toursEnabled = useStore($toursEnabled)
const retiredTips = useStore($retiredTips)
const vibeHeartsEnabled = useStore($vibeHeartsEnabled)
const backdrop = useStore($backdrop)
const introSplash = useStore($introSplash)
const installs = useStore($marketplaceInstalls)
const profiles = useStore($profiles)
const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile))
const a = t.settings.appearance
// A pointer held on the intensity slider when this overlay closes (Escape
// mid-drag) never delivers its pointerup here, which would strand the peek
// counter above zero and ghost the NEXT settings overlay. Unmount drops
// every outstanding hold.
useEffect(() => resetTranslucencyPeek, [])
// Shared by the mode/frost/area pickers: apply the choice, then show it
// through the overlay it just altered (a pulse, not a hold — see the peek
// notes on the slider itself).
const pickTranslucency =
<T,>(set: (value: T) => void) =>
(value: T) => {
triggerHaptic('selection')
set(value)
if (translucency.intensity > 0) {
pulseTranslucencyPeek()
}
}
const [query, setQuery] = useState('')
useDeepLinkHighlight({
elementId: appearanceSettingElementId,
param: 'setting',
ready: id => APPEARANCE_SEARCH_TARGETS.has(id)
})
// One box does double duty: filter installed themes live (below), and run a
// name search against the VS Code Marketplace (the Cmd-K "Install theme…"
// backend) for anything not already installed.
const needle = normalize(query)
const filteredThemes = availableThemes
.filter(
theme =>
!needle ||
theme.label.toLowerCase().includes(needle) ||
theme.name.toLowerCase().includes(needle) ||
theme.description.toLowerCase().includes(needle)
)
// Active theme first; stable sort keeps the rest in their original order.
.sort((a, b) => Number(b.name === themeName) - Number(a.name === themeName))
// Themes save per profile. Surface that only when the user actually has more
// than one profile (single-profile installs never see the distinction).
const showProfileNote = profiles.length > 1
const activeProfileName =
profiles.find(profile => normalizeProfileKey(profile.name) === activeProfileKey)?.name ?? activeProfileKey
const modeOptions = MODE_OPTIONS.map(({ id, icon }) => ({ icon, id, label: t.settings.modeOptions[id].label }))
const toolOptions = [
{ id: 'product', label: a.product },
{ id: 'technical', label: a.technical }
] as const
const sessionDensityOptions = [
{ id: 'compact', label: a.sessionDensityCompact },
{ id: 'comfortable', label: a.sessionDensityComfortable },
{ id: 'detailed', label: a.sessionDensityDetailed }
] as const satisfies readonly { id: SessionListDensity; label: string }[]
const tabStripOptions = [
{ id: 'auto', label: a.tabStripAuto },
{ id: 'always', label: a.tabStripAlways },
{ id: 'never', label: a.tabStripNever }
] as const satisfies readonly { id: TabStripDefault; label: string }[]
const embedOptions = [
{ id: 'ask', label: a.embedsAsk },
{ id: 'always', label: a.embedsAlways },
{ id: 'off', label: a.embedsOff }
] as const satisfies readonly { id: EmbedMode; label: string }[]
const uiScaleOptions = UI_SCALE_PRESETS.map(preset => ({ id: preset, label: `${preset}%` }))
const matchedScalePreset = matchUiScalePreset(zoomPercent)
return (
<SettingsContent>
<div>
<SectionHeading icon={Palette} title={a.title} />
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{a.intro}
</p>
<div className="mt-2">
<ListRow
action={<LanguageSwitcher />}
description={isSavingLocale ? t.language.saving : t.language.description}
id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.language)}
title={t.language.label}
/>
<ListRow
below={
<>
{/* One search box: filters your installed themes (the grid)
and live-searches the VS Code Marketplace below. */}
<div className="mt-3">
<input
className="w-full rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-1.5 text-[length:var(--conversation-caption-font-size)] outline-none placeholder:text-(--ui-text-tertiary) focus:border-(--ui-stroke-secondary)"
onChange={event => setQuery(event.target.value)}
placeholder="Search your themes or the VS Code Marketplace…"
spellCheck={false}
value={query}
/>
</div>
{/* Fixed-height scroll area so the (growing) theme list never
runs the page long; the grid scrolls inside it. */}
<div className="mt-3 max-h-96 overflow-y-auto pr-1">
{filteredThemes.length === 0 ? (
needle ? (
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
No installed themes match "{query.trim()}".
</p>
) : null
) : (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{filteredThemes.map(theme => {
const active = themeName === theme.name
const removable = isUserTheme(theme.name)
return (
<div className="group relative" key={theme.name}>
<button
className={cn('w-full p-2 text-left', selectableCardClass({ active, prominent: true }))}
onClick={() => {
triggerHaptic('crisp')
setTheme(theme.name)
}}
type="button"
>
<ThemePreview mode={resolvedMode} name={theme.name} />
<div className="mt-3 px-1">
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
{theme.label}
</div>
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{theme.description}
</div>
</div>
</button>
{removable && (
<button
aria-label={a.removeTheme}
className="absolute right-1.5 top-1.5 grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) opacity-0 backdrop-blur-sm transition hover:text-(--ui-red) focus-visible:opacity-100 group-hover:opacity-100"
onClick={() => {
triggerHaptic('crisp')
removeUserTheme(theme.name)
// Re-normalize off the now-missing skin → default.
if (active) {
setTheme(theme.name)
}
}}
title={a.removeTheme}
type="button"
>
<Trash2 className="size-3.5" />
</button>
)}
</div>
)
})}
</div>
)}
<MarketplaceThemeResults installs={installs} onInstalled={name => setTheme(name)} query={query} />
</div>
{showProfileNote && (
<p className="mt-3 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{a.themeProfileNote(activeProfileName)}
</p>
)}
</>
}
description={a.themeDesc}
id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.theme)}
title={
<div className="flex items-center justify-between gap-3">
<span>{a.themeTitle}</span>
<SegmentedControl
onChange={id => {
triggerHaptic('crisp')
setMode(id)
}}
options={modeOptions}
value={mode}
/>
</div>
}
wide
/>
<ListRow
action={
<SegmentedControl
onChange={id => {
triggerHaptic('selection')
setZoomPercent(Number(id))
}}
options={uiScaleOptions}
value={matchedScalePreset ?? ('' as UiScalePreset)}
/>
}
description={a.uiScaleDesc(zoomPercent)}
id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.uiScale)}
title={a.uiScaleTitle}
/>
<TerminalFontSetting />
<ListRow
action={
<SegmentedControl
onChange={id => {
triggerHaptic('selection')
setSessionListDensity(id)
}}
options={sessionDensityOptions}
value={sessionListDensity}
/>
}
description={a.sessionDensityDesc}
title={a.sessionDensityTitle}
/>
<ListRow
action={
<SegmentedControl
onChange={id => {
triggerHaptic('selection')
setTabStripDefault(id)
}}
options={tabStripOptions}
value={tabStripDefault}
/>
}
description={a.tabStripDesc}
title={a.tabStripTitle}
/>
{/* Linux has neither half of this setting (see TRANSLUCENCY_SUPPORTED),
so the row is absent there rather than offering a dead lever. */}
{TRANSLUCENCY_SUPPORTED && (
<ListRow
action={
<div
className="flex items-center gap-3"
// Arms the peek for the overlay this row lives in — the
// ghosting rules in styles.css scope to it, so no other
// overlay pays for an opacity transition it never uses.
data-translucency-peek-scope=""
>
{GLASS_SUPPORTED && (
<SegmentedControl
onChange={pickTranslucency(setTranslucencyMode)}
options={[
{ id: 'clear' as const, label: a.translucencyModeClear },
{ id: 'glass' as const, label: a.translucencyModeGlass }
]}
value={translucency.mode}
/>
)}
{/* Clear has one lever and it belongs beside the mode. Glass
has four controls, so they move into the labelled panel
below rather than crowding this line with an unlabelled
slider that means something different. */}
{!glassMode && (
<TranslucencySlider
label={a.translucencyTitle}
onChange={setTranslucency}
value={translucency.intensity}
/>
)}
</div>
}
below={
glassMode ? (
<div className="mt-3 flex flex-col gap-2.5" data-translucency-peek-scope="">
<GlassRow label={a.translucencyTintTitle}>
<TranslucencySlider
label={a.translucencyTintTitle}
onChange={setTranslucency}
value={translucency.intensity}
/>
</GlassRow>
<GlassRow label={a.translucencyFadeTitle}>
<TranslucencySlider
label={a.translucencyFadeTitle}
onChange={setTranslucencyFade}
value={translucency.fade}
/>
</GlassRow>
<GlassRow label={a.translucencyFrostTitle}>
<SegmentedControl
onChange={pickTranslucency(setTranslucencyMaterial)}
// Windows renders four rungs as three backdrops, so it
// is offered three; a frost saved on a Mac highlights
// the rung that renders the same backdrop here.
options={glassMaterialsFor(GLASS_IS_WINDOWS).map(material => ({
id: material,
label: a.translucencyFrost[material]
}))}
value={glassMaterialForPicker(translucency.material, GLASS_IS_WINDOWS)}
/>
</GlassRow>
<GlassRow label={a.translucencyScopeTitle}>
<SegmentedControl
onChange={pickTranslucency(setTranslucencyScope)}
options={GLASS_SCOPES.map(scope => ({
id: scope,
label: a.translucencyScope[scope]
}))}
value={translucency.scope}
/>
</GlassRow>
</div>
) : undefined
}
description={glassMode ? a.translucencyGlassDesc : a.translucencyDesc}
id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.translucency)}
title={a.translucencyTitle}
/>
)}
<ListRow
action={
<SegmentedControl
onChange={id => {
triggerHaptic('selection')
setBackdrop(id === 'on')
}}
options={[
{ id: 'off', label: t.common.off },
{ id: 'on', label: t.common.on }
]}
value={backdrop ? 'on' : 'off'}
/>
}
description={a.backdropDesc}
id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.backdrop)}
title={a.backdropTitle}
/>
<ListRow
action={
<SegmentedControl
onChange={id => {
triggerHaptic('selection')
setIntroSplash(id === 'on')
}}
options={[
{ id: 'off', label: t.common.off },
{ id: 'on', label: t.common.on }
]}
value={introSplash ? 'on' : 'off'}
/>
}
description={a.introSplashDesc}
id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.introSplash)}
title={a.introSplashTitle}
/>
<ToggleRow
checked={composerPopoutGesturesEnabled}
description={a.composerPopoutDesc}
label={a.composerPopoutTitle}
onChange={setComposerPopoutGesturesEnabled}
/>
<ResumeLastSessionSetting />
<ListRow
action={
<SegmentedControl
onChange={id => {
triggerHaptic('selection')
setReactionsEnabled(id === 'on')
}}
options={[
{ id: 'off', label: t.common.off },
{ id: 'on', label: t.common.on }
]}
value={reactionsEnabled ? 'on' : 'off'}
/>
}
description={a.reactionsDesc}
title={a.reactionsTitle}
/>
<ListRow
action={
<div className="flex flex-col items-end gap-1.5">
<SegmentedControl
onChange={id => {
triggerHaptic('selection')
setTipsEnabled(id === 'on')
}}
options={[
{ id: 'off', label: t.common.off },
{ id: 'on', label: t.common.on }
]}
value={tipsEnabled ? 'on' : 'off'}
/>
{/* The ✕ on a tip is permanent, so this is the only way back.
It appears once there is something to bring back. */}
{retiredTips.length > 0 && (
<Button
onClick={() => {
triggerHaptic('selection')
resetTips()
}}
size="inline"
variant="text"
>
{a.tipsReset(retiredTips.length)}
</Button>
)}
</div>
}
description={a.tipsDesc}
title={a.tipsTitle}
/>
<ListRow
action={
<SegmentedControl
onChange={id => {
triggerHaptic('selection')
setToursEnabled(id === 'on')
}}
options={[
{ id: 'off', label: t.common.off },
{ id: 'on', label: t.common.on }
]}
value={toursEnabled ? 'on' : 'off'}
/>
}
description={a.toursDesc}
title={a.toursTitle}
/>
<ListRow
action={
<SegmentedControl
onChange={id => {
triggerHaptic('selection')
setVibeHeartsEnabled(id === 'on')
}}
options={[
{ id: 'off', label: t.common.off },
{ id: 'on', label: t.common.on }
]}
value={vibeHeartsEnabled ? 'on' : 'off'}
/>
}
description={a.vibeHeartsDesc}
title={a.vibeHeartsTitle}
/>
<ListRow
action={
<SegmentedControl
onChange={id => {
triggerHaptic('selection')
setToolViewMode(id)
}}
options={toolOptions}
value={toolViewMode}
/>
}
description={a.toolViewDesc}
id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.toolView)}
title={a.toolViewTitle}
/>
<ListRow
action={
<SegmentedControl
onChange={id => {
triggerHaptic('selection')
setReasoningCollapsedByDefault(id === 'on')
}}
options={[
{ id: 'off', label: t.common.off },
{ id: 'on', label: t.common.on }
]}
value={reasoningCollapsedByDefault ? 'on' : 'off'}
/>
}
description={a.reasoningCollapsedDesc}
title={a.reasoningCollapsedTitle}
/>
<ListRow
action={
<div className="flex flex-col items-end gap-1.5">
<SegmentedControl
onChange={id => {
triggerHaptic('selection')
setEmbedMode(id)
}}
options={embedOptions}
value={embedMode}
/>
{embedAllowed.length > 0 && (
<Button
onClick={() => {
triggerHaptic('selection')
clearEmbedAllowed()
}}
size="inline"
variant="text"
>
{a.embedsReset(embedAllowed.length)}
</Button>
)}
</div>
}
description={a.embedsDesc}
id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.embeds)}
title={a.embedsTitle}
/>
</div>
</div>
<div className="mt-6">
<PetSettings />
</div>
</SettingsContent>
)
}
@@ -0,0 +1,48 @@
import { Button } from '@/components/ui/button'
import { ExternalLink } from '@/lib/icons'
import { Pill } from '../primitives'
import { openExternal } from './open-external'
import type { BillingAccountRowView } from './use-billing-state'
export function RowValue({ onAction, row }: { onAction?: () => void; row: BillingAccountRowView }) {
// Destructure to a const so narrowing survives into the onClick closure below.
const { action } = row
return (
<div className="flex min-w-0 flex-wrap items-center justify-start gap-2 @2xl:justify-end">
{row.value && (
<span className="min-w-0 truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{row.value}
</span>
)}
{row.pill && <Pill tone={row.pill.tone}>{row.pill.label}</Pill>}
{row.secondaryPill && <Pill>{row.secondaryPill}</Pill>}
{row.chips?.map(chip => (
<Button
disabled={chip.disabled}
key={chip.label}
onClick={chip.url ? () => openExternal(chip.url) : undefined}
size="sm"
type="button"
variant="outline"
>
{chip.label}
</Button>
))}
{action && (
<Button
disabled={action.disabled}
onClick={action.disabled ? undefined : onAction ? onAction : () => openExternal(action.url)}
size="sm"
type="button"
variant="outline"
>
{action.label}
{!action.disabled && action.url && <ExternalLink className="size-3.5" />}
</Button>
)}
</div>
)
}
@@ -0,0 +1,214 @@
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { BillingChargeResponse, BillingStateResponse } from './types'
const requestGatewayMock = vi.hoisted(() => vi.fn())
vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({
useGatewayRequest: () => ({ requestGateway: requestGatewayMock })
}))
import { createBillingApi, useBillingApi } from './api'
describe('createBillingApi', () => {
beforeEach(() => {
requestGatewayMock.mockReset()
vi.restoreAllMocks()
})
it('passes successful RPC results through as data', async () => {
const state = {
auto_reload: null,
balance_display: '$10.00',
balance_usd: '10',
can_charge: true,
card: null,
charge_presets: ['10'],
charge_presets_display: ['$10'],
cli_billing_enabled: true,
is_admin: true,
logged_in: true,
max_usd: '100',
min_usd: '10',
monthly_cap: null,
ok: true,
org_name: 'Nous',
portal_url: 'https://portal.nousresearch.com/billing',
role: 'OWNER'
} satisfies BillingStateResponse
requestGatewayMock.mockResolvedValueOnce(state)
const { result } = renderHook(() => useBillingApi())
const response = await result.current.fetchBillingState()
expect(response).toEqual({ data: state, ok: true })
expect(requestGatewayMock).toHaveBeenCalledWith('billing.state', {})
})
it('normalizes object-shaped refusal envelopes', async () => {
requestGatewayMock.mockResolvedValueOnce({
error: {
kind: 'no_payment_method',
message: 'No saved card.',
portal_url: 'https://portal.nousresearch.com/billing',
retry_after: 30
},
ok: false
})
const api = createBillingApi(requestGatewayMock)
const response = await api.chargeStatus('ch_123')
expect(response).toMatchObject({
ok: false,
refusal: {
kind: 'no_payment_method',
message: 'No saved card.',
portalUrl: 'https://portal.nousresearch.com/billing',
retryAfter: 30
}
})
expect(requestGatewayMock).toHaveBeenCalledWith('billing.charge_status', { charge_id: 'ch_123' })
})
it('normalizes current string-shaped refusal envelopes', async () => {
requestGatewayMock.mockResolvedValueOnce({
error: 'monthly_cap_exceeded',
message: 'Monthly spend cap reached.',
ok: false,
payload: { remainingUsd: '4.50' },
portal_url: 'https://portal.nousresearch.com/billing'
})
const api = createBillingApi(requestGatewayMock)
const response = await api.updateAutoReload({ enabled: true, reload_to_usd: '100', threshold_usd: '25' })
expect(response).toMatchObject({
ok: false,
refusal: {
kind: 'monthly_cap_exceeded',
message: 'Monthly spend cap reached.',
payload: { remainingUsd: '4.50' },
portalUrl: 'https://portal.nousresearch.com/billing'
}
})
expect(requestGatewayMock).toHaveBeenCalledWith('billing.auto_reload', {
enabled: true,
threshold: '25',
top_up_amount: '100'
})
})
it('maps thrown gateway failures to transport refusals', async () => {
requestGatewayMock.mockRejectedValueOnce(new Error('connection closed'))
const api = createBillingApi(requestGatewayMock)
const response = await api.fetchSubscriptionState()
expect(response).toEqual({
ok: false,
refusal: {
kind: 'transport',
message: 'connection closed',
raw: expect.any(Error)
}
})
})
it('maps thrown timeout failures to timeout refusals', async () => {
requestGatewayMock.mockRejectedValueOnce(new Error('request timed out after 5000ms'))
const api = createBillingApi(requestGatewayMock)
const response = await api.stepUp()
expect(response).toEqual({
ok: false,
refusal: {
kind: 'timeout',
message: 'request timed out after 5000ms',
raw: expect.any(Error)
}
})
})
it('previews a subscription change with the chosen tier id', async () => {
requestGatewayMock.mockResolvedValueOnce({ effect: 'scheduled', ok: true, target_tier_name: 'Plus' })
const api = createBillingApi(requestGatewayMock)
const response = await api.previewSubscriptionChange('tier_plus')
expect(response).toEqual({ data: { effect: 'scheduled', ok: true, target_tier_name: 'Plus' }, ok: true })
expect(requestGatewayMock).toHaveBeenCalledWith('subscription.preview', { subscription_type_id: 'tier_plus' })
})
it('schedules a subscription change with the chosen tier id', async () => {
requestGatewayMock.mockResolvedValueOnce({ message: 'Downgrade scheduled.', ok: true })
const api = createBillingApi(requestGatewayMock)
const response = await api.scheduleSubscriptionChange('tier_plus')
expect(response).toEqual({ data: { message: 'Downgrade scheduled.', ok: true }, ok: true })
expect(requestGatewayMock).toHaveBeenCalledWith('subscription.change', { subscription_type_id: 'tier_plus' })
})
it('resumes (undoes) a scheduled change with no params', async () => {
requestGatewayMock.mockResolvedValueOnce({ message: 'Change cancelled.', ok: true })
const api = createBillingApi(requestGatewayMock)
const response = await api.resumeSubscription()
expect(response).toEqual({ data: { message: 'Change cancelled.', ok: true }, ok: true })
expect(requestGatewayMock).toHaveBeenCalledWith('subscription.resume', {})
})
it('surfaces an insufficient_scope refusal from a subscription preview', async () => {
requestGatewayMock.mockResolvedValueOnce({
error: { kind: 'insufficient_scope', message: 'billing:manage required' },
ok: false
})
const api = createBillingApi(requestGatewayMock)
const response = await api.scheduleSubscriptionChange('tier_plus')
expect(response).toMatchObject({ ok: false, refusal: { kind: 'insufficient_scope' } })
})
it('sends a step-up session id when provided', async () => {
requestGatewayMock.mockResolvedValueOnce({ granted: true, ok: true })
const api = createBillingApi(requestGatewayMock)
await api.stepUp('session-123')
expect(requestGatewayMock).toHaveBeenCalledWith('billing.step_up', { session_id: 'session-123' })
})
it('sends a minted charge idempotency key and reuses it on explicit retry', async () => {
vi.spyOn(crypto, 'randomUUID').mockReturnValue('11111111-1111-4111-8111-111111111111')
const submitted = {
charge_id: 'ch_123',
idempotency_key: '11111111-1111-4111-8111-111111111111',
ok: true
} satisfies BillingChargeResponse
requestGatewayMock.mockResolvedValue(submitted)
const api = createBillingApi(requestGatewayMock)
const first = await api.charge('25')
const second = await api.charge('25', first.idempotencyKey)
expect(first).toEqual({ data: submitted, idempotencyKey: '11111111-1111-4111-8111-111111111111', ok: true })
expect(second).toEqual({ data: submitted, idempotencyKey: '11111111-1111-4111-8111-111111111111', ok: true })
expect(crypto.randomUUID).toHaveBeenCalledTimes(1)
expect(requestGatewayMock).toHaveBeenNthCalledWith(1, 'billing.charge', {
amount_usd: '25',
idempotency_key: '11111111-1111-4111-8111-111111111111'
})
expect(requestGatewayMock).toHaveBeenNthCalledWith(2, 'billing.charge', {
amount_usd: '25',
idempotency_key: '11111111-1111-4111-8111-111111111111'
})
})
})
@@ -0,0 +1,193 @@
import { createContext, useContext, useMemo } from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import type {
BillingChargeResponse,
BillingChargeStatusResponse,
BillingErrorPayload,
BillingMutationResponse,
BillingRefusalCode,
BillingStateResponse,
SubscriptionPreviewResponse,
SubscriptionStateResponse
} from './types'
export type BillingErrorKind = BillingRefusalCode
export interface BillingRefusal {
actor?: string
code?: string
kind: BillingErrorKind | 'timeout' | 'transport'
message: string
payload?: BillingErrorPayload
portalUrl?: string
raw?: unknown
recovery?: string
retryAfter?: number
}
export type BillingResult<T> = { data: T; ok: true } | { ok: false; refusal: BillingRefusal }
export type BillingChargeResult = BillingResult<BillingChargeResponse> & { idempotencyKey: string }
export interface UpdateAutoReloadInput {
enabled: boolean
reload_to_usd?: string
threshold_usd?: string
}
export type BillingRequestGateway = <T>(
method: string,
params?: Record<string, unknown>,
timeoutMs?: number,
signal?: AbortSignal
) => Promise<T>
export interface BillingApi {
charge: (amountUsd: string, idempotencyKey?: string) => Promise<BillingChargeResult>
chargeStatus: (chargeId: string) => Promise<BillingResult<BillingChargeStatusResponse>>
fetchBillingState: () => Promise<BillingResult<BillingStateResponse>>
fetchSubscriptionState: () => Promise<BillingResult<SubscriptionStateResponse>>
/** Chargeless quote for a plan change (POST /subscription/preview). */
previewSubscriptionChange: (tierId: string) => Promise<BillingResult<SubscriptionPreviewResponse>>
/** Clear a scheduled downgrade / cancellation — the undo (DELETE pending-change). */
resumeSubscription: () => Promise<BillingResult<BillingMutationResponse>>
/** Schedule a chargeless downgrade at period end (PUT pending-change). */
scheduleSubscriptionChange: (tierId: string) => Promise<BillingResult<BillingMutationResponse>>
stepUp: (sessionId?: string) => Promise<BillingResult<BillingMutationResponse>>
updateAutoReload: (input: UpdateAutoReloadInput) => Promise<BillingResult<BillingMutationResponse>>
}
interface RefusalRecord {
actor?: unknown
code?: unknown
error?: unknown
kind?: unknown
message?: unknown
payload?: unknown
portal_url?: unknown
recovery?: unknown
retry_after?: unknown
}
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null
const asOptionalString = (value: unknown): string | undefined =>
typeof value === 'string' && value.length > 0 ? value : undefined
const asOptionalNumber = (value: unknown): number | undefined => (typeof value === 'number' ? value : undefined)
const asPayload = (value: unknown): BillingErrorPayload | undefined =>
isRecord(value) ? (value as BillingErrorPayload) : undefined
const getMessage = (value: unknown): string => {
if (value instanceof Error && value.message) {
return value.message
}
if (typeof value === 'string' && value.length > 0) {
return value
}
return String(value || 'Billing request failed.')
}
const normalizeRefusal = (raw: Record<string, unknown>): BillingRefusal => {
const rawError = raw.error
const error = isRecord(rawError) ? (rawError as RefusalRecord) : undefined
const kind = asOptionalString(error?.kind) ?? asOptionalString(error?.error) ?? asOptionalString(rawError) ?? 'error'
const message = asOptionalString(error?.message) ?? asOptionalString(raw.message) ?? kind
return {
actor: asOptionalString(error?.actor) ?? asOptionalString(raw.actor),
code: asOptionalString(error?.code) ?? asOptionalString(raw.code),
kind,
message,
payload: asPayload(error?.payload) ?? asPayload(raw.payload),
portalUrl: asOptionalString(error?.portal_url) ?? asOptionalString(raw.portal_url),
raw,
recovery: asOptionalString(error?.recovery) ?? asOptionalString(raw.recovery),
retryAfter: asOptionalNumber(error?.retry_after) ?? asOptionalNumber(raw.retry_after)
}
}
const normalizeThrown = (error: unknown): BillingRefusal => {
const message = getMessage(error)
const name = error instanceof Error ? error.name : ''
return {
kind: name === 'TimeoutError' || /timed?\s*out|timeout/i.test(message) ? 'timeout' : 'transport',
message,
raw: error
}
}
const normalizeRpcResult = <T>(response: T): BillingResult<T> => {
if (isRecord(response) && response.ok === false) {
return { ok: false, refusal: normalizeRefusal(response) }
}
return { data: response, ok: true }
}
const callBilling = async <T>(
requestGateway: BillingRequestGateway,
method: string,
params: Record<string, unknown> = {}
): Promise<BillingResult<T>> => {
try {
return normalizeRpcResult(await requestGateway<T>(method, params))
} catch (error) {
return { ok: false, refusal: normalizeThrown(error) }
}
}
export const createBillingApi = (requestGateway: BillingRequestGateway): BillingApi => ({
charge: async (amountUsd, idempotencyKey = crypto.randomUUID()) => {
const result = await callBilling<BillingChargeResponse>(requestGateway, 'billing.charge', {
amount_usd: amountUsd,
idempotency_key: idempotencyKey
})
return { ...result, idempotencyKey }
},
chargeStatus: chargeId =>
callBilling<BillingChargeStatusResponse>(requestGateway, 'billing.charge_status', { charge_id: chargeId }),
fetchBillingState: () => callBilling<BillingStateResponse>(requestGateway, 'billing.state'),
fetchSubscriptionState: () => callBilling<SubscriptionStateResponse>(requestGateway, 'subscription.state'),
previewSubscriptionChange: tierId =>
callBilling<SubscriptionPreviewResponse>(requestGateway, 'subscription.preview', {
subscription_type_id: tierId
}),
resumeSubscription: () => callBilling<BillingMutationResponse>(requestGateway, 'subscription.resume', {}),
scheduleSubscriptionChange: tierId =>
callBilling<BillingMutationResponse>(requestGateway, 'subscription.change', {
subscription_type_id: tierId
}),
stepUp: sessionId =>
callBilling<BillingMutationResponse>(requestGateway, 'billing.step_up', {
...(sessionId !== undefined ? { session_id: sessionId } : {})
}),
updateAutoReload: input =>
callBilling<BillingMutationResponse>(requestGateway, 'billing.auto_reload', {
enabled: input.enabled,
...(input.threshold_usd !== undefined ? { threshold: input.threshold_usd } : {}),
...(input.reload_to_usd !== undefined ? { top_up_amount: input.reload_to_usd } : {})
})
})
// An override for the gateway-backed api — DEV fixtures provide a simulated
// implementation here so every consumer (hooks, rows) transparently runs against it
// with no fixture awareness of their own. `null` (the default) = the real gateway api.
const BillingApiContext = createContext<BillingApi | null>(null)
export const BillingApiProvider = BillingApiContext.Provider
export function useBillingApi(): BillingApi {
const override = useContext(BillingApiContext)
const { requestGateway } = useGatewayRequest()
const real = useMemo(() => createBillingApi(requestGateway), [requestGateway])
return override ?? real
}
@@ -0,0 +1,293 @@
import { useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { ListRow, Pill } from '../primitives'
import { RowValue } from './account-row-value'
import type { BillingRefusal } from './api'
import { useBillingApi } from './api'
import { initialAutoReloadAmount, validateAutoReloadInputs } from './billing-amounts'
import { BillingRefusalInline } from './inline-feedback'
import type { BillingAutoReload, BillingStateResponse } from './types'
import type { BillingAccountRowView } from './use-billing-state'
export function AutoReloadRow({
autoReload,
bounds,
row
}: {
autoReload: BillingAutoReload
bounds: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>
row: BillingAccountRowView
}) {
const api = useBillingApi()
const queryClient = useQueryClient()
const [confirmDisable, setConfirmDisable] = useState(false)
const [editing, setEditing] = useState(false)
// Validation errors are silent until the user edits a field or attempts a
// save — opening Manage on a prefilled (possibly below-min) config must not
// flash an error (spec §9).
const [showErrors, setShowErrors] = useState(false)
const [message, setMessage] = useState<null | { kind: 'error' | 'success'; text: string }>(null)
const [refusal, setRefusal] = useState<BillingRefusal | null>(null)
const [reloadTo, setReloadTo] = useState(
initialAutoReloadAmount(autoReload.reload_to_usd, autoReload.reload_to_display)
)
const [saving, setSaving] = useState(false)
const [threshold, setThreshold] = useState(
initialAutoReloadAmount(autoReload.threshold_usd, autoReload.threshold_display)
)
const validation = validateAutoReloadInputs(threshold, reloadTo, bounds)
const busy = saving
const maxBound = bounds.max_usd ?? undefined
const minBound = bounds.min_usd ?? undefined
// Only the canonical-card enabled state edits in place (flagged in the view model).
// Off / divergent-card rows have no Manage affordance (or a portal link) and render
// read-only.
const editable = row.manageInApp === true
const resetFeedback = () => {
setConfirmDisable(false)
setMessage(null)
setRefusal(null)
}
const openEdit = () => {
resetFeedback()
setShowErrors(false)
setEditing(true)
}
const cancelEdit = () => {
resetFeedback()
setEditing(false)
}
const save = async () => {
if (!validation.values || busy) {
return
}
resetFeedback()
setSaving(true)
const result = await api.updateAutoReload({
enabled: true,
reload_to_usd: validation.values.reloadTo,
threshold_usd: validation.values.threshold
})
setSaving(false)
if (!result.ok) {
setRefusal(result.refusal)
return
}
await queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
setMessage({ kind: 'success', text: 'Auto-refill updated.' })
setEditing(false)
}
const disable = async () => {
if (busy) {
return
}
resetFeedback()
setSaving(true)
// The gateway's billing.auto_reload handler unconditionally requires threshold
// + top_up_amount (invalid_request otherwise), so a disable must still carry the
// current amounts — mirroring the TUI, which always sends both.
const result = await api.updateAutoReload({
enabled: false,
reload_to_usd: initialAutoReloadAmount(autoReload.reload_to_usd, autoReload.reload_to_display),
threshold_usd: initialAutoReloadAmount(autoReload.threshold_usd, autoReload.threshold_display)
})
setSaving(false)
if (!result.ok) {
setRefusal(result.refusal)
return
}
await queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
setMessage({ kind: 'success', text: 'Auto-refill turned off.' })
setEditing(false)
}
// Read-only states (off / divergent card) keep the original ListRow shape.
if (!editable) {
return (
<ListRow
action={<RowValue row={row} />}
below={
<>
{row.caption ? (
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{row.caption}
</div>
) : null}
<BillingRefusalInline refusal={refusal} />
{message && <InlineMessage kind={message.kind}>{message.text}</InlineMessage>}
</>
}
description={row.description}
key={row.id}
title={row.title}
/>
)
}
const onField = (setter: (value: string) => void) => (event: { target: { value: string } }) => {
resetFeedback()
setShowErrors(true)
setter(event.target.value)
}
// Zero-shift by exact reservation, not a magic min-height: the edit form is
// ALWAYS rendered and both states share a single grid cell (`[grid-area:stack]`),
// so the row's height always equals the tallest state at EVERY container width —
// no breakpoint math that under-reserves when the two inputs stack on narrow
// panes. The form is `invisible` + `aria-hidden` when not editing.
return (
<div className="@container">
<div className="grid gap-3 py-3 @2xl:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] @2xl:items-start">
<div className="min-w-0">
<div className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{row.title}
</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{row.description}
</div>
<div className="mt-3 grid [grid-template-areas:'stack']">
{/* EDIT layer — always in layout (reserves exact height); hidden until editing. */}
<div aria-hidden={!editing} className={cn('space-y-2 [grid-area:stack]', !editing && 'invisible')}>
<div className="grid gap-2 @2xl:grid-cols-2">
<label className="min-w-0 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
Threshold
<Input
aria-label="Auto-refill threshold"
className="mt-1 py-[3px]"
disabled={busy || !editing}
inputMode="decimal"
max={maxBound}
min={minBound}
onChange={onField(setThreshold)}
size="sm"
step="0.01"
tabIndex={editing ? undefined : -1}
type="number"
value={threshold}
/>
</label>
<label className="min-w-0 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
Reload to
<Input
aria-label="Auto-refill reload-to amount"
className="mt-1 py-[3px]"
disabled={busy || !editing}
inputMode="decimal"
max={maxBound}
min={minBound}
onChange={onField(setReloadTo)}
size="sm"
step="0.01"
tabIndex={editing ? undefined : -1}
type="number"
value={reloadTo}
/>
</label>
</div>
{/* Pre-allocated error line — occupies height whether or not shown. */}
<div className="min-h-4 text-[length:var(--conversation-caption-font-size)] text-destructive">
{showErrors && validation.error ? validation.error : ''}
</div>
{confirmDisable ? (
<div className="flex min-w-0 flex-wrap items-center gap-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<span>Turn off auto-refill?</span>
<Button disabled={busy} onClick={() => void disable()} size="sm" type="button" variant="outline">
Turn off
</Button>
<Button
disabled={busy}
onClick={() => setConfirmDisable(false)}
size="sm"
type="button"
variant="ghost"
>
Cancel
</Button>
</div>
) : (
<Button
disabled={busy}
onClick={() => setConfirmDisable(true)}
size="sm"
tabIndex={editing ? undefined : -1}
type="button"
variant="outline"
>
Disable
</Button>
)}
{/* Refusal stays INSIDE the reserved layer so it never pushes Usage. */}
<BillingRefusalInline refusal={refusal} />
</div>
{/* VIEW layer — success feedback overlaid in the same cell when not editing. */}
{!editing && message && (
<div className="[grid-area:stack]">
<InlineMessage kind={message.kind}>{message.text}</InlineMessage>
</div>
)}
</div>
</div>
{/* Action column swaps Manage ↔ Save/Cancel in place (top-aligned, no move). */}
<div className="flex min-w-0 flex-wrap items-center justify-start gap-2 @2xl:justify-end">
{row.pill && <Pill tone={row.pill.tone}>{row.pill.label}</Pill>}
{editing ? (
<>
<Button disabled={busy || !validation.values} onClick={() => void save()} size="sm" type="button">
{busy ? 'Saving…' : 'Save'}
</Button>
<Button disabled={busy} onClick={cancelEdit} size="sm" type="button" variant="outline">
Cancel
</Button>
</>
) : (
<Button onClick={openEdit} size="sm" type="button" variant="outline">
Manage
</Button>
)}
</div>
</div>
</div>
)
}
// A one-line success/error note under the row — the only consumer of this shape.
function InlineMessage({ children, kind }: { children: string; kind: 'error' | 'success' }) {
return (
<div
className={cn(
'mt-2 text-[length:var(--conversation-caption-font-size)]',
kind === 'error' ? 'text-destructive' : 'text-(--ui-text-tertiary)'
)}
>
{children}
</div>
)
}
@@ -0,0 +1,123 @@
import type { BillingStateResponse } from './types'
import { EMPTY_BILLING_VALUE } from './use-billing-state'
export function clampAmount(raw: string, billing: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>): string {
const amount = parseAmount(raw)
if (amount == null) {
return ''
}
const min = parseAmount(billing.min_usd)
const max = parseAmount(billing.max_usd)
const clampedMin = min == null ? amount : Math.max(min, amount)
const clamped = max == null ? clampedMin : Math.min(max, clampedMin)
return formatAmountForRequest(clamped)
}
export function parseAmount(value?: null | number | string): null | number {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null
}
if (typeof value !== 'string') {
return null
}
const parsed = Number(value.replace(/[$,\s]/g, ''))
return Number.isFinite(parsed) && parsed > 0 ? parsed : null
}
export function formatAmountForRequest(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')
}
export function initialAutoReloadAmount(...candidates: Array<null | string | undefined>): string {
for (const candidate of candidates) {
const amount = parseAmount(candidate)
if (amount != null) {
return formatAmountForRequest(amount)
}
}
return ''
}
export function validateAutoReloadInputs(
thresholdRaw: string,
reloadToRaw: string,
bounds: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>
): { error?: string; values?: { reloadTo: string; threshold: string } } {
const threshold = validateBillingAmount('Threshold', thresholdRaw, bounds)
if (threshold.error || threshold.amount == null) {
return { error: threshold.error }
}
const reloadTo = validateBillingAmount('Reload-to', reloadToRaw, bounds)
if (reloadTo.error || reloadTo.amount == null) {
return { error: reloadTo.error }
}
if (reloadTo.amount <= threshold.amount) {
return { error: 'Reload-to amount must be greater than the threshold.' }
}
return {
values: {
reloadTo: formatAmountForRequest(reloadTo.amount),
threshold: formatAmountForRequest(threshold.amount)
}
}
}
export function validateBillingAmount(
label: string,
raw: string,
bounds: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>
): { amount?: number; error?: string } {
const cleaned = raw.trim().replace(/^\$/, '').trim()
if (!cleaned || !/^\d+(\.\d{1,2})?$/.test(cleaned)) {
return { error: `${label}: enter a dollar amount with at most 2 decimal places.` }
}
const amount = Number(cleaned)
if (!(amount > 0)) {
return { error: `${label}: amount must be greater than $0.` }
}
const min = parseAmount(bounds.min_usd)
if (min != null && amount < min) {
return { error: `${label}: minimum is ${formatMoney(min)}.` }
}
const max = parseAmount(bounds.max_usd)
if (max != null && amount > max) {
return { error: `${label}: maximum is ${formatMoney(max)}.` }
}
return { amount }
}
export function formatMoney(value?: null | number | string): string {
const amount = parseAmount(value)
if (amount == null) {
return EMPTY_BILLING_VALUE
}
return new Intl.NumberFormat(undefined, {
currency: 'USD',
maximumFractionDigits: amount % 1 === 0 ? 0 : 2,
minimumFractionDigits: amount % 1 === 0 ? 0 : 2,
style: 'currency'
}).format(amount)
}
@@ -0,0 +1,57 @@
import { Button } from '@/components/ui/button'
import { ExternalLink } from '@/lib/icons'
import { BillingRefusalInline } from './inline-feedback'
import { openExternal } from './open-external'
import { TierArt } from './tier-art'
import type { BillingPlanCardView } from './use-billing-state'
import { useResumeFlow } from './use-subscription-change'
export function CurrentPlanCard({ onViewPlans, plan }: { onViewPlans: () => void; plan: BillingPlanCardView }) {
const resumeFlow = useResumeFlow()
return (
<div className="@container">
<div className="grid gap-3 py-3 @2xl:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] @2xl:items-center">
<div className="flex min-w-0 items-center gap-3">
<TierArt name={plan.tierName} />
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2">
<span className="truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{plan.tierName}
</span>
{plan.price && (
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{plan.price}/mo
</span>
)}
</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{plan.caption}
</div>
</div>
</div>
<div className="flex min-w-0 flex-wrap items-center justify-start gap-2 @2xl:justify-end">
{plan.action && (
<Button onClick={onViewPlans} size="sm" type="button" variant="outline">
{plan.action.label}
</Button>
)}
{/* Scheduled downgrade → chargeless undo (subscription.resume), no confirm. */}
{plan.pending && (
<Button disabled={resumeFlow.busy} onClick={() => void resumeFlow.resume()} size="sm" type="button">
{resumeFlow.busy ? 'Undoing…' : 'Undo'}
</Button>
)}
{plan.link && (
<Button onClick={() => plan.link && openExternal(plan.link.url)} size="sm" type="button" variant="outline">
{plan.link.label}
<ExternalLink className="size-3.5" />
</Button>
)}
</div>
</div>
<BillingRefusalInline refusal={resumeFlow.refusal} />
</div>
)
}
@@ -0,0 +1,469 @@
import type { BillingResult } from './api'
import type { BillingStateResponse, SubscriptionStateResponse, SubscriptionTierOption } from './types'
const current = (
overrides: Partial<NonNullable<SubscriptionStateResponse['current']>> = {}
): NonNullable<SubscriptionStateResponse['current']> => ({
cancel_at_period_end: false,
cancellation_effective_at: null,
cancellation_effective_display: null,
credits_remaining: '120',
cycle_ends_at: '2026-07-11T08:14:55.000Z',
monthly_credits: '220',
pending_downgrade_at: null,
pending_downgrade_display: null,
pending_downgrade_tier_name: null,
tier_id: 'ultra',
tier_name: 'Ultra',
...overrides
})
export const todayBillingState = {
auto_reload: {
card: { kind: 'canonical' },
enabled: true,
reload_to_display: '$10',
reload_to_usd: '10',
threshold_display: '$5',
threshold_usd: '5'
},
balance_display: '$996.47',
balance_usd: '996.47',
can_charge: false,
card: {
brand: 'visa',
last4: '3206',
masked: 'visa ....3206'
},
charge_presets: ['100', '250', '500'],
charge_presets_display: ['$100', '$250', '$500'],
cli_billing_enabled: false,
is_admin: true,
logged_in: true,
max_usd: '1000',
min_usd: '10',
monthly_cap: {
is_default_ceiling: true,
limit_display: '$100',
limit_usd: '100',
spent_display: '$10',
spent_this_month_usd: '10'
},
ok: true,
org_name: 'sid-5',
portal_url: 'https://portal.nousresearch.com/billing',
role: 'OWNER',
usage: {
available: true,
has_topup: true,
plan_name: 'Ultra',
renews_at: '2026-07-11T08:14:55.000Z',
renews_display: 'Jul 11',
status: 'active',
subscription_remaining_display: '$120',
topup_remaining_display: '$876.47',
total_spendable_display: '$996.47'
}
} satisfies BillingStateResponse
export const todaySubscriptionState = {
can_change_plan: true,
context: 'team',
current: current(),
is_admin: true,
logged_in: true,
ok: true,
org_id: 'sid-5',
org_name: 'sid-5',
portal_url: 'https://portal.nousresearch.com/billing',
role: 'OWNER',
tiers: [
{
dollars_per_month_display: '$200',
is_current: true,
is_enabled: true,
monthly_credits: '220',
name: 'Ultra',
tier_id: 'ultra',
tier_order: 3
}
],
usage: todayBillingState.usage
} satisfies SubscriptionStateResponse
export const postTrainBillingState = {
...todayBillingState,
auto_reload: {
card: { kind: 'canonical' },
enabled: false,
reload_to_display: '$100',
reload_to_usd: '100',
threshold_display: '$25',
threshold_usd: '25'
},
balance_display: '$142.50',
balance_usd: '142.50',
can_charge: true,
card: {
brand: 'visa',
display: 'Visa ....4242 - the card on your subscription',
last4: '4242',
masked: 'visa ....4242',
resolved_via: 'subPin'
},
charge_presets: ['25', '50', '100'],
charge_presets_display: ['$25', '$50', '$100'],
cli_billing_enabled: true,
monthly_cap: {
is_default_ceiling: false,
limit_display: '$1,000',
limit_usd: '1000',
spent_display: '$180',
spent_this_month_usd: '180'
},
org_name: 'Acme Research',
usage: {
available: true,
has_topup: true,
plan_bar: {
fill_fraction: 0.4,
kind: 'plan',
pct_used: 60,
remaining_display: '$40',
spent_display: '$60',
total_display: '$100'
},
plan_name: 'Pro',
renews_at: '2026-07-31T00:00:00Z',
renews_display: 'Jul 31',
status: 'active',
subscription_remaining_display: '$40',
topup_bar: {
fill_fraction: 0.75,
kind: 'topup',
pct_used: 25,
remaining_display: '$75',
spent_display: '$25',
total_display: '$100'
},
topup_remaining_display: '$75',
total_spendable_display: '$115'
}
} satisfies BillingStateResponse
export const postTrainSubscriptionState = {
...todaySubscriptionState,
current: current({
credits_remaining: '40',
cycle_ends_at: '2026-07-31T00:00:00Z',
monthly_credits: '100',
tier_id: 'pro',
tier_name: 'Pro'
}),
org_id: 'org_123',
org_name: 'Acme Research',
tiers: [
{
dollars_per_month_display: '$20',
is_current: true,
is_enabled: true,
monthly_credits: '100',
name: 'Pro',
tier_id: 'pro',
tier_order: 2
}
],
usage: postTrainBillingState.usage
} satisfies SubscriptionStateResponse
export const loggedOutBillingState = {
...todayBillingState,
auto_reload: null,
balance_display: '$0.00',
balance_usd: null,
can_charge: false,
card: null,
charge_presets: [],
charge_presets_display: [],
logged_in: false,
monthly_cap: null,
org_name: null,
portal_url: 'https://portal.nousresearch.com/login',
role: null,
usage: undefined
} satisfies BillingStateResponse
export const loggedOutSubscriptionState = {
...todaySubscriptionState,
can_change_plan: false,
current: null,
is_admin: false,
logged_in: false,
org_id: null,
org_name: null,
portal_url: 'https://portal.nousresearch.com/login',
role: null,
tiers: [],
usage: undefined
} satisfies SubscriptionStateResponse
// Full four-tier personal catalog. tier_ids are cuid-like (Prisma) on purpose:
// tier art keys off the lowercase NAME, never the id (ids differ per env). Dollar
// credits are 0.1 / 22 / 110 / 220 to exercise the "$X credits/mo" formatting.
const personalTierCatalog: SubscriptionTierOption[] = [
{
dollars_per_month_display: '$0',
is_current: false,
is_enabled: true,
monthly_credits: '0.1',
name: 'Free',
tier_id: 'cltier000free0000personal',
tier_order: 0
},
{
dollars_per_month_display: '$20',
is_current: false,
is_enabled: true,
monthly_credits: '22',
name: 'Plus',
tier_id: 'cltier111plus1111personal',
tier_order: 1
},
{
dollars_per_month_display: '$100',
is_current: false,
is_enabled: true,
monthly_credits: '110',
name: 'Super',
tier_id: 'cltier222super222personal',
tier_order: 2
},
{
dollars_per_month_display: '$200',
is_current: false,
is_enabled: true,
monthly_credits: '220',
name: 'Ultra',
tier_id: 'cltier333ultra333personal',
tier_order: 3
}
]
const catalogWithCurrent = (currentTierId: null | string): SubscriptionTierOption[] =>
personalTierCatalog.map(tier => ({ ...tier, is_current: tier.tier_id === currentTierId }))
// Logged-in personal org, no subscription: exercises the "View plans" plan card
// and the full plans grid where every tier is an upgrade.
export const freePersonalBillingState = {
...postTrainBillingState,
balance_display: '$12.00',
balance_usd: '12.00',
org_name: 'Personal',
usage: {
available: true,
has_topup: true,
plan_name: 'Free',
renews_at: null,
renews_display: null,
status: 'active',
subscription_remaining_display: '$0',
topup_remaining_display: '$12.00',
total_spendable_display: '$12.00'
}
} satisfies BillingStateResponse
export const freePersonalSubscriptionState = {
...todaySubscriptionState,
can_change_plan: true,
context: 'personal',
current: null,
org_id: 'org_personal_free',
org_name: 'Personal',
tiers: catalogWithCurrent(null),
usage: freePersonalBillingState.usage
} satisfies SubscriptionStateResponse
// Personal subscriber on Plus: exercises the "Change plan" plan card, the current
// marker, upgrades (Super/Ultra), and the disabled downgrade (Free).
export const subscriberPersonalBillingState = {
...postTrainBillingState,
org_name: 'Personal',
usage: {
...postTrainBillingState.usage,
plan_name: 'Plus'
}
} satisfies BillingStateResponse
export const subscriberPersonalSubscriptionState = {
...todaySubscriptionState,
can_change_plan: true,
context: 'personal',
current: current({
credits_remaining: '12',
cycle_ends_at: '2026-08-15T00:00:00Z',
monthly_credits: '22',
tier_id: 'cltier111plus1111personal',
tier_name: 'Plus'
}),
org_id: 'org_personal_plus',
org_name: 'Personal',
tiers: catalogWithCurrent('cltier111plus1111personal'),
usage: subscriberPersonalBillingState.usage
} satisfies SubscriptionStateResponse
// Personal subscriber on Plus with a downgrade to Free already scheduled at period
// end: exercises the plan-card pending state + undo, and the grid's "Scheduled"
// marker on Free while Super/Ultra stay choosable.
export const pendingDowngradeSubscriptionState = {
...subscriberPersonalSubscriptionState,
current: current({
credits_remaining: '12',
cycle_ends_at: '2026-08-15T00:00:00Z',
monthly_credits: '22',
pending_downgrade_at: '2026-08-15T00:00:00Z',
pending_downgrade_display: 'Aug 15',
pending_downgrade_tier_name: 'Free',
tier_id: 'cltier111plus1111personal',
tier_name: 'Plus'
})
} satisfies SubscriptionStateResponse
// Personal subscriber on Plus with a cancellation (not a downgrade) scheduled at
// period end: exercises the plan-card "Cancels on …" copy + undo, with NO Scheduled
// grid marker (a cancellation has no target tier).
export const pendingCancellationSubscriptionState = {
...subscriberPersonalSubscriptionState,
current: current({
cancel_at_period_end: true,
cancellation_effective_at: '2026-08-15T00:00:00Z',
cancellation_effective_display: 'Aug 15',
credits_remaining: '12',
cycle_ends_at: '2026-08-15T00:00:00Z',
monthly_credits: '22',
tier_id: 'cltier111plus1111personal',
tier_name: 'Plus'
})
} satisfies SubscriptionStateResponse
const okBilling = (data: BillingStateResponse): BillingResult<BillingStateResponse> => ({ data, ok: true })
const okSubscription = (data: SubscriptionStateResponse): BillingResult<SubscriptionStateResponse> => ({
data,
ok: true
})
function withUsage(
name: string,
{
autoReload = postTrainBillingState.auto_reload,
canCharge = true,
card = postTrainBillingState.card,
cliBillingEnabled = true,
monthlyCapSpent = '89',
remaining,
subscriptionCurrent = current({ credits_remaining: remaining, monthly_credits: '220' })
}: {
autoReload?: BillingStateResponse['auto_reload']
canCharge?: boolean
card?: BillingStateResponse['card']
cliBillingEnabled?: boolean
monthlyCapSpent?: string
remaining: string
subscriptionCurrent?: SubscriptionStateResponse['current']
}
) {
const billing = {
...postTrainBillingState,
auto_reload: autoReload,
balance_display: '$142.50',
balance_usd: '142.50',
can_charge: canCharge,
card,
cli_billing_enabled: cliBillingEnabled,
monthly_cap: {
is_default_ceiling: false,
limit_display: '$100',
limit_usd: '100',
spent_display: `$${monthlyCapSpent}`,
spent_this_month_usd: monthlyCapSpent
},
org_name: `${name} Fixture`,
usage: {
...postTrainBillingState.usage,
plan_name: 'Ultra',
subscription_remaining_display: `$${remaining}`,
total_spendable_display: '$142.50'
}
} satisfies BillingStateResponse
const subscription = {
...todaySubscriptionState,
current: subscriptionCurrent,
org_name: `${name} Fixture`,
usage: billing.usage
} satisfies SubscriptionStateResponse
return { billing: okBilling(billing), subscription: okSubscription(subscription) }
}
export const billingDevFixtures = {
healthy: withUsage('Healthy', { monthlyCapSpent: '89', remaining: '132' }),
'free-personal': {
billing: okBilling(freePersonalBillingState),
subscription: okSubscription(freePersonalSubscriptionState)
},
'subscriber-personal': {
billing: okBilling(subscriberPersonalBillingState),
subscription: okSubscription(subscriberPersonalSubscriptionState)
},
'pending-cancellation': {
billing: okBilling(subscriberPersonalBillingState),
subscription: okSubscription(pendingCancellationSubscriptionState)
},
'pending-downgrade': {
billing: okBilling(subscriberPersonalBillingState),
subscription: okSubscription(pendingDowngradeSubscriptionState)
},
'auto-refill-divergent': withUsage('Auto Refill Divergent', {
autoReload: {
...postTrainBillingState.auto_reload,
card: { kind: 'distinct', payment_method_id: 'pm_divergent_1', brand: 'mastercard', last4: '4444' },
enabled: true
},
remaining: '132'
}),
low: withUsage('Low', { remaining: '19.8' }),
boundary: withUsage('Boundary', { remaining: '22' }),
'empty-overdrawn': withUsage('Empty Overdrawn', { remaining: '-0.79' }),
'cap-near': withUsage('Cap Near', { monthlyCapSpent: '92', remaining: '132' }),
'cap-hit': withUsage('Cap Hit', { monthlyCapSpent: '100', remaining: '132' }),
'no-card': withUsage('No Card', { card: null, remaining: '132' }),
'no-subscription': withUsage('No Subscription', { remaining: '132', subscriptionCurrent: null }),
'logged-out': {
billing: okBilling(loggedOutBillingState),
subscription: okSubscription(loggedOutSubscriptionState)
},
refusal: {
billing: {
ok: false,
refusal: {
kind: 'temporarily_unavailable',
message: 'Billing is temporarily unavailable.',
retryAfter: 90
}
},
subscription: okSubscription(todaySubscriptionState)
},
'billing-off': {
billing: okBilling(todayBillingState),
subscription: okSubscription(todaySubscriptionState)
}
} satisfies Record<
string,
{
billing: BillingResult<BillingStateResponse>
subscription: BillingResult<SubscriptionStateResponse>
}
>
export type BillingDevFixtureName = keyof typeof billingDevFixtures
@@ -0,0 +1,84 @@
import type { KnownBillingRefusalCode } from '@hermes/shared/billing'
import { describe, expect, it } from 'vitest'
import type { BillingRefusal } from './api'
import { resolveRefusal } from './errors'
const expectedActions: Record<
KnownBillingRefusalCode | 'timeout' | 'transport',
'none' | 'portal' | 'retry' | 'step_up'
> = {
auto_top_up_disabled_failures: 'none',
cli_billing_disabled: 'portal',
consent_required: 'portal',
endpoint_unavailable: 'retry',
idempotency_conflict: 'none',
idempotency_key_required: 'none',
insufficient_scope: 'step_up',
internal_error: 'none',
invalid_charge_id: 'none',
invalid_request: 'none',
monthly_cap_exceeded: 'portal',
network_error: 'none',
no_payment_method: 'portal',
org_access_denied: 'none',
preview_rejected: 'none',
rate_limited: 'retry',
remote_spending_disabled: 'portal',
remote_spending_revoked: 'portal',
role_required: 'portal',
session_revoked: 'portal',
stripe_unavailable: 'retry',
temporarily_unavailable: 'retry',
timeout: 'retry',
transport: 'retry',
upgrade_cap_exceeded: 'none',
validation_failed: 'none'
}
describe('resolveRefusal', () => {
it('maps every known refusal kind to copy and the expected action', () => {
for (const [kind, actionType] of Object.entries(expectedActions)) {
const resolved = resolveRefusal({
kind: kind as BillingRefusal['kind'],
message: 'Server message.',
portalUrl: 'https://portal.nousresearch.com/billing',
retryAfter: 90
})
expect(resolved.title, kind).not.toHaveLength(0)
expect(resolved.message, kind).not.toHaveLength(0)
expect(resolved.action.type, kind).toBe(actionType)
}
})
it('includes monthly cap headroom when the server sends it', () => {
const resolved = resolveRefusal({
kind: 'monthly_cap_exceeded',
message: 'Monthly spend cap reached.',
payload: { remainingUsd: '4.50' }
})
expect(resolved.message).toContain('$4.50 headroom left')
})
it('includes Stripe retry timing when the server sends it', () => {
const resolved = resolveRefusal({
kind: 'stripe_unavailable',
message: 'Stripe is unavailable.',
retryAfter: 120
})
expect(resolved.message).toContain('try again in ~2 min')
})
it('falls back sanely for unknown refusal kinds', () => {
const resolved = resolveRefusal({ kind: 'new_billing_code', message: 'Something changed upstream.' })
expect(resolved).toEqual({
action: { type: 'none' },
message: 'Something changed upstream.',
title: 'Billing request failed'
})
})
})
@@ -0,0 +1,163 @@
import type { BillingRefusal } from './api'
export interface BillingRefusalPresentation {
action: { type: 'none' } | { type: 'portal'; url?: string } | { type: 'retry' } | { type: 'step_up' }
message: string
title: string
}
const portalAction = (url?: string): BillingRefusalPresentation['action'] => ({ type: 'portal', url })
const retryMessage = (refusal: BillingRefusal): string => {
const mins = refusal.retryAfter ? ` (try again in ~${Math.max(1, Math.round(refusal.retryAfter / 60))} min)` : ''
return `🟡 Too many charges right now${mins}. This isn't a payment failure.`
}
const stripeRetryMessage = (refusal: BillingRefusal): string => {
const mins = refusal.retryAfter ? ` (try again in ~${Math.max(1, Math.round(refusal.retryAfter / 60))} min)` : ''
return `Stripe is having trouble — try again shortly${mins}`
}
export const resolveRefusal = (refusal: BillingRefusal): BillingRefusalPresentation => {
switch (refusal.kind) {
case 'consent_required':
return {
action: portalAction(refusal.portalUrl),
message: 'Confirm this card for terminal charges in the portal',
title: 'Card confirmation needed'
}
case 'insufficient_scope':
return {
action: { type: 'step_up' },
message: 'This needs Remote Spending allowed. Start a top-up to allow it, then retry.',
title: 'Remote Spending needs approval'
}
case 'remote_spending_revoked': {
const who =
refusal.actor === 'admin'
? 'An admin stopped remote spending for this terminal.'
: 'You stopped remote spending for this terminal.'
return {
action: portalAction(refusal.portalUrl),
message: `${who} Reconnect from Settings → Gateway to re-authorize this device.`,
title: 'Remote spending was stopped'
}
}
case 'session_revoked':
return {
action: portalAction(refusal.portalUrl),
message: 'Your session was logged out. Sign in again from Settings → Gateway.',
title: 'Session logged out'
}
case 'cli_billing_disabled':
case 'remote_spending_disabled':
return {
action: portalAction(refusal.portalUrl),
message:
"Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page.",
title: 'Remote spending is off'
}
case 'role_required':
return {
action: portalAction(refusal.portalUrl),
message: 'Adding funds needs an org admin/owner. Ask an admin, or manage on the portal.',
title: 'Admin role required'
}
case 'idempotency_conflict':
return {
action: { type: 'none' },
message: '🔴 That charge key was already used for a different amount. Start a fresh top-up.',
title: 'Start a fresh top-up'
}
case 'no_payment_method':
return {
action: portalAction(refusal.portalUrl),
message:
'💳 No saved card for terminal charges yet. Set one up on the portal ' +
"(one-time credit buys don't save a reusable card).",
title: 'No saved card'
}
case 'org_access_denied':
return {
action: { type: 'none' },
message: "This token isn't bound to an org you can manage",
title: 'Org access denied'
}
case 'monthly_cap_exceeded': {
const remaining = refusal.payload?.remainingUsd
return {
action: portalAction(refusal.portalUrl),
message:
remaining != null
? `🔴 Monthly spend cap reached — $${remaining} headroom left.`
: '🔴 Monthly spend cap reached.',
title: 'Monthly spend cap reached'
}
}
case 'rate_limited':
case 'temporarily_unavailable':
return {
action: { type: 'retry' },
message: retryMessage(refusal),
title: 'Too many charges right now'
}
case 'stripe_unavailable':
return {
action: { type: 'retry' },
message: stripeRetryMessage(refusal),
title: 'Stripe is having trouble'
}
case 'upgrade_cap_exceeded':
return {
action: { type: 'none' },
message: 'Daily plan-change limit reached — try again tomorrow',
title: 'Daily plan-change limit reached'
}
case 'endpoint_unavailable':
return {
action: { type: 'retry' },
message:
refusal.message ||
'Billing endpoint returned a non-JSON response (it may not be available on this deployment).',
title: 'Billing endpoint unavailable'
}
case 'timeout':
return {
action: { type: 'retry' },
message: refusal.message || 'Billing request timed out.',
title: 'Billing request timed out'
}
case 'transport':
return {
action: { type: 'retry' },
message: refusal.message || 'Billing request failed before reaching the gateway.',
title: 'Billing connection failed'
}
default:
return {
action: { type: 'none' },
message: refusal.message || 'Billing request failed.',
title: 'Billing request failed'
}
}
}
@@ -0,0 +1,35 @@
import type { BillingResult } from './api'
import type { BillingStateResponse, SubscriptionStateResponse } from './types'
export {
billingDevFixtures,
loggedOutBillingState,
loggedOutSubscriptionState,
postTrainBillingState,
postTrainSubscriptionState,
todayBillingState,
todaySubscriptionState
} from './dev-fixtures'
export const okBilling = (data: BillingStateResponse): BillingResult<BillingStateResponse> => ({ data, ok: true })
export const okSubscription = (data: SubscriptionStateResponse): BillingResult<SubscriptionStateResponse> => ({
data,
ok: true
})
export const endpointUnavailableBilling = {
ok: false,
refusal: {
kind: 'endpoint_unavailable',
message: 'Billing endpoint returned a non-JSON response.'
}
} satisfies BillingResult<BillingStateResponse>
export const endpointUnavailableSubscription = {
ok: false,
refusal: {
kind: 'endpoint_unavailable',
message: 'Subscription endpoint is not available.'
}
} satisfies BillingResult<SubscriptionStateResponse>
@@ -0,0 +1,694 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import type { ReactNode } from 'react'
import { MemoryRouter } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { formatMoney } from './billing-amounts'
import {
billingDevFixtures,
loggedOutBillingState,
loggedOutSubscriptionState,
okBilling,
okSubscription,
postTrainBillingState,
postTrainSubscriptionState,
todayBillingState,
todaySubscriptionState
} from './fixtures.test-util'
import { BillingSettings } from './index'
const apiMocks = vi.hoisted(() => ({
charge: vi.fn(),
chargeStatus: vi.fn(),
fetchBillingState: vi.fn(),
fetchSubscriptionState: vi.fn(),
openExternal: vi.fn(),
previewSubscriptionChange: vi.fn(),
resumeSubscription: vi.fn(),
scheduleSubscriptionChange: vi.fn(),
stepUp: vi.fn(),
updateAutoReload: vi.fn()
}))
vi.mock('./api', () => ({
// Pass-through provider — the mocked useBillingApi ignores any override anyway.
BillingApiProvider: ({ children }: { children: ReactNode }) => children,
useBillingApi: () => ({
charge: apiMocks.charge,
chargeStatus: apiMocks.chargeStatus,
fetchBillingState: apiMocks.fetchBillingState,
fetchSubscriptionState: apiMocks.fetchSubscriptionState,
previewSubscriptionChange: apiMocks.previewSubscriptionChange,
resumeSubscription: apiMocks.resumeSubscription,
scheduleSubscriptionChange: apiMocks.scheduleSubscriptionChange,
stepUp: apiMocks.stepUp,
updateAutoReload: apiMocks.updateAutoReload
})
}))
function renderBilling(initialEntries: string[] = ['/settings?tab=billing']) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<MemoryRouter initialEntries={initialEntries}>
<QueryClientProvider client={client}>
<BillingSettings />
</QueryClientProvider>
</MemoryRouter>
)
return client
}
beforeEach(() => {
apiMocks.fetchBillingState.mockResolvedValue(okBilling(todayBillingState))
apiMocks.fetchSubscriptionState.mockResolvedValue(okSubscription(todaySubscriptionState))
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: {
openExternal: apiMocks.openExternal
}
})
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('BillingSettings', () => {
it('renders the deployed-today payload with buy controls hidden and usage rows visible', async () => {
renderBilling()
expect(await screen.findByText('$996.47')).toBeTruthy()
expect(screen.getByText('Ultra · $200/mo')).toBeTruthy()
expect(screen.getByText('Visa •••• 3206')).toBeTruthy()
expect(
screen.getByText(
"Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page."
)
).toBeTruthy()
expect(screen.queryByRole('button', { name: '$100' })).toBeNull()
expect(screen.getByText('Charges $10 automatically when your balance falls below $5.')).toBeTruthy()
expect(screen.getByText('$120 of $220 left')).toBeTruthy()
expect(screen.getByText('$876.47')).toBeTruthy()
expect(screen.getByText('$10 of $100 used').classList.contains('tabular-nums')).toBe(true)
expect(screen.getByText('Default ceiling')).toBeTruthy()
})
it('renders the post-train payload with enabled buy controls and card provenance', async () => {
apiMocks.fetchBillingState.mockResolvedValue(okBilling(postTrainBillingState))
apiMocks.fetchSubscriptionState.mockResolvedValue(okSubscription(postTrainSubscriptionState))
renderBilling()
expect(await screen.findByText('$142.50')).toBeTruthy()
expect(screen.getByText('Visa •••• 4242 - subscription card')).toBeTruthy()
expect(screen.getByRole('button', { name: '$25' }).hasAttribute('disabled')).toBe(false)
expect(screen.getByRole('button', { name: '$50' }).hasAttribute('disabled')).toBe(false)
expect(screen.getByRole('button', { name: '$100' }).hasAttribute('disabled')).toBe(false)
expect(screen.getByRole('spinbutton', { name: 'Custom credit amount' })).toBeTruthy()
expect(screen.getByRole('button', { name: /^Buy$/ }).hasAttribute('disabled')).toBe(false)
})
it('disables buy controls when no card is on file', async () => {
const fixture = billingDevFixtures['no-card']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
renderBilling()
// No card → the payment row collapses to a single "Add payment method" link.
expect(await screen.findByRole('button', { name: /Add payment method/ })).toBeTruthy()
expect(screen.queryByText('No card on file')).toBeNull()
expect(screen.getByRole('button', { name: '$25' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: '$50' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: '$100' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('spinbutton', { name: 'Custom credit amount' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: /^Buy$/ }).hasAttribute('disabled')).toBe(true)
fireEvent.click(screen.getByRole('button', { name: /^Buy$/ }))
expect(apiMocks.charge).not.toHaveBeenCalled()
})
it('saves enabled auto-refill edits and refreshes billing state', async () => {
const client = renderBilling()
const invalidate = vi.spyOn(client, 'invalidateQueries')
apiMocks.updateAutoReload.mockResolvedValue({ data: { ok: true }, ok: true })
fireEvent.click(await screen.findByRole('button', { name: 'Manage' }))
fireEvent.change(screen.getByRole('spinbutton', { name: 'Auto-refill threshold' }), {
target: { value: '15' }
})
fireEvent.change(screen.getByRole('spinbutton', { name: 'Auto-refill reload-to amount' }), {
target: { value: '20' }
})
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() =>
expect(apiMocks.updateAutoReload).toHaveBeenCalledWith({
enabled: true,
reload_to_usd: '20',
threshold_usd: '15'
})
)
await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ['billing', 'state'] }))
expect(await screen.findByText('Auto-refill updated.')).toBeTruthy()
})
it('rejects auto-refill amounts outside the billing bounds', async () => {
renderBilling()
fireEvent.click(await screen.findByRole('button', { name: 'Manage' }))
fireEvent.change(screen.getByRole('spinbutton', { name: 'Auto-refill threshold' }), {
target: { value: '7.50' }
})
expect(screen.getByText(`Threshold: minimum is ${formatMoney(10)}.`)).toBeTruthy()
expect(screen.getByRole('button', { name: 'Save' }).hasAttribute('disabled')).toBe(true)
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(apiMocks.updateAutoReload).not.toHaveBeenCalled()
})
it('renders the enabled auto-refill row without crashing when the card is null', async () => {
apiMocks.fetchBillingState.mockResolvedValue(
okBilling({ ...todayBillingState, auto_reload: { ...todayBillingState.auto_reload, card: null } })
)
renderBilling()
expect(await screen.findByText('Charges $10 automatically when your balance falls below $5.')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Manage' })).toBeTruthy()
})
it('requires inline confirmation before disabling auto-refill', async () => {
renderBilling()
apiMocks.updateAutoReload.mockResolvedValue({ data: { ok: true }, ok: true })
fireEvent.click(await screen.findByRole('button', { name: 'Manage' }))
fireEvent.click(screen.getByRole('button', { name: 'Disable' }))
expect(screen.getByText('Turn off auto-refill?')).toBeTruthy()
expect(apiMocks.updateAutoReload).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Turn off' }))
// The gateway requires threshold + top_up_amount even to disable, so the current
// amounts ride along (todayBillingState: threshold $5, reload-to $10).
await waitFor(() =>
expect(apiMocks.updateAutoReload).toHaveBeenCalledWith({
enabled: false,
reload_to_usd: '10',
threshold_usd: '5'
})
)
})
it('opens auto-refill edit without a validation error even when the saved config is below the minimum', async () => {
// todayBillingState: threshold $5 with min_usd $10 — invalid, but opening
// Manage must stay silent until the user edits or attempts to save (spec §9).
renderBilling()
fireEvent.click(await screen.findByRole('button', { name: 'Manage' }))
expect(screen.getByRole('spinbutton', { name: 'Auto-refill threshold' })).toBeTruthy()
expect(screen.queryByText(`Threshold: minimum is ${formatMoney(10)}.`)).toBeNull()
// Save is disabled because the prefilled config is invalid — but no error yet.
expect(screen.getByRole('button', { name: 'Save' }).hasAttribute('disabled')).toBe(true)
})
it('navigates to the in-app plans grid from the plan card and back', async () => {
const fixture = billingDevFixtures['free-personal']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
renderBilling()
fireEvent.click(await screen.findByRole('button', { name: 'View plans' }))
expect(await screen.findByText('Plans')).toBeTruthy()
// No subscription → the free tier is the inert current plan, the three paid
// tiers are "Choose ↗" upgrades (no "subscribe to Free").
expect(screen.getByText('Current plan')).toBeTruthy()
expect(screen.getAllByRole('button', { name: /Choose/ }).length).toBe(3)
expect(screen.queryByRole('button', { name: 'Downgrade' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Back to billing' }))
expect(await screen.findByRole('button', { name: 'View plans' })).toBeTruthy()
})
it('renders the current marker and an actionable downgrade when deep-linked to the plans grid', async () => {
const fixture = billingDevFixtures['subscriber-personal']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
renderBilling(['/settings?tab=billing&bview=plans'])
expect(await screen.findByText('Current plan')).toBeTruthy()
// Free sits below Plus → an in-app (enabled) "Downgrade" button, not disabled.
expect(screen.getByRole('button', { name: 'Downgrade' }).hasAttribute('disabled')).toBe(false)
// Super + Ultra are upgrades.
expect(screen.getAllByRole('button', { name: /Choose/ }).length).toBe(2)
})
it('falls back to overview (no live Choose grid) when a team deep-links bview=plans', async () => {
// Default beforeEach uses todaySubscriptionState (context: 'team') — no in-app
// plans capability, so the URL must not surface a grid of Choose buttons.
renderBilling(['/settings?tab=billing&bview=plans'])
expect(await screen.findByText('Payment & credits')).toBeTruthy()
expect(screen.queryByText('Plans')).toBeNull()
expect(screen.queryByRole('button', { name: /Choose/ })).toBeNull()
})
it('falls back to overview when a non-changer personal account deep-links bview=plans', async () => {
apiMocks.fetchSubscriptionState.mockResolvedValue(
okSubscription({ ...todaySubscriptionState, can_change_plan: false, context: 'personal' })
)
renderBilling(['/settings?tab=billing&bview=plans'])
expect(await screen.findByText('Payment & credits')).toBeTruthy()
expect(screen.queryByText('Plans')).toBeNull()
expect(screen.queryByRole('button', { name: /Choose/ })).toBeNull()
})
it('runs an in-app downgrade: preview → confirm → schedule with the tier id → refetch → overview', async () => {
const fixture = billingDevFixtures['subscriber-personal']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
apiMocks.previewSubscriptionChange.mockResolvedValue({
data: {
effect: 'scheduled',
effective_at: '2026-08-15T00:00:00Z',
monthly_credits_delta: '-88',
ok: true,
target_tier_name: 'Free'
},
ok: true
})
apiMocks.scheduleSubscriptionChange.mockResolvedValue({ data: { ok: true }, ok: true })
const client = renderBilling(['/settings?tab=billing&bview=plans'])
const invalidate = vi.spyOn(client, 'invalidateQueries')
fireEvent.click(await screen.findByRole('button', { name: 'Downgrade' }))
await waitFor(() => expect(apiMocks.previewSubscriptionChange).toHaveBeenCalledWith('cltier000free0000personal'))
expect(await screen.findByText(/No charge now/)).toBeTruthy()
// Credits delta renders as signed dollars, not the raw wire string "-88".
expect(screen.getByText(/Monthly credits change: \$88\/mo\./)).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Confirm downgrade' }))
await waitFor(() => expect(apiMocks.scheduleSubscriptionChange).toHaveBeenCalledWith('cltier000free0000personal'))
await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ['billing', 'subscription'] }))
// Scheduled → back on the overview.
expect(await screen.findByText('Payment & credits')).toBeTruthy()
expect(screen.queryByText('Plans')).toBeNull()
})
it('surfaces the step-up affordance when scheduling a downgrade needs approval', async () => {
const fixture = billingDevFixtures['subscriber-personal']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
apiMocks.previewSubscriptionChange.mockResolvedValue({
data: { effect: 'scheduled', effective_at: '2026-08-15T00:00:00Z', ok: true, target_tier_name: 'Free' },
ok: true
})
apiMocks.scheduleSubscriptionChange.mockResolvedValue({
ok: false,
refusal: { kind: 'insufficient_scope', message: 'billing:manage required' }
})
renderBilling(['/settings?tab=billing&bview=plans'])
fireEvent.click(await screen.findByRole('button', { name: 'Downgrade' }))
fireEvent.click(await screen.findByRole('button', { name: 'Confirm downgrade' }))
expect(await screen.findByText('Remote Spending needs approval:')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Verify to continue' })).toBeTruthy()
// The failed schedule offers a retry in place.
expect(screen.getByRole('button', { name: 'Try again' })).toBeTruthy()
expect(apiMocks.scheduleSubscriptionChange).toHaveBeenCalledTimes(1)
})
it('undoes a scheduled downgrade from the plan card via resume', async () => {
const fixture = billingDevFixtures['pending-downgrade']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
apiMocks.resumeSubscription.mockResolvedValue({ data: { ok: true }, ok: true })
const client = renderBilling()
const invalidate = vi.spyOn(client, 'invalidateQueries')
expect(await screen.findByText('Changes to Free on Aug 15.')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Undo' }))
await waitFor(() => expect(apiMocks.resumeSubscription).toHaveBeenCalledTimes(1))
await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ['billing', 'subscription'] }))
})
it('undoes a scheduled cancellation from the plan card via resume', async () => {
const fixture = billingDevFixtures['pending-cancellation']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
apiMocks.resumeSubscription.mockResolvedValue({ data: { ok: true }, ok: true })
renderBilling()
expect(await screen.findByText('Cancels on Aug 15.')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Undo' }))
await waitFor(() => expect(apiMocks.resumeSubscription).toHaveBeenCalledTimes(1))
})
it('locks out the other downgrade tiles and Back while a schedule is in flight', async () => {
// Current = Ultra so Free/Plus/Super are all downgrades (three tiles).
apiMocks.fetchBillingState.mockResolvedValue(billingDevFixtures['subscriber-personal'].billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(
okSubscription({
...todaySubscriptionState,
can_change_plan: true,
context: 'personal',
current: { ...todaySubscriptionState.current, tier_id: 't_ultra', tier_name: 'Ultra' },
tiers: [
{
dollars_per_month_display: '$0',
is_current: false,
is_enabled: true,
monthly_credits: '0.1',
name: 'Free',
tier_id: 't_free',
tier_order: 0
},
{
dollars_per_month_display: '$20',
is_current: false,
is_enabled: true,
monthly_credits: '22',
name: 'Plus',
tier_id: 't_plus',
tier_order: 1
},
{
dollars_per_month_display: '$100',
is_current: false,
is_enabled: true,
monthly_credits: '110',
name: 'Super',
tier_id: 't_super',
tier_order: 2
},
{
dollars_per_month_display: '$200',
is_current: true,
is_enabled: true,
monthly_credits: '220',
name: 'Ultra',
tier_id: 't_ultra',
tier_order: 3
}
]
})
)
apiMocks.previewSubscriptionChange.mockResolvedValue({
data: { effect: 'scheduled', effective_at: '2026-08-15T00:00:00Z', ok: true, target_tier_name: 'Free' },
ok: true
})
let settleSchedule: (value: unknown) => void = () => {}
apiMocks.scheduleSubscriptionChange.mockReturnValue(
new Promise(resolve => {
settleSchedule = resolve
})
)
renderBilling(['/settings?tab=billing&bview=plans'])
const downgrades = await screen.findAllByRole('button', { name: 'Downgrade' })
expect(downgrades.length).toBe(3)
fireEvent.click(downgrades[0])
fireEvent.click(await screen.findByRole('button', { name: 'Confirm downgrade' }))
// Scheduling in flight → the two remaining tiles + Back are disabled.
await waitFor(() => {
const remaining = screen.getAllByRole('button', { name: 'Downgrade' })
expect(remaining.length).toBe(2)
expect(remaining.every(btn => btn.hasAttribute('disabled'))).toBe(true)
})
expect(screen.getByRole('button', { name: 'Back to billing' }).hasAttribute('disabled')).toBe(true)
settleSchedule({ data: { ok: true }, ok: true })
})
it('disables Undo while the resume is in flight', async () => {
const fixture = billingDevFixtures['pending-downgrade']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
let settleResume: (value: unknown) => void = () => {}
apiMocks.resumeSubscription.mockReturnValue(
new Promise(resolve => {
settleResume = resolve
})
)
renderBilling()
fireEvent.click(await screen.findByRole('button', { name: 'Undo' }))
await waitFor(() => expect(screen.getByRole('button', { name: 'Undoing…' }).hasAttribute('disabled')).toBe(true))
settleResume({ data: { ok: true }, ok: true })
})
it('moves focus into the confirm panel (role=status) when a downgrade opens', async () => {
const fixture = billingDevFixtures['subscriber-personal']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
apiMocks.previewSubscriptionChange.mockResolvedValue({
data: { effect: 'scheduled', effective_at: '2026-08-15T00:00:00Z', ok: true, target_tier_name: 'Free' },
ok: true
})
renderBilling(['/settings?tab=billing&bview=plans'])
fireEvent.click(await screen.findByRole('button', { name: 'Downgrade' }))
const panel = await screen.findByRole('status')
expect(panel.getAttribute('aria-live')).toBe('polite')
expect(panel).toBe(panel.ownerDocument.activeElement)
})
it('keeps the auto-refill edit form mounted so the row height is reserved before editing', async () => {
renderBilling()
await screen.findByRole('button', { name: 'Manage' })
// Not editing: the inputs are already in the DOM (height reserved) but aria-hidden,
// so the accessible query finds nothing while the hidden-inclusive query does.
expect(screen.queryByRole('spinbutton', { name: 'Auto-refill threshold' })).toBeNull()
expect(screen.getByRole('spinbutton', { name: 'Auto-refill threshold', hidden: true })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Manage' }))
// Editing reveals the same reserved input.
expect(screen.getByRole('spinbutton', { name: 'Auto-refill threshold' })).toBeTruthy()
})
it('renders auto-refill mutation refusals and step-up affordance', async () => {
renderBilling()
apiMocks.updateAutoReload.mockResolvedValue({
ok: false,
refusal: {
kind: 'insufficient_scope',
message: 'billing:manage required'
}
})
fireEvent.click(await screen.findByRole('button', { name: 'Manage' }))
fireEvent.change(screen.getByRole('spinbutton', { name: 'Auto-refill threshold' }), {
target: { value: '15' }
})
fireEvent.change(screen.getByRole('spinbutton', { name: 'Auto-refill reload-to amount' }), {
target: { value: '20' }
})
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(await screen.findByText('Remote Spending needs approval:')).toBeTruthy()
expect(screen.getByText('This needs Remote Spending allowed. Start a top-up to allow it, then retry.')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Verify to continue' })).toBeTruthy()
})
it('keeps disabled auto-refill portal-only with no enable control', async () => {
apiMocks.fetchBillingState.mockResolvedValue(okBilling(postTrainBillingState))
apiMocks.fetchSubscriptionState.mockResolvedValue(okSubscription(postTrainSubscriptionState))
renderBilling()
expect((await screen.findAllByText('Off')).length).toBeGreaterThan(0)
expect(screen.getByText('Turn on auto-refill from the portal')).toBeTruthy()
expect(screen.queryByRole('button', { name: /enable/i })).toBeNull()
expect(screen.queryByRole('button', { name: 'Manage' })).toBeNull()
})
it('disables buy controls while polling and renders the settled outcome', async () => {
let settleStatus: (value: unknown) => void = () => {}
const statusPromise = new Promise(resolve => {
settleStatus = resolve
})
apiMocks.fetchBillingState.mockResolvedValue(okBilling(postTrainBillingState))
apiMocks.fetchSubscriptionState.mockResolvedValue(okSubscription(postTrainSubscriptionState))
apiMocks.charge.mockResolvedValue({
data: {
charge_id: 'ch_123',
ok: true
},
idempotencyKey: 'key-1',
ok: true
})
apiMocks.chargeStatus.mockReturnValue(statusPromise)
renderBilling()
fireEvent.click(await screen.findByRole('button', { name: /^Buy$/ }))
expect(await screen.findByText('Processing… checking settlement')).toBeTruthy()
expect(screen.getByRole('button', { name: '$25' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: '$50' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('spinbutton', { name: 'Custom credit amount' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: /^Buy$/ }).hasAttribute('disabled')).toBe(true)
settleStatus({
data: {
amount_usd: '25',
ok: true,
status: 'settled'
},
ok: true
})
await waitFor(() => expect(screen.getByText(`${formatMoney(25)} added. Balance is refreshing.`)).toBeTruthy())
})
it('renders logged-out as a connect card without normal account rows', async () => {
apiMocks.fetchBillingState.mockResolvedValue(okBilling(loggedOutBillingState))
apiMocks.fetchSubscriptionState.mockResolvedValue(okSubscription(loggedOutSubscriptionState))
renderBilling()
expect(await screen.findByText('Connect your Nous account')).toBeTruthy()
expect(screen.getByText('Run /portal in the TUI or open the Nous portal to connect your account.')).toBeTruthy()
expect(screen.queryByText('Payment method')).toBeNull()
expect(screen.queryByText('Usage')).toBeNull()
})
it('renders danger value text for overdrawn subscription credits', async () => {
const fixture = billingDevFixtures['empty-overdrawn']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
renderBilling()
expect((await screen.findByText('$0 of $220 left · $0.79 over')).classList.contains('text-destructive')).toBe(true)
const subscriptionTrack = screen.getByRole('progressbar', { name: 'Subscription credits remaining' })
// Plain shared primitive track (no bespoke dither/tinted chrome); the
// over-limit signal rides the destructive fill instead.
expect(subscriptionTrack.classList.contains('dither')).toBe(false)
expect(subscriptionTrack.classList.contains('bg-muted')).toBe(true)
expect(subscriptionTrack.querySelector('.bg-destructive')).toBeTruthy()
})
it('renders an empty neutral usage track when a row has no bar data', async () => {
const fixture = billingDevFixtures['no-subscription']
apiMocks.fetchBillingState.mockResolvedValue(
okBilling({
...todayBillingState,
monthly_cap: {
...todayBillingState.monthly_cap,
spent_display: '$0',
spent_this_month_usd: '0'
}
})
)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
renderBilling()
await screen.findByText('Subscription credits')
const subscriptionTrack = screen.getByRole('progressbar', { name: 'Subscription credits usage' })
expect(subscriptionTrack.getAttribute('aria-valuenow')).toBe('0')
expect(subscriptionTrack.classList.contains('text-destructive')).toBe(false)
// Empty tracks are the plain shared primitive now — no hatched placeholder.
expect(subscriptionTrack.classList.contains('dither')).toBe(false)
expect(subscriptionTrack.classList.contains('bg-muted')).toBe(true)
const monthlyCapTrack = screen.getByRole('progressbar', { name: 'Monthly spend cap used' })
expect(monthlyCapTrack.getAttribute('aria-valuenow')).toBe('0')
expect(monthlyCapTrack.classList.contains('dither')).toBe(false)
expect(monthlyCapTrack.classList.contains('bg-muted')).toBe(true)
})
it('shows a warn notice that names the no-card blocker with a portal link', async () => {
const fixture = billingDevFixtures['no-card']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
renderBilling()
expect(await screen.findByText('No payment method on file')).toBeTruthy()
expect(
screen.getByText(
'Buying top-up credits and auto-refill stay disabled until a card is on file. Add one on the portal.'
)
).toBeTruthy()
expect(screen.getByRole('button', { name: /Add card/ })).toBeTruthy()
})
it('does not show the no-card notice when a card is on file', async () => {
renderBilling()
await screen.findByText('$996.47')
expect(screen.queryByText('No payment method on file')).toBeNull()
})
it('polls billing on an interval without a manual refresh control', async () => {
renderBilling()
await screen.findByText('$120 of $220 left')
// The manual refresh affordance is gone — the queries poll on their own.
expect(screen.queryByRole('button', { name: 'Refresh' })).toBeNull()
expect(screen.queryByText(/Updated/)).toBeNull()
})
})
@@ -0,0 +1,584 @@
import { useQueryClient } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Progress } from '@/components/ui/progress'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { BarChart3, CreditCard, ExternalLink, Package, Wrench } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { useRouteEnumParam } from '../../hooks/use-route-enum-param'
import {
ListRow,
ListRowSkeleton,
SectionHeading,
SectionHeadingSkeleton,
SettingsContent,
SettingsSection
} from '../primitives'
import { RowValue } from './account-row-value'
import { BillingApiProvider } from './api'
import { AutoReloadRow } from './auto-reload-row'
import { clampAmount, formatMoney } from './billing-amounts'
import { CurrentPlanCard } from './current-plan-card'
import { type BillingDevFixtureName, billingDevFixtures } from './dev-fixtures'
import { StepUpInlineAction } from './inline-feedback'
import { openExternal } from './open-external'
import { BillingPlansView } from './plans-view'
import { createSimulatedBillingApi } from './simulated-api'
import type { BillingStateResponse } from './types'
import {
type BillingAccountRowView,
type BillingNoticeView,
type BillingUsageRowView,
deriveBillingView,
useBillingState,
useSubscriptionState
} from './use-billing-state'
import { useChargeFlow } from './use-charge-poller'
import { useStepUpFlow } from './use-step-up'
// `bview` mirrors the settings pview/kview sub-view pattern (deep-linkable, replace
// navigation). `overview` is the default landing; `plans` is the in-app catalog.
const BILLING_VIEWS = ['overview', 'plans'] as const
type BillingSubView = (typeof BILLING_VIEWS)[number]
const FEATURE_BILLING_INVOICES = false
const BILLING_DEV_FIXTURE_NAMES = import.meta.env.DEV
? (Object.keys(billingDevFixtures) as BillingDevFixtureName[])
: []
type BillingFixtureSelection = 'live' | BillingDevFixtureName
function SummaryCard({ label, value, tone }: { label: string; tone?: 'muted' | 'primary'; value: string }) {
return (
<div className="min-w-0">
<div className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">{label}</div>
<div
className={cn(
'mt-1 min-w-0 truncate text-lg font-semibold tabular-nums',
tone === 'primary' ? 'text-(--ui-green)' : tone === 'muted' ? 'text-(--ui-text-tertiary)' : 'text-foreground'
)}
>
{value}
</div>
</div>
)
}
function NoticeCard({ notice }: { notice: BillingNoticeView }) {
const warn = notice.tone === 'warn'
return (
<div className={cn('mb-6 rounded-xl p-4', warn ? 'bg-(--ui-yellow)/10' : 'bg-(--ui-bg-quaternary)')}>
<div
className={cn(
'text-[length:var(--conversation-text-font-size)] font-medium',
warn ? 'text-(--ui-yellow)' : 'text-foreground'
)}
>
{notice.title}
</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{notice.message}
</div>
{notice.action && (
<Button
className="mt-3"
onClick={() => openExternal(notice.action?.url)}
size="sm"
type="button"
variant="outline"
>
{notice.action.label}
<ExternalLink className="size-3.5" />
</Button>
)}
</div>
)
}
// The payment method as it rides in the "Payment & credits" heading: the current
// card (muted) plus a single underline text action (Update / Add payment method).
function PaymentMethodAside({ row }: { row: BillingAccountRowView }) {
return (
<div className="flex min-w-0 items-center gap-2.5">
{row.value && (
<span className="min-w-0 truncate text-[length:var(--conversation-caption-font-size)] font-normal text-(--ui-text-tertiary)">
{row.value}
</span>
)}
{row.action && (
<Button
disabled={row.action.disabled}
onClick={row.action.url ? () => openExternal(row.action?.url) : undefined}
size="sm"
type="button"
variant="textStrong"
>
{row.action.label}
</Button>
)}
</div>
)
}
function AccountRow({ billing, row }: { billing?: BillingStateResponse; row: BillingAccountRowView }) {
if (row.id === 'buy_credits' && row.action && row.chips && billing?.can_charge && billing.cli_billing_enabled) {
return <BuyCreditsRow billing={billing} row={row} />
}
if (row.id === 'auto_reload' && billing?.auto_reload) {
return <AutoReloadRow autoReload={billing.auto_reload} bounds={billing} row={row} />
}
return (
<ListRow
action={<RowValue row={row} />}
below={
row.caption ? (
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{row.caption}
</div>
) : undefined
}
description={row.description}
key={row.id}
title={row.title}
/>
)
}
function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: BillingAccountRowView }) {
const presets = useMemo(
() =>
billing.charge_presets.map((amount, index) => ({
amount,
label: billing.charge_presets_display[index] || formatMoney(amount)
})),
[billing.charge_presets, billing.charge_presets_display]
)
const initialAmount = presets[0]?.amount ?? billing.min_usd ?? ''
const [amount, setAmount] = useState(initialAmount)
const flow = useChargeFlow()
const busy = flow.phase === 'charging' || flow.phase === 'polling'
const controlsDisabled = busy || !billing.card
const clampedAmount = clampAmount(amount, billing)
const canBuy = !controlsDisabled && clampedAmount !== ''
const startBuy = () => {
if (!canBuy) {
return
}
setAmount(clampedAmount)
void flow.start(clampedAmount)
}
return (
<ListRow
action={
<div className="flex min-w-0 flex-wrap items-center justify-start gap-2 @2xl:justify-end">
<SegmentedControl
disabled={controlsDisabled}
onChange={value => setAmount(value)}
options={presets.map(preset => ({ id: preset.amount, label: preset.label }))}
value={amount}
/>
<Input
aria-label="Custom credit amount"
containerClassName="w-16"
disabled={controlsDisabled}
inputMode="decimal"
max={billing.max_usd ?? undefined}
min={billing.min_usd ?? undefined}
onBlur={() => setAmount(clampedAmount)}
onChange={event => {
flow.reset()
setAmount(event.target.value)
}}
placeholder={billing.min_usd ?? ''}
prefix="$"
size="xs"
step="0.01"
type="number"
value={amount}
/>
<Button disabled={!canBuy} onClick={startBuy} size="xs" type="button" variant="secondary">
Buy
</Button>
</div>
}
below={
<BuyCreditsOutcome
amount={clampedAmount}
busy={busy}
onPortal={openExternal}
onRetry={() => {
if (!clampedAmount) {
return
}
void flow.start(clampedAmount)
}}
outcome={flow.outcome}
/>
}
description={row.description}
key={row.id}
title={row.title}
/>
)
}
function BuyCreditsOutcome({
amount,
busy,
onPortal,
onRetry,
outcome
}: {
amount: string
busy: boolean
onPortal: (url?: string) => void
onRetry: () => void
outcome: ReturnType<typeof useChargeFlow>['outcome']
}) {
const stepUp = useStepUpFlow()
if (busy) {
return (
<div className="mt-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
Processing checking settlement
</div>
)
}
if (!outcome) {
return null
}
if (outcome.kind === 'success') {
return (
<div className="mt-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{formatMoney(outcome.amountUsd ?? amount)} added. Balance is refreshing.
</div>
)
}
if (outcome.kind === 'ambiguous') {
return (
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<span>
{outcome.title}: {outcome.message}
</span>
{outcome.portalUrl && (
<Button onClick={() => onPortal(outcome.portalUrl)} size="sm" type="button" variant="outline">
Open portal
<ExternalLink className="size-3.5" />
</Button>
)}
</div>
)
}
const portalUrl = outcome.action?.type === 'portal' ? outcome.action.url : undefined
return (
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<span>
{outcome.title}: {outcome.message}
</span>
{outcome.action?.type === 'retry' && (
<Button onClick={onRetry} size="sm" type="button" variant="outline">
Retry
</Button>
)}
{outcome.action?.type === 'step_up' && <StepUpInlineAction flow={stepUp} />}
{portalUrl && (
<Button onClick={() => onPortal(portalUrl)} size="sm" type="button" variant="outline">
Open portal
<ExternalLink className="size-3.5" />
</Button>
)}
</div>
)
}
function UsageBar({ bar, fallbackLabel }: { bar?: BillingUsageRowView['bar']; fallbackLabel: string }) {
const resolvedBar = bar ?? {
label: `${fallbackLabel} usage`,
state: 'neutral',
tone: 'topup',
value: 0
}
// Plain shared primitive — no bespoke track chrome. Only the fill tone carries
// billing meaning: destructive when over-limit, green for healthy remaining
// credits, muted otherwise. Color rides the sanctioned `fillClassName` override.
const isOk = resolvedBar.state === 'ok' && (resolvedBar.tone === 'subscription' || resolvedBar.tone === 'topup')
return (
<Progress
aria-label={resolvedBar.label}
destructive={resolvedBar.state === 'danger'}
fillClassName={resolvedBar.state === 'danger' ? undefined : isOk ? 'bg-(--ui-green)' : 'bg-muted-foreground/45'}
fillStyle={{ minWidth: resolvedBar.value > 0 ? 4 : undefined }}
size="lg"
value={resolvedBar.value}
/>
)
}
function UsageRow({ row }: { row: BillingUsageRowView }) {
return (
<div className="@container">
<div className="grid min-w-0 gap-2 py-3 @2xl:grid-cols-[minmax(0,180px)_minmax(0,1fr)_220px] @2xl:items-center @2xl:gap-4">
<div className="min-w-0">
<div className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{row.title}
</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{row.caption}
</div>
</div>
<div className="min-w-0">
<UsageBar bar={row.bar} fallbackLabel={row.title} />
</div>
<div
className={cn(
'min-w-0 whitespace-nowrap text-[length:var(--conversation-text-font-size)] font-medium tabular-nums @2xl:w-[220px] @2xl:flex-none @2xl:text-right',
row.bar?.state === 'danger' ? 'text-destructive' : 'text-foreground'
)}
>
{row.value}
</div>
</div>
</div>
)
}
// DEV-only preview switcher: swaps the whole page onto a canned fixture so every
// billing state can be reviewed without a matching live account. Marked with a
// wrench + "preview" so it never reads as a shipping control (it's compiled out of
// production builds entirely).
function BillingFixtureSelect({
onValueChange,
value
}: {
onValueChange: (value: BillingFixtureSelection) => void
value: BillingFixtureSelection
}) {
return (
<div className="flex items-center gap-1.5 text-(--ui-text-tertiary)">
<Wrench className="size-3.5 shrink-0" />
<span className="text-xs font-normal">preview</span>
<Select onValueChange={value => onValueChange(value as BillingFixtureSelection)} value={value}>
<SelectTrigger
aria-label="Billing preview fixture (dev only)"
className="h-7 w-36 border-dashed border-(--ui-stroke-secondary) bg-transparent px-2 text-xs font-normal text-(--ui-text-tertiary) shadow-none hover:bg-(--ui-bg-tertiary) focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=open]:bg-(--ui-bg-tertiary)"
size="sm"
>
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="live">live</SelectItem>
{BILLING_DEV_FIXTURE_NAMES.map(name => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
function BillingHeader({
fixtureName,
onFixtureChange
}: {
fixtureName?: BillingFixtureSelection
onFixtureChange?: (value: BillingFixtureSelection) => void
}) {
return (
<div className="mb-2.5 flex items-center justify-between gap-3 pt-2 text-[length:var(--conversation-text-font-size)] font-medium">
<div className="flex min-w-0 items-center gap-2">
<BarChart3 className="size-4 shrink-0 text-muted-foreground" />
<span>Billing</span>
</div>
{import.meta.env.DEV && fixtureName && onFixtureChange ? (
<BillingFixtureSelect onValueChange={onFixtureChange} value={fixtureName} />
) : null}
</div>
)
}
// Loading shape for the billing overview: three summary cards over the Plan /
// Payment & credits / Usage sections. Rendered under the real header.
function BillingSkeleton() {
return (
<>
<div className="@container mb-6">
<div className="grid gap-3 @2xl:grid-cols-3">
{[0, 1, 2].map(i => (
<div className="min-w-0 space-y-2" key={i}>
<Skeleton className="h-3 w-24" />
<Skeleton className="h-6 w-20" />
</div>
))}
</div>
</div>
{[0, 1, 2].map(section => (
<section className="mb-6" key={section}>
<SectionHeadingSkeleton />
<div className="grid gap-1">
<ListRowSkeleton />
<ListRowSkeleton />
</div>
</section>
))}
</>
)
}
function BillingSettingsContent({
fixtureName,
onFixtureChange
}: {
fixtureName?: BillingFixtureSelection
onFixtureChange?: (value: BillingFixtureSelection) => void
}) {
const [subView, setSubView] = useRouteEnumParam<BillingSubView>('bview', BILLING_VIEWS, 'overview')
// Fixture mode flows through the SAME query path — the simulated api (supplied by
// BillingApiProvider in the DEV wrapper) backs these fetches — so there is no
// fixture short-circuit here.
const billingState = useBillingState()
const subscriptionState = useSubscriptionState()
// First load keeps the page's shape via a skeleton instead of flashing "—"
// summary cards (background refetches leave `isPending` false, so no flicker).
if (billingState.isPending) {
return (
<SettingsContent>
<BillingHeader fixtureName={fixtureName} onFixtureChange={onFixtureChange} />
<BillingSkeleton />
</SettingsContent>
)
}
const billingResult = billingState.data
const subscriptionResult = subscriptionState.data
const view = deriveBillingView(billingResult, subscriptionResult)
const billing = billingResult?.ok ? billingResult.data : undefined
const { paymentRow, refillRow, topupRow } = view
// The payment method rides in the section header (right-aligned) — the
// "Payment & credits" title already names it, so a full labelled row would just
// repeat "Payment method". The stacked rows are the remaining money controls.
const accountRows = [topupRow, refillRow].filter((row): row is BillingAccountRowView => row !== undefined)
// Gate the plans sub-view on the SAME capability that renders the in-app button
// (`plan.action`): a team / non-changer deep-linking `bview=plans` must never
// reach a grid of live Choose buttons — it falls back to the overview.
const showPlans = subView === 'plans' && view.status === 'normal' && Boolean(view.plan?.action)
if (showPlans) {
return (
<SettingsContent>
<BillingHeader fixtureName={fixtureName} onFixtureChange={onFixtureChange} />
<BillingPlansView onBack={() => setSubView('overview')} tiers={view.tiers} />
</SettingsContent>
)
}
return (
<SettingsContent>
<BillingHeader fixtureName={fixtureName} onFixtureChange={onFixtureChange} />
{view.notice && <NoticeCard notice={view.notice} />}
<div className="@container mb-6">
<div className="grid gap-3 @2xl:grid-cols-3">
{view.summary.map(item => (
<SummaryCard key={item.label} label={item.label} tone={item.tone} value={item.value} />
))}
</div>
</div>
{view.plan && (
<SettingsSection icon={Package} title="Plan">
<CurrentPlanCard onViewPlans={() => setSubView('plans')} plan={view.plan} />
</SettingsSection>
)}
{(paymentRow || accountRows.length > 0) && (
<SettingsSection
aside={paymentRow ? <PaymentMethodAside row={paymentRow} /> : undefined}
icon={CreditCard}
title="Payment & credits"
>
{accountRows.map(row => (
<AccountRow billing={billing} key={row.id} row={row} />
))}
</SettingsSection>
)}
{view.usageRows.length > 0 && (
<SettingsSection icon={BarChart3} title="Usage">
<div className="@container">
{view.usageRows.map(row => (
<UsageRow key={row.id} row={row} />
))}
</div>
</SettingsSection>
)}
{
// no endpoint yet — NAS capability-board gap
FEATURE_BILLING_INVOICES ? <SectionHeading icon={BarChart3} title="Invoices" /> : null
}
</SettingsContent>
)
}
function BillingSettingsWithDevFixtures() {
const [fixtureName, setFixtureName] = useState<BillingFixtureSelection>('live')
const queryClient = useQueryClient()
// DEV-only: a picked fixture is served by a simulated api (in-memory, mutable) that
// the whole subtree resolves via BillingApiProvider → useBillingApi. `live` → null →
// the real gateway api. Rebuilt per fixture so switching starts from a fresh copy.
const simulatedApi = useMemo(
() => (fixtureName !== 'live' ? createSimulatedBillingApi(billingDevFixtures[fixtureName]) : null),
[fixtureName]
)
// Switching fixtures (or its simulated api) must refetch, since the billing queries
// are keyed the same across fixtures.
useEffect(() => {
void queryClient.invalidateQueries({ queryKey: ['billing'] })
}, [queryClient, simulatedApi])
return (
<BillingApiProvider value={simulatedApi}>
<BillingSettingsContent fixtureName={fixtureName} onFixtureChange={setFixtureName} />
</BillingApiProvider>
)
}
export function BillingSettings() {
if (import.meta.env.DEV) {
return <BillingSettingsWithDevFixtures />
}
return <BillingSettingsContent />
}
@@ -0,0 +1,70 @@
import { Button } from '@/components/ui/button'
import { openExternalLink } from '@/lib/external-link'
import { ExternalLink } from '@/lib/icons'
import type { BillingRefusal } from './api'
import { resolveRefusal } from './errors'
import { useStepUpFlow } from './use-step-up'
export function StepUpInlineAction({ flow }: { flow: ReturnType<typeof useStepUpFlow> }) {
if (flow.verification) {
return (
<span className="inline-flex min-w-0 flex-wrap items-center gap-2">
<span className="font-mono text-[0.72rem] font-semibold text-foreground">{flow.verification.code}</span>
<Button onClick={flow.openVerification} size="sm" type="button" variant="outline">
Open verification page
<ExternalLink className="size-3.5" />
</Button>
</span>
)
}
if (flow.message) {
return (
<span className="inline-flex min-w-0 flex-wrap items-center gap-2">
<span>
{flow.message.title}: {flow.message.text}
</span>
<Button onClick={flow.dismiss} size="sm" type="button" variant="outline">
Dismiss
</Button>
</span>
)
}
if (flow.phase === 'waiting') {
return <span>Waiting for verification link</span>
}
return (
<Button onClick={() => void flow.start()} size="sm" type="button" variant="outline">
Verify to continue
</Button>
)
}
export function BillingRefusalInline({ refusal }: { refusal: BillingRefusal | null }) {
const stepUp = useStepUpFlow()
if (!refusal) {
return null
}
const resolved = resolveRefusal(refusal)
const portalUrl = resolved.action.type === 'portal' ? resolved.action.url : undefined
return (
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<span>
<span className="font-medium text-foreground">{resolved.title}:</span> {resolved.message}
</span>
{resolved.action.type === 'step_up' && <StepUpInlineAction flow={stepUp} />}
{portalUrl && (
<Button onClick={() => openExternalLink(portalUrl)} size="sm" type="button" variant="outline">
Open portal
<ExternalLink className="size-3.5" />
</Button>
)}
</div>
)
}
@@ -0,0 +1,9 @@
import { openExternalLink } from '@/lib/external-link'
// Optional-arg convenience over the canonical opener — the billing rows pass
// possibly-undefined URLs straight through from their view models.
export function openExternal(url?: string) {
if (url) {
openExternalLink(url)
}
}
@@ -0,0 +1,229 @@
import { useEffect, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { openExternalLink } from '@/lib/external-link'
import { ChevronLeft, ExternalLink } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { Pill } from '../primitives'
import { BillingRefusalInline } from './inline-feedback'
import { TierArt } from './tier-art'
import { type BillingPlanTierView, formatBillingDate, formatMonthlyCreditsDelta } from './use-billing-state'
import { type DowngradePhase, useDowngradeFlow } from './use-subscription-change'
type DowngradeFlow = ReturnType<typeof useDowngradeFlow>
// The human sentence for the panel body, derived purely from the phase. `null` while
// a refusal is the only thing to show (BillingRefusalInline renders that separately).
function previewMessage(phase: DowngradePhase, fallbackTierName: string): null | string {
if (phase.kind === 'previewing') {
return 'Checking this change…'
}
if (phase.kind === 'previewFailed') {
return null
}
const { preview } = phase
const targetName = preview.target_tier_name ?? fallbackTierName
const creditsDelta = formatMonthlyCreditsDelta(preview.monthly_credits_delta)
switch (preview.effect) {
case 'blocked':
return preview.reason ?? 'That change cannot be made here.'
case 'no_op':
return `You are already on ${targetName} — nothing to change.`
case 'scheduled':
return (
`Change to ${targetName} — takes effect ${formatBillingDate(preview.effective_at)}. No charge now; ` +
`you keep your current plan until then.${creditsDelta ? ` Monthly credits change: ${creditsDelta}.` : ''}`
)
default:
return 'This change cannot be scheduled here.'
}
}
// The in-card preview → confirm panel for a downgrade (mirrors the TUI confirm flow).
function DowngradeConfirm({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierView }) {
const active = flow.active
const panelRef = useRef<HTMLDivElement>(null)
const open = active?.target.tierId === tier.tierId
// Move focus into the panel on open so keyboard users land on the confirm flow;
// role="status"/aria-live announces the async preview text as it arrives.
useEffect(() => {
if (open) {
panelRef.current?.focus()
}
}, [open])
if (!active || active.target.tierId !== tier.tierId) {
return null
}
const { phase } = active
const captionCn = 'text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)'
const refusal = phase.kind === 'previewFailed' || phase.kind === 'scheduleFailed' ? phase.refusal : null
const busy = phase.kind === 'previewing' || phase.kind === 'scheduling'
const message = previewMessage(phase, tier.name)
const canConfirm =
(phase.kind === 'ready' && phase.preview.effect === 'scheduled') ||
phase.kind === 'scheduling' ||
phase.kind === 'scheduleFailed'
return (
<div
aria-live="polite"
className="flex min-w-0 flex-col gap-2 rounded-md bg-(--ui-bg-elevated) p-3 outline-none"
ref={panelRef}
role="status"
tabIndex={-1}
>
{message && <div className={captionCn}>{message}</div>}
<BillingRefusalInline refusal={refusal} />
<div className="flex min-w-0 flex-wrap items-center gap-2">
{phase.kind === 'previewFailed' ? (
<Button disabled={busy} onClick={flow.retryPreview} size="sm" type="button">
Try again
</Button>
) : canConfirm ? (
<Button disabled={busy} onClick={() => void flow.confirm()} size="sm" type="button">
{phase.kind === 'scheduling'
? 'Scheduling…'
: phase.kind === 'scheduleFailed'
? 'Try again'
: 'Confirm downgrade'}
</Button>
) : null}
<Button disabled={busy} onClick={flow.cancel} size="sm" type="button" variant="outline">
Cancel
</Button>
</div>
</div>
)
}
function PlanCard({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierView }) {
const isCurrent = tier.state === 'current'
const confirming = flow.active?.target.tierId === tier.tierId
const cardRef = useRef<HTMLDivElement>(null)
const wasConfirming = useRef(false)
// When the confirm panel closes (cancel / scheduled), return focus to this tile
// so keyboard focus is never left detached on the removed panel.
// eslint-disable-next-line no-restricted-syntax -- tracks previous confirming state for focus return, not an atom mirror
useEffect(() => {
if (wasConfirming.current && !confirming) {
cardRef.current?.focus()
}
wasConfirming.current = confirming
}, [confirming])
return (
<div
className={cn(
'flex min-w-0 flex-col gap-3 rounded-lg p-4 outline-none',
isCurrent ? 'bg-(--ui-green)/10' : 'bg-(--ui-bg-quaternary)'
)}
ref={cardRef}
tabIndex={-1}
>
<div className="flex min-w-0 items-center gap-3">
<TierArt name={tier.name} />
<div className="min-w-0">
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{tier.name}
</div>
<div className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{tier.priceDisplay}/mo
</div>
</div>
</div>
{tier.creditsDisplay && (
<div className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{tier.creditsDisplay}
</div>
)}
<div className="mt-auto min-w-0 pt-1">
{isCurrent && <Pill tone="primary">Current plan</Pill>}
{tier.state === 'scheduled' && <Pill>Scheduled</Pill>}
{tier.state === 'upgrade' && (
<Button
onClick={() => tier.action && openExternalLink(tier.action.url)}
size="sm"
type="button"
variant="outline"
>
{tier.action.label}
<ExternalLink className="size-3.5" />
</Button>
)}
{tier.state === 'downgrade' &&
(confirming ? (
<DowngradeConfirm flow={flow} tier={tier} />
) : (
// Disabled while another tile's change is committing — no concurrent mutation.
<Button
disabled={flow.mutating}
onClick={() => flow.begin({ tierId: tier.tierId, tierName: tier.name })}
size="sm"
type="button"
variant="outline"
>
Downgrade
</Button>
))}
</div>
</div>
)
}
export function BillingPlansView({ onBack, tiers }: { onBack: () => void; tiers: BillingPlanTierView[] }) {
// A scheduled downgrade lands the user back on the overview, where the plan card
// now shows the pending state with its undo.
const flow = useDowngradeFlow({ onScheduled: onBack })
return (
<div className="@container">
<div className="mb-2.5 flex items-center gap-2 pt-2 text-[length:var(--conversation-text-font-size)] font-medium">
<Button
aria-label="Back to billing"
className="size-7 p-0 text-(--ui-text-tertiary)"
disabled={flow.mutating}
onClick={onBack}
size="sm"
type="button"
variant="ghost"
>
<ChevronLeft className="size-4" />
</Button>
<span>Plans</span>
</div>
{tiers.length > 0 ? (
<div className="grid gap-3 @lg:grid-cols-2 @3xl:grid-cols-3">
{tiers.map(tier => (
<PlanCard flow={flow} key={tier.tierId} tier={tier} />
))}
</div>
) : (
<div className="rounded-xl bg-(--ui-bg-quaternary) p-4 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
No plans are available to change to right now.
</div>
)}
</div>
)
}
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import { billingDevFixtures } from './dev-fixtures'
import { createSimulatedBillingApi } from './simulated-api'
import { deriveBillingView } from './use-billing-state'
const FREE_TIER_ID = 'cltier000free0000personal'
describe('createSimulatedBillingApi', () => {
it('progresses the pending state through the whole loop: schedule sets it, resume clears it', async () => {
const api = createSimulatedBillingApi(billingDevFixtures['subscriber-personal'])
const billing = await api.fetchBillingState()
// Baseline: subscriber on Plus, nothing pending.
const before = deriveBillingView(billing, await api.fetchSubscriptionState())
expect(before.plan?.pending).toBeUndefined()
// Schedule a downgrade to Free → the very next fetch shows the pending card + marker.
expect((await api.scheduleSubscriptionChange(FREE_TIER_ID)).ok).toBe(true)
const afterSchedule = deriveBillingView(billing, await api.fetchSubscriptionState())
expect(afterSchedule.plan?.pending).toMatchObject({ kind: 'downgrade', tierName: 'Free' })
expect(afterSchedule.tiers.find(tier => tier.name === 'Free')?.state).toBe('scheduled')
// Undo → pending cleared on the next fetch.
expect((await api.resumeSubscription()).ok).toBe(true)
const afterResume = deriveBillingView(billing, await api.fetchSubscriptionState())
expect(afterResume.plan?.pending).toBeUndefined()
expect(afterResume.tiers.some(tier => tier.state === 'scheduled')).toBe(false)
})
it('previews a chargeless scheduled change for the chosen tier', async () => {
const api = createSimulatedBillingApi(billingDevFixtures['subscriber-personal'])
const preview = await api.previewSubscriptionChange(FREE_TIER_ID)
expect(preview).toMatchObject({ data: { effect: 'scheduled', target_tier_name: 'Free' }, ok: true })
})
it('undoes a scheduled cancellation too', async () => {
const api = createSimulatedBillingApi(billingDevFixtures['pending-cancellation'])
const billing = await api.fetchBillingState()
expect(deriveBillingView(billing, await api.fetchSubscriptionState()).plan?.pending).toMatchObject({
kind: 'cancellation'
})
await api.resumeSubscription()
expect(deriveBillingView(billing, await api.fetchSubscriptionState()).plan?.pending).toBeUndefined()
})
it('does not mutate the shared fixture object', async () => {
const api = createSimulatedBillingApi(billingDevFixtures['subscriber-personal'])
await api.scheduleSubscriptionChange(FREE_TIER_ID)
const fixture = billingDevFixtures['subscriber-personal']
expect(deriveBillingView(fixture.billing, fixture.subscription).plan?.pending).toBeUndefined()
})
})
@@ -0,0 +1,89 @@
import type { BillingApi, BillingResult } from './api'
import type { BillingStateResponse, SubscriptionPreviewResponse, SubscriptionStateResponse } from './types'
/** The shape of one `billingDevFixtures` entry — a canned billing + subscription pair. */
export interface SimulatedFixture {
billing: BillingResult<BillingStateResponse>
subscription: BillingResult<SubscriptionStateResponse>
}
// A visible-but-brief pause so the live fixture loop actually sees the "Checking…" /
// "Scheduling…" / "Undoing…" transitions rather than an instant flip.
const SIMULATED_DELAY_MS = 300
const delay = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
const ok = <T>(data: T): BillingResult<T> => ({ data, ok: true })
/**
* A fully in-memory BillingApi for DEV fixtures — no gateway. Fetches serve a mutable
* copy of the fixture, and the subscription-change mutations WRITE that copy's pending
* state, so the fixture click-through genuinely progresses: schedule sets a pending
* downgrade (→ the plan card's "Changes to …" + Undo, and the grid's Scheduled marker
* on refetch), and resume clears any pending downgrade OR cancellation. Consumers reach
* it transparently via `useBillingApi` (overridden by BillingApiProvider), so no code
* outside this file is fixture-aware.
*/
export function createSimulatedBillingApi(fixture: SimulatedFixture): BillingApi {
const billing = fixture.billing
// Mutable copy so scheduling/undo don't leak back into the shared fixture object.
let subscription: BillingResult<SubscriptionStateResponse> = structuredClone(fixture.subscription)
const patchCurrent = (patch: Partial<NonNullable<SubscriptionStateResponse['current']>>) => {
if (subscription.ok && subscription.data.current) {
subscription = ok({ ...subscription.data, current: { ...subscription.data.current, ...patch } })
}
}
const tierName = (tierId: string): null | string =>
(subscription.ok ? subscription.data.tiers.find(tier => tier.tier_id === tierId)?.name : null) ?? null
return {
charge: async (_amountUsd, idempotencyKey = 'sim-key') => ({
data: { charge_id: 'sim-charge', ok: true },
idempotencyKey,
ok: true
}),
chargeStatus: async () => ok({ amount_usd: '0', ok: true, settled_at: null, status: 'settled' }),
fetchBillingState: async () => billing,
fetchSubscriptionState: async () => subscription,
previewSubscriptionChange: async tierId => {
await delay(SIMULATED_DELAY_MS)
const preview: SubscriptionPreviewResponse = {
effect: 'scheduled',
effective_at: subscription.ok ? (subscription.data.current?.cycle_ends_at ?? null) : null,
ok: true,
target_tier_name: tierName(tierId)
}
return ok(preview)
},
resumeSubscription: async () => {
await delay(SIMULATED_DELAY_MS)
// Undo either scheduled change kind.
patchCurrent({
cancel_at_period_end: false,
cancellation_effective_at: null,
cancellation_effective_display: null,
pending_downgrade_at: null,
pending_downgrade_display: null,
pending_downgrade_tier_name: null
})
return ok({ message: 'Change cancelled.', ok: true })
},
scheduleSubscriptionChange: async tierId => {
await delay(SIMULATED_DELAY_MS)
patchCurrent({
pending_downgrade_at: subscription.ok ? (subscription.data.current?.cycle_ends_at ?? null) : null,
pending_downgrade_display: null,
pending_downgrade_tier_name: tierName(tierId)
})
return ok({ message: 'Downgrade scheduled.', ok: true })
},
stepUp: async () => ok({ granted: true, ok: true }),
updateAutoReload: async () => ok({ ok: true })
}
}
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { resolveTierArt } from './tier-art'
describe('resolveTierArt', () => {
it('keys art by lowercase tier name, case-insensitively', () => {
for (const name of ['Free', 'starter', 'Plus', 'SUPER', 'ultra']) {
expect(resolveTierArt(name)).not.toBeNull()
}
})
it('maps each named tier to its NAS blend mode', () => {
expect(resolveTierArt('free')?.blend).toBe('screen')
expect(resolveTierArt('plus')?.blend).toBe('screen')
expect(resolveTierArt('super')?.blend).toBe('lighten')
expect(resolveTierArt('ultra')?.blend).toBe('normal')
})
it('returns null for unknown or missing names so the card renders text-only', () => {
expect(resolveTierArt('Mystery')).toBeNull()
expect(resolveTierArt('')).toBeNull()
expect(resolveTierArt(null)).toBeNull()
expect(resolveTierArt(undefined)).toBeNull()
})
})
@@ -0,0 +1,69 @@
import automationArt from '@/assets/tiers/feature-automation.webp'
import connectArt from '@/assets/tiers/feature-connect.webp'
import memoryArt from '@/assets/tiers/feature-memory.webp'
import sandboxArt from '@/assets/tiers/feature-sandbox.webp'
import { cn } from '@/lib/utils'
// Reproduces the portal's tier-card hero treatment at thumbnail size: each webp sits
// over a solid Nous-blue well and blends into it. This blue well is the ONLY place
// Nous blue appears in the billing page — everything else stays on the app's own tokens.
const NOUS_BLUE = '#0000f2'
const BLEND_CLASS = {
lighten: 'mix-blend-lighten',
normal: '',
screen: 'mix-blend-screen'
} as const
type TierBlend = keyof typeof BLEND_CLASS
interface TierArtSpec {
blend: TierBlend
src: string
}
// Keyed by lowercase tier NAME, not tier_id: real tier_ids are Prisma cuids that
// differ per environment, while names are stable. `free`/`starter` share the
// entry-tier art. An unknown name resolves to null → the card renders text-only.
const TIER_ART: Record<string, TierArtSpec> = {
free: { blend: 'screen', src: connectArt },
plus: { blend: 'screen', src: memoryArt },
starter: { blend: 'screen', src: connectArt },
super: { blend: 'lighten', src: automationArt },
ultra: { blend: 'normal', src: sandboxArt }
}
export function resolveTierArt(tierName?: null | string): null | TierArtSpec {
if (!tierName) {
return null
}
return TIER_ART[tierName.trim().toLowerCase()] ?? null
}
/**
* Small rounded thumbnail (~40px) rendering the tier art over a Nous-blue well.
* Returns null for unknown tiers so the caller lays out a text-only card without
* reserving empty art space. Imported via vite static imports so the URLs resolve
* under a packaged `file://` origin with webSecurity on.
*/
export function TierArt({ className, name, size = 40 }: { className?: string; name?: null | string; size?: number }) {
const art = resolveTierArt(name)
if (!art) {
return null
}
return (
<div
className={cn('relative shrink-0 overflow-hidden rounded-md', className)}
style={{ background: NOUS_BLUE, height: size, width: size }}
>
<img
alt=""
className={cn('pointer-events-none absolute inset-0 size-full max-w-none object-cover', BLEND_CLASS[art.blend])}
src={art.src}
/>
</div>
)
}
@@ -0,0 +1,117 @@
import { describe, expect, it } from 'vitest'
import type { BillingStateResponse, SubscriptionStateResponse } from './types'
const fullBillingState = {
auto_reload: {
card: { kind: 'canonical' },
enabled: true,
reload_to_display: '$100',
reload_to_usd: '100',
threshold_display: '$25',
threshold_usd: '25'
},
balance_display: '$142.50',
balance_usd: '142.50',
can_charge: true,
card: {
brand: 'visa',
display: 'Visa ....4242 - the card on your subscription',
last4: '4242',
masked: 'visa ....4242',
resolved_via: 'subPin'
},
charge_presets: ['25', '50', '100'],
charge_presets_display: ['$25', '$50', '$100'],
cli_billing_enabled: true,
is_admin: true,
logged_in: true,
max_usd: '10000',
min_usd: '10',
monthly_cap: {
is_default_ceiling: false,
limit_display: '$1,000',
limit_usd: '1000',
spent_display: '$180',
spent_this_month_usd: '180'
},
ok: true,
org_name: 'Acme Research',
portal_url: 'https://portal.nousresearch.com/billing',
role: 'OWNER',
usage: {
available: true,
has_topup: true,
plan_bar: {
fill_fraction: 0.4,
kind: 'plan',
pct_used: 60,
remaining_display: '$40',
spent_display: '$60',
total_display: '$100'
},
plan_name: 'Pro',
renews_at: '2026-07-31T00:00:00Z',
renews_display: 'Jul 31',
status: 'active',
subscription_remaining_display: '$40',
topup_bar: {
fill_fraction: 0.75,
kind: 'topup',
pct_used: 25,
remaining_display: '$75',
spent_display: '$25',
total_display: '$100'
},
topup_remaining_display: '$75',
total_spendable_display: '$115'
}
} satisfies BillingStateResponse
const deployedTodayBillingState = {
auto_reload: null,
balance_display: '$0.00',
balance_usd: null,
can_charge: false,
card: {
brand: 'mastercard',
last4: '4444',
masked: 'mastercard ....4444'
},
charge_presets: [],
charge_presets_display: [],
cli_billing_enabled: false,
is_admin: true,
logged_in: true,
max_usd: null,
min_usd: null,
monthly_cap: null,
ok: true,
org_name: 'Fresh Deploy',
portal_url: null,
role: 'OWNER'
} satisfies BillingStateResponse
const loggedOutSubscriptionState = {
can_change_plan: false,
context: 'personal',
current: null,
is_admin: false,
logged_in: false,
ok: true,
org_id: null,
org_name: null,
portal_url: 'https://portal.nousresearch.com/login',
role: null,
tiers: []
} satisfies SubscriptionStateResponse
describe('desktop billing wire types', () => {
it('pins realistic billing and subscription RPC payload shapes', () => {
expect(fullBillingState.card?.resolved_via).toBe('subPin')
expect(deployedTodayBillingState.can_charge).toBe(false)
expect(deployedTodayBillingState.cli_billing_enabled).toBe(false)
expect(deployedTodayBillingState.card?.last4).toBe('4444')
expect(loggedOutSubscriptionState.logged_in).toBe(false)
})
})
@@ -0,0 +1,35 @@
import type {
BillingAutoReload,
BillingCardInfo,
BillingChargeResponse,
BillingChargeStatusResponse,
BillingErrorPayload,
BillingMonthlyCap,
BillingMutationResponse,
BillingRefusalCode,
BillingStateResponse,
ChargeFailureReason,
SubscriptionPreviewResponse,
SubscriptionStateResponse,
SubscriptionTierOption,
UsageBarData,
UsageModelData
} from '@hermes/shared/billing'
export type {
BillingAutoReload,
BillingCardInfo,
BillingChargeResponse,
BillingChargeStatusResponse,
BillingErrorPayload,
BillingMonthlyCap,
BillingMutationResponse,
BillingRefusalCode,
BillingStateResponse,
ChargeFailureReason,
SubscriptionPreviewResponse,
SubscriptionStateResponse,
SubscriptionTierOption,
UsageBarData,
UsageModelData
}
@@ -0,0 +1,765 @@
import { describe, expect, it } from 'vitest'
import {
billingDevFixtures,
endpointUnavailableBilling,
endpointUnavailableSubscription,
loggedOutBillingState,
loggedOutSubscriptionState,
okBilling,
okSubscription,
postTrainBillingState,
postTrainSubscriptionState,
todayBillingState,
todaySubscriptionState
} from './fixtures.test-util'
import { buildManageSubscriptionUrl, deriveBillingView, formatMonthlyCreditsDelta } from './use-billing-state'
function usageRowFor(
fixtureName: keyof typeof billingDevFixtures,
rowId: 'monthly_cap' | 'subscription_credits' | 'topup_credits'
) {
const fixture = billingDevFixtures[fixtureName]
const view = deriveBillingView(fixture.billing, fixture.subscription)
return view.usageRows.find(row => row.id === rowId)
}
function subscriptionCreditsRowForRemaining(remaining: string) {
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
current: { ...todaySubscriptionState.current, credits_remaining: remaining, monthly_credits: '220' }
})
)
return view.usageRows.find(row => row.id === 'subscription_credits')
}
function monthlyCapRowForSpent(spent: string) {
const view = deriveBillingView(
okBilling({
...todayBillingState,
monthly_cap: {
is_default_ceiling: false,
limit_display: '$100',
limit_usd: '100',
spent_display: `$${spent}`,
spent_this_month_usd: spent
}
}),
okSubscription(todaySubscriptionState)
)
return view.usageRows.find(row => row.id === 'monthly_cap')
}
describe('deriveBillingView', () => {
it('derives the deployed-today shape with fail-open disabled charge controls', () => {
const view = deriveBillingView(okBilling(todayBillingState), okSubscription(todaySubscriptionState))
expect(view.status).toBe('normal')
expect(view.summary).toContainEqual({ label: 'Balance', value: '$996.47' })
expect(view.summary).toContainEqual({ label: 'Plan', value: 'Ultra · $200/mo' })
expect(view.topupRow?.description).toBe(
"Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page."
)
expect(view.topupRow?.chips).toBeUndefined()
expect(view.refillRow).toMatchObject({
action: { label: 'Manage' },
description: 'Charges $10 automatically when your balance falls below $5.',
manageInApp: true,
pill: { label: 'Enabled', tone: 'primary' }
})
expect(view.usageRows.map(row => row.id)).toEqual(['subscription_credits', 'topup_credits', 'monthly_cap'])
})
it('derives the post-train shape with card provenance, presets, and denominated usage bars', () => {
const view = deriveBillingView(okBilling(postTrainBillingState), okSubscription(postTrainSubscriptionState))
expect(view.status).toBe('normal')
expect(view.paymentRow?.value).toBe('Visa •••• 4242 - subscription card')
expect(view.topupRow?.chips?.map(chip => chip.label)).toEqual(['$25', '$50', '$100'])
expect(view.plan?.link?.url).toBe('https://portal.nousresearch.com/manage-subscription?org_id=org_123')
expect(view.usageRows.find(row => row.id === 'subscription_credits')).toMatchObject({
bar: { value: 0.4 },
value: '$40 of $100 left'
})
})
it('points divergent auto-refill cards at the portal for reconciliation', () => {
const view = deriveBillingView(
okBilling({
...todayBillingState,
auto_reload: {
...todayBillingState.auto_reload,
card: { kind: 'distinct', payment_method_id: 'pm_1', brand: 'mastercard', last4: '4444' }
}
}),
okSubscription(todaySubscriptionState)
)
expect(view.refillRow?.caption).toContain('Mastercard ••4444')
expect(view.refillRow?.caption).toContain('reconcile')
expect(view.refillRow?.action).toEqual({
label: 'Reconcile ↗',
url: 'https://portal.nousresearch.com/billing'
})
})
it('degrades safely when a divergent auto-refill card has no display details', () => {
const view = deriveBillingView(
okBilling({
...todayBillingState,
auto_reload: {
...todayBillingState.auto_reload,
card: { kind: 'distinct', payment_method_id: 'pm_1', brand: null, last4: null }
}
}),
okSubscription(todaySubscriptionState)
)
expect(view.refillRow?.caption).toContain('a different card')
expect(view.refillRow?.caption).not.toContain('null')
expect(view.refillRow?.action?.url).toBe('https://portal.nousresearch.com/billing')
})
it('renders the normal enabled auto-refill row when the card is null (no crash)', () => {
// The gateway emits auto_reload.card: null for a missing/unknown-kind card.
const view = deriveBillingView(
okBilling({ ...todayBillingState, auto_reload: { ...todayBillingState.auto_reload, card: null } }),
okSubscription(todaySubscriptionState)
)
expect(view.refillRow).toMatchObject({
action: { label: 'Manage' },
description: 'Charges $10 automatically when your balance falls below $5.',
manageInApp: true,
pill: { label: 'Enabled', tone: 'primary' }
})
})
it('keeps buy credit controls visible but disabled when no card is on file', () => {
const fixture = billingDevFixtures['no-card']
const view = deriveBillingView(fixture.billing, fixture.subscription)
const buyCredits = view.topupRow
expect(buyCredits).toMatchObject({
action: { disabled: true, label: 'Buy' },
// The no-card blocker is explained once by the page-level notice, not
// duplicated (emoji and all) into the row description.
description: 'A single charge on your card, added to your balance today.'
})
expect(buyCredits?.description).not.toContain('💳')
expect(buyCredits?.chips?.map(chip => chip.disabled)).toEqual([true, true, true])
// The page still leads with the warn banner naming the blocker + fix.
expect(view.notice).toMatchObject({ title: 'No payment method on file', tone: 'warn' })
})
it('derives a calm logged-out card with no account or usage rows', () => {
const view = deriveBillingView(okBilling(loggedOutBillingState), okSubscription(loggedOutSubscriptionState))
expect(view.status).toBe('logged_out')
expect(view.summary.map(item => item.value)).toEqual(['—', '—', '—'])
expect(view.notice).toMatchObject({
title: 'Connect your Nous account'
})
expect(view.paymentRow).toBeUndefined()
expect(view.topupRow).toBeUndefined()
expect(view.refillRow).toBeUndefined()
expect(view.usageRows).toEqual([])
})
it('derives a refusal notice when billing.state is unavailable', () => {
const view = deriveBillingView(endpointUnavailableBilling, okSubscription(todaySubscriptionState))
expect(view.status).toBe('refusal')
expect(view.summary.map(item => item.value)).toEqual(['—', '—', '—'])
expect(view.notice).toMatchObject({
title: 'Billing endpoint unavailable'
})
expect(view.paymentRow).toBeUndefined()
expect(view.topupRow).toBeUndefined()
expect(view.refillRow).toBeUndefined()
})
it('keeps subscription unavailable as a plan-card degradation with a live portal link', () => {
const view = deriveBillingView(okBilling(todayBillingState), endpointUnavailableSubscription)
expect(view.status).toBe('normal')
expect(view.plan).toMatchObject({
caption: 'Subscription details are unavailable; opening the portal is still available.',
tierName: 'Ultra'
})
expect(view.plan?.action).toBeUndefined()
// The caption promises the portal is still reachable — so the link must exist.
expect(view.plan?.link).toMatchObject({
label: 'Adjust plan ↗',
url: 'https://portal.nousresearch.com/manage-subscription'
})
})
it('clamps overdrawn subscription credits to $0 and names the overage', () => {
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
current: { ...todaySubscriptionState.current, credits_remaining: '-0.79', monthly_credits: '220' }
})
)
const row = view.usageRows.find(r => r.id === 'subscription_credits')
expect(row?.value).toBe('$0 of $220 left · $0.79 over')
expect(row?.bar?.value).toBe(0)
})
it('marks subscription remaining bars as ok above 10% and danger at or below 10%', () => {
const elevenPercent = subscriptionCreditsRowForRemaining('24.2')
expect(elevenPercent?.bar?.state).toBe('ok')
expect(elevenPercent?.bar?.value).toBeCloseTo(0.11)
expect(usageRowFor('healthy', 'subscription_credits')?.bar).toMatchObject({
state: 'ok',
value: 0.6
})
// Owner wording is "green until 10%, then red"; the exact 10% boundary is red.
expect(usageRowFor('boundary', 'subscription_credits')?.bar).toMatchObject({
state: 'danger',
value: 0.1
})
expect(usageRowFor('low', 'subscription_credits')?.bar).toMatchObject({
state: 'danger',
value: 0.09
})
})
it('marks empty or overdrawn subscription bars as danger with a full danger track', () => {
const row = usageRowFor('empty-overdrawn', 'subscription_credits')
expect(row?.value).toBe('$0 of $220 left · $0.79 over')
expect(row?.bar).toMatchObject({
state: 'danger',
track: 'danger',
value: 0
})
})
it('marks monthly cap bars as neutral below 90% and danger at or above 90%', () => {
expect(usageRowFor('healthy', 'monthly_cap')?.bar).toMatchObject({
state: 'ok',
value: 0.89
})
expect(monthlyCapRowForSpent('90')?.bar).toMatchObject({
state: 'danger',
value: 0.9
})
expect(usageRowFor('cap-near', 'monthly_cap')?.bar).toMatchObject({
state: 'danger',
value: 0.92
})
expect(usageRowFor('cap-hit', 'monthly_cap')?.bar).toMatchObject({
state: 'danger',
track: 'danger',
value: 1
})
})
it('renders top-up balance as a bare amount — no bar (no denominator exists)', () => {
const view = deriveBillingView(okBilling(postTrainBillingState), okSubscription(postTrainSubscriptionState))
const topup = view.usageRows.find(row => row.id === 'topup_credits')
expect(topup?.value).toBe('$75')
expect(topup?.bar).toBeUndefined()
})
it('renders zero top-up balance without a bar too', () => {
const view = deriveBillingView(
okBilling({
...todayBillingState,
balance_display: '$0',
balance_usd: '0',
usage: {
...todayBillingState.usage,
topup_remaining_display: '$0'
}
}),
undefined
)
const topup = view.usageRows.find(row => row.id === 'topup_credits')
expect(topup?.value).toBe('$0')
expect(topup?.bar).toBeUndefined()
})
})
describe('derivePlanCard (current-plan card)', () => {
it('offers an in-app "View plans" button for a free personal account that can change plans', () => {
const fixture = billingDevFixtures['free-personal']
const view = deriveBillingView(fixture.billing, fixture.subscription)
expect(view.plan).toMatchObject({ action: { label: 'View plans' }, tierName: 'Free' })
expect(view.plan?.link).toBeUndefined()
})
it('offers an in-app "Change plan" button for a personal subscriber', () => {
const fixture = billingDevFixtures['subscriber-personal']
const view = deriveBillingView(fixture.billing, fixture.subscription)
expect(view.plan).toMatchObject({ action: { label: 'Change plan' }, price: '$20', tierName: 'Plus' })
expect(view.plan?.link).toBeUndefined()
})
it('gives teams a portal escape hatch and no in-app button', () => {
// todaySubscriptionState is context: 'team'.
const view = deriveBillingView(okBilling(todayBillingState), okSubscription(todaySubscriptionState))
expect(view.plan?.action).toBeUndefined()
expect(view.plan?.link).toMatchObject({
label: 'Adjust plan ↗',
url: 'https://portal.nousresearch.com/manage-subscription?org_id=sid-5'
})
})
it('gives non-changing members a portal link but no in-app button', () => {
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({ ...todaySubscriptionState, can_change_plan: false, context: 'personal' })
)
expect(view.plan?.action).toBeUndefined()
expect(view.plan?.link).toMatchObject({
label: 'Adjust plan ↗',
url: 'https://portal.nousresearch.com/manage-subscription?org_id=sid-5'
})
})
it('withholds the in-app button (no dead click) and offers the portal link when nothing is actionable', () => {
// A subscriber whose only enabled tier is the one they are already on: the grid
// would show a single inert card, so the "Change plan" button must not appear.
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
can_change_plan: true,
context: 'personal',
current: { ...todaySubscriptionState.current, tier_id: 'solo', tier_name: 'Solo' },
tiers: [
{
dollars_per_month_display: '$10',
is_current: true,
is_enabled: true,
monthly_credits: '10',
name: 'Solo',
tier_id: 'solo',
tier_order: 0
}
]
})
)
expect(view.tiers.map(tier => tier.state)).toEqual(['current'])
expect(view.plan?.action).toBeUndefined()
expect(view.plan?.link?.label).toBe('Adjust plan ↗')
})
it('gives a top-tier subscriber a portal link, not a dead in-app button', () => {
// On the highest tier, every enabled tile below is a downgrade — but downgrades are
// themselves actionable in-app at ticket 11, so this stays a "Change plan" account.
// (The dead-grid case is a subscriber whose only tile is `current`; covered above.)
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
can_change_plan: true,
context: 'personal',
current: { ...todaySubscriptionState.current, tier_id: 'top', tier_name: 'Ultra' },
tiers: [
{
dollars_per_month_display: '$0',
is_current: false,
is_enabled: true,
monthly_credits: '0.1',
name: 'Free',
tier_id: 't_free',
tier_order: 0
},
{
dollars_per_month_display: '$200',
is_current: true,
is_enabled: true,
monthly_credits: '220',
name: 'Ultra',
tier_id: 'top',
tier_order: 1
}
]
})
)
expect(view.tiers.some(tier => tier.state === 'upgrade')).toBe(false)
// Free below is an in-app downgrade → still actionable → in-app button.
expect(view.plan?.action).toMatchObject({ label: 'Change plan' })
})
it('surfaces a scheduled downgrade as the plan-card pending state (drives the undo)', () => {
const fixture = billingDevFixtures['pending-downgrade']
const view = deriveBillingView(fixture.billing, fixture.subscription)
expect(view.plan).toMatchObject({
action: { label: 'Change plan' },
caption: 'Changes to Free on Aug 15.',
pending: { kind: 'downgrade', tierName: 'Free', when: 'Aug 15' },
tierName: 'Plus'
})
})
it('surfaces a scheduled cancellation as "Cancels on …" with the same undo and no grid marker', () => {
const fixture = billingDevFixtures['pending-cancellation']
const view = deriveBillingView(fixture.billing, fixture.subscription)
expect(view.plan).toMatchObject({
action: { label: 'Change plan' },
caption: 'Cancels on Aug 15.',
pending: { kind: 'cancellation', when: 'Aug 15' },
tierName: 'Plus'
})
// A cancellation has no target tier → nothing to mark in the grid.
expect(view.tiers.some(tier => tier.state === 'scheduled')).toBe(false)
})
it('lets a pending downgrade win over a cancellation when both are set', () => {
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
can_change_plan: true,
context: 'personal',
current: {
...todaySubscriptionState.current,
cancel_at_period_end: true,
cancellation_effective_at: '2026-09-01T00:00:00Z',
cancellation_effective_display: 'Sep 1',
pending_downgrade_at: '2026-08-15T00:00:00Z',
pending_downgrade_display: 'Aug 15',
pending_downgrade_tier_name: 'Free',
tier_id: 'plus',
tier_name: 'Plus'
},
tiers: [
{
dollars_per_month_display: '$0',
is_current: false,
is_enabled: true,
monthly_credits: '0.1',
name: 'Free',
tier_id: 'free',
tier_order: 0
},
{
dollars_per_month_display: '$20',
is_current: true,
is_enabled: true,
monthly_credits: '22',
name: 'Plus',
tier_id: 'plus',
tier_order: 1
}
]
})
)
// Downgrade wins (names a concrete target); card + grid agree on it.
expect(view.plan?.pending).toMatchObject({ kind: 'downgrade', tierName: 'Free' })
expect(view.plan?.caption).toBe('Changes to Free on Aug 15.')
expect(view.tiers.find(tier => tier.name === 'Free')?.state).toBe('scheduled')
})
it('offers only the portal link when the tier catalog is empty', () => {
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
can_change_plan: true,
context: 'personal',
current: null,
tiers: []
})
)
expect(view.tiers).toEqual([])
expect(view.plan?.action).toBeUndefined()
expect(view.plan?.link?.label).toBe('Adjust plan ↗')
})
})
describe('derivePlanTiers (plans grid)', () => {
it('marks the current tier, upgrades, and in-app downgrades for a subscriber', () => {
const fixture = billingDevFixtures['subscriber-personal']
const view = deriveBillingView(fixture.billing, fixture.subscription)
const byName = Object.fromEntries(view.tiers.map(tier => [tier.name, tier]))
expect(view.tiers.map(tier => tier.name)).toEqual(['Free', 'Plus', 'Super', 'Ultra'])
expect(byName.Free.state).toBe('downgrade')
// Downgrades act in-app (no portal URL / caption) — the PlanCard wires the confirm flow.
expect('action' in byName.Free).toBe(false)
expect(byName.Plus.state).toBe('current')
expect('action' in byName.Plus).toBe(false)
expect(byName.Super).toMatchObject({
action: {
url: 'https://portal.nousresearch.com/manage-subscription?org_id=org_personal_plus&plan=cltier222super222personal'
},
creditsDisplay: '$110 credits/mo',
state: 'upgrade'
})
expect(byName.Ultra.state).toBe('upgrade')
})
it('marks the pending downgrade target "scheduled" (inert) while other tiers stay actionable', () => {
const fixture = billingDevFixtures['pending-downgrade']
const view = deriveBillingView(fixture.billing, fixture.subscription)
const byName = Object.fromEntries(view.tiers.map(tier => [tier.name, tier]))
// Free is the scheduled target → inert marker, not another "Downgrade".
expect(byName.Free.state).toBe('scheduled')
expect('action' in byName.Free).toBe(false)
expect(byName.Plus.state).toBe('current')
// Reschedule stays possible on the other lower/higher tiers.
expect(byName.Super.state).toBe('upgrade')
expect(byName.Ultra.state).toBe('upgrade')
})
it('marks the free/lowest tier current (inert) and every paid tier an upgrade when there is no subscription', () => {
const fixture = billingDevFixtures['free-personal']
const view = deriveBillingView(fixture.billing, fixture.subscription)
const byName = Object.fromEntries(view.tiers.map(tier => [tier.name, tier]))
// No "subscribe to Free" — the $0 tier is the current plan, not a choice.
expect(view.tiers.map(tier => tier.state)).toEqual(['current', 'upgrade', 'upgrade', 'upgrade'])
expect('action' in byName.Free).toBe(false)
// No downgrade state can exist without a subscription.
expect(view.tiers.some(tier => tier.state === 'downgrade')).toBe(false)
expect(byName.Plus).toMatchObject({
action: {
url: 'https://portal.nousresearch.com/manage-subscription?org_id=org_personal_free&plan=cltier111plus1111personal'
}
})
})
it('still lists a tier whose name has no art mapping (text-only card, no layout break)', () => {
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
context: 'personal',
current: null,
tiers: [
{
dollars_per_month_display: '$0',
is_current: false,
is_enabled: true,
monthly_credits: '0.1',
name: 'Free',
tier_id: 'cltier_free_0000',
tier_order: 0
},
{
dollars_per_month_display: '$5',
is_current: false,
is_enabled: true,
monthly_credits: '5',
name: 'Mystery',
tier_id: 'cltier_mystery_0000',
tier_order: 5
}
]
})
)
// The unknown-named paid tier still lists (art resolves to null → text-only).
expect(view.tiers.map(tier => tier.name)).toEqual(['Free', 'Mystery'])
expect(view.tiers.find(tier => tier.name === 'Mystery')?.state).toBe('upgrade')
})
it('keeps a grandfathered (is_enabled:false) CURRENT tier inert and orders downgrades against it', () => {
// NAS marks a grandfathered current tier is_enabled:false. It must still appear
// (inert "Current plan") and define the order boundary — lower enabled tiers are
// downgrades, higher ones are Choose.
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
context: 'personal',
current: { ...todaySubscriptionState.current, tier_id: 'legacy_mid', tier_name: 'Legacy' },
tiers: [
{
dollars_per_month_display: '$5',
is_current: false,
is_enabled: true,
monthly_credits: '5',
name: 'Basic',
tier_id: 'basic',
tier_order: 0
},
{
dollars_per_month_display: '$15',
is_current: true,
is_enabled: false,
monthly_credits: '15',
name: 'Legacy',
tier_id: 'legacy_mid',
tier_order: 1
},
{
dollars_per_month_display: '$40',
is_current: false,
is_enabled: true,
monthly_credits: '40',
name: 'Ultra',
tier_id: 'ultra',
tier_order: 2
}
]
})
)
const byName = Object.fromEntries(view.tiers.map(tier => [tier.name, tier]))
expect(view.tiers.map(tier => tier.name)).toEqual(['Basic', 'Legacy', 'Ultra'])
expect(byName.Legacy.state).toBe('current')
expect('action' in byName.Legacy).toBe(false)
expect(byName.Basic.state).toBe('downgrade')
expect('action' in byName.Basic).toBe(false)
expect(byName.Ultra).toMatchObject({ action: { label: 'Choose ↗' }, state: 'upgrade' })
})
it('backs Choose URLs with billing.portal_url (org_id + plan intact) when the subscription has no portal_url', () => {
const view = deriveBillingView(
okBilling({ ...todayBillingState, portal_url: 'https://billing.example.com/x' }),
okSubscription({
...todaySubscriptionState,
can_change_plan: true,
context: 'personal',
current: null,
portal_url: null,
tiers: [
{
dollars_per_month_display: '$0',
is_current: false,
is_enabled: true,
monthly_credits: '0.1',
name: 'Free',
tier_id: 'free0',
tier_order: 0
},
{
dollars_per_month_display: '$20',
is_current: false,
is_enabled: true,
monthly_credits: '22',
name: 'Plus',
tier_id: 'plus1',
tier_order: 1
}
]
})
)
expect(view.tiers.find(tier => tier.name === 'Plus')).toMatchObject({
action: { url: 'https://billing.example.com/manage-subscription?org_id=sid-5&plan=plus1' }
})
})
it('drops grandfathered (is_enabled: false) tiers from the grid', () => {
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
context: 'personal',
current: null,
tiers: [
{
dollars_per_month_display: '$20',
is_current: false,
is_enabled: true,
monthly_credits: '22',
name: 'Plus',
tier_id: 'plus',
tier_order: 1
},
{
dollars_per_month_display: '$9',
is_current: false,
is_enabled: false,
monthly_credits: '9',
name: 'Legacy',
tier_id: 'legacy',
tier_order: 1
}
]
})
)
expect(view.tiers.map(tier => tier.name)).toEqual(['Plus'])
})
})
describe('buildManageSubscriptionUrl', () => {
it('mirrors the TUI manage-subscription URL construction', () => {
expect(
buildManageSubscriptionUrl({
org_id: 'org_123',
portal_url: 'https://portal.nousresearch.com/billing'
})
).toBe('https://portal.nousresearch.com/manage-subscription?org_id=org_123')
})
it('appends plan=<tierId> when a tier is chosen', () => {
expect(
buildManageSubscriptionUrl(
{ org_id: 'org_123', portal_url: 'https://portal.nousresearch.com/billing' },
null,
'tier_abc'
)
).toBe('https://portal.nousresearch.com/manage-subscription?org_id=org_123&plan=tier_abc')
})
it('omits the plan param when no tier is given', () => {
expect(
buildManageSubscriptionUrl({ org_id: 'org_123', portal_url: 'https://portal.nousresearch.com/billing' }, null)
).toBe('https://portal.nousresearch.com/manage-subscription?org_id=org_123')
})
it('applies org_id + plan to the hard-coded portal fallback when no portal_url resolves', () => {
// Regression: the fallback must be the last-resort ORIGIN, not a bare return that
// silently drops org_id/plan.
expect(buildManageSubscriptionUrl({ org_id: 'org_z', portal_url: null }, null, 'tier_q')).toBe(
'https://portal.nousresearch.com/manage-subscription?org_id=org_z&plan=tier_q'
)
})
})
describe('formatMonthlyCreditsDelta', () => {
it('renders a bare negative decimal as signed dollars, never a raw number', () => {
// Credits are DOLLARS — "-88" must not render bare.
expect(formatMonthlyCreditsDelta('-88')).toBe('$88/mo')
})
it('renders a positive delta with a plus sign and dollar formatting', () => {
expect(formatMonthlyCreditsDelta('40')).toBe('+$40/mo')
expect(formatMonthlyCreditsDelta('-12.50')).toBe('$12.50/mo')
})
it('hides the line (null) for a zero or absent delta', () => {
expect(formatMonthlyCreditsDelta('0')).toBeNull()
expect(formatMonthlyCreditsDelta(null)).toBeNull()
expect(formatMonthlyCreditsDelta(undefined)).toBeNull()
expect(formatMonthlyCreditsDelta('')).toBeNull()
})
})
@@ -0,0 +1,840 @@
import { useQuery } from '@tanstack/react-query'
import { fmtDate } from '@/lib/time'
import type { BillingRefusal, BillingResult } from './api'
import { useBillingApi } from './api'
import { resolveRefusal } from './errors'
import type { BillingStateResponse, SubscriptionStateResponse, SubscriptionTierOption, UsageModelData } from './types'
export const EMPTY_BILLING_VALUE = '—'
export const FALLBACK_PORTAL_BILLING_URL = 'https://portal.nousresearch.com/billing'
export const FALLBACK_PORTAL_URL = 'https://portal.nousresearch.com'
// The billing endpoint is the authoritative source of truth for balance / cap /
// plan — the inference `x-nous-credits-*` headers are best-effort and can drift
// out of sync (notably in team/org accounts where another member's spend moves
// the shared balance without ever touching THIS client's headers). So the page
// never trusts a cache: `staleTime: 0` + `refetchOnMount: 'always'` force a
// fresh fetch every time it opens or regains focus, and it keeps polling every
// 30s while mounted (react-query only ticks an active observer; it pauses when
// the window is backgrounded — refetchIntervalInBackground defaults to false).
// A `credits.*` notice crossing additionally invalidates ['billing','state'] to
// pull the change in immediately rather than waiting for the next poll tick.
const BILLING_QUERY_OPTIONS = {
refetchInterval: 30_000,
refetchOnMount: 'always',
refetchOnWindowFocus: true,
retry: false,
staleTime: 0
} as const
export interface BillingSummaryItemView {
label: 'Auto-refill' | 'Balance' | 'Plan'
tone?: 'muted' | 'primary'
value: string
}
export interface BillingNoticeView {
action?: {
label: string
url: string
}
message: string
title: string
/** `warn` = an actionable blocker (e.g. no card); `info` = neutral guidance. */
tone?: 'info' | 'warn'
}
export interface BillingRowActionView {
disabled?: boolean
label: string
url?: string
}
export interface BillingChipView {
disabled: boolean
label: string
/** When set, clicking the chip opens this URL externally. */
url?: string
}
export interface BillingAccountRowView {
action?: BillingRowActionView
caption?: string
chips?: BillingChipView[]
description: string
id: 'auto_reload' | 'buy_credits' | 'payment_method'
/** The auto-refill row that edits its amounts in place (canonical-card enabled). */
manageInApp?: true
pill?: {
label: string
tone: 'muted' | 'primary'
}
secondaryPill?: string
title: string
value?: string
}
/**
* A change scheduled at period end that `subscription.resume` can undo. A downgrade
* names its target tier (and marks it in the grid); a cancellation has no target
* (the whole plan lapses), so the grid shows no marker for it.
*/
export type PendingPlanTransition =
{ kind: 'cancellation'; when: string } | { kind: 'downgrade'; tierName: string; when: string }
/**
* The current-plan summary that replaces the old subscription row. Carries EITHER
* one in-app `action` (View plans / Change plan) OR a portal `link` ("Adjust plan
* ↗"), never both — a discriminated pair so consumers don't guard for the impossible
* "both present" / "neither present" cases.
*/
export type BillingPlanCardView = {
caption: string
/** A scheduled downgrade / cancellation waiting at period end (drives the undo). */
pending?: PendingPlanTransition
price?: string
tierName: string
} & ({ action: { label: string }; link?: undefined } | { action?: undefined; link: { label: string; url: string } })
interface BillingPlanTierBase {
creditsDisplay?: string
name: string
priceDisplay: string
tierId: string
}
/**
* One card in the `bview=plans` grid, discriminated by `state`: `upgrade` carries its
* portal `action`; `downgrade` is actionable IN-APP (the flow keys off `tierId`, so it
* needs no url/caption); `scheduled` is the inert pending-downgrade target; `current`
* is inert. The union lets consumers read `action` without defensive `?.`.
*/
export type BillingPlanTierView =
| (BillingPlanTierBase & { state: 'current' })
| (BillingPlanTierBase & { state: 'downgrade' })
| (BillingPlanTierBase & { state: 'scheduled' })
| (BillingPlanTierBase & { action: { label: string; url: string }; state: 'upgrade' })
export interface BillingUsageRowView {
bar?: {
label: string
state: 'danger' | 'neutral' | 'ok'
tone: 'cap' | 'subscription' | 'topup'
track?: 'danger'
value: number
}
caption: string
id: 'monthly_cap' | 'subscription_credits' | 'topup_credits'
title: string
value: string
}
export interface BillingView {
notice?: BillingNoticeView
/** Payment section row. Absent outside the normal (logged-in) state. */
paymentRow?: BillingAccountRowView
/** Current-plan card (Plan section). Absent until billing.state resolves. */
plan?: BillingPlanCardView
/** Automatic-refill section row. */
refillRow?: BillingAccountRowView
status: 'loading' | 'logged_out' | 'normal' | 'refusal'
summary: BillingSummaryItemView[]
/** Live tier catalog for the plans sub-view (empty when unavailable). */
tiers: BillingPlanTierView[]
/** One-time top-up section row. */
topupRow?: BillingAccountRowView
usageRows: BillingUsageRowView[]
}
export function useBillingState(enabled = true) {
const api = useBillingApi()
return useQuery({
...BILLING_QUERY_OPTIONS,
enabled,
queryFn: () => api.fetchBillingState(),
queryKey: ['billing', 'state']
})
}
export function useSubscriptionState(enabled = true) {
const api = useBillingApi()
return useQuery({
...BILLING_QUERY_OPTIONS,
enabled,
queryFn: () => api.fetchSubscriptionState(),
queryKey: ['billing', 'subscription']
})
}
export function deriveBillingView(
stateResult?: BillingResult<BillingStateResponse>,
subscriptionResult?: BillingResult<SubscriptionStateResponse>
): BillingView {
if (!stateResult) {
return {
status: 'loading',
summary: emptySummary(),
tiers: [],
usageRows: []
}
}
if (!stateResult.ok) {
return {
notice: refusalNotice(stateResult.refusal),
status: 'refusal',
summary: emptySummary(),
tiers: [],
usageRows: []
}
}
const billing = stateResult.data
const subscription = subscriptionResult?.ok ? subscriptionResult.data : null
if (!billing.logged_in || subscription?.logged_in === false) {
return {
notice: {
action: { label: 'Open portal ↗', url: billing.portal_url ?? subscription?.portal_url ?? FALLBACK_PORTAL_URL },
message: 'Run /portal in the TUI or open the Nous portal to connect your account.',
title: 'Connect your Nous account'
},
status: 'logged_out',
summary: emptySummary(),
tiers: [],
usageRows: []
}
}
// One "can change plans in-app" verdict, shared by the plan card (button vs portal
// link) and the grid (whether upgrade tiles are actionable) so the invariant lives
// in one place.
const capable = plansCapable(subscription, subscriptionResult)
// Computed once and threaded to both the card (caption + undo) and the grid
// (Scheduled marker), so the two never disagree about what's pending.
const pending = pendingTransition(subscription?.current)
const tiers = derivePlanTiers(subscription, billing.portal_url, capable, pending)
return {
notice: noCardNotice(billing),
paymentRow: paymentMethodRow(billing),
plan: derivePlanCard(billing, subscription, subscriptionResult, tiers, capable, pending),
refillRow: autoReloadRow(billing),
status: 'normal',
summary: [
{ label: 'Balance', value: displayBalance(billing) },
{ label: 'Plan', value: displayPlan(subscription, billing.usage) },
{
label: 'Auto-refill',
tone: billing.auto_reload?.enabled ? 'primary' : billing.auto_reload ? 'muted' : undefined,
value: billing.auto_reload ? (billing.auto_reload.enabled ? 'Enabled' : 'Off') : EMPTY_BILLING_VALUE
}
],
tiers,
topupRow: buyCreditsRow(billing),
usageRows: deriveUsageRows(billing, subscription)
}
}
export function buildManageSubscriptionUrl(
subscription?: null | Pick<SubscriptionStateResponse, 'org_id' | 'portal_url'>,
fallbackPortalUrl?: null | string,
// Optional tier to pre-select on the portal, appended as `plan=<tierId>`
// (validated server-side by the NAS reader, draft #748).
tierId?: null | string
): string {
// The hard-coded portal is the LAST-RESORT origin, not a bare early return:
// org_id / plan must still be applied to it so a null portal_url never silently
// strips the params that route the user to the right org + pre-selected tier.
const portalUrls = [subscription?.portal_url, fallbackPortalUrl, FALLBACK_PORTAL_BILLING_URL].filter(
(url): url is string => typeof url === 'string' && url.length > 0
)
for (const portalUrl of portalUrls) {
try {
const url = new URL('/manage-subscription', new URL(portalUrl).origin)
if (subscription?.org_id) {
url.searchParams.set('org_id', subscription.org_id)
}
if (tierId) {
url.searchParams.set('plan', tierId)
}
return url.toString()
} catch {
// Try the next candidate; malformed portal URLs should not break settings.
}
}
return FALLBACK_PORTAL_BILLING_URL
}
export function formatBillingDate(value?: null | string): string {
if (!value) {
return EMPTY_BILLING_VALUE
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return EMPTY_BILLING_VALUE
}
return fmtDate.format(date)
}
function emptySummary(): BillingSummaryItemView[] {
return [
{ label: 'Balance', value: EMPTY_BILLING_VALUE },
{ label: 'Plan', value: EMPTY_BILLING_VALUE },
{ label: 'Auto-refill', value: EMPTY_BILLING_VALUE }
]
}
function refusalNotice(refusal: BillingRefusal): BillingNoticeView {
const resolved = resolveRefusal(refusal)
const portalUrl = resolved.action.type === 'portal' ? resolved.action.url : undefined
return {
action: portalUrl ? { label: 'Open portal ↗', url: portalUrl } : undefined,
message: resolved.message,
title: resolved.title,
tone: 'warn'
}
}
// A logged-in account with no card can't buy credits or manage auto-refill, and
// every one of those controls disables silently — so lead the page with a single
// warn banner that names the blocker and links straight to the fix.
function noCardNotice(billing: BillingStateResponse): BillingNoticeView | undefined {
if (billing.card) {
return undefined
}
return {
action: { label: 'Add card ↗', url: billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL },
message: 'Buying top-up credits and auto-refill stay disabled until a card is on file. Add one on the portal.',
title: 'No payment method on file',
tone: 'warn'
}
}
// The active tier from the UNFILTERED catalog — a grandfathered current tier is
// is_enabled:false, so it must still resolve here (by is_current or matching id).
function findCurrentTier(subscription: null | SubscriptionStateResponse): SubscriptionTierOption | undefined {
const current = subscription?.current
return subscription?.tiers?.find(tier => tier.is_current || tier.tier_id === current?.tier_id)
}
// Whether this account can change plans in-app: a personal (non-team) subscription
// the server says the user can change, whose payload actually loaded.
function plansCapable(
subscription: null | SubscriptionStateResponse,
subscriptionResult: BillingResult<SubscriptionStateResponse> | undefined
): boolean {
if (!subscription || (subscriptionResult && !subscriptionResult.ok)) {
return false
}
return subscription.context !== 'team' && Boolean(subscription.can_change_plan)
}
// Monthly credits are dollars; NAS sends a bare decimal string. Never render a
// bare number — always "$110 credits/mo" (mirrors the retired subscriptionTierChips).
function creditsPerMonthDisplay(monthlyCredits: null | string): string | undefined {
const credits = Number((monthlyCredits ?? '').replace(/,/g, ''))
return Number.isFinite(credits) && credits > 0 ? `$${credits.toLocaleString('en-US')} credits/mo` : undefined
}
/**
* A monthly-credits delta from a plan-change preview. NAS sends a bare dollar
* decimal ("-88"); credits are DOLLARS, so render it as signed dollars
* ("$88/mo"), never the raw number. Zero / absent → null so the caller hides
* the line entirely.
*/
export function formatMonthlyCreditsDelta(delta?: null | string): null | string {
const amount = parseAmount(delta)
if (amount == null || amount === 0) {
return null
}
return `${amount < 0 ? '' : '+'}${formatMoney(Math.abs(amount))}/mo`
}
/**
* The current-plan card. It offers the in-app "View plans" / "Change plan" button
* ONLY when the account is plans-capable AND the grid has an actual UPGRADE to offer
* — a top-tier subscriber (only downgrades / current below them) would otherwise open
* a grid with nothing to do. In every no-button case (teams, non-changers, refused
* subscription, top tier, empty catalog) the card ALWAYS carries the portal
* escape-hatch link so the user is never stranded on an info-only card.
*/
function derivePlanCard(
billing: BillingStateResponse,
subscription: null | SubscriptionStateResponse,
subscriptionResult: BillingResult<SubscriptionStateResponse> | undefined,
tiers: BillingPlanTierView[],
capable: boolean,
pending: PendingPlanTransition | undefined
): BillingPlanCardView {
const current = subscription?.current
const tierName = current?.tier_name ?? billing.usage?.plan_name ?? 'Free'
// Price resolves against the UNFILTERED catalog so a grandfathered current tier
// still shows its price.
const price = findCurrentTier(subscription)?.dollars_per_month_display
const renewal = formatBillingDate(current?.cycle_ends_at ?? billing.usage?.renews_at)
const unavailable = subscriptionResult ? !subscriptionResult.ok : false
const caption = unavailable
? 'Subscription details are unavailable; opening the portal is still available.'
: pending
? pending.kind === 'downgrade'
? `Changes to ${pending.tierName} on ${pending.when}.`
: `Cancels on ${pending.when}.`
: current
? `Renews ${renewal}`
: 'No active subscription — paid models draw down top-up credits.'
// Actionable = a paid tier above (upgrade) or an in-app downgrade below the current
// one. Ticket 11 counts downgrades (they act in-app, so they carry no `action`); a
// top-tier subscriber with neither still gets the portal-link fallback below.
const hasActionableTier = tiers.some(tier => tier.state === 'upgrade' || tier.state === 'downgrade')
if (capable && hasActionableTier) {
return { action: { label: current ? 'Change plan' : 'View plans' }, caption, pending, price, tierName }
}
return {
caption,
// No in-app action → always hand off to the portal so the user isn't stranded.
link: {
label: 'Adjust plan ↗',
url: buildManageSubscriptionUrl(subscription, subscription?.portal_url ?? billing.portal_url)
},
pending,
price,
tierName
}
}
// The change scheduled at period end (undoable via subscription.resume). NAS may
// carry a pending downgrade (`pending_downgrade_*`, with a target tier name) and/or a
// scheduled cancellation (`cancel_at_period_end` + `cancellation_effective_*`).
// Precedence: a downgrade WINS if both are somehow set — it names a concrete target
// tier, the stronger, more specific signal, and is what the grid marks.
function pendingTransition(
current: null | undefined | NonNullable<SubscriptionStateResponse['current']>
): PendingPlanTransition | undefined {
if (current?.pending_downgrade_tier_name && current.pending_downgrade_at) {
return {
kind: 'downgrade',
tierName: current.pending_downgrade_tier_name,
when: current.pending_downgrade_display ?? formatBillingDate(current.pending_downgrade_at)
}
}
if (current?.cancel_at_period_end && current.cancellation_effective_at) {
return {
kind: 'cancellation',
when: current.cancellation_effective_display ?? formatBillingDate(current.cancellation_effective_at)
}
}
return undefined
}
/**
* The plans-grid catalog. Each card's state depends on its order relative to the
* current tier: current = inert marker; higher = "Choose ↗" opening the portal with
* the tier pre-selected; lower = an in-app "Downgrade" (chargeless, scheduled via the
* gateway). The already-scheduled downgrade target renders as an inert "Scheduled"
* marker; other lower tiers stay actionable (picking one reschedules). With no active
* subscription the lowest-order ($0 / free) tier stands in as the current plan, so
* there is no "subscribe to Free" upgrade and no downgrade state.
*
* Empty unless `capable`: only a plans-capable account gets actionable tiles, and the
* plan card / deep-link gate on the same verdict — so the grid never mints an
* upgrade action nobody may take. `fallbackPortalUrl` (billing.portal_url) backs the
* Choose URLs when the subscription payload has no portal_url, so org_id + plan are
* never dropped.
*/
function derivePlanTiers(
subscription: null | SubscriptionStateResponse,
fallbackPortalUrl: null | string,
capable: boolean,
pending: PendingPlanTransition | undefined
): BillingPlanTierView[] {
if (!capable || !subscription) {
return []
}
const allTiers = subscription.tiers ?? []
const current = subscription.current
const explicitCurrent = findCurrentTier(subscription)
// The grid shows the enabled catalog plus the grandfathered current tier (so it
// still renders as the inert "Current plan" card), sorted low→high.
const gridTiers = allTiers
.filter(tier => tier.is_enabled || tier.tier_id === explicitCurrent?.tier_id)
.slice()
.sort((a, b) => a.tier_order - b.tier_order)
if (gridTiers.length === 0) {
return []
}
// No active subscription → the lowest-order ($0 / free) tier stands in as the
// current plan: inert, never a "subscribe to Free" upgrade, and (being lowest)
// never leaving room for a downgrade.
const currentTier = explicitCurrent ?? (current == null ? gridTiers[0] : undefined)
const currentOrder = currentTier?.tier_order
const manageBase = subscription.portal_url ?? fallbackPortalUrl
// Only a downgrade has a target tier to mark; a cancellation has none.
const pendingName = pending?.kind === 'downgrade' ? pending.tierName : null
return gridTiers.map((tier): BillingPlanTierView => {
const base: BillingPlanTierBase = {
creditsDisplay: creditsPerMonthDisplay(tier.monthly_credits),
name: tier.name,
priceDisplay: tier.dollars_per_month_display,
tierId: tier.tier_id
}
if (currentTier && tier.tier_id === currentTier.tier_id) {
return { ...base, state: 'current' }
}
// A scheduled downgrade target is inert (matched by name — NAS sends no id for
// the pending target). Name is a safe key: SubscriptionTypes.name is @unique in
// NAS, so two tiers can't collide. Checked before the downgrade branch since the
// target IS a lower tier.
if (pendingName && tier.name === pendingName) {
return { ...base, state: 'scheduled' }
}
// Downgrade = strictly below the current tier's order → an in-app chargeless
// change (the PlanCard wires the confirm flow by tierId).
if (currentOrder != null && tier.tier_order < currentOrder) {
return { ...base, state: 'downgrade' }
}
return {
...base,
action: { label: 'Choose ↗', url: buildManageSubscriptionUrl(subscription, manageBase, tier.tier_id) },
state: 'upgrade'
}
})
}
function paymentMethodRow(billing: BillingStateResponse): BillingAccountRowView {
const portalUrl = billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL
const card = billing.card
if (!card) {
// No card → a single "Add payment method" link, the way every other app does
// it. The reason (buys/auto-refill are blocked) already leads the page as a
// notice, so the row stays a bare call-to-action with no redundant status text.
return {
action: { label: 'Add payment method', url: portalUrl },
description: '',
id: 'payment_method',
title: 'Payment method'
}
}
return {
action: { label: 'Update', url: portalUrl },
description: 'Manage the card used for top-ups and subscription renewals.',
id: 'payment_method',
title: 'Payment method',
value: `${capitalize(card.brand)} •••• ${card.last4}${provenanceSuffix(card.resolved_via)}`
}
}
function buyCreditsRow(billing: BillingStateResponse): BillingAccountRowView {
if (!billing.card) {
// The no-card blocker is already spelled out by the page-level warn banner
// (noCardNotice); repeating it here — emoji and all — just clutters the row,
// so keep the plain "what buying does" line and let the controls sit disabled.
return {
action: { disabled: true, label: 'Buy' },
chips: billing.charge_presets.map(amount => ({ disabled: true, label: formatMoney(amount) })),
description: 'A single charge on your card, added to your balance today.',
id: 'buy_credits',
title: 'Buy credits now'
}
}
const disabledReason = buyCreditsDisabledReason(billing)
if (disabledReason) {
return {
description: disabledReason,
id: 'buy_credits',
title: 'Buy credits now'
}
}
return {
action: { disabled: true, label: 'Buy' },
chips: billing.charge_presets.map(amount => ({ disabled: true, label: formatMoney(amount) })),
description: 'A single charge on your card, added to your balance today.',
id: 'buy_credits',
title: 'Buy credits now'
}
}
// The generic first sentence shared by the off / absent / divergent states,
// where the concrete amounts aren't the headline. The configured state overrides
// this with the disambiguating "Charges $X … below $Y." sentence (spec §8).
const AUTO_REFILL_GENERIC = 'Keep your balance topped up when it drops below your threshold.'
function autoReloadRow(billing: BillingStateResponse): BillingAccountRowView {
const autoReload = billing.auto_reload
if (!autoReload) {
return {
action: { disabled: true, label: 'Manage' },
caption: 'Manage auto-refill from the portal.',
description: AUTO_REFILL_GENERIC,
id: 'auto_reload',
pill: { label: EMPTY_BILLING_VALUE, tone: 'muted' },
title: 'Refill when low'
}
}
if (!autoReload.enabled) {
return {
caption: 'Turn on auto-refill from the portal',
description: AUTO_REFILL_GENERIC,
id: 'auto_reload',
pill: { label: 'Off', tone: 'muted' },
title: 'Refill when low'
}
}
// A null card (gateway emits it for a missing/unknown-kind card) falls through to
// the default enabled path below — the same treatment as a canonical card.
if (autoReload.card?.kind === 'distinct') {
const { brand, last4 } = autoReload.card
const cardLabel = brand && last4 ? `${capitalize(brand)} ••${last4}` : 'a different card'
const portalUrl = billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL
return {
action: { label: 'Reconcile ↗', url: portalUrl },
caption: `Auto-refill charges ${cardLabel} — reconcile on the portal`,
description: AUTO_REFILL_GENERIC,
id: 'auto_reload',
pill: { label: 'Enabled', tone: 'primary' },
title: 'Refill when low'
}
}
const reloadTo = autoReload.reload_to_display || formatMoney(autoReload.reload_to_usd)
const threshold = autoReload.threshold_display || formatMoney(autoReload.threshold_usd)
return {
action: { label: 'Manage' },
// Numbers live in the first sentence (spec §8); the swap region below carries
// the editable fields, so no redundant caption here.
description: `Charges ${reloadTo} automatically when your balance falls below ${threshold}.`,
id: 'auto_reload',
// The only row that edits in place — AutoReloadRow keys its swap layout off this
// flag rather than sniffing the action label.
manageInApp: true,
pill: { label: 'Enabled', tone: 'primary' },
title: 'Refill when low'
}
}
function deriveUsageRows(
billing: BillingStateResponse,
subscription: null | SubscriptionStateResponse
): BillingUsageRowView[] {
const rows: BillingUsageRowView[] = []
const current = subscription?.current
const remaining = parseAmount(current?.credits_remaining)
const monthly = parseAmount(current?.monthly_credits)
const usage = subscription?.usage ?? billing.usage
// Remaining can go slightly negative (usage settles after credits hit zero).
// A raw "-$0.79 left" reads as broken — clamp to $0 and name the overage.
const subscriptionValue =
remaining != null && monthly != null
? remaining < 0
? `${formatMoney(0)} of ${formatMoney(monthly)} left · ${formatMoney(Math.abs(remaining))} over`
: `${formatMoney(remaining)} of ${formatMoney(monthly)} left`
: (usage?.subscription_remaining_display ?? usage?.plan_bar?.remaining_display ?? EMPTY_BILLING_VALUE)
const remainingFraction = remaining != null && monthly != null && monthly > 0 ? remaining / monthly : null
rows.push({
bar:
remainingFraction != null
? {
label: 'Subscription credits remaining',
state: remainingFraction <= 0.1 ? 'danger' : 'ok',
tone: 'subscription',
track: remaining != null && remaining <= 0 ? 'danger' : undefined,
value: clamp01(remainingFraction)
}
: undefined,
caption: `Resets ${formatBillingDate(current?.cycle_ends_at ?? usage?.renews_at)}`,
id: 'subscription_credits',
title: 'Subscription credits',
value: subscriptionValue
})
const topupValue = topupCreditsValue(billing, usage)
// No bar: top-ups have no denominator (the wire carries only the current
// balance, and the pool is open-ended), so a fill fraction would be fiction.
rows.push({
caption: 'Does not expire',
id: 'topup_credits',
title: 'Top-up credits',
value: topupValue
})
const cap = billing.monthly_cap
if (cap && cap.limit_usd != null) {
const limit = parseAmount(cap.limit_usd)
const spent = parseAmount(cap.spent_this_month_usd) ?? 0
const usedFraction = limit != null && limit > 0 ? spent / limit : null
const value = `${cap.spent_display || formatMoney(spent)} of ${cap.limit_display || formatMoney(limit)} used`
rows.push({
bar:
usedFraction != null
? {
label: 'Monthly spend cap used',
state: usedFraction >= 0.9 ? 'danger' : 'ok',
tone: 'cap',
track: usedFraction >= 1 ? 'danger' : undefined,
value: clamp01(usedFraction)
}
: undefined,
caption: cap.is_default_ceiling ? 'Default ceiling' : 'Monthly remote spending',
id: 'monthly_cap',
title: 'Monthly spend cap',
value
})
}
return rows
}
function displayBalance(billing: BillingStateResponse): string {
return nonEmpty(billing.balance_display) ?? formatMoney(billing.balance_usd)
}
function displayPlan(subscription: null | SubscriptionStateResponse, usage?: UsageModelData): string {
const current = subscription?.current
const tier = current?.tier_name ?? usage?.plan_name
if (!tier) {
return EMPTY_BILLING_VALUE
}
const price = findCurrentTier(subscription)?.dollars_per_month_display
return price ? `${tier} · ${price}/mo` : tier
}
function topupCreditsValue(billing: BillingStateResponse, usage?: UsageModelData): string {
return (
usage?.topup_remaining_display ??
usage?.topup_bar?.remaining_display ??
nonEmpty(billing.balance_display) ??
formatMoney(billing.balance_usd)
)
}
function buyCreditsDisabledReason(billing: BillingStateResponse): null | string {
if (!billing.is_admin) {
return resolveRefusal({ kind: 'role_required', message: '' }).message
}
if (!billing.cli_billing_enabled) {
return resolveRefusal({ kind: 'cli_billing_disabled', message: '', portalUrl: billing.portal_url ?? undefined })
.message
}
if (!billing.can_charge) {
return resolveRefusal({ kind: 'remote_spending_disabled', message: '', portalUrl: billing.portal_url ?? undefined })
.message
}
return null
}
function provenanceSuffix(resolvedVia?: null | string): string {
if (!resolvedVia) {
return ''
}
const labels: Record<string, string> = {
autoRefill: 'auto-refill card',
customerDefault: 'customer default',
subPin: 'subscription card'
}
return ` - ${labels[resolvedVia] ?? resolvedVia}`
}
function capitalize(value: string): string {
return value ? `${value.charAt(0).toUpperCase()}${value.slice(1)}` : value
}
function nonEmpty(value?: null | string): string | undefined {
return typeof value === 'string' && value.trim().length > 0 ? value : undefined
}
function parseAmount(value?: null | number | string): null | number {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null
}
if (typeof value !== 'string') {
return null
}
const parsed = Number(value.replace(/[$,\s]/g, ''))
return Number.isFinite(parsed) ? parsed : null
}
function formatMoney(value?: null | number | string): string {
const amount = parseAmount(value)
if (amount == null) {
return EMPTY_BILLING_VALUE
}
// Pin en-US so the symbol is always "$" — the server's *_display strings
// ("$996.47") sit next to these, and other locales render USD as "US$".
return new Intl.NumberFormat('en-US', {
currency: 'USD',
maximumFractionDigits: amount % 1 === 0 ? 0 : 2,
minimumFractionDigits: amount % 1 === 0 ? 0 : 2,
style: 'currency'
}).format(amount)
}
function clamp01(value: number): number {
if (!Number.isFinite(value)) {
return 0
}
return Math.max(0, Math.min(1, value))
}
@@ -0,0 +1,232 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook } from '@testing-library/react'
import { createElement, type PropsWithChildren } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { BillingResult } from './api'
import type { BillingChargeStatusResponse } from './types'
const apiMocks = vi.hoisted(() => ({
charge: vi.fn(),
chargeStatus: vi.fn()
}))
vi.mock('./api', () => ({
useBillingApi: () => ({
charge: apiMocks.charge,
chargeStatus: apiMocks.chargeStatus
})
}))
import { CHARGE_POLL_CAP_MS, pollChargeSettlement, useChargeFlow } from './use-charge-poller'
const status = (overrides: Partial<BillingChargeStatusResponse> = {}): BillingResult<BillingChargeStatusResponse> => ({
data: {
ok: true,
status: 'pending',
...overrides
},
ok: true
})
const refusal = (
kind: string,
overrides: Partial<Extract<BillingResult<BillingChargeStatusResponse>, { ok: false }>['refusal']> = {}
): BillingResult<BillingChargeStatusResponse> => ({
ok: false,
refusal: {
kind,
message: kind,
...overrides
}
})
function controlledClock() {
let current = 0
const waits: number[] = []
return {
now: () => current,
sleep: vi.fn(async (ms: number) => {
waits.push(ms)
current += ms
}),
waits
}
}
function wrapper({ children }: PropsWithChildren) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return createElement(QueryClientProvider, { client }, children)
}
beforeEach(() => {
apiMocks.charge.mockReset()
apiMocks.chargeStatus.mockReset()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('pollChargeSettlement', () => {
it('settles after pending polls', async () => {
const clock = controlledClock()
const api = {
chargeStatus: vi
.fn()
.mockResolvedValueOnce(status())
.mockResolvedValueOnce(status())
.mockResolvedValueOnce(status({ amount_usd: '25', status: 'settled' }))
}
const outcome = await pollChargeSettlement(api, 'ch_123', clock)
expect(outcome).toMatchObject({ amountUsd: '25', kind: 'success' })
expect(api.chargeStatus).toHaveBeenCalledTimes(3)
expect(clock.waits).toEqual([2000, 2000])
})
it('returns a failed outcome with the charge failure reason', async () => {
const clock = controlledClock()
const api = {
chargeStatus: vi.fn().mockResolvedValue(status({ reason: 'card_declined', status: 'failed' }))
}
const outcome = await pollChargeSettlement(api, 'ch_123', clock)
expect(outcome).toMatchObject({
kind: 'failure',
message: 'Your card was declined. Try another card on the portal.',
title: 'Charge failed'
})
expect(clock.waits).toEqual([])
})
it('backs off on rate limits, honors retryAfter, and keeps polling', async () => {
const clock = controlledClock()
const api = {
chargeStatus: vi
.fn()
.mockResolvedValueOnce(refusal('rate_limited', { retryAfter: 7 }))
.mockResolvedValueOnce(status({ amount_usd: '50', status: 'settled' }))
}
const outcome = await pollChargeSettlement(api, 'ch_123', clock)
expect(outcome).toMatchObject({ amountUsd: '50', kind: 'success' })
expect(clock.waits).toEqual([7000])
})
it('backs off when Stripe is unavailable and keeps polling', async () => {
const clock = controlledClock()
const api = {
chargeStatus: vi
.fn()
.mockResolvedValueOnce(refusal('stripe_unavailable', { retryAfter: 3 }))
.mockResolvedValueOnce(status({ amount_usd: '50', status: 'settled' }))
}
const outcome = await pollChargeSettlement(api, 'ch_123', clock)
expect(outcome).toMatchObject({ amountUsd: '50', kind: 'success' })
expect(clock.waits).toEqual([3000])
})
it('caps pending polling at 5 minutes as an ambiguous outcome', async () => {
const clock = controlledClock()
const api = {
chargeStatus: vi.fn().mockResolvedValue(status())
}
const outcome = await pollChargeSettlement(api, 'ch_123', {
...clock,
portalUrl: 'https://portal.nousresearch.com/billing'
})
expect(outcome).toEqual({
kind: 'ambiguous',
message: 'Charge may still settle. Check the portal before retrying.',
portalUrl: 'https://portal.nousresearch.com/billing',
title: 'Still processing after 5 minutes'
})
expect(clock.waits.reduce((total, ms) => total + ms, 0)).toBe(CHARGE_POLL_CAP_MS)
})
it('treats auth revocation while polling as ambiguous', async () => {
const clock = controlledClock()
const api = {
chargeStatus: vi.fn().mockResolvedValue(
refusal('session_revoked', {
message: 'Your session was logged out.',
portalUrl: 'https://portal.nousresearch.com/billing'
})
)
}
const outcome = await pollChargeSettlement(api, 'ch_123', clock)
expect(outcome).toMatchObject({
kind: 'ambiguous',
portalUrl: 'https://portal.nousresearch.com/billing',
title: 'Charge outcome unconfirmed'
})
})
})
describe('useChargeFlow', () => {
it('turns a charge refusal into an immediate outcome without polling', async () => {
apiMocks.charge.mockResolvedValue({
idempotencyKey: 'key-1',
ok: false,
refusal: {
kind: 'no_payment_method',
message: 'No saved card.',
portalUrl: 'https://portal.nousresearch.com/billing'
}
})
const { result } = renderHook(() => useChargeFlow(), { wrapper })
await act(async () => {
await result.current.start('25')
})
expect(result.current.phase).toBe('done')
expect(result.current.outcome).toMatchObject({
kind: 'failure',
title: 'No saved card'
})
expect(apiMocks.chargeStatus).not.toHaveBeenCalled()
})
it('reuses one idempotency key when retrying a failed-to-send charge', async () => {
apiMocks.charge.mockResolvedValue({
idempotencyKey: 'key-1',
ok: false,
refusal: {
kind: 'transport',
message: 'connection closed'
}
})
const { result } = renderHook(() => useChargeFlow(), { wrapper })
await act(async () => {
await result.current.start('25')
})
await act(async () => {
await result.current.start('25')
})
expect(apiMocks.charge).toHaveBeenNthCalledWith(1, '25', undefined)
expect(apiMocks.charge).toHaveBeenNthCalledWith(2, '25', 'key-1')
})
})
@@ -0,0 +1,297 @@
import { refusalPolicy } from '@hermes/shared/billing-policy'
import {
driveChargeSettlement,
SETTLEMENT_POLL_CAP_MS,
SETTLEMENT_POLL_INTERVAL_MS
} from '@hermes/shared/charge-settlement'
import { useQueryClient } from '@tanstack/react-query'
import { useCallback, useRef, useState } from 'react'
import type { BillingApi, BillingRefusal } from './api'
import { useBillingApi } from './api'
import { resolveRefusal } from './errors'
import type { BillingChargeStatusResponse } from './types'
export const CHARGE_POLL_INTERVAL_MS = SETTLEMENT_POLL_INTERVAL_MS
export const CHARGE_POLL_CAP_MS = SETTLEMENT_POLL_CAP_MS
export type ChargeFlowPhase = 'charging' | 'done' | 'idle' | 'polling'
export type ChargeFlowOutcome =
| {
amountUsd?: string | null
kind: 'success'
message: string
}
| {
action?: { type: 'portal'; url?: string } | { type: 'retry' } | { type: 'step_up' }
kind: 'failure'
message: string
retryFreshKey: boolean
title: string
}
| {
kind: 'ambiguous'
message: string
portalUrl?: string
title: string
}
export interface ChargePollClock {
now?: () => number
sleep?: (ms: number) => Promise<void>
}
export interface ChargePollOptions extends ChargePollClock {
portalUrl?: null | string
}
interface PendingChargeIntent {
amountUsd: string
idempotencyKey: string
}
const defaultSleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms))
const retryableSendKinds = new Set([
'endpoint_unavailable',
'rate_limited',
'temporarily_unavailable',
'timeout',
'transport'
])
export async function pollChargeSettlement(
api: Pick<BillingApi, 'chargeStatus'>,
chargeId: string,
opts: ChargePollOptions = {}
): Promise<ChargeFlowOutcome> {
const sleep = opts.sleep ?? defaultSleep
const now = opts.now ?? Date.now
const observed: { refusal?: BillingRefusal; status?: BillingChargeStatusResponse } = {}
const settlement = await driveChargeSettlement({
fetchStatus: async () => {
const result = await api.chargeStatus(chargeId)
if (result.ok) {
observed.refusal = undefined
observed.status = result.data
return result.data
}
observed.refusal = result.refusal
observed.status = statusFromRefusal(result.refusal)
return observed.status
},
isCancelled: () => false,
now,
sleep
})
switch (settlement.kind) {
case 'settled':
return {
amountUsd: settlement.status.amount_usd,
kind: 'success',
message: settlement.status.amount_usd ? `$${settlement.status.amount_usd} added.` : 'Credits added.'
}
case 'failed':
return {
action: { type: 'retry' },
kind: 'failure',
message: renderChargeFailed(settlement.status.reason),
retryFreshKey: true,
title: 'Charge failed'
}
case 'ambiguous': {
if (settlement.status && refusalPolicy(settlement.error).ambiguousMidPoll) {
const refusal = observed.refusal ?? refusalFromStatus(settlement.error, settlement.status)
const resolved = resolveRefusal(refusal)
const portalUrl = resolved.action.type === 'portal' ? resolved.action.url : refusal.portalUrl
return {
kind: 'ambiguous',
message: `${resolved.message} Your last charge's outcome is unconfirmed - check your balance/history before retrying.`,
portalUrl: portalUrl ?? opts.portalUrl ?? undefined,
title: 'Charge outcome unconfirmed'
}
}
return {
kind: 'failure',
message: observed.refusal?.message || 'Could not check the charge.',
retryFreshKey: true,
title: 'Could not check charge'
}
}
case 'refused':
return {
kind: 'failure',
message: observed.refusal?.message || settlement.status.message || 'Could not check the charge.',
retryFreshKey: true,
title: 'Could not check charge'
}
case 'cancelled':
case 'timed_out':
return timeoutOutcome(observed.status?.ok ? (observed.status.portal_url ?? opts.portalUrl) : opts.portalUrl)
}
}
function statusFromRefusal(refusal: BillingRefusal): BillingChargeStatusResponse {
const raw = isRecord(refusal.raw) ? refusal.raw : {}
return {
...raw,
error: refusal.kind,
message: refusal.message,
ok: false,
...(refusal.payload !== undefined ? { payload: refusal.payload } : {}),
...(refusal.portalUrl !== undefined ? { portal_url: refusal.portalUrl } : {}),
...(refusal.retryAfter !== undefined ? { retry_after: refusal.retryAfter } : {})
} as BillingChargeStatusResponse
}
function refusalFromStatus(error: string, status: BillingChargeStatusResponse): BillingRefusal {
return {
kind: error,
message: status.message || error,
payload: status.payload,
portalUrl: status.portal_url ?? undefined,
retryAfter: status.retry_after ?? undefined
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
export function useChargeFlow() {
const api = useBillingApi()
const queryClient = useQueryClient()
const [phase, setPhase] = useState<ChargeFlowPhase>('idle')
const [outcome, setOutcome] = useState<ChargeFlowOutcome | null>(null)
const phaseRef = useRef<ChargeFlowPhase>('idle')
const retryIntentRef = useRef<PendingChargeIntent | null>(null)
const setPhaseState = useCallback((next: ChargeFlowPhase) => {
phaseRef.current = next
setPhase(next)
}, [])
const reset = useCallback(() => {
retryIntentRef.current = null
setOutcome(null)
setPhaseState('idle')
}, [setPhaseState])
const start = useCallback(
async (amountUsd: string) => {
if (phaseRef.current === 'charging' || phaseRef.current === 'polling') {
return
}
const retryIntent = retryIntentRef.current
const idempotencyKey = retryIntent?.amountUsd === amountUsd ? retryIntent.idempotencyKey : undefined
setOutcome(null)
setPhaseState('charging')
const chargeResult = await api.charge(amountUsd, idempotencyKey)
if (!chargeResult.ok) {
const resolved = resolveRefusal(chargeResult.refusal)
const action =
resolved.action.type === 'portal'
? ({ type: 'portal', url: resolved.action.url } as const)
: resolved.action.type === 'retry'
? ({ type: 'retry' } as const)
: resolved.action.type === 'step_up'
? ({ type: 'step_up' } as const)
: undefined
retryIntentRef.current = shouldReuseIdempotencyKey(chargeResult.refusal)
? { amountUsd, idempotencyKey: chargeResult.idempotencyKey }
: null
setOutcome({
action,
kind: 'failure',
message: resolved.message,
retryFreshKey: false,
title: resolved.title
})
setPhaseState('done')
return
}
retryIntentRef.current = null
const chargeId = chargeResult.data.charge_id
if (!chargeId) {
setOutcome({
kind: 'failure',
message: 'The billing service accepted the request but did not return a charge id.',
retryFreshKey: true,
title: 'Charge could not be tracked'
})
setPhaseState('done')
return
}
setPhaseState('polling')
const pollOutcome = await pollChargeSettlement(api, chargeId, {
portalUrl: chargeResult.data.portal_url
})
setOutcome(pollOutcome)
setPhaseState('done')
if (pollOutcome.kind === 'success') {
void queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
}
},
[api, queryClient, setPhaseState]
)
return { outcome, phase, reset, start }
}
function shouldReuseIdempotencyKey(refusal: BillingRefusal): boolean {
return retryableSendKinds.has(refusal.kind)
}
function timeoutOutcome(portalUrl?: null | string): ChargeFlowOutcome {
return {
kind: 'ambiguous',
message: 'Charge may still settle. Check the portal before retrying.',
portalUrl: portalUrl ?? undefined,
title: 'Still processing after 5 minutes'
}
}
function renderChargeFailed(reason?: null | string): string {
switch ((reason || '').trim()) {
case 'authentication_required':
return 'Your bank requires verification (3DS). Complete it on the portal to finish this purchase.'
case 'payment_method_expired':
return 'Your card has expired. Update it on the portal.'
case 'card_declined':
return 'Your card was declined. Try another card on the portal.'
default:
return `The charge didn't go through (${reason || 'processing_error'}).`
}
}
@@ -0,0 +1,132 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook, waitFor } from '@testing-library/react'
import { createElement, type PropsWithChildren } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const apiMocks = vi.hoisted(() => ({
stepUp: vi.fn()
}))
const gatewayMock = vi.hoisted(() => {
const handlers = new Map<string, Set<(event: unknown) => void>>()
const gateway = {
on: vi.fn((eventName: string, handler: (event: unknown) => void) => {
let set = handlers.get(eventName)
if (!set) {
set = new Set()
handlers.set(eventName, set)
}
set.add(handler)
return () => set?.delete(handler)
})
}
return {
count: (eventName: string) => handlers.get(eventName)?.size ?? 0,
emit: (eventName: string, event: unknown) => {
handlers.get(eventName)?.forEach(handler => handler(event))
},
gateway,
reset: () => {
handlers.clear()
gateway.on.mockClear()
}
}
})
vi.mock('@/store/gateway', async () => {
const { atom } = (await vi.importActual('nanostores')) as { atom: (value: unknown) => unknown }
return {
$gateway: atom(gatewayMock.gateway)
}
})
vi.mock('./api', () => ({
useBillingApi: () => ({
stepUp: apiMocks.stepUp
})
}))
import { useStepUpFlow } from './use-step-up'
function createWrapper(client: QueryClient) {
return function wrapper({ children }: PropsWithChildren) {
return createElement(QueryClientProvider, { client }, children)
}
}
beforeEach(() => {
apiMocks.stepUp.mockReset()
gatewayMock.reset()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('useStepUpFlow', () => {
it('subscribes for verification, opens the verification URL, cleans up, and invalidates on completion', async () => {
let resolveStepUp: (value: unknown) => void = () => {}
const stepUpPromise = new Promise(resolve => {
resolveStepUp = resolve
})
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const invalidate = vi.spyOn(client, 'invalidateQueries')
apiMocks.stepUp.mockReturnValue(stepUpPromise)
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: {
openExternal: vi.fn()
}
})
const { result, unmount } = renderHook(() => useStepUpFlow(), { wrapper: createWrapper(client) })
act(() => {
void result.current.start()
})
expect(result.current.phase).toBe('waiting')
expect(gatewayMock.count('billing.step_up.verification')).toBe(1)
act(() => {
gatewayMock.emit('billing.step_up.verification', {
payload: {
user_code: 'ABCD-1234',
verification_url: 'https://portal.nousresearch.com/device'
},
type: 'billing.step_up.verification'
})
})
expect(result.current.phase).toBe('verifying')
expect(result.current.verification).toEqual({
code: 'ABCD-1234',
url: 'https://portal.nousresearch.com/device'
})
result.current.openVerification()
expect(window.hermesDesktop?.openExternal).toHaveBeenCalledWith('https://portal.nousresearch.com/device')
await act(async () => {
resolveStepUp({ data: { granted: true, ok: true }, ok: true })
await stepUpPromise
})
await waitFor(() => {
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['billing', 'state'] })
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['billing', 'subscription'] })
})
unmount()
expect(gatewayMock.count('billing.step_up.verification')).toBe(0)
})
})
@@ -0,0 +1,143 @@
import { useStore } from '@nanostores/react'
import { useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useRef, useState } from 'react'
import { $gateway } from '@/store/gateway'
import { useBillingApi } from './api'
import { resolveRefusal } from './errors'
export type StepUpPhase = 'idle' | 'verifying' | 'waiting'
export interface StepUpVerification {
code: string | null
url: string
}
export interface StepUpMessage {
kind: 'error' | 'success'
text: string
title: string
}
interface StepUpVerificationPayload {
user_code?: unknown
verification_url?: unknown
}
export function useStepUpFlow() {
const api = useBillingApi()
const gateway = useStore($gateway)
const queryClient = useQueryClient()
const offRef = useRef<(() => void) | null>(null)
const runningRef = useRef(false)
const runIdRef = useRef(0)
const [message, setMessage] = useState<StepUpMessage | null>(null)
const [phase, setPhase] = useState<StepUpPhase>('idle')
const [verification, setVerification] = useState<StepUpVerification | null>(null)
const unsubscribe = useCallback(() => {
offRef.current?.()
offRef.current = null
}, [])
const dismiss = useCallback(() => {
runIdRef.current += 1
runningRef.current = false
unsubscribe()
setMessage(null)
setPhase('idle')
setVerification(null)
}, [unsubscribe])
useEffect(
() => () => {
runIdRef.current += 1
runningRef.current = false
unsubscribe()
},
[unsubscribe]
)
const openVerification = useCallback(() => {
if (!verification?.url) {
return
}
void window.hermesDesktop?.openExternal?.(verification.url)
}, [verification?.url])
const start = useCallback(async () => {
if (runningRef.current) {
return
}
runningRef.current = true
const runId = runIdRef.current + 1
runIdRef.current = runId
unsubscribe()
setMessage(null)
setVerification(null)
setPhase('waiting')
offRef.current =
gateway?.on<StepUpVerificationPayload>('billing.step_up.verification', event => {
const payload = event.payload
const url = typeof payload?.verification_url === 'string' ? payload.verification_url : null
if (!url) {
return
}
setVerification({
code: typeof payload?.user_code === 'string' ? payload.user_code : null,
url
})
setPhase('verifying')
}) ?? null
const result = await api.stepUp()
if (runIdRef.current !== runId) {
return
}
runningRef.current = false
unsubscribe()
if (!result.ok) {
const resolved = resolveRefusal(result.refusal)
setMessage({
kind: 'error',
text: resolved.message,
title: resolved.title
})
return
}
if (!result.data.granted) {
setMessage({
kind: 'error',
text: 'Verification finished without allowing Remote Spending for this terminal.',
title: 'Verification was not approved'
})
return
}
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['billing', 'state'] }),
queryClient.invalidateQueries({ queryKey: ['billing', 'subscription'] })
])
setMessage({
kind: 'success',
text: 'Remote Spending is allowed for this terminal.',
title: 'Verification complete'
})
}, [api, gateway, queryClient, unsubscribe])
return { dismiss, message, openVerification, phase, start, verification }
}
@@ -0,0 +1,171 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook, waitFor } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
const apiMocks = vi.hoisted(() => ({
previewSubscriptionChange: vi.fn(),
resumeSubscription: vi.fn(),
scheduleSubscriptionChange: vi.fn()
}))
vi.mock('./api', () => ({ useBillingApi: () => apiMocks }))
import { useDowngradeFlow, useResumeFlow } from './use-subscription-change'
function wrapper({ children }: { children: ReactNode }) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return <QueryClientProvider client={client}>{children}</QueryClientProvider>
}
afterEach(() => {
vi.clearAllMocks()
vi.unstubAllEnvs()
})
describe('useDowngradeFlow', () => {
it('previews then schedules with the tier id, refetches, and calls onScheduled', async () => {
apiMocks.previewSubscriptionChange.mockResolvedValue({
data: { effect: 'scheduled', ok: true, target_tier_name: 'Free' },
ok: true
})
apiMocks.scheduleSubscriptionChange.mockResolvedValue({ data: { ok: true }, ok: true })
const onScheduled = vi.fn()
const { result } = renderHook(() => useDowngradeFlow({ onScheduled }), { wrapper })
act(() => result.current.begin({ tierId: 't_free', tierName: 'Free' }))
await waitFor(() => expect(result.current.active?.phase.kind).toBe('ready'))
expect(apiMocks.previewSubscriptionChange).toHaveBeenCalledWith('t_free')
await act(async () => {
await result.current.confirm()
})
expect(apiMocks.scheduleSubscriptionChange).toHaveBeenCalledWith('t_free')
expect(onScheduled).toHaveBeenCalledTimes(1)
expect(result.current.active).toBeNull()
})
it('records a preview refusal as the previewFailed phase and re-runs on retry', async () => {
apiMocks.previewSubscriptionChange.mockResolvedValue({
ok: false,
refusal: { kind: 'insufficient_scope', message: 'billing:manage required' }
})
const { result } = renderHook(() => useDowngradeFlow({ onScheduled: vi.fn() }), { wrapper })
act(() => result.current.begin({ tierId: 't_free', tierName: 'Free' }))
await waitFor(() => expect(result.current.active?.phase.kind).toBe('previewFailed'))
const phase = result.current.active?.phase
if (phase?.kind !== 'previewFailed') {
throw new Error('expected previewFailed phase')
}
expect(phase.refusal.kind).toBe('insufficient_scope')
act(() => result.current.retryPreview())
await waitFor(() => expect(apiMocks.previewSubscriptionChange).toHaveBeenCalledTimes(2))
})
it('cancel clears the active change without scheduling', async () => {
apiMocks.previewSubscriptionChange.mockResolvedValue({
data: { effect: 'scheduled', ok: true, target_tier_name: 'Free' },
ok: true
})
const { result } = renderHook(() => useDowngradeFlow({ onScheduled: vi.fn() }), { wrapper })
act(() => result.current.begin({ tierId: 't_free', tierName: 'Free' }))
await waitFor(() => expect(result.current.active?.phase.kind).toBe('ready'))
act(() => result.current.cancel())
expect(result.current.active).toBeNull()
expect(apiMocks.scheduleSubscriptionChange).not.toHaveBeenCalled()
})
it('exposes mutating only while the schedule RPC is in flight', async () => {
apiMocks.previewSubscriptionChange.mockResolvedValue({
data: { effect: 'scheduled', ok: true, target_tier_name: 'Free' },
ok: true
})
let settleSchedule: (value: unknown) => void = () => {}
apiMocks.scheduleSubscriptionChange.mockReturnValue(
new Promise(resolve => {
settleSchedule = resolve
})
)
const { result } = renderHook(() => useDowngradeFlow({ onScheduled: vi.fn() }), { wrapper })
act(() => result.current.begin({ tierId: 't_free', tierName: 'Free' }))
await waitFor(() => expect(result.current.active?.phase.kind).toBe('ready'))
expect(result.current.mutating).toBe(false)
act(() => {
void result.current.confirm()
})
await waitFor(() => expect(result.current.mutating).toBe(true))
act(() => settleSchedule({ data: { ok: true }, ok: true }))
await waitFor(() => expect(result.current.mutating).toBe(false))
})
it('fires a single schedule RPC when confirm is double-activated in the same tick', async () => {
apiMocks.previewSubscriptionChange.mockResolvedValue({
data: { effect: 'scheduled', ok: true, target_tier_name: 'Free' },
ok: true
})
apiMocks.scheduleSubscriptionChange.mockResolvedValue({ data: { ok: true }, ok: true })
const { result } = renderHook(() => useDowngradeFlow({ onScheduled: vi.fn() }), { wrapper })
act(() => result.current.begin({ tierId: 't_free', tierName: 'Free' }))
await waitFor(() => expect(result.current.active?.phase.kind).toBe('ready'))
// Two synchronous activations before React commits busy='schedule'.
await act(async () => {
void result.current.confirm()
void result.current.confirm()
})
expect(apiMocks.scheduleSubscriptionChange).toHaveBeenCalledTimes(1)
})
})
describe('useResumeFlow', () => {
it('resumes (undo) and clears the refusal on success', async () => {
apiMocks.resumeSubscription.mockResolvedValue({ data: { ok: true }, ok: true })
const { result } = renderHook(() => useResumeFlow(), { wrapper })
await act(async () => {
await result.current.resume()
})
expect(apiMocks.resumeSubscription).toHaveBeenCalledTimes(1)
expect(result.current.refusal).toBeNull()
})
it('surfaces a resume refusal', async () => {
apiMocks.resumeSubscription.mockResolvedValue({
ok: false,
refusal: { kind: 'insufficient_scope', message: 'billing:manage required' }
})
const { result } = renderHook(() => useResumeFlow(), { wrapper })
await act(async () => {
await result.current.resume()
})
expect(result.current.refusal?.kind).toBe('insufficient_scope')
})
})
@@ -0,0 +1,177 @@
import { useQueryClient } from '@tanstack/react-query'
import { useRef, useState } from 'react'
import type { BillingRefusal } from './api'
import { useBillingApi } from './api'
import type { SubscriptionPreviewResponse } from './types'
export interface DowngradeTarget {
tierId: string
tierName: string
}
/**
* The state machine for one downgrade attempt. Modeled as a discriminated union
* rather than four independent nullables so impossible combinations (a preview AND a
* refusal, a "ready" with no quote) simply cannot be represented, and the panel reads
* exactly one `kind`.
*/
export type DowngradePhase =
| { kind: 'previewFailed'; refusal: BillingRefusal }
| { kind: 'previewing' }
| { kind: 'ready'; preview: SubscriptionPreviewResponse }
| { kind: 'scheduleFailed'; preview: SubscriptionPreviewResponse; refusal: BillingRefusal }
| { kind: 'scheduling'; preview: SubscriptionPreviewResponse }
export interface ActiveDowngrade {
phase: DowngradePhase
target: DowngradeTarget
}
function invalidateBilling(queryClient: ReturnType<typeof useQueryClient>) {
return Promise.all([
queryClient.invalidateQueries({ queryKey: ['billing', 'state'] }),
queryClient.invalidateQueries({ queryKey: ['billing', 'subscription'] })
])
}
/**
* The in-app downgrade flow: preview (chargeless quote) → confirm → schedule at
* period end. Typed refusals surface via the shared BillingRefusalInline (which
* drives the step-up for insufficient_scope, exactly like the auto-reload save);
* the caller retries by clicking the same button after verifying. On a scheduled
* success it refetches billing + subscription and calls `onScheduled`.
*
* The api comes from `useBillingApi`, which DEV fixtures override with a simulated
* implementation — so this flow has no fixture/simulation awareness of its own.
*/
export function useDowngradeFlow({ onScheduled }: { onScheduled: () => void }) {
const api = useBillingApi()
const queryClient = useQueryClient()
const [active, setActive] = useState<ActiveDowngrade | null>(null)
// Monotonic run id discards results from a superseded/cancelled attempt.
const runIdRef = useRef(0)
// Synchronous mutex: two clicks in the same tick both see active.busy === null
// (React hasn't committed the 'schedule' state yet), so guard on a ref too — no
// double schedule RPC. Cleared on every confirm() exit (below).
const schedulingRef = useRef(false)
const runPreview = async (target: DowngradeTarget, runId: number) => {
const result = await api.previewSubscriptionChange(target.tierId)
if (runIdRef.current !== runId) {
return
}
setActive({
phase: result.ok ? { kind: 'ready', preview: result.data } : { kind: 'previewFailed', refusal: result.refusal },
target
})
}
const begin = (target: DowngradeTarget) => {
const runId = runIdRef.current + 1
runIdRef.current = runId
setActive({ phase: { kind: 'previewing' }, target })
void runPreview(target, runId)
}
const retryPreview = () => {
if (active) {
begin(active.target)
}
}
const confirm = async () => {
// Only a quoted state (ready, or a failed schedule being retried) can commit.
if (!active || schedulingRef.current || (active.phase.kind !== 'ready' && active.phase.kind !== 'scheduleFailed')) {
return
}
schedulingRef.current = true
const { target } = active
const { preview } = active.phase
const runId = runIdRef.current + 1
runIdRef.current = runId
setActive({ phase: { kind: 'scheduling', preview }, target })
const result = await api.scheduleSubscriptionChange(target.tierId)
schedulingRef.current = false
if (runIdRef.current !== runId) {
return
}
if (!result.ok) {
// A refusal (e.g. insufficient_scope → step-up) leaves the panel open in
// scheduleFailed, so the same button becomes a manual "Try again" AFTER the
// user elevates. We deliberately do NOT auto-replay the mutation on step-up
// success — this matches the auto-reload save's manual-retry pattern.
setActive({ phase: { kind: 'scheduleFailed', preview, refusal: result.refusal }, target })
return
}
await invalidateBilling(queryClient)
setActive(null)
onScheduled()
}
const cancel = () => {
runIdRef.current += 1
setActive(null)
}
// True only while the mutating RPC (schedule) is in flight — used to lock out
// every other Downgrade tile + Back while one change is committing (the server
// also 409s overlapping mutations per-org, so this is UI honesty, not the only
// defense).
const mutating = active?.phase.kind === 'scheduling'
return { active, begin, cancel, confirm, mutating, retryPreview }
}
/**
* The undo for a scheduled downgrade / cancellation: a chargeless
* `subscription.resume` (no confirm step) that refetches on success. A refusal
* (e.g. insufficient_scope → step-up) surfaces via `refusal`. The api (real or the
* DEV-fixture simulation) comes from `useBillingApi`.
*/
export function useResumeFlow() {
const api = useBillingApi()
const queryClient = useQueryClient()
const [busy, setBusy] = useState(false)
const [refusal, setRefusal] = useState<BillingRefusal | null>(null)
const runningRef = useRef(false)
const resume = async () => {
if (runningRef.current) {
return
}
runningRef.current = true
setBusy(true)
setRefusal(null)
const result = await api.resumeSubscription()
if (!result.ok) {
// Refusal → unlock immediately so the user can retry / step up.
runningRef.current = false
setBusy(false)
setRefusal(result.refusal)
return
}
// Success → hold the lock (Undo stays disabled) THROUGH the refetch, so the
// button never re-enables against the stale, still-pending card.
await invalidateBilling(queryClient)
runningRef.current = false
setBusy(false)
}
return { busy, refusal, resume }
}
@@ -0,0 +1,109 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { BrowserRealProfilePanel } from './browser-real-profile-panel'
const mocks = vi.hoisted(() => ({
cache: vi.fn(),
loadedConfig: {} as Record<string, unknown>,
notify: vi.fn(),
notifyError: vi.fn(),
save: vi.fn()
}))
vi.mock('@/hermes', () => ({
saveHermesConfigRecord: (config: Record<string, unknown>, profile?: unknown) => mocks.save(config, profile)
}))
vi.mock('@/i18n', () => ({
useI18n: () => ({
t: {
settings: {
toolsets: {
browserRealProfile: {
label: 'Use My Real Browser Profile',
description: 'Copies your default browser profile into a managed snapshot.',
enabledTitle: 'Real-profile browsing on',
enabledMessage: 'New sessions use the snapshot.',
disabledTitle: 'Real-profile browsing off',
disabledMessage: 'Snapshot will be deleted.',
failedSave: 'Could not save the real-profile setting'
}
}
}
}
})
}))
vi.mock('@/store/notifications', () => ({
notify: (...args: unknown[]) => mocks.notify(...args),
notifyError: (...args: unknown[]) => mocks.notifyError(...args)
}))
vi.mock('../hooks/use-config-record', () => ({
hermesConfigCacheWriter: () => (config: Record<string, unknown>) => mocks.cache(config),
useHermesConfigRecord: () => ({ data: mocks.loadedConfig })
}))
describe('BrowserRealProfilePanel', () => {
beforeEach(() => {
mocks.loadedConfig = { browser: { allow_private_urls: false }, model: { provider: 'nous' } }
mocks.save.mockResolvedValue({ ok: true })
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
it('renders off for a config without the key and turns it on', async () => {
render(<BrowserRealProfilePanel />)
const toggle = screen.getByRole('switch', { name: 'Use My Real Browser Profile' })
expect(toggle).toHaveProperty('ariaChecked', 'false')
await act(async () => {
fireEvent.click(toggle)
})
// Saves the WHOLE merged record with only use_real_profile added — sibling
// browser keys survive.
expect(mocks.save).toHaveBeenCalledWith(
{
browser: { allow_private_urls: false, use_real_profile: true },
model: { provider: 'nous' }
},
undefined
)
expect(mocks.cache).toHaveBeenCalledWith(mocks.save.mock.calls[0][0])
expect(mocks.notify).toHaveBeenCalled()
})
it('turns an enabled toggle off', async () => {
mocks.loadedConfig = { browser: { use_real_profile: true } }
render(<BrowserRealProfilePanel />)
const toggle = screen.getByRole('switch', { name: 'Use My Real Browser Profile' })
expect(toggle).toHaveProperty('ariaChecked', 'true')
await act(async () => {
fireEvent.click(toggle)
})
expect(mocks.save).toHaveBeenCalledWith({ browser: { use_real_profile: false } }, undefined)
})
it('rolls the optimistic cache write back when the save fails', async () => {
mocks.save.mockRejectedValue(new Error('boom'))
render(<BrowserRealProfilePanel />)
await act(async () => {
fireEvent.click(screen.getByRole('switch', { name: 'Use My Real Browser Profile' }))
})
// Last cache write restores the original record.
expect(mocks.cache).toHaveBeenLastCalledWith(mocks.loadedConfig)
expect(mocks.notifyError).toHaveBeenCalled()
})
})
@@ -0,0 +1,93 @@
import { useCallback, useState } from 'react'
import { type ProfileScope, saveHermesConfigRecord } from '@/hermes'
import { useI18n } from '@/i18n'
import { notify, notifyError } from '@/store/notifications'
import { hermesConfigCacheWriter, useHermesConfigRecord } from '../hooks/use-config-record'
import { ToggleRow } from './primitives'
interface BrowserRealProfilePanelProps {
/** Capabilities profile-scope override — the toggle reads/writes THIS
* profile's config.yaml instead of the app-wide active one. */
profile?: ProfileScope
}
/** Shared with the Browser pane's first-open consent prompt, so both surfaces
* agree on what "on" means for `browser.use_real_profile`. */
export function readUseRealProfile(record: Record<string, unknown> | undefined): boolean {
const browser = record?.browser
if (browser && typeof browser === 'object' && !Array.isArray(browser)) {
return Boolean((browser as Record<string, unknown>).use_real_profile)
}
return false
}
/**
* The `browser.use_real_profile` consent toggle, rendered at the top of the
* Capabilities → Tools → Browser detail pane (above the backend/provider
* matrix). This is the GUI home of the real-profile browsing switch: without
* it the only desktop path was the generic Settings → Config editor, which
* users reasonably never found ("no toggle in the browser section").
*
* Semantics mirror the config comment: turning it ON consents to snapshotting
* the default browser's profile (cookies/logins) into a Hermes-owned copy;
* turning it OFF deletes the snapshot store on next use. The toggle writes
* config.yaml through the same deep-merging PUT /api/config every other
* settings surface uses — applies to new sessions.
*/
export function BrowserRealProfilePanel({ profile }: BrowserRealProfilePanelProps) {
const { t } = useI18n()
const copy = t.settings.toolsets.browserRealProfile
const { data: config } = useHermesConfigRecord(profile)
const setConfig = hermesConfigCacheWriter(profile)
const [busy, setBusy] = useState(false)
const enabled = readUseRealProfile(config)
const toggle = useCallback(
async (on: boolean) => {
if (!config) {
return
}
const browser =
config.browser && typeof config.browser === 'object' && !Array.isArray(config.browser)
? (config.browser as Record<string, unknown>)
: {}
const next = { ...config, browser: { ...browser, use_real_profile: on } }
setBusy(true)
setConfig(next)
try {
await saveHermesConfigRecord(next, profile)
notify({
kind: 'info',
title: on ? copy.enabledTitle : copy.disabledTitle,
message: on ? copy.enabledMessage : copy.disabledMessage
})
} catch (err) {
setConfig(config)
notifyError(err, copy.failedSave)
} finally {
setBusy(false)
}
},
[config, copy, profile, setConfig]
)
return (
<ToggleRow
checked={enabled}
description={copy.description}
disabled={busy || !config}
label={copy.label}
onChange={on => void toggle(on)}
/>
)
}
@@ -0,0 +1,114 @@
import { useRef, useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command'
import { Input } from '@/components/ui/input'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
/**
* Free-input combobox for open-world fields (voice/model names): a plain
* Input the user can type anything into, plus a dropdown listing ALL known
* options.
*
* Replaces the old `<Input list="…">` + `<datalist>` rendering for
* FREE_INPUT_KEYS: native datalists filter by the field's current value, so a
* field already holding a valid option (e.g. `gpt-4o-mini-tts`) suggested
* only that one entry — users couldn't discover the other models/voices at
* all (and on some platforms datalists barely render). Suggestions filter by
* substring while typing, but an exact-match value shows the full list so an
* already-configured field still exposes every alternative.
*/
export function ComboboxInput({
value,
onChange,
options,
optionLabels,
placeholder,
className
}: {
value: string
onChange: (value: string) => void
options: string[]
optionLabels?: Record<string, string>
placeholder?: string
className?: string
}) {
const [open, setOpen] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const query = value.trim().toLowerCase()
const isExact = options.some(option => option.toLowerCase() === query)
const visible = query && !isExact ? options.filter(option => option.toLowerCase().includes(query)) : options
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverAnchor asChild>
<div className={cn('relative', className)}>
<Input
className="w-full pr-7"
onChange={e => {
onChange(e.target.value)
if (!open) {
setOpen(true)
}
}}
onFocus={() => setOpen(true)}
onKeyDown={e => {
if (e.key === 'Escape' || e.key === 'Enter' || e.key === 'Tab') {
setOpen(false)
}
}}
placeholder={placeholder}
ref={inputRef}
value={value}
/>
<button
aria-label="Show options"
className="absolute inset-y-0 right-1.5 flex items-center text-muted-foreground"
onClick={() => {
setOpen(current => !current)
inputRef.current?.focus()
}}
tabIndex={-1}
type="button"
>
<Codicon name={open ? 'chevron-up' : 'chevron-down'} size="1rem" />
</button>
</div>
</PopoverAnchor>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] p-0"
onOpenAutoFocus={e => e.preventDefault()}
>
<Command shouldFilter={false}>
<CommandList>
{visible.length > 0 && (
<CommandGroup>
{visible.map(option => (
<CommandItem
key={option}
onSelect={() => {
onChange(option)
setOpen(false)
}}
value={option}
>
<Codicon
className={cn('mr-2 size-4', option === value ? 'opacity-100' : 'opacity-0')}
name="check"
/>
<span className="truncate">{optionLabels?.[option] ?? option}</span>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,240 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { getActionStatus, getComputerUseStatus, grantComputerUsePermissions } from '@/hermes'
import { AlertTriangle, Check, ExternalLink, Loader2, RefreshCw, X } from '@/lib/icons'
import { upsertDesktopActionTask } from '@/store/activity'
import { notify, notifyError } from '@/store/notifications'
import type { ComputerUseStatus } from '@/types/hermes'
import { Pill } from './primitives'
interface ComputerUsePanelProps {
/** Re-read the parent toolset list after a permission/install change so the
* "Configured / Needs keys" pill stays in sync. */
onConfiguredChange?: () => void
}
// Per-OS one-liner shown when there's no TCC grant flow (Windows/Linux). macOS
// drives the permission rows instead, so it has no entry here.
const PLATFORM_NOTE: Record<string, string> = {
linux: 'Drives your desktop via the X11/XWayland accessibility stack — no permission prompt.',
win32: 'First run may trigger a Windows SmartScreen prompt for the cua-driver UIAccess worker — allow it.'
}
function tone(granted: boolean | null) {
return granted === true ? 'primary' : 'muted'
}
function GrantIcon({ granted }: { granted: boolean | null }) {
const Icon = granted === true ? Check : granted === false ? X : AlertTriangle
return <Icon className="size-3" />
}
function PermissionRow({ granted, label, hint }: { granted: boolean | null; label: string; hint: string }) {
return (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-background/55 p-2.5">
<div className="min-w-0">
<span className="text-sm font-medium">{label}</span>
<p className="mt-0.5 text-[0.7rem] text-muted-foreground">{hint}</p>
</div>
<Pill tone={tone(granted)}>
<GrantIcon granted={granted} />
{granted === true ? 'Granted' : granted === false ? 'Not granted' : 'Unknown'}
</Pill>
</div>
)
}
/**
* Cross-platform Computer Use preflight card.
*
* cua-driver runs on macOS, Windows, and Linux, but readiness differs: macOS
* needs two TCC grants (Accessibility + Screen Recording) that attach to
* cua-driver's own `com.trycua.driver` identity — not Hermes — and are
* requested via `cua-driver permissions grant` (dialog attributed to
* CuaDriver). Windows/Linux have no TCC toggles, so readiness is driver health
* from `cua-driver doctor`. The backend folds both into one `ready` signal.
*
* Binary install/upgrade stays in the cua-driver provider's post-setup runner
* below this card (the generic ToolsetConfigPanel).
*/
export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps) {
const [status, setStatus] = useState<ComputerUseStatus | null>(null)
const [loading, setLoading] = useState(true)
const [granting, setGranting] = useState(false)
const activeRef = useRef(false)
const refresh = useCallback(async () => {
try {
setStatus(await getComputerUseStatus())
} catch (err) {
notifyError(err, 'Could not read Computer Use status')
} finally {
setLoading(false)
}
}, [])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
activeRef.current = true
void refresh()
return () => void (activeRef.current = false)
}, [refresh])
const grant = useCallback(async () => {
setGranting(true)
try {
const started = await grantComputerUsePermissions()
if (!started.ok) {
notifyError(new Error('spawn failed'), 'Could not request permissions')
return
}
notify({
kind: 'info',
title: 'Approve in System Settings',
message: 'macOS will show a permission dialog attributed to CuaDriver. Approve it, then return here.'
})
// The driver waits for the user to flip the switch — poll until it exits.
for (let attempt = 0; attempt < 150 && activeRef.current; attempt += 1) {
await new Promise(resolve => window.setTimeout(resolve, 1500))
if (!activeRef.current) {
break
}
const polled = await getActionStatus(started.name, 200)
upsertDesktopActionTask(polled)
if (!polled.running) {
break
}
}
if (activeRef.current) {
await refresh()
onConfiguredChange?.()
}
} catch (err) {
if (activeRef.current) {
notifyError(err, 'Could not request permissions')
}
} finally {
if (activeRef.current) {
setGranting(false)
}
}
}, [onConfiguredChange, refresh])
if (loading) {
return (
<div className="flex items-center gap-2 px-1 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
Checking Computer Use status
</div>
)
}
if (!status) {
return null
}
if (!status.platform_supported) {
return (
<p className="px-1 text-xs text-muted-foreground">
Computer Use isn&apos;t supported on this platform ({status.platform}).
</p>
)
}
if (!status.installed) {
return (
<p className="px-1 text-xs text-muted-foreground">
Install the cua-driver backend below to drive this machine.
{status.can_grant && ' Then grant Accessibility and Screen Recording here.'}
</p>
)
}
const failingChecks = status.checks.filter(c => c.status !== 'ok')
return (
<div className="grid gap-2">
<div className="flex flex-wrap items-center justify-between gap-2 px-1">
<div className="min-w-0">
{status.can_grant ? (
<p className="text-[0.72rem] text-muted-foreground">
Grants attach to CuaDriver&apos;s own identity (com.trycua.driver), not Hermes so the dialog is
attributed to the process that drives your Mac.
</p>
) : (
<p className="text-[0.72rem] text-muted-foreground">{PLATFORM_NOTE[status.platform] ?? ''}</p>
)}
{status.version && <p className="text-[0.68rem] text-muted-foreground/80">{status.version}</p>}
</div>
<Button onClick={() => void refresh()} size="sm" variant="text">
<RefreshCw className="size-3.5" />
Recheck
</Button>
</div>
{status.can_grant ? (
<>
<PermissionRow
granted={status.accessibility}
hint="Lets cua-driver post clicks, keystrokes, and read the accessibility tree."
label="Accessibility"
/>
<PermissionRow
granted={status.screen_recording}
hint="Lets cua-driver capture screenshots of app windows."
label="Screen Recording"
/>
</>
) : (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-background/55 p-2.5">
<span className="text-sm font-medium">Driver health</span>
<Pill tone={tone(status.ready)}>
<GrantIcon granted={status.ready} />
{status.ready === true ? 'Ready' : status.ready === false ? 'Not ready' : 'Unknown'}
</Pill>
</div>
)}
{failingChecks.map(c => (
<p className="px-1 text-[0.7rem] text-muted-foreground" key={c.label}>
<AlertTriangle className="mr-1 inline size-3" />
{c.label}: {c.message}
</p>
))}
{status.error && (
<p className="px-1 text-[0.7rem] text-muted-foreground">
<AlertTriangle className="mr-1 inline size-3" />
{status.error}
</p>
)}
{status.ready ? (
<div className="flex items-center gap-1.5 px-1 text-xs text-muted-foreground">
<Check className="size-3.5" />
Computer Use is ready. Ask the agent to capture an app and click around.
</div>
) : (
status.can_grant && (
<Button disabled={granting} onClick={() => void grant()} size="sm">
{granting ? <Loader2 className="size-3.5 animate-spin" /> : <ExternalLink className="size-3.5" />}
{granting ? 'Waiting for approval…' : 'Grant permissions'}
</Button>
)
)}
</div>
)
}
@@ -0,0 +1,238 @@
import type { ReactNode } from 'react'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { useI18n } from '@/i18n'
import { prettyName } from '@/lib/text'
import { cn } from '@/lib/utils'
import type { ConfigFieldSchema } from '@/types/hermes'
import { ComboboxInput } from './combobox-input'
import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, FREE_INPUT_KEYS } from './constants'
import { FallbackModelsField } from './fallback-models-field'
import { fieldCopyForSchemaKey } from './field-copy'
import { ListRow } from './primitives'
import { SearchableSelect } from './searchable-select'
/**
* One generic config row: label + description resolved from the i18n field
* copy (falling back to the schema description), and a control picked from the
* field schema — Switch for booleans, Select for enums, free-input combobox
* (Input + datalist) for FREE_INPUT_KEYS voice/model names, and Input/Textarea
* for the rest. Shared by the Settings config sections and the Capabilities
* TTS provider panel so both surfaces render identical fields.
*/
export function ConfigField({
schemaKey,
schema,
value,
enumOptions,
optionLabels,
onChange,
descriptionExtra
}: {
schemaKey: string
schema: ConfigFieldSchema
value: unknown
enumOptions?: string[]
optionLabels?: Record<string, string>
onChange: (value: unknown) => void
descriptionExtra?: ReactNode
}) {
const { t } = useI18n()
const c = t.settings.config
const label =
fieldCopyForSchemaKey(t.settings.fieldLabels, schemaKey) ??
fieldCopyForSchemaKey(FIELD_LABELS, schemaKey) ??
prettyName(schemaKey.split('.').pop() ?? schemaKey)
const normalize = (v: string) => v.toLowerCase().replace(/[^a-z0-9]+/g, '')
const rawDescription = (
fieldCopyForSchemaKey(t.settings.fieldDescriptions, schemaKey) ??
fieldCopyForSchemaKey(FIELD_DESCRIPTIONS, schemaKey) ??
schema.description ??
''
).trim()
const normalizedDesc = normalize(rawDescription)
const description =
rawDescription && normalizedDesc !== normalize(label) && normalizedDesc !== normalize(schemaKey)
? rawDescription
: undefined
const descriptionNode: ReactNode = descriptionExtra ? (
<span className="inline-flex flex-wrap items-center gap-x-3 gap-y-1">
{description}
{descriptionExtra}
</span>
) : (
description
)
// Every config row is addressable by its canonical schema key, so a tour can
// point at one setting (`[data-tour="field-model"]`) without hunting through
// the section for an nth-child path. See lib/tour.
const row = (action: ReactNode, wide = false) => (
<ListRow action={action} data-tour={`field-${schemaKey}`} description={descriptionNode} title={label} wide={wide} />
)
// `fallback_providers` is a list of {provider, model} objects; the generic
// `list` branch below would stringify them to "[object Object]". Render the
// dedicated structured editor instead.
if (schemaKey === 'fallback_providers') {
return row(<FallbackModelsField onChange={onChange} value={value} />, true)
}
if (schema.type === 'boolean') {
return row(
<div className="flex items-center justify-end">
<Switch checked={Boolean(value)} onCheckedChange={onChange} />
</div>
)
}
const selectOptions = enumOptions ?? (schema.type === 'select' ? (schema.options ?? []).map(String) : undefined)
// Large closed-world lists (e.g. ~590 IANA timezones) get a searchable
// Popover + cmdk combobox instead of a closed Select dropdown. The schema
// opt-in via `searchable: true` keeps this deterministic — no field
// accidentally triggers based on dynamic option count.
if (selectOptions && schema.searchable) {
return row(
<SearchableSelect
clearLabel={schema.clearable ? c.systemDefault : undefined}
emptyMessage={c.noResults}
onChange={next => onChange(next)}
options={selectOptions.filter(o => o !== '')}
placeholder={c.searchPlaceholder}
value={String(value ?? '')}
/>
)
}
// Voice/model name fields are open-world (custom voice IDs, cloned voices,
// brand-new model names) — render a free-input combobox where the known
// options are dropdown suggestions instead of a closed Select gate. The old
// native <datalist> filtered by the current value, so a field already set
// to a valid option showed only that single suggestion.
if (selectOptions && FREE_INPUT_KEYS.has(schemaKey)) {
return row(
<ComboboxInput
className={CONTROL_TEXT}
onChange={onChange}
optionLabels={optionLabels}
options={selectOptions.filter(o => o !== '')}
placeholder={c.notSet}
value={String(value ?? '')}
/>
)
}
if (selectOptions) {
return row(
<Select
onValueChange={next => onChange(next === EMPTY_SELECT_VALUE ? '' : next)}
value={String(value ?? '') || EMPTY_SELECT_VALUE}
>
<SelectTrigger className={CONTROL_TEXT}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{selectOptions.map(option => (
<SelectItem key={option || EMPTY_SELECT_VALUE} value={option || EMPTY_SELECT_VALUE}>
{option
? (optionLabels?.[option] ?? prettyName(option))
: schemaKey === 'display.personality'
? c.none
: schemaKey === 'memory.provider'
? c.builtinOnly
: c.noneParen}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
if (schema.type === 'number') {
return row(
<Input
className={CONTROL_TEXT}
onChange={e => {
const raw = e.target.value
const n = raw === '' ? 0 : Number(raw)
if (!Number.isNaN(n)) {
onChange(n)
}
}}
placeholder={c.notSet}
type="number"
value={value === undefined || value === null ? '' : String(value)}
/>
)
}
if (schema.type === 'list') {
return row(
<Input
className={CONTROL_TEXT}
onChange={e =>
onChange(
e.target.value
.split(',')
.map(s => s.trim())
.filter(Boolean)
)
}
placeholder={c.commaSeparated}
value={Array.isArray(value) ? value.join(', ') : String(value ?? '')}
/>
)
}
if (typeof value === 'object' && value !== null) {
return row(
<Textarea
className={cn('min-h-28 resize-y bg-background font-mono', CONTROL_TEXT)}
onChange={e => {
try {
onChange(JSON.parse(e.target.value))
} catch {
/* keep last valid */
}
}}
placeholder={c.notSet}
spellCheck={false}
value={JSON.stringify(value, null, 2)}
/>,
true
)
}
const isLong = schema.type === 'text' || String(value ?? '').length > 100
return row(
isLong ? (
<Textarea
className={cn('min-h-24 resize-y bg-background', CONTROL_TEXT)}
onChange={e => onChange(e.target.value)}
placeholder={c.notSet}
value={String(value ?? '')}
/>
) : (
<Input
className={CONTROL_TEXT}
onChange={e => onChange(e.target.value)}
placeholder={c.notSet}
value={String(value ?? '')}
/>
),
isLong
)
}
@@ -0,0 +1,98 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { atom } from 'nanostores'
import { createRef } from 'react'
import { MemoryRouter } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const getHermesConfigRecord = vi.fn()
const getHermesConfigSchema = vi.fn()
const saveHermesConfig = vi.fn()
const getElevenLabsVoices = vi.fn()
vi.mock('@/hermes', () => ({
getHermesConfigRecord: () => getHermesConfigRecord(),
getHermesConfigSchema: () => getHermesConfigSchema(),
saveHermesConfig: (config: unknown, profile?: string) => saveHermesConfig(config, profile),
getElevenLabsVoices: () => getElevenLabsVoices(),
setApiRequestProfile: () => {}
}))
vi.mock('../hooks/use-on-profile-switch', () => ({
useOnProfileSwitch: () => {}
}))
// The real stores pull in the gateway/profile stack, which needs a live
// backend connection. This page only reads the "applies to" scope override
// and the repo-discovery signature, neither of which this test touches.
vi.mock('@/store/settings-scope', () => ({
$settingsRequestProfile: atom<string | undefined>(undefined),
$settingsScopeOverride: atom<null | string>(null)
}))
vi.mock('@/store/projects', () => ({
repoDiscoveryPolicyFromConfig: () => ({ enabled: true, roots: [], exclude_paths: [] }),
repoDiscoveryPolicySignature: (policy: unknown) => JSON.stringify(policy),
scanAndRecordRepos: vi.fn().mockResolvedValue(undefined)
}))
beforeEach(() => {
getElevenLabsVoices.mockResolvedValue({ available: false })
getHermesConfigSchema.mockResolvedValue({ fields: {} })
saveHermesConfig.mockResolvedValue({ ok: true })
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
async function renderConfigSettings() {
const { ConfigSettings } = await import('./config-settings')
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const importInputRef = createRef<HTMLInputElement>()
render(
<MemoryRouter>
<QueryClientProvider client={client}>
<ConfigSettings activeSectionId="safety" importInputRef={importInputRef} />
</QueryClientProvider>
</MemoryRouter>
)
return { importInputRef }
}
describe('ConfigSettings autosave', () => {
it('sends a later revert instead of diffing it away against the stale page-load baseline', async () => {
getHermesConfigRecord.mockResolvedValue({ checkpoints: { enabled: false }, other: 'untouched' })
vi.useFakeTimers({ shouldAdvanceTime: true })
try {
await renderConfigSettings()
const toggle = await screen.findByRole('switch')
// Edit: flip checkpoints.enabled on, let the debounced autosave fire.
toggle.click()
await vi.advanceTimersByTimeAsync(700)
await waitFor(() => expect(saveHermesConfig).toHaveBeenCalledTimes(1))
expect(saveHermesConfig.mock.calls[0][0]).toEqual({ checkpoints: { enabled: true } })
// Revert: flip it back to its original value and let autosave fire again.
toggle.click()
await vi.advanceTimersByTimeAsync(700)
await waitFor(() => expect(saveHermesConfig).toHaveBeenCalledTimes(2))
// Must still explicitly send the reverted value — diffing against the
// never-advanced page-load baseline would produce an empty patch here
// (the field is back to its original value) and leave disk stuck at
// `enabled: true` from the first save.
expect(saveHermesConfig.mock.calls[1][0]).toEqual({ checkpoints: { enabled: false } })
} finally {
vi.useRealTimers()
}
})
})
@@ -0,0 +1,530 @@
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import type { ChangeEvent } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useSearchParams } from 'react-router'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { getElevenLabsVoices, getHermesConfigSchema, saveHermesConfig } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { confirm } from '@/store/confirm'
import {
$dataUrlReadMaxMb,
clampDataUrlReadMaxMb,
DATA_URL_READ_DEFAULT_MAX_MB,
DATA_URL_READ_MAX_MAX_MB,
DATA_URL_READ_MIN_MAX_MB,
refreshDataUrlReadMaxMb,
setDataUrlReadMaxMb
} from '@/store/data-url-read-max'
import { $disableF12, setDisableF12 } from '@/store/disable-f12'
import { $keepAwake, setKeepAwake } from '@/store/keep-awake'
import { notify, notifyError } from '@/store/notifications'
import { normalizeProfileKey } from '@/store/profile'
import { repoDiscoveryPolicyFromConfig, repoDiscoveryPolicySignature, scanAndRecordRepos } from '@/store/projects'
import { $settingsRequestProfile } from '@/store/settings-scope'
import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
import { hermesConfigCacheWriter, useHermesConfigRecord } from '../hooks/use-config-record'
import { useOnProfileSwitch } from '../hooks/use-on-profile-switch'
import { PanelEmpty } from '../overlays/panel'
import { ConfigField } from './config-field'
import {
clearsEnabledToolsets,
diffConfig,
enumOptionsFor,
getNested,
isExternalMemoryProvider,
sectionFieldEntries,
setNested,
voiceFieldVisible
} from './helpers'
import { MemoryConnect } from './memory/connect'
import { ProviderConfigPanel } from './memory/provider-config-panel'
import { ModelSettings, ModelSettingsSkeleton } from './model-settings'
import { PoolLimitsSetting } from './pool-limits-setting'
import { EmptyState, ListRow, SettingsContent, SettingsSkeleton, ToggleRow } from './primitives'
import { SettingsProfileScope } from './profile-scope'
import { QuickEntrySettings } from './quick-entry-settings'
export function ConfigSettings({
activeSectionId,
onConfigSaved,
onMainModelChanged,
importInputRef
}: ConfigSettingsProps) {
// Shared "Applies to" scope (null → the app's active profile). Remount the
// inner page per scope so every draft/seed/autosave ref resets wholesale
// when the target profile changes — the same guarantee useOnProfileSwitch
// provides for app-wide switches, without hand-clearing each piece.
const scopeProfile = useStore($settingsRequestProfile)
return (
<ConfigSettingsInner
activeSectionId={activeSectionId}
importInputRef={importInputRef}
key={scopeProfile ?? '__active__'}
onConfigSaved={onConfigSaved}
onMainModelChanged={onMainModelChanged}
scopeProfile={scopeProfile}
/>
)
}
interface ConfigSettingsProps {
activeSectionId: string
onConfigSaved?: () => void
onMainModelChanged?: (provider: string, model: string) => void
importInputRef: React.RefObject<HTMLInputElement | null>
}
function ConfigSettingsInner({
activeSectionId,
onConfigSaved,
onMainModelChanged,
importInputRef,
scopeProfile
}: ConfigSettingsProps & { scopeProfile: string | undefined }) {
const { t } = useI18n()
const c = t.settings.config
const keepAwake = useStore($keepAwake)
const disableF12 = useStore($disableF12)
// The editable draft is local (debounced autosave watches it), but it's seeded
// from — and saved back through — the shared config cache, so edits are visible
// in the MCP/model surfaces and reopening the page doesn't reload-flash.
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
const { data: loadedConfig, isError: configLoadFailed, refetch: refetchConfig } = useHermesConfigRecord(scopeProfile)
// Writes land on the same cache key the query above reads (base key when
// following the active profile, suffixed when a scope override is set).
const writeConfigCache = useMemo(() => hermesConfigCacheWriter(scopeProfile), [scopeProfile])
const {
data: schemaResponse,
isError: schemaFailed,
refetch: refetchSchema
} = useQuery({
// Base key when following the active profile (matches every pre-existing
// consumer); suffixed only for an explicit scope override.
queryKey:
scopeProfile == null ? ['hermes-config-schema'] : ['hermes-config-schema', normalizeProfileKey(scopeProfile)],
queryFn: () => getHermesConfigSchema(scopeProfile),
staleTime: 5 * 60 * 1000
})
const schema = schemaResponse?.fields ?? null
const [elevenLabsVoiceOptions, setElevenLabsVoiceOptions] = useState<string[] | null>(null)
const [elevenLabsVoiceLabels, setElevenLabsVoiceLabels] = useState<Record<string, string>>({})
const saveVersionRef = useRef(0)
const savedDiscoverySignatureRef = useRef<string | undefined>(undefined)
const [saveVersion, setSaveVersion] = useState(0)
// Seed the local draft once, the first time the shared record lands.
// Background refetches thereafter must not clobber in-progress edits.
const configSeeded = useRef(false)
// Snapshot of the record as it was when the draft was seeded. Autosave
// diffs the draft against this (not against disk) so a field the user
// never touched — possibly changed out-of-band by `hermes config set`
// while this page sat open — is never resent with its stale value.
const configBaselineRef = useRef<HermesConfigRecord | null>(null)
// Serializes autosave requests so an older save that's still in flight can't
// resolve after a newer one and re-advance the baseline / cache with stale
// data — each save's diff+request only starts once the previous one lands.
const saveQueueRef = useRef<Promise<void>>(Promise.resolve())
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (loadedConfig && !configSeeded.current) {
configSeeded.current = true
configBaselineRef.current = loadedConfig
savedDiscoverySignatureRef.current = repoDiscoveryPolicySignature(repoDiscoveryPolicyFromConfig(loadedConfig))
setConfig(loadedConfig)
}
}, [loadedConfig])
// A profile switch invalidates (but doesn't clear) the shared config query, so
// the local draft would otherwise keep profile A's data and autosave it into
// B. Drop the seed + draft (re-seeds from B's refetch) and zero saveVersion so
// the pending debounced autosave is cancelled by its effect cleanup.
useOnProfileSwitch(() => {
configSeeded.current = false
configBaselineRef.current = null
savedDiscoverySignatureRef.current = undefined
setConfig(null)
saveVersionRef.current = 0
setSaveVersion(0)
saveQueueRef.current = Promise.resolve()
})
useEffect(() => {
let cancelled = false
getElevenLabsVoices(scopeProfile)
.then(result => {
if (cancelled || !result.available) {
return
}
setElevenLabsVoiceOptions(result.voices.map(voice => voice.voice_id))
setElevenLabsVoiceLabels(Object.fromEntries(result.voices.map(voice => [voice.voice_id, voice.label])))
})
.catch(() => {
if (!cancelled) {
setElevenLabsVoiceOptions(null)
setElevenLabsVoiceLabels({})
}
})
return () => void (cancelled = true)
// scopeProfile is constant per mount (the inner component is keyed on it).
}, [scopeProfile])
// eslint-disable-next-line no-restricted-syntax -- autosave bookkeeping refs, not an atom mirror
useEffect(() => {
if (!config || saveVersion === 0) {
return
}
const v = saveVersion
const snapshot = config
const t = window.setTimeout(() => {
// Chained onto the queue (not fired directly) so an older save that's
// still awaiting its response can't land after this one and undo its
// baseline advance — each save's diff is computed once its predecessor
// has fully resolved.
saveQueueRef.current = saveQueueRef.current.then(async () => {
try {
const patch = diffConfig(configBaselineRef.current ?? {}, snapshot)
const result = await saveHermesConfig(patch, scopeProfile)
if (!result.ok) {
throw new Error(c.autosaveFailed)
}
// The saved snapshot becomes the new baseline, so the next autosave
// diffs against what's actually on disk instead of the page-load
// (or last-baseline) copy — otherwise reverting a field to its
// pre-save value diffs to nothing and the revert never reaches disk.
configBaselineRef.current = snapshot
// Mirror the saved record into the shared cache so MCP/model surfaces
// reflect the edit without their own refetch.
writeConfigCache(snapshot)
if (saveVersionRef.current === v) {
// The repo-discovery scan reads the ACTIVE profile's workspace
// policy; skip it when this page is editing another profile.
if (scopeProfile == null) {
const discoverySignature = repoDiscoveryPolicySignature(repoDiscoveryPolicyFromConfig(snapshot))
if (savedDiscoverySignatureRef.current !== discoverySignature) {
savedDiscoverySignatureRef.current = discoverySignature
await scanAndRecordRepos(true)
}
}
onConfigSaved?.()
}
} catch (err) {
if (saveVersionRef.current === v) {
notifyError(err, c.autosaveFailed)
}
}
})
}, 550)
return () => window.clearTimeout(t)
// eslint-disable-next-line react-hooks/exhaustive-deps -- copy is stable; avoid re-scheduling autosave on locale change
}, [config, onConfigSaved, saveVersion])
const applyConfig = (next: HermesConfigRecord) => {
saveVersionRef.current += 1
setConfig(next)
setSaveVersion(saveVersionRef.current)
}
const updateConfig = (next: HermesConfigRecord) => {
// Guard the single most destructive config edit: clearing the entire
// "Enabled Toolsets" list silently disables memory, terminal, web search,
// delegation, and most tools, and a stray select-all + Backspace can do it.
// Auto-save is debounced with no undo, so confirm a non-empty → empty
// transition before applying it. Every other edit passes through untouched.
if (config && clearsEnabledToolsets(config, next)) {
void confirm({ destructive: true, title: c.toolsetsWipeConfirm }).then(ok => {
if (ok) {
applyConfig(next)
}
})
return
}
applyConfig(next)
}
const sectionFields = useMemo(() => {
if (!schema || !config) {
return new Map<string, [string, ConfigFieldSchema][]>()
}
return sectionFieldEntries(schema, config)
}, [schema, config])
const fields = sectionFields.get(activeSectionId) ?? []
// Deep-link target from the command palette (?field=<key>): scroll the row
// into view and flash it, then drop the param so it doesn't re-fire.
const [searchParams, setSearchParams] = useSearchParams()
const targetField = searchParams.get('field')
useEffect(() => {
if (!targetField || !config || !schema) {
return
}
const element = document.getElementById(`setting-field-${targetField}`)
if (!element) {
return
}
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
if (!element.hasAttribute('tabindex')) {
element.tabIndex = -1
}
element.focus({ preventScroll: true })
element.classList.add('setting-field-highlight')
const timeout = window.setTimeout(() => element.classList.remove('setting-field-highlight'), 1600)
setSearchParams(
previous => {
const next = new URLSearchParams(previous)
next.delete('field')
return next
},
{ replace: true }
)
return () => window.clearTimeout(timeout)
}, [config, schema, setSearchParams, targetField])
function handleImport(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) {
return
}
const reader = new FileReader()
reader.onload = () => {
try {
updateConfig(JSON.parse(String(reader.result)))
notify({ kind: 'success', title: c.imported, message: t.common.saving })
} catch (err) {
notifyError(err, c.invalidJson)
}
}
reader.readAsText(file)
e.target.value = ''
}
if (!config || !schema) {
// A failed config/schema fetch must surface a retry, not spin forever.
if ((configLoadFailed && !config) || (schemaFailed && !schema)) {
return (
<div className="flex h-full min-h-0 flex-1">
<PanelEmpty
action={
<Button
onClick={() => {
void refetchConfig()
void refetchSchema()
}}
size="sm"
>
{t.skills.refresh}
</Button>
}
icon="error"
title={c.failedLoad}
/>
</div>
)
}
// Every section keeps its shape via a skeleton; model gets its bespoke one
// (its catalog fetch is the slow part), the rest the shared field rhythm.
if (activeSectionId === 'model') {
return (
<SettingsContent>
<SettingsProfileScope className="mb-5" />
<div className="mb-6">
<ModelSettingsSkeleton />
</div>
</SettingsContent>
)
}
return <SettingsSkeleton sections={[{ rows: 6 }]} />
}
const visibleFields = activeSectionId === 'voice' ? fields.filter(([key]) => voiceFieldVisible(key, config)) : fields
return (
<SettingsContent>
{/* Which profile's config.yaml this page edits — shared across every
config-backed settings page (and hidden for single-profile users). */}
<SettingsProfileScope className="mb-5" />
{activeSectionId === 'model' && (
<div className="mb-6">
<ModelSettings onMainModelChanged={onMainModelChanged} scopeProfile={scopeProfile} />
</div>
)}
{/* Device-local desktop prefs (not config.yaml) — they live here since
keeping the machine awake and the global Quick Entry chord are both
power-user, this-computer-only knobs. */}
{activeSectionId === 'advanced' && (
<>
<ToggleRow
checked={keepAwake}
description={c.keepAwakeDesc}
label={c.keepAwakeTitle}
onChange={setKeepAwake}
/>
<ToggleRow
checked={disableF12}
description={c.disableF12Desc}
label={c.disableF12Title}
onChange={setDisableF12}
/>
<PoolLimitsSetting />
<QuickEntrySettings />
</>
)}
{/* Device-local attach/preview byte cap (main-process IPC guard). Chat is
where image-attachment behavior already lives, so this sits above the
schema fields for that section. */}
{activeSectionId === 'chat' ? <AttachmentSizeSetting /> : null}
{visibleFields.length === 0 && activeSectionId !== 'chat' ? (
<EmptyState description={c.emptyDesc} title={c.emptyTitle} />
) : visibleFields.length === 0 ? null : (
<div className="grid gap-1">
{visibleFields.map(([key, field]) => (
<div className="scroll-mt-6 rounded-lg" id={`setting-field-${key}`} key={key}>
<ConfigField
descriptionExtra={
key === 'memory.provider' && isExternalMemoryProvider(getNested(config, key)) ? (
<MemoryConnect profile={scopeProfile} provider={String(getNested(config, key))} />
) : undefined
}
enumOptions={
key === 'tts.elevenlabs.voice_id'
? enumOptionsFor(key, getNested(config, key), config, elevenLabsVoiceOptions ?? undefined)
: enumOptionsFor(key, getNested(config, key), config)
}
onChange={value => updateConfig(setNested(config, key, value))}
optionLabels={key === 'tts.elevenlabs.voice_id' ? elevenLabsVoiceLabels : undefined}
schema={field}
schemaKey={key}
value={getNested(config, key)}
/>
{key === 'memory.provider' && isExternalMemoryProvider(getNested(config, key)) ? (
<ProviderConfigPanel
key={String(getNested(config, key))}
profile={scopeProfile}
provider={String(getNested(config, key))}
/>
) : null}
</div>
))}
</div>
)}
<input
accept=".json,application/json"
className="hidden"
onChange={handleImport}
ref={importInputRef}
type="file"
/>
</SettingsContent>
)
}
/** Free-form MB cap for Desktop's data-URL attach/preview path (main-process). */
function AttachmentSizeSetting() {
const { t } = useI18n()
const c = t.settings.config
const stored = useStore($dataUrlReadMaxMb)
const [draft, setDraft] = useState(String(stored))
useEffect(() => {
void refreshDataUrlReadMaxMb()
}, [])
useEffect(() => {
setDraft(String(stored))
}, [stored])
const commit = () => {
// An empty draft means "reset to the default", not the 1 MB floor
// (Number('') === 0 would otherwise clamp down to the floor).
const applied = draft.trim() === '' ? DATA_URL_READ_DEFAULT_MAX_MB : clampDataUrlReadMaxMb(draft)
// Unchanged: snap the draft back to the stored value and skip the
// pointless IPC write + haptic.
if (applied === stored) {
setDraft(String(stored))
return
}
void setDataUrlReadMaxMb(applied).then(next => {
setDraft(String(next))
// On a bridge write failure the store keeps the old value; only
// celebrate when the new cap actually landed.
if (next === applied) {
triggerHaptic('selection')
}
})
}
return (
<ListRow
action={
<div className="flex items-center gap-2">
<Input
aria-label={c.attachmentSizeLabel}
className="w-20"
inputMode="numeric"
max={DATA_URL_READ_MAX_MAX_MB}
min={DATA_URL_READ_MIN_MAX_MB}
onBlur={commit}
onChange={event => setDraft(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
event.currentTarget.blur()
}
}}
type="number"
value={draft}
/>
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{c.attachmentSizeUnit}
</span>
</div>
}
description={c.attachmentSizeDesc}
title={c.attachmentSizeTitle}
/>
)
}
@@ -0,0 +1,394 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DesktopConnectionsRegistry } from '@/global'
import { $connection } from '@/store/session'
import {
ConnectionsRegistrySection,
findDuplicateConnection,
normalizeGatewayUrl,
sameBackendPeerLabel,
sshCompositeKey
} from './connections-registry'
const list = vi.fn()
const save = vi.fn()
const remove = vi.fn()
const setLaunchMode = vi.fn()
const setPrimary = vi.fn()
const test = vi.fn()
const registry: DesktopConnectionsRegistry = {
connections: [
{ id: 'local', kind: 'local', label: 'This device', tokenPreview: null, tokenSet: false },
{
authMode: 'token',
id: 'homelab',
kind: 'remote',
label: 'Homelab',
tokenPreview: '...abc123',
tokenSet: true,
url: 'http://homelab.lan:9119'
}
],
primary: 'local',
secureTokenStorage: true,
version: 2
}
beforeEach(() => {
$connection.set({
baseUrl: 'http://homelab.lan:9119',
connectionId: 'homelab',
isFullscreen: false,
logs: [],
mode: 'remote',
nativeOverlayWidth: 0,
token: 'test-token',
windowButtonPosition: null,
wsUrl: 'ws://homelab.lan:9119/ws'
})
list.mockResolvedValue(registry)
save.mockResolvedValue({ connection: registry.connections[1], ok: true, registry })
remove.mockResolvedValue({ ok: true, registry: { ...registry, connections: [registry.connections[0]] } })
setLaunchMode.mockResolvedValue({ ok: true, registry: { ...registry, launchMode: 'last-used' } })
setPrimary.mockResolvedValue({ ok: true, registry: { ...registry, primary: 'homelab' } })
test.mockResolvedValue({ ok: true, reachable: true })
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { connections: { list, remove, save, setLaunchMode, setPrimary, test } }
})
})
afterEach(() => {
$connection.set(null)
cleanup()
vi.clearAllMocks()
})
describe('ConnectionsRegistrySection', () => {
it('distinguishes the current connection from the registry primary', async () => {
render(<ConnectionsRegistrySection />)
await waitFor(() => expect(screen.getByText('Homelab')).toBeTruthy())
// Label and the managed pill share the copy, so expect both instances.
expect(screen.getAllByText('This device').length).toBeGreaterThan(0)
expect(screen.getByText('Current')).toBeTruthy()
expect(screen.getAllByText('Primary').length).toBeGreaterThan(0)
expect(list).toHaveBeenCalledTimes(1)
})
it('opens the add-connection editor and saves with a required label', async () => {
render(<ConnectionsRegistrySection />)
await waitFor(() => expect(screen.getByText('Homelab')).toBeTruthy())
fireEvent.click(screen.getByText('Add connection'))
// Save is disabled until a label is present.
const saveButton = screen.getByText('Save connection').closest('button')!
expect(saveButton.disabled).toBe(true)
fireEvent.change(screen.getByPlaceholderText('Homelab'), { target: { value: 'Spark box' } })
fireEvent.change(screen.getByPlaceholderText('http://homelab.lan:9119'), {
target: { value: 'http://spark.lan:9119' }
})
expect(saveButton.disabled).toBe(false)
fireEvent.click(saveButton)
await waitFor(() => expect(save).toHaveBeenCalledTimes(1))
expect(save.mock.calls[0][0]).toMatchObject({
kind: 'remote',
label: 'Spark box',
url: 'http://spark.lan:9119'
})
})
it('offers every kind on create and disables Local while the managed entry exists', async () => {
render(<ConnectionsRegistrySection />)
await waitFor(() => expect(screen.getByText('Homelab')).toBeTruthy())
fireEvent.click(screen.getByText('Add connection'))
const localKind = screen.getByRole('button', { name: 'Local' }) as HTMLButtonElement
expect(localKind.disabled).toBe(true)
expect(screen.getByRole('button', { name: 'Hermes Cloud' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Remote gateway' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'SSH' })).toBeTruthy()
})
it('rejects a duplicate gateway URL in the save path with an inline error', async () => {
render(<ConnectionsRegistrySection />)
await waitFor(() => expect(screen.getByText('Homelab')).toBeTruthy())
fireEvent.click(screen.getByText('Add connection'))
fireEvent.change(screen.getByPlaceholderText('Homelab'), { target: { value: 'Homelab twin' } })
// Same URL modulo case + trailing slash: normalized-dupe of the existing entry.
fireEvent.change(screen.getByPlaceholderText('http://homelab.lan:9119'), {
target: { value: 'HTTP://HOMELAB.LAN:9119/' }
})
fireEvent.click(screen.getByText('Save connection').closest('button')!)
await waitFor(() =>
expect(screen.getByText('A connection to this gateway URL already exists (“Homelab”).')).toBeTruthy()
)
expect(save).not.toHaveBeenCalled()
})
it('keeps the primary fallback configurable while last-used restore is enabled', async () => {
list.mockResolvedValueOnce({ ...registry, launchMode: 'last-used' })
render(<ConnectionsRegistrySection />)
await waitFor(() => expect(screen.getByText('Homelab')).toBeTruthy())
const makePrimary = screen.getByText('Make primary').closest('button')!
expect(makePrimary.disabled).toBe(false)
fireEvent.click(makePrimary)
await waitFor(() => expect(setPrimary).toHaveBeenCalledWith('homelab'))
})
it('lets users opt into restoring the last-used source', async () => {
render(<ConnectionsRegistrySection />)
const launchSetting = await screen.findByText('At startup, return to Sessions on the last-used gateway')
const addConnection = screen.getByText('Add connection')
expect(addConnection.compareDocumentPosition(launchSetting) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
fireEvent.click(screen.getByRole('switch', { name: 'At startup, return to Sessions on the last-used gateway' }))
await waitFor(() => expect(setLaunchMode).toHaveBeenCalledWith('last-used'))
})
it('offers the launch preference even for a single source', async () => {
// A local-only registry is the drift state from #90174, and the launch
// toggle is the control that lets a user out of it. Hiding it there left
// hand-editing connections.json as the only recourse.
list.mockResolvedValueOnce({ ...registry, connections: [registry.connections[0]] })
render(<ConnectionsRegistrySection />)
await waitFor(() => expect(list).toHaveBeenCalledTimes(1))
expect(screen.getByText('At startup, return to Sessions on the last-used gateway')).toBeTruthy()
})
it('keeps search out of the way for a small registry', async () => {
render(<ConnectionsRegistrySection />)
await waitFor(() => expect(screen.getByText('Homelab')).toBeTruthy())
expect(screen.queryByRole('searchbox', { name: 'Search gateways…' })).toBeNull()
})
it('sorts a large registry and searches names and endpoints', async () => {
const largeRegistry: DesktopConnectionsRegistry = {
...registry,
connections: [
{
authMode: 'token',
id: 'zulu',
kind: 'remote',
label: 'Zulu',
tokenPreview: null,
tokenSet: false,
url: 'https://zulu.example.test'
},
registry.connections[0],
...Array.from({ length: 6 }, (_, index) => ({
authMode: 'token' as const,
id: `gateway-${index}`,
kind: 'remote' as const,
label: index === 0 ? 'Alpha' : `Gateway ${index}`,
tokenPreview: null,
tokenSet: false,
url:
index === 4
? 'https://studio.example.test'
: index === 5
? 'https://studio-archive.example.test'
: `https://gateway-${index}.example.test`
}))
]
}
list.mockResolvedValueOnce(largeRegistry)
render(
<div data-testid="settings-scroller" style={{ height: 400, overflowY: 'auto' }}>
<ConnectionsRegistrySection />
</div>
)
const search = await screen.findByRole('searchbox', { name: 'Search gateways…' })
expect(search.parentElement?.className).toContain('mt-3')
expect(search.parentElement?.className).toContain('mb-0')
const settingsScroller = screen.getByTestId('settings-scroller')
settingsScroller.scrollTop = 200
vi.spyOn(search, 'getBoundingClientRect')
.mockReturnValueOnce({
bottom: 152,
height: 32,
left: 0,
right: 0,
top: 120,
width: 0,
x: 0,
y: 120,
toJSON: () => ({})
})
.mockReturnValueOnce({
bottom: 152,
height: 32,
left: 0,
right: 0,
top: 120,
width: 0,
x: 0,
y: 120,
toJSON: () => ({})
})
.mockReturnValueOnce({
bottom: 152,
height: 32,
left: 0,
right: 0,
top: 120,
width: 0,
x: 0,
y: 120,
toJSON: () => ({})
})
.mockReturnValue({
bottom: 182,
height: 32,
left: 0,
right: 0,
top: 150,
width: 0,
x: 0,
y: 150,
toJSON: () => ({})
})
const alpha = screen.getByText('Alpha')
const zulu = screen.getByText('Zulu')
expect(alpha.compareDocumentPosition(zulu) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
fireEvent.change(search, { target: { value: 'studio' } })
expect(settingsScroller.scrollTop).toBe(200)
expect(screen.getByText('Gateway 4')).toBeTruthy()
expect(screen.getByText('Gateway 5')).toBeTruthy()
expect(screen.queryByText('Alpha')).toBeNull()
settingsScroller.scrollTop = 260
fireEvent.change(search, { target: { value: 'studio.example' } })
expect(settingsScroller.scrollTop).toBe(290)
expect(screen.getByText('Gateway 4')).toBeTruthy()
expect(screen.queryByText('Gateway 5')).toBeNull()
fireEvent.change(search, { target: { value: '' } })
expect(search.closest<HTMLElement>('.border-t')?.style.minHeight).toBe('')
})
it('tests a connection through the bridge', async () => {
render(<ConnectionsRegistrySection />)
await waitFor(() => expect(screen.getByText('Homelab')).toBeTruthy())
fireEvent.click(screen.getAllByText('Test')[0])
await waitFor(() => expect(test).toHaveBeenCalled())
})
})
describe('dedupe helpers', () => {
it('normalizes gateway URLs (trim, trailing slashes, lowercase)', () => {
expect(normalizeGatewayUrl(' HTTP://Homelab.LAN:9119// ')).toBe('http://homelab.lan:9119')
})
it('normalizes ssh composites and defaults the port', () => {
expect(sshCompositeKey('alice@Box')).toBe('alice@box:22')
expect(sshCompositeKey('alice@box:22')).toBe('alice@box:22')
expect(sshCompositeKey('box:2222')).toBe('@box:2222')
expect(sshCompositeKey(' ')).toBe('')
})
it('finds at most one local entry', () => {
expect(
findDuplicateConnection({ host: '', id: null, kind: 'local', remoteProfile: '', url: '' }, registry.connections)
).toMatchObject({ id: 'local' })
// Editing the local entry itself is not a self-collision.
expect(
findDuplicateConnection(
{ host: '', id: 'local', kind: 'local', remoteProfile: '', url: '' },
registry.connections
)
).toBeNull()
})
it('keys remote/cloud dupes on the normalized URL across both kinds', () => {
expect(
findDuplicateConnection(
{ host: '', id: null, kind: 'cloud', remoteProfile: '', url: 'http://HOMELAB.lan:9119/' },
registry.connections
)
).toMatchObject({ id: 'homelab' })
expect(
findDuplicateConnection(
{ host: '', id: null, kind: 'remote', remoteProfile: '', url: 'http://other.lan:9119' },
registry.connections
)
).toBeNull()
// Editing the entry itself is not a self-collision.
expect(
findDuplicateConnection(
{ host: '', id: 'homelab', kind: 'remote', remoteProfile: '', url: 'http://homelab.lan:9119' },
registry.connections
)
).toBeNull()
})
it('keys ssh dupes on user@host:port + remote profile', () => {
const connections = [
...registry.connections,
{
host: 'box',
id: 'box',
kind: 'ssh' as const,
label: 'Box',
port: 22,
remoteProfile: 'work',
tokenPreview: null,
tokenSet: false,
user: 'alice'
}
]
expect(
findDuplicateConnection(
{ host: 'alice@box:22', id: null, kind: 'ssh', remoteProfile: 'work', url: '' },
connections
)
).toMatchObject({ id: 'box' })
// Different profile on the same host is a distinct agent source.
expect(
findDuplicateConnection(
{ host: 'alice@box:22', id: null, kind: 'ssh', remoteProfile: 'other', url: '' },
connections
)
).toBeNull()
})
it('hints "Same backend as" only on later rows sharing an install_id', () => {
const spark = { id: 'spark', installId: 'aaa', label: 'Spark' }
const sparkTs = { id: 'spark-ts', installId: 'aaa', label: 'Spark TS' }
const mini = { id: 'mini', installId: 'bbb', label: 'Mini' }
const legacy = { id: 'old', label: 'Old box' }
const connections = [spark, sparkTs, mini, legacy]
// The first occurrence carries no hint; the later duplicate points back.
expect(sameBackendPeerLabel(spark, connections)).toBeNull()
expect(sameBackendPeerLabel(sparkTs, connections)).toBe('Spark')
// Unique ids and id-less (older backend) rows never hint.
expect(sameBackendPeerLabel(mini, connections)).toBeNull()
expect(sameBackendPeerLabel(legacy, connections)).toBeNull()
})
})
File diff suppressed because it is too large Load Diff
+792
View File
@@ -0,0 +1,792 @@
import {
Box,
Brain,
Globe,
type IconComponent,
Lock,
MessageCircle,
Mic,
Monitor,
Moon,
Palette,
Sun,
Wrench
} from '@/lib/icons'
import { REASONING_EFFORTS } from '@/lib/reasoning-effort'
import type { ThemeMode } from '@/themes/context'
// Single source of truth for built-in personality names lives in
// lib/personalities (mirrors hermes_cli/personality.py BUILTIN_PERSONALITIES).
export { BUILTIN_PERSONALITIES } from '@/lib/personalities'
import { defineFieldCopy } from './field-copy'
import type { DesktopConfigSection } from './types'
// Provider group definitions used to fold raw env-var names like
// ``XAI_API_KEY`` into a single "xAI" card with a friendly label, short
// description, and signup URL. Membership is determined by longest
// prefix match (see ``providerGroup`` in helpers.ts) so more specific
// prefixes (``MINIMAX_CN_``) correctly beat their general parents
// (``MINIMAX_``). New providers should be added here so they get their
// own card in Settings → Keys instead of being lumped into "Other".
interface ProviderPrefix {
prefix: string
name: string
/** Optional one-line tagline shown beneath the group name. */
description?: string
/** Optional canonical signup/console URL surfaced from the card header. */
docsUrl?: string
/** Lower numbers float to the top of the providers list. */
priority: number
}
export const EMPTY_SELECT_VALUE = '__hermes_empty__'
export const CONTROL_TEXT = 'text-xs'
export const PROVIDER_GROUPS: ProviderPrefix[] = [
{
prefix: 'NOUS_',
name: 'Nous Portal',
description: 'Hosted Hermes & Nous-trained models',
docsUrl: 'https://portal.nousresearch.com',
priority: 0
},
{
prefix: 'FIREWORKS_',
name: 'Fireworks AI',
description: 'OpenAI-compatible direct model API',
docsUrl: 'https://app.fireworks.ai/settings/users/api-keys',
// Slot #2 — mirrors CANONICAL_PROVIDERS (after Nous, ahead of OpenRouter).
// Same numeric priority as OpenRouter; name sort puts Fireworks first.
priority: 1
},
{
prefix: 'OPENROUTER_',
name: 'OpenRouter',
description: 'Aggregator for hundreds of frontier models',
docsUrl: 'https://openrouter.ai/keys',
priority: 1
},
{
prefix: 'ANTHROPIC_',
name: 'Anthropic',
description: 'Claude API access (Sonnet, Opus, Haiku)',
docsUrl: 'https://console.anthropic.com/settings/keys',
priority: 2
},
{
prefix: 'XAI_',
name: 'xAI',
description: 'Grok models (use OAuth for SuperGrok / Premium+)',
docsUrl: 'https://console.x.ai/',
priority: 3
},
{
prefix: 'GOOGLE_',
name: 'Gemini',
description: 'Google AI Studio (Gemini 1.5 / 2.0 / 2.5)',
docsUrl: 'https://aistudio.google.com/app/apikey',
priority: 4
},
{ prefix: 'GEMINI_', name: 'Gemini', priority: 4 },
{
prefix: 'DEEPSEEK_',
name: 'DeepSeek',
description: 'Direct DeepSeek API (V3.x, R1)',
docsUrl: 'https://platform.deepseek.com/api_keys',
priority: 5
},
{
prefix: 'DASHSCOPE_',
name: 'DashScope (Qwen)',
description: 'Alibaba Cloud DashScope — Qwen and multi-vendor models',
docsUrl: 'https://modelstudio.console.alibabacloud.com/',
priority: 6
},
{ prefix: 'HERMES_QWEN_', name: 'DashScope (Qwen)', priority: 6 },
{
prefix: 'GLM_',
name: 'GLM / Z.AI',
description: 'Zhipu GLM-4.6 and Z.AI hosted endpoints',
docsUrl: 'https://z.ai/',
priority: 7
},
{ prefix: 'ZAI_', name: 'GLM / Z.AI', priority: 7 },
{ prefix: 'Z_AI_', name: 'GLM / Z.AI', priority: 7 },
{
prefix: 'KIMI_',
name: 'Kimi / Moonshot',
description: 'Moonshot Kimi K2 / coding endpoints',
docsUrl: 'https://platform.moonshot.cn/',
priority: 8
},
{
prefix: 'KIMI_CN_',
name: 'Kimi (China)',
description: 'Moonshot China endpoint',
docsUrl: 'https://platform.moonshot.cn/',
priority: 9
},
{
prefix: 'MINIMAX_',
name: 'MiniMax',
description: 'MiniMax-M2 and Hailuo international endpoints',
docsUrl: 'https://www.minimax.io/',
priority: 10
},
{
prefix: 'MINIMAX_CN_',
name: 'MiniMax (China)',
description: 'MiniMax mainland China endpoint',
docsUrl: 'https://www.minimaxi.com/',
priority: 11
},
{
prefix: 'HF_',
name: 'Hugging Face',
description: 'Inference Providers — 20+ open models via router.huggingface.co',
docsUrl: 'https://huggingface.co/settings/tokens',
priority: 12
},
{
prefix: 'OPENCODE_ZEN_',
name: 'OpenCode Zen',
description: 'Pay-as-you-go access to curated coding models',
docsUrl: 'https://opencode.ai/auth',
priority: 13
},
{
prefix: 'OPENCODE_GO_',
name: 'OpenCode Go',
description: '$10/month subscription for open coding models',
docsUrl: 'https://opencode.ai/auth',
priority: 14
},
{
prefix: 'NVIDIA_',
name: 'NVIDIA NIM',
description: 'build.nvidia.com or your own local NIM endpoint',
docsUrl: 'https://build.nvidia.com/',
priority: 15
},
{
prefix: 'OLLAMA_',
name: 'Ollama Cloud',
description: 'Cloud-hosted open models from ollama.com',
docsUrl: 'https://ollama.com/settings',
priority: 16
},
{
prefix: 'LM_',
name: 'LM Studio',
description: 'Local LM Studio server (OpenAI-compatible)',
docsUrl: 'https://lmstudio.ai/docs/local-server',
priority: 17
},
{
prefix: 'STEPFUN_',
name: 'StepFun',
description: 'StepFun Step Plan coding models',
docsUrl: 'https://platform.stepfun.com/',
priority: 18
},
{
prefix: 'XIAOMI_',
name: 'Xiaomi MiMo',
description: 'MiMo-V2.5 and Xiaomi proprietary models',
docsUrl: 'https://platform.xiaomimimo.com',
priority: 19
},
{
prefix: 'ARCEEAI_',
name: 'Arcee AI',
description: 'Arcee-hosted small + medium models',
docsUrl: 'https://chat.arcee.ai/',
priority: 20
},
{ prefix: 'ARCEE_', name: 'Arcee AI', priority: 20 },
{
prefix: 'GMI_',
name: 'GMI Cloud',
description: 'GMI Cloud GPU + model serving',
docsUrl: 'https://www.gmicloud.ai/',
priority: 21
},
{
prefix: 'AZURE_FOUNDRY_',
name: 'Azure Foundry',
description: 'Azure AI Foundry custom endpoints (OpenAI / Anthropic-compatible)',
docsUrl: 'https://ai.azure.com/',
priority: 22
},
{
prefix: 'AWS_',
name: 'AWS Bedrock',
description: 'Authenticate via AWS profile + region',
docsUrl: 'https://docs.aws.amazon.com/bedrock/latest/userguide/bedrock-regions.html',
priority: 23
}
]
// Schema-side select overrides for desktop-relevant enum fields whose
// backend schema only declares a string type.
export const ENUM_OPTIONS: Record<string, string[]> = {
'agent.image_input_mode': ['auto', 'native', 'text'],
'approvals.mode': ['manual', 'smart', 'off'],
'code_execution.mode': ['project', 'strict'],
'context.engine': ['compressor', 'default', 'custom'],
// '' = inherit the agent's own effort; the rest is the shared scale.
'delegation.reasoning_effort': ['', ...REASONING_EFFORTS],
// NOTE: memory.provider is intentionally NOT listed here. Its options are
// discovery-driven and served by the backend config schema (merged
// per-request in web_server._schema_with_dynamic_provider_options), so
// config-field consumes schema.options directly — a static list here would
// shadow that and hide user-installed/pip providers (#49513).
// Terminal execution backends — kept in sync with the dispatch ladder in
// tools/terminal_tool.py::_create_environment (local/docker/singularity/
// modal/daytona/ssh). Remote backends need extra env (image, tokens, host).
'terminal.backend': ['local', 'docker', 'singularity', 'modal', 'daytona', 'ssh'],
'stt.elevenlabs.model_id': ['scribe_v2', 'scribe_v1'],
'stt.local.model': ['tiny', 'base', 'small', 'medium', 'large-v3'],
// Speech-to-text backends — kept in sync with the stt block in
// hermes_cli/config.py (local/groq/openai/mistral/elevenlabs).
'stt.provider': ['local', 'groq', 'openai', 'mistral', 'xai', 'elevenlabs'],
// OpenAI TTS voices — the union across models (per the OpenAI TTS API
// docs). Model-specific narrowing happens in enumOptionsFor():
// tts-1 / tts-1-hd support 9 voices; gpt-4o-mini-tts supports all 13.
// Free-input field — the list is suggestions, not a gate (FREE_INPUT_KEYS).
'tts.openai.voice': [
'alloy',
'ash',
'ballad',
'cedar',
'coral',
'echo',
'fable',
'marin',
'nova',
'onyx',
'sage',
'shimmer',
'verse'
],
// Popular Edge neural voices (the full catalog is 400+ — free input).
'tts.edge.voice': [
'en-US-AriaNeural',
'en-US-JennyNeural',
'en-US-AndrewNeural',
'en-US-BrianNeural',
'en-US-GuyNeural',
'en-GB-SoniaNeural'
],
'tts.gemini.model': ['gemini-2.5-flash-preview-tts', 'gemini-2.5-pro-preview-tts'],
// Gemini TTS prebuilt voice set.
'tts.gemini.voice': [
'Zephyr',
'Puck',
'Charon',
'Kore',
'Fenrir',
'Leda',
'Orus',
'Aoede',
'Callirrhoe',
'Autonoe',
'Enceladus',
'Iapetus',
'Umbriel',
'Algieba',
'Despina',
'Erinome',
'Algenib',
'Rasalgethi',
'Laomedeia',
'Achernar',
'Alnilam',
'Schedar',
'Gacrux',
'Pulcherrima',
'Achird',
'Zubenelgenubi',
'Vindemiatrix',
'Sadachbia',
'Sadaltager',
'Sulafat'
],
'tts.xai.voice_id': ['eve'],
'tts.minimax.model': ['speech-02-hd', 'speech-02-turbo'],
'tts.mistral.model': ['voxtral-mini-tts-2603'],
'tts.kittentts.model': [
'KittenML/kitten-tts-nano-0.8-int8',
'KittenML/kitten-tts-micro-0.8-int8',
'KittenML/kitten-tts-mini-0.8-int8'
],
'tts.kittentts.voice': ['Jasper'],
'tts.piper.voice': ['en_US-lessac-medium', 'en_US-amy-medium', 'en_US-ryan-high', 'en_GB-alan-medium'],
'tts.neutts.model': ['neuphonic/neutts-air-q4-gguf', 'neuphonic/neutts-air-q8-gguf', 'neuphonic/neutts-air'],
// Text-to-speech backends — kept in sync with the built-in source of truth
// (agent/tts_registry.py::_BUILTIN_NAMES / tools/tts_tool.py::
// BUILTIN_TTS_PROVIDERS). 'xai' is Grok TTS.
'tts.provider': [
'edge',
'elevenlabs',
'openai',
'xai',
'minimax',
'mistral',
'gemini',
'neutts',
'kittentts',
'piper'
],
'stt.openai.model': ['whisper-1', 'gpt-4o-mini-transcribe', 'gpt-4o-transcribe', 'gpt-transcribe'],
'stt.mistral.model': ['voxtral-mini-latest', 'voxtral-mini-2602'],
'tts.openai.model': ['gpt-4o-mini-tts', 'tts-1', 'tts-1-hd'],
'tts.elevenlabs.model_id': ['eleven_multilingual_v2', 'eleven_turbo_v2_5', 'eleven_flash_v2_5'],
// NeuTTS local inference device.
'tts.neutts.device': ['cpu', 'cuda', 'mps'],
'updates.non_interactive_local_changes': ['stash', 'discard']
}
// Voice/model name fields render as a free-input combobox (Input + datalist)
// instead of a closed Select: providers accept custom voice IDs (ElevenLabs
// cloned voices, xAI custom voices, Edge's 400+ catalog) and ship new model
// names faster than this list updates. The ENUM_OPTIONS above become
// suggestions rather than a gate for these keys.
export const FREE_INPUT_KEYS = new Set([
'tts.edge.voice',
'tts.openai.model',
'tts.openai.voice',
'tts.elevenlabs.voice_id',
'tts.gemini.model',
'tts.gemini.voice',
'tts.xai.voice_id',
'tts.minimax.model',
'tts.minimax.voice_id',
'tts.mistral.model',
'tts.mistral.voice_id',
'tts.neutts.model',
'tts.kittentts.model',
'tts.kittentts.voice',
'tts.piper.voice',
'tts.deepinfra.model',
'tts.deepinfra.voice'
])
export const FIELD_LABELS: Record<string, string> = defineFieldCopy({
model: 'Default Model',
modelContextLength: 'Context Window',
fallbackProviders: 'Fallback Models',
toolsets: 'Enabled Toolsets',
timezone: 'Timezone',
display: {
personality: 'Personality',
showReasoning: 'Reasoning Blocks'
},
desktop: {
repoScanEnabled: 'Automatic Repository Discovery',
repoScanRoots: 'Repository Discovery Roots',
repoScanExcludePaths: 'Excluded Repository Paths'
},
agent: {
maxTurns: 'Max Agent Steps',
imageInputMode: 'Image Attachments',
apiMaxRetries: 'API Retries',
serviceTier: 'Service Tier',
toolUseEnforcement: 'Tool-Use Enforcement'
},
terminal: {
cwd: 'Working Directory',
backend: 'Execution Backend',
timeout: 'Command Timeout',
persistentShell: 'Persistent Shell',
envPassthrough: 'Environment Passthrough',
dockerImage: 'Docker Image',
singularityImage: 'Singularity Image',
modalImage: 'Modal Image',
daytonaImage: 'Daytona Image'
},
fileReadMaxChars: 'File Read Limit',
toolOutput: {
maxBytes: 'Terminal Output Limit',
maxLines: 'File Page Limit',
maxLineLength: 'Line Length Limit'
},
codeExecution: {
mode: 'Code Execution Mode'
},
approvals: {
mode: 'Approval Mode',
timeout: 'Approval Timeout',
mcpReloadConfirm: 'Confirm MCP Reloads'
},
commandAllowlist: 'Command Allowlist',
security: {
redactSecrets: 'Redact Secrets',
allowPrivateUrls: 'Allow Private URLs'
},
browser: {
allowPrivateUrls: 'Browser Private URLs',
autoLocalForPrivateUrls: 'Local Browser For Private URLs',
useRealProfile: 'Use My Real Browser Profile'
},
checkpoints: {
enabled: 'File Checkpoints',
maxSnapshots: 'Checkpoint Limit'
},
voice: {
recordKey: 'Voice Shortcut',
maxRecordingSeconds: 'Max Recording Length',
autoTts: 'Read Responses Aloud'
},
stt: {
enabled: 'Speech To Text',
echoTranscripts: 'Echo Transcripts',
provider: 'Speech-To-Text Provider',
local: {
model: 'Local Transcription Model',
language: 'Transcription Language'
},
openai: {
model: 'OpenAI STT Model'
},
groq: {
model: 'Groq STT Model'
},
mistral: {
model: 'Mistral STT Model'
},
elevenlabs: {
modelId: 'ElevenLabs STT Model',
languageCode: 'ElevenLabs Language',
tagAudioEvents: 'Tag Audio Events',
diarize: 'Speaker Diarization'
}
},
tts: {
provider: 'Text-To-Speech Provider',
edge: {
voice: 'Edge Voice'
},
openai: {
model: 'OpenAI TTS Model',
voice: 'OpenAI Voice'
},
elevenlabs: {
voiceId: 'ElevenLabs Voice',
modelId: 'ElevenLabs Model'
},
xai: {
voiceId: 'xAI (Grok) Voice',
language: 'xAI Language',
speed: 'xAI Playback Speed',
autoSpeechTags: 'xAI Auto Speech Tags',
optimizeStreamingLatency: 'xAI Streaming Latency Optimization',
sampleRate: 'xAI Sample Rate',
bitRate: 'xAI Bit Rate'
},
minimax: {
model: 'MiniMax TTS Model',
voiceId: 'MiniMax Voice'
},
mistral: {
model: 'Mistral TTS Model',
voiceId: 'Mistral Voice'
},
gemini: {
model: 'Gemini TTS Model',
voice: 'Gemini Voice'
},
neutts: {
model: 'NeuTTS Model',
device: 'NeuTTS Device'
},
kittentts: {
model: 'KittenTTS Model',
voice: 'KittenTTS Voice'
},
piper: {
voice: 'Piper Voice'
},
deepinfra: {
model: 'DeepInfra TTS Model',
voice: 'DeepInfra Voice'
}
},
memory: {
memoryEnabled: 'Persistent Memory',
userProfileEnabled: 'User Profile',
memoryCharLimit: 'Memory Budget',
userCharLimit: 'Profile Budget',
provider: 'Memory Provider'
},
context: {
engine: 'Context Engine'
},
compression: {
enabled: 'Auto-Compression',
threshold: 'Compression Threshold',
targetRatio: 'Compression Target',
protectLastN: 'Protected Recent Messages'
},
delegation: {
model: 'Subagent Model',
provider: 'Subagent Provider',
maxIterations: 'Subagent Turn Limit',
maxConcurrentChildren: 'Parallel Subagents',
childTimeoutSeconds: 'Subagent Timeout',
reasoningEffort: 'Subagent Reasoning Effort'
},
updates: {
nonInteractiveLocalChanges: 'In-App Update Local Changes'
}
})
export const FIELD_DESCRIPTIONS: Record<string, string> = defineFieldCopy({
model: 'Used for new chats unless you pick a different model in the composer.',
modelContextLength: "Leave at 0 to use the selected model's detected context window.",
fallbackProviders: 'Backup provider:model entries to try if the default model fails.',
display: {
personality: 'Default assistant style for new sessions.',
showReasoning: 'Show reasoning sections when the backend provides them.'
},
desktop: {
repoScanEnabled: 'Scan local folders for Git repositories to show in Projects.',
repoScanRoots: 'Folders to scan. Leave empty to scan your home directory.',
repoScanExcludePaths: 'Folders and their descendants to skip during repository discovery.'
},
timezone: 'IANA timezone identifier. Blank uses the system timezone.',
browser: {
useRealProfile:
"Local browsing uses your real logins. Hermes copies your default browser's profile (cookies, logins, preferences) into a managed snapshot and drives it with its packaged Chromium — your live profile is never opened directly, and the copy is refreshed from it on each run. Also lets the agent open a local real-profile session on request even when a cloud browser backend is configured. Only Chromium browsers (Chrome, Edge, Brave, Brave Origin, Chromium) are supported; a non-Chromium default fails with a clear message. Off by default."
},
agent: {
imageInputMode: 'Controls how image attachments are sent to the model.',
maxTurns: 'Upper bound for tool-calling turns before Hermes stops a run.'
},
terminal: {
cwd: 'Default project folder for tool and terminal work.',
persistentShell: 'Keep shell state between commands when the backend supports it.',
envPassthrough: 'Environment variables to pass into tool execution.',
dockerImage: 'Container image used when the execution backend is Docker.',
singularityImage: 'Image used when the execution backend is Singularity.',
modalImage: 'Image used when the execution backend is Modal.',
daytonaImage: 'Image used when the execution backend is Daytona.'
},
codeExecution: {
mode: 'How strictly code execution is scoped to the current project.'
},
fileReadMaxChars: 'Maximum characters Hermes can read from one file request.',
approvals: {
mode: 'How Hermes handles commands that need explicit approval.',
timeout: 'How long approval prompts wait before timing out.'
},
security: {
redactSecrets: 'Hide detected secrets from model-visible content when possible.'
},
checkpoints: {
enabled: 'Create rollback snapshots before file edits.'
},
memory: {
memoryEnabled: 'Save durable memories that can help future sessions.',
userProfileEnabled: 'Maintain a compact profile of user preferences.'
},
context: {
engine: 'Strategy for managing long conversations near the context limit.'
},
compression: {
enabled: 'Summarize older context when conversations get large.'
},
voice: {
autoTts: 'Automatically speak assistant responses.'
},
tts: {
xai: {
voiceId: 'xAI voice ID (e.g. eve) or a custom voice ID.',
language: 'Spoken language code (e.g. en, pt-BR) or "auto" for auto-detection.',
speed: 'Playback speed. 0.7 = slower, 1.0 = normal, 1.5 = faster.',
autoSpeechTags: 'Let an LLM insert expressive audio tags ([laughing], [sighs]) into the script before synthesis.',
optimizeStreamingLatency: 'Latency vs. quality trade-off. 0 = best quality, 2 = lowest latency.',
sampleRate: 'Audio sample rate in Hz. Higher = better quality, larger files.',
bitRate: 'MP3 bitrate in bps. Only applies when codec is mp3.'
},
neutts: {
device: 'Local inference device for NeuTTS.'
}
},
stt: {
enabled: 'Enable local or provider-backed speech transcription.',
echoTranscripts: 'Post the raw 🎙️ transcript of voice messages back to the chat.',
elevenlabs: {
languageCode: 'Optional ISO-639-3 language code. Blank lets ElevenLabs auto-detect.'
}
},
updates: {
nonInteractiveLocalChanges:
'When Hermes updates itself from the app (no terminal prompt), keep local source edits (stash) or throw them away (discard). Terminal updates always ask.'
}
})
// Curated desktop config surface: only fields a user might tune from the app.
export const SECTIONS: DesktopConfigSection[] = [
{
id: 'model',
label: 'Model',
icon: Box,
keys: ['model_context_length', 'fallback_providers']
},
{
id: 'chat',
label: 'Chat',
icon: MessageCircle,
keys: ['display.personality', 'timezone', 'display.show_reasoning', 'agent.image_input_mode']
},
{
id: 'appearance',
label: 'Appearance',
icon: Palette,
keys: []
},
{
id: 'workspace',
label: 'Workspace',
icon: Monitor,
keys: [
'terminal.cwd',
'desktop.repo_scan_enabled',
'desktop.repo_scan_roots',
'desktop.repo_scan_exclude_paths',
'code_execution.mode',
'terminal.persistent_shell',
'terminal.env_passthrough',
'file_read_max_chars'
]
},
{
id: 'safety',
label: 'Safety',
icon: Lock,
keys: [
'approvals.mode',
'approvals.timeout',
'approvals.mcp_reload_confirm',
'command_allowlist',
'security.redact_secrets',
'security.allow_private_urls',
'checkpoints.enabled'
]
},
{
id: 'browser',
label: 'Browser',
icon: Globe,
keys: ['browser.use_real_profile', 'browser.allow_private_urls', 'browser.auto_local_for_private_urls']
},
{
id: 'memory',
label: 'Memory & Context',
icon: Brain,
keys: [
'memory.memory_enabled',
'memory.user_profile_enabled',
'memory.memory_char_limit',
'memory.user_char_limit',
'memory.provider',
'context.engine',
'compression.enabled',
'compression.threshold',
'compression.target_ratio',
'compression.protect_last_n'
]
},
{
id: 'voice',
label: 'Voice',
icon: Mic,
keys: [
'tts.provider',
'stt.enabled',
'stt.echo_transcripts',
'stt.provider',
'voice.auto_tts',
'tts.edge.voice',
'tts.openai.model',
'tts.openai.voice',
'tts.elevenlabs.voice_id',
'tts.elevenlabs.model_id',
'tts.xai.voice_id',
'tts.xai.language',
'tts.xai.speed',
'tts.xai.auto_speech_tags',
'tts.xai.optimize_streaming_latency',
'tts.xai.sample_rate',
'tts.xai.bit_rate',
'tts.minimax.model',
'tts.minimax.voice_id',
'tts.mistral.model',
'tts.mistral.voice_id',
'tts.gemini.model',
'tts.gemini.voice',
'tts.neutts.model',
'tts.neutts.device',
'tts.kittentts.model',
'tts.kittentts.voice',
'tts.piper.voice',
'tts.deepinfra.model',
'tts.deepinfra.voice',
'stt.local.model',
'stt.local.language',
'stt.openai.model',
'stt.groq.model',
'stt.mistral.model',
'stt.elevenlabs.model_id',
'stt.elevenlabs.language_code',
'stt.elevenlabs.tag_audio_events',
'stt.elevenlabs.diarize',
'voice.record_key',
'voice.max_recording_seconds',
'voice.client_direct'
]
},
{
id: 'advanced',
label: 'Advanced',
icon: Wrench,
keys: [
'toolsets',
'terminal.backend',
'terminal.timeout',
'terminal.docker_image',
'terminal.singularity_image',
'terminal.modal_image',
'terminal.daytona_image',
'tool_output.max_bytes',
'tool_output.max_lines',
'tool_output.max_line_length',
'checkpoints.max_snapshots',
'agent.max_turns',
'agent.api_max_retries',
'agent.service_tier',
'agent.tool_use_enforcement',
'delegation.model',
'delegation.provider',
'delegation.max_iterations',
'delegation.max_concurrent_children',
'delegation.child_timeout_seconds',
'delegation.reasoning_effort',
'updates.non_interactive_local_changes'
]
}
]
export interface ModeOption {
id: ThemeMode
label: string
icon: IconComponent
}
export const MODE_OPTIONS: ModeOption[] = [
{ id: 'light', label: 'Light', icon: Sun },
{ id: 'dark', label: 'Dark', icon: Moon },
{ id: 'system', label: 'System', icon: Monitor }
]
@@ -0,0 +1,406 @@
import { type ChangeEvent, type KeyboardEvent } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { translateNow, useI18n } from '@/i18n'
import { ChevronDown, ExternalLink, Loader2, Save, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { EnvVarInfo } from '@/types/hermes'
import { CONTROL_TEXT } from './constants'
import { prettyName, withoutKey } from './helpers'
import { ListRow } from './primitives'
import type { EnvRowProps } from './types'
export type KeyRowProps = Omit<EnvRowProps, 'info' | 'varKey'>
/** Matches Advanced / config field controls (ListRow + Input). */
export const CREDENTIAL_CONTROL_CLASS = cn('h-8', CONTROL_TEXT)
// Resting credential field: chrome stripped so it reads as plain subtext.
// Stacked (<@2xl) it collapses to zero box (flush under its label); at @2xl it
// keeps the full control metrics (h-8 + px-2.5/py-1.5) so it centres on the
// label and nothing shifts when focus/expand adds the border. `!` beats the
// unlayered chrome CSS and the shared control sizing.
const CRED_BARE = 'border-0! bg-transparent! shadow-none! h-auto! p-0! @2xl:h-8! @2xl:px-2.5! @2xl:py-1.5!'
export const isKeyVar = (key: string, info: EnvVarInfo) => info.is_password || /(?:_API_KEY|_TOKEN|_KEY)$/.test(key)
export const friendlyFieldLabel = (key: string, info: EnvVarInfo) =>
info.description?.trim() ||
key
.replace(/_/g, ' ')
.toLowerCase()
.replace(/\b\w/g, c => c.toUpperCase())
export const credentialPlaceholder = (key: string, info: EnvVarInfo, label: string): string =>
isKeyVar(key, info)
? translateNow('settings.credentials.pasteLabelKey', label)
: /URL$/i.test(key)
? 'https://…'
: translateNow('settings.credentials.optional')
// A single credential field: a set key shows as a filled read-only input
// (redacted value) that edits in place on click. Save appears once typed; a set
// key also offers Remove, and Esc cancels without closing the overlay.
export function KeyField({
expanded = false,
info,
placeholder,
rowProps,
varKey
}: {
expanded?: boolean
info: EnvVarInfo
placeholder?: string
rowProps: KeyRowProps
varKey: string
}) {
const { t } = useI18n()
const { edits, onClear, onSave, saving, setEdits } = rowProps
const editing = edits[varKey] !== undefined
// Bare (plain subtext) only while the group is collapsed and idle. Expanding
// the card counts as "focused in", so it gets full input chrome too.
const bare = !editing && !expanded
const draft = edits[varKey] ?? ''
const dirty = draft.trim().length > 0
const busy = saving === varKey
const masked = info.redacted_value ?? '••••••••'
const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' }))
const cancel = () => setEdits(c => withoutKey(c, varKey))
const update = (e: ChangeEvent<HTMLInputElement>) => setEdits(c => ({ ...c, [varKey]: e.target.value }))
const keydown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && dirty) {
void onSave(varKey)
} else if (e.key === 'Escape' && editing) {
e.preventDefault()
e.stopPropagation()
cancel()
}
}
const editType = info.is_password ? 'password' : 'text'
if (info.is_set && !editing) {
return (
<Input
className={cn(CREDENTIAL_CONTROL_CLASS, bare && CRED_BARE, 'cursor-pointer text-muted-foreground')}
onFocus={startEdit}
readOnly
value={masked}
/>
)
}
return (
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2">
<Input
autoFocus={editing}
className={cn(CREDENTIAL_CONTROL_CLASS, bare && CRED_BARE)}
onChange={update}
onFocus={() => {
if (!editing) {
startEdit()
}
}}
onKeyDown={keydown}
placeholder={placeholder ?? t.settings.credentials.pasteKey}
type={editType}
value={draft}
/>
{/* Inline trailing controls — mirrors SearchField's inline clear button.
No floating hint row that reflows the grid or overlaps the card body;
Esc still cancels via keydown. */}
{editing && (info.is_set || dirty) && (
<div className="flex items-center gap-1">
{info.is_set && (
<Button
aria-label={t.settings.credentials.remove}
className="text-muted-foreground hover:text-destructive"
disabled={busy}
onClick={() => void onClear(varKey)}
size="icon-xs"
title={t.settings.credentials.remove}
type="button"
variant="ghost"
>
<Trash2 />
</Button>
)}
{dirty && (
<Button className="h-8" disabled={busy} onClick={() => void onSave(varKey)} size="sm">
{busy ? <Loader2 className="animate-spin" /> : <Save />}
{busy ? t.settings.credentials.saving : t.common.save}
</Button>
)}
</div>
)}
</div>
)
}
function CredentialDocsLink({ href }: { href: string }) {
const { t } = useI18n()
return (
<a
className="inline-flex w-fit items-center gap-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary) underline-offset-4 transition-colors hover:text-foreground hover:underline"
href={href}
onClick={e => e.stopPropagation()}
rel="noreferrer"
target="_blank"
>
{t.settings.credentials.getKey}
<ExternalLink className="size-3" />
</a>
)
}
/** One credential row — collapsible; description and docs link expand on click. */
export function CredentialKeyCard({
expanded,
info,
label,
onExpand,
onToggle,
placeholder,
rowProps,
varKey
}: CredentialKeyCardProps) {
const docsUrl = info.url?.trim()
const description = info.description?.trim()
const expandable = Boolean(description || docsUrl)
return (
<div
className={cn(
'@container group/card rounded-[6px] p-3 transition-colors',
expandable && 'cursor-pointer',
expandable && !expanded && 'row-hover',
expanded && 'bg-(--ui-bg-quaternary) ring-1 ring-(--ui-stroke-secondary)'
)}
onClick={expandable ? onToggle : undefined}
onKeyDown={
expandable
? e => {
// Only the card's own focus toggles it — ignore Enter/Space
// bubbling up from the inputs/buttons inside (Enter saves a key,
// Space types a space) so keyboard editing never collapses the card.
if (e.target !== e.currentTarget) {
return
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
}
}
: undefined
}
role={expandable ? 'button' : undefined}
tabIndex={expandable ? 0 : undefined}
>
{/* One CSS grid: 1 col stacked, 2 cols at @2xl. p-3 card padding = gap-3
row/col gaps, everything top-left aligned (items-start), no indents.
The label row is h-8 to line up with the input row beside it. */}
<div className="grid grid-cols-1 items-start gap-x-3 gap-y-1.5 @2xl:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] @2xl:gap-y-3">
<div className="flex h-8 min-w-0 items-center gap-2">
<span
className={cn('size-2 shrink-0 rounded-full', info.is_set ? 'bg-primary' : 'bg-(--ui-stroke-secondary)')}
/>
<span className="min-w-0 truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{label}
</span>
{expandable && (
<ChevronDown
className={cn(
'size-3.5 shrink-0 text-muted-foreground transition',
expanded ? 'rotate-180 opacity-100' : 'opacity-0 group-hover/card:opacity-100'
)}
/>
)}
</div>
<div
className="min-w-0"
onClick={e => e.stopPropagation()}
onFocus={() => {
if (expandable && !expanded) {
onExpand()
}
}}
>
<KeyField expanded={expanded} info={info} placeholder={placeholder} rowProps={rowProps} varKey={varKey} />
</div>
{expandable && expanded && (
<div className="grid gap-3 @2xl:col-span-2" onClick={e => e.stopPropagation()}>
{description && (
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{description}
</p>
)}
{docsUrl && <CredentialDocsLink href={docsUrl} />}
</div>
)}
</div>
</div>
)
}
/** Provider API key group — collapsible card; description, docs link, and advanced fields expand on click. */
export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps }: ProviderKeyRowsProps) {
const { t } = useI18n()
const docsUrl = group.docsUrl?.trim()
const description = group.description?.trim()
const expandable = Boolean(description || docsUrl || group.advanced.length > 0)
return (
<div
className={cn(
'@container group/card rounded-[6px] p-3 transition-colors',
expandable && 'cursor-pointer',
expandable && !expanded && 'row-hover',
expanded && 'bg-(--ui-bg-quaternary) ring-1 ring-(--ui-stroke-secondary)'
)}
onClick={expandable ? onToggle : undefined}
onKeyDown={
expandable
? e => {
// Only the card's own focus toggles it — ignore Enter/Space
// bubbling up from the inputs/buttons inside (Enter saves a key,
// Space types a space) so keyboard editing never collapses the card.
if (e.target !== e.currentTarget) {
return
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
}
}
: undefined
}
role={expandable ? 'button' : undefined}
tabIndex={expandable ? 0 : undefined}
>
{/* Same grid as CredentialKeyCard: 1 col stacked, 2 cols at @2xl, p-3 =
gap-3, items-start, label row h-8 to line up with the input row. */}
<div className="grid grid-cols-1 items-start gap-x-3 gap-y-1.5 @2xl:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] @2xl:gap-y-3">
<div className="flex h-8 min-w-0 items-center gap-2">
<span
className={cn(
'size-2 shrink-0 rounded-full',
group.hasAnySet ? 'bg-primary' : 'bg-(--ui-stroke-secondary)'
)}
/>
<span className="min-w-0 truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{group.name}
</span>
{expandable && (
<ChevronDown
className={cn(
'size-3.5 shrink-0 text-muted-foreground transition',
expanded ? 'rotate-180 opacity-100' : 'opacity-0 group-hover/card:opacity-100'
)}
/>
)}
</div>
<div
className="min-w-0"
onClick={e => e.stopPropagation()}
onFocus={() => {
if (expandable && !expanded) {
onExpand()
}
}}
>
<KeyField
expanded={expanded}
info={group.primary[1]}
placeholder={t.settings.credentials.pasteLabelKey(group.name)}
rowProps={rowProps}
varKey={group.primary[0]}
/>
</div>
{expandable && expanded && (
<div className="grid gap-3 @2xl:col-span-2" onClick={e => e.stopPropagation()}>
{description && (
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{description}
</p>
)}
{group.advanced.map(([key, info]) => {
const fieldLabel = isKeyVar(key, info)
? prettyName(key.replace(/(?:_API_KEY|_TOKEN|_KEY)$/i, ''))
: friendlyFieldLabel(key, info)
return (
<ListRow
action={
<KeyField
expanded={expanded}
info={info}
placeholder={credentialPlaceholder(key, info, fieldLabel)}
rowProps={rowProps}
varKey={key}
/>
}
key={key}
title={fieldLabel}
/>
)
})}
{docsUrl && <CredentialDocsLink href={docsUrl} />}
</div>
)}
</div>
</div>
)
}
export function credentialRowLabel(varKey: string, info: EnvVarInfo): string {
if (isKeyVar(varKey, info)) {
return prettyName(varKey.replace(/(?:_API_KEY|_TOKEN|_KEY)$/i, ''))
}
return prettyName(varKey)
}
interface CredentialKeyCardProps {
expanded: boolean
info: EnvVarInfo
label: string
onExpand: () => void
onToggle: () => void
placeholder: string
rowProps: KeyRowProps
varKey: string
}
interface ProviderKeyRowsProps {
expanded: boolean
group: ProviderKeyRowGroup
onExpand: () => void
onToggle: () => void
rowProps: KeyRowProps
}
export interface ProviderKeyRowGroup {
advanced: [string, EnvVarInfo][]
description?: string
docsUrl?: string
hasAnySet: boolean
name: string
primary: [string, EnvVarInfo]
}
@@ -0,0 +1,413 @@
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import {
activateCustomEndpoint,
deleteCustomEndpoint,
getCustomEndpoints,
saveCustomEndpoint,
validateCustomEndpoint
} from '@/hermes'
import { useI18n } from '@/i18n'
import { useAiturkCopy } from '@/i18n/aiturk'
import { AITURK_API_BASE_URL } from '@/lib/aiturk-provider'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Globe, Loader2, Plus, Save, Trash2, Zap } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { confirm } from '@/store/confirm'
import { notify, notifyError } from '@/store/notifications'
import type { CustomEndpoint, CustomEndpointUpdate } from '@/types/hermes'
import { EmptyState, Pill, SectionHeading, SettingsContent, SettingsSkeleton } from './primitives'
interface CustomEndpointsSettingsProps {
onConfigSaved?: () => void
onMainModelChanged?: (provider: string, model: string) => void
}
interface EndpointForm {
apiKey: string
baseUrl: string
contextLength: string
discoverModels: boolean
id: string
makeDefault: boolean
model: string
name: string
}
const EMPTY_FORM: EndpointForm = {
apiKey: '',
baseUrl: '',
contextLength: '',
discoverModels: true,
id: '',
makeDefault: true,
model: '',
name: ''
}
function formFromEndpoint(endpoint: CustomEndpoint): EndpointForm {
return {
apiKey: '',
baseUrl: endpoint.base_url,
contextLength: endpoint.context_length ? String(endpoint.context_length) : '',
discoverModels: endpoint.discover_models,
id: endpoint.id,
makeDefault: Boolean(endpoint.is_current),
model: endpoint.model,
name: endpoint.name
}
}
function toPayload(form: EndpointForm, models?: string[]): CustomEndpointUpdate {
const contextLength = Number.parseInt(form.contextLength, 10)
return {
id: form.id.trim() || undefined,
name: form.name.trim(),
base_url: form.baseUrl.trim(),
model: form.model.trim(),
api_key: form.apiKey.trim() || undefined,
context_length: Number.isFinite(contextLength) && contextLength > 0 ? contextLength : undefined,
discover_models: form.discoverModels,
make_default: form.makeDefault,
models: models?.length ? models : undefined
}
}
export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: CustomEndpointsSettingsProps) {
const { t } = useI18n()
const c = useAiturkCopy().custom
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [activating, setActivating] = useState<string | null>(null)
const [deleting, setDeleting] = useState<string | null>(null)
const [endpoints, setEndpoints] = useState<CustomEndpoint[]>([])
const [form, setForm] = useState<EndpointForm>(EMPTY_FORM)
const [discoveredModels, setDiscoveredModels] = useState<string[]>([])
async function refresh() {
const data = await getCustomEndpoints()
setEndpoints(data.endpoints)
}
useEffect(() => {
let cancelled = false
async function load() {
try {
const data = await getCustomEndpoints()
if (cancelled) {
return
}
setEndpoints(data.endpoints)
const current = data.endpoints.find(endpoint => endpoint.is_current) ?? data.endpoints[0]
if (current) {
setForm(formFromEndpoint(current))
setDiscoveredModels(current.models)
}
} catch (err) {
notifyError(err, c.loadFailed)
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
void load()
return () => {
cancelled = true
}
}, [])
async function handleSave() {
try {
setSaving(true)
const response = await saveCustomEndpoint(toPayload(form, discoveredModels))
setEndpoints(response.endpoints)
const saved = response.endpoints.find(endpoint => endpoint.id === response.id)
if (saved) {
setForm(formFromEndpoint(saved))
setDiscoveredModels(saved.models)
}
if (saved && saved.is_current) {
onMainModelChanged?.(saved.id, saved.model)
}
triggerHaptic('success')
onConfigSaved?.()
notify({ kind: 'success', message: c.saved })
} catch (err) {
notifyError(err, c.saveFailed)
} finally {
setSaving(false)
}
}
async function handleValidate() {
try {
setTesting(true)
const response = await validateCustomEndpoint(toPayload(form))
setDiscoveredModels(response.models)
if (response.ok) {
if (!form.model && response.models[0]) {
setForm(current => ({ ...current, model: response.models[0] }))
}
notify({
kind: 'success',
message: response.models.length
? c.found(response.models.length)
: c.reachable
})
} else {
notify({
kind: response.reachable ? 'warning' : 'error',
message: response.message || c.validationFailed
})
}
} catch (err) {
notifyError(err, c.validationFailed)
} finally {
setTesting(false)
}
}
async function handleActivate(endpoint: CustomEndpoint) {
try {
setActivating(endpoint.id)
const response = await activateCustomEndpoint(endpoint.id)
await refresh()
onConfigSaved?.()
onMainModelChanged?.(response.provider, response.model)
triggerHaptic('success')
} catch (err) {
notifyError(err, c.activationFailed)
} finally {
setActivating(null)
}
}
async function handleDelete(endpoint: CustomEndpoint) {
if (!(await confirm({ destructive: true, title: c.confirmDelete(endpoint.name) }))) {
return
}
try {
setDeleting(endpoint.id)
const response = await deleteCustomEndpoint(endpoint.id)
setEndpoints(response.endpoints)
if (form.id === endpoint.id) {
setForm(EMPTY_FORM)
setDiscoveredModels([])
}
onConfigSaved?.()
triggerHaptic('success')
} catch (err) {
notifyError(err, c.deleteFailed)
} finally {
setDeleting(null)
}
}
if (loading) {
return <SettingsSkeleton sections={[{ heading: true, rows: 3 }]} />
}
const allModelOptions = Array.from(new Set([...discoveredModels, form.model].filter(Boolean)))
const canSave = form.name.trim() && form.baseUrl.trim() && form.model.trim()
return (
<SettingsContent>
<div className="space-y-6">
<section>
<SectionHeading icon={Globe} meta={`${endpoints.length}`} title={c.title} />
<div className="divide-y divide-border/40 rounded-md border border-border/50">
{endpoints.length ? (
endpoints.map(endpoint => (
<div className="grid gap-3 p-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center" key={endpoint.id}>
<button
className="min-w-0 text-left"
onClick={() => {
setForm(formFromEndpoint(endpoint))
setDiscoveredModels(endpoint.models)
}}
type="button"
>
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">{endpoint.name}</span>
{endpoint.is_current && (
<Pill tone="primary">
<Check className="size-3" />
{c.active}
</Pill>
)}
{endpoint.source === 'direct-config' && <Pill>config.yaml</Pill>}
</div>
<div className="mt-1 truncate font-mono text-[0.7rem] text-muted-foreground">
{endpoint.base_url}
</div>
<div className="mt-1 flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>{endpoint.model}</span>
{endpoint.has_api_key && <span>{endpoint.api_key_preview ?? c.keySet}</span>}
</div>
</button>
<div className="flex items-center gap-2 sm:justify-end">
<Button
disabled={endpoint.is_current || activating === endpoint.id}
onClick={() => void handleActivate(endpoint)}
size="sm"
variant="outline"
>
{activating === endpoint.id ? <Loader2 className="animate-spin" /> : <Zap />}
{c.use}
</Button>
{endpoint.source !== 'direct-config' && (
<Button
className="hover:text-destructive"
disabled={deleting === endpoint.id}
onClick={() => void handleDelete(endpoint)}
size="icon-sm"
title={c.delete}
variant="ghost"
>
{deleting === endpoint.id ? <Loader2 className="animate-spin" /> : <Trash2 />}
</Button>
)}
</div>
</div>
))
) : (
<EmptyState description={c.emptyDesc} title={c.empty} />
)}
</div>
</section>
<section>
<Button className="mb-3" onClick={() => {
setForm({ ...EMPTY_FORM, name: 'TurkServis · AITURK', baseUrl: AITURK_API_BASE_URL })
setDiscoveredModels([])
}} variant="outline">{c.preset}</Button>
<SectionHeading icon={Plus} title={form.id ? c.edit : c.add} />
<div className="grid gap-3 rounded-md border border-border/50 p-3">
<div className="grid gap-3 sm:grid-cols-2">
<label className="grid gap-1.5 text-xs text-muted-foreground">
{c.name}
<Input
onChange={event => setForm(current => ({ ...current, name: event.target.value }))}
placeholder="TurkServis"
value={form.name}
/>
</label>
<label className="grid gap-1.5 text-xs text-muted-foreground">
{c.providerId}
<Input
onChange={event => setForm(current => ({ ...current, id: event.target.value }))}
placeholder="turkservis"
value={form.id}
/>
</label>
</div>
<label className="grid gap-1.5 text-xs text-muted-foreground">
{c.url}
<Input
onChange={event => setForm(current => ({ ...current, baseUrl: event.target.value }))}
placeholder="http://127.0.0.1:8081/v1"
value={form.baseUrl}
/>
</label>
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_12rem]">
<label className="grid gap-1.5 text-xs text-muted-foreground">
{c.model}
<Input
list="custom-endpoint-models"
onChange={event => setForm(current => ({ ...current, model: event.target.value }))}
placeholder="gpt-5.4"
value={form.model}
/>
<datalist id="custom-endpoint-models">
{allModelOptions.map(model => (
<option key={model} value={model} />
))}
</datalist>
</label>
<label className="grid gap-1.5 text-xs text-muted-foreground">
{c.context}
<Input
inputMode="numeric"
onChange={event => setForm(current => ({ ...current, contextLength: event.target.value }))}
placeholder={c.auto}
value={form.contextLength}
/>
</label>
</div>
<label className="grid gap-1.5 text-xs text-muted-foreground">
{c.apiKey}
<Input
onChange={event => setForm(current => ({ ...current, apiKey: event.target.value }))}
placeholder={form.id ? c.keepKey : c.optional}
type="password"
value={form.apiKey}
/>
</label>
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
<label className="flex items-center gap-2">
<Checkbox
checked={form.makeDefault}
onCheckedChange={checked => setForm(current => ({ ...current, makeDefault: checked === true }))}
/>
{c.newChats}
</label>
<label className="flex items-center gap-2">
<Checkbox
checked={form.discoverModels}
onCheckedChange={checked => setForm(current => ({ ...current, discoverModels: checked === true }))}
/>
{c.discover}
</label>
</div>
<div className="flex flex-wrap gap-2">
<Button
disabled={testing || !form.baseUrl.trim()}
onClick={() => void handleValidate()}
variant="outline"
>
{testing ? <Loader2 className="animate-spin" /> : <Zap />}
{c.test}
</Button>
<Button disabled={saving || !canSave} onClick={() => void handleSave()}>
{saving ? <Loader2 className="animate-spin" /> : <Save />}
{t.common.save}
</Button>
<Button
className={cn(!form.id && 'hidden')}
onClick={() => {
setForm(EMPTY_FORM)
setDiscoveredModels([])
}}
type="button"
variant="ghost"
>
{c.newEndpoint}
</Button>
</div>
</div>
</section>
</div>
</SettingsContent>
)
}
@@ -0,0 +1,206 @@
import { useEffect, useState } from 'react'
import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes'
import { useI18n } from '@/i18n'
import { type IconComponent } from '@/lib/icons'
import { confirm } from '@/store/confirm'
import { notify, notifyError } from '@/store/notifications'
import type { EnvVarInfo } from '@/types/hermes'
import { asText, includesQuery, redactedValue, withoutKey } from './helpers'
import { Pill } from './primitives'
import type { EnvRowProps } from './types'
// Shared filter used by every credential surface (Providers + Keys pages):
// category gate first, then a free-text match across key name + description.
export function filterEnv(info: EnvVarInfo, key: string, q: string, cat: string, extra?: string): boolean {
if (asText(info.category) !== cat) {
return false
}
if (!q) {
return true
}
return (
key.toLowerCase().includes(q) ||
includesQuery(info.description, q) ||
Boolean(extra && extra.toLowerCase().includes(q))
)
}
export function SettingsCategoryHeading({ count, icon: Icon, title }: CategoryHeadingProps) {
return (
<div className="mb-3 flex items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium">
<Icon className="size-4 text-muted-foreground" />
<span>{title}</span>
{count && <Pill>{count}</Pill>}
</div>
)
}
// Owns the env-var fetch + the edit/reveal/save/delete lifecycle so multiple
// credential pages (Providers, Keys) share one source of truth and one set of
// mutation handlers instead of duplicating the plumbing. An optional `profile`
// targets another profile's env store (the shared settings "Applies to"
// scope); undefined keeps the app-wide active profile. Request-shaped on
// purpose: the API helpers treat an explicit `null` as "target the
// primary/default backend", which is never what a settings page means.
export function useEnvCredentials(profile?: string): UseEnvCredentials {
const { t } = useI18n()
const credentials = t.settings.credentials
const toolsets = t.settings.toolsets
const [vars, setVars] = useState<Record<string, EnvVarInfo> | null>(null)
const [edits, setEdits] = useState<Record<string, string>>({})
const [revealed, setRevealed] = useState<Record<string, string>>({})
const [saving, setSaving] = useState<string | null>(null)
// Best-effort cleanup of a retired localStorage flag (global "Show
// advanced" toggle) — everything in these views is configuration-level.
useEffect(() => {
try {
window.localStorage.removeItem('desktop.settings.keys.show_advanced')
} catch {
// Ignore — old key cleanup is best-effort.
}
}, [])
useEffect(() => {
let cancelled = false
setVars(null)
void (async () => {
try {
const next = await getEnvVars(profile)
if (!cancelled) {
setVars(next)
}
} catch (err) {
notifyError(err, t.settings.keys.failedLoad)
}
})()
return () => void (cancelled = true)
// eslint-disable-next-line react-hooks/exhaustive-deps -- reload per target profile; copy is stable
}, [profile])
function patchVar(key: string, patch: Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>) {
setVars(c => (c ? { ...c, [key]: { ...c[key], ...patch } } : c))
}
function clearLocalState(key: string) {
setEdits(c => withoutKey(c, key))
setRevealed(c => withoutKey(c, key))
}
async function handleSave(key: string) {
const value = edits[key]
if (!value) {
return
}
setSaving(key)
try {
await setEnvVar(key, value, profile)
patchVar(key, { is_set: true, redacted_value: redactedValue(value) })
clearLocalState(key)
notify({ kind: 'success', title: toolsets.savedTitle, message: toolsets.savedMessage(key) })
} catch (err) {
notifyError(err, toolsets.failedSave(key))
} finally {
setSaving(null)
}
}
// Direct save for a known value (no edit-state round-trip) — used by the
// onboarding-style key form, which owns its own input. Returns a result so
// the form can surface inline errors instead of only toasting.
async function saveValue(key: string, value: string): Promise<{ message?: string; ok: boolean }> {
const trimmed = value.trim()
if (!trimmed) {
return { message: credentials.enterValueFirst, ok: false }
}
setSaving(key)
try {
await setEnvVar(key, trimmed, profile)
patchVar(key, { is_set: true, redacted_value: redactedValue(trimmed) })
clearLocalState(key)
notify({ kind: 'success', message: toolsets.savedMessage(key), title: toolsets.savedTitle })
return { ok: true }
} catch (err) {
notifyError(err, toolsets.failedSave(key))
return { message: err instanceof Error ? err.message : credentials.couldNotSave, ok: false }
} finally {
setSaving(null)
}
}
async function handleClear(key: string) {
if (!(await confirm({ destructive: true, title: toolsets.removeConfirm(key) }))) {
return
}
setSaving(key)
try {
await deleteEnvVar(key, profile)
patchVar(key, { is_set: false, redacted_value: null })
clearLocalState(key)
notify({ kind: 'success', title: toolsets.removedTitle, message: toolsets.removedMessage(key) })
} catch (err) {
notifyError(err, toolsets.failedRemove(key))
} finally {
setSaving(null)
}
}
async function handleReveal(key: string) {
if (revealed[key]) {
setRevealed(c => withoutKey(c, key))
return
}
try {
const result = await revealEnvVar(key, profile)
setRevealed(c => ({ ...c, [key]: result.value }))
} catch (err) {
notifyError(err, toolsets.failedReveal(key))
}
}
return {
saveValue,
vars,
rowProps: {
edits,
revealed,
saving,
setEdits,
onSave: handleSave,
onClear: handleClear,
onReveal: handleReveal
}
}
}
interface CategoryHeadingProps {
count?: string
icon: IconComponent
title: string
}
interface UseEnvCredentials {
rowProps: Omit<EnvRowProps, 'varKey' | 'info'>
saveValue: (key: string, value: string) => Promise<{ message?: string; ok: boolean }>
vars: Record<string, EnvVarInfo> | null
}
@@ -0,0 +1,179 @@
import type * as React from 'react'
import {
type ActionItemSpec,
ActionsContextMenu,
ActionsMenu,
type MenuKit,
renderActionItem
} from '@/components/ui/actions-menu'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { ExternalLink, Eye, EyeOff, KeyRound, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
interface EnvVarActions {
clearDisabled?: boolean
docsUrl?: string | null
isRevealed?: boolean
isSet: boolean
label: string
onClear?: () => void
onEdit: () => void
/** Internal navigation to Settings → API Keys with this key highlighted.
* Rendered only when provided AND the key is set (an unset key is managed
* right here via Set). */
onManageKeys?: () => void
onReveal?: () => void
showReveal?: boolean
}
// The shared action rows, rendered identically by the kebab dropdown and the
// row's right-click menu so the two never drift.
function useEnvVarItems({
clearDisabled = false,
docsUrl,
isRevealed = false,
isSet,
onClear,
onEdit,
onManageKeys,
onReveal,
showReveal = true
}: EnvVarActions) {
const { t } = useI18n()
const copy = t.settings.envActions
const hasClear = isSet && onClear
const hasReveal = isSet && showReveal && onReveal
const hasManageKeys = isSet && onManageKeys
const hasDocs = Boolean(docsUrl?.trim())
return (kit: MenuKit) => {
const rows: ActionItemSpec[] = []
if (hasDocs) {
rows.push({
iconNode: <ExternalLink className="size-3.5" />,
key: 'docs',
label: copy.docs,
onSelect: event => {
event.preventDefault()
triggerHaptic('selection')
window.open(docsUrl!, '_blank', 'noopener,noreferrer')
}
})
}
if (hasReveal) {
rows.push({
iconNode: isRevealed ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />,
key: 'reveal',
label: isRevealed ? copy.hideValue : copy.revealValue,
onSelect: () => {
triggerHaptic('selection')
onReveal()
}
})
}
rows.push({
icon: 'edit',
key: 'edit',
label: isSet ? copy.replace : copy.set,
onSelect: () => {
triggerHaptic('selection')
onEdit()
}
})
if (hasManageKeys) {
rows.push({
iconNode: <KeyRound className="size-3.5" />,
key: 'manage-keys',
label: copy.manageInKeys,
onSelect: () => {
triggerHaptic('selection')
onManageKeys()
}
})
}
return (
<>
{rows.map(row => renderActionItem(kit, row))}
{hasClear && (
<>
<kit.Separator />
{renderActionItem(kit, {
disabled: clearDisabled,
iconNode: <Trash2 className="size-3.5" />,
key: 'clear',
label: copy.clear,
onSelect: () => {
triggerHaptic('warning')
onClear()
},
variant: 'destructive'
})}
</>
)}
</>
)
}
}
interface EnvVarActionsMenuProps
extends EnvVarActions, Pick<React.ComponentProps<typeof ActionsMenu>, 'align' | 'sideOffset'> {
children: React.ReactNode
}
export function EnvVarActionsMenu({ align = 'end', children, sideOffset = 6, ...actions }: EnvVarActionsMenuProps) {
const { t } = useI18n()
const copy = t.settings.envActions
const items = useEnvVarItems(actions)
return (
<ActionsMenu align={align} ariaLabel={copy.actions} contentClassName="w-44" items={items} sideOffset={sideOffset}>
{children}
</ActionsMenu>
)
}
interface EnvVarContextMenuProps extends EnvVarActions {
children: React.ReactNode
}
/** Wrap an env-var row so right-clicking it opens the same menu as its kebab. */
export function EnvVarContextMenu({ children, ...actions }: EnvVarContextMenuProps) {
const { t } = useI18n()
const copy = t.settings.envActions
const items = useEnvVarItems(actions)
return (
<ActionsContextMenu ariaLabel={copy.actions} contentClassName="w-44" items={items}>
{children}
</ActionsContextMenu>
)
}
export function EnvVarActionsTrigger({
className,
...props
}: Omit<React.ComponentProps<typeof Button>, 'size' | 'variant'>) {
const { t } = useI18n()
const copy = t.settings.envActions
return (
<Button
aria-label={copy.actions}
className={cn('text-muted-foreground hover:text-foreground', className)}
size="icon-sm"
variant="ghost"
{...props}
>
<Codicon name="ellipsis" size="0.875rem" />
</Button>
)
}
@@ -0,0 +1,129 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
// Radix Select calls scrollIntoView / pointer-capture APIs jsdom lacks.
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
Element.prototype.hasPointerCapture = vi.fn(() => false)
Element.prototype.releasePointerCapture = vi.fn()
})
const getGlobalModelOptions = vi.fn()
vi.mock('@/hermes', () => ({
getGlobalModelOptions: () => getGlobalModelOptions()
}))
beforeEach(() => {
getGlobalModelOptions.mockResolvedValue({
providers: [
{ name: 'GitHub Copilot', slug: 'copilot', models: ['gpt-5-mini', 'gpt-5.4-mini'] },
{ name: 'OpenAI Codex', slug: 'openai-codex', models: ['gpt-5.4-mini'] },
{ name: 'Nous', slug: 'nous', models: ['hermes-4'] }
]
})
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
async function renderField(value: unknown, onChange = vi.fn()) {
const { FallbackModelsField } = await import('./fallback-models-field')
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<QueryClientProvider client={client}>
<FallbackModelsField onChange={onChange} value={value} />
</QueryClientProvider>
)
return onChange
}
async function renderFieldWithRerender(value: unknown, onChange = vi.fn()) {
const { FallbackModelsField } = await import('./fallback-models-field')
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const view = render(
<QueryClientProvider client={client}>
<FallbackModelsField onChange={onChange} value={value} />
</QueryClientProvider>
)
return (next: unknown) =>
view.rerender(
<QueryClientProvider client={client}>
<FallbackModelsField onChange={onChange} value={next} />
</QueryClientProvider>
)
}
const CHAIN = [
{ provider: 'copilot', model: 'gpt-5-mini' },
{ provider: 'openai-codex', model: 'gpt-5.4-mini' }
]
describe('FallbackModelsField', () => {
it('renders each {provider, model} entry as its own row (never "[object Object]")', async () => {
await renderField(CHAIN)
// One Remove control per entry proves the object list became rows — the old
// generic `list` input stringified the array to "[object Object]".
expect(screen.getAllByLabelText('Remove')).toHaveLength(2)
expect(screen.getByText('Add fallback')).toBeTruthy()
expect(screen.queryByText(/\[object Object\]/)).toBeNull()
await waitFor(() => expect(getGlobalModelOptions).toHaveBeenCalled())
})
it('removing a row emits the remaining entries', async () => {
const onChange = await renderField(CHAIN)
fireEvent.click(screen.getAllByLabelText('Remove')[0])
expect(onChange.mock.calls.at(-1)?.[0]).toEqual([{ provider: 'openai-codex', model: 'gpt-5.4-mini' }])
})
it('adding a blank row does not persist a partial entry', async () => {
const onChange = await renderField(CHAIN)
fireEvent.click(screen.getByText('Add fallback'))
// The new empty row stays in the UI but only complete pairs are emitted.
expect(onChange.mock.calls.at(-1)?.[0]).toEqual(CHAIN)
expect(screen.getAllByLabelText('Remove')).toHaveLength(3)
})
it('shows an empty-state hint when there are no fallbacks', async () => {
await renderField([])
expect(screen.getByText(/No fallback models/)).toBeTruthy()
expect(screen.queryAllByLabelText('Remove')).toHaveLength(0)
})
it('resyncs rows when persisted config changes', async () => {
const rerender = await renderFieldWithRerender(CHAIN)
expect(screen.getAllByLabelText('Remove')).toHaveLength(2)
rerender([{ provider: 'nous', model: 'hermes-4' }])
await waitFor(() => expect(screen.getAllByLabelText('Remove')).toHaveLength(1))
})
it('keeps a draft row visible after autosave re-renders the same persisted chain', async () => {
const onChange = vi.fn()
const rerender = await renderFieldWithRerender([], onChange)
fireEvent.click(screen.getByText('Add fallback'))
expect(onChange.mock.calls.at(-1)?.[0]).toEqual([])
expect(screen.getAllByLabelText('Remove')).toHaveLength(1)
// Parent autosave echo — same complete chain, new array identity.
rerender([])
await waitFor(() => expect(screen.getAllByLabelText('Remove')).toHaveLength(1))
})
})
@@ -0,0 +1,170 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { getGlobalModelOptions } from '@/hermes'
import { useI18n } from '@/i18n'
import { Plus, X } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { CONTROL_TEXT } from './constants'
interface FallbackEntry {
provider: string
model: string
}
// Normalize the raw config value (`fallback_providers`: a list of
// `{provider, model}` dicts) into editor rows. Defensive against legacy string
// entries ("provider/model") so the editor never crashes on odd data.
function normalizeEntries(value: unknown): FallbackEntry[] {
if (!Array.isArray(value)) {
return []
}
return value.map(item => {
if (item && typeof item === 'object') {
const record = item as Record<string, unknown>
return { provider: String(record.provider ?? ''), model: String(record.model ?? '') }
}
if (typeof item === 'string') {
const slash = item.indexOf('/')
return slash > 0
? { provider: item.slice(0, slash), model: item.slice(slash + 1) }
: { provider: '', model: item }
}
return { provider: '', model: '' }
})
}
function completeEntries(rows: FallbackEntry[]): FallbackEntry[] {
return rows.filter(entry => entry.provider && entry.model)
}
function entriesEqual(a: FallbackEntry[], b: FallbackEntry[]): boolean {
return (
a.length === b.length &&
a.every((entry, index) => entry.provider === b[index]?.provider && entry.model === b[index]?.model)
)
}
/**
* Structured editor for the top-level `fallback_providers` config list — a
* chain of `{provider, model}` pairs tried in order when the default model
* fails. Replaces the generic comma-string `list` input, which stringified the
* objects to "[object Object], [object Object]".
*
* Mirrors the Auxiliary Models picker in `model-settings.tsx`: provider + model
* selects sourced from `getGlobalModelOptions()`. Half-filled rows are kept in
* local state and only complete pairs are emitted upward, so the config
* autosave never persists a partial `{provider, model: ''}`.
*/
export function FallbackModelsField({
value,
onChange
}: {
value: unknown
onChange: (next: FallbackEntry[]) => void
}) {
const { t } = useI18n()
const m = t.settings.model
const modelOptions = useQuery({
queryKey: ['model-options', 'global'],
queryFn: () => getGlobalModelOptions()
})
const providers = (modelOptions.data?.providers ?? []).filter(provider => provider.slug)
const [rows, setRows] = useState<FallbackEntry[]>(() => normalizeEntries(value))
// Last complete chain we emitted (or seeded). Autosave echoes the same
// filtered list back through `value`; ignore that echo so draft rows stay.
const lastEmittedRef = useRef(normalizeEntries(value))
// Resync on real external changes (profile switch / config reload). Skip
// when `value` is just our own commit echoing through the parent.
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
const persisted = normalizeEntries(value)
if (entriesEqual(persisted, lastEmittedRef.current)) {
return
}
lastEmittedRef.current = persisted
setRows(persisted)
}, [value])
const commit = (next: FallbackEntry[]) => {
const complete = completeEntries(next)
setRows(next)
lastEmittedRef.current = complete
onChange(complete)
}
const updateRow = (index: number, patch: Partial<FallbackEntry>) =>
commit(rows.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)))
return (
<div className="grid w-full gap-1.5">
{rows.length === 0 && <p className="text-xs text-muted-foreground">{m.fallbackEmpty}</p>}
{rows.map((entry, index) => {
const providerRow = providers.find(provider => provider.slug === entry.provider)
const catalog = providerRow?.models ?? []
// Keep an out-of-catalog model selectable so an existing custom
// provider/model renders instead of showing a blank box.
const modelItems = entry.model && !catalog.includes(entry.model) ? [entry.model, ...catalog] : catalog
return (
<div className="flex flex-wrap items-center gap-2" key={index}>
<span className="w-4 shrink-0 text-center font-mono text-[0.7rem] text-muted-foreground">{index + 1}</span>
<Select onValueChange={provider => updateRow(index, { provider, model: '' })} value={entry.provider}>
<SelectTrigger className={cn('min-w-36', CONTROL_TEXT)}>
<SelectValue placeholder={m.provider} />
</SelectTrigger>
<SelectContent>
{providers.map(provider => (
<SelectItem key={provider.slug} value={provider.slug}>
{provider.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select onValueChange={model => updateRow(index, { model })} value={entry.model}>
<SelectTrigger className={cn('min-w-52 flex-1', CONTROL_TEXT)}>
<SelectValue placeholder={m.model} />
</SelectTrigger>
<SelectContent>
{modelItems.map(model => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
aria-label={t.common.remove}
onClick={() => commit(rows.filter((_, i) => i !== index))}
size="icon-xs"
variant="ghost"
>
<X className="size-3.5" />
</Button>
</div>
)
})}
<div>
<Button onClick={() => commit([...rows, { provider: '', model: '' }])} size="sm" variant="textStrong">
<Plus className="size-3.5" />
{m.fallbackAdd}
</Button>
</div>
</div>
)
}
@@ -0,0 +1,57 @@
export interface FieldCopyTree {
[key: string]: string | FieldCopyTree
}
function schemaSegmentToFieldCopySegment(segment: string): string {
return segment.replace(/_([a-z0-9])/g, (_, char: string) => char.toUpperCase())
}
function isFieldCopyTree(value: unknown): value is FieldCopyTree {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export function schemaKeyToFieldCopyKey(schemaKey: string): string {
return schemaKey.split('.').map(schemaSegmentToFieldCopySegment).join('.')
}
export function fieldCopyForSchemaKey(copy: Record<string, string>, schemaKey: string): string | undefined {
return copy[schemaKeyToFieldCopyKey(schemaKey)] ?? copy[schemaKey]
}
export function defineFieldCopy(copy: FieldCopyTree): Record<string, string> {
const result: Record<string, string> = {}
const visit = (node: FieldCopyTree, prefix: string[] = []) => {
for (const [key, value] of Object.entries(node)) {
const parts = key.split('.')
if (parts.some(part => part.length === 0)) {
throw new Error(`Invalid field copy key: ${[...prefix, key].join('.')}`)
}
const path = [...prefix, ...parts]
if (typeof value === 'string') {
const flatKey = path.join('.')
if (Object.prototype.hasOwnProperty.call(result, flatKey)) {
throw new Error(`Duplicate field copy key: ${flatKey}`)
}
result[flatKey] = value
continue
}
if (!isFieldCopyTree(value)) {
throw new Error(`Invalid field copy value for key: ${path.join('.')}`)
}
visit(value, path)
}
}
visit(copy)
return result
}
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { normalizeGatewaySettingsState, savedCloudConnectionUrl } from './gateway-settings'
describe('normalizeGatewaySettingsState', () => {
it('fills missing and undefined persisted fields with canonical defaults', () => {
const normalized = normalizeGatewaySettingsState({
mode: 'remote',
remoteAuthMode: undefined,
remoteUrl: 'https://gateway.example'
})
expect(normalized.mode).toBe('remote')
expect(normalized.remoteAuthMode).toBe('token')
expect(normalized.remoteUrl).toBe('https://gateway.example')
expect(normalized.sshHost).toBe('')
expect(normalized.sshPort).toBeNull()
expect(normalized.secureTokenStorage).toBe(true)
})
it('returns an independent default state for invalid persisted data', () => {
const first = normalizeGatewaySettingsState(null)
const second = normalizeGatewaySettingsState(undefined)
expect(first).toEqual(second)
expect(first).not.toBe(second)
})
})
describe('savedCloudConnectionUrl', () => {
it('normalizes the URL of a persisted cloud connection', () => {
expect(savedCloudConnectionUrl({ mode: 'cloud', remoteUrl: ' HTTPS://AGENT.EXAMPLE/ ' })).toBe(
'https://agent.example'
)
})
it('does not treat a stale cloud URL on a local config as connected', () => {
expect(savedCloudConnectionUrl({ mode: 'local', remoteUrl: 'https://agent.example' })).toBe('')
})
it('does not treat a remote gateway URL as a connected cloud agent', () => {
expect(savedCloudConnectionUrl({ mode: 'remote', remoteUrl: 'https://agent.example' })).toBe('')
})
})
@@ -0,0 +1,58 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const getConnectionConfig = vi.fn()
const saveConnectionConfig = vi.fn()
// This test owns the machine-level GatewaySettings contract. The managed SSH
// update section mounted below the registry has its own focused coverage
// (store/managed-updates.test.ts); keep its store subscriptions out of this
// single-purpose test.
vi.mock('./managed-updates-section', () => ({ ManagedUpdatesSection: () => null }))
const localConnection = {
cloudOrg: '',
envOverride: false,
mode: 'local',
remoteAuthMode: 'token',
remoteOauthConnected: false,
remoteTokenPreview: null,
remoteTokenSet: false,
remoteUrl: ''
}
beforeEach(() => {
getConnectionConfig.mockResolvedValue(localConnection)
saveConnectionConfig.mockResolvedValue(localConnection)
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { getConnectionConfig, saveConnectionConfig }
})
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('GatewaySettings', () => {
it('loads the machine-level connection config (no profile scoping)', async () => {
const { GatewaySettings } = await import('./gateway-settings')
render(<GatewaySettings />)
expect(await screen.findByText('Local gateway')).toBeTruthy()
expect(
screen.getByText('Start a private Hermes backend on localhost. This is the default and works offline.')
).toBeTruthy()
// The page manages the machine's gateway connections; it must load the
// global config, never a per-profile override.
await waitFor(() => expect(getConnectionConfig).toHaveBeenCalledWith(null))
expect(getConnectionConfig).not.toHaveBeenCalledWith(expect.any(String))
// The legacy per-profile scope switcher must not render.
expect(screen.queryByText('Applies to')).toBeNull()
expect(screen.queryByText('All profiles')).toBeNull()
expect(screen.queryByText('Use default gateway')).toBeNull()
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,448 @@
import { describe, expect, it } from 'vitest'
import type { HermesConfigRecord } from '@/types/hermes'
import { FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants'
import { defineFieldCopy, fieldCopyForSchemaKey, schemaKeyToFieldCopyKey } from './field-copy'
import {
clearsEnabledToolsets,
diffConfig,
enumOptionsFor,
getNested,
isExternalMemoryProvider,
providerGroup,
sectionFieldEntries,
setNested,
stripToolsetLabel,
toolsetDisplayLabel
} from './helpers'
describe('settings helpers', () => {
it('surfaces repository discovery config in Workspace with user-facing copy', () => {
const workspace = SECTIONS.find(section => section.id === 'workspace')
expect(workspace?.keys).toEqual(
expect.arrayContaining([
'desktop.repo_scan_enabled',
'desktop.repo_scan_roots',
'desktop.repo_scan_exclude_paths'
])
)
expect(fieldCopyForSchemaKey(FIELD_LABELS, 'desktop.repo_scan_enabled')).toBeTruthy()
expect(fieldCopyForSchemaKey(FIELD_DESCRIPTIONS, 'desktop.repo_scan_exclude_paths')).toBeTruthy()
})
it('does not shadow the backend schema options for memory.provider', () => {
// memory.provider options are discovery-driven and served by the backend
// config schema (merged per-request); enumOptionsFor must return undefined
// so config-field consumes schema.options instead of a stale static list.
expect(enumOptionsFor('memory.provider', '', {})).toBeUndefined()
expect(enumOptionsFor('memory.provider', 'honcho', {})).toBeUndefined()
})
describe('isExternalMemoryProvider', () => {
it('treats only real plugin names as external providers', () => {
expect(isExternalMemoryProvider('honcho')).toBe(true)
expect(isExternalMemoryProvider('hindsight')).toBe(true)
})
it('treats built-in aliases and empty values as not external', () => {
for (const value of ['', 'builtin', 'built-in', 'Builtin', 'none', ' ', undefined, null, 7]) {
expect(isExternalMemoryProvider(value)).toBe(false)
}
})
})
describe('defineFieldCopy', () => {
it('flattens nested field copy paths', () => {
const copy = defineFieldCopy({
display: {
personality: 'Personality'
},
stt: {
elevenlabs: {
language_code: 'Language'
}
}
})
expect(copy[['display', 'personality'].join('.')]).toBe('Personality')
expect(copy[['stt', 'elevenlabs', 'language_code'].join('.')]).toBe('Language')
})
it('keeps top-level flat field keys', () => {
expect(
defineFieldCopy({
model_context_length: 'Context Window',
file_read_max_chars: 'File Read Limit'
})
).toEqual({
model_context_length: 'Context Window',
file_read_max_chars: 'File Read Limit'
})
})
it('maps schema keys to camelCase translation keys', () => {
expect(schemaKeyToFieldCopyKey('model_context_length')).toBe('modelContextLength')
expect(schemaKeyToFieldCopyKey('display.show_reasoning')).toBe('display.showReasoning')
expect(schemaKeyToFieldCopyKey('tool_output.max_line_length')).toBe('toolOutput.maxLineLength')
expect(schemaKeyToFieldCopyKey('updates.non_interactive_local_changes')).toBe(
'updates.nonInteractiveLocalChanges'
)
})
it('looks up camelCase field copy by schema key with legacy fallback', () => {
const copy = defineFieldCopy({
display: {
showReasoning: 'Reasoning Blocks'
},
file_read_max_chars: 'Legacy File Read Limit',
modelContextLength: 'Context Window',
toolOutput: {
maxLineLength: 'Line Length Limit'
}
})
expect(fieldCopyForSchemaKey(copy, 'model_context_length')).toBe('Context Window')
expect(fieldCopyForSchemaKey(copy, 'display.show_reasoning')).toBe('Reasoning Blocks')
expect(fieldCopyForSchemaKey(copy, 'tool_output.max_line_length')).toBe('Line Length Limit')
expect(fieldCopyForSchemaKey(copy, 'file_read_max_chars')).toBe('Legacy File Read Limit')
})
it('rejects duplicate flattened paths', () => {
const duplicateKey = ['display', 'personality'].join('.')
expect(() =>
defineFieldCopy({
display: {
personality: 'Personality'
},
[duplicateKey]: 'Duplicate'
})
).toThrow('Duplicate field copy key: display.personality')
})
})
it('reads and writes nested config paths', () => {
const config: HermesConfigRecord = { display: { theme: 'mono' } }
const next = setNested(config, 'display.theme', 'slate')
expect(getNested(next, 'display.theme')).toBe('slate')
expect(getNested(config, 'display.theme')).toBe('mono')
})
it('rejects prototype-polluting config paths', () => {
const config: HermesConfigRecord = {}
expect(() => setNested(config, '__proto__.polluted', true)).toThrow('Unsafe config path')
expect(() => setNested(config, 'constructor.prototype.polluted', true)).toThrow('Unsafe config path')
expect(({} as Record<string, unknown>).polluted).toBeUndefined()
})
describe('stripToolsetLabel', () => {
it('removes leading emoji prefixes from registry labels', () => {
expect(stripToolsetLabel('⏰ Cron Jobs')).toBe('Cron Jobs')
expect(stripToolsetLabel('⚡ Code Execution')).toBe('Code Execution')
expect(stripToolsetLabel('❓ Clarifying Questions')).toBe('Clarifying Questions')
expect(stripToolsetLabel('🌐 Browser Automation')).toBe('Browser Automation')
expect(stripToolsetLabel('🎨 Image Generation')).toBe('Image Generation')
})
it('leaves plain titles unchanged', () => {
expect(stripToolsetLabel('Terminal & Processes')).toBe('Terminal & Processes')
})
})
describe('toolsetDisplayLabel', () => {
it('strips emoji from toolset rows', () => {
expect(toolsetDisplayLabel({ name: 'cronjob', label: '⏰ Cron Jobs' })).toBe('Cron Jobs')
})
})
describe('providerGroup', () => {
it('maps a provider env var to its labeled group', () => {
expect(providerGroup('XAI_API_KEY')).toBe('xAI')
expect(providerGroup('NOUS_API_KEY')).toBe('Nous Portal')
expect(providerGroup('FIREWORKS_API_KEY')).toBe('Fireworks AI')
expect(providerGroup('OPENROUTER_API_KEY')).toBe('OpenRouter')
})
it('prefers the longest matching prefix so CN/regional buckets win', () => {
// MINIMAX_CN_ must beat the generic MINIMAX_ prefix.
expect(providerGroup('MINIMAX_CN_API_KEY')).toBe('MiniMax (China)')
expect(providerGroup('MINIMAX_API_KEY')).toBe('MiniMax')
// KIMI_CN_ likewise must beat KIMI_.
expect(providerGroup('KIMI_CN_API_KEY')).toBe('Kimi (China)')
expect(providerGroup('KIMI_API_KEY')).toBe('Kimi / Moonshot')
// HERMES_QWEN_ shares the HERMES_ stem with other integrations.
expect(providerGroup('HERMES_QWEN_BASE_URL')).toBe('DashScope (Qwen)')
expect(providerGroup('GEMINI_API_KEY')).toBe('Gemini')
})
it('falls back to "Other" for un-grouped env vars', () => {
expect(providerGroup('SOMETHING_RANDOM')).toBe('Other')
})
})
describe('enumOptionsFor — backend selector dropdowns', () => {
const config: HermesConfigRecord = {}
it('renders a dropdown for the TTS provider including xAI (Grok)', () => {
const opts = enumOptionsFor('tts.provider', 'edge', config)
expect(opts).toBeDefined()
expect(opts).toContain('xai')
expect(opts).toContain('edge')
expect(opts).toContain('elevenlabs')
})
it('renders a dropdown for the STT provider including xAI (Grok)', () => {
const opts = enumOptionsFor('stt.provider', 'local', config)
expect(opts).toEqual(['local', 'groq', 'openai', 'mistral', 'xai', 'elevenlabs'])
})
it('renders dropdowns for per-backend model/device sub-fields', () => {
expect(enumOptionsFor('stt.openai.model', 'whisper-1', config)).toContain('gpt-4o-transcribe')
expect(enumOptionsFor('tts.openai.model', 'gpt-4o-mini-tts', config)).toContain('tts-1-hd')
expect(enumOptionsFor('tts.neutts.device', 'cpu', config)).toEqual(['cpu', 'cuda', 'mps'])
})
it('renders a dropdown for the terminal execution backend', () => {
const opts = enumOptionsFor('terminal.backend', 'local', config)
expect(opts).toEqual(['local', 'docker', 'singularity', 'modal', 'daytona', 'ssh'])
})
it('narrows OpenAI TTS voice suggestions to what the selected model supports', () => {
// gpt-4o-mini-tts (and unset/unknown models): full 13-voice set.
const full = enumOptionsFor('tts.openai.voice', 'alloy', { tts: { openai: { model: 'gpt-4o-mini-tts' } } })
expect(full).toContain('marin')
expect(full).toContain('cedar')
expect(full).toContain('ballad')
expect(full).toContain('verse')
expect(full).toHaveLength(13)
// tts-1 / tts-1-hd: the 9-voice set — no ballad/verse/marin/cedar.
for (const model of ['tts-1', 'tts-1-hd']) {
const narrowed = enumOptionsFor('tts.openai.voice', 'alloy', { tts: { openai: { model } } })
expect(narrowed).toEqual(['alloy', 'ash', 'coral', 'echo', 'fable', 'nova', 'onyx', 'sage', 'shimmer'])
}
// A hand-typed custom voice still stays selectable on tts-1.
const custom = enumOptionsFor('tts.openai.voice', 'my-cloned-voice', { tts: { openai: { model: 'tts-1' } } })
expect(custom).toContain('my-cloned-voice')
})
it('appends a hand-typed value not in the known list so it stays selected', () => {
const opts = enumOptionsFor('tts.provider', 'my-custom-command-tts', config)
expect(opts).toContain('my-custom-command-tts')
expect(opts).toContain('xai')
})
it('surfaces user-defined command-type TTS providers (canonical providers nesting + legacy)', () => {
const withCustom: HermesConfigRecord = {
tts: {
provider: 'neutts',
// canonical location the runtime resolves first: tts.providers.<name>
providers: {
higgs8: { type: 'command', command: 'curl …' },
indextts2: { type: 'command', command: 'curl …' },
// `type:` is optional at runtime — a bare command block still qualifies
typeless: { command: 'curl …' },
// misconfigured: type:command but no command → NOT a runtime provider
noop: { type: 'command' }
},
// back-compat: a top-level tts.<name> command block still resolves at runtime
mylegacy: { type: 'command', command: 'curl …' },
// a non-command block (built-in config) must NOT be offered as a provider
edge: { voice: 'en-US-JennyNeural' }
}
}
const opts = enumOptionsFor('tts.provider', 'neutts', withCustom)
expect(opts).toContain('higgs8') // canonical providers.<name>
expect(opts).toContain('indextts2') // canonical providers.<name>
expect(opts).toContain('typeless') // command block with no type: still surfaced
expect(opts).toContain('mylegacy') // legacy top-level tts.<name>
expect(opts).toContain('elevenlabs') // built-ins preserved
expect(opts).not.toContain('noop') // type:command with no command is excluded
// 'edge' appears once (the built-in), not duplicated by the config block
expect(opts!.filter(o => o === 'edge')).toHaveLength(1)
// the 'providers' container itself is never offered as a provider name
expect(opts).not.toContain('providers')
})
it('surfaces command-type STT providers too (canonical providers nesting)', () => {
const withCustom: HermesConfigRecord = {
stt: {
provider: 'local',
providers: { myasr: { type: 'command', command: 'curl …' } }
}
}
const opts = enumOptionsFor('stt.provider', 'local', withCustom)
expect(opts).toContain('myasr')
expect(opts).toContain('local')
expect(opts).not.toContain('providers')
})
// The runtime rejects a built-in name as a command provider before any config
// lookup, so such a block must never be offered — including the names the
// display list omits (`deepinfra` for TTS; `deepinfra`/`local_command` for
// STT), where filtering on ENUM_OPTIONS instead of the runtime's built-in set
// would wrongly offer a provider that can never dispatch.
it('never offers a built-in name as a command provider, even one absent from the dropdown list', () => {
const shadowing: HermesConfigRecord = {
tts: {
provider: 'edge',
providers: {
// built-in and absent from ENUM_OPTIONS['tts.provider']
deepinfra: { type: 'command', command: 'curl …' },
// built-in guard is case-insensitive at runtime (provider.lower())
EDGE: { type: 'command', command: 'curl …' },
// a genuine custom provider alongside them still surfaces
higgs8: { type: 'command', command: 'curl …' }
}
}
}
const opts = enumOptionsFor('tts.provider', 'edge', shadowing)
expect(opts).not.toContain('deepinfra')
expect(opts).not.toContain('EDGE')
expect(opts).toContain('higgs8')
expect(opts!.filter(o => o === 'edge')).toHaveLength(1)
})
it('never offers a built-in STT name absent from the dropdown list as a command provider', () => {
const shadowing: HermesConfigRecord = {
stt: {
provider: 'local',
providers: {
// both are built-in STT names omitted from ENUM_OPTIONS['stt.provider']
local_command: { type: 'command', command: 'curl …' },
deepinfra: { type: 'command', command: 'curl …' },
myasr: { type: 'command', command: 'curl …' }
}
}
}
const opts = enumOptionsFor('stt.provider', 'local', shadowing)
expect(opts).not.toContain('local_command')
expect(opts).not.toContain('deepinfra')
expect(opts).toContain('myasr')
})
})
describe('sectionFieldEntries', () => {
it('renders memory.provider from config even when the backend schema omits it', () => {
const schema = { 'memory.memory_enabled': { type: 'boolean' as const } }
const config: HermesConfigRecord = { memory: { memory_enabled: true, provider: '' } }
const memoryKeys = (sectionFieldEntries(schema, config).get('memory') ?? []).map(([key]) => key)
expect(memoryKeys).toContain('memory.provider')
})
it('infers the field type from the config value when the schema omits the key', () => {
const config: HermesConfigRecord = { memory: { provider: '', memory_enabled: true, memory_char_limit: 2200 } }
const fields = new Map(sectionFieldEntries({}, config).get('memory') ?? [])
expect(fields.get('memory.provider')?.type).toBe('string')
expect(fields.get('memory.memory_enabled')?.type).toBe('boolean')
expect(fields.get('memory.memory_char_limit')?.type).toBe('number')
})
it('prefers the backend schema entry over inference when both exist', () => {
const schema = { 'memory.provider': { type: 'select' as const, options: ['honcho'] } }
const config: HermesConfigRecord = { memory: { provider: 'honcho' } }
const field = new Map(sectionFieldEntries(schema, config).get('memory') ?? []).get('memory.provider')
expect(field?.type).toBe('select')
expect(field?.options).toEqual(['honcho'])
})
it('hides declared keys absent from both schema and config', () => {
expect(sectionFieldEntries({}, {}).get('memory') ?? []).toHaveLength(0)
})
})
describe('clearsEnabledToolsets', () => {
it('flags a non-empty → empty transition', () => {
const prev: HermesConfigRecord = { toolsets: ['memory', 'terminal', 'web_search'] }
const next: HermesConfigRecord = { toolsets: [] }
expect(clearsEnabledToolsets(prev, next)).toBe(true)
})
it('does not flag a non-empty → missing transition (deep-merge preserves the key)', () => {
// PUT /api/config deep-merges the override onto the stored config, so an
// import that omits `toolsets` keeps the existing list — no wipe happens,
// so there is nothing to confirm.
const prev: HermesConfigRecord = { toolsets: ['memory'] }
const next: HermesConfigRecord = {}
expect(clearsEnabledToolsets(prev, next)).toBe(false)
})
it('does not flag when at least one toolset remains', () => {
const prev: HermesConfigRecord = { toolsets: ['memory', 'terminal'] }
const next: HermesConfigRecord = { toolsets: ['memory'] }
expect(clearsEnabledToolsets(prev, next)).toBe(false)
})
it('does not flag when the list was already empty', () => {
const prev: HermesConfigRecord = { toolsets: [] }
const next: HermesConfigRecord = { toolsets: [] }
expect(clearsEnabledToolsets(prev, next)).toBe(false)
})
it('does not flag an unrelated edit that never touched toolsets', () => {
const prev: HermesConfigRecord = { model: 'a', toolsets: ['memory'] }
const next: HermesConfigRecord = { model: 'b', toolsets: ['memory'] }
expect(clearsEnabledToolsets(prev, next)).toBe(false)
})
})
describe('diffConfig', () => {
it('omits a top-level key the draft never touched', () => {
// The autosave baseline is a snapshot taken when Settings opened. A key
// an agent set via `hermes config set` while the page sat open must not
// come back in the patch just because it's still present in the draft.
const baseline: HermesConfigRecord = { fallback_providers: ['nara1'], timezone: 'UTC' }
const draft: HermesConfigRecord = { fallback_providers: ['nara1'], timezone: 'America/New_York' }
expect(diffConfig(baseline, draft)).toEqual({ timezone: 'America/New_York' })
})
it('includes a nested key only when it actually changed, leaving siblings out', () => {
const baseline: HermesConfigRecord = { display: { personality: 'default', show_reasoning: true } }
const draft: HermesConfigRecord = { display: { personality: 'default', show_reasoning: false } }
expect(diffConfig(baseline, draft)).toEqual({ display: { show_reasoning: false } })
})
it('sends a new key that was absent from the baseline', () => {
const baseline: HermesConfigRecord = {}
const draft: HermesConfigRecord = { timezone: 'UTC' }
expect(diffConfig(baseline, draft)).toEqual({ timezone: 'UTC' })
})
it('returns an empty object when the draft matches the baseline exactly', () => {
const baseline: HermesConfigRecord = { toolsets: ['memory'], display: { personality: 'default' } }
const draft: HermesConfigRecord = { toolsets: ['memory'], display: { personality: 'default' } }
expect(diffConfig(baseline, draft)).toEqual({})
})
it('treats an array as a whole value, not diffed element by element', () => {
const baseline: HermesConfigRecord = { toolsets: ['memory', 'terminal'] }
const draft: HermesConfigRecord = { toolsets: ['memory'] }
expect(diffConfig(baseline, draft)).toEqual({ toolsets: ['memory'] })
})
})
})
+399
View File
@@ -0,0 +1,399 @@
import { asText, normalize } from '@/lib/text'
import type { ConfigFieldSchema, HermesConfigRecord, ToolsetInfo } from '@/types/hermes'
import { BUILTIN_PERSONALITIES, ENUM_OPTIONS, PROVIDER_GROUPS, SECTIONS } from './constants'
// Canonical implementations live in @/lib/text; re-exported here so the many
// settings/capabilities call sites keep their import path.
export { asText, includesQuery, prettyName } from '@/lib/text'
/** Strip leading emoji from toolset titles (CLI registry prefixes labels with icons). */
export const stripToolsetLabel = (label: string): string =>
label.replace(/^[\p{Emoji}\p{Extended_Pictographic}\s]+/u, '').trim() || label
export const toolsetDisplayLabel = (toolset: Pick<ToolsetInfo, 'label' | 'name'>): string =>
stripToolsetLabel(asText(toolset.label || toolset.name))
export const toolNames = (t: ToolsetInfo) => (Array.isArray(t.tools) ? t.tools.map(asText).filter(Boolean) : [])
export const withoutKey = <T>(record: Record<string, T>, key: string) => {
const next = { ...record }
delete next[key]
return next
}
export const redactedValue = (v: string) => (v.length <= 8 ? '••••' : `${v.slice(0, 4)}...${v.slice(-4)}`)
// Longest-prefix match so a more specific group like ``MINIMAX_CN_`` is
// chosen over its shorter parent ``MINIMAX_``. Falls back to the bucket
// "Other" used by the Keys settings view for un-grouped env vars.
export const providerGroup = (key: string) => {
let best: (typeof PROVIDER_GROUPS)[number] | undefined
for (const candidate of PROVIDER_GROUPS) {
if (!key.startsWith(candidate.prefix)) {
continue
}
if (!best || candidate.prefix.length > best.prefix.length) {
best = candidate
}
}
return best?.name ?? 'Other'
}
export const providerMeta = (name: string) =>
PROVIDER_GROUPS.find(g => g.name === name && (g.description || g.docsUrl)) ??
PROVIDER_GROUPS.find(g => g.name === name)
export const providerPriority = (name: string) => providerMeta(name)?.priority ?? 99
const POLLUTING_PATH_PARTS = new Set(['__proto__', 'constructor', 'prototype'])
function isSafePart(part: string): boolean {
return part.length > 0 && !POLLUTING_PATH_PARTS.has(part)
}
function configPathParts(path: string): string[] {
const parts = path.split('.')
if (!parts.every(isSafePart)) {
throw new Error(`Unsafe config path: ${path}`)
}
return parts
}
function safeSet(target: Record<string, unknown>, key: string, value: unknown): void {
if (key === '__proto__' || key === 'constructor' || key === 'prototype' || !key) {
throw new Error(`Unsafe config key: ${key}`)
}
Object.defineProperty(target, key, {
value,
writable: true,
enumerable: true,
configurable: true
})
}
export function getNested(obj: HermesConfigRecord, path: string): unknown {
let cur: unknown = obj
for (const part of configPathParts(path)) {
if (cur == null || typeof cur !== 'object') {
return undefined
}
if (!Object.prototype.hasOwnProperty.call(cur, part)) {
return undefined
}
cur = (cur as Record<string, unknown>)[part]
}
return cur
}
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
/**
* Structural diff between two config snapshots: an object holding only the
* branches of `next` that changed relative to `base`. Plain-object values are
* compared key by key so editing one field doesn't drag its untouched
* siblings back into the result; arrays and scalars are compared as whole
* values.
*
* The autosave path sends this instead of the full draft so a field the user
* never touched — one an agent may have changed via `hermes config set`
* while Settings was open with a stale snapshot — is never resent with its
* now-stale value. `PUT /api/config` deep-merges onto disk, so an omitted
* key keeps whatever is currently there.
*/
export function diffConfig(base: HermesConfigRecord, next: HermesConfigRecord): HermesConfigRecord {
const patch: HermesConfigRecord = {}
for (const key of Object.keys(next)) {
const baseValue = base[key]
const nextValue = next[key]
if (isPlainObject(baseValue) && isPlainObject(nextValue)) {
const nested = diffConfig(baseValue, nextValue)
if (Object.keys(nested).length > 0) {
patch[key] = nested
}
} else if (JSON.stringify(baseValue) !== JSON.stringify(nextValue)) {
patch[key] = nextValue
}
}
return patch
}
/**
* True when an edit clears the entire "Enabled Toolsets" list — i.e. the
* previous config had a non-empty toolsets array and the next one is an
* explicit empty array.
*
* A *missing* toolsets key is deliberately NOT a clear: `PUT /api/config`
* deep-merges the override onto the stored config (`_deep_merge` preserves base
* keys absent from the override), so an import that omits `toolsets` leaves the
* existing toolsets intact. Prompting there would warn about a wipe that never
* happens. Only an explicit `[]` actually empties the list.
*
* Clearing every toolset silently disables memory, terminal, web search,
* delegation, and most tools, and config auto-saves with no undo, so callers
* use this to confirm the destructive transition before applying it. Any edit
* that keeps at least one toolset — or that never had one — returns false.
*/
export function clearsEnabledToolsets(prev: HermesConfigRecord, next: HermesConfigRecord): boolean {
const prevToolsets = getNested(prev, 'toolsets')
const nextToolsets = getNested(next, 'toolsets')
const hadToolsets = Array.isArray(prevToolsets) && prevToolsets.length > 0
const clearsToolsets = Array.isArray(nextToolsets) && nextToolsets.length === 0
return hadToolsets && clearsToolsets
}
// Voice renders only fields for the selected TTS/STT provider. Search and the
// page share this rule so every indexed field can actually mount when opened.
export function voiceFieldVisible(key: string, config: HermesConfigRecord): boolean {
const match = /^(tts|stt)\.([^.]+)\./.exec(key)
if (!match) {
return true
}
const [, domain, provider] = match
if (domain === 'stt' && !getNested(config, 'stt.enabled')) {
return false
}
return provider === String(getNested(config, `${domain}.provider`) ?? '')
}
export function inferFieldSchema(value: unknown): ConfigFieldSchema {
if (typeof value === 'boolean') {
return { type: 'boolean' }
}
if (typeof value === 'number') {
return { type: 'number' }
}
if (Array.isArray(value)) {
return { type: 'list' }
}
return { type: 'string' }
}
// Backend schema omits some declared keys; config presence is the availability signal.
export function sectionFieldEntries(
schema: Record<string, ConfigFieldSchema>,
config: HermesConfigRecord
): Map<string, [string, ConfigFieldSchema][]> {
return new Map(
SECTIONS.map(s => [
s.id,
s.keys.flatMap(k => {
const value = getNested(config, k)
const field = schema[k] ?? (value === undefined ? undefined : inferFieldSchema(value))
return field ? [[k, field] as [string, ConfigFieldSchema]] : []
})
])
)
}
export function setNested(obj: HermesConfigRecord, path: string, value: unknown): HermesConfigRecord {
const clone = structuredClone(obj)
const parts = configPathParts(path)
let cur: Record<string, unknown> = clone
for (let i = 0; i < parts.length - 1; i += 1) {
const part = parts[i]
if (!isSafePart(part)) {
throw new Error(`Unsafe config path part: ${part}`)
}
const existing = Object.prototype.hasOwnProperty.call(cur, part) ? cur[part] : undefined
if (existing == null || typeof existing !== 'object') {
safeSet(cur, part, {})
}
cur = cur[part] as Record<string, unknown>
}
safeSet(cur, parts[parts.length - 1], value)
return clone
}
function personalityOptions(config: HermesConfigRecord): string[] {
const custom = getNested(config, 'agent.personalities')
const customNames =
custom && typeof custom === 'object' && !Array.isArray(custom) ? Object.keys(custom as Record<string, unknown>) : []
return [...new Set(['', ...BUILTIN_PERSONALITIES, ...customNames])]
}
// Built-in provider names, mirroring `tts_tool.py:BUILTIN_TTS_PROVIDERS` and
// `transcription_tools.py:BUILTIN_STT_PROVIDERS`. The runtime rejects a built-in
// name as a command provider before any config lookup
// (`_resolve_command_provider_config`: `key = provider.lower().strip()`, then
// `if key in BUILTIN_*_PROVIDERS: return None`), so a ``providers.edge`` block
// declaring ``type: command`` still dispatches to native Edge.
//
// These are deliberately NOT derived from `ENUM_OPTIONS`, which is a *display*
// list and already drifts from the runtime sets: it omits `deepinfra` (TTS) and
// `deepinfra`/`local_command` (STT). Filtering on the display list would offer
// those names as command providers that the runtime would never honour.
const BUILTIN_TTS_PROVIDERS = new Set([
'edge',
'elevenlabs',
'openai',
'minimax',
'xai',
'mistral',
'gemini',
'neutts',
'kittentts',
'piper',
'deepinfra'
])
const BUILTIN_STT_PROVIDERS = new Set([
'local',
'local_command',
'groq',
'openai',
'mistral',
'xai',
'elevenlabs',
'deepinfra'
])
// A user-declared command provider, mirroring the runtime discriminator
// (`tts_tool.py:_is_command_provider_config` / `transcription_tools.py`): `type`
// is OPTIONAL and case/space-insensitive (absent or normalizing to "command"),
// and `command` MUST be a non-empty string. So a canonical block written as just
// ``{ command: "curl …" }`` with no ``type:`` — a fully valid runtime provider
// under ``providers.*`` — qualifies too, while built-in blocks (which carry
// ``voice``/``model`` and no ``command``) and the ``providers`` container itself
// (no ``command``) are skipped.
function isCommandProvider(value: unknown): boolean {
if (value == null || typeof value !== 'object' || Array.isArray(value)) {
return false
}
const record = value as Record<string, unknown>
const type = normalize(record.type)
if (type !== '' && type !== 'command') {
return false
}
return typeof record.command === 'string' && record.command.trim() !== ''
}
// Names of user-defined command providers, so the settings dropdown can offer
// them alongside the built-ins instead of only whichever one is currently active
// (otherwise, once you switch away from a custom provider it drops off the list
// and can only be reselected by hand-editing config.yaml).
//
// Mirrors the runtime's dual resolution (`tts_tool.py:_get_named_provider_config`,
// `transcription_tools.py`): the CANONICAL location is nested —
// ``tts.providers.<name>`` / ``stt.providers.<name>`` — with a back-compat
// fallback to a top-level ``tts.<name>`` / ``stt.<name>`` block. We enumerate
// both (deduped), keeping only sections that satisfy isCommandProvider and whose
// name the runtime would actually resolve as a command provider — built-ins are
// excluded case-insensitively, matching the runtime's `provider.lower().strip()`
// guard, so a ``providers.EDGE`` command block is not offered.
function commandProviderNames(config: HermesConfigRecord, section: 'tts' | 'stt'): string[] {
const builtins = section === 'tts' ? BUILTIN_TTS_PROVIDERS : BUILTIN_STT_PROVIDERS
const names = new Set<string>()
for (const path of [`${section}.providers`, section]) {
const block = getNested(config, path)
if (!block || typeof block !== 'object' || Array.isArray(block)) {
continue
}
for (const [name, value] of Object.entries(block as Record<string, unknown>)) {
if (isCommandProvider(value) && !builtins.has(normalize(name))) {
names.add(name)
}
}
}
return [...names]
}
// Voice sets per OpenAI speech model, per the OpenAI TTS API docs: tts-1 and
// tts-1-hd support 9 voices; gpt-4o-mini-tts supports those plus ballad,
// verse, marin, and cedar (13 total). Unknown/future models get the full
// union (the field is free-input anyway — this only narrows suggestions).
const OPENAI_TTS1_VOICES = new Set(['alloy', 'ash', 'coral', 'echo', 'fable', 'nova', 'onyx', 'sage', 'shimmer'])
export function enumOptionsFor(
key: string,
value: unknown,
config: HermesConfigRecord,
dynamicOptions?: string[]
): string[] | undefined {
let opts = dynamicOptions ?? (key === 'display.personality' ? personalityOptions(config) : ENUM_OPTIONS[key])
// Merge in user-defined command-type providers so custom local TTS/STT
// backends declared in config.yaml are selectable, not just the built-ins.
// The `includes` guard keeps the list duplicate-free should the display list
// ever carry a name we also enumerate.
if (!dynamicOptions && opts && (key === 'tts.provider' || key === 'stt.provider')) {
const section = key.slice(0, 3) as 'tts' | 'stt'
const custom = commandProviderNames(config, section).filter(name => !opts!.includes(name))
if (custom.length > 0) {
opts = [...opts, ...custom]
}
}
// Narrow OpenAI voice suggestions to what the selected model actually
// accepts — offering `marin` against tts-1 would 400 at the API.
if (!dynamicOptions && opts && key === 'tts.openai.voice') {
const model = asText(getNested(config, 'tts.openai.model'))
if (model === 'tts-1' || model === 'tts-1-hd') {
opts = opts.filter(voice => OPENAI_TTS1_VOICES.has(voice))
}
}
if (!opts) {
return undefined
}
const current = asText(value)
return current && !opts.includes(current) ? [...opts, current] : opts
}
// Built-in memory (MEMORY.md/USER.md) is controlled by memory_enabled, not
// memory.provider — only a real external plugin name gets provider-shaped
// affordances (config panel, OAuth connect). See #49513.
export function isExternalMemoryProvider(value: unknown): value is string {
if (typeof value !== 'string') {
return false
}
const normalized = value.trim().toLowerCase()
return normalized !== '' && normalized !== 'builtin' && normalized !== 'built-in' && normalized !== 'none'
}
+439
View File
@@ -0,0 +1,439 @@
import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useLocation, useNavigate } from 'react-router'
import { codiconIcon } from '@/components/ui/codicon'
import { KbdCombo } from '@/components/ui/kbd'
import { Tip } from '@/components/ui/tooltip'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import {
Archive,
BarChart3,
Bell,
Cpu,
Download,
Globe,
Info,
Keyboard,
KeyRound,
Package,
RefreshCw,
Search,
Settings2,
Upload,
Wrench,
Zap
} from '@/lib/icons'
import { isEditableTarget } from '@/lib/keybinds/combo'
import { typeToFocusChar } from '@/lib/keybinds/composer-focus-keys'
import { cn } from '@/lib/utils'
import { $commandPaletteOpen, openCommandPalettePage } from '@/store/command-palette'
import { confirm } from '@/store/confirm'
import { bindingsFor } from '@/store/keybinds'
import { $localModelsEnabled } from '@/store/local-models-flag'
import { notifyError } from '@/store/notifications'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { OverlayIconButton } from '../overlays/overlay-chrome'
import { OverlayMain, OverlayNav, type OverlayNavGroup, OverlaySplitLayout } from '../overlays/overlay-split-layout'
import { OverlayView } from '../overlays/overlay-view'
import { SKILLS_ROUTE } from '../routes'
import { AboutSettings } from './about-settings'
import { AppearanceSettings } from './appearance-settings'
import { BillingSettings } from './billing'
import { ConfigSettings } from './config-settings'
import { SECTIONS } from './constants'
import { GatewaySettings } from './gateway-settings'
import { KeybindSettings } from './keybind-settings'
import { KEYS_VIEWS, KeysSettings, type KeysView } from './keys-settings'
import { NotificationsSettings } from './notifications-settings'
import { PluginsSettings } from './plugins-settings'
import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings'
import { SessionsSettings } from './sessions-settings'
import type { SettingsPageProps, SettingsView as SettingsViewId } from './types'
const SETTINGS_VIEWS: readonly SettingsViewId[] = [
...SECTIONS.map(s => `config:${s.id}` as SettingsViewId),
'providers',
'gateway',
// Legacy alias: the Connections page merged into Gateways. Kept in the enum
// so saved `?tab=connections` deep links still resolve (redirected below).
'connections',
'keybinds',
'keys',
'notifications',
'billing',
'plugins',
'sessions',
'about'
]
export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
const { t } = useI18n()
const navigate = useNavigate()
const { hash, pathname, search } = useLocation()
// MCP moved out of Settings into Capabilities (/skills?tab=mcp). Keep old
// `/settings?tab=mcp` deep links working — `useRouteEnumParam` would silently
// coerce the unknown tab to the default view otherwise. Preserve `server=` so
// an old bookmark still lands on (and highlights) the selected server.
useEffect(() => {
const params = new URLSearchParams(search)
if (params.get('tab') === 'mcp') {
const server = params.get('server')
const suffix = server ? `&server=${encodeURIComponent(server)}` : ''
navigate(`${SKILLS_ROUTE}?tab=mcp${suffix}`, { replace: true })
}
}, [navigate, search])
const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId)
// Connections merged into the unified Gateways page: land old
// `?tab=connections` routes/bookmarks there instead of a dead entry.
useEffect(() => {
if (activeView === 'connections') {
setActiveView('gateway')
}
}, [activeView, setActiveView])
// Providers subnav (Accounts vs API keys) lives in its own param so each
// sub-view is deep-linkable and survives a refresh.
const [providerView, setProviderView] = useRouteEnumParam<ProviderView>('pview', PROVIDER_VIEWS, 'accounts')
const [keysView] = useRouteEnumParam<KeysView>('kview', KEYS_VIEWS, 'tools')
// Jump to a section + its sub-view in one navigate. Two sequential setters
// would each read the same stale `search` and the second would clobber the
// first's `tab` — so the sub-view never opened on narrow screens.
const openSubView = useCallback(
(tab: SettingsViewId, param: string, value: string, fallback: string) => {
const params = new URLSearchParams(search)
params.set('tab', tab)
if (value === fallback) {
params.delete(param)
} else {
params.set(param, value)
}
const qs = params.toString()
navigate({ hash, pathname, search: qs ? `?${qs}` : '' }, { replace: true })
},
[hash, navigate, pathname, search]
)
const openProviderView = useCallback(
(view: ProviderView) => openSubView('providers', 'pview', view, 'accounts'),
[openSubView]
)
const openKeysView = useCallback((view: KeysView) => openSubView('keys', 'kview', view, 'tools'), [openSubView])
const importInputRef = useRef<HTMLInputElement | null>(null)
const exportConfig = async () => {
try {
const cfg = await getHermesConfigRecord()
const blob = new Blob([JSON.stringify(cfg, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'hermes-config.json'
a.click()
URL.revokeObjectURL(url)
triggerHaptic('success')
} catch (err) {
notifyError(err, t.settings.exportFailed)
}
}
const resetConfig = async () => {
const ok = await confirm({
confirmLabel: t.settings.resetToDefaults,
destructive: true,
title: t.settings.resetConfirm
})
if (!ok) {
return
}
try {
await saveHermesConfig(await getHermesConfigDefaults())
triggerHaptic('success')
onConfigSaved?.()
} catch (err) {
notifyError(err, t.settings.resetFailed)
}
}
const navGroups: OverlayNavGroup[] = useMemo(
() => [
...SECTIONS.map(s => {
const view = `config:${s.id}` as SettingsViewId
return {
active: activeView === view,
icon: s.icon,
id: view,
label: t.settings.sections[s.id] ?? s.label,
onSelect: () => setActiveView(view)
}
}),
{
active: activeView === 'notifications',
icon: Bell,
id: 'notifications',
label: t.settings.nav.notifications,
onSelect: () => setActiveView('notifications')
},
{
active: activeView === 'billing',
icon: BarChart3,
id: 'billing',
label: t.settings.nav.billing,
onSelect: () => setActiveView('billing')
},
{
active: activeView === 'providers',
children: [
{
active: activeView === 'providers' && providerView === 'accounts',
icon: codiconIcon('account'),
id: 'pview:accounts',
label: t.settings.nav.providerAccounts,
onSelect: () => openProviderView('accounts')
},
{
active: activeView === 'providers' && providerView === 'keys',
icon: KeyRound,
id: 'pview:keys',
label: t.settings.nav.providerApiKeys,
onSelect: () => openProviderView('keys')
},
{
active: activeView === 'providers' && providerView === 'custom-endpoints',
icon: Globe,
id: 'pview:custom-endpoints',
label: t.settings.nav.providerCustomEndpoints,
onSelect: () => openProviderView('custom-endpoints')
},
// Local models ships behind the --local launch flag: no flag, no
// nav entry (the pane itself also refuses to render, so a stale
// ?pview=local deep link falls back to accounts-shaped emptiness
// rather than a hidden feature).
...($localModelsEnabled.get()
? [
{
active: activeView === 'providers' && providerView === 'local',
icon: Cpu,
id: 'pview:local',
label: t.settings.nav.providerLocalModels,
onSelect: () => openProviderView('local')
}
]
: [])
],
gapBefore: true,
icon: Zap,
id: 'providers',
label: t.settings.nav.providers,
onSelect: () => setActiveView('providers')
},
{
active: activeView === 'gateway',
icon: Globe,
id: 'gateway',
label: t.settings.nav.gateway,
onSelect: () => setActiveView('gateway')
},
{
active: activeView === 'keybinds',
icon: Keyboard,
id: 'keybinds',
label: t.settings.nav.keybinds,
onSelect: () => setActiveView('keybinds')
},
{
active: activeView === 'keys',
children: [
{
active: activeView === 'keys' && keysView === 'tools',
icon: Wrench,
id: 'kview:tools',
label: t.settings.nav.keysTools,
onSelect: () => openKeysView('tools')
},
{
active: activeView === 'keys' && keysView === 'settings',
icon: Settings2,
id: 'kview:settings',
label: t.settings.nav.keysSettings,
onSelect: () => openKeysView('settings')
}
],
icon: KeyRound,
id: 'keys',
label: t.settings.nav.apiKeys,
onSelect: () => setActiveView('keys')
},
{
active: activeView === 'plugins',
icon: Package,
id: 'plugins',
label: t.settings.nav.plugins,
onSelect: () => setActiveView('plugins')
},
{
active: activeView === 'sessions',
icon: Archive,
id: 'sessions',
label: t.settings.nav.archivedChats,
onSelect: () => setActiveView('sessions')
},
{
active: activeView === 'about',
gapBefore: true,
icon: Info,
id: 'about',
label: t.settings.nav.about,
onSelect: () => setActiveView('about')
}
],
[activeView, keysView, providerView, t, setActiveView, openProviderView, openKeysView]
)
// Type-to-search: printable keystrokes on the Settings surface (outside any
// field) open the settings-scoped palette, seeded with the character — same
// reflex as the chat surface's type-to-focus, pointed at search instead.
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if ($commandPaletteOpen.get() || isEditableTarget(event.target)) {
return
}
const char = typeToFocusChar(event)
if (char === null || char === ' ') {
return
}
event.preventDefault()
openCommandPalettePage('settings', char)
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
// Fake search pill riding the card's top edge, dead-center and half off it.
// Clicking (or just typing) opens the ⌘K palette scoped to settings; while
// the palette is up the pill hands over to it — grows slightly and fades,
// then fades back when the palette closes. It renders as chrome, not an
// input — no border, recessed fill, live ⌘K hint.
const searchCombo = bindingsFor('nav.commandPalette')[0]
const paletteOpen = useStore($commandPaletteOpen)
const searchPill = (
<button
className={cn(
'flex h-(--titlebar-control-height) items-center gap-1.5 rounded-full border border-(--ui-stroke-secondary) bg-(--ui-chat-surface-background) px-2.5 text-(--ui-text-tertiary) shadow-sm transition-all duration-200 ease-out hover:text-foreground motion-reduce:transition-none',
paletteOpen && 'pointer-events-none scale-110 opacity-0'
)}
onClick={() => {
triggerHaptic('open')
openCommandPalettePage('settings')
}}
tabIndex={paletteOpen ? -1 : undefined}
type="button"
>
<Search className="size-3" />
<span className="text-xs">{t.settings.search.pill}</span>
{searchCombo && <KbdCombo combo={searchCombo} size="sm" variant="ghost" />}
</button>
)
const navFooter = (
<>
<Tip label={t.settings.exportConfig}>
<OverlayIconButton onClick={() => void exportConfig()}>
<Download />
</OverlayIconButton>
</Tip>
<Tip label={t.settings.importConfig}>
<OverlayIconButton
onClick={() => {
triggerHaptic('open')
importInputRef.current?.click()
}}
>
<Upload />
</OverlayIconButton>
</Tip>
<Tip label={t.settings.resetToDefaults}>
<OverlayIconButton
className="hover:text-destructive"
onClick={() => {
triggerHaptic('warning')
void resetConfig()
}}
>
<RefreshCw />
</OverlayIconButton>
</Tip>
</>
)
const activeSettingsContent =
activeView === 'config:appearance' ? (
<AppearanceSettings />
) : activeView === 'about' ? (
<AboutSettings />
) : activeView === 'gateway' || activeView === 'connections' ? (
// 'connections' renders the unified page too so the frame before
// the alias redirect lands doesn't flash the fallback view.
<GatewaySettings />
) : activeView === 'keybinds' ? (
<KeybindSettings />
) : activeView.startsWith('config:') ? (
<ConfigSettings
activeSectionId={activeView.slice('config:'.length)}
importInputRef={importInputRef}
onConfigSaved={onConfigSaved}
onMainModelChanged={onMainModelChanged}
/>
) : activeView === 'providers' ? (
<ProvidersSettings
onClose={onClose}
onConfigSaved={onConfigSaved}
onMainModelChanged={onMainModelChanged}
onViewChange={setProviderView}
view={providerView}
/>
) : activeView === 'keys' ? (
<KeysSettings view={keysView} />
) : activeView === 'notifications' ? (
<NotificationsSettings />
) : activeView === 'billing' ? (
<BillingSettings />
) : activeView === 'plugins' ? (
<PluginsSettings />
) : (
<SessionsSettings />
)
return (
<OverlayView closeLabel={t.settings.closeSettings} edgeBadge={searchPill} onClose={onClose}>
<OverlaySplitLayout>
<OverlayNav footer={navFooter} groups={navGroups} />
<OverlayMain className="px-0 pb-0">{activeSettingsContent}</OverlayMain>
</OverlaySplitLayout>
</OverlayView>
)
}
export { SettingsView as SettingsPage }
@@ -0,0 +1,276 @@
import { useStore } from '@nanostores/react'
import { useMemo, useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { Kbd, KbdCombo } from '@/components/ui/kbd'
import { SearchField } from '@/components/ui/search-field'
import { Tip } from '@/components/ui/tooltip'
import { useContributions } from '@/contrib/react/use-contributions'
import { useI18n } from '@/i18n'
import {
allKeybindActions,
KEYBIND_CATEGORIES,
KEYBIND_PANEL_ACTION,
KEYBIND_READONLY,
type KeybindActionMeta,
type KeybindReadonly,
KEYBINDS_AREA
} from '@/lib/keybinds/actions'
import { formatCombo } from '@/lib/keybinds/combo'
import { arraysEqual } from '@/lib/storage'
import {
$bindings,
$capture,
beginCapture,
bindingsFor,
conflictsFor,
endCapture,
resetAllBindings,
resetBinding
} from '@/store/keybinds'
import { SettingsContent } from './primitives'
export function KeybindSettings() {
const { t } = useI18n()
const bindings = useStore($bindings)
const k = t.keybinds
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set())
// Subscribe so contributed actions appear/disappear live in the map.
useContributions(KEYBINDS_AREA)
const actionList = allKeybindActions()
const [query, setQuery] = useState('')
const openCombo = bindings[KEYBIND_PANEL_ACTION]?.[0]
const toggleCategory = (category: string) =>
setCollapsed(prev => {
const next = new Set(prev)
if (next.has(category)) {
next.delete(category)
} else {
next.add(category)
}
return next
})
// Filter actions and readonly shortcuts by label match against the query.
// When searching, categories auto-expand (collapsed state is ignored).
const isSearching = query.trim().length > 0
const filteredActions = useMemo(() => {
if (!isSearching) {
return null
}
const lower = query.toLowerCase()
return actionList.filter(action => {
if (action.id === KEYBIND_PANEL_ACTION) {
return false
}
const label = k.actions[action.id] ?? action.id
return label.toLowerCase().includes(lower) || action.id.includes(lower)
})
}, [actionList, isSearching, query, k.actions])
const filteredReadonly = useMemo(() => {
if (!isSearching) {
return null
}
const lower = query.toLowerCase()
return KEYBIND_READONLY.filter(shortcut => {
const label = k.actions[shortcut.id] ?? shortcut.id
return label.toLowerCase().includes(lower) || shortcut.id.includes(lower)
})
}, [isSearching, query, k.actions])
return (
<SettingsContent>
<div className="flex items-center justify-between gap-3 pb-3">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-foreground">{k.title}</h2>
<p className="mt-0.5 text-[0.72rem] text-muted-foreground">
{k.subtitle(openCombo ? formatCombo(openCombo) : '')}
</p>
</div>
<button
className="flex shrink-0 items-center gap-1 rounded-md text-[0.72rem] text-muted-foreground hover:text-foreground"
onClick={resetAllBindings}
type="button"
>
<Codicon name="discard" size="0.8125rem" />
{k.resetAll}
</button>
</div>
<div className="pb-3">
<SearchField
aria-label={k.search}
containerClassName="w-full"
onChange={setQuery}
placeholder={k.search}
value={query}
/>
</div>
{isSearching ? (
<div className="px-2 py-1.5">
{filteredActions?.length === 0 && filteredReadonly?.length === 0 ? (
<p className="px-2.5 py-4 text-center text-[0.82rem] text-muted-foreground"></p>
) : (
<>
{filteredActions?.map(action => (
<KeybindRow action={action} key={action.id} />
))}
{filteredReadonly?.map(shortcut => (
<ReadonlyRow key={shortcut.id} shortcut={shortcut} />
))}
</>
)}
</div>
) : (
<div className="px-2 py-1.5">
{KEYBIND_CATEGORIES.map(category => {
const actions = actionList.filter(
action => action.category === category && action.id !== KEYBIND_PANEL_ACTION
)
const readonly = KEYBIND_READONLY.filter(shortcut => shortcut.category === category)
if (actions.length === 0 && readonly.length === 0) {
return null
}
const sectionOpen = !collapsed.has(category)
return (
<section key={category}>
<CategoryHeader
label={k.categories[category] ?? category}
onToggle={() => toggleCategory(category)}
open={sectionOpen}
/>
{sectionOpen && actions.map(action => <KeybindRow action={action} key={action.id} />)}
{sectionOpen && readonly.map(shortcut => <ReadonlyRow key={shortcut.id} shortcut={shortcut} />)}
</section>
)
})}
</div>
)}
</SettingsContent>
)
}
function CategoryHeader({ label, onToggle, open }: { label: string; onToggle: () => void; open: boolean }) {
return (
<button
className="group/kbd-cat flex w-fit min-w-0 items-center gap-1 px-2.5 pb-1 pt-3 text-left leading-none"
onClick={onToggle}
type="button"
>
<span className="min-w-0 truncate text-[0.64rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground/70">
{label}
</span>
<DisclosureCaret
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/kbd-cat:opacity-100"
open={open}
size="0.6875rem"
/>
</button>
)
}
function KeybindRow({ action }: { action: KeybindActionMeta }) {
const { t } = useI18n()
const k = t.keybinds
const bindings = useStore($bindings)
const capture = useStore($capture)
// bindingsFor resolves stored overrides for late-registered (contributed)
// actions too — $bindings only carries built-ins, so a raw lookup would show
// the default instead of the user's rebinding for a plugin/contrib action.
const combos = bindingsFor(action.id, bindings)
const capturing = capture === action.id
const label = k.actions[action.id] ?? action.label ?? action.id
const isDefault = arraysEqual(combos, [...action.defaults])
const conflict = combos
.flatMap(combo => conflictsFor(action.id, combo).map(other => k.actions[other] ?? other))
.find(Boolean)
return (
<div className="group flex items-center gap-2.5 rounded-lg px-2.5 py-1 transition-colors hover:bg-(--chrome-action-hover)">
<span className="min-w-0 flex-1 truncate text-[0.82rem] text-foreground/90">{label}</span>
{conflict && (
<span className="flex size-4 items-center justify-center text-amber-500/90" title={k.conflictWith(conflict)}>
<Codicon name="warning" size="0.8125rem" />
</span>
)}
{/* Click the caps to rebind — the on-screen editor does the same thing. */}
<Tip label={k.rebind}>
<button
aria-label={k.rebind}
className="flex shrink-0 items-center gap-1 rounded-lg outline-none"
onClick={() => (capturing ? endCapture() : beginCapture(action.id))}
type="button"
>
{capturing ? (
<Kbd variant="capturing">{k.pressKey}</Kbd>
) : combos.length > 0 ? (
combos.map(combo => <KbdCombo combo={combo} key={combo} />)
) : (
<Kbd variant="ghost">{k.set}</Kbd>
)}
</button>
</Tip>
{/* Reset only shows once a binding diverges from its default; the spacer
holds the column otherwise so rows stay aligned. */}
{isDefault ? (
<span aria-hidden className="size-6 shrink-0" />
) : (
<Tip label={k.reset}>
<button
aria-label={k.reset}
className="grid size-6 shrink-0 place-items-center rounded-md text-muted-foreground/70 opacity-0 transition-all hover:bg-(--ui-control-active-background) hover:text-foreground group-hover:opacity-100"
onClick={() => resetBinding(action.id)}
type="button"
>
<Codicon name="discard" size="0.8125rem" />
</button>
</Tip>
)}
</div>
)
}
// Fixed shortcut: same layout as KeybindRow but the caps aren't interactive and
// the trailing reset slot stays empty (spacer keeps the columns aligned).
function ReadonlyRow({ shortcut }: { shortcut: KeybindReadonly }) {
const { t } = useI18n()
const k = t.keybinds
const label = k.actions[shortcut.id] ?? shortcut.id
return (
<div className="flex items-center gap-2.5 rounded-lg px-2.5 py-1">
<span className="min-w-0 flex-1 truncate text-[0.82rem] text-foreground/75">{label}</span>
<div className="flex shrink-0 items-center gap-1">
{shortcut.keys.map(key => (
<KbdCombo combo={key} key={key} />
))}
</div>
<span aria-hidden className="size-6 shrink-0" />
</div>
)
}
@@ -0,0 +1,129 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter, useNavigate } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { stubResizeObserver } from '@/test/jsdom'
import { envVar } from './test-utils'
const getEnvVars = vi.fn()
stubResizeObserver()
vi.mock('@/hermes', () => ({
deleteEnvVar: vi.fn(),
getEnvVars: (profile?: null | string) => getEnvVars(profile),
revealEnvVar: vi.fn(),
setApiRequestProfile: () => undefined,
setEnvVar: vi.fn()
}))
beforeEach(() => {
getEnvVars.mockResolvedValue({})
Object.defineProperty(Element.prototype, 'scrollIntoView', {
configurable: true,
value: vi.fn()
})
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
async function renderKeysSettings(view: 'settings' | 'tools', route = '/settings') {
const { KeysSettings } = await import('./keys-settings')
await act(async () => {
render(
<MemoryRouter initialEntries={[route]}>
<KeysSettings view={view} />
</MemoryRouter>
)
})
}
function DeepLinkButton({ target }: { target: string }) {
const navigate = useNavigate()
return (
<button onClick={() => navigate(`/settings?tab=keys&key=${target}`)} type="button">
Open key
</button>
)
}
describe('KeysSettings', () => {
it('fetches env vars for the active profile (undefined, never null) when unscoped', async () => {
// #90549 class: getEnvVars(null) targets the primary profile's env store,
// so a non-default profile's Keys page would read (and edit) the wrong
// profile. Unscoped must send undefined so the active profile applies.
await renderKeysSettings('tools')
await waitFor(() => expect(getEnvVars).toHaveBeenCalledWith(undefined))
})
it('lists tools and excludes settings / channel-managed credentials', async () => {
getEnvVars.mockResolvedValue({
BRAVE_SEARCH_API_KEY: envVar('tool', { description: 'Search the web with Brave.' }),
FIRECRAWL_API_KEY: envVar('tool', { description: 'Crawl and extract websites.' }),
GATEWAY_PROXY: envVar('setting', { description: 'Gateway reverse proxy.' }),
TELEGRAM_BOT_TOKEN: envVar('messaging', {
channel_managed: true,
description: 'Telegram bot token.'
})
})
await renderKeysSettings('tools')
expect(screen.getByText('BRAVE SEARCH')).toBeTruthy()
expect(screen.getByText('FIRECRAWL')).toBeTruthy()
expect(screen.queryByText('GATEWAY PROXY')).toBeNull()
expect(screen.queryByText('TELEGRAM BOT')).toBeNull()
expect(screen.queryByRole('combobox')).toBeNull()
})
it('lists settings rows and excludes tools / channel-managed credentials', async () => {
getEnvVars.mockResolvedValue({
API_SERVER_TOKEN: envVar('setting', { description: 'Protect the local API server.' }),
GATEWAY_PROXY: envVar('messaging', { description: 'Gateway reverse proxy address.' }),
TELEGRAM_BOT_TOKEN: envVar('messaging', {
channel_managed: true,
description: 'Telegram bot token.'
}),
BRAVE_SEARCH_API_KEY: envVar('tool', { description: 'Search the web with Brave.' })
})
await renderKeysSettings('settings')
expect(screen.getByText('API SERVER')).toBeTruthy()
expect(screen.getByText('GATEWAY PROXY')).toBeTruthy()
expect(screen.queryByText('TELEGRAM BOT')).toBeNull()
expect(screen.queryByText('BRAVE SEARCH')).toBeNull()
})
it('expands and highlights a deep-linked credential card', async () => {
getEnvVars.mockResolvedValue({
BRAVE_SEARCH_API_KEY: envVar('tool', { description: 'Search the web with Brave.' }),
FIRECRAWL_API_KEY: envVar('tool', { description: 'Crawl and extract websites.' })
})
const { KeysSettings } = await import('./keys-settings')
render(
<MemoryRouter initialEntries={['/settings?tab=keys']}>
<KeysSettings view="tools" />
<DeepLinkButton target="FIRECRAWL_API_KEY" />
</MemoryRouter>
)
expect(await screen.findByText('BRAVE SEARCH')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Open key' }))
await waitFor(() => {
const target = globalThis.document.getElementById('credential-key-FIRECRAWL_API_KEY')
expect(target?.classList).toContain('setting-field-highlight')
})
expect(screen.getByText('Crawl and extract websites.')).toBeTruthy()
})
})
@@ -0,0 +1,119 @@
import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useI18n } from '@/i18n'
import { $settingsRequestProfile } from '@/store/settings-scope'
import { CredentialKeyCard, credentialPlaceholder, credentialRowLabel } from './credential-key-ui'
import { useEnvCredentials } from './env-credentials'
import { asText } from './helpers'
import { SettingsContent, SettingsSkeleton } from './primitives'
import { SettingsProfileScope } from './profile-scope'
import { useDeepLinkHighlight } from './use-deep-link-highlight'
// Sub-views surfaced as sidebar subnav under Tools & Keys (see settings/index.tsx).
export const KEYS_VIEWS = ['tools', 'settings'] as const
export type KeysView = (typeof KEYS_VIEWS)[number]
// Providers live on their own page; messaging-platform credentials live on the
// dedicated Messaging page (and are hidden here via `channel_managed`). This
// view covers tool API keys plus server/setting env vars (API server, webhook,
// gateway), which fold into the Settings subnav.
// Backend categories that surface under each subnav. Platform credentials use the
// `messaging` category but are flagged ``channel_managed`` and configured on
// the Messaging page; only gateway-wide ``messaging`` rows (e.g. GATEWAY_PROXY)
// appear here alongside ``setting``.
const VIEW_CATEGORIES: Record<KeysView, readonly string[]> = {
settings: ['setting', 'messaging'],
tools: ['tool']
}
const credentialElementId = (key: string) => `credential-key-${key}`
export function KeysSettings({ view }: KeysSettingsProps) {
const { t } = useI18n()
// Shared settings "Applies to" scope: fetch + edit the selected profile's
// env store instead of the active one (undefined → active, the default
// path — request-shaped so the API helpers never see a primary-targeting
// null).
const scopeProfile = useStore($settingsRequestProfile)
const { rowProps, vars } = useEnvCredentials(scopeProfile)
const [openKey, setOpenKey] = useState<null | string>(null)
useEffect(() => {
setOpenKey(null)
}, [scopeProfile, view])
const entries = useMemo(() => {
if (!vars) {
return []
}
const cats = VIEW_CATEGORIES[view]
return Object.entries(vars)
.filter(([, info]) => !info.channel_managed && cats.includes(asText(info.category)))
.sort(([a], [b]) => a.localeCompare(b))
}, [vars, view])
const renderableKeys = useMemo(() => new Set(entries.map(([key]) => key)), [entries])
const resolveDeepLink = useCallback((key: string) => {
setOpenKey(key)
}, [])
const deepLinkReady = useCallback((key: string) => renderableKeys.has(key), [renderableKeys])
// Deep link from ⌘K / Capabilities env-var rows (?tab=keys&key=<ENV_KEY>):
// scroll the credential card into view, flash it, and expand it. Only
// consume keys rendered by this sub-view so a stale Tools/Settings pairing
// cannot keep trying to mount a target this pane never shows.
useDeepLinkHighlight({
elementId: credentialElementId,
onResolve: resolveDeepLink,
param: 'key',
ready: deepLinkReady
})
if (!vars) {
return <SettingsSkeleton sections={[{ rows: 5 }]} />
}
return (
<SettingsContent>
<SettingsProfileScope className="mb-5" />
{entries.length > 0 ? (
<div className="grid gap-2">
{entries.map(([key, info]) => {
const label = credentialRowLabel(key, info)
return (
<div className="scroll-mt-6 rounded-[6px]" id={credentialElementId(key)} key={key}>
<CredentialKeyCard
expanded={openKey === key}
info={info}
label={label}
onExpand={() => setOpenKey(key)}
onToggle={() => setOpenKey(prev => (prev === key ? null : key))}
placeholder={credentialPlaceholder(key, info, label)}
rowProps={rowProps}
varKey={key}
/>
</div>
)
})}
</div>
) : (
<div className="rounded-lg border border-dashed border-(--ui-stroke-tertiary) px-4 py-8 text-center text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
{t.settings.keys.empty}
</div>
)}
</SettingsContent>
)
}
interface KeysSettingsProps {
view: KeysView
}
@@ -0,0 +1,556 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter, useLocation } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/i18n'
import { $localRuntimeJobs } from '@/store/local-runtime-jobs'
import type { LocalCatalogModel, LocalHardware, LocalModelsStatus, LocalRuntimeJob } from '@/types/hermes'
import { LocalModelsSettings } from './local-models-settings'
// Mock the API layer — the pane's contract is what it RENDERS from these
// payloads, not transport.
vi.mock('@/hermes', () => ({
activateLocalModel: vi.fn(),
deleteLocalModel: vi.fn(),
downloadBrowsedModel: vi.fn(),
downloadLocalModel: vi.fn(),
ejectLocalModel: vi.fn(),
getLocalCatalog: vi.fn(),
getLocalHardware: vi.fn(),
getLocalModelsJobs: vi.fn(),
getLocalModelsStatus: vi.fn(),
getLocalRuntimeJob: vi.fn(),
installLocalRuntime: vi.fn(),
listHFRepoFiles: vi.fn(),
quickstartLocalModels: vi.fn(),
searchHFModels: vi.fn(),
sideloadLocalModel: vi.fn()
}))
import * as hermes from '@/hermes'
const mocked = vi.mocked(hermes)
const BASE_STATUS: LocalModelsStatus = {
enabled: true,
tag: 'b10290',
configured_tag: 'b10290',
update_available: false,
runtime_installed: false,
runtime_backend: null,
server_running: false,
server_base_url: null,
active_model_id: null,
loaded_models: {},
models: [],
models_dir: 'C:/somewhere/models'
}
const BASE_HARDWARE: LocalHardware = {
uma: false,
vram_total_bytes: 32 * 2 ** 30,
vram_usable_bytes: 26 * 2 ** 30,
ram_total_bytes: 256 * 2 ** 30,
ram_available_bytes: 200 * 2 ** 30,
vram_label: '32.0 GB',
gpu_name: 'NVIDIA GeForce RTX 5090',
gpu_util_percent: 12,
vram_used_bytes: 6 * 2 ** 30
}
const FITTING_MODEL: LocalCatalogModel = {
id: 'Qwen3.6-27B-UD-Q4_K_XL',
display_name: 'Qwen3.6 27B',
description: 'Best all-round agent model; long context stays fast',
size_bytes: 17.6 * 2 ** 30,
size_label: '17.6 GB',
native_context: 262144,
native_context_label: '256K',
recommended: true,
downloaded: false,
mtp: false,
fits: true,
fit_summary: 'runs at its full 256K context',
start_window: 262144,
start_window_label: '256K',
spilled: false
}
const SPILLED_MODEL: LocalCatalogModel = {
...FITTING_MODEL,
id: 'Spilled-Model',
display_name: 'Spilled Model',
recommended: false,
fits: true,
spilled: true,
start_window: 65536,
start_window_label: '64K',
fit_summary: 'starts at 64K and grows toward 256K as you use it (larger than your GPU memory — runs slower)'
}
const REFUSED_MODEL: LocalCatalogModel = {
...FITTING_MODEL,
id: 'Huge-Model',
display_name: 'Huge Model',
recommended: false,
fits: false,
fit_summary: 'Needs more memory than this machine has',
fit_detail: 'needs ~60 GiB at the 64K floor',
start_window: undefined,
start_window_label: undefined
}
function renderPane() {
return render(
<MemoryRouter>
<I18nProvider>
<LocalModelsSettings />
</I18nProvider>
</MemoryRouter>
)
}
// The fresh-machine states these tests exercise now lead with the
// quickstart card; the full pane (runtime rows, model list, browser)
// is one 'Configure…' click away. Render and click through.
async function renderFullPane() {
const result = renderPane()
const configure = await screen.findByRole('button', { name: /configure/i })
fireEvent.click(configure)
return result
}
beforeEach(() => {
mocked.getLocalModelsStatus.mockResolvedValue(BASE_STATUS)
mocked.getLocalHardware.mockResolvedValue(BASE_HARDWARE)
mocked.getLocalCatalog.mockResolvedValue({ models: [FITTING_MODEL, SPILLED_MODEL, REFUSED_MODEL] })
mocked.getLocalModelsJobs.mockResolvedValue({ jobs: [] })
$localRuntimeJobs.set([])
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('LocalModelsSettings', () => {
it('offers the runtime install with a plain-language explanation', async () => {
await renderFullPane()
expect(await screen.findByText('Install the local runtime')).toBeTruthy()
expect(screen.getByText(/runs? entirely on this machine/i)).toBeTruthy()
expect(screen.getByRole('button', { name: /install runtime/i })).toBeTruthy()
})
it('shows every catalog model with fit pills; unaffordable ones stay visible with the reason', async () => {
await renderFullPane()
expect(await screen.findByText('Qwen3.6 27B')).toBeTruthy()
// The fitting model reads as pills, not prose: green memory pill +
// green full-context pill (start_window == native, resident on GPU).
expect(screen.getByText('Fits your GPU')).toBeTruthy()
expect(screen.getByText('Full 256K context').className).toContain('emerald')
// The refused model is NOT hidden (discoverability rule): red memory
// pill, plus the ceiling it would have had.
expect(screen.getByText('Huge Model')).toBeTruthy()
expect(screen.getByText('Too big for this machine')).toBeTruthy()
// The spilled model reads amber + ONE quiet ceiling pill — the same
// 'Up to' shape the refused row wears; no start/grow pair.
expect(screen.getByText('Spilled Model')).toBeTruthy()
expect(screen.getByText('Uses system RAM')).toBeTruthy()
expect(screen.getAllByText('Up to 256K context').length).toBe(2)
expect(screen.queryByText(/Starts at/)).toBeNull()
// Its download button is disabled; the fitting model's is enabled once
// the runtime exists (here runtime_installed=false, so both disabled —
// asserted separately below).
const buttons = screen.getAllByRole('button', { name: /download · 17\.6 GB/i })
expect(buttons.every(b => (b as HTMLButtonElement).disabled)).toBe(true)
})
it('orders the catalog by fit: resident first, then spilled, then too-big', async () => {
// Scrambled input — the pane, not the backend, owns display order.
mocked.getLocalCatalog.mockResolvedValue({ models: [REFUSED_MODEL, SPILLED_MODEL, FITTING_MODEL] })
await renderFullPane()
await screen.findByText('Qwen3.6 27B')
// The matched element is the row-title span; the recommended row's
// includes its nested pill copy — strip it before comparing order.
const names = screen
.getAllByText(/^(Qwen3\.6 27B|Spilled Model|Huge Model)$/)
.map(el => el.textContent?.replace('Recommended', ''))
expect(names).toEqual(['Qwen3.6 27B', 'Spilled Model', 'Huge Model'])
})
it('never greens the full-context pill on a system-RAM model', async () => {
// Full native window, but earned by spilling into system RAM: the
// pill must not wear the green that would recommend exactly the
// wrong model.
const spilledFull: LocalCatalogModel = {
...FITTING_MODEL,
id: 'Spilled-Full',
display_name: 'Spilled Full',
recommended: false,
spilled: true,
fit_summary: 'runs its full 256K context, partly from system RAM'
}
mocked.getLocalCatalog.mockResolvedValue({ models: [spilledFull] })
await renderFullPane()
await screen.findByText('Spilled Full')
expect(screen.getByText('Full 256K context').className).not.toContain('emerald')
})
it('explains the Recommended pick on hover', async () => {
// The tooltip is the resolver's own reason, and it must actually OPEN:
// Tip works by asChild-cloning hover handlers onto the pill, so a Pill
// that swallows its rest props kills the tooltip silently (the pill
// still renders, nothing appears on hover).
mocked.getLocalCatalog.mockResolvedValue({
models: [{ ...FITTING_MODEL, recommended_reason: 'speed-gated-quality' }]
})
await renderFullPane()
await screen.findByText('Qwen3.6 27B')
fireEvent.pointerMove(screen.getByText('Recommended'))
fireEvent.pointerEnter(screen.getByText('Recommended'))
await waitFor(() =>
expect(screen.getAllByText(/would respond too slowly on its memory bandwidth/).length).toBeGreaterThan(0)
)
})
it('enables downloads only once the runtime is installed', async () => {
mocked.getLocalModelsStatus.mockResolvedValue({
...BASE_STATUS,
runtime_installed: true,
runtime_backend: 'cuda'
})
await renderFullPane()
await screen.findByText('Qwen3.6 27B')
const [fittingButton] = screen.getAllByRole('button', { name: /download · 17\.6 GB/i })
expect((fittingButton as HTMLButtonElement).disabled).toBe(false)
})
it('shows hardware facts after backfill', async () => {
await renderFullPane()
expect(await screen.findByText('NVIDIA GeForce RTX 5090')).toBeTruthy()
expect(screen.getByText(/32\.0 GB GPU memory/)).toBeTruthy()
expect(screen.getByText(/256\.0 GB RAM/)).toBeTruthy()
})
it('tracks a download job to completion and refreshes', async () => {
mocked.getLocalModelsStatus.mockResolvedValue({
...BASE_STATUS,
runtime_installed: true,
runtime_backend: 'cuda'
})
mocked.downloadLocalModel.mockResolvedValue({ job_id: 'j1' })
const running: LocalRuntimeJob = {
job_id: 'j1',
kind: 'model-download',
target: 'Qwen3.6 27B',
model_id: FITTING_MODEL.id,
status: 'running',
phase: 'downloading',
detail: 'Qwen3.6 27B — 17.6 GB',
total_bytes: 100,
done_bytes: 40,
percent: 40,
error: null
}
mocked.getLocalModelsJobs
.mockResolvedValueOnce({ jobs: [running] })
.mockResolvedValue({ jobs: [{ ...running, status: 'done', phase: 'done', done_bytes: 100, percent: 100 }] })
await renderFullPane()
await screen.findByText('Qwen3.6 27B')
const [download] = screen.getAllByRole('button', { name: /download · 17\.6 GB/i })
download.click()
// The app-level watcher follows the job; when it settles the pane
// refreshes (status + catalog re-fetched).
await waitFor(() => {
expect(mocked.getLocalModelsJobs).toHaveBeenCalled()
expect(mocked.getLocalModelsStatus.mock.calls.length).toBeGreaterThanOrEqual(2)
})
})
it('renders progress for a download discovered from the store (survives pane remount)', async () => {
mocked.getLocalModelsStatus.mockResolvedValue({
...BASE_STATUS,
runtime_installed: true,
runtime_backend: 'cuda'
})
// A running job already in the app-level store — as after closing and
// reopening the pane mid-download.
$localRuntimeJobs.set([
{
job_id: 'j9',
kind: 'model-download',
target: 'Qwen3.6 27B',
model_id: FITTING_MODEL.id,
status: 'running',
phase: 'downloading',
detail: '',
total_bytes: 100,
done_bytes: 62,
percent: 62,
error: null
}
])
await renderFullPane()
await screen.findByText('Qwen3.6 27B')
// The fitting row shows byte progress; the remaining download
// buttons belong to the other rows (spilled + refused).
expect(screen.getAllByText(/0\.0 GB of 0\.0 GB|of/).length).toBeGreaterThan(0)
const remaining = screen.queryAllByRole('button', { name: /download · 17\.6 GB/i })
expect(remaining.length).toBe(2)
expect(remaining.some(b => (b as HTMLButtonElement).disabled)).toBe(true)
})
it('surfaces a failed download with the backend message', async () => {
mocked.getLocalModelsStatus.mockResolvedValue({
...BASE_STATUS,
runtime_installed: true,
runtime_backend: 'cuda'
})
$localRuntimeJobs.set([
{
job_id: 'j2',
kind: 'model-download',
target: 'Qwen3.6 27B',
model_id: FITTING_MODEL.id,
status: 'error',
phase: 'verifying',
detail: '',
total_bytes: 100,
done_bytes: 100,
error: 'Downloaded file failed its integrity check and was removed — try again'
}
])
await renderFullPane()
await screen.findByText('Qwen3.6 27B')
expect(await screen.findByText(/integrity check/)).toBeTruthy()
})
})
describe('quickstart', () => {
it('leads with one button on a fresh machine and fires the quickstart job', async () => {
mocked.quickstartLocalModels.mockResolvedValue({
display_name: 'Qwen3.6 27B',
download_bytes: FITTING_MODEL.size_bytes,
job_id: 'q1',
model_id: 'qwen3.6-27b',
needs_download: true,
needs_runtime: true
})
renderPane()
// The card names the recommended model and the one-click action; the
// runtime/model machinery is NOT on screen.
expect(await screen.findByRole('button', { name: /set up for me/i })).toBeTruthy()
expect(screen.queryByText('Install the local runtime')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /set up for me/i }))
await waitFor(() => {
expect(mocked.quickstartLocalModels).toHaveBeenCalled()
})
})
it('pins the quickstart progress view while the job runs', async () => {
$localRuntimeJobs.set([
{
job_id: 'q1',
kind: 'quickstart',
target: 'Qwen3.6 27B',
model_id: 'qwen3.6-27b',
status: 'running',
phase: 'downloading',
detail: 'Qwen3.6 27B — 17.6 GB',
total_bytes: 100,
done_bytes: 30,
percent: 30,
error: null
}
])
renderPane()
expect(await screen.findByText('Qwen3.6 27B — 17.6 GB')).toBeTruthy()
// One job, one view: no Set up / Configure buttons while it runs.
expect(screen.queryByRole('button', { name: /set up for me/i })).toBeNull()
})
it('skips the card entirely once a model is staged', async () => {
mocked.getLocalModelsStatus.mockResolvedValue({
...BASE_STATUS,
runtime_installed: true,
runtime_backend: 'cuda',
models: [{ id: 'Qwen3.6-27B-UD-Q4_K_XL', size_bytes: 17 * 2 ** 30, size_label: '17.6 GB' }]
})
renderPane()
// Straight to the full pane — no quickstart hero for a working setup.
expect(await screen.findByText('Qwen3.6 27B')).toBeTruthy()
expect(screen.queryByRole('button', { name: /set up for me/i })).toBeNull()
})
})
describe('BrowseSection', () => {
it('searches HF after a pause and shows fit-priced files on demand', async () => {
vi.useFakeTimers()
try {
vi.mocked(hermes.searchHFModels).mockResolvedValue({
hits: [{ downloads: 872724, gated: false, likes: 47, repo: 'unsloth/Qwen3.8-27B-GGUF', updated: '2026-08-18' }]
})
vi.mocked(hermes.listHFRepoFiles).mockResolvedValue({
files: [
{ fit: 'fits-gpu', label: 'Q4_K_M', paths: ['Qwen3.8-27B-Q4_K_M.gguf'], total_bytes: 17 * 2 ** 30 },
{ fit: 'too-big', label: 'F16', paths: ['Qwen3.8-27B-F16.gguf'], total_bytes: 56 * 2 ** 30 }
]
})
render(
<MemoryRouter>
<I18nProvider>
<LocalModelsSettings />
</I18nProvider>
</MemoryRouter>
)
await act(async () => {
await vi.runOnlyPendingTimersAsync()
})
// Fresh machine leads with the quickstart card — enter the full pane.
fireEvent.click(screen.getByRole('button', { name: /configure/i }))
const box = screen.getByPlaceholderText(/search models/i)
fireEvent.change(box, { target: { value: 'qwen' } })
// Debounce: no call until the pause elapses.
expect(hermes.searchHFModels).not.toHaveBeenCalled()
await act(async () => {
await vi.advanceTimersByTimeAsync(400)
})
expect(hermes.searchHFModels).toHaveBeenCalledWith('qwen')
expect(screen.getByText('unsloth/Qwen3.8-27B-GGUF')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /show files/i }))
await act(async () => {
await vi.runOnlyPendingTimersAsync()
})
expect(screen.getByText('Q4_K_M')).toBeTruthy()
// Each tile has an explicit download button; the too-big quant's is
// disabled, the fitting one is live and starts the download.
const q4Btn = screen.getByRole('button', { name: 'Download Q4_K_M' })
const f16Btn = screen.getByRole('button', { name: 'Download F16' })
expect((f16Btn as HTMLButtonElement).disabled).toBe(true)
expect((q4Btn as HTMLButtonElement).disabled).toBe(false)
vi.mocked(hermes.downloadBrowsedModel).mockResolvedValue({ job_id: 'j1', model_id: 'Qwen3.8-27B-Q4_K_M' })
fireEvent.click(q4Btn)
await act(async () => {
await vi.runOnlyPendingTimersAsync()
})
expect(hermes.downloadBrowsedModel).toHaveBeenCalledWith('unsloth/Qwen3.8-27B-GGUF', ['Qwen3.8-27B-Q4_K_M.gguf'])
} finally {
vi.useRealTimers()
}
})
})
describe('added-by-you rows', () => {
it('staged models outside the catalog get the full action set', async () => {
vi.mocked(hermes.getLocalModelsStatus).mockResolvedValue({
...BASE_STATUS,
loaded_models: { 'Hermes-4.3-36B-Q5_K_M': 'loaded' },
models: [{ id: 'Hermes-4.3-36B-Q5_K_M', size_bytes: 25 * 2 ** 30, size_label: '25.0 GB' }],
placement: {
'Hermes-4.3-36B-Q5_K_M': {
granted_window_label: '96K',
spilled: false,
window: 98304,
window_label: '96K'
}
},
server_running: true
})
vi.mocked(hermes.getLocalCatalog).mockResolvedValue({ models: [] })
renderPane()
await screen.findByText('Hermes-4.3-36B-Q5_K_M')
// Full management surface: Use, eject, delete, live placement pill.
expect(screen.getByText(/added by you/i)).toBeTruthy()
expect(screen.getByRole('button', { name: /use/i })).toBeTruthy()
expect(screen.getByText(/96K/)).toBeTruthy()
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThanOrEqual(3)
})
})
describe('quickstart completion navigation', () => {
it('lands on a new chat when a quickstart it watched finishes; stale done jobs on mount never navigate', async () => {
const routeProbe = vi.fn()
function Probe() {
const loc = useLocation()
routeProbe(loc.pathname)
return null
}
const doneJob: LocalRuntimeJob = {
done_bytes: 0,
detail: '',
error: null,
job_id: 'stale-done',
kind: 'quickstart',
model_id: 'qwen3.8-27b',
phase: 'done',
status: 'done',
target: 'Qwen3.8 27B',
total_bytes: null
}
// A finished quickstart already in history when the pane mounts —
// must NOT trigger navigation.
$localRuntimeJobs.set([doneJob])
render(
<MemoryRouter initialEntries={['/settings']}>
<I18nProvider>
<LocalModelsSettings />
</I18nProvider>
<Probe />
</MemoryRouter>
)
await act(async () => {})
expect(routeProbe).not.toHaveBeenCalledWith('/')
// A quickstart the pane SAW running that then completes -> navigate.
const running: LocalRuntimeJob = { ...doneJob, job_id: 'live-run', phase: 'downloading', status: 'running' }
await act(async () => {
$localRuntimeJobs.set([doneJob, running])
})
await act(async () => {
$localRuntimeJobs.set([doneJob, { ...running, phase: 'done', status: 'done' }])
})
expect(routeProbe).toHaveBeenCalledWith('/')
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,151 @@
import { useStore } from '@nanostores/react'
import { Button } from '@/components/ui/button'
import type { DesktopRegistryConnection } from '@/global'
import { useI18n } from '@/i18n'
import { Download, Loader2 } from '@/lib/icons'
import { $connectionsRegistry } from '@/store/connections'
import {
$managedUpdates,
managedUpdatesSupported,
type ManagedUpdateState,
runManagedUpdate
} from '@/store/managed-updates'
import { ListRow, Pill, SectionHeading } from './primitives'
function stateTone(state: ManagedUpdateState | undefined): 'muted' | 'primary' | 'warn' {
if (!state || state.status === 'idle') {
return 'muted'
}
if (state.status === 'updating' || state.status === 'updated') {
return 'primary'
}
return 'warn'
}
function sshTarget(connection: DesktopRegistryConnection): string | null {
if (!connection.host) {
return null
}
return connection.user ? `${connection.user}@${connection.host}` : connection.host
}
/** Per-connection driver for #95942's transactional SSH update engine: one
* Update button per registered Desktop-managed SSH install, a single honest
* in-flight state (the engine exposes no streaming progress channel), and the
* correlated receipt once it lands. */
export function ManagedUpdatesSection() {
const { t } = useI18n()
const m = t.settings.managedUpdates
const registry = useStore($connectionsRegistry)
const states = useStore($managedUpdates)
const sshConnections = (registry?.connections ?? []).filter(connection => connection.kind === 'ssh')
// Fail closed: no section on an Electron main without the transactional
// bridge, and nothing to drive when no SSH install is registered.
if (!managedUpdatesSupported() || sshConnections.length === 0) {
return null
}
const statusLabel = (state: ManagedUpdateState | undefined): string | null => {
switch (state?.status) {
case 'failed':
return m.failed
case 'partial':
return m.partial
case 'refused':
return state.alreadyRunning ? m.alreadyRunning : m.refused
case 'updated':
return m.updated
case 'updating':
return m.updating
default:
return null
}
}
const receiptLine = (state: ManagedUpdateState): string | null => {
if (!state.receipt) {
return null
}
const parts = [m.receipt(state.receipt.correlationId.slice(0, 8), state.receipt.outcome)]
if (state.receipt.preVersion && state.receipt.postVersion) {
parts.push(m.receiptVersions(state.receipt.preVersion, state.receipt.postVersion))
}
if (state.receipt.stopReason) {
parts.push(state.receipt.stopReason)
}
return parts.join(' · ')
}
return (
<section className="mt-8">
<SectionHeading icon={Download} title={m.title} />
<p className="mb-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{m.intro}
</p>
<div className="grid gap-1">
{sshConnections.map(connection => {
const state = states[connection.id]
const updating = state?.status === 'updating'
const label = statusLabel(state)
const receipt = state ? receiptLine(state) : null
const restored = state?.scopes.filter(scope => scope.restored).map(scope => scope.profile) ?? []
const unrestored = state?.scopes.filter(scope => !scope.restored) ?? []
return (
<ListRow
action={
updating ? (
<Button disabled size="sm" variant="secondary">
<Loader2 className="animate-spin" /> {m.updating}
</Button>
) : (
<Button onClick={() => void runManagedUpdate(connection.id)} size="sm">
<Download /> {m.update}
</Button>
)
}
below={
state && (updating || state.message || receipt || state.scopes.length > 0) ? (
<div className="mt-1 grid gap-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{updating ? <p>{m.progress}</p> : null}
{!updating && state.message ? <p>{state.message}</p> : null}
{receipt ? <p className="font-mono text-[0.68rem]">{receipt}</p> : null}
{!updating && restored.length > 0 ? <p>{m.scopesRestored(restored.join(', '))}</p> : null}
{!updating &&
unrestored.map(scope => (
<p key={scope.profile}>{m.scopeNotRestored(scope.profile, scope.error ?? m.failed)}</p>
))}
</div>
) : null
}
description={sshTarget(connection) ?? m.sshConnection}
key={connection.id}
title={
<span className="flex items-center gap-2">
<span>{connection.label}</span>
{label ? <Pill tone={stateTone(state)}>{label}</Pill> : null}
</span>
}
/>
)
})}
</div>
</section>
)
}
@@ -0,0 +1,162 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { getMemoryProviderOAuthStatus, startMemoryProviderOAuth } from '@/hermes'
import { Check, ExternalLink, Loader2 } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
import type { MemoryProviderOAuthStatus } from '@/types/hermes'
const POLL_MS = 1500
const POLL_TIMEOUT_MS = 120_000
// Small connect affordance rendered under the provider dropdown. Capability is
// backend-driven: the status route 404s for providers without an oauth_flow
// module, so non-OAuth providers render nothing.
export function MemoryConnect({ profile, provider }: { profile?: string; provider: string }) {
const [capable, setCapable] = useState<'no' | 'unknown' | 'yes'>('unknown')
const [connected, setConnected] = useState(false)
const [auth, setAuth] = useState<MemoryProviderOAuthStatus['auth']>(null)
const [phase, setPhase] = useState<'error' | 'idle' | 'pending'>('idle')
const [detail, setDetail] = useState('')
const timer = useRef<ReturnType<typeof setInterval> | null>(null)
const deadline = useRef(0)
const stop = useCallback(() => {
if (timer.current !== null) {
clearInterval(timer.current)
timer.current = null
}
}, [])
useEffect(() => {
let active = true
setCapable('unknown')
getMemoryProviderOAuthStatus(provider, profile)
.then(s => {
if (!active) {
return
}
setCapable('yes')
setConnected(s.connected)
setAuth(s.auth)
})
.catch(() => {
if (active) {
setCapable('no')
}
})
return () => {
active = false
stop()
}
}, [profile, provider, stop])
// An error message isn't sticky — it clears back to the steady state
// (Connect link, plus the connected badge if a credential is stored).
useEffect(() => {
if (phase !== 'error') {
return
}
const t = setTimeout(() => {
setPhase('idle')
setDetail('')
}, 6000)
return () => clearTimeout(t)
}, [phase])
const connect = useCallback(async () => {
setPhase('pending')
try {
await startMemoryProviderOAuth(provider, profile)
} catch (err) {
setPhase('error')
setDetail('Could not start the connection.')
notifyError(err, 'Failed to start connection')
return
}
deadline.current = Date.now() + POLL_TIMEOUT_MS
stop()
timer.current = setInterval(() => {
void (async () => {
try {
const next = await getMemoryProviderOAuthStatus(provider, profile)
if (next.state === 'pending') {
if (Date.now() > deadline.current) {
stop()
setPhase('error')
setDetail('Timed out — try again.')
}
return
}
stop()
setConnected(next.connected)
setAuth(next.auth)
if (next.state === 'error') {
setPhase('error')
setDetail(next.detail || 'Connection failed.')
} else {
setPhase('idle')
}
} catch {
// Transient poll failure — keep trying until the deadline.
}
})()
}, POLL_MS)
}, [profile, provider, stop])
const cancel = useCallback(() => {
stop()
setPhase('idle')
}, [stop])
if (capable !== 'yes') {
return null
}
const connectLabel = connected ? (auth === 'apikey' ? 'Connect via OAuth' : 'Reconnect') : 'Connect'
return (
<span className="inline-flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
{phase === 'idle' && connected && (
<span className="inline-flex items-center gap-1 text-muted-foreground">
<Check className="size-3" />
{auth === 'apikey' ? 'api key set' : 'oauth set'}
</span>
)}
{phase === 'pending' ? (
<>
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
Waiting for browser consent
</span>
<Button className="h-auto p-0 text-xs" onClick={cancel} size="sm" type="button" variant="link">
Cancel
</Button>
</>
) : (
<Button
className="h-auto gap-1 p-0 text-xs"
onClick={() => void connect()}
size="sm"
type="button"
variant="link"
>
<ExternalLink className="size-3" />
{connectLabel}
</Button>
)}
{phase === 'error' && detail && <span className="text-destructive">{detail}</span>}
</span>
)
}
@@ -0,0 +1,129 @@
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { Tip } from '@/components/ui/tooltip'
import { Check, Info } from '@/lib/icons'
import type { MemoryProviderField } from '@/types/hermes'
import { CONTROL_TEXT } from '../constants'
// Fade the placeholder well below set values so example text never reads as data.
const FIELD_INPUT = `font-mono ${CONTROL_TEXT} placeholder:text-muted-foreground/45`
// Field label with an optional info tooltip, shared by the panel and modal rows.
export function FieldTitle({ field }: { field: MemoryProviderField }) {
if (!field.info) {
return <>{field.label}</>
}
return (
<span className="inline-flex items-center gap-1.5">
{field.label}
<Tip className="max-w-60 font-normal leading-snug whitespace-normal" label={field.info}>
<Info aria-label={`About ${field.label}`} className="size-3.5 text-muted-foreground/70" />
</Tip>
</span>
)
}
// Values are edited as strings; the backend coerces them to native types.
export function FieldControl({
field,
value,
onChange,
onCommit
}: {
field: MemoryProviderField
value: string
onChange: (value: string) => void
// Present on autosaving surfaces: discrete controls commit on change, text-like
// controls commit on blur. Absent (the modal), edits stay drafts until Save.
onCommit?: (value: string) => void
}) {
const set = (next: string) => {
onChange(next)
onCommit?.(next)
}
const commitDraft = onCommit ? () => onCommit(value) : undefined
if (field.kind === 'bool') {
return <Switch checked={value === 'true'} onCheckedChange={checked => set(checked ? 'true' : 'false')} />
}
if (field.kind === 'number') {
return (
<Input
className={FIELD_INPUT}
inputMode="numeric"
onBlur={commitDraft}
onChange={event => onChange(event.target.value)}
placeholder={field.placeholder}
type="number"
value={value}
/>
)
}
if (field.kind === 'json') {
return (
<Textarea
className={FIELD_INPUT}
onBlur={commitDraft}
onChange={event => onChange(event.target.value)}
placeholder={field.placeholder}
spellCheck={false}
value={value}
/>
)
}
if (field.kind === 'select') {
return (
<Select onValueChange={set} value={value}>
<SelectTrigger className={CONTROL_TEXT}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{field.options.map(option => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
if (field.kind === 'secret') {
return (
<div className="flex flex-col gap-1">
<Input
className={`w-full ${FIELD_INPUT}`}
onBlur={commitDraft}
onChange={event => onChange(event.target.value)}
placeholder={field.is_set ? 'Leave blank to keep current value' : field.placeholder}
type="password"
value={value}
/>
{field.is_set && (
<span className="inline-flex items-center gap-1 self-start font-mono text-[0.65rem] text-(--ui-text-tertiary)">
<Check className="size-3 text-(--ui-accent-secondary)" />
set
</span>
)}
</div>
)
}
return (
<Input
className={FIELD_INPUT}
onBlur={commitDraft}
onChange={event => onChange(event.target.value)}
placeholder={field.placeholder}
value={value}
/>
)
}
@@ -0,0 +1,115 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
const saveMemoryProviderConfig = vi.fn()
vi.mock('@/hermes', () => ({
saveMemoryProviderConfig: (provider: string, values: unknown) => saveMemoryProviderConfig(provider, values)
}))
vi.mock('@/store/profile', async () => {
const { atom } = await import('nanostores')
return { $activeGatewayProfile: atom('default') }
})
vi.mock('@/store/notifications', () => ({
notify: vi.fn(),
notifyError: vi.fn()
}))
function field(
overrides: Partial<MemoryProviderField> & Pick<MemoryProviderField, 'key' | 'kind'>
): MemoryProviderField {
return {
label: overrides.key,
value: '',
description: '',
placeholder: '',
is_set: false,
inline: false,
group: 'Other',
options: [],
...overrides
}
}
function schema(): MemoryProviderConfig {
return {
name: 'honcho',
label: 'Honcho',
docs_url: 'https://docs.honcho.dev/v3/guides/integrations/hermes',
fields: [
field({ key: 'workspace', kind: 'text', label: 'Workspace', value: 'myws', inline: true, group: 'Connection' }),
field({ key: 'saveMessages', kind: 'bool', label: 'Save messages', value: 'true', group: 'Message writing' }),
field({ key: 'dialecticMaxChars', kind: 'number', label: 'Max result chars', value: '1200', group: 'Dialectic' }),
field({
key: 'userPeerAliases',
kind: 'json',
label: 'User peer aliases',
value: '{"t":"eri"}',
group: 'Identity'
})
]
}
}
beforeEach(() => {
saveMemoryProviderConfig.mockResolvedValue({ ok: true })
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
async function renderModal(open = true) {
const { ProviderConfigModal } = await import('./provider-config-modal')
const onOpenChange = vi.fn()
const onSaved = vi.fn().mockResolvedValue(undefined)
const result = render(
<ProviderConfigModal
config={schema()}
onOpenChange={onOpenChange}
onSaved={onSaved}
open={open}
provider="honcho"
/>
)
return { ...result, onOpenChange, onSaved }
}
describe('ProviderConfigModal', () => {
it('renders every field grouped, including inline ones, with kind-specific controls', async () => {
await renderModal()
expect(await screen.findByText('Message writing')).toBeTruthy()
expect(screen.getByText('Dialectic')).toBeTruthy()
// bool -> switch, number -> spinbutton, json/text -> textbox
expect(screen.getByRole('switch')).toBeTruthy()
expect(screen.getByDisplayValue('1200')).toBeTruthy()
expect(screen.getByDisplayValue('myws')).toBeTruthy()
expect(screen.getByDisplayValue('{"t":"eri"}')).toBeTruthy()
})
it('saves only edited fields, serializing the toggled bool to "false"', async () => {
const { onSaved, onOpenChange } = await renderModal()
fireEvent.click(await screen.findByRole('switch'))
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
// A save must never ratify rendered defaults the backend does not store.
await waitFor(() => expect(saveMemoryProviderConfig).toHaveBeenCalledWith('honcho', { saveMessages: 'false' }))
await waitFor(() => expect(onSaved).toHaveBeenCalled())
expect(onOpenChange).toHaveBeenCalledWith(false)
})
it('renders nothing while closed', async () => {
await renderModal(false)
expect(screen.queryByText('Message writing')).toBeNull()
})
})
@@ -0,0 +1,161 @@
import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { saveMemoryProviderConfig } from '@/hermes'
import { ExternalLink, Loader2, Save, SlidersHorizontal } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
import { $activeGatewayProfile } from '@/store/profile'
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
import { ListRow } from '../primitives'
import { FieldControl, FieldTitle } from './field-control'
// Secrets seed blank: values are write-only and blank keeps the stored one.
function seedAll(config: MemoryProviderConfig): Record<string, string> {
return Object.fromEntries(config.fields.map(field => [field.key, field.kind === 'secret' ? '' : field.value]))
}
// Group fields in declared order, preserving first-seen group sequence.
function groupFields(fields: MemoryProviderField[]): [string, MemoryProviderField[]][] {
const groups: [string, MemoryProviderField[]][] = []
for (const field of fields) {
const name = field.group || 'Other'
const bucket = groups.find(([key]) => key === name)
if (bucket) {
bucket[1].push(field)
} else {
groups.push([name, [field]])
}
}
return groups
}
export function ProviderConfigModal({
config,
profile = null,
provider,
open,
onOpenChange,
onSaved
}: {
config: MemoryProviderConfig
profile?: null | string
provider: string
open: boolean
onOpenChange: (open: boolean) => void
onSaved: () => Promise<void> | void
}) {
const activeProfile = useStore($activeGatewayProfile)
const [values, setValues] = useState<Record<string, string>>({})
const [seeded, setSeeded] = useState<Record<string, string>>({})
const [saving, setSaving] = useState(false)
// Reseed on open so edits never start from a stale prior-session snapshot.
useEffect(() => {
if (open) {
const seed = seedAll(config)
setSeeded(seed)
setValues(seed)
}
}, [open, config])
const save = async () => {
// Untouched keys stay unsubmitted; runtime defaults still own their values.
const edited = Object.fromEntries(Object.entries(values).filter(([key, value]) => value !== seeded[key]))
setSaving(true)
try {
await saveMemoryProviderConfig(provider, edited, profile)
notify({ kind: 'success', title: `${config.label} saved`, message: 'Memory provider configuration updated.' })
await onSaved()
onOpenChange(false)
} catch (err) {
notifyError(err, `Failed to save ${config.label} settings`)
} finally {
setSaving(false)
}
}
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent bodyClassName="dt-portal-scrollbar" className="max-w-2xl">
<DialogHeader>
<DialogTitle icon={SlidersHorizontal}>{config.label} full configuration</DialogTitle>
<DialogDescription>
Every {config.label} option for the <span className="font-medium">{profile ?? activeProfile}</span> profile.
Blank fields fall back to the resolved host or built-in default.
</DialogDescription>
{config.docs_url && (
<a
className="inline-flex w-fit items-center gap-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-accent-secondary) underline-offset-4 transition-colors hover:underline"
href={config.docs_url}
onClick={event => {
event.preventDefault()
void window.hermesDesktop?.openExternal?.(config.docs_url)
}}
rel="noreferrer"
target="_blank"
>
{config.label} configuration reference
<ExternalLink className="size-3" />
</a>
)}
</DialogHeader>
<div className="min-w-0">
{groupFields(config.fields).map(([group, fields]) => (
<section className="mt-6 first:mt-2" key={group}>
<h3 className="border-b border-(--ui-accent-secondary)/30 pb-1.5 font-mono text-[0.68rem] uppercase tracking-wide text-(--ui-accent-secondary)">
{group}
</h3>
<div className="pl-1">
{fields.map(field => (
<div className="border-b border-border/40 last:border-b-0" key={field.key}>
<ListRow
action={
<FieldControl
field={field}
onChange={value => setValues(current => ({ ...current, [field.key]: value }))}
value={values[field.key] ?? ''}
/>
}
description={field.description}
title={<FieldTitle field={field} />}
/>
</div>
))}
</div>
</section>
))}
</div>
<DialogFooter>
<DialogClose asChild>
<Button size="sm" type="button" variant="ghost">
Cancel
</Button>
</DialogClose>
<Button disabled={saving} onClick={() => void save()} size="sm">
{saving ? <Loader2 className="size-3.5 animate-spin" /> : <Save />}
Save changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,209 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MemoryProviderConfig } from '@/types/hermes'
const getMemoryProviderConfig = vi.fn()
const saveMemoryProviderConfig = vi.fn()
vi.mock('@/hermes', () => ({
getMemoryProviderConfig: (provider: string) => getMemoryProviderConfig(provider),
saveMemoryProviderConfig: (provider: string, values: unknown) => saveMemoryProviderConfig(provider, values)
}))
vi.mock('@/store/profile', async () => {
const { atom } = await import('nanostores')
return { $activeGatewayProfile: atom('default') }
})
vi.mock('@/store/notifications', () => ({
notify: vi.fn(),
notifyError: vi.fn()
}))
function honchoSchema(): MemoryProviderConfig {
return {
name: 'honcho',
label: 'Honcho',
docs_url: 'https://docs.honcho.dev/v3/guides/integrations/hermes',
fields: [
{
key: 'apiKey',
label: 'API key',
kind: 'secret',
value: '',
description: 'Authenticate with Honcho Cloud.',
placeholder: 'Enter Honcho API key',
is_set: false,
inline: true,
group: 'Connection',
options: []
},
{
key: 'baseUrl',
label: 'Base URL',
kind: 'text',
value: '',
description: 'Self-hosted Honcho URL.',
placeholder: 'https://… (self-hosted)',
is_set: false,
inline: true,
group: 'Connection',
options: []
},
{
key: 'environment',
label: 'Environment',
kind: 'select',
value: 'production',
description: 'Honcho environment.',
placeholder: '',
is_set: true,
inline: true,
group: 'Connection',
options: [
{ value: 'production', label: 'Production', description: '' },
{ value: 'demo', label: 'Demo', description: '' },
{ value: 'local', label: 'Local', description: '' }
]
},
{
key: 'workspace',
label: 'Workspace',
kind: 'text',
value: 'myws',
description: 'Honcho workspace ID.',
placeholder: 'hermes',
is_set: true,
inline: true,
group: 'Connection',
options: []
},
// Non-inline field: must NOT render in the compact panel and must NOT be
// submitted when the panel saves.
{
key: 'writeFrequency',
label: 'Write frequency',
kind: 'text',
value: 'async',
description: '',
placeholder: '',
is_set: true,
inline: false,
group: 'Message writing',
options: []
}
]
}
}
beforeEach(() => {
getMemoryProviderConfig.mockResolvedValue(honchoSchema())
saveMemoryProviderConfig.mockResolvedValue({ ok: true })
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
async function renderPanel(provider = 'honcho') {
const { ProviderConfigPanel } = await import('./provider-config-panel')
return render(<ProviderConfigPanel provider={provider} />)
}
describe('ProviderConfigPanel', () => {
it('renders the declared inline fields generically', async () => {
await renderPanel()
expect(await screen.findByDisplayValue('myws')).toBeTruthy()
expect(screen.getByPlaceholderText('https://… (self-hosted)')).toBeTruthy()
expect(screen.getByText('Production')).toBeTruthy()
expect(screen.getByText('Self-hosted Honcho URL.')).toBeTruthy()
})
it('hides fields that are not marked inline', async () => {
await renderPanel()
await screen.findByDisplayValue('myws')
expect(screen.queryByDisplayValue('async')).toBeNull()
expect(screen.queryByText('Write frequency')).toBeNull()
})
it('collapses and expands the fields', async () => {
await renderPanel()
expect(await screen.findByDisplayValue('myws')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /Honcho settings/ }))
expect(screen.queryByDisplayValue('myws')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /Honcho settings/ }))
expect(await screen.findByDisplayValue('myws')).toBeTruthy()
})
it('autosaves a text field on blur as a one-key partial save', async () => {
await renderPanel()
const baseUrl = await screen.findByPlaceholderText('https://… (self-hosted)')
fireEvent.change(baseUrl, { target: { value: 'http://localhost:8000' } })
fireEvent.blur(baseUrl)
await waitFor(() =>
expect(saveMemoryProviderConfig).toHaveBeenCalledWith('honcho', { baseUrl: 'http://localhost:8000' })
)
expect(saveMemoryProviderConfig).toHaveBeenCalledTimes(1)
})
it('does not save on blur when nothing changed', async () => {
await renderPanel()
const workspace = await screen.findByDisplayValue('myws')
fireEvent.blur(workspace)
await waitFor(() => expect(screen.queryByRole('button', { name: 'Save' })).toBeNull())
expect(saveMemoryProviderConfig).not.toHaveBeenCalled()
})
it('autosaves a committed secret and clears the draft', async () => {
await renderPanel()
const apiKey = await screen.findByPlaceholderText('Enter Honcho API key')
fireEvent.blur(apiKey)
expect(saveMemoryProviderConfig).not.toHaveBeenCalled()
fireEvent.change(apiKey, { target: { value: 'hch-new-key' } })
fireEvent.blur(apiKey)
await waitFor(() => expect(saveMemoryProviderConfig).toHaveBeenCalledWith('honcho', { apiKey: 'hch-new-key' }))
await waitFor(() => expect((apiKey as HTMLInputElement).value).toBe(''))
})
it('offers a full-config trigger when modal-only fields exist', async () => {
await renderPanel()
await screen.findByDisplayValue('myws')
expect(screen.getByRole('button', { name: /Full config/ })).toBeTruthy()
})
it('shows an inline error with retry when the load fails, then recovers', async () => {
getMemoryProviderConfig.mockRejectedValueOnce(new Error('Timed out connecting to Hermes backend'))
await renderPanel()
expect(await screen.findByText(/Timed out connecting/)).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(await screen.findByDisplayValue('myws')).toBeTruthy()
})
it('renders nothing for a provider with no declared config surface', async () => {
getMemoryProviderConfig.mockResolvedValue({ name: 'builtin', label: 'builtin', docs_url: '', fields: [] })
const { container } = await renderPanel('builtin')
await waitFor(() => expect(getMemoryProviderConfig).toHaveBeenCalledWith('builtin'))
expect(container.querySelector('section')).toBeNull()
})
})
@@ -0,0 +1,164 @@
import { useCallback, useEffect, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { getMemoryProviderConfig, saveMemoryProviderConfig } from '@/hermes'
import { SlidersHorizontal } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
import { ListRow, Pill } from '../primitives'
import { FieldControl, FieldTitle } from './field-control'
import { ProviderConfigModal } from './provider-config-modal'
// Inline fields only: the compact panel must never re-write modal-owned keys.
function seedValues(config: MemoryProviderConfig): Record<string, string> {
return Object.fromEntries(
config.fields.filter(field => field.inline).map(field => [field.key, field.kind === 'secret' ? '' : field.value])
)
}
export function ProviderConfigPanel({ profile, provider }: { profile?: string; provider: string }) {
const [config, setConfig] = useState<MemoryProviderConfig | null>(null)
const [loadError, setLoadError] = useState<null | string>(null)
const [values, setValues] = useState<Record<string, string>>({})
const [saved, setSaved] = useState<Record<string, string>>({})
const [expanded, setExpanded] = useState(true)
const [showModal, setShowModal] = useState(false)
const refresh = useCallback(async () => {
try {
const next = await getMemoryProviderConfig(provider, profile)
const seed = seedValues(next)
setConfig(next)
setValues(seed)
setSaved(seed)
setLoadError(null)
} catch (err) {
setConfig(null)
setLoadError(err instanceof Error ? err.message : 'Memory provider settings failed to load')
}
}, [profile, provider])
useEffect(() => {
setConfig(null)
void refresh()
}, [refresh])
// Autosave, matching the settings page around the panel: one-key partial PUT
// on commit, silent on success, no full refresh (it would reset sibling drafts).
const commitField = useCallback(
async (field: MemoryProviderField, value: string) => {
if (value === (saved[field.key] ?? '') || (field.kind === 'secret' && !value.trim())) {
return
}
try {
await saveMemoryProviderConfig(provider, { [field.key]: value }, profile)
if (field.kind === 'secret') {
setValues(current => ({ ...current, [field.key]: '' }))
setConfig(
current =>
current && {
...current,
fields: current.fields.map(f => (f.key === field.key ? { ...f, is_set: true } : f))
}
)
} else {
setSaved(current => ({ ...current, [field.key]: value }))
}
} catch (err) {
notifyError(err, `Failed to save ${field.label}`)
}
},
[profile, provider, saved]
)
// Providers without a declared config surface (e.g. builtin) render nothing.
if (config && config.fields.length === 0) {
return null
}
if (!config) {
if (loadError) {
return (
<div className="flex items-center justify-between gap-3 py-2">
<span className="text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
Memory provider settings failed to load: {loadError}
</span>
<Button onClick={() => void refresh()} size="sm" type="button" variant="secondary">
Retry
</Button>
</div>
)
}
return <PageLoader className="min-h-24" label="Loading memory provider settings..." />
}
const inlineFields = config.fields.filter(field => field.inline)
const secretFields = config.fields.filter(field => field.kind === 'secret')
const hasFullConfig = config.fields.some(field => !field.inline)
return (
<section className="py-1">
<div className="flex items-center gap-2 py-2">
<button
aria-expanded={expanded}
className="flex min-w-0 flex-1 items-center gap-2 text-left"
onClick={() => setExpanded(open => !open)}
type="button"
>
<DisclosureCaret open={expanded} />
<span className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{config.label} settings
</span>
{secretFields.map(field => (
<Pill key={field.key}>{field.is_set ? `${field.label} set` : `${field.label} not set`}</Pill>
))}
</button>
{hasFullConfig && (
<Button onClick={() => setShowModal(true)} size="sm" type="button" variant="secondary">
<SlidersHorizontal className="size-3.5" />
Full config
</Button>
)}
</div>
{expanded && (
<div className="ml-1.5 border-l-2 border-(--ui-accent-secondary)/25 pb-4 pl-4 pr-4">
{inlineFields.map(field => (
<div className="border-b border-border/40 last:border-b-0" key={field.key}>
<ListRow
action={
<FieldControl
field={field}
onChange={value => setValues(current => ({ ...current, [field.key]: value }))}
onCommit={value => void commitField(field, value)}
value={values[field.key] ?? ''}
/>
}
description={field.description}
title={<FieldTitle field={field} />}
/>
</div>
))}
</div>
)}
{hasFullConfig && (
<ProviderConfigModal
config={config}
onOpenChange={setShowModal}
onSaved={refresh}
open={showModal}
profile={profile}
provider={provider}
/>
)}
</section>
)
}
@@ -0,0 +1,636 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
// Radix Select calls scrollIntoView on its items when the content opens; jsdom
// doesn't implement it (nor hasPointerCapture / releasePointerCapture), so stub
// them to let the dropdown open in tests.
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
Element.prototype.hasPointerCapture = vi.fn(() => false)
Element.prototype.releasePointerCapture = vi.fn()
})
const getGlobalModelInfo = vi.fn()
const getGlobalModelOptions = vi.fn()
const getAuxiliaryModels = vi.fn()
const getMoaModels = vi.fn()
const setModelAssignment = vi.fn()
const getRecommendedDefaultModel = vi.fn()
const saveMoaModels = vi.fn()
const setEnvVar = vi.fn()
const getHermesConfigRecord = vi.fn()
const saveHermesConfig = vi.fn()
const startManualLocalEndpoint = vi.fn()
const startManualOnboarding = vi.fn()
const startManualProviderOAuth = vi.fn()
let profileSwitchHandler: (() => void) | null = null
vi.mock('@/hermes', () => ({
getGlobalModelInfo: (profile?: null | string) => getGlobalModelInfo(profile),
getGlobalModelOptions: (opts?: unknown, profile?: null | string) => getGlobalModelOptions(opts, profile),
getAuxiliaryModels: (profile?: null | string) => getAuxiliaryModels(profile),
getApiRequestProfile: () => 'default',
getMoaModels: (profile?: null | string) => getMoaModels(profile),
profileScopeKey: (scope?: null | string) => (scope ?? '').trim() || 'default',
setModelAssignment: (body: unknown) => setModelAssignment(body),
getRecommendedDefaultModel: (slug: string) => getRecommendedDefaultModel(slug),
saveMoaModels: (body: unknown) => saveMoaModels(body),
setEnvVar: (key: string, value: string) => setEnvVar(key, value),
getHermesConfigRecord: () => getHermesConfigRecord(),
saveHermesConfig: (config: unknown) => saveHermesConfig(config),
setApiRequestProfile: () => {}
}))
vi.mock('@/store/onboarding', () => ({
startManualLocalEndpoint: () => startManualLocalEndpoint(),
startManualOnboarding: () => startManualOnboarding(),
startManualProviderOAuth: (slug: string) => startManualProviderOAuth(slug)
}))
vi.mock('../hooks/use-on-profile-switch', () => ({
useOnProfileSwitch: (handler: () => void) => {
profileSwitchHandler = handler
}
}))
beforeEach(() => {
getGlobalModelInfo.mockResolvedValue({ provider: 'nous', model: 'hermes-4' })
getGlobalModelOptions.mockResolvedValue({
providers: [
{
name: 'Nous',
slug: 'nous',
models: ['hermes-4', 'hermes-4-mini'],
authenticated: true,
capabilities: { 'hermes-4': { reasoning: true, fast: true } }
}
]
})
getAuxiliaryModels.mockResolvedValue({
main: { provider: 'nous', model: 'hermes-4' },
tasks: [{ task: 'vision', provider: 'auto', model: '', base_url: '' }]
})
getMoaModels.mockResolvedValue(null)
setModelAssignment.mockResolvedValue({ ok: true, provider: 'nous', model: 'hermes-4', gateway_tools: [] })
getRecommendedDefaultModel.mockResolvedValue({ provider: 'nous', model: 'hermes-4', free_tier: null })
setEnvVar.mockResolvedValue({ ok: true })
getHermesConfigRecord.mockResolvedValue({ agent: { reasoning_effort: 'medium', service_tier: 'normal' } })
saveHermesConfig.mockResolvedValue({ ok: true })
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
profileSwitchHandler = null
})
async function renderModelSettings(scopeProfile?: string) {
const { ModelSettings } = await import('./model-settings')
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(
// The aux-task deep-link highlight reads useSearchParams, so the page
// needs a router context in tests (the app provides HashRouter at root).
<MemoryRouter>
<QueryClientProvider client={client}>
<ModelSettings scopeProfile={scopeProfile} />
</QueryClientProvider>
</MemoryRouter>
)
}
describe('ModelSettings profile scope', () => {
// #90549: the API helpers treat `null` as "deliberately target the
// primary/default profile". A page following the active profile must pass
// `undefined`, or every read repaints the primary's model and the user's
// change looks reverted.
it('follows the active profile (undefined, never null) when unscoped', async () => {
await renderModelSettings()
await waitFor(() => expect(getGlobalModelInfo).toHaveBeenCalledWith(undefined))
expect(getGlobalModelOptions).toHaveBeenCalledWith(undefined, undefined)
expect(getAuxiliaryModels).toHaveBeenCalledWith(undefined)
expect(getMoaModels).toHaveBeenCalledWith(undefined)
})
it('reads through the explicit scope override when one is set', async () => {
await renderModelSettings('research')
await waitFor(() => expect(getGlobalModelInfo).toHaveBeenCalledWith('research'))
expect(getGlobalModelOptions).toHaveBeenCalledWith(undefined, 'research')
expect(getAuxiliaryModels).toHaveBeenCalledWith('research')
expect(getMoaModels).toHaveBeenCalledWith('research')
})
})
describe('ModelSettings', () => {
it('loads the current main model and lists configured providers only', async () => {
await renderModelSettings()
await waitFor(() => expect(getGlobalModelInfo).toHaveBeenCalled())
await waitFor(() => expect(getGlobalModelOptions).toHaveBeenCalled())
// Open the provider Select — only configured providers should be listed.
const triggers = await screen.findAllByRole('combobox')
fireEvent.click(triggers[0])
// "Nous" shows in both the trigger and the open list.
expect((await screen.findAllByText('Nous')).length).toBeGreaterThan(0)
expect(screen.queryByText(/DeepSeek/)).toBeNull()
})
it.each(['custom', 'local', 'custom:lab'])(
'opens local endpoint setup when %s has no inventory row',
async provider => {
getGlobalModelInfo.mockResolvedValueOnce({ provider, model: '' })
getGlobalModelOptions.mockResolvedValueOnce({ providers: [] })
await renderModelSettings()
const providerSelect = (await screen.findAllByRole('combobox'))[0]
expect(providerSelect.textContent).toContain(provider)
expect(screen.queryByText(/undefined/)).toBeNull()
expect(screen.queryByText(/signs in through your browser/)).toBeNull()
fireEvent.click(await screen.findByRole('button', { name: 'Set up provider' }))
expect(startManualLocalEndpoint).toHaveBeenCalledOnce()
expect(startManualOnboarding).not.toHaveBeenCalled()
expect(startManualProviderOAuth).not.toHaveBeenCalled()
}
)
it('opens the generic provider picker for an unknown provider with no inventory row', async () => {
getGlobalModelInfo.mockResolvedValueOnce({ provider: 'retired-provider', model: '' })
getGlobalModelOptions.mockResolvedValueOnce({ providers: [] })
await renderModelSettings()
fireEvent.click(await screen.findByRole('button', { name: 'Set up provider' }))
expect(startManualOnboarding).toHaveBeenCalledOnce()
expect(startManualLocalEndpoint).not.toHaveBeenCalled()
expect(startManualProviderOAuth).not.toHaveBeenCalled()
})
it('deep-links a known OAuth provider row into its setup flow', async () => {
getGlobalModelInfo.mockResolvedValueOnce({ provider: 'anthropic', model: '' })
getGlobalModelOptions.mockResolvedValueOnce({
providers: [
{
name: 'Anthropic',
slug: 'anthropic',
models: [],
authenticated: false,
auth_type: 'oauth'
}
]
})
await renderModelSettings()
fireEvent.click(await screen.findByRole('button', { name: 'Set up Anthropic' }))
expect(startManualProviderOAuth).toHaveBeenCalledWith('anthropic')
expect(startManualLocalEndpoint).not.toHaveBeenCalled()
expect(startManualOnboarding).not.toHaveBeenCalled()
})
it('replaces the selected provider and model when the active profile changes', async () => {
getGlobalModelInfo
.mockResolvedValueOnce({ provider: 'custom', model: 'local-a' })
.mockResolvedValueOnce({ provider: 'nous', model: 'hermes-4' })
getGlobalModelOptions
.mockResolvedValueOnce({
providers: [
{
name: 'Custom A',
slug: 'custom',
models: ['local-a'],
authenticated: true
}
]
})
.mockResolvedValueOnce({
providers: [
{
name: 'Nous',
slug: 'nous',
models: ['hermes-4'],
authenticated: true,
capabilities: { 'hermes-4': { reasoning: true, fast: true } }
}
]
})
await renderModelSettings()
expect((await screen.findAllByRole('combobox'))[0].textContent).toContain('Custom A')
await act(async () => {
profileSwitchHandler?.()
})
await waitFor(() => expect(getGlobalModelInfo).toHaveBeenCalledTimes(2))
await waitFor(() => expect(screen.getAllByRole('combobox')[0].textContent).toContain('Nous'))
expect(screen.queryByRole('button', { name: 'Set up provider' })).toBeNull()
})
it('preserves a user-defined provider endpoint when applying the main model', async () => {
getGlobalModelOptions.mockResolvedValueOnce({
providers: [
{
name: 'Nous',
slug: 'nous',
models: ['hermes-4'],
authenticated: true
},
{
name: 'Ollama',
slug: 'local-ollama',
models: ['qwen3:latest'],
authenticated: true,
is_user_defined: true,
api_url: 'http://localhost:11434/v1'
}
]
})
setModelAssignment.mockResolvedValueOnce({
ok: true,
provider: 'local-ollama',
model: 'qwen3:latest',
gateway_tools: []
})
await renderModelSettings()
const providerSelect = (await screen.findAllByRole('combobox'))[0]
fireEvent.click(providerSelect)
fireEvent.click(await screen.findByRole('option', { name: 'Ollama' }))
const modelSelect = (await screen.findAllByRole('combobox'))[1]
fireEvent.click(modelSelect)
fireEvent.click(await screen.findByRole('option', { name: 'qwen3:latest' }))
fireEvent.click(await screen.findByRole('button', { name: 'Apply' }))
await waitFor(() =>
expect(setModelAssignment).toHaveBeenCalledWith({
model: 'qwen3:latest',
provider: 'local-ollama',
scope: 'main',
base_url: 'http://localhost:11434/v1'
})
)
})
it('writes the profile default speed (service_tier) when the fast switch is toggled', async () => {
await renderModelSettings()
await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled())
const fastSwitch = await screen.findByRole('switch')
fireEvent.click(fastSwitch)
await waitFor(() =>
expect(saveHermesConfig).toHaveBeenCalledWith(
expect.objectContaining({ agent: expect.objectContaining({ service_tier: 'fast' }) })
)
)
})
it('hides the reasoning/speed defaults when the main model reports no capabilities', async () => {
getGlobalModelOptions.mockResolvedValueOnce({
providers: [
{
name: 'Nous',
slug: 'nous',
models: ['hermes-4'],
authenticated: true,
capabilities: { 'hermes-4': { reasoning: false, fast: false } }
}
]
})
await renderModelSettings()
await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled())
expect(screen.queryByRole('switch')).toBeNull()
})
it('renders the auxiliary task rows', async () => {
await renderModelSettings()
expect(await screen.findByText('Vision')).toBeTruthy()
expect(screen.getAllByText('auto · use main model').length).toBeGreaterThan(0)
})
it('assigns an auxiliary task to the main model via setModelAssignment', async () => {
await renderModelSettings()
// One "Set to main" button per task slot; the first is Vision.
const setToMainButtons = await screen.findAllByRole('button', { name: 'Set to main' })
fireEvent.click(setToMainButtons[0])
await waitFor(() =>
expect(setModelAssignment).toHaveBeenCalledWith({
model: 'hermes-4',
provider: 'nous',
scope: 'auxiliary',
task: 'vision'
})
)
})
it('carries the user-defined endpoint when an aux slot is set to a local main model', async () => {
getGlobalModelOptions.mockResolvedValueOnce({
providers: [
{
name: 'Ollama',
slug: 'local-ollama',
models: ['qwen3:latest'],
authenticated: true,
is_user_defined: true,
api_url: 'http://localhost:11434/v1'
}
]
})
getGlobalModelInfo.mockResolvedValueOnce({ provider: 'local-ollama', model: 'qwen3:latest' })
getAuxiliaryModels.mockResolvedValueOnce({
main: { provider: 'local-ollama', model: 'qwen3:latest' },
tasks: [{ task: 'vision', provider: 'auto', model: '', base_url: '' }]
})
await renderModelSettings()
const setToMainButtons = await screen.findAllByRole('button', { name: 'Set to main' })
fireEvent.click(setToMainButtons[0])
await waitFor(() =>
expect(setModelAssignment).toHaveBeenCalledWith({
model: 'qwen3:latest',
provider: 'local-ollama',
scope: 'auxiliary',
task: 'vision',
base_url: 'http://localhost:11434/v1'
})
)
})
it('warns when a main switch leaves auxiliary tasks pinned to another provider', async () => {
setModelAssignment.mockResolvedValueOnce({
ok: true,
provider: 'openrouter',
model: 'anthropic/claude-opus-4.7',
gateway_tools: [],
stale_aux: [{ task: 'compression', provider: 'nous', model: 'hermes-4' }]
})
await renderModelSettings()
await waitFor(() => expect(getGlobalModelInfo).toHaveBeenCalled())
const applyButton = await screen.findByRole('button', { name: 'Apply' })
fireEvent.click(applyButton)
// The switch-time notice names the pinned provider and offers a reset.
expect(await screen.findByText(/still run on/)).toBeTruthy()
expect(screen.getByText('nous')).toBeTruthy()
})
it('shows a persistent banner when a loaded aux slot mismatches the main provider', async () => {
getAuxiliaryModels.mockResolvedValueOnce({
main: { provider: 'nous', model: 'hermes-4' },
tasks: [{ task: 'curator', provider: 'openrouter', model: 'anthropic/claude-opus-4.7', base_url: '' }]
})
await renderModelSettings()
// Banner present on load, no switch required.
expect(await screen.findByText(/still run on/)).toBeTruthy()
})
})
describe('ModelSettings MoA preset editor', () => {
const moaConfig = () => ({
default_preset: 'default',
active_preset: '',
presets: {
default: {
reference_models: [
{ provider: 'nous', model: 'hermes-4' },
{ provider: 'openrouter', model: 'deepseek/deepseek-v4-pro' }
],
aggregator: { provider: 'openrouter', model: 'anthropic/claude-opus-4.8' },
reference_temperature: 0,
aggregator_temperature: 0,
max_tokens: 4096,
enabled: true
}
},
reference_models: [
{ provider: 'nous', model: 'hermes-4' },
{ provider: 'openrouter', model: 'deepseek/deepseek-v4-pro' }
],
aggregator: { provider: 'openrouter', model: 'anthropic/claude-opus-4.8' },
reference_temperature: 0,
aggregator_temperature: 0,
max_tokens: 4096,
enabled: true
})
beforeEach(() => {
getGlobalModelOptions.mockResolvedValue({
providers: [
{
name: 'Nous',
slug: 'nous',
models: ['hermes-4', 'hermes-4-mini'],
authenticated: true,
capabilities: { 'hermes-4': { reasoning: true, fast: true } }
},
{
name: 'OpenRouter',
slug: 'openrouter',
models: ['deepseek/deepseek-v4-pro', 'anthropic/claude-opus-4.8'],
authenticated: true
}
]
})
getMoaModels.mockResolvedValue(moaConfig())
saveMoaModels.mockImplementation((body: unknown) => Promise.resolve(body))
})
async function openReferenceEditor() {
await renderModelSettings()
expect(await screen.findByText('Reference 1')).toBeTruthy()
}
function slotSelects() {
// Combobox order in the MoA section (last 7 on the page): preset select,
// then provider+model per reference (2 refs), then aggregator
// provider+model. Reference 1's pair is therefore at -6 / -5.
const all = screen.getAllByRole('combobox')
return { ref1Provider: all.at(-6)!, ref1Model: all.at(-5)! }
}
it('holds the autosave while a slot is half-filled (provider changed, model pending)', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
try {
await openReferenceEditor()
fireEvent.click(slotSelects().ref1Provider)
fireEvent.click(await screen.findByRole('option', { name: 'OpenRouter' }))
// Model was cleared by the provider change → config incomplete → the
// debounced autosave must NOT fire, even well past the 600ms window.
await vi.advanceTimersByTimeAsync(2000)
expect(saveMoaModels).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('saves once the model pick completes the slot', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
try {
await openReferenceEditor()
fireEvent.click(slotSelects().ref1Provider)
fireEvent.click(await screen.findByRole('option', { name: 'OpenRouter' }))
await vi.advanceTimersByTimeAsync(700)
fireEvent.click(slotSelects().ref1Model)
fireEvent.click(await screen.findByRole('option', { name: 'anthropic/claude-opus-4.8' }))
await vi.advanceTimersByTimeAsync(700)
expect(saveMoaModels).toHaveBeenCalledTimes(1)
const sent = saveMoaModels.mock.calls[0][0] as ReturnType<typeof moaConfig>
expect(sent.presets.default.reference_models[0]).toEqual({
provider: 'openrouter',
model: 'anthropic/claude-opus-4.8'
})
// The untouched slots ride along unchanged — nothing reverts to defaults.
expect(sent.presets.default.reference_models[1]).toEqual({
provider: 'openrouter',
model: 'deepseek/deepseek-v4-pro'
})
expect(sent.presets.default.aggregator).toEqual({
provider: 'openrouter',
model: 'anthropic/claude-opus-4.8'
})
} finally {
vi.useRealTimers()
}
})
it('does not clear the model or save when the same provider is re-selected', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
try {
await openReferenceEditor()
fireEvent.click(slotSelects().ref1Provider)
fireEvent.click(await screen.findByRole('option', { name: 'Nous' }))
await vi.advanceTimersByTimeAsync(700)
// Radix treats re-picking the current value as a no-op (no
// onValueChange), so nothing changes: no save, model still shown.
expect(saveMoaModels).not.toHaveBeenCalled()
expect(screen.getByText('nous · hermes-4')).toBeTruthy()
} finally {
vi.useRealTimers()
}
})
it('autosaves the selected preset when its enabled switch is toggled', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
try {
await openReferenceEditor()
fireEvent.click(screen.getByRole('switch', { name: 'Enabled' }))
await vi.advanceTimersByTimeAsync(700)
expect(saveMoaModels).toHaveBeenCalledWith(
expect.objectContaining({
presets: expect.objectContaining({
default: expect.objectContaining({ enabled: false })
})
})
)
} finally {
vi.useRealTimers()
}
})
it('saves a disabled reference model without removing it (per-slot enabled toggle)', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
try {
await openReferenceEditor()
fireEvent.click(screen.getByRole('switch', { name: 'Disable reference 1' }))
await vi.advanceTimersByTimeAsync(700)
expect(saveMoaModels).toHaveBeenCalledWith(
expect.objectContaining({
presets: expect.objectContaining({
default: expect.objectContaining({
reference_models: [
expect.objectContaining({ provider: 'nous', model: 'hermes-4', enabled: false }),
expect.objectContaining({ provider: 'openrouter', model: 'deepseek/deepseek-v4-pro' })
]
})
})
})
)
} finally {
vi.useRealTimers()
}
})
})
describe('ModelSettings code-skew 503', () => {
const skewError = new Error(
'Error invoking remote method \'hermes:api\': Error: 503: {"detail":"Restart required: This process is running code from 08b4875f4a but the checkout on disk is now 48d2528066. The model picker would risk a stale-module crash — restart the Desktop-owned backend to load the new code (use Restart backend in Hermes Desktop, or quit and reopen the app)"}'
)
afterEach(() => {
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
it('unwraps the stale-backend 503 instead of dumping IPC JSON', async () => {
getGlobalModelOptions.mockRejectedValueOnce(skewError)
await renderModelSettings()
await waitFor(() => {
expect(screen.getByText(/running old code after an update/i)).toBeTruthy()
})
expect(screen.getByRole('button', { name: 'Restart backend' })).toBeTruthy()
expect(screen.queryByText(/hermes:api/)).toBeNull()
expect(screen.queryByText(/systemctl/)).toBeNull()
})
it('recycles the Desktop-owned backend and reloads the catalog', async () => {
const recycleBackend = vi.fn().mockResolvedValue({ ok: true })
;(window as unknown as { hermesDesktop: { recycleBackend: typeof recycleBackend } }).hermesDesktop = {
recycleBackend
}
getGlobalModelOptions.mockRejectedValueOnce(skewError)
await renderModelSettings()
await waitFor(() => expect(screen.getByRole('button', { name: 'Restart backend' })).toBeTruthy())
fireEvent.click(screen.getByRole('button', { name: 'Restart backend' }))
await waitFor(() => expect(recycleBackend).toHaveBeenCalledWith(undefined))
await waitFor(() => expect(getGlobalModelOptions.mock.calls.length).toBeGreaterThan(1))
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,119 @@
import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useI18n } from '@/i18n'
import { COMPLETION_SOUND_VARIANTS, previewCompletionSound } from '@/lib/completion-sound'
import { triggerHaptic } from '@/lib/haptics'
import { Bell, Play } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $completionSoundVariantId, setCompletionSoundVariantId } from '@/store/completion-sound'
import {
$nativeNotifyPrefs,
NATIVE_NOTIFICATION_KINDS,
sendTestNativeNotification,
setNativeNotifyEnabled,
setNativeNotifyKind
} from '@/store/native-notifications'
import { notify } from '@/store/notifications'
import { CONTROL_TEXT } from './constants'
import { ListRow, SectionHeading, SettingsContent, ToggleRow } from './primitives'
const CAPTION = 'text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)'
function Caption({ children, className }: { children: ReactNode; className?: string }) {
return <p className={cn(CAPTION, className)}>{children}</p>
}
export function NotificationsSettings() {
const { t } = useI18n()
const prefs = useStore($nativeNotifyPrefs)
const completionSoundVariantId = useStore($completionSoundVariantId)
const copy = t.settings.notifications
const runTest = async () => {
triggerHaptic('open')
const ok = await sendTestNativeNotification(copy.testTitle, copy.testBody)
notify({ kind: ok ? 'info' : 'error', message: ok ? copy.testSent : copy.testUnsupported })
}
return (
<SettingsContent>
<SectionHeading icon={Bell} title={copy.title} />
<Caption className="mb-2 leading-(--conversation-caption-line-height)">{copy.intro}</Caption>
<ToggleRow
checked={prefs.enabled}
description={copy.enableAllDesc}
label={copy.enableAll}
onChange={setNativeNotifyEnabled}
/>
{NATIVE_NOTIFICATION_KINDS.map(kind => (
<ToggleRow
checked={prefs.enabled && prefs.kinds[kind]}
description={copy.kinds[kind].description}
disabled={!prefs.enabled}
key={kind}
label={copy.kinds[kind].label}
onChange={on => setNativeNotifyKind(kind, on)}
/>
))}
<ListRow
action={
<div className="flex flex-wrap items-center justify-end gap-2">
<Select
onValueChange={value => {
const variantId = Number.parseInt(value, 10)
setCompletionSoundVariantId(variantId)
previewCompletionSound(variantId)
triggerHaptic('selection')
}}
value={String(completionSoundVariantId)}
>
<SelectTrigger className={cn('min-w-56', CONTROL_TEXT)}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{COMPLETION_SOUND_VARIANTS.map(variant => (
<SelectItem key={variant.id} value={String(variant.id)}>
{variant.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
className="gap-1.5"
onClick={() => {
previewCompletionSound()
triggerHaptic('crisp')
}}
size="sm"
type="button"
variant="outline"
>
<Play className="size-3.5" />
{copy.completionSoundPreview}
</Button>
</div>
}
description={copy.completionSoundDesc}
title={copy.completionSoundTitle}
/>
<div className="mt-4 flex flex-col gap-2">
<Button className="self-start" onClick={() => void runTest()} size="sm" type="button" variant="outline">
<Bell />
{copy.test}
</Button>
<Caption>{copy.focusedHint}</Caption>
</div>
</SettingsContent>
)
}
@@ -0,0 +1,403 @@
import { useStore } from '@nanostores/react'
import { type ReactNode, useEffect, useState } from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { PetThumb } from '@/components/pet/pet-thumb'
import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { Skeleton } from '@/components/ui/skeleton'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Download, Loader2, PawPrint, Pencil, Trash2 } from '@/lib/icons'
import { selectableCardClass } from '@/lib/selectable-card'
import { cn } from '@/lib/utils'
import { $petInfo, $petRoam, setPetRoam } from '@/store/pet'
import {
$petBusy,
$petGallery,
$petGalleryError,
$petGalleryStatus,
adoptPet,
exportPet as exportPetAction,
type GalleryPet,
loadPetGallery,
loadPetThumb,
PET_SCALE_DEFAULT,
PET_SCALE_MAX,
PET_SCALE_MIN,
rankedGalleryPets,
removePet as removePetAction,
renamePet as renamePetAction,
setPetEnabled,
setPetScale
} from '@/store/pet-gallery'
import { $gatewayState } from '@/store/session'
import { ListRow, SectionHeading } from './primitives'
/**
* Appearance opt-in for the floating petdex mascot. A thin view over the shared
* `pet-gallery` store — it subscribes to the atoms and calls the store actions,
* so the gallery is fetched once + cached and adopt/toggle/remove patch local
* state instead of re-pulling the network gallery. The floating mascot polls
* `pet.info`, so picking a pet here lights it up within a couple seconds.
*/
export function PetSettings() {
const { t } = useI18n()
const copy = t.settings.appearance.pet
const { requestGateway } = useGatewayRequest()
const gatewayState = useStore($gatewayState)
const gallery = useStore($petGallery)
const status = useStore($petGalleryStatus)
const error = useStore($petGalleryError)
const busySlug = useStore($petBusy)
const petInfo = useStore($petInfo)
const roam = useStore($petRoam)
const [query, setQuery] = useState('')
const [confirmDelete, setConfirmDelete] = useState<GalleryPet | null>(null)
const [renameTarget, setRenameTarget] = useState<GalleryPet | null>(null)
const [renameValue, setRenameValue] = useState('')
const scale = petInfo.scale ?? PET_SCALE_DEFAULT
useEffect(() => {
if (gatewayState !== 'open') {
return
}
void loadPetGallery(requestGateway)
}, [gatewayState, requestGateway])
const enabled = gallery?.enabled ?? false
const active = gallery?.active ?? ''
const pets = gallery?.pets ?? []
const staleBackend = status === 'stale'
const selectPet = (slug: string) => {
void adoptPet(requestGateway, slug, copy.adoptFailed(slug)).then(ok => ok && triggerHaptic('crisp'))
}
const removePet = (slug: string) => {
void removePetAction(requestGateway, slug, copy.uninstallFailed(slug)).then(ok => ok && triggerHaptic('crisp'))
}
const exportPet = (slug: string) => {
void exportPetAction(requestGateway, slug, copy.exportFailed(slug)).then(ok => ok && triggerHaptic('crisp'))
}
const saveRename = () => {
if (!renameTarget || !renameValue.trim()) {
return
}
// Optimistic: the rename paints instantly, so close now and let the RPC
// settle in the background (it rolls back + surfaces an error on failure).
const { slug } = renameTarget
setRenameTarget(null)
triggerHaptic('crisp')
void renamePetAction(requestGateway, slug, renameValue, copy.renameFailed(slug))
}
const toggle = (on: boolean) => {
void setPetEnabled(requestGateway, on, {
noneAvailable: copy.noneAvailable,
fallback: on ? copy.turnOnFailed : copy.turnOffFailed
}).then(ok => ok && triggerHaptic('crisp'))
}
// The petdex catalog is thousands of entries, so rank + cap how many render.
const RENDER_CAP = 60
const sorted = rankedGalleryPets(gallery, query)
const shown = sorted.slice(0, RENDER_CAP)
return (
<div>
<SectionHeading icon={PawPrint} title={copy.title} />
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{copy.intro}
</p>
{staleBackend && (
<p className="mt-2 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{copy.restartHint}
</p>
)}
<div className="mt-2">
<ListRow
below={
<>
<input
className="mt-3 w-full rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-1.5 text-[length:var(--conversation-caption-font-size)] outline-none placeholder:text-(--ui-text-tertiary) focus:border-(--ui-stroke-secondary)"
onChange={event => setQuery(event.target.value)}
placeholder={copy.searchPlaceholder}
spellCheck={false}
value={query}
/>
{/* Fixed-height scroll area so filtering never grows/shrinks the
page (no layout thrash); the grid scrolls inside it. */}
<div className="mt-3 h-72 overflow-y-auto pr-1">
{status === 'loading' && pets.length === 0 ? (
// First load keeps the grid's shape rather than flashing the
// "unreachable" copy before the gallery has even arrived.
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }, (_, i) => (
<div className="flex items-center gap-2.5 px-2.5 py-2" key={i}>
<Skeleton className="size-10 shrink-0 rounded-md" />
<div className="min-w-0 flex-1 space-y-1.5">
<Skeleton className="h-3.5 w-24 max-w-full" />
<Skeleton className="h-3 w-16 max-w-full" />
</div>
</div>
))}
</div>
) : pets.length === 0 ? (
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{copy.unreachable}
</p>
) : shown.length === 0 ? (
<p className="wrap-anywhere text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{copy.noMatch(query)}
</p>
) : (
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
{shown.map(pet => {
const isActive = enabled && active === pet.slug
const isBusy = busySlug === pet.slug
return (
<div className="group relative" key={pet.slug}>
<button
className={cn(
'flex w-full items-center gap-2.5 px-2.5 py-2 text-left disabled:opacity-50',
selectableCardClass({ active: isActive, prominent: pet.installed })
)}
disabled={isBusy}
onClick={() => void selectPet(pet.slug)}
type="button"
>
<PetThumb
alt={pet.displayName}
load={(slug, url) => loadPetThumb(requestGateway, slug, url)}
slug={pet.slug}
url={pet.spritesheetUrl}
/>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
<span className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
{pet.displayName}
</span>
{pet.generated && (
<span className="shrink-0 rounded-full bg-primary/15 px-1.5 py-px text-[0.625rem] font-medium text-primary">
{copy.generatedTag}
</span>
)}
</span>
<span className="block truncate text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{pet.slug}
{pet.installed ? ` · ${copy.installedTag}` : ''}
</span>
</span>
{isBusy && <Loader2 className="size-4 shrink-0 animate-spin text-(--ui-text-tertiary)" />}
</button>
{!isBusy && (pet.installed || pet.generated) && (
<div className="absolute right-1.5 top-1.5 flex gap-1 opacity-0 transition focus-within:opacity-100 group-hover:opacity-100">
{pet.generated && (
<PetAction
icon={<Pencil className="size-3.5" />}
label={copy.rename(pet.displayName)}
onClick={() => {
setRenameValue(pet.displayName)
setRenameTarget(pet)
}}
/>
)}
{pet.generated && (
<PetAction
icon={<Download className="size-3.5" />}
label={copy.exportPet(pet.displayName)}
onClick={() => exportPet(pet.slug)}
/>
)}
{pet.installed && (
// Generated pets have no remote source — deletion is
// permanent, so confirm; petdex pets just uninstall.
<PetAction
danger
icon={<Trash2 className="size-3.5" />}
label={pet.generated ? copy.delete(pet.displayName) : copy.uninstall(pet.displayName)}
onClick={() => (pet.generated ? setConfirmDelete(pet) : removePet(pet.slug))}
/>
)}
</div>
)}
</div>
)
})}
</div>
)}
</div>
{/* Always-present status line so its appearance never shifts layout. */}
<p className="mt-2 min-h-4 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{error ? (
<span className="text-(--ui-red)">{error}</span>
) : sorted.length > RENDER_CAP ? (
copy.countCapped(RENDER_CAP, sorted.length)
) : (
copy.count(sorted.length)
)}
</p>
</>
}
description={copy.chooseDesc}
title={
<div className="flex items-center justify-between gap-3">
<span>{copy.chooseTitle}</span>
<SegmentedControl
onChange={id => void toggle(id === 'on')}
options={[
{ id: 'off', label: copy.off },
{ id: 'on', label: copy.on }
]}
value={enabled ? 'on' : 'off'}
/>
</div>
}
wide
/>
{enabled && (
<ListRow
action={
<div className="flex items-center gap-3">
<input
aria-label={copy.scaleTitle}
className="h-1 w-40 cursor-pointer appearance-none rounded-full bg-(--ui-stroke-tertiary)"
max={PET_SCALE_MAX}
min={PET_SCALE_MIN}
onChange={event => {
triggerHaptic('selection')
setPetScale(requestGateway, Number(event.target.value))
}}
step={0.05}
style={{ accentColor: 'var(--dt-primary)' }}
type="range"
value={scale}
/>
<span className="w-9 text-right text-[length:var(--conversation-caption-font-size)] tabular-nums text-(--ui-text-tertiary)">
{`${Math.round(scale * 100)}%`}
</span>
</div>
}
description={copy.scaleDesc}
title={copy.scaleTitle}
/>
)}
{enabled && (
<ListRow
action={
<SegmentedControl
onChange={id => {
setPetRoam(id === 'on')
triggerHaptic('crisp')
}}
options={[
{ id: 'off', label: copy.off },
{ id: 'on', label: copy.on }
]}
value={roam ? 'on' : 'off'}
/>
}
description={copy.roamDesc}
title={copy.roamTitle}
/>
)}
</div>
<ConfirmDialog
confirmLabel={copy.deleteConfirm}
description={copy.deleteBody}
destructive
onClose={() => setConfirmDelete(null)}
onConfirm={async () => {
if (confirmDelete) {
const ok = await removePetAction(
requestGateway,
confirmDelete.slug,
copy.uninstallFailed(confirmDelete.slug)
)
if (!ok) {
throw new Error(copy.uninstallFailed(confirmDelete.slug))
}
triggerHaptic('crisp')
}
}}
open={confirmDelete !== null}
title={confirmDelete ? copy.deleteTitle(confirmDelete.displayName) : ''}
/>
<Dialog onOpenChange={open => !open && setRenameTarget(null)} open={renameTarget !== null}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{copy.renameTitle}</DialogTitle>
</DialogHeader>
<Input
autoFocus
onChange={event => setRenameValue(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
saveRename()
}
}}
placeholder={copy.renamePlaceholder}
value={renameValue}
/>
<DialogFooter>
<Button onClick={() => setRenameTarget(null)} type="button" variant="ghost">
{t.common.cancel}
</Button>
<Button disabled={!renameValue.trim()} onClick={saveRename}>
{copy.renameSave}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
/** A single hover-revealed icon action on a pet card (rename / export / delete). */
function PetAction({
danger,
icon,
label,
onClick
}: {
danger?: boolean
icon: ReactNode
label: string
onClick: () => void
}) {
return (
<Tip label={label}>
<button
aria-label={label}
className={cn(
'grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) backdrop-blur-sm transition',
danger ? 'hover:text-(--ui-red)' : 'hover:text-foreground'
)}
onClick={onClick}
type="button"
>
{icon}
</button>
</Tip>
)
}
@@ -0,0 +1,413 @@
import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useLocation, useNavigate } from 'react-router'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { NEW_CHAT_ROUTE, SETTINGS_ROUTE } from '@/app/routes'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
preventCloseButtonAutoFocus
} from '@/components/ui/dialog'
import { Switch } from '@/components/ui/switch'
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
import { useI18n } from '@/i18n'
import { ExternalLink } from '@/lib/external-link'
import { AlertTriangle } from '@/lib/icons'
import { resolvePluginSourceLinks } from '@/lib/plugin-source-urls'
import { installAgentPlugin, loadAgentPlugins } from '@/store/agent-plugins'
import { notify } from '@/store/notifications'
import {
$pluginInstallRequest,
closePluginInstallRequest,
type PluginInstallRequest
} from '@/store/plugin-install-request'
import { $activeGatewayProfile, $profileScope } from '@/store/profile'
import { $connection } from '@/store/session'
type ProbeResult = Awaited<ReturnType<NonNullable<NonNullable<Window['hermesDesktop']>['probePluginRepo']>>>
type ProbePhase = 'idle' | 'probing' | 'ready' | 'error'
export function PluginInstallModal() {
const request = useStore($pluginInstallRequest)
const { t } = useI18n()
const m = t.settings.plugins.installModal
const { requestGateway } = useGatewayRequest()
const navigate = useNavigate()
const location = useLocation()
const onSettings = location.pathname.startsWith(SETTINGS_ROUTE)
const connection = useStore($connection)
const activeProfile = useStore($activeGatewayProfile)
const profileScope = useStore($profileScope)
const [phase, setPhase] = useState<ProbePhase>('idle')
const [probe, setProbe] = useState<ProbeResult | null>(null)
const [installAgent, setInstallAgent] = useState(true)
const [installDesktop, setInstallDesktop] = useState(true)
const [enableAgent, setEnableAgent] = useState(true)
const [forceReinstall, setForceReinstall] = useState(false)
const [installing, setInstalling] = useState(false)
const [installError, setInstallError] = useState<string | null>(null)
const probeToken = useRef(0)
const resetState = useCallback(() => {
setPhase('idle')
setProbe(null)
setInstallAgent(true)
setInstallDesktop(true)
setEnableAgent(true)
setForceReinstall(false)
setInstalling(false)
setInstallError(null)
}, [])
const applyLegacyHint = useCallback((payload: PluginInstallRequest, detected: ProbeResult) => {
if (payload.legacyHint === 'agent') {
setInstallAgent(Boolean(detected.agent))
setInstallDesktop(false)
} else if (payload.legacyHint === 'desktop') {
setInstallAgent(false)
setInstallDesktop(Boolean(detected.desktop))
} else {
setInstallAgent(Boolean(detected.agent))
setInstallDesktop(Boolean(detected.desktop))
}
}, [])
const runProbe = useCallback(
async (payload: PluginInstallRequest) => {
const token = ++probeToken.current
setPhase('probing')
setProbe(null)
setInstallError(null)
setEnableAgent(payload.enable ?? true)
setForceReinstall(payload.force ?? false)
const probeFn = window.hermesDesktop?.probePluginRepo
if (!probeFn) {
if (token !== probeToken.current) {
return
}
setPhase('error')
setProbe({
ok: false,
agent: false,
desktop: false,
warnings: [],
error: m.probeUnavailable
})
return
}
const result = await probeFn({ identifier: payload.repo })
if (token !== probeToken.current) {
return
}
setProbe(result)
if (!result.ok) {
setPhase('error')
return
}
applyLegacyHint(payload, result)
setPhase('ready')
},
[applyLegacyHint, m.probeUnavailable]
)
useEffect(() => {
if (request && onSettings) {
navigate(NEW_CHAT_ROUTE)
}
}, [request, onSettings, navigate])
useEffect(() => {
if (!request) {
resetState()
return
}
void runProbe(request)
}, [request, resetState, runProbe])
const profileLabel = activeProfile || profileScope || 'default'
const agentTargetHint =
connection?.mode === 'remote' ? m.agentTargetRemote(profileLabel) : m.agentTargetLocal(profileLabel)
const sourceLinks = useMemo(() => (request ? resolvePluginSourceLinks(request.repo) : null), [request])
const handleClose = () => {
if (installing) {
return
}
probeToken.current += 1
closePluginInstallRequest()
}
const handleInstall = async () => {
if (!request || !probe?.ok || installing) {
return
}
if (!installAgent && !installDesktop) {
setInstallError(m.selectComponent)
return
}
setInstalling(true)
setInstallError(null)
const errors: string[] = []
const successes: string[] = []
try {
if (installAgent && probe.agent) {
const result = await installAgentPlugin(requestGateway, {
identifier: request.repo,
force: forceReinstall,
enable: enableAgent
})
if (result.ok) {
successes.push(m.agentSuccess(result.pluginName ?? request.repo))
if (result.missingEnv?.length) {
notify({
kind: 'warning',
message: m.missingEnv(result.missingEnv.join(', '))
})
}
for (const warning of result.warnings ?? []) {
notify({ kind: 'warning', message: warning })
}
} else {
errors.push(result.error || m.agentFailed)
}
}
if (installDesktop && probe.desktop) {
const installFn = window.hermesDesktop?.installDesktopPlugin
if (!installFn) {
errors.push(m.desktopUnavailable)
} else {
const result = await installFn({ identifier: request.repo, force: forceReinstall })
if (result.ok) {
successes.push(m.desktopSuccess(result.pluginName ?? request.repo))
await discoverRuntimePlugins()
} else {
errors.push(result.error || m.desktopFailed)
}
}
}
await loadAgentPlugins(requestGateway)
if (errors.length === 0) {
for (const message of successes) {
notify({ kind: 'success', message })
}
closePluginInstallRequest()
navigate('/settings?tab=plugins')
return
}
if (successes.length > 0) {
for (const message of successes) {
notify({ kind: 'success', message })
}
}
setInstallError(errors.join('\n'))
} finally {
setInstalling(false)
}
}
const open = request !== null && !onSettings
const busy = phase === 'probing' || installing
return (
<Dialog
onOpenChange={next => {
if (!next) {
handleClose()
}
}}
open={open}
>
<DialogContent className="max-w-lg" onOpenAutoFocus={preventCloseButtonAutoFocus}>
<DialogHeader>
<DialogTitle>{m.title}</DialogTitle>
<DialogDescription>{m.description}</DialogDescription>
</DialogHeader>
{request && (
<div className="space-y-4">
<div>
<div className="mb-1 text-[length:var(--conversation-caption-font-size)] font-medium text-foreground">
{m.repoLabel}
</div>
<div className="rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-2 font-mono text-[length:var(--conversation-caption-font-size)] break-all text-foreground">
{request.repo}
</div>
</div>
<div className="space-y-3 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-2.5">
<div className="space-y-2 text-[length:var(--conversation-caption-font-size)]">
<div className="font-medium text-foreground">{m.securityHeading}</div>
<p className="text-(--ui-text-secondary)">{m.securityIntro}</p>
</div>
{sourceLinks && (
<div className="space-y-2 border-t border-(--ui-stroke-tertiary) pt-3">
<div className="font-medium text-foreground">{m.sourceHeading}</div>
{sourceLinks.browseUrl && (
<ExternalLink
className="text-[length:var(--conversation-caption-font-size)]"
href={sourceLinks.browseUrl}
showExternalIcon
>
{sourceLinks.subdir ? m.viewPluginFiles : m.viewRepository}
</ExternalLink>
)}
<div>
<div className="mb-1 text-(--ui-text-tertiary)">{m.gitCloneLabel}</div>
<div className="rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-bg-primary) px-2.5 py-1.5 font-mono break-all text-foreground">
{sourceLinks.gitUrl}
</div>
</div>
</div>
)}
</div>
{phase === 'probing' && (
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{m.probing}
</p>
)}
{phase === 'error' && probe?.error && (
<p className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-[length:var(--conversation-caption-font-size)] text-destructive">
{probe.error}
</p>
)}
{phase === 'ready' && probe && (
<div className="space-y-3">
<div className="text-[length:var(--conversation-caption-font-size)] font-medium text-foreground">
{m.includesHeading}
</div>
{probe.agent && (
<label className="flex items-start gap-3 rounded-lg border border-(--ui-stroke-tertiary) px-3 py-2">
<Checkbox
checked={installAgent}
disabled={busy}
onCheckedChange={value => setInstallAgent(value === true)}
/>
<span className="min-w-0">
<span className="block font-medium text-foreground">{m.agentLabel}</span>
<span className="block text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{agentTargetHint}
{probe.agentName ? ` · ${probe.agentName}` : ''}
</span>
</span>
</label>
)}
{probe.desktop && (
<label className="flex items-start gap-3 rounded-lg border border-(--ui-stroke-tertiary) px-3 py-2">
<Checkbox
checked={installDesktop}
disabled={busy}
onCheckedChange={value => setInstallDesktop(value === true)}
/>
<span className="min-w-0">
<span className="block font-medium text-foreground">{m.desktopLabel}</span>
<span className="block text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{m.desktopTarget}
{probe.desktopName ? ` · ${probe.desktopName}` : ''}
</span>
</span>
</label>
)}
{probe.desktop && !probe.agent && (
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{m.desktopOnlyNote}
</p>
)}
{(probe.insecure || (probe.warnings?.length ?? 0) > 0) && (
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[length:var(--conversation-caption-font-size)] text-foreground">
<AlertTriangle
aria-hidden
className="mt-0.5 size-3.5 shrink-0 text-amber-600 dark:text-amber-400"
/>
<span>
{[...(probe.warnings ?? []), probe.insecure ? m.insecureWarning : ''].filter(Boolean).join(' ')}
</span>
</div>
)}
{probe.agent && (
<label className="flex items-center justify-between gap-3">
<span className="text-[length:var(--conversation-caption-font-size)] text-foreground">
{m.enableAgent}
</span>
<Switch checked={enableAgent} disabled={busy || !installAgent} onCheckedChange={setEnableAgent} />
</label>
)}
<label className="flex items-center justify-between gap-3">
<span className="text-[length:var(--conversation-caption-font-size)] text-foreground">
{m.forceReinstall}
</span>
<Switch checked={forceReinstall} disabled={busy} onCheckedChange={setForceReinstall} />
</label>
</div>
)}
{installError && (
<p className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 whitespace-pre-wrap text-[length:var(--conversation-caption-font-size)] text-destructive">
{installError}
</p>
)}
</div>
)}
<DialogFooter>
<Button disabled={busy} onClick={handleClose} variant="outline">
{t.common.cancel}
</Button>
<Button disabled={busy || phase !== 'ready' || !probe?.ok} onClick={() => void handleInstall()}>
{installing ? m.installing : m.install}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,233 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { requestGateway, getProfiles } = vi.hoisted(() => ({
requestGateway: vi.fn(),
getProfiles: vi.fn<() => Promise<{ profiles: { name: string; is_default: boolean }[] }>>(async () => ({
profiles: []
}))
}))
vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({
useGatewayRequest: () => ({ requestGateway })
}))
vi.mock('@/hermes', async importOriginal => ({
...(await importOriginal<Record<string, unknown>>()),
getProfiles
}))
import { $pluginRecords } from '@/contrib/plugins-store'
import { queryClient } from '@/lib/query-client'
import {
$agentPluginBusy,
$agentPlugins,
$agentPluginsError,
$agentPluginsStatus,
type AgentPluginRow
} from '@/store/agent-plugins'
import { $activeGatewayProfile } from '@/store/profile'
import { $connection, $gatewayState } from '@/store/session'
import { PluginsSettings } from './plugins-settings'
const legacyRow = {
name: 'Legacy plugin',
version: '0.20.0',
description: 'Returned by a pre-key backend',
source: 'user',
status: 'disabled'
} satisfies AgentPluginRow
const renderSettings = () =>
render(
<QueryClientProvider client={queryClient}>
<PluginsSettings />
</QueryClientProvider>
)
beforeEach(() => {
requestGateway.mockReset()
getProfiles.mockReset()
getProfiles.mockResolvedValue({ profiles: [] })
queryClient.clear()
$pluginRecords.set({})
$agentPlugins.set([legacyRow])
$agentPluginsStatus.set('ready')
$agentPluginsError.set(null)
$agentPluginBusy.set(null)
$gatewayState.set('idle')
$connection.set(null)
$activeGatewayProfile.set('default')
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
describe('PluginsSettings', () => {
it('renders and searches plugin rows returned without a canonical key', () => {
renderSettings()
expect(screen.getByText('Legacy plugin')).toBeTruthy()
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'pre-key' } })
expect(screen.getByText('Legacy plugin')).toBeTruthy()
})
it('renders keyless rows read-only instead of falling back to name-addressed toggles', () => {
// Name-addressed toggles flip every same-named plugin across category
// dirs (image_gen/fal vs video_gen/fal) — the reason toggles moved to
// canonical keys. A pre-contract-v6 row must never reach the RPC.
renderSettings()
const toggle = screen.getByRole('switch', { name: 'Enable Legacy plugin' })
expect(toggle.hasAttribute('disabled') || toggle.getAttribute('aria-disabled') === 'true').toBe(true)
fireEvent.click(toggle)
expect(requestGateway).not.toHaveBeenCalledWith('plugins.manage', expect.objectContaining({ action: 'toggle' }))
})
it('keeps duplicate-named keyless rows distinct (no React key collision)', () => {
const sibling = {
...legacyRow,
description: 'A second plugin category with the same legacy name'
}
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
$agentPlugins.set([legacyRow, sibling])
renderSettings()
expect(screen.getAllByRole('switch', { name: 'Enable Legacy plugin' })).toHaveLength(2)
expect(screen.getByText(sibling.description)).toBeTruthy()
expect(consoleError.mock.calls.flat().join(' ')).not.toContain('same key')
})
it('keeps using the canonical key when the backend provides one', async () => {
const keyedRow = { ...legacyRow, key: 'image_gen/legacy' }
$agentPlugins.set([keyedRow])
requestGateway.mockResolvedValue({ ok: true, plugin: { ...keyedRow, status: 'enabled' } })
renderSettings()
fireEvent.click(screen.getByRole('switch', { name: 'Enable Legacy plugin' }))
await waitFor(() =>
expect(requestGateway).toHaveBeenCalledWith('plugins.manage', {
action: 'toggle',
key: 'image_gen/legacy',
enable: true
})
)
})
it('hides repo-bundled built-ins and keeps the count pill in sync', () => {
// The Agent plugins section is the control panel for plugins the USER
// installed — built-ins (browser backends, cron providers, model
// providers…) ship enabled-by-default and are configured elsewhere.
$agentPlugins.set([
legacyRow,
{ ...legacyRow, name: 'browserbase', key: 'browser/browserbase', source: 'bundled' },
{ ...legacyRow, name: 'chronos', key: 'cron_providers/chronos', source: 'bundled' },
{ ...legacyRow, name: 'deepinfra', key: 'model-providers/deepinfra', source: 'bundled' }
])
renderSettings()
expect(screen.getByText('Legacy plugin')).toBeTruthy()
expect(screen.queryByText('browserbase')).toBeNull()
expect(screen.queryByText('chronos')).toBeNull()
expect(screen.queryByText('deepinfra')).toBeNull()
// Count pill reflects the filtered list, not the raw RPC row count.
expect(screen.getByText('1 installed', { exact: false })).toBeTruthy()
})
it('hides legacy other-surface categories even when the backend omits source', () => {
// Older backends may not report source reliably — the key-prefix
// fallback still hides categories other surfaces own.
$agentPlugins.set([{ ...legacyRow, name: 'deepinfra', key: 'model-providers/deepinfra', source: 'user' }])
renderSettings()
expect(screen.queryByText('deepinfra')).toBeNull()
})
it('shows no profile selector with a single profile', async () => {
getProfiles.mockResolvedValue({ profiles: [{ name: 'default', is_default: true }] })
renderSettings()
await waitFor(() => expect(getProfiles).toHaveBeenCalled())
expect(screen.queryByText('Applies to:')).toBeNull()
})
it('lists the active profile scope without a profile param and reloads scoped on change', async () => {
getProfiles.mockResolvedValue({
profiles: [
{ name: 'default', is_default: true },
{ name: 'work', is_default: false }
]
})
requestGateway.mockResolvedValue({ plugins: [legacyRow] })
$gatewayState.set('open')
renderSettings()
// Active profile scope: no profile param — older backends unchanged.
await waitFor(() => expect(requestGateway).toHaveBeenCalledWith('plugins.manage', { action: 'list' }))
await waitFor(() => expect(screen.getByText('Applies to:')).toBeTruthy())
})
it('sends toggles through the selected profile scope', async () => {
// jsdom's scrollIntoView is missing/non-functional; Radix Select calls it
// when the dropdown opens.
Element.prototype.scrollIntoView = vi.fn()
const keyedRow = { ...legacyRow, key: 'image_gen/legacy' }
getProfiles.mockResolvedValue({
profiles: [
{ name: 'default', is_default: true },
{ name: 'work', is_default: false }
]
})
requestGateway.mockImplementation(async (method: string, params?: Record<string, unknown>) => {
if (params?.action === 'list') {
return { plugins: [keyedRow] }
}
return { ok: true, plugin: { ...keyedRow, status: 'enabled' } }
})
$gatewayState.set('open')
renderSettings()
await waitFor(() => expect(screen.getByText('Applies to:')).toBeTruthy())
// Select the non-active profile scope.
fireEvent.click(screen.getByRole('combobox'))
fireEvent.click(await screen.findByText('work'))
await waitFor(() =>
expect(requestGateway).toHaveBeenCalledWith('plugins.manage', { action: 'list', profile: 'work' })
)
fireEvent.click(screen.getByRole('switch', { name: 'Enable Legacy plugin' }))
await waitFor(() =>
expect(requestGateway).toHaveBeenCalledWith('plugins.manage', {
action: 'toggle',
key: 'image_gen/legacy',
enable: true,
profile: 'work'
})
)
})
})
@@ -0,0 +1,413 @@
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { type ReactNode, useEffect, useState } from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Tip } from '@/components/ui/tooltip'
import { $pluginRecords, type PluginRecord, setPluginEnabled } from '@/contrib/plugins-store'
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
import { getProfiles } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { FolderOpen, Monitor, Package, RefreshCw } from '@/lib/icons'
import { normalize } from '@/lib/text'
import {
$agentPluginBusy,
$agentPlugins,
$agentPluginsError,
$agentPluginsStatus,
type AgentPluginRow,
type GatewayRequest,
isDesktopRelevantPlugin,
loadAgentPlugins,
toggleAgentPlugin
} from '@/store/agent-plugins'
import { notifyError } from '@/store/notifications'
import { $activeGatewayProfile } from '@/store/profile'
import { $connection, $gatewayState } from '@/store/session'
import { EmptyState, ListRowSkeleton, Pill, SettingsContent, SettingsSection } from './primitives'
import { useDeepLinkHighlight } from './use-deep-link-highlight'
const KIND_ORDER: Record<PluginRecord['kind'], number> = { disk: 0, runtime: 1, bundled: 2 }
// User-installed plugins first — mirrors `hermes plugins list --user`.
const SOURCE_ORDER: Record<string, number> = { user: 0, git: 0, project: 1, entrypoint: 2 }
const agentPluginRowKey = (row: AgentPluginRow) =>
row.key ?? [row.name, row.source, row.version, row.description].join('\0')
/** Deep-link anchor for a plugin row (`?tab=plugins&plugin=<id>`). */
export const pluginElementId = (target: string) => `plugin-${target}`
function reveal(file: string) {
void window.hermesDesktop?.revealPath?.(file)?.catch(() => undefined)
}
async function revealPluginsDir() {
try {
// Electron owns the local plugin root — deriving it from the backend's
// hermes_home breaks against a remote backend (#66899).
const dir = await window.hermesDesktop?.desktopPluginsRoot?.()
if (!dir) {
notifyError('Desktop plugins are unavailable', 'Could not resolve the plugins folder')
return
}
// openDir (not reveal): the door often doesn't exist on first use, and
// showItemInFolder on a missing path silently no-ops (esp. Windows).
const result = await window.hermesDesktop?.openDir?.(dir)
if (result && !result.ok) {
notifyError(result.error ?? 'unknown error', 'Could not open the plugins folder')
}
} catch (err) {
notifyError(err, 'Could not resolve the plugins folder')
}
}
// Agent plugins live under the BACKEND's hermes home (profile-aware), so the
// path comes from the gateway — not from the renderer's local HERMES_HOME.
// Callers gate on a local connection: openDir mkdir-creates the path, which
// must never happen for a directory that belongs to a remote box.
async function revealAgentPluginsDir(request: GatewayRequest) {
try {
const result = await request<{ home?: string }>('config.get', { key: 'profile' })
const home = (result?.home ?? '').trim()
if (!home) {
notifyError('The backend did not report its home directory', 'Could not open the plugins folder')
return
}
const opened = await window.hermesDesktop?.openDir?.(`${home}/plugins`)
if (opened && !opened.ok) {
notifyError(opened.error ?? 'unknown error', 'Could not open the plugins folder')
}
} catch (err) {
notifyError(err, 'Could not open the plugins folder')
}
}
// Compact row: name + pills and a wrapping description on the left, controls
// pinned top-right. Same type scale as ListRow, without its wide control grid.
function PluginLine({
title,
description,
controls,
id
}: {
title: ReactNode
description?: ReactNode
controls: ReactNode
id?: string
}) {
return (
<div className="flex items-start gap-3 rounded-lg py-2" id={id}>
<div className="min-w-0 flex-1 pr-4">
<div className="flex flex-wrap items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{title}
</div>
{description && (
<div className="mt-0.5 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) break-words text-(--ui-text-tertiary)">
{description}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2">{controls}</div>
</div>
)
}
function AgentPluginRowView({ row, profile }: { row: AgentPluginRow; profile: string | null }) {
const { t } = useI18n()
const p = t.settings.plugins
const { requestGateway } = useGatewayRequest()
const busy = useStore($agentPluginBusy)
const key = row.key
// Pre-contract-v6 backends return rows without a canonical key. Name-addressed
// toggles silently flip every same-named plugin across category dirs
// (image_gen/fal vs video_gen/fal), so keyless rows are read-only — the
// backend-contract skew toast tells the user to update.
const toggle = (
<Switch
aria-label={`${row.status === 'enabled' ? p.disable : p.enable} ${row.name}`}
checked={row.status === 'enabled'}
disabled={!key || busy === key}
onCheckedChange={on => {
if (!key) {
return
}
triggerHaptic('selection')
void toggleAgentPlugin(requestGateway, key, on, p.agent.toggleFailed(row.name), profile)
}}
/>
)
return (
<PluginLine
controls={key ? toggle : <Tip label={p.agent.updateBackendToManage}>{toggle}</Tip>}
description={row.description || (row.version ? `v${row.version}` : undefined)}
id={pluginElementId(key ?? row.name)}
title={
<>
<span>{row.name}</span>
<Pill>{p.agent.sources[row.source] ?? row.source}</Pill>
{row.portable && <Pill tone="primary">{p.agent.portable}</Pill>}
</>
}
/>
)
}
function AgentPluginsSection() {
const { t } = useI18n()
const p = t.settings.plugins
const { requestGateway } = useGatewayRequest()
const gatewayState = useStore($gatewayState)
const connection = useStore($connection)
const rows = useStore($agentPlugins)
const status = useStore($agentPluginsStatus)
const error = useStore($agentPluginsError)
const [query, setQuery] = useState('')
// 'Applies to' profile scope: which profile's plugins we list/toggle.
// Defaults to the app-wide active profile; overriding it here lets the user
// manage ANY profile's plugins without switching the whole app (same
// pattern as the Capabilities scope selector in app/skills). null = the
// active profile — the RPC is sent without a profile param so older
// backends keep working unchanged.
const activeProfile = useStore($activeGatewayProfile)
const [scopeOverride, setScopeOverride] = useState<null | string>(null)
const scopeProfile = scopeOverride ?? activeProfile ?? null
// The param we actually send: omit it for the active profile.
const requestProfile = scopeOverride && scopeOverride !== activeProfile ? scopeOverride : null
const { data: profilesData } = useQuery({
queryKey: ['agent-plugins-profiles'],
queryFn: getProfiles,
staleTime: 60_000
})
const profiles = profilesData?.profiles ?? []
// An app-wide profile switch retargets the default scope — drop the
// override so the list reloads for the profile the user just switched to.
useEffect(() => {
setScopeOverride(null)
}, [activeProfile])
useEffect(() => {
if (gatewayState !== 'open') {
return
}
void loadAgentPlugins(requestGateway, requestProfile)
}, [gatewayState, requestGateway, requestProfile])
const needle = normalize(query)
const sorted = rows
.filter(isDesktopRelevantPlugin)
.filter(
row =>
!needle ||
row.name.toLowerCase().includes(needle) ||
(row.key ?? '').toLowerCase().includes(needle) ||
row.description.toLowerCase().includes(needle)
)
.sort((a, b) => (SOURCE_ORDER[a.source] ?? 9) - (SOURCE_ORDER[b.source] ?? 9) || a.name.localeCompare(b.name))
return (
<SettingsSection
icon={Package}
meta={status === 'ready' ? p.count(sorted.length) : undefined}
title={p.agent.title}
>
<p className="mb-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{p.agent.blurb}
</p>
{profiles.length > 1 && (
<div className="mb-2 flex items-center gap-2">
<span className="text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
{p.agent.appliesTo}
</span>
<Select
onValueChange={name => setScopeOverride(name === activeProfile ? null : name)}
value={scopeProfile ?? ''}
>
<SelectTrigger className="h-7 w-56 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{profiles.map(profile => (
<SelectItem key={profile.name} value={profile.name}>
{profile.is_default ? 'Hermes (default)' : profile.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{connection?.mode !== 'remote' && !requestProfile && (
<div className="mb-2 flex items-center gap-3">
<Button
onClick={() => void revealAgentPluginsDir(requestGateway)}
size="sm"
type="button"
variant="textStrong"
>
<FolderOpen className="size-3.5" />
<span>{p.openFolder}</span>
</Button>
</div>
)}
<input
className="mb-2 w-full rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-1.5 text-[length:var(--conversation-caption-font-size)] outline-none placeholder:text-(--ui-text-tertiary) focus:border-(--ui-stroke-secondary)"
onChange={event => setQuery(event.target.value)}
placeholder={p.agent.search}
spellCheck={false}
value={query}
/>
{status === 'loading' || status === 'idle' ? (
<div>
<ListRowSkeleton />
<ListRowSkeleton />
<ListRowSkeleton />
</div>
) : status === 'error' ? (
<EmptyState description={error ?? undefined} title={p.agent.loadFailed} />
) : sorted.length === 0 ? (
needle ? (
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{p.agent.noMatches}
</p>
) : (
<EmptyState title={p.agent.empty} />
)
) : (
<div>
{sorted.map(row => (
<AgentPluginRowView key={agentPluginRowKey(row)} profile={requestProfile} row={row} />
))}
</div>
)}
</SettingsSection>
)
}
function PluginRow({ record }: { record: PluginRecord }) {
const { t } = useI18n()
const p = t.settings.plugins
return (
<PluginLine
controls={
<>
{record.file && (
<Tip label={p.reveal}>
<Button onClick={() => reveal(record.file!)} size="icon" variant="ghost">
<Codicon name="folder-opened" size="0.85rem" />
</Button>
</Tip>
)}
<Switch
aria-label={`${record.status === 'disabled' ? p.enable : p.disable} ${record.name}`}
checked={record.status !== 'disabled'}
onCheckedChange={on => {
triggerHaptic('selection')
void setPluginEnabled(record.id, on)
}}
/>
</>
}
description={
record.status === 'error' ? (
<span className="text-(--ui-danger,#f87171)">{record.error}</span>
) : (
(record.description ?? record.file ?? record.id)
)
}
id={pluginElementId(record.id)}
title={
<>
<span>{record.name}</span>
<Pill>{p.kinds[record.kind]}</Pill>
{record.status === 'error' && <Pill tone="primary">{p.failed}</Pill>}
</>
}
/>
)
}
export function PluginsSettings() {
const { t } = useI18n()
const p = t.settings.plugins
const records = useStore($pluginRecords)
// Deep-link from settings search (?plugin=<id or key>): rows render as soon
// as their store hydrates, so "ready" is simply target-present; the polling
// in the hook rides out the async list loads (agent rows arrive via RPC).
useDeepLinkHighlight({
param: 'plugin',
ready: () => true,
elementId: pluginElementId
})
const rows = Object.values(records).sort(
(a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || a.name.localeCompare(b.name)
)
return (
<SettingsContent>
<SettingsSection icon={Monitor} meta={p.count(rows.length)} title={p.title}>
<p className="mb-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">{p.blurb}</p>
<div className="mb-2 flex items-center gap-3">
<Button onClick={() => void revealPluginsDir()} size="sm" type="button" variant="textStrong">
<FolderOpen className="size-3.5" />
<span>{p.openFolder}</span>
</Button>
<Button
onClick={() => {
triggerHaptic('selection')
void discoverRuntimePlugins()
}}
size="sm"
type="button"
variant="textStrong"
>
<RefreshCw className="size-3.5" />
<span>{p.rescan}</span>
</Button>
</div>
{rows.length === 0 ? (
<EmptyState title={p.empty} />
) : (
<div>
{rows.map(record => (
<PluginRow key={record.id} record={record} />
))}
</div>
)}
</SettingsSection>
<AgentPluginsSection />
</SettingsContent>
)
}
@@ -0,0 +1,113 @@
import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { ListRow } from '@/app/settings/primitives'
import { Input } from '@/components/ui/input'
import { $poolLimits, loadPoolLimits, savePoolLimits } from '@/store/pool-limits'
// Bounds imported from main's clamp module so the advertised input ranges
// can never drift from what the pool actually enforces (review note on #92581).
import { POOL_LIMITS_BOUNDS } from '../../../electron/pool-limits'
const MAX_BACKENDS_MAX = POOL_LIMITS_BOUNDS.maxBackendsMax
const IDLE_MS_MAX = POOL_LIMITS_BOUNDS.idleMsMax
/** Settings → Advanced: warm-bot-backends count + backend idle timeout.
* Device-local (not profile-scoped): the pool is sized once per machine and
* changes apply live — main evicts/reaps to converge without a restart. */
export function PoolLimitsSetting() {
const limits = useStore($poolLimits)
const [maxDraft, setMaxDraft] = useState(String(limits.maxBackends))
const [idleDraft, setIdleDraft] = useState(String(limits.idleMs))
useEffect(() => {
void loadPoolLimits()
}, [])
useEffect(() => {
setMaxDraft(String(limits.maxBackends))
setIdleDraft(String(limits.idleMs))
}, [limits])
const commitMax = () => {
const parsed = Number(maxDraft)
if (!Number.isFinite(parsed) || parsed === limits.maxBackends) {
setMaxDraft(String(limits.maxBackends))
return
}
void savePoolLimits({ maxBackends: parsed })
.then(() => undefined)
.catch(() => setMaxDraft(String($poolLimits.get().maxBackends)))
}
const commitIdle = () => {
const parsed = Number(idleDraft)
if (!Number.isFinite(parsed) || parsed === limits.idleMs) {
setIdleDraft(String(limits.idleMs))
return
}
void savePoolLimits({ idleMs: parsed })
.then(() => undefined)
.catch(() => setIdleDraft(String($poolLimits.get().idleMs)))
}
return (
<>
<ListRow
action={
<div className="flex items-center gap-2">
<Input
aria-label="Warm bot backends"
className="w-20"
inputMode="numeric"
max={MAX_BACKENDS_MAX}
min={1}
onBlur={commitMax}
onChange={event => setMaxDraft(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
event.currentTarget.blur()
}
}}
type="number"
value={maxDraft}
/>
</div>
}
description="How many bot backends stay running for instant switching. Higher = faster switches, more memory (~60MB per backend). Applies immediately."
title="Warm Bot Backends"
/>
<ListRow
action={
<div className="flex items-center gap-2">
<Input
aria-label="Backend idle timeout in milliseconds"
className="w-28"
inputMode="numeric"
max={IDLE_MS_MAX}
min={60_000}
onBlur={commitIdle}
onChange={event => setIdleDraft(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
event.currentTarget.blur()
}
}}
type="number"
value={idleDraft}
/>
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">ms</span>
</div>
}
description="How long an unused bot backend stays warm before it is shut down. Raise this so bots you revisit every few minutes never pay a cold start."
title="Backend Idle Timeout"
/>
</>
)
}
@@ -0,0 +1,267 @@
import type { ComponentProps, ReactNode } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
import { triggerHaptic } from '@/lib/haptics'
import type { IconComponent } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { PAGE_INSET_X } from '../layout-constants'
// `bare` drops the page gutters + tall bottom pad for embedding in a tighter
// surface (e.g. the boot-failure recovery card owns its own padding).
export function SettingsContent({ children, bare = false }: { children: ReactNode; bare?: boolean }) {
return (
<section className="min-h-0 overflow-hidden">
<div className={cn('h-full min-h-0 overflow-y-auto', bare ? 'px-5 pb-6' : cn('pb-20', PAGE_INSET_X))}>
{children}
</div>
</section>
)
}
const PILL_VARIANT = {
muted: 'muted',
primary: 'default',
success: 'success',
warn: 'warn',
destructive: 'destructive'
} as const
// Rest props spread through to the Badge's DOM node — REQUIRED for Radix
// `asChild` composition (wrapping a Pill in `Tip` clones it with the hover
// handlers and ref as props; swallowing them left every tooltip on a Pill
// silently dead).
export function Pill({
tone = 'muted',
children,
...props
}: { tone?: keyof typeof PILL_VARIANT; children: ReactNode } & Omit<ComponentProps<typeof Badge>, 'variant'>) {
return (
<Badge variant={PILL_VARIANT[tone]} {...props}>
{children}
</Badge>
)
}
export function SectionHeading({
aside,
icon: Icon,
meta,
title
}: {
// Right-aligned trailing content on the heading row (e.g. a compact status +
// action), so a single-item section needn't repeat its own label as a row.
aside?: ReactNode
icon: IconComponent
meta?: string
title: string
}) {
return (
<div className="mb-2.5 flex items-center gap-2 pt-2 text-[length:var(--conversation-text-font-size)] font-medium">
<Icon className="size-4 shrink-0 text-muted-foreground" />
<span>{title}</span>
{meta && <Pill>{meta}</Pill>}
{aside && <div className="ml-auto flex min-w-0 items-center">{aside}</div>}
</div>
)
}
// A titled section: heading + body with the shared vertical rhythm. Keeps the
// heading and its content welded together so pages stop hand-rolling
// `<div className="mb-…"><SectionHeading/>…</div>` at every call site.
export function SettingsSection({
aside,
children,
icon,
meta,
title
}: {
aside?: ReactNode
children: ReactNode
icon: IconComponent
meta?: string
title: string
}) {
return (
<section className="mb-6">
<SectionHeading aside={aside} icon={icon} meta={meta} title={title} />
{children}
</section>
)
}
export function NavLink({
icon: Icon,
label,
active,
onClick
}: {
icon: IconComponent
label: string
active: boolean
onClick: () => void
}) {
return (
<Button
className={cn(
'flex min-h-7 w-full justify-start gap-2 rounded-md px-2 text-left text-[length:var(--conversation-text-font-size)] transition',
active
? 'bg-(--ui-bg-tertiary) text-foreground'
: 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
onClick={onClick}
size="sm"
type="button"
variant="ghost"
>
<Icon className="size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">{label}</span>
</Button>
)
}
export function ListRow({
title,
description,
hint,
action,
below,
'data-tour': dataTour,
id,
wide = false,
className
}: {
title: ReactNode
description?: ReactNode
hint?: ReactNode
action?: ReactNode
below?: ReactNode
/** Durable handle for tours (see lib/tour) — usually the field's schema key. */
'data-tour'?: string
id?: string
wide?: boolean
className?: string
}) {
return (
// Container-queried, not viewport-queried: the label/control split keys on
// the row's own pane width, so a narrow detail column (messaging, split
// views) stacks instead of squishing the label against minmax(15rem,…).
<div className={cn('@container', className)} data-tour={dataTour} id={id}>
<div
className={cn(
'grid gap-3 py-3',
!wide && '@2xl:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] @2xl:items-center'
)}
>
<div className="min-w-0">
<div className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">{title}</div>
{description && (
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{description}
</div>
)}
{hint && <div className="mt-1 block font-mono text-[0.68rem] text-muted-foreground/45">{hint}</div>}
{below}
</div>
{action && <div className={cn('min-w-0', !wide && '@2xl:justify-self-end')}>{action}</div>}
</div>
</div>
)
}
// A labelled on/off row — the canonical device-pref switch (haptic baked in).
export function ToggleRow({
checked,
description,
disabled,
label,
onChange
}: {
checked: boolean
description?: string
disabled?: boolean
label: string
onChange: (on: boolean) => void
}) {
return (
<ListRow
action={
<Switch
aria-label={label}
checked={checked}
disabled={disabled}
onCheckedChange={on => {
triggerHaptic('selection')
onChange(on)
}}
/>
}
description={description}
title={label}
/>
)
}
// Skeleton primitives mirroring the settings layout rhythm — a loading page keeps
// its shape (like ModelSettings) instead of collapsing to a centered spinner.
export function SectionHeadingSkeleton() {
return (
<div className="mb-2.5 flex items-center gap-2 pt-2">
<Skeleton className="size-4" />
<Skeleton className="h-4 w-36 max-w-full" />
</div>
)
}
export function ListRowSkeleton({ wide = false }: { wide?: boolean }) {
return (
<div className="@container">
<div
className={cn(
'grid gap-3 py-3',
!wide && '@2xl:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] @2xl:items-center'
)}
>
<div className="min-w-0 space-y-1.5">
<Skeleton className="h-3.5 w-40 max-w-full" />
<Skeleton className="h-3 w-64 max-w-full" />
</div>
{!wide && <Skeleton className="h-8 w-full @2xl:w-72 @2xl:justify-self-end" />}
</div>
</div>
)
}
// A full settings page in its loading shape: an optional leading search field
// over one or more sections, each an optional heading above a run of rows.
// `<SettingsSkeleton search sections={[{ heading, rows }]} />`.
export function SettingsSkeleton({
search = false,
sections = [{ rows: 4 }]
}: {
search?: boolean
sections?: { heading?: boolean; rows: number }[]
}) {
return (
<SettingsContent>
{search && <Skeleton className="mb-3 h-8 w-full" />}
{sections.map((section, i) => (
<section className={cn(i > 0 && 'mt-6')} key={i}>
{section.heading && <SectionHeadingSkeleton />}
<div className="grid gap-1">
{Array.from({ length: section.rows }, (_, r) => (
<ListRowSkeleton key={r} />
))}
</div>
</section>
))}
</SettingsContent>
)
}
// Canonical implementation lives in components/ui; re-exported so the many
// settings call sites keep their import path.
export { EmptyState } from '@/components/ui/empty-state'
@@ -0,0 +1,68 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { atom } from 'nanostores'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ProfileInfo } from '@/types/hermes'
// Keep store/profile's side-effecting imports inert — same seam as
// store/profile.test.ts / profile-tag.test.tsx.
vi.mock('@/store/gateway', () => ({
$gateway: atom<unknown>(null),
ensureGatewayForAgent: vi.fn(async () => undefined),
ensureGatewayForProfile: vi.fn(async () => undefined),
openGatewayForProfile: vi.fn(async () => undefined)
}))
vi.mock('@/hermes', () => ({
getProfiles: vi.fn(async () => ({ profiles: [] })),
setApiRequestProfile: vi.fn()
}))
vi.mock('@/lib/query-client', () => ({ invalidateProfileScopedQueries: vi.fn() }))
vi.mock('@/store/starmap', () => ({ resetStarmapGraph: vi.fn() }))
const { $activeGatewayProfile, $profiles } = await import('@/store/profile')
const { $settingsScopeOverride } = await import('@/store/settings-scope')
const { SettingsProfileScope } = await import('./profile-scope')
const profile = (name: string, isDefault = false): ProfileInfo =>
({ has_env: false, is_default: isDefault, model: null, name }) as unknown as ProfileInfo
beforeEach(() => {
$activeGatewayProfile.set('default')
$settingsScopeOverride.set(null)
$profiles.set([])
})
afterEach(cleanup)
describe('SettingsProfileScope', () => {
it('renders nothing with fewer than two profiles', () => {
$profiles.set([profile('default', true)])
const { container } = render(<SettingsProfileScope />)
expect(container.textContent).toBe('')
})
it('shows one chip per profile with the active profile selected by default', () => {
$profiles.set([profile('default', true), profile('coder')])
render(<SettingsProfileScope />)
expect(screen.getByRole('button', { name: 'default' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'coder' })).toBeTruthy()
// Following the active profile → no override, no "applies to X" note.
expect($settingsScopeOverride.get()).toBeNull()
})
it('selecting another profile sets the shared override; re-selecting the active clears it', () => {
$profiles.set([profile('default', true), profile('coder')])
render(<SettingsProfileScope />)
fireEvent.click(screen.getByRole('button', { name: 'coder' }))
expect($settingsScopeOverride.get()).toBe('coder')
fireEvent.click(screen.getByRole('button', { name: 'default' }))
expect($settingsScopeOverride.get()).toBeNull()
})
})
@@ -0,0 +1,77 @@
import { useStore } from '@nanostores/react'
import { useEffect } from 'react'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { $activeGatewayProfile, $profiles, normalizeProfileKey, refreshProfiles } from '@/store/profile'
import { $settingsScopeOverride, setSettingsScope } from '@/store/settings-scope'
// The same chip affordance the Gateway page uses for its per-profile
// connection overrides (gateway-settings ScopeChip). That one stays local to
// gateway-settings — its `null` chip means "all profiles", while here every
// chip is a concrete profile whose config the page edits.
export function ScopeChip({ active, label, onSelect }: { active: boolean; label: string; onSelect: () => void }) {
return (
<button
className={cn(
'rounded-full border px-3 py-1 text-[length:var(--conversation-caption-font-size)] transition',
active
? 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary) text-(--ui-text-primary)'
: 'border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover)'
)}
onClick={onSelect}
type="button"
>
{label}
</button>
)
}
/** Shared "Applies to" profile selector for the config-backed settings pages
* (Model, Workspace, Safety, Memory & Context, Voice, Tools & Keys) and the
* Messaging overlay. Backed by one nanostore ($settingsScopeOverride) so the
* selection persists across pages. Hidden with fewer than two profiles, so
* single-profile users never see it and every request keeps its unscoped
* default shape. */
export function SettingsProfileScope({ className }: { className?: string }) {
const { t } = useI18n()
const scope = t.settings.profileScope
const override = useStore($settingsScopeOverride)
const active = useStore($activeGatewayProfile)
const profiles = useStore($profiles)
// Refresh lazily so a profile created elsewhere shows up; the cached list
// paints immediately. Best-effort — a failure keeps the cached roster.
useEffect(() => {
void refreshProfiles().catch(() => undefined)
}, [])
if (profiles.length < 2) {
return null
}
const selected = normalizeProfileKey(override ?? active)
return (
<div className={cn('grid gap-2', className)}>
<div className="text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)">
{scope.appliesTo}
</div>
<div className="flex flex-wrap gap-1.5">
{profiles.map(profile => (
<ScopeChip
active={normalizeProfileKey(profile.name) === selected}
key={profile.name}
label={profile.name}
onSelect={() => setSettingsScope(profile.name)}
/>
))}
</div>
{override !== null ? (
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{scope.editsProfile(selected)}
</p>
) : null}
</div>
)
}
@@ -0,0 +1,237 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { atom } from 'nanostores'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ConfirmHost } from '@/components/confirm-host'
import { $confirmRequest } from '@/store/confirm'
import type { EnvVarInfo, OAuthProvider } from '@/types/hermes'
const listOAuthProviders = vi.fn()
const disconnectOAuthProvider = vi.fn()
const getEnvVars = vi.fn()
const startManualProviderOAuth = vi.fn()
const startManualLocalEndpoint = vi.fn()
const onboarding = atom({ manual: false })
vi.mock('@/hermes', () => ({
disconnectOAuthProvider: (providerId: string) => disconnectOAuthProvider(providerId),
getEnvVars: () => getEnvVars(),
listOAuthProviders: () => listOAuthProviders()
}))
vi.mock('@/store/onboarding', () => ({
$desktopOnboarding: onboarding,
startManualProviderOAuth: (providerId: string) => startManualProviderOAuth(providerId),
startManualLocalEndpoint: (reason: null | string) => startManualLocalEndpoint(reason)
}))
function provider(id: string, loggedIn: boolean, patch: Partial<OAuthProvider> = {}): OAuthProvider {
return {
cli_command: `hermes auth add ${id}`,
disconnectable: true,
docs_url: '',
flow: 'device_code',
id,
name: id === 'nous' ? 'Nous Portal' : 'MiniMax',
status: {
logged_in: loggedIn
},
...patch
}
}
// One `/api/env` row (an EnvVarInfo) for the API-keys view. Mirrors the
// `provider()` factory above: a valid base + per-test overrides, typed against
// the real response shape so it can't drift from EnvVarInfo.
function keyVar(patch: Partial<EnvVarInfo> = {}): EnvVarInfo {
return {
advanced: false,
category: 'provider',
description: '',
is_password: true,
is_set: false,
provider: '',
provider_label: '',
redacted_value: null,
tools: [],
url: '',
...patch
}
}
beforeEach(() => {
onboarding.set({ manual: false })
getEnvVars.mockResolvedValue({})
disconnectOAuthProvider.mockResolvedValue({ ok: true, provider: 'nous' })
listOAuthProviders.mockResolvedValue({
providers: [provider('nous', true), provider('minimax-oauth', false)]
})
})
afterEach(() => {
cleanup()
$confirmRequest.set(null)
vi.restoreAllMocks()
vi.clearAllMocks()
})
// Removal goes through confirm() from @/store/confirm, so the host has to be
// mounted for the prompt to render — same as in the real app shell.
async function renderProvidersSettings() {
const { ProvidersSettings } = await import('./providers-settings')
let result: ReturnType<typeof render>
await act(async () => {
result = render(
<>
<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="accounts" />
<ConfirmHost />
</>
)
})
return result!
}
describe('ProvidersSettings', () => {
it('disconnects a connected provider account and refreshes the accounts list', async () => {
await renderProvidersSettings()
const remove = await screen.findByRole('button', { name: 'Remove Nous Portal' })
await act(async () => {
fireEvent.click(remove)
})
// Removal is confirmed first — nothing has been disconnected yet.
expect(await screen.findByRole('dialog')).toBeTruthy()
expect(disconnectOAuthProvider).not.toHaveBeenCalled()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Disconnect' }))
})
await waitFor(() => expect(disconnectOAuthProvider).toHaveBeenCalledWith('nous'))
expect(listOAuthProviders).toHaveBeenCalledTimes(2)
})
it('leaves the account connected when the removal prompt is dismissed', async () => {
await renderProvidersSettings()
await act(async () => {
fireEvent.click(await screen.findByRole('button', { name: 'Remove Nous Portal' }))
})
await act(async () => {
fireEvent.click(await screen.findByRole('button', { name: 'Cancel' }))
})
expect(disconnectOAuthProvider).not.toHaveBeenCalled()
})
it('keeps provider selection separate from account removal', async () => {
await renderProvidersSettings()
await act(async () => {
fireEvent.click(await screen.findByText('Nous Portal'))
})
expect(startManualProviderOAuth).toHaveBeenCalledWith('nous')
expect(disconnectOAuthProvider).not.toHaveBeenCalled()
})
it('does not offer removal for externally managed providers', async () => {
listOAuthProviders.mockResolvedValue({
providers: [
provider('qwen-oauth', true, {
cli_command: 'hermes auth add qwen-oauth',
disconnect_hint: "Use `hermes auth add qwen-oauth` or that provider's CLI to remove it.",
disconnectable: false,
flow: 'external',
name: 'Qwen (via Qwen CLI)'
})
]
})
await renderProvidersSettings()
expect(await screen.findByText('Qwen Code')).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Remove Qwen Code' })).toBeNull()
expect(screen.getByText(/managed by its own CLI/)).toBeTruthy()
})
it('renders a Keys card for a backend-tagged provider with no PROVIDER_GROUPS prefix', async () => {
// A provider the backend catalog tags (provider/provider_label) but that has
// no desktop PROVIDER_GROUPS prefix row must still render its own card —
// this is the GUI/CLI drift fix: membership comes from the backend, not
// from the hand-maintained prefix list.
getEnvVars.mockResolvedValue({
WIDGETAI_API_KEY: keyVar({
provider: 'widgetai',
provider_label: 'WidgetAI',
url: 'https://widgetai.example/keys'
})
})
listOAuthProviders.mockResolvedValue({ providers: [] })
const { ProvidersSettings } = await import('./providers-settings')
await act(async () => {
render(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="keys" />)
})
expect(await screen.findByText('WidgetAI')).toBeTruthy()
})
it('orders API-key providers by priority then name, and filters them via search', async () => {
// These three providers have no curated PROVIDER_GROUPS priority, so they
// share the default priority and fall back to alphabetical among themselves
// (Acme, Middle, Zebra) — exercising the name tiebreak of the priority sort.
getEnvVars.mockResolvedValue({
ZEBRA_API_KEY: keyVar({ provider: 'zebra', provider_label: 'Zebra' }),
ACME_API_KEY: keyVar({ provider: 'acme', provider_label: 'Acme' }),
MIDDLE_API_KEY: keyVar({ provider: 'middle', provider_label: 'Middle' })
})
listOAuthProviders.mockResolvedValue({ providers: [] })
const { ProvidersSettings } = await import('./providers-settings')
render(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="keys" />)
// Equal priority → alphabetical tiebreak: Acme, Middle, Zebra.
await screen.findByText('Acme')
const labels = screen.getAllByText(/Acme|Middle|Zebra/).map(el => el.textContent)
expect(labels).toEqual(['Acme', 'Middle', 'Zebra'])
// Typing narrows the list to matching providers only.
const search = screen.getByPlaceholderText('Search providers…')
await act(async () => {
fireEvent.change(search, { target: { value: 'mid' } })
})
await waitFor(() => expect(screen.queryByText('Acme')).toBeNull())
expect(screen.getByText('Middle')).toBeTruthy()
expect(screen.queryByText('Zebra')).toBeNull()
// A non-matching query shows the empty-state copy.
await act(async () => {
fireEvent.change(search, { target: { value: 'nonesuch-xyz' } })
})
expect(await screen.findByText('No providers match your search.')).toBeTruthy()
})
it('offers a Local / custom endpoint entry in the API-keys tab that opens the custom-endpoint flow', async () => {
// Regression: the composer pill and the providers "have an API key"
// affordance both dead-end on the env-var-driven key catalog, which never
// lists a custom endpoint — so without this row there is no reachable
// Desktop GUI path to add one. See issue #62817.
getEnvVars.mockResolvedValue({})
listOAuthProviders.mockResolvedValue({ providers: [] })
const { ProvidersSettings } = await import('./providers-settings')
render(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="keys" />)
const row = await screen.findByText('Local / custom endpoint')
expect(screen.getByText(/OpenAI-compatible endpoint/)).toBeTruthy()
fireEvent.click(row)
await waitFor(() => expect(startManualLocalEndpoint).toHaveBeenCalledWith(null))
})
})
@@ -0,0 +1,554 @@
import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { runInTerminal } from '@/app/right-sidebar/store'
import {
FEATURED_ID,
FeaturedProviderRow,
FireworksProviderRow,
LocalModelsProviderRow,
OpenRouterProviderRow,
ProviderRow,
providerTitle,
sortProviders
} from '@/components/onboarding'
import { Button } from '@/components/ui/button'
import { RowButton } from '@/components/ui/row-button'
import { SearchField } from '@/components/ui/search-field'
import { disconnectOAuthProvider, listOAuthProviders } from '@/hermes'
import { useI18n } from '@/i18n'
import { Check, ChevronDown, ChevronRight, KeyRound, Loader2, Terminal, Trash2 } from '@/lib/icons'
import { normalize } from '@/lib/text'
import { cn } from '@/lib/utils'
import { confirm } from '@/store/confirm'
import { $localModelsEnabled } from '@/store/local-models-flag'
import { notify, notifyError } from '@/store/notifications'
import { $desktopOnboarding, startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding'
import type { EnvVarInfo, OAuthProvider } from '@/types/hermes'
import { isKeyVar, ProviderKeyRows } from './credential-key-ui'
import { CustomEndpointsSettings } from './custom-endpoints-settings'
import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials'
import { providerGroup, providerMeta, providerPriority } from './helpers'
import { LocalModelsSettings } from './local-models-settings'
import { SettingsContent, SettingsSkeleton } from './primitives'
// The embedded terminal (and thus the "run disconnect command" path) only
// exists in the Electron desktop shell, not the web dashboard.
const canRunInTerminal = () => typeof window !== 'undefined' && Boolean(window.hermesDesktop?.terminal)
// Parallel group headers ("Connected", "Other providers") so the expanded list
// reads as its own section instead of bleeding into the connected group.
function GroupLabel({ children }: { children: ReactNode }) {
return (
<p className="mt-3 px-0.5 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
{children}
</p>
)
}
// Sub-views surfaced as a sidebar subnav: account sign-in vs raw API keys.
export const PROVIDER_VIEWS = ['accounts', 'keys', 'custom-endpoints', 'local'] as const
export type ProviderView = (typeof PROVIDER_VIEWS)[number]
// Group the env catalog by provider — one ListRow per vendor plus optional
// advanced overrides (base URL, region, etc.). Groups without a key field are
// skipped.
//
// Grouping key precedence:
// 1. Backend `provider_label` / `provider` (from the unified provider catalog
// in hermes_cli/provider_catalog.py) — the SAME provider identity
// `hermes model` uses. This is authoritative: a provider tagged by the
// backend always renders a card, even with no PROVIDER_GROUPS row.
// 2. Desktop prefix match (`providerGroup`) — legacy fallback for provider
// env vars that predate the backend tagging.
// Only entries that resolve to neither (the "Other" bucket) are skipped.
function buildProviderKeyGroups(vars: Record<string, EnvVarInfo>): ProviderKeyGroup[] {
const buckets = new Map<string, [string, EnvVarInfo][]>()
for (const [key, info] of Object.entries(vars)) {
if (info.category !== 'provider') {
continue
}
// Prefer the backend-supplied provider label/id so the Keys tab groups by
// the same identity the CLI picker uses; fall back to the prefix guess.
const name = info.provider_label?.trim() || info.provider?.trim() || providerGroup(key)
if (name === 'Other') {
continue
}
buckets.set(name, [...(buckets.get(name) ?? []), [key, info]])
}
const groups: ProviderKeyGroup[] = []
for (const [name, entries] of buckets) {
const primary = entries.find(([k, i]) => !i.advanced && isKeyVar(k, i)) ?? entries.find(([k, i]) => isKeyVar(k, i))
if (!primary) {
continue
}
// Presentation overlay (priority, blurb, docs) is keyed by the prefix-based
// group name; when the backend introduced this provider it may have no
// overlay entry, so fall back to the backend/env metadata for display.
const meta = providerMeta(name)
groups.push({
// Advanced = the provider's non-key knobs (base URL, region, deployment).
// Skip redundant alias key vars (e.g. ANTHROPIC_TOKEN vs ANTHROPIC_API_KEY)
// so we never render a second "Paste key" input — unless one is already
// set, in which case keep it visible so it stays clearable.
advanced: entries
.filter(([k, i]) => k !== primary[0] && (!isKeyVar(k, i) || i.is_set))
.sort(([a], [b]) => a.localeCompare(b)),
description: meta?.description ?? primary[1].description,
docsUrl: meta?.docsUrl ?? primary[1].url ?? undefined,
hasAnySet: entries.some(([, i]) => i.is_set),
name,
primary,
priority: providerPriority(name)
})
}
return groups.sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name))
}
// Deliberately a near-1:1 replica of the first-run onboarding picker
// (`Picker` in desktop-onboarding-overlay): same recommended card, same
// always-visible Local models row, same provider rows, same "Other
// providers" disclosure (Fireworks and OpenRouter quick-key rows live
// inside it on both surfaces), and the same bottom-right "I have an API
// key" affordance. The leaf cards are the exact shared components, so
// the two surfaces stay visually identical. Selecting a provider hands
// off to the shared onboarding overlay, which runs that provider's real
// sign-in flow; the key affordances open the API-key catalog below.
function OAuthPicker({
disconnecting,
onDisconnect,
onTerminalDisconnect,
onWantApiKey,
onWantLocalModels,
providers
}: {
disconnecting: null | string
onDisconnect: (provider: OAuthProvider) => void
onTerminalDisconnect: (provider: OAuthProvider) => void
onWantApiKey: () => void
onWantLocalModels: () => void
providers: OAuthProvider[]
}) {
const { t } = useI18n()
const p = t.settings.providers
const [showAll, setShowAll] = useState(false)
const ordered = useMemo(() => sortProviders(providers), [providers])
if (ordered.length === 0) {
return null
}
const select = (p: OAuthProvider) => startManualProviderOAuth(p.id)
const featured = ordered.find(p => p.id === FEATURED_ID && !p.status?.logged_in) ?? null
const rest = featured ? ordered.filter(p => p.id !== FEATURED_ID) : ordered
// Keep connected accounts grouped and always visible; only the unconnected
// providers hide behind the disclosure, so the page leads with what's set up.
// Both lists preserve `sortProviders` order (curated priority, then name).
const connected = rest.filter(p => p.status?.logged_in)
const others = rest.filter(p => !p.status?.logged_in)
const collapsible = others.length > 0
const showOthers = !collapsible || showAll
return (
<section className="mb-5 grid gap-2">
<div className="flex flex-wrap items-baseline justify-between gap-x-3">
<SettingsCategoryHeading icon={KeyRound} title={p.connectAccount} />
<Button
className="text-[length:var(--conversation-caption-font-size)]"
onClick={onWantApiKey}
size="inline"
type="button"
variant="textStrong"
>
{p.haveApiKey}
</Button>
</div>
<p className="-mt-2 mb-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{p.intro}
</p>
{featured && <FeaturedProviderRow onSelect={select} provider={featured} />}
{/* Slot #2 — the no-account path, matching onboarding. Behind the
--local launch flag like every local-models surface. */}
{$localModelsEnabled.get() && <LocalModelsProviderRow onClick={onWantLocalModels} />}
{connected.length > 0 && (
<>
<GroupLabel>{p.connected}</GroupLabel>
{connected.map(p => (
<ConnectedProviderRow
disconnecting={disconnecting === p.id}
key={p.id}
onDisconnect={onDisconnect}
onSelect={select}
onTerminalDisconnect={onTerminalDisconnect}
provider={p}
/>
))}
</>
)}
{showOthers && (
<>
{connected.length > 0 && <GroupLabel>{p.otherProviders}</GroupLabel>}
{others.map(p => (
<ProviderRow key={p.id} onSelect={select} provider={p} />
))}
<FireworksProviderRow onClick={onWantApiKey} />
<OpenRouterProviderRow onClick={onWantApiKey} />
</>
)}
{collapsible && (
<Button
className="py-1 text-[length:var(--conversation-caption-font-size)]"
onClick={() => setShowAll(v => !v)}
size="inline"
type="button"
variant="text"
>
{showAll ? p.collapse : connected.length > 0 ? p.connectAnother : p.otherProviders}
<ChevronDown className={cn('size-3.5 transition', showAll && 'rotate-180')} />
</Button>
)}
</section>
)
}
function ConnectedProviderRow({
disconnecting,
onDisconnect,
onSelect,
onTerminalDisconnect,
provider
}: {
disconnecting: boolean
onDisconnect: (provider: OAuthProvider) => void
onSelect: (provider: OAuthProvider) => void
onTerminalDisconnect: (provider: OAuthProvider) => void
provider: OAuthProvider
}) {
const { t } = useI18n()
const copy = t.settings.providers
const title = providerTitle(provider)
const Trail = provider.flow === 'external' ? Terminal : ChevronRight
// Hermes can clear this provider's creds via the API.
const canDisconnect = provider.disconnectable ?? provider.flow !== 'external'
// External (CLI-managed) provider Hermes can't clear via the API, but ships a
// command we can run in the embedded terminal (Electron shell only).
const terminalDisconnect = !canDisconnect && Boolean(provider.disconnect_command) && canRunInTerminal()
// Only fall back to a static "remove it elsewhere" hint when we offer no button.
const showHint = !canDisconnect && !terminalDisconnect
return (
<div className="group grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1 rounded-[6px] transition-colors hover:bg-(--ui-control-hover-background)">
<RowButton className="min-w-0 px-3 py-2.5 text-left" onClick={() => onSelect(provider)}>
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-[length:var(--conversation-text-font-size)] font-semibold">{title}</span>
<span className="inline-flex shrink-0 items-center gap-1 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<Check className="size-3" />
{copy.connected}
</span>
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.flowSubtitles[provider.flow]}</p>
{showHint && (
<p className="mt-0.5 truncate text-[0.68rem] leading-5 text-muted-foreground/70">
{provider.flow === 'external' ? copy.removeExternalGeneric(title) : copy.removeKeyManaged(title)}
</p>
)}
</RowButton>
<div className="flex items-center gap-1 pr-2">
<Trail className="size-4 text-muted-foreground transition group-hover:text-foreground" />
{canDisconnect && (
<Button
aria-label={`${t.common.remove} ${title}`}
disabled={disconnecting}
onClick={() => onDisconnect(provider)}
size="icon-xs"
title={`${t.common.remove} ${title}`}
type="button"
variant="ghost"
>
{disconnecting ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
</Button>
)}
{terminalDisconnect && (
<Button
aria-label={`${copy.disconnect} ${title}`}
onClick={() => onTerminalDisconnect(provider)}
size="icon-xs"
title={copy.disconnectInTerminal}
type="button"
variant="ghost"
>
<Trash2 className="size-3" />
</Button>
)}
</div>
</div>
)
}
function NoProviderKeys() {
const { t } = useI18n()
return (
<div className="grid min-h-32 place-items-center px-4 py-8 text-center text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
{t.settings.providers.noProviderKeys}
</div>
)
}
// Surfaces the "Local / custom endpoint" entry point directly in the API-keys
// tab so users can add any OpenAI-compatible endpoint (Zyphra, vLLM, Ollama…)
// from the GUI. The composer pill and the providers "have an API key" affordance
// both dead-end on the env-var-driven key catalog, which never lists a custom
// endpoint — so without this row there is no reachable Desktop path to it.
// The whole row is the button so the click target and a11y focus match the
// visible area (the chevron + gutter are inside the button, not beside it).
// Pass reason: null — the onboarding overlay renders an unmapped reason string
// verbatim as a banner (see ReasonNotice in onboarding/index.tsx), and we don't
// want a raw identifier like "providers-keys-tab" showing as literal text.
function LocalEndpointRow({ onOpen }: { onOpen: (reason: null | string) => void }) {
const { t } = useI18n()
const copy = t.settings.providers.localEndpoint
return (
<RowButton
className="group grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1 rounded-[6px] px-3 py-2.5 text-left transition-colors hover:bg-(--ui-control-hover-background)"
onClick={() => onOpen(null)}
>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-[length:var(--conversation-text-font-size)] font-semibold">{copy.title}</span>
<span className="truncate text-[length:var(--conversation-caption-font-size)] leading-5 text-muted-foreground">
{copy.description}
</span>
</div>
<ChevronRight className="size-4 text-muted-foreground transition group-hover:text-foreground" />
</RowButton>
)
}
export function ProvidersSettings({
onClose,
onConfigSaved,
onMainModelChanged,
onViewChange,
view
}: ProvidersSettingsProps) {
const { t } = useI18n()
const { rowProps, vars } = useEnvCredentials()
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([])
const [openProvider, setOpenProvider] = useState<null | string>(null)
const [disconnecting, setDisconnecting] = useState<null | string>(null)
// Free-text filter for the API-keys view (provider name / env-var key / desc).
const [keyQuery, setKeyQuery] = useState('')
// The onboarding overlay owns the OAuth flow. Watch its `manual` flag so we
// re-read connection state when the user finishes (or dismisses) a sign-in
// they launched from this page — otherwise the cards keep their stale status.
const onboardingActive = useStore($desktopOnboarding).manual
const refreshOAuthProviders = useCallback(async () => {
// OAuth providers are best-effort — a failure here just hides the panel.
const { providers } = await listOAuthProviders()
setOauthProviders(providers)
}, [])
useEffect(() => {
let cancelled = false
void (async () => {
if (onboardingActive) {
return
}
try {
const { providers } = await listOAuthProviders()
if (!cancelled) {
setOauthProviders(providers)
}
} catch {
// Ignore — the OAuth panel just won't render.
}
})()
return () => void (cancelled = true)
}, [onboardingActive])
// External (CLI-managed) providers can't be cleared via the API by design —
// Hermes never deletes creds another tool owns behind a silent API call.
// Instead we run the documented removal command in the embedded terminal so
// the user sees exactly what executes, then return them to chat to watch it.
async function handleTerminalDisconnect(provider: OAuthProvider) {
const command = provider.disconnect_command
if (!command) {
return
}
const name = providerTitle(provider)
const ok = await confirm({
confirmLabel: t.settings.providers.disconnect,
destructive: true,
title: t.settings.providers.removeTerminalConfirm(name, command)
})
if (!ok) {
return
}
// Leave the settings overlay so the terminal pane (chat-only) is visible.
onClose()
runInTerminal(command)
notify({
kind: 'info',
title: t.settings.providers.removedTitle,
message: t.settings.providers.removeTerminalRunning(name)
})
}
async function handleDisconnect(provider: OAuthProvider) {
const name = providerTitle(provider)
const ok = await confirm({
confirmLabel: t.settings.providers.disconnect,
destructive: true,
title: t.settings.providers.removeConfirm(name)
})
if (!ok) {
return
}
setDisconnecting(provider.id)
try {
await disconnectOAuthProvider(provider.id)
notify({
durationMs: 3_000,
kind: 'success',
title: t.settings.providers.removedTitle,
message: t.settings.providers.removedMessage(name)
})
await refreshOAuthProviders().catch(() => undefined)
} catch (err) {
notifyError(err, t.settings.providers.failedRemove(name))
} finally {
setDisconnecting(null)
}
}
if (!vars) {
return <SettingsSkeleton search sections={[{ rows: 6 }]} />
}
const hasOauth = oauthProviders.length > 0
// The sidebar subnav owns the Accounts/API-keys split now; with no OAuth
// providers there's nothing for the "Accounts" view to show, so fall to keys.
const showApiKeys = view === 'keys' || (!hasOauth && view !== 'custom-endpoints')
const keyGroups = buildProviderKeyGroups(vars)
if (showApiKeys) {
const q = normalize(keyQuery)
const visibleGroups = q
? keyGroups.filter(group => {
const haystack = [group.name, group.description ?? '', group.primary[0], ...group.advanced.map(([k]) => k)]
return haystack.some(s => s.toLowerCase().includes(q))
})
: keyGroups
return (
<SettingsContent>
<LocalEndpointRow onOpen={startManualLocalEndpoint} />
{keyGroups.length > 0 ? (
<div className="grid gap-3">
<SearchField
aria-label={t.settings.providers.searchKeys}
containerClassName="w-full"
onChange={setKeyQuery}
placeholder={t.settings.providers.searchKeys}
value={keyQuery}
/>
{visibleGroups.length > 0 ? (
<div className="grid gap-2">
{visibleGroups.map(group => (
<ProviderKeyRows
expanded={openProvider === group.name}
group={group}
key={group.name}
onExpand={() => setOpenProvider(group.name)}
onToggle={() => setOpenProvider(prev => (prev === group.name ? null : group.name))}
rowProps={rowProps}
/>
))}
</div>
) : (
<div className="grid min-h-24 place-items-center px-4 py-6 text-center text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
{t.settings.providers.noKeysMatch}
</div>
)}
</div>
) : (
<NoProviderKeys />
)}
</SettingsContent>
)
}
if (view === 'custom-endpoints') {
return <CustomEndpointsSettings onConfigSaved={onConfigSaved} onMainModelChanged={onMainModelChanged} />
}
if (view === 'local') {
// Strict --local gate: without the launch flag the pane doesn't render
// even when local models are configured — a stale ?pview=local deep link
// (or an old shortcut) lands on the accounts view instead.
return $localModelsEnabled.get() ? <LocalModelsSettings /> : null
}
return (
<SettingsContent>
<OAuthPicker
disconnecting={disconnecting}
onDisconnect={provider => void handleDisconnect(provider)}
onTerminalDisconnect={provider => void handleTerminalDisconnect(provider)}
onWantApiKey={() => onViewChange('keys')}
onWantLocalModels={() => onViewChange('local')}
providers={oauthProviders}
/>
</SettingsContent>
)
}
interface ProviderKeyGroup {
advanced: [string, EnvVarInfo][]
description?: string
docsUrl?: string
hasAnySet: boolean
name: string
primary: [string, EnvVarInfo]
priority: number
}
interface ProvidersSettingsProps {
onClose: () => void
onConfigSaved?: () => void
onMainModelChanged?: (provider: string, model: string) => void
onViewChange: (view: ProviderView) => void
view: ProviderView
}
@@ -0,0 +1,103 @@
import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { Input } from '@/components/ui/input'
import { useI18n } from '@/i18n'
import {
$quickEntry,
canUseQuickEntry,
loadQuickEntrySettings,
QUICK_ENTRY_DEFAULT_SHORTCUT,
saveQuickEntrySettings
} from '@/store/quick-entry'
import { ListRow, ToggleRow } from './primitives'
/**
* Quick Entry — the global-hotkey mini composer's settings rows.
*
* The MAIN process is authoritative (it owns the OS accelerator), so this reads
* the live registration state on mount and surfaces the failure the feature must
* never swallow: a chord another app already owns comes back `registered: false`
* with `error: 'taken'` and says so, right under the field.
*/
export function QuickEntrySettings() {
const { t } = useI18n()
const q = t.settings.quickEntry
const state = useStore($quickEntry)
// The field is a local draft: the accelerator is only committed on blur/Enter,
// so a half-typed chord ("Alt+") never tears down the live registration.
const [draft, setDraft] = useState<null | string>(null)
useEffect(() => {
void loadQuickEntrySettings()
}, [])
if (!canUseQuickEntry()) {
return null
}
const commit = () => {
const next = (draft ?? '').trim()
setDraft(null)
if (next && next !== state.shortcut) {
void saveQuickEntrySettings({ shortcut: next })
}
}
const status =
state.registered === null
? null
: state.error === 'taken'
? q.takenBy
: state.error === 'invalid'
? q.invalidShortcut
: state.enabled && state.registered
? q.active
: null
return (
<>
<ToggleRow
checked={state.enabled}
description={q.enabledDesc}
label={q.enabledTitle}
onChange={enabled => void saveQuickEntrySettings({ enabled })}
/>
<ListRow
action={
<Input
aria-label={q.shortcutTitle}
disabled={!state.enabled}
onBlur={commit}
onChange={event => setDraft(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
commit()
}
}}
placeholder={QUICK_ENTRY_DEFAULT_SHORTCUT}
value={draft ?? state.shortcut}
/>
}
below={
status && (
<div
className={
state.error
? 'mt-1 text-[length:var(--conversation-caption-font-size)] text-amber-500/90'
: 'mt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)'
}
>
{status}
</div>
)
}
description={q.shortcutDesc}
title={q.shortcutTitle}
/>
</>
)
}
@@ -0,0 +1,130 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { stubResizeObserver } from '@/test/jsdom'
import type { ConfigFieldSchema } from '@/types/hermes'
import { ConfigField } from './config-field'
import { rankSearchOption, SearchableSelect } from './searchable-select'
beforeAll(() => {
stubResizeObserver()
Element.prototype.scrollIntoView = vi.fn()
Element.prototype.hasPointerCapture = vi.fn(() => false)
Element.prototype.releasePointerCapture = vi.fn()
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('rankSearchOption', () => {
it('ranks a final-segment match above a mid-path match', () => {
// "york" hits the city segment of America/New_York (score 2) but only a
// mid-path segment of America/New_York/Special (score 1).
expect(rankSearchOption('America/New_York', 'york')).toBe(2)
expect(rankSearchOption('America/New_York/Special', 'york')).toBe(1)
expect(rankSearchOption('America/New_York', 'york')).toBeGreaterThan(
rankSearchOption('America/New_York/Special', 'york')
)
})
it('is case-insensitive', () => {
expect(rankSearchOption('Asia/Kolkata', 'KOLKATA')).toBe(2)
expect(rankSearchOption('ASIA/KOLKATA', 'kolkata')).toBe(2)
})
it('scores a substring match anywhere as 1', () => {
expect(rankSearchOption('America/New_York', 'amer')).toBe(1)
})
it('scores a slashless option by plain substring', () => {
expect(rankSearchOption('UTC', 'ut')).toBe(1)
expect(rankSearchOption('UTC', 'xyz')).toBe(0)
})
it('scores a non-match as 0', () => {
expect(rankSearchOption('Europe/Berlin', 'tokyo')).toBe(0)
})
})
describe('SearchableSelect', () => {
const options = ['America/New_York', 'Asia/Kolkata', 'Europe/Berlin', 'UTC']
it('opens, filters, and selects an option', () => {
const onChange = vi.fn()
render(<SearchableSelect onChange={onChange} options={options} placeholder="Search…" value="" />)
fireEvent.click(screen.getByRole('combobox'))
fireEvent.change(screen.getByPlaceholderText('Search…'), { target: { value: 'kolkata' } })
fireEvent.click(screen.getByText('Asia/Kolkata'))
expect(onChange).toHaveBeenCalledWith('Asia/Kolkata')
})
it('renders the clear item when clearLabel is set and selecting it resets to blank', () => {
const onChange = vi.fn()
render(<SearchableSelect clearLabel="System default" onChange={onChange} options={options} value="Asia/Kolkata" />)
fireEvent.click(screen.getByRole('combobox'))
fireEvent.click(screen.getByText('System default'))
expect(onChange).toHaveBeenCalledWith('')
})
it('omits the clear item without clearLabel', () => {
render(<SearchableSelect onChange={vi.fn()} options={options} value="" />)
fireEvent.click(screen.getByRole('combobox'))
expect(screen.queryByText('System default')).toBeNull()
})
it('shows the placeholder when the value is blank', () => {
render(<SearchableSelect onChange={vi.fn()} options={options} placeholder="Search…" value="" />)
expect(screen.getByRole('combobox').textContent).toContain('Search…')
})
})
describe('ConfigField searchable routing', () => {
const searchableSchema: ConfigFieldSchema = {
type: 'select',
searchable: true,
clearable: true,
options: ['America/New_York', 'UTC']
}
it('routes searchable select schemas to SearchableSelect, not a free-text input', () => {
const { container } = render(
<ConfigField onChange={vi.fn()} schema={searchableSchema} schemaKey="timezone" value="UTC" />
)
// The searchable trigger renders; the generic free-text <Input> does not.
expect(container.querySelector('[data-slot="searchable-select-trigger"]')).not.toBeNull()
expect(container.querySelector('input[type="text"]')).toBeNull()
})
it('keeps plain string schemas on the free-text input', () => {
const { container } = render(
<ConfigField onChange={vi.fn()} schema={{ type: 'string' }} schemaKey="some.other.key" value="hello" />
)
expect(container.querySelector('[data-slot="searchable-select-trigger"]')).toBeNull()
expect(screen.getByDisplayValue('hello')).not.toBeNull()
})
it('surfaces the clear item via schema.clearable and resets to blank', () => {
const onChange = vi.fn()
render(<ConfigField onChange={onChange} schema={searchableSchema} schemaKey="timezone" value="UTC" />)
fireEvent.click(screen.getByRole('combobox'))
fireEvent.click(screen.getByText('System default'))
expect(onChange).toHaveBeenCalledWith('')
})
})
@@ -0,0 +1,115 @@
import { useCallback, useRef, useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { controlVariants } from '@/components/ui/control'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
/**
* cmdk filter score for one option. Case-insensitive substring match, with
* the final path segment (after the last "/") ranked above matches anywhere
* else so "york" ranks "America/New_York" over "America/New_York/Special".
* Exported for tests.
*/
export function rankSearchOption(option: string, search: string): number {
const lower = search.toLowerCase()
const itemLower = option.toLowerCase()
const slash = itemLower.lastIndexOf('/')
if (slash !== -1 && itemLower.slice(slash + 1).includes(lower)) {
return 2
}
if (itemLower.includes(lower)) {
return 1
}
return 0
}
/**
* Searchable select for large option lists (e.g. ~590 IANA timezones).
* Built on Popover + cmdk Command — the same stack as Shadcn's Combobox.
*
* The trigger renders like the existing closed `<Select>` but opens into a
* searchable Command palette. Closed-world only: the user must pick from the
* list; arbitrary text entry is not supported.
*
* `ConfigField` routes here when `schema.searchable === true`.
*/
export function SearchableSelect({
value,
onChange,
options,
placeholder = 'Search…',
emptyMessage = 'No results found.',
clearLabel
}: {
value: string
onChange: (value: string) => void
options: string[]
placeholder?: string
emptyMessage?: string
/** When set, prepends a "clear" item that sets the value to ''.
* Matches the existing <Select> pattern of EMPTY_SELECT_VALUE + "(none)". */
clearLabel?: string
}) {
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
const handleSelect = useCallback(
(selected: string) => {
onChange(selected)
setOpen(false)
},
[onChange]
)
const displayValue = value !== '' && value !== undefined ? value : placeholder
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger asChild>
<button
aria-expanded={open}
aria-haspopup="listbox"
className={cn(
controlVariants(),
'flex items-center justify-between gap-2 whitespace-nowrap',
!value && 'text-muted-foreground'
)}
data-slot="searchable-select-trigger"
ref={triggerRef}
role="combobox"
type="button"
>
<span className="truncate">{displayValue}</span>
<Codicon className="shrink-0 opacity-60" name={open ? 'chevron-up' : 'chevron-down'} size="1rem" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-[var(--radix-popover-trigger-width)] p-0">
<Command filter={rankSearchOption}>
<CommandInput autoFocus placeholder={placeholder} />
<CommandList>
<CommandEmpty>{emptyMessage}</CommandEmpty>
<CommandGroup>
{clearLabel && (
<CommandItem onSelect={() => handleSelect('')} value={clearLabel}>
<Codicon className={cn('mr-2 size-4', value === '' ? 'opacity-100' : 'opacity-0')} name="check" />
{clearLabel}
</CommandItem>
)}
{options.map(option => (
<CommandItem key={option} onSelect={() => handleSelect(option)} value={option}>
<Codicon className={cn('mr-2 size-4', option === value ? 'opacity-100' : 'opacity-0')} name="check" />
{option}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,402 @@
import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Tip } from '@/components/ui/tooltip'
import {
deleteSession,
getHermesConfigRecord,
listAllProfileSessions,
saveHermesConfig,
setSessionArchived
} from '@/hermes'
import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { pathLeaf } from '@/lib/display-path'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, ArchiveOff, FolderOpen, Loader2, Trash2 } from '@/lib/icons'
import { confirm } from '@/store/confirm'
import { notify, notifyError } from '@/store/notifications'
import { applyConfiguredDefaultProjectDir, ensureDefaultWorkspaceCwd, setSessions } from '@/store/session'
import { untombstoneSessions } from '@/store/session-removal'
import { forgetSessionUnread } from '@/store/session-unread'
import type { HermesConfigRecord, SessionInfo } from '@/types/hermes'
import { EmptyState, ListRow, SectionHeading, SettingsContent, SettingsSkeleton, ToggleRow } from './primitives'
import { useDeepLinkHighlight } from './use-deep-link-highlight'
const DEFAULT_AUTO_ARCHIVE_DAYS = 3
const ARCHIVED_FETCH_LIMIT = 200
export function SessionsSettings() {
const { t } = useI18n()
const s = t.settings.sessions
const [sessions, setLocalSessions] = useState<SessionInfo[]>([])
const [loading, setLoading] = useState(true)
const [busyId, setBusyId] = useState<string | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const result = await listAllProfileSessions(ARCHIVED_FETCH_LIMIT, 0, 'only')
setLocalSessions(result.sessions)
} catch (err) {
notifyError(err, s.failedLoad)
} finally {
setLoading(false)
}
}, [s.failedLoad])
useEffect(() => {
void load()
}, [load])
const unarchive = useCallback(
async (session: SessionInfo) => {
setBusyId(session.id)
try {
await setSessionArchived(session.id, false, session.profile)
setLocalSessions(prev => prev.filter(s => s.id !== session.id))
// Surface it again in the sidebar without waiting for a full refresh, and
// lift any optimistic eviction so the grouped tree shows it again too.
untombstoneSessions([session.id, session._lineage_root_id])
setSessions(prev => [{ ...session, archived: false }, ...prev.filter(s => s.id !== session.id)])
triggerHaptic('selection')
notify({ durationMs: 2_000, kind: 'success', message: s.restored })
} catch (err) {
notifyError(err, s.unarchiveFailed)
} finally {
setBusyId(null)
}
},
[s]
)
const remove = useCallback(
async (session: SessionInfo) => {
const ok = await confirm({
confirmLabel: s.deletePermanently,
destructive: true,
title: s.deleteConfirm(sessionTitle(session))
})
if (!ok) {
return
}
setBusyId(session.id)
try {
await deleteSession(session.id, session.profile)
// Permanent delete bypasses removeSession, so retire the persisted
// unread state here too rather than leaving it to rot.
forgetSessionUnread([session.id, session._lineage_root_id], session.profile)
setLocalSessions(prev => prev.filter(s => s.id !== session.id))
triggerHaptic('warning')
} catch (err) {
notifyError(err, s.deleteFailed)
} finally {
setBusyId(null)
}
},
[s]
)
useDeepLinkHighlight({
elementId: id => `archived-session-${id}`,
param: 'session',
ready: id => !loading && sessions.some(session => session.id === id)
})
if (loading) {
return <SettingsSkeleton sections={[{ rows: 1 }, { heading: true, rows: 4 }]} />
}
return (
<SettingsContent>
<DefaultProjectDirSetting />
<AutoArchiveSetting />
<SectionHeading
icon={Archive}
meta={sessions.length ? String(sessions.length) : undefined}
title={s.archivedTitle}
/>
<p className="mb-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{s.archivedIntro}
</p>
{sessions.length === 0 ? (
<EmptyState description={s.emptyArchivedDesc} title={s.emptyArchivedTitle} />
) : (
<div className="grid gap-1">
{sessions.map(session => {
const label = pathLeaf(session.cwd)
const busy = busyId === session.id
return (
<div className="scroll-mt-6 rounded-lg" id={`archived-session-${session.id}`} key={session.id}>
<ListRow
action={
<div className="flex items-center gap-1.5">
<Button
disabled={busy}
onClick={() => void unarchive(session)}
size="sm"
type="button"
variant="textStrong"
>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <ArchiveOff className="size-3.5" />}
<span>{s.unarchive}</span>
</Button>
<Tip label={s.deletePermanently}>
<Button
aria-label={s.deletePermanently}
className="text-muted-foreground hover:text-destructive"
disabled={busy}
onClick={() => void remove(session)}
size="icon"
type="button"
variant="ghost"
>
<Trash2 className="size-3.5" />
</Button>
</Tip>
</div>
}
description={session.preview || undefined}
hint={label ? `${label} · ${s.messages(session.message_count)}` : s.messages(session.message_count)}
title={sessionTitle(session)}
/>
</div>
)
})}
</div>
)}
</SettingsContent>
)
}
// Opt-in retention: soft-hide chats untouched for N days. The policy itself
// (last-activity sweep, pin exemption) lives in the backend
// (sessions.auto_archive in config.yaml + SessionDB.maybe_auto_archive); this
// just toggles the config keys, so CLI / gateway / Desktop all honour one
// setting. Pins are exempt on the backend, so pinned chats survive regardless.
function AutoArchiveSetting() {
const { t } = useI18n()
const s = t.settings.sessions
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
const [enabled, setEnabled] = useState(false)
const [days, setDays] = useState(DEFAULT_AUTO_ARCHIVE_DAYS)
useEffect(() => {
// Config REST is only reachable through the Electron bridge; skip in
// non-Electron contexts (tests/storybook) rather than throwing.
if (!window.hermesDesktop) {
return
}
let alive = true
void getHermesConfigRecord()
.then(record => {
if (!alive) {
return
}
const sessions = (record.sessions ?? {}) as Record<string, unknown>
const parsedDays = Number(sessions.auto_archive_days)
setConfig(record)
setEnabled(Boolean(sessions.auto_archive))
setDays(Number.isFinite(parsedDays) && parsedDays > 0 ? Math.round(parsedDays) : DEFAULT_AUTO_ARCHIVE_DAYS)
})
.catch(() => {
// Leave the control unmounted if config can't be read.
})
return () => {
alive = false
}
}, [])
const persist = useCallback(
async (autoArchive: boolean, archiveDays: number) => {
if (!config) {
return
}
const sessions = {
...((config.sessions ?? {}) as Record<string, unknown>),
auto_archive: autoArchive,
auto_archive_days: archiveDays
}
const updated = { ...config, sessions }
setConfig(updated)
try {
await saveHermesConfig(updated)
} catch (err) {
notifyError(err, s.autoArchiveFailed)
}
},
[config, s.autoArchiveFailed]
)
if (!config) {
return null
}
return (
<div className="mb-6">
<ToggleRow
checked={enabled}
description={s.autoArchiveDesc}
label={s.autoArchiveTitle}
onChange={on => {
setEnabled(on)
void persist(on, days)
}}
/>
{enabled && (
<ListRow
action={
<div className="flex items-center gap-2">
<Input
aria-label={s.autoArchiveDaysLabel}
className="w-20"
min={1}
onBlur={() => void persist(true, days)}
onChange={e => setDays(Math.max(1, Math.round(Number(e.target.value) || 1)))}
type="number"
value={days}
/>
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{s.autoArchiveDaysUnit}
</span>
</div>
}
title={s.autoArchiveDaysLabel}
/>
)}
</div>
)
}
// Lets the user pin the default cwd for new sessions. Without this, packaged
// builds on Windows used to spawn sessions in the install dir (`win-unpacked`
// / Program Files), which buried any files Hermes wrote there.
function DefaultProjectDirSetting() {
const { t } = useI18n()
const s = t.settings.sessions
const [dir, setDir] = useState<null | string>(null)
const [fallback, setFallback] = useState<string>('')
const [busy, setBusy] = useState(false)
useEffect(() => {
// The bridge is only present when running inside Electron. In a Vitest
// / Storybook / non-Electron context `window.hermesDesktop` is
// undefined, so guard the WHOLE call chain rather than chaining
// `?.settings.getDefaultProjectDir().then(...)` (the latter would
// short-circuit to `undefined.then(...)` and throw at runtime).
const settings = window.hermesDesktop?.settings
if (!settings) {
return
}
let alive = true
void settings.getDefaultProjectDir().then(result => {
if (!alive) {
return
}
setDir(result.dir)
setFallback(result.defaultLabel)
applyConfiguredDefaultProjectDir(result.dir)
})
return () => {
alive = false
}
}, [])
const choose = useCallback(async () => {
const settings = window.hermesDesktop?.settings
if (!settings) {
return
}
setBusy(true)
try {
const picked = await settings.pickDefaultProjectDir()
if (picked.canceled || !picked.dir) {
return
}
const result = await settings.setDefaultProjectDir(picked.dir)
setDir(result.dir)
applyConfiguredDefaultProjectDir(result.dir)
notify({ durationMs: 4_000, kind: 'success', message: s.defaultDirUpdated })
} catch (err) {
notifyError(err, s.updateDirFailed)
} finally {
setBusy(false)
}
}, [s])
const clear = useCallback(async () => {
const settings = window.hermesDesktop?.settings
if (!settings) {
return
}
setBusy(true)
try {
await settings.setDefaultProjectDir(null)
setDir(null)
applyConfiguredDefaultProjectDir(null)
await ensureDefaultWorkspaceCwd()
} catch (err) {
notifyError(err, s.clearDirFailed)
} finally {
setBusy(false)
}
}, [s])
return (
<div className="mb-6">
<SectionHeading icon={FolderOpen} title={s.defaultDirTitle} />
<p className="mb-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{s.defaultDirDesc}
</p>
<ListRow
action={
<div className="flex items-center gap-3">
<Button disabled={busy} onClick={() => void choose()} size="sm" type="button" variant="textStrong">
<FolderOpen className="size-3.5" />
<span>{dir ? s.change : s.choose}</span>
</Button>
{dir && (
<Button disabled={busy} onClick={() => void clear()} size="sm" type="button" variant="text">
{s.clear}
</Button>
)}
</div>
}
description={dir || s.defaultsTo(fallback || '~')}
title={dir ? dir : s.notSet}
/>
</div>
)
}
@@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest'
import { Settings2, Wrench } from '@/lib/icons'
import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
import {
buildConfigSearchEntries,
buildCredentialSearchEntries,
credentialSettingsView,
filterSettingsSearchEntries
} from './settings-search'
import { envVar } from './test-utils'
const searchCopy = {
fieldDescriptions: {
'display.personality': 'Choose how Hermes sounds in conversation.',
'tts.edge.voice': 'Voice used by Edge TTS.'
},
fieldLabels: {
'display.personality': 'Personality',
'tts.edge.voice': 'Edge voice',
'tts.openai.voice': 'OpenAI voice'
},
sections: {
chat: 'Chat',
voice: 'Voice'
}
}
describe('settings search index', () => {
it('builds config results from renderable schema fields with exact deep links', () => {
const schema: Record<string, ConfigFieldSchema> = {
'display.personality': { type: 'select' },
'tts.edge.voice': { type: 'string' },
'tts.openai.voice': { type: 'string' }
}
const config = {
display: { personality: 'default' },
tts: { provider: 'edge', edge: { voice: '' }, openai: { voice: '' } }
} as unknown as HermesConfigRecord
const entries = buildConfigSearchEntries(schema, config, searchCopy)
expect(entries.map(entry => entry.id)).toEqual([
'config-field:display.personality',
'config-field:tts.provider',
'config-field:tts.edge.voice'
])
expect(entries[0]).toMatchObject({
context: 'Chat',
description: 'Choose how Hermes sounds in conversation.',
label: 'Personality',
target: { field: 'display.personality', view: 'config:chat' }
})
expect(entries.some(entry => entry.id === 'config-field:tts.openai.voice')).toBe(false)
})
it('discovers future tool and setting entries entirely from backend metadata', () => {
const vars = {
FUTURE_CRAWLER_API_KEY: envVar('tool', {
description: 'Fetch structured pages from a new crawler.',
tools: ['future_crawl'],
url: 'https://future.example/keys'
}),
FUTURE_GATEWAY_URL: envVar('setting', { description: 'Route gateway traffic.' }),
TELEGRAM_BOT_TOKEN: envVar('messaging', { channel_managed: true }),
MODEL_PROVIDER_API_KEY: envVar('provider')
}
const entries = buildCredentialSearchEntries(
vars,
{ settings: 'Settings', tools: 'Tools' },
{ settings: Settings2, tools: Wrench }
)
expect(entries.map(entry => entry.id)).toEqual([
'credential:FUTURE_CRAWLER_API_KEY',
'credential:FUTURE_GATEWAY_URL'
])
expect(entries[0]).toMatchObject({
context: 'Tools',
label: 'FUTURE CRAWLER',
target: { key: 'FUTURE_CRAWLER_API_KEY', keysView: 'tools', view: 'keys' }
})
expect(filterSettingsSearchEntries(entries, 'structured crawler')).toHaveLength(1)
expect(filterSettingsSearchEntries(entries, 'future_crawl')[0]?.id).toBe('credential:FUTURE_CRAWLER_API_KEY')
expect(filterSettingsSearchEntries(entries, 'gateway traffic')[0]?.id).toBe('credential:FUTURE_GATEWAY_URL')
})
it('shares the Tools and Settings category boundary with the rendered page', () => {
expect(credentialSettingsView(envVar('tool'))).toBe('tools')
expect(credentialSettingsView(envVar('setting'))).toBe('settings')
expect(credentialSettingsView(envVar('messaging'))).toBe('settings')
expect(credentialSettingsView(envVar('messaging', { channel_managed: true }))).toBeNull()
expect(credentialSettingsView(envVar('provider'))).toBeNull()
})
it('uses AND matching across labels, context, descriptions, and raw keys', () => {
const entries = buildCredentialSearchEntries(
{
BRAVE_SEARCH_API_KEY: envVar('tool', { description: 'Search public web pages.' }),
FIRECRAWL_API_KEY: envVar('tool', { description: 'Extract public web pages.' })
},
{ settings: 'Settings', tools: 'Tools' },
{ settings: Settings2, tools: Wrench }
)
expect(filterSettingsSearchEntries(entries, 'brave tools')[0]?.id).toBe('credential:BRAVE_SEARCH_API_KEY')
expect(filterSettingsSearchEntries(entries, 'firecrawl extract')[0]?.id).toBe('credential:FIRECRAWL_API_KEY')
expect(filterSettingsSearchEntries(entries, 'brave extract')).toEqual([])
})
})
@@ -0,0 +1,228 @@
import type { IconComponent } from '@/lib/icons'
import { normalize } from '@/lib/text'
import type { ConfigFieldSchema, EnvVarInfo, HermesConfigRecord } from '@/types/hermes'
import { FIELD_LABELS, SECTIONS } from './constants'
import { credentialRowLabel } from './credential-key-ui'
import { fieldCopyForSchemaKey } from './field-copy'
import { prettyName, sectionFieldEntries, voiceFieldVisible } from './helpers'
import type { DesktopConfigSection, SettingsView } from './types'
export type CredentialSettingsView = 'settings' | 'tools'
export const APPEARANCE_SETTING_IDS = {
backdrop: 'appearance.backdrop',
embeds: 'appearance.embeds',
introSplash: 'appearance.intro-splash',
language: 'appearance.language',
theme: 'appearance.theme',
toolView: 'appearance.tool-view',
translucency: 'appearance.translucency',
uiScale: 'appearance.ui-scale'
} as const
export interface SettingsSearchTarget {
field?: string
key?: string
keysView?: CredentialSettingsView
plugin?: string
providerView?: 'accounts' | 'custom-endpoints' | 'keys'
setting?: string
view: SettingsView
}
export interface SettingsSearchEntry {
context: string
description?: string
icon: IconComponent
id: string
keywords: string[]
label: string
target: SettingsSearchTarget
}
interface ConfigSearchCopy {
fieldDescriptions: Record<string, string>
fieldLabels: Record<string, string>
sections: Record<string, string>
}
interface CredentialSearchCopy {
settings: string
tools: string
}
export function credentialSettingsView(info: EnvVarInfo): CredentialSettingsView | null {
if (info.channel_managed) {
return null
}
if (info.category === 'tool') {
return 'tools'
}
if (info.category === 'setting' || info.category === 'messaging') {
return 'settings'
}
return null
}
function configFieldLabel(key: string, copy: ConfigSearchCopy): string {
return (
fieldCopyForSchemaKey(copy.fieldLabels, key) ??
fieldCopyForSchemaKey(FIELD_LABELS, key) ??
prettyName(key.split('.').pop() ?? key)
)
}
function configFieldDescription(key: string, field: ConfigFieldSchema, copy: ConfigSearchCopy): string {
return fieldCopyForSchemaKey(copy.fieldDescriptions, key) ?? field.description ?? ''
}
export function buildConfigSearchEntries(
schema: Record<string, ConfigFieldSchema> | null | undefined,
config: HermesConfigRecord | null | undefined,
copy: ConfigSearchCopy,
sections: DesktopConfigSection[] = SECTIONS
): SettingsSearchEntry[] {
if (!schema || !config) {
return []
}
const sectionFields = sectionFieldEntries(schema, config)
return sections.flatMap(section => {
const context = copy.sections[section.id] ?? section.label
const fields = sectionFields.get(section.id) ?? []
const visibleFields = section.id === 'voice' ? fields.filter(([key]) => voiceFieldVisible(key, config)) : fields
return visibleFields.map(([key, field]) => ({
context,
description: configFieldDescription(key, field, copy),
icon: section.icon,
id: `config-field:${key}`,
keywords: ['settings', section.id, section.label, key],
label: configFieldLabel(key, copy),
target: {
field: key,
view: `config:${section.id}` as SettingsView
}
}))
})
}
export function buildCredentialSearchEntries(
vars: Record<string, EnvVarInfo> | null | undefined,
copy: CredentialSearchCopy,
icons: Record<CredentialSettingsView, IconComponent>
): SettingsSearchEntry[] {
if (!vars) {
return []
}
return Object.entries(vars)
.sort(([a], [b]) => a.localeCompare(b))
.flatMap(([key, info]) => {
const view = credentialSettingsView(info)
if (!view) {
return []
}
return [
{
context: view === 'tools' ? copy.tools : copy.settings,
description: info.description || undefined,
icon: icons[view],
id: `credential:${key}`,
keywords: [key, info.url ?? '', ...(Array.isArray(info.tools) ? info.tools : [])],
label: credentialRowLabel(key, info),
target: {
key,
keysView: view,
view: 'keys' as const
}
}
]
})
}
function searchScore(entry: SettingsSearchEntry, query: string): number {
const needle = normalize(query)
if (!needle) {
return 0
}
const label = normalize(entry.label)
const context = normalize(entry.context)
const haystack = normalize([entry.label, entry.context, entry.description ?? '', ...entry.keywords].join(' '))
const terms = needle.split(/\s+/).filter(Boolean)
if (!terms.every(term => haystack.includes(term))) {
return 0
}
if (label === needle) {
return 100
}
if (label.startsWith(needle)) {
return 90
}
if (label.includes(needle)) {
return 80
}
if (context.includes(needle)) {
return 70
}
if (terms.every(term => label.includes(term) || context.includes(term))) {
return 60
}
return 50
}
export function filterSettingsSearchEntries(entries: SettingsSearchEntry[], query: string): SettingsSearchEntry[] {
return entries
.map((entry, index) => ({ entry, index, score: searchScore(entry, query) }))
.filter(result => result.score > 0)
.sort((a, b) => b.score - a.score || a.index - b.index)
.map(result => result.entry)
}
/** Serialize a search target to the Settings route's query string. */
export function settingsSearchTargetQuery(target: SettingsSearchTarget): string {
const params = new URLSearchParams()
params.set('tab', target.view)
if (target.providerView) {
params.set('pview', target.providerView)
}
if (target.keysView) {
params.set('kview', target.keysView)
}
if (target.field) {
params.set('field', target.field)
}
if (target.setting) {
params.set('setting', target.setting)
}
if (target.key) {
params.set('key', target.key)
}
if (target.plugin) {
params.set('plugin', target.plugin)
}
return params.toString()
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { enrichSelectedSshHost, selectSshHost } from './ssh-host-selection'
const state = {
mode: 'ssh',
sshHost: 'linux-box',
sshUser: 'operator',
sshPort: 2222,
sshKeyPath: '/keys/linux',
sshRemoteHermesPath: '/opt/hermes'
}
describe('selectSshHost', () => {
it('clears host-specific fields when the selected host changes', () => {
expect(selectSshHost(state, 'mac-box')).toEqual({
mode: 'ssh',
sshHost: 'mac-box',
sshUser: '',
sshPort: null,
sshKeyPath: '',
sshRemoteHermesPath: ''
})
})
it('preserves state when reselecting the same host', () => {
expect(selectSshHost(state, state.sshHost)).toBe(state)
})
it('enriches only the host that produced the ssh config result', () => {
const selected = selectSshHost(state, 'mac-box')
expect(
enrichSelectedSshHost(selected, 'mac-box', {
identityFile: '~/.ssh/id_ed25519',
port: 22,
user: 'hermes'
})
).toMatchObject({
sshHost: 'mac-box',
sshUser: 'hermes',
sshPort: null,
sshKeyPath: '~/.ssh/id_ed25519'
})
expect(enrichSelectedSshHost(state, 'mac-box', { user: 'wrong' })).toBe(state)
})
})
@@ -0,0 +1,43 @@
type SshHostState = {
sshHost: string
sshUser: string
sshPort: number | null
sshKeyPath: string
sshRemoteHermesPath: string
}
type ResolvedSshHost = {
identityFile?: string | null
port?: number | null
user?: string | null
}
function selectSshHost<T extends SshHostState>(state: T, host: string): T {
if (host === state.sshHost) {
return state
}
return {
...state,
sshHost: host,
sshUser: '',
sshPort: null,
sshKeyPath: '',
sshRemoteHermesPath: ''
}
}
function enrichSelectedSshHost<T extends SshHostState>(state: T, host: string, resolved: ResolvedSshHost): T {
if (state.sshHost !== host) {
return state
}
return {
...state,
sshUser: state.sshUser || resolved.user || '',
sshPort: state.sshPort ?? (resolved.port === 22 ? null : (resolved.port ?? null)),
sshKeyPath: state.sshKeyPath || resolved.identityFile || ''
}
}
export { enrichSelectedSshHost, selectSshHost }
@@ -0,0 +1,127 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TerminalBackendsResponse } from '@/types/hermes'
const getTerminalBackends = vi.fn()
const selectTerminalBackend = vi.fn()
vi.mock('@/hermes', () => ({
getTerminalBackends: () => getTerminalBackends(),
selectTerminalBackend: (backend: string) => selectTerminalBackend(backend)
}))
vi.mock('@/store/notifications', () => ({
notify: vi.fn(),
notifyError: vi.fn()
}))
function backends(overrides: Partial<TerminalBackendsResponse> = {}): TerminalBackendsResponse {
return {
active: 'local',
backends: [
{
name: 'local',
label: 'Local',
description: 'Run commands directly on this machine. No isolation.',
active: true,
status: 'ready',
detail: ''
},
{
name: 'docker',
label: 'Docker',
description: 'Run commands in an isolated Docker container.',
active: false,
status: 'needs_setup',
detail: 'Docker daemon not reachable — start Docker and retry.'
},
{
name: 'ssh',
label: 'SSH',
description: 'Run commands on a remote host over SSH.',
active: false,
status: 'ready',
detail: 'hermes@devbox'
}
],
...overrides
}
}
beforeEach(() => {
getTerminalBackends.mockResolvedValue(backends())
selectTerminalBackend.mockResolvedValue({ ok: true, backend: 'ssh' })
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('TerminalBackendPanel', () => {
it('lists backends with status pills from the backends endpoint', async () => {
const { TerminalBackendPanel } = await import('./terminal-backend-panel')
render(<TerminalBackendPanel onConfiguredChange={vi.fn()} />)
expect(await screen.findByText('Local')).toBeTruthy()
expect(screen.getByText('Docker')).toBeTruthy()
expect(screen.getByText('SSH')).toBeTruthy()
// Ready backends show the Ready pill; needs_setup shows the warn pill.
expect(screen.getAllByText('Ready').length).toBeGreaterThanOrEqual(2)
expect(screen.getByText('Needs setup')).toBeTruthy()
expect(getTerminalBackends).toHaveBeenCalled()
})
it('shows setup guidance detail for a needs_setup backend', async () => {
const { TerminalBackendPanel } = await import('./terminal-backend-panel')
render(<TerminalBackendPanel onConfiguredChange={vi.fn()} />)
expect(await screen.findByText(/Docker daemon not reachable/)).toBeTruthy()
})
it('marks the active backend with an In use pill', async () => {
const { TerminalBackendPanel } = await import('./terminal-backend-panel')
render(<TerminalBackendPanel onConfiguredChange={vi.fn()} />)
const local = await screen.findByRole('button', { name: /Local/ })
expect(local.getAttribute('aria-pressed')).toBe('true')
expect(screen.getByText('In use')).toBeTruthy()
})
it('selects a backend when clicked and reports the change', async () => {
const onConfiguredChange = vi.fn()
const { TerminalBackendPanel } = await import('./terminal-backend-panel')
render(<TerminalBackendPanel onConfiguredChange={onConfiguredChange} />)
fireEvent.click(await screen.findByRole('button', { name: /SSH/ }))
await waitFor(() => expect(selectTerminalBackend).toHaveBeenCalledWith('ssh'))
await waitFor(() => expect(onConfiguredChange).toHaveBeenCalled())
// Active highlight moves without a refetch.
const ssh = screen.getByRole('button', { name: /SSH/ })
expect(ssh.getAttribute('aria-pressed')).toBe('true')
})
it('allows selecting a needs_setup backend (guidance instead of blocking)', async () => {
selectTerminalBackend.mockResolvedValue({ ok: true, backend: 'docker' })
const { TerminalBackendPanel } = await import('./terminal-backend-panel')
render(<TerminalBackendPanel onConfiguredChange={vi.fn()} />)
fireEvent.click(await screen.findByRole('button', { name: /Docker/ }))
await waitFor(() => expect(selectTerminalBackend).toHaveBeenCalledWith('docker'))
// The guidance detail stays visible on the now-active row.
expect(screen.getByText(/Docker daemon not reachable/)).toBeTruthy()
})
it('does not re-select the already active backend', async () => {
const { TerminalBackendPanel } = await import('./terminal-backend-panel')
render(<TerminalBackendPanel onConfiguredChange={vi.fn()} />)
fireEvent.click(await screen.findByRole('button', { name: /Local/ }))
await new Promise(resolve => setTimeout(resolve, 50))
expect(selectTerminalBackend).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,160 @@
import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { getTerminalBackends, selectTerminalBackend } from '@/hermes'
import { useI18n } from '@/i18n'
import { AlertTriangle, Check, Loader2, RefreshCw } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import type { TerminalBackendInfo, TerminalBackendsResponse } from '@/types/hermes'
import { Pill } from './primitives'
interface TerminalBackendPanelProps {
/** Re-read the parent toolset list after a backend change so any derived
* pills stay in sync. */
onConfiguredChange?: () => void
}
function StatusPill({ backend }: { backend: TerminalBackendInfo }) {
const { t } = useI18n()
const copy = t.settings.toolsets.terminalBackend
if (backend.status === 'ready') {
return (
<Pill tone="primary">
<Check className="size-3" />
{copy.ready}
</Pill>
)
}
return (
<Pill tone="muted">
<AlertTriangle className="size-3" />
{backend.status === 'needs_setup' ? copy.needsSetup : copy.unavailable}
</Pill>
)
}
/**
* Terminal execution backend picker — the Capabilities-tab counterpart of the
* `terminal.backend` config enum. Each backend row carries a live health probe
* (Docker daemon reachable, SSH host configured, Modal/Daytona credentials
* present) so users see Ready / Needs-setup guidance instead of a bare
* dropdown. Selecting a needs-setup backend is allowed — the row shows what's
* missing rather than blocking, matching the CLI configurator.
*/
export function TerminalBackendPanel({ onConfiguredChange }: TerminalBackendPanelProps) {
const { t } = useI18n()
const copy = t.settings.toolsets.terminalBackend
const [data, setData] = useState<TerminalBackendsResponse | null>(null)
const [loading, setLoading] = useState(true)
const [selecting, setSelecting] = useState<string | null>(null)
const refresh = useCallback(async () => {
setLoading(true)
try {
setData(await getTerminalBackends())
} catch (err) {
notifyError(err, copy.failedLoad)
} finally {
setLoading(false)
}
}, [copy.failedLoad])
useEffect(() => {
void refresh()
}, [refresh])
async function handleSelect(backend: TerminalBackendInfo) {
if (backend.active || selecting) {
return
}
setSelecting(backend.name)
try {
await selectTerminalBackend(backend.name)
// Mirror the backend write locally so the active highlight tracks the
// new selection without a refetch (probes are unchanged by a select).
setData(current =>
current
? {
...current,
active: backend.name,
backends: current.backends.map(b => ({ ...b, active: b.name === backend.name }))
}
: current
)
notify({ kind: 'success', title: copy.selectedTitle, message: copy.selectedMessage(backend.label) })
onConfiguredChange?.()
} catch (err) {
notifyError(err, copy.failedSelect(backend.label))
} finally {
setSelecting(null)
}
}
if (loading && !data) {
return (
<div className="flex items-center gap-2 px-1 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
{copy.loading}
</div>
)
}
if (!data) {
return null
}
return (
<div className="grid gap-1.5">
<div className="flex items-baseline justify-between gap-2 px-0.5">
<span className="text-[0.72rem] font-medium">{copy.sectionTitle}</span>
<Button disabled={loading} onClick={() => void refresh()} size="sm" variant="text">
<RefreshCw className={cn('size-3.5', loading && 'animate-spin')} />
</Button>
</div>
<div className="grid gap-1">
{data.backends.map(backend => (
<button
aria-pressed={backend.active}
className={cn(
'grid gap-0.5 rounded-lg border px-2.5 py-2 text-left transition',
backend.active
? 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
: 'border-transparent bg-background/55 hover:bg-accent/40'
)}
disabled={selecting !== null}
key={backend.name}
onClick={() => void handleSelect(backend)}
type="button"
>
<span className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium">{backend.label}</span>
<StatusPill backend={backend} />
{backend.active && (
<Pill tone="primary">
<Check className="size-3" />
{copy.inUse}
</Pill>
)}
{selecting === backend.name && <Loader2 className="size-3 animate-spin" />}
</span>
<span className="text-[0.68rem] text-muted-foreground">{backend.description}</span>
{backend.status !== 'ready' && backend.detail && (
<span className="flex items-start gap-1 text-[0.68rem] text-amber-600 dark:text-amber-300">
<AlertTriangle className="mt-0.5 size-3 shrink-0" />
{backend.detail}
{backend.active && ` ${copy.needsSetupHint}`}
</span>
)}
</button>
))}
</div>
</div>
)
}
@@ -0,0 +1,152 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $terminalFontFamily } from '../right-sidebar/terminal/terminal-font'
import { TerminalFontSetting } from './terminal-font-setting'
const mocks = vi.hoisted(() => ({
cache: vi.fn(),
loadedConfig: {} as Record<string, unknown>,
notifyError: vi.fn(),
profileSwitch: null as null | (() => void),
save: vi.fn()
}))
vi.mock('@/hermes', () => ({
saveHermesConfig: (config: Record<string, unknown>) => mocks.save(config)
}))
vi.mock('@/i18n', () => ({
useI18n: () => ({
t: {
settings: {
appearance: {
terminalFontDesc: 'Choose an installed font.',
terminalFontPlaceholder: 'MesloLGS NF or a CSS font stack',
terminalFontPreview: 'Glyph preview',
terminalFontReset: 'Use default',
terminalFontTitle: 'Terminal Font'
},
config: { autosaveFailed: 'Autosave failed' }
}
}
})
}))
vi.mock('@/store/notifications', () => ({
notifyError: (...args: unknown[]) => mocks.notifyError(...args)
}))
vi.mock('../hooks/use-config-record', () => ({
setHermesConfigCache: (config: Record<string, unknown>) => mocks.cache(config),
useHermesConfigRecord: () => ({ data: mocks.loadedConfig })
}))
vi.mock('../hooks/use-on-profile-switch', () => ({
useOnProfileSwitch: (callback: () => void) => {
mocks.profileSwitch = callback
}
}))
async function flushAutosave() {
await act(async () => {
vi.advanceTimersByTime(550)
await Promise.resolve()
await Promise.resolve()
})
}
describe('TerminalFontSetting', () => {
beforeEach(() => {
vi.useFakeTimers()
mocks.loadedConfig = {
display: { skin: 'hermes' },
terminal: { backend: 'local', cwd: '/workspace', font_family: '' }
}
mocks.save.mockResolvedValue({ ok: true })
mocks.profileSwitch = null
$terminalFontFamily.set('')
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
vi.useRealTimers()
})
it('selects MesloLGS NF and persists only the terminal font field', async () => {
render(<TerminalFontSetting />)
const input = screen.getByRole('combobox', { name: 'Terminal Font' })
fireEvent.change(input, { target: { value: 'MesloLGS NF' } })
expect($terminalFontFamily.get()).toBe('MesloLGS NF')
expect((screen.getByLabelText('Glyph preview') as HTMLElement).style.fontFamily).toContain('MesloLGS NF')
await flushAutosave()
expect(mocks.save).toHaveBeenCalledWith({
display: { skin: 'hermes' },
terminal: { backend: 'local', cwd: '/workspace', font_family: 'MesloLGS NF' }
})
expect(mocks.cache).toHaveBeenCalledWith(mocks.save.mock.calls[0][0])
})
it('accepts an arbitrary CSS stack and resets to the bundled default', async () => {
mocks.loadedConfig = {
terminal: { backend: 'local', font_family: "'Hack Nerd Font', monospace" }
}
render(<TerminalFontSetting />)
const input = screen.getByRole('combobox', { name: 'Terminal Font' })
expect((input as HTMLInputElement).value).toBe("'Hack Nerd Font', monospace")
fireEvent.change(input, { target: { value: "'Custom Powerline', monospace" } })
await flushAutosave()
expect(mocks.save.mock.calls[0][0]).toMatchObject({
terminal: { backend: 'local', font_family: "'Custom Powerline', monospace" }
})
fireEvent.click(screen.getByRole('button', { name: 'Use default' }))
expect($terminalFontFamily.get()).toBe('')
expect((screen.getByLabelText('Glyph preview') as HTMLElement).style.fontFamily).toContain('JetBrains Mono')
await flushAutosave()
expect(mocks.save.mock.calls[1][0]).toMatchObject({
terminal: { backend: 'local', font_family: '' }
})
})
it('rolls back the optimistic font when autosave fails', async () => {
mocks.loadedConfig = { terminal: { font_family: 'MesloLGS NF' } }
mocks.save.mockRejectedValue(new Error('disk full'))
render(<TerminalFontSetting />)
const input = screen.getByRole('combobox', { name: 'Terminal Font' })
fireEvent.change(input, { target: { value: 'Hack Nerd Font' } })
expect($terminalFontFamily.get()).toBe('Hack Nerd Font')
await flushAutosave()
expect((input as HTMLInputElement).value).toBe('MesloLGS NF')
expect($terminalFontFamily.get()).toBe('MesloLGS NF')
expect(mocks.notifyError).toHaveBeenCalledWith(expect.any(Error), 'Autosave failed')
})
it('drops the prior profile font and reseeds from the next profile', () => {
mocks.loadedConfig = { terminal: { font_family: 'MesloLGS NF' } }
const view = render(<TerminalFontSetting />)
expect($terminalFontFamily.get()).toBe('MesloLGS NF')
act(() => mocks.profileSwitch?.())
expect($terminalFontFamily.get()).toBe('')
expect((screen.getByRole('combobox', { name: 'Terminal Font' }) as HTMLInputElement).disabled).toBe(true)
mocks.loadedConfig = { terminal: { font_family: 'Hack Nerd Font' } }
view.rerender(<TerminalFontSetting />)
expect((screen.getByRole('combobox', { name: 'Terminal Font' }) as HTMLInputElement).value).toBe('Hack Nerd Font')
expect($terminalFontFamily.get()).toBe('Hack Nerd Font')
})
})
@@ -0,0 +1,169 @@
import { useEffect, useRef, useState } from 'react'
import {
normalizeTerminalFontFamily,
resolveTerminalFontFamily,
setTerminalFontFamilyFromConfig,
TERMINAL_FONT_SUGGESTIONS
} from '@/app/right-sidebar/terminal/terminal-font'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { saveHermesConfig } from '@/hermes'
import { useI18n } from '@/i18n'
import { notifyError } from '@/store/notifications'
import type { HermesConfigRecord } from '@/types/hermes'
import { setHermesConfigCache, useHermesConfigRecord } from '../hooks/use-config-record'
import { useOnProfileSwitch } from '../hooks/use-on-profile-switch'
import { getNested, setNested } from './helpers'
import { ListRow } from './primitives'
const AUTOSAVE_DELAY_MS = 550
function fontFamilyFromConfig(config: HermesConfigRecord): string {
return normalizeTerminalFontFamily(getNested(config, 'terminal.font_family'))
}
export function TerminalFontSetting() {
const { t } = useI18n()
const copy = t.settings.appearance
const { data: loadedConfig } = useHermesConfigRecord()
// draft === null ⇔ unseeded: nothing painted yet for this profile. The
// profile-switch handler resets it to null and records the config object
// it was looking at (`staleConfig`) — the seed effect refuses to re-seed
// from that same object, so the previous profile's cached record can't
// repopulate the field; the next profile's fetch (a new object) seeds it.
// `draft` itself is the seed marker (no ref mirroring, per the lint rule).
const [draft, setDraft] = useState<string | null>(null)
const [staleConfig, setStaleConfig] = useState<HermesConfigRecord | null>(null)
const [saveVersion, setSaveVersion] = useState(0)
const saveVersionRef = useRef(0)
// Lexically outside every useEffect so async save callbacks can cancel the
// in-flight version without assigning to a ref inside an effect body.
const cancelPendingSave = () => {
saveVersionRef.current = 0
}
useEffect(() => {
if (!loadedConfig || draft !== null || loadedConfig === staleConfig) {
return
}
const value = fontFamilyFromConfig(loadedConfig)
setDraft(value)
setTerminalFontFamilyFromConfig(value)
}, [draft, loadedConfig, staleConfig])
useOnProfileSwitch(() => {
saveVersionRef.current += 1
setDraft(null)
setStaleConfig(loadedConfig ?? null)
setSaveVersion(0)
// Do not show the previous profile's font while the new profile loads.
setTerminalFontFamilyFromConfig('')
})
useEffect(() => {
if (draft === null || saveVersion === 0 || !loadedConfig) {
return
}
const version = saveVersion
const value = normalizeTerminalFontFamily(draft)
// Already persisted (or a cache refresh confirmed it) — nothing to save.
// This also terminates the effect re-run after a successful save updates
// the shared config cache.
if (value === fontFamilyFromConfig(loadedConfig)) {
return
}
// The last successfully saved value IS what the shared config cache
// holds — successful saves write it back via setHermesConfigCache, so
// rollback re-derives from there instead of mirroring into a ref.
const rollback = fontFamilyFromConfig(loadedConfig)
const timeout = window.setTimeout(() => {
const next = setNested(loadedConfig, 'terminal.font_family', value)
void saveHermesConfig(next)
.then(result => {
if (!result.ok) {
throw new Error(t.settings.config.autosaveFailed)
}
if (saveVersionRef.current !== version) {
return
}
setHermesConfigCache(next)
})
.catch(error => {
if (saveVersionRef.current !== version) {
return
}
cancelPendingSave()
setSaveVersion(0)
setDraft(rollback)
setTerminalFontFamilyFromConfig(rollback)
notifyError(error, t.settings.config.autosaveFailed)
})
}, AUTOSAVE_DELAY_MS)
return () => window.clearTimeout(timeout)
}, [draft, loadedConfig, saveVersion, t.settings.config.autosaveFailed])
const update = (value: string) => {
saveVersionRef.current += 1
setDraft(value)
setSaveVersion(saveVersionRef.current)
setTerminalFontFamilyFromConfig(value)
}
const value = draft ?? ''
const previewFontFamily = resolveTerminalFontFamily(value)
return (
<ListRow
below={
<div className="mt-3 space-y-2">
<div className="flex items-center gap-3">
<Input
aria-label={copy.terminalFontTitle}
className="flex-1"
disabled={draft === null}
list="hermes-terminal-font-families"
onChange={event => update(event.target.value)}
placeholder={copy.terminalFontPlaceholder}
value={value}
/>
<Button disabled={!value || draft === null} onClick={() => update('')} size="inline" variant="text">
{copy.terminalFontReset}
</Button>
</div>
<datalist id="hermes-terminal-font-families">
{TERMINAL_FONT_SUGGESTIONS.map(font => (
<option key={font} value={font} />
))}
</datalist>
<div
aria-label={copy.terminalFontPreview}
className="overflow-hidden px-1 py-2 text-sm text-(--ui-text-secondary)"
style={{ fontFamily: previewFontFamily }}
>
<span className="mr-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{copy.terminalFontPreview}
</span>
<span> ~/project git:main </span>
</div>
</div>
}
description={copy.terminalFontDesc}
title={copy.terminalFontTitle}
wide
/>
)
}
@@ -0,0 +1,17 @@
import type { EnvVarInfo } from '@/types/hermes'
/** An unset secret in `category`. The Keys tab and the settings search both
* bucket by category and branch on `is_set`, so those are what tests vary. */
export function envVar(category: string, patch: Partial<EnvVarInfo> = {}): EnvVarInfo {
return {
advanced: false,
category,
description: '',
is_password: true,
is_set: false,
redacted_value: null,
tools: [],
url: '',
...patch
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,910 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router'
import { SETTINGS_ROUTE } from '@/app/routes'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
deleteEnvVar,
getActionStatus,
getToolsetConfig,
getToolsetModels,
pollOAuthSession,
type ProfileScope,
revealEnvVar,
runToolsetPostSetup,
selectToolsetModel,
selectToolsetProvider,
setEnvVar,
startOAuthLogin
} from '@/hermes'
import { useI18n } from '@/i18n'
import { Check, Loader2, Save, Terminal } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { upsertDesktopActionTask } from '@/store/activity'
import { confirm } from '@/store/confirm'
import { notify, notifyError } from '@/store/notifications'
import type {
ActionStatusResponse,
ToolEnvVar,
ToolProvider,
ToolProviderStatus,
ToolsetConfig,
ToolsetModelsResponse
} from '@/types/hermes'
import { EnvVarActionsMenu, EnvVarActionsTrigger, EnvVarContextMenu } from './env-var-actions-menu'
import { Pill } from './primitives'
import { VoiceProviderFields } from './voice-provider-fields'
interface ToolsetConfigPanelProps {
toolset: string
/** Called after a key is saved/cleared or a provider chosen, so the parent
* can refresh the "Configured / Needs keys" pill. */
onConfiguredChange?: () => void
/** Capabilities profile-scope override: configure THIS profile instead of the
* app-wide active one. Omitted (every other caller) → app-wide active
* profile, so behavior is unchanged. Threaded into every fetch below. */
profile?: ProfileScope
}
/** Toolsets whose backends expose a selectable model catalog (mirrors the
* backend's _MODEL_CATALOG_TOOLSETS map). */
const MODEL_CATALOG_TOOLSETS = new Set(['image_gen', 'video_gen'])
/**
* `useNavigate` throws when there is no react-router context. Inside Settings
* (the panel's original home) there always is one, so behavior is unchanged;
* embedded in a plugin dialog OUTSIDE the router there is none, and this
* degrades to `null` instead of crashing the whole panel. Router presence is
* stable for a mounted instance's lifetime, so the try/catch never changes the
* hook count between renders (rules-of-hooks safe).
*/
function useOptionalNavigate(): null | ReturnType<typeof useNavigate> {
try {
return useNavigate()
} catch {
return null
}
}
function providerConfigured(provider: ToolProvider, envState: Record<string, boolean>): boolean {
if (provider.env_vars.length === 0) {
return true
}
return provider.env_vars.every(ev => envState[ev.key])
}
/**
* Resolve the readiness pill state for a provider row. Prefers the honest
* server-computed `status` (keys ∧ Nous entitlement ∧ post-setup install
* state). Older backends don't send `status` — fall back to the legacy
* env-var heuristic, mapped onto the same state space (`ready` /
* `needs_keys`), so the pill still renders against an outdated runtime.
*/
function providerStatus(provider: ToolProvider, envState: Record<string, boolean>): ToolProviderStatus {
if (provider.status) {
// Env-var edits patch envState locally without a refetch — a stale
// server `status` must not keep saying "needs keys" (or "ready") after
// the user just saved (or cleared) a key in this panel.
if (provider.env_vars.length > 0) {
return provider.env_vars.every(ev => envState[ev.key]) ? 'ready' : 'needs_keys'
}
return provider.status
}
return providerConfigured(provider, envState) ? 'ready' : 'needs_keys'
}
interface EnvVarFieldProps {
envVar: ToolEnvVar
isSet: boolean
onSaved: (key: string) => void
onCleared: (key: string) => void
profile?: ProfileScope
}
function EnvVarField({ envVar, isSet, onSaved, onCleared, profile }: EnvVarFieldProps) {
const { t } = useI18n()
const copy = t.settings.toolsets
const navigate = useOptionalNavigate()
const [editing, setEditing] = useState(false)
const [value, setValue] = useState('')
const [revealed, setRevealed] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
// Internal route change to Settings → API Keys (tools sub-view) with the
// deep-link param keys-settings consumes to scroll + flash this key's card.
// No-op when there is no router (embedded outside Settings, e.g. a plugin
// dialog): the "Manage keys" affordance simply doesn't navigate there.
const openInKeys = () => navigate?.(`${SETTINGS_ROUTE}?tab=keys&key=${encodeURIComponent(envVar.key)}`)
async function handleSave() {
if (!value) {
return
}
setBusy(true)
try {
await setEnvVar(envVar.key, value, profile)
setEditing(false)
setValue('')
onSaved(envVar.key)
notify({ kind: 'success', title: copy.savedTitle, message: copy.savedMessage(envVar.key) })
} catch (err) {
notifyError(err, copy.failedSave(envVar.key))
} finally {
setBusy(false)
}
}
async function handleClear() {
if (!(await confirm({ destructive: true, title: copy.removeConfirm(envVar.key) }))) {
return
}
setBusy(true)
try {
await deleteEnvVar(envVar.key, profile)
setRevealed(null)
onCleared(envVar.key)
notify({ kind: 'success', title: copy.removedTitle, message: copy.removedMessage(envVar.key) })
} catch (err) {
notifyError(err, copy.failedRemove(envVar.key))
} finally {
setBusy(false)
}
}
async function handleReveal() {
if (revealed !== null) {
setRevealed(null)
return
}
try {
const result = await revealEnvVar(envVar.key, profile)
setRevealed(result.value)
} catch (err) {
notifyError(err, copy.failedReveal(envVar.key))
}
}
const actionProps = {
clearDisabled: busy,
docsUrl: envVar.url,
isRevealed: revealed !== null,
isSet,
label: envVar.key,
onClear: () => void handleClear(),
onEdit: () => setEditing(true),
onManageKeys: openInKeys,
onReveal: () => void handleReveal()
}
return (
<EnvVarContextMenu {...actionProps}>
<div className="grid gap-2 rounded-lg bg-background/55 p-2.5">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs font-medium">{envVar.key}</span>
<Pill tone={isSet ? 'primary' : 'muted'}>
{isSet && <Check className="size-3" />}
{isSet ? copy.set : copy.notSet}
</Pill>
</div>
{envVar.prompt && envVar.prompt !== envVar.key && (
<p className="mt-0.5 text-[0.7rem] text-muted-foreground">{envVar.prompt}</p>
)}
</div>
{!editing && (
<EnvVarActionsMenu {...actionProps}>
<EnvVarActionsTrigger onClick={event => event.stopPropagation()} />
</EnvVarActionsMenu>
)}
</div>
{isSet && revealed !== null && (
<div className="rounded-md bg-background px-2.5 py-1.5 font-mono text-xs text-foreground">
{revealed || '---'}
</div>
)}
{editing && (
<div className="flex flex-wrap items-center gap-2">
<Input
autoFocus
className="min-w-52 flex-1 font-mono"
onChange={e => setValue(e.target.value)}
placeholder={envVar.prompt || envVar.key}
type={envVar.default ? 'text' : 'password'}
value={value}
/>
<Button disabled={busy || !value} onClick={() => void handleSave()} size="sm">
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Save />}
{t.common.save}
</Button>
<Button onClick={() => setEditing(false)} size="sm" variant="text">
{t.common.cancel}
</Button>
</div>
)}
</div>
</EnvVarContextMenu>
)
}
interface PostSetupRunnerProps {
toolset: string
/** The provider's post_setup hook key (e.g. "camofox", "ddgs"). */
postSetupKey: string
/** True when the server reports the install side-effect already satisfied
* (provider status === 'ready') — renders the resting "Installed" state
* with a low-key re-run affordance instead of the primary CTA. */
installed?: boolean
/** Refresh the parent config after the install finishes (a backend may now
* report itself configured). */
onComplete?: () => void
profile?: ProfileScope
}
/**
* Runs a provider's post-setup install hook (npm / pip / binary) via the
* `/api/tools/toolsets/{name}/post-setup` spawn-action and tails the resulting
* log inline — the GUI equivalent of the install step `hermes tools` runs
* after you pick a backend that needs extra dependencies.
*
* Idempotent UX: when the backend's readiness status says the install is
* already satisfied, the primary "Run setup" CTA is replaced by an
* "Installed" pill plus a small "Re-run setup" text button, so clicking
* around the panel doesn't look like it keeps reinstalling.
*/
function PostSetupRunner({ toolset, postSetupKey, installed = false, onComplete, profile }: PostSetupRunnerProps) {
const { t } = useI18n()
const copy = t.settings.toolsets
const [running, setRunning] = useState(false)
const [status, setStatus] = useState<ActionStatusResponse | null>(null)
// Guard against overlapping polls / state updates after unmount.
const activeRef = useRef(false)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
return () => {
activeRef.current = false
}
}, [])
const run = useCallback(async () => {
setRunning(true)
setStatus(null)
activeRef.current = true
try {
const started = await runToolsetPostSetup(toolset, postSetupKey, profile)
// The spawn endpoint reports ok:false if it couldn't launch the action
// (e.g. unknown key, server-side spawn failure). Don't poll a status
// that will never exist — surface the failure and stop.
if (!started.ok) {
notifyError(new Error('spawn failed'), copy.postSetupFailed(postSetupKey))
return
}
let last: ActionStatusResponse | null = null
// Mirror command-center's runSystemAction poll loop: poll the action log
// until it exits (or we hit the attempt ceiling), feeding the global
// activity rail as we go.
for (let attempt = 0; attempt < 150 && activeRef.current; attempt += 1) {
await new Promise(resolve => window.setTimeout(resolve, 1200))
if (!activeRef.current) {
break
}
const polled = await getActionStatus(started.name, 300, profile)
last = polled
setStatus(polled)
upsertDesktopActionTask(polled)
if (!polled.running) {
break
}
}
if (activeRef.current) {
const ok = last?.exit_code === 0
notify(
ok
? {
kind: 'success',
title: copy.postSetupCompleteTitle,
message: copy.postSetupCompleteMessage(postSetupKey)
}
: { kind: 'error', title: copy.postSetupErrorTitle, message: copy.postSetupErrorMessage(postSetupKey) }
)
onComplete?.()
}
} catch (err) {
if (activeRef.current) {
notifyError(err, copy.postSetupFailed(postSetupKey))
}
} finally {
if (activeRef.current) {
setRunning(false)
}
}
}, [toolset, postSetupKey, onComplete, copy, profile])
return (
<div className="grid gap-2 rounded-lg bg-background/55 p-2.5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-[0.72rem] text-muted-foreground">
{installed ? copy.postSetupInstalledHint : copy.postSetupHint(postSetupKey)}
</p>
</div>
{installed ? (
<span className="flex items-center gap-2">
<Pill tone="primary">
<Check className="size-3" />
{copy.postSetupInstalled}
</Pill>
<Button disabled={running} onClick={() => void run()} size="sm" variant="text">
{running ? <Loader2 className="size-3.5 animate-spin" /> : <Terminal className="size-3.5" />}
{running ? copy.postSetupRunning : copy.postSetupRerun}
</Button>
</span>
) : (
<Button disabled={running} onClick={() => void run()} size="sm">
{running ? <Loader2 className="size-3.5 animate-spin" /> : <Terminal className="size-3.5" />}
{running ? copy.postSetupRunning : copy.postSetupRun}
</Button>
)}
</div>
{status && (status.lines.length > 0 || status.running) && (
<pre
className="max-h-48 overflow-y-auto rounded-md bg-background px-2.5 py-1.5 font-mono text-[0.7rem] leading-relaxed text-muted-foreground whitespace-pre-wrap"
data-selectable-text="true"
>
{status.lines.length > 0 ? status.lines.join('\n') : copy.postSetupStarting}
</pre>
)}
</div>
)
}
interface ModelCatalogPickerProps {
toolset: string
/** The picker-row name of the provider whose catalog to show. */
providerName: string
/** True when this provider is the one written to config — selecting a model
* only makes sense for the active backend. */
isActiveBackend: boolean
profile?: ProfileScope
}
/**
* Backend model catalog — the GUI counterpart of the model picker `hermes
* tools` runs after you choose an image/video generation backend (e.g. FAL's
* multi-model catalog). Renders speed / strengths / price per model as a
* radio-card list and persists the choice to `image_gen.model` /
* `video_gen.model`.
*/
function ModelCatalogPicker({ toolset, providerName, isActiveBackend, profile }: ModelCatalogPickerProps) {
const { t } = useI18n()
const copy = t.settings.toolsets
const [catalog, setCatalog] = useState<ToolsetModelsResponse | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
setLoading(true)
getToolsetModels(toolset, providerName, profile)
.then(next => {
if (!cancelled) {
setCatalog(next)
}
})
.catch(() => {
// Backend predates the models endpoint or the provider has no
// catalog — hide the section entirely rather than erroring.
if (!cancelled) {
setCatalog(null)
}
})
.finally(() => {
if (!cancelled) {
setLoading(false)
}
})
return () => void (cancelled = true)
}, [toolset, providerName, profile])
const pick = async (modelId: string) => {
setSaving(modelId)
try {
await selectToolsetModel(toolset, modelId, providerName, profile)
setCatalog(current => (current ? { ...current, current: modelId } : current))
notify({ kind: 'success', title: copy.modelSelectedTitle, message: copy.modelSelectedMessage(modelId) })
} catch (err) {
notifyError(err, copy.failedSelectModel(modelId))
} finally {
setSaving(null)
}
}
if (loading) {
return (
<div className="flex items-center gap-2 px-1 py-2 text-[0.72rem] text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
{copy.loadingModels}
</div>
)
}
if (!catalog || !catalog.has_models || catalog.models.length === 0) {
return null
}
const selected = catalog.current ?? catalog.default
return (
<div className="grid gap-1.5">
<div className="flex items-baseline justify-between gap-2 px-0.5">
<span className="text-[0.72rem] font-medium">{copy.modelSectionTitle}</span>
<span className="text-[0.68rem] text-muted-foreground">{copy.modelCount(catalog.models.length)}</span>
</div>
{!isActiveBackend && <p className="px-0.5 text-[0.68rem] text-muted-foreground">{copy.modelInactiveHint}</p>}
<div className="grid gap-1">
{catalog.models.map(model => {
const isSelected = selected === model.id
const isDefault = catalog.default === model.id
return (
<button
aria-pressed={isSelected}
className={cn(
'grid gap-0.5 rounded-lg border px-2.5 py-2 text-left transition',
isSelected
? 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
: 'border-transparent bg-background/55 hover:bg-accent/40',
!isActiveBackend && 'opacity-60'
)}
disabled={saving !== null || !isActiveBackend}
key={model.id}
onClick={() => void pick(model.id)}
type="button"
>
<span className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs font-medium">{model.display || model.id}</span>
{isSelected && (
<Pill tone="primary">
<Check className="size-3" />
{copy.modelInUse}
</Pill>
)}
{!isSelected && isDefault && <Pill>{copy.modelDefault}</Pill>}
{saving === model.id && <Loader2 className="size-3 animate-spin" />}
</span>
<span className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[0.68rem] text-muted-foreground">
{model.speed && <span>{model.speed}</span>}
{model.strengths && <span>{model.strengths}</span>}
{model.price && <span className="font-mono">{model.price}</span>}
</span>
</button>
)
})}
</div>
</div>
)
}
export function ToolsetConfigPanel({ toolset, onConfiguredChange, profile }: ToolsetConfigPanelProps) {
const { t } = useI18n()
const copy = t.settings.toolsets
const [cfg, setCfg] = useState<ToolsetConfig | null>(null)
const [loading, setLoading] = useState(true)
const [selecting, setSelecting] = useState<string | null>(null)
// Which provider row is EXPANDED in the panel (purely presentational —
// distinct from the backend-active provider in cfg.active_provider).
const [expandedProvider, setExpandedProvider] = useState<string | null>(null)
// Live per-key set/unset state, seeded from the endpoint then patched locally.
const [envState, setEnvState] = useState<Record<string, boolean>>({})
// Default-provider selection and a user click race just after config arrives:
// a stale initialization effect must never replace an explicit choice.
const providerChoiceClaimedRef = useRef(false)
// Guard the Nous Portal sign-in poll loop against unmount/state updates.
const mountedRef = useRef(true)
// eslint-disable-next-line no-restricted-syntax -- mount flag guarding an async poll loop, not an atom mirror
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
}
}, [])
const refresh = useCallback(async () => {
setLoading(true)
try {
const next = await getToolsetConfig(toolset, profile)
setCfg(next)
const seeded: Record<string, boolean> = {}
for (const provider of next.providers) {
for (const ev of provider.env_vars) {
seeded[ev.key] = ev.is_set
}
}
setEnvState(seeded)
} catch (err) {
notifyError(err, copy.failedLoad)
} finally {
setLoading(false)
}
}, [copy.failedLoad, toolset, profile])
useEffect(() => {
void refresh()
}, [refresh])
const providers = useMemo(() => cfg?.providers ?? [], [cfg])
// Default the expanded provider to the one actually active in config
// (`is_active` / `cfg.active_provider`, mirroring the CLI picker), then the
// first fully-configured provider, else the first provider. Without this the
// panel highlighted the first keyless provider (e.g. Nous Portal) even when
// the user had already selected another (e.g. DuckDuckGo).
// eslint-disable-next-line no-restricted-syntax -- one-shot provider-choice claim flag, not an atom mirror
useEffect(() => {
if (providerChoiceClaimedRef.current || expandedProvider || providers.length === 0) {
return
}
const selected =
providers.find(p => p.is_active) ??
(cfg?.active_provider ? providers.find(p => p.name === cfg.active_provider) : undefined) ??
providers.find(p => providerConfigured(p, envState)) ??
providers[0]
// Claim before enqueueing the state update. Effects can run with a stale
// expandedProvider closure after a user click, so state alone is too late
// to protect that choice.
providerChoiceClaimedRef.current = true
setExpandedProvider(selected.name)
}, [expandedProvider, providers, envState, cfg])
async function handleSelect(provider: ToolProvider) {
if (selecting !== null) {
return
}
providerChoiceClaimedRef.current = true
setExpandedProvider(provider.name)
setSelecting(provider.name)
try {
const result = await selectToolsetProvider(toolset, provider.name, undefined, profile)
// Mirror the backend write locally so dependent UI (model catalog
// enablement) tracks the new active backend without a refetch.
setCfg(current =>
current
? {
...current,
active_provider: provider.name,
providers: current.providers.map(p => ({ ...p, is_active: p.name === provider.name }))
}
: current
)
if (result.needs_nous_auth) {
// Managed Nous row selected without Portal entitlement: the config
// keys are written but the backend won't activate until the user
// signs in (the CLI runs this gate inline; the GUI surfaces it as a
// sign-in action). Reuses the existing Nous Portal device-code flow.
notify({
kind: 'warning',
title: copy.nousAuthNeededTitle,
message: copy.nousAuthNeededMessage(provider.name),
action: { label: copy.nousAuthSignIn, onClick: () => void signInToNousPortal() }
})
return
}
notify({ kind: 'success', title: copy.selectedTitle, message: copy.selectedMessage(provider.name) })
onConfiguredChange?.()
} catch (err) {
notifyError(err, copy.failedSelect(provider.name))
} finally {
setSelecting(null)
}
}
// Drive the existing Nous Portal OAuth device-code flow (the same session
// machinery onboarding uses: start → open verification URL → poll), then
// refetch the toolset config so is_active / status flip once entitled.
async function signInToNousPortal() {
try {
const start = await startOAuthLogin('nous', profile)
if (start.flow !== 'device_code') {
notifyError(new Error(`unexpected flow: ${start.flow}`), copy.nousAuthFailed)
return
}
const url = start.verification_url
if (window.hermesDesktop?.openExternal) {
try {
await window.hermesDesktop.openExternal(url)
} catch {
window.open(url, '_blank', 'noopener,noreferrer')
}
} else {
window.open(url, '_blank', 'noopener,noreferrer')
}
// Poll until the device-code session resolves (~5s cadence, bounded).
for (let attempt = 0; attempt < 120 && mountedRef.current; attempt += 1) {
await new Promise(resolve => window.setTimeout(resolve, 5000))
if (!mountedRef.current) {
return
}
const polled = await pollOAuthSession('nous', start.session_id, profile)
if (polled.status === 'approved') {
notify({ kind: 'success', title: copy.nousAuthDoneTitle, message: copy.nousAuthDoneMessage })
await refresh()
onConfiguredChange?.()
return
}
if (polled.status !== 'pending') {
notifyError(new Error(polled.error_message || `Sign-in ${polled.status}`), copy.nousAuthFailed)
return
}
}
} catch (err) {
if (mountedRef.current) {
notifyError(err, copy.nousAuthFailed)
}
}
}
function patchEnv(key: string, isSet: boolean) {
setEnvState(c => ({ ...c, [key]: isSet }))
onConfiguredChange?.()
}
async function handleSelectCapability(provider: ToolProvider, capability: 'search' | 'extract') {
setSelecting(provider.name)
try {
await selectToolsetProvider(toolset, provider.name, capability, profile)
// Mirror the backend write locally so the Search:/Extract: badges track
// the new per-capability backend without a refetch.
setCfg(current =>
current
? {
...current,
...(capability === 'search'
? { active_search_backend: provider.web_backend ?? provider.name }
: { active_extract_backend: provider.web_backend ?? provider.name })
}
: current
)
notify({
kind: 'success',
title: copy.selectedTitle,
message: copy.webCapabilitySelectedMessage(provider.name, capability)
})
onConfiguredChange?.()
} catch (err) {
notifyError(err, copy.failedSelectCapability(provider.name))
} finally {
setSelecting(null)
}
}
if (loading) {
// Inline row, not a full block loader — a big centered spinner is what
// caused the Skills/Tools tab-switch layout jump; this reads as "more
// config incoming" without reserving a tall empty area.
return (
<div className="flex items-center gap-2 px-1 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
{copy.loadingConfig}
</div>
)
}
// Nothing to configure → render nothing. An inspector explaining that there
// is nothing to explain is noise (the old expander UX needed the message so
// an expanded-empty panel didn't look broken; the always-open detail doesn't).
if (!cfg || !cfg.has_category) {
return null
}
if (providers.length === 0) {
return <p className="px-1 py-3 text-xs text-muted-foreground">{copy.noProviders}</p>
}
return (
<div className="grid gap-2">
{toolset === 'web' && cfg.active_search_backend !== undefined && (
// The runtime dispatches web_search and web_extract independently
// (web.search_backend / web.extract_backend) — show which backend
// each capability resolves to right now.
<div className="flex flex-wrap items-center gap-2 px-1">
<Pill>{copy.webSearchActive(cfg.active_search_backend || copy.webCapabilityUnset)}</Pill>
<Pill>{copy.webExtractActive(cfg.active_extract_backend || copy.webCapabilityUnset)}</Pill>
</div>
)}
{providers.map(provider => {
const isExpanded = expandedProvider === provider.name
const isBackendActive = provider.is_active || cfg?.active_provider === provider.name
const status = providerStatus(provider, envState)
const webCaps = toolset === 'web' ? (provider.capabilities ?? []) : []
const isSearchBackend = Boolean(provider.web_backend && cfg.active_search_backend === provider.web_backend)
const isExtractBackend = Boolean(provider.web_backend && cfg.active_extract_backend === provider.web_backend)
return (
<div className="overflow-hidden rounded-xl bg-background/60" key={provider.name}>
<button
aria-expanded={isExpanded}
className={cn(
'flex w-full items-center justify-between gap-3 px-3 py-2.5 text-left transition hover:bg-accent/50',
isExpanded && 'bg-accent/40'
)}
onClick={() => {
// Row click only expands/collapses — activating a backend is
// the explicit "Use this backend" button below, so browsing
// provider details never silently rewrites config.
providerChoiceClaimedRef.current = true
setExpandedProvider(current => (current === provider.name ? null : provider.name))
}}
type="button"
>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">{provider.name}</span>
{provider.badge && <Pill>{provider.badge}</Pill>}
{isBackendActive && (
<Pill tone="primary">
<Check className="size-3" />
{copy.activeBackend}
</Pill>
)}
{status === 'ready' && !isBackendActive && (
<Pill tone="primary">
<Check className="size-3" />
{copy.ready}
</Pill>
)}
{status === 'needs_auth' && <Pill tone="warn">{copy.needsSignIn}</Pill>}
{status === 'needs_setup' && <Pill tone="warn">{copy.needsSetup}</Pill>}
{isSearchBackend && <Pill tone="primary">{copy.webUsedForSearch}</Pill>}
{isExtractBackend && <Pill tone="primary">{copy.webUsedForExtract}</Pill>}
</span>
{selecting === provider.name && <Loader2 className="size-3.5 shrink-0 animate-spin" />}
</button>
{isExpanded && (
<div className="grid gap-2 bg-muted/20 p-3">
{provider.tag && <p className="text-[0.72rem] text-muted-foreground">{provider.tag}</p>}
{(toolset !== 'web' || webCaps.length === 0) && (
// Explicit activation — the old row-click-selects UX gave no
// signal about which backend was actually in use and made
// reading a row's details indistinguishable from choosing it.
<div className="flex flex-wrap items-center gap-2">
{isBackendActive ? (
<Pill tone="primary">
<Check className="size-3" />
{copy.activeBackendHint}
</Pill>
) : (
<Button disabled={selecting !== null} onClick={() => void handleSelect(provider)} size="sm">
{selecting === provider.name ? <Loader2 className="size-3.5 animate-spin" /> : <Check />}
{copy.useBackend}
</Button>
)}
</div>
)}
{webCaps.length > 0 && (
// Per-capability assignment: writes web.search_backend /
// web.extract_backend without touching the shared
// web.backend key. Hidden for capabilities the backend
// can't serve (e.g. ddgs is search-only).
<div className="flex flex-wrap items-center gap-1.5">
{webCaps.includes('search') && (
<Button
disabled={selecting !== null || isSearchBackend}
onClick={() => void handleSelectCapability(provider, 'search')}
size="xs"
variant="text"
>
{copy.webUseForSearch}
</Button>
)}
{webCaps.includes('extract') && (
<Button
disabled={selecting !== null || isExtractBackend}
onClick={() => void handleSelectCapability(provider, 'extract')}
size="xs"
variant="text"
>
{copy.webUseForExtract}
</Button>
)}
</div>
)}
{provider.requires_nous_auth && (
<p className="text-[0.72rem] text-muted-foreground">{copy.nousIncluded}</p>
)}
{provider.env_vars.length === 0 ? (
<p className="text-[0.72rem] text-muted-foreground">{copy.noApiKeyRequired}</p>
) : (
provider.env_vars.map(ev => (
<EnvVarField
envVar={ev}
isSet={Boolean(envState[ev.key])}
key={ev.key}
onCleared={key => patchEnv(key, false)}
onSaved={key => patchEnv(key, true)}
profile={profile}
/>
))
)}
{provider.post_setup && (
<PostSetupRunner
installed={provider.status === 'ready'}
onComplete={() => void refresh()}
postSetupKey={provider.post_setup}
profile={profile}
toolset={toolset}
/>
)}
{toolset === 'tts' && provider.tts_provider && (
// Voice/model settings for this backend (tts.<key>.*) —
// the same fields Settings → Voice renders, inline so the
// Capabilities panel is a complete setup surface.
<VoiceProviderFields providerKey={provider.tts_provider} section="tts" />
)}
{MODEL_CATALOG_TOOLSETS.has(toolset) && (
<ModelCatalogPicker
isActiveBackend={provider.is_active || cfg?.active_provider === provider.name}
profile={profile}
providerName={provider.name}
toolset={toolset}
/>
)}
</div>
)}
</div>
)
})}
</div>
)
}
+53
View File
@@ -0,0 +1,53 @@
import type { Dispatch, SetStateAction } from 'react'
import type { HermesGateway } from '@/hermes'
import type { IconComponent } from '@/lib/icons'
import type { EnvVarInfo } from '@/types/hermes'
export type SettingsView =
| 'about'
| 'billing'
| 'connections'
| 'gateway'
| 'keybinds'
| 'keys'
| 'notifications'
| 'plugins'
| 'providers'
| 'sessions'
| `config:${string}`
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
export interface SettingsPageProps {
gateway?: HermesGateway | null
onClose: () => void
onConfigSaved?: () => void
onMainModelChanged?: (provider: string, model: string) => void
}
export interface ProviderGroup {
name: string
priority: number
entries: [string, EnvVarInfo][]
hasAnySet: boolean
}
export interface DesktopConfigSection {
id: string
label: string
icon: IconComponent
keys: string[]
}
export interface EnvRowProps {
varKey: string
info: EnvVarInfo
edits: Record<string, string>
revealed: Record<string, string>
saving: string | null
setEdits: Dispatch<SetStateAction<Record<string, string>>>
onSave: (key: string) => void
onClear: (key: string) => void
onReveal: (key: string) => void
compact?: boolean
}
@@ -0,0 +1,10 @@
import { useAiturkCopy } from '@/i18n/aiturk'
/** OS package uninstall owns the branded app; upstream cleanup owns Hermes. */
export function UninstallSection() {
const copy = useAiturkCopy()
return <section className="grid gap-2 rounded-lg border border-border p-4">
<h3 className="font-medium">{copy.uninstallTitle}</h3>
<p className="text-sm text-muted-foreground">{copy.uninstallDescription}</p>
</section>
}
@@ -0,0 +1,100 @@
import { useEffect } from 'react'
import { useSearchParams } from 'react-router'
interface DeepLinkHighlightOptions {
param: string
ready: (target: string) => boolean
elementId: (target: string) => string
onResolve?: (target: string) => void
block?: ScrollLogicalPosition
}
// react-router's useSearchParams throws with no router context. Inside Settings
// (every original caller) there always is one, so behavior is unchanged; when a
// consumer is embedded OUTSIDE the router (e.g. McpTab in a plugin dialog) there
// is none, and this degrades to an inert [empty params, no-op setter] instead of
// crashing. Router presence is stable for a mounted instance's lifetime, so the
// try/catch never changes the hook count between renders (rules-of-hooks safe).
function useOptionalSearchParams(): ReturnType<typeof useSearchParams> {
try {
return useSearchParams()
} catch {
return [new URLSearchParams(), () => undefined]
}
}
// Deep-link from the command palette (?<param>=<id>): once the target row is
// renderable, scroll it into view and flash it, then drop the param so it
// doesn't re-fire. Returns the pending target (null once consumed) so callers
// can force the row open before it mounts.
export function useDeepLinkHighlight({
param,
ready,
elementId,
onResolve,
block = 'center'
}: DeepLinkHighlightOptions): null | string {
const [searchParams, setSearchParams] = useOptionalSearchParams()
const target = searchParams.get(param)
useEffect(() => {
if (!target || !ready(target)) {
return
}
onResolve?.(target)
let cancelled = false
let timer = 0
// onResolve may flip view state that mounts the row a few frames later, so
// poll briefly for it and only drop the param AFTER a successful scroll —
// deleting up front would lose the deep link when the target mounts late.
let attempts = 0
const attempt = () => {
if (cancelled) {
return
}
const element = document.getElementById(elementId(target))
if (element) {
element.scrollIntoView({ behavior: 'smooth', block })
if (!element.hasAttribute('tabindex')) {
element.tabIndex = -1
}
element.focus({ preventScroll: true })
element.classList.add('setting-field-highlight')
window.setTimeout(() => element.classList.remove('setting-field-highlight'), 1600)
setSearchParams(
previous => {
const next = new URLSearchParams(previous)
next.delete(param)
return next
},
{ replace: true }
)
return
}
if (attempts++ < 20) {
timer = window.setTimeout(attempt, 80)
}
}
timer = window.setTimeout(attempt, 80)
return () => {
cancelled = true
window.clearTimeout(timer)
}
}, [block, elementId, onResolve, param, ready, setSearchParams, target])
return target
}
@@ -0,0 +1,207 @@
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { useCallback, useEffect } from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { $pluginRecords } from '@/contrib/plugins-store'
import { getEnvVars, getHermesConfigSchema } from '@/hermes'
import { useI18n } from '@/i18n'
import { Package, Palette, Settings2, Wrench } from '@/lib/icons'
import { $agentPlugins, isDesktopRelevantPlugin, loadAgentPlugins } from '@/store/agent-plugins'
import { $gatewayState } from '@/store/session'
import { TRANSLUCENCY_SUPPORTED } from '@/store/translucency'
import { useHermesConfigRecord } from '../hooks/use-config-record'
import { useOnProfileSwitch } from '../hooks/use-on-profile-switch'
import {
APPEARANCE_SETTING_IDS,
buildConfigSearchEntries,
buildCredentialSearchEntries,
type SettingsSearchEntry
} from './settings-search'
/**
* The granular settings-search catalog (appearance controls, config fields,
* credentials) for the command palette's Settings page. Page destinations stay
* on the palette side it already owns section/page rows this hook only
* contributes the deep, schema-driven targets.
*/
export function useSettingsSearchCatalog(enabled: boolean) {
const { t } = useI18n()
const configQuery = useHermesConfigRecord()
const schemaQuery = useQuery({
queryKey: ['hermes-config-schema'],
queryFn: () => getHermesConfigSchema(),
enabled,
staleTime: 5 * 60 * 1000
})
const {
data: envVars,
isError: envVarsError,
isFetching: envVarsFetching,
refetch: refetchEnvVars
} = useQuery({
queryKey: ['desktop-settings-search-env-vars'],
queryFn: () => getEnvVars(),
enabled,
staleTime: 5 * 60 * 1000
})
const refreshCatalog = useCallback(() => {
void refetchEnvVars()
}, [refetchEnvVars])
useOnProfileSwitch(refreshCatalog)
// Plugin rows: desktop plugins are already in their store (discovered at
// boot); agent plugins ride the gateway, so load them the first time the
// catalog is wanted — same RPC the Plugins page fires on mount, deduped by
// the store's own inflight guard.
const { requestGateway } = useGatewayRequest()
const gatewayState = useStore($gatewayState)
const desktopPluginRecords = useStore($pluginRecords)
const agentPlugins = useStore($agentPlugins)
useEffect(() => {
if (enabled && gatewayState === 'open') {
void loadAgentPlugins(requestGateway)
}
}, [enabled, gatewayState, requestGateway])
const pluginContext = t.settings.nav.plugins
const pluginEntries: SettingsSearchEntry[] = [
...Object.values(desktopPluginRecords).map(record => ({
context: pluginContext,
description: record.description,
icon: Package,
id: `plugin:desktop:${record.id}`,
keywords: ['plugin', 'extension', record.id],
label: record.name,
target: { plugin: record.id, view: 'plugins' as const }
})),
...agentPlugins.filter(isDesktopRelevantPlugin).map(row => ({
context: pluginContext,
description: row.description || undefined,
icon: Package,
id: `plugin:agent:${row.key ?? row.name}`,
keywords: ['plugin', 'extension', ...(row.key ? [row.key] : [])],
label: row.name,
target: { plugin: row.key ?? row.name, view: 'plugins' as const }
}))
]
// Never expose stale profile-scoped targets while a catalog is refreshing.
// Field/key results wait for the current profile's data rather than briefly
// pointing into the previous one.
const configEntries =
configQuery.isFetching || schemaQuery.isFetching || configQuery.isError || schemaQuery.isError
? []
: buildConfigSearchEntries(schemaQuery.data?.fields, configQuery.data, {
fieldDescriptions: t.settings.fieldDescriptions,
fieldLabels: t.settings.fieldLabels,
sections: t.settings.sections
})
const appearanceContext = t.settings.sections.appearance
const appearance = t.settings.appearance
const appearanceEntries: SettingsSearchEntry[] = [
{
context: appearanceContext,
description: t.language.description,
icon: Palette,
id: `setting:${APPEARANCE_SETTING_IDS.language}`,
keywords: ['locale'],
label: t.language.label,
target: { setting: APPEARANCE_SETTING_IDS.language, view: 'config:appearance' }
},
{
context: appearanceContext,
description: appearance.themeDesc,
icon: Palette,
id: `setting:${APPEARANCE_SETTING_IDS.theme}`,
keywords: ['color mode', 'skin'],
label: appearance.themeTitle,
target: { setting: APPEARANCE_SETTING_IDS.theme, view: 'config:appearance' }
},
{
context: appearanceContext,
icon: Palette,
id: `setting:${APPEARANCE_SETTING_IDS.uiScale}`,
keywords: ['zoom', 'size'],
label: appearance.uiScaleTitle,
target: { setting: APPEARANCE_SETTING_IDS.uiScale, view: 'config:appearance' }
},
// Linux has no translucency row to land on, and a palette hit that scrolls
// to nothing is worse than no hit.
...(TRANSLUCENCY_SUPPORTED
? [
{
context: appearanceContext,
description: appearance.translucencyDesc,
icon: Palette,
id: `setting:${APPEARANCE_SETTING_IDS.translucency}`,
keywords: ['opacity', 'transparent'],
label: appearance.translucencyTitle,
target: { setting: APPEARANCE_SETTING_IDS.translucency, view: 'config:appearance' as const }
}
]
: []),
{
context: appearanceContext,
description: appearance.backdropDesc,
icon: Palette,
id: `setting:${APPEARANCE_SETTING_IDS.backdrop}`,
keywords: ['background', 'blur'],
label: appearance.backdropTitle,
target: { setting: APPEARANCE_SETTING_IDS.backdrop, view: 'config:appearance' }
},
{
context: appearanceContext,
description: appearance.introSplashDesc,
icon: Palette,
id: `setting:${APPEARANCE_SETTING_IDS.introSplash}`,
keywords: ['splash', 'wordmark', 'empty chat', 'new chat'],
label: appearance.introSplashTitle,
target: { setting: APPEARANCE_SETTING_IDS.introSplash, view: 'config:appearance' }
},
{
context: appearanceContext,
description: appearance.toolViewDesc,
icon: Palette,
id: `setting:${APPEARANCE_SETTING_IDS.toolView}`,
keywords: ['tool display', 'technical'],
label: appearance.toolViewTitle,
target: { setting: APPEARANCE_SETTING_IDS.toolView, view: 'config:appearance' }
},
{
context: appearanceContext,
description: appearance.embedsDesc,
icon: Palette,
id: `setting:${APPEARANCE_SETTING_IDS.embeds}`,
keywords: ['external content', 'privacy'],
label: appearance.embedsTitle,
target: { setting: APPEARANCE_SETTING_IDS.embeds, view: 'config:appearance' }
}
]
const credentialEntries = buildCredentialSearchEntries(
envVarsFetching || envVarsError ? null : envVars,
{
settings: t.settings.nav.keysSettings,
tools: t.settings.nav.keysTools
},
{ settings: Settings2, tools: Wrench }
)
return {
appearanceEntries,
configEntries,
credentialEntries,
pluginEntries
}
}
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import type { HermesConfigRecord } from '@/types/hermes'
import { voiceFieldVisible } from './helpers'
const cfg = (over: Record<string, unknown> = {}): HermesConfigRecord =>
({
tts: { provider: 'edge', edge: {}, openai: {} },
stt: { enabled: true, provider: 'local', local: {}, groq: {} },
...over
}) as unknown as HermesConfigRecord
describe('voiceFieldVisible', () => {
it('always shows top-level + non-provider keys', () => {
const config = cfg()
for (const key of ['tts.provider', 'stt.enabled', 'stt.provider', 'voice.auto_tts', 'voice.record_key']) {
expect(voiceFieldVisible(key, config)).toBe(true)
}
})
it('shows only the selected TTS provider sub-fields', () => {
const config = cfg()
expect(voiceFieldVisible('tts.edge.voice', config)).toBe(true)
expect(voiceFieldVisible('tts.openai.voice', config)).toBe(false)
expect(voiceFieldVisible('tts.elevenlabs.voice_id', config)).toBe(false)
})
it('shows only the selected STT provider sub-fields', () => {
const config = cfg()
expect(voiceFieldVisible('stt.local.model', config)).toBe(true)
expect(voiceFieldVisible('stt.groq.model', config)).toBe(false)
})
it('hides every STT provider sub-field when STT is disabled', () => {
const config = cfg({ stt: { enabled: false, provider: 'local', local: {} } })
expect(voiceFieldVisible('stt.local.model', config)).toBe(false)
// ...but the enable/provider toggles themselves stay visible.
expect(voiceFieldVisible('stt.enabled', config)).toBe(true)
expect(voiceFieldVisible('stt.provider', config)).toBe(true)
})
it('tracks a provider switch', () => {
expect(voiceFieldVisible('tts.openai.voice', cfg({ tts: { provider: 'openai', openai: {} } }))).toBe(true)
expect(voiceFieldVisible('tts.edge.voice', cfg({ tts: { provider: 'openai', openai: {} } }))).toBe(false)
})
})
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import { ENUM_OPTIONS, FREE_INPUT_KEYS, SECTIONS } from './constants'
import { voiceProviderKeys } from './voice-provider-fields'
const voiceKeys = SECTIONS.find(s => s.id === 'voice')?.keys ?? []
describe('voiceProviderKeys', () => {
it('derives per-provider field keys from the curated Voice section', () => {
expect(voiceProviderKeys('tts', 'openai')).toEqual(['tts.openai.model', 'tts.openai.voice'])
expect(voiceProviderKeys('tts', 'elevenlabs')).toEqual(['tts.elevenlabs.voice_id', 'tts.elevenlabs.model_id'])
expect(voiceProviderKeys('tts', 'edge')).toEqual(['tts.edge.voice'])
})
it('covers every built-in TTS provider the Capabilities picker offers', () => {
// Every provider key the backend TOOL_CATEGORIES["tts"] rows can carry
// (tts_provider values) must resolve to at least one config field, so the
// Capabilities panel never renders a silently-empty settings block.
for (const provider of [
'edge',
'openai',
'xai',
'elevenlabs',
'mistral',
'gemini',
'kittentts',
'piper',
'deepinfra',
'minimax'
]) {
expect(voiceProviderKeys('tts', provider).length, provider).toBeGreaterThan(0)
}
})
it('scopes to the exact provider segment (no prefix bleed)', () => {
expect(voiceProviderKeys('tts', 'mini')).toEqual([])
expect(voiceProviderKeys('stt', 'openai')).toEqual(['stt.openai.model'])
})
})
describe('voice field option coverage', () => {
it('offers the current gpt-4o-mini-tts voice set, not just the tts-1 six', () => {
const voices = ENUM_OPTIONS['tts.openai.voice']
for (const voice of ['alloy', 'ash', 'ballad', 'cedar', 'coral', 'marin', 'sage', 'verse', 'shimmer']) {
expect(voices).toContain(voice)
}
})
it('keeps voice/model name fields free-input so custom IDs are typeable', () => {
for (const key of [
'tts.openai.voice',
'tts.openai.model',
'tts.elevenlabs.voice_id',
'tts.edge.voice',
'tts.xai.voice_id',
'tts.piper.voice'
]) {
expect(FREE_INPUT_KEYS.has(key), key).toBe(true)
}
})
it('keeps closed enums (devices, providers) out of the free-input set', () => {
expect(FREE_INPUT_KEYS.has('tts.provider')).toBe(false)
expect(FREE_INPUT_KEYS.has('tts.neutts.device')).toBe(false)
expect(FREE_INPUT_KEYS.has('stt.provider')).toBe(false)
})
it('every free-input voice key that lives in the Voice section has suggestions or is intentionally bare', () => {
// Free-input keys don't *require* ENUM_OPTIONS (an empty datalist is
// fine), but any that do declare options must be actual Voice-section
// fields — a typo'd key here would silently do nothing.
for (const key of FREE_INPUT_KEYS) {
expect(voiceKeys, key).toContain(key)
}
})
})

Some files were not shown because too many files have changed in this diff Show More