From 0bdee434b3070978f501f94b8c6039918c41b652 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 20 Apr 2026 07:31:52 -0500 Subject: [PATCH] Polish memory provider config form Co-Authored-By: Paperclip --- .../MemoryProviderConfigForm.test.tsx | 140 ++++++++- .../components/MemoryProviderConfigForm.tsx | 280 +++++++++++------- 2 files changed, 315 insertions(+), 105 deletions(-) diff --git a/ui/src/components/MemoryProviderConfigForm.test.tsx b/ui/src/components/MemoryProviderConfigForm.test.tsx index cc9d911e745..998f1ffad76 100644 --- a/ui/src/components/MemoryProviderConfigForm.test.tsx +++ b/ui/src/components/MemoryProviderConfigForm.test.tsx @@ -35,6 +35,7 @@ const provider: MemoryProviderDescriptor = { label: "Search mode", description: "How QMD should retrieve matching memory files.", input: "select", + suggestedValue: "query", options: [ { value: "query", label: "Query" }, { value: "search", label: "Search" }, @@ -45,6 +46,7 @@ const provider: MemoryProviderDescriptor = { label: "Hydration snippets", description: "Maximum snippets to include in a run preamble.", input: "number", + suggestedValue: 5, min: 1, max: 25, }, @@ -53,11 +55,13 @@ const provider: MemoryProviderDescriptor = { label: "API token", input: "secret", required: true, + suggestedValue: "", }, { key: "autoIndexOnWrite", label: "Auto-index on write", input: "boolean", + suggestedValue: true, }, ], pathSuggestions: [ @@ -79,6 +83,17 @@ const provider: MemoryProviderDescriptor = { }, }; +/** Lets React detect a DOM value change on controlled textareas (see React #10140). */ +function setNativeTextareaValue(textarea: HTMLTextAreaElement, value: string) { + const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set; + const previous = textarea.value; + valueSetter?.call(textarea, value); + const tracker = (textarea as HTMLTextAreaElement & { _valueTracker?: { setValue: (v: string) => void } }) + ._valueTracker; + tracker?.setValue(previous); + textarea.dispatchEvent(new Event("input", { bubbles: true })); +} + describe("MemoryProviderConfigForm", () => { let container: HTMLDivElement; @@ -119,9 +134,27 @@ describe("MemoryProviderConfigForm", () => { expect(container.textContent).toContain("Binding directory"); expect(container.textContent).toContain("/tmp/paperclip/qmd/default"); expect(container.textContent).toContain("QMD binary"); - expect(container.textContent).toContain("warning"); + expect(container.textContent).toContain("idle"); + expect(container.textContent).toContain("Common paths"); + expect(container.querySelector("select")).toBeNull(); expect(container.querySelector('input[type="password"]')).not.toBeNull(); + const statusIndex = container.textContent?.indexOf("Status") ?? -1; + const fieldIndex = container.textContent?.indexOf("Search mode") ?? -1; + expect(statusIndex).toBeGreaterThanOrEqual(0); + expect(fieldIndex).toBeGreaterThan(statusIndex); + + const showSecretButton = Array.from(container.querySelectorAll("button")).find((button) => + button.getAttribute("aria-label") === "Show API token" + ); + expect(showSecretButton).toBeTruthy(); + + await act(async () => { + showSecretButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.querySelector('input[type="text"]')?.getAttribute("id")).toContain("apiToken"); + const advancedButton = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.includes("Advanced JSON") ); @@ -164,10 +197,115 @@ describe("MemoryProviderConfigForm", () => { expect(container.textContent).toContain("Hydration snippets must be at most 25."); expect(container.textContent).toContain("API token is required."); expect(container.textContent).toContain("Auto-index on write must be on or off."); + expect(container.querySelectorAll('[role="alert"]').length).toBeGreaterThanOrEqual(4); expect(onValidationChange).toHaveBeenLastCalledWith(false); await act(async () => { root.unmount(); }); }); + + it("resets diverged fields to suggested values from one global action", async () => { + const onChange = vi.fn(); + const root = createRoot(container); + + await act(async () => { + root.render( + , + ); + await Promise.resolve(); + }); + + const resetButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Reset to suggested") + ); + expect(resetButton).toBeTruthy(); + expect(container.textContent).not.toContain("Use suggested"); + + await act(async () => { + resetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onChange).toHaveBeenLastCalledWith({ + searchMode: "query", + topK: 5, + apiToken: "", + autoIndexOnWrite: true, + }); + + await act(async () => { + root.unmount(); + }); + }); + + it("keeps advanced JSON edits local until blur", async () => { + const onChange = vi.fn(); + const onValidationChange = vi.fn(); + const root = createRoot(container); + + await act(async () => { + root.render( + , + ); + await Promise.resolve(); + }); + + const advancedButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Advanced JSON") + ); + + await act(async () => { + advancedButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + const textarea = container.querySelector("textarea"); + expect(textarea).not.toBeNull(); + + await act(async () => { + setNativeTextareaValue(textarea!, '{"topK":'); + }); + + expect(onChange).not.toHaveBeenCalled(); + expect(container.textContent).not.toContain("Unexpected end"); + + await act(async () => { + textarea?.focus(); + textarea?.blur(); + }); + + expect(onChange).not.toHaveBeenCalled(); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("Unexpected end"); + expect(onValidationChange).toHaveBeenLastCalledWith(false); + + await act(async () => { + setNativeTextareaValue(textarea!, '{"topK": 7}'); + textarea?.focus(); + textarea?.blur(); + }); + + expect(onChange).toHaveBeenLastCalledWith({ topK: 7 }); + + await act(async () => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/MemoryProviderConfigForm.tsx b/ui/src/components/MemoryProviderConfigForm.tsx index cbbdc905d97..0686ab56ed0 100644 --- a/ui/src/components/MemoryProviderConfigForm.tsx +++ b/ui/src/components/MemoryProviderConfigForm.tsx @@ -1,14 +1,19 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useId, useMemo, useState } from "react"; import type { MemoryProviderConfigFieldMetadata, MemoryProviderDescriptor } from "@paperclipai/shared"; -import { ChevronDown, Database, FileSearch } from "lucide-react"; +import { ChevronDown, Database, Eye, EyeOff, FileSearch, RotateCcw } from "lucide-react"; import { getMemoryConfigFields, parseMemoryConfigJson, prettyMemoryConfig, validateMemoryProviderConfig, } from "../lib/memory-config-schema"; +import { StatusBadge } from "./StatusBadge"; import { Button } from "@/components/ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; import { Textarea } from "@/components/ui/textarea"; import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { cn } from "../lib/utils"; @@ -29,6 +34,16 @@ function coerceSelectValue(field: MemoryProviderConfigFieldMetadata, value: stri return option ? option.value : value; } +function healthStatus(status: string) { + if (status === "ok") return "active"; + if (status === "error") return "failed"; + return "idle"; +} + +function fieldDomId(prefix: string, key: string, suffix?: string) { + return `${prefix}-${key.replace(/[^a-zA-Z0-9_-]/g, "-")}${suffix ? `-${suffix}` : ""}`; +} + function fieldGridClass(fields: MemoryProviderConfigFieldMetadata[]) { return fields.some((field) => field.input === "boolean") ? "grid gap-3 lg:grid-cols-2" @@ -48,9 +63,16 @@ export function MemoryProviderConfigForm({ }) { const fields = useMemo(() => getMemoryConfigFields(provider), [provider]); const validation = useMemo(() => validateMemoryProviderConfig(provider, value), [provider, value]); + const fieldIdPrefix = useId(); const [advancedOpen, setAdvancedOpen] = useState(false); const [jsonText, setJsonText] = useState(() => prettyMemoryConfig(value)); const [jsonError, setJsonError] = useState(null); + const [visibleSecrets, setVisibleSecrets] = useState>({}); + + const hasSuggestedDivergence = useMemo( + () => fields.some((field) => field.suggestedValue !== undefined && !Object.is(value[field.key], field.suggestedValue)), + [fields, value], + ); useEffect(() => { setJsonText(prettyMemoryConfig(value)); @@ -65,35 +87,52 @@ export function MemoryProviderConfigForm({ onChange({ ...value, [key]: nextValue }); } + function resetSuggestedFields() { + const nextValue = { ...value }; + for (const field of fields) { + if (field.suggestedValue !== undefined) { + nextValue[field.key] = field.suggestedValue; + } + } + onChange(nextValue); + } + + function applyJsonText() { + try { + onChange(parseMemoryConfigJson(jsonText)); + setJsonError(null); + } catch (error) { + setJsonError(error instanceof Error ? error.message : "Config must be valid JSON"); + } + } + function renderField(field: MemoryProviderConfigFieldMetadata) { const error = validation.fieldErrors[field.key]; + const fieldId = fieldDomId(fieldIdPrefix, field.key); + const descriptionId = field.description ? fieldDomId(fieldIdPrefix, field.key, "description") : undefined; + const errorId = error ? fieldDomId(fieldIdPrefix, field.key, "error") : undefined; + const describedBy = [descriptionId, errorId].filter(Boolean).join(" ") || undefined; const common = ( <> -
- - {field.suggestedValue !== undefined && value[field.key] !== field.suggestedValue ? ( - - ) : null} -
- {field.description ?

{field.description}

: null} - {error ?

{error}

: null} + + {field.description ? ( +

{field.description}

+ ) : null} + {error ? ( + + ) : null} ); if (field.input === "boolean") { return ( -
+
{common}
updateField(field.key, checked)} /> @@ -106,45 +145,104 @@ export function MemoryProviderConfigForm({ return (
{common} - + + + + + {(field.options ?? []).map((option) => ( + + {option.label} + + ))} + +
); } + const isSecret = field.input === "secret"; + const secretVisible = Boolean(visibleSecrets[field.key]); + return (
{common} - { - const raw = event.target.value; - if (field.input === "number") { - updateField(field.key, coerceNumber(raw)); - } else { - updateField(field.key, raw.trim() === "" && field.defaultValue === null ? null : raw); - } - }} - /> +
+ { + const raw = event.target.value; + if (field.input === "number") { + updateField(field.key, coerceNumber(raw)); + } else { + updateField(field.key, raw.trim() === "" && field.defaultValue === null ? null : raw); + } + }} + /> + {isSecret ? ( + + ) : null} +
); } return (
+ {provider?.configMetadata?.healthChecks?.length ? ( +
+
+ + Status +
+
+ {provider.configMetadata.healthChecks.map((check) => ( +
+
+ {check.label} + +
+ {check.message ?
{check.message}
: null} +
+ ))} +
+
+ ) : null} + + {hasSuggestedDivergence ? ( +
+ +
+ ) : null} + {fields.length > 0 ? (
{fields.map((field) => renderField(field))}
) : ( @@ -154,74 +252,48 @@ export function MemoryProviderConfigForm({ )} {provider?.configMetadata?.pathSuggestions?.length ? ( -
- {provider.configMetadata.pathSuggestions.map((suggestion) => ( -
-
- - {suggestion.label} +
+ +
+ + Common paths +
+
+ {provider.configMetadata.pathSuggestions.map((suggestion) => ( +
+
{suggestion.label}
+
{suggestion.path}
+ {suggestion.description ?
{suggestion.description}
: null}
-
{suggestion.path}
- {suggestion.description ?
{suggestion.description}
: null} -
- ))} -
+ ))} +
+ ) : null} - {provider?.configMetadata?.healthChecks?.length ? ( -
- {provider.configMetadata.healthChecks.map((check) => ( -
-
- - {check.label} - - {check.status} - -
- {check.message ?
{check.message}
: null} -
- ))} -
- ) : null} - -
- - {advancedOpen ? ( -
-