Skip to content

Commit 580a52d

Browse files
committed
feat(app): the Bot agency, ready-made bots to add to a crew
A grid of the daemon's twelve roles with category filters and search, shown in a window opened by a button on the crew page and, for a crew with only its chief, on the page itself. Each card has Add to crew and Learn more; Learn more shows what the bot does, when to call it, who it pairs with and, under Details, what it is told. Adding uses catalog.add with the name and role in the owner's language and a second add gets 'Name 2'. Customize opens the new bot's details. Texts in en, pt-BR and es; a test checks every sheet in crates/botloftd/catalog has text in all three. Spec 26.5 updated to what was built.
1 parent ed7e112 commit 580a52d

24 files changed

Lines changed: 1166 additions & 23 deletions

‎app/src/App.test.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,9 @@ describe("app", () => {
104104
fireEvent.click(within(dialog).getByRole("button", { name: "Archive bot" }));
105105
expect(await screen.findByRole("heading", { level: 1, name: "Ops" })).toBeDefined();
106106
expect(within(sidebar()).queryByRole("button", { name: /Scout/ })).toBeNull();
107-
expect(fake.calls.at(-1)?.method).toBe("bots.archive");
107+
expect(fake.calls.filter((call) => call.method !== "catalog.list").at(-1)?.method).toBe(
108+
"bots.archive",
109+
);
108110
});
109111

