feat(desktop): unify Skills/Tools/MCP into the Capabilities page

Merge the old Skills + Toolsets tabs and pull MCP out of Settings into one
master-detail "Capabilities" hub (Skills / Tools / MCP / Browse Hub).

- Skills: usage-sorted from real per-skill activity, provenance badges
  (learned / built-in / hub), edit + archive for learned skills, per-tab bulk
  toggle; full-bleed empty states.
- Tools: usage-aware, container-queried rows (no early two-column collapse).
- Settings panels move onto the shared config-record query cache; the deleted
  Settings MCP page redirects (/settings?tab=mcp → /skills?tab=mcp).
- Lazy, profile-scoped, TTL-cached usage analytics so the heavy 365-day scan
  never blocks the Skills/MCP tabs. Full en/zh strings (ja/zh-hant inherit).
This commit is contained in:
Brooklyn Nicholson 2026-07-03 05:08:21 -05:00
parent e0325cf769
commit 7e6d60aadc
22 changed files with 964 additions and 983 deletions

View file

@ -0,0 +1,72 @@
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { deleteLearningNode } from '@/hermes'
import { notify } from '@/store/notifications'
export const ARCHIVE_SKILL_DESCRIPTION = 'The skill is archived and can be restored with `hermes curator restore`.'
export function notifySkillArchived(): void {
// TODO(i18n): literals until the UX settles.
notify({ kind: 'success', message: 'Restorable via hermes curator restore.', title: 'Skill archived' })
}
export async function archiveLearningSkill(id: string): Promise<void> {
const res = await deleteLearningNode(id)
if (!res.ok) {
throw new Error(res.message || 'Archive failed')
}
}
/** Fire-and-forget a mutation whose UI already applied optimistically; a failure just rolls it back + reports. */
export function fireOptimistic(action: Promise<void>, rollback: () => void, onFailure: (err: unknown) => void): void {
void action.catch(err => {
rollback()
onFailure(err)
})
}
interface ArchiveSkillConfirmDialogProps {
/** Apply optimistic UI updates; return rollback if the background archive fails. */
onApply: () => () => void
onClose: () => void
onFailure?: (err: unknown, skillName: string) => void
onSuccess?: () => void
open: boolean
skillId: string
skillName: string
}
/** Shared archive confirm for learned skills (capabilities page + memory graph). */
export function ArchiveSkillConfirmDialog({
onApply,
onClose,
onFailure,
onSuccess,
open,
skillId,
skillName
}: ArchiveSkillConfirmDialogProps) {
return (
<ConfirmDialog
confirmLabel="Archive"
description={ARCHIVE_SKILL_DESCRIPTION}
destructive
dismissOnConfirm
onClose={onClose}
onConfirm={() => {
const rollback = onApply()
fireOptimistic(
archiveLearningSkill(skillId).then(() => {
notifySkillArchived()
onSuccess?.()
}),
rollback,
err => onFailure?.(err, skillName)
)
}}
open={open}
title={`Archive ${skillName}?`}
/>
)
}

View file

