import { TextAttachmentContext } from "@/context/TextAttachmentContext"; 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 { 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; 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; }) { return ( {agentName} ); } /** * 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[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 ?? ; } function TaskChatBubbleContent({ item, animateEntry = true, queuedAction, attachedTurn, beforeTurn, hideAgentIdentity = false, actions, attachments = [], onTryAgainNoLiveExecutionPath, tryAgainNoLiveExecutionPathPending, }: TaskChatBubbleProps) { const streamlined = useStreamlinedTaskChatPresentation(); const openText = useContext(TextAttachmentContext); // Task attachments share the page gallery; standalone images retain the bubble viewer. const openIssueGallery = useContext(IssueGalleryContext); const [lightboxSrc, setLightboxSrc] = useState(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 ( ); } 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 (
{beforeTurn ?
{beforeTurn}
: null} {isHuman && !item.isCurrentUser && item.authorName ? ( ) : null} {!isHuman && item.authorName && !hideAgentIdentity ? ( ) : null} {bodyText.length > 0 ? (
{bodyText}
) : null} {imageRefs.length > 0 ? (
Images · {imageRefs.length}
{imageRefs .slice(0, imageRefs.length > 4 ? 3 : 4) .map((ref, index) => ( ))} {imageRefs.length > 4 ? ( ) : null}
{imageRefs.map((ref) => ref.name || "image").join(" · ")}
) : null} {attachmentRefs.length > 0 ? (
Files · {attachmentRefs.length} {attachmentRefs.map((ref) => { const kind = fileKindForAttachment(ref); const KindIcon = kind.icon; const size = formatFileSize(ref.byteSize); const video = isVideoLikeOutput(ref.contentType, ref.name); return ( {video ? : } {ref.name} {size ? `${kind.label} · ${size}` : kind.label} openImage(ref.url) : undefined} render={video ?
) : null} {!isHuman && item.verificationCaveats?.length ? (

Verification caveat

    {item.verificationCaveats.map((caveat, index) => (
  • {caveat.commandOrCheck} {caveat.reasonCode ? ` · ${caveat.reasonCode.replaceAll("_", " ")}` : ""} {caveat.detail ? ( {caveat.detail} ) : null}
  • ))}
) : null} {item.optimistic ? ( {item.optimistic === "queued" ? "Queued" : "Sending…"} {item.optimistic === "queued" ? queuedAction : null} ) : 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.
{attachedTurn}
) : actions ? ( streamlined ? (
{item.timestamp ? ( {item.timestamp} ) : null} {actions}
) : (
{actions} {item.timestamp ? ( {item.timestamp} ) : null}
) ) : item.timestamp || sentFromIMessage ? ( // Timestamps are always visible (round 9) — no longer hover-revealed. {sentFromIMessage ? "Sent from iMessage" : null} {sentFromIMessage && item.timestamp ? " · " : null} {item.timestamp} ) : null} {lightboxSrc !== null && lightboxIndex >= 0 ? ( { if (!open) setLightboxSrc(null); }} /> ) : null}
); }