diff --git a/doc/AGENT-ARTIFACTS.md b/doc/AGENT-ARTIFACTS.md
index 1d97ee4ae8c..e17d623d6a5 100644
--- a/doc/AGENT-ARTIFACTS.md
+++ b/doc/AGENT-ARTIFACTS.md
@@ -5,6 +5,20 @@ must be attached to the Paperclip issue before the agent chooses a final
disposition. A local workspace path is not enough, because cloud users and
reviewers often cannot access the agent's disk.
+## Upload files from a task
+
+People can open the task's Artifacts tab and use the upload icon, or drop files
+anywhere in the panel, including its header and existing artifact cards. Uploads
+save directly to the task. They do not send a message, assign an agent, or
+release a pause hold. Files from people and agents appear together in Artifacts.
+
+Markdown imports create task documents. Repeated filenames get a new document
+key instead of replacing an existing revision.
+Hidden system keys and artifact-review prefixes are reserved; imports with
+those filenames receive a visible upload document key. Other supported files use
+the existing attachment API and its type and size limits. Failed uploads show
+an inline Retry action while successful files remain available.
+
## Native runner
When `register_deliverable` is available, use it for files in the bound local or
diff --git a/tests/e2e/artifact-uploads.spec.ts b/tests/e2e/artifact-uploads.spec.ts
new file mode 100644
index 00000000000..8dc63aa6fb5
--- /dev/null
+++ b/tests/e2e/artifact-uploads.spec.ts
@@ -0,0 +1,149 @@
+import { randomUUID } from "node:crypto";
+import { expect, test, type APIRequestContext, type APIResponse, type Locator, type Page } from "@playwright/test";
+import { expectTaskPanelTab } from "./helpers/task-panel-tabs";
+
+// Real task UI, document revisions, attachment storage and pause holds.
+// Only the explicit failed-upload response in the recovery test is injected.
+async function json(response: APIResponse) {
+ expect(response.ok(), `${response.status()}: ${await response.text()}`).toBe(true);
+ return response.json();
+}
+
+async function setup(page: Page, request: APIRequestContext, mobile = false) {
+ await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 960 });
+ await page.addInitScript(() => localStorage.setItem("paperclip:panel-visible", "true"));
+ const companies = await json(await request.get("/api/companies"));
+ const company = companies.find((row: { name: string }) => row.name === "Artifact Upload Review") ?? await json(await request.post("/api/companies", { data: { name: `Artifact uploads ${randomUUID()}` } }));
+ const issue = await json(await request.post(`/api/companies/${company.id}/issues`, {
+ data: { title: `Upload handoff files ${randomUUID().slice(0, 8)}`, status: "backlog", assigneeAgentId: null, assigneeUserId: null },
+ }));
+ await json(await request.post(`/api/issues/${issue.id}/tree-holds`, { data: { mode: "pause" } }));
+ await page.goto(`/${company.issuePrefix}/issues/${issue.identifier}${mobile ? "?from=inbox&fromHref=%2Finbox%2Fmine" : ""}`);
+ await expect(page.getByRole("heading", { name: issue.title, exact: true })).toBeVisible();
+ if (mobile) {
+ await page.getByRole("button", { name: "More actions", exact: true }).click();
+ await page.getByRole("button", { name: "Properties", exact: true }).click();
+ }
+ const panel = mobile ? page.getByTestId("mobile-task-side-panel") : page.locator("aside").filter({ has: page.getByRole("button", { name: "Open a new tab", exact: true }) });
+ await panel.getByRole("button", { name: "Open a new tab", exact: true }).click();
+ await page.getByRole("option", { name: "Artifacts", exact: true }).click();
+ await expectTaskPanelTab(panel, mobile, "Artifacts");
+ await expect(panel.getByRole("button", { name: "Upload files", exact: true })).toBeVisible();
+ return { company, issue, panel };
+}
+
+async function chooseFiles(page: Page, panel: Locator, files: { name: string; mimeType: string; buffer: Buffer }[]) {
+ const chooser = page.waitForEvent("filechooser");
+ await panel.getByRole("button", { name: "Upload files", exact: true }).click();
+ await (await chooser).setFiles(files);
+}
+
+async function assertStillPaused(request: APIRequestContext, issueId: string) {
+ const issue = await json(await request.get(`/api/issues/${issueId}`));
+ expect(issue.status).toBe("backlog");
+ expect(issue.assigneeAgentId).toBeNull();
+ expect(issue.assigneeUserId).toBeNull();
+ const state = await json(await request.get(`/api/issues/${issueId}/tree-control/state`));
+ expect(state.activePauseHold).not.toBeNull();
+ expect(await json(await request.get(`/api/issues/${issueId}/comments`))).toEqual([]);
+ expect(await json(await request.get(`/api/issues/${issueId}/runs`))).toEqual([]);
+}
+
+for (const mobile of [false, true]) {
+ test(`upload documents and files while a task remains paused (mobile=${mobile})`, async ({ page, request }, testInfo) => {
+ const { issue, panel } = await setup(page, request, mobile);
+ await chooseFiles(page, panel, [
+ { name: "core-handoff.md", mimeType: "text/markdown", buffer: Buffer.from("# Engineering handoff\n\nStart with the loop inventory and preserve current task ownership.") },
+ { name: "loop-inventory.csv", mimeType: "text/csv", buffer: Buffer.from("path,pattern\ndelivery,event-driven\nroutines,clock-driven") },
+ ]);
+ await expect(panel.getByRole("button", { name: "Read document", exact: true })).toBeVisible();
+ await expect(panel.getByRole("link", { name: "Download file", exact: true })).toBeVisible();
+ await expectTaskPanelTab(panel, mobile, "Artifacts");
+ const download = page.waitForEvent("download");
+ await panel.getByRole("link", { name: "Download file", exact: true }).click();
+ expect((await download).suggestedFilename()).toBe("loop-inventory.csv");
+ await panel.getByRole("button", { name: "Read document", exact: true }).click();
+ await expect(panel.getByText("Start with the loop inventory and preserve current task ownership.", { exact: true })).toBeVisible();
+ await assertStillPaused(request, issue.id);
+ await page.reload();
+ await expect(page.getByRole("heading", { name: issue.title, exact: true })).toBeVisible();
+ const documents = await json(await request.get(`/api/issues/${issue.id}/documents`));
+ const attachments = await json(await request.get(`/api/issues/${issue.id}/attachments`));
+ expect(documents).toHaveLength(1);
+ expect(documents[0].key).toBe("core-handoff");
+ expect(attachments).toHaveLength(1);
+ expect(attachments[0].originalFilename).toBe("loop-inventory.csv");
+ await assertStillPaused(request, issue.id);
+ await page.screenshot({ path: testInfo.outputPath("uploaded-handoff-persisted.png"), fullPage: true });
+ });
+}
+
+test("the entire populated panel accepts a drop and preserves an existing document", async ({ page, request }, testInfo) => {
+ const { issue, panel } = await setup(page, request);
+ await chooseFiles(page, panel, [{ name: "handoff.md", mimeType: "text/markdown", buffer: Buffer.from("# Original handoff\n\nPreserve this version.") }]);
+ await expect(panel.getByRole("button", { name: "Read document", exact: true })).toBeVisible();
+ const transfer = await page.evaluateHandle(() => {
+ const data = new DataTransfer();
+ data.items.add(new File(["# Additional handoff\n\nNew reference, separate document."], "handoff.md", { type: "text/markdown" }));
+ data.items.add(new File(["Check saved deadlines before enabling instance sleep."], "verification.txt", { type: "text/plain" }));
+ return data;
+ });
+ const header = panel.locator("[data-presentation] > header");
+ await header.dispatchEvent("dragenter", { dataTransfer: transfer });
+ const overlay = panel.locator("[data-artifact-drop-overlay]");
+ await expect(overlay).toBeVisible();
+ const frameBounds = await panel.locator("[data-presentation]").boundingBox();
+ const overlayBounds = await overlay.boundingBox();
+ expect(overlayBounds).toEqual(frameBounds);
+ await page.screenshot({ path: testInfo.outputPath("entire-panel-dropzone.png"), fullPage: true });
+ await header.dispatchEvent("dragleave", { dataTransfer: transfer });
+ await expect(overlay).toBeHidden();
+ await header.dispatchEvent("dragenter", { dataTransfer: transfer });
+ await panel.locator("article").first().dispatchEvent("drop", { dataTransfer: transfer });
+ await expect(panel.getByRole("button", { name: "Read document", exact: true })).toHaveCount(2);
+ await expect(panel.getByText("verification.txt", { exact: true }).first()).toBeVisible();
+ const documents = await json(await request.get(`/api/issues/${issue.id}/documents`));
+ expect(documents.map((row: { key: string }) => row.key).sort()).toEqual(["handoff", "handoff-2"]);
+ expect(documents.find((row: { key: string }) => row.key === "handoff").body).toContain("Preserve this version.");
+ await assertStillPaused(request, issue.id);
+});
+
+test("a failed upload stays local and retry preserves the successful files", async ({ page, request }) => {
+ const { issue, panel } = await setup(page, request);
+ let fail = true;
+ await page.route(`**/api/companies/*/issues/${issue.id}/attachments`, async route => {
+ if (fail) { fail = false; await route.fulfill({ status: 422, contentType: "application/json", body: JSON.stringify({ error: "Upload failed. Try again." }) }); }
+ else await route.continue();
+ });
+ await chooseFiles(page, panel, [
+ { name: "failed-reference.txt", mimeType: "text/plain", buffer: Buffer.from("Reference restored after retry.") },
+ { name: "saved-reference.txt", mimeType: "text/plain", buffer: Buffer.from("Keep this successful file.") },
+ ]);
+ await expect(panel.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
+ await expect.poll(async () => (await json(await request.get(`/api/issues/${issue.id}/attachments`))).length).toBe(1);
+ await panel.getByRole("button", { name: "Retry", exact: true }).click();
+ await expect(panel.getByRole("button", { name: "Retry", exact: true })).toBeHidden();
+ await expect.poll(async () => (await json(await request.get(`/api/issues/${issue.id}/attachments`))).map((row: { originalFilename: string }) => row.originalFilename).sort()).toEqual(["failed-reference.txt", "saved-reference.txt"]);
+ await assertStillPaused(request, issue.id);
+});
+
+test("reserved filenames create visible documents without replacing internal revisions", async ({ page, request }) => {
+ const { issue, panel } = await setup(page, request);
+ const reserved = ["artifact-review-notes", "continuation-summary", "pipeline-case-body"];
+ const originals = new Map();
+ for (const key of reserved) {
+ const document = await json(await request.put(`/api/issues/${issue.id}/documents/${key}`, {
+ data: { title: "Internal context", format: "markdown", body: `Preserve the internal ${key} document.`, baseRevisionId: null },
+ }));
+ originals.set(key, document.latestRevisionId);
+ }
+ await chooseFiles(page, panel, reserved.map(key => ({ name: `${key}.md`, mimeType: "text/markdown", buffer: Buffer.from(`# Uploaded ${key}\n\nThis reference must stay visible.`) })));
+ await expect(panel.getByRole("button", { name: "Read document", exact: true })).toHaveCount(3);
+ const documents = await json(await request.get(`/api/issues/${issue.id}/documents`));
+ expect(documents.filter((row: { key: string }) => row.key.startsWith("upload-")).map((row: { key: string }) => row.key).sort()).toEqual(reserved.map(key => `upload-${key}`).sort());
+ for (const key of reserved) {
+ const document = await json(await request.get(`/api/issues/${issue.id}/documents/${key}`));
+ expect(document.latestRevisionId).toBe(originals.get(key));
+ }
+ await assertStillPaused(request, issue.id);
+});
diff --git a/ui/src/components/artifacts/ArtifactDropzone.tsx b/ui/src/components/artifacts/ArtifactDropzone.tsx
new file mode 100644
index 00000000000..7830ac1bc0a
--- /dev/null
+++ b/ui/src/components/artifacts/ArtifactDropzone.tsx
@@ -0,0 +1,75 @@
+import { useEffect, useRef, useState, type ReactNode } from "react";
+import { createPortal } from "react-dom";
+import { Upload } from "lucide-react";
+
+/** The target can be the host frame when its tab header is rendered by a portal. */
+export function ArtifactDropzone({ children, onUpload, enabled = true, target }: {
+ children: ReactNode;
+ onUpload: (files: File[]) => void;
+ enabled?: boolean;
+ target?: HTMLElement | null;
+}) {
+ const rootRef = useRef(null);
+ const uploadRef = useRef(onUpload);
+ uploadRef.current = onUpload;
+ const [dragging, setDragging] = useState(false);
+
+ useEffect(() => {
+ const element = target ?? rootRef.current;
+ setDragging(false);
+ if (!enabled || !element) return;
+ let depth = 0;
+ const hasFiles = (event: DragEvent) => Array.from(event.dataTransfer?.types ?? []).includes("Files");
+ const enter = (event: DragEvent) => {
+ if (!hasFiles(event)) return;
+ event.preventDefault();
+ event.stopPropagation();
+ depth += 1;
+ setDragging(true);
+ };
+ const over = (event: DragEvent) => {
+ if (!hasFiles(event)) return;
+ event.preventDefault();
+ event.stopPropagation();
+ if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
+ };
+ const leave = (event: DragEvent) => {
+ if (!hasFiles(event)) return;
+ event.stopPropagation();
+ depth = Math.max(0, depth - 1);
+ if (depth === 0) setDragging(false);
+ };
+ const drop = (event: DragEvent) => {
+ if (!hasFiles(event)) return;
+ event.preventDefault();
+ event.stopPropagation();
+ depth = 0;
+ setDragging(false);
+ const files = Array.from(event.dataTransfer?.files ?? []);
+ if (files.length) uploadRef.current(files);
+ };
+ element.addEventListener("dragenter", enter);
+ element.addEventListener("dragover", over);
+ element.addEventListener("dragleave", leave);
+ element.addEventListener("drop", drop);
+ return () => {
+ element.removeEventListener("dragenter", enter);
+ element.removeEventListener("dragover", over);
+ element.removeEventListener("dragleave", leave);
+ element.removeEventListener("drop", drop);
+ };
+ }, [enabled, target]);
+
+ const overlay = dragging ? (
+
+
+
Drop files to add them to this task
+
+ ) : null;
+ return (
+
+ {children}
+ {target && overlay ? createPortal(overlay, target) : overlay}
+
+ );
+}
diff --git a/ui/src/components/artifacts/ArtifactUploadArea.tsx b/ui/src/components/artifacts/ArtifactUploadArea.tsx
new file mode 100644
index 00000000000..5b36c005c25
--- /dev/null
+++ b/ui/src/components/artifacts/ArtifactUploadArea.tsx
@@ -0,0 +1,22 @@
+import type { ReactNode } from "react";
+import { SidePanelFrame } from "@/components/side-panel/SidePanelFrame";
+import { ArtifactDropzone } from "./ArtifactDropzone";
+import { ArtifactUploadButton, ArtifactUploadContent, type ArtifactUploadEntry } from "./ArtifactUploadControls";
+export type { ArtifactUploadEntry } from "./ArtifactUploadControls";
+
+/** Standalone composition of the same controls used by task panel hosts. */
+export function ArtifactUploadArea({ children, header, empty = false, entries = [], onUpload, onRetry, onDismiss }: {
+ children?: ReactNode;
+ header?: ReactNode;
+ empty?: boolean;
+ entries?: ArtifactUploadEntry[];
+ onUpload: (files: File[]) => void;
+ onRetry?: (id: string) => void;
+ onDismiss?: (id: string) => void;
+}) {
+ return
+ }>
+ {children}
+
+ ;
+}
diff --git a/ui/src/components/artifacts/ArtifactUploadControls.tsx b/ui/src/components/artifacts/ArtifactUploadControls.tsx
new file mode 100644
index 00000000000..ae86a645890
--- /dev/null
+++ b/ui/src/components/artifacts/ArtifactUploadControls.tsx
@@ -0,0 +1,66 @@
+import { useRef, type ReactNode } from "react";
+import { FileUp, Loader2, Upload, X } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+
+export interface ArtifactUploadEntry {
+ id: string;
+ filename: string;
+ status: "uploading" | "error";
+ error?: string;
+}
+
+export function ArtifactUploadButton({ onUpload }: { onUpload: (files: File[]) => void }) {
+ const inputRef = useRef(null);
+ return <>
+ {
+ const files = Array.from(event.target.files ?? []);
+ event.target.value = "";
+ if (files.length) onUpload(files);
+ }} />
+
+
+
+
+ Upload files
+
+ >;
+}
+
+export function ArtifactUploadContent({ children, empty = false, entries = [], onRetry, onDismiss }: {
+ children?: ReactNode;
+ empty?: boolean;
+ entries?: ArtifactUploadEntry[];
+ onRetry?: (id: string) => void;
+ onDismiss?: (id: string) => void;
+}) {
+ return
+ {entries.length > 0 ?
+ {entries.map((entry) => -
+ {entry.status === "uploading"
+ ?
+ : }
+
+ {entry.filename}
+
+ {entry.status === "uploading" ? "Uploading…" : entry.error ?? "Upload failed. Try again."}
+
+
+ {entry.status === "error" ?
+ {onRetry ? : null}
+ {onDismiss ? : null}
+
: null}
+ )}
+
: null}
+ {empty ?
+
+
+
Add files to this task
+
Drop files anywhere in this panel or use the upload icon.
+
+
: children}
+
;
+}
diff --git a/ui/src/components/issue-properties/IssueProperties.tsx b/ui/src/components/issue-properties/IssueProperties.tsx
index 2b00c76abae..2eb00aa6503 100644
--- a/ui/src/components/issue-properties/IssueProperties.tsx
+++ b/ui/src/components/issue-properties/IssueProperties.tsx
@@ -29,7 +29,7 @@ import { issuesApi } from "../../api/issues";
import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument";
import { useIssueDocuments } from "@/hooks/useIssueDocuments";
import { useStreamlinedUiEnabled } from "@/hooks/useStreamlinedUiEnabled";
-import { selectAgentArtifactAttachments } from "@/lib/issue-artifacts";
+import { selectArtifactAttachments } from "@/lib/issue-artifacts";
import { projectsApi } from "../../api/projects";
import { useCompany } from "../../context/CompanyContext";
import { useSidebar } from "../../context/SidebarContext";
@@ -321,12 +321,11 @@ export function IssueProperties({
|| paneTabStandaloneDocuments.length > 0;
// Artifacts covers the same three sources the tab body composes: work
// products, documents (redundant with the Plan tab, intentionally), and
- // agent-created attachments. User comment uploads stay thread-only and
- // no longer summon the tab.
+ // attachments from agents and people.
const hasArtifactsTab =
(paneTabWorkProducts?.length ?? 0) > 0
|| paneTabStandaloneDocuments.length > 0
- || selectAgentArtifactAttachments(paneTabAttachments, paneTabWorkProducts).length > 0;
+ || selectArtifactAttachments(paneTabAttachments, paneTabWorkProducts).length > 0;
const [paneTab, setPaneTab] = useState("properties");
const [closedPaneTabs, setClosedPaneTabs] = useState>(() => new Set());
// Once a plan document exists, surface it: switch the pane to the Plan tab so
diff --git a/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx b/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx
index 17d1bd1cffc..bc573fba519 100644
--- a/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx
+++ b/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx
@@ -1,5 +1,6 @@
import { TextAttachmentContext } from "@/context/TextAttachmentContext";
-import { useContext, useEffect, useMemo, useRef, useState } from "react";
+import { useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
+import { ArtifactUploadContent, type ArtifactUploadEntry } from "@/components/artifacts/ArtifactUploadControls";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
AttachmentArtifactWorkProductMetadata,
@@ -23,7 +24,7 @@ import { useIssueDocuments } from "@/hooks/useIssueDocuments";
import { useIssueWorkProducts } from "@/hooks/useIssueWorkProducts";
import {
documentDisplayTitle,
- selectAgentArtifactAttachments,
+ selectArtifactAttachments,
} from "@/lib/issue-artifacts";
import { DocumentCard } from "@/components/artifacts/RichArtifactCards";
import {
@@ -44,6 +45,7 @@ import { useLocation } from "@/lib/router";
interface IssuePropertiesArtifactsTabProps {
issue: Issue;
+ uploadState?: { entries: ArtifactUploadEntry[]; retry: (id: string) => void; dismiss: (id: string) => void };
onOpenDocument?: (document: IssueDocument) => void;
documentDeepLink?: {
requestId: number;
@@ -355,17 +357,16 @@ function DocumentRow({
/**
* Artifacts tab of the properties pane (PAP-491).
*
- * A read-only "what did this task produce" view composed from three sources:
+ * Task files and output composed from three sources:
* work products, issue documents (also readable in the Plan tab — the
- * redundancy is intentional), and agent-created attachments. Attachments
- * already promoted to attachment-backed work products are deduped out, and
- * user uploads are excluded — those stay first-class in the conversation
- * thread.
+ * redundancy is intentional), and attachments from agents and people.
+ * Attachments already promoted to work products are deduped out.
*/
export function IssuePropertiesArtifactsTab({
issue,
documentDeepLink,
onOpenDocument,
+ uploadState,
}: IssuePropertiesArtifactsTabProps) {
const {
data: attachments,
@@ -406,7 +407,7 @@ export function IssuePropertiesArtifactsTab({
const reviewDocsByKey = new Map(
(documents ?? []).map((doc) => [doc.key, doc]),
);
- const fileRows = selectAgentArtifactAttachments(attachments, workProducts);
+ const fileRows = selectArtifactAttachments(attachments, workProducts);
const runsById = useMemo(
() => new Map((runs ?? []).map((run) => [run.runId, run])),
[runs],
@@ -513,21 +514,26 @@ export function IssuePropertiesArtifactsTab({
) : null;
+ const withUploads = (content: ReactNode, empty = false) => uploadState ? (
+ {content}
+ ) : content;
+
if (
workProductRows.length === 0 &&
documentRows.length === 0 &&
fileRows.length === 0
) {
- if (feedback) return feedback;
- return (
+ if (feedback) return withUploads(feedback);
+ return withUploads(
No artifacts yet. Work products, documents, and agent-produced files
will appear here.
-
+ ,
+ true,
);
}
- return (
+ return withUploads(
{feedback}
{groupedRows.map((group) => {
diff --git a/ui/src/components/issue-properties/IssuePropertiesMarkdownWorkProduct.test.tsx b/ui/src/components/issue-properties/IssuePropertiesMarkdownWorkProduct.test.tsx
index 0b52378d5e4..d2fc25f5ffc 100644
--- a/ui/src/components/issue-properties/IssuePropertiesMarkdownWorkProduct.test.tsx
+++ b/ui/src/components/issue-properties/IssuePropertiesMarkdownWorkProduct.test.tsx
@@ -415,7 +415,7 @@ describe("markdown work product review row", () => {
expect(container.querySelector("button[aria-expanded]")).toBeNull();
});
- it.each(["image/png", "application/octet-stream"])("renders %s media tiles without duplicates or user uploads", async (contentType) => {
+ it.each(["image/png", "application/octet-stream"])("renders %s media tiles with user uploads and without promoted duplicates", async (contentType) => {
const image = { ...makeMarkdownAttachment(), contentType, originalFilename: "cover.png" };
const looseImage = { ...image, id: "loose-image", contentPath: "/api/attachments/loose-image/content" };
const video = { ...makeMarkdownAttachment(), id: "loose-video", contentType: "application/octet-stream", originalFilename: "clip.mp4", contentPath: "/api/attachments/loose-video/content" };
@@ -427,11 +427,11 @@ describe("markdown work product review row", () => {
await renderTab({}, "Cover artwork");
await waitForAssertion(() => {
const buttons = container.querySelectorAll('button[aria-label^="View image:"], button[aria-label^="Open video:"]');
- expect(buttons).toHaveLength(3);
+ expect(buttons).toHaveLength(4);
expect(container.querySelectorAll("img")).toHaveLength(2);
- expect(container.querySelectorAll("article")).toHaveLength(3);
+ expect(container.querySelectorAll("article")).toHaveLength(4);
expect(container.querySelector("video")?.getAttribute("src")).toBe(video.contentPath);
- expect(container.querySelectorAll("video")).toHaveLength(1);
+ expect(container.querySelectorAll("video")).toHaveLength(2);
});
});
diff --git a/ui/src/components/side-panel/SidePanelFrame.tsx b/ui/src/components/side-panel/SidePanelFrame.tsx
index 614455e81b4..320c9cde4ff 100644
--- a/ui/src/components/side-panel/SidePanelFrame.tsx
+++ b/ui/src/components/side-panel/SidePanelFrame.tsx
@@ -49,7 +49,7 @@ export function SidePanelFrame({
data-maximized={maximized ? "true" : "false"}
data-resizing={resizing ? "true" : "false"}
className={cn(
- "flex min-h-0 min-w-0 flex-col overflow-hidden bg-(--side-panel-bg) text-(--side-panel-fg)",
+ "relative flex min-h-0 min-w-0 flex-col overflow-hidden bg-(--side-panel-bg) text-(--side-panel-fg)",
presentation === "docked" && "h-full border-l border-border",
presentation === "sheet" && "h-full w-full",
presentation === "embedded" && "h-full rounded-xl border border-border",
diff --git a/ui/src/components/task-side-panel/TaskSidePanel.tsx b/ui/src/components/task-side-panel/TaskSidePanel.tsx
index 3e1e5e754f0..ee28cab900d 100644
--- a/ui/src/components/task-side-panel/TaskSidePanel.tsx
+++ b/ui/src/components/task-side-panel/TaskSidePanel.tsx
@@ -37,6 +37,9 @@ import { WorkspaceFileBrowser } from "@/components/WorkspaceFileBrowser";
import { AgentArtifactsPanel, AgentTasksPanel } from "@/components/chat/AgentWorkPanels";
import { useAgentChatEnabled } from "@/hooks/useAgentChatEnabled";
import { IssuePropertiesArtifactsTab } from "@/components/issue-properties/IssuePropertiesArtifactsTab";
+import { ArtifactDropzone } from "@/components/artifacts/ArtifactDropzone";
+import { ArtifactUploadButton } from "@/components/artifacts/ArtifactUploadControls";
+import { useIssueArtifactUploads } from "@/hooks/useIssueArtifactUploads";
import { IssuePropertiesPlansTab } from "@/components/issue-properties/IssuePropertiesPlansTab";
import { TaskDetailSubtasksPanel } from "@/components/task-detail/TaskDetailRelationsPanel";
import {
@@ -274,6 +277,7 @@ export function TaskSidePanel({
const conversationAgentId = agentChatEnabled ? issue.conversationAgentId ?? null : null;
const showRelatedTasks = showSubtasksTab && !conversationAgentId;
const viewer = useTaskSidePanelFileRouting();
+ const artifactUploads = useIssueArtifactUploads(issue);
const { data: documentsData } = useIssueDocuments(issue.id);
const documents = documentsData ?? EMPTY_ISSUE_DOCUMENTS;
const { data: planDocument } = useIssuePlanDocument(issue.id);
@@ -353,6 +357,7 @@ export function TaskSidePanel({
}
}, [openBrowserId, controller.activeTabId, onBrowserOpened, accountScope, issue.companyId, issue.id, fileTabsEnabled]);
const activeTab = controller.tabs.find((tab) => tab.id === controller.activeTabId) ?? null;
+ const canUploadArtifacts = activeTab?.payload.kind === "artifacts" && !conversationAgentId && !issue.id.startsWith("chat:");
const subtasksAvailable = showRelatedTasks && (taskCount > 0 || tasksTab?.hasError === true);
const hasSubtasksTab = controller.tabs.some((tab) => tab.id === "subtasks");
@@ -760,7 +765,7 @@ export function TaskSidePanel({
markInteracted();
controller.openTab(taskPanelAttachmentTab(id, title));
if (viewer.state || viewer.browse) viewer.close();
- }}>
;
+ }}>
;
} else if (activeTab.payload.kind === "issue-document") {
content = activeTab.payload.documentKey === "plan" ? (
@@ -815,11 +820,14 @@ export function TaskSidePanel({
}
const contentMode = activeTab?.contentMode ?? "full-bleed";
+ const header = <>{tabStrip}{canUploadArtifacts ?
: null}>;
return (
+
("[data-presentation]")} onUpload={artifactUploads.upload}>
- {paneHeaderSlot ? createPortal(tabStrip, paneHeaderSlot) : (
+
{artifactUploads.announcement}
+ {paneHeaderSlot ? createPortal(header, paneHeaderSlot) : (
- {tabStrip}
+ {header}
{onRequestClose ? (
mobile ? (
+
);
}
diff --git a/ui/src/hooks/useIssueArtifactUploads.ts b/ui/src/hooks/useIssueArtifactUploads.ts
new file mode 100644
index 00000000000..bc058b77fc4
--- /dev/null
+++ b/ui/src/hooks/useIssueArtifactUploads.ts
@@ -0,0 +1,65 @@
+import { useCallback, useRef, useState } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import type { Issue, IssueAttachment, IssueDocument } from "@paperclipai/shared";
+import { issuesApi } from "@/api/issues";
+import { queryKeys } from "@/lib/queryKeys";
+import { uploadedDocumentKey } from "@/lib/issue-artifact-upload";
+import type { ArtifactUploadEntry } from "@/components/artifacts/ArtifactUploadControls";
+
+export function useIssueArtifactUploads(issue: Pick
) {
+ const client = useQueryClient();
+ const [entries, setEntries] = useState([]);
+ const [announcement, setAnnouncement] = useState("");
+ const files = useRef(new Map());
+ const queue = useRef>(Promise.resolve());
+
+ const enqueue = useCallback((batch: { id: string; file: File }[]) => {
+ for (const { id, file } of batch) files.current.set(id, file);
+ setEntries(current => [
+ ...current.filter(entry => !batch.some(file => file.id === entry.id)),
+ ...batch.map(({ id, file }): ArtifactUploadEntry => ({ id, filename: file.name, status: "uploading" })),
+ ]);
+ // Serialize imports so repeated filenames in successive selections get
+ // distinct keys. The server still rejects concurrent document creates.
+ queue.current = queue.current.then(async () => {
+ for (const { id, file } of batch) {
+ try {
+ if (/\.(md|markdown)$/i.test(file.name) || file.type === "text/markdown") {
+ const body = await file.text();
+ const documents = await issuesApi.listDocuments(issue.id);
+ const basename = file.name.replace(/\.[^.]+$/, "");
+ const key = uploadedDocumentKey(file.name, documents.map(document => document.key));
+ const document = await issuesApi.upsertDocument(issue.id, key, {
+ title: basename.replace(/[-_]+/g, " ").slice(0, 200), format: "markdown", body, baseRevisionId: null,
+ });
+ client.setQueryData([...queryKeys.issues.documents(issue.id), "list"], current => [document, ...(current ?? []).filter(row => row.id !== document.id)]);
+ void client.invalidateQueries({ queryKey: queryKeys.issues.documents(issue.id) });
+ } else {
+ const attachment = await issuesApi.uploadAttachment(issue.companyId, issue.id, file);
+ client.setQueryData(queryKeys.issues.attachments(issue.id), current => [attachment, ...(current ?? []).filter(row => row.id !== attachment.id)]);
+ void client.invalidateQueries({ queryKey: queryKeys.issues.attachments(issue.id) });
+ }
+ void client.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) });
+ files.current.delete(id);
+ setEntries(current => current.filter(entry => entry.id !== id));
+ setAnnouncement(`${file.name} added to this task.`);
+ } catch (error) {
+ setEntries(current => current.map(entry => entry.id === id ? {
+ ...entry, status: "error", error: error instanceof Error ? error.message : "Upload failed. Try again.",
+ } : entry));
+ }
+ }
+ });
+ }, [client, issue.companyId, issue.id]);
+
+ const upload = useCallback((picked: File[]) => enqueue(picked.map(file => ({ id: crypto.randomUUID(), file }))), [enqueue]);
+ const retry = useCallback((id: string) => {
+ const file = files.current.get(id);
+ if (file) enqueue([{ id, file }]);
+ }, [enqueue]);
+ const dismiss = useCallback((id: string) => {
+ files.current.delete(id);
+ setEntries(current => current.filter(entry => entry.id !== id));
+ }, []);
+ return { entries, announcement, upload, retry, dismiss };
+}
diff --git a/ui/src/lib/issue-artifact-upload.test.ts b/ui/src/lib/issue-artifact-upload.test.ts
new file mode 100644
index 00000000000..d4cfb1d58c8
--- /dev/null
+++ b/ui/src/lib/issue-artifact-upload.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from "vitest";
+import { isArtifactReviewDocumentKey, isSystemIssueDocumentKey, issueDocumentKeySchema } from "@paperclipai/shared";
+import { uploadedDocumentKey } from "./issue-artifact-upload";
+
+describe("uploadedDocumentKey", () => {
+ it.each(["artifact-review-notes", "continuation-summary", "pipeline-case-body"])("keeps %s uploads visible and separate from internal documents", (reserved) => {
+ const key = uploadedDocumentKey(`${reserved}.md`, [reserved]);
+ expect(key).toBe(`upload-${reserved}`);
+ expect(isArtifactReviewDocumentKey(key)).toBe(false);
+ expect(isSystemIssueDocumentKey(key)).toBe(false);
+ expect(uploadedDocumentKey(`${reserved}.md`, [key])).toBe(`${key}-2`);
+ });
+
+ it("preserves existing filename-derived documents and bounds long keys", () => {
+ expect(uploadedDocumentKey("Core handoff.md", ["core-handoff", "core-handoff-2"])).toBe("core-handoff-3");
+ const filename = `artifact-review-${"notes-".repeat(30)}.md`;
+ const first = uploadedDocumentKey(filename, []);
+ const repeated = uploadedDocumentKey(filename, [first]);
+ expect(issueDocumentKeySchema.safeParse(repeated).success).toBe(true);
+ expect(isArtifactReviewDocumentKey(repeated)).toBe(false);
+ expect(repeated).not.toBe(first);
+ });
+});
diff --git a/ui/src/lib/issue-artifact-upload.ts b/ui/src/lib/issue-artifact-upload.ts
new file mode 100644
index 00000000000..61355412d50
--- /dev/null
+++ b/ui/src/lib/issue-artifact-upload.ts
@@ -0,0 +1,15 @@
+import { isArtifactReviewDocumentKey, isSystemIssueDocumentKey } from "@paperclipai/shared";
+
+/** Uploaded filenames must never select a hidden or internal document key. */
+export function uploadedDocumentKey(filename: string, existingKeys: Iterable): string {
+ const slug = filename.replace(/\.[^.]+$/, "").toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "document";
+ const visibleSlug = isSystemIssueDocumentKey(slug) || isArtifactReviewDocumentKey(slug)
+ ? `upload-${slug}` : slug;
+ const base = visibleSlug.slice(0, 54);
+ const keys = new Set(existingKeys);
+ let key = base;
+ let suffix = 2;
+ while (keys.has(key)) key = `${base}-${suffix++}`;
+ return key;
+}
diff --git a/ui/src/lib/issue-artifacts.test.ts b/ui/src/lib/issue-artifacts.test.ts
index 42dc6d3ee8b..1326c06a778 100644
--- a/ui/src/lib/issue-artifacts.test.ts
+++ b/ui/src/lib/issue-artifacts.test.ts
@@ -5,6 +5,7 @@ import {
getAttachmentBackedWorkProductAttachmentIds,
isAgentAttachment,
selectAgentArtifactAttachments,
+ selectArtifactAttachments,
workProductHref,
} from "./issue-artifacts";
@@ -134,6 +135,16 @@ describe("selectAgentArtifactAttachments", () => {
});
});
+describe("selectArtifactAttachments", () => {
+ it("includes files from people while deduping promoted files", () => {
+ const promotedId = "00000000-0000-4000-8000-000000000001";
+ const user = makeAttachment({ id: "user-file", createdByUserId: "user-1", issueCommentId: "comment-1" });
+ const agent = makeAttachment({ id: "agent-file", createdByAgentId: "agent-1" });
+ const promoted = makeAttachment({ id: promotedId, createdByUserId: "user-1" });
+ expect(selectArtifactAttachments([user, agent, promoted], [makePromotingWorkProduct(promotedId)])).toEqual([user, agent]);
+ });
+});
+
describe("getAttachmentBackedWorkProductAttachmentIds", () => {
it("collects attachment ids across content types", () => {
const imageId = "00000000-0000-4000-8000-000000000001";
diff --git a/ui/src/lib/issue-artifacts.ts b/ui/src/lib/issue-artifacts.ts
index f3f3d8a6d1f..e7af1582bbb 100644
--- a/ui/src/lib/issue-artifacts.ts
+++ b/ui/src/lib/issue-artifacts.ts
@@ -51,6 +51,15 @@ export function selectAgentArtifactAttachments(
);
}
+/** All task files, regardless of author, with promoted work products deduped. */
+export function selectArtifactAttachments(
+ attachments: IssueAttachment[] | null | undefined,
+ workProducts: IssueWorkProduct[] | null | undefined,
+): IssueAttachment[] {
+ const promoted = getAttachmentBackedWorkProductAttachmentIds(workProducts);
+ return (attachments ?? []).filter(attachment => !promoted.has(attachment.id));
+}
+
/**
* Where a work-product row should link. Many rows carry `url: null` with the
* usable link only in metadata — `openPath` for attachment-backed artifacts
diff --git a/ui/storybook/stories/artifact-upload/01-components.stories.tsx b/ui/storybook/stories/artifact-upload/01-components.stories.tsx
new file mode 100644
index 00000000000..2e33e82d78b
--- /dev/null
+++ b/ui/storybook/stories/artifact-upload/01-components.stories.tsx
@@ -0,0 +1,22 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { UploadPreview } from "./UploadPreview";
+import { showFileDrag } from "./fixtures";
+
+const meta = {
+ title: "Artifact upload/01 Artifacts pane", component: UploadPreview,
+ parameters: { layout: "fullscreen", docs: { description: { component: "The upload icon sits in the tab header without text or a background. The entire Artifacts panel, including its header, padding, existing cards and empty space, accepts files. Drag-over stories enter through the actual header drag handler and highlight the complete visible panel, including after scrolling. Use the upload icon for one or several files. Markdown becomes a document; other files use artifact cards. Uploads are simulated in memory and reset when the story reloads. The Uploading story holds its example pending; Retry completes the sample CSV upload. No task or execution state changes." }, story: { inline: false } } },
+ argTypes: { state: { control: "select", options: ["empty", "populated", "uploading", "error", "long"] } },
+ render: (args, context) =>
,
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+export const Empty: Story = { args: { state: "empty" } };
+export const Populated: Story = { args: { state: "populated" } };
+export const Uploading: Story = { args: { state: "uploading" } };
+export const RetryUpload: Story = { args: { state: "error" } };
+export const LongReference: Story = { args: { state: "long" } };
+export const DragOverEmptyPanel: Story = { args: { state: "empty" }, play: ({ canvasElement }) => showFileDrag(canvasElement) };
+export const DragOverExistingArtifacts: Story = { args: { state: "populated" }, play: ({ canvasElement }) => showFileDrag(canvasElement) };
+export const DragOverScrolledArtifacts: Story = { args: { state: "long" }, play: ({ canvasElement }) => showFileDrag(canvasElement, true) };
+export const Mobile: Story = { args: { state: "populated" }, globals: { viewport: { value: "mobile", isRotated: false } } };
+export const MobileError: Story = { args: { state: "error" }, globals: { viewport: { value: "mobile", isRotated: false } } };
diff --git a/ui/storybook/stories/artifact-upload/02-task-journey.stories.tsx b/ui/storybook/stories/artifact-upload/02-task-journey.stories.tsx
new file mode 100644
index 00000000000..6068e2d1848
--- /dev/null
+++ b/ui/storybook/stories/artifact-upload/02-task-journey.stories.tsx
@@ -0,0 +1,59 @@
+import { useEffect, useState } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Route, Routes } from "@/lib/router";
+import { Layout } from "@/components/Layout";
+import { TaskChatBubble } from "@/components/task-chat/TaskChatBubble";
+import { TaskChatPausedTakeover } from "@/components/task-chat/TaskChatPausedTakeover";
+import { StatusBadge } from "@/components/StatusBadge";
+import { Button } from "@/components/ui/button";
+import { ArrowLeft, Box, X } from "lucide-react";
+import { PluginLauncherProvider } from "@/plugins/launchers";
+import { useBreadcrumbs } from "@/context/BreadcrumbContext";
+import { useSidebar } from "@/context/SidebarContext";
+import { UploadPreview } from "./UploadPreview";
+import { issue, showFileDrag, type PreviewState } from "./fixtures";
+
+function TaskPage({ state = "empty", panelOpenInitially = false }: { state?: PreviewState; panelOpenInitially?: boolean }) {
+ const { setBreadcrumbs } = useBreadcrumbs();
+ const { isMobile: mobile } = useSidebar();
+ const [open, setOpen] = useState(panelOpenInitially);
+ useEffect(() => { setBreadcrumbs([{ label: "Tasks", href: "/PAP/issues" }, { label: issue.title, identifier: issue.identifier ?? undefined }]); }, [setBreadcrumbs]);
+ return
+
+
+
+
+
PAP-214Unassigned
+ {mobile ?
: null}
+
+ {issue.title}
+
+
+
+
+
+
+
+
;
+}
+
+function Journey(props: { state?: PreviewState; panelOpenInitially?: boolean }) {
+ return }>} />;
+}
+
+const meta = {
+ title: "Artifact upload/02 Paused task journey", component: Journey,
+ parameters: { layout: "fullscreen", initialEntries: ["/PAP/issues/PAP-214"], docs: { description: { component: "Start on the paused, unassigned PAP-214 placeholder in the production app shell. Upload or drop files in Artifacts, open a document in another tab, then return to Artifacts. On mobile, open Artifacts from the task header. Uses production chat bubbles, paused takeover, panel frame, desktop/mobile tabs, artifact list and document viewer. The story composes these components for review; upload persistence is simulated. No live issue is modified or resumed." }, story: { inline: false } } },
+ render: (args, context) => ,
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+export const AddToPlaceholder: Story = { args: { state: "empty" } };
+export const HandoffAttached: Story = { args: { state: "populated" } };
+export const DragOverArtifacts: Story = { args: { state: "populated" }, play: ({ canvasElement }) => showFileDrag(canvasElement) };
+export const RecoverAnUpload: Story = { args: { state: "error" } };
+export const MobileFromTask: Story = { args: { state: "empty" }, globals: { viewport: { value: "mobile", isRotated: false } } };
+export const MobileArtifacts: Story = { args: { state: "populated", panelOpenInitially: true }, globals: { viewport: { value: "mobile", isRotated: false } } };
diff --git a/ui/storybook/stories/artifact-upload/UploadPreview.tsx b/ui/storybook/stories/artifact-upload/UploadPreview.tsx
new file mode 100644
index 00000000000..556346f750b
--- /dev/null
+++ b/ui/storybook/stories/artifact-upload/UploadPreview.tsx
@@ -0,0 +1,105 @@
+import { useLayoutEffect, useRef, useState, type MouseEvent } from "react";
+import { QueryClientProvider, useQuery } from "@tanstack/react-query";
+import type { IssueDocument } from "@paperclipai/shared";
+import { ArtifactUploadArea, type ArtifactUploadEntry } from "@/components/artifacts/ArtifactUploadArea";
+import { IssuePropertiesArtifactsTab } from "@/components/issue-properties/IssuePropertiesArtifactsTab";
+import { TaskDocumentPanel } from "@/components/task-side-panel/TaskDocumentPanel";
+import { SidePanelFrame } from "@/components/side-panel/SidePanelFrame";
+import { SidePanelTabs } from "@/components/side-panel/SidePanelTabs";
+import { SidePanelMobileTabs } from "@/components/side-panel/SidePanelMobileTabs";
+import type { SidePanelTabItem } from "@/components/side-panel/types";
+import { queryKeys } from "@/lib/queryKeys";
+import { useSidebar } from "@/context/SidebarContext";
+import { addPreviewFile, createPreviewClient, installPreviewApi, issue, type PreviewState } from "./fixtures";
+
+export function UploadPreview({ state = "empty" }: { state?: PreviewState }) {
+ const [client] = useState(() => createPreviewClient(state));
+ const [entries, setEntries] = useState(() => state === "error" ? [{ id: "failed-upload", filename: "remaining-work.csv", status: "error", error: "Connection lost. Your file hasn’t been added." }] : state === "uploading" ? [{ id: "pending-upload", filename: "remaining-work.csv", status: "uploading" }] : []);
+ const files = useRef(new Map());
+ const alive = useRef(true);
+ const [announcement, setAnnouncement] = useState("");
+ const [tabs, setTabs] = useState([{ id: "artifacts", type: "view", label: "Artifacts", closable: false }]);
+ const [activeTab, setActiveTab] = useState("artifacts");
+ const [selectedDocuments, setSelectedDocuments] = useState>({});
+ const { isMobile: mobile } = useSidebar();
+ const docQuery = useQuery({ queryKey: [...queryKeys.issues.documents(issue.id), "list"], queryFn: async () => [] as IssueDocument[], initialData: client.getQueryData([...queryKeys.issues.documents(issue.id), "list"]), staleTime: Infinity }, client);
+ const productQuery = useQuery({ queryKey: queryKeys.issues.workProducts(issue.id), queryFn: async () => [], initialData: client.getQueryData(queryKeys.issues.workProducts(issue.id)), staleTime: Infinity }, client);
+
+ useLayoutEffect(() => {
+ alive.current = true;
+ files.current.set("failed-upload", new File(["path,pattern\ndelivery,event-driven\nroutines,clock-driven"], "remaining-work.csv", { type: "text/csv" }));
+ const restore = installPreviewApi(client);
+ return () => { alive.current = false; restore(); };
+ }, [client]);
+
+ async function upload(batch: { id: string; file: File }[]) {
+ for (const { id, file } of batch) {
+ files.current.set(id, file);
+ setEntries(current => [...current.filter(entry => entry.id !== id), { id, filename: file.name, status: "uploading" }]);
+ }
+ await Promise.all(batch.map(async ({ id, file }) => {
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ if (!alive.current) return;
+ try {
+ await addPreviewFile(client, file, id);
+ setEntries(current => current.filter(entry => entry.id !== id));
+ setAnnouncement(`${file.name} added to this task.`);
+ files.current.delete(id);
+ } catch {
+ setEntries(current => current.map(entry => entry.id === id ? { ...entry, status: "error", error: "This file couldn’t be read. Try again." } : entry));
+ }
+ }));
+ }
+
+ function openDocument(document: IssueDocument) {
+ setSelectedDocuments(current => ({ ...current, [document.key]: document }));
+ setTabs(current => current.some(tab => tab.id === document.key) ? current : [...current, { id: document.key, type: "document", label: document.title ?? document.key, closable: true }]);
+ setActiveTab(document.key);
+ }
+
+ function closeTab(id: string) {
+ if (id === "artifacts") return;
+ setTabs(current => current.filter(tab => tab.id !== id));
+ if (activeTab === id) setActiveTab("artifacts");
+ }
+
+ function handlePreviewLink(event: MouseEvent) {
+ const link = (event.target as HTMLElement).closest("a");
+ if (!link) return;
+ const url = new URL(link.href, window.location.origin);
+ const key = url.searchParams.get("doc");
+ if (key) {
+ const document = docQuery.data?.find(doc => doc.key === key);
+ if (document) { event.preventDefault(); openDocument(document); }
+ return;
+ }
+ const attachmentMatch = url.pathname.match(/^\/api\/attachments\/([^/]+)\/content$/);
+ const file = attachmentMatch && client.getQueryData(["storybook-upload-file", attachmentMatch[1]]);
+ if (!file) return;
+ event.preventDefault();
+ // Browser navigations bypass the in-memory fetch fixture. Supply the
+ // preview's bytes locally for the production card's open/download links.
+ const href = URL.createObjectURL(file);
+ if (url.searchParams.has("download")) {
+ const download = window.document.createElement("a");
+ download.href = href;
+ download.download = file.name;
+ download.click();
+ } else window.open(href, "_blank", "noopener,noreferrer");
+ window.setTimeout(() => URL.revokeObjectURL(href), 60_000);
+ }
+
+ const tabProps = { tabs, activeTabId: activeTab, onActiveTabChange: setActiveTab, onCloseTab: closeTab };
+ const header = mobile ? : ;
+ return
+
+
{announcement}
+ {activeTab === "artifacts" ?
void upload(picked.map(file => ({ id: crypto.randomUUID(), file })))}
+ onRetry={id => { const file = files.current.get(id); if (file) void upload([{ id, file }]); }}
+ onDismiss={id => { files.current.delete(id); setEntries(current => current.filter(entry => entry.id !== id)); }}>
+
+ :
}
+
+ ;
+}
diff --git a/ui/storybook/stories/artifact-upload/fixtures.ts b/ui/storybook/stories/artifact-upload/fixtures.ts
new file mode 100644
index 00000000000..0bf665ff862
--- /dev/null
+++ b/ui/storybook/stories/artifact-upload/fixtures.ts
@@ -0,0 +1,129 @@
+import type { IssueDocument, IssueWorkProduct } from "@paperclipai/shared";
+import { queryKeys } from "@/lib/queryKeys";
+import { uploadedDocumentKey } from "@/lib/issue-artifact-upload";
+import { QueryClient } from "@tanstack/react-query";
+import { createIssue, storybookAgents } from "../../fixtures/paperclipData";
+
+export const issue = createIssue({
+ id: "artifact-upload-placeholder", identifier: "PAP-214", issueNumber: 214,
+ title: "Instance sleep — engineering handoff", status: "backlog",
+ assigneeAgentId: null, assigneeUserId: null, executionRunId: null,
+ checkoutRunId: null, executionLockedAt: null, currentExecutionWorkspace: null,
+ executionWorkspaceId: null, documentSummaries: [], workProducts: [],
+});
+
+export const handoffBody = `# Instance sleep: engineering handoff
+
+Paperclip should release idle database connections and let inactive instances sleep without losing scheduled work, incoming events, accounting, or recovery.
+
+## Start here
+
+The inventory records the remaining work. The implementation notes explain the patterns already in use. Quiet delivery queues alone do not establish whole-instance sleep.
+
+## References
+
+- [Loop inventory](?doc=loop-inventory)
+- [Implementation notes](?doc=implementation-notes)
+
+## Current direction
+
+Work caused by a committed change should run from that event. Work caused by the passage of time belongs in one instance-wide scheduler.
+
+## What comes next
+
+1. Migrate the remaining clock-driven work to the scheduler.
+2. Prove the full writer-admission and idle/resume boundary.
+3. Verify actual app and database sleep with the hosting providers.
+`;
+
+export function makeDocument(key: string, title: string, body: string): IssueDocument {
+ return {
+ id: `document-${key}`, issueId: issue.id, companyId: issue.companyId,
+ key, title, format: "markdown", body, latestRevisionId: `revision-${key}`,
+ latestRevisionNumber: 1, createdByAgentId: null, updatedByAgentId: null,
+ createdByUserId: "user-board", updatedByUserId: "user-board",
+ lockedAt: null, lockedByAgentId: null, lockedByUserId: null,
+ createdAt: new Date("2026-10-12T15:00:00Z"), updatedAt: new Date("2026-10-12T15:00:00Z"),
+ };
+}
+
+export const documents = [
+ makeDocument("core-handoff", "Instance sleep: engineering handoff", handoffBody),
+ makeDocument("loop-inventory", "Loop inventory", "# Loop inventory\n\n| Path | Pattern | Next action |\n| --- | --- | --- |\n| Durable delivery queues | Wake after commit | Verify idle boundary |\n| Routine schedules | Saved deadline | Register with scheduler |\n| Browser live events | Visibility-aware connection | Verify disconnect on hide |\n\n## Completion evidence\n\nRecord the source, change and verification result for each item."),
+ makeDocument("implementation-notes", "Implementation notes", "# Implementation notes\n\nDurable queue rows remain authoritative. A producer registers intent before its write; the database owner defers delivery wakes until the outer transaction settles.\n\n## Recovery\n\nKeep startup scans and saved retry deadlines. Empty, settled queues need no recurring scan.\n\n## Boundary\n\nSignals are process-local to one database owner and its dedicated child pools. Cross-process handoff needs separate fencing."),
+];
+
+export type PreviewState = "empty" | "populated" | "uploading" | "error" | "long";
+
+/** Enter through the real DOM drag handler so stories show the actual state. */
+export function showFileDrag(canvas: HTMLElement, scroll = false) {
+ const panel = canvas.querySelector('[aria-label="Artifact uploads"]');
+ if (!panel) throw new Error("Artifacts drop target is missing");
+ if (scroll) {
+ const viewport = panel.querySelector("[data-side-panel-content-viewport]");
+ if (viewport) viewport.scrollTop = viewport.scrollHeight;
+ }
+ const transfer = new DataTransfer();
+ transfer.items.add(new File(["# New reference\n\nAdded through the Artifacts panel."], "new-reference.md", { type: "text/markdown" }));
+ // Include the tab header in the target rather than only the inner file list.
+ const target = panel.querySelector("header") ?? panel;
+ target.dispatchEvent(new DragEvent("dragenter", { bubbles: true, cancelable: true, dataTransfer: transfer }));
+}
+
+export function createPreviewClient(state: PreviewState) {
+ const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } });
+ client.setQueryData([...queryKeys.issues.documents(issue.id), "list"], state === "empty" ? [] : state === "long" ? [...documents, makeDocument("long-reference", "Database transaction recovery and instance sleep: detailed engineering reference with verification evidence", Array.from({ length: 20 }, (_, index) => `## Investigation ${index + 1}\n\nCheck the transaction outcome before retrying uncertain writes. Preserve the durable receipt and current task state.`).join("\n\n"))] : documents);
+ client.setQueryData(queryKeys.issues.attachments(issue.id), []);
+ client.setQueryData(queryKeys.issues.workProducts(issue.id), []);
+ client.setQueryData(queryKeys.issues.runs(issue.id), []);
+ client.setQueryData(queryKeys.agents.list(issue.companyId), storybookAgents);
+ return client;
+}
+
+// File bytes and mutations stay in this preview's memory. No live task changes.
+export async function addPreviewFile(client: QueryClient, file: File, id: string) {
+ if (/\.(md|markdown)$/i.test(file.name) || file.type === "text/markdown") {
+ const body = await file.text();
+ const current = client.getQueryData([...queryKeys.issues.documents(issue.id), "list"]) ?? [];
+ const title = file.name.replace(/\.[^.]+$/, "").replace(/[-_]/g, " ");
+ const key = uploadedDocumentKey(file.name, current.map(document => document.key));
+ client.setQueryData([...queryKeys.issues.documents(issue.id), "list"], [makeDocument(key, title, body), ...current]);
+ return;
+ }
+ client.setQueryData(["storybook-upload-file", id], file);
+ const contentPath = `/api/attachments/${id}/content`;
+ const product: IssueWorkProduct = {
+ id, companyId: issue.companyId, issueId: issue.id, projectId: issue.projectId,
+ executionWorkspaceId: null, runtimeServiceId: null, type: "artifact", provider: "paperclip",
+ externalId: null, title: file.name, url: null, status: "active", reviewState: "none",
+ isPrimary: false, healthStatus: "healthy", summary: null, createdByRunId: null,
+ metadata: { attachmentId: id, contentType: file.type || "application/octet-stream", byteSize: file.size, contentPath, openPath: contentPath, downloadPath: `${contentPath}?download=1`, originalFilename: file.name },
+ createdAt: new Date("2026-10-12T15:01:00Z"), updatedAt: new Date("2026-10-12T15:01:00Z"),
+ };
+ client.setQueryData(queryKeys.issues.workProducts(issue.id), (current = []) => [product, ...current]);
+}
+
+export function installPreviewApi(client: QueryClient) {
+ const originalFetch = window.fetch;
+ const fixtureFetch: typeof window.fetch = async (input, init) => {
+ const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, window.location.origin);
+ const base = `/api/issues/${issue.id}`;
+ const attachmentMatch = url.pathname.match(/^\/api\/attachments\/([^/]+)\/content$/);
+ if (attachmentMatch) {
+ const file = client.getQueryData(["storybook-upload-file", attachmentMatch[1]]);
+ if (file) return new Response(file, { headers: { "Content-Type": file.type || "application/octet-stream" } });
+ }
+ if (url.pathname === `${base}/work-products`) return Response.json(client.getQueryData(queryKeys.issues.workProducts(issue.id)) ?? []);
+ if (url.pathname === `${base}/attachments`) return Response.json([]);
+ if (url.pathname === `${base}/documents`) return Response.json(client.getQueryData([...queryKeys.issues.documents(issue.id), "list"]) ?? []);
+ if (url.pathname.startsWith(`${base}/documents/`)) {
+ if (url.pathname.endsWith("/annotations")) return Response.json([]);
+ const key = decodeURIComponent(url.pathname.split("/").at(-1)!);
+ const document = (client.getQueryData([...queryKeys.issues.documents(issue.id), "list"]) ?? []).find((doc) => doc.key === key);
+ return document ? Response.json(document) : Response.json({ error: "Document not found" }, { status: 404 });
+ }
+ return originalFetch(input, init);
+ };
+ window.fetch = fixtureFetch;
+ return () => { if (window.fetch === fixtureFetch) window.fetch = originalFetch; };
+}