Skip to content

Commit 90d6b76

Browse files
authored
Merge pull request #153 from httpsphl/feat/reply-to
feat(chat): reply to something in a bot's chat
2 parents b0543ac + d6f38b1 commit 90d6b76

31 files changed

Lines changed: 550 additions & 33 deletions

‎app/src/features/bots/moments.test.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ describe("glances", () => {
129129
routineId: null,
130130
questionId: null,
131131
attachments: [],
132+
replyTo: null,
132133
createdAt: Date.now(),
133134
};
134135
act(() => fake.emit({ name: "message.created", params: message }));

‎app/src/features/chat/BotRun.tsx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { ApprovalCard } from "./ApprovalCard";
1515
import { Markdown } from "./Markdown";
1616
import { MemoryNote } from "./MemoryNote";
1717
import { memoryOf, memoryPlaces } from "./memory";
18+
import { Repliable } from "./Replying";
1819
import { runParts, splitDraft } from "./rows";
1920
import { SharedFiles } from "./SharedFiles";
2021
import { isSharedFiles } from "./shared";
@@ -160,7 +161,14 @@ function BotRunView({
160161
}
161162
case "reply":
162163
// It was already on screen as it was written.
163-
return <Markdown key={head.id} text={head.body.text} />;
164+
return (
165+
<Repliable
166+
key={head.id}
167+
target={{ itemId: head.id, who: bot.name, text: head.body.text }}
168+
>
169+
<Markdown text={head.body.text} />
170+
</Repliable>
171+
);
164172
case "approval":
165173
return (
166174
<Arriving key={head.id} at={head.createdAt}>

‎app/src/features/chat/ChatComposer.tsx‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { EffortPicker } from "./EffortPicker";
2828
import { rememberImage } from "./images";
2929
import { ModelPicker } from "./ModelPicker";
3030
import { ModePicker } from "./ModePicker";
31+
import { ReplyBar, type ReplyTarget } from "./Replying";
3132
import type { Files, PendingFile } from "./useFiles";
3233

3334
const MAX_HEIGHT_PX = 240;
@@ -64,12 +65,17 @@ export const ChatComposer = memo(function ChatComposer({
6465
files,
6566
stopped,
6667
onSent,
68+
replying = null,
69+
onCancelReply,
6770
}: {
6871
bot: Bot;
6972
files: Files;
7073
/** The bot is paused or its crew is. */
7174
stopped: boolean;
7275
onSent(): void;
76+
/** What the message replies to (spec 9.3). */
77+
replying?: ReplyTarget | null;
78+
onCancelReply?(): void;
7379
}) {
7480
const api = useApi();
7581
const t = useT();
@@ -88,6 +94,13 @@ export const ChatComposer = memo(function ChatComposer({
8894
const tooLong = text.length > FIELD_LIMITS.message;
8995
const empty = !text.trim() && files.files.length === 0;
9096

97+
// Replying starts in the field.
98+
useEffect(() => {
99+
if (replying) {
100+
field.current?.focus();
101+
}
102+
}, [replying]);
103+
91104
const working = bot.state === "busy" || bot.state === "needs_approval";
92105
useEffect(() => {
93106
if (!working) {
@@ -120,6 +133,7 @@ export const ChatComposer = memo(function ChatComposer({
120133
botId: bot.id,
121134
body: text,
122135
...(attachments.length > 0 ? { attachments } : {}),
136+
...(replying ? { replyTo: replying.itemId } : {}),
123137
});
124138
// The daemon keeps the order it was given.
125139
message.attachments.forEach((attachment, index) => {
@@ -130,6 +144,7 @@ export const ChatComposer = memo(function ChatComposer({
130144
});
131145
setText("");
132146
files.clear();
147+
onCancelReply?.();
133148
playSound("sent");
134149
onSent();
135150
} catch (failure) {
@@ -142,6 +157,11 @@ export const ChatComposer = memo(function ChatComposer({
142157

143158
const enterSends = usePref(prefs.enterSends);
144159
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
160+
if (event.key === "Escape" && replying) {
161+
event.preventDefault();
162+
onCancelReply?.();
163+
return;
164+
}
145165
if (event.key !== "Enter" || event.nativeEvent.isComposing) {
146166
return;
147167
}
@@ -171,6 +191,7 @@ export const ChatComposer = memo(function ChatComposer({
171191
</p>
172192
)}
173193
<div className="@container rounded-2xl border border-line-strong bg-panel shadow-sm transition-colors focus-within:border-muted">
194+
{replying && onCancelReply && <ReplyBar target={replying} onCancel={onCancelReply} />}
174195
{files.files.length > 0 && (
175196
<ul aria-label={t.chat.composer.filesToSend} className="flex flex-wrap gap-2 px-3 pt-3">
176197
{files.files.map((file) => (

‎app/src/features/chat/ChatView.tsx‎

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { BotRun, type Live } from "./BotRun";
2525
import { ChatComposer } from "./ChatComposer";
2626
import { InboundRow } from "./InboundRow";
2727
import { NoticeRow } from "./NoticeRow";
28+
import { type ReplyTarget, StartReply } from "./Replying";
2829
import { chatRows, rowHas } from "./rows";
2930
import { useChat } from "./useChat";
3031
import { useFiles } from "./useFiles";
@@ -54,6 +55,9 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto
5455
/** Distance from the bottom to keep while older items load above. */
5556
const anchor = useRef<number | null>(null);
5657
const [dragging, setDragging] = useState(false);
58+
/** What the owner's next message replies to (spec 9.3). */
59+
const [replying, setReplying] = useState<ReplyTarget | null>(null);
60+
const cancelReply = useCallback(() => setReplying(null), []);
5761
// What was there when the owner opened this chat stays still; what
5862
// arrives while they look animates in.
5963
// biome-ignore lint/correctness/useExhaustiveDependencies: a new bot is a new chat
@@ -107,7 +111,7 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto
107111
);
108112
}
109113
if (row.kind === "inbound") {
110-
return <InboundRow key={row.key} message={row.message} bot={bot} />;
114+
return <InboundRow key={row.key} message={row.message} bot={bot} itemId={row.item.id} />;
111115
}
112116
if (row.kind === "notice") {
113117
return <NoticeRow key={row.key} notice={row.notice} at={row.item.createdAt} />;
@@ -210,15 +214,17 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto
210214
<p className="max-w-md text-muted text-sm">{t.chat.view.emptyBody}</p>
211215
</div>
212216
)}
213-
<SeenSince.Provider value={openedAt}>
214-
<ol
215-
ref={listRef}
216-
aria-label={t.chat.view.messages}
217-
className="offscreen-rows flex flex-col gap-6"
218-
>
219-
{list}
220-
</ol>
221-
</SeenSince.Provider>
217+
<StartReply.Provider value={setReplying}>
218+
<SeenSince.Provider value={openedAt}>
219+
<ol
220+
ref={listRef}
221+
aria-label={t.chat.view.messages}
222+
className="offscreen-rows flex flex-col gap-6"
223+
>
224+
{list}
225+
</ol>
226+
</SeenSince.Provider>
227+
</StartReply.Provider>
222228
</div>
223229
</div>
224230
<div className="relative w-full">
@@ -228,7 +234,15 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto
228234
className="absolute bottom-full left-1/2 z-10 mb-2 -translate-x-1/2 justify-center"
229235
/>
230236
{/* Keyed by bot: each one has its own draft (spec 15.1). */}
231-
<ChatComposer key={bot.id} bot={bot} files={files} stopped={stopped} onSent={onSent} />
237+
<ChatComposer
238+
key={bot.id}
239+
bot={bot}
240+
files={files}
241+
stopped={stopped}
242+
onSent={onSent}
243+
replying={replying}
244+
onCancelReply={cancelReply}
245+
/>
232246
</div>
233247
{dragging && (
234248
<div className="pointer-events-none absolute inset-3 grid place-items-center rounded-2xl border-2 border-accent border-dashed bg-canvas/85">

‎app/src/features/chat/InboundRow.tsx‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { BOTLOFT_COLOR, BotAvatar } from "../bots/BotAvatar";
1212
import { DeliveryStatus } from "../messages/DeliveryStatus";
1313
import { AttachmentList } from "./AttachmentList";
1414
import { Mentions } from "./mentions";
15+
import { Repliable, ReplyQuote } from "./Replying";
1516

1617
function Time({ at }: { at: number }) {
1718
return (
@@ -26,6 +27,7 @@ function OwnerMessage({ message, bot }: { message: Message; bot: Bot }) {
2627
const arrival = useArrival(message.createdAt);
2728
return (
2829
<li className={`flex flex-col items-end gap-1.5 pl-12 ${arrival}`}>
30+
{message.replyTo && <ReplyQuote botId={bot.id} reply={message.replyTo} />}
2931
<AttachmentList attachments={message.attachments} workspace={bot.workspace} align="end" />
3032
{message.body && (
3133
<p
@@ -90,7 +92,7 @@ function TaskTag({ message }: { message: Message }) {
9092
);
9193
}
9294

93-
function OtherMessage({ message }: { message: Message }) {
95+
function OtherMessage({ message, itemId }: { message: Message; itemId: string }) {
9496
const t = useT();
9597
const sender = useApp((state) => (message.fromBotId ? state.bots[message.fromBotId] : undefined));
9698
const delivery = useApp((state) => state.deliveries[message.id]);
@@ -118,12 +120,17 @@ function OtherMessage({ message }: { message: Message }) {
118120
<Time at={message.createdAt} />
119121
</div>
120122
{(!routine || open) && (
121-
<p
122-
className={`mt-1 max-w-[36rem] ${routine ? "animate-rise" : ""} whitespace-pre-wrap break-words rounded-2xl rounded-tl-md border border-line bg-panel px-4 py-2.5 leading-relaxed ${system ? "text-ink-soft" : ""}`}
123-
data-selectable
123+
<Repliable
124+
target={{ itemId, who: name, text: message.body }}
125+
className="w-fit max-w-full"
124126
>
125-
<Mentions text={message.body} />
126-
</p>
127+
<p
128+
className={`mt-1 max-w-[36rem] ${routine ? "animate-rise" : ""} whitespace-pre-wrap break-words rounded-2xl rounded-tl-md border border-line bg-panel px-4 py-2.5 leading-relaxed ${system ? "text-ink-soft" : ""}`}
129+
data-selectable
130+
>
131+
<Mentions text={message.body} />
132+
</p>
133+
</Repliable>
127134
)}
128135
{delivery && delivery.state !== "sent" && (
129136
<div className="mt-1">
@@ -138,13 +145,16 @@ function OtherMessage({ message }: { message: Message }) {
138145
export const InboundRow = memo(function InboundRow({
139146
message,
140147
bot,
148+
itemId,
141149
}: {
142150
message: Message;
143151
bot: Bot;
152+
/** The chat item that shows it, to reply to. */
153+
itemId: string;
144154
}) {
145155
return message.fromKind === "owner" ? (
146156
<OwnerMessage message={message} bot={bot} />
147157
) : (
148-
<OtherMessage message={message} />
158+
<OtherMessage message={message} itemId={itemId} />
149159
);
150160
});

‎app/src/features/chat/Replying.tsx‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// Replying to something in the bot's chat (spec 9.3): a button that shows
2+
// on what can be quoted (the bot's replies, the messages it got), the
3+
// quote over the composer, and the quote over the owner's bubble after.
4+
5+
import { CornerUpLeft, X } from "lucide-react";
6+
import { createContext, type ReactNode, useContext } from "react";
7+
import { useT } from "../../i18n";
8+
import { plainText } from "../../lib/plainText";
9+
import type { ChatItemId, MessageReply } from "../../lib/protocol.gen";
10+
import { useApp } from "../../store/context";
11+
12+
/** What the owner is replying to, while they write. */
13+
export interface ReplyTarget {
14+
itemId: ChatItemId;
15+
/** Whose words: the bot's name, another bot's, or Botloft. */
16+
who: string;
17+
text: string;
18+
}
19+
20+
/** Starts a reply; absent where the chat cannot be written to. */
21+
export const StartReply = createContext<((target: ReplyTarget) => void) | null>(null);
22+
23+
/** Wraps what can be replied to, with the button that shows on hover. */
24+
export function Repliable({
25+
target,
26+
className = "",
27+
children,
28+
}: {
29+
target: ReplyTarget;
30+
/** A bubble narrows it to its own width, for the button to sit on its corner. */
31+
className?: string;
32+
children: ReactNode;
33+
}) {
34+
const t = useT().chat.reply;
35+
const start = useContext(StartReply);
36+
if (!start) {
37+
return children;
38+
}
39+
return (
40+
<div className={`group/reply relative ${className}`}>
41+
{children}
42+
<button
43+
type="button"
44+
title={t.action}
45+
aria-label={t.to(target.who)}
46+
onClick={() => start(target)}
47+
className="absolute -top-2 right-0 grid size-7 place-items-center rounded-lg border border-line bg-panel text-muted opacity-0 shadow-sm transition-[opacity,color] hover:text-ink focus-visible:opacity-100 group-hover/reply:opacity-100"
48+
>
49+
<CornerUpLeft aria-hidden size={15} />
50+
</button>
51+
</div>
52+
);
53+
}
54+
55+
/** Over the composer: what the next message replies to, with a way out. */
56+
export function ReplyBar({ target, onCancel }: { target: ReplyTarget; onCancel(): void }) {
57+
const t = useT().chat.reply;
58+
return (
59+
<div className="flex animate-rise items-start gap-2 border-line border-b px-3.5 py-2">
60+
<CornerUpLeft aria-hidden size={14} className="mt-0.5 shrink-0 text-accent" />
61+
<div className="min-w-0 flex-1 text-xs">
62+
<p className="font-medium text-ink-soft">{t.replyingTo(target.who)}</p>
63+
<p className="truncate text-muted">{plainText(target.text)}</p>
64+
</div>
65+
<button
66+
type="button"
67+
title={t.cancel}
68+
aria-label={t.cancel}
69+
onClick={onCancel}
70+
className="grid size-6 shrink-0 place-items-center rounded-lg text-muted transition-colors hover:bg-sunken hover:text-ink"
71+
>
72+
<X aria-hidden size={13} />
73+
</button>
74+
</div>
75+
);
76+
}
77+
78+
/** Over the owner's bubble: what it replied to; a click shows it in the chat. */
79+
export function ReplyQuote({ botId, reply }: { botId: string; reply: MessageReply }) {
80+
const t = useT().chat.reply;
81+
const openAt = useApp((state) => state.openAt);
82+
return (
83+
<button
84+
type="button"
85+
title={t.jump}
86+
onClick={() => openAt(botId, reply.itemId)}
87+
className="flex max-w-[36rem] items-start gap-1.5 rounded-xl border-accent/60 border-l-2 bg-sunken/60 px-3 py-1.5 text-left text-muted text-xs transition-colors hover:text-ink"
88+
>
89+
<CornerUpLeft aria-hidden size={12} className="mt-0.5 shrink-0" />
90+
<span className="line-clamp-2">{plainText(reply.text)}</span>
91+
</button>
92+
);
93+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
2+
import { afterEach, describe, expect, test } from "vitest";
3+
import { FakeBotloft } from "../../lib/fake";
4+
import { crewOpened, openBot, renderApp } from "../../test/app";
5+
6+
afterEach(cleanup);
7+
8+
async function setup() {
9+
const fake = new FakeBotloft();
10+
const ops = fake.addCrew("Ops");
11+
const scout = fake.addBot(ops.id, "Scout", "Finds sources");
12+
const writer = fake.addBot(ops.id, "Writer", "Writes");
13+
fake.setBotState(scout.id, "idle");
14+
const said = fake.chat.reply(scout.id, "Acme signs monthly.\n\nDana approves.");
15+
fake.chat.turn(scout.id);
16+
fake.conversation.say({ from: writer.id, to: scout.id, body: "The draft is ready." });
17+
renderApp(fake);
18+
await crewOpened("Ops");
19+
openBot("Scout");
20+
await screen.findByRole("list", { name: "Messages" });
21+
return { fake, scout, said };
22+
}
23+
24+
const field = () => screen.getByRole("textbox", { name: "Message to Scout" });
25+
const sent = (fake: FakeBotloft) =>
26+
fake.calls.filter((call) => call.method === "messages.send").map((call) => call.params);
27+
28+
describe("replying to a message", () => {
29+
test("quotes the bot's reply over the composer, sends it, and shows it on the bubble", async () => {
30+
const { fake, scout, said } = await setup();
31+
fireEvent.click(screen.getByRole("button", { name: "Reply to Scout" }));
32+
expect(screen.getByText("Replying to Scout")).toBeDefined();
33+
expect(document.activeElement).toBe(field());
34+
35+
fireEvent.change(field(), { target: { value: "Make it yearly" } });
36+
fireEvent.keyDown(field(), { key: "Enter" });
37+
await waitFor(() =>
38+
expect(sent(fake)).toEqual([{ botId: scout.id, body: "Make it yearly", replyTo: said.id }]),
39+
);
40+
// The bar leaves with the message, and the bubble keeps the quote.
41+
await waitFor(() => expect(screen.queryByText("Replying to Scout")).toBeNull());
42+
const quote = await screen.findByRole("button", { name: "Acme signs monthly. Dana approves." });
43+
expect(quote.title).toBe("Show what this replies to");
44+
});
45+
46+
test("replies to another bot's message too, and Esc or X cancels", async () => {
47+
const { fake } = await setup();
48+
fireEvent.click(screen.getByRole("button", { name: "Reply to Writer" }));
49+
const bar = screen.getByText("Replying to Writer").parentElement as HTMLElement;
50+
expect(within(bar).getByText("The draft is ready.")).toBeDefined();
51+
fireEvent.keyDown(field(), { key: "Escape" });
52+
expect(screen.queryByText("Replying to Writer")).toBeNull();
53+
54+
fireEvent.click(screen.getByRole("button", { name: "Reply to Writer" }));
55+
fireEvent.click(screen.getByRole("button", { name: "Cancel the reply" }));
56+
fireEvent.change(field(), { target: { value: "Plain" } });
57+
fireEvent.keyDown(field(), { key: "Enter" });
58+
await waitFor(() => expect(sent(fake).at(-1)).not.toHaveProperty("replyTo"));
59+
});
60+
});

0 commit comments

Comments
 (0)