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
46 changes: 46 additions & 0 deletions app/src/features/crews/CrewColorField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Check } from "lucide-react";
import { useT } from "../../i18n";
import { AVATAR_PALETTE } from "../../lib/protocol.gen";

/** Swatches for a crew's color, and a way to have none. */
export function CrewColorField({
value,
onChange,
}: {
value: string | null;
onChange(color: string | null): void;
}) {
const words = useT().crews.dialog;
const same = (a: string | null, b: string | null) => a?.toLowerCase() === b?.toLowerCase();
return (
<fieldset className="m-0 flex flex-col gap-1.5 border-0 p-0">
<legend className="mb-1.5 font-medium text-ink-soft text-sm">{words.color}</legend>
<div className="flex flex-wrap items-center gap-1.5">
<button
type="button"
aria-pressed={value === null}
onClick={() => onChange(null)}
className={`rounded-lg px-2 py-1 font-medium text-xs ${value === null ? "bg-sunken text-ink ring-2 ring-accent" : "text-muted hover:text-ink"}`}
>
{words.noColor}
</button>
{AVATAR_PALETTE.map((swatch) => {
const on = same(value, swatch);
return (
<button
key={swatch}
type="button"
aria-label={words.swatch(swatch)}
aria-pressed={on}
onClick={() => onChange(swatch)}
className={`relative grid size-7 place-items-center rounded-full ${on ? "ring-2 ring-accent ring-offset-2 ring-offset-panel" : ""}`}
style={{ background: swatch }}
>
{on && <Check aria-hidden size={14} className="text-black/70" />}
</button>
);
})}
</div>
</fieldset>
);
}
8 changes: 7 additions & 1 deletion app/src/features/crews/CrewDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { SelectField, TextArea, TextField } from "../../ui/Field";
import { notifyError } from "../../ui/toast";
import { useCatalog } from "../catalog/useCatalog";
import { addTemplateBots } from "./addTemplateBots";
import { CrewColorField } from "./CrewColorField";
import type { CrewTemplate } from "./crewTemplates";
import { PickedTemplate, TemplatePicker } from "./TemplatePicker";
import { WorkFolderField } from "./WorkFolderField";
Expand All @@ -22,6 +23,7 @@ export function CrewDialog({ crew, onClose }: { crew?: Crew; onClose(): void })
const putCrew = useApp((state) => state.putCrew);
const selectCrew = useApp((state) => state.selectCrew);
const [name, setName] = useState(crew?.name ?? "");
const [color, setColor] = useState<string | null>(crew?.color ?? null);
const [folder, setFolder] = useState<string | null>(null);
const [goal, setGoal] = useState("");
const [chiefModel, setChiefModel] = useState<BotModel>("default");
Expand Down Expand Up @@ -49,7 +51,7 @@ export function CrewDialog({ crew, onClose }: { crew?: Crew; onClose(): void })
setError(null);
try {
const d = t.crews.dialog;
const saved = crew
let saved = crew
? await api.call("crews.rename", { crewId: crew.id, name })
: await api.call("crews.create", {
name,
Expand All @@ -62,6 +64,9 @@ export function CrewDialog({ crew, onClose }: { crew?: Crew; onClose(): void })
...(chiefModel !== "default" && { model: chiefModel }),
},
});
if (crew && color !== crew.color) {
saved = await api.call("crews.setColor", { crewId: crew.id, color });
}
putCrew(saved);
if (!crew) {
selectCrew(saved.id);
Expand Down Expand Up @@ -134,6 +139,7 @@ export function CrewDialog({ crew, onClose }: { crew?: Crew; onClose(): void })
autoFocus
required
/>
{crew && <CrewColorField value={color} onChange={setColor} />}
{!crew && (
<>
<TextArea
Expand Down
20 changes: 18 additions & 2 deletions app/src/features/crews/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { type Messages, useT } from "../../i18n";
import { when } from "../../lib/format";
import { plainText } from "../../lib/plainText";
import type { Activity, Bot, Crew } from "../../lib/protocol.gen";
import { activityOf, botsOf, crewList } from "../../store/app";
import { activityOf, crewList } from "../../store/app";
import { useApp } from "../../store/context";
import { isUnread, unreadIn } from "../../store/seen";
import { CountBadge } from "../../ui/Badge";
Expand All @@ -22,6 +22,8 @@ import { CrewDialog } from "./CrewDialog";
import { setAllCollapsed, setCollapsed, useCollapsed, useCollapsedSet } from "./collapsed";
import { useCrewActions } from "./crewActions";
import { row } from "./SidebarPages";
import { StateFilters } from "./StateFilters";
import { filteredBots, useStateFilter } from "./stateFilter";

/** Crews as sections and their bots as conversations (spec 15.1). */
export function Sidebar() {
Expand Down Expand Up @@ -63,6 +65,7 @@ export function Sidebar() {
onClick={() => setCreating(true)}
/>
</div>
{crews.length > 0 && <StateFilters />}
<ul className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
{crews.map((crew) => (
<CrewEntry key={crew.id} crew={crew} />
Expand All @@ -76,7 +79,8 @@ export function Sidebar() {

function CrewEntry({ crew }: { crew: Crew }) {
const t = useT();
const bots = useApp(useShallow((state) => botsOf(state, crew.id)));
const filter = useStateFilter();
const bots = useApp(useShallow((state) => filteredBots(state, crew.id, filter)));
const selected = useApp((state) => state.selectedCrewId === crew.id && !state.selectedBotId);
const unread = useApp((state) => unreadIn(state, crew.id));
const selectCrew = useApp((state) => state.selectCrew);
Expand All @@ -92,6 +96,10 @@ function CrewEntry({ crew }: { crew: Crew }) {
collapsed && bots.some((bot) => bot.state === "needs_approval" || bot.state === "auth_error");
const words = t.crews.sidebar;

// Filtering, a crew with nothing to show steps aside.
if (filter !== "all" && bots.length === 0) {
return null;
}
return (
<li className="mt-2">
<div className="relative flex items-center">
Expand Down Expand Up @@ -119,6 +127,14 @@ function CrewEntry({ crew }: { crew: Crew }) {
}}
className={`${row} h-8 gap-2 pr-2.5 pl-8 font-semibold text-sm ${selected || menuAt ? "bg-sunken" : ""}`}
>
{crew.color && (
<span
aria-hidden
data-testid="crew-color"
className="size-2.5 shrink-0 rounded-full"
style={{ background: crew.color }}
/>
)}
<span className="min-w-0 flex-1 truncate">{crew.name}</span>
{waiting && (
<span className="flex shrink-0">
Expand Down
48 changes: 48 additions & 0 deletions app/src/features/crews/StateFilters.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { useShallow } from "zustand/react/shallow";
import { useT } from "../../i18n";
import { useApp } from "../../store/context";
import {
filterCounts,
STATE_FILTERS,
type StateFilter,
setStateFilter,
useStateFilter,
} from "./stateFilter";

/** Chips above the conversation list: all, needs you, working, unread. */
export function StateFilters() {
const t = useT();
const words = t.crews.sidebar.filters;
const active = useStateFilter();
const counts = useApp(useShallow(filterCounts));
const label: Record<StateFilter, string> = {
all: words.all,
needs: words.needs,
working: words.working,
unread: words.unread,
};
return (
<fieldset
aria-label={words.label}
className="m-0 flex min-w-0 flex-wrap gap-1 border-0 p-0 px-2 pb-1.5"
>
{STATE_FILTERS.map((filter) => {
const on = filter === active;
return (
<button
key={filter}
type="button"
aria-pressed={on}
onClick={() => setStateFilter(filter)}
className={`flex items-center gap-1 rounded-full px-2 py-0.5 font-medium text-xs transition-colors ${on ? "bg-ink text-canvas" : "bg-sunken text-muted hover:text-ink"}`}
>
{label[filter]}
{counts[filter] > 0 && (
<span className="tabular-nums opacity-70">{counts[filter]}</span>
)}
</button>
);
})}
</fieldset>
);
}
31 changes: 31 additions & 0 deletions app/src/features/crews/crewColor.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { cleanup, fireEvent, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { FakeBotloft } from "../../lib/fake";
import { crewOpened, renderApp, sidebar } from "../../test/app";

afterEach(cleanup);

describe("a crew's color", () => {
test("the owner picks one in the rename dialog and it shows beside the crew", async () => {
const fake = new FakeBotloft();
const crew = fake.addCrew("Ops");
fake.addBot(crew.id, "Scout");
renderApp(fake);
await crewOpened("Ops");
expect(within(sidebar()).queryByTestId("crew-color")).toBeNull();

fireEvent.contextMenu(within(sidebar()).getByRole("button", { name: /^Ops/ }), {
clientX: 100,
clientY: 100,
});
const menu = screen.getByRole("menu", { name: "Actions for Ops" });
fireEvent.click(within(menu).getByRole("menuitem", { name: "Rename" }));
const dialog = await screen.findByRole("dialog");
fireEvent.click(within(dialog).getByRole("button", { name: "Color #5EC8FF" }));
fireEvent.click(within(dialog).getByRole("button", { name: "Rename" }));

const dot = await within(sidebar()).findByTestId("crew-color");
expect(dot.style.background).toMatch(/#5ec8ff|rgb\(94, 200, 255\)/i);
expect(fake.crew(crew.id).color).toBe("#5EC8FF");
});
});
42 changes: 42 additions & 0 deletions app/src/features/crews/stateFilter.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { act, cleanup, fireEvent, within } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { FakeBotloft } from "../../lib/fake";
import { crewOpened, renderApp, sidebar } from "../../test/app";
import { resetStateFilter } from "./stateFilter";

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

describe("state filters in the conversation list", () => {
test("a chip narrows the list to the bots in that state, with counts", async () => {
const fake = new FakeBotloft();
const ops = fake.addCrew("Ops");
const calm = fake.addCrew("Calm");
const scout = fake.addBot(ops.id, "Scout");
const writer = fake.addBot(ops.id, "Writer");
const idle = fake.addBot(calm.id, "Idle one");
fake.setBotState(scout.id, "busy");
fake.setBotState(writer.id, "needs_approval");
fake.setBotState(idle.id, "idle");
renderApp(fake);
await crewOpened("Ops");

const list = within(sidebar());
expect(list.getByRole("button", { name: /^Working\s*1$/ })).toBeTruthy();
expect(list.getByRole("button", { name: /^All\s*3$/ })).toBeTruthy();

fireEvent.click(list.getByRole("button", { name: /^Needs you/ }));
expect(list.queryByRole("button", { name: /^Scout,/ })).toBeNull();
expect(list.getByRole("button", { name: /^Writer,/ })).toBeTruthy();
// A crew with nothing in that state steps aside.
expect(list.queryByText("Calm")).toBeNull();

act(() => fake.setBotState(scout.id, "needs_approval"));
expect(list.getByRole("button", { name: /^Scout,/ })).toBeTruthy();

fireEvent.click(list.getByRole("button", { name: /^All/ }));
expect(list.getByText("Calm")).toBeTruthy();
});
});
77 changes: 77 additions & 0 deletions app/src/features/crews/stateFilter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// The conversation list's state filter: all, needs you, working, unread. It
// lasts for this window only; a filter left on across restarts would look
// like missing bots.

import { useSyncExternalStore } from "react";
import type { Bot } from "../../lib/protocol.gen";
import type { AppState } from "../../store/app";
import { botsOf, crewList } from "../../store/app";
import { isUnread } from "../../store/seen";

export const STATE_FILTERS = ["all", "needs", "working", "unread"] as const;
export type StateFilter = (typeof STATE_FILTERS)[number];

let current: StateFilter = "all";
const listeners = new Set<() => void>();

export function setStateFilter(next: StateFilter): void {
if (next === current) {
return;
}
current = next;
for (const listener of listeners) {
listener();
}
}

const subscribe = (listener: () => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
};

export function useStateFilter(): StateFilter {
return useSyncExternalStore(subscribe, () => current);
}

/** Back to all bots, for tests. */
export function resetStateFilter(): void {
setStateFilter("all");
}

/** Whether a bot belongs under `filter`. Unread is passed in: it lives in the store. */
export function matchesFilter(filter: StateFilter, bot: Bot, unread: boolean): boolean {
switch (filter) {
case "needs":
return bot.state === "needs_approval" || bot.state === "auth_error";
case "working":
return bot.state === "busy" || bot.state === "launching";
case "unread":
return unread;
default:
return true;
}
}

/** The bots of a crew that pass `filter`. */
export function filteredBots(state: AppState, crewId: string, filter: StateFilter): Bot[] {
const bots = botsOf(state, crewId);
return filter === "all"
? bots
: bots.filter((bot) => matchesFilter(filter, bot, isUnread(state, bot.id)));
}

/** How many bots each filter would show, across every crew. */
export function filterCounts(state: AppState): Record<StateFilter, number> {
const counts: Record<StateFilter, number> = { all: 0, needs: 0, working: 0, unread: 0 };
for (const crew of crewList(state)) {
for (const bot of botsOf(state, crew.id)) {
const unread = isUnread(state, bot.id);
for (const filter of STATE_FILTERS) {
if (matchesFilter(filter, bot, unread)) {
counts[filter] += 1;
}
}
}
}
return counts;
}
10 changes: 10 additions & 0 deletions app/src/i18n/en/crews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ export const crews = {
expand: (crew: string) => `Unfold ${crew}`,
/** Beside a folded crew's name, when one of its bots needs the owner. */
waiting: "a bot needs you",
filters: {
label: "Filter conversations",
all: "All",
needs: "Needs you",
working: "Working",
unread: "Unread",
},
},
/** The page of all crews, opened from "Crews" on the left. */
overview: {
Expand All @@ -36,6 +43,9 @@ export const crews = {
noBots: "No bots yet",
},
dialog: {
color: "Color",
noColor: "No color",
swatch: (color: string) => `Color ${color}`,
folder: "Work folder",
folderHint: "Where the bots put what they make. It can be a folder you already use.",
folderDefault: "A new folder inside Botloft",
Expand Down
Loading
Loading