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/AgentModelPicker.tsx b/app/src/features/chat/AgentModelPicker.tsx new file mode 100644 index 00000000..3f591a56 --- /dev/null +++ b/app/src/features/chat/AgentModelPicker.tsx @@ -0,0 +1,109 @@ +// The model of a bot that does not run on Claude Code (spec 30), picked below +// the chat like Claude's. The agent lists its models; its effort is part of +// each name ("... (High)"), so there is no second picker. + +import { ChevronDown } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useT } from "../../i18n"; +import type { AgentModel, Bot } from "../../lib/protocol.gen"; +import { useApi, useApp } from "../../store/context"; +import { POPOVER } from "../../ui/surface"; +import { attempt } from "../../ui/toast"; +import { useDismiss } from "../../ui/useDismiss"; +import { PickerOption } from "./PickerOption"; + +export function AgentModelPicker({ bot, onLater }: { bot: Bot; onLater(text: string): void }) { + const api = useApi(); + const putBot = useApp((state) => state.putBot); + const m = useT().chat.agentModel; + const [open, setOpen] = useState(false); + const [models, setModels] = useState(null); + const [failed, setFailed] = useState(false); + const root = useRef(null); + const close = useCallback(() => setOpen(false), []); + useDismiss(open, root, close); + + // Asked when the list is opened first: it takes a few seconds. + useEffect(() => { + if (!open || models !== null) { + return; + } + let live = true; + api + .call("agents.models", { agent: bot.agent }) + .then((list) => live && setModels(list)) + .catch(() => live && setFailed(true)); + return () => { + live = false; + }; + }, [open, models, api, bot.agent]); + + const current = bot.agentModel; + const nameOf = (id: string) => models?.find((model) => model.id === id)?.name ?? id; + const label = + current === null ? (bot.modelInUse ? nameOf(bot.modelInUse) : m.short) : nameOf(current); + + const choose = async (model: string | null) => { + setOpen(false); + if (model === current) { + return; + } + const working = bot.state === "busy" || bot.state === "needs_approval"; + await attempt(m.failed, async () => { + putBot(await api.call("bots.setAgentModel", { botId: bot.id, model })); + if (working) { + onLater(m.later(bot.name, model === null ? m.short : nameOf(model))); + } + }); + }; + + return ( +
+ {open && ( +
+

{m.title}

+ choose(null)} + /> + {models === null && !failed && ( +

{m.loading}

+ )} + {failed &&

{m.listFailed}

} + {models?.map((model) => ( + choose(model.id)} + /> + ))} +
+ )} + +
+ ); +} diff --git a/app/src/features/chat/ChatComposer.tsx b/app/src/features/chat/ChatComposer.tsx index ddd2afcb..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"; @@ -246,8 +247,15 @@ export const ChatComposer = memo(function ChatComposer({
- - + {/* 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)} +

+
+ )} )}