mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-14 17:40:37 +02:00
## 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>
150 lines
10 KiB
TypeScript
150 lines
10 KiB
TypeScript
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<string, string>();
|
|
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);
|
|
});
|