Files
PaperClipAI/tests/e2e/text-attachment-tabs.spec.ts
T
TonioandClaude Opus 5.5 9e9a628a57 feat(ui): turn Artifacts V1 on for every instance and hide its toggle (#19164)
## 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>
2026-11-05 08:14:49 +00:00

114 lines
7.7 KiB
TypeScript

import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import { expect, test, type APIRequestContext } from "@playwright/test";
import { downloadTaskTextArtifact } from "../runner-e2e/task-text-artifact-download.js";
import { expectTaskPanelTab, selectTaskPanelTab } from "./helpers/task-panel-tabs";
async function json(response: Awaited<ReturnType<APIRequestContext["get"]>>) {
expect(response.ok(), `${response.status()}: ${await response.text()}`).toBe(true);
return response.json();
}
for (const mobile of [false, true]) {
test(`opens uploaded text in task tabs and downloads the original (mobile=${mobile})`, async ({ page, request }, testInfo) => {
await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 });
const company = await json(await request.post("/api/companies", { data: { name: `Text files ${randomUUID()}` } }));
const issue = await json(await request.post(`/api/companies/${company.id}/issues`, {
data: { title: "Review uploaded text files", status: "backlog" },
}));
const files = [
{ name: "AGENTS.md", mimeType: "text/markdown", buffer: Buffer.from("# File charter\n\nKeep **original** bytes.\n") },
{ name: "notes.txt", mimeType: "text/plain", buffer: Buffer.from("Plain text <script>never execute</script>\n") },
];
const attachments = [];
for (const file of files) attachments.push(await json(await request.post(`/api/companies/${company.id}/issues/${issue.id}/attachments`, { multipart: { file } })));
await json(await request.post(`/api/issues/${issue.id}/comments`, {
data: { body: "Review the attached files.", attachmentIds: attachments.map((attachment) => attachment.id) },
}));
for (const [index, attachment] of attachments.entries()) {
await json(await request.post(`/api/issues/${issue.id}/work-products`, {
data: {
type: "artifact", provider: "paperclip", title: `Delivered ${files[index]!.name}`, status: "ready_for_review",
metadata: {
attachmentId: attachment.id, contentType: attachment.contentType, byteSize: attachment.byteSize,
originalFilename: files[index]!.name, contentPath: attachment.contentPath,
openPath: attachment.contentPath, downloadPath: `${attachment.contentPath}?download=1`,
},
},
}));
}
await page.goto(`/${company.issuePrefix}/issues/${issue.identifier}`);
const threadFiles = page.getByTestId("task-chat-bubble-artifacts-v1");
await threadFiles.getByRole("link", { name: "Open AGENTS.md", exact: true }).click();
const panel = mobile ? page.getByTestId("mobile-task-side-panel") : page.locator("aside").filter({ has: page.getByRole("tab", { name: "AGENTS.md", exact: true }) });
await expectTaskPanelTab(panel, mobile, "AGENTS.md");
await expect(panel.getByRole("heading", { name: "File charter", exact: true, level: 1 })).toBeVisible();
const raw = panel.getByRole("button", { name: "Raw", exact: true });
await raw.click();
await expect(raw).toHaveAttribute("aria-pressed", "true");
await expect(panel.getByLabel("AGENTS.md raw text")).toContainText("# File charter");
await panel.getByRole("button", { name: "Rendered", exact: true }).click();
await expect(panel.getByRole("heading", { name: "File charter", exact: true, level: 1 })).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("markdown-rendered.png"), fullPage: true });
const markdownDownload = page.waitForEvent("download");
await panel.getByRole("link", { name: "Download AGENTS.md", exact: true }).click();
expect(await fs.readFile((await (await markdownDownload).path())!)).toEqual(files[0]!.buffer);
if (mobile) await panel.getByRole("button", { name: "Close side panel", exact: true }).click();
await threadFiles.getByRole("link", { name: "Open notes.txt", exact: true }).click();
await expectTaskPanelTab(panel, mobile, "notes.txt");
await expect(panel.getByLabel("notes.txt raw text")).toContainText("<script>never execute</script>");
await expect(panel.getByRole("group", { name: "Markdown view", exact: true })).toHaveCount(0);
const textDownload = page.waitForEvent("download");
await panel.getByRole("link", { name: "Download notes.txt", exact: true }).click();
expect(await fs.readFile((await (await textDownload).path())!)).toEqual(files[1]!.buffer);
await page.screenshot({ path: testInfo.outputPath("plain-text-download.png"), fullPage: true });
await selectTaskPanelTab(page, panel, mobile, "AGENTS.md");
await expect(panel.getByRole("heading", { name: "File charter", exact: true, level: 1 })).toBeVisible();
if (mobile) {
await panel.getByRole("button", { name: /^Switch tabs, \d+ open$/ }).click();
const overview = page.getByRole("dialog", { name: "Open tabs", exact: true });
await expect(overview.getByRole("button", { name: "AGENTS.md", exact: true })).toHaveCount(1);
await overview.getByRole("button", { name: "AGENTS.md", exact: true }).click();
} else {
await expect(panel.getByRole("tab", { name: "AGENTS.md", exact: true })).toHaveCount(1);
}
await panel.getByRole("button", { name: "Open a new tab", exact: true }).click();
await page.getByRole("option", { name: /^Artifacts(?: Already open)?$/ }).click();
const artifactRows = panel.getByTestId("artifacts-v1-list").getByRole("listitem");
const markdownRow = artifactRows.filter({ has: page.getByRole("button", { name: "Open in tab: Delivered AGENTS.md", exact: true }) });
const review = markdownRow.getByRole("button", { name: "Open AGENTS.md", exact: true });
await review.click();
await expect(review).toHaveAttribute("aria-expanded", "true");
await expect(panel.getByRole("heading", { name: "File charter", exact: true, level: 1 })).toBeVisible();
await expect(panel.getByRole("link", { name: "Download Delivered AGENTS.md", exact: true })).toBeVisible();
const cardDownload = page.waitForEvent("download");
const textRow = artifactRows.filter({ hasText: "For review" }).filter({ has: page.getByRole("link", { name: "Open notes.txt", exact: true }) });
await textRow.getByRole("link", { name: "Download notes.txt", exact: true }).click();
expect(await fs.readFile((await (await cardDownload).path())!)).toEqual(files[1]!.buffer);
await expectTaskPanelTab(panel, mobile, "Artifacts");
await textRow.getByRole("link", { name: "Open notes.txt", exact: true }).click();
await expectTaskPanelTab(panel, mobile, "notes.txt");
await selectTaskPanelTab(page, panel, mobile, "Artifacts");
await panel.getByRole("button", { name: "Open in tab: Delivered AGENTS.md", exact: true }).click();
await expectTaskPanelTab(panel, mobile, "AGENTS.md");
});
}
test("downloads a delivered JSON chip through its visible file tab with exact attachment binding", async ({ page, request }) => {
const company = await json(await request.post("/api/companies", { data: { name: `JSON delivery ${randomUUID()}` } }));
const issue = await json(await request.post(`/api/companies/${company.id}/issues`, {
data: { title: "Read the delivered result", status: "backlog" },
}));
const original = Buffer.from('{"reference":"original","label":"latest"}\n');
const attachment = await json(await request.post(`/api/companies/${company.id}/issues/${issue.id}/attachments`, {
multipart: { file: { name: "result.json", mimeType: "application/json", buffer: original } },
}));
await json(await request.post(`/api/issues/${issue.id}/comments`, {
data: { body: `[result.json](${attachment.contentPath})` },
}));
await page.goto(`/${company.issuePrefix}/issues/${issue.identifier}`);
const downloaded = await downloadTaskTextArtifact(page, attachment.id, "result.json");
expect(await fs.readFile(downloaded)).toEqual(original);
});