Skip to content

Commit b944bde

Browse files
committed
feat(agents): agy bots get images, context and a model picker
agy's input takes text blocks only, so images go as the saved file's path (the bot opens it with view_file). The usage of its last response sizes the context meter, estimated by model family, without Compact. bots.agent_model (migration 0027), bots.setAgentModel and agents.models; the app lists the agent's models under the message box, whose names carry the effort.
1 parent 6c44afc commit b944bde

31 files changed

Lines changed: 591 additions & 49 deletions

File tree

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// The model of a bot that does not run on Claude Code (spec 30), picked below
2+
// the chat like Claude's. The agent lists its models; its effort is part of
3+
// each name ("... (High)"), so there is no second picker.
4+
5+
import { ChevronDown } from "lucide-react";
6+
import { useCallback, useEffect, useRef, useState } from "react";
7+
import { useT } from "../../i18n";
8+
import type { AgentModel, Bot } from "../../lib/protocol.gen";
9+
import { useApi, useApp } from "../../store/context";
10+
import { POPOVER } from "../../ui/surface";
11+
import { attempt } from "../../ui/toast";
12+
import { useDismiss } from "../../ui/useDismiss";
13+
import { PickerOption } from "./PickerOption";
14+
15+
export function AgentModelPicker({ bot, onLater }: { bot: Bot; onLater(text: string): void }) {
16+
const api = useApi();
17+
const putBot = useApp((state) => state.putBot);
18+
const m = useT().chat.agentModel;
19+
const [open, setOpen] = useState(false);
20+
const [models, setModels] = useState<AgentModel[] | null>(null);
21+
const [failed, setFailed] = useState(false);
22+
const root = useRef<HTMLDivElement>(null);
23+
const close = useCallback(() => setOpen(false), []);
24+
useDismiss(open, root, close);
25+
26+
// Asked when the list is opened first: it takes a few seconds.
27+
useEffect(() => {
28+
if (!open || models !== null) {
29+
return;
30+
}
31+
let live = true;
32+
api
33+
.call("agents.models", { agent: bot.agent })
34+
.then((list) => live && setModels(list))
35+
.catch(() => live && setFailed(true));
36+
return () => {
37+
live = false;
38+
};
39+
}, [open, models, api, bot.agent]);
40+
41+
const current = bot.agentModel;
42+
const nameOf = (id: string) => models?.find((model) => model.id === id)?.name ?? id;
43+
const label =
44+
current === null ? (bot.modelInUse ? nameOf(bot.modelInUse) : m.short) : nameOf(current);
45+
46+
const choose = async (model: string | null) => {
47+
setOpen(false);
48+
if (model === current) {
49+
return;
50+
}
51+
const working = bot.state === "busy" || bot.state === "needs_approval";
52+
await attempt(m.failed, async () => {
53+
putBot(await api.call("bots.setAgentModel", { botId: bot.id, model }));
54+
if (working) {
55+
onLater(m.later(bot.name, model === null ? m.short : nameOf(model)));
56+
}
57+
});
58+
};
59+
60+
return (
61+
<div ref={root} className="relative min-w-0">
62+
{open && (
63+
<div
64+
role="menu"
65+
aria-label={m.title}
66+
className={`absolute right-0 bottom-full mb-2 max-h-80 w-[22rem] max-w-[80vw] origin-bottom-right overflow-y-auto ${POPOVER}`}
67+
>
68+
<p className="px-2.5 pt-1.5 pb-1 font-medium text-muted text-xs">{m.title}</p>
69+
<PickerOption
70+
name={m.short}
71+
hint={m.defaultHint(bot.name)}
72+
checked={current === null}
73+
onSelect={() => choose(null)}
74+
/>
75+
{models === null && !failed && (
76+
<p className="px-2.5 py-2 text-muted text-xs">{m.loading}</p>
77+
)}
78+
{failed && <p className="px-2.5 py-2 text-danger text-xs">{m.listFailed}</p>}
79+
{models?.map((model) => (
80+
<PickerOption
81+
key={model.id}
82+
name={model.name}
83+
hint={model.id}
84+
checked={current === model.id}
85+
onSelect={() => choose(model.id)}
86+
/>
87+
))}
88+
</div>
89+
)}
90+
<button
91+
type="button"
92+
aria-haspopup="menu"
93+
aria-expanded={open}
94+
aria-label={m.button(label)}
95+
onClick={() => setOpen(!open)}
96+
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 ${
97+
open ? "bg-sunken text-ink" : ""
98+
}`}
99+
>
100+
<span className="truncate">{label}</span>
101+
<ChevronDown
102+
aria-hidden
103+
size={14}
104+
className={`hidden shrink-0 opacity-60 transition-transform duration-200 @xs:block ${open ? "rotate-180" : ""}`}
105+
/>
106+
</button>
107+
</div>
108+
);
109+
}

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { type Bot, FIELD_LIMITS } from "../../lib/protocol.gen";
2222
import { prefs, usePref } from "../../shell/prefs";
2323
import { playSound } from "../../shell/sounds";
2424
import { useApi } from "../../store/context";
25+
import { AgentModelPicker } from "./AgentModelPicker";
2526
import { ContextMeter } from "./ContextMeter";
2627
import { draftOf, keepDraft } from "./drafts";
2728
import { EffortPicker } from "./EffortPicker";
@@ -245,13 +246,15 @@ export const ChatComposer = memo(function ChatComposer({
245246
: text && (enterSends ? t.chat.composer.keys : t.chat.composer.keysWithCtrl)}
246247
</span>
247248
<div className="ml-auto flex min-w-0 max-w-full items-center gap-1">
248-
{/* Claude Code's own: other agents report none of it (spec 30). */}
249-
{bot.agent === "claude" && (
249+
<ContextMeter bot={bot} onLater={setLater} />
250+
{/* Another agent's effort is part of its model's name (spec 30). */}
251+
{bot.agent === "claude" ? (
250252
<>
251-
<ContextMeter bot={bot} onLater={setLater} />
252253
<EffortPicker bot={bot} onLater={setLater} />
253254
<ModelPicker bot={bot} onLater={setLater} />
254255
</>
256+
) : (
257+
<AgentModelPicker bot={bot} onLater={setLater} />
255258
)}
256259
<button
257260
type="submit"

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

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export function ContextMeter({ bot, onLater }: { bot: Bot; onLater(text: string)
3838
const limit = autoCompactTokens ?? windowTokens;
3939
const near = usedTokens >= limit * NEAR;
4040
const left = autoCompactTokens === null ? null : autoCompactTokens - usedTokens;
41+
const claude = bot.agent === "claude";
4142
const running = bot.state === "idle" || bot.state === "busy" || bot.state === "needs_approval";
4243

4344
const compact = async () => {
@@ -76,26 +77,31 @@ export function ContextMeter({ bot, onLater }: { bot: Bot; onLater(text: string)
7677
)}
7778
</div>
7879
<p className="mt-2 text-ink-soft text-sm">
79-
{left === null
80-
? m.noAuto
81-
: left > 0
82-
? m.autoLeft(tokens(left), tokens(autoCompactTokens ?? 0))
83-
: m.autoNow}
80+
{!claude
81+
? m.estimate
82+
: left === null
83+
? m.noAuto
84+
: left > 0
85+
? m.autoLeft(tokens(left), tokens(autoCompactTokens ?? 0))
86+
: m.autoNow}
8487
</p>
8588
<p className="mt-1.5 text-muted text-xs">{m.about(bot.name)}</p>
86-
<div className="mt-3 border-line border-t pt-3">
87-
<Button
88-
size="sm"
89-
disabled={compacting || !running}
90-
aria-busy={compacting}
91-
onClick={compact}
92-
>
93-
{compacting ? m.compacting : m.compact}
94-
</Button>
95-
<p className="mt-1.5 text-muted text-xs">
96-
{running || compacting ? m.compactHint(bot.name) : m.notRunning(bot.name)}
97-
</p>
98-
</div>
89+
{/* Only Claude Code answers a request to compact (spec 30). */}
90+
{claude && (
91+
<div className="mt-3 border-line border-t pt-3">
92+
<Button
93+
size="sm"
94+
disabled={compacting || !running}
95+
aria-busy={compacting}
96+
onClick={compact}
97+
>
98+
{compacting ? m.compacting : m.compact}
99+
</Button>
100+
<p className="mt-1.5 text-muted text-xs">
101+
{running || compacting ? m.compactHint(bot.name) : m.notRunning(bot.name)}
102+
</p>
103+
</div>
104+
)}
99105
</div>
100106
)}
101107
<button
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
2+
import { afterEach, describe, expect, test } from "vitest";
3+
import { FakeBotloft } from "../../lib/fake";
4+
import { crewOpened, openBot, renderApp } from "../../test/app";
5+
6+
afterEach(cleanup);
7+
8+
async function agyBot() {
9+
const fake = new FakeBotloft();
10+
const crew = fake.addCrew("Ops");
11+
const bot = fake.addBot(crew.id, "Zed");
12+
fake.bot(bot.id).agent = "agy";
13+
fake.setBotState(bot.id, "idle");
14+
fake.setContext(bot.id, {
15+
usedTokens: 120_000,
16+
windowTokens: 1_000_000,
17+
autoCompactTokens: null,
18+
compacting: false,
19+
updatedAt: 0,
20+
});
21+
renderApp(fake);
22+
await crewOpened("Ops");
23+
openBot("Zed");
24+
await screen.findByRole("list", { name: "Messages" });
25+
return fake;
26+
}
27+
28+
describe("a bot that runs on another agent", () => {
29+
test("shows its models, not Claude's, and picks one", async () => {
30+
const fake = await agyBot();
31+
expect(screen.queryByRole("button", { name: /^Effort:/ })).toBeNull();
32+
expect(screen.queryByRole("button", { name: /Plan default|Opus|Sonnet/ })).toBeNull();
33+
34+
fireEvent.click(screen.getByRole("button", { name: "Model: Default" }));
35+
const menu = await screen.findByRole("menu", { name: "Model" });
36+
fireEvent.click(
37+
await within(menu).findByRole("menuitemradio", { name: /Gemini 3.8 Flash \(High\)/ }),
38+
);
39+
await waitFor(() =>
40+
expect(fake.bots.values().next().value?.agentModel).toBe("gemini-3.8-flash-high"),
41+
);
42+
});
43+
44+
test("shows how full the conversation is, without a way to compact it", async () => {
45+
await agyBot();
46+
fireEvent.click(screen.getByRole("button", { name: /Conversation space/ }));
47+
const panel = await screen.findByRole("dialog", { name: "Conversation space" });
48+
expect(within(panel).getByText(/An estimate/)).toBeDefined();
49+
expect(within(panel).queryByRole("button", { name: "Compact now" })).toBeNull();
50+
});
51+
});

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,16 @@ export const chat = {
198198
later: (bot: string, model: string) =>
199199
`${bot} switches to ${model} when it finishes what it's doing.`,
200200
},
201+
agentModel: {
202+
title: "Model",
203+
button: (model: string) => `Model: ${model}`,
204+
short: "Default",
205+
loading: "Loading the models…",
206+
listFailed: "Could not load the list of models",
207+
failed: "Could not change the model",
208+
defaultHint: (bot: string) => `${bot} uses the agent's own default model`,
209+
later: (bot: string, model: string) => `${bot} switches to ${model}.`,
210+
},
201211
effort: {
202212
title: "Effort",
203213
button: (level: string) => `Effort: ${level}`,
@@ -242,6 +252,8 @@ export const chat = {
242252
autoLeft: (left: string, at: string) => `${left} left before it compacts by itself, at ${at}.`,
243253
autoNow: "It is full enough to compact by itself on the next message.",
244254
noAuto: "It does not compact by itself.",
255+
estimate:
256+
"An estimate: this agent does not say how large its window is, so the size comes from its model family.",
245257
compact: "Compact now",
246258
compactHint: (bot: string) =>
247259
`Replaces what came before with a summary, so ${bot} has room again and each message uses less.`,

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,16 @@ export const chat: Messages["chat"] = {
190190
later: (bot: string, model: string) =>
191191
`${bot} cambia a ${model} cuando termine lo que está haciendo.`,
192192
},
193+
agentModel: {
194+
title: "Modelo",
195+
button: (model: string) => `Modelo: ${model}`,
196+
short: "Predeterminado",
197+
loading: "Cargando los modelos…",
198+
listFailed: "No se pudo cargar la lista de modelos",
199+
failed: "No se pudo cambiar el modelo",
200+
defaultHint: (bot: string) => `${bot} usa el modelo predeterminado del propio agente`,
201+
later: (bot: string, model: string) => `${bot} cambia a ${model}.`,
202+
},
193203
effort: {
194204
title: "Esfuerzo",
195205
button: (level: string) => `Esfuerzo: ${level}`,
@@ -234,6 +244,8 @@ export const chat: Messages["chat"] = {
234244
autoLeft: (left: string, at: string) => `Faltan ${left} para que se compacte sola, en ${at}.`,
235245
autoNow: "Ya está lo bastante llena para compactarse sola en el próximo mensaje.",
236246
noAuto: "No se compacta sola.",
247+
estimate:
248+
"Una estimación: este agente no dice el tamaño de su ventana, así que el tamaño sale de la familia del modelo.",
237249
compact: "Compactar ahora",
238250
compactHint: (bot: string) =>
239251
`Cambia lo anterior por un resumen: ${bot} vuelve a tener espacio y cada mensaje gasta menos.`,

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,16 @@ export const chat: Messages["chat"] = {
186186
later: (bot: string, model: string) =>
187187
`${bot} muda para ${model} quando terminar o que está fazendo.`,
188188
},
189+
agentModel: {
190+
title: "Modelo",
191+
button: (model: string) => `Modelo: ${model}`,
192+
short: "Padrão",
193+
loading: "Carregando os modelos…",
194+
listFailed: "Não foi possível carregar a lista de modelos",
195+
failed: "Não foi possível trocar o modelo",
196+
defaultHint: (bot: string) => `${bot} usa o modelo padrão do próprio agente`,
197+
later: (bot: string, model: string) => `${bot} troca para ${model}.`,
198+
},
189199
effort: {
190200
title: "Esforço",
191201
button: (level: string) => `Esforço: ${level}`,
@@ -231,6 +241,8 @@ export const chat: Messages["chat"] = {
231241
`Faltam ${left} para ela se compactar sozinha, em ${at}.`,
232242
autoNow: "Já está cheia o bastante para se compactar sozinha na próxima mensagem.",
233243
noAuto: "Ela não se compacta sozinha.",
244+
estimate:
245+
"Uma estimativa: este agente não informa o tamanho da janela, então o tamanho vem da família do modelo.",
234246
compact: "Compactar agora",
235247
compactHint: (bot: string) =>
236248
`Troca o que veio antes por um resumo: ${bot} volta a ter espaço e cada mensagem gasta menos.`,

‎app/src/lib/fake.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,10 @@ export class FakeBotloft implements BotloftApi {
253253
daemonVersion: this.system.daemonVersion,
254254
protocol: PROTOCOL_VERSION,
255255
}),
256+
"agents.models": () => [
257+
{ id: "gemini-3.8-flash-low", name: "Gemini 3.8 Flash (Low)" },
258+
{ id: "gemini-3.8-flash-high", name: "Gemini 3.8 Flash (High)" },
259+
],
256260
"session.setLocale": ({ locale }) => {
257261
this.locale = locale;
258262
return null;

‎app/src/lib/fakeBots.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export function botHandlers(fake: FakeBotloft): Pick<Handlers, BotMethods> {
1717
}
1818
return fake.activeBots(crewId);
1919
},
20-
"bots.create": ({ crewId, name, role, instructions, color, model }) => {
20+
"bots.create": ({ crewId, name, role, instructions, color, model, agent }) => {
2121
const crew = fake.crew(crewId);
2222
const checked = checkName(name);
2323
const handle = fake.handle(checked, crewId);
@@ -33,7 +33,8 @@ export function botHandlers(fake: FakeBotloft): Pick<Handlers, BotMethods> {
3333
color: color ?? AVATAR_PALETTE[index % AVATAR_PALETTE.length] ?? "#FF7A59",
3434
paused: false,
3535
permissionMode: "default",
36-
agent: "claude",
36+
agent: agent ?? "claude",
37+
agentModel: null,
3738
model: model ?? "default",
3839
modelInUse: null,
3940
effort: "default",
@@ -80,6 +81,11 @@ export function botHandlers(fake: FakeBotloft): Pick<Handlers, BotMethods> {
8081
}
8182
return fake.changedBot(bot);
8283
},
84+
"bots.setAgentModel": ({ botId, model }) => {
85+
const bot = fake.bot(botId);
86+
bot.agentModel = model;
87+
return fake.changedBot(bot);
88+
},
8389
"bots.setEffort": ({ botId, effort }) => {
8490
const bot = fake.bot(botId);
8591
bot.effort = effort;

0 commit comments

Comments
 (0)