Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/desktop/src/app/settings/config-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { fieldCopyForSchemaKey } from './field-copy'
import { enumOptionsFor, getNested, prettyName, setNested } from './helpers'
import { ModelSettings } from './model-settings'
import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives'
import { ProviderConfigPanel } from './provider-config-panel'

function ConfigField({
schemaKey,
Expand Down Expand Up @@ -368,6 +369,9 @@ export function ConfigSettings({
schemaKey={key}
value={getNested(config, key)}
/>
{key === 'memory.provider' && typeof getNested(config, key) === 'string' && getNested(config, key) ? (
<ProviderConfigPanel provider={String(getNested(config, key))} />
) : null}
</div>
))}
</div>
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/app/settings/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ export const ENUM_OPTIONS: Record<string, string[]> = {
'code_execution.mode': ['project', 'strict'],
'context.engine': ['compressor', 'default', 'custom'],
'delegation.reasoning_effort': ['', 'minimal', 'low', 'medium', 'high', 'xhigh'],
'memory.provider': ['', 'builtin', 'honcho'],
'memory.provider': ['', 'builtin', 'hindsight', 'honcho'],
// 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).
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/app/settings/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import { defineFieldCopy, fieldCopyForSchemaKey, schemaKeyToFieldCopyKey } from
import { enumOptionsFor, getNested, providerGroup, setNested, stripToolsetLabel, toolsetDisplayLabel } from './helpers'

describe('settings helpers', () => {
it('lists Hindsight as a built-in desktop memory provider option', () => {
const options = enumOptionsFor('memory.provider', '', {})

expect(options).toContain('hindsight')
})

describe('defineFieldCopy', () => {
it('flattens nested field copy paths', () => {
const copy = defineFieldCopy({
Expand Down
142 changes: 142 additions & 0 deletions apps/desktop/src/app/settings/provider-config-panel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
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/notifications', () => ({
notify: vi.fn(),
notifyError: vi.fn()
}))

function hindsightSchema(overrides: Partial<MemoryProviderConfig['fields'][number]>[] = []): MemoryProviderConfig {
const fields: MemoryProviderConfig['fields'] = [
{
key: 'mode',
label: 'Mode',
kind: 'select',
value: 'cloud',
description: 'How Hermes connects to Hindsight.',
placeholder: '',
is_set: true,
options: [
{ value: 'cloud', label: 'Cloud', description: 'Hindsight Cloud API (lightweight, just needs an API key)' },
{ value: 'local_external', label: 'Local External', description: 'Connect to an existing Hindsight instance' }
]
},
{
key: 'api_key',
label: 'API key',
kind: 'secret',
value: '',
description: 'Used to authenticate with the Hindsight API.',
placeholder: 'Enter Hindsight API key',
is_set: false,
options: []
},
{
key: 'api_url',
label: 'API URL',
kind: 'text',
value: 'https://api.hindsight.vectorize.io',
description: '',
placeholder: '',
is_set: true,
options: []
},
{ key: 'bank_id', label: 'Bank ID', kind: 'text', value: 'hermes', description: '', placeholder: '', is_set: true, options: [] },
{
key: 'recall_budget',
label: 'Recall budget',
kind: 'select',
value: 'mid',
description: '',
placeholder: '',
is_set: true,
options: [
{ value: 'low', label: 'low', description: '' },
{ value: 'mid', label: 'mid', description: '' },
{ value: 'high', label: 'high', description: '' }
]
}
]

return {
name: 'hindsight',
label: 'Hindsight',
fields: fields.map((field, index) => ({ ...field, ...overrides[index] }))
}
}

beforeEach(() => {
getMemoryProviderConfig.mockResolvedValue(hindsightSchema())
saveMemoryProviderConfig.mockResolvedValue({ ok: true })
})

afterEach(() => {
cleanup()
vi.clearAllMocks()
})

async function renderPanel(provider = 'hindsight') {
const { ProviderConfigPanel } = await import('./provider-config-panel')

return render(<ProviderConfigPanel provider={provider} />)
}

describe('ProviderConfigPanel', () => {
it('renders the declared provider fields generically', async () => {
await renderPanel()

expect(await screen.findByDisplayValue('https://api.hindsight.vectorize.io')).toBeTruthy()
expect(screen.getByDisplayValue('hermes')).toBeTruthy()
expect(screen.getByText('Cloud')).toBeTruthy()
expect(screen.getAllByText('Hindsight Cloud API (lightweight, just needs an API key)').length).toBeGreaterThan(0)
expect(screen.getByText('mid')).toBeTruthy()
})

it('collapses and expands the fields', async () => {
await renderPanel()

expect(await screen.findByLabelText('API URL')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ }))
expect(screen.queryByLabelText('API URL')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ }))
expect(await screen.findByLabelText('API URL')).toBeTruthy()
})

it('saves edited values without requiring a secret replacement', async () => {
await renderPanel()

const apiUrl = await screen.findByLabelText('API URL')
fireEvent.change(apiUrl, { target: { value: 'http://localhost:8888' } })
fireEvent.change(screen.getByLabelText('Bank ID'), { target: { value: 'ben-bank' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))

await waitFor(() =>
expect(saveMemoryProviderConfig).toHaveBeenCalledWith('hindsight', {
mode: 'cloud',
api_key: '',
api_url: 'http://localhost:8888',
bank_id: 'ben-bank',
recall_budget: 'mid'
})
)
})

it('renders nothing for a provider with no declared config surface', async () => {
getMemoryProviderConfig.mockResolvedValue({ name: 'builtin', label: 'builtin', fields: [] })

const { container } = await renderPanel('builtin')

await waitFor(() => expect(getMemoryProviderConfig).toHaveBeenCalledWith('builtin'))
expect(container.querySelector('section')).toBeNull()
})
})
182 changes: 182 additions & 0 deletions apps/desktop/src/app/settings/provider-config-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { useCallback, useEffect, useState } from 'react'

import { Button } from '@/components/ui/button'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { getMemoryProviderConfig, saveMemoryProviderConfig } from '@/hermes'
import { Check, Loader2, Save } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'

import { CONTROL_TEXT } from './constants'
import { LoadingState, Pill } from './primitives'

/** Seed editable values from the schema: non-secret fields keep their current
* value, secret fields start blank (their value is never returned). */
function seedValues(config: MemoryProviderConfig): Record<string, string> {
return Object.fromEntries(
config.fields.map(field => [field.key, field.kind === 'secret' ? '' : field.value])
)
}

function FieldControl({
field,
value,
onChange
}: {
field: MemoryProviderField
value: string
onChange: (value: string) => void
}) {
if (field.kind === 'select') {
const selected = field.options.find(option => option.value === value)

return (
<>
<Select onValueChange={onChange} 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>
{(selected?.description || field.description) && (
<span className="text-xs text-muted-foreground">{selected?.description || field.description}</span>
)}
</>
)
}

if (field.kind === 'secret') {
return (
<div className="flex flex-wrap items-center gap-2">
<Input
className="min-w-64 flex-1 font-mono"
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 && (
<Pill tone="primary">
<Check className="size-3" />
Set
</Pill>
)}
</div>
)
}

return (
<Input
className="font-mono"
onChange={event => onChange(event.target.value)}
placeholder={field.placeholder}
value={value}
/>
)
}

export function ProviderConfigPanel({ provider }: { provider: string }) {
const [config, setConfig] = useState<MemoryProviderConfig | null>(null)
const [values, setValues] = useState<Record<string, string>>({})
const [expanded, setExpanded] = useState(true)
const [saving, setSaving] = useState(false)

const refresh = useCallback(async () => {
try {
const next = await getMemoryProviderConfig(provider)
setConfig(next)
setValues(seedValues(next))
} catch (err) {
notifyError(err, 'Memory provider settings failed to load')
setConfig(null)
}
}, [provider])

useEffect(() => {
setConfig(null)
void refresh()
}, [refresh])

const save = useCallback(async () => {
if (!config) {
return
}

setSaving(true)

try {
await saveMemoryProviderConfig(provider, values)
notify({ kind: 'success', title: `${config.label} saved`, message: 'Memory provider configuration updated.' })
await refresh()
} catch (err) {
notifyError(err, `Failed to save ${config.label} settings`)
} finally {
setSaving(false)
}
}, [config, provider, refresh, values])

// Providers without a declared config surface (e.g. builtin) render nothing.
if (config && config.fields.length === 0) {
return null
}

if (!config) {
return <LoadingState label="Loading memory provider settings..." />
}

const secretFields = config.fields.filter(field => field.kind === 'secret')

return (
<section className="py-3">
<button
aria-expanded={expanded}
className="flex w-full items-center justify-between gap-3 rounded-lg bg-background/60 px-3 py-2 text-left hover:bg-accent/50"
onClick={() => setExpanded(open => !open)}
type="button"
>
<span className="flex min-w-0 items-center gap-2">
<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>
))}
</span>
</button>

{expanded && (
<div className="mt-3 grid gap-4 rounded-xl bg-background/60 p-4">
{config.fields.map(field => (
<label className="grid gap-1.5" key={field.key}>
<span className="text-xs font-medium text-muted-foreground">{field.label}</span>
<FieldControl
field={field}
onChange={value => setValues(current => ({ ...current, [field.key]: value }))}
value={values[field.key] ?? ''}
/>
{field.kind !== 'select' && field.description && (
<span className="text-xs text-muted-foreground">{field.description}</span>
)}
</label>
))}

<div className="flex justify-end">
<Button disabled={saving} onClick={() => void save()} size="sm">
{saving ? <Loader2 className="size-3.5 animate-spin" /> : <Save />}
Save
</Button>
</div>
</div>
)}
</section>
)
}
Loading
Loading