@@ -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 && (
+
+ )}
{crew.name}
{waiting && (
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/crewColor.test.tsx b/app/src/features/crews/crewColor.test.tsx
new file mode 100644
index 00000000..daf680c6
--- /dev/null
+++ b/app/src/features/crews/crewColor.test.tsx
@@ -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");
+ });
+});
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..65b2bb33 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: {
@@ -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",
diff --git a/app/src/i18n/es/crews.ts b/app/src/i18n/es/crews.ts
index cc3dd453..985d26c2 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`),
@@ -28,6 +35,9 @@ export const crews: Messages["crews"] = {
noBots: "Ningún bot todavía",
},
dialog: {
+ color: "Color",
+ noColor: "Sin color",
+ swatch: (color: string) => `Color ${color}`,
folder: "Carpeta de trabajo",
folderHint: "Donde los bots guardan lo que hacen. Puede ser una carpeta que ya usas.",
folderDefault: "Una carpeta nueva dentro de Botloft",
diff --git a/app/src/i18n/pt-BR/crews.ts b/app/src/i18n/pt-BR/crews.ts
index a3fb5c1c..a40739c4 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`),
@@ -28,6 +35,9 @@ export const crews: Messages["crews"] = {
noBots: "Nenhum bot ainda",
},
dialog: {
+ color: "Cor",
+ noColor: "Sem cor",
+ swatch: (color: string) => `Cor ${color}`,
folder: "Pasta de trabalho",
folderHint: "Onde os bots guardam o que fazem. Pode ser uma pasta que você já usa.",
folderDefault: "Uma pasta nova dentro do Botloft",
diff --git a/app/src/lib/fakeCrews.ts b/app/src/lib/fakeCrews.ts
index d71539f4..dafb5877 100644
--- a/app/src/lib/fakeCrews.ts
+++ b/app/src/lib/fakeCrews.ts
@@ -32,6 +32,7 @@ export function crewHandlers(fake: FakeBotloft): Pick {
workFolder: workFolder === undefined ? sharedFolder(slug) : checkFolder(workFolder),
workFolderChosen: workFolder !== undefined,
leadBotId: null,
+ color: null,
paused: false,
createdAt: fake.now,
archivedAt: null,
@@ -53,6 +54,11 @@ export function crewHandlers(fake: FakeBotloft): Pick {
crew.leadBotId = botId;
return fake.changedCrew(crew);
},
+ "crews.setColor": ({ crewId, color }) => {
+ const crew = fake.crew(crewId);
+ crew.color = color === null ? null : color.toUpperCase();
+ return fake.changedCrew(crew);
+ },
"crews.rename": ({ crewId, name }) => {
const crew = fake.crew(crewId);
crew.name = checkName(name);
diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts
index 5d38e1d4..dab73383 100644
--- a/app/src/lib/protocol.gen.ts
+++ b/app/src/lib/protocol.gen.ts
@@ -113,6 +113,10 @@ workFolderChosen: boolean,
* `null` when the crew has no chief.
*/
leadBotId: BotId | null, paused: boolean,
+/**
+ * Color `#RRGGBB` the owner gave the crew; `null` for none.
+ */
+color: string | null,
/**
* Unix time in milliseconds.
*/
@@ -400,6 +404,12 @@ lead?: NewLead, };
export type CrewsRenameParams = { crewId: CrewId, name: string, };
+export type CrewsSetColorParams = { crewId: CrewId,
+/**
+ * `#RRGGBB`; `null` clears the color.
+ */
+color: string | null, };
+
export type CrewsSetPausedParams = { crewId: CrewId, paused: boolean, };
export type CrewsSetWorkFolderParams = { crewId: CrewId,
@@ -2144,6 +2154,7 @@ export interface RpcMethods {
"crews.list": { params: undefined; result: Array };
"crews.create": { params: CrewsCreateParams; result: Crew };
"crews.rename": { params: CrewsRenameParams; result: Crew };
+ "crews.setColor": { params: CrewsSetColorParams; result: Crew };
"crews.setPaused": { params: CrewsSetPausedParams; result: Crew };
"crews.setWorkFolder": { params: CrewsSetWorkFolderParams; result: Crew };
"crews.setLead": { params: CrewsSetLeadParams; result: Crew };
diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs
index f32d55c6..46b6505e 100644
--- a/crates/botloft-core/src/protocol/export.rs
+++ b/crates/botloft-core/src/protocol/export.rs
@@ -144,6 +144,7 @@ fn export_bindings() {
out.decl::();
out.decl::();
out.decl::();
+ out.decl::();
out.decl::();
out.decl::();
out.decl::();
@@ -264,6 +265,11 @@ fn export_bindings() {
&out.name::(),
&crew,
);
+ out.method(
+ method::CREWS_SET_COLOR,
+ &out.name::(),
+ &crew,
+ );
out.method(
method::CREWS_SET_PAUSED,
&out.name::(),
diff --git a/crates/botloft-core/src/protocol/methods.rs b/crates/botloft-core/src/protocol/methods.rs
index b39cd1bd..f7434437 100644
--- a/crates/botloft-core/src/protocol/methods.rs
+++ b/crates/botloft-core/src/protocol/methods.rs
@@ -185,6 +185,15 @@ pub struct CrewsRenameParams {
pub name: String,
}
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+#[cfg_attr(test, derive(ts_rs::TS))]
+pub struct CrewsSetColorParams {
+ pub crew_id: CrewId,
+ /// `#RRGGBB`; `null` clears the color.
+ pub color: Option,
+}
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(test, derive(ts_rs::TS))]
diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs
index ce86bddd..d0daf516 100644
--- a/crates/botloft-core/src/protocol/mod.rs
+++ b/crates/botloft-core/src/protocol/mod.rs
@@ -114,6 +114,7 @@ pub mod method {
pub const CREWS_LIST: &str = "crews.list";
pub const CREWS_CREATE: &str = "crews.create";
pub const CREWS_RENAME: &str = "crews.rename";
+ pub const CREWS_SET_COLOR: &str = "crews.setColor";
pub const CREWS_SET_PAUSED: &str = "crews.setPaused";
pub const CREWS_SET_WORK_FOLDER: &str = "crews.setWorkFolder";
pub const CREWS_SET_LEAD: &str = "crews.setLead";
diff --git a/crates/botloft-core/src/protocol/model.rs b/crates/botloft-core/src/protocol/model.rs
index ab71c457..2b55a37b 100644
--- a/crates/botloft-core/src/protocol/model.rs
+++ b/crates/botloft-core/src/protocol/model.rs
@@ -23,6 +23,8 @@ pub struct Crew {
/// `null` when the crew has no chief.
pub lead_bot_id: Option,
pub paused: bool,
+ /// Color `#RRGGBB` the owner gave the crew; `null` for none.
+ pub color: Option,
/// Unix time in milliseconds.
pub created_at: i64,
/// Unix time in milliseconds; `null` while the crew is active.
diff --git a/crates/botloft-store/migrations/0025_crew_color.sql b/crates/botloft-store/migrations/0025_crew_color.sql
new file mode 100644
index 00000000..2dc5fc56
--- /dev/null
+++ b/crates/botloft-store/migrations/0025_crew_color.sql
@@ -0,0 +1,2 @@
+-- A crew's color (spec 15.1): `#RRGGBB` the owner picked, NULL for none.
+ALTER TABLE crews ADD COLUMN color TEXT;
diff --git a/crates/botloft-store/src/bots/tests.rs b/crates/botloft-store/src/bots/tests.rs
index b3da9fc7..61e1e64e 100644
--- a/crates/botloft-store/src/bots/tests.rs
+++ b/crates/botloft-store/src/bots/tests.rs
@@ -13,6 +13,7 @@ fn setup() -> (Store, CrewId) {
work_folder_chosen: false,
lead_bot_id: None,
paused: false,
+ color: None,
created_at: 1,
archived_at: None,
};
diff --git a/crates/botloft-store/src/crews.rs b/crates/botloft-store/src/crews.rs
index 10954d04..7a1dab04 100644
--- a/crates/botloft-store/src/crews.rs
+++ b/crates/botloft-store/src/crews.rs
@@ -6,7 +6,8 @@ use rusqlite::{OptionalExtension, Row, params};
use crate::{Result, Store, parse_column, unique_as_duplicate};
-const COLUMNS: &str = "id, name, slug, paused, created_at, archived_at, work_dir, lead_bot_id";
+const COLUMNS: &str =
+ "id, name, slug, paused, created_at, archived_at, work_dir, lead_bot_id, color";
/// A crew without a chosen folder comes back with an empty `work_folder`;
/// the daemon fills in the `shared` folder, which depends on its paths.
@@ -22,6 +23,7 @@ fn from_row(row: &Row<'_>) -> rusqlite::Result {
.get::<_, Option>(7)?
.and_then(|id| id.parse().ok()),
paused: row.get(3)?,
+ color: row.get(8)?,
created_at: row.get(4)?,
archived_at: row.get(5)?,
})
@@ -35,7 +37,9 @@ impl Store {
pub fn insert_crew(&self, crew: &Crew) -> Result<()> {
self.conn
.execute(
- &format!("INSERT INTO crews ({COLUMNS}) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)"),
+ &format!(
+ "INSERT INTO crews ({COLUMNS}) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)"
+ ),
params![
crew.id.as_str(),
crew.name,
@@ -44,7 +48,8 @@ impl Store {
crew.created_at,
crew.archived_at,
work_dir(crew),
- crew.lead_bot_id.as_ref().map(|id| id.as_str())
+ crew.lead_bot_id.as_ref().map(|id| id.as_str()),
+ crew.color
],
)
.map_err(|err| unique_as_duplicate(err, "crew slug"))?;
@@ -81,18 +86,19 @@ impl Store {
}
/// Saves the mutable fields: name, paused, archived_at, the chosen work
- /// folder and the chief.
+ /// folder, the chief and the color.
pub fn update_crew(&self, crew: &Crew) -> Result<()> {
self.conn.execute(
"UPDATE crews SET name = ?2, paused = ?3, archived_at = ?4, work_dir = ?5, \
- lead_bot_id = ?6 WHERE id = ?1",
+ lead_bot_id = ?6, color = ?7 WHERE id = ?1",
params![
crew.id.as_str(),
crew.name,
crew.paused,
crew.archived_at,
work_dir(crew),
- crew.lead_bot_id.as_ref().map(|id| id.as_str())
+ crew.lead_bot_id.as_ref().map(|id| id.as_str()),
+ crew.color
],
)?;
Ok(())
@@ -128,6 +134,7 @@ mod tests {
work_folder_chosen: false,
lead_bot_id: None,
paused: false,
+ color: None,
created_at,
archived_at: None,
}
diff --git a/crates/botloft-store/src/lib.rs b/crates/botloft-store/src/lib.rs
index 6634d699..333e54db 100644
--- a/crates/botloft-store/src/lib.rs
+++ b/crates/botloft-store/src/lib.rs
@@ -176,6 +176,7 @@ pub(crate) mod tests {
work_folder_chosen: false,
lead_bot_id: None,
paused: false,
+ color: None,
created_at: 0,
archived_at: None,
};
diff --git a/crates/botloft-store/src/migrate.rs b/crates/botloft-store/src/migrate.rs
index e88e0fde..e76e64de 100644
--- a/crates/botloft-store/src/migrate.rs
+++ b/crates/botloft-store/src/migrate.rs
@@ -31,6 +31,7 @@ const MIGRATIONS: &[(u32, &str)] = &[
(22, include_str!("../migrations/0022_crew_access_files.sql")),
(23, include_str!("../migrations/0023_plan_share.sql")),
(24, include_str!("../migrations/0024_mcp_servers.sql")),
+ (25, include_str!("../migrations/0025_crew_color.sql")),
];
/// Schema version after every migration has run.
diff --git a/crates/botloftd/src/rpc/dispatch.rs b/crates/botloftd/src/rpc/dispatch.rs
index 78af886d..9cb6da7b 100644
--- a/crates/botloftd/src/rpc/dispatch.rs
+++ b/crates/botloftd/src/rpc/dispatch.rs
@@ -30,6 +30,7 @@ pub fn dispatch(daemon: &Daemon, name: &str, params: Option) -> Result reply(crews::list(daemon)),
method::CREWS_CREATE => reply(crews::create(daemon, parse(params)?)),
method::CREWS_RENAME => reply(crews::rename(daemon, parse(params)?)),
+ method::CREWS_SET_COLOR => reply(crews::set_color(daemon, parse(params)?)),
method::CREWS_SET_PAUSED => reply(crews::set_paused(daemon, parse(params)?)),
method::CREWS_SET_WORK_FOLDER => reply(crews::set_work_folder(daemon, parse(params)?)),
method::CREWS_SET_LEAD => reply(lead::set_lead(daemon, parse(params)?)),
diff --git a/crates/botloftd/src/service/crews.rs b/crates/botloftd/src/service/crews.rs
index 5f7aa56e..79d106d3 100644
--- a/crates/botloftd/src/service/crews.rs
+++ b/crates/botloftd/src/service/crews.rs
@@ -1,9 +1,10 @@
//! `crews.*` operations.
+use botloft_core::avatar::normalize_color;
use botloft_core::ids::CrewId;
use botloft_core::protocol::{
- Crew, CrewIdParams, CrewsCreateParams, CrewsRenameParams, CrewsSetPausedParams,
- CrewsSetWorkFolderParams,
+ Crew, CrewIdParams, CrewsCreateParams, CrewsRenameParams, CrewsSetColorParams,
+ CrewsSetPausedParams, CrewsSetWorkFolderParams,
};
use botloft_core::{now_ms, slug, validate};
use botloft_store::Store;
@@ -48,6 +49,7 @@ pub fn create(daemon: &Daemon, params: CrewsCreateParams) -> ApiResult {
work_folder: chosen.unwrap_or_default(),
lead_bot_id: None,
paused: false,
+ color: None,
created_at: now_ms(),
archived_at: None,
};
@@ -82,6 +84,22 @@ pub fn rename(daemon: &Daemon, params: CrewsRenameParams) -> ApiResult {
Ok(changed(daemon, crew))
}
+/// Gives the crew a color, or clears it.
+pub fn set_color(daemon: &Daemon, params: CrewsSetColorParams) -> ApiResult {
+ let color = match params.color.as_deref() {
+ None => None,
+ Some(input) => Some(
+ normalize_color(input)
+ .ok_or_else(|| ApiError::validation("color must look like #RRGGBB"))?,
+ ),
+ };
+ let store = daemon.store();
+ let mut crew = active(&store, ¶ms.crew_id)?;
+ crew.color = color;
+ store.update_crew(&crew)?;
+ Ok(changed(daemon, crew))
+}
+
/// Moves the crew to another work folder, or back to `shared`. Each bot
/// restarts to reach it once nothing is in progress (spec 7.4).
pub fn set_work_folder(daemon: &Daemon, params: CrewsSetWorkFolderParams) -> ApiResult {
diff --git a/crates/botloftd/src/workspace/files.rs b/crates/botloftd/src/workspace/files.rs
index ce3a01b3..9d51c361 100644
--- a/crates/botloftd/src/workspace/files.rs
+++ b/crates/botloftd/src/workspace/files.rs
@@ -295,6 +295,7 @@ mod tests {
work_folder_chosen: false,
lead_bot_id: None,
paused: false,
+ color: None,
created_at: 0,
archived_at: None,
};
diff --git a/crates/botloftd/src/workspace/folder.rs b/crates/botloftd/src/workspace/folder.rs
index 57f2e453..a6dd6355 100644
--- a/crates/botloftd/src/workspace/folder.rs
+++ b/crates/botloftd/src/workspace/folder.rs
@@ -146,6 +146,7 @@ mod tests {
work_folder_chosen: work_folder.is_some(),
lead_bot_id: None,
paused: false,
+ color: None,
created_at: 0,
archived_at: None,
}
diff --git a/crates/botloftd/src/workspace/mod.rs b/crates/botloftd/src/workspace/mod.rs
index ef4521e9..b609a63d 100644
--- a/crates/botloftd/src/workspace/mod.rs
+++ b/crates/botloftd/src/workspace/mod.rs
@@ -145,6 +145,7 @@ mod tests {
work_folder_chosen: false,
lead_bot_id: None,
paused: false,
+ color: None,
created_at: 0,
archived_at: None,
};
@@ -243,6 +244,7 @@ mod tests {
work_folder_chosen: folder.is_some(),
lead_bot_id: None,
paused: false,
+ color: None,
created_at: 0,
archived_at: None,
};
diff --git a/crates/botloftd/tests/rpc_crews_bots.rs b/crates/botloftd/tests/rpc_crews_bots.rs
index 8c17bf40..4f363ff2 100644
--- a/crates/botloftd/tests/rpc_crews_bots.rs
+++ b/crates/botloftd/tests/rpc_crews_bots.rs
@@ -127,6 +127,29 @@ async fn updates_refresh_the_rules_and_keep_the_bot_memory() {
.expect("rename");
assert!(read(ws.join(".claude/rules/botloft.md")).contains("crew **Docs Team**"));
+ let colored = app
+ .call(
+ "crews.setColor",
+ json!({ "crewId": crew["id"], "color": "#5ec8ff" }),
+ )
+ .await
+ .expect("color");
+ assert_eq!(colored["color"], "#5EC8FF");
+ app.call(
+ "crews.setColor",
+ json!({ "crewId": crew["id"], "color": "blue" }),
+ )
+ .await
+ .expect_err("not a hex color");
+ let cleared = app
+ .call(
+ "crews.setColor",
+ json!({ "crewId": crew["id"], "color": null }),
+ )
+ .await
+ .expect("clear");
+ assert_eq!(cleared["color"], serde_json::Value::Null);
+
let paused = app
.call(
"bots.setPaused",
diff --git a/docs/spec.md b/docs/spec.md
index a0125a69..cb20ab62 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -682,6 +682,7 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co
| `crews.list` | | `Crew[]` |
| `crews.create` | `name, workFolder?, lead?` (`{name, role, instructions, model?}`: o chefe, 10.2) | `Crew` |
| `crews.rename` | `crewId, name` | `Crew` |
+| `crews.setColor` | `crewId, color` (`#RRGGBB`, ou `null` para tirar) | `Crew`; a cor aparece como um ponto ao lado do nome na lista de equipes |
| `crews.setPaused` | `crewId, paused` | `Crew` |
| `crews.setLead` | `crewId, botId` (ou `null`, sem chefe) | `Crew`; o chefe antigo e o novo reiniciam quando nada estiver em andamento (10.2) |
| `crews.setWorkFolder` | `crewId, workFolder` (caminho, ou `null` para voltar à `shared\`) | `Crew`; os bots reiniciam na pasta nova quando nada estiver em andamento (5) |
@@ -752,7 +753,7 @@ Pragmas: `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_time
| Tabela | Colunas principais |
|---|---|
-| `crews` | `id, name, slug, paused, work_dir, lead_bot_id, created_at, archived_at` (`work_dir` é a pasta escolhida; `NULL` usa a `shared`. `lead_bot_id` é o chefe, sem chave estrangeira: ele é gravado junto com a crew) |
+| `crews` | `id, name, slug, paused, work_dir, lead_bot_id, color, created_at, archived_at` (`work_dir` é a pasta escolhida; `NULL` usa a `shared`. `lead_bot_id` é o chefe, sem chave estrangeira: ele é gravado junto com a crew. `color` é o `#RRGGBB` que o dono escolheu, `NULL` sem cor) |
| `bots` | `id, crew_id, name, handle, slug, role, instructions, color, paused, permission_mode, model, model_in_use, effort, effort_default, token_hash, session_id, created_at, archived_at` (`effort_default` é o nível do próprio modelo, como o Claude Code disse: `low` a `max`, `none` ou `NULL`, 7.4) |
| `messages` | `id, crew_id, from_kind (owner/bot/system), from_bot_id, to_bot_id, kind (note/task/result/system), body, task_id, created_at`; `reply_item_id` e `reply_text` quando o dono respondeu a algo do chat (9.3; sem chave estrangeira, porque os itens saem antes das messages na exclusão, 7.6) |
| `attachments` | `id, message_id, name, media_type, size, path, created_at` |