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 -1
View File
@@ -154,7 +154,7 @@ Envs are set in Settings, under Environment (super admin only), not in `.env`. T
- **Per-bot models:** in a workspace, its `workspace_harness_settings` row wins (`source: "workspace"`). Otherwise a worker bot inherits the defaults per harness unless it has a `bot_harness_settings` row (same fields as `harness_settings`, unique per bot and harness). `grabHarnessSetting({ harness, bot_id })` returns the bot's row (`source: "bot"`) or the default; the coordinator always gets the default. Every entry point takes `bot_id` and passes it on (text loop -> media reading/transcription), so a bot's run uses its own models throughout. Saved by `saveBotHarnessSetting` (`inherit` deletes the row); the page data is `grabBotHarnessOverview`. A bot's chat header has a ghost model icon (`(partials)/header-icon-button.tsx`; the text model and provider show under the bot's name) that opens a modal with every harness (`(partials)/bot-models-modal.tsx`); the modal and `/admin/bots/[bot_id]/models` both use `(partials)/bot-harness-form.tsx`. On the coordinator the modal edits the defaults themselves (`api/admin/harness/save-settings`, the same settings as Settings, under Models, without the providers): super admins only, the icon is disabled for everyone else. Every one of these wraps `settings/models/(partials)/harness-settings-form.tsx`: per harness a provider select first (`harness-provider-select.tsx`: any provider, one provider, or on a bot "Same as the default"), then the model select narrowed to it. Removing a provider (`deleteHarnessProvider`) puts default and bot settings that picked it or its models back on any provider (`save` prop; "Same as the default" shows when the overview item has `default`). - **Per-bot models:** in a workspace, its `workspace_harness_settings` row wins (`source: "workspace"`). Otherwise a worker bot inherits the defaults per harness unless it has a `bot_harness_settings` row (same fields as `harness_settings`, unique per bot and harness). `grabHarnessSetting({ harness, bot_id })` returns the bot's row (`source: "bot"`) or the default; the coordinator always gets the default. Every entry point takes `bot_id` and passes it on (text loop -> media reading/transcription), so a bot's run uses its own models throughout. Saved by `saveBotHarnessSetting` (`inherit` deletes the row); the page data is `grabBotHarnessOverview`. A bot's chat header has a ghost model icon (`(partials)/header-icon-button.tsx`; the text model and provider show under the bot's name) that opens a modal with every harness (`(partials)/bot-models-modal.tsx`); the modal and `/admin/bots/[bot_id]/models` both use `(partials)/bot-harness-form.tsx`. On the coordinator the modal edits the defaults themselves (`api/admin/harness/save-settings`, the same settings as Settings, under Models, without the providers): super admins only, the icon is disabled for everyone else. Every one of these wraps `settings/models/(partials)/harness-settings-form.tsx`: per harness a provider select first (`harness-provider-select.tsx`: any provider, one provider, or on a bot "Same as the default"), then the model select narrowed to it. Removing a provider (`deleteHarnessProvider`) puts default and bot settings that picked it or its models back on any provider (`save` prop; "Same as the default" shows when the overview item has `default`).
- **Tools:** `HarnessTool` (built per run with its bot bound): own tools validate with a zod `schema`; tools described elsewhere pass a JSON schema as `parameters` and validate themselves. `run-tool-call.ts` reports bad calls and failures back to the model as `Error: ...`, and caps every tool call at `AppData.HarnessToolTimeoutMs` (aborted, its result handed back as an error the model can work around). Bot tools in `src/harness/tools/bot/`: `update_memory`, `update_instructions` (`grabBotTools`, every run), `start_task` and, for the coordinator with workers, `delegate_task` (`grabBotChatTools`, chat replies only; the job's `requested_by_bot_id` is the coordinator, and `relayJobToRequester` posts the worker's result or failure in its conversation, pushed as `relay` by `pushBotNotice`), `ask_user_to_take_over` (see Take-over), plus for the coordinator `create_workspace` and `update_workspace` (a new workspace with its shared context, or changing one; both also in its tasks, so a task that gathers material can write it into the workspace), `create_bot` (add a worker with its name, role and instructions, and join it to a workspace straight away) and, in its own chat, `add_bot_to_workspace` (put an existing worker in one of the user's workspaces) and `remove_bot_from_workspace` (take it out, keeping its conversation there), `assign_computer` (grant or revoke a worker's direct access, `add/remove-bot-to/from-computer`; access through a workspace stays and the result says so), `update_bot` (change a worker's name, role or core instructions, `src/functions/backend/db/bots/update-bot.ts`), `find_bot` and `find_workspace` (look the crew, or the live workspaces, up by id, name, role/description or instructions/context), `delete_bot` (a worker with its conversations, tasks, schedules and workspace places; the coordinator can't go) and `delete_workspace` (its conversations, tasks, schedules and links; its bots and computers stay). `search_computers` (`src/harness/tools/computer/search-computers-tool.ts`, chat replies and tasks) runs one read-only command on all of a bot's computers at once over the sandbox REST API (`run-computer-command.ts`), each output under its `[computer:ID]`, secrets redacted, capped by `AppData.ComputerSearch*`. - **Tools:** `HarnessTool` (built per run with its bot bound): own tools validate with a zod `schema`; tools described elsewhere pass a JSON schema as `parameters` and validate themselves. `run-tool-call.ts` reports bad calls and failures back to the model as `Error: ...`, and caps every tool call at `AppData.HarnessToolTimeoutMs` (aborted, its result handed back as an error the model can work around). Bot tools in `src/harness/tools/bot/`: `update_memory`, `update_instructions` (`grabBotTools`, every run), `start_task` and, for the coordinator with workers, `delegate_task` (`grabBotChatTools`, chat replies only; the job's `requested_by_bot_id` is the coordinator, and `relayJobToRequester` posts the worker's result or failure in its conversation, pushed as `relay` by `pushBotNotice`), `ask_user_to_take_over` (see Take-over), plus for the coordinator `create_workspace` and `update_workspace` (a new workspace with its shared context, or changing one; both also in its tasks, so a task that gathers material can write it into the workspace), `create_bot` (add a worker with its name, role and instructions, and join it to a workspace straight away) and, in its own chat, `add_bot_to_workspace` (put an existing worker in one of the user's workspaces) and `remove_bot_from_workspace` (take it out, keeping its conversation there), `assign_computer` (grant or revoke a worker's direct access, `add/remove-bot-to/from-computer`; access through a workspace stays and the result says so), `update_bot` (change a worker's name, role or core instructions, `src/functions/backend/db/bots/update-bot.ts`), `find_bot` and `find_workspace` (look the crew, or the live workspaces, up by id, name, role/description or instructions/context), `delete_bot` (a worker with its conversations, tasks, schedules and workspace places; the coordinator can't go) and `delete_workspace` (its conversations, tasks, schedules and links; its bots and computers stay). `search_computers` (`src/harness/tools/computer/search-computers-tool.ts`, chat replies and tasks) runs one read-only command on all of a bot's computers at once over the sandbox REST API (`run-computer-command.ts`), each output under its `[computer:ID]`, secrets redacted, capped by `AppData.ComputerSearch*`.
- **MCP:** `src/harness/mcp/`: `connectMcpServer` (Streamable HTTP, SSE fallback), `grabMcpTools` (each server tool becomes a `HarnessTool` with its JSON schema), `mcpResultToText` (text as is, images read by the vision harness, `isError` thrown). Computers are reached this way: `openComputerTools` (`src/harness/tools/computer/`) connects to the computer's AIO sandbox at `grabComputerMcpUrl` (the `MAIN_COMPUTER_URL` app setting for the main computer, for a dev server outside docker, e.g. `http://localhost:8170`, else `http://<hostname>:<port>/mcp`, or its IP address when the hostname can't be reached), leaves out `ComputerMcpExcludedTools`, and sets the computer's envs (see Envs). - **MCP:** `src/harness/mcp/`: `connectMcpServer` (Streamable HTTP, SSE fallback), `grabMcpTools` (each server tool becomes a `HarnessTool` with its JSON schema), `mcpResultToText` (text as is, images read by the vision harness, `isError` thrown). Computers are reached this way: `openComputerTools` (`src/harness/tools/computer/`) connects to the computer's AIO sandbox at `grabComputerMcpUrl` (the `MAIN_COMPUTER_URL` app setting for the main computer, for a dev server outside docker, e.g. `http://localhost:8170`, else `http://<hostname>:<port>/mcp`, or its IP address when the hostname can't be reached), leaves out `ComputerMcpExcludedTools`, and sets the computer's envs (see Envs).
- **MCPs and custom tools (extensions):** installs add their own MCP servers (`mcp_servers`, Settings, under MCPs) and tools (`custom_tools`, Settings, under Tools). A bot gets the ones given to it (`bot_mcp_servers`, `bot_custom_tools`) and, in a workspace, that workspace's (`workspace_mcp_servers`, `workspace_custom_tools`); `botExtensionsSql` (`src/utils/bot-extensions-sql.ts`, tested) resolves a run's, `grabBotExtensions` reads them. `all_bots` ("every bot by default") only decides what bots are given: turning it on gives it to every bot (`attachToAllBots`, the coordinator included) and every new bot gets it (`attachDefaultExtensions`, from `createBot` and `ensureCoordinatorBot`); turning it off takes it from no one, and a bot that drops one keeps it dropped (`src/scripts/attach-every-bot-extensions.ts`, safe to rerun, moved installs from the old "reaches every bot" meaning). Super admins define them. Super admins and admins (the super admin alone for the coordinator, `assertBotExtensionManager`) give and take them per bot on the bot's settings page and under its chat's composer (`composer-tools.tsx`: chips for everything it can use there, a workspace's not removable, and an Add modal; before them `composer-computer-button.tsx` opens the bot's current computer in the viewer), and per workspace on the workspace's settings page; the settings pages share `settings/(partials)/extensions-group.tsx`, all through `useExtensions` (`src/hooks/`), `grabBotExtensionsOverview` / `grabWorkspaceExtensionsOverview` (`bot_extensions` / `workspace_extensions` page props, both on `grabExtensionsOverview`) and `api/admin/bots/` and `api/admin/workspaces/` `add-extension`, `remove-extension` (`setBotExtension`, `setWorkspaceExtension`); the MCPs and Tools pages' Access modal sets bots and workspaces in bulk (`setExtensionAccess`, an admin never touches the coordinator's or `all_bots`). Helpers in `src/functions/backend/db/extensions/`, API in `api/admin/mcps/` and `api/admin/tools/`, zod in `src/schemas/extension-schema.ts`. `openBotExtensions` (`src/harness/tools/extensions/`) turns them into harness tools for chat replies and tasks (built-ins win a name clash; `close()` in the runners' `finally`; `note` tells the bot about a server it can't use). Templates (`fillTemplate`, `src/utils/fill-template.ts`): `{{args.x}}` and `{{env.KEY}}`, envs from `grabRunEnvs` (workspace beats general, app settings never, no need to be set on computers); secret values are redacted from every result. - **MCPs and custom tools (extensions):** installs add their own MCP servers (`mcp_servers`, Settings, under MCPs) and tools (`custom_tools`, Settings, under Tools). A bot gets the ones given to it (`bot_mcp_servers`, `bot_custom_tools`) and, in a workspace, that workspace's (`workspace_mcp_servers`, `workspace_custom_tools`); `botExtensionsSql` (`src/utils/bot-extensions-sql.ts`, tested) resolves a run's, `grabBotExtensions` reads them. `all_bots` ("every bot by default") only decides what bots are given: turning it on gives it to every bot (`attachToAllBots`, the coordinator included) and every new bot gets it (`attachDefaultExtensions`, from `createBot` and `ensureCoordinatorBot`); turning it off takes it from no one, and a bot that drops one keeps it dropped (`src/scripts/attach-every-bot-extensions.ts`, safe to rerun, moved installs from the old "reaches every bot" meaning). Super admins define them. Super admins and admins (the super admin alone for the coordinator, `assertBotExtensionManager`) give and take them per bot on the bot's settings page and under its chat's composer (`composer-tools.tsx`: one "N tools" button (`composer-tools-modal.tsx`) counting everything it can use there, which opens a modal listing them (a workspace's not removable) with the add picker, so ten of them don't fill the composer; beside it `composer-computer-button.tsx` opens the bot's current computer in the viewer), and per workspace on the workspace's settings page; the settings pages share `settings/(partials)/extensions-group.tsx`, all through `useExtensions` (`src/hooks/`), `grabBotExtensionsOverview` / `grabWorkspaceExtensionsOverview` (`bot_extensions` / `workspace_extensions` page props, both on `grabExtensionsOverview`) and `api/admin/bots/` and `api/admin/workspaces/` `add-extension`, `remove-extension` (`setBotExtension`, `setWorkspaceExtension`); the MCPs and Tools pages' Access modal sets bots and workspaces in bulk (`setExtensionAccess`, an admin never touches the coordinator's or `all_bots`). Helpers in `src/functions/backend/db/extensions/`, API in `api/admin/mcps/` and `api/admin/tools/`, zod in `src/schemas/extension-schema.ts`. `openBotExtensions` (`src/harness/tools/extensions/`) turns them into harness tools for chat replies and tasks (built-ins win a name clash; `close()` in the runners' `finally`; `note` tells the bot about a server it can't use). Templates (`fillTemplate`, `src/utils/fill-template.ts`): `{{args.x}}` and `{{env.KEY}}`, envs from `grabRunEnvs` (workspace beats general, app settings never, no need to be set on computers); secret values are redacted from every result.
- **Custom tools** (`CustomToolKinds` in `src/dict/custom-tool-dict.ts`): a name (`checkCustomToolName`: snake_case, never a built-in's, `ReservedToolNames`), a description, a JSON-schema `parameters` and a `config` per kind, built by `buildCustomTool` (`src/harness/tools/custom/`): `http` (`runHttpTool`: the app fetches, arguments URL-encoded in the URL, no body template sends the arguments as JSON), `script` (`runScriptTool`: bash/python/javascript on the run's computer, the task's or in chat the bot's current one, over the sandbox REST API in a subshell, computer envs set, arguments as JSON in `COBBLE_ARGS` and on stdin; left out without a computer), `prompt` (`runPromptTool`: one `generateText` turn with the bot's models). Super admins try one from the page (`testCustomTool`, the main computer for scripts): a saved one from its row (Try), or the form as it is, saved or not (`draft`, nothing recorded), from the Test panel in its modal (`custom-tool-test-panel.tsx`). Every run (a bot's call or a try; not bad arguments or a cancel) is saved as the tool's `status`, `last_error` and `last_checked_at` (`recordCustomToolRun`); editing resets it to `untested`. - **Custom tools** (`CustomToolKinds` in `src/dict/custom-tool-dict.ts`): a name (`checkCustomToolName`: snake_case, never a built-in's, `ReservedToolNames`), a description, a JSON-schema `parameters` and a `config` per kind, built by `buildCustomTool` (`src/harness/tools/custom/`): `http` (`runHttpTool`: the app fetches, arguments URL-encoded in the URL, no body template sends the arguments as JSON), `script` (`runScriptTool`: bash/python/javascript on the run's computer, the task's or in chat the bot's current one, over the sandbox REST API in a subshell, computer envs set, arguments as JSON in `COBBLE_ARGS` and on stdin; left out without a computer), `prompt` (`runPromptTool`: one `generateText` turn with the bot's models). Super admins try one from the page (`testCustomTool`, the main computer for scripts): a saved one from its row (Try), or the form as it is, saved or not (`draft`, nothing recorded), from the Test panel in its modal (`custom-tool-test-panel.tsx`). Every run (a bot's call or a try; not bad arguments or a cancel) is saved as the tool's `status`, `last_error` and `last_checked_at` (`recordCustomToolRun`); editing resets it to `untested`.
- **MCP servers** (`McpTransports` in `src/dict/mcp-dict.ts`): `remote` (URL plus templated headers, `connectMcpServer`) or `stdio` (a command run with the host's shell, `hostShell`, in `DATA_DIR/mcp/<id>` (`grabMcpDir`), after its `setup_command`, which `runMcpSetup` reruns whenever it changes (marker file) or on Reinstall; its env is only its own `env` map plus the shell basics, `grabMcpStdioEnv`, never the app's). Their tools are listed into `tools_cache` (`syncMcpServer`: on save, Refresh, and hourly for remote ones by the `sync-mcp-servers` cron) and named `<slug>__<tool>` (`mcpToolName`); the modal's Test connection checks the form as it is, saved or not, outside the pool (`testMcpDraft`, `testMcpServer`, `api/admin/mcps/test-mcp`, a new stdio server's setup runs in `mcp/0`); a run that can't connect, or connects again after failing, updates its `status` too (`recordMcpServerConnection`); runs build tools from the cache (`mcpHarnessTool`) and connect on the first call (`openMcpServer`). stdio servers live in a per-process pool (`src/harness/mcp/mcp-stdio-pool.ts`), stopped after `AppData.McpStdioIdleMs` idle, on save or delete. - **MCP servers** (`McpTransports` in `src/dict/mcp-dict.ts`): `remote` (URL plus templated headers, `connectMcpServer`) or `stdio` (a command run with the host's shell, `hostShell`, in `DATA_DIR/mcp/<id>` (`grabMcpDir`), after its `setup_command`, which `runMcpSetup` reruns whenever it changes (marker file) or on Reinstall; its env is only its own `env` map plus the shell basics, `grabMcpStdioEnv`, never the app's). Their tools are listed into `tools_cache` (`syncMcpServer`: on save, Refresh, and hourly for remote ones by the `sync-mcp-servers` cron) and named `<slug>__<tool>` (`mcpToolName`); the modal's Test connection checks the form as it is, saved or not, outside the pool (`testMcpDraft`, `testMcpServer`, `api/admin/mcps/test-mcp`, a new stdio server's setup runs in `mcp/0`); a run that can't connect, or connects again after failing, updates its `status` too (`recordMcpServerConnection`); runs build tools from the cache (`mcpHarnessTool`) and connect on the first call (`openMcpServer`). stdio servers live in a per-process pool (`src/harness/mcp/mcp-stdio-pool.ts`), stopped after `AppData.McpStdioIdleMs` idle, on save or delete.
- **Check before and after acting:** every reply and task gets `ActionCheckNote` (`src/data/harness-prompts.ts`): look for an existing record before creating one (update it instead), and verify every action took effect before saying it's done. The tools back it: `update_memory` and `update_instructions` read the save back (`checkSavedText`, `grabBotSavedText`) and fail if it didn't take; `create_bot` refuses a name already on the crew, `create_schedule` a twin of one of the bot's schedules (`findDuplicateCronJob`: same place and same title, or same timing and brief), and `start_task`/`delegate_task` a task with the same title still open in that session (`grabOpenBotTask`); `create_workspace` already refuses a name in use. - **Check before and after acting:** every reply and task gets `ActionCheckNote` (`src/data/harness-prompts.ts`): look for an existing record before creating one (update it instead), and verify every action took effect before saying it's done. The tools back it: `update_memory` and `update_instructions` read the save back (`checkSavedText`, `grabBotSavedText`) and fail if it didn't take; `create_bot` refuses a name already on the crew, `create_schedule` a twin of one of the bot's schedules (`findDuplicateCronJob`: same place and same title, or same timing and brief), and `start_task`/`delegate_task` a task with the same title still open in that session (`grabOpenBotTask`); `create_workspace` already refuses a name in use.
@@ -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 useExtensions from "@/src/hooks/use-extensions";
import type { BotExtensionsPayload, BotWithLastMessage } from "@/src/types"; import type { BotExtensionsPayload, BotWithLastMessage } from "@/src/types";
import { BotComputersContext } from "../(data)/bot-computers-context"; import { BotComputersContext } from "../(data)/bot-computers-context";
import ComposerAddToolModal from "./composer-add-tool-modal";
import ComposerComputerButton from "./composer-computer-button"; import ComposerComputerButton from "./composer-computer-button";
import ComposerToolChip from "./composer-tool-chip"; import ComposerToolsModal from "./composer-tools-modal";
import { Dot } from "lucide-react"; import { Dot } from "lucide-react";
type Props = { type Props = {
@@ -16,10 +15,9 @@ type Props = {
}; };
/** /**
* The MCP servers and tools the bot can use here, under the composer. * Under the composer: one button counting the MCP servers and tools the
* Super admins and admins remove any it was given (even one every bot * bot can use here (they open in a modal, so ten of them don't fill the
* started with) and add more; a workspace's own stay. The computer the * composer), then the computer it's on, opening it in the viewer.
* bot is on sits before them, opening it in the viewer.
*/ */
export default function ComposerTools({ bot, workspace_id, initial }: Props) { export default function ComposerTools({ bot, workspace_id, initial }: Props) {
const extensions = useExtensions({ const extensions = useExtensions({
@@ -40,34 +38,14 @@ export default function ComposerTools({ bot, workspace_id, initial }: Props) {
return ( return (
<Row className="w-full max-w-container mx-auto gap-1.5! items-center px-3 md:px-6 pb-3 -mt-3"> <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 ? ( {has_tools ? (
<Span className="text-[12px] leading-4 text-ink-secondary"> <ComposerToolsModal
Tools bot_name={bot.name || "this bot"}
</Span> attached={extensions.attached}
) : 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"}
addable={extensions.addable} addable={extensions.addable}
loading={Boolean(extensions.busy)} can_manage={extensions.can_manage}
busy={extensions.busy}
onAdd={extensions.add} onAdd={extensions.add}
onRemove={extensions.remove}
/> />
) : null} ) : null}