Add dedicated bot, workspace and user settings pages

This commit is contained in:
2026-10-19 13:17:17 +01:00
parent 731ea73486
commit 7667f2bc2f
74 changed files with 2183 additions and 358 deletions
@@ -0,0 +1,92 @@
import { useState } from "react";
import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
import HarnessSettingsForm from "@/src/pages/admin/settings/models/(partials)/harness-settings-form";
import postHarnessApi from "@/src/pages/admin/settings/models/(functions)/post-harness-api";
import saveBotHarnessSettings from "@/src/functions/frontend/save-bot-harness-settings";
import saveWorkspaceHarnessSettings from "@/src/functions/frontend/save-workspace-harness-settings";
type Props = {
bot: BotWithLastMessage;
/**
* In a workspace the form saves the workspace's models, for every bot
* working there.
*/
workspace_id?: number | null;
/**
* A worker's `grabBotHarnessOverview`; for the coordinator, the
* defaults (`grabHarnessOverview`).
*/
harnesses: HarnessOverview[];
/**
* Gets the fresh harnesses after each save.
*/
onSaved?: (harnesses: HarnessOverview[]) => void;
};
/**
* The Models settings form (`HarnessSettingsForm`) for one bot. A worker
* saves its own picks ("same as the default" drops one). The coordinator
* runs on the defaults, so it saves the defaults themselves, which only a
* super admin can.
*/
export default function BotHarnessForm({ bot, workspace_id, harnesses: initial, onSaved }: Props) {
const [harnesses, setHarnesses] = useState(initial);
/**
* Remounts the form when the data changes under it.
*/
const [version, setVersion] = useState(0);
const [notice, setNotice] = useState<string>();
const saved = (fresh: HarnessOverview[], msg?: string) => {
setHarnesses(fresh);
setNotice(msg || "Saved.");
setVersion((prev) => prev + 1);
onSaved?.(fresh);
};
return (
<HarnessSettingsForm
key={version}
harnesses={harnesses}
notice={notice}
save={async ({ settings }) => {
if (workspace_id) {
const res = await saveWorkspaceHarnessSettings({
workspace_id,
settings: settings.map((setting) => ({
...setting,
inherit: Boolean(setting.inherit),
})),
});
if (res.success && res.singleRes) saved(res.singleRes.harnesses, res.msg);
return res;
}
if (bot.kind == "coordinator") {
const res = await postHarnessApi({
path: "save-settings",
body: { settings },
});
if (res.success && res.singleRes) saved(res.singleRes.harnesses, res.msg);
return res;
}
const res = await saveBotHarnessSettings({
bot_id: Number(bot.id),
settings: settings.map((setting) => ({
...setting,
inherit: Boolean(setting.inherit),
})),
});
if (res.success && res.singleRes) saved(res.singleRes.harnesses, res.msg);
return res;
}}
/>
);
}
@@ -0,0 +1,50 @@
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,4 +1,5 @@
import type { ReactNode } from "react";
import Link from "@moduletrace/twui/layout/Link";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
@@ -6,6 +7,10 @@ import Stack from "@moduletrace/twui/layout/Stack";
type Props = {
avatar: ReactNode;
title: string;
/**
* Where the title links, for rows that open a page.
*/
href?: string;
/**
* A word beside the title, e.g. "Default" or "You".
*/
@@ -21,15 +26,29 @@ type Props = {
* One row of a settings list: an avatar, a title and one line about it,
* then what can be done with it.
*/
export default function SettingsListRow({ avatar, title, badge, subtitle, children }: Props) {
export default function SettingsListRow({ avatar, title, href, badge, subtitle, children }: Props) {
const title_content = (
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
{title}
{badge ? <Span className="ml-2 text-[11px] font-bold leading-4 text-ink-secondary">{badge}</Span> : null}
</Span>
);
return (
<Row className="w-full min-h-14 gap-3! flex-nowrap items-center px-3 py-2">
{avatar}
<Stack className="grow min-w-0 gap-0!">
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
{title}
{badge ? <Span className="ml-2 text-[11px] font-bold leading-4 text-ink-secondary">{badge}</Span> : null}
</Span>
{href ? (
<Link
href={href}
title={title}
className="block min-w-0 border-0 leading-normal hover:opacity-100 hover:underline focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-surface rounded-default-sm"
>
{title_content}
</Link>
) : (
title_content
)}
{subtitle ? (
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">{subtitle}</Span>
) : null}
@@ -0,0 +1,36 @@
import H2 from "@moduletrace/twui/layout/H2";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
type Props = {
title: string;
description: string;
/**
* An anchor, so the rail can link to this section.
*/
id?: string;
};
/**
* Heads a run of settings groups, e.g. the models form on a bot or
* workspace page.
*/
export default function SettingsSectionHeading({
title,
description,
id,
}: Props) {
return (
<Stack
id={id}
className="w-full scroll-mt-4 gap-1! pt-10 pb-2 border-0 border-b border-solid border-hairline"
>
<H2 className="text-base! leading-6! tracking-normal! font-bold text-ink mb-0!">
{title}
</H2>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{description}
</Span>
</Stack>
);
}
@@ -1,6 +1,7 @@
import type { ReactNode } from "react";
import { twMerge } from "tailwind-merge";
import Stack from "@moduletrace/twui/layout/Stack";
import SettingsBreadcrumbs from "./settings-breadcrumbs";
import SettingsPaneHeader from "./settings-pane-header";
type Props = {
@@ -11,6 +12,10 @@ type Props = {
* A wider column, for tables (activity).
*/
wide?: boolean;
/**
* A nested record page's name, for its breadcrumb trail.
*/
breadcrumb_title?: string;
};
/**
@@ -22,6 +27,7 @@ export default function SettingsShell({
description,
children,
wide,
breadcrumb_title,
}: Props) {
return (
<Stack className="grow min-w-0 h-full gap-0! items-stretch">
@@ -33,6 +39,9 @@ export default function SettingsShell({
wide && "max-w-[1200px]",
)}
>
{breadcrumb_title ? (
<SettingsBreadcrumbs title={breadcrumb_title} />
) : null}
{children}
</Stack>
</Stack>