+ ,
+ );
+ 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. */}
+