Skip to content
Open
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
91 changes: 91 additions & 0 deletions apps/app/src/components/layout/AppBreadcrumbs.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// @vitest-environment jsdom

import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter, useLocation } from "react-router-dom";
import { afterEach, describe, expect, it } from "vitest";
import { AppBreadcrumbs } from "./AppBreadcrumbs";

afterEach(cleanup);

function LocationProbe() {
return (
<output aria-label="Current location">{useLocation().pathname}</output>
);
}

describe("AppBreadcrumbs", () => {
it("navigates through ancestors while keeping the resource passive", () => {
render(
<MemoryRouter
initialEntries={[
"/plugins/automations/automations/proj_personal/weekly-review",
]}
>
<AppBreadcrumbs
breadcrumbs={[
{
label: "Automations",
to: "/plugins/automations/automations",
},
{
label: "Installed",
to: "/plugins/automations/automations",
},
{ label: "Weekly review" },
]}
usesDesktopChrome={false}
/>
<LocationProbe />
</MemoryRouter>,
);

expect(screen.getByText("Weekly review").getAttribute("aria-current")).toBe(
"page",
);
expect(screen.queryByRole("link", { name: "Weekly review" })).toBeNull();

fireEvent.click(screen.getByRole("link", { name: "Installed" }));
expect(screen.getByLabelText("Current location").textContent).toBe(
"/plugins/automations/automations",
);
});

it("keeps ancestors fixed and truncates only the current crumb in narrow layouts", () => {
render(
<MemoryRouter>
<div className="w-48 overflow-hidden">
<AppBreadcrumbs
breadcrumbs={[
{
label: "Automations",
to: "/plugins/automations/automations",
},
{
label: "Installed",
to: "/plugins/automations/automations",
},
{
label:
"A very long automation name that must fit a narrow header",
},
]}
usesDesktopChrome
/>
</div>
</MemoryRouter>,
);

const navigation = screen.getByRole("navigation", { name: "Breadcrumb" });
const current = screen.getByText(
"A very long automation name that must fit a narrow header",
);

expect(navigation.className).toContain("min-w-0");
expect(navigation.querySelector("ol")?.className).toContain("min-w-0");
expect(
screen.getByRole("link", { name: "Automations" }).className,
).toContain("shrink-0");
expect(current.className).toContain("min-w-0");
expect(current.className).toContain("truncate");
});
});
62 changes: 62 additions & 0 deletions apps/app/src/components/layout/AppBreadcrumbs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Link } from "react-router-dom";
import { Icon } from "@bb/shared-ui/icon";
import { cn } from "@bb/shared-ui/lib/utils";
import { MACOS_WINDOW_NO_DRAG_CLASS } from "@/lib/bb-desktop";

export interface AppBreadcrumbSegment {
label: string;
to?: string;
}

export function AppBreadcrumbs({
breadcrumbs,
usesDesktopChrome,
}: {
breadcrumbs: readonly AppBreadcrumbSegment[];
usesDesktopChrome: boolean;
}) {
return (
<nav aria-label="Breadcrumb" className="min-w-0">
<ol className="flex min-w-0 items-center gap-1.5 text-sm font-semibold">
{breadcrumbs.map((segment, index) => {
const isLast = index === breadcrumbs.length - 1;
return (
<li
key={`${segment.label}-${index}`}
className="flex min-w-0 items-center gap-1.5"
>
{index > 0 ? (
<Icon
name="ChevronRight"
className="size-3.5 shrink-0 text-subtle-foreground"
/>
) : null}
{!isLast && segment.to ? (
<Link
to={segment.to}
className={cn(
"-mx-2 inline-flex min-h-7 shrink-0 cursor-pointer items-center rounded-md px-2 text-muted-foreground transition-colors hover:bg-state-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
usesDesktopChrome && MACOS_WINDOW_NO_DRAG_CLASS,
)}
>
{segment.label}
</Link>
) : (
<span
aria-current={isLast ? "page" : undefined}
className={
isLast
? "min-w-0 truncate"
: "shrink-0 text-muted-foreground"
}
>
{segment.label}
</span>
)}
</li>
);
})}
</ol>
</nav>
);
}
79 changes: 79 additions & 0 deletions apps/app/src/components/layout/AppLayout.tools-breadcrumbs.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
resolveAutomationBreadcrumbs,
resolveToolsBreadcrumbs,
TOOLS_NAV_ITEMS,
} from "@/components/tools/tools-navigation";
Expand Down Expand Up @@ -93,3 +94,81 @@ describe("resolveToolsBreadcrumbs", () => {
).toBeNull();
});
});

