From 6c44afc642de2fe4e8e6179151cefcde75384a03 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:51:58 -0300 Subject: [PATCH 1/2] 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. --- app/src/features/bots/BotDialog.tsx | 41 +++++++++++++++++---- app/src/features/bots/agentPicker.test.tsx | 32 ++++++++++++++++ app/src/features/chat/ChatComposer.tsx | 11 ++++-- app/src/i18n/en/bots.ts | 10 +++++ app/src/i18n/es/bots.ts | 10 +++++ app/src/i18n/pt-BR/bots.ts | 10 +++++ app/src/lib/fake.ts | 1 + app/src/lib/protocol.gen.ts | 7 +++- crates/botloft-core/src/protocol/methods.rs | 3 ++ crates/botloftd/src/service/mod.rs | 6 ++- docs/spec.md | 4 +- 11 files changed, 120 insertions(+), 15 deletions(-) create mode 100644 app/src/features/bots/agentPicker.test.tsx diff --git a/app/src/features/bots/BotDialog.tsx b/app/src/features/bots/BotDialog.tsx index 1b5153cc..5fbcb8ef 100644 --- a/app/src/features/bots/BotDialog.tsx +++ b/app/src/features/bots/BotDialog.tsx @@ -3,6 +3,7 @@ import { type FormEvent, useState } from "react"; import { useT } from "../../i18n"; import { errorText } from "../../lib/api"; import { + type AgentKind, AVATAR_PALETTE, type Bot, type BotModel, @@ -17,6 +18,8 @@ import { BotAvatar } from "./BotAvatar"; import { ColorPicker } from "./ColorPicker"; const MODELS: BotModel[] = ["default", "fable", "opus", "sonnet", "haiku"]; +/** Stable, for a selector: a new array each time would render in a loop. */ +const CLAUDE_ONLY: AgentKind[] = ["claude"]; type Props = { onClose(): void } & ({ crewId: CrewId; bot?: undefined } | { bot: Bot }); @@ -34,6 +37,10 @@ export function BotDialog(props: Props) { const custom = color !== undefined && !(AVATAR_PALETTE as readonly string[]).includes(color); const [picking, setPicking] = useState(custom); const [model, setModel] = useState(editing?.model ?? "default"); + // Which agent runs the bot is chosen once, when it is made (spec 30). + const agents = useApp((state) => state.system?.enabledAgents ?? CLAUDE_ONLY); + const [agent, setAgent] = useState("claude"); + const runsOn: AgentKind = editing?.agent ?? agent; const [error, setError] = useState(null); const [busy, setBusy] = useState(false); @@ -62,7 +69,8 @@ export function BotDialog(props: Props) { role, instructions, ...(color !== undefined && { color }), - ...(model !== "default" && { model }), + ...(runsOn === "claude" && model !== "default" && { model }), + ...(runsOn !== "claude" && { agent: runsOn }), }); } putBot(bot); @@ -118,13 +126,30 @@ export function BotDialog(props: Props) { placeholder={t.bots.dialog.instructionsPlaceholder} hint={editing ? t.bots.dialog.instructionsHint : undefined} /> - setModel(event.target.value as BotModel)} - options={MODELS.map((value) => ({ value, label: t.chat.model.names[value] }))} - hint={t.chat.model.cost} - /> + {!editing && agents.length > 1 && ( +
+ setAgent(event.target.value as AgentKind)} + options={agents.map((value) => ({ value, label: t.bots.dialog.agent.names[value] }))} + /> + {agent !== "claude" && ( +

+ {t.bots.dialog.agent.experimental} +

+ )} +
+ )} + {runsOn === "claude" && ( + setModel(event.target.value as BotModel)} + options={MODELS.map((value) => ({ value, label: t.chat.model.names[value] }))} + hint={t.chat.model.cost} + /> + )}
{t.bots.dialog.color} diff --git a/app/src/features/bots/agentPicker.test.tsx b/app/src/features/bots/agentPicker.test.tsx new file mode 100644 index 00000000..d221ca3e --- /dev/null +++ b/app/src/features/bots/agentPicker.test.tsx @@ -0,0 +1,32 @@ +import { cleanup, fireEvent, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { FakeBotloft } from "../../lib/fake"; +import { crewOpened, renderApp } from "../../test/app"; + +afterEach(cleanup); + +async function openNewBot(agents: ("claude" | "agy")[]) { + const fake = new FakeBotloft(); + fake.system = { ...fake.system, enabledAgents: agents }; + fake.addCrew("Ops"); + renderApp(fake); + await crewOpened("Ops"); + const [button] = await screen.findAllByRole("button", { name: "New bot" }); + fireEvent.click(button as HTMLElement); + return within(await screen.findByRole("dialog", { name: "New bot" })); +} + +describe("choosing the agent of a new bot", () => { + test("with only Claude Code there is no choice to make", async () => { + const dialog = await openNewBot(["claude"]); + expect(dialog.queryByLabelText("Agent")).toBeNull(); + expect(dialog.getByLabelText("Model")).toBeDefined(); + }); + + test("an experimental agent comes with its warning and no model choice", async () => { + const dialog = await openNewBot(["claude", "agy"]); + fireEvent.change(dialog.getByLabelText("Agent"), { target: { value: "agy" } }); + expect(dialog.getByRole("note").textContent).toMatch(/cannot ask you/); + expect(dialog.queryByLabelText("Model")).toBeNull(); + }); +}); diff --git a/app/src/features/chat/ChatComposer.tsx b/app/src/features/chat/ChatComposer.tsx index ddd2afcb..ab2a68af 100644 --- a/app/src/features/chat/ChatComposer.tsx +++ b/app/src/features/chat/ChatComposer.tsx @@ -245,9 +245,14 @@ export const ChatComposer = memo(function ChatComposer({ : text && (enterSends ? t.chat.composer.keys : t.chat.composer.keysWithCtrl)}
- - - + {/* Claude Code's own: other agents report none of it (spec 30). */} + {bot.agent === "claude" && ( + <> + + + + + )} +
+ ); +} diff --git a/app/src/features/chat/ChatComposer.tsx b/app/src/features/chat/ChatComposer.tsx index ab2a68af..c0ca0818 100644 --- a/app/src/features/chat/ChatComposer.tsx +++ b/app/src/features/chat/ChatComposer.tsx @@ -22,6 +22,7 @@ import { type Bot, FIELD_LIMITS } from "../../lib/protocol.gen"; import { prefs, usePref } from "../../shell/prefs"; import { playSound } from "../../shell/sounds"; import { useApi } from "../../store/context"; +import { AgentModelPicker } from "./AgentModelPicker"; import { ContextMeter } from "./ContextMeter"; import { draftOf, keepDraft } from "./drafts"; import { EffortPicker } from "./EffortPicker"; @@ -245,13 +246,15 @@ export const ChatComposer = memo(function ChatComposer({ : text && (enterSends ? t.chat.composer.keys : t.chat.composer.keysWithCtrl)}
- {/* Claude Code's own: other agents report none of it (spec 30). */} - {bot.agent === "claude" && ( + + {/* Another agent's effort is part of its model's name (spec 30). */} + {bot.agent === "claude" ? ( <> - + ) : ( + )}

- {left === null - ? m.noAuto - : left > 0 - ? m.autoLeft(tokens(left), tokens(autoCompactTokens ?? 0)) - : m.autoNow} + {!claude + ? m.estimate + : left === null + ? m.noAuto + : left > 0 + ? m.autoLeft(tokens(left), tokens(autoCompactTokens ?? 0)) + : m.autoNow}

{m.about(bot.name)}

-
- -

- {running || compacting ? m.compactHint(bot.name) : m.notRunning(bot.name)} -

-
+ {/* Only Claude Code answers a request to compact (spec 30). */} + {claude && ( +
+ +

+ {running || compacting ? m.compactHint(bot.name) : m.notRunning(bot.name)} +

+
+ )} )}