diff --git a/canvas/src/components/MemoryInspectorPanel.tsx b/canvas/src/components/MemoryInspectorPanel.tsx index 0c8f99fc..ed54d8b5 100644 --- a/canvas/src/components/MemoryInspectorPanel.tsx +++ b/canvas/src/components/MemoryInspectorPanel.tsx @@ -13,6 +13,12 @@ interface MemoryEntry { /** Omitted by the API when there is no TTL (Go omitempty) */ expires_at?: string; updated_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). + * Absent on plain list fetches — renders gracefully without a badge. + */ + similarity_score?: number; } interface WriteResult { @@ -35,6 +41,28 @@ function formatRelativeTime(iso: string): string { return new Date(iso).toLocaleDateString(); } +// ── Skeleton rows — shown during re-fetches when entries already exist ──────── + +function MemorySkeletonRows() { + return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+
+
+
+
+
+ ))} +
+ ); +} + // ── Component ───────────────────────────────────────────────────────────────── export function MemoryInspectorPanel({ workspaceId }: Props) { @@ -42,7 +70,26 @@ export function MemoryInspectorPanel({ workspaceId }: Props) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - // Expand/edit/delete state — keyed by entry.key (string primitive, no new objects) + // ── Search state ──────────────────────────────────────────────────────────── + /** Raw input value — updated on every keystroke. */ + 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()), + 300 + ); + 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(""); @@ -56,16 +103,25 @@ export function MemoryInspectorPanel({ workspaceId }: Props) { setLoading(true); setError(null); try { - // API returns MemoryEntry[] (flat array, never wrapped, never null) - const data = await api.get(`/workspaces/${workspaceId}/memory`); - setEntries(data); + const url = debouncedQuery + ? `/workspaces/${workspaceId}/memory?q=${encodeURIComponent(debouncedQuery)}` + : `/workspaces/${workspaceId}/memory`; + 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) + ) + : data; + setEntries(sorted); } catch (e) { setError(e instanceof Error ? e.message : "Failed to load memory entries"); setEntries([]); } finally { setLoading(false); } - }, [workspaceId]); + }, [workspaceId, debouncedQuery]); useEffect(() => { loadEntries(); @@ -87,7 +143,6 @@ export function MemoryInspectorPanel({ workspaceId }: Props) { const saveEdit = useCallback( async (entry: MemoryEntry) => { - // Validate JSON before touching network let parsed: unknown; try { parsed = JSON.parse(editValue); @@ -129,7 +184,9 @@ export function MemoryInspectorPanel({ workspaceId }: Props) { 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."); + setEditError( + "Version conflict — entry changed elsewhere. Reload to see latest." + ); } else { setEditError(msg); } @@ -152,9 +209,10 @@ export function MemoryInspectorPanel({ workspaceId }: Props) { if (expandedKey === key) setExpandedKey(null); try { - await api.del(`/workspaces/${workspaceId}/memory/${encodeURIComponent(key)}`); + 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(); } @@ -162,7 +220,8 @@ export function MemoryInspectorPanel({ workspaceId }: Props) { // ── Render ────────────────────────────────────────────────────────────────── - if (loading) { + // Full-screen loader — only on the very first fetch (no entries cached yet). + if (loading && entries.length === 0 && !error) { return (
Loading memory… @@ -172,10 +231,54 @@ export function MemoryInspectorPanel({ workspaceId }: Props) { return (
+ {/* Search bar */} +
+
+ {/* Magnifying glass icon */} + + setSearchQuery(e.target.value)} + placeholder="Semantic search…" + aria-label="Search memory entries" + 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 && ( + + )} +
+
+ {/* Toolbar */} -
+
- {entries.length === 1 ? "1 entry" : `${entries.length} entries`} + {debouncedQuery + ? `${entries.length} result${entries.length !== 1 ? "s" : ""}` + : entries.length === 1 + ? "1 entry" + : `${entries.length} entries`} + . +

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

No memory entries yet

+

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

