diff --git a/app/src/features/chat/BotRun.tsx b/app/src/features/chat/BotRun.tsx
index a0034f5f..ff86f15c 100644
--- a/app/src/features/chat/BotRun.tsx
+++ b/app/src/features/chat/BotRun.tsx
@@ -165,6 +165,7 @@ function BotRunView({
diff --git a/app/src/features/chat/ChatView.tsx b/app/src/features/chat/ChatView.tsx
index 166af57e..66c30df3 100644
--- a/app/src/features/chat/ChatView.tsx
+++ b/app/src/features/chat/ChatView.tsx
@@ -25,6 +25,7 @@ import { BotRun, type Live } from "./BotRun";
import { ChatComposer } from "./ChatComposer";
import { InboundRow } from "./InboundRow";
import { NoticeRow } from "./NoticeRow";
+import { ReactionsOf, useReactions } from "./Reactions";
import { type ReplyTarget, StartReply } from "./Replying";
import { chatRows, rowHas } from "./rows";
import { useChat } from "./useChat";
@@ -58,6 +59,7 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto
/** What the owner's next message replies to (spec 9.3). */
const [replying, setReplying] = useState(null);
const cancelReply = useCallback(() => setReplying(null), []);
+ const reactions = useReactions(bot.id);
// What was there when the owner opened this chat stays still; what
// arrives while they look animates in.
// biome-ignore lint/correctness/useExhaustiveDependencies: a new bot is a new chat
@@ -215,15 +217,17 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto
)}
-
-
- {list}
-
-
+
+
+
+ {list}
+
+
+
diff --git a/app/src/features/chat/Reactions.tsx b/app/src/features/chat/Reactions.tsx
new file mode 100644
index 00000000..8931a937
--- /dev/null
+++ b/app/src/features/chat/Reactions.tsx
@@ -0,0 +1,139 @@
+// The owner's reactions to what the bot wrote (spec 8.9): a button beside
+// the reply button opens the emoji, and the one picked sits under the reply,
+// saying whether the bot will see it with the next message or already did.
+
+import { SmilePlus } from "lucide-react";
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { useT } from "../../i18n";
+import { REACTIONS, type Reaction } from "../../lib/protocol.gen";
+import { useApi, useApp } from "../../store/context";
+import { attempt } from "../../ui/toast";
+import { useDismiss } from "../../ui/useDismiss";
+
+export interface Reactions {
+ byItem: Record;
+ set(itemId: string, emoji: string | null): void;
+}
+
+/** The open chat's reactions; absent where none can be put. */
+export const ReactionsOf = createContext(null);
+
+/** The bot's reactions, loaded on each connection and kept current. */
+export function useReactions(botId: string): Reactions {
+ const api = useApi();
+ const t = useT().chat.react;
+ const open = useApp((state) => state.connection.kind === "open");
+ const [byItem, setByItem] = useState>({});
+ useEffect(() => {
+ if (!open) return;
+ let live = true;
+ void api.call("reactions.list", { botId }).then(
+ (list) => live && setByItem(Object.fromEntries(list.map((one) => [one.itemId, one]))),
+ () => undefined,
+ );
+ const stop = api.subscribe((event) => {
+ if (event.name !== "reaction.changed" || event.params.botId !== botId) return;
+ const { itemId, reaction } = event.params;
+ setByItem((before) => {
+ const { [itemId]: _, ...rest } = before;
+ return reaction ? { ...rest, [itemId]: reaction } : rest;
+ });
+ });
+ return () => {
+ live = false;
+ stop();
+ };
+ }, [api, botId, open]);
+ const set = useCallback(
+ (itemId: string, emoji: string | null) =>
+ attempt(t.failed, () => api.call("reactions.set", { botId, itemId, emoji })),
+ [api, botId, t.failed],
+ );
+ return useMemo(() => ({ byItem, set }), [byItem, set]);
+}
+
+/** Opens the emoji to react with. */
+export function ReactButton({ itemId, bot }: { itemId: string; bot: string }) {
+ const t = useT().chat.react;
+ const reactions = useContext(ReactionsOf);
+ const [open, setOpen] = useState(false);
+ const root = useRef(null);
+ const close = useCallback(() => setOpen(false), []);
+ useDismiss(open, root, close);
+ if (!reactions) {
+ return null;
+ }
+ const current = reactions.byItem[itemId]?.emoji ?? null;
+ return (
+
+
+ {open && (
+
+ {REACTIONS.map((emoji) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+/** Under the reply: the owner's reaction; a click takes it off. */
+export function ReactionMark({ itemId, bot }: { itemId: string; bot: string }) {
+ const t = useT().chat.react;
+ const reactions = useContext(ReactionsOf);
+ const reaction = reactions?.byItem[itemId];
+ if (!reactions || !reaction) {
+ return null;
+ }
+ const seen = reaction.sentIn !== null;
+ return (
+
+
+ {seen ? t.seen(bot) : t.waiting(bot)}
+
+ );
+}
diff --git a/app/src/features/chat/Replying.tsx b/app/src/features/chat/Replying.tsx
index 84f6a2b7..48e42e1e 100644
--- a/app/src/features/chat/Replying.tsx
+++ b/app/src/features/chat/Replying.tsx
@@ -8,6 +8,7 @@ import { useT } from "../../i18n";
import { plainText } from "../../lib/plainText";
import type { ChatItemId, MessageReply } from "../../lib/protocol.gen";
import { useApp } from "../../store/context";
+import { ReactButton, ReactionMark } from "./Reactions";
/** What the owner is replying to, while they write. */
export interface ReplyTarget {
@@ -20,14 +21,20 @@ export interface ReplyTarget {
/** Starts a reply; absent where the chat cannot be written to. */
export const StartReply = createContext<((target: ReplyTarget) => void) | null>(null);
-/** Wraps what can be replied to, with the button that shows on hover. */
+/**
+ * Wraps what can be replied to, with the buttons that show on hover: reply,
+ * and for the bot's own replies, react (spec 8.9).
+ */
export function Repliable({
target,
+ react = false,
className = "",
children,
}: {
target: ReplyTarget;
- /** A bubble narrows it to its own width, for the button to sit on its corner. */
+ /** The bot's own reply: the owner can react to it too. */
+ react?: boolean;
+ /** A bubble narrows it to its own width, for the buttons to sit on its corner. */
className?: string;
children: ReactNode;
}) {
@@ -39,15 +46,19 @@ export function Repliable({
return (
{children}
-
+ {react && }
+
+ {react && }
+
+
);
}
diff --git a/app/src/features/chat/reactions.test.tsx b/app/src/features/chat/reactions.test.tsx
new file mode 100644
index 00000000..2dbdc8c6
--- /dev/null
+++ b/app/src/features/chat/reactions.test.tsx
@@ -0,0 +1,67 @@
+import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
+import { afterEach, describe, expect, test } from "vitest";
+import { FakeBotloft } from "../../lib/fake";
+import { crewOpened, openBot, renderApp } from "../../test/app";
+
+afterEach(cleanup);
+
+async function setup() {
+ 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");
+ const said = fake.chat.reply(scout.id, "Acme signs annual.");
+ fake.chat.turn(scout.id);
+ fake.conversation.say({ from: writer.id, to: scout.id, body: "The draft is ready." });
+ renderApp(fake);
+ await crewOpened("Ops");
+ openBot("Scout");
+ await screen.findByRole("list", { name: "Messages" });
+ return { fake, scout, said };
+}
+
+function react(emoji: string) {
+ fireEvent.click(screen.getByRole("button", { name: "React to what Scout wrote" }));
+ const menu = screen.getByRole("menu", { name: "React to what Scout wrote" });
+ fireEvent.click(within(menu).getByRole("menuitemradio", { name: emoji }));
+}
+
+describe("reacting to a reply", () => {
+ test("puts the emoji under the reply, waiting for the next message, then seen", async () => {
+ const { fake, scout, said } = await setup();
+ // Only the bot's own replies take one.
+ expect(screen.getAllByRole("button", { name: /^React to/ })).toHaveLength(1);
+
+ react("👍");
+ expect(await screen.findByText("Scout sees it with your next message")).toBeDefined();
+ expect(fake.calls.at(-1)).toMatchObject({
+ method: "reactions.set",
+ params: { botId: scout.id, itemId: said.id, emoji: "👍" },
+ });
+ // The bot was not sent anything.
+ expect(fake.calls.some((call) => call.method === "messages.send")).toBe(false);
+
+ const field = screen.getByRole("textbox", { name: "Message to Scout" });
+ fireEvent.change(field, { target: { value: "Next?" } });
+ fireEvent.keyDown(field, { key: "Enter" });
+ expect(await screen.findByText("Scout saw it")).toBeDefined();
+ });
+
+ test("another emoji replaces it, the same one or a click on it takes it off", async () => {
+ await setup();
+ react("👍");
+ await screen.findByRole("button", { name: "👍 · Take the reaction off" });
+ react("🎉");
+ await screen.findByRole("button", { name: "🎉 · Take the reaction off" });
+ react("🎉");
+ await waitFor(() =>
+ expect(screen.queryByRole("button", { name: /Take the reaction off/ })).toBeNull(),
+ );
+ react("❤️");
+ fireEvent.click(await screen.findByRole("button", { name: "❤️ · Take the reaction off" }));
+ await waitFor(() =>
+ expect(screen.queryByRole("button", { name: /Take the reaction off/ })).toBeNull(),
+ );
+ });
+});
diff --git a/app/src/i18n/en/chat.ts b/app/src/i18n/en/chat.ts
index d7928649..b2d066eb 100644
--- a/app/src/i18n/en/chat.ts
+++ b/app/src/i18n/en/chat.ts
@@ -103,6 +103,15 @@ export const chat = {
cancel: "Cancel the reply",
jump: "Show what this replies to",
},
+ /** The owner's reactions to the bot's replies (spec 8.9). */
+ react: {
+ action: "React",
+ to: (bot: string) => `React to what ${bot} wrote`,
+ remove: "Take the reaction off",
+ waiting: (bot: string) => `${bot} sees it with your next message`,
+ seen: (bot: string) => `${bot} saw it`,
+ failed: "Could not save the reaction",
+ },
approval: {
asks: (bot: string, action: string) => `${bot} asks to ${action}`,
wants: (bot: string, action: string) => `${bot} wants to ${action}`,
diff --git a/app/src/i18n/es/chat.ts b/app/src/i18n/es/chat.ts
index 1b832958..d32c08f7 100644
--- a/app/src/i18n/es/chat.ts
+++ b/app/src/i18n/es/chat.ts
@@ -95,6 +95,14 @@ export const chat: Messages["chat"] = {
cancel: "Cancelar la respuesta",
jump: "Mostrar el mensaje respondido",
},
+ react: {
+ action: "Reaccionar",
+ to: (bot) => `Reaccionar a lo que escribió ${bot}`,
+ remove: "Quitar la reacción",
+ waiting: (bot) => `${bot} lo verá con tu próximo mensaje`,
+ seen: (bot) => `${bot} lo vio`,
+ failed: "No se pudo guardar la reacción",
+ },
approval: {
asks: (bot: string, action: string) => `${bot} pide ${action}`,
wants: (bot: string, action: string) => `${bot} quiere ${action}`,
diff --git a/app/src/i18n/pt-BR/chat.ts b/app/src/i18n/pt-BR/chat.ts
index 53321e68..30b0efd2 100644
--- a/app/src/i18n/pt-BR/chat.ts
+++ b/app/src/i18n/pt-BR/chat.ts
@@ -94,6 +94,14 @@ export const chat: Messages["chat"] = {
cancel: "Cancelar a resposta",
jump: "Mostrar a mensagem respondida",
},
+ react: {
+ action: "Reagir",
+ to: (bot) => `Reagir ao que ${bot} escreveu`,
+ remove: "Tirar a reação",
+ waiting: (bot) => `${bot} vê junto com sua próxima mensagem`,
+ seen: (bot) => `${bot} viu`,
+ failed: "Não foi possível salvar a reação",
+ },
approval: {
asks: (bot: string, action: string) => `${bot} pede para ${action}`,
wants: (bot: string, action: string) => `${bot} quer ${action}`,
diff --git a/app/src/lib/fake.ts b/app/src/lib/fake.ts
index a6c08a15..13b75c38 100644
--- a/app/src/lib/fake.ts
+++ b/app/src/lib/fake.ts
@@ -11,6 +11,7 @@ import { FakeConversation } from "./fakeConversation";
import { crewHandlers } from "./fakeCrews";
import { FakeFiles } from "./fakeFiles";
import { FakeQuestions } from "./fakeQuestions";
+import { FakeReactions } from "./fakeReactions";
import { FakeRoutines } from "./fakeRoutines";
import { conflict, invalid, notFound, slugify } from "./fakeRules";
import { FakeScreens } from "./fakeScreens";
@@ -77,6 +78,7 @@ export class FakeBotloft implements BotloftApi {
readonly browser = new FakeBrowser(this);
readonly screens = new FakeScreens(this);
readonly questions = new FakeQuestions(this);
+ readonly reactions = new FakeReactions(this);
/** Clock for created and updated times. */
now = Date.now();
closed = false;
@@ -261,6 +263,7 @@ export class FakeBotloft implements BotloftApi {
...this.screens.handlers(),
...this.routines.handlers(),
...this.questions.handlers(),
+ ...this.reactions.handlers(),
...usageHandlers(this),
...searchHandlers(this),
};
diff --git a/app/src/lib/fakeConversation.ts b/app/src/lib/fakeConversation.ts
index debc4557..42abff50 100644
--- a/app/src/lib/fakeConversation.ts
+++ b/app/src/lib/fakeConversation.ts
@@ -221,6 +221,10 @@ export class FakeConversation {
this.fake.emit({ name: "message.created", params: message });
this.fake.emit({ name: "delivery.changed", params: delivery });
this.fake.chat.add(message.toBotId, { kind: "inbound", message });
+ // Every message of the owner takes the reactions waiting (spec 8.9).
+ if (message.fromKind === "owner") {
+ this.fake.reactions.sendWith(message.toBotId, message.id);
+ }
return delivery;
}
diff --git a/app/src/lib/fakeReactions.ts b/app/src/lib/fakeReactions.ts
new file mode 100644
index 00000000..ef089213
--- /dev/null
+++ b/app/src/lib/fakeReactions.ts
@@ -0,0 +1,69 @@
+// The fake daemon's reactions (spec 8.9): the owner's emoji on a bot's
+// reply, waiting for the owner's next message, which takes them along.
+
+import type { FakeBotloft, Handlers } from "./fake";
+import { invalid, notFound } from "./fakeRules";
+import type { BotId, MessageId, Reaction } from "./protocol.gen";
+import { REACTIONS } from "./protocol.gen";
+
+type ReactionMethods = Extract;
+
+export class FakeReactions {
+ readonly reactions = new Map();
+
+ constructor(private readonly fake: FakeBotloft) {}
+
+ private changed(botId: BotId, itemId: string, reaction: Reaction | null) {
+ this.fake.emit({ name: "reaction.changed", params: { botId, itemId, reaction } });
+ }
+
+ /** The owner's message `messageId` to `botId` takes what waits. */
+ sendWith(botId: BotId, messageId: MessageId): void {
+ for (const reaction of this.reactions.values()) {
+ if (reaction.botId === botId && reaction.sentIn === null) {
+ reaction.sentIn = messageId;
+ this.changed(botId, reaction.itemId, { ...reaction });
+ }
+ }
+ }
+
+ handlers(): Pick {
+ return {
+ "reactions.list": ({ botId }) => {
+ this.fake.bot(botId);
+ return [...this.reactions.values()]
+ .filter((reaction) => reaction.botId === botId)
+ .map((reaction) => ({ ...reaction }));
+ },
+ "reactions.set": ({ botId, itemId, emoji }) => {
+ if (emoji !== null && !(REACTIONS as readonly string[]).includes(emoji)) {
+ throw invalid(`emoji must be one of ${REACTIONS.join(" ")}`);
+ }
+ this.fake.bot(botId);
+ const item = this.fake.chat.items.find((one) => one.id === itemId && one.botId === botId);
+ if (!item) {
+ throw notFound(`chat item ${itemId}`);
+ }
+ if (item.body.kind !== "reply") {
+ throw invalid("only a reply of the bot takes a reaction");
+ }
+ if (emoji === null) {
+ if (this.reactions.delete(itemId)) {
+ this.changed(botId, itemId, null);
+ }
+ return null;
+ }
+ const reaction: Reaction = {
+ botId,
+ itemId,
+ emoji,
+ createdAt: this.fake.now,
+ sentIn: null,
+ };
+ this.reactions.set(itemId, reaction);
+ this.changed(botId, itemId, { ...reaction });
+ return { ...reaction };
+ },
+ };
+ }
+}
diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts
index bbe172e3..c9034d31 100644
--- a/app/src/lib/protocol.gen.ts
+++ b/app/src/lib/protocol.gen.ts
@@ -918,6 +918,40 @@ export type QuestionsAnswerParams = { questionId: QuestionId, answer: string, };
export type QuestionIdParams = { questionId: QuestionId, };
+/** The emoji the owner can react with (spec 8.9). */
+export const REACTIONS = ["👍", "❤️", "😂", "🎉", "🙏", "👀"] as const;
+
+export type Reaction = { botId: BotId,
+/**
+ * The bot's reply it is on.
+ */
+itemId: ChatItemId,
+/**
+ * One of [`REACTIONS`].
+ */
+emoji: string,
+/**
+ * Unix time in milliseconds.
+ */
+createdAt: number,
+/**
+ * The owner's message that took it to the bot; `null` while it waits
+ * for one.
+ */
+sentIn: MessageId | null, };
+
+export type ReactionsListParams = { botId: BotId, };
+
+/**
+ * Puts the owner's reaction on a reply, or takes it off with `null`.
+ */
+export type ReactionsSetParams = { botId: BotId, itemId: ChatItemId, emoji: string | null, };
+
+/**
+ * A reaction was put, changed, sent or taken off (`reaction` is `null`).
+ */
+export type ReactionChanged = { botId: BotId, itemId: ChatItemId, reaction: Reaction | null, };
+
export type TurnItem = { durationMs: number,
/**
* Tokens the turn used (spec 8.7); `null` when Claude Code did not say
@@ -1433,6 +1467,8 @@ export interface RpcMethods {
"questions.list": { params: QuestionsListParams; result: Array };
"questions.answer": { params: QuestionsAnswerParams; result: Question };
"questions.dismiss": { params: QuestionIdParams; result: Question };
+ "reactions.list": { params: ReactionsListParams; result: Array };
+ "reactions.set": { params: ReactionsSetParams; result: Reaction | null };
"chat.search": { params: ChatSearchParams; result: Array };
"browser.list": { params: undefined; result: Array };
"browser.watch": { params: BrowserWatchParams; result: BrowserView };
@@ -1466,6 +1502,7 @@ export interface RpcNotifications {
"routine.changed": Routine;
"routine.run": RoutineRun;
"question.changed": Question;
+ "reaction.changed": ReactionChanged;
"bot.rules": BotRules;
"browser.changed": BrowserState;
"browser.action": BrowserAction;
diff --git a/crates/botloft-core/src/envelope.rs b/crates/botloft-core/src/envelope.rs
index 1ed2c05e..2570ba07 100644
--- a/crates/botloft-core/src/envelope.rs
+++ b/crates/botloft-core/src/envelope.rs
@@ -156,6 +156,31 @@ pub fn reply(quote: &str, body: &str) -> String {
)
}
+/// Longest part of a reply quoted with a reaction to it, in characters.
+pub const QUOTED_REACTION_MAX_CHARS: usize = 120;
+
+/// The owner's reactions since their last message (spec 8.9), one line
+/// each, above what that message says. Each is an emoji and the reply it
+/// is on, already quoted on one line.
+pub fn with_reactions(reactions: &[(String, String)], text: &str) -> String {
+ if reactions.is_empty() {
+ return text.to_owned();
+ }
+ let lines: Vec = reactions
+ .iter()
+ .map(|(emoji, quote)| format!("Reacted {emoji} to: \"{quote}\""))
+ .collect();
+ format!(
+ "{}
+
+{text}",
+ lines.join(
+ "
+"
+ )
+ )
+}
+
/// Time left until `deadline_ms`, rounded for a reader who does not know
/// the current time.
pub fn due(deadline_ms: i64, now_ms: i64) -> String {
@@ -245,6 +270,22 @@ Then send the yearly quote."
);
}
+ #[test]
+ fn reactions_go_above_the_owner_words_one_line_each() {
+ let reactions = [
+ ("👍".to_owned(), "Acme signs annual.".to_owned()),
+ ("🎉".to_owned(), "All tests pass.".to_owned()),
+ ];
+ assert_eq!(
+ with_reactions(&reactions, "Next?"),
+ "Reacted 👍 to: \"Acme signs annual.\"
+Reacted 🎉 to: \"All tests pass.\"
+
+Next?"
+ );
+ assert_eq!(with_reactions(&[], "Next?"), "Next?");
+ }
+
#[test]
fn a_deleted_sender_cannot_be_answered() {
let text = envelope(Sender::DeletedBot, MessageKind::Note, None).render(0);
diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs
index 25290c11..70129ad3 100644
--- a/crates/botloft-core/src/protocol/export.rs
+++ b/crates/botloft-core/src/protocol/export.rs
@@ -18,6 +18,8 @@ use crate::ids::{
mod browser;
#[path = "export_questions.rs"]
mod questions;
+#[path = "export_reactions.rs"]
+mod reactions;
#[path = "export_routines.rs"]
mod routines;
#[path = "export_rules.rs"]
@@ -176,6 +178,7 @@ fn export_bindings() {
out.decl::();
usage::decls(&mut out);
questions::decls(&mut out);
+ reactions::decls(&mut out);
out.decl::();
out.decl::();
out.decl::();
@@ -337,6 +340,7 @@ fn export_bindings() {
rules::methods(&mut out);
routines::methods(&mut out);
questions::methods(&mut out);
+ reactions::methods(&mut out);
search::methods(&mut out);
browser::methods(&mut out);
out.text.push_str("}\n\n");
@@ -383,6 +387,7 @@ fn export_bindings() {
session::notifications(&mut out);
routines::notifications(&mut out);
questions::notifications(&mut out);
+ reactions::notifications(&mut out);
rules::notifications(&mut out);
browser::notifications(&mut out);
out.text.push_str("}\n\n");
diff --git a/crates/botloft-core/src/protocol/export_reactions.rs b/crates/botloft-core/src/protocol/export_reactions.rs
new file mode 100644
index 00000000..b3e301a7
--- /dev/null
+++ b/crates/botloft-core/src/protocol/export_reactions.rs
@@ -0,0 +1,46 @@
+//! The reactions part of the TypeScript bindings (spec 8.9).
+
+use std::fmt::Write as _;
+
+use super::super::*;
+use super::Out;
+
+pub(super) fn decls(out: &mut Out) {
+ let emoji: Vec = REACTIONS
+ .iter()
+ .map(|emoji| format!("\"{emoji}\""))
+ .collect();
+ let _ = writeln!(
+ out.text,
+ "/** The emoji the owner can react with (spec 8.9). */
+export const REACTIONS = [{}] as const;
+",
+ emoji.join(", ")
+ );
+ out.decl::();
+ out.decl::();
+ out.decl::();
+ out.decl::();
+}
+
+pub(super) fn methods(out: &mut Out) {
+ out.method(
+ method::REACTIONS_LIST,
+ &out.name::(),
+ &out.name::>(),
+ );
+ out.method(
+ method::REACTIONS_SET,
+ &out.name::(),
+ &out.name::