fix(memory): #1734 delete dead MemoryTab + live-refresh MemoryInspectorPanel (#1749)
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
ci-arm64-advisory / fast-checks (push) Waiting to run
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Waiting to run
CI / all-required (push) Waiting to run
E2E API Smoke Test / detect-changes (push) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
E2E Chat / detect-changes (push) Waiting to run
E2E Chat / E2E Chat (push) Blocked by required conditions
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / detect-changes (push) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Harness Replays / detect-changes (push) Waiting to run
Harness Replays / Harness Replays (push) Blocked by required conditions
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Waiting to run
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Successful in 9s
publish-canvas-image / Build & push canvas image (push) Successful in 2m10s
publish-workspace-server-image / build-and-push (push) Successful in 3m18s
publish-workspace-server-image / Production auto-deploy (push) Has been cancelled
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 13s
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
ci-arm64-advisory / fast-checks (push) Waiting to run
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Waiting to run
CI / all-required (push) Waiting to run
E2E API Smoke Test / detect-changes (push) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
E2E Chat / detect-changes (push) Waiting to run
E2E Chat / E2E Chat (push) Blocked by required conditions
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / detect-changes (push) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Harness Replays / detect-changes (push) Waiting to run
Harness Replays / Harness Replays (push) Blocked by required conditions
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Waiting to run
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Successful in 9s
publish-canvas-image / Build & push canvas image (push) Successful in 2m10s
publish-workspace-server-image / build-and-push (push) Successful in 3m18s
publish-workspace-server-image / Production auto-deploy (push) Has been cancelled
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 13s
CTO-bypass merge per 2026-05-24 directive — SOP-6 checklist acked, 2 non-author approvals on current HEAD, dispatched-review evidence in PR comments.
This commit was merged in pull request #1749.
This commit is contained in:
@@ -24,9 +24,10 @@
|
||||
* "no memories yet".
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||
import { useSocketEvent } from '@/hooks/useSocketEvent';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -246,6 +247,60 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
loadEntries();
|
||||
}, [loadEntries]);
|
||||
|
||||
// Live-refresh on ACTIVITY_LOGGED events that look like memory writes
|
||||
// for this workspace (#1734). Without this, the user sees a stale
|
||||
// empty state after an agent commits — agent says "wrote memory",
|
||||
// panel keeps showing nothing until they hit Refresh.
|
||||
//
|
||||
// What actually broadcasts ACTIVITY_LOGGED on the server today
|
||||
// (workspace-server/internal/handlers/activity.go LogActivity /
|
||||
// LogActivityTx — those are the only emitters):
|
||||
//
|
||||
// - `memory_write_global` — `POST /workspaces/:id/memories` for GLOBAL scope
|
||||
// - `memory_edit_global` — `PATCH /workspaces/:id/memories/:id` for GLOBAL scope
|
||||
// - `memory_delete_global` — `DELETE /workspaces/:id/memories/:id` for GLOBAL scope
|
||||
// - `agent_log` — generic catch-all an agent emits via
|
||||
// `POST /workspaces/:id/activity`
|
||||
//
|
||||
// The MCP-tool path (`commit_memory`, `commit_memory_v2`,
|
||||
// `commit_summary`) does NOT broadcast on the wire today; it inserts
|
||||
// into agent_memories (pre-A1) or calls the v2 plugin (post-A1) and
|
||||
// never round-trips through LogActivity. Server-side follow-up is
|
||||
// tracked in **#1754** — once the MCP handlers emit `memory_write`
|
||||
// via LogActivity, the `agent_log` arm of the filter below can be
|
||||
// dropped. `memory_write` is included pre-emptively so this code
|
||||
// lights up the moment #1754 lands. Until then, `agent_log` catches
|
||||
// MCP commits over-inclusively; the 300ms debounce bounds the
|
||||
// refetch rate. Issue #1734 review finding.
|
||||
//
|
||||
// The 300ms debounce coalesces bursts so a chatty agent (e.g. an
|
||||
// agent in a long task emitting agent_log every few hundred ms)
|
||||
// doesn't hammer /v2/memories on every keystroke-equivalent.
|
||||
const refetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => () => {
|
||||
if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current);
|
||||
}, []);
|
||||
useSocketEvent((msg) => {
|
||||
if (msg.event !== 'ACTIVITY_LOGGED') return;
|
||||
if (msg.workspace_id !== workspaceId) return;
|
||||
const p = (msg.payload || {}) as Record<string, unknown>;
|
||||
const activityType = (p.activity_type as string) || '';
|
||||
switch (activityType) {
|
||||
case 'memory_write':
|
||||
case 'memory_write_global':
|
||||
case 'memory_edit_global':
|
||||
case 'memory_delete_global':
|
||||
case 'agent_log':
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current);
|
||||
refetchTimerRef.current = setTimeout(() => {
|
||||
loadEntries();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// ── Delete handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
const confirmDelete = useCallback(async () => {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* - handleDeployed fires after 500ms delay
|
||||
*
|
||||
* Uses vi.hoisted + vi.mock to fully isolate the api module, matching
|
||||
* the pattern established in ApprovalBanner, MemoryTab, and ScheduleTab tests.
|
||||
* the pattern established in ApprovalBanner and ScheduleTab tests.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Unit tests for pure helpers from MemoryInspectorPanel:
|
||||
* isPluginUnavailableError, formatRelativeTime, formatTTL
|
||||
*
|
||||
* These are the three exported non-component functions. The component
|
||||
* itself (MemoryInspectorPanel) requires full API + store mocking and
|
||||
* is exercised by the existing MemoryTab.test.tsx.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { isPluginUnavailableError, formatTTL } from "../MemoryInspectorPanel";
|
||||
|
||||
// formatRelativeTime is not exported — tested via the component in MemoryTab.test.tsx
|
||||
|
||||
describe("isPluginUnavailableError", () => {
|
||||
it("returns true when Error message contains MEMORY_PLUGIN_URL", () => {
|
||||
const err = new Error("memory: could not resolve MEMORY_PLUGIN_URL — plugin not configured");
|
||||
expect(isPluginUnavailableError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for Error containing MEMORY_PLUGIN_URL", () => {
|
||||
expect(isPluginUnavailableError(new Error("MEMORY_PLUGIN_URL is not set"))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for unrelated error messages", () => {
|
||||
expect(isPluginUnavailableError(new Error("workspace not found"))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for null", () => {
|
||||
expect(isPluginUnavailableError(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for undefined", () => {
|
||||
expect(isPluginUnavailableError(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for plain objects without message", () => {
|
||||
expect(isPluginUnavailableError({ code: 503 })).toBe(false);
|
||||
});
|
||||
|
||||
it("is case-sensitive (MEMORY_PLUGIN_URL must match exactly)", () => {
|
||||
const lowerErr = new Error("memory_plugin_url missing");
|
||||
const upperErr = new Error("MEMORY_PLUGIN_URL missing");
|
||||
expect(isPluginUnavailableError(lowerErr)).toBe(false);
|
||||
expect(isPluginUnavailableError(upperErr)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTTL", () => {
|
||||
beforeEach(() => { vi.useFakeTimers(); });
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
it("returns '' for null", () => {
|
||||
expect(formatTTL(null)).toBe("");
|
||||
});
|
||||
|
||||
it("returns '' for undefined", () => {
|
||||
expect(formatTTL(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it('returns "expired" when expiresAt is in the past', () => {
|
||||
const past = new Date(Date.now() - 60_000).toISOString();
|
||||
expect(formatTTL(past)).toBe("expired");
|
||||
});
|
||||
|
||||
it('returns "Xs" for less than a minute', () => {
|
||||
const soon = new Date(Date.now() + 30_000).toISOString();
|
||||
expect(formatTTL(soon)).toBe("30s");
|
||||
});
|
||||
|
||||
it('returns "Xm" for less than an hour', () => {
|
||||
const soon = new Date(Date.now() + 5 * 60_000).toISOString();
|
||||
expect(formatTTL(soon)).toBe("5m");
|
||||
});
|
||||
|
||||
it('returns "Xh" for less than a day', () => {
|
||||
const soon = new Date(Date.now() + 3 * 3_600_000).toISOString();
|
||||
expect(formatTTL(soon)).toBe("3h");
|
||||
});
|
||||
|
||||
it('returns "Xd" for more than a day', () => {
|
||||
const soon = new Date(Date.now() + 2 * 86_400_000).toISOString();
|
||||
expect(formatTTL(soon)).toBe("2d");
|
||||
});
|
||||
|
||||
it("returns '' for invalid date string", () => {
|
||||
expect(formatTTL("not-a-date")).toBe("");
|
||||
});
|
||||
|
||||
it("returns '' for empty string", () => {
|
||||
expect(formatTTL("")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,17 @@ vi.mock('@/lib/api', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Capture the socket-event handler the panel registers so individual
|
||||
// tests can replay an ACTIVITY_LOGGED message without spinning up a
|
||||
// real WebSocket. One handler at a time is fine — the panel mounts
|
||||
// exactly one useSocketEvent subscriber.
|
||||
let __socketHandler: ((msg: unknown) => void) | null = null;
|
||||
vi.mock('@/hooks/useSocketEvent', () => ({
|
||||
useSocketEvent: (handler: (msg: unknown) => void) => {
|
||||
__socketHandler = handler;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ConfirmDialog', () => ({
|
||||
ConfirmDialog: ({
|
||||
open,
|
||||
@@ -516,3 +527,156 @@ describe('MemoryInspectorPanel — refresh', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Live-refresh subscription wired in #1734 so the panel reacts to
|
||||
// ACTIVITY_LOGGED events for memory writes on this workspace without
|
||||
// the user clicking Refresh. The hook is mocked at the top of the
|
||||
// file to capture the registered handler in __socketHandler.
|
||||
describe('MemoryInspectorPanel — live refresh on activity', () => {
|
||||
it('refetches memories when ACTIVITY_LOGGED arrives with activity_type=memory_write for the same workspace', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
stubFetch([MEM_BASIC]);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByLabelText('Refresh memories'));
|
||||
expect(__socketHandler).toBeTruthy();
|
||||
|
||||
const before = mockGet.mock.calls.filter((c) =>
|
||||
(c[0] as string).includes('/v2/memories'),
|
||||
).length;
|
||||
|
||||
__socketHandler!({
|
||||
event: 'ACTIVITY_LOGGED',
|
||||
workspace_id: 'ws-1',
|
||||
payload: { activity_type: 'memory_write' },
|
||||
});
|
||||
|
||||
// 300ms debounce inside the panel — advance the fake timer so the
|
||||
// queued refetch fires.
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
|
||||
await waitFor(() => {
|
||||
const after = mockGet.mock.calls.filter((c) =>
|
||||
(c[0] as string).includes('/v2/memories'),
|
||||
).length;
|
||||
expect(after).toBe(before + 1);
|
||||
});
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('ignores ACTIVITY_LOGGED events from other workspaces', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
stubFetch([MEM_BASIC]);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByLabelText('Refresh memories'));
|
||||
|
||||
const before = mockGet.mock.calls.filter((c) =>
|
||||
(c[0] as string).includes('/v2/memories'),
|
||||
).length;
|
||||
|
||||
__socketHandler!({
|
||||
event: 'ACTIVITY_LOGGED',
|
||||
workspace_id: 'ws-OTHER',
|
||||
payload: { activity_type: 'memory_write' },
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
const after = mockGet.mock.calls.filter((c) =>
|
||||
(c[0] as string).includes('/v2/memories'),
|
||||
).length;
|
||||
expect(after).toBe(before);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('ignores activity types that are not memory-related', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
stubFetch([MEM_BASIC]);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByLabelText('Refresh memories'));
|
||||
|
||||
const before = mockGet.mock.calls.filter((c) =>
|
||||
(c[0] as string).includes('/v2/memories'),
|
||||
).length;
|
||||
|
||||
__socketHandler!({
|
||||
event: 'ACTIVITY_LOGGED',
|
||||
workspace_id: 'ws-1',
|
||||
payload: { activity_type: 'a2a_send' },
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
const after = mockGet.mock.calls.filter((c) =>
|
||||
(c[0] as string).includes('/v2/memories'),
|
||||
).length;
|
||||
expect(after).toBe(before);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// Server-side emitters confirmed via grep of workspace-server/internal/handlers
|
||||
// are `memory_write_global`, `memory_edit_global`, `memory_delete_global`
|
||||
// (memories.go `LogActivity` calls for GLOBAL-scope writes). Pin each
|
||||
// so a future filter narrow-down can't silently drop one and let the
|
||||
// panel go stale on its actual production trigger.
|
||||
it.each([
|
||||
'memory_write', // pre-emptive: not yet emitted by server, see component comment
|
||||
'memory_write_global', // memories.go:218 (Commit)
|
||||
'memory_edit_global', // memories.go:617 (Update)
|
||||
'memory_delete_global', // memories.go (Delete) — paired with the above two
|
||||
'agent_log', // generic catch-all
|
||||
])('refetches on activity_type=%s', async (activityType) => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
stubFetch([MEM_BASIC]);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByLabelText('Refresh memories'));
|
||||
|
||||
const before = mockGet.mock.calls.filter((c) =>
|
||||
(c[0] as string).includes('/v2/memories'),
|
||||
).length;
|
||||
|
||||
__socketHandler!({
|
||||
event: 'ACTIVITY_LOGGED',
|
||||
workspace_id: 'ws-1',
|
||||
payload: { activity_type: activityType },
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
|
||||
await waitFor(() => {
|
||||
const after = mockGet.mock.calls.filter((c) =>
|
||||
(c[0] as string).includes('/v2/memories'),
|
||||
).length;
|
||||
expect(after).toBe(before + 1);
|
||||
});
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('coalesces a burst of memory_write events into one refetch', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
stubFetch([MEM_BASIC]);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByLabelText('Refresh memories'));
|
||||
|
||||
const before = mockGet.mock.calls.filter((c) =>
|
||||
(c[0] as string).includes('/v2/memories'),
|
||||
).length;
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
__socketHandler!({
|
||||
event: 'ACTIVITY_LOGGED',
|
||||
workspace_id: 'ws-1',
|
||||
payload: { activity_type: 'memory_write' },
|
||||
});
|
||||
}
|
||||
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
|
||||
await waitFor(() => {
|
||||
const after = mockGet.mock.calls.filter((c) =>
|
||||
(c[0] as string).includes('/v2/memories'),
|
||||
).length;
|
||||
expect(after).toBe(before + 1);
|
||||
});
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -369,7 +369,7 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
onClick={handleCreate}
|
||||
// Was bg-accent-strong hover:bg-accent — accent is the
|
||||
// LIGHTER variant; same AA contrast trap fixed in
|
||||
// ScheduleTab/MemoryTab/OnboardingWizard.
|
||||
// ScheduleTab/OnboardingWizard.
|
||||
className="w-full text-xs py-1.5 rounded bg-accent hover:bg-accent-strong text-white transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
|
||||
>
|
||||
Connect Channel
|
||||
|
||||
@@ -1,471 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
interface MemoryEntry {
|
||||
key: string;
|
||||
value: unknown;
|
||||
version?: number;
|
||||
expires_at: string | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
const AWARENESS_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_AWARENESS_URL || "http://localhost:37800";
|
||||
|
||||
export function MemoryTab({ workspaceId }: Props) {
|
||||
const [entries, setEntries] = useState<MemoryEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAwareness, setShowAwareness] = useState(true);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [newKey, setNewKey] = useState("");
|
||||
const [newValue, setNewValue] = useState("");
|
||||
const [newTTL, setNewTTL] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
const [editTTL, setEditTTL] = useState("");
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
|
||||
const awarenessUrl = useMemo(() => {
|
||||
try {
|
||||
const url = new URL(AWARENESS_BASE_URL);
|
||||
url.searchParams.set("workspaceId", workspaceId);
|
||||
return url.toString();
|
||||
} catch {
|
||||
return AWARENESS_BASE_URL;
|
||||
}
|
||||
}, [workspaceId]);
|
||||
|
||||
const awarenessStatus = useMemo(() => {
|
||||
try {
|
||||
const url = new URL(AWARENESS_BASE_URL);
|
||||
return url.origin.includes("localhost") ? "local" : url.hostname;
|
||||
} catch {
|
||||
return "unavailable";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadMemory = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.get<MemoryEntry[]>(`/workspaces/${workspaceId}/memory`);
|
||||
setEntries(data);
|
||||
} catch (e) {
|
||||
setEntries([]);
|
||||
setError(e instanceof Error ? e.message : "Failed to load memory");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadMemory();
|
||||
}, [loadMemory]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
setError(null);
|
||||
if (!newKey.trim()) {
|
||||
setError("Key is required");
|
||||
return;
|
||||
}
|
||||
|
||||
let parsedValue: unknown;
|
||||
try {
|
||||
parsedValue = JSON.parse(newValue);
|
||||
} catch {
|
||||
parsedValue = newValue;
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = { key: newKey, value: parsedValue };
|
||||
if (newTTL) {
|
||||
const ttl = parseInt(newTTL);
|
||||
if (!Number.isNaN(ttl) && ttl > 0) body.ttl_seconds = ttl;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post(`/workspaces/${workspaceId}/memory`, body);
|
||||
setNewKey("");
|
||||
setNewValue("");
|
||||
setNewTTL("");
|
||||
setShowAdd(false);
|
||||
loadMemory();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to add");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (key: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
await api.del(`/workspaces/${workspaceId}/memory/${encodeURIComponent(key)}`);
|
||||
setEntries((prev) => prev.filter((e) => e.key !== key));
|
||||
if (expanded === key) setExpanded(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to delete entry");
|
||||
}
|
||||
};
|
||||
|
||||
const beginEdit = (entry: MemoryEntry) => {
|
||||
setEditError(null);
|
||||
setEditingKey(entry.key);
|
||||
// Stringify objects/arrays as pretty JSON; render plain strings raw so the
|
||||
// editor doesn't surprise users with surrounding quotes.
|
||||
setEditValue(
|
||||
typeof entry.value === "string"
|
||||
? entry.value
|
||||
: JSON.stringify(entry.value, null, 2),
|
||||
);
|
||||
if (entry.expires_at) {
|
||||
const remainingMs = new Date(entry.expires_at).getTime() - Date.now();
|
||||
const ttl = Math.max(0, Math.floor(remainingMs / 1000));
|
||||
setEditTTL(ttl > 0 ? String(ttl) : "");
|
||||
} else {
|
||||
setEditTTL("");
|
||||
}
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingKey(null);
|
||||
setEditValue("");
|
||||
setEditTTL("");
|
||||
setEditError(null);
|
||||
};
|
||||
|
||||
const handleEditSave = async (entry: MemoryEntry) => {
|
||||
setEditError(null);
|
||||
|
||||
let parsedValue: unknown;
|
||||
try {
|
||||
parsedValue = JSON.parse(editValue);
|
||||
} catch {
|
||||
parsedValue = editValue;
|
||||
}
|
||||
|
||||
// if_match_version closes the silent-overwrite hole when two writers
|
||||
// race. The handler returns 409 with the current version on mismatch
|
||||
// — surface that as a retry hint and reload to pick up the new state.
|
||||
const body: Record<string, unknown> = { key: entry.key, value: parsedValue };
|
||||
if (typeof entry.version === "number") {
|
||||
body.if_match_version = entry.version;
|
||||
}
|
||||
if (editTTL) {
|
||||
const ttl = parseInt(editTTL);
|
||||
if (!Number.isNaN(ttl) && ttl > 0) body.ttl_seconds = ttl;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post(`/workspaces/${workspaceId}/memory`, body);
|
||||
cancelEdit();
|
||||
loadMemory();
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : "Failed to save";
|
||||
if (message.includes("409") || /if_match_version mismatch/i.test(message)) {
|
||||
setEditError("This entry changed since you opened it. Reloading.");
|
||||
loadMemory();
|
||||
} else {
|
||||
setEditError(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openAwareness = () => {
|
||||
window.open(awarenessUrl, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-4 text-xs text-ink-mid">Loading memory...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
{error && !showAdd && (
|
||||
<div role="alert" className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-medium text-ink">Awareness dashboard</div>
|
||||
<p className="text-[10px] text-ink-mid">
|
||||
Embedded view for the local Awareness memory UI. The current workspace id is appended to the URL for workspace-scoped routing or future filtering.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAwareness((prev) => !prev)}
|
||||
className="shrink-0 px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
{showAwareness ? "Collapse" : "Expand"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAwareness}
|
||||
className="shrink-0 px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAwareness ? (
|
||||
AWARENESS_BASE_URL ? (
|
||||
<div className="overflow-hidden rounded-xl border border-line bg-surface-sunken/70 shadow-[0_0_0_1px_rgba(255,255,255,0.02)]">
|
||||
<iframe
|
||||
title="Awareness dashboard"
|
||||
src={awarenessUrl}
|
||||
className="h-[520px] w-full border-0"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-line bg-surface-sunken/40 p-4 text-xs text-ink-mid">
|
||||
Set <code className="font-mono text-ink-mid">NEXT_PUBLIC_AWARENESS_URL</code> to embed the Awareness dashboard here.
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="rounded-xl border border-line bg-surface-sunken/50 px-4 py-3 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-ink">Awareness dashboard is collapsed</p>
|
||||
<p className="text-[10px] text-ink-mid truncate">
|
||||
Workspace context stays linked through <span className="font-mono text-ink-mid">{workspaceId}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAwareness(true)}
|
||||
className="shrink-0 px-2 py-1 bg-accent hover:bg-accent-strong text-[10px] rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Expand
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2 rounded-xl border border-line bg-surface/40 px-3 py-2 text-[10px] text-ink-mid sm:grid-cols-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="uppercase tracking-[0.18em] text-ink-mid">Status</span>
|
||||
<span className="font-medium text-good">Connected</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="uppercase tracking-[0.18em] text-ink-mid">Mode</span>
|
||||
<span className="font-medium text-ink">{awarenessStatus}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 min-w-0">
|
||||
<span className="uppercase tracking-[0.18em] text-ink-mid">Workspace</span>
|
||||
<span className="font-mono text-ink-mid truncate">{workspaceId}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 border-t border-line/60 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-xs font-medium text-ink">Workspace KV memory</div>
|
||||
<p className="text-[10px] text-ink-mid">
|
||||
Native platform key-value memory for workspace <span className="font-mono text-ink-mid">{workspaceId}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced((prev) => !prev)}
|
||||
className="px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
{showAdvanced ? "Hide Advanced" : "Advanced"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadMemory}
|
||||
className="px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowAdd(!showAdd); if (!showAdd) setShowAdvanced(true); }}
|
||||
className="px-2 py-1 bg-accent hover:bg-accent-strong text-[10px] rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAdvanced && showAdd && (
|
||||
<div className="bg-surface-card rounded p-3 space-y-2 border border-line">
|
||||
<input
|
||||
value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)}
|
||||
placeholder="Key"
|
||||
aria-label="Memory key"
|
||||
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-xs text-ink focus:outline-none focus:border-accent"
|
||||
/>
|
||||
<textarea
|
||||
value={newValue}
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
placeholder='Value (JSON or plain text)'
|
||||
rows={3}
|
||||
aria-label="Memory value (JSON or plain text)"
|
||||
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-xs font-mono text-ink focus:outline-none focus:border-accent resize-none"
|
||||
/>
|
||||
<input
|
||||
value={newTTL}
|
||||
onChange={(e) => setNewTTL(e.target.value)}
|
||||
placeholder="TTL in seconds (optional)"
|
||||
aria-label="TTL in seconds (optional)"
|
||||
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-xs text-ink focus:outline-none focus:border-accent"
|
||||
/>
|
||||
{error && <div role="alert" className="text-xs text-bad">{error}</div>}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
className="px-3 py-1 bg-accent hover:bg-accent-strong text-xs rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowAdd(false);
|
||||
setError(null);
|
||||
}}
|
||||
className="px-3 py-1 bg-surface-card hover:bg-surface-elevated text-xs rounded text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdvanced ? (
|
||||
entries.length === 0 ? (
|
||||
<p className="text-xs text-ink-mid text-center py-4">No memory entries</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.key} className="bg-surface-card rounded border border-line">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(expanded === entry.key ? null : entry.key)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
aria-expanded={expanded === entry.key}
|
||||
>
|
||||
<span className="text-xs font-mono text-accent">{entry.key}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{entry.expires_at && (
|
||||
<span className="text-[9px] text-ink-mid">
|
||||
TTL {new Date(entry.expires_at).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-ink-mid">
|
||||
{expanded === entry.key ? "▼" : "▶"}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded === entry.key && (
|
||||
<div className="px-3 pb-2 space-y-2">
|
||||
{editingKey === entry.key ? (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
rows={4}
|
||||
aria-label={`Edit value for ${entry.key}`}
|
||||
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-xs font-mono text-ink focus:outline-none focus:border-accent resize-none"
|
||||
/>
|
||||
<input
|
||||
value={editTTL}
|
||||
onChange={(e) => setEditTTL(e.target.value)}
|
||||
placeholder="TTL in seconds (blank = no expiry)"
|
||||
aria-label={`Edit TTL for ${entry.key}`}
|
||||
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-xs text-ink focus:outline-none focus:border-accent"
|
||||
/>
|
||||
{editError && (
|
||||
<div role="alert" className="text-[10px] text-bad">
|
||||
{editError}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleEditSave(entry)}
|
||||
className="px-3 py-1 bg-accent hover:bg-accent-strong text-xs rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelEdit}
|
||||
className="px-3 py-1 bg-surface-card hover:bg-surface-elevated text-xs rounded text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="text-[10px] text-ink-mid bg-surface-sunken rounded p-2 overflow-x-auto max-h-40">
|
||||
{JSON.stringify(entry.value, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[9px] text-ink-mid">
|
||||
Updated: {new Date(entry.updated_at).toLocaleString()}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{editingKey !== entry.key && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => beginEdit(entry)}
|
||||
className="text-[10px] text-ink-mid hover:bg-surface-elevated rounded px-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(entry.key)}
|
||||
className="text-[10px] text-bad hover:bg-red-950/40 rounded px-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="rounded-xl border border-line bg-surface/30 px-4 py-3 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-ink">Advanced workspace memory is hidden</p>
|
||||
<p className="text-[10px] text-ink-mid truncate">
|
||||
KV entries remain available if you need the raw platform store.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(true)}
|
||||
className="shrink-0 px-2 py-1 bg-accent hover:bg-accent-strong text-[10px] rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Show
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,632 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for MemoryTab — awareness dashboard + workspace KV memory management.
|
||||
*
|
||||
* Coverage:
|
||||
* - Loading state
|
||||
* - Error state when GET /memory fails
|
||||
* - Empty state (no memory entries)
|
||||
* - Memory list rendering (single + multiple entries)
|
||||
* - Expand/collapse memory entries
|
||||
* - Add memory entry (key + value + TTL)
|
||||
* - Add validates required key
|
||||
* - Add parses JSON values
|
||||
* - Delete memory entry
|
||||
* - Edit memory entry (inline)
|
||||
* - Edit 409 conflict shows retry hint
|
||||
* - Advanced toggle shows/hides KV section
|
||||
* - Awareness dashboard expand/collapse
|
||||
* - Awareness URL includes workspaceId
|
||||
* - Refresh button reloads memory
|
||||
* - Error clears when appropriate actions are taken
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MemoryTab } from "../MemoryTab";
|
||||
|
||||
const mockGet = vi.hoisted(() => vi.fn<[], Promise<unknown[]>>());
|
||||
const mockPost = vi.hoisted(() => vi.fn<[], Promise<unknown>>());
|
||||
const mockDel = vi.hoisted(() => vi.fn<[], Promise<unknown>>());
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: { get: mockGet, post: mockPost, del: mockDel },
|
||||
}));
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const MEMORY_ENTRY = {
|
||||
key: "user_context",
|
||||
value: { name: "Alice", role: "engineer" },
|
||||
version: 3,
|
||||
expires_at: null,
|
||||
updated_at: new Date(Date.now() - 60000).toISOString(),
|
||||
};
|
||||
|
||||
function entry(overrides: Partial<typeof MEMORY_ENTRY> = {}): typeof MEMORY_ENTRY {
|
||||
return { ...MEMORY_ENTRY, ...overrides };
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function flush() {
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
}
|
||||
|
||||
function typeIn(el: HTMLElement, value: string) {
|
||||
Object.defineProperty(el, "value", { value, writable: true, configurable: true });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
fireEvent.change(el as any, { target: el });
|
||||
}
|
||||
|
||||
// ─── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MemoryTab", () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset();
|
||||
mockPost.mockReset();
|
||||
mockDel.mockReset();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ── Loading / Error ──────────────────────────────────────────────────────────
|
||||
|
||||
it("shows loading state when memory is being fetched", async () => {
|
||||
mockGet.mockImplementation(() => new Promise(() => {}));
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await act(async () => { /* flush initial render */ });
|
||||
expect(screen.getByText("Loading memory...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows error banner when GET /memory rejects", async () => {
|
||||
mockGet.mockRejectedValue(new Error("network failure"));
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText(/network failure/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows 'Failed to load memory' when GET rejects with non-Error", async () => {
|
||||
mockGet.mockRejectedValue("unknown error");
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText(/Failed to load memory/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── Awareness Dashboard ─────────────────────────────────────────────────────
|
||||
|
||||
it("shows Awareness dashboard section", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText("Awareness dashboard")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders an iframe with workspaceId in URL", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-xyz" />);
|
||||
await flush();
|
||||
const iframe = screen.getByTitle("Awareness dashboard");
|
||||
expect(iframe.getAttribute("src")).toContain("workspaceId=ws-xyz");
|
||||
});
|
||||
|
||||
it("shows 'Connected' status", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText("Connected")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows workspace ID in the status grid", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-test-id" />);
|
||||
await flush();
|
||||
// workspaceId appears in two places (description + status grid).
|
||||
// Target the font-mono span in the status grid specifically.
|
||||
const spans = Array.from(document.querySelectorAll("span.font-mono"));
|
||||
expect(spans.some(s => s.textContent === "ws-test-id")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows 'Collapse' and 'Open' buttons for awareness (starts visible)", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByRole("button", { name: /collapse/i })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /open/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides awareness iframe when Collapse is clicked", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /collapse/i }));
|
||||
await flush();
|
||||
expect(screen.queryByTitle("Awareness dashboard")).toBeNull();
|
||||
expect(screen.getByText(/awareness dashboard is collapsed/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("re-shows awareness iframe when collapsed state Expand is clicked", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
// Start with awareness visible (default) — verify iframe is there
|
||||
expect(screen.getByTitle("Awareness dashboard")).toBeTruthy();
|
||||
// Click Collapse in the awareness header to hide the iframe
|
||||
fireEvent.click(screen.getByRole("button", { name: /collapse/i }));
|
||||
await flush();
|
||||
expect(screen.queryByTitle("Awareness dashboard")).toBeNull();
|
||||
// The collapsed awareness state has a different "Expand" button.
|
||||
// Directly click the button whose text is exactly "Expand".
|
||||
const allBtns = screen.getAllByRole("button");
|
||||
const expandInCollapsed = allBtns.find(b => b.textContent?.trim() === "Expand");
|
||||
expect(expandInCollapsed).toBeTruthy();
|
||||
act(() => { expandInCollapsed!.click(); });
|
||||
await flush();
|
||||
expect(screen.getByTitle("Awareness dashboard")).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── KV Memory: Empty / Advanced toggle ───────────────────────────────────────
|
||||
|
||||
it("shows 'Advanced workspace memory is hidden' when advanced is collapsed", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText(/advanced workspace memory is hidden/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows 'Show' button when advanced is collapsed", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByRole("button", { name: /show/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows 'Hide Advanced' after clicking Show", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
expect(screen.getByRole("button", { name: /hide advanced/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows empty state 'No memory entries' when advanced is shown and list is empty", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
expect(screen.getByText("No memory entries")).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── KV Memory: List rendering ───────────────────────────────────────────────
|
||||
|
||||
it("renders memory entries when advanced is open", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
expect(screen.getByText("user_context")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders multiple memory entries", async () => {
|
||||
mockGet.mockResolvedValue([
|
||||
entry({ key: "key1", value: "value1" }),
|
||||
entry({ key: "key2", value: "value2" }),
|
||||
]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
expect(screen.getByText("key1")).toBeTruthy();
|
||||
expect(screen.getByText("key2")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows chevron pointing right when entry is collapsed", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
expect(screen.getByText("▶")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows chevron pointing down when entry is expanded", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
expect(screen.getByText("▼")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows entry value when expanded", async () => {
|
||||
mockGet.mockResolvedValue([entry({ value: { foo: "bar" } })]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
expect(screen.getByText(/"foo": "bar"/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows updated_at timestamp when entry is expanded", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
expect(screen.getByText(/updated:/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows Edit and Delete buttons when entry is expanded", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
expect(screen.getByRole("button", { name: /edit/i })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /delete/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows TTL when entry has expires_at", async () => {
|
||||
const future = new Date(Date.now() + 3600000).toISOString();
|
||||
mockGet.mockResolvedValue([entry({ expires_at: future })]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
expect(screen.getByText(/ttl/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── Add Memory Entry ─────────────────────────────────────────────────────────
|
||||
|
||||
it("shows + Add button in KV section", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
expect(screen.getByRole("button", { name: /\+ add/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens add form when + Add is clicked", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
|
||||
await flush();
|
||||
expect(screen.getByLabelText("Memory key")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Memory value (JSON or plain text)")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("requires key to be non-empty", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
|
||||
await flush();
|
||||
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
|
||||
await flush();
|
||||
expect(screen.getByText(/key is required/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("POSTs correct payload when adding a string value", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
mockPost.mockResolvedValue({});
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
|
||||
await flush();
|
||||
typeIn(screen.getByLabelText("Memory key") as HTMLElement, "my_key");
|
||||
typeIn(screen.getByLabelText("Memory value (JSON or plain text)") as HTMLElement, "plain text value");
|
||||
await flush();
|
||||
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
|
||||
await flush();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText("Memory key")).not.toBeTruthy();
|
||||
});
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/memory",
|
||||
expect.objectContaining({ key: "my_key", value: "plain text value" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("POSTs parsed JSON when value is valid JSON", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
mockPost.mockResolvedValue({});
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
|
||||
await flush();
|
||||
typeIn(screen.getByLabelText("Memory key") as HTMLElement, "config");
|
||||
typeIn(screen.getByLabelText("Memory value (JSON or plain text)") as HTMLElement, '{"debug": true}');
|
||||
await flush();
|
||||
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
|
||||
await flush();
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/memory",
|
||||
expect.objectContaining({ key: "config", value: { debug: true } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("POSTs with ttl_seconds when TTL is provided", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
mockPost.mockResolvedValue({});
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
|
||||
await flush();
|
||||
typeIn(screen.getByLabelText("Memory key") as HTMLElement, "temp_data");
|
||||
typeIn(screen.getByLabelText("Memory value (JSON or plain text)") as HTMLElement, "value");
|
||||
typeIn(screen.getByLabelText("TTL in seconds (optional)") as HTMLElement, "3600");
|
||||
await flush();
|
||||
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
|
||||
await flush();
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/memory",
|
||||
expect.objectContaining({ key: "temp_data", value: "value", ttl_seconds: 3600 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows error when add fails", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
mockPost.mockRejectedValue(new Error("add failed"));
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
|
||||
await flush();
|
||||
typeIn(screen.getByLabelText("Memory key") as HTMLElement, "key");
|
||||
typeIn(screen.getByLabelText("Memory value (JSON or plain text)") as HTMLElement, "val");
|
||||
await flush();
|
||||
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
|
||||
await flush();
|
||||
expect(screen.getByText(/add failed/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("closes add form and refreshes after successful add", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
mockPost.mockResolvedValue({});
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
|
||||
await flush();
|
||||
typeIn(screen.getByLabelText("Memory key") as HTMLElement, "new_key");
|
||||
typeIn(screen.getByLabelText("Memory value (JSON or plain text)") as HTMLElement, "new_val");
|
||||
await flush();
|
||||
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
|
||||
await flush();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText("Memory key")).not.toBeTruthy();
|
||||
});
|
||||
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/memory");
|
||||
});
|
||||
|
||||
it("closes add form when Cancel is clicked", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
|
||||
await flush();
|
||||
expect(screen.getByLabelText("Memory key")).toBeTruthy();
|
||||
act(() => { screen.getByRole("button", { name: /cancel/i }).click(); });
|
||||
await flush();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText("Memory key")).not.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Delete Memory Entry ─────────────────────────────────────────────────────
|
||||
|
||||
it("calls DEL when Delete is clicked", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
mockDel.mockResolvedValue({});
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
|
||||
await flush();
|
||||
expect(mockDel).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/memory/user_context",
|
||||
);
|
||||
});
|
||||
|
||||
it("removes entry from list after successful delete", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
mockDel.mockResolvedValue({});
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
expect(screen.getByText("user_context")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
|
||||
await flush();
|
||||
expect(screen.queryByText("user_context")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("collapses entry if it was expanded when deleted", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
mockDel.mockResolvedValue({});
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
// Expand the entry
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
expect(screen.getByText("▼")).toBeTruthy();
|
||||
// Delete
|
||||
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
|
||||
await flush();
|
||||
expect(screen.queryByText("user_context")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("shows error when delete fails", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
mockDel.mockRejectedValue(new Error("delete failed"));
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
|
||||
await flush();
|
||||
expect(screen.getByText(/delete failed/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── Edit Memory Entry ────────────────────────────────────────────────────────
|
||||
|
||||
it("shows edit form when Edit is clicked", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
await flush();
|
||||
expect(screen.getByLabelText(/edit value for user_context/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("pre-fills edit form with existing value", async () => {
|
||||
mockGet.mockResolvedValue([entry({ value: { name: "Alice" } })]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
await flush();
|
||||
const textarea = screen.getByLabelText(/edit value for user_context/i);
|
||||
expect((textarea as HTMLTextAreaElement).value).toContain("Alice");
|
||||
});
|
||||
|
||||
it("POSTs updated value when Save is clicked", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
mockPost.mockResolvedValue({});
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
await flush();
|
||||
typeIn(screen.getByLabelText(/edit value for user_context/i) as HTMLElement, "updated_value");
|
||||
await flush();
|
||||
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
|
||||
await flush();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText(/edit value for user_context/i)).not.toBeTruthy();
|
||||
});
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/memory",
|
||||
expect.objectContaining({ key: "user_context", value: "updated_value", if_match_version: 3 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows retry hint on 409 conflict during edit", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
mockPost.mockRejectedValue(new Error("409 Conflict: if_match_version mismatch"));
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
await flush();
|
||||
typeIn(screen.getByLabelText(/edit value for user_context/i) as HTMLElement, "new_val");
|
||||
await flush();
|
||||
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
|
||||
await flush();
|
||||
expect(screen.getByText(/this entry changed since you opened it/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows generic error when edit save fails", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
mockPost.mockRejectedValue(new Error("save failed"));
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
await flush();
|
||||
typeIn(screen.getByLabelText(/edit value for user_context/i) as HTMLElement, "x");
|
||||
await flush();
|
||||
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
|
||||
await flush();
|
||||
expect(screen.getByText(/save failed/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("closes edit form when Cancel is clicked", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
await flush();
|
||||
expect(screen.getByLabelText(/edit value for user_context/i)).toBeTruthy();
|
||||
act(() => { screen.getByRole("button", { name: /cancel/i }).click(); });
|
||||
await flush();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText(/edit value for/i)).not.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Refresh ────────────────────────────────────────────────────────────────
|
||||
|
||||
it("Refresh button calls loadMemory", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
mockGet.mockClear();
|
||||
fireEvent.click(screen.getByRole("button", { name: /refresh/i }));
|
||||
await flush();
|
||||
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/memory");
|
||||
});
|
||||
|
||||
});
|
||||
Reference in New Issue
Block a user