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 <[email protected]>
This commit is contained in:
2026-10-19 20:37:25 +01:00
co-authored by Claude Opus 5.5
parent 139b3e443d
commit bf7109952b
8 changed files with 282 additions and 15 deletions
@@ -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<AddFlow>({
openAdd: () => {},
openForm: () => {},
openLinkBots: () => {},
});
+30 -11
View File
@@ -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<AddKind | null>(null);
const [notice, setNotice] = useState<string>();
/**
* 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<AddKind | null>(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 (
<AddFlowContext.Provider value={{ openAdd: () => setChoosing(true), openForm }}>
<AddFlowContext.Provider
value={{ openAdd: () => setChoosing(true), openForm, openLinkBots: () => setLinking(true) }}
>
{children}
<AddModalShell
key={chooser_key}
open={choosing}
setOpen={(open) => {
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) {
))}
</Stack>
</AddModalShell>
{workspace?.id ? (
<LinkBotsModal
key={`link-${chooser_key}`}
open={linking}
setOpen={underForm(setLinking)}
workspace_id={Number(workspace.id)}
workspace_name={workspace.name}
onNewBot={() => openForm({ kind: "bot" })}
/>
) : null}
<AddModalShell
open={form == "bot"}
setOpen={(open) => !open && close()}
@@ -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 (
<Row className="h-14 items-center gap-3 rounded-default px-3 flex-nowrap hover:bg-sunken transition-colors duration-[var(--m-fast)]">
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
face={(bot.face || "idle") as BotFace}
/>
<Stack className="grow min-w-0 gap-0!">
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">{bot.name}</Span>
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">{bot.role || ""}</Span>
</Stack>
<Button
title={`Add ${bot.name || "this bot"}`}
type="button"
variant="ghost"
color="gray"
size="small"
loading={linking}
disabled={disabled}
onClick={onLink}
className="shrink-0"
>
Add
</Button>
</Row>
);
}
@@ -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<BotWithLastMessage[]>();
const [query, setQuery] = useState("");
const [linking_id, setLinkingId] = useState<number>();
const [error, setError] = useState<string>();
useEffect(() => {
if (!open) return;
setError(undefined);
postAdminApi<BotWithLastMessage>({ 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<WorkspaceBot>({
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 (
<AddModalShell
open={open}
setOpen={setOpen}
title={`Add bots to ${workspace_name || "this workspace"}`}
description="Bots here work with the workspace's context, computers and models."
>
{bots?.length ? (
<Input
id="link-bots-search"
title="Search bots"
placeholder="Search by name or job"
autoComplete="off"
defaultValue={query}
onChange={(e) => setQuery(e.target.value)}
wrapperWrapperProps={{ className: "mt-4" }}
/>
) : null}
<Stack className="w-full mt-2 max-h-[50vh] overflow-y-auto gap-0! items-stretch">
{found.map((bot) => (
<LinkBotRow
key={bot.id}
bot={bot}
linking={linking_id == bot.id}
disabled={Boolean(linking_id)}
onLink={() => link({ bot })}
/>
))}
{!bots || found.length ? null : (
<Span className="px-3 py-6 text-[13px] leading-[18px] text-ink-secondary">
{search ? `No bot matches "${query.trim()}".` : "Every bot is already here."}
</Span>
)}
</Stack>
<SettingsStatus status={error ? { error: true, msg: error } : undefined} />
<Button
title="Create a new bot for this workspace"
type="button"
variant="ghost"
color="gray"
size="small"
onClick={onNewBot}
className="self-start gap-1! mt-3"
>
<Plus size={14} aria-hidden />
New bot
</Button>
</AddModalShell>
);
}
@@ -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
<Divider className="shrink-0 mx-1 @rail:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
<WorkspaceRow workspace={{ ...workspace, unread: 0 }} selected={on_workspace_page} />
<RailBotsSection
action={<RailSectionAddButton title="Add a bot to this workspace" onClick={() => openForm({ kind: "bot" })} />}
action={<RailSectionAddButton title="Add bots to this workspace" onClick={openLinkBots} />}
title="Workspace bots"
workers={workers}
selected_id={on_workspace_page ? undefined : selected_id}
+14 -1
View File
@@ -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<APIResponseObject<WorkspaceBot>> = async ({ req, body }) => {
try {
@@ -36,6 +40,15 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<WorkspaceBot>> = a
await addBotToWorkspace({ user_id: user.id, workspace_id, bot_id });
const bot = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
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 }),
@@ -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<APIResponseObject<BotWithLastMessage>> = 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 }) };
}
};