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
2 changes: 1 addition & 1 deletion app/src/dev/seedChats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export function seedChats(fake: FakeBotloft, crew: Crew): void {
at(92);
chat.reply(crew.scout, SUMMARY);
const handoff = chat.tool(crew.scout, "mcp__botloft__send_message", {
summary: "to @writer",
summary: "@writer",
input: JSON.stringify({ to: "writer", kind: "task", body: "Turn week-39.md into the report" }),
});
chat.finish(handoff, '{"ok":true}');
Expand Down
2 changes: 1 addition & 1 deletion app/src/features/browser/browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ describe("browser panel", () => {
fake.chat.tool(scout.id, "Bash", { summary: "ls", status: "done" });
await openScout(fake);
expect(screen.getAllByRole("button", { name: /Watch in browser/ })).toHaveLength(1);
fireEvent.click(screen.getByRole("button", { name: "Watch in browser: browser_open" }));
fireEvent.click(screen.getByRole("button", { name: "Watch in browser: Open a page" }));
expect(await screen.findByRole("complementary", { name: "Scout's browser" })).toBeDefined();
});
});
Expand Down
7 changes: 4 additions & 3 deletions app/src/features/chat/ApprovalCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ import { HELP_TOOL, HelpCard } from "../browser/HelpCard";
import { SITE_TOOL, SiteCard } from "../browser/SiteCard";
import { PLAN_TOOL, PlanCard } from "./PlanCard";
import { SUGGEST_TOOL, SuggestionCard } from "./SuggestionCard";
import { pretty, toolLabel } from "./ToolLines";
import { pretty } from "./ToolLines";
import { toolAction } from "./toolNames";
import { useAnswer } from "./useAnswer";

