Files
PaperClipAI/ui/src/lib/issue-artifacts.test.ts
T
DottaandPaperclip 762d680daf feat(ui): upload files directly in task artifacts (#15964)
## 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 <noreply@paperclip.ing>
2026-10-12 08:11:31 -05:00

221 lines
8.1 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { IssueAttachment, IssueWorkProduct } from "@paperclipai/shared";
import {
documentDisplayTitle,
getAttachmentBackedWorkProductAttachmentIds,
isAgentAttachment,
selectAgentArtifactAttachments,
selectArtifactAttachments,
workProductHref,
} from "./issue-artifacts";
function makeAttachment(overrides: Partial<IssueAttachment> & { id: string }): IssueAttachment {
return {
companyId: "company-1",
issueId: "issue-1",
issueCommentId: null,
assetId: "asset-1",
provider: "local",
objectKey: `objects/${overrides.id}`,
contentType: "image/png",
byteSize: 1024,
sha256: "0".repeat(64),
originalFilename: "shot.png",
createdByAgentId: null,
createdByUserId: null,
createdAt: new Date("2026-08-01T12:00:00Z"),
updatedAt: new Date("2026-08-01T12:00:00Z"),
contentPath: `/api/attachments/${overrides.id}/content`,
...overrides,
} as IssueAttachment;
}
function makePromotingWorkProduct(attachmentId: string): IssueWorkProduct {
return {
id: `wp-${attachmentId}`,
companyId: "company-1",
projectId: null,
issueId: "issue-1",
executionWorkspaceId: null,
runtimeServiceId: null,
type: "artifact",
provider: "paperclip",
externalId: null,
title: "output.png",
url: null,
status: "active",
reviewState: "none",
isPrimary: false,
healthStatus: "unknown",
summary: null,
metadata: {
attachmentId,
contentType: "image/png",
byteSize: 1024,
contentPath: `/api/attachments/${attachmentId}/content`,
openPath: `/api/attachments/${attachmentId}/content`,
downloadPath: `/api/attachments/${attachmentId}/content?download=1`,
originalFilename: "output.png",
},
createdByRunId: null,
createdAt: new Date("2026-08-01T12:00:00Z"),
updatedAt: new Date("2026-08-01T12:00:00Z"),
} as IssueWorkProduct;
}
describe("isAgentAttachment", () => {
it("accepts agent-authored rows regardless of comment binding", () => {
expect(isAgentAttachment(makeAttachment({ id: "a", createdByAgentId: "agent-1" }))).toBe(true);
expect(
isAgentAttachment(
makeAttachment({ id: "b", createdByAgentId: "agent-1", issueCommentId: "comment-1" }),
),
).toBe(true);
});
it("rejects user uploads", () => {
expect(isAgentAttachment(makeAttachment({ id: "c", createdByUserId: "user-1" }))).toBe(false);
expect(
isAgentAttachment(
makeAttachment({ id: "d", createdByUserId: "user-1", issueCommentId: "comment-1" }),
),
).toBe(false);
});
it("treats authorless rows as agent output only when not comment-bound", () => {
expect(isAgentAttachment(makeAttachment({ id: "e" }))).toBe(true);
expect(isAgentAttachment(makeAttachment({ id: "f", issueCommentId: "comment-1" }))).toBe(false);
});
});
describe("selectAgentArtifactAttachments", () => {
it("keeps agent attachments and drops user uploads", () => {
const agent = makeAttachment({ id: "agent-file", createdByAgentId: "agent-1" });
const user = makeAttachment({
id: "user-file",
createdByUserId: "user-1",
issueCommentId: "comment-1",
});
expect(selectAgentArtifactAttachments([agent, user], [])).toEqual([agent]);
});
it("dedupes attachments already promoted to work products", () => {
// The promotion metadata schema requires a UUID attachmentId.
const promotedId = "00000000-0000-4000-8000-000000000001";
const promoted = makeAttachment({ id: promotedId, createdByAgentId: "agent-1" });
const loose = makeAttachment({ id: "loose", createdByAgentId: "agent-1" });
const result = selectAgentArtifactAttachments(
[promoted, loose],
[makePromotingWorkProduct(promotedId)],
);
expect(result.map((a) => a.id)).toEqual(["loose"]);
});
it("tolerates missing inputs", () => {
expect(selectAgentArtifactAttachments(null, null)).toEqual([]);
});
it("dedupes markdown attachments promoted to work products", () => {
// Markdown is excluded from the binary Output surface, so this dedupe must
// not depend on getPromotedOutputAttachmentIds (LOOA-1533 duplicate rows).
const promotedId = "00000000-0000-4000-8000-000000000002";
const promoted = makeAttachment({
id: promotedId,
createdByAgentId: "agent-1",
contentType: "text/markdown",
originalFilename: "report.md",
});
const workProduct = makePromotingWorkProduct(promotedId);
workProduct.metadata = {
...(workProduct.metadata as Record<string, unknown>),
contentType: "text/markdown",
originalFilename: "report.md",
};
expect(selectAgentArtifactAttachments([promoted], [workProduct])).toEqual([]);
});
});
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";
const markdownId = "00000000-0000-4000-8000-000000000002";
const markdownWorkProduct = makePromotingWorkProduct(markdownId);
markdownWorkProduct.metadata = {
...(markdownWorkProduct.metadata as Record<string, unknown>),
contentType: "text/markdown",
originalFilename: "report.md",
};
const ids = getAttachmentBackedWorkProductAttachmentIds([
makePromotingWorkProduct(imageId),
markdownWorkProduct,
]);
expect(ids).toEqual(new Set([imageId, markdownId]));
});
it("ignores non-canonical or non-paperclip work products", () => {
const attachmentId = "00000000-0000-4000-8000-000000000003";
const foreign = { ...makePromotingWorkProduct(attachmentId), provider: "github" };
const invalid = {
...makePromotingWorkProduct(attachmentId),
metadata: { attachmentId, openPath: "https://evil.example/content" },
};
expect(getAttachmentBackedWorkProductAttachmentIds([foreign, invalid])).toEqual(new Set());
expect(getAttachmentBackedWorkProductAttachmentIds(null)).toEqual(new Set());
});
});
describe("workProductHref", () => {
it("prefers the top-level url", () => {
expect(
workProductHref({ url: "https://example.com/pr/1", metadata: { openPath: "/api/x" } }),
).toBe("https://example.com/pr/1");
});
it("falls back to metadata.openPath, then metadata.url", () => {
expect(
workProductHref({ url: null, metadata: { openPath: "/api/attachments/a/content" } }),
).toBe("/api/attachments/a/content");
expect(
workProductHref({ url: null, metadata: { url: "https://tunnel.here.now/x" } }),
).toBe("https://tunnel.here.now/x");
expect(
workProductHref({
url: null,
metadata: { openPath: "/api/attachments/a/content", url: "https://tunnel.here.now/x" },
}),
).toBe("/api/attachments/a/content");
});
it("ignores non-string and blank metadata values", () => {
expect(workProductHref({ url: null, metadata: { openPath: 42, url: " " } })).toBeNull();
});
it("returns null without url or metadata links", () => {
expect(workProductHref({ url: null, metadata: null })).toBeNull();
expect(workProductHref({ url: null, metadata: { attachmentId: "a" } })).toBeNull();
});
});
describe("documentDisplayTitle", () => {
it("prefers the stored title", () => {
expect(documentDisplayTitle({ key: "synthesis", title: "Findings Synthesis" })).toBe(
"Findings Synthesis",
);
});
it("humanizes the key when no title is set", () => {
expect(documentDisplayTitle({ key: "synthesis", title: null })).toBe("Synthesis");
expect(documentDisplayTitle({ key: "design_notes-v2", title: " " })).toBe("Design notes v2");
});
});