diff --git a/app/src/features/crews/StateFilters.tsx b/app/src/features/crews/StateFilters.tsx
new file mode 100644
index 00000000..2809ee9d
--- /dev/null
+++ b/app/src/features/crews/StateFilters.tsx
@@ -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 = {
+ all: words.all,
+ needs: words.needs,
+ working: words.working,
+ unread: words.unread,
+ };
+ return (
+
+ );
+}
diff --git a/app/src/features/crews/stateFilter.test.tsx b/app/src/features/crews/stateFilter.test.tsx
new file mode 100644
index 00000000..9bd2720a
--- /dev/null
+++ b/app/src/features/crews/stateFilter.test.tsx
@@ -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();
+ });
+});
diff --git a/app/src/features/crews/stateFilter.ts b/app/src/features/crews/stateFilter.ts
new file mode 100644
index 00000000..84b561fc
--- /dev/null
+++ b/app/src/features/crews/stateFilter.ts
@@ -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 {
+ const counts: Record = { 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;
+}
diff --git a/app/src/i18n/en/crews.ts b/app/src/i18n/en/crews.ts
index d0a0e45e..1aca287e 100644
--- a/app/src/i18n/en/crews.ts
+++ b/app/src/i18n/en/crews.ts
@@ -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: {
diff --git a/app/src/i18n/es/crews.ts b/app/src/i18n/es/crews.ts
index cc3dd453..6930bbd7 100644
--- a/app/src/i18n/es/crews.ts
+++ b/app/src/i18n/es/crews.ts
@@ -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`),
diff --git a/app/src/i18n/pt-BR/crews.ts b/app/src/i18n/pt-BR/crews.ts
index a3fb5c1c..d8ebecb1 100644
--- a/app/src/i18n/pt-BR/crews.ts
+++ b/app/src/i18n/pt-BR/crews.ts
@@ -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`),