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:
parent
d432326e7a
commit
8527beca56
2 changed files with 195 additions and 0 deletions
111
ui/src/components/assistant/HistorySheet.test.tsx
Normal file
111
ui/src/components/assistant/HistorySheet.test.tsx
Normal 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]");
|
||||||
|
});
|
||||||
|
});
|
||||||
84
ui/src/components/assistant/HistorySheet.tsx
Normal file
84
ui/src/components/assistant/HistorySheet.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue