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
2 changes: 2 additions & 0 deletions app/src/features/bots/BotPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { DesktopGrants } from "../desktop/DesktopGrants";
import { SignInButton } from "../onboarding/SignIn";
import { AllowRules } from "./AllowRules";
import type { stateView } from "./BotStateBadge";
import { CrewAccessList } from "./CrewAccessList";

export function Notices({
bot,
Expand Down Expand Up @@ -78,6 +79,7 @@ export function Details({ bot, onClose }: { bot: Bot; onClose(): void }) {
</dd>
</div>
<AllowRules bot={bot} />
<CrewAccessList bot={bot} />
<DesktopGrants bot={bot} />
</dl>
</SidePanel>
Expand Down
87 changes: 87 additions & 0 deletions app/src/features/bots/CrewAccessList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// The other crews a bot may reach for good (spec 10.4), in its details:
// each crew, or one bot of it, with a button that takes it back. Read when
// shown and again when a request to reach a crew is answered.

import { Network, X } from "lucide-react";
import { useEffect, useState } from "react";
import { useT } from "../../i18n";
import type { Bot, CrewAccess } from "../../lib/protocol.gen";
import { useApi } from "../../store/context";
import { Button } from "../../ui/Button";
import { attempt } from "../../ui/toast";
import { CREW_ACCESS_TOOL } from "../chat/CrewAccessCard";

function useCrewAccess(botId: Bot["id"]) {
const api = useApi();
const [list, setList] = useState<CrewAccess[] | null>(null);
useEffect(() => {
let alive = true;
const read = () =>
api.call("crewAccess.list", { botId }).then(
(listed) => alive && setList(listed),
// An older daemon has no access to other crews: the list stays empty.
() => alive && setList([]),
);
setList(null);
read();
const stop = api.subscribe((event) => {
if (event.name !== "chat.item" || event.params.item.botId !== botId) {
return;
}
const body = event.params.item.body;
if (body.kind === "approval" && body.toolName === CREW_ACCESS_TOOL) {
read();
}
});
return () => {
alive = false;
stop();
};
}, [api, botId]);
return [list, setList] as const;
}

export function CrewAccessList({ bot }: { bot: Bot }) {
const words = useT().bots.details;
const api = useApi();
const [list, setList] = useCrewAccess(bot.id);
return (
<div>
<dt className="text-muted text-xs">{words.crews}</dt>
<dd className="mt-1">
{list !== null && list.length === 0 && (
<p className="text-ink-soft text-xs leading-relaxed">{words.crewsNone(bot.name)}</p>
)}
{list !== null && list.length > 0 && (
<ul className="flex flex-col gap-1.5">
{list.map((access) => {
const what = access.targetName
? words.crewsBot(access.targetName, access.crewName)
: words.crewsWhole(access.crewName);
return (
<li
key={access.id}
className="flex items-center gap-2 rounded-lg border border-line bg-sunken py-1.5 pr-1 pl-2.5"
>
<Network aria-hidden size={14} className="shrink-0 text-muted" />
<span className="min-w-0 flex-1 text-xs">{what}</span>
<Button
variant="ghost"
size="sm"
icon={X}
label={words.crewsRemove(what)}
onClick={() =>
attempt(words.crewsRemoveFailed, async () => {
setList(await api.call("crewAccess.revoke", { accessId: access.id }));
})
}
/>
</li>
);
})}
</ul>
)}
</dd>
</div>
);
}
3 changes: 3 additions & 0 deletions app/src/features/chat/ApprovalCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from "../routines/RoutineChangeCard";
import { ROUTINE_TOOL, RoutineRequestCard } from "../routines/RoutineRequestCard";
import { BotChangeCard, CHANGE_BOT_TOOL } from "./BotChangeCard";
import { CREW_ACCESS_TOOL, CrewAccessCard } from "./CrewAccessCard";
import { commandOf, isCommand } from "./command";
import { PLAN_TOOL, PlanCard } from "./PlanCard";
import { SUGGEST_TOOL, SuggestionCard } from "./SuggestionCard";
Expand Down Expand Up @@ -74,6 +75,8 @@ export function ApprovalCard({ approval, bot }: { approval: ApprovalItem; bot: B
return <RoutineChangeCard approval={approval} bot={bot} />;
case DELETE_ROUTINE_TOOL:
return <RoutineDeleteCard approval={approval} bot={bot} />;
case CREW_ACCESS_TOOL:
return <CrewAccessCard approval={approval} bot={bot} />;
case CHANGE_BOT_TOOL:
return <BotChangeCard approval={approval} bot={bot} />;
case SITE_TOOL:
Expand Down
151 changes: 151 additions & 0 deletions app/src/features/chat/CrewAccessCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// A bot asking to reach another crew, or one bot of it (spec 10.4): what
// it wants there and why. The owner allows it only now, always for that
// bot, always for the whole crew, or says no with a note. Answered, it
// shrinks to one line that says for how long.

import { Ban, Check, Network, TimerOff } from "lucide-react";
import { useState } from "react";
import { useT } from "../../i18n";
import { errorText } from "../../lib/api";
import type { ApprovalItem, Bot } from "../../lib/protocol.gen";
import { useApi } from "../../store/context";
import { Button } from "../../ui/Button";
import { NoteArea, SectionCard, SettledLine } from "../../ui/ChatCard";

export const CREW_ACCESS_TOOL = "mcp__botloft__ask_crew_access";

type Scope = "once" | "bot" | "crew";

interface Asked {
crew: string;
bot: string | null;
access: string[];
why: string;
scope?: Scope;
}

function askedOf(input: string): Partial<Asked> {
try {
return JSON.parse(input) as Partial<Asked>;
} catch {
return {};
}
}

function Answered({ approval, bot }: { approval: ApprovalItem; bot: Bot }) {
const w = useT().chat.crewAccess;
const asked = askedOf(approval.input);
const where = asked.crew ?? "";
if (approval.status === "allowed") {
const scope = asked.scope ?? "once";
const text =
scope === "crew"
? w.doneCrew(bot.name, where)
: scope === "bot"
? w.doneBot(bot.name, asked.bot ?? "", where)
: w.doneOnce(bot.name, where);
return <SettledLine icon={Check} tone="ok" text={text} />;
}
if (approval.status === "denied") {
return (
<SettledLine
icon={Ban}
tone="danger"
text={w.declined(bot.name, where)}
note={approval.note}
/>
);
}
return <SettledLine icon={TimerOff} tone="quiet" text={w.expired(where)} />;
}

export function CrewAccessCard({ approval, bot }: { approval: ApprovalItem; bot: Bot }) {
const t = useT();
const w = t.chat.crewAccess;
const api = useApi();
const [note, setNote] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
if (approval.status !== "pending") {
return <Answered approval={approval} bot={bot} />;
}
const asked = askedOf(approval.input);
const crew = asked.crew ?? "";
const target = asked.bot ?? null;
const answer = async (allow: boolean, scope?: Scope) => {
setBusy(true);
setError(null);
const trimmed = note.trim();
try {
await api.call("approvals.answer", {
approvalId: approval.approvalId,
allow,
...(!allow && trimmed ? { note: trimmed } : {}),
...(allow && scope ? { input: JSON.stringify({ scope }) } : {}),
});
} catch (failure) {
setError(`${w.failed}: ${errorText(failure)}`);
}
setBusy(false);
};
return (
<SectionCard
label={target ? w.titleBot(bot.name, target, crew) : w.titleCrew(bot.name, crew)}
icon={Network}
tone="accent"
footer={
<>
<NoteArea
label={w.noteLabel(bot.name)}
value={note}
onChange={setNote}
placeholder={w.notePlaceholder(bot.name)}
/>
<div className="mt-2.5 flex flex-wrap gap-2">
<Button
variant="primary"
icon={Check}
disabled={busy}
onClick={() => answer(true, "once")}
>
{w.once}
</Button>
{target && (
<Button disabled={busy} onClick={() => answer(true, "bot")}>
{w.alwaysBot(target)}
</Button>
)}
<Button disabled={busy} onClick={() => answer(true, "crew")}>
{w.alwaysCrew(crew)}
</Button>
<Button icon={Ban} disabled={busy} onClick={() => answer(false)}>
{w.decline}
</Button>
</div>
</>
}
>
<div className="flex flex-col gap-2 px-4 py-3 text-sm">
<ul className="flex flex-col gap-1">
{(asked.access ?? []).map((kind) => (
<li key={kind} className="text-ink-soft">
{kind === "talk" ? w.talk(target ? target : crew) : kind}
</li>
))}
</ul>
{asked.why && (
<p>
<span className="text-muted">{w.why(bot.name)} </span>
<span data-selectable>{asked.why}</span>
</p>
)}
<p className="text-muted text-xs">{w.explain}</p>
{error && (
<p role="alert" className="text-danger text-sm">
{error}
</p>
)}
</div>
</SectionCard>
);
}
2 changes: 2 additions & 0 deletions app/src/features/chat/ToolLines.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
LoaderCircle,
type LucideIcon,
Monitor,
Network,
Send,
SquareTerminal,
UserPen,
Expand Down Expand Up @@ -58,6 +59,7 @@ const ICONS: Record<string, LucideIcon> = {
send_message: Send,
suggest_bot: UserPlus,
change_bot: UserPen,
ask_crew_access: Network,
schedule_routine: AlarmClock,
my_routines: AlarmClock,
change_routine: AlarmClock,
Expand Down
81 changes: 81 additions & 0 deletions app/src/features/chat/crewAccess.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { FakeBotloft } from "../../lib/fake";
import { crewOpened, openBot, renderApp } from "../../test/app";
import { CREW_ACCESS_TOOL } from "./CrewAccessCard";

afterEach(cleanup);

async function openScout() {
const fake = new FakeBotloft();
const ops = fake.addCrew("Ops");
const scout = fake.addBot(ops.id, "Scout", "Finds sources");
fake.setBotState(scout.id, "idle");
renderApp(fake);
await crewOpened("Ops");
openBot("Scout");
await screen.findByRole("list", { name: "Messages" });
return { fake, scout };
}

const answered = (fake: FakeBotloft) =>
fake.calls.find((call) => call.method === "approvals.answer")?.params as
| { allow: boolean; input?: string; note?: string }
| undefined;

const request = (bot: string | null) =>
JSON.stringify({
crew: "Blog",
bot,
handle: bot?.toLowerCase() ?? null,
access: ["talk"],
why: "To get this week's post.",
});

describe("a bot asking to reach another crew", () => {
test("for one bot: only now, always that bot, always the crew or deny", async () => {
const { fake, scout } = await openScout();
act(() => {
fake.chat.ask(scout.id, CREW_ACCESS_TOOL, "Blog", request("Writer"));
});
const card = screen.getByRole("region", {
name: "Scout wants to reach Writer, of the crew Blog",
});
expect(within(card).getByText(/^Talk with Writer/)).toBeDefined();
expect(within(card).getByText("To get this week's post.")).toBeDefined();
expect(within(card).getByRole("button", { name: "Only now" })).toBeDefined();
expect(within(card).getByRole("button", { name: "Always the crew Blog" })).toBeDefined();
fireEvent.click(within(card).getByRole("button", { name: "Always Writer" }));
await waitFor(() => expect(answered(fake)?.allow).toBe(true));
expect(JSON.parse(answered(fake)?.input as string)).toEqual({ scope: "bot" });
});

test("for the whole crew there is no bot to always allow", async () => {
const { fake, scout } = await openScout();
act(() => {
fake.chat.ask(scout.id, CREW_ACCESS_TOOL, "Blog", request(null));
});
const card = screen.getByRole("region", { name: "Scout wants to reach the crew Blog" });
expect(within(card).queryByRole("button", { name: /^Always Writer/ })).toBeNull();
fireEvent.change(within(card).getByRole("textbox"), { target: { value: "Not this week" } });
fireEvent.click(within(card).getByRole("button", { name: "Deny" }));
await waitFor(() =>
expect(answered(fake)).toMatchObject({ allow: false, note: "Not this week" }),
);
});

test("lasting access shows in the bot's details and can be taken back", async () => {
const { fake, scout } = await openScout();
fake.crewAccess.add(scout.id, "Blog");
fake.crewAccess.add(scout.id, "Shop", { id: "bot_clerk", name: "Clerk" });
fireEvent.click(screen.getByRole("button", { name: /^Show details/ }));
const details = screen.getByRole("complementary", { name: "About Scout" });
expect(await within(details).findByText("The whole crew Blog")).toBeDefined();
expect(within(details).getByText("Clerk, of the crew Shop")).toBeDefined();
fireEvent.click(
within(details).getByRole("button", { name: "Take back: The whole crew Blog" }),
);
await waitFor(() => expect(within(details).queryByText("The whole crew Blog")).toBeNull());
expect(within(details).getByText("Clerk, of the crew Shop")).toBeDefined();
});
});
7 changes: 7 additions & 0 deletions app/src/i18n/en/bots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,5 +130,12 @@ export const bots = {
`Nothing yet. When ${bot} asks for something, "Always allow" puts it here.`,
alwaysRemove: (what: string) => `Ask again: ${what}`,
alwaysRemoveFailed: "Could not remove it",
crews: "Other crews",
crewsNone: (bot: string) =>
`${bot} reaches only its own crew. When it asks for another one, "Always" puts it here.`,
crewsWhole: (crew: string) => `The whole crew ${crew}`,
crewsBot: (bot: string, crew: string) => `${bot}, of the crew ${crew}`,
crewsRemove: (what: string) => `Take back: ${what}`,
crewsRemoveFailed: "Could not take it back",
},
};
Loading
Loading