Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions apps/loopover-miner-ui/src/chat-rail.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ChatRail open onOpenChange={vi.fn()} />);

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(<ChatRail open={false} onOpenChange={vi.fn()} />);

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(<ChatRail open={false} onOpenChange={onOpenChange} />);

fireEvent.click(screen.getByRole("button", { name: /show chat/i }));
expect(onOpenChange).toHaveBeenLastCalledWith(true);

rerender(<ChatRail open onOpenChange={onOpenChange} />);
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(<ChatRail open onOpenChange={vi.fn()} />);

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(
<RootShell>
<div>Overview page</div>
</RootShell>,
);

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(
<RootShell>
<div>Overview page</div>
</RootShell>,
);

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(
<RootShell>
<div>Portfolio page</div>
</RootShell>,
);
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();
});
});
89 changes: 89 additions & 0 deletions apps/loopover-miner-ui/src/components/chat-rail.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex h-full flex-col gap-2 p-4">
<p className="font-mono text-token-xs uppercase tracking-[0.2em] text-primary">Chat</p>
<p className="text-token-sm text-muted-foreground">Ask about this miner&rsquo;s local state. Coming soon.</p>
</div>
);
}

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 (
<>
<Button
type="button"
variant="outline"
size="sm"
aria-expanded={open}
aria-controls={RAIL_PANEL_ID}
onClick={() => onOpenChange(!open)}
>
Chat
</Button>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent id={RAIL_PANEL_ID} side="right" className="w-[380px] p-0">
<SheetHeader className="sr-only">
<SheetTitle>Chat</SheetTitle>
<SheetDescription>Ask about this miner&rsquo;s local state.</SheetDescription>
</SheetHeader>
<RailBody />
</SheetContent>
</Sheet>
</>
);
}

// 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 (
<div className="flex shrink-0 flex-col items-end gap-2 p-2">
<Button
type="button"
variant="outline"
size="sm"
aria-expanded={open}
aria-controls={RAIL_PANEL_ID}
onClick={() => onOpenChange(!open)}
>
{open ? "Hide chat" : "Show chat"}
</Button>
<aside
id={RAIL_PANEL_ID}
aria-label="Chat"
data-state={open ? "open" : "collapsed"}
hidden={!open}
style={open ? { width: RAIL_WIDTH_PX } : undefined}
className="h-full border-l-hairline"
>
<RailBody />
</aside>
</div>
);
}
32 changes: 27 additions & 5 deletions apps/loopover-miner-ui/src/routes/__root.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<RootShell>
<Outlet />
</RootShell>
);
}

/**
* 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 `<Outlet/>` 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 (
<div className="min-h-screen bg-background text-foreground">
<header className="border-b-hairline px-6 py-4">
Expand Down Expand Up @@ -49,9 +67,13 @@ function RootLayout() {
<ThemeToggle />
</div>
</header>
<main className="mx-auto max-w-5xl px-6 py-8">
<Outlet />
</main>
{/* Row: routed content + the persistent rail docked beside it (never overlapping) on wide viewports. */}
<div className="mx-auto flex w-full max-w-[calc(64rem+380px)] items-stretch">
<main className="min-w-0 flex-1 px-6 py-8">
<div className="mx-auto max-w-5xl">{children}</div>
</main>
<ChatRail open={railOpen} onOpenChange={setRailOpen} />
</div>
<GrafanaFooterLink />
</div>
);
Expand Down
Loading