feat(ui): offer Lighthouse AI help in the add-provider wizard (#13178)

This commit is contained in:
Alejandro Bailo authored and GitHub committed 2026-10-27 16:55:26 +01:00
1 parent d7bffba4ac
commit 4eb468619f
50 files changed
+2214 -170

No files matched your search

@@ -22,6 +22,7 @@ import { isLighthouseChatRoute } from "@/lib/lighthouse-routes";
import {
clampSidePanelWidth,
getSidePanelMaxWidth,
SIDE_PANEL_ELEMENT_ID,
SIDE_PANEL_MIN_WIDTH,
SIDE_PANEL_PUSH_MEDIA_QUERY,
} from "@/lib/ui-layout";
@@ -115,6 +116,7 @@ export function GlobalSidePanel() {
return (
<SidePanel
id={SIDE_PANEL_ELEMENT_ID}
open={isOpen}
aria-label="Side panel"
data-testid="global-side-panel"
+1
View File
@@ -1,4 +1,5 @@
export { DetailSidePanel } from "./detail-side-panel";
export { GlobalSidePanel } from "./global-side-panel";
export { SidePanelHostModal } from "./side-panel-host-modal";
export { getVisibleSidePanelTabs, SIDE_PANEL_TABS } from "./side-panel-tabs";
export { SidePanelTrigger } from "./side-panel-trigger";
@@ -0,0 +1,136 @@
import { useState } from "react";
import { beforeEach, describe, expect, it } from "vitest";
import { page, userEvent } from "vitest/browser";
import { render } from "@/__tests__/render-browser";
import { SidePanel } from "@/components/shadcn/side-panel/side-panel";
import { SIDE_PANEL_ELEMENT_ID } from "@/lib/ui-layout";
import {
selectHasModalHost,
SIDE_PANEL_TAB,
useSidePanelStore,
} from "@/store/side-panel";
import { SidePanelHostModal } from "./side-panel-host-modal";
const PANEL_WIDTH = 480;
function PanelStandIn() {
const isOpen = useSidePanelStore((state) => state.isOpen);
return (
<SidePanel
id={SIDE_PANEL_ELEMENT_ID}
open={isOpen}
aria-label="Side panel"
style={{ width: PANEL_WIDTH }}
>
<textarea aria-label="Lighthouse composer" />
</SidePanel>
);
}
function WizardStandIn() {
return (
<>
<PanelStandIn />
<SidePanelHostModal
open
onOpenChange={() => {}}
title="Adding A Provider"
>
<input aria-label="AWS account ID" />
</SidePanelHostModal>
</>
);
}
// The page behind goes inert while the wizard is open, like MainLayout does.
function ShellWithOpener() {
const isShellInert = useSidePanelStore(selectHasModalHost);
const [open, setOpen] = useState(false);
return (
<>
<main inert={isShellInert}>
<button type="button" onClick={() => setOpen(true)}>
Add provider
</button>
</main>
<PanelStandIn />
<SidePanelHostModal
open={open}
onOpenChange={setOpen}
title="Adding A Provider"
>
<input aria-label="AWS account ID" />
</SidePanelHostModal>
</>
);
}
describe("SidePanelHostModal beside the side panel", () => {
beforeEach(() => {
useSidePanelStore.setState({
isOpen: false,
selectedTab: SIDE_PANEL_TAB.AI_CHAT,
width: PANEL_WIDTH,
modalHostCount: 0,
});
});
it("lets the user chat in the panel without losing the wizard or its input", async () => {
// Given: the user is halfway through the wizard
render(<WizardStandIn />);
await userEvent.fill(page.getByLabelText("AWS account ID"), "123456789012");
// When: Lighthouse opens beside it and the user writes in the composer
useSidePanelStore.getState().openPanel(SIDE_PANEL_TAB.AI_CHAT);
await userEvent.click(page.getByLabelText("Lighthouse composer"));
await userEvent.keyboard("How do I create the IAM role?");
// Then: the wizard is still there, untouched, and clear of the panel
await expect.element(page.getByRole("dialog")).toBeVisible();
await expect
.element(page.getByLabelText("AWS account ID"))
.toHaveValue("123456789012");
await expect
.element(page.getByLabelText("Lighthouse composer"))
.toHaveValue("How do I create the IAM role?");
await expect
.poll(
() => page.getByRole("dialog").element().getBoundingClientRect().right,
)
.toBeLessThanOrEqual(window.innerWidth - PANEL_WIDTH);
});
it("closes only the panel on Escape inside it and hands focus back to the wizard", async () => {
// Given
render(<WizardStandIn />);
useSidePanelStore.getState().openPanel(SIDE_PANEL_TAB.AI_CHAT);
await userEvent.click(page.getByLabelText("Lighthouse composer"));
// When
await userEvent.keyboard("{Escape}");
// Then
expect(useSidePanelStore.getState().isOpen).toBe(false);
await expect.element(page.getByRole("dialog")).toBeVisible();
await expect.element(page.getByRole("dialog")).toHaveFocus();
});
it("gives focus back to the button that opened the wizard once the page is usable again", async () => {
// Given: the user opened the wizard and is typing in it
render(<ShellWithOpener />);
await userEvent.click(page.getByRole("button", { name: "Add provider" }));
await userEvent.click(page.getByLabelText("AWS account ID"));
await userEvent.keyboard("1234");
// When
await userEvent.keyboard("{Escape}");
// Then
await expect.element(page.getByRole("dialog")).not.toBeInTheDocument();
await expect
.element(page.getByRole("button", { name: "Add provider" }))
.toHaveFocus();
});
});
@@ -0,0 +1,106 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SIDE_PANEL_ELEMENT_ID } from "@/lib/ui-layout";
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
import { SidePanelHostModal } from "./side-panel-host-modal";
vi.mock("next/navigation", () => ({
usePathname: () => "/providers",
}));
function renderWithPanel(onOpenChange = vi.fn(), open = true) {
return render(
<>
<aside id={SIDE_PANEL_ELEMENT_ID}>
<textarea aria-label="Lighthouse composer" />
</aside>
<SidePanelHostModal
open={open}
onOpenChange={onOpenChange}
title="Add provider"
>
<button type="button">Next</button>
</SidePanelHostModal>
</>,
);
}
describe("SidePanelHostModal", () => {
beforeEach(() => {
useSidePanelStore.setState({
isOpen: true,
selectedTab: SIDE_PANEL_TAB.AI_CHAT,
modalHostCount: 0,
});
});
it("keeps the dialog open while the user chats in the side panel", async () => {
// Given
const user = userEvent.setup();
const onOpenChange = vi.fn();
renderWithPanel(onOpenChange);
await screen.findByRole("dialog");
// When
await user.click(screen.getByLabelText("Lighthouse composer"));
await user.keyboard("How do I create the IAM role?");
// Then
expect(onOpenChange).not.toHaveBeenCalled();
expect(screen.getByLabelText("Lighthouse composer")).toHaveValue(
"How do I create the IAM role?",
);
});
it("closes only the side panel when Escape is pressed inside it", async () => {
// Given
const user = userEvent.setup();
const onOpenChange = vi.fn();
renderWithPanel(onOpenChange);
await user.click(await screen.findByLabelText("Lighthouse composer"));
// When
await user.keyboard("{Escape}");
// Then: the panel closes, the dialog stays and takes the focus back
expect(useSidePanelStore.getState().isOpen).toBe(false);
expect(onOpenChange).not.toHaveBeenCalled();
expect(screen.getByRole("dialog")).toHaveFocus();
});
it("still closes on Escape pressed inside the dialog", async () => {
// Given
const user = userEvent.setup();
const onOpenChange = vi.fn();
renderWithPanel(onOpenChange);
await user.click(await screen.findByRole("button", { name: "Next" }));
// When
await user.keyboard("{Escape}");
// Then
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(useSidePanelStore.getState().isOpen).toBe(true);
});
it("holds the app shell inert only while it is open", async () => {
// Given
const { rerender } = renderWithPanel();
await screen.findByRole("dialog");
expect(useSidePanelStore.getState().modalHostCount).toBe(1);
// When
rerender(
<SidePanelHostModal open={false} onOpenChange={vi.fn()} title="Add">
<button type="button">Next</button>
</SidePanelHostModal>,
);
// Then
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(useSidePanelStore.getState().modalHostCount).toBe(0);
});
});
@@ -0,0 +1,63 @@
"use client";
import { type ComponentProps, useRef } from "react";
import { Modal } from "@/components/shadcn/modal";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { useSidePanelPushWidth } from "@/hooks/use-side-panel-push-width";
import { SIDE_PANEL_ELEMENT_ID } from "@/lib/ui-layout";
import { useSidePanelStore } from "@/store/side-panel";
type SidePanelHostModalProps = Omit<
ComponentProps<typeof Modal>,
"modal" | "insetRight" | "isInsetResizing" | "ref"
>;
function isInsideSidePanel(target: EventTarget | null): boolean {
return (
target instanceof Element &&
target.closest(`#${SIDE_PANEL_ELEMENT_ID}`) !== null
);
}
// A dialog that shares the screen with the global side panel: it centers in
// the space the panel leaves and nothing done in the panel can dismiss it.
export function SidePanelHostModal({
onEscapeKeyDown,
children,
...props
}: SidePanelHostModalProps) {
const insetRight = useSidePanelPushWidth();
const isResizing = useSidePanelStore((state) => state.isResizing);
const contentRef = useRef<HTMLDivElement>(null);
const handleEscapeKeyDown = (event: KeyboardEvent) => {
onEscapeKeyDown?.(event);
if (event.defaultPrevented || !isInsideSidePanel(event.target)) return;
// Radix handles Escape first (document capture), so the panel's own
// listener sees it prevented: close the panel here instead of the dialog.
event.preventDefault();
useSidePanelStore.getState().closePanel();
contentRef.current?.focus();
};
return (
<Modal
{...props}
ref={contentRef}
modal={false}
insetRight={insetRight}
isInsetResizing={isResizing}
onEscapeKeyDown={handleEscapeKeyDown}
>
<ModalHostRegistration />
{children}
</Modal>
);
}
// Mounted with the dialog content, so it is registered exactly while open.
function ModalHostRegistration() {
useMountEffect(() => useSidePanelStore.getState().registerModalHost());
return null;
}