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; }; +}