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
117 changes: 77 additions & 40 deletions app/src/features/catalog/AgencyGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
// The Bot agency's roles (spec 26.5): filters, search and the cards, or one
// role in full after "Learn more". Used in the window the crew's button
// opens and, for a crew with only its chief, in the page itself.
// opens and, for a crew with only its chief, in the page itself, where
// `featured` shows just a few and a button to see them all.

import { useState } from "react";
import { useT } from "../../i18n";
import { AVATAR_PALETTE, type BotTemplate, type Crew } from "../../lib/protocol.gen";
import { useApp } from "../../store/context";
import { Button } from "../../ui/Button";
import { AgencyCard } from "./AgencyCard";
import { RoleDetail } from "./RoleDetail";
import { roleText } from "./roleText";
Expand All @@ -14,12 +16,32 @@ import { useCatalog } from "./useCatalog";

type Category = BotTemplate["category"] | "all";

const CATEGORIES: Category[] = ["all", "code", "design", "content", "research", "business"];
const CATEGORIES: Category[] = [
"all",
"product",
"marketing",
"code",
"design",
"content",
"research",
"business",
];

/** A fixed color per role, so the mascots tell the cards apart. */
const colorOf = (index: number) => AVATAR_PALETTE[index % AVATAR_PALETTE.length] ?? "#FF7A59";

export function AgencyGrid({ crew, onLeave }: { crew: Crew; onLeave?: () => void }) {
export function AgencyGrid({
crew,
onLeave,
featured,
onSeeAll,
}: {
crew: Crew;
onLeave?: () => void;
/** Role ids to show alone, in this order, without filters or search. */
featured?: readonly string[];
onSeeAll?: () => void;
}) {
const t = useT();
const { roles, failed } = useCatalog();
const { add, added, busy } = useAddRole(crew);
Expand Down Expand Up @@ -59,46 +81,56 @@ export function AgencyGrid({ crew, onLeave }: { crew: Crew; onLeave?: () => void
}

const needle = query.trim().toLowerCase();
const shown = roles.filter((role) => {
if (category !== "all" && role.category !== category) {
return false;
}
const text = roleText(t, role);
return !needle || `${text.name} ${text.summary} ${text.role}`.toLowerCase().includes(needle);
});
const shown = featured
? featured.flatMap((id) => roles.filter((role) => role.id === id))
: roles.filter((role) => {
if (category !== "all" && role.category !== category) {
return false;
}
const text = roleText(t, role);
return (
!needle || `${text.name} ${text.summary} ${text.role}`.toLowerCase().includes(needle)
);
});
// Only the kinds that have a role to show.
const kinds = CATEGORIES.filter(
(id) => id === "all" || roles.some((role) => role.category === id),
);

return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
aria-label={t.catalog.search}
placeholder={t.catalog.search}
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"
/>
<fieldset
aria-label={t.catalog.filter}
className="m-0 flex min-w-0 flex-wrap gap-1.5 border-0 p-0"
>
{CATEGORIES.map((id) => (
<button
key={id}
type="button"
aria-pressed={category === id}
onClick={() => setCategory(id)}
className={`h-7 rounded-full border px-2.5 font-medium text-xs transition-colors ${
category === id
? "border-ink bg-ink text-canvas"
: "border-line-strong text-ink-soft hover:bg-sunken hover:text-ink"
}`}
>
{t.catalog.categories[id]}
</button>
))}
</fieldset>
</div>
{!featured && (
<div className="flex flex-wrap items-center gap-2">
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
aria-label={t.catalog.search}
placeholder={t.catalog.search}
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"
/>
<fieldset
aria-label={t.catalog.filter}
className="m-0 flex min-w-0 flex-wrap gap-1.5 border-0 p-0"
>
{kinds.map((id) => (
<button
key={id}
type="button"
aria-pressed={category === id}
onClick={() => setCategory(id)}
className={`h-7 rounded-full border px-2.5 font-medium text-xs transition-colors ${
category === id
? "border-ink bg-ink text-canvas"
: "border-line-strong text-ink-soft hover:bg-sunken hover:text-ink"
}`}
>
{t.catalog.categories[id]}
</button>
))}
</fieldset>
</div>
)}
{shown.length === 0 ? (
<p className="py-6 text-center text-muted text-sm">{t.catalog.none}</p>
) : (
Expand All @@ -121,6 +153,11 @@ export function AgencyGrid({ crew, onLeave }: { crew: Crew; onLeave?: () => void
})}
</ul>
)}
{featured && onSeeAll && (
<div>
<Button onClick={onSeeAll}>{t.catalog.seeAll}</Button>
</div>
)}
</div>
);
}
21 changes: 19 additions & 2 deletions app/src/features/catalog/AgencyInvite.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,37 @@
import { useState } from "react";
import { useT } from "../../i18n";
import type { Crew } from "../../lib/protocol.gen";
import { AgencyGrid } from "./AgencyGrid";
import { BotAgencyDialog } from "./BotAgencyDialog";

