feat(nexus): add MemorySheet slide-over (phase 9)

Right-side 340px slide-over that lists the facts stored in the existing
assistantMemoryApi and lets the user append new facts or clear memory.
Handles loading, error, and empty states, closes on backdrop click or
ESC, and degrades to a workspace prompt when no company is selected.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-11 12:19:36 +00:00
parent 2c3f4ff623
commit a0cb132e9d
2 changed files with 368 additions and 0 deletions

View file

@ -0,0 +1,140 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// Mock the memory API before importing the component.
const getMemory = vi.fn();
const appendFact = vi.fn();
const clearMemory = vi.fn();
vi.mock("../../api/assistantMemory", () => ({
assistantMemoryApi: {
getMemory: (...args: unknown[]) => getMemory(...args),
appendFact: (...args: unknown[]) => appendFact(...args),
clearMemory: (...args: unknown[]) => clearMemory(...args),
},
}));
import { MemorySheet } from "./MemorySheet";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function flush() {
return new Promise<void>((resolve) => setTimeout(resolve, 0));
}
describe("<MemorySheet />", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot> | null = null;
let queryClient: QueryClient;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = null;
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
getMemory.mockReset();
appendFact.mockReset();
clearMemory.mockReset();
});
afterEach(() => {
if (root) {
act(() => {
root!.unmount();
});
root = null;
}
if (container.parentNode) container.remove();
});
function render(node: React.ReactNode) {
root = createRoot(container);
act(() => {
root!.render(<QueryClientProvider client={queryClient}>{node}</QueryClientProvider>);
});
}
it("renders nothing when closed", () => {
render(<MemorySheet open={false} onClose={() => {}} companyId="co-1" />);
expect(container.querySelector('[data-testid="memory-sheet-panel"]')).toBeNull();
expect(getMemory).not.toHaveBeenCalled();
});
it("renders the panel and backdrop when open", () => {
getMemory.mockResolvedValue({ companyId: "co-1", facts: [], updatedAt: null });
render(<MemorySheet open onClose={() => {}} companyId="co-1" />);
expect(container.querySelector('[data-testid="memory-sheet-panel"]')).not.toBeNull();
expect(container.querySelector('[data-testid="memory-sheet-backdrop"]')).not.toBeNull();
});
it("shows a workspace prompt when companyId is null", () => {
render(<MemorySheet open onClose={() => {}} companyId={null} />);
expect(container.textContent ?? "").toContain("Select a workspace");
expect(getMemory).not.toHaveBeenCalled();
});
it("lists facts returned by the API", async () => {
getMemory.mockResolvedValue({
companyId: "co-1",
facts: ["Prefers terse answers", "Uses Claude Code"],
updatedAt: "2026-04-11T10:00:00Z",
});
render(<MemorySheet open onClose={() => {}} companyId="co-1" />);
// Wait for the query to resolve and React to flush.
await act(async () => {
await flush();
await flush();
});
const list = container.querySelector('[data-testid="memory-sheet-facts"]');
expect(list).not.toBeNull();
const items = list!.querySelectorAll("li");
expect(items).toHaveLength(2);
expect(items[0]!.textContent).toContain("Prefers terse answers");
expect(items[1]!.textContent).toContain("Uses Claude Code");
});
it("shows an empty-state message when there are no facts", async () => {
getMemory.mockResolvedValue({ companyId: "co-1", facts: [], updatedAt: null });
render(<MemorySheet open onClose={() => {}} companyId="co-1" />);
await act(async () => {
await flush();
await flush();
});
expect(container.querySelector('[data-testid="memory-sheet-empty"]')).not.toBeNull();
});
it("closes on backdrop click and on ESC", async () => {
getMemory.mockResolvedValue({ companyId: "co-1", facts: [], updatedAt: null });
const onClose = vi.fn();
render(<MemorySheet open onClose={onClose} companyId="co-1" />);
const backdrop = container.querySelector(
'[data-testid="memory-sheet-backdrop"]',
) as HTMLButtonElement;
act(() => {
backdrop.click();
});
expect(onClose).toHaveBeenCalledTimes(1);
act(() => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
});
expect(onClose).toHaveBeenCalledTimes(2);
});
it("uses the right-side 340px panel position", () => {
getMemory.mockResolvedValue({ companyId: "co-1", facts: [], updatedAt: null });
render(<MemorySheet open onClose={() => {}} companyId="co-1" />);
const panel = container.querySelector('[data-testid="memory-sheet-panel"]') as HTMLElement;
expect(panel.className).toContain("right-0");
expect(panel.className).toContain("w-[340px]");
});
});

View file

