diff --git a/doc/AGENT-ARTIFACTS.md b/doc/AGENT-ARTIFACTS.md index 157200c97f3..a1e4a170a52 100644 --- a/doc/AGENT-ARTIFACTS.md +++ b/doc/AGENT-ARTIFACTS.md @@ -209,6 +209,10 @@ 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. + 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 diff --git a/tests/e2e/text-attachment-tabs.spec.ts b/tests/e2e/text-attachment-tabs.spec.ts index cd56c2cddfe..98a11d60eca 100644 --- a/tests/e2e/text-attachment-tabs.spec.ts +++ b/tests/e2e/text-attachment-tabs.spec.ts @@ -23,6 +23,18 @@ for (const mobile of [false, true]) { 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 }) }); @@ -50,5 +62,22 @@ for (const mobile of [false, 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); + await panel.getByRole("button", { name: "Open a new tab", exact: true }).click(); + await page.getByRole("option", { name: "Artifacts", exact: true }).click(); + const review = panel.getByRole("button", { name: /Delivered AGENTS.md.*For review/ }); + await review.click(); + await expect(review).toHaveAttribute("aria-expanded", "true"); + await expect(panel.getByRole("heading", { name: "File charter", exact: true })).toBeVisible(); + await expect(review).toContainText("Rev 1"); + await expect(panel.getByRole("link", { name: "Download Delivered AGENTS.md", exact: true })).toBeVisible(); + const cardDownload = page.waitForEvent("download"); + await panel.getByRole("link", { name: "Download: Delivered notes.txt", 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/issue-properties/IssuePropertiesArtifactsTab.tsx b/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx index ea66b7be3af..11fb1ff498e 100644 --- a/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx +++ b/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx @@ -1,6 +1,5 @@ import { TextAttachmentContext } from "@/context/TextAttachmentContext"; import { isTextAttachment } from "@/lib/issue-attachments"; -import { getAttachmentArtifactWorkProductMetadata } from "@paperclipai/shared"; import { useContext, useEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -102,6 +101,7 @@ function MarkdownWorkProductRow({ reviewDoc: IssueDocument | undefined; openRequestId?: number; }) { + const openTextAttachment = useContext(TextAttachmentContext); const [expanded, setExpanded] = useState(false); const [annotationPanelOpen, setAnnotationPanelOpen] = useState(false); const headerRef = useRef(null); @@ -236,6 +236,17 @@ function MarkdownWorkProductRow({ onToggle={() => setAnnotationPanelOpen((open) => !open)} /> ) : null} + {openTextAttachment ? ( + + ) : null} { 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); diff --git a/ui/src/components/issue-properties/IssuePropertiesMarkdownWorkProduct.test.tsx b/ui/src/components/issue-properties/IssuePropertiesMarkdownWorkProduct.test.tsx index 2f8f814a4e2..35f7f37844a 100644 --- a/ui/src/components/issue-properties/IssuePropertiesMarkdownWorkProduct.test.tsx +++ b/ui/src/components/issue-properties/IssuePropertiesMarkdownWorkProduct.test.tsx @@ -8,6 +8,8 @@ import type { Issue, IssueAttachment, IssueDocument, IssueWorkProduct } from "@p import { artifactReviewDocumentKey } from "@paperclipai/shared"; import { IssuePropertiesArtifactsTab } from "./IssuePropertiesArtifactsTab"; import { ApiError } from "@/api/client"; +import { TextAttachmentContext } from "@/context/TextAttachmentContext"; +import { RichWorkProductCard } from "@/components/task-chat/RichWorkProductCard"; const mockIssuesApi = vi.hoisted(() => ({ listAttachments: vi.fn(async (): Promise => []), @@ -159,6 +161,7 @@ async function waitForAssertion(assertion: () => void, attempts = 20) { describe("markdown work product review row", () => { let container: HTMLDivElement; let root: Root | null = null; + const openText = vi.fn(); beforeEach(() => { vi.clearAllMocks(); @@ -193,7 +196,9 @@ describe("markdown work product review row", () => { await act(async () => currentRoot.render( - + + + , ), ); @@ -234,6 +239,40 @@ describe("markdown work product review row", () => { expect(mockIssuesApi.ensureWorkProductReviewDocument).not.toHaveBeenCalled(); }); + it("opens the attachment in a tab without replacing its review controls", async () => { + mockUseIssueDocuments.mockReturnValue({ data: [makeReviewDocument({ latestRevisionNumber: 3 })] }); + await renderTab(); + + expect(container.textContent).toContain("Rev 3"); + expect(container.querySelector(`[data-testid="annotation-count-${REVIEW_KEY}"]`)).not.toBeNull(); + const open = container.querySelector('button[aria-label="Open in tab: Verification report"]') as HTMLButtonElement; + expect(open).not.toBeNull(); + await act(async () => open.click()); + expect(openText).toHaveBeenCalledWith(ATTACHMENT_ID, "report.md"); + expect(expandButton().getAttribute("aria-expanded")).toBe("false"); + await act(async () => expandButton().click()); + expect(container.querySelector(`[data-testid="annotation-surface-${REVIEW_KEY}"]`)).not.toBeNull(); + }); + + it.each(["compact", "card"] as const)("keeps %s text-card downloads separate from opening a tab", async (variant) => { + root = createRoot(container); + await act(async () => root!.render( + + + , + )); + const download = container.querySelector('a[aria-label="Download: Verification report"]') as HTMLAnchorElement; + expect(download.getAttribute("href")).toBe(`/api/attachments/${ATTACHMENT_ID}/content?download=1`); + const click = new MouseEvent("click", { bubbles: true, cancelable: true }); + await act(async () => { download.dispatchEvent(click); }); + expect(click.defaultPrevented).toBe(false); + expect(openText).not.toHaveBeenCalled(); + const open = container.querySelector('button[aria-label="Open in tab: Verification report"]') as HTMLButtonElement; + expect(open).not.toBeNull(); + await act(async () => open.click()); + expect(openText).toHaveBeenCalledWith(ATTACHMENT_ID, "report.md"); + }); + it("materializes the document on first expand", async () => { mockIssuesApi.ensureWorkProductReviewDocument.mockResolvedValue(makeReviewDocument()); await renderTab(); diff --git a/ui/src/components/task-chat/RichWorkProductCard.tsx b/ui/src/components/task-chat/RichWorkProductCard.tsx index ae053089389..46581ded661 100644 --- a/ui/src/components/task-chat/RichWorkProductCard.tsx +++ b/ui/src/components/task-chat/RichWorkProductCard.tsx @@ -190,6 +190,7 @@ export function RichWorkProductCard({ workProduct, href, variant = "card" }: Ric const openText = useContext(TextAttachmentContext); const textMetadata = getAttachmentArtifactWorkProductMetadata(workProduct); + const canOpenText = Boolean(openText && textMetadata && isTextAttachment(textMetadata)); const additions = numberMeta(metadata, "additions"); const deletions = numberMeta(metadata, "deletions"); const files = numberMeta(metadata, "files", "changedFiles"); @@ -277,12 +278,22 @@ export function RichWorkProductCard({ workProduct, href, variant = "card" }: Ric
    {chip ? : null} + {canOpenText ? ( + + ) : null} {mediaPath ? ( ) : 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}