/** The few roles the page shows first; the window has them all. */
export const FEATURED = [
"developer",
"designer",
"writer",
"researcher",
"social-media",
"customer-support",
"personal-assistant",
"project-manager",
] as const;

/**
* For a crew with only its chief, or nobody: the owner often does not know
* what to create, so the Bot agency is on the page itself (spec 26.5).
* what to create, so a few bots of the Bot agency are on the page itself,
* with a button for the rest (spec 26.5).
*/
export function AgencyInvite({ crew }: { crew: Crew }) {
const t = useT();
const [all, setAll] = useState(false);
return (
<section aria-label={t.catalog.title} className="mt-6 flex flex-col gap-3">
<div>
<h2 className="font-semibold text-base tracking-tight">{t.catalog.invite.title}</h2>
<p className="mt-0.5 text-muted text-sm">{t.catalog.invite.body}</p>
</div>
<AgencyGrid crew={crew} />
<AgencyGrid crew={crew} featured={FEATURED} onSeeAll={() => setAll(true)} />
{all && <BotAgencyDialog crew={crew} onClose={() => setAll(false)} />}
</section>
);
}
20 changes: 18 additions & 2 deletions app/src/features/catalog/agency.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ const addCalls = (fake: FakeBotloft) =>
fake.calls.filter((call) => call.method === "catalog.add").map((call) => call.params);