describe("resolveAutomationBreadcrumbs", () => {
it("maps the installed and browse surfaces to automation breadcrumbs", () => {
expect(
resolveAutomationBreadcrumbs("/plugins/automations/automations"),
).toEqual([
{
label: "Automations",
to: "/plugins/automations/automations",
},
{ label: "Installed" },
]);
expect(
resolveAutomationBreadcrumbs("/plugins/automations/automations/browse"),
).toEqual([
{
label: "Automations",
to: "/plugins/automations/automations",
},
{ label: "Browse" },
]);
});

it("keeps detail ancestors clickable and replaces the loading fallback label", () => {
const detailPath =
"/plugins/automations/automations/proj_personal/weekly-review";

expect(resolveAutomationBreadcrumbs(detailPath)).toEqual([
{
label: "Automations",
to: "/plugins/automations/automations",
},
{
label: "Installed",
to: "/plugins/automations/automations",
},
{ label: "weekly-review" },
]);
expect(resolveAutomationBreadcrumbs(detailPath, "Weekly review")).toEqual([
{
label: "Automations",
to: "/plugins/automations/automations",
},
{
label: "Installed",
to: "/plugins/automations/automations",
},
{ label: "Weekly review" },
]);
expect(
resolveAutomationBreadcrumbs(`${detailPath}/edit`, "Weekly review"),
).toEqual([
{
label: "Automations",
to: "/plugins/automations/automations",
},
{
label: "Installed",
to: "/plugins/automations/automations",
},
{ label: "Weekly review" },
]);
});

it("uses the route id when automation data is missing", () => {
expect(
resolveAutomationBreadcrumbs(
"/plugins/automations/automations/proj_personal/missing%20automation",
)?.at(-1),
).toEqual({ label: "missing automation" });
});

it("does not claim unrelated plugin routes", () => {
expect(
resolveAutomationBreadcrumbs("/plugins/simple-notes/simple-notes"),
).toBeNull();
});
});
82 changes: 27 additions & 55 deletions apps/app/src/components/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@ import { AppCommandShortcutHint } from "@/components/commands/AppCommandShortcut
import { SettingsSidebar } from "@/components/settings/SettingsSidebar";
import { ToolsSidebar } from "@/components/tools/ToolsSidebar";
import { ToolsHubExperimentProvider } from "@/components/tools/tools-experiment-context";
import { resolveToolsBreadcrumbs } from "@/components/tools/tools-navigation";
import {
resolveAutomationBreadcrumbs,
resolveToolsBreadcrumbs,
} from "@/components/tools/tools-navigation";
import { AppBreadcrumbs } from "./AppBreadcrumbs";
import { resourceRouteLabelAtom } from "./resourceRouteLabelAtom";
import { AppPageHeader, HEADER_ICON_BUTTON_CLASS } from "./AppPageHeader";
import { stripProjectThreads } from "@/hooks/queries/project-queries";
import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query";
Expand Down Expand Up @@ -55,7 +60,6 @@ import {
MACOS_CHROME_TRAFFIC_LIGHT_AXIS_NUDGE_CLASS,
MACOS_TRAFFIC_LIGHT_RESERVE_OFFSET_CLASS,
MACOS_WINDOW_DRAG_CLASS,
MACOS_WINDOW_NO_DRAG_CLASS,
shouldReserveMacosTrafficLights,
shouldUseMacosDesktopChrome,
} from "@/lib/bb-desktop";
Expand Down Expand Up @@ -331,54 +335,17 @@ function AppHeader({
Boolean(headerTitle) ||
Boolean(meta.subtitle);

const center = pluginPanel ? (
const center = headerBreadcrumbs ? (
<div className="min-w-0 flex-1">
<AppBreadcrumbs
breadcrumbs={headerBreadcrumbs}
usesDesktopChrome={usesDesktopChrome}
/>
</div>
) : pluginPanel ? (
<PluginPanelHeaderCenter panel={pluginPanel} />
) : hasCenterContent ? (
<div className="min-w-0 flex-1">
{headerBreadcrumbs ? (
<nav aria-label="Breadcrumb" className="min-w-0">
<ol className="flex min-w-0 items-center gap-1.5 text-sm font-semibold">
{headerBreadcrumbs.map((segment, index) => {
const isLast = index === headerBreadcrumbs.length - 1;
return (
<li
key={`${segment.label}-${index}`}
className="flex min-w-0 items-center gap-1.5"
>
{index > 0 ? (
<Icon
name="ChevronRight"
className="size-3.5 shrink-0 text-subtle-foreground"
/>
) : null}
{!isLast && segment.to ? (
<Link
to={segment.to}
className={cn(
"-mx-2 inline-flex min-h-7 shrink-0 cursor-pointer items-center rounded-md px-2 text-muted-foreground transition-colors hover:bg-state-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
usesDesktopChrome && MACOS_WINDOW_NO_DRAG_CLASS,
)}
>
{segment.label}
</Link>
) : (
<span
aria-current={isLast ? "page" : undefined}
className={
isLast
? "min-w-0 truncate"
: "shrink-0 text-muted-foreground"
}
>
{segment.label}
</span>
)}
</li>
);
})}
</ol>
</nav>
) : null}
{headerTitle ? (
<p className="truncate text-sm font-semibold">{headerTitle}</p>
) : null}
Expand Down Expand Up @@ -437,8 +404,8 @@ export function AppLayout({ children }: AppLayoutProps) {
const contentShellRef = useRef<HTMLDivElement>(null);
useMobileVisualViewportHeight(contentShellRef, isCompactViewport);
const location = useLocation();
const [resourceRouteLabel, setResourceRouteLabel] = useState<string | null>(
null,
const [resourceRouteLabel, setResourceRouteLabel] = useAtom(
resourceRouteLabelAtom,
);
useEffect(() => {
setResourceRouteLabel(null);
Expand All @@ -465,7 +432,7 @@ export function AppLayout({ children }: AppLayoutProps) {
handleResourceRouteLabel,
);
};
}, [location.pathname]);
}, [location.pathname, setResourceRouteLabel]);
const navigate = useNavigate();
const {
appRoutePath,
Expand Down Expand Up @@ -647,16 +614,21 @@ export function AppLayout({ children }: AppLayoutProps) {
resourceRouteLabel,
)
: null;
const automationBreadcrumbs = resolveAutomationBreadcrumbs(
location.pathname,
resourceRouteLabel,
);
const routeBreadcrumbs = toolsBreadcrumbs ?? automationBreadcrumbs;
const meta = isThreadView
? {
title: thread ? getThreadDisplayTitle(thread) : "Thread",
subtitle: undefined,
}
: toolsBreadcrumbs
: routeBreadcrumbs
? {
title: "",
subtitle: undefined,
breadcrumbs: toolsBreadcrumbs,
breadcrumbs: routeBreadcrumbs,
}
: isArchivedView && projectId
? isProjectlessProjectId(projectId)
Expand Down Expand Up @@ -708,9 +680,9 @@ export function AppLayout({ children }: AppLayoutProps) {
if (pluginPanel) {
return pluginPanel.title;
}
if (toolsBreadcrumbs) {
const sectionLabel = toolsBreadcrumbs[0]?.label ?? "BB";
const pageLabel = toolsBreadcrumbs.at(-1)?.label ?? sectionLabel;
if (routeBreadcrumbs) {
const sectionLabel = routeBreadcrumbs[0]?.label ?? "BB";
const pageLabel = routeBreadcrumbs.at(-1)?.label ?? sectionLabel;
return pageLabel === sectionLabel
? sectionLabel
: `${pageLabel} · ${sectionLabel}`;
Expand Down
7 changes: 6 additions & 1 deletion apps/app/src/components/layout/AppPageHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,12 @@ export const HEADER_ICON_BUTTON_CLASS = COARSE_POINTER_HEADER_ICON_BUTTON_CLASS;
export const HEADER_REDUCED_GLYPH_ICON_BUTTON_CLASS =
COARSE_POINTER_HEADER_REDUCED_GLYPH_ICON_BUTTON_CLASS;

export const HEADER_MAXIMIZE_ICON_BUTTON_CLASS =
/**
* Shared geometry for the maximize and close controls at the end of a pane
* header. Keeping both controls on one class gives their button boxes and
* glyphs the same center axis.
*/
export const HEADER_PANE_ACTION_ICON_BUTTON_CLASS =
HEADER_REDUCED_GLYPH_ICON_BUTTON_CLASS;

interface AppPageHeaderProps {
Expand Down
Loading
Loading