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:
parent
f3f5ce32fe
commit
79f806e2d3
383
canvas/src/components/MemoryInspectorPanel.tsx
Normal file
383
canvas/src/components/MemoryInspectorPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
402
canvas/src/components/__tests__/MemoryInspectorPanel.test.tsx
Normal file
402
canvas/src/components/__tests__/MemoryInspectorPanel.test.tsx
Normal 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));
|
||||
});
|
||||
});
|
||||
@ -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 }));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user