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.
This commit is contained in:
Erosika 2026-07-09 16:39:24 -04:00
parent 1508ece2d9
commit b45f5bef16
3 changed files with 85 additions and 10 deletions

View file

@ -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<string, [string, ConfigFieldSchema][]>()
}
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) ?? []

View file

@ -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)
})
})
})

View file

@ -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<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)