describe("the Bot agency", () => {
test("the invitation shows a few bots, and a button for all of them", async () => {
await openCrewWithChief();
const invite = await screen.findByRole("region", { name: "Bot agency" });
expect(await within(invite).findAllByRole("listitem")).toHaveLength(8);
expect(within(invite).queryByRole("searchbox")).toBeNull();

fireEvent.click(within(invite).getByRole("button", { name: "See all bots" }));
const dialog = await screen.findByRole("dialog", { name: "Bot agency" });
expect(await within(dialog).findAllByRole("listitem")).toHaveLength(26);
});

test("a crew with only its chief invites the owner to pick bots, and a click adds one", async () => {
const fake = await openCrewWithChief();
const invite = await screen.findByRole("region", { name: "Bot agency" });
Expand Down Expand Up @@ -71,7 +82,12 @@ describe("the Bot agency", () => {
await openCrewWithChief();
fireEvent.click(screen.getByRole("button", { name: "Bot agency" }));
const dialog = await screen.findByRole("dialog", { name: "Bot agency" });
expect(await within(dialog).findAllByRole("listitem")).toHaveLength(12);
expect(await within(dialog).findAllByRole("listitem")).toHaveLength(26);

// Only the kinds that have a role are offered.
expect(within(dialog).queryByRole("button", { name: "Marketing & sales" })).toBeNull();
fireEvent.click(within(dialog).getByRole("button", { name: "Product & management" }));
expect(within(dialog).getAllByRole("listitem")).toHaveLength(14);

fireEvent.click(within(dialog).getByRole("button", { name: "Research" }));
expect(within(dialog).getAllByRole("listitem")).toHaveLength(2);
Expand Down Expand Up @@ -113,7 +129,7 @@ describe("the Bot agency", () => {
expect(addCalls(fake)[0]).toMatchObject({ templateId: "code-reviewer" });

fireEvent.click(within(dialog).getByRole("button", { name: "Back to all bots" }));
expect(within(dialog).getAllByRole("listitem")).toHaveLength(12);
expect(within(dialog).getAllByRole("listitem")).toHaveLength(26);
});

test("the same role twice gets another name", async () => {
Expand Down
2 changes: 1 addition & 1 deletion app/src/features/catalog/sheets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const ids = Object.keys(sheets).map((path) => path.replace(/^.*\/(.+)\.toml$/, "

describe("the roles the owner reads about", () => {
test("there is a sheet for each, and the fake daemon lists the same", () => {
expect(ids.length).toBe(12);
expect(ids.length).toBeGreaterThanOrEqual(26);
expect(FAKE_CATALOG.map((role) => role.id).sort()).toEqual([...ids].sort());
});

Expand Down
122 changes: 7 additions & 115 deletions app/src/i18n/en/catalog.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { base } from "./catalogBase";
import { product } from "./catalogProduct";

// The Bot agency (spec 26): ready-made bots the owner adds to a crew. The
// roles are the daemon's sheets, by id; this is what the owner reads about
// each. Plain words: "bot" and "crew", never "template" or "prompt".
Expand All @@ -21,12 +24,15 @@ export const catalog = {
content: "Content",
research: "Research",
business: "Business",
product: "Product & management",
marketing: "Marketing & sales",
},
none: "No bot matches that.",
loading: "Loading the bots…",
add: "Add to crew",
learnMore: "Learn more",
back: "Back to all bots",
seeAll: "See all bots",
joined: (name: string) => `${name} joined the crew`,
customize: "Customize",
failed: {
Expand All @@ -39,119 +45,5 @@ export const catalog = {
pairs: "Works well with",
technical: "What the bot is told when it starts",
},
roles: {
developer: {
name: "Developer",
role: "Writes and changes code, runs the tests and explains what changed",
summary: "Builds and fixes software, checks its own work and says what it did",
about:
"Writes new features, fixes bugs and tidies up code in your project. It runs the tests after each change and tells you in plain words what it did and what is still open.",
when: "When you have a website, an app or a script to build or fix.",
pairs:
"Code Reviewer and QA Tester, who check its work, and Designer, who gives it screens to build.",
},
"code-reviewer": {
name: "Code Reviewer",
role: "Reviews what other bots changed and points out bugs, risks and needless complexity",
summary: "Reads changes with a critical eye and says what is wrong or risky",
about:
"Reads the changes another bot made and lists what could break, what is risky and what is more complicated than it needs to be. It never edits the code itself, it only reports, so you decide. It thinks harder than most bots, so it uses a bit more of your plan.",
when: "After a Developer changes something that matters: a payment, a login, anything hard to undo.",
pairs: "Developer, whose work it reviews, and QA Tester.",
},
"qa-tester": {
name: "QA Tester",
role: "Tests what was built, tries to break it and reports how to repeat each problem",
summary: "Uses the work like a person would and writes down how to repeat each problem",
about:
"Tries what was built the way a real person would, then the way a careless one would, and reports each problem with the steps to repeat it. It does not fix things, it finds them.",
when: "Before you show or ship something, or when people say that it broke.",
pairs: "Developer, who fixes what it finds, and Code Reviewer.",
},
designer: {
name: "Designer",
role: "Designs screens and pages in HTML that you see live and can ask to change",
summary: "Designs pages and screens you can watch take shape, then refines them with you",
about:
"Designs pages and screens as HTML. You watch each one take shape in the design area while it works, and ask for changes the way you would with a person.",
when: "For a landing page, an app screen, a menu, a card or any visual idea you want to see before building it.",
pairs: "Developer, who can build what it designs, and Writer, for the words on the page.",
},
writer: {
name: "Writer",
role: "Writes articles, e-mails and pages in your tone of voice",
summary: "Writes clear texts that sound like you and fit who will read them",
about:
"Writes articles, e-mails, product texts and pages in your tone of voice. Give it a sample of your own writing and it matches it.",
when: "When the words need to be clear and sound like you.",
pairs: "Researcher, for facts, and Translator, for other languages.",
},
"social-media": {
name: "Social Media",
role: "Plans content and writes posts for each network, but never publishes on its own",
summary: "Comes up with ideas, a calendar and posts for you to approve",
about:
"Plans what to post and writes the posts for each network. It never publishes on its own: you approve every post.",
when: "When you want a steady presence without starting from a blank page each time.",
pairs: "Designer, for the images, and Writer, for longer texts.",
},
translator: {
name: "Translator",
role: "Translates and adapts texts between languages, keeping the tone and flagging what gets lost",
summary: "Translates so it reads naturally and tells you where the meaning shifts",
about:
"Translates texts and adapts them for the people who will read them, keeping the tone and telling you where meaning is lost.",
when: "For a website in another language, an e-mail to a client abroad or a document you cannot read.",
pairs: "Writer, to rework a text that needs more than a translation.",
},
researcher: {
name: "Researcher",
role: "Researches a question on the web, checks the sources and reports what it found and what it did not",
summary:
"Looks things up on the web, checks the sources and gives you a short answer with links",
about:
"Looks things up on the web with its own browser, which you can watch, compares sources and gives you a short answer with the links. It says what it could not find.",
when: "For prices, competitors, rules, how-tos or any question you would otherwise spend an hour searching.",
pairs: "Writer and Data Analyst, who use what it finds.",
},
"data-analyst": {
name: "Data Analyst",
role: "Reads spreadsheets and data, does the math, shows the work and explains what it means",
summary:
"Turns spreadsheets and numbers into answers, shows how it got there and draws charts",
about:
"Reads spreadsheets and data, does the math, shows how it got each result and explains what it means, with charts you can see.",
when: "When you have numbers (sales, expenses, results) and want answers, not tables.",
pairs: "Researcher, for facts from outside your data.",
},
"sales-prospector": {
name: "Sales Prospector",
role: "Finds and qualifies possible customers and drafts the messages, but sends nothing without you",
summary:
"Finds people and companies that fit, sorts them and drafts first messages for you to approve",
about:
"Finds people and companies that fit what you sell, explains why, and drafts the first message to each. It sends nothing without your approval. It works better with a connected tool for the network where you look for customers.",
when: "When you need a list of good leads and a start for each conversation.",
pairs: "Writer, to polish the messages, and Researcher, to dig into a company.",
},
"customer-support": {
name: "Customer Support",
role: "Answers customers' questions from the material you gave it and passes on what it cannot answer",
summary:
"Answers questions kindly using your own material and hands you what it cannot answer",
about:
"Answers customers' questions from the material you give it and hands you the cases it cannot solve. It drafts the replies and waits for your approval at first.",
when: "When the same questions keep coming and you want help answering them well.",
pairs: "Developer, for technical problems, and Writer, for wording.",
},
"personal-assistant": {
name: "Personal Assistant",
role: "Keeps your tasks in order, summarizes what you need to read and drafts e-mails",
summary: "Keeps your tasks organized, sums up what you need to read and drafts your e-mails",
about:
"Keeps your tasks in order, summarizes what you need to read, drafts e-mails and reminds you of what matters. Calendar and e-mail only work if you connect a tool for them.",
when: "When your day is full of small things that pile up.",
pairs: "Researcher and Writer, for bigger jobs.",
},
},
roles: { ...base, ...product },
};
Loading
Loading