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
4 changes: 3 additions & 1 deletion app/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
12 changes: 8 additions & 4 deletions app/src/features/bots/delete.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 },
});
Expand Down Expand Up @@ -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");
Expand Down
77 changes: 77 additions & 0 deletions app/src/features/catalog/AgencyCard.tsx
Original file line number Diff line number Diff line change
@@ -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: "<name> joined the crew", and a way to change it. */
export function Joined({ bot, onCustomize }: { bot: Bot; onCustomize(): void }) {
const t = useT();
return (
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
<span className="flex items-center gap-1 font-medium text-ok">
<Check aria-hidden size={14} />
{t.catalog.joined(bot.name)}
</span>
<button
type="button"
onClick={onCustomize}
className="font-medium text-ink-soft underline-offset-2 hover:text-ink hover:underline"
>
{t.catalog.customize}
</button>
</p>
);
}

/** 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 (
<li className="flex flex-col gap-2 rounded-xl border border-line bg-panel p-3.5">
<div className="flex items-center gap-2.5">
<BotAvatar color={color} size={32} />
<div className="min-w-0 flex-1">
<h3 className="truncate font-semibold">{text.name}</h3>
<p className="truncate text-muted text-xs">{t.catalog.categories[template.category]}</p>
</div>
</div>
<p className="line-clamp-2 min-h-10 text-ink-soft text-sm">{text.summary}</p>
<div className="flex flex-wrap gap-2">
<Button
variant="primary"
size="sm"
icon={Plus}
disabled={busy}
aria-label={`${t.catalog.add}: ${text.name}`}
onClick={onAdd}
>
{t.catalog.add}
</Button>
<Button size="sm" aria-label={`${t.catalog.learnMore}: ${text.name}`} onClick={onLearnMore}>
{t.catalog.learnMore}
</Button>
</div>
{added && <Joined bot={added} onCustomize={onCustomize} />}
</li>
);
}
126 changes: 126 additions & 0 deletions app/src/features/catalog/AgencyGrid.tsx
Original file line number Diff line number Diff line change
@@ -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<Category>("all");
const [query, setQuery] = useState("");
const [open, setOpen] = useState<string | null>(null);

if (failed) {
return null;
}
if (roles === null) {
return <p className="text-muted text-sm">{t.catalog.loading}</p>;
}

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 (
<RoleDetail
template={opened}
text={roleText(t, opened)}
added={bot}
busy={busy === opened.id}
onAdd={() => 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 (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
aria-label={t.catalog.search}
placeholder={t.catalog.search}
className="h-8 w-48 rounded-lg border border-line-strong bg-canvas px-2.5 text-ink text-sm outline-none placeholder:text-muted focus:border-accent"
/>
<fieldset
aria-label={t.catalog.filter}
className="m-0 flex min-w-0 flex-wrap gap-1.5 border-0 p-0"
>
{CATEGORIES.map((id) => (
<button
key={id}
type="button"
aria-pressed={category === id}
onClick={() => setCategory(id)}
className={`h-7 rounded-full border px-2.5 font-medium text-xs transition-colors ${
category === id
? "border-ink bg-ink text-canvas"
: "border-line-strong text-ink-soft hover:bg-sunken hover:text-ink"
}`}
>
{t.catalog.categories[id]}
</button>
))}
</fieldset>
</div>
{shown.length === 0 ? (
<p className="py-6 text-center text-muted text-sm">{t.catalog.none}</p>
) : (
<ul className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-3">
{shown.map((role) => {
const bot = added[role.id];
return (
<AgencyCard
key={role.id}
template={role}
text={roleText(t, role)}
color={colorOf(roles.indexOf(role))}
added={bot}
busy={busy === role.id}
onAdd={() => add(role)}
onLearnMore={() => setOpen(role.id)}
onCustomize={() => bot && customize(bot.id)}
/>
);
})}
</ul>
)}
</div>
);
}
20 changes: 20 additions & 0 deletions app/src/features/catalog/AgencyInvite.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section aria-label={t.catalog.title} className="mt-6 flex flex-col gap-3">
<div>
<h2 className="font-semibold text-base tracking-tight">{t.catalog.invite.title}</h2>
<p className="mt-0.5 text-muted text-sm">{t.catalog.invite.body}</p>
</div>
<AgencyGrid crew={crew} />
</section>
);
}
15 changes: 15 additions & 0 deletions app/src/features/catalog/BotAgencyDialog.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog title={t.catalog.title} width="lg" onClose={onClose}>
<p className="mb-3 text-muted text-sm">{t.catalog.intro}</p>
<AgencyGrid crew={crew} onLeave={onClose} />
</Dialog>
);
}
85 changes: 85 additions & 0 deletions app/src/features/catalog/RoleDetail.tsx
Original file line number Diff line number Diff line change
@@ -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<BotTemplateFull | null>(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 (
<section aria-label={text.name} className="flex flex-col gap-4">
<div>
<Button variant="ghost" size="sm" icon={ArrowLeft} onClick={onBack}>
{t.catalog.back}
</Button>
</div>
<header>
<h3 className="font-semibold text-lg tracking-tight">{text.name}</h3>
<p className="mt-0.5 text-muted text-sm">{text.summary}</p>
</header>
{sections.map(
([title, body]) =>
body && (
<div key={title}>
<h4 className="font-medium text-muted text-xs">{title}</h4>
<p className="mt-0.5 text-sm">{body}</p>
</div>
),
)}
{sheet && (
<Details label={words.technical}>
{`${sheet.model} · ${sheet.effort}\n\n${sheet.instructions}`}
</Details>
)}
<div className="flex flex-wrap items-center gap-3">
<Button variant="primary" icon={Plus} disabled={busy} onClick={onAdd}>
{t.catalog.add}
</Button>
{added && <Joined bot={added} onCustomize={onCustomize} />}
</div>
</section>
);
}
Loading
Loading