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
+1 -1
View File
@@ -122,7 +122,7 @@ export default function AddFlow({ children }: Props) {
const pathname = pageProps?.url?.pathname;
if (
(linked && pathname == `/admin/workspaces/${workspace?.id}`) ||
(linked && pathname == `/admin/settings/workspaces/${workspace?.id}`) ||
pathname == SettingsComputersPath
) {
window.location.reload();
@@ -7,7 +7,11 @@ type Props = {
submit_label: string;
loading?: boolean;
error?: string;
onCancel: () => void;
/**
* Left out on a form embedded in a settings page: then there is
* nothing to cancel into.
*/
onCancel?: () => void;
};
/**
@@ -19,17 +23,19 @@ export default function AddFormActions({ submit_label, loading, error, onCancel
<Stack className="gap-3! items-stretch">
<SettingsStatus status={error ? { error: true, msg: error } : undefined} />
<Row className="gap-2! flex-nowrap justify-end">
<Button
title="Cancel"
type="button"
variant="ghost"
color="gray"
size="small"
disabled={loading}
onClick={onCancel}
>
Cancel
</Button>
{onCancel ? (
<Button
title="Cancel"
type="button"
variant="ghost"
color="gray"
size="small"
disabled={loading}
onClick={onCancel}
>
Cancel
</Button>
) : null}
<Button title={submit_label} type="submit" size="small" loading={loading} disabled={loading}>
{submit_label}
</Button>
@@ -49,7 +49,7 @@ export default function AddWorkspaceForm({ onCancel }: Props) {
await setProfileImage({ target: "workspace", id: workspace_id, data_url: image.data_url }).catch(() => null);
}
window.location.href = `/admin/workspaces/${workspace_id}`;
window.location.href = `/admin/settings/workspaces/${workspace_id}`;
};
return (
+27 -2
View File
@@ -20,7 +20,15 @@ type Props = {
* Edits this bot; adds a worker when not given.
*/
bot?: BUN_SQLITE_COBBLE_BOTS;
onCancel: () => void;
/**
* Left out when the form is embedded in a settings page.
*/
onCancel?: () => void;
/**
* Called with the saved bot instead of reloading. For the settings
* page, which updates in place.
*/
onSaved?: (bot: BUN_SQLITE_COBBLE_BOTS) => void;
};
/**
@@ -30,7 +38,7 @@ type Props = {
* The coordinator keeps its name and has no colour: only its picture and
* instructions change.
*/
export default function BotForm({ bot, onCancel }: Props) {
export default function BotForm({ bot, onCancel, onSaved }: Props) {
const { pageProps } = useContext(AppContext);
const workspace = pageProps?.workspace;
const crew = pageProps?.all_bots || pageProps?.bots || [];
@@ -72,6 +80,8 @@ export default function BotForm({ bot, onCancel }: Props) {
return;
}
let image_media_id: number | null | undefined;
if (image.changed) {
const image_res = await setProfileImage({
target: "bot",
@@ -84,9 +94,24 @@ export default function BotForm({ bot, onCancel }: Props) {
setError(image_res?.msg || "The bot is saved, but its picture isn't. Try another image.");
return;
}
image_media_id = image_res.singleRes?.image_media_id;
}
if (bot?.id) {
const saved = res.singleRes as BUN_SQLITE_COBBLE_BOTS | undefined;
if (onSaved && saved?.id) {
setLoading(false);
onSaved({
...saved,
...(image_media_id === undefined
? {}
: { image_media_id: image_media_id ?? undefined }),
});
return;
}
window.location.reload();
return;
}
@@ -0,0 +1,127 @@
import { useContext, type ReactNode } from "react";
import Divider from "@moduletrace/twui/layout/Divider";
import BotAvatar from "@/src/components/general/bot-avatar";
import UserAvatar from "@/src/components/general/user-avatar";
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
import { UserTypes } from "@/src/dict/user-types-dict";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import {
SettingsRecordBack,
SettingsRecordSections,
} from "@/src/dict/settings-record-sections-dict";
import { AppContext } from "@/src/pages/__root";
import parseSettingsRecordPath from "@/src/utils/parse-settings-record-path";
import parseUserTypes from "@/src/utils/parse-user-types";
import RailBackLink from "./rail-back-link";
import RailSettingsLink from "./rail-settings-link";
import RailSettingsList from "./rail-settings-list";
import RailSettingsRecordRow from "./rail-settings-record-row";
type Props = {
pathname?: string;
};
/**
* Settings mode of the rail. On a list page it shows the settings sub
* pages; on a bot, workspace or user record page it drills into that
* record: a back link to its list, the record, then its sections.
*/
export default function RailSettingsAside({ pathname }: Props) {
const { pageProps } = useContext(AppContext);
const record = parseSettingsRecordPath({ pathname });
if (!record) {
return <RailSettingsList pathname={pathname} />;
}
const back = SettingsRecordBack[record.kind];
const sections = SettingsRecordSections[record.kind].filter((section) => {
if (
record.kind == "bots" &&
section.url == "#delete" &&
pageProps?.bot?.kind == "coordinator"
) {
return false;
}
if (section.url == "#models") {
return record.kind == "bots"
? Boolean(pageProps?.bot_harness_overview?.harnesses?.length)
: record.kind == "workspaces"
? Boolean(
pageProps?.workspace_harness_overview?.harnesses?.length,
)
: true;
}
return true;
});
let avatar: ReactNode = null;
let title = "";
let subtitle = "";
if (record.kind == "bots") {
const bot = pageProps?.bot;
if (!bot?.id) return <RailSettingsList pathname={pathname} />;
title = bot.name || `Bot ${bot.id}`;
subtitle =
bot.role ||
(bot.kind == "coordinator"
? "Coordinates your crew"
: "Worker bot");
avatar = (
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
face={(bot.face || "idle") as BotFace}
/>
);
} else if (record.kind == "workspaces") {
const workspace = pageProps?.workspace;
if (!workspace?.id) return <RailSettingsList pathname={pathname} />;
title = workspace.name || `Workspace ${workspace.id}`;
subtitle = workspace.description || "A workspace";
avatar = (
<WorkspaceAvatar image_media_id={workspace.image_media_id} />
);
} else {
const person = pageProps?.target_person;
if (!person?.id) return <RailSettingsList pathname={pathname} />;
const types = UserTypes.filter((type) =>
parseUserTypes({ user_types: person.user_types }).includes(
type.value,
),
)
.map((type) => type.title)
.join(", ");
title =
[person.first_name, person.last_name].filter(Boolean).join(" ") ||
person.email ||
`User ${person.id}`;
subtitle = types || person.email || "User";
avatar = <UserAvatar person={person} size="md" />;
}
return (
<>
<RailBackLink title={back.title} href={back.href} />
<Divider className="shrink-0 mx-1 lg:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
<RailSettingsRecordRow
avatar={avatar}
title={title}
subtitle={subtitle}
/>
{sections.map((section) => (
<RailSettingsLink key={section.url} {...section} />
))}
</>
);
}
@@ -0,0 +1,39 @@
import type { ReactNode } from "react";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
type Props = {
avatar: ReactNode;
title: string;
subtitle?: string;
};
/**
* The record whose settings are open, at the top of its rail. Same 48px
* row as a bot, selected.
*/
export default function RailSettingsRecordRow({
avatar,
title,
subtitle,
}: Props) {
return (
<Row
aria-current="page"
className="w-full shrink-0 min-h-12 gap-3! flex-nowrap items-center rounded-default px-1 lg:px-3 bg-ink/5"
>
{avatar}
<Stack className="hidden lg:flex grow min-w-0 gap-0!">
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
{title}
</Span>
{subtitle ? (
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">
{subtitle}
</Span>
) : null}
</Stack>
</Row>
);
}
@@ -1,9 +1,12 @@
import { useContext } from "react";
import Link from "@moduletrace/twui/layout/Link";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
import { twMerge } from "tailwind-merge";
import { AppContext } from "@/src/pages/__root";
import type { WorkspaceWithActivity } from "@/src/types";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
type Props = {
workspace: WorkspaceWithActivity;
@@ -15,9 +18,14 @@ type Props = {
* hue, so unread is said in words beside the description.
*/
export default function WorkspaceRow({ workspace, selected }: Props) {
const { pageProps } = useContext(AppContext);
const href = canManageCrew({ user_types: pageProps?.user_types })
? `/admin/settings/workspaces/${workspace.id}`
: `/admin/workspaces/${workspace.id}`;
return (
<Link
href={`/admin/workspaces/${workspace.id}`}
href={href}
aria-current={selected ? "page" : undefined}
title={workspace.name}
className={twMerge(
+2 -2
View File
@@ -7,7 +7,7 @@ import { RailPages } from "@/src/dict/rail-pages-dict";
import RailHeader from "../(partials)/rail-header";
import RailPagesList from "../(partials)/rail-pages-list";
import RailCrewList from "../(partials)/rail-crew-list";
import RailSettingsList from "../(partials)/rail-settings-list";
import RailSettingsAside from "../(partials)/rail-settings-aside";
import RailUserRow from "../(partials)/rail-user-row";
import RailWorkspaceList from "../(partials)/rail-workspace-list";
@@ -48,7 +48,7 @@ export default function BotsRail() {
<RailHeader show_add={!is_settings_page && !is_rail_page} />
<Stack className="grow min-h-0 overflow-y-auto gap-0! items-stretch px-2 lg:px-3 pt-1 pb-3">
{is_settings_page ? (
<RailSettingsList pathname={pathname} />
<RailSettingsAside pathname={pathname} />
) : is_rail_page ? (
<RailPagesList pathname={pathname} />
) : in_workspace && live_workspace ? (