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
92 changes: 92 additions & 0 deletions app/src/features/account/AgentPrograms.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Settings, "General": where the experimental agents' programs are. Botloft
// looks in the usual places; when someone installed one somewhere else (mise,
// npm, a folder of their own), they say where here (spec 30).

import { useState } from "react";
import { useT } from "../../i18n";
import type { AgentCheck } from "../../lib/protocol.gen";
import { useApi, useApp } from "../../store/context";
import { Button } from "../../ui/Button";
import { TextField } from "../../ui/Field";
import { attempt } from "../../ui/toast";

const NO_CHECKS: AgentCheck[] = [];

export function AgentPrograms() {
const checks = useApp((state) => state.system?.agentChecks ?? NO_CHECKS);
if (checks.length === 0) {
return null;
}
return (
<div className="flex flex-col gap-4">
{checks.map((check) => (
<Program key={check.agent} check={check} />
))}
</div>
);
}

function Program({ check }: { check: AgentCheck }) {
const t = useT();
const s = t.account.settings;
const api = useApi();
const system = useApp((state) => state.system);
const putSystem = useApp((state) => state.putSystem);
const name = t.bots.dialog.agent.names[check.agent];
const [text, setText] = useState("");
const [busy, setBusy] = useState(false);

const found = check.path !== null;
const status = found
? check.version === null
? s.programFoundNoVersion(check.path ?? "")
: s.programFound(check.version, check.path ?? "")
: s.programMissing(name);

const save = async (path: string) => {
setBusy(true);
const saved = await attempt(s.programFailed, async () => {
const next = await api.call("agents.setPath", { agent: check.agent, path });
if (system) {
putSystem({
...system,
agentChecks: system.agentChecks.map((have) => (have.agent === next.agent ? next : have)),
});
}
});
setBusy(false);
if (saved) {
setText("");
}
};

return (
<div className="flex flex-col gap-2">
<p className="font-medium text-sm">{name}</p>
<p
className={`text-xs leading-relaxed ${found ? "text-muted" : "text-warn"}`}
data-selectable
>
{status}
</p>
<div className="flex items-end gap-2">
<div className="min-w-0 flex-1">
<TextField
label={s.programPath(name)}
value={text}
placeholder={s.programPlaceholder}
onChange={(event) => setText(event.target.value)}
/>
</div>
<Button
variant="secondary"
disabled={busy || text.trim() === ""}
onClick={() => save(text)}
>
{s.programSave}
</Button>
</div>
<p className="text-muted text-xs leading-relaxed">{s.programHint}</p>
</div>
);
}
2 changes: 2 additions & 0 deletions app/src/features/account/GeneralSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { SYSTEM } from "../../lib/system";
import { prefs, usePref } from "../../shell/prefs";
import { useApp } from "../../store/context";
import { Select } from "../../ui/Select";
import { AgentPrograms } from "./AgentPrograms";
import { Section, Toggle } from "./settingsParts";
import { useDaemonSettings } from "./useDaemonSettings";

