mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Artifacts show the output of agent work on the Artifacts page, in the panel Artifacts tab, and in the task thread > - #17228 added the Artifacts V1 presentation behind an experimental setting that is off by default > - The presentation is now ready for all users, so an opt-in toggle is no longer necessary > - A new default alone does not reach all instances, because each experimental write persists every flag > - This pull request removes the toggle and turns V1 on for every instance > - The benefit is one artifact presentation for all users, with an off switch for hosting operators ## Linked Issues or Issue Description Refs #17228 Refs #17263 **Is your feature request related to a problem? Please describe.** Artifacts V1 shows only on instances where an admin turned on an experimental setting. Other users see the previous cards. **Describe the solution you would like** Show Artifacts V1 on all instances. Remove the toggle from Experimental settings. **Describe alternatives you have considered** A new default without a change to the hook. This does not work, because many instances have a stored false that nobody chose, and no toggle would remain to change it. ## What Changed - Removed the Artifacts V1 card from Instance Settings, Experimental. - Changed `enableArtifactsV1` to default on in the schema, the feature catalog, and the settings service. - Changed `resolveArtifactsV1Enabled` to ignore a stored false. It honours false only when the value comes from managed config. - Kept the flag and the previous cards, so a hosting operator can hold V1 off. - Kept the key in the Cloud staging list, so a managed false does not hold V1 off on staging. - Updated unit tests, the Artifacts V1 plan, and the directions-lab Today reference. - Moved the attachment and upload e2e specs and the runner download helper to the V1 rows. - Added V1 tests for a Markdown review: expansion, deep link, and open, raw and download actions. ## Verification - `cd ui && npx vitest run`: one test file failed before its fix. That file tests the previous cards, and it now mocks the hook to off. I reran it with the other changed UI tests, and they pass. I did not repeat the full UI run after the fix. - `npx playwright test --config tests/e2e/playwright.config.ts tests/e2e/text-attachment-tabs.spec.ts tests/e2e/artifact-uploads.spec.ts tests/e2e/artifact-tab-arrival.spec.ts`: 10 tests pass on V1. - `tests/e2e/cloud-auth.spec.ts` failed once in CI on a task heading. It does not touch artifacts, and 10 local runs pass. - `cd server && npx vitest run src/__tests__/instance-settings`: 183 tests pass. - `cd packages/shared && npx vitest run src/feature-catalog.test.ts src/validators/instance.test.ts`: 35 tests pass. - `pnpm run check:token-gates` and the `ui` and `server` typechecks pass. I did not run the full repository check. - In the artifact directions lab, with no stored settings, the Today right panel renders the V1 rows. - I did not test this on a live instance. After merge, confirm on staging that the Experimental page has no Artifacts V1 card and that all three surfaces show V1. ## Risks - All users see V1 after the next release, not only staging users. To revert, revert this pull request. - Users cannot turn V1 off. Only a hosting operator can, through managed config, and not on Cloud staging. - An instance with a managed `enableArtifactsV1: false` stays on the previous cards. The cloud harness source has no reference to this key, but I cannot read the live fleet configuration. - V1 surfaces render before settings load. An operator-held instance can briefly show V1 before the previous cards. - Admins who use the API can still write the flag. The write has no effect on unmanaged instances. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use and code execution. ## 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 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
179 lines
12 KiB
TypeScript
179 lines
12 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { readFile } from "node:fs/promises";
|
|
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);
|
|
const markdown = "# Engineering handoff\n\nStart with the loop inventory and preserve current task ownership.";
|
|
const csv = "path,pattern\ndelivery,event-driven\nroutines,clock-driven";
|
|
await chooseFiles(page, panel, [
|
|
{ name: "core-handoff.md", mimeType: "text/markdown", buffer: Buffer.from(markdown) },
|
|
{ name: "loop-inventory.csv", mimeType: "text/csv", buffer: Buffer.from(csv) },
|
|
]);
|
|
const document = panel.locator("#document-core-handoff");
|
|
await expect(document.getByRole("heading", { name: "Engineering handoff", exact: true })).toBeVisible();
|
|
await expect(document.getByText("Start with the loop inventory and preserve current task ownership.", { exact: true })).toBeVisible();
|
|
await expect(panel.getByRole("link", { name: "Download loop-inventory.csv", exact: true })).toBeVisible();
|
|
await expectTaskPanelTab(panel, mobile, "Artifacts");
|
|
const download = page.waitForEvent("download");
|
|
await panel.getByRole("link", { name: "Download loop-inventory.csv", exact: true }).click();
|
|
const downloaded = await download;
|
|
expect(downloaded.suggestedFilename()).toBe("loop-inventory.csv");
|
|
expect(await readFile((await downloaded.path())!, "utf8")).toBe(csv);
|
|
await document.getByRole("button", { name: "Collapse core-handoff document", exact: true }).click();
|
|
await expect(document.getByText("Start with the loop inventory and preserve current task ownership.", { exact: true })).toBeHidden();
|
|
await document.getByRole("button", { name: "Expand core-handoff document", exact: true }).click();
|
|
await expect(document.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(documents[0].body).toBe(markdown);
|
|
expect(attachments).toHaveLength(1);
|
|
expect(attachments[0].originalFilename).toBe("loop-inventory.csv");
|
|
if (mobile) {
|
|
await page.getByRole("button", { name: "More actions", exact: true }).click();
|
|
await page.getByRole("button", { name: "Properties", exact: true }).click();
|
|
}
|
|
await expect(document.getByRole("heading", { name: "Engineering handoff", exact: true })).toBeVisible();
|
|
await expect(document.getByText("Start with the loop inventory and preserve current task ownership.", { exact: true })).toBeVisible();
|
|
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);
|
|
const original = "# Original handoff\n\nPreserve this version.";
|
|
await chooseFiles(page, panel, [{ name: "handoff.md", mimeType: "text/markdown", buffer: Buffer.from(original) }]);
|
|
const document = panel.locator("#document-handoff");
|
|
await expect(document.getByRole("heading", { name: "Original handoff", exact: true })).toBeVisible();
|
|
const originalDocument = await json(await request.get(`/api/issues/${issue.id}/documents/handoff`));
|
|
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 document.dispatchEvent("drop", { dataTransfer: transfer });
|
|
await expect(panel.locator("#document-handoff, #document-handoff-2")).toHaveCount(2);
|
|
await expect(document.getByText("Preserve this version.", { exact: true })).toBeVisible();
|
|
await expect(panel.locator("#document-handoff-2").getByText("New reference, separate document.", { exact: true })).toBeVisible();
|
|
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"]);
|
|
const preserved = documents.find((row: { key: string }) => row.key === "handoff");
|
|
expect(preserved.body).toBe(original);
|
|
expect(preserved.latestRevisionId).toBe(originalDocument.latestRevisionId);
|
|
expect(documents.find((row: { key: string }) => row.key === "handoff-2").body).toBe("# Additional handoff\n\nNew reference, separate document.");
|
|
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.locator('[id^="document-upload-"]')).toHaveCount(3);
|
|
for (const key of reserved) {
|
|
const document = panel.locator(`#document-upload-${key}`);
|
|
await expect(document.getByRole("heading", { name: `Uploaded ${key}`, exact: true })).toBeVisible();
|
|
await expect(document.getByText("This reference must stay visible.", { exact: true })).toBeVisible();
|
|
}
|
|
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);
|
|
});
|