mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 13:38:01 +02:00
## 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>
74 lines
5.4 KiB
TypeScript
74 lines
5.4 KiB
TypeScript
// @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");
|
|
});
|
|
});
|