Skip to content
Merged
6 changes: 3 additions & 3 deletions apps/app/e2e/product-flows.e2e.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ describe("sidebar and content panel", () => {
it("opens New chat and the per-project compose entry", async () => {
await mountApp();
await waitForText(sample());
await page.getByRole("link", { name: "New chat" }).click();
await page.getByTestId("sidebar").getByRole("link", { name: "New chat", exact: true }).click();
await expect.poll(() => window.location.pathname).toBe("/draft");
await waitForComposer();

Expand Down Expand Up @@ -136,7 +136,7 @@ describe("schedules and pull requests", () => {
await mountApp();
await page.getByRole("link", { name: "Scheduled" }).click();
await waitForText("No schedules yet");
await page.getByRole("button", { name: "Create" }).click();
await page.getByRole("button", { name: "New task" }).click();
await waitForText("Name");
await page.getByLabelText("Name").fill("e2e nightly");
await page.getByLabelText("Prompt").fill("e2e scheduled ping");
Expand Down Expand Up @@ -164,7 +164,7 @@ describe("git workspace and review", () => {
await importFolder(sampleGit());
await waitForText(sampleGit());

await page.getByRole("link", { name: "New chat" }).click();
await page.getByTestId("sidebar").getByRole("link", { name: "New chat", exact: true }).click();
await openDraftForProject(sampleGit());
await waitForText("Current directory", 20_000);
await page.getByText("Current directory").first().click();
Expand Down
23 changes: 23 additions & 0 deletions apps/app/src/components/layout/app-nav.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Clock, GitPullRequestIcon, House, Puzzle, Settings } from "lucide-react";
import type { LucideIcon } from "lucide-react";

export type AppNavItem = {
readonly icon: LucideIcon;
readonly label: string;
readonly search?: Record<string, never>;
readonly to: "/draft" | "/schedules" | "/pull-requests" | "/plugins" | "/settings";
};

export const appNav = {
home: { to: "/draft", label: "New chat", icon: House },
sections: [
{ to: "/schedules", label: "Scheduled", icon: Clock, search: {} },
{ to: "/pull-requests", label: "Pull requests", icon: GitPullRequestIcon },
{ to: "/plugins", label: "Plugins", icon: Puzzle },
],
footer: { to: "/settings", label: "Settings", icon: Settings },
} as const satisfies {
readonly home: AppNavItem;
readonly sections: readonly AppNavItem[];
readonly footer: AppNavItem;
};
134 changes: 134 additions & 0 deletions apps/app/src/components/layout/app-rail.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { SidebarProvider } from "@getpie/ui/components/sidebar";
import {
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
Outlet,
RouterProvider,
} from "@tanstack/react-router";
import { expect, it } from "vitest";
import { render } from "vitest-browser-react";
import { page, userEvent } from "vitest/browser";

import "@/index.css";
import { PlatformProvider } from "@/platform-provider";

import { AppRail } from "./app-rail";

it("spaces the rail buttons and shows right-side tooltips on hover and keyboard focus without losing link navigation", async () => {
await page.viewport(1280, 800);
const root = createRootRoute({
component: () => (
<PlatformProvider
value={{ os: "macos", windowChrome: { titlebarHeight: 44, toggleInset: 88 } }}
>
<SidebarProvider className="h-svh">
<AppRail />
<main>
<Outlet />
</main>
</SidebarProvider>
</PlatformProvider>
),
});
const destinations = [
["/draft", "New chat"],
["/schedules", "Scheduled"],
["/pull-requests", "Pull requests"],
["/plugins", "Plugins"],
["/settings", "Settings"],
] as const;
const router = createRouter({
routeTree: root.addChildren(
destinations.map(([path]) =>
createRoute({
getParentRoute: () => root,
path,
component: () => <p>{path}</p>,
}),
),
),
history: createMemoryHistory({ initialEntries: ["/draft"] }),
});
await router.load();
await render(<RouterProvider router={router} />);

const home = page.getByRole("link", { name: "New chat", exact: true });
const scheduled = page.getByRole("link", { name: "Scheduled", exact: true });
const homeBounds = home.element().getBoundingClientRect();
expect(homeBounds.width).toBe(36);
expect(homeBounds.height).toBe(36);
expect(scheduled.element().getBoundingClientRect().top - homeBounds.bottom).toBe(8);

await userEvent.tab();
await expect.element(home).toHaveFocus();
await expect.element(page.getByText("New chat", { exact: true })).toBeVisible();
await userEvent.keyboard("{Escape}");
await expect.element(page.getByText("New chat", { exact: true })).not.toBeInTheDocument();

for (const [, label] of destinations) {
const link = page.getByRole("link", { name: label, exact: true });
expect(link.element().hasAttribute("title")).toBe(false);
// Escape dismisses the tooltip without a pointerleave. Hover only reopens
// after the pointer enters again, so leave the trigger first.
await page.getByRole("main").hover();
await link.hover();
const tooltip = page.getByText(label, { exact: true });
await expect.element(tooltip).toBeVisible();
expect(tooltip.element().closest('[data-slot="tooltip-popup"]')).not.toBeNull();
expect(tooltip.element().getBoundingClientRect().left).toBeGreaterThanOrEqual(
link.element().getBoundingClientRect().right,
);
}
await page.getByRole("link", { name: "Settings", exact: true }).click();
await expect.poll(() => router.state.location.pathname).toBe("/settings");
await expect
.element(page.getByRole("link", { name: "Settings", exact: true }))
.toHaveAttribute("aria-current", "page");
});

it("forwards tooltip trigger props to the web sidebar toggle", async () => {
await page.viewport(1280, 800);
const root = createRootRoute({
component: () => (
<PlatformProvider value={{}}>
<SidebarProvider className="h-svh">
<AppRail />
<main>
<Outlet />
</main>
</SidebarProvider>
</PlatformProvider>
),
});
const draft = createRoute({
getParentRoute: () => root,
path: "/draft",
component: () => <p>/draft</p>,
});
const router = createRouter({
routeTree: root.addChildren([draft]),
history: createMemoryHistory({ initialEntries: ["/draft"] }),
});
await router.load();
await render(<RouterProvider router={router} />);

const toggle = page.getByRole("button", { name: "Toggle Sidebar", exact: true });
await page.getByRole("main").hover();
await toggle.hover();
const tooltip = page.getByText("Toggle sidebar", { exact: true });
await expect.element(tooltip).toBeVisible();
const popup = tooltip.element().closest('[data-slot="tooltip-popup"]');
expect(popup).not.toBeNull();
expect(Object.hasOwn(toggle.element().dataset, "baseUiTooltipTrigger")).toBe(true);
expect(tooltip.element().getBoundingClientRect().left).toBeGreaterThanOrEqual(
toggle.element().getBoundingClientRect().right,
);

await page.getByRole("main").hover();
await expect.element(tooltip).not.toBeInTheDocument();
await userEvent.tab();
await expect.element(toggle).toHaveFocus();
await expect.element(page.getByText("Toggle sidebar", { exact: true })).toBeVisible();
});
77 changes: 77 additions & 0 deletions apps/app/src/components/layout/app-rail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { Button } from "@getpie/ui/components/button";
import { useSidebar } from "@getpie/ui/components/sidebar";
import {
Tooltip,
TooltipPopup,
TooltipProvider,
TooltipTrigger,
} from "@getpie/ui/components/tooltip";
import { cn } from "@getpie/ui/lib/utils";
import { Link, useMatch } from "@tanstack/react-router";
import type { ReactElement, ReactNode } from "react";

import { appNav, type AppNavItem } from "@/components/layout/app-nav";
import { ShellSidebarToggle } from "@/components/layout/shell-sidebar-toggle";
import { usePlatform } from "@/platform-context";
import { isDesktopHost } from "@/platform-host";

/** Icon column that stays while the session list collapses. Lights live in the shell row above. */
export function AppRail(): ReactElement {
const desktop = isDesktopHost(usePlatform());
// Match the sidebar sheet breakpoint (800px), not Tailwind md (768px).
const mobile = useSidebar().isMobile;
return (
<TooltipProvider delay={300}>
<nav
aria-label="App navigation"
className={cn(
"w-13 shrink-0 flex-col items-center gap-2 px-1 pt-2 pb-2",
mobile ? "hidden" : "flex",
)}
data-slot="app-rail"
>
{!desktop ? (
<Tooltip>
<TooltipTrigger render={<ShellSidebarToggle />} />
<TooltipPopup side="right" sideOffset={8}>
Toggle sidebar
</TooltipPopup>
</Tooltip>
) : null}
<RailLink item={appNav.home} />
{appNav.sections.map((item) => (
<RailLink item={item} key={item.to} />
))}
<div className="mt-auto">
<RailLink item={appNav.footer} />
</div>
</nav>
</TooltipProvider>
);
}

function RailLink({ item }: { readonly item: AppNavItem }): ReactNode {
const { icon: Icon, label, search, to } = item;
const active = useMatch({ from: to, shouldThrow: false }) !== undefined;
return (
<Tooltip>
<TooltipTrigger
render={
<Button
aria-current={active ? "page" : undefined}
aria-label={label}
data-pressed={active ? "" : undefined}
render={<Link search={search} to={to} />}
size="icon-lg"
variant="ghost"
>
<Icon />
</Button>
}
/>
<TooltipPopup side="right" sideOffset={8}>
{label}
</TooltipPopup>
</Tooltip>
);
}
96 changes: 96 additions & 0 deletions apps/app/src/components/layout/app-shell.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { SidebarProvider } from "@getpie/ui/components/sidebar";
import {
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
RouterProvider,
} from "@tanstack/react-router";
import { domAnimation, LazyMotion } from "motion/react";
import { expect, it } from "vitest";
import { render } from "vitest-browser-react";
import { page } from "vitest/browser";

import "@/index.css";
import { PlatformProvider } from "@/platform-provider";

import { AppShellBody, AppShellMain, AppShellSidebar } from "./app-shell";
import { ContentPanel } from "./content-panel/model/content-panel";
import { ContentPanelSessionProvider } from "./content-panel/react/session-provider";
import type { AnyPanelView } from "./content-panel/react/view";

it("keeps one bordered inset frame around the sidebar and main when the sidebar collapses", async () => {
await page.viewport(1280, 800);
const contentPanel = new ContentPanel<AnyPanelView>();
const root = createRootRoute({
component: () => (
<PlatformProvider
value={{
os: "macos",
windowChrome: { titlebarHeight: 44, toggleInset: 88 },
}}
>
<SidebarProvider className="h-svh overflow-hidden" defaultOpen>
<LazyMotion features={domAnimation}>
<ContentPanelSessionProvider contentPanel={contentPanel} sessionRef={null}>
<AppShellBody>
<AppShellSidebar>
<div>Session list</div>
</AppShellSidebar>
<AppShellMain>
<main>Main canvas</main>
</AppShellMain>
</AppShellBody>
</ContentPanelSessionProvider>
</LazyMotion>
</SidebarProvider>
</PlatformProvider>
),
});
const draft = createRoute({ getParentRoute: () => root, path: "/draft", component: () => null });
const router = createRouter({
routeTree: root.addChildren([draft]),
history: createMemoryHistory({ initialEntries: ["/draft"] }),
});
await router.load();
await render(<RouterProvider router={router} />);

const frame = document.querySelector('[data-slot="shell-panel"]');
expect(frame).toBeInstanceOf(HTMLElement);
if (!(frame instanceof HTMLElement)) throw new Error("Missing shared shell panel");
expect(frame.contains(page.getByText("Session list").element())).toBe(true);
expect(frame.contains(page.getByText("Main canvas").element())).toBe(true);
expect(frame.contains(page.getByRole("navigation").element())).toBe(false);
expect(getComputedStyle(frame).borderTopWidth).toBe("1px");
expect(parseFloat(getComputedStyle(frame).borderTopLeftRadius)).toBeGreaterThan(0);
const before = frame.getBoundingClientRect();
expect(before.x).toBe(52);
expect(before.y).toBe(44);
expect(before.right).toBe(1276);
expect(before.bottom).toBe(796);

const toggle = page.getByRole("button", { name: "Toggle Sidebar" });
const toggleBounds = toggle.element().getBoundingClientRect();
expect(toggleBounds.left).toBe(88);
expect(toggleBounds.top + toggleBounds.height / 2).toBe(22);
const titlebar = toggle.element().closest("header");
if (!titlebar) throw new Error("Missing desktop titlebar");
expect(toggleBounds.top + toggleBounds.height / 2).toBe(
(titlebar.getBoundingClientRect().top + before.top) / 2,
);

await toggle.click();
await expect
.poll(
() => document.querySelector('[data-slot="sidebar-drawer"]')?.getBoundingClientRect().width,
)
.toBe(0);
const after = frame.getBoundingClientRect();
expect([after.x, after.y, after.width, after.height]).toEqual([
before.x,
before.y,
before.width,
before.height,
]);
expect(getComputedStyle(frame).borderTopWidth).toBe("1px");
});
Loading
Loading