mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +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>
317 lines
9.7 KiB
TypeScript
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} />;
|
|
}
|