Open the composer's tools in a modal behind one counting button

The chips under the composer clogged it once a bot had a handful of MCP servers and tools. They are now one button reading "N tools" (with an alert icon when one is off or not working) that opens them in a modal, where any the bot was given can still be removed and more added.
This commit is contained in:
2026-10-21 16:49:56 +01:00
parent 66903f7a00
commit b0af4db1bf
6 changed files with 208 additions and 158 deletions
@@ -1,72 +0,0 @@
import { useState } from "react";
import Modal from "@moduletrace/twui/elements/Modal";
import Button from "@moduletrace/twui/layout/Button";
import H2 from "@moduletrace/twui/layout/H2";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { Plus } from "lucide-react";
import ExtensionAddSelect from "@/src/components/general/extension-add-select";
import type { BotExtensionsPayload } from "@/src/types";
type Props = {
bot_name: string;
addable: BotExtensionsPayload["addable"];
loading?: boolean;
onAdd: (params: { kind: "mcp" | "tool"; id: number }) => Promise<boolean>;
};
/**
* The "Add" chip under the composer: picks an MCP server or tool to give
* the bot, everywhere it works.
*/
export default function ComposerAddToolModal({
bot_name,
addable,
loading,
onAdd,
}: Props) {
const [open, setOpen] = useState(false);
if (!addable.length) return null;
return (
<Modal
target={
<Button
title={`Give ${bot_name} a tool or MCP server`}
type="button"
variant="ghost"
color="gray"
size="small"
beforeIcon={<Plus size={12} aria-hidden />}
className="gap-1! px-2! py-0.5! text-[12px] leading-4"
>
Add
</Button>
}
targetWrapperProps={{ className: "shrink-0" }}
open={open}
setOpen={setOpen}
className="max-w-lg bg-paper p-6"
>
<Stack className="w-full gap-4! items-stretch">
<Stack className="w-full gap-1! items-stretch">
<H2 className="text-lg! leading-6! tracking-normal! font-bold text-ink">
Add a tool
</H2>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{`${bot_name} can use it everywhere it works, for everyone it works for.`}
</Span>
</Stack>
<ExtensionAddSelect
id="composer-add-extension"
addable={addable}
loading={loading}
onAdd={async (picked) => {
if (await onAdd(picked)) setOpen(false);
}}
/>
</Stack>
</Modal>
);
}
@@ -1,53 +0,0 @@
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import { CircleAlert, X } from "lucide-react";
import extensionIcon from "@/src/components/general/extension-icon";
import type { BotExtension } from "@/src/types";
type Props = {
item: BotExtension;
busy?: boolean;
/**
* Takes it from the bot; not given for a workspace's own or when the
* user can't.
*/
onRemove?: () => void;
};
/**
* One MCP server or tool the bot can use here, under the composer. One
* that isn't working shows an alert icon, one that's off is struck out.
*/
export default function ComposerToolChip({ item, busy, onRemove }: Props) {
const Icon = item.state == "error" ? CircleAlert : extensionIcon({ kind: item.kind });
const notes = [
item.detail,
item.source == "workspace" ? "From this workspace" : "",
!item.enabled ? "Off for every bot" : item.state == "error" ? "Not working" : "",
].filter(Boolean);
return (
<Row
title={`${item.name}: ${notes.join(" · ")}${item.description ? `\n${item.description}` : ""}`}
className={`max-w-48 gap-1.5! flex-nowrap items-center rounded-default-lg border border-solid border-hairline bg-surface pl-2 py-0.5 text-[12px] leading-4 ${onRemove ? "pr-0.5" : "pr-2"} ${item.enabled ? "text-ink" : "text-ink-secondary line-through"}`}
>
<Icon size={12} aria-hidden className="shrink-0" />
<Span className="truncate">{item.name}</Span>
{onRemove ? (
<Button
title={`Remove ${item.name}`}
type="button"
variant="ghost"
color="gray"
size="small"
className="shrink-0 p-0.5!"
disabled={busy}
onClick={onRemove}
>
<X size={12} aria-hidden />
</Button>
) : null}
</Row>
);
}
@@ -0,0 +1,74 @@
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 { X } from "lucide-react";
import extensionIcon from "@/src/components/general/extension-icon";
import type { BotExtension } from "@/src/types";
type Props = {
item: BotExtension;
busy?: boolean;
/**
* Takes it from the bot; not given for a workspace's own or when the
* user can't.
*/
onRemove?: () => void;
};
/**
* One MCP server or tool the bot can use here, in the composer's tools
* modal. One that isn't working says so, one that's off is struck out.
*/
export default function ComposerToolRow({ item, busy, onRemove }: Props) {
const Icon = extensionIcon({ kind: item.kind });
const notes = [
item.detail,
item.source == "workspace" ? "From this workspace" : "",
!item.enabled
? "Off for every bot"
: item.state == "error"
? "Not working"
: item.state == "untested"
? "Not checked yet"
: "",
].filter(Boolean);
return (
<Row className="w-full gap-2.5! flex-nowrap items-center px-3 py-2 border-0 border-b border-solid border-hairline last:border-b-0">
<Icon
size={14}
aria-hidden
className="shrink-0 text-ink-secondary"
/>
<Stack className="grow min-w-0 gap-0!">
<Span
title={item.description}
className={`text-sm leading-5 truncate w-full ${item.enabled ? "text-ink" : "text-ink-secondary line-through"}`}
>
{item.name}
</Span>
{notes.length ? (
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">
{notes.join(" · ")}
</Span>
) : null}
</Stack>
{onRemove ? (
<Button
title={`Remove ${item.name}`}
aria-label={`Remove ${item.name}`}
type="button"
variant="ghost"
color="gray"
size="small"
disabled={busy}
onClick={onRemove}
className="shrink-0 p-1!"
>
<X size={14} aria-hidden />
</Button>
) : null}
</Row>
);
}
@@ -0,0 +1,123 @@
import { useState } from "react";
import Modal from "@moduletrace/twui/elements/Modal";
import Button from "@moduletrace/twui/layout/Button";
import H2 from "@moduletrace/twui/layout/H2";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { CircleAlert, Wrench } from "lucide-react";
import ExtensionAddSelect from "@/src/components/general/extension-add-select";
import type { BotExtension, BotExtensionsPayload } from "@/src/types";
import ComposerToolRow from "./composer-tool-row";
type Props = {
bot_name: string;
attached: BotExtension[];
addable: BotExtensionsPayload["addable"];
can_manage: boolean;
/**
* The extension being changed, as `kind:id`.
*/
busy?: string;
onAdd: (params: { kind: "mcp" | "tool"; id: number }) => Promise<boolean>;
onRemove: (params: {
kind: "mcp" | "tool";
id: number;
name: string;
}) => Promise<boolean>;
};
/**
* The composer's tools button: how many MCP servers and tools the bot can
* use here, opening them in a modal rather than filling the composer with
* chips. Super admins and admins remove any it was given (even one every
* bot started with) and add more; a workspace's own stay.
*/
export default function ComposerToolsModal({
bot_name,
attached,
addable,
can_manage,
busy,
onAdd,
onRemove,
}: Props) {
const [open, setOpen] = useState(false);
const count = attached.length;
const has_problem = attached.some(
(item) => !item.enabled || item.state == "error",
);
const TriggerIcon = has_problem ? CircleAlert : Wrench;
const label = count ? `${count} ${count == 1 ? "tool" : "tools"}` : "Add tools";
return (
<Modal
target={
<Button
title={`The tools ${bot_name} can use here`}
type="button"
variant="ghost"
color="gray"
size="small"
beforeIcon={<TriggerIcon size={12} aria-hidden />}
className="shrink-0 gap-1! px-2! py-0.5! text-[12px] leading-4 text-ink-secondary"
>
{label}
</Button>
}
targetWrapperProps={{ className: "shrink-0" }}
open={open}
setOpen={setOpen}
className="max-w-lg bg-paper p-6"
>
<Stack className="w-full gap-1! items-stretch">
<H2 className="text-lg! leading-6! tracking-normal! font-bold text-ink">
{`Tools ${bot_name} can use here`}
</H2>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{count
? "MCP servers and tools it was given, plus this workspace's. Remove one to take it away, or add another below."
: "It has none yet. Give it an MCP server or tool below, or make one under Settings."}
</Span>
</Stack>
{count ? (
<Stack
aria-label={`Tools ${bot_name} can use here`}
className="w-full mt-4 py-1 gap-0! items-stretch rounded-default border border-solid border-hairline bg-surface"
>
{attached.map((item) => (
<ComposerToolRow
key={`${item.kind}:${item.id}`}
item={item}
busy={busy == `${item.kind}:${item.id}`}
onRemove={
can_manage && item.source == "bot"
? () =>
onRemove({
kind: item.kind,
id: item.id,
name: item.name,
})
: undefined
}
/>
))}
</Stack>
) : null}
{can_manage && addable.length ? (
<Stack className="w-full mt-4 gap-2! items-stretch">
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{`Give ${bot_name} another one. It can use it everywhere it works, for everyone it works for.`}
</Span>
<ExtensionAddSelect
id="composer-add-extension"
addable={addable}
loading={Boolean(busy)}
onAdd={onAdd}
/>
</Stack>
) : null}
</Modal>
);
}
@@ -4,9 +4,8 @@ import Span from "@moduletrace/twui/layout/Span";
import useExtensions from "@/src/hooks/use-extensions";
import type { BotExtensionsPayload, BotWithLastMessage } from "@/src/types";
import { BotComputersContext } from "../(data)/bot-computers-context";
import ComposerAddToolModal from "./composer-add-tool-modal";
import ComposerComputerButton from "./composer-computer-button";
import ComposerToolChip from "./composer-tool-chip";
import ComposerToolsModal from "./composer-tools-modal";
import { Dot } from "lucide-react";
type Props = {
@@ -16,10 +15,9 @@ type Props = {
};
/**
* The MCP servers and tools the bot can use here, under the composer.
* Super admins and admins remove any it was given (even one every bot
* started with) and add more; a workspace's own stay. The computer the
* bot is on sits before them, opening it in the viewer.
* Under the composer: one button counting the MCP servers and tools the
* bot can use here (they open in a modal, so ten of them don't fill the
* composer), then the computer it's on, opening it in the viewer.
*/
export default function ComposerTools({ bot, workspace_id, initial }: Props) {
const extensions = useExtensions({
@@ -40,34 +38,14 @@ export default function ComposerTools({ bot, workspace_id, initial }: Props) {
return (
<Row className="w-full max-w-container mx-auto gap-1.5! items-center px-3 md:px-6 pb-3 -mt-3">
{has_tools ? (
<Span className="text-[12px] leading-4 text-ink-secondary">
Tools
</Span>
) : null}
{extensions.attached.map((item) => (
<ComposerToolChip
key={`${item.kind}:${item.id}`}
item={item}
busy={extensions.busy == `${item.kind}:${item.id}`}
onRemove={
extensions.can_manage && item.source == "bot"
? () =>
extensions.remove({
kind: item.kind,
id: item.id,
name: item.name,
})
: undefined
}
/>
))}
{extensions.can_manage ? (
<ComposerAddToolModal
bot_name={bot.name || "This bot"}
<ComposerToolsModal
bot_name={bot.name || "this bot"}
attached={extensions.attached}
addable={extensions.addable}
loading={Boolean(extensions.busy)}
can_manage={extensions.can_manage}
busy={extensions.busy}
onAdd={extensions.add}
onRemove={extensions.remove}
/>
) : null}