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
71 changes: 71 additions & 0 deletions app/src/features/crews/AutoModeSwitch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { useId } from "react";
import { useT } from "../../i18n";
import { Switch } from "../../ui/Switch";

/**
* The crew's automatic mode (spec 7.4): on, every agent of the crew works
* without asking at each step, the ones it has and the ones that join later.
* `card` is the form version, with its explanation; the other is the compact
* one for the crew's header.
*/
export function AutoModeSwitch({
checked,
onChange,
card = false,
disabled,
}: {
checked: boolean;
onChange(checked: boolean): void;
card?: boolean;
disabled?: boolean | undefined;
}) {
const t = useT().crews.autoMode;
const labelId = useId();
const hintId = useId();
if (!card) {
return (
<div
title={t.hint}
className="flex shrink-0 items-center gap-2 rounded-xl border border-line px-2.5 py-1.5 text-sm"
>
<span id={labelId} className="font-medium">
{t.label}
</span>
<Switch
checked={checked}
onChange={onChange}
labelledBy={labelId}
describedBy={hintId}
disabled={disabled}
/>
<span id={hintId} className="sr-only">
{t.hint}
</span>
</div>
);
}
return (
<div
className={`flex items-start gap-3 rounded-xl border px-3.5 py-3 ${
checked ? "border-accent/45 bg-accent/8" : "border-line"
}`}
>
<div className="min-w-0 flex-1">
<p id={labelId} className="font-medium text-sm">
{t.label}
</p>
<p id={hintId} className="mt-0.5 text-muted text-sm">
{t.hint}
</p>
</div>
<Switch
checked={checked}
onChange={onChange}
labelledBy={labelId}
describedBy={hintId}
disabled={disabled}
className="mt-0.5"
/>
</div>
);
}
4 changes: 4 additions & 0 deletions app/src/features/crews/CrewDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Dialog } from "../../ui/Dialog";
import { SelectField, TextArea, TextField } from "../../ui/Field";
import { notifyError } from "../../ui/toast";
import { useCatalog } from "../catalog/useCatalog";
import { AutoModeSwitch } from "./AutoModeSwitch";
import { addTemplateBots } from "./addTemplateBots";
import { CrewColorField } from "./CrewColorField";
import type { CrewTemplate } from "./crewTemplates";
Expand Down Expand Up @@ -39,6 +40,7 @@ export function CrewDialog({
);
const [color, setColor] = useState<string | null>(crew?.color ?? null);
const [folder, setFolder] = useState<string | null>(null);
const [autoMode, setAutoMode] = useState(false);
const [goal, setGoal] = useState(start ? t.crewTemplates.items[start.id].goal : "");
const [chiefModel, setChiefModel] = useState<BotModel>("default");
const selectBot = useApp((state) => state.selectBot);
Expand Down Expand Up @@ -70,6 +72,7 @@ export function CrewDialog({
: await api.call("crews.create", {
name,
...(folder && { workFolder: folder }),
...(autoMode && { autoMode: true }),
// Every new crew starts with its chief (spec 10.2).
lead: {
name: d.chiefName,
Expand Down Expand Up @@ -165,6 +168,7 @@ export function CrewDialog({
placeholder={t.crews.dialog.goalPlaceholder}
hint={t.crews.dialog.goalHint}
/>
<AutoModeSwitch card checked={autoMode} onChange={setAutoMode} />
<WorkFolderField value={folder} onChange={setFolder} />
<SelectField
label={t.crews.dialog.chiefModel}
Expand Down
2 changes: 2 additions & 0 deletions app/src/features/crews/CrewView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Composer } from "../messages/Composer";
import { Timeline } from "../messages/Timeline";
import { CrewRoutines } from "../routines/RoutineList";
import { TaskList } from "../tasks/TaskList";
import { AutoModeSwitch } from "./AutoModeSwitch";
import { CrewBots } from "./CrewBots";
import { useCrewActions } from "./crewActions";

Expand Down Expand Up @@ -56,6 +57,7 @@ ${words.openFolderHint}`}
</p>
</div>
<CallPills calls={calls} className="max-w-[45%] justify-end" />
<AutoModeSwitch checked={crew.autoMode} onChange={actions.setAutoMode} />
<Button title={t.catalog.openHint} onClick={actions.agency.onSelect}>
{actions.agency.label}
</Button>
Expand Down
74 changes: 74 additions & 0 deletions app/src/features/crews/autoMode.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { FakeBotloft } from "../../lib/fake";
import { crewOpened, openNewCrewForm, renderApp } from "../../test/app";

afterEach(cleanup);

describe("the crew's automatic mode", () => {
test("the new-crew form has the switch, and the crew is created in automatic mode", async () => {
const fake = new FakeBotloft();
fake.addCrew("Ops");
renderApp(fake);
await crewOpened("Ops");

const dialog = await openNewCrewForm();
fireEvent.change(within(dialog).getByLabelText("Name"), { target: { value: "Sales" } });
const toggle = within(dialog).getByRole("switch", { name: "Automatic mode" });
expect(toggle.getAttribute("aria-checked")).toBe("false");
fireEvent.click(toggle);
fireEvent.click(within(dialog).getByRole("button", { name: "Create crew" }));

await crewOpened("Chief");
const create = fake.calls.find((call) => call.method === "crews.create");
expect(create?.params).toMatchObject({ name: "Sales", autoMode: true });
const crew = [...fake.crews.values()].find((item) => item.name === "Sales");
expect(crew?.autoMode).toBe(true);
const chief = [...fake.bots.values()].find((bot) => bot.crewId === crew?.id);
expect(chief?.permissionMode).toBe("auto");
});

test("a crew made without it stays manual", async () => {
const fake = new FakeBotloft();
fake.addCrew("Ops");
renderApp(fake);
await crewOpened("Ops");

const dialog = await openNewCrewForm();
fireEvent.change(within(dialog).getByLabelText("Name"), { target: { value: "Plain" } });
fireEvent.click(within(dialog).getByRole("button", { name: "Create crew" }));

await crewOpened("Chief");
const create = fake.calls.find((call) => call.method === "crews.create");
expect(create?.params).not.toHaveProperty("autoMode");
});

test("the header switch turns the whole crew to automatic, and keeps it for new agents", async () => {
const fake = new FakeBotloft();
const crew = fake.addCrew("Ops");
fake.addBot(crew.id, "Scout");
renderApp(fake);
await crewOpened("Ops");

fireEvent.click(screen.getByRole("switch", { name: "Automatic mode" }));
await waitFor(() =>
expect(fake.calls.some((call) => call.method === "crews.setAutoMode")).toBe(true),
);
await waitFor(() =>
expect(
screen.getByRole("switch", { name: "Automatic mode" }).getAttribute("aria-checked"),
).toBe("true"),
);
for (const bot of fake.activeBots(crew.id)) {
expect(bot.permissionMode).toBe("auto");
}

const added = await fake.call("bots.create", {
crewId: crew.id,
name: "Later",
role: "Helps",
instructions: "",
});
expect(added.permissionMode).toBe("auto");
});
});
8 changes: 8 additions & 0 deletions app/src/features/crews/crewActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export interface CrewActions {
pause: MenuItem & { icon: LucideIcon };
/** Opens the work folder in the system's file manager. */
openFolder(): void;
/** Turns the crew's automatic mode on or off (spec 7.4). */
setAutoMode(enabled: boolean): void;
/** Rename, the folder, archive and delete. */
items: MenuItem[];
/** The dialog an action opened, `null` while none is. */
Expand Down Expand Up @@ -68,6 +70,11 @@ export function useCrewActions(crew: Crew): CrewActions {
putCrew(await api.call("crews.setPaused", { crewId: crew.id, paused })),
);

const setAutoMode = (enabled: boolean) =>
attempt(t.crews.autoMode.failed, async () =>
putCrew(await api.call("crews.setAutoMode", { crewId: crew.id, enabled })),
);

let dialogs: ReactNode | null = null;
if (open === "bot") {
dialogs = <BotDialog crewId={crew.id} onClose={close} />;
Expand Down Expand Up @@ -114,6 +121,7 @@ export function useCrewActions(crew: Crew): CrewActions {
newBot: { label: t.crews.newBot, icon: Plus, onSelect: () => setOpen("bot") },
agency: { label: t.catalog.open, onSelect: () => setOpen("agency") },
openFolder,
setAutoMode,
pause: crew.paused
? { label: words.resume, icon: Play, onSelect: () => setPaused(false) }
: { label: words.pause, icon: Pause, onSelect: () => setPaused(true) },
Expand Down
6 changes: 6 additions & 0 deletions app/src/i18n/en/crews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ export const crews = {
newCrew: "New crew",
newBot: "New agent",
rename: "Rename",
/** The crew's automatic mode (spec 7.4): a switch in the new-crew form and in the crew's header. */
autoMode: {
label: "Automatic mode",
hint: "Every agent in this crew works without asking at each step, including the ones you add later. Anything risky still comes to you.",
failed: "Could not change the automatic mode",
},
paused: "Paused",
sidebar: {
/** The right-click menu of a crew in the list. */
Expand Down
6 changes: 6 additions & 0 deletions app/src/i18n/es/crews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ export const crews: Messages["crews"] = {
newCrew: "Nuevo equipo",
newBot: "Nuevo agente",
rename: "Renombrar",
/** The crew's automatic mode (spec 7.4): a switch in the new-crew form and in the crew's header. */
autoMode: {
label: "Modo automático",
hint: "Todos los agentes de este equipo trabajan sin preguntar en cada paso, incluidos los que añadas después. Lo que sea arriesgado sigue llegando a ti.",
failed: "No se pudo cambiar el modo automático",
},
paused: "En pausa",
sidebar: {
menuOf: (crew) => `Acciones de ${crew}`,
Expand Down
6 changes: 6 additions & 0 deletions app/src/i18n/pt-BR/crews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ export const crews: Messages["crews"] = {
newCrew: "Nova equipe",
newBot: "Novo agente",
rename: "Renomear",
/** The crew's automatic mode (spec 7.4): a switch in the new-crew form and in the crew's header. */
autoMode: {
label: "Modo automático",
hint: "Todos os agentes desta equipe trabalham sem perguntar a cada passo, inclusive os que você adicionar depois. O que for arriscado ainda chega até você.",
failed: "Não foi possível mudar o modo automático",
},
paused: "Pausada",
sidebar: {
menuOf: (crew) => `Ações de ${crew}`,
Expand Down
2 changes: 1 addition & 1 deletion app/src/lib/fakeBots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export function botHandlers(fake: FakeBotloft): Pick<Handlers, BotMethods> {
instructions,
color: color ?? AVATAR_PALETTE[index % AVATAR_PALETTE.length] ?? "#FF7A59",
paused: false,
permissionMode: "default",
permissionMode: crew.autoMode ? "auto" : "default",
agent: agent ?? "claude",
agentModel: null,
allowedCommands: [],
Expand Down
16 changes: 15 additions & 1 deletion app/src/lib/fakeCrews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ function checkFolder(folder: string): string {
export function crewHandlers(fake: FakeBotloft): Pick<Handlers, CrewMethods> {
return {
"crews.list": () => [...fake.crews.values()].filter((crew) => crew.archivedAt === null),
"crews.create": ({ name, workFolder, lead }) => {
"crews.create": ({ name, workFolder, lead, autoMode }) => {
const checked = checkName(name);
const slug = slugify(checked, "crew");
const crew: Crew = {
Expand All @@ -34,6 +34,7 @@ export function crewHandlers(fake: FakeBotloft): Pick<Handlers, CrewMethods> {
leadBotId: null,
color: null,
paused: false,
autoMode: autoMode === true,
createdAt: fake.now,
archivedAt: null,
};
Expand Down Expand Up @@ -69,6 +70,19 @@ export function crewHandlers(fake: FakeBotloft): Pick<Handlers, CrewMethods> {
crew.paused = paused;
return fake.changedCrew(crew);
},
"crews.setAutoMode": ({ crewId, enabled }) => {
const crew = fake.crew(crewId);
crew.autoMode = enabled;
if (enabled) {
for (const bot of fake.activeBots(crewId)) {
if (bot.permissionMode !== "auto") {
bot.permissionMode = "auto";
fake.changedBot(bot);
}
}
}
return fake.changedCrew(crew);
},
"crews.setWorkFolder": ({ crewId, workFolder }) => {
const crew = fake.crew(crewId);
crew.workFolder = workFolder === null ? sharedFolder(crew.slug) : checkFolder(workFolder);
Expand Down
14 changes: 13 additions & 1 deletion app/src/lib/protocol.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,11 @@ workFolderChosen: boolean,
* `null` when the crew has no chief.
*/
leadBotId: BotId | null, paused: boolean,
/**
* Whether every bot of the crew runs in automatic mode, the ones it has
* and the ones that join later (spec 7.4).
*/
autoMode: boolean,
/**
* Color `#RRGGBB` the owner gave the crew; `null` for none.
*/
Expand Down Expand Up @@ -439,7 +444,11 @@ workFolder?: string,
/**
* The chief, created with the crew (spec 10.2).
*/
lead?: NewLead, };
lead?: NewLead,
/**
* Starts the crew with every bot in automatic mode; off when absent.
*/
autoMode?: boolean, };

export type CrewsRenameParams = { crewId: CrewId, name: string, };

Expand All @@ -451,6 +460,8 @@ color: string | null, };

export type CrewsSetPausedParams = { crewId: CrewId, paused: boolean, };

export type CrewsSetAutoModeParams = { crewId: CrewId, enabled: boolean, };

export type CrewsSetWorkFolderParams = { crewId: CrewId,
/**
* `null` goes back to the crew's `shared` folder.
Expand Down Expand Up @@ -2269,6 +2280,7 @@ export interface RpcMethods {
"crews.rename": { params: CrewsRenameParams; result: Crew };
"crews.setColor": { params: CrewsSetColorParams; result: Crew };
"crews.setPaused": { params: CrewsSetPausedParams; result: Crew };
"crews.setAutoMode": { params: CrewsSetAutoModeParams; result: Crew };
"crews.setWorkFolder": { params: CrewsSetWorkFolderParams; result: Crew };
"crews.setLead": { params: CrewsSetLeadParams; result: Crew };
"crews.archive": { params: CrewIdParams; result: Crew };
Expand Down
6 changes: 6 additions & 0 deletions crates/botloft-core/src/protocol/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ fn export_bindings() {
out.decl::<CrewsRenameParams>();
out.decl::<CrewsSetColorParams>();
out.decl::<CrewsSetPausedParams>();
out.decl::<CrewsSetAutoModeParams>();
out.decl::<CrewsSetWorkFolderParams>();
out.decl::<NewLead>();
out.decl::<CrewsSetLeadParams>();
Expand Down Expand Up @@ -287,6 +288,11 @@ fn export_bindings() {
&out.name::<CrewsSetPausedParams>(),
&crew,
);
out.method(
method::CREWS_SET_AUTO_MODE,
&out.name::<CrewsSetAutoModeParams>(),
&crew,
);
out.method(
method::CREWS_SET_WORK_FOLDER,
&out.name::<CrewsSetWorkFolderParams>(),
Expand Down
12 changes: 12 additions & 0 deletions crates/botloft-core/src/protocol/methods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,10 @@ pub struct CrewsCreateParams {
#[serde(default)]
#[cfg_attr(test, ts(optional))]
pub lead: Option<NewLead>,
/// Starts the crew with every bot in automatic mode; off when absent.
#[serde(default)]
#[cfg_attr(test, ts(optional))]
pub auto_mode: Option<bool>,
}

/// The bot that leads a new crew. The app writes its name and role in the
Expand Down Expand Up @@ -245,6 +249,14 @@ pub struct CrewsSetPausedParams {
pub paused: bool,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(test, derive(ts_rs::TS))]
pub struct CrewsSetAutoModeParams {
pub crew_id: CrewId,
pub enabled: bool,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(test, derive(ts_rs::TS))]
Expand Down
Loading
Loading