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" && ( + <> + + + + + )}