110112
test("pausing a crew shows it everywhere", async () => {

‎app/src/features/bots/delete.test.tsx‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import { FakeBotloft } from "../../lib/fake";
44
import { RpcError } from "../../lib/rpc";
55
import { crewOpened, openBot, openTab, renderApp, sidebar } from "../../test/app";
66

7+
/** The last call that is not the Bot agency loading, which an empty crew page does. */
8+
const lastCall = (fake: FakeBotloft) =>
9+
fake.calls.filter((call) => call.method !== "catalog.list").at(-1);
10+
711
afterEach(cleanup);
812

913
/** A crew "Ops" with Scout and Writer, open on the crew's page. */
@@ -44,7 +48,7 @@ describe("deleting a bot", () => {
4448
// Back on the crew's page, without the bot.
4549
await waitFor(() => expect(inSidebar("Scout")).toBeNull());
4650
expect(screen.getByRole("heading", { level: 1, name: "Ops" })).toBeDefined();
47-
expect(fake.calls.at(-1)).toEqual({ method: "bots.delete", params: { botId: scout.id } });
51+
expect(lastCall(fake)).toEqual({ method: "bots.delete", params: { botId: scout.id } });
4852
expect(fake.bots.has(scout.id)).toBe(false);
4953
expect(inSidebar("Writer")).not.toBeNull();
5054
});
@@ -132,7 +136,7 @@ describe("deleting a crew", () => {
132136
await waitFor(() =>
133137
expect(within(sidebar()).queryByRole("button", { name: "Ops" })).toBeNull(),
134138
);
135-
expect(fake.calls.at(-1)).toEqual({ method: "crews.delete", params: { crewId: crew.id } });
139+
expect(lastCall(fake)).toEqual({ method: "crews.delete", params: { crewId: crew.id } });
136140
expect(fake.bots.has(scout.id)).toBe(false);
137141
expect(inSidebar("Scout")).toBeNull();
138142
expect(fake.crews.has(docs.id)).toBe(true);
@@ -183,7 +187,7 @@ describe("sending the folder to the Recycle Bin", () => {
183187

184188
fireEvent.click(within(dialog).getByRole("button", { name: "Delete bot" }));
185189
await waitFor(() => expect(inSidebar("Scout")).toBeNull());
186-
expect(fake.calls.at(-1)).toEqual({
190+
expect(lastCall(fake)).toEqual({
187191
method: "bots.delete",
188192
params: { botId: scout.id, recycleFolder: true },
189193
});
@@ -228,7 +232,7 @@ describe("sending the folder to the Recycle Bin", () => {
228232
expect(within(dialog).getByText("C:\\Users\\owner\\Botloft\\ops")).toBeDefined();
229233
fireEvent.click(within(dialog).getByRole("button", { name: "Delete crew" }));
230234
await waitFor(() => expect(fake.recycled).toEqual(["C:\\Users\\owner\\Botloft\\ops"]));
231-
expect(fake.calls.at(-1)?.params).toEqual({ crewId: ops.id, recycleFolder: true });
235+
expect(lastCall(fake)?.params).toEqual({ crewId: ops.id, recycleFolder: true });
232236

233237
fireEvent.click(within(sidebar()).getByRole("button", { name: "Site" }));
234238
await crewOpened("Site");
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { Check, Plus } from "lucide-react";
2+
import { useT } from "../../i18n";
3+
import type { Bot, BotTemplate } from "../../lib/protocol.gen";
4+
import { Button } from "../../ui/Button";
5+
import { BotAvatar } from "../bots/BotAvatar";
6+
import type { RoleText } from "./roleText";
7+
8+
/** What a role just did: "<name> joined the crew", and a way to change it. */
9+
export function Joined({ bot, onCustomize }: { bot: Bot; onCustomize(): void }) {
10+
const t = useT();
11+
return (
12+
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
13+
<span className="flex items-center gap-1 font-medium text-ok">
14+
<Check aria-hidden size={14} />
15+
{t.catalog.joined(bot.name)}
16+
</span>
17+
<button
18+
type="button"
19+
onClick={onCustomize}
20+
className="font-medium text-ink-soft underline-offset-2 hover:text-ink hover:underline"
21+
>
22+
{t.catalog.customize}
23+
</button>
24+
</p>
25+
);
26+
}
27+
28+
/** A role as a card: what it is, and the two things to do with it. */
29+
export function AgencyCard({
30+
template,
31+
text,
32+
color,
33+
added,
34+
busy,
35+
onAdd,
36+
onLearnMore,
37+
onCustomize,
38+
}: {
39+
template: BotTemplate;
40+
text: RoleText;
41+
color: string;
42+
added: Bot | undefined;
43+
busy: boolean;
44+
onAdd(): void;
45+
onLearnMore(): void;
46+
onCustomize(): void;
47+
}) {
48+
const t = useT();
49+
return (
50+
<li className="flex flex-col gap-2 rounded-xl border border-line bg-panel p-3.5">
51+
<div className="flex items-center gap-2.5">
52+
<BotAvatar color={color} size={32} />
53+
<div className="min-w-0 flex-1">
54+
<h3 className="truncate font-semibold">{text.name}</h3>
55+
<p className="truncate text-muted text-xs">{t.catalog.categories[template.category]}</p>
56+
</div>
57+
</div>
58+
<p className="line-clamp-2 min-h-10 text-ink-soft text-sm">{text.summary}</p>
59+
<div className="flex flex-wrap gap-2">
60+
<Button
61+
variant="primary"
62+
size="sm"
63+
icon={Plus}
64+
disabled={busy}
65+
aria-label={`${t.catalog.add}: ${text.name}`}
66+
onClick={onAdd}
67+
>
68+
{t.catalog.add}
69+
</Button>
70+
<Button size="sm" aria-label={`${t.catalog.learnMore}: ${text.name}`} onClick={onLearnMore}>
71+
{t.catalog.learnMore}
72+
</Button>
73+
</div>
74+
{added && <Joined bot={added} onCustomize={onCustomize} />}
75+
</li>
76+
);
77+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// The Bot agency's roles (spec 26.5): filters, search and the cards, or one
2+
// role in full after "Learn more". Used in the window the crew's button
3+
// opens and, for a crew with only its chief, in the page itself.
4+
5+
import { useState } from "react";
6+
import { useT } from "../../i18n";
7+
import { AVATAR_PALETTE, type BotTemplate, type Crew } from "../../lib/protocol.gen";
8+
import { useApp } from "../../store/context";
9+
import { AgencyCard } from "./AgencyCard";
10+
import { RoleDetail } from "./RoleDetail";
11+
import { roleText } from "./roleText";
12+
import { useAddRole } from "./useAddRole";
13+
import { useCatalog } from "./useCatalog";
14+
15+
type Category = BotTemplate["category"] | "all";
16+
17+
const CATEGORIES: Category[] = ["all", "code", "design", "content", "research", "business"];
18+
19+
/** A fixed color per role, so the mascots tell the cards apart. */
20+
const colorOf = (index: number) => AVATAR_PALETTE[index % AVATAR_PALETTE.length] ?? "#FF7A59";
21+
22+
export function AgencyGrid({ crew, onLeave }: { crew: Crew; onLeave?: () => void }) {
23+
const t = useT();
24+
const { roles, failed } = useCatalog();
25+
const { add, added, busy } = useAddRole(crew);
26+
const selectBot = useApp((state) => state.selectBot);
27+
const setPanel = useApp((state) => state.setPanel);
28+
const [category, setCategory] = useState<Category>("all");
29+
const [query, setQuery] = useState("");
30+
const [open, setOpen] = useState<string | null>(null);
31+
32+
if (failed) {
33+
return null;
34+
}
35+
if (roles === null) {
36+
return <p className="text-muted text-sm">{t.catalog.loading}</p>;
37+
}
38+
39+
const customize = (botId: string) => {
40+
selectBot(botId);
41+
setPanel(botId, "details");
42+
onLeave?.();
43+
};
44+
45+
const opened = roles.find((role) => role.id === open);
46+
if (opened) {
47+
const bot = added[opened.id];
48+
return (
49+
<RoleDetail
50+
template={opened}
51+
text={roleText(t, opened)}
52+
added={bot}
53+
busy={busy === opened.id}
54+
onAdd={() => add(opened)}
55+
onBack={() => setOpen(null)}
56+
onCustomize={() => bot && customize(bot.id)}
57+
/>
58+
);
59+
}
60+
61+
const needle = query.trim().toLowerCase();
62+
const shown = roles.filter((role) => {
63+
if (category !== "all" && role.category !== category) {
64+
return false;
65+
}
66+
const text = roleText(t, role);
67+
return !needle || `${text.name} ${text.summary} ${text.role}`.toLowerCase().includes(needle);
68+
});
69+
70+
return (
71+
<div className="flex flex-col gap-3">
72+
<div className="flex flex-wrap items-center gap-2">
73+
<input
74+
type="search"
75+
value={query}
76+
onChange={(event) => setQuery(event.target.value)}
77+
aria-label={t.catalog.search}
78+
placeholder={t.catalog.search}
79+
className="h-8 w-48 rounded-lg border border-line-strong bg-canvas px-2.5 text-ink text-sm outline-none placeholder:text-muted focus:border-accent"
80+
/>
81+
<fieldset
82+
aria-label={t.catalog.filter}
83+
className="m-0 flex min-w-0 flex-wrap gap-1.5 border-0 p-0"
84+
>
85+
{CATEGORIES.map((id) => (
86+
<button
87+
key={id}
88+
type="button"
89+
aria-pressed={category === id}
90+
onClick={() => setCategory(id)}
91+
className={`h-7 rounded-full border px-2.5 font-medium text-xs transition-colors ${
92+
category === id
93+
? "border-ink bg-ink text-canvas"
94+
: "border-line-strong text-ink-soft hover:bg-sunken hover:text-ink"
95+
}`}
96+
>
97+
{t.catalog.categories[id]}
98+
</button>
99+
))}
100+
</fieldset>
101+
</div>
102+
{shown.length === 0 ? (
103+
<p className="py-6 text-center text-muted text-sm">{t.catalog.none}</p>
104+
) : (
105+
<ul className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-3">
106+
{shown.map((role) => {
107+
const bot = added[role.id];
108+
return (
109+
<AgencyCard
110+
key={role.id}
111+
template={role}
112+
text={roleText(t, role)}
113+
color={colorOf(roles.indexOf(role))}
114+
added={bot}
115+
busy={busy === role.id}
116+
onAdd={() => add(role)}
117+
onLearnMore={() => setOpen(role.id)}
118+
onCustomize={() => bot && customize(bot.id)}
119+
/>
120+
);
121+
})}
122+
</ul>
123+
)}
124+
</div>
125+
);
126+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { useT } from "../../i18n";
2+
import type { Crew } from "../../lib/protocol.gen";
3+
import { AgencyGrid } from "./AgencyGrid";
4+
5+
/**
6+
* For a crew with only its chief, or nobody: the owner often does not know
7+
* what to create, so the Bot agency is on the page itself (spec 26.5).
8+
*/
9+
export function AgencyInvite({ crew }: { crew: Crew }) {
10+
const t = useT();
11+
return (
12+
<section aria-label={t.catalog.title} className="mt-6 flex flex-col gap-3">
13+
<div>
14+
<h2 className="font-semibold text-base tracking-tight">{t.catalog.invite.title}</h2>
15+
<p className="mt-0.5 text-muted text-sm">{t.catalog.invite.body}</p>
16+
</div>
17+
<AgencyGrid crew={crew} />
18+
</section>
19+
);
20+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { useT } from "../../i18n";
2+
import type { Crew } from "../../lib/protocol.gen";
3+
import { Dialog } from "../../ui/Dialog";
4+
import { AgencyGrid } from "./AgencyGrid";
5+
6+
/** The Bot agency, opened from a crew's page. */
7+
export function BotAgencyDialog({ crew, onClose }: { crew: Crew; onClose(): void }) {
8+
const t = useT();
9+
return (
10+
<Dialog title={t.catalog.title} width="lg" onClose={onClose}>
11+
<p className="mb-3 text-muted text-sm">{t.catalog.intro}</p>
12+
<AgencyGrid crew={crew} onLeave={onClose} />
13+
</Dialog>
14+
);
15+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { ArrowLeft, Plus } from "lucide-react";
2+
import { useEffect, useState } from "react";
3+
import { useT } from "../../i18n";
4+
import type { Bot, BotTemplate, BotTemplateFull } from "../../lib/protocol.gen";
5+
import { useApi } from "../../store/context";
6+
import { Button } from "../../ui/Button";
7+
import { Details } from "../../ui/Details";
8+
import { Joined } from "./AgencyCard";
9+
import type { RoleText } from "./roleText";
10+
11+
/** "Learn more" about a role: what it does, and what the bot is told. */
12+
export function RoleDetail({
13+
template,
14+
text,
15+
added,
16+
busy,
17+
onAdd,
18+
onBack,
19+
onCustomize,
20+
}: {
21+
template: BotTemplate;
22+
text: RoleText;
23+
added: Bot | undefined;
24+
busy: boolean;
25+
onAdd(): void;
26+
onBack(): void;
27+
onCustomize(): void;
28+
}) {
29+
const t = useT();
30+
const api = useApi();
31+
const words = t.catalog.detail;
32+
const [sheet, setSheet] = useState<BotTemplateFull | null>(null);
33+
34+
useEffect(() => {
35+
let live = true;
36+
// Only for "Details": a failure just leaves it out.
37+
api.call("catalog.get", { id: template.id }).then(
38+
(full) => live && setSheet(full),
39+
() => {},
40+
);
41+
return () => {
42+
live = false;
43+
};
44+
}, [api, template.id]);
45+
46+
const sections: [string, string | null][] = [
47+
[words.what, text.about ?? text.role],
48+
[words.when, text.when],
49+
[words.pairs, text.pairs],
50+
];
51+
52+
return (
53+
<section aria-label={text.name} className="flex flex-col gap-4">
54+
<div>
55+
<Button variant="ghost" size="sm" icon={ArrowLeft} onClick={onBack}>
56+
{t.catalog.back}
57+
</Button>
58+
</div>
59+
<header>
60+
<h3 className="font-semibold text-lg tracking-tight">{text.name}</h3>
61+
<p className="mt-0.5 text-muted text-sm">{text.summary}</p>
62+
</header>
63+
{sections.map(
64+
([title, body]) =>
65+
body && (
66+
<div key={title}>
67+
<h4 className="font-medium text-muted text-xs">{title}</h4>
68+
<p className="mt-0.5 text-sm">{body}</p>
69+
</div>
70+
),
71+
)}
72+
{sheet && (
73+
<Details label={words.technical}>
74+
{`${sheet.model} · ${sheet.effort}\n\n${sheet.instructions}`}
75+
</Details>
76+
)}
77+
<div className="flex flex-wrap items-center gap-3">
78+
<Button variant="primary" icon={Plus} disabled={busy} onClick={onAdd}>
79+
{t.catalog.add}
80+
</Button>
81+
{added && <Joined bot={added} onCustomize={onCustomize} />}
82+
</div>
83+
</section>
84+
);
85+
}

0 commit comments

Comments
 (0)