From f0f4d0e76169ce282acd18e3f45dd2339b9c2348 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Tue, 5 May 2026 15:53:28 -0700 Subject: [PATCH] feat(memory): redesign Memory tab for v2 plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the v1 LOCAL/TEAM/GLOBAL tab trio (mapped to the deprecated shared_context model) with a v2 plugin-driven UI. Without this, canvas Memory tab was reading the frozen agent_memories table while all post-cutover agent writes went to the plugin's memory_records — the tab silently displayed stale data. ## Backend (workspace-server) New routes under wsAuth, all behind the existing per-tenant token: GET /workspaces/:id/v2/namespaces → readable + writable lists GET /workspaces/:id/v2/memories → plugin search proxy DELETE /workspaces/:id/v2/memories/:mid → plugin forget proxy memories_v2.go — slim handler: - Server-side ACL: every search request is intersected with the resolver's readable-namespaces set (canvas-supplied namespace that the workspace can't read returns [] not 403, matches v1 existence-non-inferring shape). - Returns 503 with "set MEMORY_PLUGIN_URL" hint when plugin isn't wired (canvas surfaces a banner). - Maps plugin not_found → 404, other plugin errors → 502. - View shaping: NamespaceView.label rendered server-side ("Workspace (abc-1234)", "Team (t-99)", "Org (acme)", custom) so canvas doesn't parse namespace names. MemoryView surfaces pin/expires_at/score/source_workspace_id from Propagation. memories_v2_test.go — 100% line + 100% function coverage: - 503 path on every endpoint when unwired - Namespaces success + readable/writable error paths - Search: empty intersection, full-path query/kind/limit propagation, namespace=/no-namespace branches, propagation map missing/wrong-type, intersect error, plugin error - Forget: success, plugin not_found→404, other plugin errors→502, missing memoryId→400 - Helpers: namespaceLabel for all 4 kinds + truncation, parseLimit edge cases (default/0/negative/over-cap/non-num), memoryToView field round-trip, indexOfColon, shortID ## Frontend (canvas) MemoryInspectorPanel rewritten for v2: - Drop LOCAL/TEAM/GLOBAL trio. Namespace dropdown driven by GET /v2/namespaces.readable, "All namespaces" default. - New per-row badges: kind (F/S/C), source (agent/runtime/user), pin (📌), TTL countdown (⌛12h / "expired"), score% on semantic search, source-workspace ⇡ws-pee for propagated. - Drop Edit button — v2 plugin contract has no PATCH; the model is forget + recommit. Forget stays. - Plugin-unavailable banner with operator hint when /v2/* returns 503. - Bug fix surfaced by test: rollback-on-failed-delete order of operations (loadEntries() called setError(null) AFTER we set the failure message, wiping it). Reload first, then set the error. MemoryEditorDialog deleted — Add was POST /memories which v2 doesn't support from canvas (writes go via MCP). The legacy Edit-flow tests go with it. ## Test results Backend: `go test ./internal/handlers/` — all pass Backend coverage on memories_v2.go: 100% lines, 100% functions Canvas: `vitest run` — 91 files, 1273 tests pass (26 new) Canvas coverage on MemoryInspectorPanel.tsx: 100% lines, 100% functions, 96.7% statements, 84.7% branches (uncovered branches are defensive `?? fallback` for contract-impossible kind/source values) ## Migration note The legacy v1 GET/POST/PATCH/DELETE on /workspaces/:id/memories remains in place for the back-compat MCP shim (mcp_tools_memory_v2's legacy routing) and admin export/import. PR-9 (#283) drops agent_memories along with the v1 endpoints once the cutover verification window closes. --- canvas/src/components/MemoryEditorDialog.tsx | 261 ------- .../src/components/MemoryInspectorPanel.tsx | 602 +++++++++------ .../__tests__/MemoryEditorDialog.test.tsx | 202 ----- .../__tests__/MemoryInspectorPanel.test.tsx | 706 +++++++++--------- .../tabs/__tests__/MemoryTab.edit.test.tsx | 220 ------ .../internal/handlers/memories_v2.go | 416 +++++++++++ .../internal/handlers/memories_v2_test.go | 669 +++++++++++++++++ workspace-server/internal/router/router.go | 14 + 8 files changed, 1838 insertions(+), 1252 deletions(-) delete mode 100644 canvas/src/components/MemoryEditorDialog.tsx delete mode 100644 canvas/src/components/__tests__/MemoryEditorDialog.test.tsx delete mode 100644 canvas/src/components/tabs/__tests__/MemoryTab.edit.test.tsx create mode 100644 workspace-server/internal/handlers/memories_v2.go create mode 100644 workspace-server/internal/handlers/memories_v2_test.go diff --git a/canvas/src/components/MemoryEditorDialog.tsx b/canvas/src/components/MemoryEditorDialog.tsx deleted file mode 100644 index 6625412a..00000000 --- a/canvas/src/components/MemoryEditorDialog.tsx +++ /dev/null @@ -1,261 +0,0 @@ -'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 */} -
- -