From a0cb132e9dc158ee0e659570fb3fd7f72e6cca4c Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Sat, 11 Apr 2026 12:19:36 +0000 Subject: [PATCH] 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) --- .../components/assistant/MemorySheet.test.tsx | 140 +++++++++++ ui/src/components/assistant/MemorySheet.tsx | 228 ++++++++++++++++++ 2 files changed, 368 insertions(+) create mode 100644 ui/src/components/assistant/MemorySheet.test.tsx create mode 100644 ui/src/components/assistant/MemorySheet.tsx 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 && ( +