diff --git a/apps/desktop/src/app/learning/archive-skill-confirm-dialog.tsx b/apps/desktop/src/app/learning/archive-skill-confirm-dialog.tsx new file mode 100644 index 000000000..298e890ee --- /dev/null +++ b/apps/desktop/src/app/learning/archive-skill-confirm-dialog.tsx @@ -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 { + 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, 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 ( + { + const rollback = onApply() + + fireOptimistic( + archiveLearningSkill(skillId).then(() => { + notifySkillArchived() + onSuccess?.() + }), + rollback, + err => onFailure?.(err, skillName) + ) + }} + open={open} + title={`Archive ${skillName}?`} + /> + ) +} diff --git a/apps/desktop/src/app/settings/computer-use-panel.tsx b/apps/desktop/src/app/settings/computer-use-panel.tsx index ada5c08e3..bf30b3b83 100644 --- a/apps/desktop/src/app/settings/computer-use-panel.tsx +++ b/apps/desktop/src/app/settings/computer-use-panel.tsx @@ -134,7 +134,7 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps) if (loading) { return ( -
+
Checking Computer Use status…
@@ -147,7 +147,7 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps) if (!status.platform_supported) { return ( -

+

Computer Use isn't supported on this platform ({status.platform}).

) @@ -155,7 +155,7 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps) if (!status.installed) { return ( -

+

Install the cua-driver backend below to drive this machine. {status.can_grant && ' Then grant Accessibility and Screen Recording here.'}

@@ -165,7 +165,7 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps) const failingChecks = status.checks.filter(c => c.status !== 'ok') return ( -
+
{status.can_grant ? ( diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx index 6a4878aa8..6c29aec82 100644 --- a/apps/desktop/src/app/settings/config-settings.tsx +++ b/apps/desktop/src/app/settings/config-settings.tsx @@ -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(null) - const [_defaults, setDefaults] = useState(null) - const [schema, setSchema] = useState | 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(null) const [elevenLabsVoiceLabels, setElevenLabsVoiceLabels] = useState>({}) 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?.() diff --git a/apps/desktop/src/app/settings/credential-key-ui.tsx b/apps/desktop/src/app/settings/credential-key-ui.tsx index dc829ae68..3cf59d58a 100644 --- a/apps/desktop/src/app/settings/credential-key-ui.tsx +++ b/apps/desktop/src/app/settings/credential-key-ui.tsx @@ -159,9 +159,9 @@ export function CredentialKeyCard({ return (
-
+
e.stopPropagation()} onFocus={() => { if (expandable && !expanded) { @@ -236,9 +236,9 @@ export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps return (
-
+
e.stopPropagation()} onFocus={() => { if (expandable && !expanded) { diff --git a/apps/desktop/src/app/settings/helpers.ts b/apps/desktop/src/app/settings/helpers.ts index d08bc5a60..ff5818129 100644 --- a/apps/desktop/src/app/settings/helpers.ts +++ b/apps/desktop/src/app/settings/helpers.ts @@ -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 => diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx index 09a029709..36bd46aa1 100644 --- a/apps/desktop/src/app/settings/index.tsx +++ b/apps/desktop/src/app/settings/index.tsx @@ -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')} /> -
+
)} - setActiveView('mcp')} - /> setActiveView('sessions')} /> -
+
) : activeView === 'keys' ? ( - ) : activeView === 'mcp' ? ( - ) : activeView === 'notifications' ? ( ) : ( diff --git a/apps/desktop/src/app/settings/mcp-settings.tsx b/apps/desktop/src/app/settings/mcp-settings.tsx deleted file mode 100644 index c638f0b2c..000000000 --- a/apps/desktop/src/app/settings/mcp-settings.tsx +++ /dev/null @@ -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> -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) => - 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('servers') - const [config, setConfig] = useState(null) - const [selected, setSelected] = useState(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(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 - } - - const saveServer = async () => { - const nextName = name.trim() - - if (!nextName) { - notify({ kind: 'error', title: m.nameRequiredTitle, message: m.nameRequiredMessage }) - - return - } - - let parsed: Record - - try { - const raw = JSON.parse(body) - - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { - throw new Error(m.objectRequired) - } - - parsed = raw as Record - } 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 ( - -
-
- setView('servers')} /> - setView('catalog')} /> -
- {view === 'servers' && ( -
- - -
- )} -
- - {view === 'catalog' ? ( - void refreshConfig()} /> - ) : ( -
-
- {names.length === 0 ? ( - - ) : ( -
- {names.map(serverName => { - const server = servers[serverName] - const active = selected === serverName - - return ( - - ) - })} -
- )} -
- -
-
-
- - {selected ? m.editServer : m.newServer} -
- {selected && ( -
- - void toggleEnabled(selected, checked)} - /> -
- )} -
- {testResult && ( -
- {testResult.ok ? m.testOk(testResult.tools.length) : `${m.testFailed}: ${testResult.error ?? ''}`} - {testResult.ok && testResult.tools.length > 0 && ( -
- {testResult.tools.map(tool => ( - - {tool.name} - - ))} -
- )} -
- )} - -