mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
## 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>
654 lines
25 KiB
TypeScript
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: `` }} />
|
|
</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();
|
|
});
|
|
});
|