feat(ui): open text attachments in task tabs

Restore the recovered PAP-61 viewer, use accessible icon controls for rendered/raw Markdown and downloads, and keep explicit mobile file requests on the visible panel. Verify uploads, rendering, tab selection, and exact original downloads on desktop and mobile.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-27 15:59:17 -05:00
1 parent 23fc19ffb7
commit 6beece96ed
16 files changed
+442 -10

No files matched your search

+16
View File
@@ -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.
+54
View File
@@ -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<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) },
}));
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("<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 })).toBeVisible();
await expect(panel.getByRole("tab", { name: "AGENTS.md", exact: true })).toHaveCount(1);
});
}
+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"
@@ -1,4 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
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";
import type {
@@ -359,6 +362,7 @@ function DocumentRow({
* thread.
*/
export function IssuePropertiesArtifactsTab({ issue, documentDeepLink, onOpenDocument }: IssuePropertiesArtifactsTabProps) {
const openTextAttachment = useContext(TextAttachmentContext);
const { data: attachments } = useQuery({
queryKey: queryKeys.issues.attachments(issue.id),
queryFn: () => 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 <li key={row.id} className="col-span-full min-w-0">
<button type="button" className={cn(ROW_CLASS, "w-full text-left hover:bg-accent/50")} onClick={() => openTextAttachment(textMetadata.attachmentId, textMetadata.originalFilename ?? wp.title)}>
<FileText className="size-4 shrink-0 text-muted-foreground" /><span className="min-w-0 flex-1 truncate">{wp.title}</span><ChevronRight className="size-4" />
</button>
</li>;
}
const markdownMetadata = getMarkdownWorkProductAttachmentMetadata(wp);
if (markdownMetadata) {
const reviewKey = artifactReviewDocumentKey(wp.id);
@@ -517,7 +529,7 @@ export function IssuePropertiesArtifactsTab({ issue, documentDeepLink, onOpenDoc
}
return (
<li key={row.id} className="col-span-full min-w-0">
<a href={attachmentOpenPath(attachment)} target="_blank" rel="noreferrer" className={cn(ROW_CLASS, "hover:bg-accent/50")}>
<a href={attachmentOpenPath(attachment)} onClick={(event) => { 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")}>
<Paperclip className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{attachment.originalFilename ?? attachment.objectKey}</span>
<span className="shrink-0 text-(length:--text-micro) text-muted-foreground">{formatBytes(attachment.byteSize)}</span>
@@ -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 : <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} onClick={(event) => { 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 : <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,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>{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,
@@ -396,6 +404,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",
@@ -653,7 +670,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} />
@@ -664,6 +685,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
@@ -6,6 +6,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";
@@ -2323,6 +2324,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="Media artifacts">
<p className="text-sm text-muted-foreground">Images and videos use gallery tiles. The whole tile opens the task gallery; files and links keep compact, fully clickable rows. Task/Artifact Gallery in Storybook covers playable videos, mixed files, narrow panels, and unavailable previews.</p>
<div className="grid max-w-2xl grid-cols-1 gap-3 sm:grid-cols-2">
+17 -2
View File
@@ -1,3 +1,4 @@
import { TextAttachmentContext } from "../context/TextAttachmentContext";
import { useUserPreferences } from "../hooks/useUserPreferences";
import { DispositionRecoveryProvider } from "../components/DispositionRecoveryNotice";
import { AgentAvatar } from "@/components/AgentAvatar";
@@ -2899,6 +2900,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);
@@ -3596,6 +3598,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 },
@@ -5652,6 +5661,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,
@@ -5689,6 +5700,8 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
issuePanelKey,
openNewSubIssue,
openPanel,
openAttachment,
handleAttachmentOpened,
openSkill,
handleSkillOpened,
panelChildIssues,
@@ -7389,7 +7402,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={
@@ -8175,6 +8188,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}
@@ -8254,7 +8269,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") } 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 } };