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