mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 10:13:11 +02:00
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:
1 parent
23fc19ffb7
commit
6beece96ed
16 files changed
+442
-10
No files matched your search
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 } };
|
||||
Reference in new issue
Block a user