mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-02 02:07:25 +08:00
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:
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
].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") {
|
||||
|
||||
@@ -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);
|
||||
@@ -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 ?? ""));
|
||||
}
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 } };
|
||||
Reference in New Issue
Block a user