+ );
+}
+
+/** The calls going on, as pills; nothing when there are none. */
+export function CallPills({ calls, className = "" }: { calls: LiveCall[]; className?: string }) {
+ const t = useT().messages.calls;
+ if (calls.length === 0) {
+ return null;
+ }
+ return (
+
+ {calls.map((call) => (
+
+ ))}
+
+ );
+}
diff --git a/app/src/features/messages/calls.test.tsx b/app/src/features/messages/calls.test.tsx
new file mode 100644
index 00000000..c264868e
--- /dev/null
+++ b/app/src/features/messages/calls.test.tsx
@@ -0,0 +1,114 @@
+import { act, cleanup, fireEvent, screen, within } from "@testing-library/react";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { FakeBotloft } from "../../lib/fake";
+import type { Delivery, Message } from "../../lib/protocol.gen";
+import { liveCalls, nextChange, PICKED_UP_MS, withCall } from "../../store/calls";
+import { crewOpened, openBot, renderApp } from "../../test/app";
+
+afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+});
+
+const NOW = 1_000_000_000_000;
+
+function message(id: string, changes: Partial = {}): Message {
+ return {
+ id,
+ crewId: "crw_1",
+ fromKind: "bot",
+ fromBotId: "bot_a",
+ toBotId: "bot_b",
+ kind: "note",
+ body: "",
+ taskId: null,
+ routineId: null,
+ questionId: null,
+ attachments: [],
+ createdAt: NOW,
+ ...changes,
+ } as Message;
+}
+
+function delivery(messageId: string, changes: Partial = {}): Delivery {
+ return {
+ id: `dlv_${messageId}`,
+ messageId,
+ botId: "bot_b",
+ state: "sending",
+ attempts: 0,
+ nextAttemptAt: NOW,
+ lastError: null,
+ readAt: null,
+ updatedAt: NOW,
+ ...changes,
+ } as Delivery;
+}
+
+describe("calls", () => {
+ test("only a bot's message to another bot is a call", () => {
+ const empty = { calls: {}, deliveries: {} };
+ expect(withCall(empty, message("m1", { fromKind: "owner", fromBotId: null }))).toBeNull();
+ expect(withCall(empty, message("m1", { toBotId: "bot_a" }))).toBeNull();
+ expect(Object.keys(withCall(empty, message("m1"))?.calls ?? {})).toEqual(["m1"]);
+ });
+
+ test("ring until read, show the pick-up for a moment, and end when dead", () => {
+ const calls = withCall({ calls: {}, deliveries: {} }, message("m1"), NOW)?.calls ?? {};
+ const at = (deliveries: Record, now: number) =>
+ liveCalls({ calls, deliveries }, now);
+
+ expect(at({}, NOW + 1000)[0]?.readAt).toBeNull();
+ const read = { m1: delivery("m1", { state: "sent", readAt: NOW + 2000 }) };
+ expect(at(read, NOW + 3000)[0]?.readAt).toBe(NOW + 2000);
+ expect(nextChange(at(read, NOW + 3000), NOW + 3000)).toBe(PICKED_UP_MS - 1000 + 1);
+ expect(at(read, NOW + 2000 + PICKED_UP_MS + 1)).toEqual([]);
+ expect(at({ m1: delivery("m1", { state: "dead" }) }, NOW + 1000)).toEqual([]);
+ });
+
+ test("one pill per pair of bots, the latest call", () => {
+ let state = { calls: {}, deliveries: {} };
+ state = { ...state, ...withCall(state, message("m1"), NOW) };
+ state = { ...state, ...withCall(state, message("m2", { createdAt: NOW + 5 }), NOW) };
+ state = {
+ ...state,
+ ...withCall(state, message("m3", { fromBotId: "bot_b", toBotId: "bot_a" }), NOW),
+ };
+ expect(liveCalls(state, NOW + 10).map((call) => call.messageId)).toEqual(["m3", "m2"]);
+ });
+});
+
+describe("in the app", () => {
+ test("the caller's chat and the crew page show the call until it is picked up", 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");
+ fake.setBotState(scout.id, "idle");
+ renderApp(fake);
+ await crewOpened("Ops");
+ openBot("Scout");
+ await screen.findByRole("list", { name: "Messages" });
+
+ let sent: ReturnType | undefined;
+ act(() => {
+ sent = fake.conversation.say({ from: scout.id, to: writer.id, body: "Draft the report" });
+ });
+ const pills = () => screen.getByRole("list", { name: "Bots calling each other" });
+ expect(within(pills()).getByText("Calling Writer")).toBeDefined();
+ expect(within(pills()).getByText("Scout:")).toBeDefined();
+
+ // The crew page shows it too.
+ fireEvent.click(screen.getAllByRole("button", { name: "Ops" })[0] as HTMLElement);
+ expect(
+ within(await screen.findByRole("list", { name: "Bots calling each other" })).getByText(
+ "Calling Writer",
+ ),
+ ).toBeDefined();
+
+ act(() => {
+ fake.conversation.read((sent as NonNullable).delivery.id);
+ });
+ expect(within(pills()).getByText("Writer picked it up")).toBeDefined();
+ });
+});
diff --git a/app/src/i18n/en/messages.ts b/app/src/i18n/en/messages.ts
index 01c2b7a7..e644cce1 100644
--- a/app/src/i18n/en/messages.ts
+++ b/app/src/i18n/en/messages.ts
@@ -3,6 +3,14 @@
// waiting or not delivered, never a "lease" or a "dead letter".
export const messages = {
+ /** Bots calling each other, live. */
+ calls: {
+ label: "Bots calling each other",
+ /** Before the call, for screen readers: the mascots show it. */
+ caller: (bot: string) => `${bot}:`,
+ calling: (bot: string) => `Calling ${bot}`,
+ picked: (bot: string) => `${bot} picked it up`,
+ },
composer: {
messageTo: "Message to",
recipient: "Recipient",
diff --git a/app/src/i18n/es/messages.ts b/app/src/i18n/es/messages.ts
index cbfdf4f1..8aa6c67e 100644
--- a/app/src/i18n/es/messages.ts
+++ b/app/src/i18n/es/messages.ts
@@ -1,6 +1,12 @@
import type { Messages } from "../en";
export const messages: Messages["messages"] = {
+ calls: {
+ label: "Bots llamándose entre sí",
+ caller: (bot) => `${bot}:`,
+ calling: (bot) => `Llamando a ${bot}`,
+ picked: (bot) => `${bot} respondió`,
+ },
composer: {
messageTo: "Mensaje para",
recipient: "Destinatario",
diff --git a/app/src/i18n/pt-BR/messages.ts b/app/src/i18n/pt-BR/messages.ts
index 7b73e49d..726ecf4b 100644
--- a/app/src/i18n/pt-BR/messages.ts
+++ b/app/src/i18n/pt-BR/messages.ts
@@ -1,6 +1,12 @@
import type { Messages } from "../en";
export const messages: Messages["messages"] = {
+ calls: {
+ label: "Bots chamando uns aos outros",
+ caller: (bot) => `${bot}:`,
+ calling: (bot) => `Chamando ${bot}`,
+ picked: (bot) => `${bot} atendeu`,
+ },
composer: {
messageTo: "Mensagem para",
recipient: "Destinatário",
diff --git a/app/src/index.css b/app/src/index.css
index c46d631b..c25f9d53 100644
--- a/app/src/index.css
+++ b/app/src/index.css
@@ -18,6 +18,7 @@
@import "./files.css";
@import "./search.css";
@import "./activity.css";
+@import "./calls.css";
@import "./browser-stage.css";
@import "./motion-less.css";
@import "./paused.css";
diff --git a/app/src/store/app.ts b/app/src/store/app.ts
index 569440ec..238b82ca 100644
--- a/app/src/store/app.ts
+++ b/app/src/store/app.ts
@@ -23,6 +23,7 @@ import type {
Task,
TaskId,
} from "../lib/protocol.gen";
+import { type Call, withCall } from "./calls";
import { withQuestion } from "./questions";
import { loadMarked, loadSeen, loadSeenSince, seenActions, withReply } from "./seen";
@@ -50,6 +51,8 @@ export interface AppState {
* updated ones and every dead one, then every change.
*/
deliveries: Record;
+ /** Messages bots sent each other since the app connected (`calls.ts`). */
+ calls: Record;
tasks: Record;
/** Every routine that is not archived (spec 20), then every change. */
routines: Record;
@@ -163,6 +166,7 @@ export function createAppStore(api: BotloftApi): AppStore {
bots: {},
activity: {},
deliveries: {},
+ calls: {},
tasks: {},
routines: {},
browsers: {},
@@ -252,6 +256,7 @@ function withoutBots(state: AppState, botIds: BotId[]): Partial {
browsers: kept(state.browsers, (browser) => !gone.has(browser.botId)),
questions: kept(state.questions, (question) => !gone.has(question.botId)),
deliveries: kept(state.deliveries, (delivery) => !gone.has(delivery.botId)),
+ calls: kept(state.calls, (call) => !gone.has(call.fromBotId) && !gone.has(call.toBotId)),
tasks: kept(
state.tasks,
(task) => !gone.has(task.requesterBotId) && !gone.has(task.assigneeBotId),
@@ -311,6 +316,8 @@ export function applyEvent(state: AppState, event: ServerEvent): Partial;
+ deliveries: Record;
+}
+
+/** The call as it stands at `now`, or `null` once it is over. */
+function live(call: Call, deliveries: CallState["deliveries"], now: number): LiveCall | null {
+ const delivery = deliveries[call.messageId];
+ if (delivery?.state === "dead") {
+ return null;
+ }
+ const readAt = delivery?.readAt ?? null;
+ if (readAt === null ? now - call.at > RING_MS : now - readAt > PICKED_UP_MS) {
+ return null;
+ }
+ return { ...call, readAt };
+}
+
+/** A new message: a call if one bot sent it to another. Over calls leave. */
+export function withCall(state: CallState, message: Message, now = Date.now()) {
+ const { fromBotId, toBotId } = message;
+ if (message.fromKind !== "bot" || !fromBotId || fromBotId === toBotId) {
+ return null;
+ }
+ const calls: Record = {};
+ for (const call of Object.values(state.calls)) {
+ if (live(call, state.deliveries, now)) {
+ calls[call.messageId] = call;
+ }
+ }
+ calls[message.id] = {
+ messageId: message.id,
+ crewId: message.crewId,
+ fromBotId,
+ toBotId,
+ at: message.createdAt,
+ };
+ return { calls };
+}
+
+/**
+ * The calls going on, oldest first, one per pair of bots (its latest),
+ * kept to those `keep` wants.
+ */
+export function liveCalls(
+ state: CallState,
+ now: number,
+ keep: (call: Call) => boolean = () => true,
+): LiveCall[] {
+ const byPair = new Map();
+ for (const call of Object.values(state.calls)) {
+ const shown = keep(call) ? live(call, state.deliveries, now) : null;
+ const pair = `${call.fromBotId}>${call.toBotId}`;
+ const before = byPair.get(pair);
+ if (shown && (!before || before.at <= shown.at)) {
+ byPair.set(pair, shown);
+ }
+ }
+ return [...byPair.values()].sort((a, b) => a.at - b.at);
+}
+
+/** When the next shown call ends by itself, to look again then. */
+export function nextChange(calls: LiveCall[], now: number): number | null {
+ const ends = calls.map((call) =>
+ call.readAt === null ? call.at + RING_MS : call.readAt + PICKED_UP_MS,
+ );
+ return ends.length ? Math.max(0, Math.min(...ends) - now) + 1 : null;
+}
diff --git a/docs/spec.md b/docs/spec.md
index cea5fd82..0d5428bd 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -883,6 +883,7 @@ No chat:
- O dono fala em balões à direita; o bot, à esquerda, com markdown.
- Embaixo da mensagem do dono, e em cada linha da timeline da crew, fica onde ela está (9.1), com ícone e texto: esperando, entregando, "Entregue" com um tique e, quando o bot começou a trabalhar nela, "Lida" com dois tiques. Os dois tiques de "Lida" ficam em azul-claro, como nos apps de mensagem, para o dono ver de relance que o bot leu; a palavra continua na cor apagada das outras linhas, e é ela que diz o estado. A cor é o token `--read` (`#1787c9` no tema claro, `#5cc4f2` no escuro): mais clara que o `--work`, que é de bot trabalhando e de mensagem sendo entregue, e com contraste de ao menos 3:1 sobre `canvas`, `panel` e `sunken` nos dois temas, o que um teste confere. O azul dos apps de mensagem (`#53bdeb`) não passa de 2,2:1 no tema claro, por isso o claro usa um tom mais fechado.
- Mensagens de outros bots aparecem à esquerda, com o avatar e o nome de quem mandou.
+- **Bots se chamando.** Uma message de um bot para outro é uma chamada até o outro pegá-la (`read_at`, 9.1). Enquanto toca, uma pílula mostra os mascotes dos dois, um sobre o outro em círculos, com "Chamando " e três pontos; quando o outro pega, vira " atendeu", com um tique, por 4 s, e some. Uma delivery `dead` encerra a chamada, e uma que ninguém pegou em uma hora deixa de ser chamada. Aparece no chat de quem chamou, flutuando acima do compositor sem mexer na conversa, e no cabeçalho da página da crew, com as chamadas de todos os bots dela. Uma pílula por par de bots, a da chamada mais nova. O app guarda quem chamou quem a partir de `message.created` desde que conectou; se foi atendida, sabe pela delivery, que toda conexão carrega de novo. Os mascotes da pílula se olham como os outros (15.3). O leitor de tela ouve quem chama antes ("Writer: Chamando Revisão").
- Um bot citado pelo handle ("@writer") nas respostas e nas messages fica em azul-claro (token `--mention`, que ainda lê a 4,5:1) e um pouco mais forte, para não se perder no texto. E-mail, caminho e o que está em código ou link não contam.
- O que o bot faz com as ferramentas aparece em linhas compactas (ícone, ferramenta, resumo e estado), que abrem para mostrar entrada e saída. As chamadas seguidas ficam dobradas numa linha só, com uma seta para abrir todas: enquanto o bot trabalha nelas, a linha diz a chamada em que ele está (o nome com um brilho passando e um círculo girando, sem os três pontos embaixo) e troca, subindo, a cada chamada nova; quando ele escreve, pede algo ou para, a linha vira um resumo por tipo, na ordem em que fez, com as falhas no fim ("2 passos no navegador, 1 comando rodado (1 falha)"). Uma chamada sozinha que já terminou fica na sua própria linha. Com menos movimento, o nome fica parado.
- Pedido de aprovação é um cartão com o que o bot quer fazer e os botões Permitir e Negar.