Files
PaperClipAI/ui/src/components/task-chat/TaskChatBubble.test.tsx
T
DottaandPaperclip 3052686b1e fix(ui): hide task chat attribution badge (#13251)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The task chat shows messages from agents and users.
> - An agent message can contain an internal on-behalf-of user value.
> - The new task view showed this value as a badge next to the agent
name.
> - This badge added unwanted identity text to the task chat.
> - This pull request removes the badge from the new task view.
> - The benefit is a simpler agent identity row in the task chat.

## Linked Issues or Issue Description

**What happened?**

The new task view shows a `for <user>` badge next to an agent name when
a message has an on-behalf-of user value.

**Expected behavior**

The task chat shows the agent name without the on-behalf-of badge.

**Steps to reproduce**

1. Open the new task view.
2. Show an agent message that has an on-behalf-of user value.
3. See the attribution badge next to the agent name.

**Paperclip version or commit**

The issue occurs on `master` at commit `d0b7ba419`.

**Deployment mode**

Local dev and all other modes that use the web UI.

## What Changed

- Removed the attribution chip from the task chat agent identity.
- Added a regression test that supplies an on-behalf-of value and
confirms that the badge is absent.

## Verification

- `pnpm --filter @paperclipai/ui exec vitest run
src/components/task-chat/TaskChatBubble.test.tsx` passed with 21 tests.
- `pnpm check:token-gates` passed.
- `pnpm --filter @paperclipai/ui typecheck` passed.

## Risks

- Low risk. The change only removes one badge from the new task chat
view.
- The message data remains unchanged.

> This is a small UI fix. It does not add roadmap scope.

## Model Used

- OpenAI Codex, GPT-5.4, with reasoning and tool use. The provider did
not expose the context window size.

## 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 (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g.
`docs/no-internal-issue-references`, `fix/sandbox-secret-resolution`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-14 09:58:44 -05:00

537 lines
19 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 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("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: `![Proof](${contentPath})` }} />
</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("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 human bubble's markdown as on-accent so prose text follows text-white", () => {
render({ id: "m1", kind: "message", author: "human", 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();
});
});