diff --git a/app/src/App.test.tsx b/app/src/App.test.tsx
index 97df36f6..09dbe885 100644
--- a/app/src/App.test.tsx
+++ b/app/src/App.test.tsx
@@ -36,7 +36,7 @@ describe("app", () => {
type("Name", "Ops");
fireEvent.click(screen.getByRole("button", { name: "Create crew" }));
expect(await screen.findByRole("heading", { level: 1, name: "Ops" })).toBeDefined();
- expect(within(sidebar()).getByRole("button", { name: /Ops/ })).toBeDefined();
+ expect(within(sidebar()).getByRole("button", { name: "Ops" })).toBeDefined();
});
test("a new bot opens with its handle and follows its state", async () => {
diff --git a/app/src/features/bots/delete.test.tsx b/app/src/features/bots/delete.test.tsx
index ff07d360..d0d309bd 100644
--- a/app/src/features/bots/delete.test.tsx
+++ b/app/src/features/bots/delete.test.tsx
@@ -119,7 +119,7 @@ describe("deleting a crew", () => {
const { fake, crew, scout } = await twoBots();
const docs = fake.addCrew("Docs");
await waitFor(() =>
- expect(within(sidebar()).getByRole("button", { name: /Docs/ })).toBeDefined(),
+ expect(within(sidebar()).getByRole("button", { name: "Docs" })).toBeDefined(),
);
const dialog = askToDeleteCrew("Ops");
expect(dialog.textContent).toContain(
@@ -130,13 +130,13 @@ describe("deleting a crew", () => {
fireEvent.click(within(dialog).getByRole("button", { name: "Delete crew" }));
await waitFor(() =>
- expect(within(sidebar()).queryByRole("button", { name: /Ops/ })).toBeNull(),
+ expect(within(sidebar()).queryByRole("button", { name: "Ops" })).toBeNull(),
);
expect(fake.calls.at(-1)).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);
- expect(within(sidebar()).getByRole("button", { name: /Docs/ })).toBeDefined();
+ expect(within(sidebar()).getByRole("button", { name: "Docs" })).toBeDefined();
});
test("an empty crew and a crew of one read right", async () => {
@@ -230,7 +230,7 @@ describe("sending the folder to the Recycle Bin", () => {
await waitFor(() => expect(fake.recycled).toEqual(["C:\\Users\\owner\\Botloft\\ops"]));
expect(fake.calls.at(-1)?.params).toEqual({ crewId: ops.id, recycleFolder: true });
- fireEvent.click(within(sidebar()).getByRole("button", { name: /Site/ }));
+ fireEvent.click(within(sidebar()).getByRole("button", { name: "Site" }));
await crewOpened("Site");
fireEvent.click(screen.getByRole("button", { name: "More crew actions" }));
fireEvent.click(screen.getByRole("menuitem", { name: "Delete crew" }));
diff --git a/app/src/features/crews/CrewsOverview.tsx b/app/src/features/crews/CrewsOverview.tsx
new file mode 100644
index 00000000..8662b2e9
--- /dev/null
+++ b/app/src/features/crews/CrewsOverview.tsx
@@ -0,0 +1,120 @@
+import { Pause, Plus } from "lucide-react";
+import { useState } from "react";
+import { useShallow } from "zustand/react/shallow";
+import { useT } from "../../i18n";
+import type { Bot, Crew } from "../../lib/protocol.gen";
+import { botsOf, crewList } from "../../store/app";
+import { useApp } from "../../store/context";
+import { unreadIn } from "../../store/seen";
+import { Button } from "../../ui/Button";
+import { BotAvatar, moodOf } from "../bots/BotAvatar";
+import { CrewDialog } from "./CrewDialog";
+
+/** Faces shown on a card; the rest are counted. */
+const FACES = 6;
+
+/** Every crew as a card (spec 15.1), opened from "Crews" on the left. */
+export function CrewsOverview() {
+ const t = useT();
+ const words = t.crews.overview;
+ const crews = useApp(useShallow(crewList));
+ const botCount = useApp((state) => Object.keys(state.bots).length);
+ const [creating, setCreating] = useState(false);
+ return (
+
+
+
+
+ {crews.map((crew) => (
+
+ ))}
+
+
+ {creating && setCreating(false)} />}
+
+ );
+}
+
+function CrewCard({ crew }: { crew: Crew }) {
+ const t = useT();
+ const words = t.crews.overview;
+ const bots = useApp(useShallow((state) => botsOf(state, crew.id)));
+ const unread = useApp((state) => unreadIn(state, crew.id));
+ const selectCrew = useApp((state) => state.selectCrew);
+ const { working, waiting } = tally(bots);
+ const parts = [
+ waiting > 0 && words.waiting(waiting),
+ working > 0 && words.working(working),
+ ].filter((part): part is string => Boolean(part));
+ const status = crew.paused
+ ? t.crews.paused
+ : parts.join(" · ") || (bots.length > 0 ? words.calm : words.noBots);
+ return (
+
+
+
+ );
+}
+
+function tally(bots: Bot[]): { working: number; waiting: number } {
+ let working = 0;
+ let waiting = 0;
+ for (const bot of bots) {
+ if (bot.state === "busy" || bot.state === "launching") {
+ working += 1;
+ } else if (bot.state === "needs_approval" || bot.state === "auth_error") {
+ waiting += 1;
+ }
+ }
+ return { working, waiting };
+}
diff --git a/app/src/features/crews/Sidebar.tsx b/app/src/features/crews/Sidebar.tsx
index 58a066cf..89240959 100644
--- a/app/src/features/crews/Sidebar.tsx
+++ b/app/src/features/crews/Sidebar.tsx
@@ -1,4 +1,4 @@
-import { Pause, Plus } from "lucide-react";
+import { ChevronDown, ChevronsDownUp, ChevronsUpDown, Pause, Plus } from "lucide-react";
import { memo, useCallback, useState } from "react";
import { createPortal } from "react-dom";
import { useShallow } from "zustand/react/shallow";
@@ -18,19 +18,40 @@ import { useBotActions } from "../bots/botActions";
import { ChiefBadge, isChief } from "../bots/ChiefBadge";
import { toolAction, toolTitle } from "../chat/toolNames";
import { CrewDialog } from "./CrewDialog";
+import { setAllCollapsed, setCollapsed, useCollapsed, useCollapsedSet } from "./collapsed";
/** Crews as sections and their bots as conversations (spec 15.1). */
export function Sidebar() {
const t = useT();
+ const words = t.crews.sidebar;
const crews = useApp(useShallow(crewList));
+ const overview = useApp((state) => state.selectedCrewId === null);
+ const selectCrew = useApp((state) => state.selectCrew);
+ const collapsed = useCollapsedSet();
+ const allFolded = crews.length > 0 && crews.every((crew) => collapsed.has(crew.id));
const [creating, setCreating] = useState(false);
return (
-