feat(canvas): semantic search UI for memory inspector (issue #783)

Adds a debounced (300ms) search input to MemoryInspectorPanel with
?q= fetch, similarity_score% badges, skeleton rows during re-fetches,
search-specific empty state, and an immediate-reset clear button.
Tests: 722 passing (+4 new: debounce, badge present/absent, clear).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
molecule-ai[bot] 2026-04-17 17:04:33 +00:00 committed by GitHub
parent d06666d87a
commit 6c6e703ec9
2 changed files with 273 additions and 23 deletions

View File

@ -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 (01). Only present when the API is queried
* with ?q=<query> 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 (
<div className="space-y-1.5" aria-busy="true" aria-label="Loading entries">
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className="rounded-lg border border-zinc-800/60 bg-zinc-900/50 px-3 py-3 animate-pulse"
>
<div className="flex items-center gap-2">
<div className="h-2 rounded bg-zinc-700/50 flex-1" />
<div className="h-2 rounded bg-zinc-700/50 w-8" />
<div className="h-2 rounded bg-zinc-700/50 w-6" />
<div className="h-2 rounded bg-zinc-700/50 w-10" />
</div>
</div>
))}
</div>
);
}
// ── Component ─────────────────────────────────────────────────────────────────
export function MemoryInspectorPanel({ workspaceId }: Props) {
@ -42,7 +70,26 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<string | null>(null);
const [editingKey, setEditingKey] = useState<string | null>(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<MemoryEntry[]>(`/workspaces/${workspaceId}/memory`);
setEntries(data);
const url = debouncedQuery
? `/workspaces/${workspaceId}/memory?q=${encodeURIComponent(debouncedQuery)}`
: `/workspaces/${workspaceId}/memory`;
const data = await api.get<MemoryEntry[]>(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 (
<div className="flex items-center justify-center h-32">
<span className="text-xs text-zinc-500">Loading memory</span>
@ -172,10 +231,54 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
return (
<div className="flex flex-col h-full">
{/* Search bar */}
<div className="px-4 pt-3 pb-2 border-b border-zinc-800/40 shrink-0">
<div className="relative flex items-center">
{/* Magnifying glass icon */}
<svg
width="12"
height="12"
viewBox="0 0 16 16"
fill="none"
className="absolute left-2.5 text-zinc-500 pointer-events-none shrink-0"
aria-hidden="true"
>
<circle cx="7" cy="7" r="4.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M11 11l3 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
<input
type="search"
value={searchQuery}
onChange={(e) => 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 && (
<button
onClick={() => {
setSearchQuery("");
// Skip the debounce delay for clear — reset immediately
setDebouncedQuery("");
}}
aria-label="Clear search"
className="absolute right-2 text-zinc-500 hover:text-zinc-200 transition-colors text-sm leading-none"
>
×
</button>
)}
</div>
</div>
{/* Toolbar */}
<div className="px-4 py-3 border-b border-zinc-800/40 flex items-center justify-between shrink-0">
<div className="px-4 py-2.5 border-b border-zinc-800/40 flex items-center justify-between shrink-0">
<span className="text-[11px] text-zinc-500">
{entries.length === 1 ? "1 entry" : `${entries.length} entries`}
{debouncedQuery
? `${entries.length} result${entries.length !== 1 ? "s" : ""}`
: entries.length === 1
? "1 entry"
: `${entries.length} entries`}
</span>
<button
onClick={loadEntries}
@ -188,22 +291,49 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
{/* Error banner */}
{error && (
<div className="mx-4 mt-3 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded text-xs text-red-400">
<div className="mx-4 mt-3 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded text-xs text-red-400 shrink-0">
{error}
</div>
)}
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{entries.length === 0 ? (
/* Empty state */
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
<span className="text-4xl text-zinc-700" aria-hidden="true"></span>
<p className="text-sm font-medium text-zinc-400">No memory entries yet</p>
<p className="text-[11px] text-zinc-600 max-w-[200px] leading-relaxed">
Memory entries will appear here when the workspace writes to its KV store.
</p>
</div>
{loading ? (
/* Skeleton rows — visible during search-transition re-fetches */
<MemorySkeletonRows />
) : entries.length === 0 ? (
debouncedQuery ? (
/* Search-specific empty state */
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
<span className="text-4xl text-zinc-700" aria-hidden="true"></span>
<p className="text-sm font-medium text-zinc-400">
No memories match your search
</p>
<p className="text-[11px] text-zinc-600 max-w-[200px] leading-relaxed">
Try a different query or{" "}
<button
onClick={() => {
setSearchQuery("");
setDebouncedQuery("");
}}
className="text-blue-500 hover:text-blue-400 underline transition-colors"
>
clear the search
</button>
.
</p>
</div>
) : (
/* Default empty state */
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
<span className="text-4xl text-zinc-700" aria-hidden="true"></span>
<p className="text-sm font-medium text-zinc-400">No memory entries yet</p>
<p className="text-[11px] text-zinc-600 max-w-[200px] leading-relaxed">
Memory entries will appear here when the workspace writes to its KV
store.
</p>
</div>
)
) : (
<div className="space-y-1.5">
{entries.map((entry) => {
@ -294,6 +424,16 @@ function MemoryEntryRow({
<span className="text-[9px] text-zinc-600 shrink-0 font-mono">
v{entry.version}
</span>
{/* Similarity score badge — only rendered when backend provides a score */}
{entry.similarity_score != null && (
<span
className="text-[9px] text-zinc-500 shrink-0 font-mono tabular-nums"
title={`Similarity: ${(entry.similarity_score * 100).toFixed(1)}%`}
data-testid="similarity-badge"
>
{Math.round(entry.similarity_score * 100)}%
</span>
)}
<span className="text-[9px] text-zinc-600 shrink-0">
{formatRelativeTime(entry.updated_at)}
</span>

View File

@ -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(<MemoryInspectorPanel workspaceId="ws-1" />);
// 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(<MemoryInspectorPanel workspaceId="ws-1" />);
// 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(<MemoryInspectorPanel workspaceId="ws-1" />);
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(<MemoryInspectorPanel workspaceId="ws-1" />);
// 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();
});
});