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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ describe("app", () => {
expect(await screen.findByRole("heading", { name: "Welcome to Botloft" })).toBeDefined();
expect(screen.getByText("Version 2.1.284")).toBeDefined();
fireEvent.click(screen.getByRole("button", { name: "Create your first crew" }));
const chooser = await screen.findByRole("dialog", { name: "Start from a template" });
fireEvent.click(within(chooser).getByRole("button", { name: /Start with an empty crew/ }));
type("Name", "Ops");
fireEvent.click(screen.getByRole("button", { name: "Create crew" }));
expect(await screen.findByRole("heading", { level: 1, name: "Ops" })).toBeDefined();
Expand Down
3 changes: 3 additions & 0 deletions app/src/features/catalog/AgencyGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ const CATEGORIES: Category[] = [
"finance",
"people",
"security",
"health",
"games",
"legal",
];

/** A fixed color per role, so the mascots tell the cards apart. */
Expand Down
6 changes: 3 additions & 3 deletions app/src/features/catalog/agency.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ describe("the Bot agency", () => {

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(108);
expect(await within(dialog).findAllByRole("listitem")).toHaveLength(129);
});

test("a crew with only its chief invites the owner to pick bots, and a click adds one", async () => {
Expand Down Expand Up @@ -82,7 +82,7 @@ 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(108);
expect(await within(dialog).findAllByRole("listitem")).toHaveLength(129);

fireEvent.click(within(dialog).getByRole("button", { name: "Marketing & sales" }));
expect(within(dialog).getAllByRole("listitem")).toHaveLength(24);
Expand Down Expand Up @@ -129,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(108);
expect(within(dialog).getAllByRole("listitem")).toHaveLength(129);
});

test("the same role twice gets another name", async () => {
Expand Down
12 changes: 9 additions & 3 deletions app/src/features/catalog/useCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,21 @@ 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 } {
/**
* The roles of the Bot agency, read once when the screen opens. With
* `enabled` false nothing is read (a dialog that only sometimes needs them).
*/
export function useCatalog(enabled = true): { roles: BotTemplate[] | null; failed: boolean } {
const t = useT();
const api = useApi();
const [roles, setRoles] = useState<BotTemplate[] | null>(null);
const [failed, setFailed] = useState(false);
const failure = t.catalog.failed.load;

useEffect(() => {
if (!enabled) {
return;
}
let live = true;
api.call("catalog.list", {}).then(
(list) => live && setRoles(list),
Expand All @@ -26,7 +32,7 @@ export function useCatalog(): { roles: BotTemplate[] | null; failed: boolean } {
return () => {
live = false;
};
}, [api, failure]);
}, [api, failure, enabled]);

return { roles, failed };
}
56 changes: 51 additions & 5 deletions app/src/features/crews/CrewDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import { useApi, useApp } from "../../store/context";
import { Button } from "../../ui/Button";
import { Dialog } from "../../ui/Dialog";
import { SelectField, TextArea, TextField } from "../../ui/Field";
import { notifyError } from "../../ui/toast";
import { useCatalog } from "../catalog/useCatalog";
import { addTemplateBots } from "./addTemplateBots";
import type { CrewTemplate } from "./crewTemplates";
import { PickedTemplate, TemplatePicker } from "./TemplatePicker";
import { WorkFolderField } from "./WorkFolderField";

const MODELS: BotModel[] = ["default", "fable", "opus", "sonnet", "haiku"];
Expand All @@ -24,6 +29,19 @@ export function CrewDialog({ crew, onClose }: { crew?: Crew; onClose(): void })
const putBot = useApp((state) => state.putBot);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
// A new crew starts from a template (spec 29.1) when the catalog loaded:
// `undefined` is the list of templates, `null` an empty crew.
const { roles, failed: catalogFailed } = useCatalog(!crew);
const [template, setTemplate] = useState<CrewTemplate | null | undefined>(undefined);
const loading = !crew && roles === null && !catalogFailed;
const choosing = !crew && template === undefined && roles !== null;

const pick = (picked: CrewTemplate) => {
const item = t.crewTemplates.items[picked.id];
setTemplate(picked);
setName((current) => current || item.name);
setGoal(item.goal);
};

const submit = async (event: FormEvent) => {
event.preventDefault();
Expand Down Expand Up @@ -52,6 +70,15 @@ export function CrewDialog({ crew, onClose }: { crew?: Crew; onClose(): void })
for (const bot of bots) {
putBot(bot);
}
if (template && roles) {
const made = await addTemplateBots(api, t, saved.id, template, roles);
for (const bot of made.added) {
putBot(bot);
}
if (made.failed > 0) {
notifyError(t.crewTemplates.partial(made.failed), made.error);
}
}
if (saved.leadBotId) {
selectBot(saved.leadBotId);
}
Expand All @@ -66,18 +93,37 @@ export function CrewDialog({ crew, onClose }: { crew?: Crew; onClose(): void })
const formId = "crew-form";
return (
<Dialog
title={crew ? t.crews.dialog.renameTitle : t.crews.newCrew}
title={crew ? t.crews.dialog.renameTitle : choosing ? t.crewTemplates.title : t.crews.newCrew}
onClose={onClose}
footer={
<>
<Button onClick={onClose}>{t.common.cancel}</Button>
<Button variant="primary" type="submit" form={formId} disabled={busy}>
{crew ? t.crews.rename : t.crews.dialog.create}
</Button>
{!loading && !choosing && (
<Button variant="primary" type="submit" form={formId} disabled={busy}>
{crew ? t.crews.rename : t.crews.dialog.create}
</Button>
)}
</>
}
>
<form id={formId} onSubmit={submit} className="flex flex-col gap-3">
{loading && <p className="text-muted text-sm">{t.catalog.loading}</p>}
{choosing && roles && <TemplatePicker onPick={pick} onScratch={() => setTemplate(null)} />}
<form
id={formId}
onSubmit={submit}
hidden={loading || choosing}
className="flex flex-col gap-3"
>
{template && roles && (
<PickedTemplate
template={template}
roles={roles}
onChange={() => {
setTemplate(undefined);
setGoal("");
}}
/>
)}
<TextField
label={t.crews.dialog.name}
value={name}
Expand Down
93 changes: 93 additions & 0 deletions app/src/features/crews/TemplatePicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { ChevronLeft } from "lucide-react";
import { useT } from "../../i18n";
import type { BotTemplate } from "../../lib/protocol.gen";
import { roleText } from "../catalog/roleText";
import { CREW_TEMPLATES, type CrewTemplate } from "./crewTemplates";

/** The names of a template's bots, in the owner's language. */
export function roleNames(
t: ReturnType<typeof useT>,
template: CrewTemplate,
roles: readonly BotTemplate[],
): string[] {
return template.roles.flatMap((id) => {
const role = roles.find((candidate) => candidate.id === id);
return role ? [roleText(t, role).name] : [];
});
}

/** The first step of a new crew: a ready-made team, or an empty crew. */
export function TemplatePicker({
onPick,
onScratch,
}: {
onPick(template: CrewTemplate): void;
onScratch(): void;
}) {
const t = useT();
const text = t.crewTemplates;
return (
<div className="flex flex-col gap-3">
<p className="text-ink-soft text-sm">{text.intro}</p>
<ul className="m-0 grid list-none grid-cols-1 gap-2 p-0 sm:grid-cols-2">
{CREW_TEMPLATES.map((template) => {
const item = text.items[template.id];
return (
<li key={template.id} className="flex">
<button
type="button"
onClick={() => onPick(template)}
className="flex w-full flex-col gap-1 rounded-xl border border-line bg-panel p-3 text-left transition-colors hover:border-line-strong hover:bg-sunken"
>
<span className="font-semibold">{item.name}</span>
<span className="line-clamp-2 text-ink-soft text-sm">{item.summary}</span>
<span className="text-muted text-xs">{text.bots(template.roles.length)}</span>
</button>
</li>
);
})}
</ul>
<button
type="button"
onClick={onScratch}
className="flex flex-col gap-0.5 rounded-xl border border-line border-dashed p-3 text-left transition-colors hover:border-line-strong hover:bg-sunken"
>
<span className="font-semibold">{text.scratch}</span>
<span className="text-ink-soft text-sm">{text.scratchHint}</span>
</button>
</div>
);
}

/** What a picked template adds, with a way back to the list. */
export function PickedTemplate({
template,
roles,
onChange,
}: {
template: CrewTemplate;
roles: readonly BotTemplate[];
onChange(): void;
}) {
const t = useT();
const text = t.crewTemplates;
return (
<div className="flex flex-col gap-1.5 rounded-xl border border-line bg-sunken p-3 text-sm">
<div className="flex items-center justify-between gap-2">
<span className="font-semibold">{text.items[template.id].name}</span>
<button
type="button"
onClick={onChange}
className="flex items-center gap-0.5 font-medium text-ink-soft hover:text-ink"
>
<ChevronLeft aria-hidden size={14} />
{text.change}
</button>
</div>
<p className="text-ink-soft">
<span className="font-medium">{text.adds}: </span>
{roleNames(t, template, roles).join(", ")}
</p>
</div>
);
}
46 changes: 46 additions & 0 deletions app/src/features/crews/addTemplateBots.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { Messages } from "../../i18n";
import type { BotloftApi } from "../../lib/api";
import type { Bot, BotTemplate, CrewId } from "../../lib/protocol.gen";
import { roleText } from "../catalog/roleText";
import { freeName } from "../catalog/useAddRole";
import type { CrewTemplate } from "./crewTemplates";

/**
* Adds the bots of `template` to a crew just created, one after the other,
* each with its name and role in the owner's language (`catalog.add`, as the
* Bot agency does). A bot that fails does not stop the others: the crew is
* already there, and the owner can add what is missing from the Bot agency.
*/
export async function addTemplateBots(
api: BotloftApi,
t: Messages,
crewId: CrewId,
template: CrewTemplate,
roles: readonly BotTemplate[],
): Promise<{ added: Bot[]; failed: number; error: unknown }> {
const added: Bot[] = [];
let failed = 0;
let error: unknown = null;
for (const id of template.roles) {
const role = roles.find((candidate) => candidate.id === id);
if (!role) {
failed += 1;
continue;
}
const text = roleText(t, role);
try {
added.push(
await api.call("catalog.add", {
crewId,
templateId: role.id,
name: freeName(text.name, added),
role: text.role,
}),
);
} catch (caught) {
failed += 1;
error ??= caught;
}
}
return { added, failed, error };
}
5 changes: 2 additions & 3 deletions app/src/features/crews/chief.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { act, cleanup, fireEvent, screen, within } from "@testing-library/react"
import { afterEach, describe, expect, test } from "vitest";
import { FakeBotloft } from "../../lib/fake";
import { SUGGEST_TOOL } from "../../lib/fakeChat";
import { crewOpened, openBot, renderApp, sidebar } from "../../test/app";
import { crewOpened, openBot, openNewCrewForm, renderApp, sidebar } from "../../test/app";

afterEach(cleanup);

Expand Down Expand Up @@ -47,8 +47,7 @@ describe("the crew's chief", () => {
fake.addCrew("Ops");
renderApp(fake);
await crewOpened("Ops");
fireEvent.click(screen.getByRole("button", { name: "New crew" }));
const dialog = screen.getByRole("dialog", { name: "New crew" });
const dialog = await openNewCrewForm();
fireEvent.change(within(dialog).getByLabelText("Name"), { target: { value: "Bakery" } });
fireEvent.change(within(dialog).getByLabelText("What is this crew for?"), {
target: { value: "Build my bakery's website" },
Expand Down
Loading
Loading