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: 1 addition & 1 deletion app/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
8 changes: 4 additions & 4 deletions app/src/features/bots/delete.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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" }));
Expand Down
120 changes: 120 additions & 0 deletions app/src/features/crews/CrewsOverview.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section aria-label={t.crews.sidebar.label} className="flex min-h-0 flex-1 flex-col">
<header className="flex items-center gap-3 border-line border-b px-5 py-3.5">
<div className="min-w-0 flex-1">
<h1 className="truncate font-semibold text-xl tracking-tight">{t.crews.sidebar.label}</h1>
<p className="mt-0.5 text-muted text-sm">
{words.count(crews.length)} · {t.crews.view.bots(botCount)}
</p>
</div>
<Button variant="primary" icon={Plus} onClick={() => setCreating(true)}>
{t.crews.newCrew}
</Button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto p-5">
<ul className="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-3">
{crews.map((crew) => (
<CrewCard key={crew.id} crew={crew} />
))}
</ul>
</div>
{creating && <CrewDialog onClose={() => setCreating(false)} />}
</section>
);
}

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 (
<li>
<button
type="button"
onClick={() => selectCrew(crew.id)}
className="flex h-full w-full flex-col gap-3 rounded-xl border border-line bg-panel p-3.5 text-left transition-[border-color,transform,box-shadow] duration-200 hover:-translate-y-0.5 hover:border-line-strong hover:shadow-sm"
>
<span className="flex w-full items-center gap-2">
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold">{crew.name}</span>
<span className="block text-muted text-xs">{t.crews.view.bots(bots.length)}</span>
</span>
{unread > 0 && (
<span className="grid h-[18px] min-w-[18px] place-items-center rounded-full bg-accent px-1 font-semibold text-[11px] text-canvas tabular-nums">
<span aria-hidden>{unread}</span>
<span className="sr-only">{t.crews.sidebar.unreadCount(unread)}</span>
</span>
)}
</span>
{bots.length > 0 && (
<span aria-hidden className="flex items-center gap-1">
{bots.slice(0, FACES).map((bot) => (
// Still: a page of crews would otherwise repaint every flame.
<BotAvatar
key={bot.id}
color={bot.color}
size={26}
mood={moodOf(bot, crew.paused)}
still
/>
))}
{bots.length > FACES && (
<span className="ml-1 text-muted text-xs tabular-nums">+{bots.length - FACES}</span>
)}
</span>
)}
<span
className={`flex items-center gap-1.5 text-xs ${waiting > 0 && !crew.paused ? "font-medium text-warn" : "text-muted"}`}
>
{crew.paused && <Pause aria-hidden size={12} />}
{status}
</span>
</button>
</li>
);
}

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 };
}
102 changes: 76 additions & 26 deletions app/src/features/crews/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 (
<div className="flex w-72 shrink-0 flex-col border-line border-r bg-panel">
<nav aria-label={t.crews.sidebar.label} className="flex min-h-0 flex-1 flex-col">
<div className="flex h-10 shrink-0 items-center justify-between pr-1.5 pl-4">
<h2 className="font-semibold text-muted text-xs uppercase tracking-[0.12em]">
{t.crews.sidebar.label}
<nav aria-label={words.label} className="flex min-h-0 flex-1 flex-col">
<div className="flex h-10 shrink-0 items-center gap-0.5 pr-1.5 pl-2">
<h2 className="min-w-0 flex-1">
<button
type="button"
aria-current={overview ? "page" : undefined}
title={words.showAll}
onClick={() => selectCrew(null)}
className={`rounded-md px-2 py-1 font-semibold text-xs uppercase tracking-[0.12em] transition-colors hover:bg-sunken hover:text-ink ${overview ? "bg-sunken text-ink" : "text-muted"}`}
>
{words.label}
</button>
</h2>
<Button
variant="ghost"
size="sm"
icon={allFolded ? ChevronsUpDown : ChevronsDownUp}
label={allFolded ? words.expandAll : words.collapseAll}
onClick={() => setAllCollapsed(allFolded ? [] : crews.map((crew) => crew.id))}
/>
<Button
variant="ghost"
size="sm"
Expand Down Expand Up @@ -60,31 +81,60 @@ function CrewEntry({ crew }: { crew: Crew }) {
const selected = useApp((state) => state.selectedCrewId === crew.id && !state.selectedBotId);
const unread = useApp((state) => unreadIn(state, crew.id));
const selectCrew = useApp((state) => state.selectCrew);
const openBotId = useApp((state) => state.selectedBotId);
const collapsed = useCollapsed(crew.id);
// Folded, the open conversation still shows, and a dot tells a bot waits.
const shown = collapsed ? bots.filter((bot) => bot.id === openBotId) : bots;
const waiting =
collapsed && bots.some((bot) => bot.state === "needs_approval" || bot.state === "auth_error");
const words = t.crews.sidebar;

return (
<li className="mt-2">
<button
type="button"
aria-current={selected ? "page" : undefined}
onClick={() => selectCrew(crew.id)}
className={`${row} h-8 gap-2 px-2.5 font-semibold text-sm ${selected ? "bg-sunken" : ""}`}
>
<span className="min-w-0 flex-1 truncate">{crew.name}</span>
{unread > 0 && (
<span className="grid h-[18px] min-w-[18px] place-items-center rounded-full bg-accent px-1 font-semibold text-[11px] text-canvas tabular-nums">
<span aria-hidden>{unread}</span>
<span className="sr-only">{t.crews.sidebar.unreadCount(unread)}</span>
</span>
)}
{crew.paused && (
<span className="flex items-center gap-1 font-medium text-quiet text-xs">
<Pause aria-hidden size={12} />
{t.crews.paused}
</span>
)}
</button>
<div className="relative flex items-center">
<button
type="button"
aria-expanded={!collapsed}
aria-label={collapsed ? words.expand(crew.name) : words.collapse(crew.name)}
title={collapsed ? words.expand(crew.name) : words.collapse(crew.name)}
onClick={() => setCollapsed(crew.id, !collapsed)}
className="absolute left-1 z-10 grid size-6 place-items-center rounded-md text-muted hover:bg-line hover:text-ink"
>
<ChevronDown
aria-hidden
size={14}
className={`transition-transform duration-150 ${collapsed ? "-rotate-90" : ""}`}
/>
</button>
<button
type="button"
aria-current={selected ? "page" : undefined}
onClick={() => selectCrew(crew.id)}
className={`${row} h-8 gap-2 pr-2.5 pl-8 font-semibold text-sm ${selected ? "bg-sunken" : ""}`}
>
<span className="min-w-0 flex-1 truncate">{crew.name}</span>
{waiting && (
<span className="flex shrink-0">
<span aria-hidden className="live-dot" style={{ background: "var(--warn)" }} />
<span className="sr-only">{words.waiting}</span>
</span>
)}
{unread > 0 && (
<span className="grid h-[18px] min-w-[18px] place-items-center rounded-full bg-accent px-1 font-semibold text-[11px] text-canvas tabular-nums">
<span aria-hidden>{unread}</span>
<span className="sr-only">{t.crews.sidebar.unreadCount(unread)}</span>
</span>
)}
{crew.paused && (
<span className="flex items-center gap-1 font-medium text-quiet text-xs">
<Pause aria-hidden size={12} />
{t.crews.paused}
</span>
)}
</button>
</div>
<ul>
{bots.map((bot) => (
{shown.map((bot) => (
<Conversation key={bot.id} bot={bot} crew={crew} />
))}
</ul>
Expand Down
87 changes: 87 additions & 0 deletions app/src/features/crews/collapse.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { act, cleanup, fireEvent, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { FakeBotloft } from "../../lib/fake";
import { crewOpened, openBot, renderApp, sidebar } from "../../test/app";
import { resetCollapsed } from "./collapsed";

afterEach(() => {
cleanup();
resetCollapsed();
});

const inSidebar = (name: string) =>
within(sidebar()).queryByRole("button", { name: new RegExp(`^${name},`) });

function twoCrews() {
const fake = new FakeBotloft();
const ops = fake.addCrew("Ops");
const scout = fake.addBot(ops.id, "Scout");
const writer = fake.addBot(ops.id, "Writer");
const docs = fake.addCrew("Docs");
const editor = fake.addBot(docs.id, "Editor");
for (const bot of [scout, writer, editor]) {
fake.setBotState(bot.id, "idle");
}
return { fake, ops, scout, writer, docs, editor };
}

describe("folding a crew in the sidebar", () => {
test("hides its bots, keeps the open one, and is remembered", async () => {
const { fake, ops, scout } = twoCrews();
renderApp(fake);
await crewOpened("Ops");
openBot("Writer");

fireEvent.click(within(sidebar()).getByRole("button", { name: "Fold Ops" }));
expect(inSidebar("Scout")).toBeNull();
// The open conversation stays in view.
expect(inSidebar("Writer")).not.toBeNull();
expect(inSidebar("Editor")).not.toBeNull();
expect(localStorage.getItem("botloft.collapsedCrews")).toContain(ops.id);

// A bot waiting for the owner shows on the folded crew.
act(() => fake.setBotState(scout.id, "needs_approval"));
expect(within(sidebar()).getByRole("button", { name: /^Ops/ }).textContent).toContain(
"a bot needs you",
);

const unfold = within(sidebar()).getByRole("button", { name: "Unfold Ops" });
expect(unfold.getAttribute("aria-expanded")).toBe("false");
fireEvent.click(unfold);
expect(inSidebar("Scout")).not.toBeNull();
});

test("one button folds every crew, and unfolds them again", async () => {
const { fake } = twoCrews();
renderApp(fake);
await crewOpened("Ops");

fireEvent.click(within(sidebar()).getByRole("button", { name: "Fold all crews" }));
expect(inSidebar("Scout")).toBeNull();
expect(inSidebar("Editor")).toBeNull();

fireEvent.click(within(sidebar()).getByRole("button", { name: "Unfold all crews" }));
expect(inSidebar("Scout")).not.toBeNull();
expect(inSidebar("Editor")).not.toBeNull();
});
});

describe("the page of all crews", () => {
test("opens from Crews on the left and leads to each crew", async () => {
const { fake, editor } = twoCrews();
fake.setBotState(editor.id, "busy");
renderApp(fake);
await crewOpened("Ops");

fireEvent.click(within(sidebar()).getByRole("button", { name: "Crews" }));
await crewOpened("Crews");
const page = screen.getByRole("region", { name: "Crews" });
expect(page.textContent).toContain("2 crews · 3 bots");
const docs = within(page).getByRole("button", { name: /^Docs/ });
expect(docs.textContent).toContain("1 working");
expect(within(page).getByRole("button", { name: /^Ops/ }).textContent).toContain("All quiet");

fireEvent.click(docs);
await crewOpened("Docs");
});
});
Loading
Loading