diff --git a/app/src/calls.css b/app/src/calls.css new file mode 100644 index 00000000..aa0f0061 --- /dev/null +++ b/app/src/calls.css @@ -0,0 +1,12 @@ +/* Bots calling each other (spec 15.3): the two mascots of a call sit in + round badges, the second over the first, so the pair reads at a glance. */ + +.call-avatar { + display: grid; + place-items: center; + width: 26px; + height: 26px; + border-radius: 999px; + background: var(--sunken); + box-shadow: 0 0 0 2px var(--panel); +} diff --git a/app/src/dev/preview.ts b/app/src/dev/preview.ts index 6a89508c..a8a9e87e 100644 --- a/app/src/dev/preview.ts +++ b/app/src/dev/preview.ts @@ -11,6 +11,7 @@ import { FakeHost } from "../lib/fakeHost"; import type { Host } from "../lib/host"; import { PROTOCOL_VERSION } from "../lib/protocol.gen"; import { seedBrowser } from "./seedBrowser"; +import { seedCalls } from "./seedCalls"; import { seedChats } from "./seedChats"; import { seedChief } from "./seedChief"; import { seedContext } from "./seedContext"; @@ -132,6 +133,7 @@ function seed(fake: FakeBotloft): void { fake.browser.rest(analyst.id); seedScreens(fake, research.id); seedHands(fake, research.id); + seedCalls(fake, { writer: writer.id, reviewer: reviewer.id }); const ops = fake.addCrew("Ops"); const deploy = fake.addBot(ops.id, "Deploy", "Ships the site on Fridays"); const watcher = fake.addBot(ops.id, "Watcher", "Keeps an eye on the error log"); diff --git a/app/src/dev/seedCalls.ts b/app/src/dev/seedCalls.ts new file mode 100644 index 00000000..cae81c45 --- /dev/null +++ b/app/src/dev/seedCalls.ts @@ -0,0 +1,32 @@ +// Dev only: Writer calls Revisão now and then, so the preview shows bots +// calling each other (spec 15.3), ringing and then picked up. + +import type { FakeBotloft } from "../lib/fake"; +import type { BotId } from "../lib/protocol.gen"; + +const FIRST_MS = 4000; +const EVERY_MS = 40_000; +const PICK_UP_MS = 6000; +const TIMES = 3; + +export function seedCalls(fake: FakeBotloft, bots: { writer: BotId; reviewer: BotId }): void { + let made = 0; + const call = () => { + fake.now = Date.now(); + const { delivery } = fake.conversation.say({ + from: bots.writer, + to: bots.reviewer, + kind: "task", + body: "The draft is in shared/drafts/week-39-report.md. Check the facts and the tone?", + }); + setTimeout(() => { + fake.now = Date.now(); + fake.conversation.read(delivery.id); + }, PICK_UP_MS); + made += 1; + if (made < TIMES) { + setTimeout(call, EVERY_MS); + } + }; + setTimeout(call, FIRST_MS); +} diff --git a/app/src/features/chat/ChatView.tsx b/app/src/features/chat/ChatView.tsx index 1066c772..3f3a470a 100644 --- a/app/src/features/chat/ChatView.tsx +++ b/app/src/features/chat/ChatView.tsx @@ -20,6 +20,7 @@ import { Button } from "../../ui/Button"; import { Callout } from "../../ui/Callout"; import { SeenSince } from "../../ui/motion"; import { BotAvatar, moodOf } from "../bots/BotAvatar"; +import { CallPills, useLiveCalls } from "../messages/CallPills"; import { BotRun, type Live } from "./BotRun"; import { ChatComposer } from "./ChatComposer"; import { InboundRow } from "./InboundRow"; @@ -39,6 +40,8 @@ const hasFiles = (event: DragEvent) => event.dataTransfer.types.includes("Files" /** Skips the renders of the view around it; the text being written re-renders only the last run. */ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; stopped: boolean }) { + // The bots this one is calling, over the end of its chat (spec 15.3). + const calls = useLiveCalls((call) => call.fromBotId === bot.id, bot.id); const t = useT(); const focus = useApp((state) => (state.focus?.botId === bot.id ? state.focus.itemId : null)); const chat = useChat(bot.id, focus); @@ -218,7 +221,12 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto -
+
+ {/* Floats over the chat's end, so nothing moves when it comes. */} + {/* Keyed by bot: each one has its own draft (spec 15.1). */}
diff --git a/app/src/features/crews/CrewView.tsx b/app/src/features/crews/CrewView.tsx index 6847a09b..62b166c3 100644 --- a/app/src/features/crews/CrewView.tsx +++ b/app/src/features/crews/CrewView.tsx @@ -22,6 +22,7 @@ import { Menu } from "../../ui/Menu"; import { type Tab, Tabs, tabId } from "../../ui/Tabs"; import { attempt } from "../../ui/toast"; import { BotDialog } from "../bots/BotDialog"; +import { CallPills, useLiveCalls } from "../messages/CallPills"; import { Composer } from "../messages/Composer"; import { Timeline } from "../messages/Timeline"; import { CrewRoutines } from "../routines/RoutineList"; @@ -40,6 +41,7 @@ export function CrewView({ crew }: { crew: Crew }) { const host = useHost(); const putCrew = useApp((state) => state.putCrew); const bots = useApp(useShallow((state) => botsOf(state, crew.id))); + const calls = useLiveCalls((call) => call.crewId === crew.id, crew.id); const [open, setOpen] = useState(null); const [pane, setPane] = useState("bots"); const close = () => setOpen(null); @@ -86,6 +88,7 @@ export function CrewView({ crew }: { crew: Crew }) {

+ diff --git a/app/src/features/messages/CallPills.tsx b/app/src/features/messages/CallPills.tsx new file mode 100644 index 00000000..82440563 --- /dev/null +++ b/app/src/features/messages/CallPills.tsx @@ -0,0 +1,94 @@ +// Bots calling each other, live (spec 15.3): one pill per call, with the +// mascots of both bots stacked and "Calling Writer…" until the other bot +// picks the message up, then "Writer picked it up" for a moment. + +import { Check } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { useT } from "../../i18n"; +import { type Call, type LiveCall, liveCalls, nextChange } from "../../store/calls"; +import { useApp } from "../../store/context"; +import { BotAvatar } from "../bots/BotAvatar"; + +/** The calls `keep` wants, kept current as they ring and end. */ +export function useLiveCalls(keep: (call: Call) => boolean, key: string): LiveCall[] { + const calls = useApp((state) => state.calls); + const deliveries = useApp((state) => state.deliveries); + const [now, setNow] = useState(Date.now); + // biome-ignore lint/correctness/useExhaustiveDependencies: `key` names what `keep` keeps + const shown = useMemo( + () => liveCalls({ calls, deliveries }, Math.max(now, Date.now()), keep), + [calls, deliveries, now, key], + ); + // Look again when the next one ends by itself. + useEffect(() => { + const wait = nextChange(shown, Date.now()); + if (wait === null) return; + const timer = setTimeout(() => setNow(Date.now()), wait); + return () => clearTimeout(timer); + }, [shown]); + return shown; +} + +function Pill({ call }: { call: LiveCall }) { + const t = useT().messages.calls; + const from = useApp((state) => state.bots[call.fromBotId]); + const to = useApp((state) => state.bots[call.toBotId]); + if (!from || !to) { + return null; + } + const picked = call.readAt !== null; + return ( +
  • + + + + + + + + + {picked ? ( + + + {t.caller(from.name)} + {t.picked(to.name)} + + ) : ( + + {/* The mascots say who calls; a screen reader hears it. */} + {t.caller(from.name)} + {t.calling(to.name)} + + {[0, 160, 320].map((delay) => ( + + ))} + + + )} +
  • + ); +} + +/** 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 ( + + ); +} 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.