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
123 changes: 123 additions & 0 deletions app/src/features/account/TokensByBot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// 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.

import { useEffect, useState } from "react";
import { useT } from "../../i18n";
import { errorText } from "../../lib/api";
import { tokens, usedTokens } from "../../lib/format";
import type { BotTokens } from "../../lib/protocol.gen";
import { useApi } from "../../store/context";
import { Callout } from "../../ui/Callout";
import { Choices } from "../../ui/Choices";
import { BotAvatar } from "../bots/BotAvatar";

type Period = "today" | "week" | "month" | "all";

const DAY_MS = 86_400_000;

/** When `period` starts, in Unix ms. */
export function periodStart(period: Period, now = Date.now()): number {
switch (period) {
case "today":
return new Date(now).setHours(0, 0, 0, 0);
case "week":
return now - 7 * DAY_MS;
case "month":
return now - 30 * DAY_MS;
case "all":
return 0;
}
}

export function TokensByBot() {
const u = useT().account.usage.tokens;
const api = useApi();
const [period, setPeriod] = useState<Period>("today");
const [bots, setBots] = useState<BotTokens[] | null>(null);
const [failed, setFailed] = useState<string | null>(null);

useEffect(() => {
let alive = true;
setFailed(null);
api.call("usage.tokens", { since: periodStart(period) }).then(
(list) => alive && setBots(list),
(error) => alive && setFailed(errorText(error)),
);
return () => {
alive = false;
};
}, [api, period]);

const periods = (["today", "week", "month", "all"] as const).map((value) => ({
value,
label: u.periods[value],
}));
const most = Math.max(1, ...(bots ?? []).map((bot) => usedTokens(bot.tokens)));

return (
<section className="flex flex-col gap-3 border-line border-t pt-4">
<h3 className="font-semibold">{u.title}</h3>
<p className="text-ink-soft leading-relaxed">{u.intro}</p>
<Choices<Period> label={u.period} value={period} options={periods} onChange={setPeriod} />
{failed ? (
<Callout tone="danger" title={u.failed}>
{failed}
</Callout>
) : bots === null ? null : bots.length === 0 ? (
<p className="text-muted">{u.empty}</p>
) : (
<ul aria-label={u.title} className="flex flex-col gap-3">
{bots.map((bot) => (
<Row key={bot.botId} bot={bot} most={most} />
))}
{bots.length > 1 && <Total bots={bots} />}
</ul>
)}
</section>
);
}

function Row({ bot, most }: { bot: BotTokens; most: number }) {
const u = useT().account.usage.tokens;
const used = usedTokens(bot.tokens);
return (
<li className="flex items-center gap-3">
<BotAvatar color={bot.color} size={24} />
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-baseline justify-between gap-3">
<span className="truncate font-medium">
{bot.name}
{bot.archived && <span className="ml-1.5 text-muted text-xs">{u.archived}</span>}
</span>
<span className="shrink-0 tabular-nums">{tokens(used)}</span>
</div>
<div aria-hidden className="h-1 w-full bg-sunken">
<div
className="h-full origin-left animate-grow bg-work"
style={{ width: `${(used / most) * 100}%` }}
/>
</div>
<span className="text-muted text-xs">
{u.detail(bot.turns, tokens(bot.tokens.cacheRead))}
</span>
</div>
</li>
);
}

function Total({ bots }: { bots: BotTokens[] }) {
const u = useT().account.usage.tokens;
const used = bots.reduce((sum, bot) => sum + usedTokens(bot.tokens), 0);
const turns = bots.reduce((sum, bot) => sum + bot.turns, 0);
const reread = bots.reduce((sum, bot) => sum + bot.tokens.cacheRead, 0);
return (
<li className="flex flex-col gap-1 border-line border-t pt-3">
<div className="flex items-baseline justify-between gap-3 font-semibold">
<span>{u.total}</span>
<span className="tabular-nums">{tokens(used)}</span>
</div>
<span className="text-muted text-xs">{u.detail(turns, tokens(reread))}</span>
</li>
);
}
4 changes: 3 additions & 1 deletion app/src/features/account/UsageDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
// How much of the Claude plan the bots have used (spec 8.1), from the
// usage Claude Code reports while bots work.
// usage Claude Code reports while bots work, and the tokens each bot used.

