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:
Erosika 2026-07-08 10:45:22 -04:00
parent 7faacb6dab
commit 67d64124c3
3 changed files with 97 additions and 52 deletions

View file

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

View file

@ -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 () => {

View file

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