From bf7109952b15208fe0a8f048994f207f5d117518 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Mon, 19 Oct 2026 20:37:25 +0100 Subject: [PATCH] Link existing bots from the workspace rail's "+" The "+" beside "Workspace bots" now opens a modal listing the crew's workers not in the workspace yet (api/admin/workspaces/linkable-bots), each added in one click, with "New bot" stacking the bot form over it. Adding a bot to a workspace now pushes server:bot-created so other rails pick it up live. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- src/layouts/bots/(data)/add-flow-context.ts | 6 + src/layouts/bots/(partials)/add-flow.tsx | 41 ++++-- src/layouts/bots/(partials)/link-bot-row.tsx | 46 +++++++ .../bots/(partials)/link-bots-modal.tsx | 125 ++++++++++++++++++ .../bots/(partials)/rail-workspace-list.tsx | 4 +- src/pages/api/admin/workspaces/add-bot.ts | 15 ++- .../api/admin/workspaces/linkable-bots.ts | 58 ++++++++ 8 files changed, 282 insertions(+), 15 deletions(-) create mode 100644 src/layouts/bots/(partials)/link-bot-row.tsx create mode 100644 src/layouts/bots/(partials)/link-bots-modal.tsx create mode 100644 src/pages/api/admin/workspaces/linkable-bots.ts diff --git a/AGENTS.md b/AGENTS.md index e820c38..75ca5fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -160,7 +160,7 @@ Live routes under `/admin` (see `AdminSections`): `/admin/bots`, `/admin/workspa - `/admin/settings/backups` - super admin only (listed in `SettingsPages`): create/list/download database snapshots in `db/.backups`, plus `restore/` (restore a listed backup, or upload a SQLite database file which is saved to `.backups` then restored). Shells out to the `bun-sqlite` CLI (`backup`, and `import ` since `restore` is interactive) via `src/functions/backend/backups/`; API routes in `api/admin/backups/` - `/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), `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/` and `tools/` (see MCPs and custom tools: 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`)), each 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); 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`). +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`). ## Page Directory Rules diff --git a/src/layouts/bots/(data)/add-flow-context.ts b/src/layouts/bots/(data)/add-flow-context.ts index 7ff8326..95cb3fd 100644 --- a/src/layouts/bots/(data)/add-flow-context.ts +++ b/src/layouts/bots/(data)/add-flow-context.ts @@ -11,6 +11,11 @@ export type AddFlow = { * a workspace page's "New computer"). */ openForm: (params: { kind: AddKind }) => void; + /** + * Opens the link-bots modal of the workspace being viewed (the + * workspace rail's "+"), whose "New bot" opens the bot form over it. + */ + openLinkBots: () => void; }; /** @@ -19,4 +24,5 @@ export type AddFlow = { export const AddFlowContext = React.createContext({ openAdd: () => {}, openForm: () => {}, + openLinkBots: () => {}, }); diff --git a/src/layouts/bots/(partials)/add-flow.tsx b/src/layouts/bots/(partials)/add-flow.tsx index bae4b6f..a64d083 100644 --- a/src/layouts/bots/(partials)/add-flow.tsx +++ b/src/layouts/bots/(partials)/add-flow.tsx @@ -11,6 +11,7 @@ import AddComputerForm from "./add-computer-form"; import AddModalShell from "./add-modal-shell"; import AddNotice from "./add-notice"; import AddWorkspaceForm from "./add-workspace-form"; +import LinkBotsModal from "./link-bots-modal"; type Props = { children: ReactNode; @@ -25,12 +26,13 @@ type Props = { export default function AddFlow({ children }: Props) { const { pageProps } = useContext(AppContext); const [choosing, setChoosing] = useState(false); + const [linking, setLinking] = useState(false); const [form, setForm] = useState(null); const [notice, setNotice] = useState(); /** * twui closes every open modal on Escape. While a form is open over the - * Add modal, the Add modal is remounted open instead, so only the form - * closes. + * Add modal (or the link-bots modal), that modal is remounted open + * instead, so only the form closes. */ const form_ref = useRef(null); const [chooser_key, setChooserKey] = useState(0); @@ -49,25 +51,32 @@ export default function AddFlow({ children }: Props) { setForm(kind); }; + /** + * Closes a modal the forms open over, unless a form is open on top. + */ + const underForm = (setter: (open: boolean) => void) => (open: boolean) => { + if (!open && form_ref.current) { + setChooserKey((prev) => prev + 1); + return; + } + + setter(open); + }; + const close = () => { form_ref.current = null; setForm(null); }; return ( - setChoosing(true), openForm }}> + setChoosing(true), openForm, openLinkBots: () => setLinking(true) }} + > {children} { - if (!open && form_ref.current) { - setChooserKey((prev) => prev + 1); - return; - } - - setChoosing(open); - }} + setOpen={underForm(setChoosing)} title="Add" description={ workspace?.id @@ -85,6 +94,16 @@ export default function AddFlow({ children }: Props) { ))} + {workspace?.id ? ( + openForm({ kind: "bot" })} + /> + ) : null} !open && close()} diff --git a/src/layouts/bots/(partials)/link-bot-row.tsx b/src/layouts/bots/(partials)/link-bot-row.tsx new file mode 100644 index 0000000..193134c --- /dev/null +++ b/src/layouts/bots/(partials)/link-bot-row.tsx @@ -0,0 +1,46 @@ +import Button from "@moduletrace/twui/layout/Button"; +import Row from "@moduletrace/twui/layout/Row"; +import Span from "@moduletrace/twui/layout/Span"; +import Stack from "@moduletrace/twui/layout/Stack"; +import BotAvatar from "@/src/components/general/bot-avatar"; +import type { BotFace, BotHue } from "@/src/dict/bot-dict"; +import type { BotWithLastMessage } from "@/src/types"; + +type Props = { + bot: BotWithLastMessage; + linking?: boolean; + disabled?: boolean; + onLink: () => void; +}; + +/** + * One bot in the link-bots modal: stone, name, job, and Add. + */ +export default function LinkBotRow({ bot, linking, disabled, onLink }: Props) { + return ( + + + + {bot.name} + {bot.role || ""} + + + + ); +} diff --git a/src/layouts/bots/(partials)/link-bots-modal.tsx b/src/layouts/bots/(partials)/link-bots-modal.tsx new file mode 100644 index 0000000..24af929 --- /dev/null +++ b/src/layouts/bots/(partials)/link-bots-modal.tsx @@ -0,0 +1,125 @@ +import { useEffect, useState } from "react"; +import Input from "@moduletrace/twui/form/Input"; +import Button from "@moduletrace/twui/layout/Button"; +import Span from "@moduletrace/twui/layout/Span"; +import Stack from "@moduletrace/twui/layout/Stack"; +import { Plus } from "lucide-react"; +import type { BotWithLastMessage, WorkspaceBot } from "@/src/types"; +import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status"; +import postAdminApi from "../(functions)/post-admin-api"; +import AddModalShell from "./add-modal-shell"; +import LinkBotRow from "./link-bot-row"; + +type Props = { + open: boolean; + setOpen: (open: boolean) => void; + workspace_id: number; + workspace_name?: string | null; + /** + * Opens the Add-a-bot form over this modal. + */ + onNewBot: () => void; +}; + +/** + * The workspace rail's "+": the crew's workers not in the workspace yet, + * each added with one click, and "New bot" for one that doesn't exist. + * Adding reloads the page, so the rail and the dashboard show the new crew. + */ +export default function LinkBotsModal({ open, setOpen, workspace_id, workspace_name, onNewBot }: Props) { + const [bots, setBots] = useState(); + const [query, setQuery] = useState(""); + const [linking_id, setLinkingId] = useState(); + const [error, setError] = useState(); + + useEffect(() => { + if (!open) return; + + setError(undefined); + + postAdminApi({ path: "workspaces/linkable-bots", body: { workspace_id } }).then((res) => { + if (res.success) { + setBots(res.payload || []); + return; + } + + setBots([]); + setError(res.msg || "Couldn't load the bots."); + }); + }, [open, workspace_id]); + + const link = async ({ bot }: { bot: BotWithLastMessage }) => { + if (!window.confirm(`Add ${bot.name || "this bot"} to ${workspace_name || "this workspace"}?`)) return; + + setLinkingId(Number(bot.id)); + setError(undefined); + + const res = await postAdminApi({ + path: "workspaces/add-bot", + body: { workspace_id, bot_id: bot.id }, + }); + + if (res.success) { + window.location.reload(); + return; + } + + setLinkingId(undefined); + setError(res.msg || "That didn't work."); + }; + + const search = query.trim().toLowerCase(); + const found = (bots || []).filter( + (bot) => !search || `${bot.name} ${bot.role || ""}`.toLowerCase().includes(search), + ); + + return ( + + {bots?.length ? ( + setQuery(e.target.value)} + wrapperWrapperProps={{ className: "mt-4" }} + /> + ) : null} + + {found.map((bot) => ( + link({ bot })} + /> + ))} + {!bots || found.length ? null : ( + + {search ? `No bot matches "${query.trim()}".` : "Every bot is already here."} + + )} + + + + + ); +} diff --git a/src/layouts/bots/(partials)/rail-workspace-list.tsx b/src/layouts/bots/(partials)/rail-workspace-list.tsx index 1310efb..bfa571c 100644 --- a/src/layouts/bots/(partials)/rail-workspace-list.tsx +++ b/src/layouts/bots/(partials)/rail-workspace-list.tsx @@ -27,7 +27,7 @@ type Props = { * workspace's context. The coordinator is reached from the main rail. */ export default function RailWorkspaceList({ workspace, bots, selected_id, on_workspace_page }: Props) { - const { openForm } = useContext(AddFlowContext); + const { openLinkBots } = useContext(AddFlowContext); const workers = bots.filter((bot) => bot.kind !== "coordinator"); const base = `/admin/workspaces/${workspace.id}`; @@ -40,7 +40,7 @@ export default function RailWorkspaceList({ workspace, bots, selected_id, on_wor openForm({ kind: "bot" })} />} + action={} title="Workspace bots" workers={workers} selected_id={on_workspace_page ? undefined : selected_id} diff --git a/src/pages/api/admin/workspaces/add-bot.ts b/src/pages/api/admin/workspaces/add-bot.ts index 2b5ba0d..f4af444 100644 --- a/src/pages/api/admin/workspaces/add-bot.ts +++ b/src/pages/api/admin/workspaces/add-bot.ts @@ -1,3 +1,6 @@ +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db"; +import pushBotCreated from "@/src/bots-queue/push-bot-created"; import userAuth from "@/src/functions/backend/auth/user-auth"; import addBotToWorkspace from "@/src/functions/backend/db/workspaces/add-bot-to-workspace"; import grabWorkspaceBots from "@/src/functions/backend/db/workspaces/grab-workspace-bots"; @@ -14,7 +17,8 @@ import type { /** * Puts one of the session user's bots in one of their workspaces. Returns - * the workspace's crew. + * the workspace's crew, and pushes the bot to everyone who now sees it + * there. */ export const handler: BunextAPIRouteHandler> = async ({ req, body }) => { try { @@ -36,6 +40,15 @@ export const handler: BunextAPIRouteHandler> = a await addBotToWorkspace({ user_id: user.id, workspace_id, bot_id }); + const bot = ( + await BunSQLite.sql({ + sql: `SELECT * FROM bots WHERE id = ? LIMIT 1`, + values: [bot_id], + }) + ).payload?.[0]; + + if (bot) await pushBotCreated({ bot }); + return { success: true, payload: await grabWorkspaceBots({ user_id: user.id, workspace_id }), diff --git a/src/pages/api/admin/workspaces/linkable-bots.ts b/src/pages/api/admin/workspaces/linkable-bots.ts new file mode 100644 index 0000000..f356dfe --- /dev/null +++ b/src/pages/api/admin/workspaces/linkable-bots.ts @@ -0,0 +1,58 @@ +import userAuth from "@/src/functions/backend/auth/user-auth"; +import grabBots from "@/src/functions/backend/db/bots/grab-bots"; +import grabUserWorkspace from "@/src/functions/backend/db/workspaces/grab-user-workspace"; +import grabWorkspaceBots from "@/src/functions/backend/db/workspaces/grab-workspace-bots"; +import type { BotWithLastMessage } from "@/src/types"; +import { WorkspaceIdRequestSchema } from "@/src/schemas/workspace-schema"; +import AppError from "@/src/utils/app-error"; +import parseWithSchema from "@/src/utils/parse-with-schema"; +import canManageCrew from "@/src/utils/rbac/can-manage-crew"; +import safeErrorMessage from "@/src/utils/safe-error-message"; +import type { + APIResponseObject, + BunextAPIRouteHandler, +} from "@moduletrace/bunext/types"; + +/** + * The crew's workers that aren't in a workspace yet, for the workspace + * rail's "+". Super admins and admins only. + */ +export const handler: BunextAPIRouteHandler> = async ({ req, body }) => { + try { + if (req.method != "POST") { + throw new AppError(`Method not allowed`); + } + + const { user, user_types } = await userAuth({ req }); + + if (!user?.logged_in_status || !user.id) { + return { success: false, msg: `Unauthorized`, logoutUser: true }; + } + + if (!canManageCrew({ user_types })) { + throw new AppError(`Only admins add bots to workspaces.`); + } + + const { workspace_id } = parseWithSchema({ schema: WorkspaceIdRequestSchema, data: body }); + + const workspace = await grabUserWorkspace({ workspace_id, user_id: user.id }); + + if (!workspace?.id) { + throw new AppError(`Workspace not found.`); + } + + const [bots, members] = await Promise.all([ + grabBots({ user_id: user.id }), + grabWorkspaceBots({ user_id: user.id, workspace_id }), + ]); + + return { + success: true, + payload: bots.filter( + (bot) => bot.kind != "coordinator" && !members.some((member) => member.id == bot.id), + ), + }; + } catch (error: unknown) { + return { success: false, msg: safeErrorMessage({ error }) }; + } +};