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:
parent
2c3f4ff623
commit
a0cb132e9d
2 changed files with 368 additions and 0 deletions
140
ui/src/components/assistant/MemorySheet.test.tsx
Normal file
140
ui/src/components/assistant/MemorySheet.test.tsx
Normal 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]");
|
||||
});
|
||||
});
|
||||
228
ui/src/components/assistant/MemorySheet.tsx
Normal file
228
ui/src/components/assistant/MemorySheet.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue