From c50ac61f89a8c8a2021497a8fa12aba373593dbb Mon Sep 17 00:00:00 2001 From: Molecule AI Frontend Engineer Date: Fri, 17 Apr 2026 15:23:22 +0000 Subject: [PATCH 01/36] feat(canvas): add MemoryInspectorPanel for workspace KV memory (issue #730) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds MemoryInspectorPanel.tsx — a focused inspector for per-workspace platform memory entries. Replaces MemoryTab in the SidePanel "memory" tab. - GET /workspaces/:id/memory loads entries (flat MemoryEntry[] — confirmed with Backend Engineer: fields are key/value/version/expires_at/updated_at, no scope, write verb is POST not PATCH) - Empty state: "No memory entries yet" with ◇ icon - Click entry → expand → show JSON value, version badge, relative timestamp - Edit flow: textarea pre-filled with JSON.stringify(value), Save calls POST with if_match_version for optimistic concurrency, optimistic update with rollback on 409/error, invalid-JSON guard - Delete flow: button → ConfirmDialog → optimistic removal → DELETE call - Refresh button re-fetches entries - 665 tests pass (43 files), next build clean, 'use client' check passes Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/MemoryInspectorPanel.tsx | 383 +++++++++++++++++ canvas/src/components/SidePanel.tsx | 4 +- .../__tests__/MemoryInspectorPanel.test.tsx | 402 ++++++++++++++++++ .../__tests__/SidePanel.tabs.test.tsx | 2 +- 4 files changed, 788 insertions(+), 3 deletions(-) create mode 100644 canvas/src/components/MemoryInspectorPanel.tsx create mode 100644 canvas/src/components/__tests__/MemoryInspectorPanel.test.tsx diff --git a/canvas/src/components/MemoryInspectorPanel.tsx b/canvas/src/components/MemoryInspectorPanel.tsx new file mode 100644 index 00000000..0c8f99fc --- /dev/null +++ b/canvas/src/components/MemoryInspectorPanel.tsx @@ -0,0 +1,383 @@ +'use client'; + +import { useState, useEffect, useCallback } from "react"; +import { api } from "@/lib/api"; +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; +} + +interface WriteResult { + status: string; + key: string; + version: number; +} + +interface Props { + workspaceId: string; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function formatRelativeTime(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + if (diff < 60_000) return `${Math.floor(diff / 1000)}s`; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m`; + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h`; + return new Date(iso).toLocaleDateString(); +} + +// ── Component ───────────────────────────────────────────────────────────────── + +export function MemoryInspectorPanel({ workspaceId }: Props) { + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Expand/edit/delete state — keyed by entry.key (string primitive, 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); + + // ── Data loading ──────────────────────────────────────────────────────────── + + const loadEntries = useCallback(async () => { + setLoading(true); + setError(null); + try { + // API returns MemoryEntry[] (flat array, never wrapped, never null) + const data = await api.get(`/workspaces/${workspaceId}/memory`); + setEntries(data); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load memory entries"); + setEntries([]); + } finally { + setLoading(false); + } + }, [workspaceId]); + + 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) => { + // Validate JSON before touching network + 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); + + // Optimistic removal + setEntries((prev) => prev.filter((e) => e.key !== key)); + if (expandedKey === key) setExpandedKey(null); + + try { + await api.del(`/workspaces/${workspaceId}/memory/${encodeURIComponent(key)}`); + } catch (e) { + // On failure, reload to restore the true state + setError(e instanceof Error ? e.message : "Delete failed — reloading..."); + await loadEntries(); + } + }, [pendingDeleteKey, expandedKey, workspaceId, loadEntries]); + + // ── Render ────────────────────────────────────────────────────────────────── + + if (loading) { + return ( +
+ Loading memory… +
+ ); + } + + return ( +
+ {/* Toolbar */} +
+ + {entries.length === 1 ? "1 entry" : `${entries.length} entries`} + + +
+ + {/* Error banner */} + {error && ( +
+ {error} +
+ )} + + {/* Content */} +
+ {entries.length === 0 ? ( + /* Empty state */ +
+ +

No memory entries yet

+

+ Memory entries will appear here when the workspace writes to its KV store. +

+
+ ) : ( +
+ {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)} + /> + ); + })} +
+ )} +
+ + {/* Delete confirmation dialog */} + setPendingDeleteKey(null)} + /> +
+ ); +} + +// ── MemoryEntryRow sub-component ────────────────────────────────────────────── + +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) { + return ( +
+ {/* Header row — click to expand/collapse */} + + + {/* Expanded body */} + {isExpanded && ( +
+ {entry.expires_at && ( +

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

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