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} +

)}
@@ -42,19 +43,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 +97,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)} /> + ) : ( + + )}
  • -

    - {message.body} -

    + {(!routine || open) && ( +

    + +

    + )} {delivery && delivery.state !== "sent" && (
    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`.