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
29 changes: 29 additions & 0 deletions apps/loopover-miner-ui/src/chat-rail.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { fireEvent, render, screen } from "@testing-library/react";
import * as React from "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
Expand All @@ -14,6 +15,15 @@ vi.mock("@tanstack/react-router", async () => {
};
});

// Stateful stub so the #7792 close/reopen test can assert in-rail React state survives the mobile sheet cycle
// without standing up the real chat backend / streaming stack.
vi.mock("./components/chat/conversation", () => ({
ChatConversation: () => {
const [draft, setDraft] = React.useState("");
return <input aria-label="chat draft" value={draft} onChange={(event) => setDraft(event.target.value)} />;
},
}));

import { ChatRail } from "./components/chat-rail";
import { RootShell } from "./routes/__root";

Expand Down Expand Up @@ -81,6 +91,25 @@ describe("ChatRail (#6513)", () => {
expect(screen.getByRole("dialog")).toBeTruthy(); // Sheet content
expect(screen.queryByRole("complementary")).toBeNull(); // never the docked panel on mobile
});

it("preserves chat draft state across a mobile sheet close/reopen cycle (#7792)", () => {
setViewport(400);
const onOpenChange = vi.fn();
const { rerender } = render(<ChatRail open onOpenChange={onOpenChange} />);

const draft = screen.getByRole("textbox", { name: /chat draft/i });
fireEvent.change(draft, { target: { value: "still typing…" } });
expect((draft as HTMLInputElement).value).toBe("still typing…");

// Close the sheet (same open=false transition accidental tap-outside / Escape / toggle would cause).
rerender(<ChatRail open={false} onOpenChange={onOpenChange} />);
// forceMount keeps the dialog content in the tree even while closed.
expect(screen.getByRole("dialog", { hidden: true })).toBeTruthy();

// Reopen — draft must still be there (RailBody never unmounted).
rerender(<ChatRail open onOpenChange={onOpenChange} />);
expect((screen.getByRole("textbox", { name: /chat draft/i }) as HTMLInputElement).value).toBe("still typing…");
});
});

describe("RootShell chat-rail integration (#6513)", () => {
Expand Down
5 changes: 4 additions & 1 deletion apps/loopover-miner-ui/src/components/chat-rail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ export function ChatRail({ open, onOpenChange }: ChatRailProps) {
Chat
</Button>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent id={RAIL_PANEL_ID} side="right" className="w-[380px] p-0">
{/* forceMount: keep RailBody mounted when the sheet closes so chat state survives, matching the
desktop `<aside hidden={!open}>` contract (#7792). SheetContent already forwards unknown props
onto Radix Dialog.Content, which honors forceMount. */}
<SheetContent id={RAIL_PANEL_ID} side="right" className="w-[380px] p-0" forceMount>
<SheetHeader className="sr-only">
<SheetTitle>Chat</SheetTitle>
<SheetDescription>Ask about this miner&rsquo;s local state.</SheetDescription>
Expand Down
39 changes: 31 additions & 8 deletions packages/loopover-ui-kit/src/components/sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,18 @@ interface SheetContentProps
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
>(({ side = "right", className, children, forceMount, ...props }, ref) => (
// When callers pass forceMount (e.g. chat-rail mobile sheet, #7792), forward it to Portal + Overlay +
// Content together: Content-only forceMount is a no-op if Portal's Presence has already unmounted the
// subtree. Default (forceMount undefined) keeps the prior unmount-on-close behavior for every other sheet.
<SheetPortal forceMount={forceMount}>
<SheetOverlay forceMount={forceMount} />
<SheetPrimitive.Content
ref={ref}
forceMount={forceMount}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background cursor-pointer transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
Expand All @@ -71,14 +79,29 @@ const SheetContent = React.forwardRef<
));
SheetContent.displayName = SheetPrimitive.Content.displayName;

const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className,
)}
{...props}
/>
);
SheetHeader.displayName = "SheetHeader";

const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
Expand Down
Loading