Files
PaperClipAI/ui/src/components/artifacts/IssueArtifactCard.tsx
T
DottaandPaperclip e912f0df53 fix(ui): open text attachments in task tabs (#14297)
## 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>
2026-09-29 16:40:10 -05:00

317 lines
9.7 KiB
TypeScript

import { useContext, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
getAttachmentArtifactWorkProductMetadata,
type IssueWorkProduct,
} from "@paperclipai/shared";
import { IssueGalleryContext } from "@/context/IssueGalleryContext";
import { TextAttachmentContext } from "@/context/TextAttachmentContext";
import { ImageGalleryModal } from "@/components/ImageGalleryModal";
import {
RichWorkProductCard,
stateChipFor,
} from "@/components/task-chat/RichWorkProductCard";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { isImageLikeOutput, isVideoLikeOutput } from "@/lib/issue-output";
import { attachmentDownloadPath, isTextAttachment } from "@/lib/issue-attachments";
import { workProductHref } from "@/lib/issue-artifacts";
import { formatDateTime } from "@/lib/utils";
import {
artifactText as text,
artifactNumber as number,
artifactUrl,
artifactPreviewUrl,
artifactFileSize,
CSV_PREVIEW_MAX_BYTES,
loadArtifactCsv,
} from "@/lib/artifact-card-data";
import {
CommitCard,
DataCard,
FileCard,
ImageCard,
LinkPreviewCard,
PullRequestCard,
VideoCard,
type ArtifactIdentity,
} from "./RichArtifactCards";
export interface IssueArtifactFileProps extends ArtifactIdentity {
id: string;
attachmentId?: string;
filename: string;
contentType: string;
contentPath: string;
downloadPath: string;
openPath?: string;
byteSize: number | null;
metadata?: Record<string, unknown> | null;
}
/** Shared by uploads and promoted uploads; both use the issue's existing gallery. */
export function IssueArtifactFile(props: IssueArtifactFileProps) {
const { metadata = null, attachmentId } = props;
const openGallery = useContext(IssueGalleryContext);
const openTextAttachment = useContext(TextAttachmentContext);
const openTextAction = openTextAttachment && attachmentId && isTextAttachment({
contentType: props.contentType,
originalFilename: props.filename,
}) ? (
<Button
variant="outline"
size="sm"
aria-label={`Open in tab: ${props.title}`}
onClick={() => openTextAttachment(attachmentId, props.filename)}
>
Open in tab
</Button>
) : null;
const [galleryOpen, setGalleryOpen] = useState(false);
const [csvRequested, setCsvRequested] = useState(false);
const image = isImageLikeOutput(props.contentType, props.filename);
const video = isVideoLikeOutput(props.contentType, props.filename);
const csv =
props.contentType.split(";")[0] === "text/csv" ||
/\.csv$/i.test(props.filename);
const tooLarge =
props.byteSize !== null && props.byteSize > CSV_PREVIEW_MAX_BYTES;
const localCsv =
csv &&
/^\/api\/attachments\/[a-zA-Z0-9-]+\/content$/.test(props.contentPath);
const data = useQuery({
queryKey: ["artifact-csv", props.id, props.contentPath, props.updatedAt],
queryFn: ({ signal }) => loadArtifactCsv(props.contentPath, signal),
enabled: localCsv && !tooLarge && csvRequested,
retry: false,
staleTime: Infinity,
});
const contentPath = artifactUrl(props.contentPath);
const downloadPath = artifactUrl(props.downloadPath);
const onOpen = () => {
if (!openGallery?.(contentPath)) setGalleryOpen(true);
};
if ((image || video) && contentPath) {
return (
<>
{video ? (
<VideoCard
{...props}
videoUrl={contentPath}
posterUrl={artifactPreviewUrl(text(metadata, "posterUrl"))}
duration={text(metadata, "durationLabel")}
onOpen={onOpen}
/>
) : (
<ImageCard
{...props}
imageUrl={contentPath}
alt={text(metadata, "alt") || props.title}
width={number(metadata, "width")}
height={number(metadata, "height")}
onOpen={onOpen}
/>
)}
{galleryOpen && (
<ImageGalleryModal
items={[
{
id: props.id,
contentPath,
contentType: props.contentType,
originalFilename: props.filename,
downloadPath,
},
]}
initialIndex={0}
open
onOpenChange={setGalleryOpen}
/>
)}
</>
);
}
if (localCsv && !tooLarge && data.data) {
return (
<DataCard
{...props}
{...data.data}
downloadUrl={downloadPath}
actions={
<>
{openTextAction}
{downloadPath ? (
<Button asChild size="sm" variant="outline">
<a href={downloadPath} download={props.filename}>
Download file
</a>
</Button>
) : null}
</>
}
/>
);
}
return (
<div className="flex flex-col gap-2">
<FileCard
{...props}
fileSize={artifactFileSize(props.byteSize)}
entries={[]}
downloadUrl={downloadPath}
openUrl={artifactUrl(props.openPath) || contentPath}
actions={
<>
{openTextAction}
{localCsv && !tooLarge && !data.isError ? (
<Button
variant="outline"
size="sm"
disabled={data.isFetching}
onClick={() => setCsvRequested(true)}
>
{data.isFetching ? "Loading preview…" : "Preview data"}
</Button>
) : null}
</>
}
/>
{csv && (tooLarge || data.isError || !localCsv) && (
<div className="px-2 text-xs text-muted-foreground" role="status">
{tooLarge
? "CSV is too large to preview. Download the file to view it."
: data.isError
? data.error.message
: "CSV preview is unavailable. Download the file to view it."}
{data.isError && (
<Button
variant="ghost"
size="sm"
onClick={() => void data.refetch()}
>
Retry preview
</Button>
)}
</div>
)}
</div>
);
}
export function IssueWorkProductArtifactCard({
workProduct: wp,
author,
}: {
workProduct: IssueWorkProduct;
author: string;
}) {
const m = wp.metadata;
const href = artifactUrl(workProductHref(wp));
const chip = stateChipFor(wp.type, wp.status, wp.reviewState);
const identity: ArtifactIdentity = {
title: wp.title,
summary: wp.summary ?? "",
author,
updatedAt: formatDateTime(wp.updatedAt),
statusBadge:
wp.healthStatus === "unhealthy" ? (
<Badge variant="destructive">Unhealthy</Badge>
) : chip ? (
<Badge variant="outline">{chip.label}</Badge>
) : undefined,
};
const diff = {
additions: number(m, "additions"),
deletions: number(m, "deletions"),
filesChanged: number(m, "changedFiles", "files"),
};
if (wp.type === "pull_request") {
const state =
text(m, "state") || (wp.status === "active" ? "open" : wp.status);
const checks = text(m, "checks");
return (
<PullRequestCard
{...identity}
{...diff}
number={number(m, "number", "pullRequestNumber")}
repository={text(m, "repo", "repository", "repositoryName")}
sourceBranch={text(m, "headRef", "head", "headBranch", "branch")}
targetBranch={text(m, "baseRef", "base", "baseBranch")}
state={
state === "open" ||
state === "draft" ||
state === "merged" ||
state === "closed"
? state
: "unknown"
}
checks={
checks === "passed" || checks === "failed" || checks === "pending"
? checks
: "unknown"
}
evidenceSource={text(m, "evidenceSource")}
reviewSummary={text(m, "reviewSummary")}
url={href}
statusBadge={
wp.reviewState === "changes_requested" ||
wp.reviewState === "needs_board_review" ||
wp.status === "ready_for_review" ||
wp.status === "changes_requested" ||
wp.status === "failed" ||
wp.healthStatus === "unhealthy"
? identity.statusBadge
: undefined
}
/>
);
}
if (wp.type === "commit")
return (
<CommitCard
{...identity}
{...diff}
sha={text(m, "sha", "shortSha") || wp.externalId || ""}
repository={text(m, "repo", "repository", "repositoryName")}
branch={text(m, "branch", "branchName")}
url={href}
/>
);
if (wp.type === "preview_url")
return (
<LinkPreviewCard
{...identity}
url={href}
imageUrl={artifactPreviewUrl(text(m, "imageUrl"))}
imageAlt={text(m, "imageAlt") || wp.title}
/>
);
if (wp.type === "artifact") {
const attachment = getAttachmentArtifactWorkProductMetadata(wp);
const contentPath = artifactUrl(
attachment?.contentPath || text(m, "contentPath") || href,
);
return (
<IssueArtifactFile
{...identity}
id={wp.id}
attachmentId={attachment?.attachmentId}
filename={text(m, "originalFilename") || wp.title}
contentType={text(m, "contentType")}
contentPath={contentPath}
openPath={artifactUrl(text(m, "openPath")) || href || contentPath}
downloadPath={
artifactUrl(text(m, "downloadPath")) ||
(artifactPreviewUrl(contentPath)
? attachmentDownloadPath({ contentPath })
: href || contentPath)
}
byteSize={number(m, "byteSize", "size")}
metadata={m}
/>
);
}
// Branches, external documents, and runtime services keep their existing actions and health semantics.
return <RichWorkProductCard workProduct={wp} href={href || null} />;
}