diff --git a/doc/AGENT-ARTIFACTS.md b/doc/AGENT-ARTIFACTS.md index bc2bcd6fc4..6a451b5f3f 100644 --- a/doc/AGENT-ARTIFACTS.md +++ b/doc/AGENT-ARTIFACTS.md @@ -201,3 +201,27 @@ 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 work products keep their expandable review document, annotations, +revision indicator, and document links. **Open in tab** is a separate action. +Work-product cards keep **Download** as a direct original-file download. +Text file cards provide **Open in tab** beside their existing actions. CSV cards +keep this action before and after loading their data preview. + +Markdown attachments offer **Rendered** and **Raw** views. Other supported text +files display literal text. Image references and diagram source remain inert; +opening a preview does not load attachment-selected media URLs. 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 0000000000..bf10aeaffe --- /dev/null +++ b/tests/e2e/text-attachment-tabs.spec.ts @@ -0,0 +1,85 @@ +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) }, + })); + for (const [index, attachment] of attachments.entries()) { + await json(await request.post(`/api/issues/${issue.id}/work-products`, { + data: { + type: "artifact", provider: "paperclip", title: `Delivered ${files[index]!.name}`, status: "ready_for_review", + metadata: { + attachmentId: attachment.id, contentType: attachment.contentType, byteSize: attachment.byteSize, + originalFilename: files[index]!.name, contentPath: attachment.contentPath, + openPath: attachment.contentPath, downloadPath: `${attachment.contentPath}?download=1`, + }, + }, + })); + } + 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, level: 1 })).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, level: 1 })).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, level: 1 })).toBeVisible(); + await expect(panel.getByRole("tab", { name: "AGENTS.md", exact: true })).toHaveCount(1); + await panel.getByRole("button", { name: "Open a new tab", exact: true }).click(); + await page.getByRole("option", { name: /^Artifacts(?: Already open)?$/ }).click(); + const markdownCard = panel.getByRole("article").filter({ has: page.getByRole("heading", { name: "Delivered AGENTS.md", exact: true }) }); + const review = markdownCard.getByRole("button", { name: /^(Read|Close) document$/ }); + await review.click(); + await expect(review).toHaveAttribute("aria-expanded", "true"); + await expect(panel.getByRole("heading", { name: "File charter", exact: true, level: 1 })).toBeVisible(); + await expect(markdownCard).toContainText("revision 1"); + await expect(panel.getByRole("link", { name: "Download Delivered AGENTS.md", exact: true })).toBeVisible(); + const cardDownload = page.waitForEvent("download"); + const textCard = panel.getByRole("article").filter({ has: page.getByRole("heading", { name: "Delivered notes.txt", exact: true }) }); + await textCard.getByRole("link", { name: "Download file", exact: true }).click(); + expect(await fs.readFile((await (await cardDownload).path())!)).toEqual(files[1]!.buffer); + await expect(panel.getByRole("tab", { name: "Artifacts", exact: true })).toHaveAttribute("aria-selected", "true"); + await panel.getByRole("button", { name: "Open in tab: Delivered notes.txt", exact: true }).click(); + await expect(panel.getByRole("tab", { name: "notes.txt", exact: true })).toHaveAttribute("aria-selected", "true"); + await panel.getByRole("tab", { name: "Artifacts", exact: true }).click(); + await panel.getByRole("button", { name: "Open in tab: Delivered AGENTS.md", exact: true }).click(); + await expect(panel.getByRole("tab", { name: "AGENTS.md", exact: true })).toHaveAttribute("aria-selected", "true"); + }); +} diff --git a/ui/src/components/IssueAttachmentsSection.tsx b/ui/src/components/IssueAttachmentsSection.tsx index 0852c0c4f2..5e1a590b27 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 (
openTextAttachment(attachmentId, props.filename)} + > + Open in tab + + ) : null; const [galleryOpen, setGalleryOpen] = useState(false); const [csvRequested, setCsvRequested] = useState(false); const image = isImageLikeOutput(props.contentType, props.filename); @@ -115,8 +131,27 @@ export function IssueArtifactFile(props: IssueArtifactFileProps) { ); } - if (localCsv && !tooLarge && data.data) - return ; + if (localCsv && !tooLarge && data.data) { + return ( + + {openTextAction} + {downloadPath ? ( + + ) : null} + + } + /> + ); + } return (
setCsvRequested(true)} - > - {data.isFetching ? "Loading preview…" : "Preview data"} - - ) : undefined + <> + {openTextAction} + {localCsv && !tooLarge && !data.isError ? ( + + ) : null} + } /> {csv && (tooLarge || data.isError || !localCsv) && ( @@ -257,6 +295,7 @@ export function IssueWorkProductArtifactCard({ ({ ...(await importOriginal()), @@ -26,29 +27,37 @@ describe("CSV preview consent", () => { const client = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); + const openText = vi.fn(); try { await act(async () => root.render( - {Array.from({ length: 20 }, (_, i) => ( - - ))} + + {Array.from({ length: 20 }, (_, i) => ( + + ))} + , ), ); expect(load).not.toHaveBeenCalled(); + const open = container.querySelector('button[aria-label="Open in tab: Data 0"]'); + await act(async () => open!.click()); + expect(openText).toHaveBeenCalledWith("attachment-0", "report-0.csv"); + expect(load).not.toHaveBeenCalled(); const preview = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent === "Preview data", ); @@ -63,6 +72,12 @@ describe("CSV preview consent", () => { expect.any(AbortSignal), ); expect(container.textContent).toContain("View data"); + const download = container.querySelector('a[download="report-0.csv"]'); + expect(download?.textContent).toBe("Download file"); + expect(download?.getAttribute("href")).toBe("/api/attachments/csv-0/content?download=1"); + const openAfterPreview = container.querySelector('button[aria-label="Open in tab: Data 0"]'); + await act(async () => openAfterPreview!.click()); + expect(openText).toHaveBeenCalledTimes(2); } finally { await act(async () => root.unmount()); client.clear(); diff --git a/ui/src/components/artifacts/RichArtifactCards.tsx b/ui/src/components/artifacts/RichArtifactCards.tsx index 2c09febcb5..f26a761c60 100644 --- a/ui/src/components/artifacts/RichArtifactCards.tsx +++ b/ui/src/components/artifacts/RichArtifactCards.tsx @@ -432,6 +432,7 @@ export interface DataCardProps extends ArtifactIdentity { rows: (string | number)[][]; truncated?: boolean; downloadUrl?: string; + actions?: ReactNode; } function DataTable({ columns, rows }: Pick) { return ( @@ -493,24 +494,27 @@ export function DataCard(props: DataCardProps) {
{chip ? : null} + {canOpenText ? ( + + ) : null} {mediaPath ? ( ) : actionHref ? ( - + {compact ? null : {action}} ) : null} diff --git a/ui/src/components/task-chat/TaskChatBubble.tsx b/ui/src/components/task-chat/TaskChatBubble.tsx index 649ee205b4..7733c04e66 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 ?