From 023ce24197a85a6ab9837f549e57ec7f60b131b0 Mon Sep 17 00:00:00 2001
From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com>
Date: Thu, 1 Oct 2026 09:02:17 -0300
Subject: [PATCH] feat(app): show each bot's crew in tokens by bot
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two crews can have bots with the same name, and the usage dialog only
showed the name. usage.tokens now carries the crew's name, and each row
reads "Ops · Worked 2 times · ...".
---
app/src/features/account/TokensByBot.tsx | 12 +++++++---
app/src/features/account/tokens.test.tsx | 28 +++++++++++++++++++----
app/src/i18n/en/account.ts | 5 ++--
app/src/i18n/es/account.ts | 5 ++--
app/src/i18n/pt-BR/account.ts | 5 ++--
app/src/lib/fakeUsage.ts | 6 ++++-
app/src/lib/protocol.gen.ts | 4 ++++
crates/botloft-core/src/protocol/usage.rs | 2 ++
crates/botloft-store/src/usage.rs | 9 +++++---
docs/spec.md | 4 ++--
10 files changed, 61 insertions(+), 19 deletions(-)
diff --git a/app/src/features/account/TokensByBot.tsx b/app/src/features/account/TokensByBot.tsx
index 3a9d57c7..7b4d33fa 100644
--- a/app/src/features/account/TokensByBot.tsx
+++ b/app/src/features/account/TokensByBot.tsx
@@ -1,6 +1,7 @@
// Tokens each bot used over a period (spec 8.7), in the usage dialog: the
// new text read plus the replies written, with the conversation reread
-// each time shown apart.
+// each time shown apart. Each bot shows its crew, as two crews can have
+// bots with the same name.
import { useEffect, useState } from "react";
import { useT } from "../../i18n";
@@ -99,7 +100,12 @@ function Row({ bot, most }: { bot: BotTokens; most: number }) {
/>
- {u.detail(bot.turns, tokens(bot.tokens.cacheRead), reloadedTokens(bot.tokens.reloaded))}
+ {u.detail(
+ bot.crew,
+ bot.turns,
+ tokens(bot.tokens.cacheRead),
+ reloadedTokens(bot.tokens.reloaded),
+ )}
@@ -119,7 +125,7 @@ function Total({ bots }: { bots: BotTokens[] }) {
{tokens(used)}
- {u.detail(turns, tokens(reread), reloadedTokens(reloaded))}
+ {u.detail(null, turns, tokens(reread), reloadedTokens(reloaded))}
);
diff --git a/app/src/features/account/tokens.test.tsx b/app/src/features/account/tokens.test.tsx
index 5d756b71..33f88a7e 100644
--- a/app/src/features/account/tokens.test.tsx
+++ b/app/src/features/account/tokens.test.tsx
@@ -34,14 +34,14 @@ describe("tokens by bot", () => {
const rows = within(list).getAllByRole("listitem");
// Each turn: 12 new, 1,800 into the cache and 640 written count; 24,000 reread do not.
expect(rows.map((row) => row.textContent)).toEqual([
- "Scout4.9kWorked 2 times · 48k reread",
- "Writer2.5kWorked once · 24k reread",
+ "Scout4.9kOps · Worked 2 times · 48k reread",
+ "Writer2.5kOps · Worked once · 24k reread",
"All bots7.4kWorked 3 times · 72k reread",
]);
fireEvent.click(within(dialog).getByRole("radio", { name: "7 days" }));
expect(await within(dialog).findByText("4.9k")).toBeDefined();
- expect(dialog.textContent).toContain("Writer4.9kWorked 2 times");
+ expect(dialog.textContent).toContain("Writer4.9kOps · Worked 2 times");
expect(dialog.textContent).toContain("All bots9.8k");
});
@@ -64,7 +64,27 @@ describe("tokens by bot", () => {
within(list)
.getAllByRole("listitem")
.map((row) => row.textContent),
- ).toEqual(["Scout4.1kWorked 2 times · 24k reread · 72k reloaded"]);
+ ).toEqual(["Scout4.1kOps · Worked 2 times · 24k reread · 72k reloaded"]);
+ });
+
+ test("bots with the same name in two crews show their crew", async () => {
+ const fake = new FakeBotloft();
+ const ops = fake.addCrew("Ops");
+ const news = fake.addCrew("Newsroom");
+ fake.chat.turn(fake.addBot(ops.id, "Scout", "Finds sources").id);
+ fake.chat.turn(fake.addBot(news.id, "Scout", "Finds stories").id);
+
+ const dialog = await openUsage(fake);
+ const list = await within(dialog).findByRole("list", { name: "Tokens by bot" });
+ expect(
+ within(list)
+ .getAllByRole("listitem")
+ .map((row) => row.textContent),
+ ).toEqual([
+ "Scout2.5kNewsroom · Worked once · 24k reread",
+ "Scout2.5kOps · Worked once · 24k reread",
+ "All bots4.9kWorked 2 times · 48k reread",
+ ]);
});
test("a period with no work says so", async () => {
diff --git a/app/src/i18n/en/account.ts b/app/src/i18n/en/account.ts
index 179f4303..91ef15c0 100644
--- a/app/src/i18n/en/account.ts
+++ b/app/src/i18n/en/account.ts
@@ -36,8 +36,9 @@ export const account = {
empty: "No bot worked in this period.",
failed: "Could not load the tokens",
archived: "archived",
- detail: (times: number, reread: string, reloaded: string | null) =>
- `Worked ${times === 1 ? "once" : `${times} times`} · ${reread} reread${
+ /** `crew` is null on the total of all bots. */
+ detail: (crew: string | null, times: number, reread: string, reloaded: string | null) =>
+ `${crew === null ? "" : `${crew} · `}Worked ${times === 1 ? "once" : `${times} times`} · ${reread} reread${
reloaded === null ? "" : ` · ${reloaded} reloaded`
}`,
total: "All bots",
diff --git a/app/src/i18n/es/account.ts b/app/src/i18n/es/account.ts
index 2338f1ec..291d8182 100644
--- a/app/src/i18n/es/account.ts
+++ b/app/src/i18n/es/account.ts
@@ -36,8 +36,9 @@ export const account: Messages["account"] = {
empty: "Ningún bot trabajó en este período.",
failed: "No se pudieron cargar los tokens",
archived: "archivado",
- detail: (times: number, reread: string, reloaded: string | null) =>
- `Trabajó ${times === 1 ? "1 vez" : `${times} veces`} · ${reread} releídos${
+ /** `crew` is null on the total of all bots. */
+ detail: (crew: string | null, times: number, reread: string, reloaded: string | null) =>
+ `${crew === null ? "" : `${crew} · `}Trabajó ${times === 1 ? "1 vez" : `${times} veces`} · ${reread} releídos${
reloaded === null ? "" : ` · ${reloaded} recargados`
}`,
total: "Todos los bots",
diff --git a/app/src/i18n/pt-BR/account.ts b/app/src/i18n/pt-BR/account.ts
index 45777e6d..93085ee6 100644
--- a/app/src/i18n/pt-BR/account.ts
+++ b/app/src/i18n/pt-BR/account.ts
@@ -36,8 +36,9 @@ export const account: Messages["account"] = {
empty: "Nenhum bot trabalhou nesse período.",
failed: "Não deu para carregar os tokens",
archived: "arquivado",
- detail: (times: number, reread: string, reloaded: string | null) =>
- `Trabalhou ${times === 1 ? "1 vez" : `${times} vezes`} · ${reread} relidos${
+ /** `crew` is null on the total of all bots. */
+ detail: (crew: string | null, times: number, reread: string, reloaded: string | null) =>
+ `${crew === null ? "" : `${crew} · `}Trabalhou ${times === 1 ? "1 vez" : `${times} vezes`} · ${reread} relidos${
reloaded === null ? "" : ` · ${reloaded} recarregados`
}`,
total: "Todos os bots",
diff --git a/app/src/lib/fakeUsage.ts b/app/src/lib/fakeUsage.ts
index 38670d1f..d1c42cd3 100644
--- a/app/src/lib/fakeUsage.ts
+++ b/app/src/lib/fakeUsage.ts
@@ -20,6 +20,7 @@ export function usageHandlers(fake: FakeBotloft): Pick
botId: bot.id,
name: bot.name,
color: bot.color,
+ crew: fake.crew(bot.crewId, false).name,
archived: bot.archivedAt !== null,
turns: 0,
tokens: { input: 0, cacheWrite: 0, reloaded: 0, cacheRead: 0, output: 0 },
@@ -37,7 +38,10 @@ export function usageHandlers(fake: FakeBotloft): Pick
};
}
return [...byBot.values()].sort(
- (a, b) => usedTokens(b.tokens) - usedTokens(a.tokens) || a.name.localeCompare(b.name),
+ (a, b) =>
+ usedTokens(b.tokens) - usedTokens(a.tokens) ||
+ a.name.localeCompare(b.name) ||
+ a.crew.localeCompare(b.crew),
);
},
};
diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts
index 756a998c..f47fce18 100644
--- a/app/src/lib/protocol.gen.ts
+++ b/app/src/lib/protocol.gen.ts
@@ -785,6 +785,10 @@ since: number, };
* One bot's tokens since the time asked for.
*/
export type BotTokens = { botId: BotId, name: string, color: string,
+/**
+ * The name of the bot's crew, so two bots with one name stay apart.
+ */
+crew: string,
/**
* The bot is archived.
*/
diff --git a/crates/botloft-core/src/protocol/usage.rs b/crates/botloft-core/src/protocol/usage.rs
index d0be44e2..a3ef13a4 100644
--- a/crates/botloft-core/src/protocol/usage.rs
+++ b/crates/botloft-core/src/protocol/usage.rs
@@ -52,6 +52,8 @@ pub struct BotTokens {
pub bot_id: BotId,
pub name: String,
pub color: String,
+ /// The name of the bot's crew, so two bots with one name stay apart.
+ pub crew: String,
/// The bot is archived.
pub archived: bool,
/// Turns that reported tokens.
diff --git a/crates/botloft-store/src/usage.rs b/crates/botloft-store/src/usage.rs
index 41e9380d..190c6fb4 100644
--- a/crates/botloft-store/src/usage.rs
+++ b/crates/botloft-store/src/usage.rs
@@ -20,20 +20,22 @@ impl Store {
SUM(json_extract(c.data, '$.tokens.cacheWrite')), \
SUM(json_extract(c.data, '$.tokens.cacheRead')), \
SUM(json_extract(c.data, '$.tokens.output')) AS output, \
- COALESCE(SUM(json_extract(c.data, '$.tokens.reloaded')), 0) AS reloaded \
- FROM chat_items c JOIN bots b ON b.id = c.bot_id \
+ COALESCE(SUM(json_extract(c.data, '$.tokens.reloaded')), 0) AS reloaded, \
+ w.name \
+ FROM chat_items c JOIN bots b ON b.id = c.bot_id JOIN crews w ON w.id = b.crew_id \
WHERE c.kind = 'turn' AND c.created_at >= ?1 \
AND json_type(c.data, '$.tokens') = 'object' \
GROUP BY b.id \
ORDER BY SUM(json_extract(c.data, '$.tokens.input')) \
+ SUM(json_extract(c.data, '$.tokens.cacheWrite')) - reloaded + output DESC, \
- b.name",
+ b.name, w.name",
)?;
let rows = stmt.query_map(params![since], |row| {
Ok(BotTokens {
bot_id: parse_column(row, 0)?,
name: row.get(1)?,
color: row.get(2)?,
+ crew: row.get(10)?,
archived: row.get(3)?,
turns: row.get(4)?,
tokens: TokenUsage {
@@ -104,6 +106,7 @@ mod tests {
assert_eq!(order, [writer, scout], "the bot that used the most first");
let scout_tokens = &all[1];
assert_eq!(scout_tokens.name, "scout");
+ assert_eq!(scout_tokens.crew, "Ops");
assert!(!scout_tokens.archived);
assert_eq!(scout_tokens.turns, 2);
assert_eq!(
diff --git a/docs/spec.md b/docs/spec.md
index 2a989728..f87686f7 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -416,7 +416,7 @@ O dono vê quantos tokens cada bot gasta: em cada turno, no chat, e somados por
- **O que conta.** O número em destaque é `input + cacheWrite - reloaded + output`: o texto novo que o modelo leu e o que escreveu. O Claude Code manda quase tudo que é novo (a mensagem, o resultado de uma ferramenta) para o cache, por isso o `input` sozinho fica perto de zero e o novo está no `cacheWrite`. O `cacheRead` é quase todo a conversa relida a cada pedido, pesa bem menos e numa conversa longa seria a maior parte do total; ele aparece à parte, como "relidos".
- **A conversa recarregada.** Cada pedido manda a conversa inteira. Quando o cache expirou, porque o bot ficou parado ou o processo reiniciou, a parte da conversa que o cache não tinha mais é gravada de novo e vem no `cacheWrite`, misturada com o que é novo; num turno curto depois de uma pausa ela é quase tudo (19). O daemon separa essa parte em `reloaded`, sem guardar nada: para cada pedido da conversa principal (sem subagentes nem respostas sintéticas, contado uma vez por `message.id`), o que a conversa já tinha e não veio do cache, `min(cacheWrite, antes - cacheRead)`, onde `antes` é `input + cacheWrite + cacheRead` do pedido anterior ou, sem pedido visto, o tamanho que o Claude Code respondeu (8.6). Uma conversa nova ou recém-compactada não tem `antes` e não conta nada. O valor é limitado ao `cacheWrite` do `result`. O app mostra à parte só a partir de mil tokens, como "recarregou a conversa (72 mil)" no chat e "recarregados" no diálogo de uso.
- **No chat.** A linha do fim do turno diz a duração e os tokens ("Concluído em 4,2 s · 2,5 mil tokens"), e a conversa recarregada quando houve; o detalhe (novos, escritos, relidos, recarregados) fica no título da linha. A unidade é o turno, não a mensagem: mensagens que chegam juntas entram num turno só, e não dá para dividir os tokens entre elas.
-- **Por bot.** `usage.tokens {since}` soma os itens `turn` com `tokens` terminados a partir de `since` (ms Unix), por bot, com nome, cor e se está arquivado, do que mais gastou para o que menos. Bots sem turno no período ficam de fora; bots excluídos levam os itens junto (7.6). O app oferece hoje (desde a meia-noite local), 7 dias, 30 dias e tudo, com o total dos bots embaixo.
+- **Por bot.** `usage.tokens {since}` soma os itens `turn` com `tokens` terminados a partir de `since` (ms Unix), por bot, com nome, cor, o nome da equipe (dois bots podem ter o mesmo nome em equipes diferentes) e se está arquivado, do que mais gastou para o que menos. Bots sem turno no período ficam de fora; bots excluídos levam os itens junto (7.6). O app oferece hoje (desde a meia-noite local), 7 dias, 30 dias e tudo, com o total dos bots embaixo.
- Uma compactação (8.6) não vira item `turn` e não entra na conta.
## 9. Mensagens e entrega
@@ -624,7 +624,7 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co
| `browser.list`, `browser.watch`, `browser.unwatch`, `browser.take`, `browser.release`, `browser.input` | seção 21.7 | o navegador dos bots, a tela ao vivo e o dono no controle |
| `screens.list` | seção 22.4 | as telas HTML do bot |
| `settings.get` | | `Settings`: `startWithWindows`, `keepAwake` e `approvalWaitMinutes`, como estão no `config.toml` (6) |
-| `usage.tokens` | `since` (ms Unix, não negativo) | `BotTokens[]`: `botId`, `name`, `color`, `archived`, `turns` e `tokens` somados (8.7) |
+| `usage.tokens` | `since` (ms Unix, não negativo) | `BotTokens[]`: `botId`, `name`, `color`, `crew` (nome da equipe), `archived`, `turns` e `tokens` somados (8.7) |
| `settings.update` | `startWithWindows?, keepAwake?, approvalWaitMinutes?` (1 a 1440) | `Settings`; grava o que veio e aplica na hora (6, 14). Se a tarefa não pode mudar, nada é gravado e volta um erro |
`Crew` traz `workFolder`, o caminho da pasta de trabalho (a escolhida ou a `shared\`), `workFolderChosen` e `leadBotId`, o chefe (10.2). `Bot` traz também `permissionMode`, `model`, `modelInUse`, `effort` e `effortDefault` (7.4), `context` (8.6; `null` enquanto o Claude Code não disse o tamanho da conversa) e `lastActivity`: o último item do chat resumido em uma linha, para a lista de conversas: `kind` (`owner`, `message`, `reply`, `tool`, `approval`, `notice`), `text`, `tool` e `at`. O `text` não tem palavras do daemon: a mensagem do dono vem sem "You:", e uma ferramenta ou um pedido vêm só com o resumo (num comando, com a explicação do bot, se ele deu uma, 10.1), com a ferramenta à parte em `tool`; o app completa no idioma do dono ("Mandar uma mensagem · @writer", "Aguardando aprovação: rodar um comando").