From 580a52dae139767809a84c15eee159b5df2e6ce8 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:05:38 -0300 Subject: [PATCH] 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. --- app/src/App.test.tsx | 4 +- app/src/features/bots/delete.test.tsx | 12 +- app/src/features/catalog/AgencyCard.tsx | 77 +++++++++ app/src/features/catalog/AgencyGrid.tsx | 126 +++++++++++++++ app/src/features/catalog/AgencyInvite.tsx | 20 +++ app/src/features/catalog/BotAgencyDialog.tsx | 15 ++ app/src/features/catalog/RoleDetail.tsx | 85 ++++++++++ app/src/features/catalog/agency.test.tsx | 151 ++++++++++++++++++ app/src/features/catalog/roleText.ts | 32 ++++ app/src/features/catalog/sheets.test.ts | 42 +++++ app/src/features/catalog/useAddRole.ts | 50 ++++++ app/src/features/catalog/useCatalog.ts | 32 ++++ app/src/features/crews/CrewBots.tsx | 43 +++-- app/src/features/crews/CrewView.tsx | 5 +- app/src/features/crews/botMenu.test.tsx | 5 +- app/src/features/crews/crewActions.tsx | 9 +- app/src/i18n/en/catalog.ts | 157 +++++++++++++++++++ app/src/i18n/en/index.ts | 2 + app/src/i18n/es/catalog.ts | 154 ++++++++++++++++++ app/src/i18n/es/index.ts | 2 + app/src/i18n/pt-BR/catalog.ts | 154 ++++++++++++++++++ app/src/i18n/pt-BR/index.ts | 2 + app/vite.config.ts | 2 + docs/spec.md | 8 +- 24 files changed, 1166 insertions(+), 23 deletions(-) create mode 100644 app/src/features/catalog/AgencyCard.tsx create mode 100644 app/src/features/catalog/AgencyGrid.tsx create mode 100644 app/src/features/catalog/AgencyInvite.tsx create mode 100644 app/src/features/catalog/BotAgencyDialog.tsx create mode 100644 app/src/features/catalog/RoleDetail.tsx create mode 100644 app/src/features/catalog/agency.test.tsx create mode 100644 app/src/features/catalog/roleText.ts create mode 100644 app/src/features/catalog/sheets.test.ts create mode 100644 app/src/features/catalog/useAddRole.ts create mode 100644 app/src/features/catalog/useCatalog.ts create mode 100644 app/src/i18n/en/catalog.ts create mode 100644 app/src/i18n/es/catalog.ts create mode 100644 app/src/i18n/pt-BR/catalog.ts diff --git a/app/src/App.test.tsx b/app/src/App.test.tsx index 09dbe885..75dc8966 100644 --- a/app/src/App.test.tsx +++ b/app/src/App.test.tsx @@ -104,7 +104,9 @@ describe("app", () => { fireEvent.click(within(dialog).getByRole("button", { name: "Archive bot" })); expect(await screen.findByRole("heading", { level: 1, name: "Ops" })).toBeDefined(); expect(within(sidebar()).queryByRole("button", { name: /Scout/ })).toBeNull(); - expect(fake.calls.at(-1)?.method).toBe("bots.archive"); + expect(fake.calls.filter((call) => call.method !== "catalog.list").at(-1)?.method).toBe( + "bots.archive", + ); }); test("pausing a crew shows it everywhere", async () => { diff --git a/app/src/features/bots/delete.test.tsx b/app/src/features/bots/delete.test.tsx index d0d309bd..8bf15e40 100644 --- a/app/src/features/bots/delete.test.tsx +++ b/app/src/features/bots/delete.test.tsx @@ -4,6 +4,10 @@ import { FakeBotloft } from "../../lib/fake"; import { RpcError } from "../../lib/rpc"; import { crewOpened, openBot, openTab, renderApp, sidebar } from "../../test/app"; +/** The last call that is not the Bot agency loading, which an empty crew page does. */ +const lastCall = (fake: FakeBotloft) => + fake.calls.filter((call) => call.method !== "catalog.list").at(-1); + afterEach(cleanup); /** A crew "Ops" with Scout and Writer, open on the crew's page. */ @@ -44,7 +48,7 @@ describe("deleting a bot", () => { // Back on the crew's page, without the bot. await waitFor(() => expect(inSidebar("Scout")).toBeNull()); expect(screen.getByRole("heading", { level: 1, name: "Ops" })).toBeDefined(); - expect(fake.calls.at(-1)).toEqual({ method: "bots.delete", params: { botId: scout.id } }); + expect(lastCall(fake)).toEqual({ method: "bots.delete", params: { botId: scout.id } }); expect(fake.bots.has(scout.id)).toBe(false); expect(inSidebar("Writer")).not.toBeNull(); }); @@ -132,7 +136,7 @@ describe("deleting a crew", () => { await waitFor(() => expect(within(sidebar()).queryByRole("button", { name: "Ops" })).toBeNull(), ); - expect(fake.calls.at(-1)).toEqual({ method: "crews.delete", params: { crewId: crew.id } }); + expect(lastCall(fake)).toEqual({ method: "crews.delete", params: { crewId: crew.id } }); expect(fake.bots.has(scout.id)).toBe(false); expect(inSidebar("Scout")).toBeNull(); expect(fake.crews.has(docs.id)).toBe(true); @@ -183,7 +187,7 @@ describe("sending the folder to the Recycle Bin", () => { fireEvent.click(within(dialog).getByRole("button", { name: "Delete bot" })); await waitFor(() => expect(inSidebar("Scout")).toBeNull()); - expect(fake.calls.at(-1)).toEqual({ + expect(lastCall(fake)).toEqual({ method: "bots.delete", params: { botId: scout.id, recycleFolder: true }, }); @@ -228,7 +232,7 @@ describe("sending the folder to the Recycle Bin", () => { expect(within(dialog).getByText("C:\\Users\\owner\\Botloft\\ops")).toBeDefined(); fireEvent.click(within(dialog).getByRole("button", { name: "Delete crew" })); await waitFor(() => expect(fake.recycled).toEqual(["C:\\Users\\owner\\Botloft\\ops"])); - expect(fake.calls.at(-1)?.params).toEqual({ crewId: ops.id, recycleFolder: true }); + expect(lastCall(fake)?.params).toEqual({ crewId: ops.id, recycleFolder: true }); fireEvent.click(within(sidebar()).getByRole("button", { name: "Site" })); await crewOpened("Site"); diff --git a/app/src/features/catalog/AgencyCard.tsx b/app/src/features/catalog/AgencyCard.tsx new file mode 100644 index 00000000..9cdc98e0 --- /dev/null +++ b/app/src/features/catalog/AgencyCard.tsx @@ -0,0 +1,77 @@ +import { Check, Plus } from "lucide-react"; +import { useT } from "../../i18n"; +import type { Bot, BotTemplate } from "../../lib/protocol.gen"; +import { Button } from "../../ui/Button"; +import { BotAvatar } from "../bots/BotAvatar"; +import type { RoleText } from "./roleText"; + +/** What a role just did: " joined the crew", and a way to change it. */ +export function Joined({ bot, onCustomize }: { bot: Bot; onCustomize(): void }) { + const t = useT(); + return ( +

+ + + {t.catalog.joined(bot.name)} + + +

+ ); +} + +/** A role as a card: what it is, and the two things to do with it. */ +export function AgencyCard({ + template, + text, + color, + added, + busy, + onAdd, + onLearnMore, + onCustomize, +}: { + template: BotTemplate; + text: RoleText; + color: string; + added: Bot | undefined; + busy: boolean; + onAdd(): void; + onLearnMore(): void; + onCustomize(): void; +}) { + const t = useT(); + return ( +
  • +
    + +
    +

    {text.name}

    +

    {t.catalog.categories[template.category]}

    +
    +
    +

    {text.summary}

    +
    + + +
    + {added && } +
  • + ); +} diff --git a/app/src/features/catalog/AgencyGrid.tsx b/app/src/features/catalog/AgencyGrid.tsx new file mode 100644 index 00000000..26fe3981 --- /dev/null +++ b/app/src/features/catalog/AgencyGrid.tsx @@ -0,0 +1,126 @@ +// 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. + +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 { AgencyCard } from "./AgencyCard"; +import { RoleDetail } from "./RoleDetail"; +import { roleText } from "./roleText"; +import { useAddRole } from "./useAddRole"; +import { useCatalog } from "./useCatalog"; + +type Category = BotTemplate["category"] | "all"; + +const CATEGORIES: Category[] = ["all", "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 }) { + const t = useT(); + const { roles, failed } = useCatalog(); + const { add, added, busy } = useAddRole(crew); + const selectBot = useApp((state) => state.selectBot); + const setPanel = useApp((state) => state.setPanel); + const [category, setCategory] = useState("all"); + const [query, setQuery] = useState(""); + const [open, setOpen] = useState(null); + + if (failed) { + return null; + } + if (roles === null) { + return

    {t.catalog.loading}

    ; + } + + const customize = (botId: string) => { + selectBot(botId); + setPanel(botId, "details"); + onLeave?.(); + }; + + const opened = roles.find((role) => role.id === open); + if (opened) { + const bot = added[opened.id]; + return ( + add(opened)} + onBack={() => setOpen(null)} + onCustomize={() => bot && customize(bot.id)} + /> + ); + } + + 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); + }); + + return ( +
    +
    + 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" + /> +
    + {CATEGORIES.map((id) => ( + + ))} +
    +
    + {shown.length === 0 ? ( +

    {t.catalog.none}

    + ) : ( +
      + {shown.map((role) => { + const bot = added[role.id]; + return ( + add(role)} + onLearnMore={() => setOpen(role.id)} + onCustomize={() => bot && customize(bot.id)} + /> + ); + })} +
    + )} +
    + ); +} diff --git a/app/src/features/catalog/AgencyInvite.tsx b/app/src/features/catalog/AgencyInvite.tsx new file mode 100644 index 00000000..f0328043 --- /dev/null +++ b/app/src/features/catalog/AgencyInvite.tsx @@ -0,0 +1,20 @@ +import { useT } from "../../i18n"; +import type { Crew } from "../../lib/protocol.gen"; +import { AgencyGrid } from "./AgencyGrid"; + +/** + * 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). + */ +export function AgencyInvite({ crew }: { crew: Crew }) { + const t = useT(); + return ( +
    +
    +

    {t.catalog.invite.title}

    +

    {t.catalog.invite.body}

    +
    + +
    + ); +} diff --git a/app/src/features/catalog/BotAgencyDialog.tsx b/app/src/features/catalog/BotAgencyDialog.tsx new file mode 100644 index 00000000..957a3256 --- /dev/null +++ b/app/src/features/catalog/BotAgencyDialog.tsx @@ -0,0 +1,15 @@ +import { useT } from "../../i18n"; +import type { Crew } from "../../lib/protocol.gen"; +import { Dialog } from "../../ui/Dialog"; +import { AgencyGrid } from "./AgencyGrid"; + +/** The Bot agency, opened from a crew's page. */ +export function BotAgencyDialog({ crew, onClose }: { crew: Crew; onClose(): void }) { + const t = useT(); + return ( + +

    {t.catalog.intro}

    + +
    + ); +} diff --git a/app/src/features/catalog/RoleDetail.tsx b/app/src/features/catalog/RoleDetail.tsx new file mode 100644 index 00000000..a4ac510e --- /dev/null +++ b/app/src/features/catalog/RoleDetail.tsx @@ -0,0 +1,85 @@ +import { ArrowLeft, Plus } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useT } from "../../i18n"; +import type { Bot, BotTemplate, BotTemplateFull } from "../../lib/protocol.gen"; +import { useApi } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { Details } from "../../ui/Details"; +import { Joined } from "./AgencyCard"; +import type { RoleText } from "./roleText"; + +/** "Learn more" about a role: what it does, and what the bot is told. */ +export function RoleDetail({ + template, + text, + added, + busy, + onAdd, + onBack, + onCustomize, +}: { + template: BotTemplate; + text: RoleText; + added: Bot | undefined; + busy: boolean; + onAdd(): void; + onBack(): void; + onCustomize(): void; +}) { + const t = useT(); + const api = useApi(); + const words = t.catalog.detail; + const [sheet, setSheet] = useState(null); + + useEffect(() => { + let live = true; + // Only for "Details": a failure just leaves it out. + api.call("catalog.get", { id: template.id }).then( + (full) => live && setSheet(full), + () => {}, + ); + return () => { + live = false; + }; + }, [api, template.id]); + + const sections: [string, string | null][] = [ + [words.what, text.about ?? text.role], + [words.when, text.when], + [words.pairs, text.pairs], + ]; + + return ( +
    +
    + +
    +
    +

    {text.name}

    +

    {text.summary}

    +
    + {sections.map( + ([title, body]) => + body && ( +
    +

    {title}

    +

    {body}

    +
    + ), + )} + {sheet && ( +
    + {`${sheet.model} · ${sheet.effort}\n\n${sheet.instructions}`} +
    + )} +
    + + {added && } +
    +
    + ); +} diff --git a/app/src/features/catalog/agency.test.tsx b/app/src/features/catalog/agency.test.tsx new file mode 100644 index 00000000..fcf1f28b --- /dev/null +++ b/app/src/features/catalog/agency.test.tsx @@ -0,0 +1,151 @@ +import { act, cleanup, fireEvent, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { setLocaleChoice } from "../../i18n"; +import { FakeBotloft } from "../../lib/fake"; +import { crewOpened, renderApp, sidebar } from "../../test/app"; + +afterEach(() => { + cleanup(); + setLocaleChoice("system"); +}); + +/** A crew "Site" with only its chief, open on its page. */ +async function openCrewWithChief() { + const fake = new FakeBotloft(); + await fake.call("crews.create", { + name: "Site", + lead: { name: "Chief", role: "Leads the crew", instructions: "Build the site" }, + }); + renderApp(fake); + await crewOpened("Site"); + return fake; +} + +const addCalls = (fake: FakeBotloft) => + fake.calls.filter((call) => call.method === "catalog.add").map((call) => call.params); + +describe("the Bot agency", () => { + 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" }); + expect(within(invite).getByText("Who do you want on your crew?")).toBeDefined(); + + fireEvent.click(within(invite).getByRole("button", { name: "Add to crew: Designer" })); + expect(await within(invite).findByText("Designer joined the crew")).toBeDefined(); + expect(addCalls(fake)).toEqual([ + { + crewId: expect.any(String), + templateId: "designer", + name: "Designer", + role: "Designs screens and pages in HTML that you see live and can ask to change", + }, + ]); + expect(within(sidebar()).getByRole("button", { name: /^Designer/ })).toBeDefined(); + }); + + test("the invitation stays while the page is open, and is not there for a crew that has a team", async () => { + const fake = await openCrewWithChief(); + const invite = await screen.findByRole("region", { name: "Bot agency" }); + fireEvent.click(within(invite).getByRole("button", { name: "Add to crew: Writer" })); + await within(invite).findByText("Writer joined the crew"); + fireEvent.click(within(invite).getByRole("button", { name: "Add to crew: Designer" })); + await within(invite).findByText("Designer joined the crew"); + + // Opening the crew again, with a team, shows no invitation. + const other = await fake.call("crews.create", { name: "Blog" }); + for (const name of ["Ana", "Bia"]) { + await fake.call("bots.create", { + crewId: other.id, + name, + role: "", + instructions: "", + }); + } + fireEvent.click(within(sidebar()).getAllByRole("button", { name: /^Blog/ })[0] as HTMLElement); + await crewOpened("Blog"); + await act(async () => {}); + expect(screen.queryByRole("region", { name: "Bot agency" })).toBeNull(); + }); + + test("the crew's button opens the whole agency, with filters and search", async () => { + 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); + + fireEvent.click(within(dialog).getByRole("button", { name: "Research" })); + expect(within(dialog).getAllByRole("listitem")).toHaveLength(2); + expect( + within(dialog).getByRole("button", { name: "Research" }).getAttribute("aria-pressed"), + ).toBe("true"); + + fireEvent.click(within(dialog).getByRole("button", { name: "All" })); + fireEvent.change(within(dialog).getByRole("searchbox", { name: "Search bots" }), { + target: { value: "spreadsheets" }, + }); + const found = within(dialog).getAllByRole("listitem"); + expect(found).toHaveLength(1); + expect(within(found[0] as HTMLElement).getByText("Data Analyst")).toBeDefined(); + + fireEvent.change(within(dialog).getByRole("searchbox", { name: "Search bots" }), { + target: { value: "zzz" }, + }); + expect(within(dialog).getByText("No bot matches that.")).toBeDefined(); + }); + + test("Learn more shows what the bot does and what it is told, and adds it from there", async () => { + const fake = await openCrewWithChief(); + fireEvent.click(screen.getByRole("button", { name: "Bot agency" })); + const dialog = await screen.findByRole("dialog", { name: "Bot agency" }); + fireEvent.click( + await within(dialog).findByRole("button", { name: "Learn more: Code Reviewer" }), + ); + + const detail = await within(dialog).findByRole("region", { name: "Code Reviewer" }); + expect(within(detail).getByText("When to call it")).toBeDefined(); + expect(within(detail).getByText("Works well with")).toBeDefined(); + const technical = await within(detail).findByText("What the bot is told when it starts"); + expect(technical.closest("details")?.textContent).toContain("### What you do"); + expect(technical.closest("details")?.textContent).toContain("high"); + + fireEvent.click(within(detail).getByRole("button", { name: "Add to crew" })); + expect(await within(detail).findByText("Code Reviewer joined the crew")).toBeDefined(); + 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); + }); + + test("the same role twice gets another name", async () => { + const fake = await openCrewWithChief(); + const invite = await screen.findByRole("region", { name: "Bot agency" }); + const add = within(invite).getByRole("button", { name: "Add to crew: Designer" }); + fireEvent.click(add); + await within(invite).findByText("Designer joined the crew"); + fireEvent.click(add); + await within(invite).findByText("Designer 2 joined the crew"); + expect(addCalls(fake).map((params) => (params as { name: string }).name)).toEqual([ + "Designer", + "Designer 2", + ]); + }); + + test("Customize opens the new bot", async () => { + await openCrewWithChief(); + const invite = await screen.findByRole("region", { name: "Bot agency" }); + fireEvent.click(within(invite).getByRole("button", { name: "Add to crew: Designer" })); + await within(invite).findByText("Designer joined the crew"); + fireEvent.click(within(invite).getByRole("button", { name: "Customize" })); + expect(await screen.findByRole("heading", { level: 1, name: "Designer" })).toBeDefined(); + }); + + test("the owner reads it in their language", async () => { + setLocaleChoice("pt-BR"); + const fake = await openCrewWithChief(); + const invite = await screen.findByRole("region", { name: "Agência de bots" }); + expect(within(invite).getByText("Quem você quer na sua equipe?")).toBeDefined(); + fireEvent.click(within(invite).getByRole("button", { name: "Adicionar na equipe: Redator" })); + expect(await within(invite).findByText("Redator entrou na equipe")).toBeDefined(); + expect(addCalls(fake)[0]).toMatchObject({ templateId: "writer", name: "Redator" }); + }); +}); diff --git a/app/src/features/catalog/roleText.ts b/app/src/features/catalog/roleText.ts new file mode 100644 index 00000000..5abe290d --- /dev/null +++ b/app/src/features/catalog/roleText.ts @@ -0,0 +1,32 @@ +// What the owner reads about a role (spec 26.5): the app's text in the +// owner's language, by the role's id. A role the app has no text for (a +// daemon newer than the app) shows the daemon's English. + +import type { Messages } from "../../i18n"; +import type { BotTemplate } from "../../lib/protocol.gen"; + +type Known = Messages["catalog"]["roles"]["developer"]; + +export interface RoleText { + name: string; + role: string; + summary: string; + /** `null` for a role the app has no text for. */ + about: string | null; + when: string | null; + pairs: string | null; +} + +export function roleText(t: Messages, template: BotTemplate): RoleText { + const known = (t.catalog.roles as Record)[template.id]; + return ( + known ?? { + name: template.name, + role: template.role, + summary: template.summary, + about: null, + when: null, + pairs: null, + } + ); +} diff --git a/app/src/features/catalog/sheets.test.ts b/app/src/features/catalog/sheets.test.ts new file mode 100644 index 00000000..d98a0613 --- /dev/null +++ b/app/src/features/catalog/sheets.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "vitest"; +import { en } from "../../i18n/en"; +import { es } from "../../i18n/es"; +import { ptBR } from "../../i18n/pt-BR"; +import { FAKE_CATALOG } from "../../lib/fakeCatalog"; + +// The daemon's sheets, one file each (spec 26.2). +const sheets = import.meta.glob("../../../../crates/botloftd/catalog/*.toml", { + query: "?raw", + import: "default", + eager: true, +}); + +const ids = Object.keys(sheets).map((path) => path.replace(/^.*\/(.+)\.toml$/, "$1")); + +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(FAKE_CATALOG.map((role) => role.id).sort()).toEqual([...ids].sort()); + }); + + test.each([ + ["English", en], + ["Portuguese", ptBR], + ["Spanish", es], + ])("every sheet has its text in %s", (_name, messages) => { + const roles = messages.catalog.roles as Record< + string, + { name: string; role: string; summary: string; about: string; when: string; pairs: string } + >; + expect(Object.keys(roles).sort()).toEqual([...ids].sort()); + for (const id of ids) { + const text = roles[id]; + for (const field of ["name", "role", "summary", "about", "when", "pairs"] as const) { + expect(text?.[field]?.trim(), `${id}.${field}`).toBeTruthy(); + } + // The daemon takes a role of one line, up to 200 characters. + expect(text?.role.length, `${id}.role`).toBeLessThanOrEqual(200); + expect(text?.role.includes("\n")).toBe(false); + } + }); +}); diff --git a/app/src/features/catalog/useAddRole.ts b/app/src/features/catalog/useAddRole.ts new file mode 100644 index 00000000..0b400949 --- /dev/null +++ b/app/src/features/catalog/useAddRole.ts @@ -0,0 +1,50 @@ +import { useState } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { useT } from "../../i18n"; +import type { Bot, BotTemplate, Crew } from "../../lib/protocol.gen"; +import { botsOf } from "../../store/app"; +import { useApi, useApp } from "../../store/context"; +import { attempt } from "../../ui/toast"; +import { roleText } from "./roleText"; + +/** `name`, or `name 2`, `name 3`... when the crew already has a bot called so. */ +export function freeName(name: string, bots: Bot[]): string { + const taken = new Set(bots.map((bot) => bot.name.trim().toLowerCase())); + let candidate = name; + for (let number = 2; taken.has(candidate.toLowerCase()); number += 1) { + candidate = `${name} ${number}`; + } + return candidate; +} + +/** + * Adds a role to `crew` as a bot (`catalog.add`), with the name and role in + * the owner's language. `added` is the last bot each role made while the + * screen was open, so its card can say so. + */ +export function useAddRole(crew: Crew) { + const t = useT(); + const api = useApi(); + const putBot = useApp((state) => state.putBot); + const bots = useApp(useShallow((state) => botsOf(state, crew.id))); + const [added, setAdded] = useState>({}); + const [busy, setBusy] = useState(null); + + const add = async (template: BotTemplate) => { + const text = roleText(t, template); + setBusy(template.id); + await attempt(t.catalog.failed.add, async () => { + const bot = await api.call("catalog.add", { + crewId: crew.id, + templateId: template.id, + name: freeName(text.name, bots), + role: text.role, + }); + putBot(bot); + setAdded((prev) => ({ ...prev, [template.id]: bot })); + }); + setBusy(null); + }; + + return { add, added, busy }; +} diff --git a/app/src/features/catalog/useCatalog.ts b/app/src/features/catalog/useCatalog.ts new file mode 100644 index 00000000..c24e0894 --- /dev/null +++ b/app/src/features/catalog/useCatalog.ts @@ -0,0 +1,32 @@ +import { useEffect, useState } from "react"; +import { useT } from "../../i18n"; +import type { BotTemplate } from "../../lib/protocol.gen"; +import { useApi } from "../../store/context"; +import { notifyError } from "../../ui/toast"; + +/** The roles of the Bot agency, read once when the screen opens. */ +export function useCatalog(): { roles: BotTemplate[] | null; failed: boolean } { + const t = useT(); + const api = useApi(); + const [roles, setRoles] = useState(null); + const [failed, setFailed] = useState(false); + const failure = t.catalog.failed.load; + + useEffect(() => { + let live = true; + api.call("catalog.list", {}).then( + (list) => live && setRoles(list), + (error) => { + if (live) { + setFailed(true); + notifyError(failure, error); + } + }, + ); + return () => { + live = false; + }; + }, [api, failure]); + + return { roles, failed }; +} diff --git a/app/src/features/crews/CrewBots.tsx b/app/src/features/crews/CrewBots.tsx index d209904d..f7219ac0 100644 --- a/app/src/features/crews/CrewBots.tsx +++ b/app/src/features/crews/CrewBots.tsx @@ -1,4 +1,5 @@ import { Plus } from "lucide-react"; +import { useState } from "react"; import { useT } from "../../i18n"; import type { Bot, Crew } from "../../lib/protocol.gen"; import { useApp } from "../../store/context"; @@ -6,22 +7,42 @@ import { Button } from "../../ui/Button"; import { BotStateBadge } from "../bots/BotStateBadge"; import { ChiefBadge, isChief } from "../bots/ChiefBadge"; import { ListAvatar } from "../bots/ListAvatar"; +import { AgencyInvite } from "../catalog/AgencyInvite"; /** The crew's bots as cards; a card opens the bot. */ export function CrewBots({ crew, bots, onNewBot }: { crew: Crew; bots: Bot[]; onNewBot(): void }) { const t = useT(); - const selectBot = useApp((state) => state.selectBot); - if (bots.length === 0) { - return ( -
    -

    {t.crews.bots.empty(crew.name)}

    -

    {t.crews.bots.emptyHint}

    - -
    - ); + // The Bot agency is on the page for a crew with only its chief, or nobody + // (spec 26.5). Decided when the crew opens and kept while it is open, so + // the owner can add several bots and see each one join. + const wanted = bots.length <= 1 && crew.archivedAt === null; + const [invited, setInvited] = useState({ crew: crew.id, show: wanted }); + let invite = invited.show; + if (invited.crew !== crew.id) { + invite = wanted; + setInvited({ crew: crew.id, show: wanted }); } + return ( + <> + {bots.length === 0 ? ( +
    +

    {t.crews.bots.empty(crew.name)}

    +

    {t.crews.bots.emptyHint}

    + +
    + ) : ( + + )} + {invite && } + + ); +} + +function BotCards({ crew, bots }: { crew: Crew; bots: Bot[] }) { + const t = useT(); + const selectBot = useApp((state) => state.selectBot); return (
      {bots.map((bot) => ( diff --git a/app/src/features/crews/CrewView.tsx b/app/src/features/crews/CrewView.tsx index 9a0c5b5b..2e0ae989 100644 --- a/app/src/features/crews/CrewView.tsx +++ b/app/src/features/crews/CrewView.tsx @@ -1,4 +1,4 @@ -import { Ellipsis, Folder, Plus } from "lucide-react"; +import { Ellipsis, Folder, Plus, Sparkles } from "lucide-react"; import { useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { useT } from "../../i18n"; @@ -56,6 +56,9 @@ ${words.openFolderHint}`}

      + diff --git a/app/src/features/crews/botMenu.test.tsx b/app/src/features/crews/botMenu.test.tsx index a1d939e2..3512abdb 100644 --- a/app/src/features/crews/botMenu.test.tsx +++ b/app/src/features/crews/botMenu.test.tsx @@ -76,7 +76,10 @@ describe("a bot's right-click menu", () => { await waitFor(() => expect(within(sidebar()).queryByRole("button", { name: /Scout/ })).toBeNull(), ); - expect(fake.calls.at(-1)).toEqual({ method: "bots.archive", params: { botId: scout.id } }); + expect(fake.calls.filter((call) => call.method !== "catalog.list").at(-1)).toEqual({ + method: "bots.archive", + params: { botId: scout.id }, + }); // Writer stays open through it all. expect(screen.getByRole("heading", { level: 1, name: "Writer" })).toBeDefined(); expect(fake.bots.get(writer.id)?.archivedAt).toBeNull(); diff --git a/app/src/features/crews/crewActions.tsx b/app/src/features/crews/crewActions.tsx index 9a64c764..8896ce90 100644 --- a/app/src/features/crews/crewActions.tsx +++ b/app/src/features/crews/crewActions.tsx @@ -7,6 +7,7 @@ import { Pencil, Play, Plus, + Sparkles, Trash2, } from "lucide-react"; import { type ReactNode, useState } from "react"; @@ -19,14 +20,17 @@ import { Confirm } from "../../ui/Confirm"; import type { MenuItem } from "../../ui/Menu"; import { attempt } from "../../ui/toast"; import { BotDialog } from "../bots/BotDialog"; +import { BotAgencyDialog } from "../catalog/BotAgencyDialog"; import { CrewDialog } from "./CrewDialog"; import { DeleteCrew } from "./DeleteCrew"; -type Open = "bot" | "rename" | "archive" | "delete" | { move: string } | null; +type Open = "bot" | "agency" | "rename" | "archive" | "delete" | { move: string } | null; export interface CrewActions { /** A new bot in the crew. */ newBot: MenuItem; + /** The Bot agency: ready-made bots to add to the crew (spec 26.5). */ + agency: MenuItem; /** Pause or resume, as the crew is now. */ pause: MenuItem & { icon: LucideIcon }; /** Opens the work folder in the system's file manager. */ @@ -68,6 +72,8 @@ export function useCrewActions(crew: Crew): CrewActions { let dialogs: ReactNode | null = null; if (open === "bot") { dialogs = ; + } else if (open === "agency") { + dialogs = ; } else if (open === "rename") { dialogs = ; } else if (open === "archive") { @@ -107,6 +113,7 @@ export function useCrewActions(crew: Crew): CrewActions { return { newBot: { label: t.crews.newBot, icon: Plus, onSelect: () => setOpen("bot") }, + agency: { label: t.catalog.open, icon: Sparkles, onSelect: () => setOpen("agency") }, openFolder, pause: crew.paused ? { label: words.resume, icon: Play, onSelect: () => setPaused(false) } diff --git a/app/src/i18n/en/catalog.ts b/app/src/i18n/en/catalog.ts new file mode 100644 index 00000000..12bccca4 --- /dev/null +++ b/app/src/i18n/en/catalog.ts @@ -0,0 +1,157 @@ +// 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". + +export const catalog = { + title: "Bot agency", + open: "Bot agency", + openHint: "Ready-made bots you can add to this crew", + intro: + "Pick a bot for the job. It joins this crew ready to work, and you can change anything about it afterwards.", + invite: { + title: "Who do you want on your crew?", + body: "Not sure what to create? Pick a bot for the job. You can change anything about it afterwards.", + }, + search: "Search bots", + filter: "Kinds of bots", + categories: { + all: "All", + code: "Code", + design: "Design", + content: "Content", + research: "Research", + business: "Business", + }, + none: "No bot matches that.", + loading: "Loading the bots…", + add: "Add to crew", + learnMore: "Learn more", + back: "Back to all bots", + joined: (name: string) => `${name} joined the crew`, + customize: "Customize", + failed: { + load: "Could not load the bots", + add: "Could not add the bot", + }, + detail: { + what: "What it does", + when: "When to call it", + 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.", + }, + }, +}; diff --git a/app/src/i18n/en/index.ts b/app/src/i18n/en/index.ts index c12ff239..fc900f23 100644 --- a/app/src/i18n/en/index.ts +++ b/app/src/i18n/en/index.ts @@ -4,6 +4,7 @@ import { account } from "./account"; import { alerts } from "./alerts"; import { bots } from "./bots"; import { browser } from "./browser"; +import { catalog } from "./catalog"; import { chat } from "./chat"; import { common } from "./common"; import { connections } from "./connections"; @@ -31,6 +32,7 @@ export const en = { alerts, bots, browser, + catalog, chat, connections, crews, diff --git a/app/src/i18n/es/catalog.ts b/app/src/i18n/es/catalog.ts new file mode 100644 index 00000000..74ca56a3 --- /dev/null +++ b/app/src/i18n/es/catalog.ts @@ -0,0 +1,154 @@ +import type { Messages } from "../en"; + +export const catalog: Messages["catalog"] = { + title: "Agencia de bots", + open: "Agencia de bots", + openHint: "Bots listos que puedes añadir a este equipo", + intro: + "Elige un bot para el trabajo. Entra en este equipo listo para trabajar, y después puedes cambiar lo que quieras de él.", + invite: { + title: "¿A quién quieres en tu equipo?", + body: "¿No sabes qué crear? Elige un bot para el trabajo. Después puedes cambiar lo que quieras de él.", + }, + search: "Buscar bots", + filter: "Tipos de bot", + categories: { + all: "Todos", + code: "Código", + design: "Diseño", + content: "Contenido", + research: "Investigación", + business: "Negocios", + }, + none: "Ningún bot coincide con eso.", + loading: "Cargando los bots…", + add: "Añadir al equipo", + learnMore: "Saber más", + back: "Volver a todos los bots", + joined: (name) => `${name} entró en el equipo`, + customize: "Personalizar", + failed: { + load: "No se pudieron cargar los bots", + add: "No se pudo añadir el bot", + }, + detail: { + what: "Qué hace", + when: "Cuándo llamarlo", + pairs: "Combina bien con", + technical: "Lo que recibe el bot cuando empieza", + }, + roles: { + developer: { + name: "Desarrollador", + role: "Escribe y cambia código, ejecuta las pruebas y explica qué cambió", + summary: "Crea y arregla software, revisa su propio trabajo y cuenta qué hizo", + about: + "Escribe funciones nuevas, corrige errores y ordena el código de tu proyecto. Ejecuta las pruebas después de cada cambio y te cuenta, con palabras sencillas, qué hizo y qué falta.", + when: "Cuando tienes un sitio web, una app o un script que crear o arreglar.", + pairs: + "Revisor de código y Tester, que revisan su trabajo, y Diseñador, que le pasa las pantallas para construir.", + }, + "code-reviewer": { + name: "Revisor de código", + role: "Revisa lo que cambiaron otros bots y señala errores, riesgos y complicaciones innecesarias", + summary: "Lee los cambios con ojo crítico y dice qué está mal o es arriesgado", + about: + "Lee los cambios que hizo otro bot y enumera lo que puede romperse, lo que es arriesgado y lo que está más complicado de lo necesario. Nunca edita el código, solo informa, y la decisión es tuya. Piensa más a fondo que la mayoría de los bots, así que gasta un poco más de tu plan.", + when: "Después de que un Desarrollador cambie algo importante: un pago, un acceso, cualquier cosa difícil de deshacer.", + pairs: "Desarrollador, cuyo trabajo revisa, y Tester.", + }, + "qa-tester": { + name: "Tester", + role: "Prueba lo que se hizo, intenta romperlo e informa cómo repetir cada problema", + summary: "Usa el trabajo como lo haría una persona y anota cómo repetir cada problema", + about: + "Prueba lo que se construyó como lo haría una persona de verdad, y luego como lo haría alguien descuidado, e informa de cada problema con los pasos para repetirlo. No lo arregla, lo encuentra.", + when: "Antes de mostrar o publicar algo, o cuando la gente dice que se rompió.", + pairs: "Desarrollador, que arregla lo que encuentra, y Revisor de código.", + }, + designer: { + name: "Diseñador", + role: "Diseña pantallas y páginas en HTML que ves en vivo y puedes pedir que cambie", + summary: "Diseña páginas y pantallas que ves tomar forma y luego las afina contigo", + about: + "Diseña páginas y pantallas en HTML. Ves cada una tomar forma en el área de diseño mientras trabaja, y le pides cambios como se los pedirías a una persona.", + when: "Para una página de ventas, una pantalla de app, un menú, una tarjeta o cualquier idea visual que quieras ver antes de construirla.", + pairs: + "Desarrollador, que puede construir lo que diseña, y Redactor, para las palabras de la página.", + }, + writer: { + name: "Redactor", + role: "Escribe artículos, correos y páginas con tu tono de voz", + summary: "Escribe textos claros que suenan como tú y sirven a quien los va a leer", + about: + "Escribe artículos, correos, textos de producto y páginas con tu tono de voz. Dale una muestra de tu forma de escribir y la sigue.", + when: "Cuando las palabras tienen que ser claras y sonar como tú.", + pairs: "Investigador, para los datos, y Traductor, para otros idiomas.", + }, + "social-media": { + name: "Redes sociales", + role: "Planifica contenido y escribe publicaciones para cada red, pero nunca publica por su cuenta", + summary: "Propone ideas, calendario y publicaciones para que las apruebes", + about: + "Planifica qué publicar y escribe las publicaciones para cada red. Nunca publica por su cuenta: tú apruebas cada una.", + when: "Cuando quieres presencia constante sin empezar de cero cada vez.", + pairs: "Diseñador, para las imágenes, y Redactor, para textos más largos.", + }, + translator: { + name: "Traductor", + role: "Traduce y adapta textos entre idiomas, manteniendo el tono y avisando de lo que se pierde", + summary: "Traduce para que suene natural y avisa dónde cambia el sentido", + about: + "Traduce textos y los adapta para quien los va a leer, manteniendo el tono y avisando dónde se pierde el sentido.", + when: "Para un sitio web en otro idioma, un correo a un cliente en el extranjero o un documento que no puedes leer.", + pairs: "Redactor, para rehacer un texto que necesita más que una traducción.", + }, + researcher: { + name: "Investigador", + role: "Investiga una pregunta en la web, comprueba las fuentes e informa qué encontró y qué no", + summary: "Busca en la web, comprueba las fuentes y te da una respuesta corta con enlaces", + about: + "Busca en la web con su propio navegador, que puedes mirar, compara fuentes y te da una respuesta corta con los enlaces. Dice lo que no pudo encontrar.", + when: "Para precios, competidores, normas, tutoriales o cualquier duda que de otro modo te llevaría una hora buscar.", + pairs: "Redactor y Analista de datos, que usan lo que encuentra.", + }, + "data-analyst": { + name: "Analista de datos", + role: "Lee hojas de cálculo y datos, hace las cuentas, muestra cómo llegó y explica qué significa", + summary: + "Convierte hojas de cálculo y números en respuestas, muestra la cuenta y hace gráficos", + about: + "Lee hojas de cálculo y datos, hace las cuentas, muestra cómo llegó a cada resultado y explica qué significa, con gráficos que puedes ver.", + when: "Cuando tienes números (ventas, gastos, resultados) y quieres respuestas, no tablas.", + pairs: "Investigador, para datos de fuera de tus datos.", + }, + "sales-prospector": { + name: "Prospector de ventas", + role: "Encuentra y evalúa posibles clientes y redacta los mensajes, pero no envía nada sin ti", + summary: + "Encuentra personas y empresas que encajan, las ordena y redacta los primeros mensajes", + about: + "Encuentra personas y empresas que encajan con lo que vendes, explica por qué y redacta el primer mensaje para cada una. No envía nada sin tu aprobación. Funciona mejor con una herramienta conectada a la red donde buscas clientes.", + when: "Cuando necesitas una lista de buenos contactos y un comienzo para cada conversación.", + pairs: "Redactor, para pulir los mensajes, e Investigador, para indagar en una empresa.", + }, + "customer-support": { + name: "Atención al cliente", + role: "Responde dudas de clientes con el material que le diste y pasa lo que no sabe", + summary: "Responde dudas con amabilidad usando tu material y te pasa lo que no sabe", + about: + "Responde dudas de clientes con el material que le das y te pasa los casos que no resuelve. Redacta las respuestas y, al principio, espera tu aprobación.", + when: "Cuando las mismas preguntas no dejan de llegar y quieres ayuda para responderlas bien.", + pairs: "Desarrollador, para problemas técnicos, y Redactor, para la forma de decirlo.", + }, + "personal-assistant": { + name: "Asistente personal", + role: "Mantiene tus tareas en orden, resume lo que necesitas leer y redacta correos", + summary: "Organiza tus tareas, resume lo que necesitas leer y redacta tus correos", + about: + "Mantiene tus tareas en orden, resume lo que necesitas leer, redacta correos y te recuerda lo que importa. La agenda y el correo solo funcionan si conectas una herramienta para ello.", + when: "Cuando tu día está lleno de pequeñas cosas que se acumulan.", + pairs: "Investigador y Redactor, para trabajos más grandes.", + }, + }, +}; diff --git a/app/src/i18n/es/index.ts b/app/src/i18n/es/index.ts index ea0f9044..eb5c57b1 100644 --- a/app/src/i18n/es/index.ts +++ b/app/src/i18n/es/index.ts @@ -5,6 +5,7 @@ import { account } from "./account"; import { alerts } from "./alerts"; import { bots } from "./bots"; import { browser } from "./browser"; +import { catalog } from "./catalog"; import { chat } from "./chat"; import { common } from "./common"; import { connections } from "./connections"; @@ -32,6 +33,7 @@ export const es: Messages = { alerts, bots, browser, + catalog, chat, connections, crews, diff --git a/app/src/i18n/pt-BR/catalog.ts b/app/src/i18n/pt-BR/catalog.ts new file mode 100644 index 00000000..21a9cc05 --- /dev/null +++ b/app/src/i18n/pt-BR/catalog.ts @@ -0,0 +1,154 @@ +import type { Messages } from "../en"; + +export const catalog: Messages["catalog"] = { + title: "Agência de bots", + open: "Agência de bots", + openHint: "Bots prontos que você pode adicionar a esta equipe", + intro: + "Escolha um bot para o trabalho. Ele entra nesta equipe pronto para trabalhar, e depois você pode mudar o que quiser nele.", + invite: { + title: "Quem você quer na sua equipe?", + body: "Não sabe o que criar? Escolha um bot para o trabalho. Depois você pode mudar o que quiser nele.", + }, + search: "Buscar bots", + filter: "Tipos de bot", + categories: { + all: "Todos", + code: "Código", + design: "Design", + content: "Conteúdo", + research: "Pesquisa", + business: "Negócios", + }, + none: "Nenhum bot combina com isso.", + loading: "Carregando os bots…", + add: "Adicionar na equipe", + learnMore: "Saber mais", + back: "Voltar para todos os bots", + joined: (name) => `${name} entrou na equipe`, + customize: "Personalizar", + failed: { + load: "Não deu para carregar os bots", + add: "Não deu para adicionar o bot", + }, + detail: { + what: "O que faz", + when: "Quando chamar", + pairs: "Combina bem com", + technical: "O que o bot recebe quando começa", + }, + roles: { + developer: { + name: "Desenvolvedor", + role: "Escreve e muda código, roda os testes e explica o que mudou", + summary: "Cria e conserta software, confere o próprio trabalho e conta o que fez", + about: + "Escreve funções novas, corrige erros e arruma o código do seu projeto. Roda os testes a cada mudança e conta, em palavras simples, o que fez e o que ainda falta.", + when: "Quando você tem um site, um app ou um script para criar ou consertar.", + pairs: + "Revisor de código e Testador, que conferem o trabalho dele, e Designer, que passa as telas para ele montar.", + }, + "code-reviewer": { + name: "Revisor de código", + role: "Revisa o que os outros bots mudaram e aponta erros, riscos e complicação à toa", + summary: "Lê as mudanças com olhar crítico e diz o que está errado ou arriscado", + about: + "Lê as mudanças que outro bot fez e lista o que pode quebrar, o que é arriscado e o que está mais complicado do que precisa. Ele nunca edita o código, só relata, e a decisão é sua. Pensa mais fundo que a maioria dos bots, então gasta um pouco mais do seu plano.", + when: "Depois que um Desenvolvedor muda algo importante: um pagamento, um login, qualquer coisa difícil de desfazer.", + pairs: "Desenvolvedor, de quem ele revisa o trabalho, e Testador.", + }, + "qa-tester": { + name: "Testador", + role: "Testa o que foi feito, tenta quebrar e conta como repetir cada problema", + summary: "Usa o trabalho como uma pessoa usaria e anota como repetir cada problema", + about: + "Experimenta o que foi feito do jeito que uma pessoa de verdade faria, e depois do jeito de quem não tem cuidado, e relata cada problema com os passos para repetir. Ele não conserta, ele encontra.", + when: "Antes de mostrar ou publicar algo, ou quando as pessoas dizem que quebrou.", + pairs: "Desenvolvedor, que conserta o que ele acha, e Revisor de código.", + }, + designer: { + name: "Designer", + role: "Desenha telas e páginas em HTML que você vê ao vivo e pode pedir para mudar", + summary: "Desenha páginas e telas que você vê tomando forma e depois refina com você", + about: + "Desenha páginas e telas em HTML. Você vê cada uma tomando forma na área de design enquanto ele trabalha, e pede mudanças como pediria a uma pessoa.", + when: "Para uma página de venda, uma tela de app, um cardápio, um cartão ou qualquer ideia visual que você queira ver antes de construir.", + pairs: + "Desenvolvedor, que pode construir o que ele desenha, e Redator, para as palavras da página.", + }, + writer: { + name: "Redator", + role: "Escreve artigos, e-mails e páginas no seu tom de voz", + summary: "Escreve textos claros que soam como você e servem a quem vai ler", + about: + "Escreve artigos, e-mails, textos de produto e páginas no seu tom de voz. Dê a ele um exemplo do seu jeito de escrever e ele acompanha.", + when: "Quando as palavras precisam ser claras e soar como você.", + pairs: "Pesquisador, para os fatos, e Tradutor, para outros idiomas.", + }, + "social-media": { + name: "Social media", + role: "Planeja conteúdo e escreve posts para cada rede, mas nunca publica sozinho", + summary: "Cria ideias, calendário e posts para você aprovar", + about: + "Planeja o que postar e escreve os posts para cada rede. Nunca publica sozinho: você aprova cada post.", + when: "Quando você quer presença constante sem começar do zero toda vez.", + pairs: "Designer, para as imagens, e Redator, para textos mais longos.", + }, + translator: { + name: "Tradutor", + role: "Traduz e adapta textos entre idiomas, mantendo o tom e avisando o que se perde", + summary: "Traduz para soar natural e avisa onde o sentido muda", + about: + "Traduz textos e adapta para quem vai ler, mantendo o tom e avisando onde o sentido se perde.", + when: "Para um site em outro idioma, um e-mail a um cliente no exterior ou um documento que você não consegue ler.", + pairs: "Redator, para refazer um texto que precisa de mais do que tradução.", + }, + researcher: { + name: "Pesquisador", + role: "Pesquisa uma pergunta na web, confere as fontes e conta o que achou e o que não achou", + summary: "Procura na web, confere as fontes e dá uma resposta curta com links", + about: + "Procura na web com o próprio navegador, que você pode acompanhar, compara fontes e dá uma resposta curta com os links. Ele diz o que não conseguiu achar.", + when: "Para preços, concorrentes, regras, passo a passo ou qualquer dúvida em que você gastaria uma hora pesquisando.", + pairs: "Redator e Analista de dados, que usam o que ele acha.", + }, + "data-analyst": { + name: "Analista de dados", + role: "Lê planilhas e dados, faz as contas, mostra como chegou lá e explica o que significa", + summary: "Transforma planilhas e números em respostas, mostra a conta e faz gráficos", + about: + "Lê planilhas e dados, faz as contas, mostra como chegou a cada resultado e explica o que significa, com gráficos que você vê.", + when: "Quando você tem números (vendas, gastos, resultados) e quer respostas, não tabelas.", + pairs: "Pesquisador, para fatos de fora dos seus dados.", + }, + "sales-prospector": { + name: "Prospector de vendas", + role: "Encontra e avalia possíveis clientes e rascunha as mensagens, mas não envia nada sem você", + summary: + "Encontra pessoas e empresas que combinam, organiza e rascunha as primeiras mensagens", + about: + "Encontra pessoas e empresas que combinam com o que você vende, explica o porquê e rascunha a primeira mensagem para cada uma. Não envia nada sem a sua aprovação. Funciona melhor com uma ferramenta conectada à rede onde você procura clientes.", + when: "Quando você precisa de uma lista de bons contatos e de um começo para cada conversa.", + pairs: "Redator, para polir as mensagens, e Pesquisador, para investigar uma empresa.", + }, + "customer-support": { + name: "Atendimento ao cliente", + role: "Responde dúvidas de clientes com o material que você deu e passa adiante o que não sabe", + summary: + "Responde dúvidas com gentileza usando o seu material e passa para você o que não sabe", + about: + "Responde dúvidas de clientes com o material que você dá e passa para você os casos que não resolve. Rascunha as respostas e, no começo, espera a sua aprovação.", + when: "Quando as mesmas perguntas não param de chegar e você quer ajuda para responder bem.", + pairs: "Desenvolvedor, para problemas técnicos, e Redator, para o jeito de dizer.", + }, + "personal-assistant": { + name: "Assistente pessoal", + role: "Mantém as suas tarefas em ordem, resume o que você precisa ler e rascunha e-mails", + summary: "Organiza as suas tarefas, resume o que você precisa ler e rascunha seus e-mails", + about: + "Mantém as suas tarefas em ordem, resume o que você precisa ler, rascunha e-mails e lembra do que importa. Agenda e e-mail só funcionam se você conectar uma ferramenta para isso.", + when: "Quando o seu dia está cheio de pequenas coisas que se acumulam.", + pairs: "Pesquisador e Redator, para trabalhos maiores.", + }, + }, +}; diff --git a/app/src/i18n/pt-BR/index.ts b/app/src/i18n/pt-BR/index.ts index be998d69..3dd9eb77 100644 --- a/app/src/i18n/pt-BR/index.ts +++ b/app/src/i18n/pt-BR/index.ts @@ -5,6 +5,7 @@ import { account } from "./account"; import { alerts } from "./alerts"; import { bots } from "./bots"; import { browser } from "./browser"; +import { catalog } from "./catalog"; import { chat } from "./chat"; import { common } from "./common"; import { connections } from "./connections"; @@ -32,6 +33,7 @@ export const ptBR: Messages = { alerts, bots, browser, + catalog, chat, connections, crews, diff --git a/app/vite.config.ts b/app/vite.config.ts index 4f34b618..f0777aaa 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -28,6 +28,8 @@ export default defineConfig(({ mode }) => ({ allow: [ searchForWorkspaceRoot(process.cwd()), fileURLToPath(new URL("../crates/botloftd/src/screens", import.meta.url)), + // The sheets of the Bot agency, which a test reads (spec 26.2). + fileURLToPath(new URL("../crates/botloftd/catalog", import.meta.url)), ], }, }, diff --git a/docs/spec.md b/docs/spec.md index 42e7673f..370451d7 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -1997,10 +1997,10 @@ instructions = """ ### 26.5 App -- **Onde aparece.** (1) Numa equipe que só tem o chefe, a página da equipe mostra o catálogo como convite: "Quem você quer na sua equipe?". (2) Em qualquer equipe, um botão "Agência de bots" ao lado de criar bot abre a mesma tela. Ela é sempre da equipe aberta. -- **Tela.** Uma grade de cartões, com filtro por categoria e busca por nome e resumo (no idioma do app). Cada cartão tem o nome ("Designer"), uma linha dizendo o que o bot faz, e dois botões: **Adicionar na equipe** e **Saber mais**. -- **Saber mais** abre um painel com o texto de `catalog.ts`: o que faz, quando chamar, com quem combina e o que vale ligar (por exemplo uma ferramenta conectada). Em "Detalhes", como o resto do que é técnico (15.6), ficam as instruções do bot (somente leitura, em inglês), o modelo e o esforço: o dono pode ler o que o bot vai receber. O painel tem também o botão de adicionar. -- **Adicionar** cria na hora, sem diálogo, com o nome do idioma do dono (se já existe na equipe, "Designer 2") por `catalog.add`. Um aviso diz que o bot entrou na equipe, com "Personalizar", que abre os detalhes do bot, onde o dono muda nome, função, instruções, modelo e o resto como em qualquer bot. O catálogo não muda, e o dono pode adicionar a mesma função de novo. +- **Onde aparece.** (1) Numa equipe que só tem o chefe, ou nenhum bot, a aba Bots mostra a Agência embaixo dos cartões, como convite: "Quem você quer na sua equipe?". Isso é decidido quando a equipe abre e vale enquanto ela fica aberta, para o dono poder adicionar vários bots e ver cada um entrar; ao voltar à equipe já com time, o convite não aparece. (2) Em qualquer equipe, um botão "Agência de bots" ao lado de "Novo bot" abre a mesma tela numa janela. Ela é sempre da equipe aberta. +- **Tela.** Uma grade de cartões, com filtro por categoria e busca por nome, resumo e função (no idioma do app). Cada cartão tem o mascote numa cor fixa por papel, o nome ("Designer"), a categoria, uma linha dizendo o que o bot faz, e dois botões: **Adicionar na equipe** e **Saber mais**. +- **Saber mais** troca a grade, no mesmo lugar (sem outra janela por cima), pelo papel inteiro, com "Voltar para todos os bots": o texto de `catalog.ts` (o que faz, quando chamar, com quem combina, e o que vale ligar, como uma ferramenta conectada, quando for o caso). Em "Detalhes", como o resto do que é técnico (15.6), ficam o modelo, o esforço e as instruções do bot (somente leitura, em inglês): o dono pode ler o que o bot vai receber. A tela tem também o botão de adicionar. +- **Adicionar** cria na hora, sem diálogo, com o nome do idioma do dono (se já existe na equipe, "Designer 2") por `catalog.add`. O cartão (ou a tela do papel) diz na hora que o bot entrou na equipe, com "Personalizar", que fecha a Agência e abre o bot com o painel de detalhes, onde o dono muda nome, função, instruções, modelo e o resto como em qualquer bot. Não há aviso flutuante: assim o dono pode adicionar vários e ver cada um. O catálogo não muda, e o dono pode adicionar a mesma função de novo. - **Textos** nos três idiomas (15.6), sem jargão: "bot" e "equipe", nunca "template", "prompt" ou "daemon", e descrevendo o bot pelo nome, não por "Claude". ### 26.6 Segurança e privacidade