diff --git a/app/src/activity.css b/app/src/activity.css
new file mode 100644
index 00000000..470560fd
--- /dev/null
+++ b/app/src/activity.css
@@ -0,0 +1,40 @@
+/* What a bot is doing right now (features/chat/ToolGroup.tsx): a light
+ sweeps across the name of the call it is on. */
+
+.activity-shine {
+ background:
+ linear-gradient(
+ 90deg,
+ var(--muted) 0%,
+ var(--muted) 35%,
+ var(--ink) 50%,
+ var(--muted) 65%,
+ var(--muted) 100%
+ )
+ 0 0 / 250% 100%;
+ background-clip: text;
+ color: transparent;
+ animation: activity-shine 2.2s linear infinite;
+}
+
+@keyframes activity-shine {
+ from {
+ background-position: 100% 0;
+ }
+ to {
+ background-position: 0% 0;
+ }
+}
+
+/* Without motion, the name stands still in plain ink. */
+@media (prefers-reduced-motion: reduce) {
+ .activity-shine {
+ background: none;
+ color: var(--ink);
+ }
+}
+
+:root[data-motion="less"] .activity-shine {
+ background: none;
+ color: var(--ink);
+}
diff --git a/app/src/features/browser/browser.test.tsx b/app/src/features/browser/browser.test.tsx
index 39750efa..065ce76c 100644
--- a/app/src/features/browser/browser.test.tsx
+++ b/app/src/features/browser/browser.test.tsx
@@ -233,6 +233,9 @@ describe("browser panel", () => {
});
fake.chat.tool(scout.id, "Bash", { summary: "ls", status: "done" });
await openScout(fake);
+ fireEvent.click(
+ screen.getByRole("button", { name: "Took 1 step in the browser, ran 1 command" }),
+ );
expect(screen.getAllByRole("button", { name: /Watch in browser/ })).toHaveLength(1);
fireEvent.click(screen.getByRole("button", { name: "Watch in browser: Open a page" }));
expect(await screen.findByRole("complementary", { name: "Scout's browser" })).toBeDefined();
diff --git a/app/src/features/chat/BotRun.tsx b/app/src/features/chat/BotRun.tsx
index 5e844c27..443dfe86 100644
--- a/app/src/features/chat/BotRun.tsx
+++ b/app/src/features/chat/BotRun.tsx
@@ -15,7 +15,7 @@ import { Markdown } from "./Markdown";
import { runParts, splitDraft } from "./rows";
import { SharedFiles } from "./SharedFiles";
import { isSharedFiles } from "./shared";
-import { ToolLines } from "./ToolLines";
+import { ToolGroup } from "./ToolGroup";
export interface Live {
/** Text of the reply being written. */
@@ -108,6 +108,12 @@ function BotRunView({
live?: Live | undefined;
}) {
const first = items[0];
+ const parts = runParts(items);
+ // The bot is still on the last group of calls until it writes or stops.
+ const busy = Boolean(live?.working && !live.draft);
+ const tail = parts.at(-1)?.[0];
+ // Its line already says it is working.
+ const onCalls = tail?.body.kind === "tool" && !isSharedFiles(tail);
return (
// A turn shows up as the bot starts it, still empty; what it does then
// arrives inside, so a run with items never animates as a whole.
@@ -123,14 +129,14 @@ function BotRunView({
)}
- {runParts(items).map((part) => {
+ {parts.map((part, index) => {
const head = part[0] as ChatItem;
switch (head.body.kind) {
case "tool":
return isSharedFiles(head) ? (
) : (
-
+
);
case "reply":
// It was already on screen as it was written.
@@ -158,7 +164,7 @@ function BotRunView({
}
})}
{live?.draft && }
- {live && !live.draft && live.working && }
+ {busy && !onCalls && }
);
diff --git a/app/src/features/chat/InboundRow.tsx b/app/src/features/chat/InboundRow.tsx
index 0c8e0cee..44a5acad 100644
--- a/app/src/features/chat/InboundRow.tsx
+++ b/app/src/features/chat/InboundRow.tsx
@@ -1,8 +1,8 @@
// A message to the bot, in its chat: the owner's on the right in a bubble,
// another bot's or Botloft's on the left under its name (spec 15.3).
-import { AlarmClock, ListTodo, Reply } from "lucide-react";
-import { memo } from "react";
+import { AlarmClock, ChevronRight, ListTodo, Reply } from "lucide-react";
+import { memo, useState } from "react";
import { useT } from "../../i18n";
import { when } from "../../lib/format";
import type { Bot, Message } from "../../lib/protocol.gen";
@@ -11,6 +11,7 @@ import { useArrival } from "../../ui/motion";
import { BOTLOFT_COLOR, BotAvatar } from "../bots/BotAvatar";
import { DeliveryStatus } from "../messages/DeliveryStatus";
import { AttachmentList } from "./AttachmentList";
+import { Mentions } from "./mentions";
function Time({ at }: { at: number }) {
return (
@@ -31,7 +32,7 @@ function OwnerMessage({ message, bot }: { message: Message; bot: Bot }) {
className="max-w-[36rem] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-sunken px-4 py-2.5 leading-relaxed"
data-selectable
>
- {message.body}
+
diff --git a/app/src/features/chat/Markdown.tsx b/app/src/features/chat/Markdown.tsx
index f1cdaaca..a62b3ca8 100644
--- a/app/src/features/chat/Markdown.tsx
+++ b/app/src/features/chat/Markdown.tsx
@@ -10,6 +10,7 @@ import remarkGfm from "remark-gfm";
import { useT } from "../../i18n";
import { useHost } from "../../store/context";
import { attempt } from "../../ui/toast";
+import { remarkMentions } from "./mentions";
function isWeb(href: string | undefined): href is string {
return href !== undefined && /^https?:\/\/\S+$/i.test(href);
@@ -55,6 +56,8 @@ function Picture({ src, alt }: { src: string | Blob | undefined; alt: string | u
);
}
+const plugins = [remarkGfm, remarkMentions];
+
const components: Components = {
a: ({ href, children }) => {children},
img: ({ src, alt }) => ,
@@ -70,7 +73,7 @@ export const Markdown = memo(function Markdown({
}) {
return (
-
+
{text}
diff --git a/app/src/features/chat/ToolGroup.tsx b/app/src/features/chat/ToolGroup.tsx
new file mode 100644
index 00000000..c449552d
--- /dev/null
+++ b/app/src/features/chat/ToolGroup.tsx
@@ -0,0 +1,80 @@
+// Tool calls that ran together, folded into one line (spec 15.3). While
+// the bot works, the line says what it is doing now and changes with each
+// call; once it is done, the line sums the group up. The arrow opens every
+// call, each with its own line.
+
+import { ChevronRight, LoaderCircle } from "lucide-react";
+import { useState } from "react";
+import { useT } from "../../i18n";
+import type { ChatItem, ToolItem } from "../../lib/protocol.gen";
+import { ToolLines } from "./ToolLines";
+import { toolDetail, toolTitle } from "./toolNames";
+import { toolSummary } from "./toolSummary";
+
+/** The call the bot is on, swapped in as the next one starts. */
+function Now({ tool }: { tool: ToolItem }) {
+ const t = useT();
+ const detail = tool.explanation || toolDetail(tool, t.tools);
+ return (
+
+
+ {toolTitle(tool.name, t.tools)}
+ {detail && (
+
+ {detail}
+
+ )}
+
+ );
+}
+
+export function ToolGroup({ items, active }: { items: ChatItem[]; active: boolean }) {
+ const t = useT();
+ const [open, setOpen] = useState(false);
+ const calls = items.flatMap((item) =>
+ item.body.kind === "tool" ? [{ id: item.id, tool: item.body }] : [],
+ );
+ const now = calls.at(-1);
+ // One finished call is already a single line.
+ if (!now || (!active && calls.length === 1)) {
+ return ;
+ }
+ return (
+
+
+ {open && (
+
+
+
+ )}
+
+ );
+}
diff --git a/app/src/features/chat/chat.test.tsx b/app/src/features/chat/chat.test.tsx
index a07a0a9e..7d1b9782 100644
--- a/app/src/features/chat/chat.test.tsx
+++ b/app/src/features/chat/chat.test.tsx
@@ -113,8 +113,10 @@ describe("chat", () => {
fake.chat.turn(scout.id);
fake.setBotState(scout.id, "idle");
});
- expect(within(line).getByLabelText("Done")).toBeDefined();
- fireEvent.click(line);
+ // Done, the call is a line of its own again.
+ const ran = within(chat()).getByRole("button", { name: /Run a command/ });
+ expect(within(ran).getByLabelText("Done")).toBeDefined();
+ fireEvent.click(ran);
expect(within(chat()).getByText("12 passed")).toBeDefined();
expect(within(chat()).getByText("Command")).toBeDefined();
expect(within(chat()).getByText("npm test")).toBeDefined();
diff --git a/app/src/features/chat/mentions.test.tsx b/app/src/features/chat/mentions.test.tsx
new file mode 100644
index 00000000..42d88a1b
--- /dev/null
+++ b/app/src/features/chat/mentions.test.tsx
@@ -0,0 +1,41 @@
+import { cleanup, render } from "@testing-library/react";
+import { afterEach, describe, expect, test } from "vitest";
+import { FakeHost } from "../../lib/fakeHost";
+import { HostProvider } from "../../store/context";
+import { Markdown } from "./Markdown";
+import { splitMentions } from "./mentions";
+
+afterEach(cleanup);
+
+const mentions = (text: string) =>
+ splitMentions(text)
+ .filter((part) => part.mention)
+ .map((part) => part.text);
+
+describe("mentions", () => {
+ test("are handles on their own, not e-mails or paths", () => {
+ expect(mentions("@manychat confirmed, ask @atendente-2.")).toEqual([
+ "@manychat",
+ "@atendente-2",
+ ]);
+ expect(mentions("(@writer) and @Writer")).toEqual(["@writer", "@Writer"]);
+ expect(mentions("mail ana@example.com or see C:\\x\\@cache and node_modules/@types")).toEqual(
+ [],
+ );
+ expect(splitMentions("hi @scout!").map((part) => part.text)).toEqual(["hi ", "@scout", "!"]);
+ });
+
+ test("stand out in a reply, but not inside code or links", () => {
+ const { container } = render(
+
+
+ ,
+ );
+ const marked = [...container.querySelectorAll(".chat-mention")].map((node) => node.textContent);
+ expect(marked).toEqual(["@manychat"]);
+ });
+});
diff --git a/app/src/features/chat/mentions.tsx b/app/src/features/chat/mentions.tsx
new file mode 100644
index 00000000..16081f4d
--- /dev/null
+++ b/app/src/features/chat/mentions.tsx
@@ -0,0 +1,74 @@
+// A bot named by its handle ("@writer") stands out in what bots write, so
+// it is not lost in the middle of a sentence (spec 15.3). An e-mail address
+// or a path with an @ inside is not a mention.
+
+import type { ReactNode } from "react";
+
+const MENTION = /(^|[^\w@./\\-])(@[a-z0-9][\w-]*)/gi;
+
+/** `text` cut into plain parts and mentions. */
+export function splitMentions(text: string): { text: string; mention: boolean }[] {
+ const parts: { text: string; mention: boolean }[] = [];
+ let from = 0;
+ for (const match of text.matchAll(MENTION)) {
+ const start = match.index + (match[1] as string).length;
+ if (start > from) {
+ parts.push({ text: text.slice(from, start), mention: false });
+ }
+ const mention = match[2] as string;
+ parts.push({ text: mention, mention: true });
+ from = start + mention.length;
+ }
+ if (from < text.length) {
+ parts.push({ text: text.slice(from), mention: false });
+ }
+ return parts;
+}
+
+/** Plain text with its mentions marked. */
+export function Mentions({ text }: { text: string }): ReactNode {
+ return splitMentions(text).map((part, index) =>
+ part.mention ? (
+ // biome-ignore lint/suspicious/noArrayIndexKey: the parts of one text never move
+
+ {part.text}
+
+ ) : (
+ part.text
+ ),
+ );
+}
+
+interface Node {
+ type: string;
+ value?: string;
+ children?: Node[];
+ data?: { hName: string; hProperties: { className: string[] } };
+}
+
+function mark(node: Node) {
+ // A link already stands out, and code is shown as it was written.
+ if (!node.children || node.type === "link" || node.type === "linkReference") {
+ return;
+ }
+ node.children = node.children.flatMap((child): Node[] => {
+ if (child.type !== "text" || child.value === undefined) {
+ mark(child);
+ return [child];
+ }
+ return splitMentions(child.value).map((part) =>
+ part.mention
+ ? {
+ type: "mention",
+ data: { hName: "span", hProperties: { className: ["chat-mention"] } },
+ children: [{ type: "text", value: part.text }],
+ }
+ : { type: "text", value: part.text },
+ );
+ });
+}
+
+/** A remark plugin that marks the mentions of a markdown reply. */
+export function remarkMentions() {
+ return (tree: Node) => mark(tree);
+}
diff --git a/app/src/features/chat/toolGroup.test.tsx b/app/src/features/chat/toolGroup.test.tsx
new file mode 100644
index 00000000..81b767d0
--- /dev/null
+++ b/app/src/features/chat/toolGroup.test.tsx
@@ -0,0 +1,90 @@
+import { act, cleanup, fireEvent, 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 { toolSummary } from "./toolSummary";
+
+afterEach(cleanup);
+
+const done = (name: string, failed = false) => ({
+ name,
+ status: failed ? ("failed" as const) : ("done" as const),
+});
+
+describe("a group of tool calls", () => {
+ test("sums up in one line, by kind and in order", () => {
+ const calls = [
+ done("mcp__botloft__browser_open"),
+ done("mcp__botloft__browser_look"),
+ done("Bash", true),
+ done("Read"),
+ done("Bash"),
+ ];
+ expect(toolSummary(calls, en.chat.tools.group)).toBe(
+ "Took 2 steps in the browser, ran 2 commands, read 1 file (1 failed)",
+ );
+ expect(toolSummary(calls, ptBR.chat.tools.group)).toBe(
+ "2 passos no navegador, 2 comandos rodados, 1 arquivo lido (1 falha)",
+ );
+ expect(toolSummary([done("mcp__other__frobnicate")], en.chat.tools.group)).toBe("Used 1 tool");
+ });
+
+ test("says what the bot does now, then folds into its summary", 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 chat = await screen.findByRole("list", { name: "Messages" });
+
+ let read = fake.chat.items[0];
+ act(() => {
+ read = fake.chat.tool(scout.id, "Read", { summary: "notes.md" });
+ });
+ // One line for the group, on the call it is on, with no dots under it.
+ const line = within(chat).getByRole("button", { name: /Read a file/ });
+ expect(line.getAttribute("aria-expanded")).toBe("false");
+ expect(within(line).getByLabelText("Running")).toBeDefined();
+ expect(within(chat).queryByRole("status", { name: "Working" })).toBeNull();
+
+ act(() => {
+ if (read) {
+ fake.chat.finish(read, "# Notes");
+ }
+ fake.chat.tool(scout.id, "Bash", { summary: "npm test", explanation: "Run the tests" });
+ });
+ expect(within(chat).queryByRole("button", { name: /Read a file/ })).toBeNull();
+ const now = within(chat).getByRole("button", { name: /Run a command/ });
+ expect(within(now).getByText("Run the tests")).toBeDefined();
+
+ act(() => {
+ fake.chat.turn(scout.id);
+ fake.setBotState(scout.id, "idle");
+ });
+ const summary = within(chat).getByRole("button", { name: "Read 1 file, ran 1 command" });
+ fireEvent.click(summary);
+ expect(summary.getAttribute("aria-expanded")).toBe("true");
+ const calls = within(chat).getByRole("list", { name: "Tool calls" });
+ expect(within(calls).getAllByRole("listitem")).toHaveLength(2);
+ expect(within(calls).getByText("Run the tests")).toBeDefined();
+ });
+
+ test("a single finished call stays its own line", async () => {
+ const fake = new FakeBotloft();
+ const ops = fake.addCrew("Ops");
+ const scout = fake.addBot(ops.id, "Scout", "Finds sources");
+ fake.setBotState(scout.id, "idle");
+ fake.chat.tool(scout.id, "Bash", { summary: "ls", status: "done" });
+ fake.chat.turn(scout.id);
+ renderApp(fake);
+ await crewOpened("Ops");
+ openBot("Scout");
+ const chat = await screen.findByRole("list", { name: "Messages" });
+ const line = within(chat).getByRole("button", { name: /Run a command/ });
+ expect(within(line).getByLabelText("Done")).toBeDefined();
+ });
+});
diff --git a/app/src/features/chat/toolSummary.ts b/app/src/features/chat/toolSummary.ts
new file mode 100644
index 00000000..48c14e36
--- /dev/null
+++ b/app/src/features/chat/toolSummary.ts
@@ -0,0 +1,47 @@
+// A finished group of tool calls in one short line (spec 15.3): what kind
+// of work it was, counted, in the order the bot did it, and how many
+// calls failed.
+
+import type { Messages } from "../../i18n/en";
+import type { ToolItem } from "../../lib/protocol.gen";
+import { toolKey } from "./toolNames";
+
+type Words = Messages["chat"]["tools"]["group"];
+type Kind = Exclude;
+
+const KINDS: Record = {
+ Bash: "command",
+ PowerShell: "command",
+ BashOutput: "command",
+ KillShell: "command",
+ KillBash: "command",
+ Read: "read",
+ LS: "read",
+ Write: "edit",
+ Edit: "edit",
+ MultiEdit: "edit",
+ NotebookEdit: "edit",
+ Grep: "search",
+ Glob: "search",
+ WebFetch: "web",
+ WebSearch: "web",
+ send_message: "message",
+};
+
+function kindOf(name: string): Kind {
+ const key = toolKey(name);
+ return KINDS[key] ?? (key.startsWith("browser") ? "browser" : "other");
+}
+
+/** "Ran 4 commands, read 2 files (1 failed)". */
+export function toolSummary(tools: Pick[], words: Words): string {
+ const counts = new Map();
+ for (const tool of tools) {
+ const kind = kindOf(tool.name);
+ counts.set(kind, (counts.get(kind) ?? 0) + 1);
+ }
+ const text = [...counts].map(([kind, count]) => words[kind](count)).join(", ");
+ const failed = tools.filter((tool) => tool.status === "failed").length;
+ const line = text.charAt(0).toUpperCase() + text.slice(1);
+ return failed > 0 ? `${line} (${words.failed(failed)})` : line;
+}
diff --git a/app/src/features/files/files.test.tsx b/app/src/features/files/files.test.tsx
index 4e339844..02d99f06 100644
--- a/app/src/features/files/files.test.tsx
+++ b/app/src/features/files/files.test.tsx
@@ -118,6 +118,9 @@ describe("files panel", () => {
fake.chat.tool(scout.id, "Bash", { summary: "ls", status: "done" });
await openScout(fake);
expect(screen.queryByRole("complementary", { name: "Files from Scout" })).toBeNull();
+ fireEvent.click(
+ screen.getByRole("button", { name: "Edited 2 files, ran 1 command (1 failed)" }),
+ );
// Only a call that changed a file, and worked, has the button.
expect(screen.getAllByRole("button", { name: /Show in files/ })).toHaveLength(1);
diff --git a/app/src/features/routines/routines.test.tsx b/app/src/features/routines/routines.test.tsx
index fa1e4f6c..75790d8f 100644
--- a/app/src/features/routines/routines.test.tsx
+++ b/app/src/features/routines/routines.test.tsx
@@ -175,7 +175,14 @@ describe("routines", () => {
void fake.call("routines.runNow", { routineId: id as string });
});
openTab("Chat");
- expect(await screen.findByText("Routine · Morning summary")).toBeDefined();
+ // Only its name, until the owner opens what it asks.
+ const tag = await screen.findByRole("button", { name: "Routine · Morning summary" });
+ const prompt = fake.routines.routines.get(id as string)?.prompt as string;
+ const chat = screen.getByRole("list", { name: "Messages" });
+ expect(within(chat).queryByText(prompt)).toBeNull();
+ fireEvent.click(tag);
+ expect(tag.getAttribute("aria-expanded")).toBe("true");
+ expect(within(chat).getByText(prompt)).toBeDefined();
});
test("the crew's page lists every bot's routines", async () => {
diff --git a/app/src/features/screens/screens.test.tsx b/app/src/features/screens/screens.test.tsx
index 74a501cd..88a98870 100644
--- a/app/src/features/screens/screens.test.tsx
+++ b/app/src/features/screens/screens.test.tsx
@@ -154,6 +154,7 @@ describe("design area", () => {
status: "done",
});
await openScout(fake);
+ fireEvent.click(screen.getByRole("button", { name: "Edited 2 files" }));
expect(screen.getAllByRole("button", { name: /Show in screens/ })).toHaveLength(1);
fireEvent.click(screen.getByRole("button", { name: "Show in screens: about.html" }));
expect(await within(panel()).findByRole("button", { name: "All screens" })).toBeDefined();
diff --git a/app/src/i18n/en/chat.ts b/app/src/i18n/en/chat.ts
index 35b3ce44..8a7f0f61 100644
--- a/app/src/i18n/en/chat.ts
+++ b/app/src/i18n/en/chat.ts
@@ -50,6 +50,7 @@ export const chat = {
task: "Task",
result: "Result",
goneBot: "A bot no longer in the crew",
+ routine: "Show what the routine asks",
},
run: {
working: "Working",
@@ -71,6 +72,20 @@ export const chat = {
error: "Error",
command: "Command",
commandCut: "This command is too long to show in full.",
+ /** A finished group of calls in one line: "Ran 4 commands, read 2 files". */
+ group: {
+ command: (n: number) => (n === 1 ? "ran 1 command" : `ran ${n} commands`),
+ read: (n: number) => (n === 1 ? "read 1 file" : `read ${n} files`),
+ edit: (n: number) => (n === 1 ? "edited 1 file" : `edited ${n} files`),
+ search: (n: number) =>
+ n === 1 ? "searched the files once" : `searched the files ${n} times`,
+ web: (n: number) => (n === 1 ? "read 1 web page" : `read ${n} web pages`),
+ browser: (n: number) =>
+ n === 1 ? "took 1 step in the browser" : `took ${n} steps in the browser`,
+ message: (n: number) => (n === 1 ? "sent 1 message" : `sent ${n} messages`),
+ other: (n: number) => (n === 1 ? "used 1 tool" : `used ${n} tools`),
+ failed: (n: number) => `${n} failed`,
+ },
},
approval: {
asks: (bot: string, action: string) => `${bot} asks to ${action}`,
diff --git a/app/src/i18n/es/chat.ts b/app/src/i18n/es/chat.ts
index 9ec6508d..b618370c 100644
--- a/app/src/i18n/es/chat.ts
+++ b/app/src/i18n/es/chat.ts
@@ -46,6 +46,7 @@ export const chat: Messages["chat"] = {
task: "Tarea",
result: "Resultado",
goneBot: "Un bot que ya no está en el equipo",
+ routine: "Mostrar lo que pide la rutina",
},
run: {
working: "Trabajando",
@@ -67,6 +68,18 @@ export const chat: Messages["chat"] = {
error: "Error",
command: "Comando",
commandCut: "Este comando es demasiado largo para mostrarse entero.",
+ group: {
+ command: (n: number) => (n === 1 ? "1 comando ejecutado" : `${n} comandos ejecutados`),
+ read: (n: number) => (n === 1 ? "1 archivo leído" : `${n} archivos leídos`),
+ edit: (n: number) => (n === 1 ? "1 archivo editado" : `${n} archivos editados`),
+ search: (n: number) =>
+ n === 1 ? "1 búsqueda en los archivos" : `${n} búsquedas en los archivos`,
+ web: (n: number) => (n === 1 ? "1 página web leída" : `${n} páginas web leídas`),
+ browser: (n: number) => (n === 1 ? "1 paso en el navegador" : `${n} pasos en el navegador`),
+ message: (n: number) => (n === 1 ? "1 mensaje enviado" : `${n} mensajes enviados`),
+ other: (n: number) => (n === 1 ? "1 herramienta usada" : `${n} herramientas usadas`),
+ failed: (n: number) => (n === 1 ? "1 fallo" : `${n} fallos`),
+ },
},
approval: {
asks: (bot: string, action: string) => `${bot} pide ${action}`,
diff --git a/app/src/i18n/pt-BR/chat.ts b/app/src/i18n/pt-BR/chat.ts
index c153690b..269827b3 100644
--- a/app/src/i18n/pt-BR/chat.ts
+++ b/app/src/i18n/pt-BR/chat.ts
@@ -46,6 +46,7 @@ export const chat: Messages["chat"] = {
task: "Tarefa",
result: "Resultado",
goneBot: "Um bot que não está mais na equipe",
+ routine: "Mostrar o que a rotina pede",
},
run: {
working: "Trabalhando",
@@ -67,6 +68,17 @@ export const chat: Messages["chat"] = {
error: "Erro",
command: "Comando",
commandCut: "Este comando é longo demais para aparecer inteiro.",
+ group: {
+ command: (n: number) => (n === 1 ? "1 comando rodado" : `${n} comandos rodados`),
+ read: (n: number) => (n === 1 ? "1 arquivo lido" : `${n} arquivos lidos`),
+ edit: (n: number) => (n === 1 ? "1 arquivo editado" : `${n} arquivos editados`),
+ search: (n: number) => (n === 1 ? "1 busca nos arquivos" : `${n} buscas nos arquivos`),
+ web: (n: number) => (n === 1 ? "1 página da web lida" : `${n} páginas da web lidas`),
+ browser: (n: number) => (n === 1 ? "1 passo no navegador" : `${n} passos no navegador`),
+ message: (n: number) => (n === 1 ? "1 mensagem enviada" : `${n} mensagens enviadas`),
+ other: (n: number) => (n === 1 ? "1 ferramenta usada" : `${n} ferramentas usadas`),
+ failed: (n: number) => (n === 1 ? "1 falha" : `${n} falhas`),
+ },
},
approval: {
asks: (bot: string, action: string) => `${bot} pede para ${action}`,
diff --git a/app/src/index.css b/app/src/index.css
index 9c7ebeb1..f3e6150e 100644
--- a/app/src/index.css
+++ b/app/src/index.css
@@ -14,6 +14,7 @@
@import "./color.css";
@import "./files.css";
@import "./search.css";
+@import "./activity.css";
@import "./motion-less.css";
@import "./paused.css";
@import "./offscreen.css";
@@ -42,6 +43,9 @@
/* The check marks of a message the bot has read: a lighter blue than
--work, still 3:1 or more on canvas, panel and sunken in both themes. */
--read: #1787c9;
+ /* A bot named in what bots write ("@writer"): a light blue that still
+ reads at 4.5:1, since it colors words. */
+ --mention: #0e6aa0;
/* The thin edge around a bot's avatar, so light colors hold their shape. */
--avatar-edge: rgb(0 0 0 / 0.2);
/* Under menus, pickers and dialogs. */
@@ -70,6 +74,7 @@
--danger: #ff6d60;
--idle-state: #8c8c86;
--read: #5cc4f2;
+ --mention: #5cc4f2;
--avatar-edge: rgb(255 255 255 / 0.14);
--lift: rgb(0 0 0 / 0.5);
--scrim: rgb(0 0 0 / 0.6);
@@ -271,6 +276,11 @@
font-weight: 600;
}
+ .chat-mention {
+ color: var(--mention);
+ font-weight: 500;
+ }
+
.chat-md input[type="checkbox"] {
margin-right: 0.4em;
}
diff --git a/app/src/ui/tokens.test.ts b/app/src/ui/tokens.test.ts
index 4883f4af..15d6419b 100644
--- a/app/src/ui/tokens.test.ts
+++ b/app/src/ui/tokens.test.ts
@@ -38,6 +38,7 @@ const TEXT = [
"warn",
"danger",
"idle-state",
+ "mention",
];
describe.each([
diff --git a/docs/spec.md b/docs/spec.md
index 5271c28b..ca1c49fe 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -883,7 +883,8 @@ No chat:
- O dono fala em balões à direita; o bot, à esquerda, com markdown.
- Embaixo da mensagem do dono, e em cada linha da timeline da crew, fica onde ela está (9.1), com ícone e texto: esperando, entregando, "Entregue" com um tique e, quando o bot começou a trabalhar nela, "Lida" com dois tiques. Os dois tiques de "Lida" ficam em azul-claro, como nos apps de mensagem, para o dono ver de relance que o bot leu; a palavra continua na cor apagada das outras linhas, e é ela que diz o estado. A cor é o token `--read` (`#1787c9` no tema claro, `#5cc4f2` no escuro): mais clara que o `--work`, que é de bot trabalhando e de mensagem sendo entregue, e com contraste de ao menos 3:1 sobre `canvas`, `panel` e `sunken` nos dois temas, o que um teste confere. O azul dos apps de mensagem (`#53bdeb`) não passa de 2,2:1 no tema claro, por isso o claro usa um tom mais fechado.
- Mensagens de outros bots aparecem à esquerda, com o avatar e o nome de quem mandou.
-- O que o bot faz com as ferramentas aparece em linhas compactas (ícone, ferramenta, resumo e estado), agrupadas por turno, que abrem para mostrar entrada e saída.
+- Um bot citado pelo handle ("@writer") nas respostas e nas messages fica em azul-claro (token `--mention`, que ainda lê a 4,5:1) e um pouco mais forte, para não se perder no texto. E-mail, caminho e o que está em código ou link não contam.
+- O que o bot faz com as ferramentas aparece em linhas compactas (ícone, ferramenta, resumo e estado), que abrem para mostrar entrada e saída. As chamadas seguidas ficam dobradas numa linha só, com uma seta para abrir todas: enquanto o bot trabalha nelas, a linha diz a chamada em que ele está (o nome com um brilho passando e um círculo girando, sem os três pontos embaixo) e troca, subindo, a cada chamada nova; quando ele escreve, pede algo ou para, a linha vira um resumo por tipo, na ordem em que fez, com as falhas no fim ("2 passos no navegador, 1 comando rodado (1 falha)"). Uma chamada sozinha que já terminou fica na sua própria linha. Com menos movimento, o nome fica parado.
- Pedido de aprovação é um cartão com o que o bot quer fazer e os botões Permitir e Negar.
- Pedido para seguir com um plano (10.1) é um cartão com o plano em markdown, um campo para o que deve mudar e os botões Aprovar plano e Pedir mudanças. Respondido, vira uma linha que abre o plano de novo.
- Anexos aparecem como miniatura (imagem) ou cartão com nome, tipo e tamanho.
@@ -1196,7 +1197,7 @@ Sem jargão (15.2): o dono não vê "cron", "overlap" nem "timezone" no caminho
- "Se a anterior ainda não terminou": pular ou esperar a vez;
- "Se o computador estava desligado na hora": rodar quando ligar, ou pular;
- "Avançado": expressão cron.
-- No chat, a message da rotina aparece com a etiqueta "Rotina · ".
+- No chat, a message da rotina aparece só com a etiqueta "Rotina · " e uma seta; o texto que a rotina manda fica dobrado até o dono abrir.
- Na página da crew, uma aba com as rotinas de todos os bots dela.
- Uma execução `failed` entra na marca da barra de tarefas (15.2) até o dono abrir o bot. O app guarda quando o dono abriu cada bot (`localStorage`, `botloft.seen`); um bot aberto não marca.
- O store carrega `routines.list` a cada conexão e segue `routine.changed` e `routine.run`.