Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 14 additions & 2 deletions app/src/features/account/SettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ import {
type LucideIcon,
MessageSquare,
Palette,
Plug,
SlidersHorizontal,
} from "lucide-react";
import { useId, useState } from "react";
import { useT } from "../../i18n";
import { Dialog } from "../../ui/Dialog";
import { ConnectedToolsSettings } from "../connections/ConnectedToolsSettings";
import { AboutSettings } from "./AboutSettings";
import { AlertsSettings } from "./AlertsSettings";
import { AppearanceSettings } from "./AppearanceSettings";
Expand All @@ -24,14 +26,23 @@ import { BackupSettings } from "./BackupSettings";
import { ChatSettings } from "./ChatSettings";
import { GeneralSettings } from "./GeneralSettings";

type Page = "general" | "chat" | "alerts" | "appearance" | "archived" | "backup" | "about";
type Page =
| "general"
| "chat"
| "alerts"
| "appearance"
| "archived"
| "tools"
| "backup"
| "about";

const PAGES: { id: Page; icon: LucideIcon }[] = [
{ id: "general", icon: SlidersHorizontal },
{ id: "chat", icon: MessageSquare },
{ id: "alerts", icon: Bell },
{ id: "appearance", icon: Palette },
{ id: "archived", icon: Archive },
{ id: "tools", icon: Plug },
{ id: "backup", icon: DatabaseBackup },
{ id: "about", icon: Info },
];
Expand Down Expand Up @@ -61,7 +72,7 @@ export function SettingsDialog({ onClose }: { onClose(): void }) {
aria-selected={selected}
aria-controls={`${id}-panel`}
onClick={() => setPage(each)}
className={`flex h-8 items-center gap-2 rounded-lg px-2.5 text-left font-medium text-sm transition-colors ${
className={`flex min-h-8 items-center gap-2 rounded-lg px-2.5 py-1 text-left font-medium text-sm transition-colors ${
selected ? "bg-sunken text-ink" : "text-ink-soft hover:bg-sunken hover:text-ink"
}`}
>
Expand All @@ -82,6 +93,7 @@ export function SettingsDialog({ onClose }: { onClose(): void }) {
{page === "alerts" && <AlertsSettings />}
{page === "appearance" && <AppearanceSettings />}
{page === "archived" && <ArchivedSettings />}
{page === "tools" && <ConnectedToolsSettings />}
{page === "backup" && <BackupSettings />}
{page === "about" && <AboutSettings />}
</div>
Expand Down
2 changes: 2 additions & 0 deletions app/src/features/bots/BotPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { Bot, Crew } from "../../lib/protocol.gen";
import { Button } from "../../ui/Button";
import { Callout } from "../../ui/Callout";
import { SidePanel } from "../../ui/SidePanel";
import { BotTools } from "../connections/BotTools";
import { DesktopGrants } from "../desktop/DesktopGrants";
import { SignInButton } from "../onboarding/SignIn";
import { AllowRules } from "./AllowRules";
Expand Down Expand Up @@ -80,6 +81,7 @@ export function Details({ bot, onClose }: { bot: Bot; onClose(): void }) {
</div>
<AllowRules bot={bot} />
<CrewAccessList bot={bot} />
<BotTools bot={bot} />
<DesktopGrants bot={bot} />
</dl>
</SidePanel>
Expand Down
7 changes: 5 additions & 2 deletions app/src/features/chat/toolNames.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@ describe("tool names", () => {
expect(toolTitle("mcp__botloft__browser_open", ptBR.tools)).toBe("Abrir uma página");
expect(toolTitle("Bash", ptBR.tools)).toBe("Rodar um comando");
expect(toolAction("Write", ptBR.tools)).toBe("escrever um arquivo");
// One with no name here keeps its own.
expect(toolTitle("mcp__other__frobnicate", en.tools)).toBe("frobnicate");
// A tool of the owner's own is named with the tool it belongs to.
expect(toolTitle("mcp__other__frobnicate", en.tools)).toBe("frobnicate (other)");
expect(toolTitle("mcp__linkedin__search_people", en.tools)).toBe("search people (linkedin)");
// One with no name here and no tool to belong to keeps its own.
expect(toolTitle("Frobnicate", en.tools)).toBe("Frobnicate");
expect(toolAction("mcp__other__frobnicate", ptBR.tools)).toBe("usar frobnicate");
expect(toolTitle("constructor", en.tools)).toBe("constructor");
});
Expand Down
11 changes: 10 additions & 1 deletion app/src/features/chat/toolNames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ import type { Messages } from "../../i18n/en";

type Words = Messages["tools"];

/** A tool of the owner's, `mcp__<slug>__<tool>`, as "tool name (slug)". */
function connected(name: string): string | null {
const [prefix, slug, ...tool] = name.split("__");
if (prefix !== "mcp" || !slug || slug === "botloft" || tool.length === 0) {
return null;
}
return `${tool.join("__").replaceAll("_", " ")} (${slug})`;
}

/** `mcp__botloft__send_message` is `send_message`; built-ins keep theirs. */
export function toolKey(name: string): string {
return name.split("__").at(-1) ?? name;
Expand All @@ -19,7 +28,7 @@ function known(key: string, t: Words): string | undefined {
/** The tool as a title: "Send a message"; an unknown one, by its name. */
export function toolTitle(name: string, t: Words): string {
const key = toolKey(name);
return known(key, t) ?? key;
return known(key, t) ?? connected(name) ?? key;
}

/** The tool inside a sentence: "wants to send a message". */
Expand Down
155 changes: 155 additions & 0 deletions app/src/features/connections/AddToolDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// "Connect a tool" (spec 25.2): the owner pastes its settings, sees what
// it is and what it can do on their computer, and says they trust it. A
// program (`stdio`) runs with the owner's powers, so it never saves without
// that "I understand".

import { useMemo, useState } from "react";
import { useT } from "../../i18n";
import { errorText } from "../../lib/api";
import type { McpSaveParams } from "../../lib/protocol.gen";
import { useApi } from "../../store/context";
import { Button } from "../../ui/Button";
import { Callout } from "../../ui/Callout";
import { Details } from "../../ui/Details";
import { Dialog } from "../../ui/Dialog";
import { TextArea, TextField } from "../../ui/Field";
import { type PasteProblem, parsePasted } from "./paste";

/** The command line of a program, or the address, as one line. */
function whatItRuns(tool: McpSaveParams): string {
return tool.kind === "stdio" ? [tool.command, ...tool.args].join(" ") : (tool.url ?? "");
}

export function AddToolDialog({ onClose }: { onClose(): void }) {
const all = useT();
const t = all.connections;
const api = useApi();
const [text, setText] = useState("");
const [name, setName] = useState("");
const [purpose, setPurpose] = useState("");
const [trusted, setTrusted] = useState(false);
const [busy, setBusy] = useState(false);
const [failed, setFailed] = useState<string | null>(null);
const pasted = useMemo(() => parsePasted(text, name), [text, name]);
const tools = pasted.ok ? pasted.tools : [];
const runsPrograms = tools.some((tool) => tool.kind === "stdio");

const problem = (found: PasteProblem): string => {
const words = t.problems;
switch (found.reason) {
case "unsupported":
return words.unsupported(found.server);
case "unknownField":
return words.unknownField(found.server, found.field);
case "badValue":
return words.badValue(found.server, found.field);
default:
return words[found.reason];
}
};
// Nothing is said about an empty box until something is in it.
const complaint = !pasted.ok && pasted.reason !== "empty" ? problem(pasted) : null;
const asksName = !pasted.ok && pasted.reason === "needsName";

const connect = async () => {
setBusy(true);
setFailed(null);
try {
for (const tool of tools) {
await api.call("mcp.save", {
...tool,
description: tools.length === 1 ? purpose.trim() : "",
});
}
onClose();
} catch (error) {
setFailed(errorText(error));
setBusy(false);
}
};

return (
<Dialog
title={t.adding.title}
onClose={onClose}
footer={
<>
<Button onClick={onClose}>{all.common.cancel}</Button>
<Button
variant="primary"
disabled={tools.length === 0 || (runsPrograms && !trusted) || busy}
onClick={connect}
>
{busy ? t.adding.connecting : t.adding.connect}
</Button>
</>
}
>
<div className="flex flex-col gap-3">
<TextArea
label={t.adding.paste}
hint={t.adding.pasteHint}
rows={6}
className="font-mono"
value={text}
onChange={(event) => {
setText(event.target.value);
setTrusted(false);
}}
/>
{(asksName || name) && (
<TextField
label={t.adding.name}
hint={t.adding.nameHint}
value={name}
onChange={(event) => setName(event.target.value)}
/>
)}
{complaint && (
<p role="alert" className="text-danger text-sm">
{complaint}
</p>
)}
{tools.length > 0 && (
<div className="flex flex-col gap-2">
<p className="text-ink-soft text-sm">{t.adding.found(tools.length)}</p>
{tools.map((tool) => (
<div key={tool.name} className="rounded-lg border border-line bg-sunken px-3 py-2">
<p className="font-medium text-sm">{tool.name}</p>
<p className="mt-0.5 text-ink-soft text-xs leading-relaxed">
{tool.kind === "stdio"
? t.adding.program(tool.name)
: t.adding.address(tool.name)}
</p>
<Details label={t.adding.command}>{whatItRuns(tool)}</Details>
</div>
))}
{tools.length === 1 && (
<TextField
label={t.adding.purpose}
hint={t.adding.purposeHint}
value={purpose}
onChange={(event) => setPurpose(event.target.value)}
/>
)}
{runsPrograms && (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={trusted}
onChange={(event) => setTrusted(event.target.checked)}
/>
{t.adding.understood}
</label>
)}
</div>
)}
{failed && (
<Callout tone="danger" title={t.adding.failed}>
{failed}
</Callout>
)}
</div>
</Dialog>
);
}
78 changes: 78 additions & 0 deletions app/src/features/connections/BotTools.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// What a bot can use of the owner's connected tools (spec 25.3, 25.5), in
// its details: a switch for each tool, and whether the tool came up when
// the bot started.

import { useT } from "../../i18n";
import type { Bot, McpServer, McpServerState } from "../../lib/protocol.gen";
import { useApi } from "../../store/context";
import { Badge } from "../../ui/Badge";
import { Details } from "../../ui/Details";
import { Switch } from "../../ui/Switch";
import { attempt } from "../../ui/toast";
import { useConnections } from "./useConnections";

const TONES = { connected: "ok", pending: "quiet", needs_auth: "warn", failed: "danger" } as const;

function State({ state }: { state: McpServerState | undefined }) {
const t = useT().connections;
if (!state) {
return null;
}
return (
<div className="shrink-0 text-right">
<Badge tone={TONES[state.state]}>{t.states[state.state] ?? state.state}</Badge>
</div>
);
}

export function BotTools({ bot }: { bot: Bot }) {
const t = useT().connections;
const api = useApi();
const overview = useConnections();
if (!overview) {
return null;
}
const mine = overview.bots.find((link) => link.botId === bot.id);
const using = mine?.serverIds ?? [];
const set = (server: McpServer, on: boolean) => {
const next = on ? [...using, server.id] : using.filter((id) => id !== server.id);
return attempt(t.bot.failed, () => api.call("bot.mcp.set", { botId: bot.id, serverIds: next }));
};

return (
<div>
<dt className="text-muted text-xs">{t.bot.title}</dt>
<dd className="mt-1">
{overview.servers.length === 0 ? (
<p className="text-ink-soft text-xs leading-relaxed">{t.bot.none}</p>
) : (
<ul className="flex flex-col gap-1.5">
{overview.servers.map((server) => {
const on = using.includes(server.id);
const state = on
? mine?.states.find((each) => each.serverId === server.id)
: undefined;
return (
<li
key={server.id}
className="rounded-lg border border-line bg-sunken px-2.5 py-1.5"
>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-sm">{server.name}</span>
<State state={state} />
<Switch
checked={on}
label={t.bot.toggle(server.name, bot.name)}
onChange={(next) => set(server, next)}
/>
</div>
{state?.error && <Details label={t.bot.reason}>{state.error}</Details>}
</li>
);
})}
</ul>
)}
</dd>
</div>
);
}
Loading
Loading