mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage agents at work. > - Task threads show a time below each message. > - Own messages align with the right edge. > - The copy control added in #19442 sits to the right of the time. > - This shifts the time inward even when the control is hidden. > - This pull request moves the control to the left of the time. > - The time stays at the outside edge. ## Linked Issues or Issue Description Refs #19442. **What happened?** The copy control occupies space to the right of the time on own messages. **Expected behavior** The copy control sits to the left of the time. The time stays at the right edge. **Steps to reproduce** Open a task with your own sent message. Move the pointer over its footer. ## What Changed - Move TaskChatBubbleActions before the time in the own-message footer. - Keep the layout change inside TaskChatBubble. No shared contract change is needed. ## Verification - All 36 TaskChatBubble tests pass. - Design-token gates pass. - UI typecheck and production build pass. - Browser layout checks pass on desktop and touch: copy is left of the time, the time ends at the bubble edge, and the time does not move on hover. - Clipboard contents and copied feedback pass. - Screenshots cover idle, hover, copied feedback, and touch using the production component in the existing Storybook fixture. - Current-head CI passed: build, typecheck, selected general tests, all eight e2e shards, policy, security, and aggregate verification. - Greptile and Paperclip reviews score the current commit 5/5. No open findings or review threads remain. - The first Greptile review requested an order assertion. The existing copy test now checks that the control precedes the time and the time is the last footer element. All 36 component tests pass. The review thread is resolved. - Initial test and Storybook commands used the wrong working directory or argument form. Corrected commands run successfully. - Local typecheck and Storybook initially could not resolve mdast-util-from-markdown. A frozen dependency install restored the missing package. Typecheck, build, and browser checks then passed. An early browser retry reached the server before it was ready; the ready-server run passed. ## Risks - Low risk. This change only moves the existing control before the time. ## Model Used - OpenAI GPT-6 Codex. Exact deployed model ID and context window are not exposed in this runtime. The agent used reasoning, tools, and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with Fixes / Closes / Refs OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket id or instance details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable (existing copy tests cover unchanged behavior; browser checks cover position) - [x] I have updated relevant documentation to reflect my changes (the behavior is described here) - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
690 lines
27 KiB
TypeScript
690 lines
27 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { act, 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 { MemoryRouter, useLocation } from "@/lib/router";
|
|
import { TaskChatAgentIdentity, TaskChatBubble } from "./TaskChatBubble";
|
|
import { copyTextToClipboard } from "@/lib/clipboard";
|
|
import { paperclipHumanAvatarUrl } from "@/lib/issue-chat-human-author";
|
|
import type { TaskChatMessageItem } from "./task-chat-model";
|
|
|
|
vi.mock("@/context/CompanyContext", async (importOriginal) => ({
|
|
...await importOriginal<typeof import("@/context/CompanyContext")>(),
|
|
useCompany: () => ({ selectedCompany: { issuePrefix: "TES" } }),
|
|
}));
|
|
|
|
vi.mock("@/lib/clipboard", () => ({ copyTextToClipboard: vi.fn().mockResolvedValue(undefined) }));
|
|
|
|
function CurrentPath() {
|
|
return <output>{useLocation().pathname}</output>;
|
|
}
|
|
|
|
describe("chat author navigation", () => {
|
|
it.each(["name", "avatar"])("opens the responding agent's company-scoped overview from its %s", async (target) => {
|
|
const container = document.body.appendChild(document.createElement("div"));
|
|
const root = createRoot(container);
|
|
try {
|
|
flushSync(() => root.render(
|
|
<MemoryRouter initialEntries={["/TES/chats/conversation-agent"]}>
|
|
<TaskChatAgentIdentity agentName="Responding agent" agent={{ id: "responding-agent" }} />
|
|
<CurrentPath />
|
|
</MemoryRouter>,
|
|
));
|
|
const identity = container.querySelector('[data-testid="task-chat-agent-identity"]')!;
|
|
const clickTarget = target === "avatar"
|
|
? identity.querySelector('[data-testid="task-chat-agent-avatar"]')!
|
|
: [...identity.querySelectorAll("span")].find(node => node.textContent === "Responding agent")!;
|
|
await act(async () => {
|
|
clickTarget.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }));
|
|
});
|
|
expect(container.querySelector("output")?.textContent).toBe("/TES/agents/responding-agent/overview");
|
|
} finally {
|
|
flushSync(() => root.unmount());
|
|
container.remove();
|
|
}
|
|
});
|
|
|
|
it("keeps an identity without an agent ID readable and unlinked", () => {
|
|
const container = document.body.appendChild(document.createElement("div"));
|
|
const root = createRoot(container);
|
|
try {
|
|
flushSync(() => root.render(<TaskChatAgentIdentity agentName="Unknown agent" agent={{ name: "Unknown agent" }} />));
|
|
expect(container.textContent).toBe("Unknown agent");
|
|
expect(container.querySelector("a")).toBeNull();
|
|
} finally {
|
|
flushSync(() => root.unmount());
|
|
container.remove();
|
|
}
|
|
});
|
|
});
|
|
|
|
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("copies the viewer's original Markdown beside the timestamp, including attachment links", async () => {
|
|
const text = "**My message**\n\n[report.txt](/api/attachments/report/content)";
|
|
flushSync(() => root!.render(
|
|
<ThemeProvider>
|
|
<TaskChatBubble item={{ id: "own", kind: "message", author: "human", isCurrentUser: true, text, timestamp: "15m ago" }} />
|
|
</ThemeProvider>,
|
|
));
|
|
const toolbar = container.querySelector('[data-testid="task-chat-bubble-actions"]')!;
|
|
const copyWrapper = toolbar.parentElement!;
|
|
const timestamp = copyWrapper.nextElementSibling;
|
|
expect(timestamp?.textContent).toBe("15m ago");
|
|
expect(timestamp).toBe(copyWrapper.parentElement?.lastElementChild);
|
|
const button = toolbar.querySelector('button[aria-label="Copy message"]') as HTMLButtonElement;
|
|
await act(async () => button.click());
|
|
expect(copyTextToClipboard).toHaveBeenLastCalledWith(text);
|
|
expect(button.querySelector(".lucide-check")).not.toBeNull();
|
|
});
|
|
|
|
it.each([
|
|
{ author: "human" as const, isCurrentUser: false },
|
|
{ author: "human" as const, isCurrentUser: undefined },
|
|
{ author: "agent" as const, isCurrentUser: true },
|
|
{ author: "human" as const, isCurrentUser: true, optimistic: "pending" as const },
|
|
{ author: "human" as const, isCurrentUser: true, optimistic: "queued" as const },
|
|
])("omits the own-message toolbar for %j", (state) => {
|
|
flushSync(() => root!.render(
|
|
<ThemeProvider>
|
|
<TaskChatBubble item={{ id: "other", kind: "message", text: "Message", timestamp: "15m ago", ...state }} />
|
|
</ThemeProvider>,
|
|
));
|
|
expect(container.querySelector('[data-testid="task-chat-bubble-actions"]')).toBeNull();
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|