diff --git a/apps/loopover-miner-ui/src/chat-rail.test.tsx b/apps/loopover-miner-ui/src/chat-rail.test.tsx new file mode 100644 index 0000000000..6290908b33 --- /dev/null +++ b/apps/loopover-miner-ui/src/chat-rail.test.tsx @@ -0,0 +1,123 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +// The TanStack Router lib is not under test here — RootShell's own rail-state persistence is. Stub Link so the +// shell renders in isolation without a live RouterProvider (Link would otherwise throw for lack of a router +// context). The routed page is passed to RootShell as `children` in these tests. +vi.mock("@tanstack/react-router", async () => { + const react = await import("react"); + return { + createRootRoute: (options: unknown) => ({ options }), + Outlet: () => null, + Link: ({ children, to, ...rest }: { children?: React.ReactNode; to?: unknown }) => + react.createElement("a", { href: typeof to === "string" ? to : "#", ...rest }, children), + }; +}); + +import { ChatRail } from "./components/chat-rail"; +import { RootShell } from "./routes/__root"; + +const originalInnerWidth = window.innerWidth; + +// useIsMobile decides off window.innerWidth and needs window.matchMedia to exist (jsdom omits it). Set both so +// the same setViewport() call drives the docked-vs-sheet branch deterministically. +function setViewport(width: number) { + Object.defineProperty(window, "innerWidth", { configurable: true, writable: true, value: width }); + vi.stubGlobal( + "matchMedia", + vi.fn().mockImplementation((query: string) => ({ + matches: width < 768, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); +} + +afterEach(() => { + Object.defineProperty(window, "innerWidth", { configurable: true, writable: true, value: originalInnerWidth }); + vi.unstubAllGlobals(); +}); + +describe("ChatRail (#6513)", () => { + it("docks a complementary panel (not a sheet) on a wide viewport when open", () => { + setViewport(1200); + render(); + + const panel = screen.getByRole("complementary", { name: /chat/i }); + expect(panel.getAttribute("data-state")).toBe("open"); + expect(screen.queryByRole("dialog")).toBeNull(); // docked, not the mobile sheet + }); + + it("hides the docked panel from the a11y tree when collapsed, keeping the toggle visible", () => { + setViewport(1200); + render(); + + expect(screen.queryByRole("complementary")).toBeNull(); // collapsed → hidden + expect(screen.getByRole("button", { name: /show chat/i })).toBeTruthy(); + }); + + it("the toggle requests an open/close change on a wide viewport", () => { + setViewport(1200); + const onOpenChange = vi.fn(); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: /show chat/i })); + expect(onOpenChange).toHaveBeenLastCalledWith(true); + + rerender(); + fireEvent.click(screen.getByRole("button", { name: /hide chat/i })); + expect(onOpenChange).toHaveBeenLastCalledWith(false); + }); + + it("uses the ui-kit Sheet slide-over (not the docked panel) below the mobile breakpoint", () => { + setViewport(400); + render(); + + expect(screen.getByRole("dialog")).toBeTruthy(); // Sheet content + expect(screen.queryByRole("complementary")).toBeNull(); // never the docked panel on mobile + }); +}); + +describe("RootShell chat-rail integration (#6513)", () => { + it("mounts exactly one rail toggle and renders the routed content", () => { + setViewport(1200); + render( + +
Overview page
+
, + ); + + expect(screen.getByText("Overview page")).toBeTruthy(); + expect(screen.getAllByRole("button", { name: /chat/i })).toHaveLength(1); // mounted once + }); + + it("keeps the rail's open state across a simulated client-side navigation", () => { + setViewport(1200); + const { rerender } = render( + +
Overview page
+
, + ); + + fireEvent.click(screen.getByRole("button", { name: /show chat/i })); + expect(screen.getByRole("complementary", { name: /chat/i })).toBeTruthy(); + + // Navigate: the Outlet content swaps while RootShell stays mounted. + rerender( + +
Portfolio page
+
, + ); + expect(screen.getByText("Portfolio page")).toBeTruthy(); + expect(screen.queryByText("Overview page")).toBeNull(); + + // Rail state survived the navigation. + expect(screen.getByRole("complementary", { name: /chat/i })).toBeTruthy(); + expect(screen.getByRole("button", { name: /hide chat/i })).toBeTruthy(); + }); +}); diff --git a/apps/loopover-miner-ui/src/components/chat-rail.tsx b/apps/loopover-miner-ui/src/components/chat-rail.tsx new file mode 100644 index 0000000000..52adee8e22 --- /dev/null +++ b/apps/loopover-miner-ui/src/components/chat-rail.tsx @@ -0,0 +1,89 @@ +// Persistent chat-rail shell (#6513). A pure structural shell mounted once in __root.tsx so it survives +// client-side route navigation: on wide viewports it docks as a ~380px panel beside the routed content; below +// the ui-kit `useIsMobile` breakpoint it collapses to the same `Sheet`-based slide-over `sidebar.tsx` uses for +// its own mobile mode (rather than a second, bespoke mobile-collapse mechanism). This ships with static +// placeholder content only — no composer, message list, streaming, or backend call; those layer on later. +import * as React from "react"; + +import { Button } from "@loopover/ui-kit/components/button"; +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@loopover/ui-kit/components/sheet"; +import { useIsMobile } from "@loopover/ui-kit/hooks/use-mobile"; + +const RAIL_WIDTH_PX = 380; +const RAIL_PANEL_ID = "chat-rail-panel"; + +/** The rail's inner content. Static placeholder for this shell issue — the real composer/message-list land later. */ +function RailBody() { + return ( +
+

