diff --git a/app/src/features/bots/moments.test.tsx b/app/src/features/bots/moments.test.tsx index 34182f71..d81a6d75 100644 --- a/app/src/features/bots/moments.test.tsx +++ b/app/src/features/bots/moments.test.tsx @@ -129,6 +129,7 @@ describe("glances", () => { routineId: null, questionId: null, attachments: [], + replyTo: null, createdAt: Date.now(), }; act(() => fake.emit({ name: "message.created", params: message })); diff --git a/app/src/features/chat/BotRun.tsx b/app/src/features/chat/BotRun.tsx index af75be12..a0034f5f 100644 --- a/app/src/features/chat/BotRun.tsx +++ b/app/src/features/chat/BotRun.tsx @@ -15,6 +15,7 @@ import { ApprovalCard } from "./ApprovalCard"; import { Markdown } from "./Markdown"; import { MemoryNote } from "./MemoryNote"; import { memoryOf, memoryPlaces } from "./memory"; +import { Repliable } from "./Replying"; import { runParts, splitDraft } from "./rows"; import { SharedFiles } from "./SharedFiles"; import { isSharedFiles } from "./shared"; @@ -160,7 +161,14 @@ function BotRunView({ } case "reply": // It was already on screen as it was written. - return ; + return ( + + + + ); case "approval": return ( diff --git a/app/src/features/chat/ChatComposer.tsx b/app/src/features/chat/ChatComposer.tsx index 89538a3c..ddd2afcb 100644 --- a/app/src/features/chat/ChatComposer.tsx +++ b/app/src/features/chat/ChatComposer.tsx @@ -28,6 +28,7 @@ import { EffortPicker } from "./EffortPicker"; import { rememberImage } from "./images"; import { ModelPicker } from "./ModelPicker"; import { ModePicker } from "./ModePicker"; +import { ReplyBar, type ReplyTarget } from "./Replying"; import type { Files, PendingFile } from "./useFiles"; const MAX_HEIGHT_PX = 240; @@ -64,12 +65,17 @@ export const ChatComposer = memo(function ChatComposer({ files, stopped, onSent, + replying = null, + onCancelReply, }: { bot: Bot; files: Files; /** The bot is paused or its crew is. */ stopped: boolean; onSent(): void; + /** What the message replies to (spec 9.3). */ + replying?: ReplyTarget | null; + onCancelReply?(): void; }) { const api = useApi(); const t = useT(); @@ -88,6 +94,13 @@ export const ChatComposer = memo(function ChatComposer({ const tooLong = text.length > FIELD_LIMITS.message; const empty = !text.trim() && files.files.length === 0; + // Replying starts in the field. + useEffect(() => { + if (replying) { + field.current?.focus(); + } + }, [replying]); + const working = bot.state === "busy" || bot.state === "needs_approval"; useEffect(() => { if (!working) { @@ -120,6 +133,7 @@ export const ChatComposer = memo(function ChatComposer({ botId: bot.id, body: text, ...(attachments.length > 0 ? { attachments } : {}), + ...(replying ? { replyTo: replying.itemId } : {}), }); // The daemon keeps the order it was given. message.attachments.forEach((attachment, index) => { @@ -130,6 +144,7 @@ export const ChatComposer = memo(function ChatComposer({ }); setText(""); files.clear(); + onCancelReply?.(); playSound("sent"); onSent(); } catch (failure) { @@ -142,6 +157,11 @@ export const ChatComposer = memo(function ChatComposer({ const enterSends = usePref(prefs.enterSends); const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && replying) { + event.preventDefault(); + onCancelReply?.(); + return; + } if (event.key !== "Enter" || event.nativeEvent.isComposing) { return; } @@ -171,6 +191,7 @@ export const ChatComposer = memo(function ChatComposer({

)}
+ {replying && onCancelReply && } {files.files.length > 0 && (
    {files.files.map((file) => ( diff --git a/app/src/features/chat/ChatView.tsx b/app/src/features/chat/ChatView.tsx index 1066c772..afd0abbd 100644 --- a/app/src/features/chat/ChatView.tsx +++ b/app/src/features/chat/ChatView.tsx @@ -24,6 +24,7 @@ import { BotRun, type Live } from "./BotRun"; import { ChatComposer } from "./ChatComposer"; import { InboundRow } from "./InboundRow"; import { NoticeRow } from "./NoticeRow"; +import { type ReplyTarget, StartReply } from "./Replying"; import { chatRows, rowHas } from "./rows"; import { useChat } from "./useChat"; import { useFiles } from "./useFiles"; @@ -51,6 +52,9 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto /** Distance from the bottom to keep while older items load above. */ const anchor = useRef(null); const [dragging, setDragging] = useState(false); + /** What the owner's next message replies to (spec 9.3). */ + const [replying, setReplying] = useState(null); + const cancelReply = useCallback(() => setReplying(null), []); // 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 @@ -104,7 +108,7 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto ); } if (row.kind === "inbound") { - return ; + return ; } if (row.kind === "notice") { return ; @@ -207,20 +211,30 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto

    {t.chat.view.emptyBody}

)} - -
    - {list} -
-
+ + +
    + {list} +
+
+
{/* Keyed by bot: each one has its own draft (spec 15.1). */} - +
{dragging && (
diff --git a/app/src/features/chat/InboundRow.tsx b/app/src/features/chat/InboundRow.tsx index 44a5acad..5f361a7f 100644 --- a/app/src/features/chat/InboundRow.tsx +++ b/app/src/features/chat/InboundRow.tsx @@ -12,6 +12,7 @@ import { BOTLOFT_COLOR, BotAvatar } from "../bots/BotAvatar"; import { DeliveryStatus } from "../messages/DeliveryStatus"; import { AttachmentList } from "./AttachmentList"; import { Mentions } from "./mentions"; +import { Repliable, ReplyQuote } from "./Replying"; function Time({ at }: { at: number }) { return ( @@ -26,6 +27,7 @@ function OwnerMessage({ message, bot }: { message: Message; bot: Bot }) { const arrival = useArrival(message.createdAt); return (
  • + {message.replyTo && } {message.body && (

    (message.fromBotId ? state.bots[message.fromBotId] : undefined)); const delivery = useApp((state) => state.deliveries[message.id]); @@ -118,12 +120,17 @@ function OtherMessage({ message }: { message: Message }) {

  • {(!routine || open) && ( -

    - -

    +

    + +

    + )} {delivery && delivery.state !== "sent" && (
    @@ -138,13 +145,16 @@ function OtherMessage({ message }: { message: Message }) { export const InboundRow = memo(function InboundRow({ message, bot, + itemId, }: { message: Message; bot: Bot; + /** The chat item that shows it, to reply to. */ + itemId: string; }) { return message.fromKind === "owner" ? ( ) : ( - + ); }); diff --git a/app/src/features/chat/Replying.tsx b/app/src/features/chat/Replying.tsx new file mode 100644 index 00000000..84f6a2b7 --- /dev/null +++ b/app/src/features/chat/Replying.tsx @@ -0,0 +1,93 @@ +// Replying to something in the bot's chat (spec 9.3): a button that shows +// on what can be quoted (the bot's replies, the messages it got), the +// quote over the composer, and the quote over the owner's bubble after. + +import { CornerUpLeft, X } from "lucide-react"; +import { createContext, type ReactNode, useContext } from "react"; +import { useT } from "../../i18n"; +import { plainText } from "../../lib/plainText"; +import type { ChatItemId, MessageReply } from "../../lib/protocol.gen"; +import { useApp } from "../../store/context"; + +/** What the owner is replying to, while they write. */ +export interface ReplyTarget { + itemId: ChatItemId; + /** Whose words: the bot's name, another bot's, or Botloft. */ + who: string; + text: string; +} + +/** 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. */ +export function Repliable({ + target, + className = "", + children, +}: { + target: ReplyTarget; + /** A bubble narrows it to its own width, for the button to sit on its corner. */ + className?: string; + children: ReactNode; +}) { + const t = useT().chat.reply; + const start = useContext(StartReply); + if (!start) { + return children; + } + return ( +
    + {children} + +
    + ); +} + +/** Over the composer: what the next message replies to, with a way out. */ +export function ReplyBar({ target, onCancel }: { target: ReplyTarget; onCancel(): void }) { + const t = useT().chat.reply; + return ( +
    + +
    +

    {t.replyingTo(target.who)}

    +

    {plainText(target.text)}

    +
    + +
    + ); +} + +/** Over the owner's bubble: what it replied to; a click shows it in the chat. */ +export function ReplyQuote({ botId, reply }: { botId: string; reply: MessageReply }) { + const t = useT().chat.reply; + const openAt = useApp((state) => state.openAt); + return ( + + ); +} diff --git a/app/src/features/chat/reply.test.tsx b/app/src/features/chat/reply.test.tsx new file mode 100644 index 00000000..f8cb0c5a --- /dev/null +++ b/app/src/features/chat/reply.test.tsx @@ -0,0 +1,60 @@ +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 monthly.\n\nDana approves."); + 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 }; +} + +const field = () => screen.getByRole("textbox", { name: "Message to Scout" }); +const sent = (fake: FakeBotloft) => + fake.calls.filter((call) => call.method === "messages.send").map((call) => call.params); + +describe("replying to a message", () => { + test("quotes the bot's reply over the composer, sends it, and shows it on the bubble", async () => { + const { fake, scout, said } = await setup(); + fireEvent.click(screen.getByRole("button", { name: "Reply to Scout" })); + expect(screen.getByText("Replying to Scout")).toBeDefined(); + expect(document.activeElement).toBe(field()); + + fireEvent.change(field(), { target: { value: "Make it yearly" } }); + fireEvent.keyDown(field(), { key: "Enter" }); + await waitFor(() => + expect(sent(fake)).toEqual([{ botId: scout.id, body: "Make it yearly", replyTo: said.id }]), + ); + // The bar leaves with the message, and the bubble keeps the quote. + await waitFor(() => expect(screen.queryByText("Replying to Scout")).toBeNull()); + const quote = await screen.findByRole("button", { name: "Acme signs monthly. Dana approves." }); + expect(quote.title).toBe("Show what this replies to"); + }); + + test("replies to another bot's message too, and Esc or X cancels", async () => { + const { fake } = await setup(); + fireEvent.click(screen.getByRole("button", { name: "Reply to Writer" })); + const bar = screen.getByText("Replying to Writer").parentElement as HTMLElement; + expect(within(bar).getByText("The draft is ready.")).toBeDefined(); + fireEvent.keyDown(field(), { key: "Escape" }); + expect(screen.queryByText("Replying to Writer")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Reply to Writer" })); + fireEvent.click(screen.getByRole("button", { name: "Cancel the reply" })); + fireEvent.change(field(), { target: { value: "Plain" } }); + fireEvent.keyDown(field(), { key: "Enter" }); + await waitFor(() => expect(sent(fake).at(-1)).not.toHaveProperty("replyTo")); + }); +}); diff --git a/app/src/features/chat/rows.test.ts b/app/src/features/chat/rows.test.ts index f08716a4..10ce0415 100644 --- a/app/src/features/chat/rows.test.ts +++ b/app/src/features/chat/rows.test.ts @@ -41,6 +41,7 @@ const inbound = (body: string, at?: number) => routineId: null, questionId: null, attachments: [], + replyTo: null, createdAt: at ?? start, }, }, diff --git a/app/src/i18n/en/chat.ts b/app/src/i18n/en/chat.ts index 8601eda4..d7928649 100644 --- a/app/src/i18n/en/chat.ts +++ b/app/src/i18n/en/chat.ts @@ -95,6 +95,14 @@ export const chat = { changed: "What changed", now: "The memory now says:", }, + /** Replying to something in the chat (spec 9.3). */ + reply: { + action: "Reply", + to: (who: string) => `Reply to ${who}`, + replyingTo: (who: string) => `Replying to ${who}`, + cancel: "Cancel the reply", + jump: "Show what this replies to", + }, 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 b95f63f8..1b832958 100644 --- a/app/src/i18n/es/chat.ts +++ b/app/src/i18n/es/chat.ts @@ -88,6 +88,13 @@ export const chat: Messages["chat"] = { changed: "Lo que cambió", now: "Ahora la memoria dice:", }, + reply: { + action: "Responder", + to: (who) => `Responder a ${who}`, + replyingTo: (who) => `Respondiendo a ${who}`, + cancel: "Cancelar la respuesta", + jump: "Mostrar el mensaje respondido", + }, 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 93a106d8..53321e68 100644 --- a/app/src/i18n/pt-BR/chat.ts +++ b/app/src/i18n/pt-BR/chat.ts @@ -87,6 +87,13 @@ export const chat: Messages["chat"] = { changed: "O que mudou", now: "Agora a memória diz:", }, + reply: { + action: "Responder", + to: (who) => `Responder a ${who}`, + replyingTo: (who) => `Respondendo a ${who}`, + cancel: "Cancelar a resposta", + jump: "Mostrar a mensagem respondida", + }, approval: { asks: (bot: string, action: string) => `${bot} pede para ${action}`, wants: (bot: string, action: string) => `${bot} quer ${action}`, diff --git a/app/src/lib/fakeConversation.ts b/app/src/lib/fakeConversation.ts index 0655826c..debc4557 100644 --- a/app/src/lib/fakeConversation.ts +++ b/app/src/lib/fakeConversation.ts @@ -14,6 +14,7 @@ import { FIELD_LIMITS, type Message, type MessageKind, + type MessageReply, type Task, type TaskId, } from "./protocol.gen"; @@ -55,11 +56,31 @@ export class FakeConversation { routineId: null, questionId: null, attachments: [], + replyTo: null, createdAt: this.fake.now, }; return { message, delivery: this.record(message) }; } + /** What a reply quotes, as the daemon checks it (spec 9.3). */ + private quote(botId: BotId, itemId: string): MessageReply { + const item = this.fake.chat.items.find((one) => one.id === itemId && one.botId === botId); + if (!item) { + throw notFound(`chat item ${itemId}`); + } + const text = + item.body.kind === "reply" + ? item.body.text + : item.body.kind === "inbound" + ? item.body.message.body + : null; + if (text === null) { + throw invalid("reply_to: only a reply or a message can be quoted"); + } + const flat = text.split(/\s+/).filter(Boolean).join(" "); + return { itemId, text: flat.length > 300 ? `${flat.slice(0, 299)}…` : flat }; + } + /** The courier moves a delivery along. */ deliver(deliveryId: string, state: DeliveryState, lastError: string | null = null): Delivery { const delivery = this.delivery(deliveryId); @@ -111,7 +132,7 @@ export class FakeConversation { handlers(): Conversation { return { - "messages.send": ({ botId, body, attachments }) => { + "messages.send": ({ botId, body, attachments, replyTo }) => { const bot = this.fake.bot(botId); const files = attachments ?? []; if (!body.trim() && files.length === 0) { @@ -132,6 +153,7 @@ export class FakeConversation { routineId: null, questionId: null, attachments: files.map((file) => this.saved(file)), + replyTo: replyTo ? this.quote(botId, replyTo) : null, createdAt: this.fake.now, }; this.record(message); diff --git a/app/src/lib/fakeQuestions.ts b/app/src/lib/fakeQuestions.ts index d8a9ff9e..72eae181 100644 --- a/app/src/lib/fakeQuestions.ts +++ b/app/src/lib/fakeQuestions.ts @@ -88,6 +88,7 @@ export class FakeQuestions { routineId: null, questionId: question.id, attachments: [], + replyTo: null, createdAt: this.fake.now, }); return question; diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index ed9f4261..bbe172e3 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -620,11 +620,24 @@ questionId: QuestionId | null, * Files the owner attached (spec 9.5); empty for everything else. */ attachments: Array, +/** + * What in the bot's chat the owner replied to (spec 9.3). + */ +replyTo: MessageReply | null, /** * Unix time in milliseconds. */ createdAt: number, }; +/** + * The chat item a message replies to, and its text as quoted. + */ +export type MessageReply = { itemId: ChatItemId, +/** + * The start of the item's text, on one line (at most 300 characters). + */ +text: string, }; + /** * Getting one message into one bot's process. */ @@ -682,7 +695,11 @@ export type MessagesSendParams = { botId: BotId, body: string, /** * Up to 10 files (spec 9.5). */ -attachments?: Array, }; +attachments?: Array, +/** + * A reply of the bot, or a message it got, in its chat (spec 9.3). + */ +replyTo?: ChatItemId, }; /** * Newest first. Page back with `before` set to the oldest id received. diff --git a/crates/botloft-core/src/envelope.rs b/crates/botloft-core/src/envelope.rs index 8336b57d..1ed2c05e 100644 --- a/crates/botloft-core/src/envelope.rs +++ b/crates/botloft-core/src/envelope.rs @@ -141,6 +141,21 @@ pub fn answer(question_id: &str, question: &str, body: &str) -> String { ) } +/// Longest part of a chat item quoted in a message that replies to it, in +/// characters. +pub const QUOTED_REPLY_MAX_CHARS: usize = 300; + +/// The owner's message replying to something in the bot's chat (spec 9.3): +/// their words, after a line that quotes what they reply to. `quote` is +/// already on one line. +pub fn reply(quote: &str, body: &str) -> String { + format!( + "Replying to: \"{quote}\" + +{body}" + ) +} + /// 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 { @@ -220,6 +235,16 @@ mod tests { ); } + #[test] + fn a_reply_quotes_what_it_replies_to_above_the_owner_words() { + assert_eq!( + reply("Acme signs annual.", "Then send the yearly quote."), + "Replying to: \"Acme signs annual.\" + +Then send the yearly quote." + ); + } + #[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 10074136..25290c11 100644 --- a/crates/botloft-core/src/protocol/export.rs +++ b/crates/botloft-core/src/protocol/export.rs @@ -157,6 +157,7 @@ fn export_bindings() { out.decl::(); out.decl::(); out.decl::(); + out.decl::(); out.decl::(); out.decl::(); out.decl::(); diff --git a/crates/botloft-core/src/protocol/messaging.rs b/crates/botloft-core/src/protocol/messaging.rs index f2b0750d..6347b83b 100644 --- a/crates/botloft-core/src/protocol/messaging.rs +++ b/crates/botloft-core/src/protocol/messaging.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use crate::ids::{ - AttachmentId, BotId, CrewId, DeliveryId, MessageId, QuestionId, RoutineId, TaskId, + AttachmentId, BotId, ChatItemId, CrewId, DeliveryId, MessageId, QuestionId, RoutineId, TaskId, }; text_enum!( @@ -77,10 +77,23 @@ pub struct Message { pub question_id: Option, /// Files the owner attached (spec 9.5); empty for everything else. pub attachments: Vec, + /// What in the bot's chat the owner replied to (spec 9.3). + #[serde(default)] + pub reply_to: Option, /// Unix time in milliseconds. pub created_at: i64, } +/// The chat item a message replies to, and its text as quoted. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct MessageReply { + pub item_id: ChatItemId, + /// The start of the item's text, on one line (at most 300 characters). + pub text: String, +} + /// A file attached to a message, saved in the bot's folder. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -208,6 +221,10 @@ pub struct MessagesSendParams { #[serde(default)] #[cfg_attr(test, ts(optional))] pub attachments: Option>, + /// A reply of the bot, or a message it got, in its chat (spec 9.3). + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub reply_to: Option, } /// Newest first. Page back with `before` set to the oldest id received. diff --git a/crates/botloft-store/migrations/0018_message_reply.sql b/crates/botloft-store/migrations/0018_message_reply.sql new file mode 100644 index 00000000..fea65e49 --- /dev/null +++ b/crates/botloft-store/migrations/0018_message_reply.sql @@ -0,0 +1,6 @@ +-- Replying to something in a bot's chat (spec 9.3): the message keeps the +-- chat item it replies to (no foreign key: chat items leave before +-- messages when a bot is deleted, spec 7.6) and its text as quoted then. + +ALTER TABLE messages ADD COLUMN reply_item_id TEXT; +ALTER TABLE messages ADD COLUMN reply_text TEXT; diff --git a/crates/botloft-store/src/lib.rs b/crates/botloft-store/src/lib.rs index 9fd894c3..61360bac 100644 --- a/crates/botloft-store/src/lib.rs +++ b/crates/botloft-store/src/lib.rs @@ -213,6 +213,7 @@ pub(crate) mod tests { task_id: None, routine_id: None, question_id: None, + reply_to: None, attachments: Vec::new(), created_at: 0, }; diff --git a/crates/botloft-store/src/messages.rs b/crates/botloft-store/src/messages.rs index 3cbd4587..f4b52685 100644 --- a/crates/botloft-store/src/messages.rs +++ b/crates/botloft-store/src/messages.rs @@ -6,13 +6,13 @@ use botloft_core::ids::{ AttachmentId, BotId, ChatItemId, CrewId, MessageId, QuestionId, RoutineId, TaskId, }; use botloft_core::protocol::{ - Attachment, ChatBody, ChatItem, Delivery, InboundItem, Message, Task, + Attachment, ChatBody, ChatItem, Delivery, InboundItem, Message, MessageReply, Task, }; use rusqlite::{Connection, OptionalExtension, Row, params}; use crate::{Result, Store, cached_execute, cached_row, parse_column, to_sql_int}; -const COLUMNS: &str = "id, crew_id, from_kind, from_bot_id, to_bot_id, kind, body, task_id, created_at, routine_id, \n question_id"; +const COLUMNS: &str = "id, crew_id, from_kind, from_bot_id, to_bot_id, kind, body, task_id, created_at, routine_id, \n question_id, reply_item_id, reply_text"; fn from_row(row: &Row<'_>) -> rusqlite::Result { Ok(Message { @@ -27,6 +27,10 @@ fn from_row(row: &Row<'_>) -> rusqlite::Result { routine_id: optional_column(row, 9)?, question_id: optional_column(row, 10)?, attachments: Vec::new(), + reply_to: match (optional_column::(row, 11)?, row.get(12)?) { + (Some(item_id), Some(text)) => Some(MessageReply { item_id, text }), + _ => None, + }, created_at: row.get(8)?, }) } @@ -105,7 +109,7 @@ impl Store { cached_execute( conn, &format!( - "INSERT INTO messages ({COLUMNS}) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)" + "INSERT INTO messages ({COLUMNS}) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)" ), params![ message.id.as_str(), @@ -119,6 +123,11 @@ impl Store { message.created_at, message.routine_id.as_ref().map(RoutineId::as_str), message.question_id.as_ref().map(QuestionId::as_str), + message + .reply_to + .as_ref() + .map(|reply| reply.item_id.as_str()), + message.reply_to.as_ref().map(|reply| reply.text.as_str()), ], )?; for attachment in &message.attachments { diff --git a/crates/botloft-store/src/migrate.rs b/crates/botloft-store/src/migrate.rs index 178dace7..c7ff6b83 100644 --- a/crates/botloft-store/src/migrate.rs +++ b/crates/botloft-store/src/migrate.rs @@ -24,6 +24,7 @@ const MIGRATIONS: &[(u32, &str)] = &[ (15, include_str!("../migrations/0015_questions.sql")), (16, include_str!("../migrations/0016_chat_search.sql")), (17, include_str!("../migrations/0017_routine_signals.sql")), + (18, include_str!("../migrations/0018_message_reply.sql")), ]; /// Schema version after every migration has run. diff --git a/crates/botloftd/src/courier/render.rs b/crates/botloftd/src/courier/render.rs index ccacef79..a5ea3004 100644 --- a/crates/botloftd/src/courier/render.rs +++ b/crates/botloftd/src/courier/render.rs @@ -44,6 +44,10 @@ pub(super) fn render(message: &Message, context: &Context<'_>, now: i64) -> Rend // The owner is the session's user: their words go as written. SenderKind::Owner => { let body = with_attachment_list(&message.body, &message.attachments); + let body = match &message.reply_to { + Some(reply) => envelope::reply(&reply.text, &body), + None => body, + }; match context.question { Some((id, question)) => envelope::answer(id, question, &body), None => body, @@ -132,8 +136,8 @@ fn inline_image(attachment: &Attachment, workspace: &Path) -> Option { #[cfg(test)] mod tests { - use botloft_core::ids::{AttachmentId, BotId, CrewId, MessageId}; - use botloft_core::protocol::MessageKind; + use botloft_core::ids::{AttachmentId, BotId, ChatItemId, CrewId, MessageId}; + use botloft_core::protocol::{MessageKind, MessageReply}; use super::*; @@ -150,6 +154,7 @@ mod tests { routine_id: None, question_id: None, attachments: Vec::new(), + reply_to: None, created_at: 0, } } @@ -185,6 +190,31 @@ mod tests { assert!(text.ends_with("hi")); } + #[test] + fn a_reply_quotes_the_chat_item_above_the_owner_words() { + let dir = tempfile::tempdir().expect("tempdir"); + let context = Context { + crew_name: "Ops", + sender_handle: None, + task: None, + workspace: dir.path(), + routine: None, + question: None, + }; + let mut owner = message(SenderKind::Owner, "Make it yearly"); + owner.reply_to = Some(MessageReply { + item_id: ChatItemId::generate(), + text: "Acme signs monthly.".into(), + }); + let line = parse(&render(&owner, &context, 0)); + assert_eq!( + line["message"]["content"][0]["text"], + "Replying to: \"Acme signs monthly.\" + +Make it yearly" + ); + } + #[test] fn attachments_are_listed_and_small_images_go_inline() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/crates/botloftd/src/routines/fire.rs b/crates/botloftd/src/routines/fire.rs index 2c0562fa..59005201 100644 --- a/crates/botloftd/src/routines/fire.rs +++ b/crates/botloftd/src/routines/fire.rs @@ -155,6 +155,7 @@ pub(super) fn attempt( task_id: None, routine_id: Some(routine.id.clone()), question_id: None, + reply_to: None, attachments: Vec::new(), created_at: now, }; diff --git a/crates/botloftd/src/service/delete.rs b/crates/botloftd/src/service/delete.rs index c2f05cf4..80347f41 100644 --- a/crates/botloftd/src/service/delete.rs +++ b/crates/botloftd/src/service/delete.rs @@ -185,6 +185,7 @@ fn tell(daemon: &Daemon, store: &Store, crew: &Crew, to: BotId, body: String) { task_id: None, routine_id: None, question_id: None, + reply_to: None, attachments: Vec::new(), created_at: daemon.clock.now_ms(), }; diff --git a/crates/botloftd/src/service/messages.rs b/crates/botloftd/src/service/messages.rs index 65b1e69a..d89493bd 100644 --- a/crates/botloftd/src/service/messages.rs +++ b/crates/botloftd/src/service/messages.rs @@ -1,12 +1,13 @@ //! `messages.*` operations, and posting a message for the courier to deliver //! (spec 9.1). -use botloft_core::ids::{DeliveryId, MessageId}; +use botloft_core::envelope::QUOTED_REPLY_MAX_CHARS; +use botloft_core::ids::{BotId, ChatItemId, DeliveryId, MessageId}; use botloft_core::protocol::{ - ChatItem, Delivery, DeliveryState, Message, MessageKind, MessagesListParams, - MessagesSendParams, SenderKind, Task, + ChatBody, ChatItem, Delivery, DeliveryState, Message, MessageKind, MessageReply, + MessagesListParams, MessagesSendParams, SenderKind, Task, }; -use botloft_core::validate; +use botloft_core::{chat, validate}; use botloft_store::{MessageFilter, Store}; use super::{ApiError, ApiResult, attachments, bots, crews}; @@ -36,6 +37,10 @@ pub fn send(daemon: &Daemon, params: MessagesSendParams) -> ApiResult { let store = daemon.store(); // Archived or deleted while the files were saved. let (crew, bot) = bots::active(&store, ¶ms.bot_id)?; + let reply_to = match ¶ms.reply_to { + Some(item) => Some(quote(&store, &bot.id, item)?), + None => None, + }; let message = Message { id: MessageId::generate(), crew_id: crew.id, @@ -47,12 +52,41 @@ pub fn send(daemon: &Daemon, params: MessagesSendParams) -> ApiResult { task_id: None, routine_id: None, question_id: None, + reply_to, attachments, created_at: now, }; post(daemon, &store, message, None) } +/// What the owner replies to (spec 9.3): something the bot wrote, or a +/// message it got, in its own chat, quoted on one line. +fn quote(store: &Store, bot: &BotId, item_id: &ChatItemId) -> ApiResult { + let item = store + .chat_item(item_id)? + .filter(|item| &item.bot_id == bot) + .ok_or_else(|| ApiError::NotFound(format!("chat item {item_id} does not exist")))?; + let text = match &item.body { + ChatBody::Reply(reply) => &reply.text, + ChatBody::Inbound(inbound) => &inbound.message.body, + _ => { + return Err(ApiError::validation( + "reply_to: only a reply or a message can be quoted", + )); + } + }; + let text = chat::one_line(text, QUOTED_REPLY_MAX_CHARS); + if text.is_empty() { + return Err(ApiError::validation( + "reply_to: the item has no text to quote", + )); + } + Ok(MessageReply { + item_id: item.id, + text, + }) +} + /// Stores the message with a pending delivery (and the task it creates), /// tells the app and wakes the courier. pub(crate) fn post( diff --git a/crates/botloftd/src/service/questions.rs b/crates/botloftd/src/service/questions.rs index 41f9e749..dd41475b 100644 --- a/crates/botloftd/src/service/questions.rs +++ b/crates/botloftd/src/service/questions.rs @@ -126,6 +126,7 @@ pub fn answer(daemon: &Daemon, params: QuestionsAnswerParams) -> ApiResult ApiResult<() task_id: Some(task.id.clone()), routine_id: None, question_id: None, + reply_to: None, attachments: Vec::new(), created_at: now, }; diff --git a/crates/botloftd/tests/reply_to.rs b/crates/botloftd/tests/reply_to.rs new file mode 100644 index 00000000..9292838b --- /dev/null +++ b/crates/botloftd/tests/reply_to.rs @@ -0,0 +1,107 @@ +//! The owner replying to something in a bot's chat (spec 9.3): the message +//! keeps what it quotes, and the bot reads the quote above the words. + +mod common; + +use botloft_core::protocol::BotState; +use common::TestDaemon; +use common::bots::text_of; +use common::stream; +use serde_json::{Value, json}; + +const NOT_FOUND: i64 = -32002; +const VALIDATION: i64 = -32004; + +async fn history(app: &mut common::Client, bot: &Value) -> Vec { + app.call("chat.history", json!({ "botId": bot["id"] })) + .await + .expect("history") + .as_array() + .expect("items") + .clone() +} + +fn of_kind<'a>(items: &'a [Value], kind: &str) -> &'a Value { + items + .iter() + .find(|item| item["body"]["kind"] == kind) + .expect("item of kind") +} + +#[tokio::test] +async fn a_reply_quotes_the_item_for_the_bot_and_keeps_it() { + let t = TestDaemon::start_supervised().await; + let mut app = t.session().await; + let crew = app + .call("crews.create", json!({ "name": "Ops" })) + .await + .expect("crew"); + let new_bot = + |name: &str| json!({ "crewId": crew["id"], "name": name, "role": "", "instructions": "" }); + let scout = app + .call("bots.create", new_bot("Scout")) + .await + .expect("bot"); + let writer = app + .call("bots.create", new_bot("Writer")) + .await + .expect("bot"); + t.until_state(&scout, BotState::Idle).await; + let process = t.process_of(&scout).await; + + // Scout answers a first message. + app.call( + "messages.send", + json!({ "botId": scout["id"], "body": "Who signs?" }), + ) + .await + .expect("send"); + let line = process.wait_lines(1).await.remove(0); + stream::answer(&process, &line, "Acme signs monthly.\nDana approves.").await; + t.until_state(&scout, BotState::Idle).await; + let items = history(&mut app, &scout).await; + let reply = of_kind(&items, "reply"); + + // The owner replies to it. + let params = json!({ "botId": scout["id"], "body": "Make it yearly", "replyTo": reply["id"] }); + let message = app.call("messages.send", params).await.expect("reply"); + assert_eq!(message["replyTo"]["itemId"], reply["id"]); + assert_eq!( + message["replyTo"]["text"], + "Acme signs monthly. Dana approves." + ); + let line = process.wait_lines(2).await.remove(1); + assert_eq!( + text_of(&line), + "Replying to: \"Acme signs monthly. Dana approves.\"\n\nMake it yearly" + ); + + // It stays with the message, as the chat and the lists read it. + let items = history(&mut app, &scout).await; + assert_eq!( + items[0]["body"]["message"]["replyTo"]["itemId"], + reply["id"] + ); + let listed = app + .call("messages.list", json!({ "botId": scout["id"] })) + .await + .expect("list"); + assert_eq!( + listed[0]["replyTo"]["text"], + "Acme signs monthly. Dana approves." + ); + + // Only an item of this bot's chat, with words. + let refused = |item: &Value, to: &Value| json!({ "botId": to["id"], "body": "Hm", "replyTo": item["id"] }); + for (params, expected) in [ + (refused(reply, &writer), NOT_FOUND), + (refused(of_kind(&items, "turn"), &scout), VALIDATION), + ] { + let code = app + .call("messages.send", params) + .await + .expect_err("refused") + .code; + assert_eq!(code, expected); + } +} diff --git a/crates/botloftd/tests/routine_runs.rs b/crates/botloftd/tests/routine_runs.rs index de19eb6e..c1a76c4a 100644 --- a/crates/botloftd/tests/routine_runs.rs +++ b/crates/botloftd/tests/routine_runs.rs @@ -76,6 +76,7 @@ async fn a_message_that_joins_the_runs_turn_leaves_the_run_to_it() { bot_id: s.bot.clone(), body: "one more thing".into(), attachments: None, + reply_to: None, }, ) .expect("send"); diff --git a/docs/spec.md b/docs/spec.md index ba7be71c..c0586a19 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -480,6 +480,14 @@ A resposta sai no stdout como `{"type":"control_response","response":{"subtype": A mensagem do **dono** vai como ele escreveu, sem envelope: é o usuário da sessão falando, com a autoridade de quem digita. A resposta a uma pergunta do bot vem depois de uma linha que cita a pergunta (23.4). +**Responder a algo do chat.** O dono pode responder a uma fala do bot (item `reply`) ou a uma message que ele recebeu (item `inbound`) no chat desse bot: `messages.send` leva `replyTo` com o id do item. O daemon confere que o item é do mesmo bot e tem texto (senão, `not_found` ou `validation`) e guarda na message `replyTo {itemId, text}`, com o texto numa linha só e cortado em 300 caracteres, como estava na hora. O bot recebe a citação antes das palavras do dono: + +``` +Replying to: "" + + +``` + Mensagens de **outros bots** e **avisos do daemon** levam um envelope em inglês, como as regras geradas (5.1): ``` @@ -645,7 +653,7 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co | `approvals.answer` | `approvalId, allow, note?, input?, always?` (`input`: a sugestão de bot como o dono a deixou, 10.2; `always`: com `allow`, grava o `always` do pedido como regra do bot, 10.1) | `Approval` | | `rules.list` | `botId` | `AllowRule[]`: o que o bot faz sem perguntar, da regra mais antiga à mais nova (10.1) | | `rules.delete` | `ruleId` | `{botId, rules}`: as regras que ficaram; o bot volta a perguntar pelo que saiu (10.1) | -| `messages.send` | `botId, body, attachments?` (`[{name, mediaType, data}]`, data em base64) | `Message` | +| `messages.send` | `botId, body, attachments?` (`[{name, mediaType, data}]`, data em base64), `replyTo?` (id de um item do chat do bot, 9.3) | `Message` | | `messages.list` | `crewId?, botId?, before?, limit?` | `Message[]` | | `attachments.read` | `attachmentId` | `{mediaType, data}`, data em base64 (9.5) | | `files.list` | `botId` | `BotFile[]`: os arquivos que o bot fez, do mais novo ao mais antigo (8.4) | @@ -688,7 +696,7 @@ Pragmas: `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_time |---|---| | `crews` | `id, name, slug, paused, work_dir, lead_bot_id, created_at, archived_at` (`work_dir` é a pasta escolhida; `NULL` usa a `shared`. `lead_bot_id` é o chefe, sem chave estrangeira: ele é gravado junto com a crew) | | `bots` | `id, crew_id, name, handle, slug, role, instructions, color, paused, permission_mode, model, model_in_use, effort, effort_default, token_hash, session_id, created_at, archived_at` (`effort_default` é o nível do próprio modelo, como o Claude Code disse: `low` a `max`, `none` ou `NULL`, 7.4) | -| `messages` | `id, crew_id, from_kind (owner/bot/system), from_bot_id, to_bot_id, kind (note/task/result/system), body, task_id, created_at` | +| `messages` | `id, crew_id, from_kind (owner/bot/system), from_bot_id, to_bot_id, kind (note/task/result/system), body, task_id, created_at`; `reply_item_id` e `reply_text` quando o dono respondeu a algo do chat (9.3; sem chave estrangeira, porque os itens saem antes das messages na exclusão, 7.6) | | `attachments` | `id, message_id, name, media_type, size, path, created_at` | | `deliveries` | `id, message_id, bot_id, state, attempts, next_attempt_at, lease_until, last_error, sent_generation, turn_uuid, read_at, updated_at` | | `tasks` | `id, crew_id, requester_bot_id, assignee_bot_id, status, deadline_at, hops, origin_task_id, result, created_at, updated_at` | @@ -881,6 +889,7 @@ No chat: - O chat ocupa toda a largura que sobra, alinhado à esquerda, com o compositor na mesma largura (sem coluna centralizada, que deixava vazio dos dois lados); o dia é uma pílula no meio. - O dono fala em balões à direita; o bot, à esquerda, com markdown. +- Passando o mouse numa fala do bot ou numa message de outro bot, aparece no canto o botão de responder (a seta para a esquerda; pelo teclado, ele aparece com o foco). Ele leva a citação para cima do compositor, "Respondendo a " com o começo do texto e um X; o campo ganha o foco, e Esc cancela. Enviada, a citação vai junto (9.3) e aparece em cima do balão do dono, com uma borda de destaque; um clique leva até o item citado no chat. - 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. - 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.