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
12 changes: 10 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
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>
);
}
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;
}
7 changes: 7 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 Down
7 changes: 7 additions & 0 deletions app/src/i18n/es/crews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ export const crews: Messages["crews"] = {
collapse: (crew: string) => `Contraer ${crew}`,
expand: (crew: string) => `Expandir ${crew}`,
waiting: "un bot te necesita",
filters: {
label: "Filtrar conversaciones",
all: "Todas",
needs: "Te necesitan",
working: "Trabajando",
unread: "Sin leer",
},
},
overview: {
count: (count: number) => (count === 1 ? "1 equipo" : `${count} equipos`),
Expand Down
7 changes: 7 additions & 0 deletions app/src/i18n/pt-BR/crews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ export const crews: Messages["crews"] = {
collapse: (crew: string) => `Recolher ${crew}`,
expand: (crew: string) => `Expandir ${crew}`,
waiting: "um bot precisa de você",
filters: {
label: "Filtrar conversas",
all: "Todas",
needs: "Precisam de você",
working: "Trabalhando",
unread: "Não lidas",
},
},
overview: {
count: (count: number) => (count === 1 ? "1 equipe" : `${count} equipes`),
Expand Down
Loading