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
57 changes: 49 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,12 @@ 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");
// One command prefix per line, for a bot whose agent cannot ask (spec 30).
const [commands, setCommands] = useState((editing?.allowedCommands ?? []).join("\n"));
// 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 All @@ -50,6 +59,10 @@ export function BotDialog(props: Props) {
...(role !== editing.role && { role }),
...(instructions !== editing.instructions && { instructions }),
...(color !== undefined && color !== editing.color && { color }),
...(editing.agent !== "claude" &&
commands !== editing.allowedCommands.join("\n") && {
allowedCommands: commands.split("\n"),
}),
});
if (model !== editing.model) {
// The bot restarts on it once nothing is in progress.
Expand All @@ -62,7 +75,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 +132,40 @@ 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>
)}
{editing && editing.agent !== "claude" && (
<TextArea
label={t.bots.dialog.commands.title}
value={commands}
rows={4}
onChange={(event) => setCommands(event.target.value)}
placeholder={t.bots.dialog.commands.placeholder}
hint={t.bots.dialog.commands.hint}
/>
)}
{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
Loading
Loading