Files
PaperClipAI/ui/src/components/task-chat/TaskChatBubble.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

493 lines
21 KiB
TypeScript

import { CoordinatorRelativeTime } from "@/components/coordinator/CoordinatorRelativeTime";
import { useAgentChatRead } from "@/hooks/useAgentChatRead";
import { Link } from "@/lib/router";
import { useCoordinatorPresentation } from "@/components/coordinator/CoordinatorPresentation";
import { TextAttachmentContext } from "@/context/TextAttachmentContext";
import { useArtifactsV1Enabled } from "@/hooks/useArtifactsV1Enabled";
import { ArtifactRow, ArtifactRowGroup, inlineRowStyle, subjectFromAttachmentRef } from "@/components/artifacts/v1";
import { isTextAttachment } from "@/lib/issue-attachments";
import { ArtifactPreview } from "@/components/artifacts/ArtifactCard";
import { isVideoLikeOutput } from "@/lib/issue-output";
import { AgentAvatar, type AvatarAgent } from "../AgentAvatar";
import { Identity } from "../Identity";
import { paperclipHumanAvatarUrl } from "@/lib/issue-chat-human-author";
import { useCallback, useContext, useState, type ReactNode } from "react";
import { useEmailComment } from "@/components/EmailMessageCard";
import type { IssueAttachment } from "@paperclipai/shared";
import { IssueGalleryContext } from "@/context/IssueGalleryContext";
import { cn } from "@/lib/utils";
import { agentDetailHref } from "@/pages/agent-detail-navigation";
import { useStreamlinedTaskChatPresentation } from "./presentation-mode";
import { MarkdownBody } from "@/components/MarkdownBody";
import {
ImageGalleryModal,
type GalleryMediaItem,
} from "@/components/ImageGalleryModal";
import {
Attachment,
AttachmentContent,
AttachmentDescription,
AttachmentGroup,
AttachmentMedia,
AttachmentTitle,
AttachmentTrigger,
} from "@/components/ui/attachment";
import {
extractAttachmentRefs,
extractImageRefs,
fileKindForAttachment,
formatFileSize,
hydrateAttachmentRefs,
isImageAttachment,
stripStandaloneImageEmbeds,
type AttachmentRef,
} from "./task-chat-attachments";
import { TaskChatSystemNotice } from "./TaskChatSystemNotice";
import type { TaskChatMessageItem } from "./task-chat-model";
interface TaskChatBubbleProps {
item: TaskChatMessageItem;
/** Requeues a blocked task after a no-live-execution-path recovery notice. */
onTryAgainNoLiveExecutionPath?: () => Promise<void> | void;
tryAgainNoLiveExecutionPathPending?: boolean;
/** Disable the entrance animation when replacing an already-visible live response. */
animateEntry?: boolean;
/** Action shown beside the queued state for an interruptible message. */
queuedAction?: ReactNode;
/**
* The settled run turn rendered on this bubble's footer line (round 9):
* replaces the plain timestamp with "2:34 PM · ✓ Worked · 38s · 3 tools"
* (the timestamp leads the summary), expandable to the nested tool history.
* Supplied by TaskChatThreadView when `item.attachedTurn` is set.
*/
attachedTurn?: ReactNode;
/** New-runner Worked header, rendered above the durable final response. */
beforeTurn?: ReactNode;
/** The durable runner turn already owns the shared agent identity row. */
hideAgentIdentity?: boolean;
/**
* copy · 👍 · 👎 controls for an agent bubble's footer line (PAP-413).
* Rendered here only for a runless reply (leading the bare timestamp); when
* an attached turn is present it owns these via its `leading` slot instead,
* so this bubble skips them. Human/system bubbles pass nothing.
*/
actions?: ReactNode;
attachments?: IssueAttachment[];
}
function initialsForName(name: string) {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}
export function TaskChatAgentIdentity({
agentName,
agentIcon,
agent,
}: {
agentName: string;
agentIcon?: string | null;
agent?: AvatarAgent;
}) {
const identity = (
<>
<span data-testid="task-chat-agent-avatar"><AgentAvatar agent={agent} name={agentName} size={24} /></span>
<span className="text-sm font-semibold text-foreground">{agentName}</span>
</>
);
const className = "flex items-center gap-2 px-1";
return agent?.id ? (
<Link
to={agentDetailHref(agent.id)}
className={cn(className, "no-underline hover:no-underline")}
data-testid="task-chat-agent-identity"
>
{identity}
</Link>
) : (
<span className={className} data-testid="task-chat-agent-identity">
{identity}
</span>
);
}
/**
* Viewer-relative message row — the primary legibility signal. Own messages sit
* right in a solid accent bubble; agents and other humans sit on the left
* directly on the page surface with their own avatar and name;
* system notices are centered and recede.
*/
function galleryItemForMedia(
src: string,
name?: string,
attachment?: ReturnType<typeof hydrateAttachmentRefs>[number],
): GalleryMediaItem {
return {
id: attachment?.id ?? src,
contentPath: attachment?.openPath ?? src,
openPath: attachment?.openPath,
downloadPath: attachment?.downloadPath,
contentType: attachment?.contentType ?? "",
originalFilename: name?.trim() ? name : "image",
};
}
function uniqueAttachmentRefs(refs: AttachmentRef[]): AttachmentRef[] {
return refs.filter(
(ref, index) =>
refs.findIndex(
(candidate) =>
(ref.id && candidate.id === ref.id) || candidate.url === ref.url,
) === index,
);
}
export function TaskChatBubble(props: TaskChatBubbleProps) {
const email = useEmailComment(props.item.id);
return email ?? <TaskChatBubbleContent {...props} />;
}
function TaskChatBubbleContent({
item,
animateEntry = true,
queuedAction,
attachedTurn,
beforeTurn,
hideAgentIdentity = false,
actions,
attachments = [],
onTryAgainNoLiveExecutionPath,
tryAgainNoLiveExecutionPathPending,
}: TaskChatBubbleProps) {
const readRef = useAgentChatRead(item.id, item.createdAtIso, item.author === "agent" && !item.optimistic);
const streamlined = useStreamlinedTaskChatPresentation();
const coordinator = useCoordinatorPresentation();
const recordedTimestamp = item.createdAtIso ?? (typeof item.atMs === "number" && Number.isFinite(item.atMs) ? new Date(item.atMs) : null);
const timestamp = coordinator && recordedTimestamp && !item.optimistic
? <CoordinatorRelativeTime value={recordedTimestamp} /> : item.timestamp;
const { enabled: artifactsV1 } = useArtifactsV1Enabled();
const openText = useContext(TextAttachmentContext);
// Task attachments share the page gallery; standalone images retain the bubble viewer.
const openIssueGallery = useContext(IssueGalleryContext);
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
// Keep MarkdownBody's memo boundary intact when only the live tail changes.
// A fresh callback here reparses every historical response on every update.
const openImage = useCallback((src: string) => {
if (!openIssueGallery?.(src)) setLightboxSrc(src);
}, [openIssueGallery]);
if (item.interstitial) {
// Interstitial updates are ephemeral (PAP-361): while streaming the text
// lives on the live parent row's line (TaskChatStatusItem.selfTalk), and
// once finished it renders nowhere — the run log / classic transcript
// remain the archive. Never rendered as a bubble.
return null;
}
if (item.author === "system") {
// Collapsed humanized one-liner, expandable to the full detail (PAP-443).
return (
<TaskChatSystemNotice
item={item}
onTryAgainNoLiveExecutionPath={onTryAgainNoLiveExecutionPath}
tryAgainNoLiveExecutionPathPending={tryAgainNoLiveExecutionPathPending}
/>
);
}
const isHuman = item.author === "human";
const isOwnMessage = isHuman && item.isCurrentUser === true;
const sentFromIMessage = isHuman && item.sourceChannel === "imessage-photon";
// Non-image file references ("[name](/api/attachments/…/content)") render as
// attachment chips under the bubble; link-only lines leave the body text.
const { refs: linkedRefs, text: bodyWithoutAttachmentLinks } =
extractAttachmentRefs(item.text);
const embeddedImageRefs = extractImageRefs(bodyWithoutAttachmentLinks);
const bodyText = stripStandaloneImageEmbeds(bodyWithoutAttachmentLinks);
const hydratedLinkedRefs = hydrateAttachmentRefs(linkedRefs, attachments);
const hydratedEmbeddedRefs = hydrateAttachmentRefs(
embeddedImageRefs,
attachments,
);
const boundAttachmentRefs: AttachmentRef[] = attachments
.filter((attachment) => attachment.issueCommentId === item.id)
.map((attachment) => ({
id: attachment.id,
name: attachment.originalFilename?.trim() || "attachment",
url: attachment.contentPath,
contentType: attachment.contentType,
byteSize: attachment.byteSize,
openPath: attachment.openPath,
downloadPath: attachment.downloadPath,
}));
const imageRefs = uniqueAttachmentRefs([
...hydratedEmbeddedRefs,
...hydratedLinkedRefs.filter(isImageAttachment),
...boundAttachmentRefs.filter(isImageAttachment),
]);
const attachmentRefs = uniqueAttachmentRefs([
...hydratedLinkedRefs.filter((ref) => !isImageAttachment(ref)),
...boundAttachmentRefs.filter((ref) => !isImageAttachment(ref)),
]);
const mediaRefs = [...imageRefs, ...attachmentRefs.filter((ref) => isVideoLikeOutput(ref.contentType, ref.name))];
const galleryItems: GalleryMediaItem[] =
lightboxSrc !== null && !mediaRefs.some((ref) => ref.url === lightboxSrc)
? // A clicked image the extractor missed (e.g. inline HTML) still gets a
// single-item lightbox rather than nothing.
[galleryItemForMedia(lightboxSrc)]
: mediaRefs.map((ref) => galleryItemForMedia(ref.url, ref.name, ref));
const lightboxIndex =
lightboxSrc === null
? -1
: Math.max(0, mediaRefs.findIndex((ref) => ref.url === lightboxSrc));
return (
<div
data-conversation-message={isHuman ? "human" : "agent"}
ref={readRef}
className={cn(
"flex w-full flex-col gap-1",
animateEntry && "tc-enter-bubble",
isOwnMessage ? "items-end" : "items-start",
)}
>
{beforeTurn ? <div className="w-full pb-1">{beforeTurn}</div> : null}
{item.chatMessage && <Link to={`/issues/${item.chatMessage.sourceTaskIdentifier}`} className="px-1 text-xs text-muted-foreground underline underline-offset-4">
From {item.chatMessage.sourceTaskIdentifier}
</Link>}
{isHuman && !item.isCurrentUser && item.authorName ? (
<span className="px-1 text-sm font-semibold text-foreground" data-testid="task-chat-human-identity" data-author-user-id={item.authorUserId}>
<Identity name={item.authorName} avatarUrl={paperclipHumanAvatarUrl(item.authorAvatarUrl)} size="default" className="gap-2" />
</span>
) : null}
{!isHuman && item.authorName && !hideAgentIdentity ? (
<TaskChatAgentIdentity
agentName={item.authorName}
agentIcon={item.agentIcon}
agent={item.agent}
/>
) : null}
{bodyText.length > 0 ? (
<div
data-conversation-text={isHuman ? (isOwnMessage ? "human" : "progress") : item.channel === "progress" ? "progress" : "final"}
// Stable hook so the TaskChatLab bubble-treatment explorations
// (PAP-501) can scope background/border overrides to the agent
// bubble body without touching the live thread.
data-testid={
isHuman ? "task-chat-human-bubble" : "task-chat-agent-bubble"
}
className={cn(
"break-words py-2 text-sm",
isOwnMessage
? "max-w-(--pct-85) rounded-2xl rounded-br-sm bg-(--liveness-blue) px-3.5 text-white"
: "w-full bg-transparent px-1 text-foreground",
)}
>
<MarkdownBody
// The own-message bubble sits on the solid --liveness-blue accent, so the
// prose body text must follow the bubble's `text-white` rather than
// the default light-mode prose color (which reads as black on blue).
// `paperclip-markdown-on-accent` flips prose tokens to currentColor
// (== inherited white) in both themes; dark mode was already correct
// only because `prose-invert` happened to lighten the text.
className={isOwnMessage ? "paperclip-markdown-on-accent" : undefined}
softBreaks
linkIssueReferences
onImageClick={openImage}
>
{bodyText}
</MarkdownBody>
</div>
) : null}
{!artifactsV1 && imageRefs.length > 0 ? (
<div
className="flex max-w-(--pct-85) flex-col gap-2"
data-testid="task-chat-bubble-media"
>
<span className="text-xs text-muted-foreground">
Images · {imageRefs.length}
</span>
<div className="grid grid-cols-4 gap-2">
{imageRefs
.slice(0, imageRefs.length > 4 ? 3 : 4)
.map((ref, index) => (
<button
key={ref.url}
type="button"
className="group aspect-video min-w-0 overflow-hidden rounded-md bg-muted outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`Open ${ref.name || `image ${index + 1}`}`}
onClick={() => openImage(ref.url)}
>
<img
src={ref.openPath ?? ref.url}
alt={ref.name}
loading="lazy"
className="h-full w-full object-cover transition-transform group-hover:scale-(--s-1_02)"
/>
</button>
))}
{imageRefs.length > 4 ? (
<button
type="button"
className="aspect-video min-w-0 rounded-md bg-muted text-sm font-semibold text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`Open ${imageRefs.length - 3} more screenshots`}
onClick={() => openImage(imageRefs[3].url)}
>
+{imageRefs.length - 3}
</button>
) : null}
</div>
<span className="truncate text-xs text-muted-foreground">
{imageRefs.map((ref) => ref.name || "image").join(" · ")}
</span>
</div>
) : null}
{!artifactsV1 && attachmentRefs.length > 0 ? (
<div className="flex max-w-(--pct-85) flex-col gap-2">
<span className="text-xs text-muted-foreground">
Files · {attachmentRefs.length}
</span>
<AttachmentGroup data-testid="task-chat-bubble-attachments">
{attachmentRefs.map((ref) => {
const kind = fileKindForAttachment(ref);
const KindIcon = kind.icon;
const size = formatFileSize(ref.byteSize);
const video = isVideoLikeOutput(ref.contentType, ref.name);
return (
<Attachment key={ref.url} size="sm">
<AttachmentMedia className={video ? "aspect-video group-data-[size=sm]/attachment:w-20" : undefined}>
{video ? <ArtifactPreview artifact={{ title: ref.name, contentPath: ref.openPath ?? ref.url, mediaKind: "video" }} /> : <KindIcon aria-hidden />}
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle className="max-w-48">
{ref.name}
</AttachmentTitle>
<AttachmentDescription className="max-w-48">
{size ? `${kind.label} · ${size}` : kind.label}
</AttachmentDescription>
</AttachmentContent>
<AttachmentTrigger
aria-label={`Open ${ref.name}`}
onClick={video ? () => openImage(ref.url) : undefined}
render={video ? <button type="button" /> :
<a
href={ref.openPath ?? ref.url}
onClick={(event) => {
if (openText && ref.id && isTextAttachment({ contentType: ref.contentType ?? "", originalFilename: ref.name }) && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey) {
event.preventDefault(); openText(ref.id, ref.name);
}
}}
target="_blank"
rel="noreferrer"
/>
}
/>
</Attachment>
);
})}
</AttachmentGroup>
</div>
) : null}
{!isHuman && item.verificationCaveats?.length ? (
<div
className="mx-1 w-(--sz-calc-7) rounded-md border border-amber-500/25 bg-amber-500/5 px-3 py-2 text-xs"
data-testid="task-chat-verification-caveats"
>
<p className="font-medium text-amber-800 dark:text-amber-200">
Verification caveat
</p>
<ul className="mt-1 space-y-1 text-muted-foreground">
{item.verificationCaveats.map((caveat, index) => (
<li key={`${caveat.commandOrCheck}:${index}`}>
<span className="font-mono text-foreground">
{caveat.commandOrCheck}
</span>
{caveat.reasonCode
? ` · ${caveat.reasonCode.replaceAll("_", " ")}`
: ""}
{caveat.detail ? (
<span className="block">{caveat.detail}</span>
) : null}
</li>
))}
</ul>
</div>
) : null}
{coordinator && recordedTimestamp && attachedTurn ? <span className="px-1 text-(length:--text-micro) text-muted-foreground"><CoordinatorRelativeTime value={recordedTimestamp!} /></span> : null}
{item.optimistic ? (
<span className="flex items-center gap-1 px-1 text-(length:--text-micro) text-muted-foreground">
<span>{item.optimistic === "queued" ? "Queued" : "Sending…"}</span>
{item.optimistic === "queued" ? queuedAction : null}
</span>
) : attachedTurn ? (
// The settled turn takes over the footer line: timestamp + "✓ Worked"
// summary, always visible; expanding stretches beneath the bubble. The
// copy/👍/👎 actions (PAP-413) ride the turn's summary row via its
// `leading` slot — not this wrapper — so they stay anchored to the
// summary line when the tool history expands beneath it.
<div
className="self-stretch"
data-testid="task-chat-bubble-attached-turn"
>
{attachedTurn}
</div>
) : actions ? (
streamlined ? (
<div className="flex w-full items-center justify-between gap-2 px-1">
{timestamp ? (
<span className="text-(length:--text-micro) text-muted-foreground">
{timestamp}
</span>
) : null}
{actions}
</div>
) : (
<div className="flex items-center gap-1">
{actions}
{timestamp ? (
<span className="px-1 text-(length:--text-micro) text-muted-foreground">
{timestamp}
</span>
) : null}
</div>
)
) : timestamp || sentFromIMessage ? (
// Timestamps are always visible (round 9) — no longer hover-revealed.
<span className="px-1 text-(length:--text-micro) text-muted-foreground">
{sentFromIMessage ? "Sent from iMessage" : null}
{sentFromIMessage && timestamp ? " · " : null}
{timestamp}
</span>
) : null}
{artifactsV1 && imageRefs.length + attachmentRefs.length > 0 ? (
// Artifacts V1 (doc/plans/2026-10-22-artifacts-mvp-v1.md): the files a message produced hang under its
// footer, 12px below, as rows inline at equal widths, wrapping past three. Pictures and video open the
// gallery, text opens a text tab when the panel offers one, anything else opens in a new tab.
<div className="w-full pt-2" data-testid="task-chat-bubble-artifacts-v1">
<ArtifactRowGroup>
{[...imageRefs, ...attachmentRefs].map((ref) => {
const subject = subjectFromAttachmentRef(ref);
const picture = isImageAttachment(ref) || isVideoLikeOutput(ref.contentType, ref.name);
const textTab = Boolean(openText && ref.id && isTextAttachment({ contentType: ref.contentType ?? "", originalFilename: ref.name }));
if (picture) return <ArtifactRow key={ref.url} subject={subject} onClick={() => openImage(ref.url)} style={inlineRowStyle} />;
// A text file keeps its link (modifier clicks, copying the address) while a plain click opens the text tab.
if (textTab) return <ArtifactRow key={ref.url} subject={subject} href={ref.openPath ?? ref.url} external onClick={() => openText?.(ref.id!, ref.name)} style={inlineRowStyle} />;
return <ArtifactRow key={ref.url} subject={subject} href={ref.openPath ?? ref.url} external style={inlineRowStyle} />;
})}
</ArtifactRowGroup>
</div>
) : null}
{lightboxSrc !== null && lightboxIndex >= 0 ? (
<ImageGalleryModal
items={galleryItems}
initialIndex={lightboxIndex}
open
onOpenChange={(open) => {
if (!open) setLightboxSrc(null);
}}
/>
) : null}
</div>
);
}