Skip to content

Commit 6c44afc

Browse files
committed
feat(app): pick the agent of a new bot (A4 of spec 30)
system.status lists enabledAgents. The new-bot dialog offers the agent when there is more than one, with the experimental warning and no model choice for non-Claude agents; the model, effort and context meter under the message box show only for Claude bots.
1 parent d61ebee commit 6c44afc

11 files changed

Lines changed: 120 additions & 15 deletions

File tree

‎app/src/features/bots/BotDialog.tsx‎

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { type FormEvent, useState } from "react";
33
import { useT } from "../../i18n";
44
import { errorText } from "../../lib/api";
55
import {
6+
type AgentKind,
67
AVATAR_PALETTE,
78
type Bot,
89
type BotModel,
@@ -17,6 +18,8 @@ import { BotAvatar } from "./BotAvatar";
1718
import { ColorPicker } from "./ColorPicker";
1819

1920
const MODELS: BotModel[] = ["default", "fable", "opus", "sonnet", "haiku"];
21+
/** Stable, for a selector: a new array each time would render in a loop. */
22+
const CLAUDE_ONLY: AgentKind[] = ["claude"];
2023

2124
type Props = { onClose(): void } & ({ crewId: CrewId; bot?: undefined } | { bot: Bot });
2225

@@ -34,6 +37,10 @@ export function BotDialog(props: Props) {
3437
const custom = color !== undefined && !(AVATAR_PALETTE as readonly string[]).includes(color);
3538
const [picking, setPicking] = useState(custom);
3639
const [model, setModel] = useState<BotModel>(editing?.model ?? "default");
40+
// Which agent runs the bot is chosen once, when it is made (spec 30).
41+
const agents = useApp((state) => state.system?.enabledAgents ?? CLAUDE_ONLY);
42+
const [agent, setAgent] = useState<AgentKind>("claude");
43+
const runsOn: AgentKind = editing?.agent ?? agent;
3744
const [error, setError] = useState<string | null>(null);
3845
const [busy, setBusy] = useState(false);
3946

@@ -62,7 +69,8 @@ export function BotDialog(props: Props) {
6269
role,
6370
instructions,
6471
...(color !== undefined && { color }),
65-
...(model !== "default" && { model }),
72+
...(runsOn === "claude" && model !== "default" && { model }),
73+
...(runsOn !== "claude" && { agent: runsOn }),
6674
});
6775
}
6876
putBot(bot);
@@ -118,13 +126,30 @@ export function BotDialog(props: Props) {
118126
placeholder={t.bots.dialog.instructionsPlaceholder}
119127
hint={editing ? t.bots.dialog.instructionsHint : undefined}
120128
/>
121-
<SelectField
122-
label={t.chat.model.title}
123-
value={model}
124-
onChange={(event) => setModel(event.target.value as BotModel)}
125-
options={MODELS.map((value) => ({ value, label: t.chat.model.names[value] }))}
126-
hint={t.chat.model.cost}
127-
/>
129+
{!editing && agents.length > 1 && (
130+
<div className="flex flex-col gap-2">
131+
<SelectField
132+
label={t.bots.dialog.agent.title}
133+
value={agent}
134+
onChange={(event) => setAgent(event.target.value as AgentKind)}
135+
options={agents.map((value) => ({ value, label: t.bots.dialog.agent.names[value] }))}
136+
/>
137+
{agent !== "claude" && (
138+
<p role="note" className="rounded-lg bg-sunken p-2.5 text-ink-soft text-xs">
139+
{t.bots.dialog.agent.experimental}
140+
</p>
141+
)}
142+
</div>
143+
)}
144+
{runsOn === "claude" && (
145+
<SelectField
146+
label={t.chat.model.title}
147+
value={model}
148+
onChange={(event) => setModel(event.target.value as BotModel)}
149+
options={MODELS.map((value) => ({ value, label: t.chat.model.names[value] }))}
150+
hint={t.chat.model.cost}
151+
/>
152+
)}
128153
<fieldset className="flex flex-col gap-1.5">
129154
<legend className="mb-1.5 font-medium text-ink-soft text-sm">
130155
{t.bots.dialog.color}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { cleanup, fireEvent, screen, within } from "@testing-library/react";
2+
import { afterEach, describe, expect, test } from "vitest";
3+
import { FakeBotloft } from "../../lib/fake";
4+
import { crewOpened, renderApp } from "../../test/app";
5+
6+
afterEach(cleanup);
7+
8+
async function openNewBot(agents: ("claude" | "agy")[]) {
9+
const fake = new FakeBotloft();
10+
fake.system = { ...fake.system, enabledAgents: agents };
11+
fake.addCrew("Ops");
12+
renderApp(fake);
13+
await crewOpened("Ops");
14+
const [button] = await screen.findAllByRole("button", { name: "New bot" });
15+
fireEvent.click(button as HTMLElement);
16+
return within(await screen.findByRole("dialog", { name: "New bot" }));
17+
}
18+
19+
describe("choosing the agent of a new bot", () => {
20+
test("with only Claude Code there is no choice to make", async () => {
21+
const dialog = await openNewBot(["claude"]);
22+
expect(dialog.queryByLabelText("Agent")).toBeNull();
23+
expect(dialog.getByLabelText("Model")).toBeDefined();
24+
});
25+
26+
test("an experimental agent comes with its warning and no model choice", async () => {
27+
const dialog = await openNewBot(["claude", "agy"]);
28+
fireEvent.change(dialog.getByLabelText("Agent"), { target: { value: "agy" } });
29+
expect(dialog.getByRole("note").textContent).toMatch(/cannot ask you/);
30+
expect(dialog.queryByLabelText("Model")).toBeNull();
31+
});
32+
});

‎app/src/features/chat/ChatComposer.tsx‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -245,9 +245,14 @@ export const ChatComposer = memo(function ChatComposer({
245245
: text && (enterSends ? t.chat.composer.keys : t.chat.composer.keysWithCtrl)}
246246
</span>
247247
<div className="ml-auto flex min-w-0 max-w-full items-center gap-1">
248-
<ContextMeter bot={bot} onLater={setLater} />
249-
<EffortPicker bot={bot} onLater={setLater} />
250-
<ModelPicker bot={bot} onLater={setLater} />
248+
{/* Claude Code's own: other agents report none of it (spec 30). */}
249+
{bot.agent === "claude" && (
250+
<>
251+
<ContextMeter bot={bot} onLater={setLater} />
252+
<EffortPicker bot={bot} onLater={setLater} />
253+
<ModelPicker bot={bot} onLater={setLater} />
254+
</>
255+
)}
251256
<button
252257
type="submit"
253258
aria-label={t.chat.composer.send}

‎app/src/i18n/en/bots.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,16 @@ export const bots = {
9797
instructions: "Instructions",
9898
instructionsPlaceholder: "How this bot works, what it may do on its own and when to ask.",
9999
instructionsHint: "Saved to the bot's rules now; the bot reads them the next time it starts.",
100+
agent: {
101+
title: "Agent",
102+
names: {
103+
claude: "Claude Code",
104+
agy: "Antigravity (experimental)",
105+
codex: "Codex",
106+
},
107+
experimental:
108+
"Experimental. This bot cannot ask you before it acts. It can work with files in its own folders and use the crew's tools, but it cannot run commands, and it has no usage meter or model choice.",
109+
},
100110
color: "Color",
101111
swatch: (color: string) => `Color ${color}`,
102112
colorUnset: "Left unset, the bot gets the crew's next color.",

‎app/src/i18n/es/bots.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,16 @@ export const bots: Messages["bots"] = {
9595
"Cómo trabaja este bot, qué puede hacer por su cuenta y cuándo debe preguntar.",
9696
instructionsHint:
9797
"Se guardan ahora en las reglas del bot; el bot las lee la próxima vez que se inicie.",
98+
agent: {
99+
title: "Agente",
100+
names: {
101+
claude: "Claude Code",
102+
agy: "Antigravity (experimental)",
103+
codex: "Codex",
104+
},
105+
experimental:
106+
"Experimental. Este bot no puede preguntarte antes de actuar. Trabaja con archivos de sus propias carpetas y usa las herramientas del equipo, pero no ejecuta comandos, y no tiene medidor de uso ni elección de modelo.",
107+
},
98108
color: "Color",
99109
swatch: (color: string) => `Color ${color}`,
100110
colorUnset: "Si no eliges uno, el bot recibe el siguiente color del equipo.",

‎app/src/i18n/pt-BR/bots.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,16 @@ export const bots: Messages["bots"] = {
9494
instructionsPlaceholder:
9595
"Como este bot trabalha, o que ele pode fazer sozinho e quando deve perguntar.",
9696
instructionsHint: "Salvas agora nas regras do bot; ele as lê na próxima vez que iniciar.",
97+
agent: {
98+
title: "Agente",
99+
names: {
100+
claude: "Claude Code",
101+
agy: "Antigravity (experimental)",
102+
codex: "Codex",
103+
},
104+
experimental:
105+
"Experimental. Este bot não consegue perguntar a você antes de agir. Ele trabalha com arquivos das próprias pastas e usa as ferramentas da equipe, mas não roda comandos, e não tem medidor de uso nem escolha de modelo.",
106+
},
97107
color: "Cor",
98108
swatch: (color: string) => `Cor ${color}`,
99109
colorUnset: "Se você não escolher, o bot recebe a próxima cor da equipe.",

‎app/src/lib/fake.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export class FakeBotloft implements BotloftApi {
6969
},
7070
deliveries: { pending: 0, dead: 0 },
7171
usage: null,
72+
enabledAgents: ["claude"],
7273
};
7374
/** How many times the app asked for a new Claude Code check. */
7475
refreshes = 0;

‎app/src/lib/protocol.gen.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,12 @@ account: OwnerAccount, deliveries: DeliveryBacklog,
398398
/**
399399
* The Claude account's usage as last reported; `null` before any turn.
400400
*/
401-
usage: AccountUsage | null, };
401+
usage: AccountUsage | null,
402+
/**
403+
* The agents a bot may be made for (spec 30): Claude Code, and the
404+
* experimental ones the owner switched on.
405+
*/
406+
enabledAgents: Array<AgentKind>, };
402407

403408
export type CrewsCreateParams = { name: string,
404409
/**

‎crates/botloft-core/src/protocol/methods.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ pub struct SystemStatus {
7878
pub deliveries: DeliveryBacklog,
7979
/// The Claude account's usage as last reported; `null` before any turn.
8080
pub usage: Option<AccountUsage>,
81+
/// The agents a bot may be made for (spec 30): Claude Code, and the
82+
/// experimental ones the owner switched on.
83+
pub enabled_agents: Vec<AgentKind>,
8184
}
8285

8386
/// The owner, as the app shows them in its account area (spec 15.1).

‎crates/botloftd/src/service/mod.rs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ pub mod settings;
3232
pub mod tasks;
3333
pub mod usage;
3434

35-
use botloft_core::protocol::{OwnerAccount, PROTOCOL_VERSION, SystemStatus, error_code};
35+
use botloft_core::protocol::{AgentKind, OwnerAccount, PROTOCOL_VERSION, SystemStatus, error_code};
3636
use botloft_core::slug;
3737
use botloft_core::validate::ValidationError;
3838
use botloft_store::StoreError;
@@ -117,6 +117,10 @@ pub fn status(daemon: &Daemon) -> ApiResult<SystemStatus> {
117117
},
118118
deliveries,
119119
usage: daemon.usage(),
120+
enabled_agents: [AgentKind::Claude, AgentKind::Agy, AgentKind::Codex]
121+
.into_iter()
122+
.filter(|kind| daemon.supervisor.agent_enabled(*kind))
123+
.collect(),
120124
})
121125
}
122126

0 commit comments

Comments
 (0)