Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions app/src/features/crews/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -226,6 +231,6 @@ function activityText(activity: Activity, t: Messages): string {
return text ? `${title} · ${text}` : title;
}
default:
return text;
return plainText(text);
}
}
26 changes: 26 additions & 0 deletions app/src/features/crews/preview.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
});
2 changes: 1 addition & 1 deletion app/src/features/questions/questions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
38 changes: 38 additions & 0 deletions app/src/lib/plainText.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
43 changes: 43 additions & 0 deletions app/src/lib/plainText.ts
Original file line number Diff line number Diff line change
@@ -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();
}
2 changes: 1 addition & 1 deletion docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading