Skip to content

Commit 281cd59

Browse files
committed
fix(app): show conversation previews without Markdown marks
The sidebar's line under each bot showed the last reply raw, so a bold title read "**Vendas do dia:** 1 venda". What someone wrote (a reply, a message, a question) now shows as plain words: plainText takes off strong, emphasis, code, links, headings, quotes, lists and table pipes, and keeps a lone * or _ inside a word. A tool's command or path keeps its own characters.
1 parent cadb1ab commit 281cd59

6 files changed

Lines changed: 119 additions & 7 deletions

File tree

‎app/src/features/crews/Sidebar.tsx‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createPortal } from "react-dom";
44
import { useShallow } from "zustand/react/shallow";
55
import { type Messages, useT } from "../../i18n";
66
import { when } from "../../lib/format";
7+
import { plainText } from "../../lib/plainText";
78
import type { Activity, Bot, Crew } from "../../lib/protocol.gen";
89
import { activityOf, botsOf, crewList } from "../../store/app";
910
import { useApp } from "../../store/context";
@@ -207,17 +208,21 @@ const Conversation = memo(function Conversation({ bot, crew }: { bot: Bot; crew:
207208
);
208209
});
209210

210-
/** The conversation-list line, worded here from what the daemon sends. */
211-
/** The line in the owner's language: the daemon sends no wording. */
211+
/**
212+
* The conversation-list line, worded here in the owner's language from what
213+
* the daemon sends (it sends no wording). What someone wrote shows as plain
214+
* words: the chat renders its Markdown, a line would show the marks raw. A
215+
* command or a path keeps its own characters.
216+
*/
212217
function activityText(activity: Activity, t: Messages): string {
213218
const { text, tool } = activity;
214219
switch (activity.kind) {
215220
case "owner":
216-
return t.crews.sidebar.fromOwner(text);
221+
return t.crews.sidebar.fromOwner(plainText(text));
217222
case "approval":
218223
return t.crews.sidebar.awaitingApproval(tool ? toolAction(tool, t.tools) : text);
219224
case "question":
220-
return t.questions.activity(text);
225+
return t.questions.activity(plainText(text));
221226
case "tool": {
222227
if (!tool) {
223228
return text;
@@ -226,6 +231,6 @@ function activityText(activity: Activity, t: Messages): string {
226231
return text ? `${title} · ${text}` : title;
227232
}
228233
default:
229-
return text;
234+
return plainText(text);
230235
}
231236
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { cleanup, within } from "@testing-library/react";
2+
import { afterEach, expect, test } from "vitest";
3+
import { FakeBotloft } from "../../lib/fake";
4+
import { crewOpened, renderApp, sidebar } from "../../test/app";
5+
6+
afterEach(cleanup);
7+
8+
test("a bot's last reply shows in the list as plain words, a command as it is", async () => {
9+
const fake = new FakeBotloft();
10+
const crew = fake.addCrew("Bakery");
11+
const sales = fake.addBot(crew.id, "Sales");
12+
sales.lastActivity = {
13+
kind: "reply",
14+
text: "**Vendas do dia, 03/10:** 1 venda _nova_",
15+
tool: null,
16+
at: Date.now(),
17+
};
18+
const ops = fake.addBot(crew.id, "Ops");
19+
ops.lastActivity = { kind: "tool", text: "ls **/*.md", tool: "Bash", at: Date.now() };
20+
renderApp(fake);
21+
await crewOpened("Bakery");
22+
23+
const list = within(sidebar());
24+
expect(list.getByText("Vendas do dia, 03/10: 1 venda nova")).toBeDefined();
25+
expect(list.getByText(/ls \*\*\/\*\.md/)).toBeDefined();
26+
});

‎app/src/features/questions/questions.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ describe("questions to the owner", () => {
3434
expect(within(card).getByText("client").tagName).toBe("STRONG");
3535
// The card stands for the call; no tool line repeats it.
3636
expect(within(chat).queryByText("Ask you a question")).toBeNull();
37-
expect(within(sidebar()).getByText("Question: Which **client** first?")).toBeDefined();
37+
expect(within(sidebar()).getByText("Question: Which client first?")).toBeDefined();
3838

3939
fireEvent.click(within(card).getByRole("button", { name: "Globex" }));
4040
await within(chat).findByText("You answered");

‎app/src/lib/plainText.test.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { describe, expect, test } from "vitest";
2+
import { plainText } from "./plainText";
3+
4+
describe("plainText", () => {
5+
test("drops strong, emphasis, code and strikethrough marks", () => {
6+
expect(plainText("**Vendas do dia, 03/10:** 1 venda nova")).toBe(
7+
"Vendas do dia, 03/10: 1 venda nova",
8+
);
9+
expect(plainText("__done__, *now* and _soon_, `npm test`, ~~old~~")).toBe(
10+
"done, now and soon, npm test, old",
11+
);
12+
expect(plainText("***both***")).toBe("both");
13+
});
14+
15+
test("keeps a lone star or underscore inside words", () => {
16+
expect(plainText("rename snake_case_name, 2*3*4 = 24")).toBe(
17+
"rename snake_case_name, 2*3*4 = 24",
18+
);
19+
});
20+
21+
test("drops headings, quotes and list marks, joining lines", () => {
22+
expect(plainText("## Report\n> - first\n2. second\n- [x] third")).toBe(
23+
"Report first second third",
24+
);
25+
});
26+
27+
test("keeps the text of links and images, and leaves out fences and rules", () => {
28+
expect(plainText("See [the docs](https://example.com) ![chart](a.png)")).toBe(
29+
"See the docs chart",
30+
);
31+
expect(plainText("Run:\n```sh\nls\n```\n---\nDone")).toBe("Run: ls Done");
32+
});
33+
34+
test("table cells become words, escapes their characters", () => {
35+
expect(plainText("| Item | Qty |\n| --- | --- |\n| Cake | 2 |")).toBe("Item Qty Cake 2");
36+
expect(plainText("1\\*2 is \\#1")).toBe("1*2 is #1");
37+
});
38+
});

‎app/src/lib/plainText.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// A message's Markdown as plain words, for one-line previews (the sidebar):
2+
// the chat renders the marks, a preview line would show them raw, as in
3+
// "**Sales of the day:** 1 sale".
4+
5+
/** The marks around a span of text, in the order they are taken off. */
6+
const SPANS: [RegExp, string][] = [
7+
// Images and links keep their text: ![alt](url), [text](url).
8+
[/!\[([^\]]*)\]\([^)]*\)/g, "$1"],
9+
[/\[([^\]]+)\]\([^)]*\)/g, "$1"],
10+
// Code, then strong, emphasis and strikethrough. A lone _ or * inside a
11+
// word (snake_case, 2*3) stays.
12+
[/`([^`]+)`/g, "$1"],
13+
[/(\*\*|__)(?=\S)(.+?)(?<=\S)\1/g, "$2"],
14+
[/(^|[^\w*])\*(?=\S)(.+?)(?<=\S)\*(?![\w*])/g, "$1$2"],
15+
[/(^|[^\w_])_(?=\S)(.+?)(?<=\S)_(?![\w_])/g, "$1$2"],
16+
[/~~(?=\S)(.+?)(?<=\S)~~/g, "$1"],
17+
];
18+
19+
/** The marks at the start of a line: headings, quotes, list items, rules. */
20+
const LINE_START = /^\s{0,3}(?:#{1,6}\s+|>\s?|[-*+]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+)/;
21+
22+
/** `markdown` as plain words on one line. */
23+
export function plainText(markdown: string): string {
24+
const lines = markdown
25+
.split(/\r?\n/)
26+
.filter((line) => !/^\s*(?:```|~~~|[-*_]{3,}\s*$|\|?\s*:?-{3,})/.test(line))
27+
.map((line) => {
28+
let text = line;
29+
// Quotes and lists can nest: "> - item".
30+
for (let pass = 0; pass < 3 && LINE_START.test(text); pass++) {
31+
text = text.replace(LINE_START, "");
32+
}
33+
return text.replace(/\|/g, " ");
34+
});
35+
let text = lines.join(" ");
36+
for (const [mark, keep] of SPANS) {
37+
text = text.replace(mark, keep);
38+
}
39+
return text
40+
.replace(/\\([\\`*_{}[\]()#+\-.!>~|])/g, "$1")
41+
.replace(/\s+/g, " ")
42+
.trim();
43+
}

‎docs/spec.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -797,7 +797,7 @@ O chat e as mensagens de uma equipe guardam todas as páginas já carregadas. As
797797

798798
Layout, como um app de mensagens:
799799

800-
- **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`).
800+
- **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`).
801801
- **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.
802802
- **Á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.
803803
- **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.

0 commit comments

Comments
 (0)