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) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-11 12:18:31 +00:00
parent d432326e7a
commit 8527beca56
2 changed files with 195 additions and 0 deletions

View file

@ -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 }) => (
<div data-testid="chat-conversation-list-stub" data-company-id={companyId}>
conversation-list
</div>
),
}));
import { HistorySheet } from "./HistorySheet";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("<HistorySheet />", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot> | 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(<HistorySheet open={false} onClose={() => {}} 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(<HistorySheet open onClose={() => {}} 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(<HistorySheet open onClose={() => {}} 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(<HistorySheet open onClose={() => {}} 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(<HistorySheet open onClose={onClose} companyId="co-1" />);
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(<HistorySheet open onClose={onClose} companyId="co-1" />);
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(<HistorySheet open={false} onClose={onClose} companyId="co-1" />);
act(() => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
});
expect(onClose).not.toHaveBeenCalled();
});
it("uses a left-side panel positioned against the icon rail", () => {
render(<HistorySheet open onClose={() => {}} 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]");
});
});

View file

@ -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 */}
<button
type="button"
onClick={onClose}
aria-label="Close history"
data-testid="history-sheet-backdrop"
className="fixed inset-0 z-40 bg-black/40"
/>
{/* Panel */}
<aside
role="dialog"
aria-modal="true"
aria-label="Conversation history"
data-testid="history-sheet-panel"
className={cn(
"fixed top-12 bottom-0 left-[56px] z-50 w-[320px] flex flex-col",
"border-r 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">
History
</h2>
<button
type="button"
onClick={onClose}
aria-label="Close history"
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 min-h-0 overflow-hidden">
{companyId ? (
<ChatConversationList companyId={companyId} />
) : (
<p className="px-4 py-6 text-xs text-muted-foreground">
Select a workspace to view conversations.
</p>
)}
</div>
</aside>
</>
);
}