mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-19 01:46:15 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agent work often ends with a Markdown or plain-text file. > - Task attachments currently open outside the task panel. > - Users need to inspect those files while keeping the task conversation in view. > - This pull request opens text attachments in task tabs and adds rendered, raw, and download controls. > - The same controls work in the mobile task drawer. ## Linked Issues or Issue Description **What happened?** Opening a text attachment did not put its content in a task tab. Markdown files had no in-task rendered/raw toggle. **Expected behavior** Open Markdown and text attachments in one reusable task tab. Show Markdown as rendered content or raw text. Download the original file. **Steps to reproduce** 1. Upload a Markdown file and a plain-text file to a task comment. 2. Open each attachment from the task conversation or artifact list. 3. Switch Markdown between Rendered and Raw. Download both files. 4. Repeat at a mobile viewport width. Related work: #14193 controls artifact tab arrival. This change adds text attachment content tabs. ## What Changed - Route text attachment opens from conversation and artifact cards into task tabs. - Add a text attachment panel with accessible Rendered, Raw, and Download controls. - Preserve ordinary links for other file types. - Support the selected attachment in the mobile drawer. - Keep text-tab actions on the current rich artifact cards, including CSV previews. - Render attachment image references and diagram source without loading media URLs. - Add browser regression tests, component tests, Storybook examples, and usage documentation. ## Verification - Full workspace typecheck, production build, Storybook build, and UI token gates pass locally. - All 6,960 UI tests pass. The additional media regression passes against the real Markdown renderer and fails before the fix. CSV coverage verifies direct downloads and text tabs after preview. - Both desktop and mobile browser cases pass locally. They check rendered/raw Markdown, literal plain text, reusable tabs, review controls, and exact original download bytes. The local fixture used a separate database port because an existing socket occupied the default range. - The full CI test matrix passes on `82be5efbef26927b237a031725bb3d7fa79f637f`. The duplicate local `pnpm test:run` was stopped after this CI result; it did not complete locally. - Greptile is 5/5 on the final commit. All review threads are resolved, and the security scan passes. - All 54 final-head checks pass, including the canary dry run. The two optional Storybook jobs are skipped. ## Risks - Text attachment links now open in the task panel. Other content types keep their existing link behavior. - File display still depends on the existing authenticated attachment route. There are no API or database changes. - Raw text is displayed as text, including strings that look like HTML. Rendered Markdown keeps media references inert. ## Model Used OpenAI Codex, based on GPT-6, with code execution, browser testing, and subagent tool use. The runtime does not expose an exact serving model variant or context-window size. Recovered earlier implementation changes were reviewed and tested; their exact model metadata is unavailable. ## 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>
436 lines
17 KiB
TypeScript
436 lines
17 KiB
TypeScript
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 { 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> | 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 (
|
|
<span
|
|
className="flex items-center gap-2 px-1"
|
|
data-testid="task-chat-agent-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>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Author-typed message row — the primary legibility signal. Human messages sit
|
|
* right in a solid accent bubble; agent messages sit directly on the page
|
|
* surface with an avatar author header (the agent's assigned icon + 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 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<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 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
|
|
className={cn(
|
|
"flex w-full flex-col gap-1",
|
|
animateEntry && "tc-enter-bubble",
|
|
isHuman ? "items-end" : "items-start",
|
|
)}
|
|
>
|
|
{beforeTurn ? <div className="w-full pb-1">{beforeTurn}</div> : null}
|
|
{!isHuman && item.authorName && !hideAgentIdentity ? (
|
|
<TaskChatAgentIdentity
|
|
agentName={item.authorName}
|
|
agentIcon={item.agentIcon}
|
|
agent={item.agent}
|
|
/>
|
|
) : null}
|
|
{bodyText.length > 0 ? (
|
|
<div
|
|
// 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",
|
|
isHuman
|
|
? "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 human 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={isHuman ? "paperclip-markdown-on-accent" : undefined}
|
|
softBreaks
|
|
linkIssueReferences
|
|
onImageClick={openImage}
|
|
>
|
|
{bodyText}
|
|
</MarkdownBody>
|
|
</div>
|
|
) : null}
|
|
{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}
|
|
{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}
|
|
{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">
|
|
{item.timestamp ? (
|
|
<span className="text-(length:--text-micro) text-muted-foreground">
|
|
{item.timestamp}
|
|
</span>
|
|
) : null}
|
|
{actions}
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-1">
|
|
{actions}
|
|
{item.timestamp ? (
|
|
<span className="px-1 text-(length:--text-micro) text-muted-foreground">
|
|
{item.timestamp}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
)
|
|
) : item.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 && item.timestamp ? " · " : null}
|
|
{item.timestamp}
|
|
</span>
|
|
) : null}
|
|
{lightboxSrc !== null && lightboxIndex >= 0 ? (
|
|
<ImageGalleryModal
|
|
items={galleryItems}
|
|
initialIndex={lightboxIndex}
|
|
open
|
|
onOpenChange={(open) => {
|
|
if (!open) setLightboxSrc(null);
|
|
}}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|