diff --git a/app/src/features/crews/Sidebar.tsx b/app/src/features/crews/Sidebar.tsx index 1869779f..3e7b3f4c 100644 --- a/app/src/features/crews/Sidebar.tsx +++ b/app/src/features/crews/Sidebar.tsx @@ -4,6 +4,7 @@ import { createPortal } from "react-dom"; import { useShallow } from "zustand/react/shallow"; import { type Messages, useT } from "../../i18n"; import { when } from "../../lib/format"; +import { plainText } from "../../lib/plainText"; import type { Activity, Bot, Crew } from "../../lib/protocol.gen"; import { activityOf, botsOf, crewList } from "../../store/app"; import { useApp } from "../../store/context"; @@ -207,17 +208,21 @@ const Conversation = memo(function Conversation({ bot, crew }: { bot: Bot; crew: ); }); -/** The conversation-list line, worded here from what the daemon sends. */ -/** The line in the owner's language: the daemon sends no wording. */ +/** + * The conversation-list line, worded here in the owner's language from what + * the daemon sends (it sends no wording). What someone wrote shows as plain + * words: the chat renders its Markdown, a line would show the marks raw. A + * command or a path keeps its own characters. + */ function activityText(activity: Activity, t: Messages): string { const { text, tool } = activity; switch (activity.kind) { case "owner": - return t.crews.sidebar.fromOwner(text); + return t.crews.sidebar.fromOwner(plainText(text)); case "approval": return t.crews.sidebar.awaitingApproval(tool ? toolAction(tool, t.tools) : text); case "question": - return t.questions.activity(text); + return t.questions.activity(plainText(text)); case "tool": { if (!tool) { return text; @@ -226,6 +231,6 @@ function activityText(activity: Activity, t: Messages): string { return text ? `${title} · ${text}` : title; } default: - return text; + return plainText(text); } } diff --git a/app/src/features/crews/preview.test.tsx b/app/src/features/crews/preview.test.tsx new file mode 100644 index 00000000..bac54be9 --- /dev/null +++ b/app/src/features/crews/preview.test.tsx @@ -0,0 +1,26 @@ +import { cleanup, within } from "@testing-library/react"; +import { afterEach, expect, test } from "vitest"; +import { FakeBotloft } from "../../lib/fake"; +import { crewOpened, renderApp, sidebar } from "../../test/app"; + +afterEach(cleanup); + +test("a bot's last reply shows in the list as plain words, a command as it is", async () => { + const fake = new FakeBotloft(); + const crew = fake.addCrew("Bakery"); + const sales = fake.addBot(crew.id, "Sales"); + sales.lastActivity = { + kind: "reply", + text: "**Vendas do dia, 03/10:** 1 venda _nova_", + tool: null, + at: Date.now(), + }; + const ops = fake.addBot(crew.id, "Ops"); + ops.lastActivity = { kind: "tool", text: "ls **/*.md", tool: "Bash", at: Date.now() }; + renderApp(fake); + await crewOpened("Bakery"); + + const list = within(sidebar()); + expect(list.getByText("Vendas do dia, 03/10: 1 venda nova")).toBeDefined(); + expect(list.getByText(/ls \*\*\/\*\.md/)).toBeDefined(); +}); diff --git a/app/src/features/questions/questions.test.tsx b/app/src/features/questions/questions.test.tsx index 7199a088..a2091af6 100644 --- a/app/src/features/questions/questions.test.tsx +++ b/app/src/features/questions/questions.test.tsx @@ -34,7 +34,7 @@ describe("questions to the owner", () => { expect(within(card).getByText("client").tagName).toBe("STRONG"); // The card stands for the call; no tool line repeats it. expect(within(chat).queryByText("Ask you a question")).toBeNull(); - expect(within(sidebar()).getByText("Question: Which **client** first?")).toBeDefined(); + expect(within(sidebar()).getByText("Question: Which client first?")).toBeDefined(); fireEvent.click(within(card).getByRole("button", { name: "Globex" })); await within(chat).findByText("You answered"); diff --git a/app/src/lib/plainText.test.ts b/app/src/lib/plainText.test.ts new file mode 100644 index 00000000..01764532 --- /dev/null +++ b/app/src/lib/plainText.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "vitest"; +import { plainText } from "./plainText"; + +describe("plainText", () => { + test("drops strong, emphasis, code and strikethrough marks", () => { + expect(plainText("**Vendas do dia, 03/10:** 1 venda nova")).toBe( + "Vendas do dia, 03/10: 1 venda nova", + ); + expect(plainText("__done__, *now* and _soon_, `npm test`, ~~old~~")).toBe( + "done, now and soon, npm test, old", + ); + expect(plainText("***both***")).toBe("both"); + }); + + test("keeps a lone star or underscore inside words", () => { + expect(plainText("rename snake_case_name, 2*3*4 = 24")).toBe( + "rename snake_case_name, 2*3*4 = 24", + ); + }); + + test("drops headings, quotes and list marks, joining lines", () => { + expect(plainText("## Report\n> - first\n2. second\n- [x] third")).toBe( + "Report first second third", + ); + }); + + test("keeps the text of links and images, and leaves out fences and rules", () => { + expect(plainText("See [the docs](https://example.com) ![chart](a.png)")).toBe( + "See the docs chart", + ); + expect(plainText("Run:\n```sh\nls\n```\n---\nDone")).toBe("Run: ls Done"); + }); + + test("table cells become words, escapes their characters", () => { + expect(plainText("| Item | Qty |\n| --- | --- |\n| Cake | 2 |")).toBe("Item Qty Cake 2"); + expect(plainText("1\\*2 is \\#1")).toBe("1*2 is #1"); + }); +}); diff --git a/app/src/lib/plainText.ts b/app/src/lib/plainText.ts new file mode 100644 index 00000000..3e63bac6 --- /dev/null +++ b/app/src/lib/plainText.ts @@ -0,0 +1,43 @@ +// A message's Markdown as plain words, for one-line previews (the sidebar): +// the chat renders the marks, a preview line would show them raw, as in +// "**Sales of the day:** 1 sale". + +/** The marks around a span of text, in the order they are taken off. */ +const SPANS: [RegExp, string][] = [ + // Images and links keep their text: ![alt](url), [text](url). + [/!\[([^\]]*)\]\([^)]*\)/g, "$1"], + [/\[([^\]]+)\]\([^)]*\)/g, "$1"], + // Code, then strong, emphasis and strikethrough. A lone _ or * inside a + // word (snake_case, 2*3) stays. + [/`([^`]+)`/g, "$1"], + [/(\*\*|__)(?=\S)(.+?)(?<=\S)\1/g, "$2"], + [/(^|[^\w*])\*(?=\S)(.+?)(?<=\S)\*(?![\w*])/g, "$1$2"], + [/(^|[^\w_])_(?=\S)(.+?)(?<=\S)_(?![\w_])/g, "$1$2"], + [/~~(?=\S)(.+?)(?<=\S)~~/g, "$1"], +]; + +/** The marks at the start of a line: headings, quotes, list items, rules. */ +const LINE_START = /^\s{0,3}(?:#{1,6}\s+|>\s?|[-*+]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+)/; + +/** `markdown` as plain words on one line. */ +export function plainText(markdown: string): string { + const lines = markdown + .split(/\r?\n/) + .filter((line) => !/^\s*(?:```|~~~|[-*_]{3,}\s*$|\|?\s*:?-{3,})/.test(line)) + .map((line) => { + let text = line; + // Quotes and lists can nest: "> - item". + for (let pass = 0; pass < 3 && LINE_START.test(text); pass++) { + text = text.replace(LINE_START, ""); + } + return text.replace(/\|/g, " "); + }); + let text = lines.join(" "); + for (const [mark, keep] of SPANS) { + text = text.replace(mark, keep); + } + return text + .replace(/\\([\\`*_{}[\]()#+\-.!>~|])/g, "$1") + .replace(/\s+/g, " ") + .trim(); +} diff --git a/docs/spec.md b/docs/spec.md index fda2b397..5963eb0e 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -797,7 +797,7 @@ O chat e as mensagens de uma equipe guardam todas as páginas já carregadas. As Layout, como um app de mensagens: -- **Barra lateral:** crews como seções, com os bots como conversas. Cada conversa mostra avatar, nome, estado (cor, ícone e texto; um bot parado e livre, `idle`, não mostra estado, porque é o normal) e a prévia da última atividade (`lastActivity`) com a hora. Um bot que respondeu desde a última vez que o dono olhou o chat dele fica marcado como não lido, como num app de mensagens: nome em negrito, prévia e hora em destaque e um ponto na cor de destaque no fim da linha, até o dono abrir o chat. Vale toda resposta (`lastReplyAt`, e cada item `reply` que chega), mesmo com outra coisa depois dela na conversa. Com o chat aberto e a janela à vista, o que chega já conta como visto; com a janela escondida, conta quando ela volta. O app guarda no `localStorage` quando o dono olhou cada bot (`botloft.seen`) e, na primeira vez, a hora de começar a marcar (`botloft.seenSince`): respostas de antes contam como vistas, para uma atualização não marcar todos os bots de uma vez. O dono também marca um bot como não lido pelo menu dele ("Marcar como não lida"; num bot marcado, "Marcar como lida"), para lembrar de voltar à conversa: a marca fica no `localStorage` (`botloft.unread`) até ele abrir o chat, e marcar o chat aberto o fecha e volta à página da crew. Ao lado do nome de cada crew, um número na cor de destaque diz quantos bots dela estão não lidos. Ela aparece desde a conexão, também na tela de boas-vindas antes da primeira crew. Um clique direito numa conversa (ou a tecla de menu do teclado) abre o menu do bot onde o ponteiro está, sem abrir o bot: são as mesmas ações do menu "mais" do cabeçalho (editar, tornar chefe ou deixar de ser, reiniciar com uma conversa nova, abrir a pasta, marcar como não lida ou lida, arquivar, excluir), vindas do mesmo lugar no código (`useBotActions`), para os dois nunca divergirem. O menu fica inteiro dentro da janela, anda com as setas e fecha com Esc, com um clique fora ou quando a lista rola. Um botão na barra de título, ao lado do nome do Botloft, esconde a barra lateral (ou Ctrl+B), para numa tela pequena sobrarem só o chat e o painel do bot: o resto se alarga de uma vez e ela desliza para a esquerda por cima dele (só `transform`, para a conversa não ser refeita a cada quadro). A escolha fica no `localStorage` (`botloft.sidebar`). Escondida, um ponto no botão avisa quando algo espera o dono (o mesmo que marca o ícone na barra de tarefas, 15.2) ou, parado e na cor de destaque, quando algum bot tem resposta não lida. Na tela de boas-vindas, antes da primeira crew, ela não se esconde. Uma seta ao lado do nome de cada crew a recolhe: ficam só o nome, o número de não lidos e, se um bot dela espera o dono, um ponto na cor de aviso; a conversa aberta continua à vista. Um botão no topo recolhe ou expande todas. As crews recolhidas ficam no `localStorage` (`botloft.collapsedCrews`). +- **Barra lateral:** crews como seções, com os bots como conversas. Cada conversa mostra avatar, nome, estado (cor, ícone e texto; um bot parado e livre, `idle`, não mostra estado, porque é o normal) e a prévia da última atividade (`lastActivity`) com a hora. O que alguém escreveu (resposta, mensagem, pergunta) aparece em texto simples, sem as marcas de Markdown que o chat desenha (`**negrito**`, títulos, listas, links); um comando ou caminho de ferramenta fica como veio. Um bot que respondeu desde a última vez que o dono olhou o chat dele fica marcado como não lido, como num app de mensagens: nome em negrito, prévia e hora em destaque e um ponto na cor de destaque no fim da linha, até o dono abrir o chat. Vale toda resposta (`lastReplyAt`, e cada item `reply` que chega), mesmo com outra coisa depois dela na conversa. Com o chat aberto e a janela à vista, o que chega já conta como visto; com a janela escondida, conta quando ela volta. O app guarda no `localStorage` quando o dono olhou cada bot (`botloft.seen`) e, na primeira vez, a hora de começar a marcar (`botloft.seenSince`): respostas de antes contam como vistas, para uma atualização não marcar todos os bots de uma vez. O dono também marca um bot como não lido pelo menu dele ("Marcar como não lida"; num bot marcado, "Marcar como lida"), para lembrar de voltar à conversa: a marca fica no `localStorage` (`botloft.unread`) até ele abrir o chat, e marcar o chat aberto o fecha e volta à página da crew. Ao lado do nome de cada crew, um número na cor de destaque diz quantos bots dela estão não lidos. Ela aparece desde a conexão, também na tela de boas-vindas antes da primeira crew. Um clique direito numa conversa (ou a tecla de menu do teclado) abre o menu do bot onde o ponteiro está, sem abrir o bot: são as mesmas ações do menu "mais" do cabeçalho (editar, tornar chefe ou deixar de ser, reiniciar com uma conversa nova, abrir a pasta, marcar como não lida ou lida, arquivar, excluir), vindas do mesmo lugar no código (`useBotActions`), para os dois nunca divergirem. O menu fica inteiro dentro da janela, anda com as setas e fecha com Esc, com um clique fora ou quando a lista rola. Um botão na barra de título, ao lado do nome do Botloft, esconde a barra lateral (ou Ctrl+B), para numa tela pequena sobrarem só o chat e o painel do bot: o resto se alarga de uma vez e ela desliza para a esquerda por cima dele (só `transform`, para a conversa não ser refeita a cada quadro). A escolha fica no `localStorage` (`botloft.sidebar`). Escondida, um ponto no botão avisa quando algo espera o dono (o mesmo que marca o ícone na barra de tarefas, 15.2) ou, parado e na cor de destaque, quando algum bot tem resposta não lida. Na tela de boas-vindas, antes da primeira crew, ela não se esconde. Uma seta ao lado do nome de cada crew a recolhe: ficam só o nome, o número de não lidos e, se um bot dela espera o dono, um ponto na cor de aviso; a conversa aberta continua à vista. Um botão no topo recolhe ou expande todas. As crews recolhidas ficam no `localStorage` (`botloft.collapsedCrews`). - **Página das crews:** clicar em "Equipes", no topo da barra lateral, abre no meio da janela todas as crews como cartões: nome, número de bots, os rostos dos primeiros seis (parados, para a página não redesenhar cada chama), quantos bots trabalham e quantos esperam o dono, ou "Pausada". Um cartão abre a crew. É também o que aparece quando nenhuma crew está escolhida (a crew aberta foi arquivada ou excluída), e ela continua aberta depois de uma reconexão; só a primeira carga abre a primeira crew. - **Área da conta**, no pé da barra lateral, como nos apps de chat: um círculo com a inicial, o nome do dono (do Windows) e o plano do Claude ("Plano Max"; sem plano, a organização ou o e-mail; desconectado, "Sem conta do Claude conectada"). Um clique abre um menu para cima com o e-mail e: **Uso** (as janelas de uso do plano, 8.1, com a parte usada e quando renovam; antes da primeira resposta de um bot, um aviso de que ainda não há dados), **Configurações** (abaixo), **Idioma** (submenu ao lado, com a escolha na hora), **Novidades** (a página de releases no navegador) e **Ajuda** (o README no navegador). Tema, tamanho e idioma saíram da barra de título; ela só mostra o idioma nas telas de preparo, que ainda não têm barra lateral. - **Configurações**, um diálogo com as partes listadas à esquerda (abas verticais): **Geral**, **Conversa**, **Avisos**, **Aparência**, **Arquivados** e **Sobre**. As escolhas que são só do app ficam no `localStorage` (`botloft.whenClosed`, `botloft.tray`, `botloft.openAtSignIn`, `botloft.notifyNeeds`, `botloft.notifyDone`, `botloft.markReplies`, `botloft.sound`, `botloft.appSounds`, `botloft.enterSends`, `botloft.followBot`, `botloft.lessMotion`), como o tema. Em Geral, primeiro o **Idioma**, numa lista suspensa (um botão com a escolha que abre a lista embaixo, no visual dos menus, com as setas, Enter e Esc, que fecha só a lista); depois **Em segundo plano**, três chaves, cada uma com uma linha que diz o que a escolha atual faz: "Continuar trabalhando depois de fechar o Botloft" (ligada, os bots seguem depois que a janela fecha; desligada, fechar a janela a esconde, chama `daemon_stop` e fecha, e os bots continuam de onde pararam quando o app abre de novo, com "Iniciando seus bots…" no lugar de "Preparando o Botloft…"; a escolha é do app), "Mostrar o Botloft perto do relógio" (só com a anterior ligada; abaixo), "Iniciar com o Windows" (`startWithWindows`, 14), "Abrir a janela ao entrar no Windows" (só com a anterior ligada; desligada, o Botloft começa perto do relógio, ou só abre quando o dono abrir) e "Não deixar o computador dormir enquanto os bots trabalham" (`keepAwake`, com a linha de que a tampa e o Suspender ainda põem o computador para dormir). As duas do daemon vêm de `settings.get` ao abrir, mudam na hora na tela e voltam atrás, com um aviso, se `settings.update` falhar. Em Conversa, "Enter envia a mensagem" (desligada, Enter quebra a linha e Ctrl+Enter envia, e a dica do compositor diz isso) e "Abrir o navegador e as telas quando um bot começar a usar" (desligada, só o ponto no botão avisa), e quanto tempo um pedido de permissão espera pelo dono (15 min, 30 min, 1 h, 2 h, 4 h ou 8 h; um valor posto à mão no `config.toml` entra na lista), com a linha de que sem resposta o pedido é negado (10.1). Em Avisos, "Quando um bot precisar de você" (ligada), "Quando um bot terminar" (desligada), "Marcar o ícone quando um bot responder" (ligada, `botloft.markReplies`: a marca do ícone na barra de tarefas, 15.2, aparece também enquanto algum bot está não lido; só no Windows, onde essa marca existe) e "Tocar um som" (ligada), com a linha de que, com a janela fechada, os avisos só chegam com o Botloft perto do relógio quando ele não está. Em Aparência, tema, tamanho e "Menos animações", que marca a raiz com `data-motion="less"` e para tudo como o Windows faria (15.3). Em **Arquivados**, uma linha que explica que os bots e as equipes arquivados estão parados e fora da vista, mas o Botloft ainda guarda as conversas deles, e a lista, lida de `archive.list` ao abrir a parte: primeiro as equipes ("Equipe · 2 bots · arquivada há 3 dias"), depois os bots arquivados de equipes ativas ("Bot de Ops · arquivado há 3 dias"); um bot de uma equipe arquivada sai junto com ela e não aparece sozinho. Cada linha tem um botão Excluir, que abre a mesma confirmação dos menus (7.6), dizendo que o bot ou a equipe "sai do Botloft de vez" em vez de "para agora", porque já estava parado. Sem nada arquivado, a parte diz isso. Não há como desarquivar. Em **Sobre**: as versões do Botloft e do Claude Code, o e-mail e "Procurar atualizações", que consulta o feed na hora (15.5) e diz que esta é a versão mais recente ou mostra "Ver atualização", que abre o diálogo de atualizar. A linha do Botloft na tela de boas-vindas segue as mesmas escolhas.