Chat

+

Ask about this miner’s local state. Coming soon.

+
+ ); +} + +export interface ChatRailProps { + /** Whether the rail is expanded (docked panel / open sheet). Owned by the mounting shell so it survives nav. */ + open: boolean; + /** Requests an open/closed change — from the toggle button or the sheet's own dismiss affordances. */ + onOpenChange: (open: boolean) => void; +} + +export function ChatRail({ open, onOpenChange }: ChatRailProps) { + const isMobile = useIsMobile(); + + // Below the breakpoint: reuse the ui-kit Sheet slide-over (same mechanism sidebar.tsx uses on mobile), rather + // than docking a 380px panel that would swamp a narrow viewport. + if (isMobile) { + return ( + <> + + + + + Chat + Ask about this miner’s local state. + + + + + + ); + } + + // Wide viewport: dock a ~380px panel beside the routed content. Collapsing only hides it (never unmounts it), + // so any future in-rail state is preserved across an expand/collapse cycle. + return ( +
+ + +
+ ); +} diff --git a/apps/loopover-miner-ui/src/routes/__root.tsx b/apps/loopover-miner-ui/src/routes/__root.tsx index f92ec776ed..cf2fc5ee43 100644 --- a/apps/loopover-miner-ui/src/routes/__root.tsx +++ b/apps/loopover-miner-ui/src/routes/__root.tsx @@ -1,12 +1,30 @@ import { Outlet, createRootRoute, Link } from "@tanstack/react-router"; +import * as React from "react"; import { GrafanaFooterLink } from "@/components/grafana-footer-link"; import { ThemeToggle } from "@/components/theme-toggle"; +import { ChatRail } from "@/components/chat-rail"; export const Route = createRootRoute({ - component: RootLayout, + component: RootComponent, }); -function RootLayout() { +function RootComponent() { + return ( + + + + ); +} + +/** + * The persistent app shell (#6513). Exported for unit testing. It owns the chat-rail open/collapsed state, and + * because it's rendered by the root route, TanStack Router keeps it — and that state — mounted across + * client-side navigation between the four routes, so the rail never resets on a route change. The routed page + * is `children` (the `` content), which is what swaps on navigation while this shell stays mounted. + */ +export function RootShell({ children }: { children: React.ReactNode }) { + const [railOpen, setRailOpen] = React.useState(false); + return (
@@ -49,9 +67,13 @@ function RootLayout() {
-
- -
+ {/* Row: routed content + the persistent rail docked beside it (never overlapping) on wide viewports. */} +
+
+
{children}
+
+ +
);