mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-19 10:36:26 +02:00
Preserve human author identity in task chat and align only the current viewer on the right. Show other humans and agents on the left with their own identity. Include mixed-author Storybook journeys and regression coverage. Co-Authored-By: Paperclip <noreply@paperclip.ing>
605 lines
23 KiB
TypeScript
605 lines
23 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import type { ReactNode } from "react";
|
|
import type { IssueAttachment } from "@paperclipai/shared";
|
|
import { flushSync } from "react-dom";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { ThemeProvider } from "@/context/ThemeContext";
|
|
import { IssueGalleryContext } from "@/context/IssueGalleryContext";
|
|
import { TaskChatBubble } from "./TaskChatBubble";
|
|
import { paperclipHumanAvatarUrl } from "@/lib/issue-chat-human-author";
|
|
import type { TaskChatMessageItem } from "./task-chat-model";
|
|
|
|
describe("TaskChatBubble attachment chips", () => {
|
|
let container: HTMLDivElement;
|
|
let root: Root | null = null;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
flushSync(() => root?.unmount());
|
|
root = null;
|
|
container.remove();
|
|
});
|
|
|
|
function renderMessage(
|
|
text: string,
|
|
author: TaskChatMessageItem["author"] = "human",
|
|
attachments: IssueAttachment[] = [],
|
|
) {
|
|
const item: TaskChatMessageItem = { id: "m1", kind: "message", author, text };
|
|
flushSync(() =>
|
|
root!.render(
|
|
<ThemeProvider>
|
|
<TaskChatBubble item={item} attachments={attachments} />
|
|
</ThemeProvider>,
|
|
),
|
|
);
|
|
}
|
|
|
|
it("renders another human on the left like an agent, and moves them right when they become the viewer", () => {
|
|
const item: TaskChatMessageItem = {
|
|
id: "sam-comment", kind: "message", author: "human", text: "I still see the old task title.",
|
|
authorUserId: "sam", authorName: "Sam Rivera", isCurrentUser: false,
|
|
};
|
|
const render = (isCurrentUser: boolean) => flushSync(() => root!.render(
|
|
<ThemeProvider><TaskChatBubble item={{ ...item, isCurrentUser }} /></ThemeProvider>,
|
|
));
|
|
render(false);
|
|
const identity = container.querySelector('[data-testid="task-chat-human-identity"]');
|
|
expect(identity?.textContent).toContain("Sam Rivera");
|
|
expect(identity?.querySelector('[data-slot="avatar-fallback"]')?.textContent).toBe("SR");
|
|
expect(identity?.getAttribute("data-author-user-id")).toBe("sam");
|
|
expect(container.querySelector('[data-testid="task-chat-human-bubble"]')?.parentElement?.classList.contains("items-start")).toBe(true);
|
|
const bubble = container.querySelector('[data-testid="task-chat-human-bubble"]');
|
|
expect(bubble?.className).toContain("bg-transparent");
|
|
expect(bubble?.className).not.toContain("bg-(--liveness-blue)");
|
|
expect(bubble?.querySelector(".paperclip-markdown")?.className).not.toContain("paperclip-markdown-on-accent");
|
|
render(true);
|
|
expect(bubble?.parentElement?.classList.contains("items-end")).toBe(true);
|
|
expect(bubble?.className).toContain("bg-(--liveness-blue)");
|
|
expect(container.querySelector('[data-testid="task-chat-human-identity"]')).toBeNull();
|
|
expect(container.textContent).toContain("I still see the old task title.");
|
|
});
|
|
|
|
it("uses initials instead of loading an external human avatar", () => {
|
|
flushSync(() => root!.render(
|
|
<ThemeProvider><TaskChatBubble item={{
|
|
id: "external-avatar", kind: "message", author: "human", text: "Please check this.",
|
|
authorName: "Sam Rivera", authorAvatarUrl: "https://example.com/track.png", isCurrentUser: false,
|
|
}} /></ThemeProvider>,
|
|
));
|
|
const identity = container.querySelector('[data-testid="task-chat-human-identity"]');
|
|
expect(identity?.querySelector("img")).toBeNull();
|
|
expect(identity?.querySelector('[data-slot="avatar-fallback"]')?.textContent).toBe("SR");
|
|
expect(paperclipHumanAvatarUrl("/api/assets/sam/content")).toBe("/api/assets/sam/content");
|
|
expect(paperclipHumanAvatarUrl("https://example.com/track.png")).toBeNull();
|
|
expect(paperclipHumanAvatarUrl("//example.com/track.png")).toBeNull();
|
|
});
|
|
|
|
it("shows persistent iMessage attribution only on inbound human bubbles", () => {
|
|
for (const author of ["human", "agent"] as const) {
|
|
flushSync(() => root!.render(
|
|
<ThemeProvider>
|
|
<TaskChatBubble item={{ id: "photon", kind: "message", author, text: "A reply", timestamp: "1:56 PM", sourceChannel: "imessage-photon" }} />
|
|
</ThemeProvider>,
|
|
));
|
|
expect(container.textContent?.includes("Sent from iMessage")).toBe(author === "human");
|
|
}
|
|
renderMessage("Board reply");
|
|
expect(container.textContent).not.toContain("Sent from iMessage");
|
|
});
|
|
|
|
it("opens attachment images in the shared task gallery", () => {
|
|
const openGallery = vi.fn(() => true);
|
|
const contentPath = "/api/attachments/shared-image/content";
|
|
flushSync(() => root!.render(
|
|
<ThemeProvider>
|
|
<IssueGalleryContext.Provider value={openGallery}>
|
|
<TaskChatBubble item={{ id: "m1", kind: "message", author: "agent", text: `` }} />
|
|
</IssueGalleryContext.Provider>
|
|
</ThemeProvider>,
|
|
));
|
|
const image = container.querySelector<HTMLImageElement>(`img[src="${contentPath}"]`);
|
|
expect(image).not.toBeNull();
|
|
flushSync(() => image!.click());
|
|
expect(openGallery).toHaveBeenCalledWith(contentPath);
|
|
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
|
});
|
|
|
|
it("renders a file reference as an attachment chip linking to the file", () => {
|
|
renderMessage("Here you go.\n\n[notes.txt](/api/attachments/abc/content) ");
|
|
|
|
const group = container.querySelector('[data-testid="task-chat-bubble-attachments"]');
|
|
expect(group).not.toBeNull();
|
|
expect(group?.textContent).toContain("notes.txt");
|
|
expect(group?.textContent).toContain("Text");
|
|
const link = group?.querySelector<HTMLAnchorElement>("a[href='/api/attachments/abc/content']");
|
|
expect(link).not.toBeNull();
|
|
// The link-only line left the rendered body; the prose stayed.
|
|
expect(container.textContent).toContain("Here you go.");
|
|
expect(container.textContent).not.toContain("(/api/attachments/abc/content)");
|
|
});
|
|
|
|
it("renders a chip-only row when the message body is just the file link", () => {
|
|
renderMessage("[report.pdf](/api/attachments/r1/content)", "agent");
|
|
|
|
expect(container.querySelector('[data-testid="task-chat-bubble-attachments"]')).not.toBeNull();
|
|
// No empty bubble is left behind once the only line is chipped.
|
|
expect(container.textContent).toContain("report.pdf");
|
|
expect(container.textContent).toContain("PDF");
|
|
});
|
|
|
|
it("opens video thumbnails in a local gallery when no task gallery is available", () => {
|
|
renderMessage("[clip.mp4](/api/attachments/clip/content)", "agent", [
|
|
attachment({ id: "clip", originalFilename: "clip.mp4", contentType: "video/mp4" }),
|
|
]);
|
|
const group = container.querySelector('[data-testid="task-chat-bubble-attachments"]');
|
|
expect(group?.querySelector("video")?.getAttribute("src")).toBe("/api/attachments/clip/content");
|
|
expect(group?.querySelector("video")?.autoplay).toBe(false);
|
|
flushSync(() => group!.querySelector<HTMLButtonElement>('button[aria-label="Open clip.mp4"]')!.click());
|
|
expect(document.querySelector('[role="dialog"] video')?.getAttribute("src")).toBe("/api/attachments/clip/content");
|
|
expect(document.querySelector('[role="dialog"] a[download]')?.getAttribute("href")).toBe("/api/attachments/clip/content?download=1");
|
|
});
|
|
|
|
it.each(["video/mp4", "application/octet-stream"])("opens %s video attachments in the shared task gallery", (contentType) => {
|
|
const openGallery = vi.fn(() => true);
|
|
const clip = attachment({ id: "clip", originalFilename: "clip.mp4", contentType });
|
|
flushSync(() => root!.render(
|
|
<ThemeProvider>
|
|
<IssueGalleryContext.Provider value={openGallery}>
|
|
<TaskChatBubble item={{ id: "m1", kind: "message", author: "agent", text: `[clip.mp4](${clip.contentPath})` }} attachments={[clip]} />
|
|
</IssueGalleryContext.Provider>
|
|
</ThemeProvider>,
|
|
));
|
|
flushSync(() => container.querySelector<HTMLButtonElement>('button[aria-label="Open clip.mp4"]')!.click());
|
|
expect(openGallery).toHaveBeenCalledWith(clip.contentPath);
|
|
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
|
});
|
|
|
|
it("leaves messages without file references untouched", () => {
|
|
renderMessage("Just words and a [normal link](https://example.com).");
|
|
|
|
expect(container.querySelector('[data-testid="task-chat-bubble-attachments"]')).toBeNull();
|
|
expect(container.textContent).toContain("Just words");
|
|
});
|
|
|
|
it("renders an extensionless PNG attachment as a thumbnail", () => {
|
|
renderMessage(
|
|
"Done.\n\n[desktop Default](/api/attachments/img/content)",
|
|
"agent",
|
|
[
|
|
attachment({
|
|
id: "img",
|
|
originalFilename: "desktop Default",
|
|
contentType: "image/png",
|
|
byteSize: 4096,
|
|
}),
|
|
],
|
|
);
|
|
|
|
const media = container.querySelector('[data-testid="task-chat-bubble-media"]');
|
|
expect(media).not.toBeNull();
|
|
expect(media?.querySelector("img")?.getAttribute("alt")).toBe("desktop Default");
|
|
expect(container.querySelector('[data-testid="task-chat-bubble-attachments"]')).toBeNull();
|
|
});
|
|
|
|
it("shows three thumbnails and an overflow tile for five images", () => {
|
|
const links = Array.from(
|
|
{ length: 5 },
|
|
(_, index) => `[shot ${index + 1}](/api/attachments/img${index + 1}/content)`,
|
|
).join("\n");
|
|
const attachments = Array.from({ length: 5 }, (_, index) =>
|
|
attachment({
|
|
id: `img${index + 1}`,
|
|
originalFilename: `shot ${index + 1}`,
|
|
contentType: "image/png",
|
|
}),
|
|
);
|
|
renderMessage(links, "agent", attachments);
|
|
|
|
const media = container.querySelector('[data-testid="task-chat-bubble-media"]');
|
|
expect(media?.querySelectorAll("img")).toHaveLength(3);
|
|
expect(media?.textContent).toContain("+2");
|
|
|
|
const secondThumbnail = media?.querySelectorAll("button")[1];
|
|
flushSync(() => secondThumbnail?.click());
|
|
expect(document.body.textContent).toContain("2 / 5");
|
|
});
|
|
|
|
it("shows a typed file chip with its stored size", () => {
|
|
renderMessage(
|
|
"[verification.log](/api/attachments/log/content)",
|
|
"agent",
|
|
[
|
|
attachment({
|
|
id: "log",
|
|
originalFilename: "verification.log",
|
|
contentType: "text/plain",
|
|
byteSize: 14 * 1024,
|
|
}),
|
|
],
|
|
);
|
|
|
|
const group = container.querySelector('[data-testid="task-chat-bubble-attachments"]');
|
|
expect(group?.textContent).toContain("Log · 14.0 KB");
|
|
});
|
|
|
|
it("renders only the provider attachments bound to this comment without Markdown refs", () => {
|
|
renderMessage("Please inspect both files.", "human", [
|
|
attachment({
|
|
id: "photo",
|
|
originalFilename: "evidence.png",
|
|
contentType: "image/png",
|
|
byteSize: 4096,
|
|
}),
|
|
attachment({
|
|
id: "notes",
|
|
originalFilename: "notes.txt",
|
|
contentType: "text/plain",
|
|
byteSize: 128,
|
|
}),
|
|
attachment({
|
|
id: "other-comment",
|
|
issueCommentId: "m2",
|
|
originalFilename: "unrelated.txt",
|
|
contentType: "text/plain",
|
|
}),
|
|
]);
|
|
|
|
expect(
|
|
container.querySelector('[data-testid="task-chat-bubble-media"] img')
|
|
?.getAttribute("alt"),
|
|
).toBe("evidence.png");
|
|
expect(container.textContent).toContain("Images · 1");
|
|
const group = container.querySelector(
|
|
'[data-testid="task-chat-bubble-attachments"]',
|
|
);
|
|
expect(group?.textContent).toContain("notes.txt");
|
|
expect(group?.textContent).not.toContain("unrelated.txt");
|
|
});
|
|
|
|
it("does not duplicate a bound attachment already referenced in the comment", () => {
|
|
renderMessage(
|
|
"[notes.txt](/api/attachments/notes/content)",
|
|
"human",
|
|
[
|
|
attachment({
|
|
id: "notes",
|
|
originalFilename: "notes.txt",
|
|
contentType: "text/plain",
|
|
}),
|
|
],
|
|
);
|
|
|
|
const group = container.querySelector(
|
|
'[data-testid="task-chat-bubble-attachments"]',
|
|
);
|
|
expect(group?.querySelectorAll("a")).toHaveLength(1);
|
|
expect(container.textContent).toContain("Files · 1");
|
|
});
|
|
});
|
|
|
|
function attachment(overrides: Partial<IssueAttachment>): IssueAttachment {
|
|
const id = overrides.id ?? "attachment";
|
|
return {
|
|
id,
|
|
companyId: "company",
|
|
issueId: "issue",
|
|
issueCommentId: "m1",
|
|
assetId: `asset-${id}`,
|
|
provider: "paperclip",
|
|
objectKey: id,
|
|
contentType: "application/octet-stream",
|
|
byteSize: 1,
|
|
sha256: id,
|
|
originalFilename: id,
|
|
createdByAgentId: "agent",
|
|
createdByUserId: null,
|
|
createdAt: new Date("2026-09-02T00:00:00Z"),
|
|
updatedAt: new Date("2026-09-02T00:00:00Z"),
|
|
contentPath: `/api/attachments/${id}/content`,
|
|
openPath: `/api/attachments/${id}/content`,
|
|
downloadPath: `/api/attachments/${id}/content?download=1`,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("TaskChatBubble accent-bubble text color", () => {
|
|
let container: HTMLDivElement;
|
|
let root: Root | null = null;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
flushSync(() => root?.unmount());
|
|
root = null;
|
|
container.remove();
|
|
});
|
|
|
|
function render(item: TaskChatMessageItem) {
|
|
flushSync(() =>
|
|
root!.render(
|
|
<ThemeProvider>
|
|
<TaskChatBubble item={item} />
|
|
</ThemeProvider>,
|
|
),
|
|
);
|
|
}
|
|
|
|
it("marks the own-message markdown as on-accent so prose text follows text-white", () => {
|
|
render({ id: "m1", kind: "message", author: "human", isCurrentUser: true, text: "when a new task is created…" });
|
|
const body = container.querySelector(".paperclip-markdown");
|
|
expect(body).not.toBeNull();
|
|
// Without this class the light-mode prose body color reads as black on blue.
|
|
expect(body?.className).toContain("paperclip-markdown-on-accent");
|
|
});
|
|
|
|
it("leaves the neutral agent bubble on default prose colors", () => {
|
|
render({ id: "m1", kind: "message", author: "agent", authorName: "CEO", text: "Final reply." });
|
|
const body = container.querySelector(".paperclip-markdown");
|
|
expect(body).not.toBeNull();
|
|
expect(body?.className).not.toContain("paperclip-markdown-on-accent");
|
|
});
|
|
});
|
|
|
|
describe("TaskChatBubble agent page-surface treatment", () => {
|
|
it("does not show an on-behalf-of badge in the new task view", () => {
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
|
|
flushSync(() =>
|
|
root.render(
|
|
<ThemeProvider>
|
|
<TaskChatBubble
|
|
item={{ id: "attributed", kind: "message", author: "agent", authorName: "Fable", onBehalfOfUserName: "Dotta", text: "Done." }}
|
|
/>
|
|
</ThemeProvider>,
|
|
),
|
|
);
|
|
|
|
expect(container.textContent).toContain("Fable");
|
|
expect(container.textContent).not.toContain("for Dotta");
|
|
expect(container.querySelector('[data-testid="comment-attribution-chip"]')).toBeNull();
|
|
|
|
flushSync(() => root.unmount());
|
|
container.remove();
|
|
});
|
|
|
|
it("renders agent prose without a card background or constrained width", () => {
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
|
|
flushSync(() =>
|
|
root.render(
|
|
<ThemeProvider>
|
|
<TaskChatBubble
|
|
item={{ id: "agent-message", kind: "message", author: "agent", authorName: "Codex", text: "A long-form agent response" }}
|
|
/>
|
|
</ThemeProvider>,
|
|
),
|
|
);
|
|
|
|
const bubble = container.querySelector('[data-testid="task-chat-agent-bubble"]');
|
|
expect(bubble).not.toBeNull();
|
|
expect(bubble?.className).toContain("w-full");
|
|
expect(bubble?.className).toContain("bg-transparent");
|
|
expect(bubble?.className).toContain("px-1");
|
|
expect(bubble?.className).not.toContain("rounded-2xl");
|
|
expect(bubble?.className).not.toContain("bg-(--bubble-agent)");
|
|
expect(bubble?.className).not.toContain("max-w-(--pct-85)");
|
|
|
|
flushSync(() => root.unmount());
|
|
container.remove();
|
|
});
|
|
});
|
|
|
|
describe("TaskChatBubble verification caveats", () => {
|
|
it("renders non-blocking not-run verification beneath the durable agent reply", () => {
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
|
|
flushSync(() => root.render(
|
|
<ThemeProvider>
|
|
<TaskChatBubble item={{
|
|
id: "agent-message",
|
|
kind: "message",
|
|
author: "agent",
|
|
authorName: "Runner",
|
|
text: "The implementation is complete.",
|
|
verificationCaveats: [{
|
|
commandOrCheck: "Run npm test",
|
|
reasonCode: "tool_unavailable",
|
|
detail: "Node and npm are unavailable in this environment.",
|
|
}],
|
|
}} />
|
|
</ThemeProvider>,
|
|
));
|
|
|
|
const caveat = container.querySelector('[data-testid="task-chat-verification-caveats"]');
|
|
expect(caveat?.textContent).toContain("Verification caveat");
|
|
expect(caveat?.textContent).toContain("Run npm test");
|
|
expect(caveat?.textContent).toContain("tool unavailable");
|
|
expect(caveat?.textContent).toContain("Node and npm are unavailable");
|
|
|
|
flushSync(() => root.unmount());
|
|
container.remove();
|
|
});
|
|
});
|
|
|
|
describe("TaskChatBubble interstitial self-talk (PAP-357)", () => {
|
|
let container: HTMLDivElement;
|
|
let root: Root | null = null;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
flushSync(() => root?.unmount());
|
|
root = null;
|
|
container.remove();
|
|
});
|
|
|
|
function renderItem(item: TaskChatMessageItem) {
|
|
flushSync(() =>
|
|
root!.render(
|
|
<ThemeProvider>
|
|
<TaskChatBubble item={item} />
|
|
</ThemeProvider>,
|
|
),
|
|
);
|
|
}
|
|
|
|
it("renders nothing — self-talk is ambient signal, suppressed in this view", () => {
|
|
renderItem({
|
|
id: "m1",
|
|
kind: "message",
|
|
author: "agent",
|
|
authorName: "CEO",
|
|
text: "Let me check the **adapter** first.",
|
|
interstitial: true,
|
|
streaming: true,
|
|
});
|
|
expect(container.textContent).toBe("");
|
|
expect(container.querySelector('[data-testid="task-chat-agent-avatar"]')).toBeNull();
|
|
});
|
|
|
|
it("keeps the bubble and avatar header for non-interstitial agent replies", () => {
|
|
renderItem({
|
|
id: "m1",
|
|
kind: "message",
|
|
author: "agent",
|
|
authorName: "CEO",
|
|
text: "Final reply.",
|
|
});
|
|
expect(container.querySelector('[data-testid="task-chat-interstitial"]')).toBeNull();
|
|
expect(container.querySelector('[data-testid="task-chat-agent-avatar"]')).not.toBeNull();
|
|
expect(container.textContent).toContain("CEO");
|
|
});
|
|
});
|
|
|
|
describe("TaskChatBubble footer line (round 9)", () => {
|
|
let container: HTMLDivElement;
|
|
let root: Root | null = null;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
flushSync(() => root?.unmount());
|
|
root = null;
|
|
container.remove();
|
|
});
|
|
|
|
function renderBubble(item: TaskChatMessageItem, attachedTurn?: ReactNode) {
|
|
flushSync(() =>
|
|
root!.render(
|
|
<ThemeProvider>
|
|
<TaskChatBubble item={item} attachedTurn={attachedTurn} />
|
|
</ThemeProvider>,
|
|
),
|
|
);
|
|
}
|
|
|
|
it("shows the timestamp always — not only on hover", () => {
|
|
renderBubble({ id: "m1", kind: "message", author: "agent", authorName: "CEO", text: "Done.", timestamp: "2:34 PM" });
|
|
const stamp = [...container.querySelectorAll("span")].find((el) => el.textContent === "2:34 PM");
|
|
expect(stamp).not.toBeNull();
|
|
expect(stamp?.className).not.toContain("opacity-0");
|
|
expect(stamp?.className).not.toContain("group-hover");
|
|
});
|
|
|
|
it("renders the attached turn in place of the plain timestamp footer", () => {
|
|
renderBubble(
|
|
{ id: "m1", kind: "message", author: "agent", authorName: "CEO", text: "Done.", timestamp: "2:34 PM" },
|
|
<div data-testid="fake-attached-turn">2:34 PM · Worked</div>,
|
|
);
|
|
const slot = container.querySelector('[data-testid="task-chat-bubble-attached-turn"]');
|
|
expect(slot).not.toBeNull();
|
|
expect(slot?.querySelector('[data-testid="fake-attached-turn"]')).not.toBeNull();
|
|
// The plain footer timestamp is replaced (the attached line carries it).
|
|
const plain = [...container.querySelectorAll("span")].filter((el) => el.textContent === "2:34 PM");
|
|
expect(plain).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("TaskChatBubble footer actions (PAP-413)", () => {
|
|
let container: HTMLDivElement;
|
|
let root: Root | null = null;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
flushSync(() => root?.unmount());
|
|
root = null;
|
|
container.remove();
|
|
});
|
|
|
|
function render(item: TaskChatMessageItem, opts?: { attachedTurn?: ReactNode; actions?: ReactNode }) {
|
|
flushSync(() =>
|
|
root!.render(
|
|
<ThemeProvider>
|
|
<TaskChatBubble item={item} attachedTurn={opts?.attachedTurn} actions={opts?.actions} />
|
|
</ThemeProvider>,
|
|
),
|
|
);
|
|
}
|
|
|
|
const actions = <div data-testid="fake-actions">actions</div>;
|
|
|
|
it("hands actions to the attached turn (not this wrapper) so they ride the summary row", () => {
|
|
render(
|
|
{ id: "m1", kind: "message", author: "agent", authorName: "CEO", text: "Done.", timestamp: "2:34 PM" },
|
|
{ attachedTurn: <div data-testid="fake-attached-turn">2:34 PM · Worked</div>, actions },
|
|
);
|
|
const slot = container.querySelector('[data-testid="task-chat-bubble-attached-turn"]');
|
|
expect(slot).not.toBeNull();
|
|
// The turn owns the footer line; the bubble no longer places actions beside
|
|
// it (they ride the turn's `leading` slot, anchored to the summary line).
|
|
expect(slot?.querySelector('[data-testid="fake-attached-turn"]')).not.toBeNull();
|
|
expect(slot?.querySelector('[data-testid="fake-actions"]')).toBeNull();
|
|
});
|
|
|
|
it("keeps runless timestamp left and actions at the stable right edge", () => {
|
|
render(
|
|
{ id: "m1", kind: "message", author: "agent", authorName: "CEO", text: "Done.", timestamp: "2:34 PM" },
|
|
{ actions },
|
|
);
|
|
expect(container.querySelector('[data-testid="fake-actions"]')).not.toBeNull();
|
|
const stamp = [...container.querySelectorAll("span")].find((el) => el.textContent === "2:34 PM");
|
|
expect(stamp).not.toBeNull();
|
|
const actionNode = container.querySelector('[data-testid="fake-actions"]')!;
|
|
expect(stamp!.compareDocumentPosition(actionNode) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0);
|
|
expect(actionNode.parentElement?.className).toContain("justify-between");
|
|
});
|
|
|
|
it("omits the actions row entirely when none are supplied (human bubble)", () => {
|
|
render({ id: "m1", kind: "message", author: "human", text: "Hi", timestamp: "2:34 PM" });
|
|
expect(container.querySelector('[data-testid="fake-actions"]')).toBeNull();
|
|
const stamp = [...container.querySelectorAll("span")].find((el) => el.textContent === "2:34 PM");
|
|
expect(stamp).not.toBeNull();
|
|
});
|
|
});
|