diff --git a/canvas/src/components/MemoryInspectorPanel.tsx b/canvas/src/components/MemoryInspectorPanel.tsx index eac67c65..52f24991 100644 --- a/canvas/src/components/MemoryInspectorPanel.tsx +++ b/canvas/src/components/MemoryInspectorPanel.tsx @@ -6,26 +6,24 @@ import { ConfirmDialog } from "@/components/ConfirmDialog"; // ── Types ───────────────────────────────────────────────────────────────────── -interface MemoryEntry { - key: string; - value: unknown; - version: number; - /** Omitted by the API when there is no TTL (Go omitempty) */ - expires_at?: string; - updated_at: string; +/** Memory entry returned by GET /workspaces/:id/memories */ +export interface MemoryEntry { + id: string; + workspace_id: string; + content: string; + scope: "LOCAL" | "TEAM" | "GLOBAL"; + namespace: string; + created_at: string; /** * Semantic similarity score (0–1). Only present when the API is queried - * with ?q= and the pgvector backend has been deployed (issue #776). + * with ?q= and the pgvector backend has been deployed. * Absent on plain list fetches — renders gracefully without a badge. */ similarity_score?: number; } -interface WriteResult { - status: string; - key: string; - version: number; -} +type Scope = "LOCAL" | "TEAM" | "GLOBAL"; +const SCOPES: Scope[] = ["LOCAL", "TEAM", "GLOBAL"]; interface Props { workspaceId: string; @@ -34,16 +32,10 @@ interface Props { // ── Helpers ─────────────────────────────────────────────────────────────────── /** - * Sanitise a memory key for use in an HTML id attribute. - * HTML IDs must not contain whitespace; many non-alphanumeric characters also - * cause selector or ARIA failures. Replace every non-alphanumeric character - * with a hyphen, collapse consecutive hyphens, then strip leading/trailing ones. + * Sanitise a memory id for use in an HTML id attribute. */ -function sanitizeId(key: string): string { - return key - .replace(/[^a-zA-Z0-9]/g, "-") - .replace(/-+/g, "-") - .replace(/^-|-$/g, ""); +function sanitizeId(id: string): string { + return id.replace(/[^a-zA-Z0-9]/g, "-"); } function formatRelativeTime(iso: string): string { @@ -54,7 +46,7 @@ function formatRelativeTime(iso: string): string { return new Date(iso).toLocaleDateString(); } -// ── Skeleton rows — shown during re-fetches when entries already exist ──────── +// ── Skeleton rows ────────────────────────────────────────────────────────────── function MemorySkeletonRows() { return ( @@ -79,20 +71,16 @@ function MemorySkeletonRows() { // ── Component ───────────────────────────────────────────────────────────────── export function MemoryInspectorPanel({ workspaceId }: Props) { + const [activeScope, setActiveScope] = useState("LOCAL"); + const [activeNamespace, setActiveNamespace] = useState(""); const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - // ── Search state ──────────────────────────────────────────────────────────── - /** Raw input value — updated on every keystroke. */ + // ── Search state (debounced) ──────────────────────────────────────────────── const [searchQuery, setSearchQuery] = useState(""); - /** - * Debounced value — drives the API fetch. - * Lags searchQuery by 300 ms to avoid hammering the endpoint on every key. - */ const [debouncedQuery, setDebouncedQuery] = useState(""); - // 300 ms debounce: cancel previous timer whenever searchQuery changes. useEffect(() => { const timer = setTimeout( () => setDebouncedQuery(searchQuery.trim()), @@ -101,14 +89,8 @@ export function MemoryInspectorPanel({ workspaceId }: Props) { return () => clearTimeout(timer); }, [searchQuery]); - // ── Expand/edit/delete state (keyed by entry.key — primitives, no new objects) - - const [expandedKey, setExpandedKey] = useState(null); - const [editingKey, setEditingKey] = useState(null); - const [editValue, setEditValue] = useState(""); - const [editError, setEditError] = useState(null); - const [saving, setSaving] = useState(false); - const [pendingDeleteKey, setPendingDeleteKey] = useState(null); + // ── Delete state ───────────────────────────────────────────────────────────── + const [pendingDeleteId, setPendingDeleteId] = useState(null); // ── Data loading ──────────────────────────────────────────────────────────── @@ -116,12 +98,15 @@ export function MemoryInspectorPanel({ workspaceId }: Props) { setLoading(true); setError(null); try { - const url = debouncedQuery - ? `/workspaces/${workspaceId}/memory?q=${encodeURIComponent(debouncedQuery)}` - : `/workspaces/${workspaceId}/memory`; + const params = new URLSearchParams(); + params.set("scope", activeScope); + if (debouncedQuery) params.set("q", debouncedQuery); + if (activeNamespace) params.set("namespace", activeNamespace); + + const url = `/workspaces/${workspaceId}/memories?${params.toString()}`; const data = await api.get(url); + // When a semantic query is active, sort by similarity_score descending. - // Entries without a score (older backend) fall to the end gracefully. const sorted = debouncedQuery ? [...data].sort( (a, b) => (b.similarity_score ?? 0) - (a.similarity_score ?? 0) @@ -129,123 +114,70 @@ export function MemoryInspectorPanel({ workspaceId }: Props) { : data; setEntries(sorted); } catch (e) { - setError(e instanceof Error ? e.message : "Failed to load memory entries"); + setError(e instanceof Error ? e.message : "Failed to load memories"); setEntries([]); } finally { setLoading(false); } - }, [workspaceId, debouncedQuery]); + }, [workspaceId, activeScope, debouncedQuery, activeNamespace]); useEffect(() => { loadEntries(); }, [loadEntries]); - // ── Edit handlers ─────────────────────────────────────────────────────────── - - const startEdit = useCallback((entry: MemoryEntry) => { - setEditingKey(entry.key); - setEditValue(JSON.stringify(entry.value, null, 2)); - setEditError(null); - }, []); - - const cancelEdit = useCallback(() => { - setEditingKey(null); - setEditValue(""); - setEditError(null); - }, []); - - const saveEdit = useCallback( - async (entry: MemoryEntry) => { - let parsed: unknown; - try { - parsed = JSON.parse(editValue); - } catch { - setEditError("Invalid JSON — fix the syntax before saving"); - return; - } - - setSaving(true); - setEditError(null); - - // Optimistic update — capture rollback snapshot before mutating - const snapshot = entries; - setEntries((prev) => - prev.map((e) => - e.key === entry.key - ? { - ...e, - value: parsed, - version: e.version + 1, - updated_at: new Date().toISOString(), - } - : e - ) - ); - setEditingKey(null); - setEditValue(""); - - try { - await api.post(`/workspaces/${workspaceId}/memory`, { - key: entry.key, - value: parsed, - if_match_version: entry.version, - }); - } catch (e) { - // Roll back optimistic update on any error - setEntries(snapshot); - setEditingKey(entry.key); - setEditValue(JSON.stringify(entry.value, null, 2)); - const msg = e instanceof Error ? e.message : "Save failed"; - if (msg.includes("409") || msg.toLowerCase().includes("mismatch")) { - setEditError( - "Version conflict — entry changed elsewhere. Reload to see latest." - ); - } else { - setEditError(msg); - } - } finally { - setSaving(false); - } - }, - [entries, editValue, workspaceId] - ); - // ── Delete handlers ───────────────────────────────────────────────────────── const confirmDelete = useCallback(async () => { - if (!pendingDeleteKey) return; - const key = pendingDeleteKey; - setPendingDeleteKey(null); + if (!pendingDeleteId) return; + const id = pendingDeleteId; + setPendingDeleteId(null); // Optimistic removal - setEntries((prev) => prev.filter((e) => e.key !== key)); - if (expandedKey === key) setExpandedKey(null); + setEntries((prev) => prev.filter((e) => e.id !== id)); try { - await api.del( - `/workspaces/${workspaceId}/memory/${encodeURIComponent(key)}` - ); + await api.del(`/workspaces/${workspaceId}/memories/${encodeURIComponent(id)}`); } catch (e) { setError(e instanceof Error ? e.message : "Delete failed — reloading..."); await loadEntries(); } - }, [pendingDeleteKey, expandedKey, workspaceId, loadEntries]); + }, [pendingDeleteId, workspaceId, loadEntries]); // ── Render ────────────────────────────────────────────────────────────────── - // Full-screen loader — only on the very first fetch (no entries cached yet). if (loading && entries.length === 0 && !error) { return (
- Loading memory… + Loading memories…
); } return (
- {/* Search bar */} + {/* Scope tabs */}
+
+ {SCOPES.map((scope) => ( + + ))} +
+
+ + {/* Search bar + namespace filter */} +
{/* Magnifying glass icon */} setSearchQuery(e.target.value)} placeholder="Semantic search…" - aria-label="Search memory entries" + aria-label="Search memories" className="w-full bg-zinc-900 border border-zinc-700/60 focus:border-blue-500/60 rounded-lg pl-8 pr-7 py-1.5 text-[11px] text-zinc-200 placeholder-zinc-600 focus:outline-none transition-colors" /> - {/* Clear button — only shown when there is a query */} {searchQuery && ( )}
+ + {/* Namespace filter */} +
+ + setActiveNamespace(e.target.value)} + placeholder="all namespaces" + aria-label="Filter by namespace" + className="flex-1 bg-zinc-900 border border-zinc-700/60 focus:border-blue-500/60 rounded px-2 py-1 text-[11px] text-zinc-200 placeholder-zinc-600 focus:outline-none transition-colors min-w-0" + /> +
{/* Toolbar */} @@ -290,13 +236,13 @@ export function MemoryInspectorPanel({ workspaceId }: Props) { {debouncedQuery ? `${entries.length} result${entries.length !== 1 ? "s" : ""}` : entries.length === 1 - ? "1 entry" - : `${entries.length} entries`} + ? "1 memory" + : `${entries.length} memories`} @@ -316,11 +262,9 @@ export function MemoryInspectorPanel({ workspaceId }: Props) { {/* Content */}
{loading ? ( - /* Skeleton rows — visible during search-transition re-fetches */ ) : entries.length === 0 ? ( debouncedQuery ? ( - /* Search-specific empty state */

@@ -341,56 +285,40 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {

) : ( - /* Default empty state */
-

No memory entries yet

+

No {activeScope} memories

- Memory entries will appear here when the workspace writes to its KV - store. + {activeScope === "LOCAL" + ? "This workspace has not written any local memories yet." + : activeScope === "TEAM" + ? "No team memories shared with this workspace yet." + : "No global memories exist yet."}

) ) : (
- {entries.map((entry) => { - const isExpanded = expandedKey === entry.key; - const isEditing = editingKey === entry.key; - return ( - { - const next = isExpanded ? null : entry.key; - setExpandedKey(next); - if (!next && isEditing) cancelEdit(); - }} - onEditValueChange={setEditValue} - onStartEdit={() => startEdit(entry)} - onSave={() => saveEdit(entry)} - onCancelEdit={cancelEdit} - onDelete={() => setPendingDeleteKey(entry.key)} - /> - ); - })} + {entries.map((entry) => ( + setPendingDeleteId(entry.id)} + /> + ))}
)}
{/* Delete confirmation dialog */} setPendingDeleteKey(null)} + onCancel={() => setPendingDeleteId(null)} />
); @@ -400,155 +328,97 @@ export function MemoryInspectorPanel({ workspaceId }: Props) { interface MemoryEntryRowProps { entry: MemoryEntry; - isExpanded: boolean; - isEditing: boolean; - editValue: string; - editError: string | null; - saving: boolean; - onToggle: () => void; - onEditValueChange: (v: string) => void; - onStartEdit: () => void; - onSave: () => void; - onCancelEdit: () => void; onDelete: () => void; } -function MemoryEntryRow({ - entry, - isExpanded, - isEditing, - editValue, - editError, - saving, - onToggle, - onEditValueChange, - onStartEdit, - onSave, - onCancelEdit, - onDelete, -}: MemoryEntryRowProps) { - const bodyId = `mem-body-${sanitizeId(entry.key)}`; +function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) { + const [expanded, setExpanded] = useState(false); + const bodyId = `mem-body-${sanitizeId(entry.id)}`; + return (
- {/* Header row — click to expand/collapse */} + {/* Header row */} {/* Expanded body */} - {isExpanded && ( + {expanded && (
- {entry.expires_at && ( -

- Expires: {new Date(entry.expires_at).toLocaleString()} -

- )} - - {isEditing ? ( - /* Edit mode */ -
-