diff --git a/doc/AGENT-ARTIFACTS.md b/doc/AGENT-ARTIFACTS.md index bc2bcd6fc41..157200c97f3 100644 --- a/doc/AGENT-ARTIFACTS.md +++ b/doc/AGENT-ARTIFACTS.md @@ -201,3 +201,19 @@ PAPERCLIP_FILE_DELIVERY_DAYTONA=1 pnpm exec vitest run server/src/__tests__/file The live fixture deletes each sandbox before checking that its attachments remain downloadable from Paperclip. It does not run unless explicitly enabled. + +## Text attachment previews + +In the task chat layout, select a text attachment in Artifacts, a work-product +card, or a chat attachment chip to open a named right-side tab. Reopening the +same attachment focuses its existing tab. Tabs can be switched and closed. +On mobile, the same viewer opens in the task details drawer. + +Markdown attachments offer **Rendered** and **Raw** views. Other supported text +files display literal text. The viewer provides a download action. Preview reads +are limited to 512 KiB; oversized, unsupported, or unavailable files show an +explicit fallback instead of attempting an unbounded render. A failed read can +be retried. Workspace files continue to use the existing workspace file viewer. + +Storybook: **Tasks / Text file tabs** covers opening from Artifacts, Markdown, +plain text, empty files, oversized files, missing attachments, and a narrow panel. diff --git a/tests/e2e/text-attachment-tabs.spec.ts b/tests/e2e/text-attachment-tabs.spec.ts new file mode 100644 index 00000000000..cd56c2cddfe --- /dev/null +++ b/tests/e2e/text-attachment-tabs.spec.ts @@ -0,0 +1,54 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import { expect, test, type APIRequestContext } from "@playwright/test"; + +async function json(response: Awaited>) { + expect(response.ok(), `${response.status()}: ${await response.text()}`).toBe(true); + return response.json(); +} + +for (const mobile of [false, true]) { + test(`opens uploaded text in task tabs and downloads the original (mobile=${mobile})`, async ({ page, request }, testInfo) => { + await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }); + const company = await json(await request.post("/api/companies", { data: { name: `Text files ${randomUUID()}` } })); + const issue = await json(await request.post(`/api/companies/${company.id}/issues`, { + data: { title: "Review uploaded text files", status: "backlog" }, + })); + const files = [ + { name: "AGENTS.md", mimeType: "text/markdown", buffer: Buffer.from("# File charter\n\nKeep **original** bytes.\n") }, + { name: "notes.txt", mimeType: "text/plain", buffer: Buffer.from("Plain text \n") }, + ]; + const attachments = []; + for (const file of files) attachments.push(await json(await request.post(`/api/companies/${company.id}/issues/${issue.id}/attachments`, { multipart: { file } }))); + await json(await request.post(`/api/issues/${issue.id}/comments`, { + data: { body: "Review the attached files.", attachmentIds: attachments.map((attachment) => attachment.id) }, + })); + await page.goto(`/${company.issuePrefix}/issues/${issue.identifier}`); + await page.getByRole("link", { name: "Open AGENTS.md", exact: true }).click(); + const panel = mobile ? page.getByTestId("mobile-task-side-panel") : page.locator("aside").filter({ has: page.getByRole("tab", { name: "AGENTS.md", exact: true }) }); + await expect(panel.getByRole("tab", { name: "AGENTS.md", exact: true })).toHaveAttribute("aria-selected", "true"); + await expect(panel.getByRole("heading", { name: "File charter", exact: true })).toBeVisible(); + const raw = panel.getByRole("button", { name: "Raw", exact: true }); + await raw.click(); + await expect(raw).toHaveAttribute("aria-pressed", "true"); + await expect(panel.getByLabel("AGENTS.md raw text")).toContainText("# File charter"); + await panel.getByRole("button", { name: "Rendered", exact: true }).click(); + await expect(panel.getByRole("heading", { name: "File charter", exact: true })).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath("markdown-rendered.png"), fullPage: true }); + const markdownDownload = page.waitForEvent("download"); + await panel.getByRole("link", { name: "Download AGENTS.md", exact: true }).click(); + expect(await fs.readFile((await (await markdownDownload).path())!)).toEqual(files[0]!.buffer); + if (mobile) await panel.getByRole("button", { name: "Toggle side panel", exact: true }).click(); + await page.getByRole("link", { name: "Open notes.txt", exact: true }).click(); + await expect(panel.getByRole("tab", { name: "notes.txt", exact: true })).toHaveAttribute("aria-selected", "true"); + await expect(panel.getByLabel("notes.txt raw text")).toContainText(""); + await expect(panel.getByRole("group", { name: "Markdown view", exact: true })).toHaveCount(0); + const textDownload = page.waitForEvent("download"); + await panel.getByRole("link", { name: "Download notes.txt", exact: true }).click(); + expect(await fs.readFile((await (await textDownload).path())!)).toEqual(files[1]!.buffer); + await page.screenshot({ path: testInfo.outputPath("plain-text-download.png"), fullPage: true }); + await panel.getByRole("tab", { name: "AGENTS.md", exact: true }).click(); + await expect(panel.getByRole("heading", { name: "File charter", exact: true })).toBeVisible(); + await expect(panel.getByRole("tab", { name: "AGENTS.md", exact: true })).toHaveCount(1); + }); +} diff --git a/ui/src/components/IssueAttachmentsSection.tsx b/ui/src/components/IssueAttachmentsSection.tsx index 0852c0c4f22..5e1a590b27f 100644 --- a/ui/src/components/IssueAttachmentsSection.tsx +++ b/ui/src/components/IssueAttachmentsSection.tsx @@ -1,4 +1,6 @@ -import { useMemo, useState, type DragEvent, type ReactNode } from "react"; +import { TextAttachmentContext } from "@/context/TextAttachmentContext"; +import { isTextAttachment } from "@/lib/issue-attachments"; +import { useContext, useMemo, useState, type DragEvent, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import type { IssueAttachment } from "@paperclipai/shared"; import { Download, ExternalLink, FileText, Maximize2, Paperclip, Trash2 } from "lucide-react"; @@ -55,6 +57,10 @@ function AttachmentActions({ deletePending?: boolean; onPreview?: (attachment: IssueAttachment) => void; }) { + const openText = useContext(TextAttachmentContext); + const openInPanel = (event: React.MouseEvent) => { + if (openText && isTextAttachment(attachment) && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey) { event.preventDefault(); openText(attachment.id, attachmentFilename(attachment)); } + }; const filename = attachmentFilename(attachment); return (
@@ -70,7 +76,7 @@ function AttachmentActions({ ) : null} @@ -112,6 +118,10 @@ function MarkdownAttachmentCard({ onDelete?: (attachmentId: string) => void; deletePending?: boolean; }) { + const openText = useContext(TextAttachmentContext); + const openInPanel = (event: React.MouseEvent) => { + if (openText && isTextAttachment(attachment) && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey) { event.preventDefault(); openText(attachment.id, attachmentFilename(attachment)); } + }; const filename = attachmentFilename(attachment); const { data, isLoading, error } = useQuery({ queryKey: queryKeys.issues.attachmentPreview(attachment.id), @@ -124,7 +134,7 @@ function MarkdownAttachmentCard({
- {filename} + {filename}
@@ -187,13 +197,17 @@ function GenericAttachmentRow({ onDelete?: (attachmentId: string) => void; deletePending?: boolean; }) { + const openText = useContext(TextAttachmentContext); + const openInPanel = (event: React.MouseEvent) => { + if (openText && isTextAttachment(attachment) && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey) { event.preventDefault(); openText(attachment.id, attachmentFilename(attachment)); } + }; const filename = attachmentFilename(attachment); return (
issuesApi.listAttachments(issue.id), @@ -460,6 +464,14 @@ export function IssuePropertiesArtifactsTab({ issue, documentDeepLink, onOpenDoc {group.rows.map((row) => { if (row.kind === "work_product") { const wp = row.value; + const textMetadata = getAttachmentArtifactWorkProductMetadata(wp); + if (openTextAttachment && textMetadata && isTextAttachment(textMetadata)) { + return
  • + +
  • ; + } const markdownMetadata = getMarkdownWorkProductAttachmentMetadata(wp); if (markdownMetadata) { const reviewKey = artifactReviewDocumentKey(wp.id); @@ -517,7 +529,7 @@ export function IssuePropertiesArtifactsTab({ issue, documentDeepLink, onOpenDoc } return (
  • - + { if (openTextAttachment && isTextAttachment(attachment) && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey) { event.preventDefault(); openTextAttachment(attachment.id, filename); } }} target="_blank" rel="noreferrer" className={cn(ROW_CLASS, "hover:bg-accent/50")}> {attachment.originalFilename ?? attachment.objectKey} {formatBytes(attachment.byteSize)} diff --git a/ui/src/components/task-chat/RichWorkProductCard.tsx b/ui/src/components/task-chat/RichWorkProductCard.tsx index a86f6db44d3..ae053089389 100644 --- a/ui/src/components/task-chat/RichWorkProductCard.tsx +++ b/ui/src/components/task-chat/RichWorkProductCard.tsx @@ -1,3 +1,6 @@ +import { TextAttachmentContext } from "@/context/TextAttachmentContext"; +import { isTextAttachment } from "@/lib/issue-attachments"; +import { getAttachmentArtifactWorkProductMetadata } from "@paperclipai/shared"; import { useContext, useState, type CSSProperties } from "react"; import { IssueGalleryContext } from "@/context/IssueGalleryContext"; import { ArtifactPreview } from "@/components/artifacts/ArtifactCard"; @@ -185,6 +188,8 @@ export function RichWorkProductCard({ workProduct, href, variant = "card" }: Ric break; } + const openText = useContext(TextAttachmentContext); + const textMetadata = getAttachmentArtifactWorkProductMetadata(workProduct); const additions = numberMeta(metadata, "additions"); const deletions = numberMeta(metadata, "deletions"); const files = numberMeta(metadata, "files", "changedFiles"); @@ -277,7 +282,7 @@ export function RichWorkProductCard({ workProduct, href, variant = "card" }: Ric {compact ? null : {action}} ) : actionHref ? ( - + { if (openText && textMetadata && isTextAttachment(textMetadata) && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey) { event.preventDefault(); openText(textMetadata.attachmentId, textMetadata.originalFilename ?? workProduct.title); } }} aria-label={`${action}: ${workProduct.title}`} className="inline-flex items-center gap-1 text-xs font-medium text-foreground after:absolute after:inset-0 after:rounded-md focus-visible:outline-none focus-visible:after:ring-2 focus-visible:after:ring-ring" target={actionHref.startsWith("http") ? "_blank" : undefined} rel={actionHref.startsWith("http") ? "noreferrer" : undefined}> {compact ? null : {action}} ) : null} diff --git a/ui/src/components/task-chat/TaskChatBubble.tsx b/ui/src/components/task-chat/TaskChatBubble.tsx index 649ee205b46..7733c04e660 100644 --- a/ui/src/components/task-chat/TaskChatBubble.tsx +++ b/ui/src/components/task-chat/TaskChatBubble.tsx @@ -1,3 +1,5 @@ +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"; @@ -140,6 +142,7 @@ function TaskChatBubbleContent({ 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); @@ -331,6 +334,11 @@ function TaskChatBubbleContent({ render={video ?