From 3811b7e1e9ae2de080d42cab02f45b5b3f12f0b5 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:35:07 -0300 Subject: [PATCH 1/3] feat(chat): fold a run's tool calls into one line While the bot works, the line names the call it is on, with a light sweeping across it, and changes with each new call. Once it is done, the line sums the calls up by kind, with the failures at the end. The arrow opens every call. A single finished call keeps its own line. --- app/src/activity.css | 40 ++++++++++ app/src/features/browser/browser.test.tsx | 3 + app/src/features/chat/BotRun.tsx | 14 +++- app/src/features/chat/ToolGroup.tsx | 80 ++++++++++++++++++++ app/src/features/chat/chat.test.tsx | 6 +- app/src/features/chat/toolGroup.test.tsx | 90 +++++++++++++++++++++++ app/src/features/chat/toolSummary.ts | 47 ++++++++++++ app/src/features/files/files.test.tsx | 3 + app/src/features/screens/screens.test.tsx | 1 + app/src/i18n/en/chat.ts | 14 ++++ app/src/i18n/es/chat.ts | 12 +++ app/src/i18n/pt-BR/chat.ts | 11 +++ app/src/index.css | 1 + docs/spec.md | 2 +- 14 files changed, 317 insertions(+), 7 deletions(-) create mode 100644 app/src/activity.css create mode 100644 app/src/features/chat/ToolGroup.tsx create mode 100644 app/src/features/chat/toolGroup.test.tsx create mode 100644 app/src/features/chat/toolSummary.ts 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/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/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/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..6d00d5a4 100644 --- a/app/src/i18n/en/chat.ts +++ b/app/src/i18n/en/chat.ts @@ -71,6 +71,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..9d59b894 100644 --- a/app/src/i18n/es/chat.ts +++ b/app/src/i18n/es/chat.ts @@ -67,6 +67,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..5f9ab109 100644 --- a/app/src/i18n/pt-BR/chat.ts +++ b/app/src/i18n/pt-BR/chat.ts @@ -67,6 +67,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..4ed0d2d9 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"; diff --git a/docs/spec.md b/docs/spec.md index 5271c28b..1e42fc56 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -883,7 +883,7 @@ 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. +- 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. From 9540614e4a59541352f4104936236fb9f959a3f8 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:35:07 -0300 Subject: [PATCH 2/3] feat(chat): show a routine's message by its name, folded The tag with the routine's name opens what the routine asks; until then the chat only shows the name. --- app/src/features/chat/InboundRow.tsx | 66 +++++++++++++++------ app/src/features/routines/routines.test.tsx | 9 ++- app/src/i18n/en/chat.ts | 1 + app/src/i18n/es/chat.ts | 1 + app/src/i18n/pt-BR/chat.ts | 1 + docs/spec.md | 2 +- 6 files changed, 60 insertions(+), 20 deletions(-) diff --git a/app/src/features/chat/InboundRow.tsx b/app/src/features/chat/InboundRow.tsx index 0c8e0cee..c0b2c57e 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"; @@ -42,19 +42,41 @@ function OwnerMessage({ message, bot }: { message: Message; bot: Bot }) { ); } -function TaskTag({ message }: { message: Message }) { +/** A routine's tag, which opens what the routine asks: folded, it is only the name. */ +function RoutineTag({ + message, + open, + onToggle, +}: { + message: Message; + open: boolean; + onToggle: () => void; +}) { const t = useT(); const routine = useApp((state) => message.routineId ? state.routines[message.routineId] : undefined, ); - if (message.kind === "routine") { - return ( - - - {t.routines.tag(routine?.name ?? t.routines.tab)} - - ); - } + return ( + + ); +} + +function TaskTag({ message }: { message: Message }) { + const t = useT(); if (message.kind !== "task" && message.kind !== "result") { return null; } @@ -74,6 +96,8 @@ function OtherMessage({ message }: { message: Message }) { const system = message.fromKind === "system"; const name = system ? "Botloft" : (sender?.name ?? t.chat.inbound.goneBot); const arrival = useArrival(message.createdAt); + const routine = message.kind === "routine"; + const [open, setOpen] = useState(false); return (
  • {name} {sender && @{sender.handle}} - + {routine ? ( + setOpen(!open)} /> + ) : ( + + )}