function Answered({ approval }: { approval: ApprovalItem }) {
const t = useT();
const label = toolLabel(approval.toolName);
const label = toolAction(approval.toolName, t.tools);
const line = "flex min-w-0 items-center gap-2 rounded-lg px-1.5 py-1 text-sm";
switch (approval.status) {
case "allowed":
Expand Down Expand Up @@ -71,7 +72,7 @@ function ToolApproval({ approval, bot }: { approval: ApprovalItem; bot: Bot }) {
if (approval.status !== "pending") {
return <Answered approval={approval} />;
}
const label = toolLabel(approval.toolName);
const label = toolAction(approval.toolName, t.tools);

return (
<section
Expand Down
22 changes: 8 additions & 14 deletions app/src/features/chat/ToolLines.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { useArrival } from "../../ui/motion";
import { isBrowserTool, ShowBrowser } from "../browser/showBrowser";
import { ShowFile } from "../files/showFile";
import { isScreenFile, ShowScreen } from "../screens/showScreen";
import { toolDetail, toolKey, toolTitle } from "./toolNames";

const ICONS: Record<string, LucideIcon> = {
Bash: SquareTerminal,
Expand Down Expand Up @@ -56,11 +57,6 @@ function iconOf(label: string): LucideIcon {
return ICONS[label] ?? (label.startsWith("browser_") ? Globe : Wrench);
}

/** `mcp__botloft__send_message` reads as `send_message`. */
export function toolLabel(name: string): string {
return name.split("__").at(-1) ?? name;
}

/** JSON indented for reading; anything else as it came. */
export function pretty(text: string): string {
try {
Expand Down Expand Up @@ -95,8 +91,9 @@ function ToolLine({ tool, createdAt }: { tool: ToolItem; createdAt: number }) {
const showBrowser = useContext(ShowBrowser);
const showScreen = useContext(ShowScreen);
const arrival = useArrival(createdAt);
const label = toolLabel(tool.name);
const Icon = iconOf(label);
const title = toolTitle(tool.name, t.tools);
const detail = toolDetail(tool, t.tools);
const Icon = iconOf(toolKey(tool.name));
const block =
"max-h-64 overflow-auto whitespace-pre-wrap break-all rounded-lg border border-line bg-sunken px-2.5 py-1.5 font-mono text-xs leading-relaxed";
return (
Expand All @@ -109,12 +106,9 @@ function ToolLine({ tool, createdAt }: { tool: ToolItem; createdAt: number }) {
className="group flex min-w-0 flex-1 items-center gap-2 rounded-lg px-1.5 py-1 text-left text-sm transition-colors hover:bg-sunken"
>
<Icon aria-hidden size={14} className="shrink-0 text-muted" />
<span className="shrink-0 font-medium">{label}</span>
<span
className="min-w-0 flex-1 truncate font-mono text-muted text-xs"
title={tool.summary}
>
{tool.summary}
<span className="shrink-0 font-medium">{title}</span>
<span className="min-w-0 flex-1 truncate font-mono text-muted text-xs" title={detail}>
{detail}
</span>
<Status status={tool.status} />
<ChevronRight
Expand Down Expand Up @@ -149,7 +143,7 @@ function ToolLine({ tool, createdAt }: { tool: ToolItem; createdAt: number }) {
<button
type="button"
title={t.browser.showInPanel}
aria-label={`${t.browser.showInPanel}: ${label}`}
aria-label={`${t.browser.showInPanel}: ${title}`}
onClick={() => showBrowser()}
className="grid size-6 shrink-0 place-items-center rounded-md text-muted transition-colors hover:bg-sunken hover:text-ink"
>
Expand Down
11 changes: 6 additions & 5 deletions app/src/features/chat/chat.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ describe("chat", () => {
input: '{"command":"npm test"}',
});
});
const line = within(chat()).getByRole("button", { name: /Bash/ });
const line = within(chat()).getByRole("button", { name: /Run a command/ });
expect(within(line).getByText("Run the tests")).toBeDefined();
expect(within(line).getByLabelText("Running")).toBeDefined();
act(() => {
if (call) {
Expand All @@ -93,13 +94,13 @@ describe("chat", () => {
act(() => {
fake.chat.ask(scout.id, "Bash", "rm -rf build", '{"command":"rm -rf build"}');
});
const card = screen.getByRole("region", { name: "Scout asks to use Bash" });
const card = screen.getByRole("region", { name: "Scout asks to run a command" });
expect(within(card).getByText("rm -rf build")).toBeDefined();
fireEvent.change(within(card).getByLabelText("Note for Scout if you deny"), {
target: { value: "keep the cache" },
});
fireEvent.click(within(card).getByRole("button", { name: "Deny" }));
expect(await within(chat()).findByText("You denied Bash")).toBeDefined();
expect(await within(chat()).findByText("Denied: run a command")).toBeDefined();
expect(fake.calls.at(-1)).toEqual({
method: "approvals.answer",
params: { approvalId: expect.any(String), allow: false, note: "keep the cache" },
Expand All @@ -108,9 +109,9 @@ describe("chat", () => {
act(() => {
fake.chat.ask(scout.id, "Write", "notes.md");
});
const next = screen.getByRole("region", { name: "Scout asks to use Write" });
const next = screen.getByRole("region", { name: "Scout asks to write a file" });
fireEvent.click(within(next).getByRole("button", { name: "Allow" }));
expect(await within(chat()).findByText("You allowed Write")).toBeDefined();
expect(await within(chat()).findByText("Allowed: write a file")).toBeDefined();
});

test("files are picked or dropped, shown before sending, and sent with the text", async () => {
Expand Down
67 changes: 67 additions & 0 deletions app/src/features/chat/toolNames.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { act, cleanup, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { en } from "../../i18n/en";
import { ptBR } from "../../i18n/pt-BR";
import { FakeBotloft } from "../../lib/fake";
import { crewOpened, openBot, renderApp } from "../../test/app";
import { toolAction, toolDetail, toolTitle } from "./toolNames";

afterEach(cleanup);

const call = (name: string, summary: string, input: unknown) => ({
name,
summary,
input: JSON.stringify(input),
});

describe("tool names", () => {
test("read as plain actions in the owner's language", () => {
expect(toolTitle("mcp__botloft__send_message", en.tools)).toBe("Send a message");
expect(toolTitle("mcp__botloft__browser_open", ptBR.tools)).toBe("Abrir uma página");
expect(toolTitle("Bash", ptBR.tools)).toBe("Rodar um comando");
expect(toolAction("Write", ptBR.tools)).toBe("escrever um arquivo");
// One with no name here keeps its own.
expect(toolTitle("mcp__other__frobnicate", en.tools)).toBe("frobnicate");
expect(toolAction("mcp__other__frobnicate", ptBR.tools)).toBe("usar frobnicate");
expect(toolTitle("constructor", en.tools)).toBe("constructor");
});

test("say what a call is about without English words, from its input", () => {
const message = call("mcp__botloft__send_message", "to @writer", { to: "writer", body: "hi" });
expect(toolDetail(message, ptBR.tools)).toBe("@writer");
const load = call("ToolSearch", "load send_message, Read", {
query: "select:mcp__botloft__send_message,Read",
});
expect(toolDetail(load, ptBR.tools)).toBe("Mandar uma mensagem, Ler um arquivo");
expect(toolDetail(call("TodoWrite", "Updated the plan", { todos: [] }), en.tools)).toBe("");
expect(toolDetail(call("mcp__botloft__complete_task", "task tsk_1", {}), en.tools)).toBe("");
expect(toolDetail(call("mcp__botloft__browser_click", "e4", { ref: "e4" }), en.tools)).toBe("");
const scroll = call("mcp__botloft__browser_scroll", "down", { to: "down" });
expect(toolDetail(scroll, ptBR.tools)).toBe("para baixo");
expect(toolDetail(call("Bash", "Run the tests", { command: "npm test" }), en.tools)).toBe(
"Run the tests",
);
});

test("the conversation list names the tool and the request", async () => {
const fake = new FakeBotloft();
const ops = fake.addCrew("Ops");
const scout = fake.addBot(ops.id, "Scout", "Finds sources");
fake.setBotState(scout.id, "busy");
renderApp(fake);
await crewOpened("Ops");
openBot("Scout");
const row = () => screen.getByRole("button", { name: /^Scout,/ });
act(() => {
fake.chat.tool(scout.id, "mcp__botloft__send_message", {
summary: "@writer",
input: '{"to":"writer","body":"hi"}',
});
});
expect(within(row()).getByText("Send a message · @writer")).toBeDefined();
act(() => {
fake.chat.ask(scout.id, "Bash", "rm -rf build", '{"command":"rm -rf build"}');
});
expect(within(row()).getByText("Waiting for approval: run a command")).toBeDefined();
});
});
79 changes: 79 additions & 0 deletions app/src/features/chat/toolNames.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// A tool in the owner's language (spec 15.6): its name as an action ("Send
// a message", not `send_message`) and what it is about. The daemon's
// summaries carry no wording; where one would need words, they are made
// here from the tool's input.

import type { Messages } from "../../i18n/en";

type Words = Messages["tools"];

/** `mcp__botloft__send_message` is `send_message`; built-ins keep theirs. */
export function toolKey(name: string): string {
return name.split("__").at(-1) ?? name;
}

function known(key: string, t: Words): string | undefined {
return Object.hasOwn(t.names, key) ? t.names[key as keyof Words["names"]] : undefined;
}

/** The tool as a title: "Send a message"; an unknown one, by its name. */
export function toolTitle(name: string, t: Words): string {
const key = toolKey(name);
return known(key, t) ?? key;
}

/** The tool inside a sentence: "wants to send a message". */
export function toolAction(name: string, t: Words): string {
const key = toolKey(name);
const title = known(key, t);
return title ? title.charAt(0).toLowerCase() + title.slice(1) : t.use(key);
}

function input(tool: { input: string }): Record<string, unknown> {
try {
const parsed: unknown = JSON.parse(tool.input);
return typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : {};
} catch {
return {};
}
}

/** What the call is about, after the title. */
export function toolDetail(
tool: { name: string; summary: string; input: string },
t: Words,
): string {
switch (toolKey(tool.name)) {
case "send_message": {
const to = input(tool).to;
return typeof to === "string" ? `@${to.replace(/^@/, "")}` : "";
}
// Refs like "e12" mean nothing to the owner: the browser panel shows
// what the bot clicked, by name.
case "complete_task":
case "TodoWrite":
case "browser_click":
case "browser_type":
case "browser_select":
return "";
case "browser_scroll": {
const to = input(tool).to;
return typeof to === "string" && Object.hasOwn(t.scroll, to)
? t.scroll[to as keyof Words["scroll"]]
: "";
}
case "ToolSearch": {
const query = input(tool).query;
if (typeof query === "string" && query.startsWith("select:")) {
return query
.slice("select:".length)
.split(",")
.map((name) => toolTitle(name.trim(), t))
.join(", ");
}
return tool.summary;
}
default:
return tool.summary;
}
}
22 changes: 15 additions & 7 deletions app/src/features/crews/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { AccountArea } from "../account/AccountArea";
import { BotAvatar, moodOf } from "../bots/BotAvatar";
import { BotStateBadge, stateView } from "../bots/BotStateBadge";
import { ChiefBadge, isChief } from "../bots/ChiefBadge";
import { toolAction, toolTitle } from "../chat/toolNames";
import { CrewDialog } from "./CrewDialog";

/** Crews as sections and their bots as conversations (spec 15.1). */
Expand Down Expand Up @@ -113,9 +114,7 @@ function Conversation({ bot, crew }: { bot: Bot; crew: Crew }) {
<span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs">
<BotStateBadge bot={bot} crewPaused={crew.paused} compact />
<span className="min-w-0 truncate text-muted">
{activity
? activityText(activity, t.crews.sidebar)
: bot.role || t.crews.sidebar.noMessages}
{activity ? activityText(activity, t) : bot.role || t.crews.sidebar.noMessages}
</span>
</span>
</span>
Expand All @@ -125,13 +124,22 @@ function Conversation({ bot, crew }: { bot: Bot; crew: Crew }) {
}

/** The conversation-list line, worded here from what the daemon sends. */
function activityText(activity: Activity, t: Messages["crews"]["sidebar"]): string {
/** The line in the owner's language: the daemon sends no wording. */
function activityText(activity: Activity, t: Messages): string {
const { text, tool } = activity;
switch (activity.kind) {
case "owner":
return t.fromOwner(activity.text);
return t.crews.sidebar.fromOwner(text);
case "approval":
return t.awaitingApproval(activity.text);
return t.crews.sidebar.awaitingApproval(tool ? toolAction(tool, t.tools) : text);
case "tool": {
if (!tool) {
return text;
}
const title = toolTitle(tool, t.tools);
return text ? `${title} · ${text}` : title;
}
default:
return activity.text;
return text;
}
}
10 changes: 5 additions & 5 deletions app/src/i18n/en/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,18 +61,18 @@ export const chat = {
error: "Error",
},
approval: {
asks: (bot: string, tool: string) => `${bot} asks to use ${tool}`,
wants: (bot: string, tool: string) => `${bot} wants to use ${tool}`,
asks: (bot: string, action: string) => `${bot} asks to ${action}`,
wants: (bot: string, action: string) => `${bot} wants to ${action}`,
fullInput: "Full input",
noteLabel: (bot: string) => `Note for ${bot} if you deny`,
notePlaceholder: "Why not? Sent to the bot if you deny (optional)",
allow: "Allow",
deny: "Deny",
allowFailed: "Could not allow it",
denyFailed: "Could not deny it",
allowed: (tool: string) => `You allowed ${tool}`,
denied: (tool: string) => `You denied ${tool}`,
expired: (tool: string) => `${tool} was not approved in time`,
allowed: (action: string) => `Allowed: ${action}`,
denied: (action: string) => `Denied: ${action}`,
expired: (action: string) => `Not answered in time: ${action}`,
},
mode: {
title: "Mode",
Expand Down
2 changes: 2 additions & 0 deletions app/src/i18n/en/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { onboarding } from "./onboarding";
import { routines } from "./routines";
import { screens } from "./screens";
import { shell } from "./shell";
import { tools } from "./tools";
import { updates } from "./updates";

export const en = {
Expand All @@ -28,6 +29,7 @@ export const en = {
messages,
routines,
screens,
tools,
};

export type Messages = typeof en;
Loading
Loading