From 8527beca560bc252f654e09ea2b34db97fc129d3 Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Sat, 11 Apr 2026 12:18:31 +0000 Subject: [PATCH] feat(nexus): add HistorySheet slide-over (phase 9) Left-side 320px slide-over butted against the icon rail. Wraps the existing ChatConversationList so conversation grouping, search, pinning and create/rename flows are preserved as-is. Closes on backdrop click or ESC and includes a dismiss button in the header. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../assistant/HistorySheet.test.tsx | 111 ++++++++++++++++++ ui/src/components/assistant/HistorySheet.tsx | 84 +++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 ui/src/components/assistant/HistorySheet.test.tsx create mode 100644 ui/src/components/assistant/HistorySheet.tsx diff --git a/ui/src/components/assistant/HistorySheet.test.tsx b/ui/src/components/assistant/HistorySheet.test.tsx new file mode 100644 index 00000000..26f6d098 --- /dev/null +++ b/ui/src/components/assistant/HistorySheet.test.tsx @@ -0,0 +1,111 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Stub ChatConversationList to avoid pulling its dependency tree into the +// test (react-query, ChatPanelContext, etc.). We only want to assert that +// HistorySheet mounts it when companyId is present. +vi.mock("../ChatConversationList", () => ({ + ChatConversationList: ({ companyId }: { companyId: string }) => ( +
+ conversation-list +
+ ), +})); + +import { HistorySheet } from "./HistorySheet"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +describe("", () => { + let container: HTMLDivElement; + let root: ReturnType | null = null; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = null; + }); + + 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="history-sheet-panel"]')).toBeNull(); + expect(container.querySelector('[data-testid="history-sheet-backdrop"]')).toBeNull(); + }); + + it("renders the panel and a backdrop when open", () => { + render( {}} companyId="co-1" />); + expect(container.querySelector('[data-testid="history-sheet-panel"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="history-sheet-backdrop"]')).not.toBeNull(); + }); + + it("mounts ChatConversationList inside the panel when companyId is present", () => { + render( {}} companyId="co-1" />); + const stub = container.querySelector('[data-testid="chat-conversation-list-stub"]'); + expect(stub).not.toBeNull(); + expect(stub?.getAttribute("data-company-id")).toBe("co-1"); + }); + + it("shows a placeholder when companyId is null", () => { + render( {}} companyId={null} />); + expect(container.querySelector('[data-testid="chat-conversation-list-stub"]')).toBeNull(); + expect(container.textContent ?? "").toContain("Select a workspace"); + }); + + it("calls onClose when the backdrop is clicked", () => { + const onClose = vi.fn(); + render(); + const backdrop = container.querySelector( + '[data-testid="history-sheet-backdrop"]', + ) as HTMLButtonElement; + act(() => { + backdrop.click(); + }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("calls onClose when ESC is pressed", () => { + const onClose = vi.fn(); + render(); + act(() => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("does not listen for ESC when closed", () => { + const onClose = vi.fn(); + render(); + act(() => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + }); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("uses a left-side panel positioned against the icon rail", () => { + render( {}} companyId="co-1" />); + const panel = container.querySelector('[data-testid="history-sheet-panel"]') as HTMLElement; + // The Tailwind class string should include left-[56px] and w-[320px]. + expect(panel.className).toContain("left-[56px]"); + expect(panel.className).toContain("w-[320px]"); + }); +}); diff --git a/ui/src/components/assistant/HistorySheet.tsx b/ui/src/components/assistant/HistorySheet.tsx new file mode 100644 index 00000000..bf61f67a --- /dev/null +++ b/ui/src/components/assistant/HistorySheet.tsx @@ -0,0 +1,84 @@ +// [nexus] History slide-over sheet for the Assistant (Phase 9). +// +// Left-side slide-over that butts against the 56px icon rail. Wraps the +// existing ChatConversationList so we preserve the conversation model +// without reimplementing search, pinning, or create/rename. +import { useEffect } from "react"; +import { X } from "lucide-react"; +import { ChatConversationList } from "../ChatConversationList"; +import { cn } from "@/lib/utils"; + +export interface HistorySheetProps { + open: boolean; + onClose: () => void; + companyId: string | null; + className?: string; +} + +export function HistorySheet({ open, onClose, companyId, className }: HistorySheetProps) { + // 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]); + + if (!open) return null; + + return ( + <> + {/* Backdrop — click to dismiss */} + + +
+ {companyId ? ( + + ) : ( +

+ Select a workspace to view conversations. +

+ )} +
+ + + ); +}