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>
This commit is contained in:
Dotta
2026-09-29 16:40:10 -05:00
committed by GitHub
co-authored by Paperclip
parent 81a52eb740
commit e912f0df53
21 changed files with 674 additions and 58 deletions
+24
View File
@@ -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.
+85
View File
@@ -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<ReturnType<APIRequestContext["get"]>>) {
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 <script>never execute</script>\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("<script>never execute</script>");
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");
});
}
+18 -4
View File
@@ -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 (
<div className="flex shrink-0 items-center gap-1">
@@ -70,7 +76,7 @@ function AttachmentActions({
</Button>
) : null}
<Button asChild variant="ghost" size="icon-sm" title="Open in new tab">
<a href={attachmentOpenPath(attachment)} target="_blank" rel="noreferrer" aria-label={`Open ${filename}`}>
<a href={attachmentOpenPath(attachment)} onClick={openInPanel} target="_blank" rel="noreferrer" aria-label={`Open ${filename}`}>
<ExternalLink className="h-4 w-4" />
</a>
</Button>
@@ -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({
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate text-sm font-medium" title={filename}>{filename}</span>
<a href={attachmentOpenPath(attachment)} onClick={openInPanel} className="truncate text-sm font-medium hover:underline" title={filename}>{filename}</a>
</div>
<AttachmentMeta attachment={attachment} />
</div>
@@ -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 (
<Card id={`attachment-${attachment.id}`} className="flex-row scroll-mt-20 items-center gap-2.5 p-2">
<OutputFileTile contentType={attachment.contentType} />
<div className="min-w-0 flex-1">
<a
href={attachmentOpenPath(attachment)}
href={attachmentOpenPath(attachment)} onClick={openInPanel}
target="_blank"
rel="noreferrer"
className="block truncate text-sm font-medium text-foreground hover:underline"
@@ -5,6 +5,7 @@ import {
type IssueWorkProduct,
} from "@paperclipai/shared";
import { IssueGalleryContext } from "@/context/IssueGalleryContext";
import { TextAttachmentContext } from "@/context/TextAttachmentContext";
import { ImageGalleryModal } from "@/components/ImageGalleryModal";
import {
RichWorkProductCard,
@@ -13,7 +14,7 @@ import {
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { isImageLikeOutput, isVideoLikeOutput } from "@/lib/issue-output";
import { attachmentDownloadPath } from "@/lib/issue-attachments";
import { attachmentDownloadPath, isTextAttachment } from "@/lib/issue-attachments";
import { workProductHref } from "@/lib/issue-artifacts";
import { formatDateTime } from "@/lib/utils";
import {
@@ -38,6 +39,7 @@ import {
export interface IssueArtifactFileProps extends ArtifactIdentity {
id: string;
attachmentId?: string;
filename: string;
contentType: string;
contentPath: string;
@@ -49,8 +51,22 @@ export interface IssueArtifactFileProps extends ArtifactIdentity {
/** Shared by uploads and promoted uploads; both use the issue's existing gallery. */
export function IssueArtifactFile(props: IssueArtifactFileProps) {
const { metadata = null } = props;
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);
@@ -115,8 +131,27 @@ export function IssueArtifactFile(props: IssueArtifactFileProps) {
</>
);
}
if (localCsv && !tooLarge && data.data)
return <DataCard {...props} {...data.data} downloadUrl={downloadPath} />;
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
@@ -126,16 +161,19 @@ export function IssueArtifactFile(props: IssueArtifactFileProps) {
downloadUrl={downloadPath}
openUrl={artifactUrl(props.openPath) || contentPath}
actions={
localCsv && !tooLarge && !data.isError ? (
<Button
variant="outline"
size="sm"
disabled={data.isFetching}
onClick={() => setCsvRequested(true)}
>
{data.isFetching ? "Loading preview…" : "Preview data"}
</Button>
) : undefined
<>
{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) && (
@@ -257,6 +295,7 @@ export function IssueWorkProductArtifactCard({
<IssueArtifactFile
{...identity}
id={wp.id}
attachmentId={attachment?.attachmentId}
filename={text(m, "originalFilename") || wp.title}
contentType={text(m, "contentType")}
contentPath={contentPath}
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import { IssueArtifactFile } from "./IssueArtifactCard";
import { loadArtifactCsv } from "@/lib/artifact-card-data";
import { TextAttachmentContext } from "@/context/TextAttachmentContext";
vi.mock("@/lib/artifact-card-data", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/artifact-card-data")>()),
@@ -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(
<QueryClientProvider client={client}>
{Array.from({ length: 20 }, (_, i) => (
<IssueArtifactFile
key={i}
id={`csv-${i}`}
title={`Data ${i}`}
summary=""
author=""
updatedAt="Today"
filename={`report-${i}.csv`}
contentType="text/csv"
contentPath={`/api/attachments/csv-${i}/content`}
downloadPath={`/api/attachments/csv-${i}/content?download=1`}
byteSize={100}
/>
))}
<TextAttachmentContext.Provider value={openText}>
{Array.from({ length: 20 }, (_, i) => (
<IssueArtifactFile
key={i}
id={`csv-${i}`}
attachmentId={`attachment-${i}`}
title={`Data ${i}`}
summary=""
author=""
updatedAt="Today"
filename={`report-${i}.csv`}
contentType="text/csv"
contentPath={`/api/attachments/csv-${i}/content`}
downloadPath={`/api/attachments/csv-${i}/content?download=1`}
byteSize={100}
/>
))}
</TextAttachmentContext.Provider>
</QueryClientProvider>,
),
);
expect(load).not.toHaveBeenCalled();
const open = container.querySelector<HTMLButtonElement>('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<HTMLAnchorElement>('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<HTMLButtonElement>('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();
@@ -432,6 +432,7 @@ export interface DataCardProps extends ArtifactIdentity {
rows: (string | number)[][];
truncated?: boolean;
downloadUrl?: string;
actions?: ReactNode;
}
function DataTable({ columns, rows }: Pick<DataCardProps, "columns" | "rows">) {
return (
@@ -493,24 +494,27 @@ export function DataCard(props: DataCardProps) {
<Footer
{...props}
action={
<Viewer
title={props.title}
description={props.filename}
action="View data"
>
<DataTable {...props} />
<Button asChild variant="outline" size="sm" className="w-fit">
<a
download={props.filename}
href={
props.downloadUrl ||
`data:text/csv;charset=utf-8,${encodeURIComponent(csv)}`
}
>
Download CSV
</a>
</Button>
</Viewer>
<div className="flex flex-wrap items-center gap-2">
{props.actions}
<Viewer
title={props.title}
description={props.filename}
action="View data"
>
<DataTable {...props} />
<Button asChild variant="outline" size="sm" className="w-fit">
<a
download={props.filename}
href={
props.downloadUrl ||
`data:text/csv;charset=utf-8,${encodeURIComponent(csv)}`
}
>
Download CSV
</a>
</Button>
</Viewer>
</div>
}
/>
</Card>
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { TextAttachmentContext } from "@/context/TextAttachmentContext";
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
AttachmentArtifactWorkProductMetadata,
@@ -12,7 +13,7 @@ import {
getMarkdownWorkProductAttachmentMetadata,
isArtifactReviewDocumentKey,
} from "@paperclipai/shared";
import { Download, ExternalLink } from "lucide-react";
import { Download, ExternalLink, FileText } from "lucide-react";
import { ApiError } from "@/api/client";
import { issuesApi } from "@/api/issues";
import { activityApi } from "@/api/activity";
@@ -93,6 +94,7 @@ function MarkdownWorkProductRow({
author: string;
openRequestId?: number;
}) {
const openTextAttachment = useContext(TextAttachmentContext);
const [expanded, setExpanded] = useState(false);
const [annotationPanelOpen, setAnnotationPanelOpen] = useState(false);
const headerRef = useRef<HTMLDivElement | null>(null);
@@ -232,6 +234,17 @@ function MarkdownWorkProductRow({
}}
/>
) : null}
{openTextAttachment ? (
<button
type="button"
onClick={() => openTextAttachment(metadata.attachmentId, metadata.originalFilename ?? workProduct.title)}
aria-label={`Open in tab: ${workProduct.title}`}
title="Open in tab"
className="shrink-0 px-1.5 py-1.5 text-muted-foreground hover:text-foreground"
>
<FileText className="h-3 w-3" />
</button>
) : null}
<a
href={metadata.openPath}
target="_blank"
@@ -602,6 +615,7 @@ export function IssuePropertiesArtifactsTab({
<li key={row.id} className="col-span-full min-w-0">
<IssueArtifactFile
id={attachment.id}
attachmentId={attachment.id}
title={filename}
summary=""
author={
@@ -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<unknown[]> => []),
@@ -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(
<QueryClientProvider client={queryClient}>
<IssuePropertiesArtifactsTab issue={issue} {...props} />
<TextAttachmentContext.Provider value={openText}>
<IssuePropertiesArtifactsTab issue={issue} {...props} />
</TextAttachmentContext.Provider>
</QueryClientProvider>,
),
);
@@ -235,6 +240,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("revision 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(
<TextAttachmentContext.Provider value={openText}>
<RichWorkProductCard workProduct={makeMarkdownWorkProduct()} href={`/api/attachments/${ATTACHMENT_ID}/content`} variant={variant} />
</TextAttachmentContext.Provider>,
));
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();
@@ -326,6 +365,10 @@ describe("markdown work product review row", () => {
(anchor) => anchor.getAttribute("download") === "loose-notes.md",
);
expect(looseLink?.getAttribute("href")).toBe("/api/attachments/22222222-2222-4222-8222-222222222222/content?download=1");
const open = container.querySelector<HTMLButtonElement>('button[aria-label="Open in tab: loose-notes.md"]');
expect(open).not.toBeNull();
await act(async () => open!.click());
expect(openText).toHaveBeenCalledWith(loose.id, "loose-notes.md");
});
it("keeps non-markdown work products on the download row", async () => {
@@ -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,9 @@ export function RichWorkProductCard({ workProduct, href, variant = "card" }: Ric
break;
}
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");
@@ -272,12 +278,22 @@ export function RichWorkProductCard({ workProduct, href, variant = "card" }: Ric
</div>
<div className={cn("flex shrink-0 items-center", compact ? "gap-1.5" : "gap-2")}>
{chip ? <Chip chip={chip} /> : null}
{canOpenText ? (
<button
type="button"
onClick={() => openText!(textMetadata!.attachmentId, textMetadata!.originalFilename ?? workProduct.title)}
aria-label={`Open in tab: ${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"
>
{compact ? null : <span className="hidden @sm:inline">Open in tab</span>}<FileText aria-hidden className="h-3 w-3" />
</button>
) : null}
{mediaPath ? (
<button type="button" onClick={openGallery} 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">
{compact ? null : <span className="hidden @sm:inline">{action}</span>}<Maximize2 aria-hidden className="h-3 w-3" />
</button>
) : actionHref ? (
<a href={actionHref} 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}>
<a href={actionHref} aria-label={`${action}: ${workProduct.title}`} className={cn("inline-flex items-center gap-1 text-xs font-medium text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", canOpenText ? "relative z-10" : "after:absolute after:inset-0 after:rounded-md focus-visible:after:ring-2 focus-visible:after:ring-ring")} target={actionHref.startsWith("http") ? "_blank" : undefined} rel={actionHref.startsWith("http") ? "noreferrer" : undefined}>
{compact ? null : <span className="hidden @sm:inline">{action}</span>}<ExternalLink aria-hidden className="h-3 w-3" />
</a>
) : null}
@@ -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<string | null>(null);
@@ -331,6 +334,11 @@ function TaskChatBubbleContent({
render={video ? <button type="button" /> :
<a
href={ref.openPath ?? ref.url}
onClick={(event) => {
if (openText && ref.id && isTextAttachment({ contentType: ref.contentType ?? "", originalFilename: ref.name }) && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey) {
event.preventDefault(); openText(ref.id, ref.name);
}
}}
target="_blank"
rel="noreferrer"
/>
@@ -0,0 +1,31 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { ThemeProvider } from "@/context/ThemeContext";
import { TextAttachmentPreview } from "./TaskAttachmentPanel";
describe("attachment Markdown media", () => {
it("renders uploaded image references without loading or preloading their URLs", () => {
const html = renderToStaticMarkup(
<ThemeProvider>
<TextAttachmentPreview
title="report.md"
markdown
text={[
"# Report",
"![External image](https://images.example/tracker.png)",
"![Local image](/api/attachments/image-id/content)",
"![Inline image](data:image/png;base64,iVBORw0KGgo=)",
].join("\n\n")}
downloadUrl="/api/attachments/report-id/content?download=1"
/>
</ThemeProvider>,
);
expect(html).toContain("Report</h1>");
expect(html).toContain("data-markdown-image-reference");
expect(html).toContain("External image");
expect(html).not.toContain("<img");
expect(html).not.toContain('rel="preload"');
expect(html).not.toContain('src="https://images.example/');
expect(html).toContain("/api/attachments/report-id/content?download=1");
});
});
@@ -0,0 +1,73 @@
// @vitest-environment jsdom
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { queryKeys } from "@/lib/queryKeys";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TaskAttachmentPanel, TextAttachmentPreview, readTextPreview, TEXT_PREVIEW_MAX_BYTES } from "./TaskAttachmentPanel";
import { isTextAttachment } from "@/lib/issue-attachments";
import { readTaskSidePanelState, taskPanelAttachmentTab, writeTaskSidePanelState } from "@/lib/task-side-panel-state";
vi.mock("@/components/MarkdownBody", () => ({ MarkdownBody: ({ children }: { children: string }) => <article data-rendered>{children}</article> }));
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => { window.localStorage.clear(); vi.unstubAllGlobals(); });
describe("text attachment tabs", () => {
it("does not fetch missing or oversized attachments and offers download for oversized files", async () => {
const fetch = vi.fn(); vi.stubGlobal("fetch", fetch);
const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } });
client.setQueryData(queryKeys.issues.attachments("task"), [{ id: "large", originalFilename: "large.txt", contentType: "text/plain", byteSize: TEXT_PREVIEW_MAX_BYTES + 1, contentPath: "/api/attachments/large/content" }]);
const host = document.createElement("div"); const root = createRoot(host);
await act(async () => root.render(<QueryClientProvider client={client}><TaskAttachmentPanel issueId="task" attachmentId="missing" /></QueryClientProvider>));
expect(host.textContent).toContain("File no longer available");
await act(async () => root.render(<QueryClientProvider client={client}><TaskAttachmentPanel issueId="task" attachmentId="large" /></QueryClientProvider>));
expect(host.textContent).toContain("too large");
expect(host.querySelector("a")?.textContent).toBe("Download file");
expect(fetch).not.toHaveBeenCalled();
await act(async () => root.unmount()); client.clear();
});
it("classifies text without treating PDF or images as text", () => {
for (const [contentType, originalFilename] of [["text/plain", "notes.txt"], ["application/octet-stream", "AGENTS.md"], ["application/json", "report.json"]]) expect(isTextAttachment({ contentType, originalFilename })).toBe(true);
expect(isTextAttachment({ contentType: "application/pdf", originalFilename: "report.pdf" })).toBe(false);
expect(isTextAttachment({ contentType: "image/png", originalFilename: "fake.txt" })).toBe(false);
});
it("retains attachment tabs when workspace browsing is disabled", () => {
const tab = taskPanelAttachmentTab("file-1", "AGENTS.md");
writeTaskSidePanelState("user", "company", "task", { state: { tabs: [tab], activeTabId: tab.id }, launcherOpen: false, userInteracted: true, autoPlanHandled: false, updatedAt: 1 });
expect(readTaskSidePanelState("user", "company", "task", false)?.state.tabs).toEqual([tab]);
expect(taskPanelAttachmentTab("file-1", "Renamed.md").id).toBe(tab.id);
});
it("switches rendered/raw Markdown and keeps a download action", async () => {
const host = document.createElement("div"); document.body.append(host);
const root = createRoot(host);
await act(async () => root.render(<TextAttachmentPreview title="AGENTS.md" markdown text="# Charter" downloadUrl="/api/attachments/file-1/content?download=1" />));
expect(host.querySelector("[data-rendered]")).not.toBeNull();
const raw = host.querySelector<HTMLButtonElement>('button[aria-label="Raw"]');
const rendered = host.querySelector<HTMLButtonElement>('button[aria-label="Rendered"]');
expect(raw?.querySelector("svg")).not.toBeNull();
expect(rendered?.querySelector("svg")).not.toBeNull();
expect(raw?.textContent).toBe("");
await act(async () => raw!.click());
expect(host.querySelector("pre")?.textContent).toBe("# Charter");
expect(host.querySelector("a")?.getAttribute("href")).toContain("download=1");
await act(async () => rendered!.click());
expect(host.querySelector("[data-rendered]")).not.toBeNull();
await act(async () => root.unmount()); host.remove();
});
it("shows plain text literally without Markdown controls", async () => {
const host = document.createElement("div"); const root = createRoot(host);
await act(async () => root.render(<TextAttachmentPreview title="notes.txt" markdown={false} text="<script>alert(1)</script>" downloadUrl="/download" />));
expect(host.querySelector("script")).toBeNull();
expect(host.querySelector("pre")?.textContent).toContain("<script>");
expect(host.querySelector('[aria-label="Markdown view"]')).toBeNull();
expect(host.querySelector('a[aria-label="Download notes.txt"] svg')).not.toBeNull();
await act(async () => root.unmount());
});
it("rejects HTTP errors, oversized responses and binary data", async () => {
await expect(readTextPreview(new Response("denied", { status: 403 }))).rejects.toThrow("403");
await expect(readTextPreview(new Response("x".repeat(TEXT_PREVIEW_MAX_BYTES + 1)))).rejects.toThrow("too large");
await expect(readTextPreview(new Response("binary\0data"))).rejects.toThrow("binary");
expect(await readTextPreview(new Response(""))).toBe("");
expect(await readTextPreview(new Response("héllo"))).toBe("héllo");
});
});
@@ -0,0 +1,103 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Code2, Download, Eye } from "lucide-react";
import { issuesApi } from "@/api/issues";
import { Button } from "@/components/ui/button";
import { MarkdownBody } from "@/components/MarkdownBody";
import { attachmentDownloadPath, isMarkdownAttachment, isTextAttachment } from "@/lib/issue-attachments";
import { queryKeys } from "@/lib/queryKeys";
export const TEXT_PREVIEW_MAX_BYTES = 512 * 1024;
/** Bound the actual response, not just producer-supplied attachment metadata. */
export async function readTextPreview(response: Response) {
if (!response.ok) throw new Error(`Could not load file (${response.status}).`);
if (!response.body) throw new Error("The file response is empty.");
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8", { fatal: true });
let bytes = 0;
let text = "";
try {
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
bytes += chunk.value.byteLength;
if (bytes > TEXT_PREVIEW_MAX_BYTES) throw new Error("This file is too large to preview. Download it instead.");
text += decoder.decode(chunk.value, { stream: true });
}
text += decoder.decode();
if (text.includes("\0")) throw new Error("This file contains binary data. Download it instead.");
return text;
} finally {
await reader.cancel();
reader.releaseLock();
}
}
export function TextAttachmentPreview({ title, text, markdown, downloadUrl }: {
title: string;
text: string;
markdown: boolean;
downloadUrl: string;
}) {
const [raw, setRaw] = useState(false);
return (
<div className="flex h-full min-h-0 flex-col">
<header className="flex items-center gap-2 border-b border-border px-3 py-2">
<h2 className="min-w-0 flex-1 truncate text-sm font-medium" title={title}>{title}</h2>
{markdown ? (
<div className="flex gap-1" role="group" aria-label="Markdown view">
<Button size="icon-sm" variant={raw ? "ghost" : "secondary"} aria-label="Rendered" title="Rendered" aria-pressed={!raw} onClick={() => setRaw(false)}>
<Eye aria-hidden />
</Button>
<Button size="icon-sm" variant={raw ? "secondary" : "ghost"} aria-label="Raw" title="Raw" aria-pressed={raw} onClick={() => setRaw(true)}>
<Code2 aria-hidden />
</Button>
</div>
) : null}
<Button asChild variant="ghost" size="icon-sm">
<a href={downloadUrl} download aria-label={`Download ${title}`} title={`Download ${title}`}><Download aria-hidden /></a>
</Button>
</header>
<div className="min-h-0 flex-1 overflow-auto p-4">
{text.length === 0 ? <p className="text-sm text-muted-foreground">File is empty.</p>
: markdown && !raw ? <MarkdownBody mediaMode="reference">{text}</MarkdownBody>
: <pre className="whitespace-pre-wrap break-words font-mono text-sm" aria-label={`${title} raw text`}>{text}</pre>}
</div>
</div>
);
}
export function TaskAttachmentPanel({ issueId, attachmentId }: { issueId: string; attachmentId: string }) {
const attachments = useQuery({
queryKey: queryKeys.issues.attachments(issueId),
queryFn: () => issuesApi.listAttachments(issueId),
});
// Re-resolve against this task's authorized attachment list; never trust persisted URLs.
const attachment = attachments.data?.find((item) => item.id === attachmentId);
const eligible = attachment && isTextAttachment(attachment) && attachment.byteSize <= TEXT_PREVIEW_MAX_BYTES;
const content = useQuery({
queryKey: ["task-text-attachment", issueId, attachmentId],
queryFn: async ({ signal }) => readTextPreview(await fetch(
`/api/attachments/${encodeURIComponent(attachmentId)}/content`,
{ signal, credentials: "same-origin" },
)),
enabled: Boolean(eligible),
retry: false,
});
if (attachments.isLoading) return <p className="p-4 text-sm" role="status">Loading file…</p>;
if (attachments.isError) return <div className="p-4" role="alert">Could not load attachment details. <Button onClick={() => void attachments.refetch()}>Retry</Button></div>;
if (!attachment) return <p className="p-4 text-sm" role="status">File no longer available. Close this tab or choose another file.</p>;
const downloadUrl = attachmentDownloadPath(attachment);
if (!eligible || content.isError) {
return (
<div className="space-y-3 p-4" role="alert">
<p className="text-sm">{content.isError ? "Could not preview this file. Retry or download it." : "This file is too large or is not supported for text preview. Download it instead."}</p>
{eligible ? <Button onClick={() => void content.refetch()}>Retry</Button> : null}
<Button asChild variant="outline"><a href={downloadUrl} download>Download file</a></Button>
</div>
);
}
if (content.data === undefined) return <p className="p-4 text-sm" role="status">Loading file…</p>;
return <TextAttachmentPreview title={attachment.originalFilename ?? attachment.id} text={content.data} markdown={isMarkdownAttachment(attachment)} downloadUrl={downloadUrl} />;
}
@@ -73,6 +73,8 @@ vi.mock("@/components/WorkspaceFileBrowser", () => ({
WorkspaceFileBrowser: () => <div>Files browser</div>,
}));
vi.mock("./TaskAttachmentPanel", () => ({ TaskAttachmentPanel: ({ attachmentId }: { attachmentId: string }) => <div>Text file {attachmentId}</div> }));
vi.mock("./TaskDocumentPanel", () => ({
TaskDocumentPanel: ({ documentKey }: { documentKey: string }) => <div>{`Document ${documentKey}`}</div>,
}));
@@ -158,6 +160,28 @@ describe("TaskSidePanel", () => {
);
}
it("opens, deduplicates, switches and closes text attachment tabs", async () => {
const onAttachmentOpened = vi.fn();
await render(panel({ openAttachment: { id: "file-1", title: "AGENTS.md", requestId: 1 }, onAttachmentOpened }));
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("AGENTS.md");
expect(container.textContent).toContain("Text file file-1");
expect(onAttachmentOpened).toHaveBeenCalledOnce();
await render(panel({ openAttachment: { id: "file-2", title: "summary.txt", requestId: 2 } }));
await render(panel({ openAttachment: { id: "file-1", title: "AGENTS.md", requestId: 3 } }));
expect(container.querySelectorAll('[data-side-panel-tab-target="attachment:file-1"]')).toHaveLength(1);
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("AGENTS.md");
await act(async () => container.querySelector<HTMLButtonElement>('button[aria-label="Close AGENTS.md"]')?.click());
expect(container.querySelector('[data-side-panel-tab-target="attachment:file-1"]')).toBeNull();
});
it("lets an attachment request take focus from a workspace-file route", async () => {
routeFixture.location.search = "?file=README.md&workspace=project";
window.history.replaceState(null, "", `${routeFixture.location.pathname}${routeFixture.location.search}`);
await render(panel({ fileTabsEnabled: true, openAttachment: { id: "file-1", title: "notes.txt", requestId: 1 } }));
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("notes.txt");
expect(routeFixture.navigate).toHaveBeenCalledWith(expect.objectContaining({ search: "" }), expect.anything());
});
it("opens Properties on first visit", async () => {
await render(panel());
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Properties");
@@ -1,3 +1,5 @@
import { TextAttachmentContext } from "@/context/TextAttachmentContext";
import { TaskAttachmentPanel } from "./TaskAttachmentPanel";
import {
useCallback,
useEffect,
@@ -62,6 +64,7 @@ import { useLocation, useNavigate } from "@/lib/router";
import {
readTaskSidePanelState,
taskPanelArtifactsTab,
taskPanelAttachmentTab,
taskPanelDocumentTab,
taskPanelFilesTab,
taskPanelPropertiesTab,
@@ -101,6 +104,8 @@ export interface TaskSidePanelProps {
showSubtasksTab?: boolean;
/** Optional related-work projection; the host still owns tab layout and state. */
tasksTab?: { count: number; content: ReactNode; hasError?: boolean };
onAttachmentOpened?: () => void;
openAttachment?: { id: string; title: string; requestId: number } | null;
openSkillId?: string | null;
openSkillName?: string | null;
onSkillOpened?: (skillId: string) => void;
@@ -113,6 +118,7 @@ function tabIcon(tab: SidePanelTabRecord<TaskSidePanelTabPayload>): ReactNode {
case "properties": return <SlidersHorizontal />;
case "subtasks": return <ListTree />;
case "artifacts": return <Box />;
case "attachment": return <FileText />;
case "files-browser": return <FolderOpen />;
case "workspace-file": return <FileCode2 />;
case "issue-document": return tab.payload.documentKey === "plan" ? <Lightbulb /> : <FileText />;
@@ -236,6 +242,8 @@ export function TaskSidePanel({
streamlinedTabs = false,
showSubtasksTab = false,
tasksTab,
openAttachment,
onAttachmentOpened,
openSkillId,
openSkillName,
onSkillOpened,
@@ -399,6 +407,15 @@ export function TaskSidePanel({
onArtifactsOpened?.(artifactsOpenRequestId);
}, [artifactsOpenRequestId, controller.openTab, onArtifactsOpened]);
useEffect(() => {
if (!openAttachment) return;
userInteractedRef.current = true;
setLauncherOpen(false);
controller.openTab(taskPanelAttachmentTab(openAttachment.id, openAttachment.title));
if (viewer.state || viewer.browse) viewer.close();
onAttachmentOpened?.();
}, [controller.openTab, openAttachment, onAttachmentOpened]);
const recentFilesQuery = useQuery({
queryKey: queryKeys.issues.fileResources(issue.id, {
workspace: "auto",
@@ -656,7 +673,11 @@ export function TaskSidePanel({
/>
);
} else if (activeTab.payload.kind === "artifacts") {
content = <IssuePropertiesArtifactsTab issue={issue} onOpenDocument={openDocument} />;
content = <TextAttachmentContext.Provider value={(id, title) => {
markInteracted();
controller.openTab(taskPanelAttachmentTab(id, title));
if (viewer.state || viewer.browse) viewer.close();
}}><IssuePropertiesArtifactsTab issue={issue} onOpenDocument={openDocument} /></TextAttachmentContext.Provider>;
} else if (activeTab.payload.kind === "issue-document") {
content = activeTab.payload.documentKey === "plan" ? (
<IssuePropertiesPlansTab issue={issue} inline={inline} />
@@ -667,6 +688,8 @@ export function TaskSidePanel({
initialDocument={documentByKey.get(activeTab.payload.documentKey)}
/>
);
} else if (activeTab.payload.kind === "attachment") {
content = <TaskAttachmentPanel key={activeTab.payload.attachmentId} issueId={issue.id} attachmentId={activeTab.payload.attachmentId} />;
} else if (activeTab.payload.kind === "skill") {
content = <TaskSkillPanel companyId={issue.companyId} skillId={activeTab.payload.skillId} />;
} else if (activeTab.payload.kind === "files-browser") {
+4
View File
@@ -0,0 +1,4 @@
import { createContext } from "react";
/** The task owns panel visibility; callers retain their normal link fallback. */
export const TextAttachmentContext = createContext<((id: string, title: string) => void) | null>(null);
+12
View File
@@ -40,3 +40,15 @@ export function isMarkdownAttachment(
) {
return isMarkdownAttachmentContent(attachment);
}
export function isTextAttachment(attachment: {
contentType: string;
originalFilename?: string | null;
}) {
const type = attachment.contentType.toLowerCase().split(";")[0].trim();
return isMarkdownAttachmentContent(attachment)
|| type.startsWith("text/")
|| /^(application\/(json|xml|javascript|x-yaml|yaml)|application\/[\w.-]+\+json)$/.test(type)
|| (["", "application/octet-stream"].includes(type)
&& /\.(txt|log|csv|json|yaml|yml|toml|ini|sh|ts|tsx|js|jsx|py|css|html)$/i.test(attachment.originalFilename ?? ""));
}
+8
View File
@@ -33,6 +33,7 @@ export type TaskSidePanelTabPayload =
| { kind: "properties" }
| { kind: "subtasks" }
| { kind: "artifacts" }
| { kind: "attachment"; attachmentId: string }
| { kind: "skill"; skillId: string }
| { kind: "issue-document"; documentKey: string }
| {
@@ -91,6 +92,9 @@ function parsePayload(value: unknown): TaskSidePanelTabPayload | null {
if (kind === "properties") return { kind };
if (kind === "subtasks") return { kind };
if (kind === "artifacts") return { kind };
if (kind === "attachment") {
return typeof input.attachmentId === "string" && input.attachmentId.length > 0 ? { kind, attachmentId: input.attachmentId } : null;
}
if (kind === "skill") {
return typeof input.skillId === "string" && input.skillId.length > 0 ? { kind, skillId: input.skillId } : null;
}
@@ -278,3 +282,7 @@ export function taskPanelWorkspaceFileTab(input: {
},
};
}
export function taskPanelAttachmentTab(attachmentId: string, title: string): SidePanelTabRecord<TaskSidePanelTabPayload> {
return { id: `attachment:${attachmentId}`, type: "attachment", label: title, closable: true, contentMode: "full-bleed", payload: { kind: "attachment", attachmentId } };
}
+9
View File
@@ -8,6 +8,7 @@ import { SetupWizardNavigation, SetupWizardFooter } from "../components/SetupWiz
import { RemoteMcpDesignExample } from "@/features/connections/remote-mcp/RemoteMcpDesignExample";
import { AgentChatPicker } from "@/components/AgentChatPicker";
import { TaskChatProjectCreatedCard } from "@/components/task-chat/TaskChatProjectCreatedCard";
import { TextAttachmentPreview } from "@/components/task-side-panel/TaskAttachmentPanel";
import { AnnouncementCard } from "@/components/AnnouncementCard";
import { announcementPreview, announcementAnimationPreview, announcementAnimationPreviewSrc } from "@/lib/announcement-preview";
import { TaskDetailTasksPanel } from "@/components/task-detail/TaskDetailTasksPanel";
@@ -2375,6 +2376,14 @@ export function DesignGuide() {
</div>
</Section>
<Section title="Text attachment tabs">
<p className="text-sm text-muted-foreground">Uploaded text opens in a named task tab. Markdown offers Rendered and Raw icon controls; every text file has a download action.</p>
<div className="grid gap-4 md:grid-cols-2">
<TextAttachmentPreview title="README.md" text={"# Project notes\n\nReview the **original** file."} markdown downloadUrl="data:text/markdown,%23%20Project%20notes" />
<TextAttachmentPreview title="notes.txt" text="Plain text stays literal: <example>" markdown={false} downloadUrl="data:text/plain,Plain%20text" />
</div>
</Section>
<Section title="Connection recovery">
<SubSection title="Waiting for server">
<CloudAccessError temporary retrying={false} onRetry={() => undefined} />
+17 -2
View File
@@ -1,3 +1,4 @@
import { TextAttachmentContext } from "../context/TextAttachmentContext";
import { WorkspaceExportRecovery } from "../components/WorkspaceExportRecovery";
import { useUserPreferences } from "../hooks/useUserPreferences";
import { DispositionRecoveryProvider } from "../components/DispositionRecoveryNotice";
@@ -2905,6 +2906,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
requestId: number;
handled?: boolean;
} | null>(null);
const [openAttachment, setOpenAttachment] = useState<{ issueId: string; id: string; title: string; requestId: number } | null>(null);
const [openSkill, setOpenSkill] = useState<{ id: string; name: string } | null>(null);
const handleSkillOpened = useCallback((skillId: string) => {
setOpenSkill((current) => current?.id === skillId ? null : current);
@@ -3602,6 +3604,13 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
}
setPanelVisible(true);
}, [issue?.id, setPanelVisible, suppressPanelUntilPlan]);
const handleAttachmentOpened = useCallback(() => setOpenAttachment(null), []);
const handleOpenTextAttachment = useCallback((id: string, title: string) => {
if (!issue?.id) return;
setOpenAttachment({ issueId: issue.id, id, title, requestId: Date.now() });
openTaskSidePanel();
if (isMobile) setMobilePropsOpen(true);
}, [issue?.id, openTaskSidePanel, isMobile]);
const handleOpenSkill = useCallback((skillId: string, name: string) => {
const next = openSkillPanelState(
{ panelBeforePlanOverrideIssueId },
@@ -5672,6 +5681,8 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
checkingMonitorNow: checkIssueMonitorNow.isPending,
documentDeepLink:
documentDeepLink?.issueId === panelIssue.id ? documentDeepLink : null,
onAttachmentOpened: handleAttachmentOpened,
openAttachment: !isMobile && openAttachment?.issueId === panelIssue.id ? openAttachment : null,
openSkillId: openSkill?.id ?? null,
openSkillName: openSkill?.name ?? null,
onSkillOpened: handleSkillOpened,
@@ -5709,6 +5720,8 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
issuePanelKey,
openNewSubIssue,
openPanel,
openAttachment,
handleAttachmentOpened,
openSkill,
handleSkillOpened,
panelChildIssues,
@@ -7411,7 +7424,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
return (
<FileViewerProvider issueId={conversation && !conversation.issue ? "" : issue.id} enabled={fileViewerEnabled}>
<IssueGalleryContext.Provider value={openIssueGallery}>
<TextAttachmentContext.Provider value={taskChatShellEnabled ? handleOpenTextAttachment : null}><IssueGalleryContext.Provider value={openIssueGallery}>
<div
data-task-chat-shell={taskChatShellEnabled ? "" : undefined}
className={
@@ -8200,6 +8213,8 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
artifactsOpenRequestId={isMobile && !artifactsOpenRequest?.handled && artifactsOpenRequest?.issueId === issue.id
? artifactsOpenRequest.requestId : undefined}
onArtifactsOpened={handleArtifactsOpened}
onAttachmentOpened={handleAttachmentOpened}
openAttachment={openAttachment?.issueId === issue.id ? openAttachment : null}
openSkillId={openSkill?.id ?? null}
openSkillName={openSkill?.name ?? null}
onSkillOpened={handleSkillOpened}
@@ -8279,7 +8294,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
) : null}
<ScrollToBottom />
</div>
</IssueGalleryContext.Provider>
</IssueGalleryContext.Provider></TextAttachmentContext.Provider>
</FileViewerProvider>
);
}
@@ -0,0 +1,52 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { IssueAttachment } from "@paperclipai/shared";
import { TaskSidePanel } from "@/components/task-side-panel/TaskSidePanel";
import { queryKeys } from "@/lib/queryKeys";
import { createIssue } from "../fixtures/paperclipData";
const markdown = "# Account review charter\n\nInvestigate whether new and active accounts succeeded.\n\n## Rules\n\n- Read customer data only.\n- Keep private information out of reports.\n- Report failures with evidence.\n\n```text\nReview → Evidence → Report\n```";
const plain = "Daily review summary\n\nAccounts reviewed: 20\nCompleted successfully: 18\nNeeds investigation: 2\n\n<This is plain text, not HTML.>";
function TextFilesStory({ initial = "list", width = 640 }: { initial?: "list" | "markdown" | "text" | "empty" | "large" | "missing"; width?: number }) {
const [fixture] = useState(() => {
const issue = createIssue({ id: `text-files-${initial}-${width}`, title: "Review account outcomes", conversationAgentId: "reviewer" });
const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } });
const files = [
{ id: "charter", originalFilename: "AGENTS.md", contentType: "text/markdown", byteSize: 320 },
{ id: "summary", originalFilename: "summary.txt", contentType: "text/plain", byteSize: 160 },
{ id: "empty", originalFilename: "empty.txt", contentType: "text/plain", byteSize: 0 },
{ id: "large", originalFilename: "large.log", contentType: "text/plain", byteSize: 600000 },
].map((file) => ({ ...file, issueId: issue.id, companyId: issue.companyId, createdByAgentId: "reviewer", createdByUserId: null, issueCommentId: null, objectKey: file.originalFilename, contentPath: `/api/attachments/${file.id}/content`, downloadPath: `data:text/plain;charset=utf-8,${encodeURIComponent(file.id === "charter" ? markdown : plain)}`, createdAt: new Date("2026-09-27T00:00:00Z"), updatedAt: new Date("2026-09-27T00:00:00Z") } as IssueAttachment));
client.setQueryData(queryKeys.issues.attachments(issue.id), files);
client.setQueryData(queryKeys.issues.workProducts(issue.id), []);
client.setQueryData([...queryKeys.issues.documents(issue.id), "list"], []);
client.setQueryData([...queryKeys.issues.documents(issue.id), "plan"], null);
client.setQueryData(queryKeys.issues.runs(issue.id), []);
client.setQueryData(queryKeys.agents.list(issue.companyId), []);
for (const [id, text] of [["charter", markdown], ["summary", plain], ["empty", ""]]) client.setQueryData(["task-text-attachment", issue.id, id], text);
window.localStorage.removeItem(`paperclip:task-side-panel:v1:storybook-text-files:${issue.companyId}`);
return { client, issue };
});
const id = initial === "markdown" ? "charter" : initial === "text" ? "summary" : initial;
return <QueryClientProvider client={fixture.client}>
<main className="min-h-screen bg-background p-6 text-foreground">
<div className="mx-auto flex max-w-6xl flex-wrap gap-6">
<div className="min-w-0 flex-1"><h1 className="text-xl font-semibold">Review account outcomes</h1><p className="mt-3 text-sm text-muted-foreground">Open AGENTS.md or summary.txt from Artifacts. Each file opens in its own tab. Markdown can switch between rendered and raw text.</p></div>
<section className="h-(--sz-640px) max-w-full overflow-hidden rounded-lg border border-border" style={{ width }} aria-label="Task side panel">
<TaskSidePanel issue={fixture.issue} accountScope="storybook-text-files" inline onUpdate={() => {}} fileTabsEnabled={false} openAttachment={initial === "list" ? null : { id, title: id === "charter" ? "AGENTS.md" : `${id}.txt`, requestId: 1 }} />
</section>
</div>
</main>
</QueryClientProvider>;
}
const meta = { title: "Tasks/Text file tabs", component: TextFilesStory, parameters: { layout: "fullscreen" }, render: (args, context) => <TextFilesStory key={context.id} {...args} /> } satisfies Meta<typeof TextFilesStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const OpenFromArtifacts: Story = {};
export const Markdown: Story = { args: { initial: "markdown" } };
export const PlainText: Story = { args: { initial: "text" } };
export const Empty: Story = { args: { initial: "empty" } };
export const Oversize: Story = { args: { initial: "large" } };
export const Missing: Story = { args: { initial: "missing" } };
export const Narrow: Story = { args: { initial: "markdown", width: 360 } };