Skip to content

Commit 93f3467

Browse files
feat(miner-ui): add the persistent collapsible chat rail shell (#6513) (#6564)
Mounts a persistent chat rail once in the root route so it survives client-side navigation across the four routes. - apps/loopover-miner-ui/src/components/chat-rail.tsx — a pure structural shell. Wide viewports dock a ~380px complementary panel beside the routed content; below the ui-kit useIsMobile breakpoint it collapses to the same Sheet-based slide-over sidebar.tsx uses on mobile (imported from @loopover/ui-kit, not a second bespoke mobile-collapse mechanism). A visible toggle expands/collapses it; collapsing only hides the docked panel (never unmounts it), so future in-rail state is preserved across an expand/collapse cycle. Static placeholder content only — no composer, message list, streaming, or backend call. - apps/loopover-miner-ui/src/routes/__root.tsx — restructured into a row that holds the routed <main> and the rail side by side without changing the four routes' own content (and preserving the existing active-route nav highlighting). The rail open/collapsed state lives in the exported RootShell that the root route mounts once, so it persists across route navigation. No @loopover/ui-kit change, no route-tree change, no scroll-area/avatar/ state-views usage, no config flag. This app's files are outside Codecov's coverage.include; the local vitest gate stays green. Tests — apps/loopover-miner-ui/src/chat-rail.test.tsx: - Wide viewport docks a complementary panel (not the sheet); collapsed hides it from the a11y tree while the toggle stays visible; the toggle requests the open/close change. - Below the mobile breakpoint the rail renders via the ui-kit Sheet slide-over, not the docked panel. - RootShell mounts exactly one rail and keeps its open state across a simulated client-side navigation (the Outlet content swaps while the shell stays mounted). Closes #6513
1 parent e066a90 commit 93f3467

3 files changed

Lines changed: 239 additions & 5 deletions

File tree

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { fireEvent, render, screen } from "@testing-library/react";
2+
import { afterEach, describe, expect, it, vi } from "vitest";
3+
4+
// The TanStack Router lib is not under test here — RootShell's own rail-state persistence is. Stub Link so the
5+
// shell renders in isolation without a live RouterProvider (Link would otherwise throw for lack of a router
6+
// context). The routed page is passed to RootShell as `children` in these tests.
7+
vi.mock("@tanstack/react-router", async () => {
8+
const react = await import("react");
9+
return {
10+
createRootRoute: (options: unknown) => ({ options }),
11+
Outlet: () => null,
12+
Link: ({ children, to, ...rest }: { children?: React.ReactNode; to?: unknown }) =>
13+
react.createElement("a", { href: typeof to === "string" ? to : "#", ...rest }, children),
14+
};
15+
});
16+
17+
import { ChatRail } from "./components/chat-rail";
18+
import { RootShell } from "./routes/__root";
19+
20+
const originalInnerWidth = window.innerWidth;
21+
22+
// useIsMobile decides off window.innerWidth and needs window.matchMedia to exist (jsdom omits it). Set both so
23+
// the same setViewport() call drives the docked-vs-sheet branch deterministically.
24+
function setViewport(width: number) {
25+
Object.defineProperty(window, "innerWidth", { configurable: true, writable: true, value: width });
26+
vi.stubGlobal(
27+
"matchMedia",
28+
vi.fn().mockImplementation((query: string) => ({
29+
matches: width < 768,
30+
media: query,
31+
onchange: null,
32+
addEventListener: vi.fn(),
33+
removeEventListener: vi.fn(),
34+
addListener: vi.fn(),
35+
removeListener: vi.fn(),
36+
dispatchEvent: vi.fn(),
37+
})),
38+
);
39+
}
40+
41+
afterEach(() => {
42+
Object.defineProperty(window, "innerWidth", { configurable: true, writable: true, value: originalInnerWidth });
43+
vi.unstubAllGlobals();
44+
});
45+
46+
describe("ChatRail (#6513)", () => {
47+
it("docks a complementary panel (not a sheet) on a wide viewport when open", () => {
48+
setViewport(1200);
49+
render(<ChatRail open onOpenChange={vi.fn()} />);
50+
51+
const panel = screen.getByRole("complementary", { name: /chat/i });
52+
expect(panel.getAttribute("data-state")).toBe("open");
53+
expect(screen.queryByRole("dialog")).toBeNull(); // docked, not the mobile sheet
54+
});
55+
56+
it("hides the docked panel from the a11y tree when collapsed, keeping the toggle visible", () => {
57+
setViewport(1200);
58+
render(<ChatRail open={false} onOpenChange={vi.fn()} />);
59+
60+
expect(screen.queryByRole("complementary")).toBeNull(); // collapsed → hidden
61+
expect(screen.getByRole("button", { name: /show chat/i })).toBeTruthy();
62+
});
63+
64+
it("the toggle requests an open/close change on a wide viewport", () => {
65+
setViewport(1200);
66+
const onOpenChange = vi.fn();
67+
const { rerender } = render(<ChatRail open={false} onOpenChange={onOpenChange} />);
68+
69+
fireEvent.click(screen.getByRole("button", { name: /show chat/i }));
70+
expect(onOpenChange).toHaveBeenLastCalledWith(true);
71+
72+
rerender(<ChatRail open onOpenChange={onOpenChange} />);
73+
fireEvent.click(screen.getByRole("button", { name: /hide chat/i }));
74+
expect(onOpenChange).toHaveBeenLastCalledWith(false);
75+
});
76+
77+
it("uses the ui-kit Sheet slide-over (not the docked panel) below the mobile breakpoint", () => {
78+
setViewport(400);
79+
render(<ChatRail open onOpenChange={vi.fn()} />);
80+
81+
expect(screen.getByRole("dialog")).toBeTruthy(); // Sheet content
82+
expect(screen.queryByRole("complementary")).toBeNull(); // never the docked panel on mobile
83+
});
84+
});
85+
86+
describe("RootShell chat-rail integration (#6513)", () => {
87+
it("mounts exactly one rail toggle and renders the routed content", () => {
88+
setViewport(1200);
89+
render(
90+
<RootShell>
91+
<div>Overview page</div>
92+
</RootShell>,
93+
);
94+
95+
expect(screen.getByText("Overview page")).toBeTruthy();
96+
expect(screen.getAllByRole("button", { name: /chat/i })).toHaveLength(1); // mounted once
97+
});
98+
99+
it("keeps the rail's open state across a simulated client-side navigation", () => {
100+
setViewport(1200);
101+
const { rerender } = render(
102+
<RootShell>
103+
<div>Overview page</div>
104+
</RootShell>,
105+
);
106+
107+
fireEvent.click(screen.getByRole("button", { name: /show chat/i }));
108+
expect(screen.getByRole("complementary", { name: /chat/i })).toBeTruthy();
109+
110+
// Navigate: the Outlet content swaps while RootShell stays mounted.
111+
rerender(
112+
<RootShell>
113+
<div>Portfolio page</div>
114+
</RootShell>,
115+
);
116+
expect(screen.getByText("Portfolio page")).toBeTruthy();
117+
expect(screen.queryByText("Overview page")).toBeNull();
118+
119+
// Rail state survived the navigation.
120+
expect(screen.getByRole("complementary", { name: /chat/i })).toBeTruthy();
121+
expect(screen.getByRole("button", { name: /hide chat/i })).toBeTruthy();
122+
});
123+
});
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Persistent chat-rail shell (#6513). A pure structural shell mounted once in __root.tsx so it survives
2+
// client-side route navigation: on wide viewports it docks as a ~380px panel beside the routed content; below
3+
// the ui-kit `useIsMobile` breakpoint it collapses to the same `Sheet`-based slide-over `sidebar.tsx` uses for
4+
// its own mobile mode (rather than a second, bespoke mobile-collapse mechanism). This ships with static
5+
// placeholder content only — no composer, message list, streaming, or backend call; those layer on later.
6+
import * as React from "react";
7+
8+
import { Button } from "@loopover/ui-kit/components/button";
9+
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@loopover/ui-kit/components/sheet";
10+
import { useIsMobile } from "@loopover/ui-kit/hooks/use-mobile";
11+
12+
const RAIL_WIDTH_PX = 380;
13+
const RAIL_PANEL_ID = "chat-rail-panel";
14+
15+
/** The rail's inner content. Static placeholder for this shell issue — the real composer/message-list land later. */
16+
function RailBody() {
17+
return (
18+
<div className="flex h-full flex-col gap-2 p-4">
19+
<p className="font-mono text-token-xs uppercase tracking-[0.2em] text-primary">Chat</p>
20+
<p className="text-token-sm text-muted-foreground">Ask about this miner&rsquo;s local state. Coming soon.</p>
21+
</div>
22+
);
23+
}
24+
25+
export interface ChatRailProps {
26+
/** Whether the rail is expanded (docked panel / open sheet). Owned by the mounting shell so it survives nav. */
27+
open: boolean;
28+
/** Requests an open/closed change — from the toggle button or the sheet's own dismiss affordances. */
29+
onOpenChange: (open: boolean) => void;
30+
}
31+
32+
export function ChatRail({ open, onOpenChange }: ChatRailProps) {
33+
const isMobile = useIsMobile();
34+
35+
// Below the breakpoint: reuse the ui-kit Sheet slide-over (same mechanism sidebar.tsx uses on mobile), rather
36+
// than docking a 380px panel that would swamp a narrow viewport.
37+
if (isMobile) {
38+
return (
39+
<>
40+
<Button
41+
type="button"
42+
variant="outline"
43+
size="sm"
44+
aria-expanded={open}
45+
aria-controls={RAIL_PANEL_ID}
46+
onClick={() => onOpenChange(!open)}
47+
>
48+
Chat
49+
</Button>
50+
<Sheet open={open} onOpenChange={onOpenChange}>
51+
<SheetContent id={RAIL_PANEL_ID} side="right" className="w-[380px] p-0">
52+
<SheetHeader className="sr-only">
53+
<SheetTitle>Chat</SheetTitle>
54+
<SheetDescription>Ask about this miner&rsquo;s local state.</SheetDescription>
55+
</SheetHeader>
56+
<RailBody />
57+
</SheetContent>
58+
</Sheet>
59+
</>
60+
);
61+
}
62+
63+
// Wide viewport: dock a ~380px panel beside the routed content. Collapsing only hides it (never unmounts it),
64+
// so any future in-rail state is preserved across an expand/collapse cycle.
65+
return (
66+
<div className="flex shrink-0 flex-col items-end gap-2 p-2">
67+
<Button
68+
type="button"
69+
variant="outline"
70+
size="sm"
71+
aria-expanded={open}
72+
aria-controls={RAIL_PANEL_ID}
73+
onClick={() => onOpenChange(!open)}
74+
>
75+
{open ? "Hide chat" : "Show chat"}
76+
</Button>
77+
<aside
78+
id={RAIL_PANEL_ID}
79+
aria-label="Chat"
80+
data-state={open ? "open" : "collapsed"}
81+
hidden={!open}
82+
style={open ? { width: RAIL_WIDTH_PX } : undefined}
83+
className="h-full border-l-hairline"
84+
>
85+
<RailBody />
86+
</aside>
87+
</div>
88+
);
89+
}

apps/loopover-miner-ui/src/routes/__root.tsx

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,30 @@
11
import { Outlet, createRootRoute, Link } from "@tanstack/react-router";
2+
import * as React from "react";
23
import { GrafanaFooterLink } from "@/components/grafana-footer-link";
34
import { ThemeToggle } from "@/components/theme-toggle";
5+
import { ChatRail } from "@/components/chat-rail";
46

57
export const Route = createRootRoute({
6-
component: RootLayout,
8+
component: RootComponent,
79
});
810

9-
function RootLayout() {
11+
function RootComponent() {
12+
return (
13+
<RootShell>
14+
<Outlet />
15+
</RootShell>
16+
);
17+
}
18+
19+
/**
20+
* The persistent app shell (#6513). Exported for unit testing. It owns the chat-rail open/collapsed state, and
21+
* because it's rendered by the root route, TanStack Router keeps it — and that state — mounted across
22+
* client-side navigation between the four routes, so the rail never resets on a route change. The routed page
23+
* is `children` (the `<Outlet/>` content), which is what swaps on navigation while this shell stays mounted.
24+
*/
25+
export function RootShell({ children }: { children: React.ReactNode }) {
26+
const [railOpen, setRailOpen] = React.useState(false);
27+
1028
return (
1129
<div className="min-h-screen bg-background text-foreground">
1230
<header className="border-b-hairline px-6 py-4">
@@ -49,9 +67,13 @@ function RootLayout() {
4967
<ThemeToggle />
5068
</div>
5169
</header>
52-
<main className="mx-auto max-w-5xl px-6 py-8">
53-
<Outlet />
54-
</main>
70+
{/* Row: routed content + the persistent rail docked beside it (never overlapping) on wide viewports. */}
71+
<div className="mx-auto flex w-full max-w-[calc(64rem+380px)] items-stretch">
72+
<main className="min-w-0 flex-1 px-6 py-8">
73+
<div className="mx-auto max-w-5xl">{children}</div>
74+
</main>
75+
<ChatRail open={railOpen} onOpenChange={setRailOpen} />
76+
</div>
5577
<GrafanaFooterLink />
5678
</div>
5779
);

0 commit comments

Comments
 (0)