@ -134,7 +134,7 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps)
if (loading) {
return (
<div className="mt-3 flex items-center gap-2 px-1 text-xs text-muted-foreground">
<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>
@ -147,7 +147,7 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps)
if (!status.platform_supported) {
return (
<p className="mt-3 px-1 text-xs text-muted-foreground">
<p className="px-1 text-xs text-muted-foreground">
Computer Use isn&apos;t supported on this platform ({status.platform}).
</p>
)
@ -155,7 +155,7 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps)
if (!status.installed) {
return (
<p className="mt-3 px-1 text-xs text-muted-foreground">
<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>
@ -165,7 +165,7 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps)
const failingChecks = status.checks.filter(c => c.status !== 'ok')
return (
<div className="mt-3 grid gap-2">
<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 ? (

View file

@ -1,3 +1,4 @@
import { useQuery } from '@tanstack/react-query'
import type { ChangeEvent, ReactNode } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
@ -6,18 +7,13 @@ 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 {
getElevenLabsVoices,
getHermesConfigDefaults,
getHermesConfigRecord,
getHermesConfigSchema,
saveHermesConfig
} from '@/hermes'
import { getElevenLabsVoices, getHermesConfigSchema, saveHermesConfig } from '@/hermes'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
import { setHermesConfigCache, useHermesConfigRecord } from '../hooks/use-config-record'
import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants'
import { fieldCopyForSchemaKey } from './field-copy'
import { enumOptionsFor, getNested, prettyName, setNested } from './helpers'
@ -225,31 +221,32 @@ export function ConfigSettings({
}) {
const { t } = useI18n()
const c = t.settings.config
// 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 [_defaults, setDefaults] = useState<HermesConfigRecord | null>(null)
const [schema, setSchema] = useState<Record<string, ConfigFieldSchema> | null>(null)
const { data: loadedConfig } = useHermesConfigRecord()
const { data: schemaResponse } = useQuery({
queryKey: ['hermes-config-schema'],
queryFn: getHermesConfigSchema,
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 [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)
useEffect(() => {
let cancelled = false
Promise.all([getHermesConfigRecord(), getHermesConfigDefaults(), getHermesConfigSchema()])
.then(([c, d, s]) => {
if (cancelled) {
return
}
setConfig(c)
setDefaults(d)
setSchema(s.fields)
})
.catch(err => notifyError(err, c.failedLoad))
return () => void (cancelled = true)
// eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount; copy is stable
}, [])
if (loadedConfig && !configSeeded.current) {
configSeeded.current = true
setConfig(loadedConfig)
}
}, [loadedConfig])
useEffect(() => {
let cancelled = false
@ -284,6 +281,9 @@ export function ConfigSettings({
void (async () => {
try {
await saveHermesConfig(config)
// Mirror the saved record into the shared cache so MCP/model surfaces
// reflect the edit without their own refetch.
setHermesConfigCache(config)
if (saveVersionRef.current === v) {
onConfigSaved?.()

View file

@ -159,9 +159,9 @@ export function CredentialKeyCard({
return (
<div
className={cn(
'group/card rounded-[6px] px-2 py-1 transition-colors',
'@container group/card rounded-[6px] px-2 py-1 transition-colors',
expandable && 'cursor-pointer',
expandable && !expanded && 'hover:bg-(--ui-row-hover-background)',
expandable && !expanded && 'row-hover',
expanded && 'bg-(--ui-bg-quaternary) ring-1 ring-(--ui-stroke-secondary)'
)}
onClick={expandable ? onToggle : undefined}
@ -178,7 +178,7 @@ export function CredentialKeyCard({
role={expandable ? 'button' : undefined}
tabIndex={expandable ? 0 : undefined}
>
<div className="grid gap-3 py-2 sm:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] sm:items-center">
<div className="grid gap-3 py-2 @2xl:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] @2xl:items-center">
<div className="flex 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)')}
@ -199,7 +199,7 @@ export function CredentialKeyCard({
</div>
<div
className="min-w-0 sm:justify-self-end"
className="min-w-0 @2xl:justify-self-end"
onClick={e => e.stopPropagation()}
onFocus={() => {
if (expandable && !expanded) {
@ -236,9 +236,9 @@ export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps
return (
<div
className={cn(
'group/card rounded-[6px] px-2 py-1 transition-colors',
'@container group/card rounded-[6px] px-2 py-1 transition-colors',
expandable && 'cursor-pointer',
expandable && !expanded && 'hover:bg-(--ui-row-hover-background)',
expandable && !expanded && 'row-hover',
expanded && 'bg-(--ui-bg-quaternary) ring-1 ring-(--ui-stroke-secondary)'
)}
onClick={expandable ? onToggle : undefined}
@ -255,7 +255,7 @@ export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps
role={expandable ? 'button' : undefined}
tabIndex={expandable ? 0 : undefined}
>
<div className="grid gap-3 py-2 sm:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] sm:items-center">
<div className="grid gap-3 py-2 @2xl:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] @2xl:items-center">
<div className="flex min-w-0 items-center gap-2">
<span
className={cn(
@ -279,7 +279,7 @@ export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps
</div>
<div
className="min-w-0 sm:justify-self-end"
className="min-w-0 @2xl:justify-self-end"
onClick={e => e.stopPropagation()}
onFocus={() => {
if (expandable && !expanded) {

View file

@ -1,12 +1,11 @@
import { asText } from '@/lib/text'
import type { HermesConfigRecord, ToolsetInfo } from '@/types/hermes'
import { BUILTIN_PERSONALITIES, ENUM_OPTIONS, PROVIDER_GROUPS } from './constants'
export const asText = (v: unknown): string => (typeof v === 'string' ? v : v == null ? '' : String(v))
export const includesQuery = (v: unknown, q: string) => asText(v).toLowerCase().includes(q)
export const prettyName = (v: string) => v.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
// 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 =>

View file

@ -1,4 +1,5 @@
import { useRef } from 'react'
import { useEffect, useRef } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { codiconIcon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
@ -12,6 +13,7 @@ import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { OverlayIconButton } from '../overlays/overlay-chrome'
import { OverlayMain, OverlayNavItem, OverlaySidebar, 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'
@ -19,7 +21,6 @@ import { ConfigSettings } from './config-settings'
import { SECTIONS } from './constants'
import { GatewaySettings } from './gateway-settings'
import { KEYS_VIEWS, KeysSettings, type KeysView } from './keys-settings'
import { McpSettings } from './mcp-settings'
import { NotificationsSettings } from './notifications-settings'
import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings'
import { SessionsSettings } from './sessions-settings'
@ -30,14 +31,25 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
'providers',
'gateway',
'keys',
'mcp',
'notifications',
'sessions',
'about'
]
export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
const { t } = useI18n()
const navigate = useNavigate()
const { 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.
useEffect(() => {
if (new URLSearchParams(search).get('tab') === 'mcp') {
navigate(`${SKILLS_ROUTE}?tab=mcp`, { replace: true })
}
}, [navigate, search])
const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId)
// Providers subnav (Accounts vs API keys) lives in its own param so each
// sub-view is deep-linkable and survives a refresh.
@ -109,7 +121,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
label={t.settings.nav.notifications}
onClick={() => setActiveView('notifications')}
/>
<div className="my-2 h-px bg-border/30" />
<div aria-hidden className="h-2" />
<OverlayNavItem
active={activeView === 'providers'}
icon={Zap}
@ -164,19 +176,13 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
/>
</div>
)}
<OverlayNavItem
active={activeView === 'mcp'}
icon={Wrench}
label={t.settings.nav.mcp}
onClick={() => setActiveView('mcp')}
/>
<OverlayNavItem
active={activeView === 'sessions'}
icon={Archive}
label={t.settings.nav.archivedChats}
onClick={() => setActiveView('sessions')}
/>
<div className="my-2 h-px bg-border/30" />
<div aria-hidden className="h-2" />
<OverlayNavItem
active={activeView === 'about'}
icon={Info}
@ -231,8 +237,6 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
<ProvidersSettings onClose={onClose} onViewChange={setProviderView} view={providerView} />
) : activeView === 'keys' ? (
<KeysSettings view={keysView} />
) : activeView === 'mcp' ? (
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} />
) : activeView === 'notifications' ? (
<NotificationsSettings />
) : (

View file

@ -1,534 +0,0 @@
import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import {
getHermesConfigRecord,
getMcpCatalog,
type HermesGateway,
installMcpCatalogEntry,
type McpCatalogEntry,
saveHermesConfig,
setMcpServerEnabled,
testMcpServer
} from '@/hermes'
import { useI18n } from '@/i18n'
import { Wrench } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { $activeSessionId } from '@/store/session'
import type { HermesConfigRecord, McpServerTestResponse } from '@/types/hermes'
import { EmptyState, LoadingState, Pill, SettingsContent } from './primitives'
import { useDeepLinkHighlight } from './use-deep-link-highlight'
interface McpSettingsProps {
gateway?: HermesGateway | null
onConfigSaved?: () => void
}
type McpServers = Record<string, Record<string, unknown>>
type McpView = 'catalog' | 'servers'
const EMPTY_SERVER = {
command: '',
args: [],
env: {}
}
function getServers(config: HermesConfigRecord | null): McpServers {
const raw = config?.mcp_servers
return raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as McpServers) : {}
}
const transportLabel = (server: Record<string, unknown>) =>
typeof server.transport === 'string'
? server.transport
: typeof server.url === 'string'
? 'http'
: typeof server.command === 'string'
? 'stdio'
: 'custom'
export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
const { t } = useI18n()
const m = t.settings.mcp
const activeSessionId = useStore($activeSessionId)
const [view, setView] = useState<McpView>('servers')
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
const [selected, setSelected] = useState<string | null>(null)
const [name, setName] = useState('')
const [body, setBody] = useState('')
const [saving, setSaving] = useState(false)
const [reloading, setReloading] = useState(false)
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<McpServerTestResponse | null>(null)
const [togglingEnabled, setTogglingEnabled] = useState(false)
useEffect(() => {
let cancelled = false
getHermesConfigRecord()
.then(next => {
if (cancelled) {
return
}
setConfig(next)
const first = Object.keys(getServers(next)).sort()[0] ?? null
setSelected(first)
})
.catch(err => notifyError(err, m.failedLoad))
return () => void (cancelled = true)
// eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount; copy is stable
}, [])
const servers = useMemo(() => getServers(config), [config])
const names = useMemo(() => Object.keys(servers).sort(), [servers])
useDeepLinkHighlight({
block: 'nearest',
elementId: serverName => `mcp-server-${serverName}`,
onResolve: setSelected,
param: 'server',
ready: serverName => Boolean(config) && serverName in servers
})
useEffect(() => {
const server = selected ? servers[selected] : null
setName(selected ?? '')
setBody(JSON.stringify(server ?? EMPTY_SERVER, null, 2))
setTestResult(null)
}, [selected, servers])
const refreshConfig = useCallback(async () => {
try {
const next = await getHermesConfigRecord()
setConfig(next)
} catch (err) {
notifyError(err, m.failedLoad)
}
}, [m.failedLoad])
if (!config) {
return <LoadingState label={m.loading} />
}
const saveServer = async () => {
const nextName = name.trim()
if (!nextName) {
notify({ kind: 'error', title: m.nameRequiredTitle, message: m.nameRequiredMessage })
return
}
let parsed: Record<string, unknown>
try {
const raw = JSON.parse(body)
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
throw new Error(m.objectRequired)
}
parsed = raw as Record<string, unknown>
} catch (err) {
notifyError(err, m.invalidJson)
return
}
setSaving(true)
try {
const nextServers = { ...servers }
if (selected && selected !== nextName) {
delete nextServers[selected]
}
nextServers[nextName] = parsed
const nextConfig = { ...config, mcp_servers: nextServers }
await saveHermesConfig(nextConfig)
setConfig(nextConfig)
setSelected(nextName)
onConfigSaved?.()
notify({ kind: 'success', title: m.savedTitle, message: m.savedMessage(nextName) })
} catch (err) {
notifyError(err, m.saveFailed)
} finally {
setSaving(false)
}
}
const removeServer = async (serverName: string) => {
setSaving(true)
try {
const nextServers = { ...servers }
delete nextServers[serverName]
const nextConfig = { ...config, mcp_servers: nextServers }
await saveHermesConfig(nextConfig)
setConfig(nextConfig)
setSelected(Object.keys(nextServers).sort()[0] ?? null)
onConfigSaved?.()
} catch (err) {
notifyError(err, m.removeFailed)
} finally {
setSaving(false)
}
}
const reloadMcp = async () => {
if (!gateway) {
notify({ kind: 'warning', title: m.gatewayUnavailableTitle, message: m.gatewayUnavailableMessage })
return
}
setReloading(true)
try {
await gateway.request('reload.mcp', {
confirm: true,
session_id: activeSessionId ?? undefined
})
notify({ kind: 'success', title: m.reloadedTitle, message: m.reloadedMessage })
} catch (err) {
notifyError(err, m.reloadFailed)
} finally {
setReloading(false)
}
}
const runTest = async (serverName: string) => {
setTesting(true)
setTestResult(null)
try {
const result = await testMcpServer(serverName)
setTestResult(result)
} catch (err) {
setTestResult({ ok: false, error: err instanceof Error ? err.message : String(err), tools: [] })
} finally {
setTesting(false)
}
}
const toggleEnabled = async (serverName: string, enabled: boolean) => {
setTogglingEnabled(true)
try {
await setMcpServerEnabled(serverName, enabled)
// Mirror the change locally so the editor and list stay in sync.
const nextServers = { ...servers, [serverName]: { ...servers[serverName], enabled } }
setConfig({ ...config, mcp_servers: nextServers })
notify({
kind: 'success',
title: enabled ? m.serverEnabled(serverName) : m.serverDisabled(serverName),
message: ''
})
} catch (err) {
notifyError(err, m.toggleFailed(serverName))
} finally {
setTogglingEnabled(false)
}
}
const selectedEnabled = selected ? servers[selected]?.enabled !== false : true
return (
<SettingsContent>
<div className="mb-4 flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<TabButton active={view === 'servers'} label={m.tabServers} onClick={() => setView('servers')} />
<TabButton active={view === 'catalog'} label={m.tabCatalog} onClick={() => setView('catalog')} />
</div>
{view === 'servers' && (
<div className="flex items-center gap-4">
<Button onClick={() => setSelected(null)} size="xs" variant="text">
{m.newServer}
</Button>
<Button disabled={reloading} onClick={() => void reloadMcp()} size="xs" variant="text">
{reloading ? m.reloading : m.reload}
</Button>
</div>
)}
</div>
{view === 'catalog' ? (
<McpCatalogBrowser onInstalled={() => void refreshConfig()} />
) : (
<div className="grid min-h-0 gap-6 lg:grid-cols-[16rem_minmax(0,1fr)]">
<div className="min-h-64">
{names.length === 0 ? (
<EmptyState description={m.emptyDesc} title={m.emptyTitle} />
) : (
<div className="grid gap-0.5">
{names.map(serverName => {
const server = servers[serverName]
const active = selected === serverName
return (
<button
className={cn(
'scroll-mt-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-(--chrome-action-hover)',
active ? 'bg-(--ui-bg-tertiary) text-foreground' : 'text-muted-foreground'
)}
id={`mcp-server-${serverName}`}
key={serverName}
onClick={() => setSelected(serverName)}
type="button"
>
<div className="truncate text-sm font-medium">{serverName}</div>
<div className="mt-1 flex items-center gap-1.5">
<Pill>{transportLabel(server)}</Pill>
{(server.enabled === false || server.disabled === true) && <Pill>{m.disabled}</Pill>}
</div>
</button>
)
})}
</div>
)}
</div>
<div className="grid content-start gap-3">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Wrench className="size-4 text-muted-foreground" />
{selected ? m.editServer : m.newServer}
</div>
{selected && (
<div className="flex items-center gap-2">
<Button disabled={testing} onClick={() => void runTest(selected)} size="xs" variant="text">
{testing ? m.testing : m.test}
</Button>
<Switch
aria-label={selectedEnabled ? m.disableServer(selected) : m.enableServer(selected)}
checked={selectedEnabled}
disabled={togglingEnabled}
onCheckedChange={checked => void toggleEnabled(selected, checked)}
/>
</div>
)}
</div>
{testResult && (
<div
className={cn(
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-3 text-xs',
testResult.ok ? 'text-emerald-400' : 'text-destructive'
)}
>
{testResult.ok ? m.testOk(testResult.tools.length) : `${m.testFailed}: ${testResult.error ?? ''}`}
{testResult.ok && testResult.tools.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{testResult.tools.map(tool => (
<span
className="rounded-md bg-(--ui-bg-quinary) px-1.5 py-0.5 font-mono text-[0.65rem] text-(--ui-text-tertiary)"
key={tool.name}
title={tool.description}
>
{tool.name}
</span>
))}
</div>
)}
</div>
)}
<label className="grid gap-1.5">
<span className="text-xs text-muted-foreground">{m.name}</span>
<Input onChange={event => setName(event.currentTarget.value)} placeholder="filesystem" value={name} />
</label>
<label className="grid gap-1.5">
<span className="text-xs text-muted-foreground">{m.serverJson}</span>
<Textarea
className="min-h-80 font-mono text-xs"
onChange={event => setBody(event.currentTarget.value)}
spellCheck={false}
value={body}
/>
</label>
<div className="flex items-center justify-between">
{selected ? (
<Button
className="text-destructive hover:text-destructive"
disabled={saving}
onClick={() => void removeServer(selected)}
size="xs"
variant="text"
>
{m.remove}
</Button>
) : (
<span />
)}
<Button disabled={saving} onClick={() => void saveServer()} size="sm">
{saving ? t.common.saving : m.saveServer}
</Button>
</div>
</div>
</div>
)}
</SettingsContent>
)
}
function TabButton({ active, label, onClick }: { active: boolean; label: string; onClick: () => void }) {
return (
<button
className={cn(
'cursor-pointer text-sm font-medium transition-colors',
active ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'
)}
onClick={onClick}
type="button"
>
{label}
</button>
)
}
/** Nous-approved MCP catalog browser the desktop counterpart of
* `hermes mcp catalog` / `hermes mcp install` and the dashboard MCP page. */
function McpCatalogBrowser({ onInstalled }: { onInstalled: () => void }) {
const { t } = useI18n()
const m = t.settings.mcp
const [entries, setEntries] = useState<McpCatalogEntry[] | null>(null)
const [installing, setInstalling] = useState<null | string>(null)
// Per-entry env var drafts for catalog entries that need credentials.
const [envDrafts, setEnvDrafts] = useState<Record<string, Record<string, string>>>({})
const [envOpenFor, setEnvOpenFor] = useState<null | string>(null)
useEffect(() => {
let cancelled = false
getMcpCatalog()
.then(response => {
if (!cancelled) {
setEntries(response.entries)
}
})
.catch(err => {
if (!cancelled) {
notifyError(err, m.catalogLoadFailed)
setEntries([])
}
})
return () => void (cancelled = true)
// eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount
}, [])
const install = async (entry: McpCatalogEntry) => {
const required = entry.required_env.filter(env => env.required)
const draft = envDrafts[entry.name] ?? {}
if (required.some(env => !draft[env.name]?.trim())) {
if (envOpenFor !== entry.name) {
setEnvOpenFor(entry.name)
return
}
notify({ kind: 'error', title: m.catalogEnvPrompt(entry.name), message: m.catalogEnvRequired })
return
}
setInstalling(entry.name)
try {
await installMcpCatalogEntry(entry.name, draft)
notify({ kind: 'success', title: m.catalogInstallStarted(entry.name), message: '' })
setEntries(
current =>
current?.map(row => (row.name === entry.name ? { ...row, installed: true, enabled: true } : row)) ?? current
)
setEnvOpenFor(null)
onInstalled()
} catch (err) {
notifyError(err, m.catalogInstallFailed(entry.name))
} finally {
setInstalling(null)
}
}
if (entries === null) {
return <LoadingState label={m.catalogLoading} />
}
if (entries.length === 0) {
return <EmptyState description={m.catalogEmpty} title={m.tabCatalog} />
}
return (
<div>
{entries.map(entry => {
const envOpen = envOpenFor === entry.name
const draft = envDrafts[entry.name] ?? {}
return (
<div className="px-0 py-2.5" key={entry.name}>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">{entry.name}</span>
<Pill>{entry.transport}</Pill>
{entry.installed && (
<Badge className="bg-emerald-500/15 text-emerald-400">
{entry.enabled ? m.catalogEnabled : m.catalogInstalled}
</Badge>
)}
{entry.needs_install && !entry.installed && <Pill>{m.catalogNeedsInstall}</Pill>}
</div>
<Button
disabled={entry.installed || installing !== null}
onClick={() => void install(entry)}
size="xs"
variant="textStrong"
>
{installing === entry.name
? m.catalogInstalling
: entry.installed
? m.catalogInstalled
: m.catalogInstall}
</Button>
</div>
<p className="mt-1 text-xs text-muted-foreground">{entry.description}</p>
{envOpen && entry.required_env.length > 0 && (
<div className="mt-2 grid max-w-md gap-2">
{entry.required_env.map(env => (
<label className="grid gap-1" key={env.name}>
<span className="text-xs text-muted-foreground">
{env.prompt || env.name}
{env.required ? ' *' : ''}
</span>
<Input
onChange={event =>
setEnvDrafts(prev => ({
...prev,
[entry.name]: { ...prev[entry.name], [env.name]: event.currentTarget.value }
}))
}
type="password"
value={draft[env.name] ?? ''}
/>
</label>
))}
</div>
)}
</div>
)
})}
</div>
)
}

View file

@ -8,7 +8,6 @@ import {
getAuxiliaryModels,
getGlobalModelInfo,
getGlobalModelOptions,
getHermesConfigRecord,
getMoaModels,
getRecommendedDefaultModel,
saveHermesConfig,
@ -28,7 +27,8 @@ import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import { startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding'
import type { HermesConfigRecord } from '@/types/hermes'
import { invalidateHermesConfig, setHermesConfigCache, useHermesConfigRecord } from '../hooks/use-config-record'
import { CONTROL_TEXT } from './constants'
import { getNested, setNested } from './helpers'
@ -136,9 +136,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
const [moa, setMoa] = useState<MoaConfigResponse | null>(null)
const [selectedMoaPreset, setSelectedMoaPreset] = useState('')
const [newMoaPresetName, setNewMoaPresetName] = useState('')
// Full profile config, kept so the reasoning/speed defaults round-trip
// (read agent.* → write back the whole record) like the generic config page.
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
// agent.* defaults round-trip through the shared config cache (read → write
// back the whole record), so a save here shows in the MCP/config surfaces.
const { data: config } = useHermesConfigRecord()
const setConfig = setHermesConfigCache
const [applying, setApplying] = useState(false)
const [editingAuxTask, setEditingAuxTask] = useState<null | string>(null)
const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' })
@ -155,12 +156,11 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
setError('')
try {
const [modelInfo, modelOptions, auxiliaryModels, moaModels, cfg] = await Promise.all([
const [modelInfo, modelOptions, auxiliaryModels, moaModels] = await Promise.all([
getGlobalModelInfo(),
getGlobalModelOptions(),
getAuxiliaryModels(),
getMoaModels().catch(() => null),
getHermesConfigRecord()
getMoaModels().catch(() => null)
])
setMainModel({ model: modelInfo.model, provider: modelInfo.provider })
@ -174,7 +174,9 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
setSelectedMoaPreset(prev => (prev && moaModels.presets[prev] ? prev : moaModels.default_preset))
}
setConfig(cfg)
// The config record loads via its own shared query; a model switch can
// change it server-side (aux slots), so nudge that cache to refetch.
void invalidateHermesConfig()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
@ -191,9 +193,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
// MoA reference/aggregator slots must never be the moa virtual provider —
// that would create a recursive MoA tree (the backend rejects it on save).
// Hide it from the slot selectors so it isn't offered as a dead choice.
const moaSlotProviderOptions = providerOptions.filter(
provider => (provider.slug || '').toLowerCase() !== 'moa'
)
const moaSlotProviderOptions = providerOptions.filter(provider => (provider.slug || '').toLowerCase() !== 'moa')
const selectedProviderRow = useMemo(
() => providers.find(provider => provider.slug === selectedProvider),
@ -312,6 +312,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
const rawEffort = String(getNested(config ?? {}, 'agent.reasoning_effort') ?? '')
.trim()
.toLowerCase()
const effortValue = rawEffort === 'false' || rawEffort === 'disabled' ? 'none' : rawEffort || 'medium'
const fastOn = isFastTier(getNested(config ?? {}, 'agent.service_tier'))
@ -785,6 +786,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
...moa,
default_preset: selectedMoaPreset || moa.default_preset
}
void saveMoa(next)
}}
size="sm"
@ -802,12 +804,14 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
const presets = { ...moa.presets }
delete presets[selectedMoaPreset]
const fallback = Object.keys(presets)[0]
const next: MoaConfigResponse = {
...moa,
presets,
default_preset: moa.default_preset === selectedMoaPreset ? fallback : moa.default_preset,
active_preset: moa.active_preset === selectedMoaPreset ? '' : moa.active_preset
}
setSelectedMoaPreset(Object.keys(moa.presets).find(name => name !== selectedMoaPreset) || '')
void saveMoa(next)
}}
@ -826,6 +830,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
disabled={!newMoaPresetName.trim() || !!moa.presets[newMoaPresetName.trim()] || applying}
onClick={() => {
const name = newMoaPresetName.trim()
const next: MoaConfigResponse = {
...moa,
presets: {
@ -833,6 +838,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
[name]: { ...currentMoaPreset, reference_models: [...currentMoaPreset.reference_models] }
}
}
setSelectedMoaPreset(name)
setNewMoaPresetName('')
void saveMoa(next)

View file

@ -76,23 +76,28 @@ export function ListRow({
wide?: boolean
}) {
return (
<div
className={cn(
'grid gap-3 py-3 sm:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] sm:items-center',
wide && 'sm:grid-cols-1 sm:items-start'
)}
>
<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>
// 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="@container">
<div
className={cn(
'grid gap-3 py-3',
!wide && '@2xl:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] @2xl:items-center'
)}
{hint && <div className="mt-1 block font-mono text-[0.68rem] text-muted-foreground/45">{hint}</div>}
{below}
>
<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>
{action && <div className={cn('min-w-0', !wide && 'sm:justify-self-end')}>{action}</div>}
</div>
)
}
@ -101,13 +106,6 @@ export function LoadingState({ label }: { label: string }) {
return <PageLoader label={label} />
}
export function EmptyState({ title, description }: { title: string; description: string }) {
return (
<div className="grid min-h-48 place-items-center text-center">
<div>
<div className="text-sm font-medium">{title}</div>
<div className="mt-1 text-xs text-muted-foreground">{description}</div>
</div>
</div>
)
}
// 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'

View file

@ -66,6 +66,12 @@ function config(overrides: Partial<ToolsetConfig> = {}): ToolsetConfig {
}
beforeEach(() => {
// Radix menus/selects call these on open; jsdom implements neither, so the
// dropdown never opens without the stubs (mirrors model-settings.test.tsx).
Element.prototype.scrollIntoView = vi.fn()
Element.prototype.hasPointerCapture = vi.fn(() => false)
Element.prototype.releasePointerCapture = vi.fn()
getToolsetConfig.mockResolvedValue(config())
getToolsetModels.mockResolvedValue({
name: 'tts',
@ -165,8 +171,10 @@ describe('ToolsetConfigPanel', () => {
const elevenlabs = await screen.findByRole('button', { name: /ElevenLabs/ })
fireEvent.click(elevenlabs)
// Click "Set" to reveal the input for the unset key.
fireEvent.click(await screen.findByRole('button', { name: 'Set' }))
// Open the credential actions menu (Radix opens on pointerdown), then "Set".
const trigger = await screen.findByRole('button', { name: /Actions for ELEVENLABS_API_KEY/ })
fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' })
fireEvent.click(await screen.findByRole('menuitem', { name: 'Set' }))
const input = await screen.findByPlaceholderText('ElevenLabs API key')
fireEvent.change(input, { target: { value: 'sk-test-123' } })

View file

@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
@ -512,32 +511,31 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
onConfiguredChange?.()
}
const emptyMessage = useMemo(() => {
if (loading || !cfg) {
return null
}
if (!cfg.has_category) {
return copy.noProviderOptions
}
if (providers.length === 0) {
return copy.noProviders
}
return null
}, [cfg, copy, loading, providers.length])
if (loading) {
return <PageLoader className="min-h-32" label={copy.loadingConfig} />
// 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>
)
}
if (emptyMessage) {
return <p className="px-1 py-3 text-xs text-muted-foreground">{emptyMessage}</p>
// 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="mt-3 grid gap-2">
<div className="grid gap-2">
{providers.map(provider => {
const isActive = activeProvider === provider.name
const configured = providerConfigured(provider, envState)

View file

@ -8,7 +8,6 @@ export type SettingsView =
| 'about'
| 'gateway'
| 'keys'
| 'mcp'
| 'notifications'
| 'providers'
| 'sessions'

View file

@ -9,10 +9,7 @@ describe('withActive', () => {
const curated = ['hermes-4', 'hermes-4-mini']
it('prepends a custom model missing from the curated list', () => {
expect(withActive(curated, 'anthropic/claude-opus-4.7')).toEqual([
'anthropic/claude-opus-4.7',
...curated
])
expect(withActive(curated, 'anthropic/claude-opus-4.7')).toEqual(['anthropic/claude-opus-4.7', ...curated])
})
it('leaves the list untouched when the active model is already curated', () => {

View file

@ -1,24 +1,32 @@
// @vitest-environment jsdom
import { QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as HermesApi from '@/hermes'
import { queryClient } from '@/lib/query-client'
const getSkills = vi.fn()
const getToolsets = vi.fn()
const toggleSkill = vi.fn()
const toggleToolset = vi.fn()
const getToolsetConfig = vi.fn()
const selectToolsetProvider = vi.fn()
const getUsageAnalytics = vi.fn()
vi.mock('@/hermes', () => ({
// Partial mock: keep the real module (SkillsView pulls in @/store/profile,
// whose import-time subscription calls setApiRequestProfile) and stub only the
// calls we assert on.
vi.mock('@/hermes', async importOriginal => ({
...(await importOriginal<typeof HermesApi>()),
getSkills: () => getSkills(),
getToolsets: () => getToolsets(),
toggleSkill: (name: string, enabled: boolean) => toggleSkill(name, enabled),
toggleToolset: (name: string, enabled: boolean) => toggleToolset(name, enabled),
getToolsetConfig: (name: string) => getToolsetConfig(name),
selectToolsetProvider: (toolset: string, provider: string) => selectToolsetProvider(toolset, provider),
deleteEnvVar: vi.fn(),
revealEnvVar: vi.fn(),
setEnvVar: vi.fn()
getUsageAnalytics: (days: number) => getUsageAnalytics(days)
}))
// Notifications hit nanostores/timers we don't care about here.
@ -43,9 +51,12 @@ function toolset(overrides: Record<string, unknown> = {}) {
function renderSkills() {
return import('./index').then(({ SkillsView }) =>
render(
<MemoryRouter initialEntries={['/skills?tab=toolsets']}>
<SkillsView />
</MemoryRouter>
// SkillsView reads skills/toolsets via useQuery, so it needs a provider.
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/skills?tab=toolsets']}>
<SkillsView />
</MemoryRouter>
</QueryClientProvider>
)
)
}
@ -54,12 +65,15 @@ beforeEach(() => {
getSkills.mockResolvedValue([])
getToolsets.mockResolvedValue([toolset()])
toggleToolset.mockResolvedValue({ ok: true, name: 'web', enabled: false })
getToolsetConfig.mockResolvedValue({ has_category: false, active_provider: null, providers: [] })
getToolsetConfig.mockResolvedValue({ has_category: true, active_provider: null, providers: [] })
getUsageAnalytics.mockResolvedValue({ tools: [] })
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
// Shared singleton client — drop cached skills/toolsets so each test refetches.
queryClient.clear()
})
describe('SkillsView toolset management', () => {
@ -79,23 +93,20 @@ describe('SkillsView toolset management', () => {
await renderSkills()
expect(await screen.findByText('Cron Jobs')).toBeTruthy()
// The label renders in both the row and the auto-selected detail header, so
// assert via the switch's (emoji-stripped) accessible name and the absence
// of the emoji rather than a single-match text lookup.
await screen.findByRole('switch', { name: 'Toggle Cron Jobs toolset' })
expect(screen.queryByText(/⏰/)).toBeNull()
})
it('keeps the configured pill alongside the switch', async () => {
it('renders the provider config panel inline for the selected toolset', async () => {
// The master-detail UI dropped the resting "Configured" pill and the
// "Configure" expander: the detail column auto-selects the first toolset
// and renders its config panel directly, which fetches on mount.
await renderSkills()
await screen.findByRole('switch', { name: 'Toggle Web Search toolset' })
expect(screen.getByText('Configured')).toBeTruthy()
})
it('expands the provider config panel when the configured pill is clicked', async () => {
await renderSkills()
const configureBtn = await screen.findByRole('button', { name: 'Configure Web Search' })
fireEvent.click(configureBtn)
await waitFor(() => expect(getToolsetConfig).toHaveBeenCalledWith('web'))
})
})

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,6 @@
import { Codecs, persistentAtom } from '@/lib/persisted'
// Per-view sort direction for the Capabilities lists — persisted so each tab
// remembers most/least-used across navigations and restarts.
export const $skillsSortDesc = persistentAtom('hermes.desktop.capabilities.skillsSortDesc', true, Codecs.bool)
export const $toolsetsSortDesc = persistentAtom('hermes.desktop.capabilities.toolsetsSortDesc', true, Codecs.bool)

View file

@ -26,6 +26,7 @@ export const en: Translations = {
done: 'Done',
error: 'Error',
failed: 'Failed',
formatJson: 'Format JSON',
free: 'Free',
loading: 'Loading…',
notSet: 'Not set',
@ -165,8 +166,7 @@ export const en: Translations = {
remoteDisplayBanner: {
message: reason =>
`Software rendering active — remote display detected (${reason}). GPU acceleration is disabled to prevent flickering.`,
dismiss: 'Dismiss'
`Software rendering active — remote display detected (${reason}). GPU acceleration is disabled to prevent flickering.`
},
titlebar: {
@ -758,11 +758,12 @@ export const en: Translations = {
skills: {
tabSkills: 'Skills',
tabToolsets: 'Toolsets',
tabToolsets: 'Tools',
tabMcp: 'MCP',
tabHub: 'Browse Hub',
all: 'All',
searchSkills: 'Search skills...',
searchToolsets: 'Search toolsets...',
searchToolsets: 'Search tools...',
refresh: 'Refresh skills',
refreshing: 'Refreshing skills',
loading: 'Loading capabilities...',
@ -784,8 +785,15 @@ export const en: Translations = {
toolsetDisabled: 'Toolset disabled',
appliesToNewSessions: name => `${name} applies to new sessions.`,
failedToUpdate: name => `Failed to update ${name}`,
sortMostUsed: 'Most used',
sortAlpha: 'AZ',
enableAll: 'Enable all',
disableAll: 'Disable all',
bulkUpdated: count => `Updated ${count} ${count === 1 ? 'item' : 'items'} for new sessions.`,
bulkNoChange: 'Nothing to change.',
usageCount: count => `used ${count}×`,
hub: {
searchPlaceholder: 'Search the skill hub (official, GitHub, community)...',
searchPlaceholder: 'Search the skill hub',
search: 'Search',
searching: 'Searching...',
connectingHubs: 'Connecting to skill hubs...',
@ -800,6 +808,7 @@ export const en: Translations = {
install: 'Install',
installing: 'Installing...',
uninstall: 'Uninstall',
uninstalling: 'Uninstalling...',
updateAll: 'Update installed',
updating: 'Updating...',
preview: 'Preview',
@ -888,7 +897,6 @@ export const en: Translations = {
ageHours: hours => `${hours}h ago`,
durationSeconds: seconds => `${seconds}s`,
durationMinutes: (minutes, seconds) => `${minutes}m ${seconds}s`,
tokensK: k => `${k}k tok`,
tokens: value => `${value} tok`
},
@ -905,7 +913,7 @@ export const en: Translations = {
appearance: 'Appearance',
settings: 'Settings',
changeTheme: 'Change theme',
changeColorMode: 'Change color mode...',
changeColorMode: 'Change color mode',
pets: {
title: 'Pets',
placeholder: 'Search pets…',
@ -952,7 +960,8 @@ export const en: Translations = {
startOver: 'Start over'
},
installTheme: {
title: 'Install theme...',
title: 'Install theme…',
pageTitle: 'Install theme',
placeholder: 'Search the VS Code Marketplace...',
loading: 'Searching the Marketplace...',
error: 'Could not reach the Marketplace.',
@ -975,7 +984,7 @@ export const en: Translations = {
nav: {
newChat: { title: 'New session', detail: 'Start a fresh session' },
settings: { title: 'Settings', detail: 'Configure Hermes desktop' },
skills: { title: 'Skills & Tools', detail: 'Enable skills, toolsets, and providers' },
skills: { title: 'Capabilities', detail: 'Skills, tools, and MCP servers' },
messaging: { title: 'Messaging', detail: 'Set up Telegram, Slack, Discord, and more' },
artifacts: { title: 'Artifacts', detail: 'Browse generated outputs' }
},
@ -1218,9 +1227,9 @@ export const en: Translations = {
allProfiles: 'All profiles',
showAllProfiles: 'Show all profiles',
switchToProfile: name => `Switch to ${name}`,
manageProfiles: 'Manage profiles...',
manageProfiles: 'Manage profiles',
actionsFor: name => `Actions for ${name}`,
color: 'Color...',
color: 'Color',
colorFor: name => `Color for ${name}`,
setColor: color => `Set color ${color}`,
autoColor: 'Auto',
@ -1233,6 +1242,8 @@ export const en: Translations = {
env: 'env',
defaultBadge: 'Default',
rename: 'Rename',
renameMenu: 'Rename…',
editSoul: 'Edit SOUL.md…',
copySetup: 'Copy setup',
copying: 'Copying...',
modelLabel: 'Model',
@ -1433,7 +1444,7 @@ export const en: Translations = {
sidebar: {
nav: {
'new-session': 'New session',
skills: 'Skills & Tools',
skills: 'Capabilities',
messaging: 'Messaging',
artifacts: 'Artifacts'
},

View file

@ -26,6 +26,7 @@ export const ja = defineLocale({
done: '完了',
error: 'エラー',
failed: '失敗',
formatJson: 'JSON を整形',
free: '無料',
loading: '読み込み中…',
notSet: '未設定',
@ -166,8 +167,7 @@ export const ja = defineLocale({
remoteDisplayBanner: {
message: reason =>
`ソフトウェアレンダリングが有効です — リモートディスプレイを検出しました(${reason})。ちらつきを防ぐため GPU アクセラレーションは無効化されています。`,
dismiss: '閉じる'
`ソフトウェアレンダリングが有効です — リモートディスプレイを検出しました(${reason})。ちらつきを防ぐため GPU アクセラレーションは無効化されています。`
},
titlebar: {
@ -839,6 +839,7 @@ export const ja = defineLocale({
skills: {
tabSkills: 'スキル',
tabToolsets: 'ツールセット',
tabMcp: 'MCP',
all: 'すべて',
searchSkills: 'スキルを検索...',
searchToolsets: 'ツールセットを検索...',
@ -862,7 +863,14 @@ export const ja = defineLocale({
toolsetEnabled: 'ツールセットを有効にしました',
toolsetDisabled: 'ツールセットを無効にしました',
appliesToNewSessions: name => `${name} は新しいセッションに適用されます。`,
failedToUpdate: name => `${name} の更新に失敗しました`
failedToUpdate: name => `${name} の更新に失敗しました`,
sortMostUsed: '使用頻度順',
sortAlpha: 'AZ',
enableAll: 'すべて有効化',
disableAll: 'すべて無効化',
bulkUpdated: count => `${count} 件を新しいセッション向けに更新しました。`,
bulkNoChange: '変更するものはありません。',
usageCount: count => `${count} 回使用`
},
starmap: {
@ -907,7 +915,6 @@ export const ja = defineLocale({
ageHours: hours => `${hours}時間前`,
durationSeconds: seconds => `${seconds}`,
durationMinutes: (minutes, seconds) => `${minutes}${seconds}`,
tokensK: k => `${k}k トーク`,
tokens: value => `${value} トーク`
},
@ -924,7 +931,7 @@ export const ja = defineLocale({
appearance: '外観',
settings: '設定',
changeTheme: 'テーマを変更',
changeColorMode: 'カラーモードを変更...',
changeColorMode: 'カラーモードを変更',
pets: {
title: 'ペット',
placeholder: 'ペットを検索…',
@ -970,7 +977,8 @@ export const ja = defineLocale({
startOver: 'やり直す'
},
installTheme: {
title: 'テーマをインストール...',
title: 'テーマをインストール…',
pageTitle: 'テーマをインストール',
placeholder: 'VS Code Marketplace を検索...',
loading: 'Marketplace を検索中...',
error: 'Marketplace に接続できませんでした。',
@ -1195,9 +1203,9 @@ export const ja = defineLocale({
allProfiles: 'すべてのプロファイル',
showAllProfiles: 'すべてのプロファイルを表示',
switchToProfile: name => `${name} に切り替え`,
manageProfiles: 'プロファイルを管理...',
manageProfiles: 'プロファイルを管理',
actionsFor: name => `${name} のアクション`,
color: 'カラー...',
color: 'カラー',
colorFor: name => `${name} のカラー`,
setColor: color => `カラー ${color} に設定`,
autoColor: '自動',
@ -1210,6 +1218,8 @@ export const ja = defineLocale({
env: 'env',
defaultBadge: 'デフォルト',
rename: '名前を変更',
renameMenu: '名前を変更…',
editSoul: 'SOUL.md を編集…',
copySetup: 'セットアップをコピー',
copying: 'コピー中...',
modelLabel: 'モデル',

View file

@ -1,3 +1,5 @@
import { normalize } from '@/lib/text'
import type { Locale } from './types'
export const DEFAULT_LOCALE: Locale = 'en'
@ -74,11 +76,11 @@ export function normalizeLocale(value: unknown): Locale {
return DEFAULT_LOCALE
}
return LOCALE_ALIASES[value.trim().toLowerCase()] ?? DEFAULT_LOCALE
return LOCALE_ALIASES[normalize(value)] ?? DEFAULT_LOCALE
}
export function isSupportedLocaleValue(value: unknown): boolean {
return typeof value === 'string' && LOCALE_ALIASES[value.trim().toLowerCase()] != null
return typeof value === 'string' && LOCALE_ALIASES[normalize(value)] != null
}
export function localeConfigValue(locale: Locale): string {

View file

@ -71,6 +71,7 @@ export interface Translations {
done: string
error: string
failed: string
formatJson: string
free: string
loading: string
notSet: string
@ -208,7 +209,6 @@ export interface Translations {
remoteDisplayBanner: {
message: (reason: string) => string
dismiss: string
}
titlebar: {
@ -656,6 +656,7 @@ export interface Translations {
skills: {
tabSkills: string
tabToolsets: string
tabMcp: string
tabHub: string
all: string
searchSkills: string
@ -681,6 +682,13 @@ export interface Translations {
toolsetDisabled: string
appliesToNewSessions: (name: string) => string
failedToUpdate: (name: string) => string
sortMostUsed: string
sortAlpha: string
enableAll: string
disableAll: string
bulkUpdated: (count: number) => string
bulkNoChange: string
usageCount: (count: number | string) => string
hub: {
searchPlaceholder: string
search: string
@ -696,6 +704,7 @@ export interface Translations {
install: string
installing: string
uninstall: string
uninstalling: string
updateAll: string
updating: string
preview: string
@ -779,8 +788,7 @@ export interface Translations {
ageHours: (hours: number) => string
durationSeconds: (seconds: string) => string
durationMinutes: (minutes: number, seconds: number) => string
tokensK: (k: string) => string
tokens: (value: number) => string
tokens: (value: number | string) => string
}
commandCenter: {
@ -843,6 +851,7 @@ export interface Translations {
}
installTheme: {
title: string
pageTitle: string
placeholder: string
loading: string
error: string
@ -1017,6 +1026,8 @@ export interface Translations {
env: string
defaultBadge: string
rename: string
renameMenu: string
editSoul: string
copySetup: string
copying: string
modelLabel: string

View file

@ -26,6 +26,7 @@ export const zhHant = defineLocale({
done: '完成',
error: '錯誤',
failed: '失敗',
formatJson: '格式化 JSON',
free: '免費',
loading: '載入中…',
notSet: '未設定',
@ -160,8 +161,7 @@ export const zhHant = defineLocale({
},
remoteDisplayBanner: {
message: reason => `軟體繪圖已啟用 — 偵測到遠端顯示(${reason})。為防止畫面閃爍,已停用 GPU 加速。`,
dismiss: '關閉'
message: reason => `軟體繪圖已啟用 — 偵測到遠端顯示(${reason})。為防止畫面閃爍,已停用 GPU 加速。`
},
titlebar: {
@ -811,6 +811,7 @@ export const zhHant = defineLocale({
skills: {
tabSkills: '技能',
tabToolsets: '工具集',
tabMcp: 'MCP',
all: '全部',
searchSkills: '搜尋技能...',
searchToolsets: '搜尋工具集...',
@ -834,7 +835,14 @@ export const zhHant = defineLocale({
toolsetEnabled: '工具集已啟用',
toolsetDisabled: '工具集已停用',
appliesToNewSessions: name => `${name} 將套用至新工作階段。`,
failedToUpdate: name => `更新 ${name} 失敗`
failedToUpdate: name => `更新 ${name} 失敗`,
sortMostUsed: '最常用',
sortAlpha: 'AZ',
enableAll: '全部啟用',
disableAll: '全部停用',
bulkUpdated: count => `已為新工作階段更新 ${count} 項。`,
bulkNoChange: '沒有需要變更的內容。',
usageCount: count => `已使用 ${count}`
},
starmap: {
@ -879,7 +887,6 @@ export const zhHant = defineLocale({
ageHours: hours => `${hours} 小時前`,
durationSeconds: seconds => `${seconds}`,
durationMinutes: (minutes, seconds) => `${minutes}${seconds}`,
tokensK: k => `${k}k 詞元`,
tokens: value => `${value} 詞元`
},
@ -896,7 +903,7 @@ export const zhHant = defineLocale({
appearance: '外觀',
settings: '設定',
changeTheme: '變更主題',
changeColorMode: '變更色彩模式...',
changeColorMode: '變更色彩模式',
pets: {
title: '寵物',
placeholder: '搜尋寵物…',
@ -942,7 +949,8 @@ export const zhHant = defineLocale({
startOver: '重新開始'
},
installTheme: {
title: '安裝主題...',
title: '安裝主題…',
pageTitle: '安裝主題',
placeholder: '搜尋 VS Code Marketplace...',
loading: '正在搜尋 Marketplace...',
error: '無法連接到 Marketplace。',
@ -1151,9 +1159,9 @@ export const zhHant = defineLocale({
allProfiles: '全部設定檔',
showAllProfiles: '顯示全部設定檔',
switchToProfile: name => `切換至 ${name}`,
manageProfiles: '管理設定檔...',
manageProfiles: '管理設定檔',
actionsFor: name => `${name} 的動作`,
color: '顏色...',
color: '顏色',
colorFor: name => `${name} 的顏色`,
setColor: color => `設定顏色 ${color}`,
autoColor: '自動',
@ -1166,6 +1174,8 @@ export const zhHant = defineLocale({
env: 'env',
defaultBadge: '預設',
rename: '重新命名',
renameMenu: '重新命名…',
editSoul: '編輯 SOUL.md…',
copySetup: '複製安裝指令',
copying: '複製中…',
modelLabel: '模型',
@ -1419,8 +1429,7 @@ export const zhHant = defineLocale({
copyPath: '複製路徑',
removeFromSidebar: '從側邊欄移除',
createFailed: '無法建立專案',
staleBackend:
'請更新 Hermes 後端以建立專案——目前後端比桌面應用舊(設定 → 更新 → 後端)。',
staleBackend: '請更新 Hermes 後端以建立專案——目前後端比桌面應用舊(設定 → 更新 → 後端)。',
deleteConfirm: '這會從 Hermes 中移除已儲存的專案。檔案、git 儲存庫和工作樹維持不變。',
startWork: '新增工作樹',
newWorktreeTitle: '新增工作樹',

View file

@ -26,6 +26,7 @@ export const zh: Translations = {
done: '完成',
error: '错误',
failed: '失败',
formatJson: '格式化 JSON',
free: '免费',
loading: '加载中…',
notSet: '未设置',
@ -160,8 +161,7 @@ export const zh: Translations = {
},
remoteDisplayBanner: {
message: reason => `软件渲染已启用 — 检测到远程显示(${reason})。为防止画面闪烁,已禁用 GPU 加速。`,
dismiss: '关闭'
message: reason => `软件渲染已启用 — 检测到远程显示(${reason})。为防止画面闪烁,已禁用 GPU 加速。`
},
titlebar: {
@ -943,6 +943,7 @@ export const zh: Translations = {
skills: {
tabSkills: '技能',
tabToolsets: '工具集',
tabMcp: 'MCP',
tabHub: '浏览技能中心',
all: '全部',
searchSkills: '搜索技能…',
@ -968,8 +969,15 @@ export const zh: Translations = {
toolsetDisabled: '工具集已禁用',
appliesToNewSessions: name => `${name} 将应用于新会话。`,
failedToUpdate: name => `更新 ${name} 失败`,
sortMostUsed: '最常用',
sortAlpha: 'AZ',
enableAll: '全部启用',
disableAll: '全部停用',
bulkUpdated: count => `已为新会话更新 ${count} 项。`,
bulkNoChange: '没有需要更改的内容。',
usageCount: count => `已使用 ${count}`,
hub: {
searchPlaceholder: '搜索技能中心官方、GitHub、社区…',
searchPlaceholder: '搜索技能中心',
search: '搜索',
searching: '搜索中…',
connectingHubs: '正在连接技能中心…',
@ -983,6 +991,7 @@ export const zh: Translations = {
install: '安装',
installing: '安装中…',
uninstall: '卸载',
uninstalling: '卸载中…',
updateAll: '更新已安装',
updating: '更新中…',
preview: '预览',
@ -1070,7 +1079,6 @@ export const zh: Translations = {
ageHours: hours => `${hours} 小时前`,
durationSeconds: seconds => `${seconds}`,
durationMinutes: (minutes, seconds) => `${minutes}${seconds}`,
tokensK: k => `${k}k 词元`,
tokens: value => `${value} 词元`
},
@ -1087,7 +1095,7 @@ export const zh: Translations = {
appearance: '外观',
settings: '设置',
changeTheme: '更改主题',
changeColorMode: '更改颜色模式...',
changeColorMode: '更改颜色模式',
pets: {
title: '宠物',
placeholder: '搜索宠物…',
@ -1133,7 +1141,8 @@ export const zh: Translations = {
startOver: '重新开始'
},
installTheme: {
title: '安装主题...',
title: '安装主题…',
pageTitle: '安装主题',
placeholder: '搜索 VS Code Marketplace...',
loading: '正在搜索 Marketplace...',
error: '无法连接到 Marketplace。',
@ -1397,9 +1406,9 @@ export const zh: Translations = {
allProfiles: '全部配置档案',
showAllProfiles: '显示全部配置档案',
switchToProfile: name => `切换到 ${name}`,
manageProfiles: '管理配置档案...',
manageProfiles: '管理配置档案',
actionsFor: name => `${name} 的操作`,
color: '颜色...',
color: '颜色',
colorFor: name => `${name} 的颜色`,
setColor: color => `设置颜色 ${color}`,
autoColor: '自动',
@ -1412,6 +1421,8 @@ export const zh: Translations = {
env: 'env',
defaultBadge: '默认',
rename: '重命名',
renameMenu: '重命名…',
editSoul: '编辑 SOUL.md…',
copySetup: '复制安装命令',
copying: '复制中…',
modelLabel: '模型',