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
41 changes: 33 additions & 8 deletions app/src/features/bots/BotDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 });

Expand All @@ -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<BotModel>(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<AgentKind>("claude");
const runsOn: AgentKind = editing?.agent ?? agent;
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -118,13 +126,30 @@ export function BotDialog(props: Props) {
placeholder={t.bots.dialog.instructionsPlaceholder}
hint={editing ? t.bots.dialog.instructionsHint : undefined}
/>
<SelectField
label={t.chat.model.title}
value={model}
onChange={(event) => 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 && (
<div className="flex flex-col gap-2">
<SelectField
label={t.bots.dialog.agent.title}
value={agent}
onChange={(event) => setAgent(event.target.value as AgentKind)}
options={agents.map((value) => ({ value, label: t.bots.dialog.agent.names[value] }))}
/>
{agent !== "claude" && (
<p role="note" className="rounded-lg bg-sunken p-2.5 text-ink-soft text-xs">
{t.bots.dialog.agent.experimental}
</p>
)}
</div>
)}
{runsOn === "claude" && (
<SelectField
label={t.chat.model.title}
value={model}
onChange={(event) => setModel(event.target.value as BotModel)}
options={MODELS.map((value) => ({ value, label: t.chat.model.names[value] }))}
hint={t.chat.model.cost}
/>
)}
<fieldset className="flex flex-col gap-1.5">
<legend className="mb-1.5 font-medium text-ink-soft text-sm">
{t.bots.dialog.color}
Expand Down
32 changes: 32 additions & 0 deletions app/src/features/bots/agentPicker.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
});
});
109 changes: 109 additions & 0 deletions app/src/features/chat/AgentModelPicker.tsx
Original file line number Diff line number Diff line change
@@ -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<AgentModel[] | null>(null);
const [failed, setFailed] = useState(false);
const root = useRef<HTMLDivElement>(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 (
<div ref={root} className="relative min-w-0">
{open && (
<div
role="menu"
aria-label={m.title}
className={`absolute right-0 bottom-full mb-2 max-h-80 w-[22rem] max-w-[80vw] origin-bottom-right overflow-y-auto ${POPOVER}`}
>
<p className="px-2.5 pt-1.5 pb-1 font-medium text-muted text-xs">{m.title}</p>
<PickerOption
name={m.short}
hint={m.defaultHint(bot.name)}
checked={current === null}
onSelect={() => choose(null)}
/>
{models === null && !failed && (
<p className="px-2.5 py-2 text-muted text-xs">{m.loading}</p>
)}
{failed && <p className="px-2.5 py-2 text-danger text-xs">{m.listFailed}</p>}
{models?.map((model) => (
<PickerOption
key={model.id}
name={model.name}
hint={model.id}
checked={current === model.id}
onSelect={() => choose(model.id)}
/>
))}
</div>
)}
<button
type="button"
aria-haspopup="menu"
aria-expanded={open}
aria-label={m.button(label)}
onClick={() => setOpen(!open)}
className={`flex h-8 max-w-full items-center gap-1 rounded-full px-2.5 text-sm text-ink-soft transition-colors hover:bg-sunken hover:text-ink ${
open ? "bg-sunken text-ink" : ""
}`}
>
<span className="truncate">{label}</span>
<ChevronDown
aria-hidden
size={14}
className={`hidden shrink-0 opacity-60 transition-transform duration-200 @xs:block ${open ? "rotate-180" : ""}`}
/>
</button>
</div>
);
}
12 changes: 10 additions & 2 deletions app/src/features/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -246,8 +247,15 @@ export const ChatComposer = memo(function ChatComposer({
</span>
<div className="ml-auto flex min-w-0 max-w-full items-center gap-1">
<ContextMeter bot={bot} onLater={setLater} />
<EffortPicker bot={bot} onLater={setLater} />
<ModelPicker bot={bot} onLater={setLater} />
{/* Another agent's effort is part of its model's name (spec 30). */}
{bot.agent === "claude" ? (
<>
<EffortPicker bot={bot} onLater={setLater} />
<ModelPicker bot={bot} onLater={setLater} />
</>
) : (
<AgentModelPicker bot={bot} onLater={setLater} />
)}
<button
type="submit"
aria-label={t.chat.composer.send}
Expand Down
42 changes: 24 additions & 18 deletions app/src/features/chat/ContextMeter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export function ContextMeter({ bot, onLater }: { bot: Bot; onLater(text: string)
const limit = autoCompactTokens ?? windowTokens;
const near = usedTokens >= limit * NEAR;
const left = autoCompactTokens === null ? null : autoCompactTokens - usedTokens;
const claude = bot.agent === "claude";
const running = bot.state === "idle" || bot.state === "busy" || bot.state === "needs_approval";

const compact = async () => {
Expand Down Expand Up @@ -76,26 +77,31 @@ export function ContextMeter({ bot, onLater }: { bot: Bot; onLater(text: string)
)}
</div>
<p className="mt-2 text-ink-soft text-sm">
{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}
</p>
<p className="mt-1.5 text-muted text-xs">{m.about(bot.name)}</p>
<div className="mt-3 border-line border-t pt-3">
<Button
size="sm"
disabled={compacting || !running}
aria-busy={compacting}
onClick={compact}
>
{compacting ? m.compacting : m.compact}
</Button>
<p className="mt-1.5 text-muted text-xs">
{running || compacting ? m.compactHint(bot.name) : m.notRunning(bot.name)}
</p>
</div>
{/* Only Claude Code answers a request to compact (spec 30). */}
{claude && (
<div className="mt-3 border-line border-t pt-3">
<Button
size="sm"
disabled={compacting || !running}
aria-busy={compacting}
onClick={compact}
>
{compacting ? m.compacting : m.compact}
</Button>
<p className="mt-1.5 text-muted text-xs">
{running || compacting ? m.compactHint(bot.name) : m.notRunning(bot.name)}
</p>
</div>
)}
</div>
)}
<button
Expand Down
51 changes: 51 additions & 0 deletions app/src/features/chat/agentBot.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { FakeBotloft } from "../../lib/fake";
import { crewOpened, openBot, renderApp } from "../../test/app";

afterEach(cleanup);

async function agyBot() {
const fake = new FakeBotloft();
const crew = fake.addCrew("Ops");
const bot = fake.addBot(crew.id, "Zed");
fake.bot(bot.id).agent = "agy";
fake.setBotState(bot.id, "idle");
fake.setContext(bot.id, {
usedTokens: 120_000,
windowTokens: 1_000_000,
autoCompactTokens: null,
compacting: false,
updatedAt: 0,
});
renderApp(fake);
await crewOpened("Ops");
openBot("Zed");
await screen.findByRole("list", { name: "Messages" });
return fake;
}

describe("a bot that runs on another agent", () => {
test("shows its models, not Claude's, and picks one", async () => {
const fake = await agyBot();
expect(screen.queryByRole("button", { name: /^Effort:/ })).toBeNull();
expect(screen.queryByRole("button", { name: /Plan default|Opus|Sonnet/ })).toBeNull();

fireEvent.click(screen.getByRole("button", { name: "Model: Default" }));
const menu = await screen.findByRole("menu", { name: "Model" });
fireEvent.click(
await within(menu).findByRole("menuitemradio", { name: /Gemini 3.8 Flash \(High\)/ }),
);
await waitFor(() =>
expect(fake.bots.values().next().value?.agentModel).toBe("gemini-3.8-flash-high"),
);
});

test("shows how full the conversation is, without a way to compact it", async () => {
await agyBot();
fireEvent.click(screen.getByRole("button", { name: /Conversation space/ }));
const panel = await screen.findByRole("dialog", { name: "Conversation space" });
expect(within(panel).getByText(/An estimate/)).toBeDefined();
expect(within(panel).queryByRole("button", { name: "Compact now" })).toBeNull();
});
});
10 changes: 10 additions & 0 deletions app/src/i18n/en/bots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,16 @@ export const bots = {
instructions: "Instructions",
instructionsPlaceholder: "How this bot works, what it may do on its own and when to ask.",
instructionsHint: "Saved to the bot's rules now; the bot reads them the next time it starts.",
agent: {
title: "Agent",
names: {
claude: "Claude Code",
agy: "Antigravity (experimental)",
codex: "Codex",
},
experimental:
"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.",
},
color: "Color",
swatch: (color: string) => `Color ${color}`,
colorUnset: "Left unset, the bot gets the crew's next color.",
Expand Down
12 changes: 12 additions & 0 deletions app/src/i18n/en/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,16 @@ export const chat = {
later: (bot: string, model: string) =>
`${bot} switches to ${model} when it finishes what it's doing.`,
},
agentModel: {
title: "Model",
button: (model: string) => `Model: ${model}`,
short: "Default",
loading: "Loading the models…",
listFailed: "Could not load the list of models",
failed: "Could not change the model",
defaultHint: (bot: string) => `${bot} uses the agent's own default model`,
later: (bot: string, model: string) => `${bot} switches to ${model}.`,
},
effort: {
title: "Effort",
button: (level: string) => `Effort: ${level}`,
Expand Down Expand Up @@ -242,6 +252,8 @@ export const chat = {
autoLeft: (left: string, at: string) => `${left} left before it compacts by itself, at ${at}.`,
autoNow: "It is full enough to compact by itself on the next message.",
noAuto: "It does not compact by itself.",
estimate:
"An estimate: this agent does not say how large its window is, so the size comes from its model family.",
compact: "Compact now",
compactHint: (bot: string) =>
`Replaces what came before with a summary, so ${bot} has room again and each message uses less.`,
Expand Down
Loading
Loading