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 ( +
+
+
+

{t.crews.sidebar.label}

+

+ {words.count(crews.length)} · {t.crews.view.bots(botCount)} +

+
+ +
+
+ +
+ {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 (
    -