@ -0,0 +1,228 @@
// [nexus] Memory slide-over sheet for the Assistant (Phase 9).
//
// Right-side 340px slide-over. Reads from the existing assistantMemoryApi
// and renders the list of remembered facts. Phase 9 supports "append fact"
// and "clear memory"; richer editing (edit individual facts, reorder,
// delete one) is a future affordance and left as a comment hook.
import { useEffect, useState } from "react";
import { X, Plus, Trash2, Loader2 } from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { assistantMemoryApi, type AssistantMemory } from "../../api/assistantMemory";
import { cn } from "@/lib/utils";
export interface MemorySheetProps {
open: boolean;
onClose: () => void;
companyId: string | null;
className?: string;
}
export function MemorySheet({ open, onClose, companyId, className }: MemorySheetProps) {
// ESC closes the sheet.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
onClose();
}
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
const queryClient = useQueryClient();
const enabled = open && !!companyId;
const { data, isLoading, isError, refetch } = useQuery<AssistantMemory>({
queryKey: ["assistant-memory", companyId],
queryFn: () => assistantMemoryApi.getMemory(companyId!),
enabled,
staleTime: 30_000,
});
const [draft, setDraft] = useState("");
const [pending, setPending] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
async function handleAppend() {
const value = draft.trim();
if (!value || !companyId || pending) return;
setPending(true);
setErrorMsg(null);
try {
await assistantMemoryApi.appendFact(companyId, value);
setDraft("");
queryClient.invalidateQueries({ queryKey: ["assistant-memory", companyId] });
} catch (err) {
setErrorMsg(err instanceof Error ? err.message : "Could not save memory");
} finally {
setPending(false);
}
}
async function handleClear() {
if (!companyId || pending) return;
const confirmed = window.confirm("Clear all assistant memory? This cannot be undone.");
if (!confirmed) return;
setPending(true);
setErrorMsg(null);
try {
await assistantMemoryApi.clearMemory(companyId);
queryClient.invalidateQueries({ queryKey: ["assistant-memory", companyId] });
} catch (err) {
setErrorMsg(err instanceof Error ? err.message : "Could not clear memory");
} finally {
setPending(false);
}
}
if (!open) return null;
return (
<>
<button
type="button"
onClick={onClose}
aria-label="Close memory"
data-testid="memory-sheet-backdrop"
className="fixed inset-0 z-40 bg-black/40"
/>
<aside
role="dialog"
aria-modal="true"
aria-label="Assistant memory"
data-testid="memory-sheet-panel"
className={cn(
"fixed top-12 bottom-0 right-0 z-50 w-[340px] flex flex-col",
"border-l border-border bg-background",
className,
)}
>
<header className="flex items-center justify-between px-4 py-3 border-b border-border">
<h2 className="text-[11px] font-semibold uppercase tracking-[0.1em] text-muted-foreground">
Memory
</h2>
<button
type="button"
onClick={onClose}
aria-label="Close memory"
className={cn(
"flex h-7 w-7 items-center justify-center rounded-[4px] text-muted-foreground hover:text-primary",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
>
<X className="h-4 w-4" />
</button>
</header>
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
{!companyId && (
<p className="text-xs text-muted-foreground">
Select a workspace to view assistant memory.
</p>
)}
{companyId && isLoading && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
Loading memory
</div>
)}
{companyId && isError && (
<div className="flex flex-col gap-2 text-xs text-muted-foreground">
<p>Couldn't load assistant memory.</p>
<button
type="button"
onClick={() => refetch()}
className="self-start rounded-[4px] border border-border px-2 py-1 text-[11px] uppercase tracking-[0.1em] text-muted-foreground hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
Retry
</button>
</div>
)}
{companyId && data && data.facts.length === 0 && (
<p className="text-xs text-muted-foreground" data-testid="memory-sheet-empty">
Nothing remembered yet. Add a fact below and the assistant will carry it
across conversations.
</p>
)}
{companyId && data && data.facts.length > 0 && (
<ul data-testid="memory-sheet-facts" className="space-y-2">
{data.facts.map((fact, index) => (
<li
key={`${index}-${fact.slice(0, 24)}`}
className="rounded-[4px] border border-border bg-card px-3 py-2 text-xs text-foreground"
>
{fact}
</li>
))}
</ul>
)}
{errorMsg && (
<p className="text-xs text-destructive" data-testid="memory-sheet-error">
{errorMsg}
</p>
)}
</div>
{companyId && (
<footer className="border-t border-border px-4 py-3 space-y-2">
<label className="block text-[10px] font-semibold uppercase tracking-[0.1em] text-muted-foreground">
Teach the assistant
</label>
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="e.g. Prefers terse answers"
rows={2}
data-testid="memory-sheet-draft"
className={cn(
"w-full rounded-[4px] border border-border bg-card px-2 py-1.5 text-xs text-foreground placeholder:text-muted-foreground",
"resize-none",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
disabled={pending}
/>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleAppend}
disabled={pending || !draft.trim()}
aria-label="Add memory fact"
data-testid="memory-sheet-add"
className={cn(
"inline-flex items-center gap-1 rounded-[4px] border border-primary px-2 py-1 text-[11px] uppercase tracking-[0.1em] text-primary",
"hover:bg-primary/10 disabled:border-border disabled:text-muted-foreground disabled:cursor-not-allowed",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
>
{pending ? <Loader2 className="h-3 w-3 animate-spin" /> : <Plus className="h-3 w-3" />}
Add
</button>
<button
type="button"
onClick={handleClear}
disabled={pending || !data || data.facts.length === 0}
aria-label="Clear all memory"
data-testid="memory-sheet-clear"
className={cn(
"inline-flex items-center gap-1 rounded-[4px] px-2 py-1 text-[11px] uppercase tracking-[0.1em] text-muted-foreground",
"hover:text-primary disabled:cursor-not-allowed disabled:hover:text-muted-foreground/40",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
>
<Trash2 className="h-3 w-3" />
Clear
</button>
</div>
</footer>
)}
</aside>
</>
);
}