Skip to content

Commit abd3352

Browse files
committed
feat(crews): start a crew from a template (spec 29.1)
"New crew" now opens on a list of ten ready-made teams (content studio, software team, online store, growth team, research desk, back office, personal office, course studio, game studio, people team) and an empty crew. A template fills in the name and the chief's goal and says which bots it adds; creating makes the crew with its chief and then adds each bot from the catalog, in the owner's language. A bot that fails does not stop the others, and the owner is told. App only: no new daemon method. The first-run "Create your first crew" uses the same step.
1 parent a8a6545 commit abd3352

17 files changed

Lines changed: 688 additions & 17 deletions

‎app/src/App.test.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ describe("app", () => {
3333
expect(await screen.findByRole("heading", { name: "Welcome to Botloft" })).toBeDefined();
3434
expect(screen.getByText("Version 2.1.284")).toBeDefined();
3535
fireEvent.click(screen.getByRole("button", { name: "Create your first crew" }));
36+
const chooser = await screen.findByRole("dialog", { name: "Start from a template" });
37+
fireEvent.click(within(chooser).getByRole("button", { name: /Start with an empty crew/ }));
3638
type("Name", "Ops");
3739
fireEvent.click(screen.getByRole("button", { name: "Create crew" }));
3840
expect(await screen.findByRole("heading", { level: 1, name: "Ops" })).toBeDefined();

‎app/src/features/catalog/useCatalog.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,21 @@ import type { BotTemplate } from "../../lib/protocol.gen";
44
import { useApi } from "../../store/context";
55
import { notifyError } from "../../ui/toast";
66

7-
/** The roles of the Bot agency, read once when the screen opens. */
8-
export function useCatalog(): { roles: BotTemplate[] | null; failed: boolean } {
7+
/**
8+
* The roles of the Bot agency, read once when the screen opens. With
9+
* `enabled` false nothing is read (a dialog that only sometimes needs them).
10+
*/
11+
export function useCatalog(enabled = true): { roles: BotTemplate[] | null; failed: boolean } {
912
const t = useT();
1013
const api = useApi();
1114
const [roles, setRoles] = useState<BotTemplate[] | null>(null);
1215
const [failed, setFailed] = useState(false);
1316
const failure = t.catalog.failed.load;
1417

1518
useEffect(() => {
19+
if (!enabled) {
20+
return;
21+
}
1622
let live = true;
1723
api.call("catalog.list", {}).then(
1824
(list) => live && setRoles(list),
@@ -26,7 +32,7 @@ export function useCatalog(): { roles: BotTemplate[] | null; failed: boolean } {
2632
return () => {
2733
live = false;
2834
};
29-
}, [api, failure]);
35+
}, [api, failure, enabled]);
3036

3137
return { roles, failed };
3238
}

‎app/src/features/crews/CrewDialog.tsx‎

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import { useApi, useApp } from "../../store/context";
66
import { Button } from "../../ui/Button";
77
import { Dialog } from "../../ui/Dialog";
88
import { SelectField, TextArea, TextField } from "../../ui/Field";
9+
import { notifyError } from "../../ui/toast";
10+
import { useCatalog } from "../catalog/useCatalog";
11+
import { addTemplateBots } from "./addTemplateBots";
12+
import type { CrewTemplate } from "./crewTemplates";
13+
import { PickedTemplate, TemplatePicker } from "./TemplatePicker";
914
import { WorkFolderField } from "./WorkFolderField";
1015

1116
const MODELS: BotModel[] = ["default", "fable", "opus", "sonnet", "haiku"];
@@ -24,6 +29,19 @@ export function CrewDialog({ crew, onClose }: { crew?: Crew; onClose(): void })
2429
const putBot = useApp((state) => state.putBot);
2530
const [error, setError] = useState<string | null>(null);
2631
const [busy, setBusy] = useState(false);
32+
// A new crew starts from a template (spec 29.1) when the catalog loaded:
33+
// `undefined` is the list of templates, `null` an empty crew.
34+
const { roles, failed: catalogFailed } = useCatalog(!crew);
35+
const [template, setTemplate] = useState<CrewTemplate | null | undefined>(undefined);
36+
const loading = !crew && roles === null && !catalogFailed;
37+
const choosing = !crew && template === undefined && roles !== null;
38+
39+
const pick = (picked: CrewTemplate) => {
40+
const item = t.crewTemplates.items[picked.id];
41+
setTemplate(picked);
42+
setName((current) => current || item.name);
43+
setGoal(item.goal);
44+
};
2745

2846
const submit = async (event: FormEvent) => {
2947
event.preventDefault();
@@ -52,6 +70,15 @@ export function CrewDialog({ crew, onClose }: { crew?: Crew; onClose(): void })
5270
for (const bot of bots) {
5371
putBot(bot);
5472
}
73+
if (template && roles) {
74+
const made = await addTemplateBots(api, t, saved.id, template, roles);
75+
for (const bot of made.added) {
76+
putBot(bot);
77+
}
78+
if (made.failed > 0) {
79+
notifyError(t.crewTemplates.partial(made.failed), made.error);
80+
}
81+
}
5582
if (saved.leadBotId) {
5683
selectBot(saved.leadBotId);
5784
}
@@ -66,18 +93,37 @@ export function CrewDialog({ crew, onClose }: { crew?: Crew; onClose(): void })
6693
const formId = "crew-form";
6794
return (
6895
<Dialog
69-
title={crew ? t.crews.dialog.renameTitle : t.crews.newCrew}
96+
title={crew ? t.crews.dialog.renameTitle : choosing ? t.crewTemplates.title : t.crews.newCrew}
7097
onClose={onClose}
7198
footer={
7299
<>
73100
<Button onClick={onClose}>{t.common.cancel}</Button>
74-
<Button variant="primary" type="submit" form={formId} disabled={busy}>
75-
{crew ? t.crews.rename : t.crews.dialog.create}
76-
</Button>
101+
{!loading && !choosing && (
102+
<Button variant="primary" type="submit" form={formId} disabled={busy}>
103+
{crew ? t.crews.rename : t.crews.dialog.create}
104+
</Button>
105+
)}
77106
</>
78107
}
79108
>
80-
<form id={formId} onSubmit={submit} className="flex flex-col gap-3">
109+
{loading && <p className="text-muted text-sm">{t.catalog.loading}</p>}
110+
{choosing && roles && <TemplatePicker onPick={pick} onScratch={() => setTemplate(null)} />}
111+
<form
112+
id={formId}
113+
onSubmit={submit}
114+
hidden={loading || choosing}
115+
className="flex flex-col gap-3"
116+
>
117+
{template && roles && (
118+
<PickedTemplate
119+
template={template}
120+
roles={roles}
121+
onChange={() => {
122+
setTemplate(undefined);
123+
setGoal("");
124+
}}
125+
/>
126+
)}
81127
<TextField
82128
label={t.crews.dialog.name}
83129
value={name}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { ChevronLeft } from "lucide-react";
2+
import { useT } from "../../i18n";
3+
import type { BotTemplate } from "../../lib/protocol.gen";
4+
import { roleText } from "../catalog/roleText";
5+
import { CREW_TEMPLATES, type CrewTemplate } from "./crewTemplates";
6+
7+
/** The names of a template's bots, in the owner's language. */
8+
export function roleNames(
9+
t: ReturnType<typeof useT>,
10+
template: CrewTemplate,
11+
roles: readonly BotTemplate[],
12+
): string[] {
13+
return template.roles.flatMap((id) => {
14+
const role = roles.find((candidate) => candidate.id === id);
15+
return role ? [roleText(t, role).name] : [];
16+
});
17+
}
18+
19+
/** The first step of a new crew: a ready-made team, or an empty crew. */
20+
export function TemplatePicker({
21+
onPick,
22+
onScratch,
23+
}: {
24+
onPick(template: CrewTemplate): void;
25+
onScratch(): void;
26+
}) {
27+
const t = useT();
28+
const text = t.crewTemplates;
29+
return (
30+
<div className="flex flex-col gap-3">
31+
<p className="text-ink-soft text-sm">{text.intro}</p>
32+
<ul className="m-0 grid list-none grid-cols-1 gap-2 p-0 sm:grid-cols-2">
33+
{CREW_TEMPLATES.map((template) => {
34+
const item = text.items[template.id];
35+
return (
36+
<li key={template.id} className="flex">
37+
<button
38+
type="button"
39+
onClick={() => onPick(template)}
40+
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"
41+
>
42+
<span className="font-semibold">{item.name}</span>
43+
<span className="line-clamp-2 text-ink-soft text-sm">{item.summary}</span>
44+
<span className="text-muted text-xs">{text.bots(template.roles.length)}</span>
45+
</button>
46+
</li>
47+
);
48+
})}
49+
</ul>
50+
<button
51+
type="button"
52+
onClick={onScratch}
53+
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"
54+
>
55+
<span className="font-semibold">{text.scratch}</span>
56+
<span className="text-ink-soft text-sm">{text.scratchHint}</span>
57+
</button>
58+
</div>
59+
);
60+
}
61+
62+
/** What a picked template adds, with a way back to the list. */
63+
export function PickedTemplate({
64+
template,
65+
roles,
66+
onChange,
67+
}: {
68+
template: CrewTemplate;
69+
roles: readonly BotTemplate[];
70+
onChange(): void;
71+
}) {
72+
const t = useT();
73+
const text = t.crewTemplates;
74+
return (
75+
<div className="flex flex-col gap-1.5 rounded-xl border border-line bg-sunken p-3 text-sm">
76+
<div className="flex items-center justify-between gap-2">
77+
<span className="font-semibold">{text.items[template.id].name}</span>
78+
<button
79+
type="button"
80+
onClick={onChange}
81+
className="flex items-center gap-0.5 font-medium text-ink-soft hover:text-ink"
82+
>
83+
<ChevronLeft aria-hidden size={14} />
84+
{text.change}
85+
</button>
86+
</div>
87+
<p className="text-ink-soft">
88+
<span className="font-medium">{text.adds}: </span>
89+
{roleNames(t, template, roles).join(", ")}
90+
</p>
91+
</div>
92+
);
93+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { Messages } from "../../i18n";
2+
import type { BotloftApi } from "../../lib/api";
3+
import type { Bot, BotTemplate, CrewId } from "../../lib/protocol.gen";
4+
import { roleText } from "../catalog/roleText";
5+
import { freeName } from "../catalog/useAddRole";
6+
import type { CrewTemplate } from "./crewTemplates";
7+
8+
/**
9+
* Adds the bots of `template` to a crew just created, one after the other,
10+
* each with its name and role in the owner's language (`catalog.add`, as the
11+
* Bot agency does). A bot that fails does not stop the others: the crew is
12+
* already there, and the owner can add what is missing from the Bot agency.
13+
*/
14+
export async function addTemplateBots(
15+
api: BotloftApi,
16+
t: Messages,
17+
crewId: CrewId,
18+
template: CrewTemplate,
19+
roles: readonly BotTemplate[],
20+
): Promise<{ added: Bot[]; failed: number; error: unknown }> {
21+
const added: Bot[] = [];
22+
let failed = 0;
23+
let error: unknown = null;
24+
for (const id of template.roles) {
25+
const role = roles.find((candidate) => candidate.id === id);
26+
if (!role) {
27+
failed += 1;
28+
continue;
29+
}
30+
const text = roleText(t, role);
31+
try {
32+
added.push(
33+
await api.call("catalog.add", {
34+
crewId,
35+
templateId: role.id,
36+
name: freeName(text.name, added),
37+
role: text.role,
38+
}),
39+
);
40+
} catch (caught) {
41+
failed += 1;
42+
error ??= caught;
43+
}
44+
}
45+
return { added, failed, error };
46+
}

‎app/src/features/crews/chief.test.tsx‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { act, cleanup, fireEvent, screen, within } from "@testing-library/react"
22
import { afterEach, describe, expect, test } from "vitest";
33
import { FakeBotloft } from "../../lib/fake";
44
import { SUGGEST_TOOL } from "../../lib/fakeChat";
5-
import { crewOpened, openBot, renderApp, sidebar } from "../../test/app";
5+
import { crewOpened, openBot, openNewCrewForm, renderApp, sidebar } from "../../test/app";
66

77
afterEach(cleanup);
88

@@ -47,8 +47,7 @@ describe("the crew's chief", () => {
4747
fake.addCrew("Ops");
4848
renderApp(fake);
4949
await crewOpened("Ops");
50-
fireEvent.click(screen.getByRole("button", { name: "New crew" }));
51-
const dialog = screen.getByRole("dialog", { name: "New crew" });
50+
const dialog = await openNewCrewForm();
5251
fireEvent.change(within(dialog).getByLabelText("Name"), { target: { value: "Bakery" } });
5352
fireEvent.change(within(dialog).getByLabelText("What is this crew for?"), {
5453
target: { value: "Build my bakery's website" },
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
2+
import { afterEach, describe, expect, test } from "vitest";
3+
import { FakeBotloft } from "../../lib/fake";
4+
import { FAKE_CATALOG } from "../../lib/fakeCatalog";
5+
import { RpcError } from "../../lib/rpc";
6+
import { crewOpened, renderApp, sidebar } from "../../test/app";
7+
import { CREW_TEMPLATES } from "./crewTemplates";
8+
9+
afterEach(cleanup);
10+
11+
/** The app with one crew open, and the template step of "New crew" showing. */
12+
async function openTemplates() {
13+
const fake = new FakeBotloft();
14+
fake.addCrew("Ops");
15+
renderApp(fake);
16+
await crewOpened("Ops");
17+
fireEvent.click(screen.getByRole("button", { name: "New crew" }));
18+
return { fake, chooser: await screen.findByRole("dialog", { name: "Start from a template" }) };
19+
}
20+
21+
describe("the crew templates", () => {
22+
test("every role of every template is a role of the catalog, and a crew has room for it", () => {
23+
const known = new Set(FAKE_CATALOG.map((role) => role.id));
24+
for (const template of CREW_TEMPLATES) {
25+
for (const id of template.roles) {
26+
expect(known.has(id), `${template.id}: ${id}`).toBe(true);
27+
}
28+
expect(new Set(template.roles).size).toBe(template.roles.length);
29+
// The chief takes one of the 12 places of a crew.
30+
expect(template.roles.length).toBeLessThanOrEqual(11);
31+
}
32+
expect(new Set(CREW_TEMPLATES.map((template) => template.id)).size).toBe(CREW_TEMPLATES.length);
33+
});
34+
35+
test("the first step lists the templates and an empty crew", async () => {
36+
const { chooser } = await openTemplates();
37+
expect(within(chooser).getAllByRole("listitem")).toHaveLength(CREW_TEMPLATES.length);
38+
expect(within(chooser).getByRole("button", { name: /Software team/ })).toBeDefined();
39+
expect(within(chooser).getByRole("button", { name: /Start with an empty crew/ })).toBeDefined();
40+
});
41+
42+
test("picking a template fills the form and says which bots it adds", async () => {
43+
const { chooser } = await openTemplates();
44+
fireEvent.click(within(chooser).getByRole("button", { name: /Game studio/ }));
45+
46+
const dialog = screen.getByRole("dialog", { name: "New crew" });
47+
expect((within(dialog).getByLabelText("Name") as HTMLInputElement).value).toBe("Game studio");
48+
expect(
49+
(within(dialog).getByLabelText("What is this crew for?") as HTMLTextAreaElement).value,
50+
).toContain("Design my game");
51+
expect(
52+
within(dialog).getByText(/Game Designer, Narrative Designer, Level Designer/),
53+
).toBeDefined();
54+
55+
fireEvent.click(within(dialog).getByRole("button", { name: "Pick another template" }));
56+
expect(screen.getByRole("dialog", { name: "Start from a template" })).toBeDefined();
57+
});
58+
59+
test("creating from a template makes the crew with its chief and then each bot", async () => {
60+
const { fake, chooser } = await openTemplates();
61+
fireEvent.click(within(chooser).getByRole("button", { name: /Online store/ }));
62+
const dialog = screen.getByRole("dialog", { name: "New crew" });
63+
fireEvent.click(within(dialog).getByRole("button", { name: "Create crew" }));
64+
65+
await crewOpened("Online store");
66+
const template = CREW_TEMPLATES.find((item) => item.id === "online-store");
67+
await waitFor(() =>
68+
expect(fake.calls.filter((call) => call.method === "catalog.add")).toHaveLength(
69+
template?.roles.length ?? -1,
70+
),
71+
);
72+
expect(
73+
fake.calls.filter((call) => call.method === "catalog.add").map((call) => call.params),
74+
).toEqual((template?.roles ?? []).map((id) => expect.objectContaining({ templateId: id })));
75+
const create = fake.calls.find((call) => call.method === "crews.create");
76+
expect(create?.params).toMatchObject({ name: "Online store", lead: { name: "Chief" } });
77+
expect(await within(sidebar()).findByRole("button", { name: /^Bookkeeper/ })).toBeDefined();
78+
});
79+
80+
test("a bot that cannot be added is told, and the crew stays", async () => {
81+
const { fake, chooser } = await openTemplates();
82+
fireEvent.click(within(chooser).getByRole("button", { name: /Research desk/ }));
83+
fake.failNext("catalog.add", new RpcError(-32603, "no room"));
84+
fireEvent.click(
85+
within(screen.getByRole("dialog", { name: "New crew" })).getByRole("button", {
86+
name: "Create crew",
87+
}),
88+
);
89+
90+
expect(await screen.findByText(/1 bot could not be added/)).toBeDefined();
91+
expect(fake.calls.filter((call) => call.method === "catalog.add")).toHaveLength(6);
92+
expect(await screen.findByRole("heading", { level: 1, name: "Chief" })).toBeDefined();
93+
});
94+
});

0 commit comments

Comments
 (0)