From b45f5bef163b69b30d32e9000c117669c25064b6 Mon Sep 17 00:00:00 2001 From: Erosika Date: Thu, 9 Jul 2026 16:39:24 -0400 Subject: [PATCH] fix(desktop): render declared settings rows when backend schema omits them The Memory & Context tab dropped any section key missing from /api/config/schema, so when the backend began skipping memory.provider (hidden in favor of the web dashboard's Plugins page) the desktop lost its Memory Provider picker entirely, along with the provider config panel and OAuth connect affordance mounted under it. The desktop already declares its form in constants (section keys, labels, enum options), and GET /api/config still returns the value. Gate row existence on config presence instead: use the schema entry when present, otherwise infer the field type from the config value. Keys unknown to an older backend stay hidden since they are absent from its config too. --- .../src/app/settings/config-settings.tsx | 12 +++-- apps/desktop/src/app/settings/helpers.test.ts | 45 ++++++++++++++++++- apps/desktop/src/app/settings/helpers.ts | 38 +++++++++++++++- 3 files changed, 85 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx index e7953310adc6..cac0d197f2ce 100644 --- a/apps/desktop/src/app/settings/config-settings.tsx +++ b/apps/desktop/src/app/settings/config-settings.tsx @@ -18,9 +18,9 @@ import { setHermesConfigCache, useHermesConfigRecord } from '../hooks/use-config import { useOnProfileSwitch } from '../hooks/use-on-profile-switch' import { PanelEmpty } from '../overlays/panel' -import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants' +import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS } from './constants' import { fieldCopyForSchemaKey } from './field-copy' -import { enumOptionsFor, getNested, prettyName, setNested } from './helpers' +import { enumOptionsFor, getNested, prettyName, sectionFieldEntries, setNested } from './helpers' import { MemoryConnect } from './memory/connect' import { ModelSettings, ModelSettingsSkeleton } from './model-settings' import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives' @@ -328,14 +328,12 @@ export function ConfigSettings({ } const sectionFields = useMemo(() => { - if (!schema) { + if (!schema || !config) { return new Map() } - return new Map( - SECTIONS.map(s => [s.id, s.keys.flatMap(k => (schema[k] ? [[k, schema[k]] as [string, ConfigFieldSchema]] : []))]) - ) - }, [schema]) + return sectionFieldEntries(schema, config) + }, [schema, config]) const fields = sectionFields.get(activeSectionId) ?? [] diff --git a/apps/desktop/src/app/settings/helpers.test.ts b/apps/desktop/src/app/settings/helpers.test.ts index c04dcb42498c..1c8ac1303d6e 100644 --- a/apps/desktop/src/app/settings/helpers.test.ts +++ b/apps/desktop/src/app/settings/helpers.test.ts @@ -3,7 +3,15 @@ import { describe, expect, it } from 'vitest' import type { HermesConfigRecord } from '@/types/hermes' import { defineFieldCopy, fieldCopyForSchemaKey, schemaKeyToFieldCopyKey } from './field-copy' -import { enumOptionsFor, getNested, providerGroup, setNested, stripToolsetLabel, toolsetDisplayLabel } from './helpers' +import { + enumOptionsFor, + getNested, + providerGroup, + sectionFieldEntries, + setNested, + stripToolsetLabel, + toolsetDisplayLabel +} from './helpers' describe('settings helpers', () => { it('lists the desktop memory provider options in their declared order', () => { @@ -175,4 +183,39 @@ describe('settings helpers', () => { expect(opts).toContain('xai') }) }) + + 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) + }) + }) }) diff --git a/apps/desktop/src/app/settings/helpers.ts b/apps/desktop/src/app/settings/helpers.ts index ff5818129053..16bee76b4ea5 100644 --- a/apps/desktop/src/app/settings/helpers.ts +++ b/apps/desktop/src/app/settings/helpers.ts @@ -1,7 +1,7 @@ import { asText } from '@/lib/text' -import type { HermesConfigRecord, ToolsetInfo } from '@/types/hermes' +import type { ConfigFieldSchema, HermesConfigRecord, ToolsetInfo } from '@/types/hermes' -import { BUILTIN_PERSONALITIES, ENUM_OPTIONS, PROVIDER_GROUPS } from './constants' +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. @@ -97,6 +97,40 @@ export function getNested(obj: HermesConfigRecord, path: string): unknown { return cur } +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 (e.g. memory.provider); config presence is the availability signal. +export function sectionFieldEntries( + schema: Record, + config: HermesConfigRecord +): Map { + 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)