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:
@@ -230,7 +230,7 @@ Live routes under `/admin` (see `AdminSections`): `/admin/bots`, `/admin/workspa
|
||||
- Routines, Files, Usage, Activity and Help are the `RailPages` (`src/dict/rail-pages-dict.ts`), opened from the rail's user popover (which lists them, then Settings and Sign out). On them the rail shows its own aside (`(partials)/rail-pages-list.tsx`): a back link to the bots, then those pages, like settings. A page carries `super_admin_only` or a `can_view` rule (Usage is `canManageCrew`), and `canSeeRailItem` (`src/utils/rbac/can-see-rail-item.ts`, tested) decides both the popover and the aside, so a page is never offered in one and hidden in the other
|
||||
- `/admin/settings/backups` - super admin only (listed in `SettingsPages`), two groups. Database snapshots: create, list, restore, download and delete in `db/.backups` (`bun-sqlite backup`); "Restore from file" beside "Create backup" opens `upload-restore-modal.tsx` (upload a SQLite database file, which is saved to `.backups` then restored with `bun-sqlite import <file>`, since `restore` is interactive). Whole-data archives: `write-data-backup-archive.ts` tars all of `DATA_DIR` (database, media, computer homes, bot and workspace files, MCP installs) into `BACKUP_ARCHIVE_DIR` as `cobble-data-<epoch-ms>.tar.gz` (via `tar -czf` into a `.part` name, renamed on success so a cut-off run is never listed, with a `--transform` putting every member under one root folder named after the archive), keeping the newest `AppData.DataBackupsKept` and never touching `db/.backups`; its own "Restore from file" uploads a tar.gz (`upload-restore-data.ts`, gzip header checked first). `restore-data-backup.ts` extracts an archive to a temp dir, requires exactly one root folder holding a `db` folder (`findArchiveRoot`, since an upload is saved under a fresh timestamp and its inner name no longer matches the file name), takes a safety archive before touching anything (`write-data-backup-archive.ts`, not the locked `create-data-backup.ts`), empties `DATA_DIR` (the snapshots included) and copies the archive's in, then `restart-app.ts` restarts the process through the docker socket after the response lands (`process.exit`, the container's restart policy brings it back), because the open SQLite handle points at the replaced file. Row actions are icon-only (`RowIconButton`, `src/components/general/row-icon-button.tsx`): restore, download, delete. `BACKUP_ARCHIVE_DIR` is a `ProcessOnlyEnvKeys` start-up path (the installer mounts `$COBBLE_HOME/backups-archives` there; outside docker it falls back to `~/.cobble/backups`). Every backup and restore, of either kind, runs one at a time via `with-backup-run-lock.ts`. Helpers in `src/functions/backend/backups/`, API routes in `api/admin/backups/`
|
||||
- `/admin/settings/database` - super admin only (listed in `SettingsPages`), a read-only browser of the install's SQLite database, no writes anywhere in it. `grabDatabaseTables` reads the catalog once (every table and its columns, SQLite's own `sqlite_*` tables and sqlite-vec's `bot_file_vectors_*` shadow tables left out) and adds each table's `COUNT(*)` and its size from `dbstat` (index pages folded back onto their table). `grabTableRecords` then returns one page (`AppData.DatabaseBrowserPageSize`, 50) of the open table's records newest key first, every value already cut in SQL at `AppData.DatabaseBrowserCellMaxChars` (400) so a table of long texts never ships a megabyte of JSON, and a `truncated` flag per cell; the search matches over the concatenation of every column's text (`buildSearchClause`: every word, or the whole phrase), which is what also works on a virtual table, where `MATCH` and `LIKE` over a vector blob do not. A record's key is its single primary key column, else its rowid, else nothing (a virtual table with no primary key reads as plain cells); clicking a cell's row opens `grabTableRecord` for that key, which returns the record whole up to `AppData.DatabaseBrowserValueMaxChars` (20000), the answer to a cell cut at three lines. `clampPage` puts a page past the end on the last one. The page's state (table, search, mode, page) lives in the address through `databaseHref`, so a table or a search can be linked to; the open table's first page comes with the page props and anything else loads through the API. Helpers in `src/functions/backend/database/` (`build-table-catalog-sql`, `build-table-sizes-sql`, `build-records-sql`, `build-search-clause`, `resolve-table-key`, `build-record-row`, `quote-sql-identifier`), API `api/admin/database/records` and `record` (GET only, `grabSuperAdminUser`), zod in `src/schemas/database-schema.ts`, page under `src/pages/admin/settings/database/`
|
||||
- `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar; for super admins the prompt appendix (`prompt-section.tsx`, `system-prompt-append-form.tsx`) saving `api/admin/settings/system-prompt-append` into the `bot_system_prompt_append` app setting, and the run limits (`run-limits-section.tsx`, `run-limits-form.tsx`) saving `api/admin/settings/run-limits` into the `harness_max_tool_steps` and `bot_reply_max_tool_steps` app settings (`grabRunLimitsOverview`, `run_limits` page prop, 0 clearing a limit back to its code default); and, for `canManageCrew`, the bot file quota: `storage-section.tsx`, `storage-quota-form.tsx` saving `api/admin/settings/storage-quota` into `app_settings`; and, for everyone, the running version: `version-section.tsx`, `version-line.tsx` showing `pageProps.running_version` from `runningVersion` (`src/utils/running-version.ts`: `COBBLE_VERSION`, the release tag the image was built with, else `package.json`'s version, in development too, with `(development)` after it and no such suffix in production), `profile/` (current user profile), `notifications/` (this device's Web Push switch, a test send, and every device the user subscribed), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete), `mcps/`, `tools/` and `skills/` (see MCPs, custom tools and skills: super admins add, edit, refresh or try, delete; admins only set Access, `extension-access-modal.tsx`; add, edit, access and try open in modals (`AddModalShell`). A skill has no status: nothing connects or runs, so `skill-row.tsx` puts its slug, who gets it (`extension-access-label.ts`) and its description where a server's or tool's status line goes), each MCP server or tool row with a status line under it (`extension-status-line.tsx`: connected or not, last run worked or failed, when, and why), those five for `canManageCrew` only (`grab-crew-settings-server-props.ts`, `can_view` in the dict) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), both with the one `UserForm` (`admin/settings/users/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry
|
||||
- `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar; for super admins the prompt appendix (`prompt-section.tsx`, `system-prompt-append-form.tsx`) saving `api/admin/settings/system-prompt-append` into the `bot_system_prompt_append` app setting, and the run limits (`run-limits-section.tsx`, `run-limits-form.tsx`) saving `api/admin/settings/run-limits` into the `harness_max_tool_steps` and `bot_reply_max_tool_steps` app settings (`grabRunLimitsOverview`, `run_limits` page prop, 0 clearing a limit back to its code default); and, for `canManageCrew`, the bot file quota: `storage-section.tsx`, `storage-quota-form.tsx` saving `api/admin/settings/storage-quota` into `app_settings`; and, for everyone, the running version: `version-section.tsx`, `version-line.tsx` showing `pageProps.running_version` from `runningVersion` (`src/utils/running-version.ts`: `COBBLE_VERSION`, the release tag the image was built with, else `package.json`'s version, in development too, with `(development)` after it and no such suffix in production), `profile/` (current user profile), `notifications/` (this device's Web Push switch, a test send, and every device the user subscribed), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column that opens with the breadcrumb trail, `src/components/general/admin-breadcrumbs.tsx`: twui's `Breadcrumbs` reading `Home`, the sections and the page off `pageProps.url.pathname`, the last crumb named by the page's own `title` so a record reads `Scout` rather than `3` and `/admin/logs` reads `Activity`; `hasBreadcrumbTrail` drops it only on the crew of bots, which is home, and nothing outside `/admin` gets one). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete), `mcps/`, `tools/` and `skills/` (see MCPs, custom tools and skills: super admins add, edit, refresh or try, delete; admins only set Access, `extension-access-modal.tsx`; add, edit, access and try open in modals (`AddModalShell`). A skill has no status: nothing connects or runs, so `skill-row.tsx` puts its slug, who gets it (`extension-access-label.ts`) and its description where a server's or tool's status line goes), each MCP server or tool row with a status line under it (`extension-status-line.tsx`: connected or not, last run worked or failed, when, and why), those five for `canManageCrew` only (`grab-crew-settings-server-props.ts`, `can_view` in the dict) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), both with the one `UserForm` (`admin/settings/users/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry
|
||||
|
||||
Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout or aside. The rail header's Add icon (super admins and admins only, like the rail's "+" buttons) opens the Add flow (`(partials)/add-flow.tsx`, `AddFlowContext`): a bot, a workspace or (super admins) a computer, each form in a modal stacked over the choice, so closing it goes back there (the Bots and Workspaces labels have a "+" that opens the form directly; in a workspace's rail, "Workspace bots" has a "+" that opens `link-bots-modal.tsx` instead: the crew's workers not in it yet (`api/admin/workspaces/linkable-bots`), each added with `add-bot`, which pushes `server:bot-created`, and "New bot" stacking the bot form over it); on a workspace's pages new bots and computers join it. The rail shows the crew (`(partials)/rail-crew-list.tsx`, needs `bots` and `workspaces` in page props): the coordinator, the latest `AppData.RailRecentBots` workers (plus any open, unread or needing you, `pick-rail-bots`), "See all bots" with a few stones opening every bot in a modal, then Workspaces with a "+". On `/admin/workspaces/*` it shows the workspace's crew instead (`(partials)/rail-workspace-list.tsx`: back link, the workspace, then only the bots attached to it, "Workspace bots"; the coordinator stays in the main crew rail, and on a workspace bot's page the back link returns to the workspace page), except on `/admin/settings/*` where it shows "Back to bots" plus the settings sub pages (`(partials)/rail-settings-list.tsx`). Page heroes use the shared `AdminHero` component (`src/components/general/admin-hero.tsx`).
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import Breadcrumbs from "@moduletrace/twui/elements/Breadcrumbs";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
|
||||
import hasBreadcrumbTrail from "@/src/utils/has-breadcrumb-trail";
|
||||
import Divider from "@moduletrace/twui/layout/Divider";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* The address the page is at, e.g. `/admin/settings/bots/3`.
|
||||
*/
|
||||
pathname?: string | null;
|
||||
/**
|
||||
* The page's own name, for the last crumb: what the address can't say,
|
||||
* since its last segment is an id (`Scout`, not `3`) or a slug that
|
||||
* differs from the name the page shows (`MCPs`, not `Mcps`).
|
||||
*/
|
||||
title?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The trail above a page's content: `Home`, then every section the address
|
||||
* passes through, then the page itself, named by `title`.
|
||||
*
|
||||
* It is `twui`'s `Breadcrumbs`, which reads the trail off the path, so the
|
||||
* sections come from the address and only the page's own name is given
|
||||
* here; `hasBreadcrumbTrail` says which pages get one at all.
|
||||
*/
|
||||
export default function AdminBreadcrumbs({ pathname, title }: Props) {
|
||||
if (!hasBreadcrumbTrail({ pathname })) return null;
|
||||
|
||||
return (
|
||||
<Stack
|
||||
className={twMerge(
|
||||
"w-full shrink-0 pt-5 pb-1 sticky top-0 z-10 bg-background-light",
|
||||
"dark:bg-background-dark",
|
||||
)}
|
||||
>
|
||||
<Breadcrumbs
|
||||
pageUrl={pathname || AdminHomePath}
|
||||
currentTitle={title}
|
||||
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"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Divider />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
-1
@@ -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,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} />
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import hasBreadcrumbTrail from "./has-breadcrumb-trail";
|
||||
|
||||
describe("hasBreadcrumbTrail", () => {
|
||||
test("a top-level page has one, so a lone page never goes without", () => {
|
||||
expect(hasBreadcrumbTrail({ pathname: "/admin/routines" })).toBe(true);
|
||||
});
|
||||
|
||||
test("a nested page has one", () => {
|
||||
expect(hasBreadcrumbTrail({ pathname: "/admin/settings/general" })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(hasBreadcrumbTrail({ pathname: "/admin/settings/bots/3" })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("the crew of bots is home, so it has none", () => {
|
||||
expect(hasBreadcrumbTrail({ pathname: "/admin/bots" })).toBe(false);
|
||||
expect(hasBreadcrumbTrail({ pathname: "/admin/bots/" })).toBe(false);
|
||||
});
|
||||
|
||||
test("the address that only redirects to home has none either", () => {
|
||||
expect(hasBreadcrumbTrail({ pathname: "/admin" })).toBe(false);
|
||||
expect(hasBreadcrumbTrail({ pathname: "/admin/" })).toBe(false);
|
||||
});
|
||||
|
||||
test("a folder in the files browser keeps the trail of its page", () => {
|
||||
expect(
|
||||
hasBreadcrumbTrail({
|
||||
pathname: "/admin/files?volume=bot&owner=1&path=reports",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("nothing outside the admin pages has one", () => {
|
||||
expect(hasBreadcrumbTrail({ pathname: "/auth/login" })).toBe(false);
|
||||
expect(hasBreadcrumbTrail({ pathname: "/" })).toBe(false);
|
||||
expect(hasBreadcrumbTrail({ pathname: "" })).toBe(false);
|
||||
expect(hasBreadcrumbTrail({})).toBe(false);
|
||||
expect(hasBreadcrumbTrail({ pathname: null })).toBe(false);
|
||||
});
|
||||
|
||||
test("a path that merely starts like admin does not count", () => {
|
||||
expect(hasBreadcrumbTrail({ pathname: "/administrator" })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
|
||||
|
||||
type Params = {
|
||||
pathname?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether an address has a trail above it: `Home`, then every section it
|
||||
* passes through, then the page. `twui`'s `Breadcrumbs` draws the trail
|
||||
* itself from the path, so this only answers which pages get one.
|
||||
*
|
||||
* The crew of bots is where the app lands, and its own address would
|
||||
* read `Home / Bots`, both the same page, so it has none. Neither does
|
||||
* anything outside the admin pages, since everything else redirects to
|
||||
* the bots.
|
||||
*
|
||||
* The trailing slash and the query string are ignored, so a folder opened
|
||||
* in the files browser keeps the trail of the page it is on.
|
||||
*/
|
||||
export default function hasBreadcrumbTrail({ pathname }: Params): boolean {
|
||||
if (!pathname) return false;
|
||||
|
||||
const path = pathname.split("?")[0]!.replace(/\/$/, "");
|
||||
|
||||
if (path != "/admin" && !path.startsWith("/admin/")) return false;
|
||||
|
||||
/**
|
||||
* `/admin` only redirects to the crew of bots, which is home.
|
||||
*/
|
||||
return path != AdminHomePath && path != "/admin";
|
||||
}
|
||||
Reference in New Issue
Block a user