diff --git a/canvas/src/components/MemoryEditorDialog.tsx b/canvas/src/components/MemoryEditorDialog.tsx new file mode 100644 index 00000000..6625412a --- /dev/null +++ b/canvas/src/components/MemoryEditorDialog.tsx @@ -0,0 +1,261 @@ +'use client'; + +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { api } from "@/lib/api"; +import type { MemoryEntry } from "@/components/MemoryInspectorPanel"; + +type Scope = "LOCAL" | "TEAM" | "GLOBAL"; +const SCOPES: Scope[] = ["LOCAL", "TEAM", "GLOBAL"]; + +interface AddProps { + open: boolean; + mode: "add"; + workspaceId: string; + defaultScope: Scope; + defaultNamespace?: string; + entry?: undefined; + onClose: () => void; + onSaved: () => void; +} + +interface EditProps { + open: boolean; + mode: "edit"; + workspaceId: string; + entry: MemoryEntry; + defaultScope?: undefined; + defaultNamespace?: undefined; + onClose: () => void; + onSaved: () => void; +} + +type Props = AddProps | EditProps; + +export function MemoryEditorDialog(props: Props) { + const { open, mode, workspaceId, onClose, onSaved } = props; + const dialogRef = useRef(null); + const [mounted, setMounted] = useState(false); + const [scope, setScope] = useState("LOCAL"); + const [namespace, setNamespace] = useState("general"); + const [content, setContent] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + setMounted(true); + }, []); + + // Reset form whenever the dialog opens. + useEffect(() => { + if (!open) return; + setError(null); + setSaving(false); + if (mode === "edit" && props.entry) { + setScope(props.entry.scope); + setNamespace(props.entry.namespace || "general"); + setContent(props.entry.content); + } else if (mode === "add") { + setScope(props.defaultScope); + setNamespace(props.defaultNamespace || "general"); + setContent(""); + } + // mode/props are stable per-open; intentional shallow deps. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + // Move focus into the dialog when it opens (WCAG SC 2.4.3). + useEffect(() => { + if (!open || !mounted) return; + const raf = requestAnimationFrame(() => { + dialogRef.current?.querySelector("textarea, input, select")?.focus(); + }); + return () => cancelAnimationFrame(raf); + }, [open, mounted]); + + // Escape closes; Cmd/Ctrl-Enter saves. + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + const handleSaveRef = useRef<() => void>(() => {}); + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onCloseRef.current(); + } else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + handleSaveRef.current(); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [open]); + + const handleSave = async () => { + if (saving) return; + const trimmed = content.trim(); + if (!trimmed) { + setError("Content cannot be empty"); + return; + } + setError(null); + setSaving(true); + try { + if (mode === "add") { + await api.post(`/workspaces/${workspaceId}/memories`, { + content: trimmed, + scope, + namespace: namespace.trim() || "general", + }); + } else { + // PATCH only sends fields that changed. Content always changeable; + // namespace only sent if it differs from the original (saves a + // no-op write through redactSecrets + re-embed). + const original = props.entry; + const body: Record = {}; + if (trimmed !== original.content) body.content = trimmed; + const ns = namespace.trim() || "general"; + if (ns !== original.namespace) body.namespace = ns; + if (Object.keys(body).length === 0) { + // No-op edit — close without an HTTP round-trip. + onSaved(); + onClose(); + return; + } + await api.patch( + `/workspaces/${workspaceId}/memories/${encodeURIComponent(original.id)}`, + body, + ); + } + onSaved(); + onClose(); + } catch (e) { + setError(e instanceof Error ? e.message : "Save failed"); + } finally { + setSaving(false); + } + }; + handleSaveRef.current = handleSave; + + if (!open || !mounted) return null; + + const titleId = "memory-editor-title"; + const isEdit = mode === "edit"; + + return createPortal( +
+
+ +
+
+

+ {isEdit ? "Edit memory" : "Add memory"} +

+ + {/* Scope */} +
+ + {isEdit ? ( +
+ {scope} +
+ ) : ( +
+ {SCOPES.map((s) => ( + + ))} +
+ )} +
+ + {/* Namespace */} +
+ + setNamespace(e.target.value)} + placeholder="general" + className="w-full bg-surface border border-line/60 focus:border-accent/60 rounded px-2 py-1.5 text-[12px] text-ink placeholder-zinc-600 focus:outline-none transition-colors" + /> +
+ + {/* Content */} +
+ +