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
12 changes: 12 additions & 0 deletions app/src/calls.css
Original file line number Diff line number Diff line change
@@ -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);
}
2 changes: 2 additions & 0 deletions app/src/dev/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand Down
32 changes: 32 additions & 0 deletions app/src/dev/seedCalls.ts
Original file line number Diff line number Diff line change
@@ -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);
}
10 changes: 9 additions & 1 deletion app/src/features/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);
Expand Down Expand Up @@ -218,7 +221,12 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto
</SeenSince.Provider>
</div>
</div>
<div className="w-full">
<div className="relative w-full">
{/* Floats over the chat's end, so nothing moves when it comes. */}
<CallPills
calls={calls}
className="absolute bottom-full left-1/2 z-10 mb-2 -translate-x-1/2 justify-center"
/>
{/* Keyed by bot: each one has its own draft (spec 15.1). */}
<ChatComposer key={bot.id} bot={bot} files={files} stopped={stopped} onSent={onSent} />
</div>
Expand Down
3 changes: 3 additions & 0 deletions app/src/features/crews/CrewView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<Open>(null);
const [pane, setPane] = useState<Pane>("bots");
const close = () => setOpen(null);
Expand Down Expand Up @@ -86,6 +88,7 @@ export function CrewView({ crew }: { crew: Crew }) {
</button>
</p>
</div>
<CallPills calls={calls} className="max-w-[45%] justify-end" />
<Button variant="primary" icon={Plus} onClick={() => setOpen("bot")}>
{t.crews.newBot}
</Button>
Expand Down
94 changes: 94 additions & 0 deletions app/src/features/messages/CallPills.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<li
className={`call-pill flex animate-rise items-center gap-2 rounded-full border bg-panel py-1 pr-3 pl-1 text-sm shadow-sm ${
picked ? "border-ok/45" : "border-line"
}`}
data-picked={picked || undefined}
>
<span aria-hidden className="flex items-center">
<span className="call-avatar">
<BotAvatar botId={from.id} color={from.color} size={20} />
</span>
<span className="call-avatar -ml-2">
<BotAvatar botId={to.id} color={to.color} size={20} />
</span>
</span>
{picked ? (
<span className="flex items-center gap-1 text-ok">
<Check aria-hidden size={13} className="shrink-0" />
<span className="sr-only">{t.caller(from.name)}</span>
{t.picked(to.name)}
</span>
) : (
<span className="flex items-center gap-1.5 text-ink-soft">
{/* The mascots say who calls; a screen reader hears it. */}
<span className="sr-only">{t.caller(from.name)}</span>
<span className="truncate">{t.calling(to.name)}</span>
<span aria-hidden className="flex items-center gap-0.5">
{[0, 160, 320].map((delay) => (
<span
key={delay}
className="size-1 animate-dot rounded-full bg-accent"
style={{ animationDelay: `${delay}ms` }}
/>
))}
</span>
</span>
)}
</li>
);
}

/** 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 (
<ul aria-label={t.label} aria-live="polite" className={`flex flex-wrap gap-2 ${className}`}>
{calls.map((call) => (
<Pill key={call.messageId} call={call} />
))}
</ul>
);
}
114 changes: 114 additions & 0 deletions app/src/features/messages/calls.test.tsx
Original file line number Diff line number Diff line change
@@ -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> = {}): 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> = {}): 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<string, Delivery>, 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<typeof fake.conversation.say> | 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<typeof sent>).delivery.id);
});
expect(within(pills()).getByText("Writer picked it up")).toBeDefined();
});
});
8 changes: 8 additions & 0 deletions app/src/i18n/en/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions app/src/i18n/es/messages.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
6 changes: 6 additions & 0 deletions app/src/i18n/pt-BR/messages.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
1 change: 1 addition & 0 deletions app/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading
Loading