import { useT } from "../../i18n";
import { fromNow } from "../../lib/format";
import type { UsageWindow } from "../../lib/protocol.gen";
import { useApp } from "../../store/context";
import { Callout } from "../../ui/Callout";
import { Dialog } from "../../ui/Dialog";
import { TokensByBot } from "./TokensByBot";

export function UsageDialog({ onClose }: { onClose(): void }) {
const u = useT().account.usage;
Expand All @@ -31,6 +32,7 @@ export function UsageDialog({ onClose }: { onClose(): void }) {
<p className="text-muted text-xs">{u.updated(fromNow(usage.observedAt))}</p>
</>
)}
<TokensByBot />
</div>
</Dialog>
);
Expand Down
59 changes: 59 additions & 0 deletions app/src/features/account/tokens.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { App } from "../../App";
import type { Client } from "../../lib/client";
import { FakeBotloft } from "../../lib/fake";
import { FakeHost } from "../../lib/fakeHost";
import { periodStart } from "./TokensByBot";

afterEach(cleanup);

async function openUsage(fake: FakeBotloft) {
render(<App host={new FakeHost()} connect={() => fake as Client} />);
fireEvent.click(await screen.findByRole("button", { name: /Ana Lima/ }));
fireEvent.click(screen.getByRole("menuitem", { name: "Usage" }));
return screen.getByRole("dialog", { name: "Usage" });
}

describe("tokens by bot", () => {
test("each bot's tokens add up over the period chosen, with the total", async () => {
const fake = new FakeBotloft();
const ops = fake.addCrew("Ops");
const scout = fake.addBot(ops.id, "Scout", "Finds sources");
const writer = fake.addBot(ops.id, "Writer", "Writes");
const now = fake.now;
fake.now = now - 3 * 86_400_000;
fake.chat.turn(writer.id);
fake.now = now;
fake.chat.turn(scout.id);
fake.chat.turn(scout.id);
fake.chat.turn(writer.id);

const dialog = await openUsage(fake);
const list = await within(dialog).findByRole("list", { name: "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",
"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("All bots9.8k");
});

test("a period with no work says so", async () => {
const dialog = await openUsage(new FakeBotloft());
expect(await within(dialog).findByText("No bot worked in this period.")).toBeDefined();
});

test("today starts at midnight; the others count back from now", () => {
const now = new Date(2026, 8, 30, 15, 20).getTime();
expect(periodStart("today", now)).toBe(new Date(2026, 8, 30).getTime());
expect(periodStart("week", now)).toBe(now - 7 * 86_400_000);
expect(periodStart("all", now)).toBe(0);
});
});
12 changes: 9 additions & 3 deletions app/src/features/chat/BotRun.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import { CircleAlert } from "lucide-react";
import type { ReactNode } from "react";
import { useT } from "../../i18n";
import { duration, when } from "../../lib/format";
import { duration, tokens, usedTokens, when } from "../../lib/format";
import type { Bot, ChatItem, TurnItem } from "../../lib/protocol.gen";
import { useArrival } from "../../ui/motion";
import { BotAvatar, moodOf } from "../bots/BotAvatar";
Expand All @@ -32,9 +32,15 @@ function TurnEnd({ turn }: { turn: TurnItem }) {
);
}
const time = duration(turn.durationMs);
const used = turn.tokens;
const detail = used && {
read: tokens(used.input + used.cacheWrite),
wrote: tokens(used.output),
reread: tokens(used.cacheRead),
};
return (
<p className="text-muted text-xs" title={t.chat.run.took(time, turn.costUsd)}>
{t.chat.run.done(time)}
<p className="text-muted text-xs" title={t.chat.run.took(time, detail)}>
{t.chat.run.done(time, used && tokens(usedTokens(used)))}
</p>
);
}
Expand Down
5 changes: 4 additions & 1 deletion app/src/features/chat/chat.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,10 @@ describe("chat", () => {
expect(within(chat()).getByText("12 passed")).toBeDefined();
expect(within(chat()).getByText("Command")).toBeDefined();
expect(within(chat()).getByText("npm test")).toBeDefined();
expect(within(chat()).getByText("Done in 4.2 s")).toBeDefined();
const done = within(chat()).getByText("Done in 4.2 s · 2.5k tokens");
expect(done.title).toBe(
"Took 4.2 s. Read 1.8k new tokens and wrote 640; reread 24k of the conversation, which weighs much less.",
);
});

