Give every admin page a breadcrumb trail

The trail is twui's Breadcrumbs reading Home, the sections and the page
off the address, rendered as the first thing in SettingsShell's content
column, so every page that is not a conversation gets one without a page
touching it.

The last crumb is named by the page's own title rather than the address:
a record reads Scout instead of its id, /admin/logs reads Activity, and
/admin/settings/mcps reads MCPs. That replaces the breadcrumb_title prop
the four settings record pages had to pass, so settings-breadcrumbs.tsx
goes.

hasBreadcrumbTrail drops the trail on the crew of bots, home, and on
anything outside /admin; the trailing slash and query string are ignored,
so a folder in the files browser keeps the trail of the page it is on.
This commit is contained in:
2026-10-23 16:49:01 +01:00
parent ee687e1d31
commit 30a4df183c
12 changed files with 258 additions and 68 deletions
@@ -0,0 +1,94 @@
import { describe, expect, test } from "bun:test";
import { renderToString } from "react-dom/server";
import type { AppContextObject, PagePropsType } from "@/src/types";
import { AppContext } from "@/src/pages/__root";
import SettingsShell from "./settings-shell";
type RenderProps = {
pathname: string;
/**
* The page's own title, the name the header shows.
*/
title: string;
};
function render({ pathname, title }: RenderProps) {
const pageProps: PagePropsType = {
url: { pathname } as PagePropsType["url"],
};
const context = { pageProps } as AppContextObject;
return renderToString(
<AppContext.Provider value={context}>
<SettingsShell
title={title}
description="Work your bots do on a schedule."
>
<span>PAGE_CONTENT</span>
</SettingsShell>
</AppContext.Provider>,
);
}
describe("SettingsShell breadcrumbs", () => {
test("a top-level page shows Home and itself", () => {
const html = render({ pathname: "/admin/routines", title: "Routines" });
expect(html).toContain('aria-label="Breadcrumb"');
expect(html).toContain(">Home<");
expect(html).toContain(">Routines<");
expect(html).toContain('href="/admin/routines"');
});
test("a settings page walks through Settings up to the page", () => {
const html = render({
pathname: "/admin/settings/general",
title: "General",
});
expect(html).toContain('href="/admin"');
expect(html).toContain('href="/admin/settings"');
expect(html).toContain('href="/admin/settings/general"');
});
test("a record page reads its name, not the id in its address", () => {
const html = render({
pathname: "/admin/settings/bots/3",
title: "Scout",
});
expect(html).toContain(">Scout<");
expect(html).not.toContain(">3<");
});
test("a page whose slug differs from its name shows the name", () => {
const html = render({
pathname: "/admin/settings/change-password",
title: "Change password",
});
expect(html).toContain(">Change password<");
expect(html).not.toContain(">Change Password<");
});
test("Activity keeps its name though its address says logs", () => {
const html = render({ pathname: "/admin/logs", title: "Activity" });
expect(html).toContain(">Activity<");
expect(html).not.toContain(">Logs<");
});
test("the trail sits above the page's content, right after the header", () => {
const html = render({ pathname: "/admin/routines", title: "Routines" });
expect(html.indexOf('aria-label="Breadcrumb"')).toBeLessThan(
html.indexOf("PAGE_CONTENT"),
);
});
test("the crew of bots, which is home, has no trail above it", () => {
const html = render({ pathname: "/admin/bots", title: "Bots" });
expect(html).not.toContain('aria-label="Breadcrumb"');
});
});
@@ -1,50 +0,0 @@
import { useContext } from "react";
import Breadcrumbs from "@moduletrace/twui/elements/Breadcrumbs";
import Stack from "@moduletrace/twui/layout/Stack";
import { ChevronRight } from "lucide-react";
import { AppContext } from "@/src/pages/__root";
type Props = {
/**
* The record's name, shown where the URL has its id.
*/
title: string;
};
/**
* The hierarchy above a nested settings page: Settings / Bots / Scout.
* Built from the path, with the record's name replacing its id.
*/
export default function SettingsBreadcrumbs({ title }: Props) {
const { pageProps } = useContext(AppContext);
const pathname = pageProps?.url?.pathname;
if (!pathname) return null;
return (
<Stack className="w-full shrink-0 pt-5 pb-1">
<Breadcrumbs
pageUrl={pathname}
currentTitle={title}
skipHome
excludeRegexMatch={/^admin$/}
linkProps={{
className:
"border-0 text-[13px] leading-[18px] text-ink-secondary no-underline hover:text-ink hover:opacity-100 dark:text-ink-secondary",
}}
currentLinkProps={{
title,
className:
"border-0 text-[13px] leading-[18px] text-ink no-underline pointer-events-none dark:text-ink",
}}
divider={
<ChevronRight
size={12}
aria-hidden
className="shrink-0 text-ink-secondary"
/>
}
/>
</Stack>
);
}
@@ -1,7 +1,8 @@
import type { ReactNode } from "react";
import { useContext, type ReactNode } from "react";
import { twMerge } from "tailwind-merge";
import Stack from "@moduletrace/twui/layout/Stack";
import SettingsBreadcrumbs from "./settings-breadcrumbs";
import AdminBreadcrumbs from "@/src/components/general/admin-breadcrumbs";
import { AppContext } from "@/src/pages/__root";
import SettingsPaneHeader from "./settings-pane-header";
type Props = {
@@ -12,10 +13,6 @@ type Props = {
* A wider column, for tables (activity).
*/
wide?: boolean;
/**
* A nested record page's name, for its breadcrumb trail.
*/
breadcrumb_title?: string;
/**
* Controls at the end of the pane header, e.g. a settings link.
*/
@@ -23,17 +20,19 @@ type Props = {
};
/**
* The settings pane beside the rail: a 64px header like the conversation
* header, then one readable column that scrolls on its own.
* The pane beside the rail: a 64px header like the conversation header,
* then one readable column that scrolls on its own, opening with the
* breadcrumb trail above the page.
*/
export default function SettingsShell({
title,
description,
children,
wide,
breadcrumb_title,
action,
}: Props) {
const { pageProps } = useContext(AppContext);
return (
<Stack className="grow min-w-0 h-full gap-0! items-stretch">
<SettingsPaneHeader
@@ -48,9 +47,15 @@ export default function SettingsShell({
wide && "max-w-[1200px]",
)}
>
{breadcrumb_title ? (
<SettingsBreadcrumbs title={breadcrumb_title} />
) : null}
{/**
* The page's own `title` names the last crumb: the
* address would say the id of a record, or a slug that
* differs from the name the page shows.
*/}
<AdminBreadcrumbs
pathname={pageProps?.url?.pathname}
title={title}
/>
{children}
</Stack>
</Stack>
@@ -39,7 +39,6 @@ export default function BotSettingsSection() {
? "Coordinates your crew and hands work to it"
: "A worker bot.")
}
breadcrumb_title={name}
>
<BotOverviewGroup bot={bot} />
<BotDetailsGroup
@@ -45,7 +45,6 @@ export default function ComputerSettingsSection() {
computer.description ||
"Its container, the bots that reach it, and its workspaces."
}
breadcrumb_title={name}
>
<ComputerOverviewGroup computer={computer} />
{is_super_admin ? (
+4 -1
View File
@@ -4,7 +4,10 @@ import settingsPageMeta from "../(functions)/settings-page-meta";
export default function AdminSettingsMcpsPage() {
return (
<SettingsShell title="MCPs" description="MCP servers whose tools your bots can use.">
<SettingsShell
title="MCPs"
description="MCP servers whose tools your bots can use."
>
<McpsSettingsSection />
</SettingsShell>
);
@@ -39,7 +39,6 @@ export default function UserSettingsSection() {
description={
types_title || person.email || "A user on this install."
}
breadcrumb_title={name}
>
<UserOverviewGroup
person={person}
@@ -50,7 +50,6 @@ export default function WorkspaceOverview() {
workspace.description ||
"Context, bots, computers and models for this workspace."
}
breadcrumb_title={name}
>
<WorkspaceImageGroup workspace={workspace} />
<WorkspaceDetailsGroup workspace={workspace} onSaved={setWorkspace} />