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>
This commit is contained in:
TonioandClaude Opus 5.5 authored and GitHub committed 2026-11-05 08:14:49 +00:00
1 parent ea42a2e417
commit 9e9a628a57
16 files changed
+122 -48

No files matched your search

@@ -45,7 +45,7 @@ export function loadKit(id: DirectionId): Promise<DirectionKit> {
- `ThreadWorkProduct`: a task document becomes a `TaskChatProtocolCard` resource item with ``subtitle: `Document · rev ${artifact.revision}` ``; anything else becomes `RichWorkProductCard` with `workProductHref(workProduct)` (`ThreadWorkProduct.tsx:13-31`; the product split is `ui/src/components/TaskChatThread.tsx:1290-1312`).
- `ThreadAttachments`: mirrored; uses `@/components/ui/attachment`, `ArtifactPreview`, `fileKindForAttachment`, `formatFileSize`, `isImageAttachment`, `isVideoLikeOutput` (`ThreadAttachments.tsx:1-15`).
**GENERIC**, flag-gated components. Before mounting, grep the feature's flag hook under `ui/src/components` and `ui/src/pages`. For artifacts, `useArtifactsV1Enabled()` is called at `IssuePropertiesArtifactsTab.tsx:414`, `TaskChatBubble.tsx:149`, `TaskChatProtocolCard.tsx:1045` and in `pages/Artifacts.tsx`; it resolves `settings?.enableArtifactsV1 === true` (`ui/src/hooks/useArtifactsV1Enabled.ts:7-9`) from `GET /api/instance/settings/experimental` (`ui/src/api/instanceSettings.ts:20-21`). The artifact stub has no route for it, answers `{}`, and the flag is false: Today's panel and its task-document thread row render the flag-OFF presentation (the generic card from `TaskChatProtocolCard.tsx:1072`), while `ArtifactCard`, `ArtifactGroupCard` and `RichWorkProductCard` are flag-independent. To show a flagged presentation under Today, register a route returning `{ enableArtifactsV1: true }` for that pathname. A generated lab already has that route: `api-routes.ts` answers any pathname ending `/instance/settings/experimental` with `registry.today.flags` (`{}` as scaffolded), so set the flag in `registry.ts` (`today: { flags: { enableArtifactsV1: true } }`) or at run time with `setLabFlags`.
**GENERIC**, flag-gated components. Before mounting, grep the feature's flag hook under `ui/src/components` and `ui/src/pages`. For artifacts, `useArtifactsV1Enabled()` is called at `IssuePropertiesArtifactsTab.tsx:414`, `TaskChatBubble.tsx:149`, `TaskChatProtocolCard.tsx:1045` and in `pages/Artifacts.tsx`; it resolves to on unless a managed `enableArtifactsV1: false` holds it off (`resolveArtifactsV1Enabled` in `ui/src/hooks/useArtifactsV1Enabled.ts`), read from `GET /api/instance/settings/experimental` (`ui/src/api/instanceSettings.ts:20-21`). The artifact stub has no route for it and answers `{}`, so Today's panel and its task-document thread row render the V1 rows, while `ArtifactCard`, `ArtifactGroupCard` and `RichWorkProductCard` are flag-independent. To show the previous cards under Today, return `{ enableArtifactsV1: false, managedKeys: { enableArtifactsV1: { managed: true, managedBy: "paperclip-cloud" } } }` for that pathname. A generated lab already has that route: `api-routes.ts` answers any pathname ending `/instance/settings/experimental` with `registry.today.flags` (`{}` as scaffolded), so set those values in `registry.ts` or at run time with `setLabFlags`.
## 2. Adapters: lab fixtures to product types
+9
View File
@@ -143,3 +143,12 @@ the wiring is covered by tests.
fetched to find out, and a front that moves on to another picture gets a fresh chance.
- Token count grows by about thirty `--artifact-*` entries; they are namespaced and
documented in the token block.
## Update 2026-11-04: on for every instance
Artifacts V1 now renders for every instance and the Experimental toggle is gone. `enableArtifactsV1`
stays in the schema and catalog with a default of on. `resolveArtifactsV1Enabled` ignores a stored
false, because every experimental write persists the whole normalized object, so a stored false only
means an instance saved some other experiment. A value from `PAPERCLIP_MANAGED_CONFIG` is still
honoured, which leaves operators an off switch outside Cloud staging. The previous cards remain in
the code until that switch is retired.
+2 -2
View File
@@ -211,8 +211,8 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
description:
"The artifact presentation from the artifact directions lab: paper stacks and tiles on the Artifacts page, compact rows in the panel's Artifacts tab, and grouped rows in the task thread.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
cloudDefault: true,
selfHostedDefault: true,
},
enableConferenceRoomChat: {
title: "Conference Room Chat",
+1 -1
View File
@@ -65,7 +65,7 @@ export const instanceExperimentalSettingsSchema = z.object({
enableCases: z.boolean().default(false),
enableAgentChat: z.boolean().default(false),
enableCombinedInboxTasks: z.boolean().default(false),
enableArtifactsV1: z.boolean().default(false),
enableArtifactsV1: z.boolean().default(true),
enableConferenceRoomChat: z.boolean().default(false),
enableClassicTaskInterface: z.boolean().default(false),
enableIssuePlanDecompositions: z.boolean().default(false),
@@ -20,6 +20,14 @@ function managedConfig(features: ManagedInstanceConfig["features"] = {}): Manage
};
}
describe("Artifacts V1 defaults", () => {
it("is on for self-hosted and Cloud instances that never stored it", () => {
expect(INSTANCE_FEATURE_CATALOG.enableArtifactsV1).toMatchObject({ cloudDefault: true, selfHostedDefault: true });
expect(applyCloudCatalogDefaults(normalizeExperimentalSettings({}), {}, null).enableArtifactsV1).toBe(true);
expect(applyCloudCatalogDefaults(normalizeExperimentalSettings({}), {}, managedConfig()).enableArtifactsV1).toBe(true);
});
});
describe("applyCloudCatalogDefaults", () => {
it("pins the catalog so this rule has something to guard", () => {
// The rule exists for flags that default on for self-hosted and off for
@@ -110,7 +110,7 @@ describe("instance settings service", () => {
enableMuse: false,
enableGrokBot: false,
enableCombinedInboxTasks: false,
enableArtifactsV1: false,
enableArtifactsV1: true,
enableChatConnectors: false,
enableGitHubReviewBots: false,
enableMemoryConnectors: false,
+2 -2
View File
@@ -268,7 +268,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableCases: parsed.data.enableCases ?? false,
enableAgentChat: parsed.data.enableAgentChat ?? false,
enableCombinedInboxTasks: parsed.data.enableCombinedInboxTasks ?? false,
enableArtifactsV1: parsed.data.enableArtifactsV1 ?? false,
enableArtifactsV1: parsed.data.enableArtifactsV1 ?? true,
enableConferenceRoomChat: parsed.data.enableConferenceRoomChat ?? false,
enableClassicTaskInterface: parsed.data.enableClassicTaskInterface ?? false,
enableIssuePlanDecompositions: parsed.data.enableIssuePlanDecompositions ?? false,
@@ -325,7 +325,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableCases: false,
enableAgentChat: false,
enableCombinedInboxTasks: false,
enableArtifactsV1: false,
enableArtifactsV1: true,
enableConferenceRoomChat: false,
enableClassicTaskInterface: false,
enableIssuePlanDecompositions: false,
+2 -2
View File
@@ -62,10 +62,10 @@ for (const mobile of [false, true]) {
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 file", 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 file", exact: true }).click();
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);
+9 -8
View File
@@ -38,7 +38,8 @@ for (const mobile of [false, true]) {
}));
}
await page.goto(`/${company.issuePrefix}/issues/${issue.identifier}`);
await page.getByRole("link", { name: "Open AGENTS.md", exact: true }).click();
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();
@@ -53,7 +54,7 @@ for (const mobile of [false, true]) {
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 page.getByRole("link", { name: "Open notes.txt", 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);
@@ -73,19 +74,19 @@ for (const mobile of [false, true]) {
}
await panel.getByRole("button", { name: "Open a new tab", exact: true }).click();
await page.getByRole("option", { name: /^Artifacts(?: Already open)?$/ }).click();
const markdownCard = panel.getByRole("article").filter({ has: page.getByRole("heading", { name: "Delivered AGENTS.md", exact: true }) });
const review = markdownCard.getByRole("button", { name: /^(Read|Close) document$/ });
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(markdownCard).toContainText("revision 1");
await expect(panel.getByRole("link", { name: "Download Delivered AGENTS.md", exact: true })).toBeVisible();
const cardDownload = page.waitForEvent("download");
const textCard = panel.getByRole("article").filter({ has: page.getByRole("heading", { name: "Delivered notes.txt", exact: true }) });
await textCard.getByRole("link", { name: "Download file", exact: true }).click();
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 panel.getByRole("button", { name: "Open in tab: Delivered notes.txt", exact: true }).click();
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();
@@ -2,7 +2,7 @@ import { expect, type Page } from "@playwright/test";
export async function downloadTaskTextArtifact(page: Page, attachmentId: string, name: string) {
const contentPath = `/api/attachments/${encodeURIComponent(attachmentId)}/content`;
const chip = page.getByTestId("task-chat-bubble-attachments").locator(`a[href*="${contentPath}"]`).last();
const chip = page.getByTestId("task-chat-bubble-artifacts-v1").locator(`a[href*="${contentPath}"]`).last();
await expect(chip).toBeVisible({ timeout: 30_000 });
await expect(chip).toHaveAttribute("aria-label", `Open ${name}`);
await chip.click();
@@ -6,6 +6,7 @@ import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Issue, IssueAttachment, IssueDocument, IssueWorkProduct } from "@paperclipai/shared";
import { TextAttachmentContext } from "@/context/TextAttachmentContext";
import { IssuePropertiesArtifactsTab, TaskGeneralDocuments, type TaskDocumentEditorOptions } from "./IssuePropertiesArtifactsTab";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -177,12 +178,14 @@ describe("IssuePropertiesArtifactsTab with Artifacts V1 on", () => {
vi.clearAllMocks();
});
function render(props: { onOpenDocument?: (document: IssueDocument) => void; documentEditor?: TaskDocumentEditorOptions } = {}) {
function render(props: { onOpenDocument?: (document: IssueDocument) => void; documentEditor?: TaskDocumentEditorOptions; documentDeepLink?: { requestId: number; documentKey: string } } = {}, openText?: (attachmentId: string, name: string) => void) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
flushSync(() => {
root.render(
<QueryClientProvider client={queryClient}>
<IssuePropertiesArtifactsTab issue={issue} documentEditor={editorOptions} {...props} />
<TextAttachmentContext.Provider value={openText ?? null}>
<IssuePropertiesArtifactsTab issue={issue} documentEditor={editorOptions} {...props} />
</TextAttachmentContext.Provider>
</QueryClientProvider>,
);
});
@@ -299,6 +302,55 @@ describe("IssuePropertiesArtifactsTab with Artifacts V1 on", () => {
expect(service?.textContent).not.toContain("Running");
});
describe("a Markdown work product", () => {
const contentPath = `/api/attachments/${ATTACHMENT_ID}/content`;
const reviewKey = "artifact-review-wp-report";
const markdownWorkProduct = {
...pdfWorkProduct,
id: "wp-report",
title: "Verification report",
metadata: { attachmentId: ATTACHMENT_ID, contentType: "text/markdown", byteSize: 64, contentPath, openPath: contentPath, downloadPath: `${contentPath}?download=1`, originalFilename: "report.md" },
};
const reviewDocument = { ...planDocument, id: "doc-review", key: reviewKey, title: "Verification report", body: "Rendered review body" };
const reviewRow = () => container.querySelector<HTMLButtonElement>('button[aria-label="Open report.md"]');
beforeEach(() => {
mockIssuesApi.listAttachments.mockResolvedValue([]);
mockIssuesApi.listWorkProducts.mockResolvedValue([markdownWorkProduct]);
mockUseIssueDocuments.mockReturnValue({ data: [reviewDocument] });
});
it("expands its review in place from the row", async () => {
render();
await waitFor(() => expect(reviewRow()).not.toBeNull());
expect(reviewRow()!.getAttribute("aria-expanded")).toBe("false");
expect(container.textContent).not.toContain("Rendered review body");
act(() => reviewRow()!.click());
expect(reviewRow()!.getAttribute("aria-expanded")).toBe("true");
await waitFor(() => expect(container.textContent).toContain("Rendered review body"));
expect(mockIssuesApi.ensureWorkProductReviewDocument).not.toHaveBeenCalled();
});
it("opens its review from a deep link", async () => {
render({ documentDeepLink: { requestId: 1, documentKey: reviewKey } });
await waitFor(() => expect(container.textContent).toContain("Rendered review body"));
expect(reviewRow()!.getAttribute("aria-expanded")).toBe("true");
});
it("keeps its open, raw and download actions beside the row", async () => {
const openText = vi.fn();
render({}, openText);
await waitFor(() => expect(reviewRow()).not.toBeNull());
const raw = container.querySelector<HTMLAnchorElement>('a[aria-label="Open raw Verification report"]');
expect(raw?.getAttribute("href")).toBe(contentPath);
expect(raw?.getAttribute("target")).toBe("_blank");
expect(container.querySelector('a[aria-label="Download Verification report"]')?.getAttribute("href")).toBe(`${contentPath}?download=1`);
act(() => container.querySelector<HTMLButtonElement>('button[aria-label="Open in tab: Verification report"]')!.click());
expect(openText).toHaveBeenCalledWith(ATTACHMENT_ID, "report.md");
expect(reviewRow()!.getAttribute("aria-expanded")).toBe("false");
});
});
it("opens a task document the way the panel asks, through onOpenDocument", async () => {
const onOpenDocument = vi.fn();
render({ onOpenDocument });
@@ -26,6 +26,7 @@ const mockUseIssueDocuments = vi.hoisted(() =>
vi.fn((): { data: IssueDocument[] } => ({ data: [] })),
);
vi.mock("@/hooks/useIssueDocuments", () => ({ useIssueDocuments: mockUseIssueDocuments }));
vi.mock("@/hooks/useArtifactsV1Enabled", () => ({ useArtifactsV1Enabled: () => ({ enabled: false, loaded: true }) }));
vi.mock("@/lib/router", () => ({
Link: ({ to, children, ...props }: { to: string; children: React.ReactNode }) => <a href={to} {...props}>{children}</a>,
useLocation: () => ({ hash: "" }),
+15 -8
View File
@@ -2,14 +2,21 @@ import { describe, expect, it } from "vitest";
import { resolveArtifactsV1Enabled } from "./useArtifactsV1Enabled";
describe("resolveArtifactsV1Enabled", () => {
it("is off until an instance opts in", () => {
expect(resolveArtifactsV1Enabled(undefined)).toBe(false);
expect(resolveArtifactsV1Enabled(null)).toBe(false);
expect(resolveArtifactsV1Enabled({})).toBe(false);
expect(resolveArtifactsV1Enabled({ enableArtifactsV1: false })).toBe(false);
});
it("is on when the setting is", () => {
it("is on before settings load and on every unmanaged instance", () => {
expect(resolveArtifactsV1Enabled(undefined)).toBe(true);
expect(resolveArtifactsV1Enabled(null)).toBe(true);
expect(resolveArtifactsV1Enabled({})).toBe(true);
expect(resolveArtifactsV1Enabled({ enableArtifactsV1: true })).toBe(true);
});
it("ignores a stored false, which other experiment writes persist", () => {
expect(resolveArtifactsV1Enabled({ enableArtifactsV1: false })).toBe(true);
expect(resolveArtifactsV1Enabled({ enableArtifactsV1: false, managedKeys: {} })).toBe(true);
});
it("honours a hosting operator's managed value", () => {
const managedKeys = { enableArtifactsV1: { managed: true as const, managedBy: "paperclip-cloud" as const } };
expect(resolveArtifactsV1Enabled({ enableArtifactsV1: false, managedKeys })).toBe(false);
expect(resolveArtifactsV1Enabled({ enableArtifactsV1: true, managedKeys })).toBe(true);
});
});
+10 -8
View File
@@ -1,11 +1,11 @@
import { useContext } from "react";
import type { InstanceExperimentalSettings } from "@paperclipai/shared";
import type { InstanceExperimentalSettingsWithManaged } from "@paperclipai/shared";
import { QueryClient, QueryClientContext, useQuery } from "@tanstack/react-query";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { queryKeys } from "@/lib/queryKeys";
export function resolveArtifactsV1Enabled(settings: Partial<Pick<InstanceExperimentalSettings, "enableArtifactsV1">> | null | undefined): boolean {
return settings?.enableArtifactsV1 === true;
export function resolveArtifactsV1Enabled(settings: Partial<Pick<InstanceExperimentalSettingsWithManaged, "enableArtifactsV1" | "managedKeys">> | null | undefined): boolean {
return !settings?.managedKeys?.enableArtifactsV1 || settings.enableArtifactsV1 !== false;
}
let detachedClient: QueryClient | null = null;
@@ -15,11 +15,13 @@ function getDetachedClient(): QueryClient {
}
/**
* Artifacts V1 (Settings > Experimental): the artifact directions lab's
* presentation on the Artifacts page, the panel's Artifacts tab and the task
* thread. Off by default — and off when rendered outside a query client, as
* the app root is in some harnesses — so every surface renders as before
* until an instance opts in. See doc/plans/2026-10-22-artifacts-mvp-v1.md.
* Artifacts V1: the artifact directions lab's presentation on the Artifacts
* page, the panel's Artifacts tab and the task thread. On for every instance,
* with no setting to change it: writes persist every flag, so a stored false
* only means an instance saved some other experiment. A hosting operator's
* managed value can still hold it off. Off when rendered outside a query
* client, as the app root is in some harnesses. See
* doc/plans/2026-10-22-artifacts-mvp-v1.md.
*/
export function useArtifactsV1Enabled(): { enabled: boolean; loaded: boolean } {
const contextClient = useContext(QueryClientContext);
@@ -1176,6 +1176,12 @@ describe("InstanceExperimentalSettings — operator-hidden cards", () => {
expect(container.textContent).not.toContain("Show the Apps navigation");
});
it("offers no Artifacts V1 toggle", async () => {
await renderPage([]);
expect(container.textContent).toContain("Beta skills");
expect(container.textContent).not.toContain("Artifacts V1");
});
it("keeps only permitted controls in alphabetical order, including when hidden features are enabled", async () => {
setWorktreeRuntimeMeta(true);
const visible = new Set(["enableExternalObjects", "enableMemoryConnectors", "enableSimplifiedEnglishInteractions"]);
@@ -320,18 +320,6 @@ export function InstanceExperimentalSettings() {
ariaLabel="Toggle agent chat experimental setting"
/>
<ExperimentalToggleCard
title="Artifacts V1"
description="Show artifacts the way the artifact directions lab settled: paper stacks and tiles on the Artifacts page, compact rows in the panel's Artifacts tab, and grouped rows in the task thread."
footnote="Presentation only. Turning this off restores the current cards; no data changes."
checked={experimentalQuery.data?.enableArtifactsV1 ?? false}
onCheckedChange={(checked) => toggleMutation.mutate({ enableArtifactsV1: checked })}
disabled={toggleMutation.isPending}
settingKey="enableArtifactsV1"
managed={managedKeys.enableArtifactsV1}
ariaLabel="Toggle artifacts V1 experimental setting"
/>
<ExperimentalToggleCard
title="Assistant connections (MCP)"
description="Connect Codex, Claude, and other assistants to your Paperclip organization. People sign in, select an organization once, and approve access to review work, delegate tasks, and add feedback."