test("an approval waits for the owner, and a denial carries a note", async () => {
Expand Down
2 changes: 1 addition & 1 deletion app/src/features/chat/rows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const tool = (name: string) =>
output: null,
file: null,
});
const turn = () => item({ kind: "turn", durationMs: 1000, costUsd: null, error: null });
const turn = () => item({ kind: "turn", durationMs: 1000, tokens: null, error: null });
const inbound = (body: string, at?: number) =>
item(
{
Expand Down
13 changes: 13 additions & 0 deletions app/src/i18n/en/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@ export const account = {
resets: (relative: string) => `Resets ${relative}`,
limited: "Limit reached. Your bots wait until it resets.",
updated: (relative: string) => `Updated ${relative}`,
tokens: {
title: "Tokens by bot",
intro:
"Tokens are the pieces of text a bot reads and writes. The count adds the new text read and the replies written; the conversation reread each time shows apart, as it weighs much less.",
period: "Period",
periods: { today: "Today", week: "7 days", month: "30 days", all: "All time" },
empty: "No bot worked in this period.",
failed: "Could not load the tokens",
archived: "archived",
detail: (times: number, reread: string) =>
`Worked ${times === 1 ? "once" : `${times} times`} · ${reread} reread`,
total: "All bots",
},
},
settings: {
title: "Settings",
Expand Down
18 changes: 15 additions & 3 deletions app/src/i18n/en/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@
// files and the composer. Bot names, tool names and what the bots write
// are data and never pass through here.

/** A turn's tokens, already written as numbers. */
export interface TurnTokens {
read: string;
wrote: string;
reread: string;
}

export const chat = {
view: {
label: (bot: string) => `Chat with ${bot}`,
Expand Down Expand Up @@ -47,9 +54,14 @@ export const chat = {
},
run: {
working: "Working",
done: (time: string) => `Done in ${time}`,
took: (time: string, usd: number | null) =>
`Took ${time}${usd === null ? "" : ` · about $${usd.toFixed(2)} of usage`}`,
done: (time: string, tokens: string | null) =>
`Done in ${time}${tokens === null ? "" : ` · ${tokens} tokens`}`,
took: (time: string, tokens: TurnTokens | null) =>
`Took ${time}${
tokens === null
? ""
: `. Read ${tokens.read} new tokens and wrote ${tokens.wrote}; reread ${tokens.reread} of the conversation, which weighs much less.`
}`,
stopped: (reason: string) => `The bot stopped working on this: ${reason}`,
},
tools: {
Expand Down
13 changes: 13 additions & 0 deletions app/src/i18n/es/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@ export const account: Messages["account"] = {
resets: (relative: string) => `Se renueva ${relative}`,
limited: "Límite alcanzado. Tus bots esperan hasta que se renueve.",
updated: (relative: string) => `Actualizado ${relative}`,
tokens: {
title: "Tokens por bot",
intro:
"Los tokens son los trozos de texto que un bot lee y escribe. La cuenta suma el texto nuevo leído y las respuestas escritas; la conversación releída cada vez aparece aparte, porque pesa mucho menos.",
period: "Período",
periods: { today: "Hoy", week: "7 días", month: "30 días", all: "Todo" },
empty: "Ningún bot trabajó en este período.",
failed: "No se pudieron cargar los tokens",
archived: "archivado",
detail: (times: number, reread: string) =>
`Trabajó ${times === 1 ? "1 vez" : `${times} veces`} · ${reread} releídos`,
total: "Todos los bots",
},
},
settings: {
title: "Configuración",
Expand Down
10 changes: 6 additions & 4 deletions app/src/i18n/es/chat.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Messages } from "../en";
import type { TurnTokens } from "../en/chat";

export const chat: Messages["chat"] = {
view: {
Expand Down Expand Up @@ -48,12 +49,13 @@ export const chat: Messages["chat"] = {
},
run: {
working: "Trabajando",
done: (time: string) => `Terminado en ${time}`,
took: (time: string, usd: number | null) =>
done: (time: string, tokens: string | null) =>
`Terminado en ${time}${tokens === null ? "" : ` · ${tokens} tokens`}`,
took: (time: string, tokens: TurnTokens | null) =>
`Tardó ${time}${
usd === null
tokens === null
? ""
: ` · unos ${usd.toLocaleString("es", { style: "currency", currency: "USD" })} de uso`
: `. Leyó ${tokens.read} tokens nuevos y escribió ${tokens.wrote}; releyó ${tokens.reread} de la conversación, que pesa mucho menos.`
}`,
stopped: (reason: string) => `El bot dejó de trabajar en esto: ${reason}`,
},
Expand Down
13 changes: 13 additions & 0 deletions app/src/i18n/pt-BR/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@ export const account: Messages["account"] = {
resets: (relative: string) => `Renova ${relative}`,
limited: "Limite atingido. Seus bots esperam até ele renovar.",
updated: (relative: string) => `Atualizado ${relative}`,
tokens: {
title: "Tokens por bot",
intro:
"Tokens são os pedaços de texto que um bot lê e escreve. A conta soma o texto novo lido e as respostas escritas; a conversa relida a cada vez aparece à parte, porque pesa bem menos.",
period: "Período",
periods: { today: "Hoje", week: "7 dias", month: "30 dias", all: "Tudo" },
empty: "Nenhum bot trabalhou nesse período.",
failed: "Não deu para carregar os tokens",
archived: "arquivado",
detail: (times: number, reread: string) =>
`Trabalhou ${times === 1 ? "1 vez" : `${times} vezes`} · ${reread} relidos`,
total: "Todos os bots",
},
},
settings: {
title: "Configurações",
Expand Down
10 changes: 6 additions & 4 deletions app/src/i18n/pt-BR/chat.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Messages } from "../en";
import type { TurnTokens } from "../en/chat";

export const chat: Messages["chat"] = {
view: {
Expand Down Expand Up @@ -48,12 +49,13 @@ export const chat: Messages["chat"] = {
},
run: {
working: "Trabalhando",
done: (time: string) => `Concluído em ${time}`,
took: (time: string, usd: number | null) =>
done: (time: string, tokens: string | null) =>
`Concluído em ${time}${tokens === null ? "" : ` · ${tokens} tokens`}`,
took: (time: string, tokens: TurnTokens | null) =>
`Levou ${time}${
usd === null
tokens === null
? ""
: ` · cerca de ${usd.toLocaleString("pt-BR", { style: "currency", currency: "USD" })} de uso`
: `. Leu ${tokens.read} tokens novos e escreveu ${tokens.wrote}; releu ${tokens.reread} da conversa, que pesa bem menos.`
}`,
stopped: (reason: string) => `O bot parou de trabalhar nisto: ${reason}`,
},
Expand Down
Loading
Loading