Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
yilsem committed 2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 files changed
+3442369

No files matched your search

@@ -0,0 +1,162 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { getMemoryProviderOAuthStatus, startMemoryProviderOAuth } from '@/hermes'
import { Check, ExternalLink, Loader2 } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
import type { MemoryProviderOAuthStatus } from '@/types/hermes'
const POLL_MS = 1500
const POLL_TIMEOUT_MS = 120_000
// Small connect affordance rendered under the provider dropdown. Capability is
// backend-driven: the status route 404s for providers without an oauth_flow
// module, so non-OAuth providers render nothing.
export function MemoryConnect({ profile, provider }: { profile?: string; provider: string }) {
const [capable, setCapable] = useState<'no' | 'unknown' | 'yes'>('unknown')
const [connected, setConnected] = useState(false)
const [auth, setAuth] = useState<MemoryProviderOAuthStatus['auth']>(null)
const [phase, setPhase] = useState<'error' | 'idle' | 'pending'>('idle')
const [detail, setDetail] = useState('')
const timer = useRef<ReturnType<typeof setInterval> | null>(null)
const deadline = useRef(0)
const stop = useCallback(() => {
if (timer.current !== null) {
clearInterval(timer.current)
timer.current = null
}
}, [])
useEffect(() => {
let active = true
setCapable('unknown')
getMemoryProviderOAuthStatus(provider, profile)
.then(s => {
if (!active) {
return
}
setCapable('yes')
setConnected(s.connected)
setAuth(s.auth)
})
.catch(() => {
if (active) {
setCapable('no')
}
})
return () => {
active = false
stop()
}
}, [profile, provider, stop])
// An error message isn't sticky — it clears back to the steady state
// (Connect link, plus the connected badge if a credential is stored).
useEffect(() => {
if (phase !== 'error') {
return
}
const t = setTimeout(() => {
setPhase('idle')
setDetail('')
}, 6000)
return () => clearTimeout(t)
}, [phase])
const connect = useCallback(async () => {
setPhase('pending')
try {
await startMemoryProviderOAuth(provider, profile)
} catch (err) {
setPhase('error')
setDetail('Could not start the connection.')
notifyError(err, 'Failed to start connection')
return
}
deadline.current = Date.now() + POLL_TIMEOUT_MS
stop()
timer.current = setInterval(() => {
void (async () => {
try {
const next = await getMemoryProviderOAuthStatus(provider, profile)
if (next.state === 'pending') {
if (Date.now() > deadline.current) {
stop()
setPhase('error')
setDetail('Timed out — try again.')
}
return
}
stop()
setConnected(next.connected)
setAuth(next.auth)
if (next.state === 'error') {
setPhase('error')
setDetail(next.detail || 'Connection failed.')
} else {
setPhase('idle')
}
} catch {
// Transient poll failure — keep trying until the deadline.
}
})()
}, POLL_MS)
}, [profile, provider, stop])
const cancel = useCallback(() => {
stop()
setPhase('idle')
}, [stop])
if (capable !== 'yes') {
return null
}
const connectLabel = connected ? (auth === 'apikey' ? 'Connect via OAuth' : 'Reconnect') : 'Connect'
return (
<span className="inline-flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
{phase === 'idle' && connected && (
<span className="inline-flex items-center gap-1 text-muted-foreground">
<Check className="size-3" />
{auth === 'apikey' ? 'api key set' : 'oauth set'}
</span>
)}
{phase === 'pending' ? (
<>
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
Waiting for browser consent…
</span>
<Button className="h-auto p-0 text-xs" onClick={cancel} size="sm" type="button" variant="link">
Cancel
</Button>
</>
) : (
<Button
className="h-auto gap-1 p-0 text-xs"
onClick={() => void connect()}
size="sm"
type="button"
variant="link"
>
<ExternalLink className="size-3" />
{connectLabel}
</Button>
)}
{phase === 'error' && detail && <span className="text-destructive">{detail}</span>}
</span>
)
}
@@ -0,0 +1,129 @@
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { Tip } from '@/components/ui/tooltip'
import { Check, Info } from '@/lib/icons'
import type { MemoryProviderField } from '@/types/hermes'
import { CONTROL_TEXT } from '../constants'
// Fade the placeholder well below set values so example text never reads as data.
const FIELD_INPUT = `font-mono ${CONTROL_TEXT} placeholder:text-muted-foreground/45`
// Field label with an optional info tooltip, shared by the panel and modal rows.
export function FieldTitle({ field }: { field: MemoryProviderField }) {
if (!field.info) {
return <>{field.label}</>
}
return (
<span className="inline-flex items-center gap-1.5">
{field.label}
<Tip className="max-w-60 font-normal leading-snug whitespace-normal" label={field.info}>
<Info aria-label={`About ${field.label}`} className="size-3.5 text-muted-foreground/70" />
</Tip>
</span>
)
}
// Values are edited as strings; the backend coerces them to native types.
export function FieldControl({
field,
value,
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 => set(checked ? 'true' : 'false')} />
}
if (field.kind === 'number') {
return (
<Input
className={FIELD_INPUT}
inputMode="numeric"
onBlur={commitDraft}
onChange={event => onChange(event.target.value)}
placeholder={field.placeholder}
type="number"
value={value}
/>
)
}
if (field.kind === 'json') {
return (
<Textarea
className={FIELD_INPUT}
onBlur={commitDraft}
onChange={event => onChange(event.target.value)}
placeholder={field.placeholder}
spellCheck={false}
value={value}
/>
)
}
if (field.kind === 'select') {
return (
<Select onValueChange={set} 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>
)
}
if (field.kind === 'secret') {
return (
<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"
value={value}
/>
{field.is_set && (
<span className="inline-flex items-center gap-1 self-start font-mono text-[0.65rem] text-(--ui-text-tertiary)">
<Check className="size-3 text-(--ui-accent-secondary)" />
set
</span>
)}
</div>
)
}
return (
<Input
className={FIELD_INPUT}
onBlur={commitDraft}
onChange={event => onChange(event.target.value)}
placeholder={field.placeholder}
value={value}
/>
)
}
@@ -0,0 +1,115 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
const saveMemoryProviderConfig = vi.fn()
vi.mock('@/hermes', () => ({
saveMemoryProviderConfig: (provider: string, values: unknown) => saveMemoryProviderConfig(provider, values)
}))
vi.mock('@/store/profile', async () => {
const { atom } = await import('nanostores')
return { $activeGatewayProfile: atom('default') }
})
vi.mock('@/store/notifications', () => ({
notify: vi.fn(),
notifyError: vi.fn()
}))
function field(
overrides: Partial<MemoryProviderField> & Pick<MemoryProviderField, 'key' | 'kind'>
): MemoryProviderField {
return {
label: overrides.key,
value: '',
description: '',
placeholder: '',
is_set: false,
inline: false,
group: 'Other',
options: [],
...overrides
}
}
function schema(): MemoryProviderConfig {
return {
name: 'honcho',
label: 'Honcho',
docs_url: 'https://docs.honcho.dev/v3/guides/integrations/hermes',
fields: [
field({ key: 'workspace', kind: 'text', label: 'Workspace', value: 'myws', inline: true, group: 'Connection' }),
field({ key: 'saveMessages', kind: 'bool', label: 'Save messages', value: 'true', group: 'Message writing' }),
field({ key: 'dialecticMaxChars', kind: 'number', label: 'Max result chars', value: '1200', group: 'Dialectic' }),
field({
key: 'userPeerAliases',
kind: 'json',
label: 'User peer aliases',
value: '{"t":"eri"}',
group: 'Identity'
})
]
}
}
beforeEach(() => {
saveMemoryProviderConfig.mockResolvedValue({ ok: true })
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
async function renderModal(open = true) {
const { ProviderConfigModal } = await import('./provider-config-modal')
const onOpenChange = vi.fn()
const onSaved = vi.fn().mockResolvedValue(undefined)
const result = render(
<ProviderConfigModal
config={schema()}
onOpenChange={onOpenChange}
onSaved={onSaved}
open={open}
provider="honcho"
/>
)
return { ...result, onOpenChange, onSaved }
}
describe('ProviderConfigModal', () => {
it('renders every field grouped, including inline ones, with kind-specific controls', async () => {
await renderModal()
expect(await screen.findByText('Message writing')).toBeTruthy()
expect(screen.getByText('Dialectic')).toBeTruthy()
// bool -> switch, number -> spinbutton, json/text -> textbox
expect(screen.getByRole('switch')).toBeTruthy()
expect(screen.getByDisplayValue('1200')).toBeTruthy()
expect(screen.getByDisplayValue('myws')).toBeTruthy()
expect(screen.getByDisplayValue('{"t":"eri"}')).toBeTruthy()
})
it('saves only edited fields, serializing the toggled bool to "false"', async () => {
const { onSaved, onOpenChange } = await renderModal()
fireEvent.click(await screen.findByRole('switch'))
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
// A save must never ratify rendered defaults the backend does not store.
await waitFor(() => expect(saveMemoryProviderConfig).toHaveBeenCalledWith('honcho', { saveMessages: 'false' }))
await waitFor(() => expect(onSaved).toHaveBeenCalled())
expect(onOpenChange).toHaveBeenCalledWith(false)
})
it('renders nothing while closed', async () => {
await renderModal(false)
expect(screen.queryByText('Message writing')).toBeNull()
})
})
@@ -0,0 +1,161 @@
import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { saveMemoryProviderConfig } from '@/hermes'
import { ExternalLink, Loader2, Save, SlidersHorizontal } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
import { $activeGatewayProfile } from '@/store/profile'
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
import { ListRow } from '../primitives'
import { FieldControl, FieldTitle } from './field-control'
// Secrets seed blank: values are write-only and blank keeps the stored one.
function seedAll(config: MemoryProviderConfig): Record<string, string> {
return Object.fromEntries(config.fields.map(field => [field.key, field.kind === 'secret' ? '' : field.value]))
}
// Group fields in declared order, preserving first-seen group sequence.
function groupFields(fields: MemoryProviderField[]): [string, MemoryProviderField[]][] {
const groups: [string, MemoryProviderField[]][] = []
for (const field of fields) {
const name = field.group || 'Other'
const bucket = groups.find(([key]) => key === name)
if (bucket) {
bucket[1].push(field)
} else {
groups.push([name, [field]])
}
}
return groups
}
export function ProviderConfigModal({
config,
profile = null,
provider,
open,
onOpenChange,
onSaved
}: {
config: MemoryProviderConfig
profile?: null | string
provider: string
open: boolean
onOpenChange: (open: boolean) => void
onSaved: () => Promise<void> | void
}) {
const activeProfile = useStore($activeGatewayProfile)
const [values, setValues] = useState<Record<string, string>>({})
const [seeded, setSeeded] = useState<Record<string, string>>({})
const [saving, setSaving] = useState(false)
// Reseed on open so edits never start from a stale prior-session snapshot.
useEffect(() => {
if (open) {
const seed = seedAll(config)
setSeeded(seed)
setValues(seed)
}
}, [open, config])
const save = async () => {
// Untouched keys stay unsubmitted; runtime defaults still own their values.
const edited = Object.fromEntries(Object.entries(values).filter(([key, value]) => value !== seeded[key]))
setSaving(true)
try {
await saveMemoryProviderConfig(provider, edited, profile)
notify({ kind: 'success', title: `${config.label} saved`, message: 'Memory provider configuration updated.' })
await onSaved()
onOpenChange(false)
} catch (err) {
notifyError(err, `Failed to save ${config.label} settings`)
} finally {
setSaving(false)
}
}
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent bodyClassName="dt-portal-scrollbar" className="max-w-2xl">
<DialogHeader>
<DialogTitle icon={SlidersHorizontal}>{config.label} — full configuration</DialogTitle>
<DialogDescription>
Every {config.label} option for the <span className="font-medium">{profile ?? activeProfile}</span> profile.
Blank fields fall back to the resolved host or built-in default.
</DialogDescription>
{config.docs_url && (
<a
className="inline-flex w-fit items-center gap-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-accent-secondary) underline-offset-4 transition-colors hover:underline"
href={config.docs_url}
onClick={event => {
event.preventDefault()
void window.hermesDesktop?.openExternal?.(config.docs_url)
}}
rel="noreferrer"
target="_blank"
>
{config.label} configuration reference
<ExternalLink className="size-3" />
</a>
)}
</DialogHeader>
<div className="min-w-0">
{groupFields(config.fields).map(([group, fields]) => (
<section className="mt-6 first:mt-2" key={group}>
<h3 className="border-b border-(--ui-accent-secondary)/30 pb-1.5 font-mono text-[0.68rem] uppercase tracking-wide text-(--ui-accent-secondary)">
{group}
</h3>
<div className="pl-1">
{fields.map(field => (
<div className="border-b border-border/40 last:border-b-0" key={field.key}>
<ListRow
action={
<FieldControl
field={field}
onChange={value => setValues(current => ({ ...current, [field.key]: value }))}
value={values[field.key] ?? ''}
/>
}
description={field.description}
title={<FieldTitle field={field} />}
/>
</div>
))}
</div>
</section>
))}
</div>
<DialogFooter>
<DialogClose asChild>
<Button size="sm" type="button" variant="ghost">
Cancel
</Button>
</DialogClose>
<Button disabled={saving} onClick={() => void save()} size="sm">
{saving ? <Loader2 className="size-3.5 animate-spin" /> : <Save />}
Save changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,209 @@
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/profile', async () => {
const { atom } = await import('nanostores')
return { $activeGatewayProfile: atom('default') }
})
vi.mock('@/store/notifications', () => ({
notify: vi.fn(),
notifyError: vi.fn()
}))
function honchoSchema(): MemoryProviderConfig {
return {
name: 'honcho',
label: 'Honcho',
docs_url: 'https://docs.honcho.dev/v3/guides/integrations/hermes',
fields: [
{
key: 'apiKey',
label: 'API key',
kind: 'secret',
value: '',
description: 'Authenticate with Honcho Cloud.',
placeholder: 'Enter Honcho API key',
is_set: false,
inline: true,
group: 'Connection',
options: []
},
{
key: 'baseUrl',
label: 'Base URL',
kind: 'text',
value: '',
description: 'Self-hosted Honcho URL.',
placeholder: 'https://… (self-hosted)',
is_set: false,
inline: true,
group: 'Connection',
options: []
},
{
key: 'environment',
label: 'Environment',
kind: 'select',
value: 'production',
description: 'Honcho environment.',
placeholder: '',
is_set: true,
inline: true,
group: 'Connection',
options: [
{ value: 'production', label: 'Production', description: '' },
{ value: 'demo', label: 'Demo', description: '' },
{ value: 'local', label: 'Local', description: '' }
]
},
{
key: 'workspace',
label: 'Workspace',
kind: 'text',
value: 'myws',
description: 'Honcho workspace ID.',
placeholder: 'hermes',
is_set: true,
inline: true,
group: 'Connection',
options: []
},
// Non-inline field: must NOT render in the compact panel and must NOT be
// submitted when the panel saves.
{
key: 'writeFrequency',
label: 'Write frequency',
kind: 'text',
value: 'async',
description: '',
placeholder: '',
is_set: true,
inline: false,
group: 'Message writing',
options: []
}
]
}
}
beforeEach(() => {
getMemoryProviderConfig.mockResolvedValue(honchoSchema())
saveMemoryProviderConfig.mockResolvedValue({ ok: true })
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
async function renderPanel(provider = 'honcho') {
const { ProviderConfigPanel } = await import('./provider-config-panel')
return render(<ProviderConfigPanel provider={provider} />)
}
describe('ProviderConfigPanel', () => {
it('renders the declared inline fields generically', async () => {
await renderPanel()
expect(await screen.findByDisplayValue('myws')).toBeTruthy()
expect(screen.getByPlaceholderText('https://… (self-hosted)')).toBeTruthy()
expect(screen.getByText('Production')).toBeTruthy()
expect(screen.getByText('Self-hosted Honcho URL.')).toBeTruthy()
})
it('hides fields that are not marked inline', async () => {
await renderPanel()
await screen.findByDisplayValue('myws')
expect(screen.queryByDisplayValue('async')).toBeNull()
expect(screen.queryByText('Write frequency')).toBeNull()
})
it('collapses and expands the fields', async () => {
await renderPanel()
expect(await screen.findByDisplayValue('myws')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /Honcho settings/ }))
expect(screen.queryByDisplayValue('myws')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /Honcho settings/ }))
expect(await screen.findByDisplayValue('myws')).toBeTruthy()
})
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.blur(baseUrl)
await waitFor(() =>
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 () => {
await renderPanel()
await screen.findByDisplayValue('myws')
expect(screen.getByRole('button', { name: /Full config/ })).toBeTruthy()
})
it('shows an inline error with retry when the load fails, then recovers', async () => {
getMemoryProviderConfig.mockRejectedValueOnce(new Error('Timed out connecting to Hermes backend'))
await renderPanel()
expect(await screen.findByText(/Timed out connecting/)).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(await screen.findByDisplayValue('myws')).toBeTruthy()
})
it('renders nothing for a provider with no declared config surface', async () => {
getMemoryProviderConfig.mockResolvedValue({ name: 'builtin', label: 'builtin', docs_url: '', fields: [] })
const { container } = await renderPanel('builtin')
await waitFor(() => expect(getMemoryProviderConfig).toHaveBeenCalledWith('builtin'))
expect(container.querySelector('section')).toBeNull()
})
})
@@ -0,0 +1,164 @@
import { useCallback, useEffect, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { getMemoryProviderConfig, saveMemoryProviderConfig } from '@/hermes'
import { SlidersHorizontal } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
import { ListRow, Pill } from '../primitives'
import { FieldControl, FieldTitle } from './field-control'
import { ProviderConfigModal } from './provider-config-modal'
// Inline fields only: the compact panel must never re-write modal-owned keys.
function seedValues(config: MemoryProviderConfig): Record<string, string> {
return Object.fromEntries(
config.fields.filter(field => field.inline).map(field => [field.key, field.kind === 'secret' ? '' : field.value])
)
}
export function ProviderConfigPanel({ profile, provider }: { profile?: string; 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 [showModal, setShowModal] = useState(false)
const refresh = useCallback(async () => {
try {
const next = await getMemoryProviderConfig(provider, profile)
const seed = seedValues(next)
setConfig(next)
setValues(seed)
setSaved(seed)
setLoadError(null)
} catch (err) {
setConfig(null)
setLoadError(err instanceof Error ? err.message : 'Memory provider settings failed to load')
}
}, [profile, provider])
useEffect(() => {
setConfig(null)
void refresh()
}, [refresh])
// 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
}
try {
await saveMemoryProviderConfig(provider, { [field.key]: value }, profile)
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}`)
}
},
[profile, provider, saved]
)
// Providers without a declared config surface (e.g. builtin) render nothing.
if (config && config.fields.length === 0) {
return null
}
if (!config) {
if (loadError) {
return (
<div className="flex items-center justify-between gap-3 py-2">
<span className="text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
Memory provider settings failed to load: {loadError}
</span>
<Button onClick={() => void refresh()} size="sm" type="button" variant="secondary">
Retry
</Button>
</div>
)
}
return <PageLoader className="min-h-24" label="Loading memory provider settings..." />
}
const inlineFields = config.fields.filter(field => field.inline)
const secretFields = config.fields.filter(field => field.kind === 'secret')
const hasFullConfig = config.fields.some(field => !field.inline)
return (
<section className="py-1">
<div className="flex items-center gap-2 py-2">
<button
aria-expanded={expanded}
className="flex min-w-0 flex-1 items-center gap-2 text-left"
onClick={() => setExpanded(open => !open)}
type="button"
>
<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>
))}
</button>
{hasFullConfig && (
<Button onClick={() => setShowModal(true)} size="sm" type="button" variant="secondary">
<SlidersHorizontal className="size-3.5" />
Full config…
</Button>
)}
</div>
{expanded && (
<div className="ml-1.5 border-l-2 border-(--ui-accent-secondary)/25 pb-4 pl-4 pr-4">
{inlineFields.map(field => (
<div className="border-b border-border/40 last:border-b-0" key={field.key}>
<ListRow
action={
<FieldControl
field={field}
onChange={value => setValues(current => ({ ...current, [field.key]: value }))}
onCommit={value => void commitField(field, value)}
value={values[field.key] ?? ''}
/>
}
description={field.description}
title={<FieldTitle field={field} />}
/>
</div>
))}
</div>
)}
{hasFullConfig && (
<ProviderConfigModal
config={config}
onOpenChange={setShowModal}
onSaved={refresh}
open={showModal}
profile={profile}
provider={provider}
/>
)}
</section>
)
}