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:
@@ -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: () => {},
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }) };
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user