From 762d680daf3563d42a0c4c280761c233b21713c7 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:11:31 -0500 Subject: [PATCH] feat(ui): upload files directly in task artifacts (#15964) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip helps people manage agent work and retain its context. > - Task documents and attachments keep that context with the task. > - The Artifacts panel showed output but could not accept a new file. > - Uploading through a message can require resuming a paused task. > - Existing document and attachment APIs can save files directly. > - This PR adds direct uploads and a drop target to the task panel. > - People can add context while keeping work paused and unassigned. ## Linked Issues or Issue Description **What existing behavior does this improve?** The task Artifacts panel and its existing document and attachment APIs. **Subsystem affected** ui/ — React board UI. No server, schema, or API changes. **Current behavior** The Artifacts panel cannot upload a file. It also excludes files uploaded by people. The message composer can require a task to resume before it submits attachments. **Proposed behavior** Use the upload icon or drop files anywhere in the open Artifacts panel. The target includes the header, padding, and existing cards. Files save directly to the task without a message or a pause change. **Reason and benefit** People can prepare a handoff on a paused, unassigned task without starting agent work. Markdown becomes a task document. Other supported files use attachment storage. **Breaking changes** Files from people now appear in Artifacts alongside agent output. Promoted attachments still appear once. A repeated Markdown filename creates a separate document instead of replacing an existing revision. Existing APIs and limits stay the same. **Additional context** Related upload API work: Refs #7472 and Refs #7248. This PR adds the missing task-panel entry point. ROADMAP.md lists Artifacts and Work Products as available; this is a scoped UI improvement. ## What Changed - Add an upload icon with no visible label or background. - Accept file drops across the full task panel, including the desktop portal header and mobile tabs. - Show per-file progress and inline Retry and Dismiss actions. - Import Markdown under a new document key and retain existing revisions. - Include files from people in Artifacts and dedupe promoted attachments. - Add component and task-journey stories for empty, populated, loading, error, mobile, and drag-over states. - Add real browser tests for persistence, pause preservation, duplicate document names, full-panel drops, and partial failure recovery. ## Verification - Five Playwright tests passed against an isolated instance started by the source `test-drive` command. The document API, attachment storage, pause holds, downloads, and task UI were real. Only the explicit upload failure was injected. - The browser tests verified desktop and mobile uploads, document reading, persistence after reload, no task comments or runs, header drops, preservation of original and hidden internal documents, reserved filename handling, and upload retry. - 163 focused UI tests passed. - `pnpm -r typecheck` passed. - `pnpm build` passed. - `pnpm check:token-gates` passed. - The full Vitest suite is running after repair of an exhausted local PostgreSQL shared-memory pool. The initial run failed to initialize PostgreSQL. Six unattached segments had no living creator or last-user process; removing them restored free slots. - CI and the current-head Greptile review are pending. ## Risks - Artifacts now includes user files, so existing tasks can show more cards. - Uploads retain the server's authorization, file-type, size, and revision checks. No migration is needed. - Binary attachment uploads use the existing API. A transport failure with an unknown save outcome can retain the existing API's retry ambiguity. ## Model Used OpenAI GPT-6 through Codex. The exact runtime snapshot ID and context-window size were not exposed to this session. The model used repository inspection, code editing, shell tools, and browser automation. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/AGENT-ARTIFACTS.md | 14 ++ tests/e2e/artifact-uploads.spec.ts | 149 ++++++++++++++++++ .../components/artifacts/ArtifactDropzone.tsx | 75 +++++++++ .../artifacts/ArtifactUploadArea.tsx | 22 +++ .../artifacts/ArtifactUploadControls.tsx | 66 ++++++++ .../issue-properties/IssueProperties.tsx | 7 +- .../IssuePropertiesArtifactsTab.tsx | 30 ++-- ...ssuePropertiesMarkdownWorkProduct.test.tsx | 8 +- .../components/side-panel/SidePanelFrame.tsx | 2 +- .../task-side-panel/TaskSidePanel.tsx | 15 +- ui/src/hooks/useIssueArtifactUploads.ts | 65 ++++++++ ui/src/lib/issue-artifact-upload.test.ts | 23 +++ ui/src/lib/issue-artifact-upload.ts | 15 ++ ui/src/lib/issue-artifacts.test.ts | 11 ++ ui/src/lib/issue-artifacts.ts | 9 ++ .../artifact-upload/01-components.stories.tsx | 22 +++ .../02-task-journey.stories.tsx | 59 +++++++ .../stories/artifact-upload/UploadPreview.tsx | 105 ++++++++++++ .../stories/artifact-upload/fixtures.ts | 129 +++++++++++++++ 19 files changed, 802 insertions(+), 24 deletions(-) create mode 100644 tests/e2e/artifact-uploads.spec.ts create mode 100644 ui/src/components/artifacts/ArtifactDropzone.tsx create mode 100644 ui/src/components/artifacts/ArtifactUploadArea.tsx create mode 100644 ui/src/components/artifacts/ArtifactUploadControls.tsx create mode 100644 ui/src/hooks/useIssueArtifactUploads.ts create mode 100644 ui/src/lib/issue-artifact-upload.test.ts create mode 100644 ui/src/lib/issue-artifact-upload.ts create mode 100644 ui/storybook/stories/artifact-upload/01-components.stories.tsx create mode 100644 ui/storybook/stories/artifact-upload/02-task-journey.stories.tsx create mode 100644 ui/storybook/stories/artifact-upload/UploadPreview.tsx create mode 100644 ui/storybook/stories/artifact-upload/fixtures.ts 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 ? ( +
+
+ ) : 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" + ?
  • )} +
: null} + {empty ?
+
: 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; }; +}