Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -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 0–100 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'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'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
@@ -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'] })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user