Specialists ask questions, request approvals and the computer screen in their own threads, and the user only finds out by opening each thread. With the main conversation as the single entry point, those requests have to reach the user there, with the specialist as the visible sender. - inbox.list returns, for the current space and the user's own bots, pending asks and approvals (bot messages with a pending ask block whose run waits for input), takeover requests, and unread specialist threads. Asks already in the main conversation are left out; takeovers stay because their controls live in the bot's own thread. Each item carries the bot, the thread target, run and message ids, and the ask block for asks and approvals. - The main conversation shows the items above the composer. Asks and approvals are answered in place through threads.answer with the source thread target; takeovers and unread threads open the bot's thread. The list refreshes when the bots' status, unread state or newest message changes, every 15 seconds, and after an answer. - The inbox shrinks and scrolls so it never pushes the composer out of view. The transcript now clips its scroller: with no room left in the column, the scroller's padding used to spill over whatever sat below it. - The ask block schema is extracted from MessageBlock so the inbox contract reuses it, and AskCard accepts a className. Tests: unit tests for the item builder and the component, a Postgres suite for the query (added to the harness list), inbox cases in the authorization suite, and an E2E spec in which a specialist asks in its own thread and the user answers from the main conversation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
220 lines
7.2 KiB
TypeScript
220 lines
7.2 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import type { InboxItem } from "@rakazo/contracts";
|
|
import type { ReactNode } from "react";
|
|
import { act } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
|
|
const api = vi.hoisted(() => ({ list: vi.fn(), answer: vi.fn(), markRead: vi.fn() }));
|
|
vi.mock("../lib/rpc", () => ({
|
|
rpc: { inbox: { list: api.list }, threads: { answer: api.answer, markRead: api.markRead } },
|
|
}));
|
|
vi.mock("@lingui/react/macro", () => {
|
|
const t = (parts: TemplateStringsArray) => parts.join("");
|
|
return { useLingui: () => ({ t }), Trans: ({ children }: { children: ReactNode }) => children };
|
|
});
|
|
vi.mock("@rakazo/ui-web", () => ({
|
|
BotAvatar: () => <span data-avatar="" />,
|
|
parseBotAvatar: (color: string) => ({ color }),
|
|
resolvePersonaColorDef: (_id: string, color: string) => ({ light: color }),
|
|
}));
|
|
vi.mock("./AskCard", () => ({
|
|
AskCard: ({
|
|
block,
|
|
onAnswer,
|
|
}: {
|
|
block: { text: string };
|
|
onAnswer: (text: string) => Promise<void>;
|
|
}) => (
|
|
// Like the real card, show a failed answer instead of rejecting.
|
|
<button
|
|
type="button"
|
|
data-answer={block.text}
|
|
onClick={() => void onAnswer("Paris").catch(() => undefined)}
|
|
>
|
|
{block.text}
|
|
</button>
|
|
),
|
|
}));
|
|
|
|
import { MainInbox } from "./MainInbox";
|
|
|
|
const scout = { id: "bot-scout", name: "Scout", color: "#3B82F6" };
|
|
const writer = { id: "bot-writer", name: "Writer", color: "#D9508A" };
|
|
const createdAt = "2026-10-02T09:00:00.000Z";
|
|
|
|
const askInOwnThread: InboxItem = {
|
|
kind: "ask",
|
|
bot: scout,
|
|
target: { botId: scout.id },
|
|
runId: "run-ask",
|
|
messageId: "msg-ask",
|
|
ask: { kind: "ask", text: "Which city should I use?", status: "pending" },
|
|
createdAt,
|
|
};
|
|
const approvalInGroup: InboxItem = {
|
|
kind: "approval",
|
|
bot: writer,
|
|
target: { groupId: "group-ops" },
|
|
runId: "run-approval",
|
|
messageId: "msg-approval",
|
|
ask: { kind: "ask", text: "Review before writing to notes", status: "pending" },
|
|
createdAt,
|
|
};
|
|
const takeover: InboxItem = {
|
|
kind: "takeover",
|
|
bot: writer,
|
|
target: { botId: writer.id },
|
|
runId: "run-takeover",
|
|
messageId: "msg-takeover",
|
|
text: "Sign in to continue.",
|
|
createdAt,
|
|
};
|
|
const unread: InboxItem = {
|
|
kind: "unread",
|
|
bot: scout,
|
|
target: { botId: scout.id },
|
|
messageId: "msg-unread",
|
|
text: "Draft ready for review",
|
|
createdAt,
|
|
};
|
|
|
|
const idleBots = [
|
|
{ id: scout.id, status: "idle", unread: false, preview: "" },
|
|
{ id: writer.id, status: "idle", unread: false, preview: "" },
|
|
];
|
|
|
|
let container: HTMLDivElement;
|
|
let root: Root;
|
|
const onOpenBot = vi.fn();
|
|
const onOpenGroup = vi.fn();
|
|
|
|
function render(bots = idleBots) {
|
|
return act(async () =>
|
|
root.render(<MainInbox bots={bots} onOpenBot={onOpenBot} onOpenGroup={onOpenGroup} />),
|
|
);
|
|
}
|
|
|
|
function itemOf(kind: InboxItem["kind"]) {
|
|
const found = container.querySelector<HTMLElement>(
|
|
`[data-testid='main-inbox-item'][data-kind='${kind}']`,
|
|
);
|
|
if (!found) throw new Error(`Missing ${kind} item`);
|
|
return found;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
api.list.mockReset();
|
|
api.answer.mockReset();
|
|
api.markRead.mockReset().mockResolvedValue({ ok: true });
|
|
onOpenBot.mockReset();
|
|
onOpenGroup.mockReset();
|
|
container = document.createElement("div");
|
|
document.body.append(container);
|
|
root = createRoot(container);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await act(async () => root.unmount());
|
|
container.remove();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("renders nothing until a bot needs the user", async () => {
|
|
api.list.mockResolvedValue({ items: [] });
|
|
await render();
|
|
|
|
expect(api.list).toHaveBeenCalledOnce();
|
|
expect(container.querySelector("[data-testid='main-inbox']")).toBeNull();
|
|
});
|
|
|
|
it("shows each item with its bot as the sender", async () => {
|
|
api.list.mockResolvedValue({ items: [askInOwnThread, approvalInGroup, takeover, unread] });
|
|
await render();
|
|
|
|
const inbox = container.querySelector("[data-testid='main-inbox']");
|
|
expect(inbox?.getAttribute("aria-label")).toBe("Needs you");
|
|
expect(itemOf("ask").textContent).toContain("Scout");
|
|
expect(itemOf("ask").textContent).toContain("Which city should I use?");
|
|
expect(itemOf("approval").textContent).toContain("Writer");
|
|
expect(itemOf("takeover").textContent).toContain("Sign in to continue.");
|
|
expect(itemOf("unread").textContent).toContain("Draft ready for review");
|
|
});
|
|
|
|
it("answers through the thread the ask came from and drops only that item", async () => {
|
|
api.list
|
|
.mockResolvedValueOnce({ items: [askInOwnThread, approvalInGroup] })
|
|
.mockReturnValue(new Promise(() => undefined));
|
|
api.answer.mockResolvedValue({ ok: true });
|
|
await render();
|
|
|
|
await act(async () =>
|
|
itemOf("approval").querySelector<HTMLButtonElement>("[data-answer]")?.click(),
|
|
);
|
|
|
|
expect(api.answer).toHaveBeenCalledWith({
|
|
groupId: "group-ops",
|
|
runId: "run-approval",
|
|
messageId: "msg-approval",
|
|
answer: "Paris",
|
|
});
|
|
expect(api.markRead).not.toHaveBeenCalled();
|
|
expect(api.list).toHaveBeenCalledTimes(2);
|
|
expect(container.querySelectorAll("[data-testid='main-inbox-item']")).toHaveLength(1);
|
|
expect(itemOf("ask")).toBeTruthy();
|
|
});
|
|
|
|
it("marks the bot's own thread read before answering, so the question does not return as unread", async () => {
|
|
api.list.mockResolvedValueOnce({ items: [askInOwnThread] }).mockResolvedValue({ items: [] });
|
|
api.answer.mockResolvedValue({ ok: true });
|
|
await render();
|
|
|
|
await act(async () => itemOf("ask").querySelector<HTMLButtonElement>("[data-answer]")?.click());
|
|
|
|
expect(api.markRead).toHaveBeenCalledWith({ botId: scout.id });
|
|
expect(api.markRead.mock.invocationCallOrder[0]).toBeLessThan(
|
|
api.answer.mock.invocationCallOrder[0]!,
|
|
);
|
|
expect(container.querySelector("[data-testid='main-inbox']")).toBeNull();
|
|
});
|
|
|
|
it("refreshes after a failed answer so a stale ask disappears", async () => {
|
|
api.list.mockResolvedValueOnce({ items: [askInOwnThread] }).mockResolvedValue({ items: [] });
|
|
api.answer.mockRejectedValue(new Error("This prompt is no longer awaiting an answer"));
|
|
await render();
|
|
|
|
await act(async () => itemOf("ask").querySelector<HTMLButtonElement>("[data-answer]")?.click());
|
|
|
|
expect(api.answer).toHaveBeenCalledWith({
|
|
botId: scout.id,
|
|
runId: "run-ask",
|
|
messageId: "msg-ask",
|
|
answer: "Paris",
|
|
});
|
|
expect(container.querySelector("[data-testid='main-inbox']")).toBeNull();
|
|
});
|
|
|
|
it("opens the bot's thread for takeovers and unread threads, and the source thread for asks", async () => {
|
|
api.list.mockResolvedValue({ items: [approvalInGroup, takeover, unread] });
|
|
await render();
|
|
|
|
await act(async () => itemOf("takeover").querySelector("button")?.click());
|
|
await act(async () => itemOf("unread").querySelector("button")?.click());
|
|
await act(async () => itemOf("approval").querySelector("button")?.click());
|
|
|
|
expect(onOpenBot.mock.calls).toEqual([[writer.id], [scout.id]]);
|
|
expect(onOpenGroup.mock.calls).toEqual([["group-ops"]]);
|
|
});
|
|
|
|
it("refreshes when a bot's state changes, not on every render", async () => {
|
|
api.list.mockResolvedValue({ items: [] });
|
|
await render();
|
|
await render([...idleBots]);
|
|
expect(api.list).toHaveBeenCalledOnce();
|
|
|
|
await render([{ ...idleBots[0]!, status: "waiting_input" }, idleBots[1]!]);
|
|
expect(api.list).toHaveBeenCalledTimes(2);
|
|
});
|