+
+ ) ) : (
{entries.map((entry) => { @@ -294,6 +424,16 @@ function MemoryEntryRow({ v{entry.version} + {/* Similarity score badge — only rendered when backend provides a score */} + {entry.similarity_score != null && ( + + {Math.round(entry.similarity_score * 100)}% + + )} {formatRelativeTime(entry.updated_at)} diff --git a/canvas/src/components/__tests__/MemoryInspectorPanel.test.tsx b/canvas/src/components/__tests__/MemoryInspectorPanel.test.tsx index 198caf22..1cb709ac 100644 --- a/canvas/src/components/__tests__/MemoryInspectorPanel.test.tsx +++ b/canvas/src/components/__tests__/MemoryInspectorPanel.test.tsx @@ -7,7 +7,7 @@ * and Refresh. */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react"; // ── Mocks (must be hoisted before any imports) ──────────────────────────────── @@ -400,3 +400,113 @@ describe("MemoryInspectorPanel — Refresh button", () => { await waitFor(() => expect(mockGet).toHaveBeenCalledTimes(2)); }); }); + +// ── Semantic search (issue #783) ────────────────────────────────────────────── + +describe("MemoryInspectorPanel — semantic search", () => { + // Ensure fake timers never leak into the next test even if a test throws + afterEach(() => { + vi.useRealTimers(); + }); + + it("does not call API before 300ms debounce elapses after typing", async () => { + vi.useFakeTimers(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockGet.mockResolvedValue([] as any); + render(); + + // Flush initial load — api.get returns an already-resolved Promise + // (microtask), so act() drains it without advancing fake timers + await act(async () => {}); + + mockGet.mockClear(); + + act(() => { + fireEvent.change(screen.getByLabelText("Search memory entries"), { + target: { value: "task queue" }, + }); + }); + + // 200ms elapsed — debounce has NOT fired yet + await act(async () => { + vi.advanceTimersByTime(200); + }); + expect(mockGet).not.toHaveBeenCalled(); + + // Another 150ms (total 350ms > 300ms threshold) — debounce fires + await act(async () => { + vi.advanceTimersByTime(150); + }); + // Flush the async loadEntries that was triggered + await act(async () => {}); + + expect(mockGet).toHaveBeenCalledWith( + "/workspaces/ws-1/memory?q=task%20queue" + ); + + vi.useRealTimers(); + }); + + it("renders similarity-badge with rounded percentage when entry has similarity_score", async () => { + mockGet.mockResolvedValue([ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { ...ENTRY_A, similarity_score: 0.87 }, + ] as any); + render(); + + // Wait for the entry key to appear in the header + await waitFor(() => screen.getByText("task-queue")); + + const badge = document.querySelector('[data-testid="similarity-badge"]'); + expect(badge).toBeTruthy(); + expect(badge?.textContent).toBe("87%"); + }); + + it("does not render similarity-badge when entry has no similarity_score", async () => { + // ENTRY_A has no similarity_score field + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockGet.mockResolvedValue([ENTRY_A] as any); + render(); + + await waitFor(() => screen.getByText("task-queue")); + + expect( + document.querySelector('[data-testid="similarity-badge"]') + ).toBeNull(); + }); + + it("clear button resets debouncedQuery immediately and re-fetches without ?q=", async () => { + vi.useFakeTimers(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockGet.mockResolvedValue([] as any); + render(); + + // Flush initial load + await act(async () => {}); + + act(() => { + fireEvent.change(screen.getByLabelText("Search memory entries"), { + target: { value: "sessions" }, + }); + }); + + // Advance past debounce — debouncedQuery becomes "sessions" + await act(async () => { + vi.advanceTimersByTime(350); + }); + await act(async () => {}); // flush async loadEntries + expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/memory?q=sessions"); + mockGet.mockClear(); + + // Click × clear button — skips debounce, resets debouncedQuery immediately + act(() => { + fireEvent.click(screen.getByRole("button", { name: "Clear search" })); + }); + await act(async () => {}); // flush state update → loadEntries → api.get + + // Should re-fetch the unfiltered list (no q= parameter) + expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/memory"); + + vi.useRealTimers(); + }); +});