diff --git a/ui/src/components/assistant/MemorySheet.test.tsx b/ui/src/components/assistant/MemorySheet.test.tsx new file mode 100644 index 00000000..a20b7322 --- /dev/null +++ b/ui/src/components/assistant/MemorySheet.test.tsx @@ -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((resolve) => setTimeout(resolve, 0)); +} + +describe("", () => { + let container: HTMLDivElement; + let root: ReturnType | 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({node}); + }); + } + + it("renders nothing when closed", () => { + render( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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(); + + 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( {}} 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]"); + }); +}); diff --git a/ui/src/components/assistant/MemorySheet.tsx b/ui/src/components/assistant/MemorySheet.tsx new file mode 100644 index 00000000..49f349f9 --- /dev/null +++ b/ui/src/components/assistant/MemorySheet.tsx @@ -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({ + 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(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 ( + <> + + + +
+ {!companyId && ( +

+ Select a workspace to view assistant memory. +

+ )} + + {companyId && isLoading && ( +
+ + Loading memory… +
+ )} + + {companyId && isError && ( +
+

Couldn't load assistant memory.

+ +
+ )} + + {companyId && data && data.facts.length === 0 && ( +

+ Nothing remembered yet. Add a fact below and the assistant will carry it + across conversations. +

+ )} + + {companyId && data && data.facts.length > 0 && ( +
    + {data.facts.map((fact, index) => ( +
  • + {fact} +
  • + ))} +
+ )} + + {errorMsg && ( +

+ {errorMsg} +

+ )} +
+ + {companyId && ( +