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
1 change: 1 addition & 0 deletions app/src/features/bots/moments.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ describe("glances", () => {
routineId: null,
questionId: null,
attachments: [],
replyTo: null,
createdAt: Date.now(),
};
act(() => fake.emit({ name: "message.created", params: message }));
Expand Down
10 changes: 9 additions & 1 deletion app/src/features/chat/BotRun.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -160,7 +161,14 @@ function BotRunView({
}
case "reply":
// It was already on screen as it was written.
return <Markdown key={head.id} text={head.body.text} />;
return (
<Repliable
key={head.id}
target={{ itemId: head.id, who: bot.name, text: head.body.text }}
>
<Markdown text={head.body.text} />
</Repliable>
);
case "approval":
return (
<Arriving key={head.id} at={head.createdAt}>
Expand Down
21 changes: 21 additions & 0 deletions app/src/features/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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) {
Expand Down Expand Up @@ -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) => {
Expand All @@ -130,6 +144,7 @@ export const ChatComposer = memo(function ChatComposer({
});
setText("");
files.clear();
onCancelReply?.();
playSound("sent");
onSent();
} catch (failure) {
Expand All @@ -142,6 +157,11 @@ export const ChatComposer = memo(function ChatComposer({

const enterSends = usePref(prefs.enterSends);
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === "Escape" && replying) {
event.preventDefault();
onCancelReply?.();
return;
}
if (event.key !== "Enter" || event.nativeEvent.isComposing) {
return;
}
Expand Down Expand Up @@ -171,6 +191,7 @@ export const ChatComposer = memo(function ChatComposer({
</p>
)}
<div className="@container rounded-2xl border border-line-strong bg-panel shadow-sm transition-colors focus-within:border-muted">
{replying && onCancelReply && <ReplyBar target={replying} onCancel={onCancelReply} />}
{files.files.length > 0 && (
<ul aria-label={t.chat.composer.filesToSend} className="flex flex-wrap gap-2 px-3 pt-3">
{files.files.map((file) => (
Expand Down
36 changes: 25 additions & 11 deletions app/src/features/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<number | null>(null);
const [dragging, setDragging] = useState(false);
/** What the owner's next message replies to (spec 9.3). */
const [replying, setReplying] = useState<ReplyTarget | null>(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
Expand Down Expand Up @@ -104,7 +108,7 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto
);
}
if (row.kind === "inbound") {
return <InboundRow key={row.key} message={row.message} bot={bot} />;
return <InboundRow key={row.key} message={row.message} bot={bot} itemId={row.item.id} />;
}
if (row.kind === "notice") {
return <NoticeRow key={row.key} notice={row.notice} at={row.item.createdAt} />;
Expand Down Expand Up @@ -207,20 +211,30 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto
<p className="max-w-md text-muted text-sm">{t.chat.view.emptyBody}</p>
</div>
)}
<SeenSince.Provider value={openedAt}>
<ol
ref={listRef}
aria-label={t.chat.view.messages}
className="offscreen-rows flex flex-col gap-6"
>
{list}
</ol>
</SeenSince.Provider>
<StartReply.Provider value={setReplying}>
<SeenSince.Provider value={openedAt}>
<ol
ref={listRef}
aria-label={t.chat.view.messages}
className="offscreen-rows flex flex-col gap-6"
>
{list}
</ol>
</SeenSince.Provider>
</StartReply.Provider>
</div>
</div>
<div className="w-full">
{/* Keyed by bot: each one has its own draft (spec 15.1). */}
<ChatComposer key={bot.id} bot={bot} files={files} stopped={stopped} onSent={onSent} />
<ChatComposer
key={bot.id}
bot={bot}
files={files}
stopped={stopped}
onSent={onSent}
replying={replying}
onCancelReply={cancelReply}
/>
</div>
{dragging && (
<div className="pointer-events-none absolute inset-3 grid place-items-center rounded-2xl border-2 border-accent border-dashed bg-canvas/85">
Expand Down
24 changes: 17 additions & 7 deletions app/src/features/chat/InboundRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -26,6 +27,7 @@ function OwnerMessage({ message, bot }: { message: Message; bot: Bot }) {
const arrival = useArrival(message.createdAt);
return (
<li className={`flex flex-col items-end gap-1.5 pl-12 ${arrival}`}>
{message.replyTo && <ReplyQuote botId={bot.id} reply={message.replyTo} />}
<AttachmentList attachments={message.attachments} workspace={bot.workspace} align="end" />
{message.body && (
<p
Expand Down Expand Up @@ -90,7 +92,7 @@ function TaskTag({ message }: { message: Message }) {
);
}

function OtherMessage({ message }: { message: Message }) {
function OtherMessage({ message, itemId }: { message: Message; itemId: string }) {
const t = useT();
const sender = useApp((state) => (message.fromBotId ? state.bots[message.fromBotId] : undefined));
const delivery = useApp((state) => state.deliveries[message.id]);
Expand Down Expand Up @@ -118,12 +120,17 @@ function OtherMessage({ message }: { message: Message }) {
<Time at={message.createdAt} />
</div>
{(!routine || open) && (
<p
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" : ""}`}
data-selectable
<Repliable
target={{ itemId, who: name, text: message.body }}
className="w-fit max-w-full"
>
<Mentions text={message.body} />
</p>
<p
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" : ""}`}
data-selectable
>
<Mentions text={message.body} />
</p>
</Repliable>
)}
{delivery && delivery.state !== "sent" && (
<div className="mt-1">
Expand All @@ -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" ? (
<OwnerMessage message={message} bot={bot} />
) : (
<OtherMessage message={message} />
<OtherMessage message={message} itemId={itemId} />
);
});
93 changes: 93 additions & 0 deletions app/src/features/chat/Replying.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={`group/reply relative ${className}`}>
{children}
<button
type="button"
title={t.action}
aria-label={t.to(target.who)}
onClick={() => start(target)}
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"
>
<CornerUpLeft aria-hidden size={15} />
</button>
</div>
);
}

/** 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 (
<div className="flex animate-rise items-start gap-2 border-line border-b px-3.5 py-2">
<CornerUpLeft aria-hidden size={14} className="mt-0.5 shrink-0 text-accent" />
<div className="min-w-0 flex-1 text-xs">
<p className="font-medium text-ink-soft">{t.replyingTo(target.who)}</p>
<p className="truncate text-muted">{plainText(target.text)}</p>
</div>
<button
type="button"
title={t.cancel}
aria-label={t.cancel}
onClick={onCancel}
className="grid size-6 shrink-0 place-items-center rounded-lg text-muted transition-colors hover:bg-sunken hover:text-ink"
>
<X aria-hidden size={13} />
</button>
</div>
);
}

/** 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 (
<button
type="button"
title={t.jump}
onClick={() => openAt(botId, reply.itemId)}
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"
>
<CornerUpLeft aria-hidden size={12} className="mt-0.5 shrink-0" />
<span className="line-clamp-2">{plainText(reply.text)}</span>
</button>
);
}
60 changes: 60 additions & 0 deletions app/src/features/chat/reply.test.tsx
Original file line number Diff line number Diff line change
@@ -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"));
});
});
Loading
Loading