From 2c7df0d80d451d15742baa696e488b93e7adec58 Mon Sep 17 00:00:00 2001
From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com>
Date: Fri, 9 Oct 2026 13:25:47 -0300
Subject: [PATCH 1/2] feat(app): state filters in the conversation list
Chips for all, needs you, working and unread, with counts; a crew with
nothing in the chosen state steps aside. Window-only, not remembered.
---
app/src/features/crews/Sidebar.tsx | 12 +++-
app/src/features/crews/StateFilters.tsx | 48 +++++++++++++
app/src/features/crews/stateFilter.test.tsx | 42 +++++++++++
app/src/features/crews/stateFilter.ts | 77 +++++++++++++++++++++
app/src/i18n/en/crews.ts | 7 ++
app/src/i18n/es/crews.ts | 7 ++
app/src/i18n/pt-BR/crews.ts | 7 ++
7 files changed, 198 insertions(+), 2 deletions(-)
create mode 100644 app/src/features/crews/StateFilters.tsx
create mode 100644 app/src/features/crews/stateFilter.test.tsx
create mode 100644 app/src/features/crews/stateFilter.ts
diff --git a/app/src/features/crews/Sidebar.tsx b/app/src/features/crews/Sidebar.tsx
index ec59085c..c3d0c8f8 100644
--- a/app/src/features/crews/Sidebar.tsx
+++ b/app/src/features/crews/Sidebar.tsx
@@ -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";
@@ -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() {
@@ -63,6 +65,7 @@ export function Sidebar() {
onClick={() => setCreating(true)}
/>
+ {crews.length > 0 && }
{crews.map((crew) => (
@@ -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);
@@ -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 (
-
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`),
From 688149b92858a6a6fc5d5de523c044f4ae0f2938 Mon Sep 17 00:00:00 2001
From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com>
Date: Fri, 9 Oct 2026 13:42:12 -0300
Subject: [PATCH 2/2] feat(crews): a color for each crew
crews.setColor stores a #RRGGBB (migration 0025); the rename dialog has
swatches and the crew list shows a dot beside the name.
---
app/src/features/crews/CrewColorField.tsx | 46 +++++++++++++++++++
app/src/features/crews/CrewDialog.tsx | 8 +++-
app/src/features/crews/Sidebar.tsx | 8 ++++
app/src/features/crews/crewColor.test.tsx | 31 +++++++++++++
app/src/i18n/en/crews.ts | 3 ++
app/src/i18n/es/crews.ts | 3 ++
app/src/i18n/pt-BR/crews.ts | 3 ++
app/src/lib/fakeCrews.ts | 6 +++
app/src/lib/protocol.gen.ts | 11 +++++
crates/botloft-core/src/protocol/export.rs | 6 +++
crates/botloft-core/src/protocol/methods.rs | 9 ++++
crates/botloft-core/src/protocol/mod.rs | 1 +
crates/botloft-core/src/protocol/model.rs | 2 +
.../migrations/0025_crew_color.sql | 2 +
crates/botloft-store/src/bots/tests.rs | 1 +
crates/botloft-store/src/crews.rs | 19 +++++---
crates/botloft-store/src/lib.rs | 1 +
crates/botloft-store/src/migrate.rs | 1 +
crates/botloftd/src/rpc/dispatch.rs | 1 +
crates/botloftd/src/service/crews.rs | 22 ++++++++-
crates/botloftd/src/workspace/files.rs | 1 +
crates/botloftd/src/workspace/folder.rs | 1 +
crates/botloftd/src/workspace/mod.rs | 2 +
crates/botloftd/tests/rpc_crews_bots.rs | 23 ++++++++++
docs/spec.md | 3 +-
25 files changed, 204 insertions(+), 10 deletions(-)
create mode 100644 app/src/features/crews/CrewColorField.tsx
create mode 100644 app/src/features/crews/crewColor.test.tsx
create mode 100644 crates/botloft-store/migrations/0025_crew_color.sql
diff --git a/app/src/features/crews/CrewColorField.tsx b/app/src/features/crews/CrewColorField.tsx
new file mode 100644
index 00000000..93b5ee70
--- /dev/null
+++ b/app/src/features/crews/CrewColorField.tsx
@@ -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 (
+
+ );
+}
diff --git a/app/src/features/crews/CrewDialog.tsx b/app/src/features/crews/CrewDialog.tsx
index a6436cfa..e572e9e7 100644
--- a/app/src/features/crews/CrewDialog.tsx
+++ b/app/src/features/crews/CrewDialog.tsx
@@ -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";
@@ -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(crew?.color ?? null);
const [folder, setFolder] = useState(null);
const [goal, setGoal] = useState("");
const [chiefModel, setChiefModel] = useState("default");
@@ -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,
@@ -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);
@@ -134,6 +139,7 @@ export function CrewDialog({ crew, onClose }: { crew?: Crew; onClose(): void })
autoFocus
required
/>
+ {crew && }
{!crew && (
<>