Expand Down Expand Up @@ -49,6 +50,7 @@ export function GeneralSettings() {
disabled={!settings}
onChange={(on) => change({ showExperimentalAgents: on })}
/>
{settings?.showExperimentalAgents && <AgentPrograms />}
</Section>
{settings && enabledAgents.length > 1 && (
<Section title={s.defaultAgent}>
Expand Down
24 changes: 24 additions & 0 deletions app/src/features/account/settings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,30 @@ describe("settings", () => {
).toEqual([{ showExperimentalAgents: true }]);
});

test("an agent that is not found says so, and the path the owner gives is used", async () => {
const fake = new FakeBotloft();
fake.settings = { ...fake.settings, showExperimentalAgents: true };
fake.system = {
...fake.system,
enabledAgents: ["claude", "codex"],
agentChecks: [{ agent: "codex", version: null, path: null }],
};
renderApp(fake);
const dialog = await openSettings();
expect(await within(dialog).findByText(/Codex was not found on this computer/)).toBeDefined();
const save = within(dialog).getByRole("button", { name: "Save" });
expect(save.hasAttribute("disabled")).toBe(true);

fireEvent.change(within(dialog).getByLabelText("Where Codex is"), {
target: { value: "C:/tools/mise/codex.exe" },
});
fireEvent.click(save);
expect(await within(dialog).findByText(/Found: C:\/tools\/mise\/codex\.exe/)).toBeDefined();
expect(
fake.calls.filter((call) => call.method === "agents.setPath").map((c) => c.params),
).toEqual([{ agent: "codex", path: "C:/tools/mise/codex.exe" }]);
});

test("the icon and the window at sign-in show only where they apply", async () => {
renderApp();
const dialog = await openSettings();
Expand Down
14 changes: 14 additions & 0 deletions app/src/features/bots/BotPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { X } from "lucide-react";
import { useT } from "../../i18n";
import type { Bot, Crew } from "../../lib/protocol.gen";
import { useApp } from "../../store/context";
import { Button } from "../../ui/Button";
import { Callout } from "../../ui/Callout";
import { SidePanel } from "../../ui/SidePanel";
Expand All @@ -26,6 +27,19 @@ export function Notices({
}) {
const t = useT();
const notices = [];
const checks = useApp((state) => state.system?.agentChecks);
// An agent that is not on the computer is restarted over and over: say why.
const missing =
bot.agent !== "claude" &&
checks?.some((check) => check.agent === bot.agent && check.path === null) === true;
if (missing) {
const name = t.bots.dialog.agent.names[bot.agent];
notices.push(
<Callout key="program" tone="warn" title={t.bots.notices.programMissing(name)}>
{t.bots.notices.programMissingBody}
</Callout>,
);
}
if (crew.paused && !bot.paused) {
notices.push(
<Callout key="crew" title={t.bots.notices.crewPaused(crew.name)}>
Expand Down
20 changes: 20 additions & 0 deletions app/src/features/bots/agentPicker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,23 @@ describe("Codex in the new-bot dialog", () => {
expect(dialog.queryByLabelText("Model")).toBeNull();
});
});

describe("an agent whose program is not on the computer", () => {
test("its chat says why it keeps restarting", async () => {
const fake = new FakeBotloft();
fake.system = {
...fake.system,
enabledAgents: ["claude", "codex"],
agentChecks: [{ agent: "codex", version: null, path: null }],
};
const crew = fake.addCrew("Ops");
const bot = fake.addBot(crew.id, "Cody");
fake.bot(bot.id).agent = "codex";
fake.setBotState(bot.id, "backoff");
renderApp(fake);
await crewOpened("Ops");
fireEvent.click(await screen.findByRole("button", { name: /^Cody,/ }));
expect(await screen.findByText("Codex was not found")).toBeDefined();
expect(screen.getByText(/tell Botloft where it is in Settings/)).toBeDefined();
});
});
12 changes: 9 additions & 3 deletions app/src/features/onboarding/Welcome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function Welcome() {
const [creating, setCreating] = useState(false);
const [idea, setIdea] = useState<CrewTemplate | undefined>(undefined);
const checks = useApp((state) => state.system?.agentChecks ?? NO_CHECKS);
const otherReady = checks.some((check) => check.version !== null);
const otherReady = checks.some((check) => check.path !== null);
const whenClosed = usePref(prefs.whenClosed);
const { settings } = useDaemonSettings();
const running =
Expand Down Expand Up @@ -60,9 +60,15 @@ export function Welcome() {
const agents = checks.map((check) => (
<Check
key={check.agent}
state={check.version === null ? "bad" : "ok"}
state={check.path === null ? "bad" : "ok"}
title={t.bots.dialog.agent.names[check.agent]}
detail={check.version === null ? w.agentMissing : w.version(check.version)}
detail={
check.version !== null
? w.version(check.version)
: check.path !== null
? check.path
: w.agentMissing
}
/>
));

Expand Down
4 changes: 2 additions & 2 deletions app/src/features/onboarding/agents.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ describe("the first screen for someone who does not use Claude Code", () => {
claudeVersion: null,
runtimeError: "Claude Code was not found",
enabledAgents: ["claude", "agy"],
agentChecks: [{ agent: "agy", version: "1.3.1" }],
agentChecks: [{ agent: "agy", version: "1.3.1", path: "C:agyagy.exe" }],
};
renderApp(fake);
expect(await screen.findByText("Antigravity (experimental)")).toBeDefined();
Expand All @@ -27,7 +27,7 @@ describe("the first screen for someone who does not use Claude Code", () => {
fake.system = {
...fake.system,
enabledAgents: ["claude", "agy"],
agentChecks: [{ agent: "agy", version: null }],
agentChecks: [{ agent: "agy", version: null, path: null }],
};
renderApp(fake);
expect(await screen.findByText(/Not found\. Install it/)).toBeDefined();
Expand Down
10 changes: 10 additions & 0 deletions app/src/i18n/en/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,16 @@ export const account = {
"Still being tested. An agent that runs on Codex can read any file on this computer, and one that runs on Antigravity cannot ask you before it acts. Agents already made on them keep working either way.",
defaultAgentHint:
"Used for new agents, the chief of a new crew and the agents a template adds. Each agent keeps the engine it was made with.",
programFound: (version: string, path: string) => `Found: ${path} (${version})`,
programFoundNoVersion: (path: string) => `Found: ${path}`,
programMissing: (name: string) =>
`${name} was not found on this computer. Install it, or say below where it is.`,
programPath: (name: string) => `Where ${name} is`,
programPlaceholder: "Full path to the program",
programSave: "Save",
programFailed: "Could not save the path",
programHint:
"Leave it alone if the agent was found. If you installed it with a manager like mise or npm, paste the full path of its program.",
chat: "Chat",
enterSends: "Enter sends the message",
enterSendsOn: "Shift+Enter starts a new line.",
Expand Down
2 changes: 2 additions & 0 deletions app/src/i18n/en/bots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ export const bots = {
notices: {
crewPaused: (crew: string) => `${crew} is paused`,
crewPausedBody: "Its agents stay stopped until you resume the crew.",
programMissing: (name: string) => `${name} was not found`,
programMissingBody: "Install it, or tell Botloft where it is in Settings.",
},
details: {
title: (name: string) => `About ${name}`,
Expand Down
10 changes: 10 additions & 0 deletions app/src/i18n/es/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,16 @@ export const account: Messages["account"] = {
"Todavía en pruebas. Un agente que funciona con Codex puede leer cualquier archivo de este equipo, y uno que funciona con Antigravity no puede preguntarte antes de actuar. Los agentes ya creados con ellos siguen funcionando igual.",
defaultAgentHint:
"Vale para los agentes nuevos, el jefe de un equipo nuevo y los agentes que añade una plantilla. Cada agente conserva el motor con el que se creó.",
programFound: (version: string, path: string) => `Encontrado: ${path} (${version})`,
programFoundNoVersion: (path: string) => `Encontrado: ${path}`,
programMissing: (name: string) =>
`${name} no se encontró en este equipo. Instálalo o indica abajo dónde está.`,
programPath: (name: string) => `Dónde está ${name}`,
programPlaceholder: "Ruta completa del programa",
programSave: "Guardar",
programFailed: "No se pudo guardar la ruta",
programHint:
"Déjalo como está si se encontró el agente. Si lo instalaste con un gestor como mise o npm, pega la ruta completa de su programa.",
chat: "Chat",
enterSends: "Enter envía el mensaje",
enterSendsOn: "Shift+Enter empieza una nueva línea.",
Expand Down
2 changes: 2 additions & 0 deletions app/src/i18n/es/bots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ export const bots: Messages["bots"] = {
notices: {
crewPaused: (crew: string) => `El equipo ${crew} está en pausa`,
crewPausedBody: "Sus agentes siguen detenidos hasta que reanudes el equipo.",
programMissing: (name: string) => `${name} no se encontró`,
programMissingBody: "Instálalo o dile a Botloft dónde está, en Configuración.",
},
details: {
title: (name: string) => `Acerca de ${name}`,
Expand Down
10 changes: 10 additions & 0 deletions app/src/i18n/pt-BR/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,16 @@ export const account: Messages["account"] = {
"Ainda em teste. Um agente que roda no Codex pode ler qualquer arquivo deste computador, e um que roda no Antigravity não consegue perguntar a você antes de agir. Os agentes que já foram feitos neles continuam funcionando de qualquer jeito.",
defaultAgentHint:
"Vale para agentes novos, o chefe de uma equipe nova e os agentes que um modelo de equipe adiciona. Cada agente guarda o motor com que foi criado.",
programFound: (version: string, path: string) => `Encontrado: ${path} (${version})`,
programFoundNoVersion: (path: string) => `Encontrado: ${path}`,
programMissing: (name: string) =>
`${name} não foi encontrado neste computador. Instale ou diga abaixo onde ele está.`,
programPath: (name: string) => `Onde está o ${name}`,
programPlaceholder: "Caminho completo do programa",
programSave: "Salvar",
programFailed: "Não foi possível salvar o caminho",
programHint:
"Deixe como está se o agente foi encontrado. Se você o instalou por um gerenciador como mise ou npm, cole o caminho completo do programa dele.",
chat: "Conversa",
enterSends: "Enter envia a mensagem",
enterSendsOn: "Shift+Enter começa uma nova linha.",
Expand Down
2 changes: 2 additions & 0 deletions app/src/i18n/pt-BR/bots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ export const bots: Messages["bots"] = {
notices: {
crewPaused: (crew: string) => `A equipe ${crew} está pausada`,
crewPausedBody: "Os agentes dela ficam parados até você retomar a equipe.",
programMissing: (name: string) => `${name} não foi encontrado`,
programMissingBody: "Instale ou diga ao Botloft onde ele está, em Configurações.",
},
details: {
title: (name: string) => `Sobre ${name}`,
Expand Down
12 changes: 12 additions & 0 deletions app/src/lib/fake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,18 @@ export class FakeBotloft implements BotloftApi {
daemonVersion: this.system.daemonVersion,
protocol: PROTOCOL_VERSION,
}),
"agents.setPath": ({ agent, path }) => {
const check = {
agent,
version: path === "" ? null : "1.0.0",
path: path === "" ? null : path,
};
this.system = {
...this.system,
agentChecks: this.system.agentChecks.map((have) => (have.agent === agent ? check : have)),
};
return check;
},
"agents.models": () => [
{ id: "gemini-3.8-flash-low", name: "Gemini 3.8 Flash (Low)" },
{ id: "gemini-3.8-flash-high", name: "Gemini 3.8 Flash (High)" },
Expand Down
16 changes: 15 additions & 1 deletion app/src/lib/protocol.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -532,7 +532,11 @@ export type AgentCheck = { agent: AgentKind,
* What `--version` said; `null` when the program was not found or did
* not answer.
*/
version: string | null, };
version: string | null,
/**
* The program Botloft would run for it; `null` when none was found.
*/
path: string | null, };

/**
* The usage of an agent's account, with the plan it is on.
Expand All @@ -543,6 +547,15 @@ export type AgentUsage = { agent: AgentKind,
*/
plan: string | null, usage: AccountUsage, };

/**
* The program of an agent, set by the owner.
*/
export type AgentsSetPathParams = { agent: AgentKind,
/**
* The executable; empty goes back to looking for it.
*/
path: string, };

export type BotIdParams = { botId: BotId, };

export type BotsRestartParams = { botId: BotId,
Expand Down Expand Up @@ -2245,6 +2258,7 @@ export interface RpcMethods {
"system.refresh": { params: undefined; result: SystemStatus };
"settings.get": { params: undefined; result: Settings };
"settings.update": { params: SettingsUpdateParams; result: Settings };
"agents.setPath": { params: AgentsSetPathParams; result: AgentCheck };
"crews.list": { params: undefined; result: Array<Crew> };
"crews.create": { params: CrewsCreateParams; result: Crew };
"crews.rename": { params: CrewsRenameParams; result: Crew };
Expand Down
6 changes: 6 additions & 0 deletions crates/botloft-core/src/protocol/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ fn export_bindings() {
out.decl::<AgentModel>();
out.decl::<AgentCheck>();
out.decl::<AgentUsage>();
out.decl::<AgentsSetPathParams>();
out.decl::<BotIdParams>();
out.decl::<BotsRestartParams>();
out.decl::<BotStateChanged>();
Expand Down Expand Up @@ -260,6 +261,11 @@ fn export_bindings() {
&out.name::<SettingsUpdateParams>(),
&out.name::<Settings>(),
);
out.method(
method::AGENTS_SET_PATH,
&out.name::<AgentsSetPathParams>(),
&out.name::<AgentCheck>(),
);
out.method(method::CREWS_LIST, "undefined", &crews);
out.method(
method::CREWS_CREATE,
Expand Down
Loading
Loading