mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
feat(desktop): autosave the inline provider panel, drop its Save button
Everything else on the settings page writes through on change; the panel hoarding edits behind a Save button meant navigation silently discarded them. Discrete controls (switch, select) commit on change, text-like fields commit on blur, each as a one-key partial save — silent on success, toast on failure, no full refresh so sibling drafts survive. A committed secret clears its draft and flips the set pill locally. The modal keeps explicit Save changes: a dialog is a transaction with Cancel semantics.
This commit is contained in:
parent
7faacb6dab
commit
67d64124c3
3 changed files with 97 additions and 52 deletions
|
|
@ -31,14 +31,24 @@ export function FieldTitle({ field }: { field: MemoryProviderField }) {
|
|||
export function FieldControl({
|
||||
field,
|
||||
value,
|
||||
onChange
|
||||
onChange,
|
||||
onCommit
|
||||
}: {
|
||||
field: MemoryProviderField
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
// Present on autosaving surfaces: discrete controls commit on change, text-like
|
||||
// controls commit on blur. Absent (the modal), edits stay drafts until Save.
|
||||
onCommit?: (value: string) => void
|
||||
}) {
|
||||
const set = (next: string) => {
|
||||
onChange(next)
|
||||
onCommit?.(next)
|
||||
}
|
||||
const commitDraft = onCommit ? () => onCommit(value) : undefined
|
||||
|
||||
if (field.kind === 'bool') {
|
||||
return <Switch checked={value === 'true'} onCheckedChange={checked => onChange(checked ? 'true' : 'false')} />
|
||||
return <Switch checked={value === 'true'} onCheckedChange={checked => set(checked ? 'true' : 'false')} />
|
||||
}
|
||||
|
||||
if (field.kind === 'number') {
|
||||
|
|
@ -46,6 +56,7 @@ export function FieldControl({
|
|||
<Input
|
||||
className={FIELD_INPUT}
|
||||
inputMode="numeric"
|
||||
onBlur={commitDraft}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
type="number"
|
||||
|
|
@ -58,6 +69,7 @@ export function FieldControl({
|
|||
return (
|
||||
<Textarea
|
||||
className={FIELD_INPUT}
|
||||
onBlur={commitDraft}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
spellCheck={false}
|
||||
|
|
@ -68,7 +80,7 @@ export function FieldControl({
|
|||
|
||||
if (field.kind === 'select') {
|
||||
return (
|
||||
<Select onValueChange={onChange} value={value}>
|
||||
<Select onValueChange={set} value={value}>
|
||||
<SelectTrigger className={CONTROL_TEXT}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
|
@ -88,6 +100,7 @@ export function FieldControl({
|
|||
<div className="flex flex-col gap-1">
|
||||
<Input
|
||||
className={`w-full ${FIELD_INPUT}`}
|
||||
onBlur={commitDraft}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
placeholder={field.is_set ? 'Leave blank to keep current value' : field.placeholder}
|
||||
type="password"
|
||||
|
|
@ -106,6 +119,7 @@ export function FieldControl({
|
|||
return (
|
||||
<Input
|
||||
className={FIELD_INPUT}
|
||||
onBlur={commitDraft}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
value={value}
|
||||
|
|
|
|||
|
|
@ -146,22 +146,41 @@ describe('ProviderConfigPanel', () => {
|
|||
expect(await screen.findByDisplayValue('myws')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('saves only inline values, with a blank secret', async () => {
|
||||
it('autosaves a text field on blur as a one-key partial save', async () => {
|
||||
await renderPanel()
|
||||
|
||||
const baseUrl = await screen.findByPlaceholderText('https://… (self-hosted)')
|
||||
fireEvent.change(baseUrl, { target: { value: 'http://localhost:8000' } })
|
||||
fireEvent.change(screen.getByDisplayValue('myws'), { target: { value: 'ben-bank' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
fireEvent.blur(baseUrl)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(saveMemoryProviderConfig).toHaveBeenCalledWith('honcho', {
|
||||
apiKey: '',
|
||||
baseUrl: 'http://localhost:8000',
|
||||
environment: 'production',
|
||||
workspace: 'ben-bank'
|
||||
})
|
||||
expect(saveMemoryProviderConfig).toHaveBeenCalledWith('honcho', { baseUrl: 'http://localhost:8000' })
|
||||
)
|
||||
expect(saveMemoryProviderConfig).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not save on blur when nothing changed', async () => {
|
||||
await renderPanel()
|
||||
|
||||
const workspace = await screen.findByDisplayValue('myws')
|
||||
fireEvent.blur(workspace)
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: 'Save' })).toBeNull())
|
||||
expect(saveMemoryProviderConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('autosaves a committed secret and clears the draft', async () => {
|
||||
await renderPanel()
|
||||
|
||||
const apiKey = await screen.findByPlaceholderText('Enter Honcho API key')
|
||||
fireEvent.blur(apiKey)
|
||||
expect(saveMemoryProviderConfig).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.change(apiKey, { target: { value: 'hch-new-key' } })
|
||||
fireEvent.blur(apiKey)
|
||||
|
||||
await waitFor(() => expect(saveMemoryProviderConfig).toHaveBeenCalledWith('honcho', { apiKey: 'hch-new-key' }))
|
||||
await waitFor(() => expect((apiKey as HTMLInputElement).value).toBe(''))
|
||||
})
|
||||
|
||||
it('offers a full-config trigger when modal-only fields exist', async () => {
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ import { useCallback, useEffect, useState } from 'react'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { getMemoryProviderConfig, runMemoryProviderAction, saveMemoryProviderConfig } from '@/hermes'
|
||||
import { Loader2, Save, SlidersHorizontal } from '@/lib/icons'
|
||||
import { Loader2, SlidersHorizontal } from '@/lib/icons'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { MemoryProviderConfig } from '@/types/hermes'
|
||||
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
|
||||
|
||||
import { FieldControl, FieldTitle } from './field-control'
|
||||
import { ListRow, LoadingState, Pill } from '../primitives'
|
||||
|
|
@ -22,16 +22,18 @@ export function ProviderConfigPanel({ provider }: { provider: string }) {
|
|||
const [config, setConfig] = useState<MemoryProviderConfig | null>(null)
|
||||
const [loadError, setLoadError] = useState<null | string>(null)
|
||||
const [values, setValues] = useState<Record<string, string>>({})
|
||||
const [saved, setSaved] = useState<Record<string, string>>({})
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [runningAction, setRunningAction] = useState<null | string>(null)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const next = await getMemoryProviderConfig(provider)
|
||||
const seed = seedValues(next)
|
||||
setConfig(next)
|
||||
setValues(seedValues(next))
|
||||
setValues(seed)
|
||||
setSaved(seed)
|
||||
setLoadError(null)
|
||||
} catch (err) {
|
||||
setConfig(null)
|
||||
|
|
@ -44,23 +46,34 @@ export function ProviderConfigPanel({ provider }: { provider: string }) {
|
|||
void refresh()
|
||||
}, [refresh])
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
// Autosave, matching the settings page around the panel: one-key partial PUT
|
||||
// on commit, silent on success, no full refresh (it would reset sibling drafts).
|
||||
const commitField = useCallback(
|
||||
async (field: MemoryProviderField, value: string) => {
|
||||
if (value === (saved[field.key] ?? '') || (field.kind === 'secret' && !value.trim())) {
|
||||
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])
|
||||
try {
|
||||
await saveMemoryProviderConfig(provider, { [field.key]: value })
|
||||
if (field.kind === 'secret') {
|
||||
setValues(current => ({ ...current, [field.key]: '' }))
|
||||
setConfig(
|
||||
current =>
|
||||
current && {
|
||||
...current,
|
||||
fields: current.fields.map(f => (f.key === field.key ? { ...f, is_set: true } : f))
|
||||
}
|
||||
)
|
||||
} else {
|
||||
setSaved(current => ({ ...current, [field.key]: value }))
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to save ${field.label}`)
|
||||
}
|
||||
},
|
||||
[provider, saved]
|
||||
)
|
||||
|
||||
const runAction = useCallback(
|
||||
async (key: string, label: string) => {
|
||||
|
|
@ -141,6 +154,7 @@ export function ProviderConfigPanel({ provider }: { provider: string }) {
|
|||
<FieldControl
|
||||
field={field}
|
||||
onChange={value => setValues(current => ({ ...current, [field.key]: value }))}
|
||||
onCommit={value => void commitField(field, value)}
|
||||
value={values[field.key] ?? ''}
|
||||
/>
|
||||
}
|
||||
|
|
@ -150,26 +164,24 @@ export function ProviderConfigPanel({ provider }: { provider: string }) {
|
|||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-3">
|
||||
{config.actions.map(action => (
|
||||
<Button
|
||||
disabled={runningAction !== null || saving}
|
||||
key={action.key}
|
||||
onClick={() => void runAction(action.key, action.label)}
|
||||
size="sm"
|
||||
title={action.description || undefined}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
{runningAction === action.key && <Loader2 className="size-3.5 animate-spin" />}
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
<Button disabled={saving || runningAction !== null} onClick={() => void save()} size="sm">
|
||||
{saving ? <Loader2 className="size-3.5 animate-spin" /> : <Save />}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
{config.actions.length > 0 && (
|
||||
<div className="flex items-center justify-end gap-2 pt-3">
|
||||
{config.actions.map(action => (
|
||||
<Button
|
||||
disabled={runningAction !== null}
|
||||
key={action.key}
|
||||
onClick={() => void runAction(action.key, action.label)}
|
||||
size="sm"
|
||||
title={action.description || undefined}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
{runningAction === action.key && <Loader2 className="size-3.5 animate-spin" />}
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue