feat(canvas): add MemoryInspectorPanel for workspace KV memory (issue #730)

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 <noreply@anthropic.com>
This commit is contained in:
molecule-ai[bot] 2026-04-17 15:24:53 +00:00 committed by GitHub
parent f3f5ce32fe
commit 79f806e2d3
4 changed files with 788 additions and 3 deletions

View File

@ -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<MemoryEntry[]>([]);
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)
const [expandedKey, setExpandedKey] = useState<string | null>(null);
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const [editError, setEditError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [pendingDeleteKey, setPendingDeleteKey] = useState<string | null>(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<MemoryEntry[]>(`/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<WriteResult>(`/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 (
<div className="flex items-center justify-center h-32">
<span className="text-xs text-zinc-500">Loading memory</span>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Toolbar */}
<div className="px-4 py-3 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`}
</span>
<button
onClick={loadEntries}
className="px-2 py-1 text-[11px] bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded transition-colors"
aria-label="Refresh memory entries"
>
Refresh
</button>
</div>
{/* 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">
{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>
) : (
<div className="space-y-1.5">
{entries.map((entry) => {
const isExpanded = expandedKey === entry.key;
const isEditing = editingKey === entry.key;
return (
<MemoryEntryRow
key={entry.key}
entry={entry}
isExpanded={isExpanded}
isEditing={isEditing}
editValue={editValue}
editError={editError}
saving={saving}
onToggle={() => {
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)}
/>
);
})}
</div>
)}
</div>
{/* Delete confirmation dialog */}
<ConfirmDialog
open={pendingDeleteKey !== null}
title="Delete memory entry"
message={`Delete key "${pendingDeleteKey}"? This cannot be undone.`}
confirmLabel="Delete"
confirmVariant="danger"
onConfirm={confirmDelete}
onCancel={() => setPendingDeleteKey(null)}
/>
</div>
);
}
// ── 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 (
<div className="rounded-lg border border-zinc-800/60 bg-zinc-900/50 overflow-hidden">
{/* Header row — click to expand/collapse */}
<button
className="w-full flex items-center gap-2 px-3 py-2.5 text-left hover:bg-zinc-800/30 transition-colors"
onClick={onToggle}
aria-expanded={isExpanded}
>
<span className="text-[10px] font-mono text-blue-400 truncate flex-1 min-w-0">
{entry.key}
</span>
<span className="text-[9px] text-zinc-600 shrink-0 font-mono">
v{entry.version}
</span>
<span className="text-[9px] text-zinc-600 shrink-0">
{formatRelativeTime(entry.updated_at)}
</span>
<span className="text-[9px] text-zinc-500 shrink-0" aria-hidden="true">
{isExpanded ? "▼" : "▶"}
</span>
</button>
{/* Expanded body */}
{isExpanded && (
<div className="border-t border-zinc-800/50 px-3 pb-3 pt-2 space-y-2">
{entry.expires_at && (
<p className="text-[9px] text-zinc-500">
Expires: {new Date(entry.expires_at).toLocaleString()}
</p>
)}
{isEditing ? (
/* Edit mode */
<div className="space-y-2">
<textarea
value={editValue}
onChange={(e) => onEditValueChange(e.target.value)}
rows={6}
aria-label="Edit memory value"
className="w-full bg-zinc-950 border border-zinc-700 focus:border-blue-500 rounded px-2 py-1.5 text-[11px] font-mono text-zinc-100 focus:outline-none resize-none transition-colors"
/>
{editError && (
<p className="text-[10px] text-red-400">{editError}</p>
)}
<div className="flex items-center gap-2">
<button
onClick={onSave}
disabled={saving}
className="px-3 py-1 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-xs rounded text-white transition-colors"
>
{saving ? "Saving…" : "Save"}
</button>
<button
onClick={onCancelEdit}
disabled={saving}
className="px-3 py-1 bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 text-xs rounded text-zinc-300 transition-colors"
>
Cancel
</button>
</div>
</div>
) : (
/* Read mode */
<div className="space-y-2">
<pre className="text-[10px] font-mono text-zinc-300 bg-zinc-950 rounded p-2 overflow-x-auto max-h-48 whitespace-pre-wrap break-all">
{JSON.stringify(entry.value, null, 2)}
</pre>
<div className="flex items-center justify-between gap-2">
<span className="text-[9px] text-zinc-600">
Updated: {new Date(entry.updated_at).toLocaleString()}
</span>
<div className="flex items-center gap-1.5 shrink-0">
<button
onClick={(e) => {
e.stopPropagation();
onStartEdit();
}}
aria-label={`Edit ${entry.key}`}
className="text-[10px] px-2 py-0.5 bg-zinc-700 hover:bg-zinc-600 rounded text-zinc-300 transition-colors"
>
Edit
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
aria-label={`Delete ${entry.key}`}
className="text-[10px] px-2 py-0.5 bg-red-950/40 hover:bg-red-900/50 border border-red-900/30 rounded text-red-400 transition-colors"
>
Delete
</button>
</div>
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@ -11,7 +11,7 @@ import { ChatTab } from "./tabs/ChatTab";
import { ConfigTab } from "./tabs/ConfigTab";
import { TerminalTab } from "./tabs/TerminalTab";
import { FilesTab } from "./tabs/FilesTab";
import { MemoryTab } from "./tabs/MemoryTab";
import { MemoryInspectorPanel } from "./MemoryInspectorPanel";
import { TracesTab } from "./tabs/TracesTab";
import { EventsTab } from "./tabs/EventsTab";
import { ActivityTab } from "./tabs/ActivityTab";
@ -243,7 +243,7 @@ export function SidePanel() {
{panelTab === "schedule" && <ScheduleTab key={selectedNodeId} workspaceId={selectedNodeId} />}
{panelTab === "channels" && <ChannelsTab key={selectedNodeId} workspaceId={selectedNodeId} />}
{panelTab === "files" && <FilesTab key={selectedNodeId} workspaceId={selectedNodeId} />}
{panelTab === "memory" && <MemoryTab key={selectedNodeId} workspaceId={selectedNodeId} />}
{panelTab === "memory" && <MemoryInspectorPanel key={selectedNodeId} workspaceId={selectedNodeId} />}
{panelTab === "traces" && <TracesTab key={selectedNodeId} workspaceId={selectedNodeId} />}
{panelTab === "events" && <EventsTab key={selectedNodeId} workspaceId={selectedNodeId} />}
</div>

View File

@ -0,0 +1,402 @@
// @vitest-environment jsdom
/**
* MemoryInspectorPanel tests issue #730
*
* Covers: loading, empty state, entry list, expand, edit flow (happy path,
* invalid JSON, cancel), delete flow (confirm, cancel), optimistic updates,
* and Refresh.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
// ── Mocks (must be hoisted before any imports) ────────────────────────────────
vi.mock("@/lib/api", () => ({
api: {
get: vi.fn(),
post: vi.fn(),
del: vi.fn(),
},
}));
// ConfirmDialog uses createPortal + a `mounted` state guard that requires
// useEffect to fire. We mock it to a simple inline rendering so tests are
// synchronous and don't depend on document.body portal availability.
vi.mock("@/components/ConfirmDialog", () => ({
ConfirmDialog: ({
open,
title,
message,
onConfirm,
onCancel,
}: {
open: boolean;
title: string;
message: string;
confirmLabel?: string;
confirmVariant?: string;
onConfirm: () => void;
onCancel: () => void;
singleButton?: boolean;
}) =>
open ? (
<div data-testid="confirm-dialog">
<p data-testid="dialog-title">{title}</p>
<p data-testid="dialog-message">{message}</p>
<button onClick={onConfirm}>Confirm Delete</button>
<button onClick={onCancel}>Cancel Delete</button>
</div>
) : null,
}));
// ── Imports (after mocks) ─────────────────────────────────────────────────────
import { api } from "@/lib/api";
import { MemoryInspectorPanel } from "../MemoryInspectorPanel";
// ── Typed mock helpers ────────────────────────────────────────────────────────
const mockGet = vi.mocked(api.get);
const mockPost = vi.mocked(api.post);
const mockDel = vi.mocked(api.del);
// ── Sample fixtures ───────────────────────────────────────────────────────────
const NOW = new Date("2026-04-17T12:00:00.000Z").toISOString();
const LATER = new Date("2026-04-17T13:00:00.000Z").toISOString();
const ENTRY_A = {
key: "task-queue",
value: { pending: ["t-1", "t-2"], done: [] },
version: 3,
updated_at: NOW,
};
const ENTRY_B = {
key: "session-token",
value: "abc123",
version: 1,
expires_at: LATER,
updated_at: NOW,
};
const TWO_ENTRIES = [ENTRY_A, ENTRY_B];
// ── Setup / teardown ──────────────────────────────────────────────────────────
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
// ── Loading & empty state ─────────────────────────────────────────────────────
describe("MemoryInspectorPanel — loading and empty state", () => {
it("shows loading indicator before data arrives", () => {
// Never resolves within this test — just checks the loading UI
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockReturnValue(new Promise(() => {}) as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
expect(screen.getByText(/loading memory/i)).toBeTruthy();
});
it("renders empty state when API returns []", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([] as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() =>
expect(screen.getByText("No memory entries yet")).toBeTruthy()
);
});
it("fetches from the correct workspace memory endpoint", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([] as any);
render(<MemoryInspectorPanel workspaceId="ws-abc-123" />);
await waitFor(() =>
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-abc-123/memory")
);
});
it("shows error banner when fetch throws", async () => {
mockGet.mockRejectedValue(new Error("Network error"));
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() =>
expect(screen.getByText("Network error")).toBeTruthy()
);
});
});
// ── Entry list ────────────────────────────────────────────────────────────────
describe("MemoryInspectorPanel — entry list", () => {
beforeEach(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue(TWO_ENTRIES as any);
});
it("renders a row for every entry key", async () => {
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("task-queue"));
expect(screen.getByText("session-token")).toBeTruthy();
});
it("displays '2 entries' count in the toolbar", async () => {
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => expect(screen.getByText("2 entries")).toBeTruthy());
});
it("displays '1 entry' (singular) when there is one entry", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([ENTRY_A] as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => expect(screen.getByText("1 entry")).toBeTruthy());
});
it("shows version badge for each entry", async () => {
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("task-queue"));
expect(screen.getByText("v3")).toBeTruthy();
expect(screen.getByText("v1")).toBeTruthy();
});
it("entries are collapsed by default (value not visible)", async () => {
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("task-queue"));
// The JSON value should NOT be rendered while collapsed
expect(screen.queryByText(/"pending"/)).toBeNull();
});
});
// ── Expand / collapse ─────────────────────────────────────────────────────────
describe("MemoryInspectorPanel — expand/collapse", () => {
beforeEach(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue(TWO_ENTRIES as any);
});
it("clicking a row header expands it and shows the JSON value", async () => {
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("task-queue"));
// Click to expand
fireEvent.click(
screen.getByText("task-queue").closest("button")!
);
await waitFor(() =>
expect(screen.getByText(/"pending"/)).toBeTruthy()
);
});
it("clicking the header again collapses the row", async () => {
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("task-queue"));
const headerBtn = screen.getByText("task-queue").closest("button")!;
fireEvent.click(headerBtn); // expand
await waitFor(() => screen.getByText(/"pending"/));
fireEvent.click(headerBtn); // collapse
await waitFor(() =>
expect(screen.queryByText(/"pending"/)).toBeNull()
);
});
it("shows expires_at when present", async () => {
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("session-token"));
fireEvent.click(
screen.getByText("session-token").closest("button")!
);
await waitFor(() =>
expect(screen.getByText(/expires/i)).toBeTruthy()
);
});
});
// ── Edit flow ─────────────────────────────────────────────────────────────────
describe("MemoryInspectorPanel — edit flow", () => {
beforeEach(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue(TWO_ENTRIES as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockPost.mockResolvedValue({ status: "ok", key: "task-queue", version: 4 } as any);
});
/** Helper: expand entry-A and click its Edit button */
async function openEditForEntryA() {
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("task-queue"));
fireEvent.click(screen.getByText("task-queue").closest("button")!);
await waitFor(() =>
screen.getByRole("button", { name: "Edit task-queue" })
);
fireEvent.click(screen.getByRole("button", { name: "Edit task-queue" }));
}
it("shows a textarea pre-filled with the entry value after clicking Edit", async () => {
await openEditForEntryA();
const ta = screen.getByRole("textbox", { name: "Edit memory value" });
expect(ta).toBeTruthy();
expect((ta as HTMLTextAreaElement).value).toContain("pending");
});
it("shows Save and Cancel buttons in edit mode", async () => {
await openEditForEntryA();
expect(screen.getByRole("button", { name: /^save$/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /^cancel$/i })).toBeTruthy();
});
it("POSTs to correct path with key, parsed value, and if_match_version", async () => {
await openEditForEntryA();
fireEvent.change(
screen.getByRole("textbox", { name: "Edit memory value" }),
{ target: { value: '{"updated":true}' } }
);
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
await waitFor(() => expect(mockPost).toHaveBeenCalled());
const [path, body] = mockPost.mock.calls[0] as [
string,
{ key: string; value: unknown; if_match_version: number }
];
expect(path).toBe("/workspaces/ws-1/memory");
expect(body.key).toBe("task-queue");
expect(body.if_match_version).toBe(3); // ENTRY_A.version
expect(body.value).toEqual({ updated: true });
});
it("closes the edit form on successful save", async () => {
await openEditForEntryA();
fireEvent.change(
screen.getByRole("textbox", { name: "Edit memory value" }),
{ target: { value: '"new-value"' } }
);
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
await waitFor(() =>
expect(
screen.queryByRole("textbox", { name: "Edit memory value" })
).toBeNull()
);
});
it("shows an inline error (no API call) for syntactically invalid JSON", async () => {
await openEditForEntryA();
fireEvent.change(
screen.getByRole("textbox", { name: "Edit memory value" }),
{ target: { value: "{{bad json" } }
);
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
// Error message appears, textarea stays open, api.post NOT called
await waitFor(() =>
expect(screen.getByText(/invalid json/i)).toBeTruthy()
);
expect(mockPost).not.toHaveBeenCalled();
expect(screen.getByRole("textbox", { name: "Edit memory value" })).toBeTruthy();
});
it("Cancel closes the edit form without calling api.post", async () => {
await openEditForEntryA();
fireEvent.click(screen.getByRole("button", { name: /^cancel$/i }));
await waitFor(() =>
expect(
screen.queryByRole("textbox", { name: "Edit memory value" })
).toBeNull()
);
expect(mockPost).not.toHaveBeenCalled();
});
});
// ── Delete flow ───────────────────────────────────────────────────────────────
describe("MemoryInspectorPanel — delete flow", () => {
beforeEach(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue(TWO_ENTRIES as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockDel.mockResolvedValue({ status: "deleted" } as any);
});
/** Helper: expand entry-A and click its Delete button */
async function openDeleteForEntryA() {
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("task-queue"));
fireEvent.click(screen.getByText("task-queue").closest("button")!);
await waitFor(() =>
screen.getByRole("button", { name: "Delete task-queue" })
);
fireEvent.click(screen.getByRole("button", { name: "Delete task-queue" }));
}
it("opens the ConfirmDialog when Delete is clicked", async () => {
await openDeleteForEntryA();
expect(screen.getByTestId("confirm-dialog")).toBeTruthy();
expect(screen.getByTestId("dialog-title").textContent).toBe(
"Delete memory entry"
);
});
it("includes the key in the dialog message", async () => {
await openDeleteForEntryA();
expect(screen.getByTestId("dialog-message").textContent).toContain(
"task-queue"
);
});
it("calls api.del with the correct URL-encoded path on confirm", async () => {
await openDeleteForEntryA();
fireEvent.click(screen.getByText("Confirm Delete"));
await waitFor(() =>
expect(mockDel).toHaveBeenCalledWith(
"/workspaces/ws-1/memory/task-queue"
)
);
});
it("removes the entry from the list optimistically after confirm", async () => {
await openDeleteForEntryA();
fireEvent.click(screen.getByText("Confirm Delete"));
await waitFor(() =>
expect(screen.queryByText("task-queue")).toBeNull()
);
// Sibling entry unaffected
expect(screen.getByText("session-token")).toBeTruthy();
});
it("closes the ConfirmDialog without deleting when Cancel is clicked", async () => {
await openDeleteForEntryA();
fireEvent.click(screen.getByText("Cancel Delete"));
await waitFor(() =>
expect(screen.queryByTestId("confirm-dialog")).toBeNull()
);
expect(mockDel).not.toHaveBeenCalled();
// Entry still present
expect(screen.getByText("task-queue")).toBeTruthy();
});
});
// ── Refresh ───────────────────────────────────────────────────────────────────
describe("MemoryInspectorPanel — Refresh button", () => {
it("re-fetches entries when the Refresh button is clicked", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([] as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("No memory entries yet"));
expect(mockGet).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "Refresh memory entries" }));
await waitFor(() => expect(mockGet).toHaveBeenCalledTimes(2));
});
});

View File

@ -13,7 +13,7 @@ vi.mock("../tabs/ChatTab", () => ({ ChatTab: () => null }));
vi.mock("../tabs/ConfigTab", () => ({ ConfigTab: () => null }));
vi.mock("../tabs/TerminalTab", () => ({ TerminalTab: () => null }));
vi.mock("../tabs/FilesTab", () => ({ FilesTab: () => null }));
vi.mock("../tabs/MemoryTab", () => ({ MemoryTab: () => null }));
vi.mock("../MemoryInspectorPanel", () => ({ MemoryInspectorPanel: () => null }));
vi.mock("../tabs/TracesTab", () => ({ TracesTab: () => null }));
vi.mock("../tabs/EventsTab", () => ({ EventsTab: () => null }));
vi.mock("../tabs/ActivityTab", () => ({ ActivityTab: () => null }));