Files
PaperClipAI/ui/src/components/task-chat/TaskChatBubble.test.tsx
T
DottaandPaperclip 912cff9392 fix(ui): link chat message identities to agent overviews (#18615)
## Thinking Path

> - Paperclip lets people manage and converse with agents.
> - Chat replies identify their author with an avatar and name.
> - Those identities currently offer no path to the responding agent's
profile.
> - The existing overview route already provides that context within the
current company.
> - This PR links the shared chat identity to that overview while
preserving its layout and typography.
> - People can inspect the responding agent by clicking either its name
or avatar.

## Linked Issues or Issue Description

Related: #17169 links the conversation's page header. This change covers
the author identity above messages and active turns.

**What existing behavior does this improve?**
Navigation from chat message authors to their agent overview.

**Subsystem affected**
ui/ — shared task and standing-chat identity component.

**Current behavior**
An agent's name and avatar above its message are plain text and an
image.

**Proposed behavior**
The name and avatar form one company-scoped link to the actual author's
overview. Both retain their appearance, including no underline on hover.
An identity without an agent ID remains plain text.

**Reason and benefit**
Readers can inspect the responding agent directly from the conversation.

**Breaking changes**
None; this adds navigation to an existing read surface.

## What Changed

- Link the shared agent identity to its existing overview route when an
ID is available.
- Preserve the existing avatar, text, spacing, and non-underlined
appearance.
- Document this entry point and the desktop/mobile verification recipe.

## Verification

- `pnpm -r typecheck` — passed.
- `pnpm build` — passed.
- Existing bubble, runner-turn, turn, task-thread, and AgentChat tests —
461 passed.
- `pnpm check:token-gates`, `node scripts/check-maps.mjs`, and `git diff
--check` — passed.
- Chromium production `TaskChatThread` in `Layout` (mixed-author
Storybook fixture): dark and light desktop identity screenshots are
pixel-identical before/after and on hover; no underline, and the href
points to the responding author in the same company. The preview is UI
evidence, not backend/provider acceptance. The separate standing-chat
preview had stale history/email fixtures, and the optional mobile
preview placed the identity behind its composer; neither is claimed as
acceptance evidence.
- Added router integration tests click both name and avatar from a
different agent's chat and assert the author's company-scoped overview;
unknown IDs remain unlinked. All 30 bubble/identity tests pass.
- Full UI project: 10,284 tests passed, one 5-second timeout in the
unchanged design-sandbox snapshot test. Its isolated rerun passed all 12
tests; the initial timeout remains recorded, and its cause was not
established.
- Final UI typecheck passed after adding the tests.
- `pnpm test:run` stopped before running tests: its unchanged server
chat shard filters selected no tests. The full UI result and isolated
rerun are recorded above. This is not a claim that the full local
repository suite passed.

- [Final-head CI
run](https://github.com/paperclipai/paperclip/actions/runs/41475021544)
passed, including build, typecheck, unit/integration suites, all browser
shards, and both aggregate gates. Greptile is 5/5 with its review thread
resolved. The separate Paperclip Review check is still queued, waiting
for its assigned agent; no assessment has been published yet.

## Risks

Low risk: navigation uses the existing company-aware router and explicit
author ID. Unknown identities stay unlinked. The shared identity is used
by standing chats, task replies, and runner headers.

## Model Used

OpenAI GPT-6, running as Codex with reasoning, code editing, and tool
execution. The exact served model ID and context-window size are not
exposed in this session.

## 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/...`, `fix/...`)
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
- [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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-02 10:41:00 -06:00

654 lines
25 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 { 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" } }),
}));
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("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("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();
});
});