fix(memory): #1734 delete dead MemoryTab + live-refresh MemoryInspectorPanel
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
sop-checklist / review-refire (pull_request) Waiting to run
sop-tier-check / tier-check (pull_request) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 3s
CI / Python Lint & Test (pull_request) Successful in 7s
CI / Detect changes (pull_request) Successful in 8s
E2E API Smoke Test / detect-changes (pull_request) Successful in 6s
E2E Chat / detect-changes (pull_request) Successful in 6s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 8s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Successful in 7s
Harness Replays / detect-changes (pull_request) Successful in 3s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 31s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 3s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 3s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 3s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Failing after 1m6s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m12s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 5s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m17s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m6s
review-check-tests / review-check.sh regression tests (pull_request) Successful in 5s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m19s
gate-check-v3 / gate-check (pull_request) Successful in 5s
qa-review / approved (pull_request) Successful in 5s
security-review / approved (pull_request) Failing after 4s
sop-checklist / all-items-acked (pull_request) acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, local-postgres-e2
sop-checklist / na-declarations (pull_request) N/A: (none)
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m8s
CI / Platform (Go) (pull_request) Successful in 4s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 8s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 19s
E2E Chat / E2E Chat (pull_request) Successful in 8s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 6s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 5s
Harness Replays / Harness Replays (pull_request) Successful in 4s
CI / Canvas (Next.js) (pull_request) Successful in 6m53s
CI / all-required (pull_request) Successful in 22m35s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
sop-checklist / review-refire (pull_request) Waiting to run
sop-tier-check / tier-check (pull_request) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 3s
CI / Python Lint & Test (pull_request) Successful in 7s
CI / Detect changes (pull_request) Successful in 8s
E2E API Smoke Test / detect-changes (pull_request) Successful in 6s
E2E Chat / detect-changes (pull_request) Successful in 6s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 8s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Successful in 7s
Harness Replays / detect-changes (pull_request) Successful in 3s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 31s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 3s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 3s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 3s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Failing after 1m6s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m12s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 5s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m17s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m6s
review-check-tests / review-check.sh regression tests (pull_request) Successful in 5s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m19s
gate-check-v3 / gate-check (pull_request) Successful in 5s
qa-review / approved (pull_request) Successful in 5s
security-review / approved (pull_request) Failing after 4s
sop-checklist / all-items-acked (pull_request) acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, local-postgres-e2
sop-checklist / na-declarations (pull_request) N/A: (none)
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m8s
CI / Platform (Go) (pull_request) Successful in 4s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 8s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 19s
E2E Chat / E2E Chat (pull_request) Successful in 8s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 6s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 5s
Harness Replays / Harness Replays (pull_request) Successful in 4s
CI / Canvas (Next.js) (pull_request) Successful in 6m53s
CI / all-required (pull_request) Successful in 22m35s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
The bug report cited canvas/src/components/tabs/MemoryTab.tsx:60 as the
smoking gun ("UI reads K/V store, not v2 plugin"). Reading the source
on main showed that MemoryTab.tsx is dead code — not imported by any
production component. SidePanel.tsx:313 (the actually-rendered tab
host) wires MemoryInspectorPanel, which already reads from
GET /workspaces/:id/v2/memories and already handles the
plugin-unavailable 503 with a banner.
So the user's "agent says wrote, UI shows nothing" symptom is fully
explained by the A0+A1 plumbing problem addressed in PR #1742 and
PR #1747 — the canvas surface was never wrong. This PR collapses to:
1. Delete the dead MemoryTab.tsx + its __tests__/MemoryTab.test.tsx.
This also completes the canvas portion of #1735 — the awareness
<iframe> block lived inside MemoryTab and goes away with it.
2. Remove the stale MemoryTab reference from a CSS-fix comment in
ChannelsTab.tsx (comment-only, no code change).
3. Add the missing piece on MemoryInspectorPanel: live-refresh
subscription on ACTIVITY_LOGGED events for memory writes on the
current workspace. Without this, even with A1 wiring v2 correctly,
the user still has to click Refresh after an agent commit. The
subscription follows the existing useSocketEvent pattern (prior
art: ActivityTab, A2ATopologyOverlay, CommunicationOverlay):
- Filter event === 'ACTIVITY_LOGGED'.
- Filter workspace_id matches this panel.
- Filter payload.activity_type is 'memory_write' or 'agent_log'
(the catch-all for agent-self-reported tool calls; matches the
server-side activity-type enum in activity.go:869).
- Debounce 300ms so a chatty agent doesn't hammer /v2/memories.
- Cleanup the timer on unmount.
Tests: vi.mock('@/hooks/useSocketEvent', …) captures the registered
handler. Four new tests in MemoryInspectorPanel.test.tsx cover:
- refetch fires on a memory_write event for the matching workspace
- ignores events from other workspaces
- ignores non-memory activity types (a2a_send tested as the
representative case)
- coalesces a burst of 5 events into exactly 1 refetch
Stage A:
- npx vitest run src/components/__tests__/MemoryInspectorPanel.test.tsx
→ 34 passed (including the 4 new live-refresh cases).
- Full canvas sweep: npx vitest run → 220 test files, 3348 passed,
1 skipped (~170s).
- npx tsc --noEmit clean on every file in this PR. The pre-existing
TS errors in ContextMenu.test.tsx, EmptyState.test.tsx,
OrgCancelButton.test.tsx, SidePanel.general.test.tsx,
WorkspaceNode.test.tsx, ChannelsTab.test.tsx are not new and not
touched here.
Sequencing:
- Independent of #1742 (A0 plugin schema isolation) and #1747 (A1
v1-fallback removal). Either order works. After A0+A1 land, the
live-refresh path is exercised on day 1; before they land, the
delete still ships clean and the new handler is dormant until v2 is
reachable.
Refs: closes #1734, completes the canvas portion of #1735, depends
on no Go change but unblocks the user-visible piece of the v2
memory work tracked in #1733.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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,32 @@ 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.
|
||||
//
|
||||
// Filter narrowly: same workspace, and activity_type that maps to a
|
||||
// memory write (`memory_write` is the explicit one; `agent_log` is
|
||||
// the catch-all an agent emits when calling its own MCP tools).
|
||||
// Coalesce bursts via the refetchTimerRef so a chatty agent 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) || '';
|
||||
if (activityType !== 'memory_write' && activityType !== 'agent_log') return;
|
||||
if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current);
|
||||
refetchTimerRef.current = setTimeout(() => {
|
||||
loadEntries();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// ── Delete handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
const confirmDelete = useCallback(async () => {
|
||||
|
||||
@@ -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,118 @@ 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();
|
||||
});
|
||||
|
||||
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