diff --git a/doc/evals.md b/doc/evals.md
index 58282008d69..353fe60e268 100644
--- a/doc/evals.md
+++ b/doc/evals.md
@@ -90,7 +90,9 @@ successor. Registration and calibration are separate from a live staging pass.
[Reusable Product journeys](../tests/runner-e2e/PRODUCT-JOURNEYS.md) add explicit
deployment targets and starting-state recipes. The shared task-properties
-journey uses no model; Cloud onboarding supplies reusable verified state. Cloud
+journey and task-document create/edit/reopen journey use no model; Cloud
+onboarding supplies reusable verified state. The document journey checks exact
+saved content and both revision records through independent API reads. Cloud
fleet operations and trusted CI remain private. Local eval gateway qualification
does not qualify Cloud routes, and these selections stay outside `--all`.
diff --git a/maps/feature-map/documents-artifacts.md b/maps/feature-map/documents-artifacts.md
index f5e0eb6c980..a7813b17f1b 100644
--- a/maps/feature-map/documents-artifacts.md
+++ b/maps/feature-map/documents-artifacts.md
@@ -26,6 +26,15 @@ Agent access: [companion](../agent-access-map/documents-artifacts.md).
Open a document from the task thread or document side panel.
+To create or edit a general task document, open **Open a new tab → Artifacts**
+in the task side panel, then **New document** or **Document actions → Edit
+document**. Edits use the shared versioned editor and autosave. Classic mode
+keeps that editor in the task body. Plans retain their conversation and approval
+flow and cannot be created through the general document editor.
+Unfinished new documents, including a body typed before its key, survive a tab
+switch or reload within the same account and task. Cancel removes the backup;
+a confirmed creation clears it.
+
Select an inline image to open the document's image gallery. Use the left and
right arrow keys or the Previous and Next buttons to move through images in
reading order. The URL retains the document and adds a one-based `image` position,
@@ -56,6 +65,13 @@ Open `/artifacts` and its grouping, search, and media controls.
The explicit `build-revise` [Product journey](../../tests/runner-e2e/PRODUCT-JOURNEYS.md) covers `documents-artifacts/attachments`: browser downloads of two ZIP versions, an independent content oracle, and preservation of the original. Its shared source is not proof of a pass on any deployed target.
+The explicit `task-document-persistence` journey covers `documents-artifacts/documents`:
+browser creation and editing, task reopen/reload, exact saved title/body and
+independent original/latest revision reads, board-user authorship and zero agent
+runs. The setup owns a backlog task but does not seed a document. Calibration
+rejects stale content, lost history and foreign ownership. A target needs a fresh
+live pass before adding this journey to its scheduled checks.
+
Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use a task with a text document, a small attachment, and a generated work product. Use nonsensitive fixture files.
### `task-document`
diff --git a/tests/e2e/artifact-tab-arrival.spec.ts b/tests/e2e/artifact-tab-arrival.spec.ts
index ffed3168e2d..2f93a32e288 100644
--- a/tests/e2e/artifact-tab-arrival.spec.ts
+++ b/tests/e2e/artifact-tab-arrival.spec.ts
@@ -56,7 +56,9 @@ for (const mobile of [false, true]) {
else await expect(artifacts).toHaveAttribute("aria-selected", "false");
await artifacts.click();
await expectTaskPanelTab(panel, mobile, "Artifacts");
- await expect(panel.getByRole("heading", { name: "Arriving report", level: 2, exact: true })).toBeVisible();
+ const document = panel.locator("#document-report");
+ await expect(document.getByRole("heading", { name: "Arriving report", level: 1, exact: true })).toBeVisible();
+ await expect(document.locator("p").filter({ hasText: /^Durable output\.$/ })).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("artifact-opened-by-user.png"), fullPage: true });
});
}
diff --git a/tests/e2e/artifact-uploads.spec.ts b/tests/e2e/artifact-uploads.spec.ts
index 8dc63aa6fb5..9ccce84f188 100644
--- a/tests/e2e/artifact-uploads.spec.ts
+++ b/tests/e2e/artifact-uploads.spec.ts
@@ -1,4 +1,5 @@
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";
@@ -52,18 +53,26 @@ async function assertStillPaused(request: APIRequestContext, issueId: string) {
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("# 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") },
+ { name: "core-handoff.md", mimeType: "text/markdown", buffer: Buffer.from(markdown) },
+ { name: "loop-inventory.csv", mimeType: "text/csv", buffer: Buffer.from(csv) },
]);
- await expect(panel.getByRole("button", { name: "Read document", exact: true })).toBeVisible();
+ 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 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();
+ 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();
@@ -71,8 +80,15 @@ for (const mobile of [false, true]) {
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 });
});
@@ -80,8 +96,11 @@ for (const mobile of [false, 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 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" }));
@@ -99,12 +118,17 @@ test("the entire populated panel accepts a drop and preserves an existing docume
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 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"]);
- expect(documents.find((row: { key: string }) => row.key === "handoff").body).toContain("Preserve this version.");
+ 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);
});
@@ -138,7 +162,12 @@ test("reserved filenames create visible documents without replacing internal rev
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);
+ 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) {
diff --git a/tests/runner-e2e/FIXTURES.md b/tests/runner-e2e/FIXTURES.md
index ce7060a65b8..597418ec6b3 100644
--- a/tests/runner-e2e/FIXTURES.md
+++ b/tests/runner-e2e/FIXTURES.md
@@ -14,6 +14,10 @@ OpenCode delegation or changing the other suites' model.
fixture graph through `FixtureRegistry.setupAll(requested)`. Omitting the
argument preserves the existing all-fixtures behavior. A verified onboarding
context may seed the graph; independent setup is labeled as recipe provenance.
+The model-free task-document journey reuses the run-owned backlog-task fixture.
+It creates and changes the document only through the production browser editor;
+setup never seeds the saved outcome. Task ownership also covers its documents
+and revisions during cleanup.
## Connection creation fixtures
diff --git a/tests/runner-e2e/PRODUCT-JOURNEYS.md b/tests/runner-e2e/PRODUCT-JOURNEYS.md
index 98864325189..34cdd31a158 100644
--- a/tests/runner-e2e/PRODUCT-JOURNEYS.md
+++ b/tests/runner-e2e/PRODUCT-JOURNEYS.md
@@ -28,6 +28,7 @@ never start a server or depend on a tenant filesystem.
| `ask-question` | Company, configured agent capability, positive model allowance and exactly one permitted work run | Reuse the existing cross-model Ask question prompt; require one successful run with matching company/task/agent/model/environment, one saved exact answer, and visible readback after reload |
| `question-answer-resume` | Native agent; up to three work runs | Reuse the classic two-question story: answer a choice and a free-text question through the browser, verify each native continuation, then read the exact saved answers in an agent-authored task document after reload |
| `task-properties` | Company, signed-in board user, task-write capability, prepared run-owned backlog task | Open its properties, assign it to the signed-in user, reload, check both UI and saved assignment; verify no agent run |
+| `task-document-persistence` | Company, signed-in board user, task-write capability, prepared run-owned backlog task; no model allowance or runs | Create a general document through Artifacts, edit its title and body, reopen and reload it; independently verify exact canonical content, preserved original and latest revisions, board-user authorship, and no agent work |
| `task-search-filter` | Company, signed-in board user, task-write capability; zero model runs | Search three owned target/control tasks through the task list, require exact matching identities, filter Backlog to exclude the completed control, reload and return from the exact saved task with search/filter context intact |
| `plan-revise-accept` | Native agent; three work runs | Reuse the classic Plan story: canonical draft, browser revision request, new revision-bound confirmation, approval and exact saved final response |
| `build-revise` | Native agent; two work runs; qualified Docker artifact oracle | Browser-download both ZIP versions, independently exercise source/CLI behavior, verify different hashes and preserve the original download |
@@ -54,6 +55,16 @@ The shared Studio helper waits for the original saved file body before typing.
An editor control can mount before the file query completes; editing that empty
draft can otherwise lose the original content when the file arrives.
+The task-document journey uses the normal default-chat Artifacts pane and the
+shared document editor. Classic mode retains its editor in the task body. The
+general editor excludes Plans, which retain their conversation and approval
+flow. Setup creates only an owned backlog task, never the document or its
+revisions. The oracle accepts additional autosave revisions, but requires both
+measured versions to remain exact and independently readable. Shared coverage
+does not establish a live pass or admit the journey to a target's daily plan.
+The reopen step selects Backlog explicitly and searches the owned title. A
+user's saved task view cannot route that check to Done or a personal inbox.
+
The project/task journey records durable project and task ownership before each
creation and immediately saves the response IDs. Its project must retain its
planned state. Independent project-filtered task reads must contain the exact
diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md
index 738cf7688eb..01049d9e5a2 100644
--- a/tests/runner-e2e/README.md
+++ b/tests/runner-e2e/README.md
@@ -253,7 +253,7 @@ accordingly. Report regeneration recomputes interval verdicts, retains changed
The explicit [deployment-targeted Product journeys](PRODUCT-JOURNEYS.md) reuse
browser actions across managed self-hosted and Cloud targets. They include a
-model-free task-properties story, fresh Cloud onboarding, credential-free
+model-free task-properties and document-persistence stories, fresh Cloud onboarding, credential-free
previews, private evidence, and prerequisite reuse; `--all` is unchanged.
Cloud onboarding may retain a versioned `onboardingConnect` receipt for the
Connect action: its last checkpoint, validation/hire response state and HTTP
diff --git a/tests/runner-e2e/billing.test.ts b/tests/runner-e2e/billing.test.ts
index 037ccae91ec..06f9508cb1f 100644
--- a/tests/runner-e2e/billing.test.ts
+++ b/tests/runner-e2e/billing.test.ts
@@ -50,6 +50,98 @@ describe("runner E2E billing summaries", () => {
expect(fresh.complete).toBe(false);
});
+ it("counts a captured browser-only receipt without private journey metadata as zero runs", () => {
+ const browser = result({
+ suiteId: "qa-basics",
+ executionId: "qa-basics.browser.daytona.task-document-persistence",
+ environmentId: "daytona",
+ provider: null,
+ model: null,
+ runtimeMode: "none",
+ runIds: [],
+ usage: { runs: [] },
+ });
+ const billing = summarizeExecutionBilling(browser);
+ expect(billing).toMatchObject({
+ llm: { runCount: 0, runsWithTokenUsage: 0, runsWithReportedCost: 0, costStatus: "unavailable" },
+ runtime: { agentRunDurationMs: 0, costStatus: "unavailable" },
+ complete: false,
+ });
+ expect(aggregateCampaignBilling([browser])).toMatchObject({
+ llm: { runCount: 0, costStatus: "unavailable" },
+ agentRunDurationMs: 0,
+ testsWithCompleteBilling: 0,
+ });
+ });
+
+ it.each([
+ { runIds: undefined, usage: { runs: [] } },
+ { runIds: [], usage: undefined },
+ { runIds: [], usage: null },
+ { runIds: [], usage: {} },
+ { runIds: [], usage: { runs: [], collection: "unavailable" } },
+ ])("does not infer zero work from a missing browser-only receipt: %j", receipt => {
+ const billing = summarizeExecutionBilling(result({ runtimeMode: "none", ...receipt }));
+ expect(billing).toMatchObject({
+ llm: { runCount: 1, runsWithTokenUsage: 0, runsWithReportedCost: 0, costStatus: "unavailable" },
+ runtime: { agentRunDurationMs: null },
+ complete: false,
+ });
+ });
+
+ it.each([
+ { inputTokens: 100, outputTokens: 20, costUsd: 0.01 },
+ { runs: [{ runId: "run-1", usage: { inputTokens: 100, outputTokens: 20, costUsd: 0.01 } }] },
+ ])("retains usage that contradicts browser-only journey metadata: %j", usage => {
+ const billing = summarizeExecutionBilling(result({
+ runtimeMode: "none",
+ runIds: [],
+ usage,
+ productJourney: {
+ outcome: "passed", startingState: "recipe", phases: [], checks: [],
+ resourceDisposition: "released", privacy: "private",
+ },
+ }));
+ expect(billing).toMatchObject({
+ llm: { runCount: 1, inputTokens: 100, outputTokens: 20, reportedCostUsd: 0.01 },
+ runtime: { agentRunDurationMs: null },
+ });
+ });
+
+ it.each([false, true])("does not accept conflicting aggregate usage as zero-work proof (private journey: %s)", privateJourney => {
+ const browser = result({
+ runtimeMode: "none",
+ runIds: [],
+ usage: { runs: [], inputTokens: 100, outputTokens: 20, costUsd: 0.01 },
+ ...(privateJourney ? { productJourney: {
+ outcome: "passed" as const, startingState: "recipe" as const, phases: [], checks: [],
+ resourceDisposition: "released" as const, privacy: "private" as const,
+ } } : {}),
+ });
+ expect(summarizeExecutionBilling(browser)).toMatchObject({
+ llm: { runCount: 1, costStatus: "unavailable" },
+ runtime: { agentRunDurationMs: null },
+ complete: false,
+ });
+ });
+
+ it.each(["native", "legacy"] as const)("keeps %s run accounting separate from a captured empty browser receipt", runtimeMode => {
+ const empty = result({ runtimeMode, runIds: [], usage: { runs: [] } });
+ expect(summarizeExecutionBilling(empty).llm.runCount).toBe(1);
+ const measured = result({
+ runtimeMode,
+ runIds: ["run-1", "run-2"],
+ usage: { runs: [
+ { runId: "run-1", usage: { inputTokens: 10, outputTokens: 2, costUsd: 0.01 } },
+ { runId: "run-2", usage: { inputTokens: 20, outputTokens: 4, costUsd: 0.02 } },
+ ] },
+ });
+ expect(summarizeExecutionBilling(measured).llm).toMatchObject({
+ runCount: 2, runsWithTokenUsage: 2, runsWithReportedCost: 2,
+ inputTokens: 30, outputTokens: 6, reportedCostUsd: 0.03,
+ });
+ });
+
it("keeps missing agent time separate from attempt duration and measured zero", () => {
const interrupted = result({ status: "failed", durationMs: 108_850 });
expect(summarizeExecutionBilling(interrupted).runtime.agentRunDurationMs).toBeNull();
diff --git a/tests/runner-e2e/billing.ts b/tests/runner-e2e/billing.ts
index 766167c0bfb..0322dd9c690 100644
--- a/tests/runner-e2e/billing.ts
+++ b/tests/runner-e2e/billing.ts
@@ -283,7 +283,11 @@ export function summarizeExecutionBilling(
// Some retained journeys store their measured summary without raw run usage.
// Regeneration must preserve those measurements, including unknown prices.
if (result.usage === undefined && result.billing) return structuredClone(result.billing);
- const browserOnly = Boolean(result.productJourney && result.runtimeMode === "none" && !result.runIds?.length);
+ const capturedEmptyUsage = Array.isArray(result.usage?.runs) && result.usage.runs.length === 0 &&
+ Object.keys(result.usage).length === 1;
+ const browserOnly = result.runtimeMode === "none" && !result.runIds?.length &&
+ (result.usage == null ? Boolean(result.productJourney)
+ : capturedEmptyUsage && (Boolean(result.productJourney) || Array.isArray(result.runIds)));
const requestedRunCount = browserOnly ? 0 : Math.max(result.runIds?.length ?? 0, 1);
const entries = browserOnly ? [] : usageEntries(result.usage, requestedRunCount);
let inputTokens = 0;
@@ -335,7 +339,7 @@ export function summarizeExecutionBilling(
const runCount = Math.max(requestedRunCount, entries.length);
const costStatus = costCoverageStatus(runCount, runsWithTokenUsage, runsWithReportedCost, runsWithEstimatedCost, runsWithPartialReportedCost);
const runtime = { ...fallbackRuntimeUsage(result) };
- if (browserOnly) runtime.agentRunDurationMs = 0;
+ if (browserOnly && runCount === 0) runtime.agentRunDurationMs = 0;
const estimatedRuntimeCostUsd = runtime.estimatedListCostUsd ?? 0;
const judgments = [...(result.firstTaskQuality ? [result.firstTaskQuality] : []), ...(result.completionQuality ?? [])];
const sumKnown = (key: "inputTokens" | "outputTokens" | "estimatedCostUsd") => judgments.some(q => q[key] === null) ? null : judgments.reduce((sum, q) => sum + (q[key] ?? 0), 0);
diff --git a/tests/runner-e2e/catalog.test.ts b/tests/runner-e2e/catalog.test.ts
index 57b3034d240..a8ba74083b5 100644
--- a/tests/runner-e2e/catalog.test.ts
+++ b/tests/runner-e2e/catalog.test.ts
@@ -77,7 +77,7 @@ describe("runner E2E catalog", () => {
});
it("exports deployment journeys separately from the model matrix and default selection", () => {
- expect(productJourneys.map(journey => journey.id)).toEqual(["task-create", "project-task-context", "ask-question", "question-answer-resume", "task-properties", "task-search-filter", "plan-revise-accept", "build-revise", "protected-actions", "create-skill-studio", "stop-redirect", "followup-while-running", "cloud-onboarding"]);
+ expect(productJourneys.map(journey => journey.id)).toEqual(["task-create", "project-task-context", "ask-question", "question-answer-resume", "task-properties", "task-document-persistence", "task-search-filter", "plan-revise-accept", "build-revise", "protected-actions", "create-skill-studio", "stop-redirect", "followup-while-running", "cloud-onboarding"]);
expect(runnerSuites.some(suite => suite.id === "product-journeys")).toBe(false);
expect(runnerMatrix.some(execution => execution.suite.id === "product-journeys")).toBe(false);
expect(selectRunnerExecutions(parseRunnerSelectors(["--all"])).some(execution => execution.suite.id === "product-journeys")).toBe(false);
diff --git a/tests/runner-e2e/journey-features.ts b/tests/runner-e2e/journey-features.ts
index dc64adb43d7..73ec8eb1de4 100644
--- a/tests/runner-e2e/journey-features.ts
+++ b/tests/runner-e2e/journey-features.ts
@@ -22,7 +22,7 @@ associate(["product-journeys"], { "task-create": ["tasks/create"], "ask-question
"question-answer-resume": ["questions-and-approvals/questions", "questions-and-approvals/continuation"],
"followup-while-running": ["steering/queue"],
"project-task-context": ["projects/lifecycle", "projects/task-context", "tasks/create"],
- "task-properties": ["tasks/properties"], "task-search-filter": ["tasks/organize"], "cloud-onboarding": ["onboarding/first-task"] });
+ "task-properties": ["tasks/properties"], "task-document-persistence": ["documents-artifacts/documents"], "task-search-filter": ["tasks/organize"], "cloud-onboarding": ["onboarding/first-task"] });
associate(["antigravity-agent-journey"], {
"saved-subscription-two-turn": ["agents/hire", "agents/instructions", "runs-adapters/validation", "runs-adapters/session",
"tasks/create", "tasks/completion", "documents-artifacts/attachments", "documents-artifacts/work-products"],
diff --git a/tests/runner-e2e/product-journeys/catalog.ts b/tests/runner-e2e/product-journeys/catalog.ts
index 3013851a360..365a3ac1794 100644
--- a/tests/runner-e2e/product-journeys/catalog.ts
+++ b/tests/runner-e2e/product-journeys/catalog.ts
@@ -7,6 +7,7 @@ export const productJourneys = [
{ id: "ask-question", label: "Ask an agent a question and read its answer", requires: ["company", "board-user", "task-write", "agent"], capabilities: ["questions-and-approvals/questions"], paid: true },
{ id: "question-answer-resume", label: "Answer agent questions and verify the saved result", requires: ["company", "board-user", "task-write", "agent"], capabilities: ["questions-and-approvals/questions"], paid: true },
{ id: "task-properties", label: "Assign a task to the signed-in user", requires: ["company", "board-user", "task-write", "backlog-task"], capabilities: ["tasks/properties"], paid: false },
+ { id: "task-document-persistence", label: "Create, edit, and reopen a task document", requires: ["company", "board-user", "task-write", "backlog-task"], capabilities: ["documents-artifacts/documents"], paid: false },
{ id: "task-search-filter", label: "Search and filter tasks, then reopen the exact saved result", requires: ["company", "board-user", "task-write"], capabilities: ["tasks/organize"], paid: false },
{ id: "plan-revise-accept", label: "Revise and approve a saved Plan, then complete the task", requires: ["company", "board-user", "task-write", "agent"], capabilities: ["questions-and-approvals/plan-review"], paid: true },
{ id: "build-revise", label: "Download and independently verify both artifact versions", requires: ["company", "board-user", "task-write", "agent"], capabilities: ["documents-artifacts/attachments"], paid: true },
diff --git a/tests/runner-e2e/product-journeys/launch.ts b/tests/runner-e2e/product-journeys/launch.ts
index 72706d53ba9..074bfd51aa5 100644
--- a/tests/runner-e2e/product-journeys/launch.ts
+++ b/tests/runner-e2e/product-journeys/launch.ts
@@ -12,6 +12,7 @@ import { journeyFixtures } from "./fixtures.js";
import { taskProperties } from "./task-properties.js";
import { taskSearchFilter } from "./task-search-filter.js";
import { askQuestion, taskCreate } from "./basic-flows.js";
+import { taskDocumentPersistence } from "./task-document-flow.js";
import { projectTaskContext } from "./project-context-flow.js";
import { runAgentJourney } from "./agent-flows.js";
import { runActiveFollowup } from "./active-followup-flow.js";
@@ -107,7 +108,7 @@ export async function launchProductJourneys(args: string[], repositoryRoot: stri
try { sha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repositoryRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch { /* Packaged harnesses may not include Git metadata. */ }
const source = resolveRunnerE2ESource({ sha, ref: null, workflowRunUrl: null });
const definitionHash = createHash("sha256").update(JSON.stringify(productJourneys));
- for (const name of ["catalog.ts", "task-properties.ts", "task-search-filter.ts", "task-search-evidence.ts", "basic-flows.ts", "basic-evidence.ts", "project-context-flow.ts", "project-context-readback.ts", "project-context-evidence.ts", "agent-flows.ts", "active-followup-flow.ts", "active-followup-evidence.ts", "clarification-flow.ts", "clarification-evidence.ts", "../question-resume-ui.ts", "../question-resume-scoring.ts", "../runtime-question-readiness.ts", "../chat-flow.ts", "../continuation-readiness.ts", "../continuation-cases.ts", "success-contract.ts", "product-screenshot.ts", "cloud-onboarding.ts", "onboarding-connect.ts", "onboarding-first-task.ts", "workspace-deferral.ts", "onboarding-pre-acceptance.ts", "artifact-evidence.ts", "fixtures.ts", "launch.ts", "types.ts", "collection.ts", "local-target.ts"]) definitionHash.update(await readFile(path.join(import.meta.dirname, name)));
+ for (const name of ["catalog.ts", "task-properties.ts", "task-search-filter.ts", "task-search-evidence.ts", "basic-flows.ts", "basic-evidence.ts", "task-document-flow.ts", "task-document-evidence.ts", "project-context-flow.ts", "project-context-readback.ts", "project-context-evidence.ts", "agent-flows.ts", "active-followup-flow.ts", "active-followup-evidence.ts", "clarification-flow.ts", "clarification-evidence.ts", "../question-resume-ui.ts", "../question-resume-scoring.ts", "../runtime-question-readiness.ts", "../chat-flow.ts", "../continuation-readiness.ts", "../continuation-cases.ts", "success-contract.ts", "product-screenshot.ts", "cloud-onboarding.ts", "onboarding-connect.ts", "onboarding-first-task.ts", "workspace-deferral.ts", "onboarding-pre-acceptance.ts", "artifact-evidence.ts", "fixtures.ts", "launch.ts", "types.ts", "collection.ts", "local-target.ts"]) definitionHash.update(await readFile(path.join(import.meta.dirname, name)));
for (const file of ["catalog.ts", "api.ts", "run-observations.ts", "native-task-reply.ts", "native-event-envelope.ts", "user-actions.ts", "plan-revision-flow.ts", "skill-studio-flow.ts", "protected-action-flow.ts", "story-artifact.ts", "stop-redirect-flow.ts", "everyday-cases.ts", "everyday-delivery.ts", "everyday-decisions.ts", "connection-guidance-evidence.ts", "everyday-artifact.py"]) definitionHash.update(await readFile(path.join(import.meta.dirname, "..", file)));
definitionHash.update(runnerSuites.flatMap(suite => suite.tasks).find(task => task.id === "ask-question")!.buildPrompt("definition"));
const suiteDefinitionHash = definitionHash.digest("hex");
@@ -221,6 +222,7 @@ export async function launchProductJourneys(args: string[], repositoryRoot: stri
await captureProductScreenshot(ready!.page, ready!.api.origin, path.join(directory, file), deadlineAt);
} });
} else if (id === "task-create") await taskCreate(ready, session!, campaignId, evidence);
+ else if (id === "task-document-persistence") await taskDocumentPersistence(ready, session!, campaignId, evidence);
else if (id === "project-task-context") await projectTaskContext(ready, session!, campaignId, evidence);
else if (id === "task-search-filter") await taskSearchFilter(ready, session!, campaignId, evidence);
else await taskProperties(ready, session!, campaignId, evidence);
diff --git a/tests/runner-e2e/product-journeys/success-contract.ts b/tests/runner-e2e/product-journeys/success-contract.ts
index 67d37578e3e..baff0d5eec3 100644
--- a/tests/runner-e2e/product-journeys/success-contract.ts
+++ b/tests/runner-e2e/product-journeys/success-contract.ts
@@ -11,6 +11,7 @@ export function agentJourneyRunCountValid(id: string, count: unknown): boolean {
export const journeyRequiredChecks: Record = {
"task-properties": ["assignment-persisted", "no-agent-work"],
"task-create": ["browser-created-task-persisted", "no-agent-work"],
+ "task-document-persistence": ["browser-document-created", "document-revision-history-preserved", "document-reopened-after-reload", "no-agent-work"],
"project-task-context": ["browser-created-project-persisted", "browser-created-project-task-persisted", "project-task-association-reloaded", "project-task-context-visible", "no-agent-work"],
"task-search-filter": ["task-search-results-exact", "task-status-filter-excludes-controls", "task-search-filter-reloaded", "task-search-result-reopened", "no-agent-work"],
"ask-question": ["question-answer-persisted", "one-bound-successful-run", "configured-model-and-environment"],
diff --git a/tests/runner-e2e/product-journeys/task-document-evidence.test.ts b/tests/runner-e2e/product-journeys/task-document-evidence.test.ts
new file mode 100644
index 00000000000..67d29e4f062
--- /dev/null
+++ b/tests/runner-e2e/product-journeys/task-document-evidence.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it } from "vitest";
+import { isAgentJourney, parseTargetConfig, preparationPreview } from "./catalog.js";
+import { taskDocumentHistoryPasses, taskDocumentMatches, type TaskDocumentRevision, type TaskDocumentSnapshot } from "./task-document-evidence.js";
+import { journeyRequiredChecks } from "./success-contract.js";
+import { validateRetainedRunnerResult } from "../result-validation.js";
+import type { RunnerE2EResult } from "../types.js";
+
+function evidence() {
+ const original = { companyId: "company", issueId: "task", userId: "user", key: "qa-note", title: "Original title", body: "Original body" };
+ const revised = { ...original, title: "Revised title", body: "Revised body" };
+ const initial: TaskDocumentSnapshot = { id: "document", companyId: "company", issueId: "task", key: "qa-note", title: original.title, body: original.body,
+ format: "markdown", latestRevisionId: "revision-1", latestRevisionNumber: 1,
+ createdByUserId: "user", createdByAgentId: null, updatedByUserId: "user", updatedByAgentId: null };
+ const current: TaskDocumentSnapshot = { ...initial, title: revised.title, body: revised.body, latestRevisionId: "revision-3", latestRevisionNumber: 3 };
+ const revision = (document: TaskDocumentSnapshot): TaskDocumentRevision => ({ id: document.latestRevisionId!, documentId: document.id,
+ companyId: document.companyId, issueId: document.issueId, key: document.key, title: document.title, body: document.body, format: document.format,
+ revisionNumber: document.latestRevisionNumber, createdByUserId: "user", createdByAgentId: null, createdByRunId: null });
+ const originalRevision = revision(initial);
+ const currentRevision = revision(current);
+ return { initial, current, original, revised, originalRevision, currentRevision, revisions: [originalRevision, currentRevision] };
+}
+
+describe("task document persistence oracle", () => {
+ it("accepts exact canonical content and preserved revisions, including extra autosave revisions", () => {
+ expect(taskDocumentHistoryPasses(evidence())).toBe(true);
+ expect(taskDocumentMatches(undefined, evidence().original)).toBe(false);
+ });
+
+ it.each([
+ ["wrong company", (e: ReturnType) => { e.current.companyId = "other"; }],
+ ["wrong task", (e: ReturnType) => { e.current.issueId = "other"; }],
+ ["wrong key", (e: ReturnType) => { e.current.key = "plan"; }],
+ ["stale body", (e: ReturnType) => { e.current.body = e.original.body; }],
+ ["stale title", (e: ReturnType) => { e.current.title = e.original.title; }],
+ ["different document", (e: ReturnType) => { e.current.id = "other"; }],
+ ["wrong author", (e: ReturnType) => { e.initial.createdByUserId = "other"; }],
+ ["agent edit", (e: ReturnType) => { e.current.updatedByAgentId = "agent"; }],
+ ["absent revision pointer", (e: ReturnType) => { e.current.latestRevisionId = null; }],
+ ["unchanged revision", (e: ReturnType) => { e.current.latestRevisionId = e.initial.latestRevisionId; }],
+ ["regressed revision number", (e: ReturnType) => { e.current.latestRevisionNumber = 1; }],
+ ["missing history", (e: ReturnType) => { e.revisions = []; }],
+ ["lost original", (e: ReturnType) => { e.revisions = [e.currentRevision]; }],
+ ["overwritten original", (e: ReturnType) => { e.originalRevision.body = e.revised.body; }],
+ ["wrong revision owner", (e: ReturnType) => { e.currentRevision.documentId = "other"; }],
+ ["wrong revision scope", (e: ReturnType) => { e.originalRevision.companyId = "other"; }],
+ ["agent-created revision", (e: ReturnType) => { e.currentRevision.createdByRunId = "run"; }],
+ ["duplicate revision", (e: ReturnType) => { e.revisions.push(e.originalRevision); }],
+ ["list differs from independent read", (e: ReturnType) => { e.revisions = [{ ...e.originalRevision, body: "Wrong" }, e.currentRevision]; }],
+ ])("rejects %s", (_label, change) => {
+ const input = evidence();
+ change(input);
+ expect(taskDocumentHistoryPasses(input)).toBe(false);
+ });
+
+ it("requires the complete closed browser-only result and zero model allowance", () => {
+ const id = "task-document-persistence";
+ const config = parseTargetConfig({ version: 1, id: "qa", mode: "existing-cloud", adapterModule: "/private/provider.mjs", journeys: [id],
+ executionEnvironment: "daytona", baseline: { features: {}, connections: [] }, limits: { durationMs: 120_000, modelBudgetCents: 0, maxRuns: 0 } });
+ expect(isAgentJourney(id)).toBe(false);
+ expect(preparationPreview(config).journeys[0]).toMatchObject({ paid: false, capabilities: ["documents-artifacts/documents"], requires: ["company", "board-user", "task-write", "backlog-task"] });
+ const result: RunnerE2EResult = { schema: "paperclip.runner-e2e.result/v2", executionId: `product-journeys.qa.browser.daytona.${id}`, suiteId: "product-journeys",
+ suiteDefinitionHash: "definition", profileId: "browser", environmentId: "daytona", caseId: id, provider: null, model: null, runtimeMode: "none",
+ status: "passed", attempt: 1, cleanup: "passed", startedAt: "2026-10-30T10:00:00Z", finishedAt: "2026-10-30T10:00:01Z", durationMs: 1000,
+ productJourney: { outcome: "passed", startingState: "recipe", privacy: "private", resourceDisposition: "released", phases: [{ phase: "journey", outcome: "passed" }],
+ checks: journeyRequiredChecks[id]!.map(id => ({ id, passed: true })), deployment: { kind: "cloud", targetId: "qa", origin: "https://qa.example.test", commit: "a".repeat(40),
+ cloudRelease: "release", runtimeImage: null, configurationHash: "configuration" } } };
+ expect(() => validateRetainedRunnerResult(result)).not.toThrow();
+ for (const check of journeyRequiredChecks[id]!) {
+ const partial = structuredClone(result);
+ partial.productJourney!.checks = partial.productJourney!.checks.filter(item => item.id !== check);
+ expect(() => validateRetainedRunnerResult(partial)).toThrow();
+ }
+ for (const unexpected of [{ runIds: ["run"] }, { runtimeMode: "native" as const }, { provider: "provider" }, { model: "model" }]) {
+ expect(() => validateRetainedRunnerResult({ ...structuredClone(result), ...unexpected })).toThrow();
+ }
+ });
+});
diff --git a/tests/runner-e2e/product-journeys/task-document-evidence.ts b/tests/runner-e2e/product-journeys/task-document-evidence.ts
new file mode 100644
index 00000000000..ff4c9d2d80d
--- /dev/null
+++ b/tests/runner-e2e/product-journeys/task-document-evidence.ts
@@ -0,0 +1,54 @@
+/** Saved-state checks. Browser actions must produce these records through the normal API. */
+export interface TaskDocumentExpectation {
+ companyId: string; issueId: string; userId: string; key: string; title: string; body: string;
+}
+export interface TaskDocumentSnapshot {
+ id: string; companyId: string; issueId: string; key: string; title: string | null; body: string; format: string;
+ latestRevisionId: string | null; latestRevisionNumber: number;
+ createdByUserId: string | null; createdByAgentId: string | null;
+ updatedByUserId: string | null; updatedByAgentId: string | null;
+}
+export interface TaskDocumentRevision {
+ id: string; companyId: string; issueId: string; documentId: string; key: string;
+ title: string | null; body: string; format: string; revisionNumber: number;
+ createdByUserId: string | null; createdByAgentId: string | null; createdByRunId?: string | null;
+}
+
+export function taskDocumentMatches(document: TaskDocumentSnapshot | undefined, expected: TaskDocumentExpectation): boolean {
+ return Boolean(document && document.id && document.latestRevisionId
+ && Number.isSafeInteger(document.latestRevisionNumber) && document.latestRevisionNumber > 0
+ && document.companyId === expected.companyId && document.issueId === expected.issueId
+ && document.key === expected.key && document.format === "markdown"
+ && document.title === expected.title && document.body === expected.body
+ && document.createdByUserId === expected.userId && document.createdByAgentId === null
+ && document.updatedByUserId === expected.userId && document.updatedByAgentId === null);
+}
+
+function revisionMatches(revision: TaskDocumentRevision | undefined, document: TaskDocumentSnapshot, expected: TaskDocumentExpectation): boolean {
+ return Boolean(revision && revision.id === document.latestRevisionId
+ && revision.revisionNumber === document.latestRevisionNumber && revision.documentId === document.id
+ && revision.companyId === expected.companyId && revision.issueId === expected.issueId
+ && revision.key === expected.key && revision.format === "markdown"
+ && revision.title === expected.title && revision.body === expected.body
+ && revision.createdByUserId === expected.userId && revision.createdByAgentId === null
+ && revision.createdByRunId == null);
+}
+
+export function taskDocumentHistoryPasses(input: {
+ initial: TaskDocumentSnapshot; current: TaskDocumentSnapshot;
+ original: TaskDocumentExpectation; revised: TaskDocumentExpectation;
+ revisions: TaskDocumentRevision[];
+ originalRevision: TaskDocumentRevision; currentRevision: TaskDocumentRevision;
+}): boolean {
+ const { initial, current, original, revised, revisions } = input;
+ return taskDocumentMatches(initial, original) && taskDocumentMatches(current, revised)
+ && initial.id === current.id && initial.latestRevisionId !== current.latestRevisionId
+ && current.latestRevisionNumber > initial.latestRevisionNumber
+ && original.body !== revised.body && original.title !== revised.title
+ && revisions.filter(revision => revision.id === initial.latestRevisionId).length === 1
+ && revisions.filter(revision => revision.id === current.latestRevisionId).length === 1
+ && revisionMatches(input.originalRevision, initial, original)
+ && revisionMatches(input.currentRevision, current, revised)
+ && revisionMatches(revisions.find(revision => revision.id === initial.latestRevisionId), initial, original)
+ && revisionMatches(revisions.find(revision => revision.id === current.latestRevisionId), current, revised);
+}
diff --git a/tests/runner-e2e/product-journeys/task-document-flow.test.ts b/tests/runner-e2e/product-journeys/task-document-flow.test.ts
new file mode 100644
index 00000000000..0d28da0be36
--- /dev/null
+++ b/tests/runner-e2e/product-journeys/task-document-flow.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it, vi } from "vitest";
+import { reopenTaskDocument } from "./task-document-flow.js";
+import type { JourneyContext } from "./types.js";
+
+describe("task document reopen navigation", () => {
+ it.each(["done", "mine"])("finds the backlog task instead of inheriting the saved %s view", async savedView => {
+ const issue = { title: "Journey QA task document" };
+ let view = savedView;
+ let search = "";
+ const click = vi.fn(async () => {
+ if (view !== "backlog" || search !== issue.title) throw new Error("Owned backlog task is not in this task list");
+ });
+ const reload = vi.fn();
+ const context = {
+ api: { origin: "https://qa.example.test" }, company: { issuePrefix: "QA" },
+ page: {
+ goto: vi.fn(async (url: string) => {
+ const params = new URL(url).searchParams;
+ view = params.get("view") ?? savedView;
+ search = params.get("q") ?? "";
+ }),
+ reload,
+ getByRole: (role: string) => role === "link"
+ ? { filter: ({ hasText }: { hasText: string }) => {
+ expect(hasText).toBe(issue.title);
+ return { first: () => ({ click }) };
+ } }
+ : { isVisible: async () => true },
+ },
+ } as unknown as JourneyContext;
+ await reopenTaskDocument(context, issue);
+ expect(click).toHaveBeenCalledOnce();
+ expect(reload).toHaveBeenCalledOnce();
+ });
+});
diff --git a/tests/runner-e2e/product-journeys/task-document-flow.ts b/tests/runner-e2e/product-journeys/task-document-flow.ts
new file mode 100644
index 00000000000..29ea5491a7d
--- /dev/null
+++ b/tests/runner-e2e/product-journeys/task-document-flow.ts
@@ -0,0 +1,92 @@
+import { expect } from "@playwright/test";
+import { taskDocumentHistoryPasses, taskDocumentMatches, type TaskDocumentExpectation, type TaskDocumentRevision, type TaskDocumentSnapshot } from "./task-document-evidence.js";
+import { JourneyError, type JourneyContext, type JourneyEvidence, type TargetSession } from "./types.js";
+
+async function openDocumentAuthoring({ page }: JourneyContext) {
+ const create = page.getByRole("button", { name: "New document", exact: true });
+ if (await create.isVisible()) return;
+ const properties = page.getByRole("button", { name: /^(Show properties|Properties)$/ }).first();
+ if (await properties.isVisible()) await properties.click();
+ const artifacts = page.getByRole("tab", { name: /^Artifacts/ }).first();
+ if (await artifacts.isVisible()) await artifacts.click();
+ else {
+ await page.getByRole("button", { name: "Open a new tab", exact: true }).click();
+ await page.getByRole("option", { name: /^Artifacts/ }).click();
+ }
+ await expect(create).toBeVisible();
+}
+
+export async function reopenTaskDocument(context: JourneyContext, issue: { title: string }) {
+ const { page, api, company } = context;
+ await page.goto(`${api.origin}/${company.issuePrefix}/issues?view=backlog&q=${encodeURIComponent(issue.title)}`);
+ await page.getByRole("link").filter({ hasText: issue.title }).first().click();
+ await page.reload();
+ await openDocumentAuthoring(context);
+}
+
+export async function taskDocumentPersistence(context: JourneyContext, _session: TargetSession, campaignId: string, evidence: JourneyEvidence) {
+ const { page, api, company, user } = context;
+ const issue = context.fixtures?.backlogTask;
+ if (!issue?.id || !issue.identifier) throw new JourneyError("blocked", "missing_prepared_backlog_task");
+ const route = `/api/issues/${issue.id}`;
+ const key = "qa-note";
+ const documentRoute = `${route}/documents/${key}`;
+ const original: TaskDocumentExpectation = { companyId: company.id, issueId: issue.id, userId: user.id, key,
+ title: `QA document ${campaignId}`, body: `Original task note for ${campaignId}.` };
+ const revised = { ...original, title: `${original.title} revised`, body: `Persisted document ${campaignId}: revised text survives a reload.` };
+ if ((await api.get(`${route}/documents`)).some(document => document.key === key)) {
+ throw new JourneyError("failed", "task_document_fixture_not_fresh");
+ }
+
+ await page.goto(`${api.origin}/${company.issuePrefix}/issues/${issue.identifier}`);
+ await openDocumentAuthoring(context);
+ await page.getByRole("button", { name: "New document", exact: true }).click();
+ const form = page.getByPlaceholder("Document key", { exact: true }).locator("..");
+ await page.getByPlaceholder("Document key", { exact: true }).fill(key);
+ await page.getByPlaceholder("Optional title", { exact: true }).fill(original.title);
+ const newEditor = form.locator('[contenteditable="true"], textarea').first();
+ await newEditor.fill(original.body);
+ await page.getByRole("button", { name: "Create document", exact: true }).click();
+ await expect.poll(async () => taskDocumentMatches((await api.get(`${route}/documents`)).find(document => document.key === key), original),
+ { timeout: 30_000, intervals: [300, 600, 1000] }).toBe(true);
+ const initial = await api.get(documentRoute);
+ if (!taskDocumentMatches(initial, original)) throw new JourneyError("failed", "created_document_mismatch");
+ evidence.checks.push({ id: "browser-document-created", passed: true });
+
+ // Reopen the persisted document before editing; a locally retained draft cannot stand in for the saved body.
+ await page.reload();
+ await openDocumentAuthoring(context);
+ const document = page.locator(`#document-${key}`);
+ await expect(document.getByText(original.body, { exact: true })).toBeVisible();
+ await document.getByRole("button", { name: "Document actions", exact: true }).click();
+ await page.getByRole("menuitem", { name: "Edit document", exact: true }).click();
+ const editor = document.locator('[contenteditable="true"], textarea').first();
+ if (await editor.getAttribute("contenteditable") === "true") await expect(editor).toHaveText(original.body);
+ else await expect(editor).toHaveValue(original.body);
+ await document.getByPlaceholder("Optional title", { exact: true }).fill(revised.title);
+ await editor.fill(revised.body);
+ // Leaving the editor uses the production blur/autosave contract.
+ await page.getByRole("button", { name: "New document", exact: true }).focus();
+ await expect.poll(async () => taskDocumentMatches(await api.get(documentRoute), revised),
+ { timeout: 30_000, intervals: [300, 600, 1000] }).toBe(true);
+ const current = await api.get(documentRoute);
+ const revisions = await api.get(`${documentRoute}/revisions`);
+ const originalRevision = await api.get(`${documentRoute}/revisions/${initial.latestRevisionId}`);
+ const currentRevision = await api.get(`${documentRoute}/revisions/${current.latestRevisionId}`);
+ if (!taskDocumentHistoryPasses({ initial, current, original, revised, revisions, originalRevision, currentRevision })) {
+ throw new JourneyError("failed", "task_document_revision_history_mismatch");
+ }
+ evidence.checks.push({ id: "document-revision-history-preserved", passed: true });
+
+ await reopenTaskDocument(context, issue);
+ await expect(document.getByText(revised.title, { exact: true })).toBeVisible();
+ await expect(document.getByText(revised.body, { exact: true })).toBeVisible();
+ const reopened = await api.get(documentRoute);
+ const savedIssue = await api.get<{ companyId: string; status: string }>(route);
+ if (!taskDocumentMatches(reopened, revised) || reopened.latestRevisionId !== current.latestRevisionId
+ || reopened.latestRevisionNumber !== current.latestRevisionNumber) throw new JourneyError("failed", "task_document_reload_mismatch");
+ if (savedIssue.companyId !== company.id || savedIssue.status !== "backlog" || (await api.get(`${route}/runs`)).length) {
+ throw new JourneyError("failed", "browser_only_journey_started_agent_work");
+ }
+ evidence.checks.push({ id: "document-reopened-after-reload", passed: true }, { id: "no-agent-work", passed: true });
+}
diff --git a/tests/runner-e2e/product-journeys/types.ts b/tests/runner-e2e/product-journeys/types.ts
index 24f57c2cf7f..3d39d6b37e3 100644
--- a/tests/runner-e2e/product-journeys/types.ts
+++ b/tests/runner-e2e/product-journeys/types.ts
@@ -2,7 +2,7 @@ import type { BrowserContext, Page } from "@playwright/test";
import type { ConnectionApi } from "../connection-target.js";
export type JourneyOutcome = "passed" | "failed" | "blocked" | "unsupported";
-export type JourneyId = "task-properties" | "task-create" | "project-task-context" | "ask-question" | "cloud-onboarding"
+export type JourneyId = "task-properties" | "task-create" | "project-task-context" | "task-document-persistence" | "ask-question" | "cloud-onboarding"
| "question-answer-resume" | "task-search-filter" | "followup-while-running" | "plan-revise-accept" | "build-revise" | "protected-actions" | "create-skill-studio" | "stop-redirect";
export type TargetMode = "managed-self-hosted" | "existing-cloud" | "new-cloud";
export interface DeploymentIdentity {
diff --git a/tests/runner-e2e/report-catalog.test.ts b/tests/runner-e2e/report-catalog.test.ts
index 09d6bd6d6d7..3c7b774488d 100644
--- a/tests/runner-e2e/report-catalog.test.ts
+++ b/tests/runner-e2e/report-catalog.test.ts
@@ -89,6 +89,7 @@ describe("trusted report catalog discovery", () => {
const product = { ...known, suite: { ...known.suite, id: "product-journeys" } };
expect(journeyCapabilities(product).map(capability => capability.id)).toEqual(["questions-and-approvals/questions"]);
expect(journeyCapabilities({ ...product, task: { ...known.task, id: "task-create" } }).map(capability => capability.id)).toEqual(["tasks/create"]);
+ expect(journeyCapabilities({ ...product, task: { ...known.task, id: "task-document-persistence" } }).map(capability => capability.id)).toEqual(["documents-artifacts/documents"]);
expect(journeyCapabilities({ ...product, task: { ...known.task, id: "task-search-filter" } }).map(capability => capability.id)).toEqual(["tasks/organize"]);
});
diff --git a/tests/runner-e2e/result-validation.ts b/tests/runner-e2e/result-validation.ts
index d85b195394e..73e726d6491 100644
--- a/tests/runner-e2e/result-validation.ts
+++ b/tests/runner-e2e/result-validation.ts
@@ -434,7 +434,7 @@ export function validateRetainedRunnerResult(
if (!evidence) return invalid("result.productJourney");
const required = journeyRequiredChecks[checked.caseId];
if (evidence.outcome !== "passed" || !evidence.deployment || !required || !required.every(id => evidence.checks.some(c => c.id === id && c.passed)) || evidence.checks.some(check => !check.passed) || evidence.phases.some(p => p.outcome !== "passed") || checked.cleanup !== "passed") invalid("result.productJourney");
- if (["task-properties", "task-create", "project-task-context", "task-search-filter"].includes(checked.caseId) && (checked.runtimeMode !== "none" || checked.provider !== null || checked.model !== null || checked.runIds?.length)) invalid("result.browserOnly");
+ if (["task-properties", "task-create", "project-task-context", "task-document-persistence", "task-search-filter"].includes(checked.caseId) && (checked.runtimeMode !== "none" || checked.provider !== null || checked.model !== null || checked.runIds?.length)) invalid("result.browserOnly");
if (Object.hasOwn(agentJourneyRuns, checked.caseId) && (!agentJourneyRunCountValid(checked.caseId, checked.runIds?.length)
|| !checked.model || !checked.provider || checked.runtimeMode !== "native")) invalid("result.agentJourneyRun");
if (checked.caseId === "cloud-onboarding" && (!checked.runIds?.length || !evidence.operations || !evidence.operations.requestCount || !evidence.operations.traceIds.length || evidence.operations.campaignId !== evidence.campaignId || evidence.operations.attemptId !== evidence.attemptId)) invalid("result.paidJourneyReceipt");
diff --git a/ui/src/components/IssueDocumentsSection.test.tsx b/ui/src/components/IssueDocumentsSection.test.tsx
index cd456427ef1..16f3f4ed0dc 100644
--- a/ui/src/components/IssueDocumentsSection.test.tsx
+++ b/ui/src/components/IssueDocumentsSection.test.tsx
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
-import { act as reactAct } from "react";
+import { act as reactAct, useState } from "react";
import type { ComponentProps } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
@@ -314,6 +314,417 @@ describe("IssueDocumentsSection", () => {
container.remove();
});
+ it.each([false, true])("restores an unfinished document after its Artifacts pane unmounts (general only: %s)", async generalDocumentsOnly => {
+ mockIssuesApi.listDocuments.mockResolvedValue([]);
+ const root = createRoot(container);
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
+ client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
+ function TaskPane() {
+ const [artifacts, setArtifacts] = useState(true);
+ return <>
+
+
+ {artifacts ? : null}
+ >;
+ }
+ await act(async () => root.render());
+ await flush();
+ const button = (name: string) => Array.from(container.querySelectorAll("button")).find(element => element.textContent === name)!;
+ await act(async () => Array.from(container.querySelectorAll("button")).find(element => element.textContent?.includes("New document"))!.click());
+ await act(async () => markdownEditorMockState.changeBody?.("Body typed before its key"));
+ const title = container.querySelector('input[placeholder="Optional title"]')!;
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(title, "Unfinished note");
+ title.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+ const form = container.querySelector('input[placeholder="Document key"]')!.parentElement!;
+ await act(async () => form.dispatchEvent(new FocusEvent("focusout", { bubbles: true, relatedTarget: button("Properties") })));
+ expect(mockIssuesApi.upsertDocument).not.toHaveBeenCalled();
+ await act(async () => button("Properties").click());
+ expect(container.textContent).not.toContain("Body typed before its key");
+ await act(async () => button("Artifacts").click());
+ await flush();
+ expect(container.textContent).toContain("Body typed before its key");
+ expect(container.querySelector('input[placeholder="Document key"]')?.value).toBe("");
+ expect(container.querySelector('input[placeholder="Optional title"]')?.value).toBe("Unfinished note");
+ expect(mockIssuesApi.upsertDocument).not.toHaveBeenCalled();
+ const cancel = Array.from(container.querySelectorAll("button")).find(element => element.textContent?.includes("Cancel"))!;
+ await act(async () => cancel.click());
+ await act(async () => button("Properties").click());
+ await act(async () => button("Artifacts").click());
+ await flush();
+ expect(container.querySelector('input[placeholder="Document key"]')).toBeNull();
+ expect(container.textContent).not.toContain("Body typed before its key");
+ await act(async () => root.unmount());
+ client.clear();
+ });
+
+ it("keeps new document drafts within their task and signed-in account", async () => {
+ mockIssuesApi.listDocuments.mockResolvedValue([]);
+ const issue = createIssue();
+ const otherTask = { ...issue, id: "task-b", identifier: "DOC-2" };
+ const root = createRoot(container);
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
+ client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
+ const show = (task: Issue) => root.render();
+ await act(async () => show(issue));
+ await flush();
+ await act(async () => Array.from(container.querySelectorAll("button")).find(element => element.textContent?.includes("New document"))!.click());
+ await act(async () => markdownEditorMockState.changeBody?.("Private unfinished document"));
+ await act(async () => client.setQueryData(queryKeys.auth.session, { user: { id: "user-2" } }));
+ await flush();
+ expect(container.textContent).not.toContain("Private unfinished document");
+ expect(container.querySelector('input[placeholder="Document key"]')).toBeNull();
+ await act(async () => {
+ client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
+ show(otherTask);
+ });
+ await flush();
+ expect(container.textContent).not.toContain("Private unfinished document");
+ await act(async () => show(issue));
+ await flush();
+ expect(container.textContent).toContain("Private unfinished document");
+ expect(mockIssuesApi.upsertDocument).not.toHaveBeenCalled();
+ await act(async () => root.unmount());
+ client.clear();
+ });
+
+ it.each([
+ { generalDocumentsOnly: false, saveOutcome: "saved" },
+ { generalDocumentsOnly: false, saveOutcome: "failed" },
+ { generalDocumentsOnly: true, saveOutcome: "saved" },
+ { generalDocumentsOnly: true, saveOutcome: "failed" },
+ ])("reopens the latest edits after pending creation ($generalDocumentsOnly, $saveOutcome)", async ({ generalDocumentsOnly, saveOutcome }) => {
+ const { ApiError } = await import("../api/client");
+ const issue = createIssue();
+ const initial = createIssueDocument({ key: "notes", title: "Initial title", body: "Initial body", latestRevisionId: "created-revision", latestRevisionNumber: 1 });
+ const revised = { ...initial, title: "Latest title", body: "Latest body", latestRevisionId: "edited-revision", latestRevisionNumber: 2 };
+ let savedDocuments: IssueDocument[] = [];
+ const pendingCreate = deferred();
+ mockIssuesApi.listDocuments.mockImplementation(async () => savedDocuments);
+ mockIssuesApi.upsertDocument.mockImplementationOnce(async () => {
+ const saved = await pendingCreate.promise;
+ savedDocuments = [saved];
+ return saved;
+ }).mockImplementationOnce(async () => {
+ if (saveOutcome === "failed") throw new ApiError("Save refused", 400, null);
+ savedDocuments = [revised];
+ return revised;
+ });
+ const root = createRoot(container);
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity }, mutations: { retry: false } } });
+ client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
+ function TaskPane() {
+ const [artifacts, setArtifacts] = useState(true);
+ return <>
+
+
+ {artifacts ? : null}
+ >;
+ }
+ const button = (name: string) => Array.from(container.querySelectorAll("button")).find(element => element.textContent === name)!;
+ const typeInput = async (placeholder: string, value: string) => act(async () => {
+ const input = container.querySelector(`input[placeholder="${placeholder}"]`)!;
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, value);
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+ try {
+ await act(async () => root.render());
+ await flush();
+ await act(async () => Array.from(container.querySelectorAll("button")).find(element => element.textContent?.includes("New document"))!.click());
+ await typeInput("Document key", "notes");
+ await typeInput("Optional title", initial.title!);
+ await act(async () => markdownEditorMockState.changeBody?.(initial.body));
+ await act(async () => button("Create document").click());
+ expect(mockIssuesApi.upsertDocument).toHaveBeenCalledOnce();
+ await typeInput("Optional title", "Title typed during creation");
+ await act(async () => markdownEditorMockState.changeBody?.("Body typed during creation"));
+ await act(async () => pendingCreate.resolve(initial));
+ await flush();
+ const titleAfterCreate = container.querySelector('input[placeholder="Optional title"]')?.value;
+ expect(container.querySelector('[data-testid="markdown-editor"]')?.textContent).toBe("Body typed during creation");
+ await typeInput("Optional title", revised.title);
+ await act(async () => markdownEditorMockState.changeBody?.(revised.body));
+ await act(async () => new Promise(resolve => setTimeout(resolve, 1100)));
+ await flush();
+ expect(mockIssuesApi.upsertDocument).toHaveBeenCalledTimes(2);
+ expect(mockIssuesApi.upsertDocument).toHaveBeenLastCalledWith(issue.id, "notes", expect.objectContaining({
+ title: revised.title, body: revised.body, baseRevisionId: initial.latestRevisionId,
+ }));
+ await act(async () => button("Properties").click());
+ await act(async () => button("Artifacts").click());
+ await flush();
+ expect(container.querySelector('input[placeholder="Document key"]')).toBeNull();
+ expect(container.textContent).toContain(revised.body);
+ expect(titleAfterCreate).toBe("Title typed during creation");
+ expect(localStorage.getItem(`paperclip:new-document-draft:v1:user-1:${issue.id}:${generalDocumentsOnly ? "general" : "all"}`)).toBeNull();
+ const editBackup = localStorage.getItem(`paperclip:document-draft:v2:user-1:${issue.id}:notes`);
+ if (saveOutcome === "failed") {
+ expect(container.querySelector('[data-testid="markdown-editor"]')?.textContent).toBe(revised.body);
+ expect(container.querySelector('input[placeholder="Optional title"]')?.value).toBe(revised.title);
+ expect(JSON.parse(editBackup!)).toMatchObject({ body: revised.body, title: revised.title, baseRevisionId: initial.latestRevisionId, isNew: false });
+ } else expect(editBackup).toBeNull();
+ } finally {
+ await act(async () => root.unmount());
+ client.clear();
+ }
+ });
+
+ it.each([
+ { generalDocumentsOnly: false, reopenBeforeCreate: false, remoteChange: false },
+ { generalDocumentsOnly: false, reopenBeforeCreate: true, remoteChange: false },
+ { generalDocumentsOnly: true, reopenBeforeCreate: false, remoteChange: false },
+ { generalDocumentsOnly: true, reopenBeforeCreate: true, remoteChange: false },
+ { generalDocumentsOnly: true, reopenBeforeCreate: false, remoteChange: true },
+ ])("resumes edits when creation finishes after the Artifacts pane unmounts ($generalDocumentsOnly, $reopenBeforeCreate, $remoteChange)", async ({ generalDocumentsOnly, reopenBeforeCreate, remoteChange }) => {
+ const { ApiError } = await import("../api/client");
+ const issue = createIssue();
+ const initial = createIssueDocument({ key: "notes", title: "Submitted title", body: "Submitted body", latestRevisionId: "created-revision", latestRevisionNumber: 1 });
+ const remote = { ...initial, title: "Remote title", body: "Another writer's text", latestRevisionId: "remote-revision", latestRevisionNumber: 2 };
+ let savedDocuments: IssueDocument[] = [];
+ const pendingCreate = deferred();
+ mockIssuesApi.listDocuments.mockImplementation(async () => savedDocuments);
+ mockIssuesApi.getDocument.mockImplementation(async () => savedDocuments[0]);
+ mockIssuesApi.upsertDocument.mockImplementation(async (_issueId, _key, data) => {
+ if (mockIssuesApi.upsertDocument.mock.calls.length === 1) {
+ const saved = await pendingCreate.promise;
+ savedDocuments = [saved];
+ return saved;
+ }
+ if (data.baseRevisionId !== savedDocuments[0].latestRevisionId) throw new ApiError("Document changed", 409, null);
+ const revised = { ...initial, title: data.title, body: data.body, latestRevisionId: "edited-revision", latestRevisionNumber: 2 };
+ savedDocuments = [revised];
+ return revised;
+ });
+ const root = createRoot(container);
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity }, mutations: { retry: false } } });
+ client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
+ function TaskPane() {
+ const [artifacts, setArtifacts] = useState(true);
+ return <>
+
+
+ {artifacts ? : null}
+ >;
+ }
+ const button = (name: string) => Array.from(container.querySelectorAll("button")).find(element => element.textContent === name)!;
+ const typeInput = async (placeholder: string, value: string) => act(async () => {
+ const input = container.querySelector(`input[placeholder="${placeholder}"]`)!;
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, value);
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+ const title = reopenBeforeCreate ? "Newest reopened title" : "Title typed during creation";
+ const body = reopenBeforeCreate ? "Newest reopened body" : "Body typed during creation";
+ try {
+ await act(async () => root.render());
+ await flush();
+ await act(async () => Array.from(container.querySelectorAll("button")).find(element => element.textContent?.includes("New document"))!.click());
+ await typeInput("Document key", "notes");
+ await typeInput("Optional title", initial.title!);
+ await act(async () => markdownEditorMockState.changeBody?.(initial.body));
+ await act(async () => button("Create document").click());
+ await typeInput("Optional title", "Title typed during creation");
+ await act(async () => markdownEditorMockState.changeBody?.("Body typed during creation"));
+ await act(async () => button("Properties").click());
+ expect(container.querySelector('[data-testid="markdown-editor"]')).toBeNull();
+ if (reopenBeforeCreate) {
+ await act(async () => button("Artifacts").click());
+ await flush();
+ await typeInput("Optional title", title);
+ await act(async () => markdownEditorMockState.changeBody?.(body));
+ }
+ await act(async () => pendingCreate.resolve(initial));
+ await flush();
+ if (remoteChange) await act(async () => {
+ savedDocuments = [remote];
+ client.setQueryData(queryKeys.issues.documents(issue.id), savedDocuments);
+ });
+ if (!reopenBeforeCreate) await act(async () => button("Artifacts").click());
+ await flush();
+ expect(container.querySelector('input[placeholder="Document key"]')).toBeNull();
+ expect(container.querySelector('[data-testid="markdown-editor"]')?.textContent).toBe(body);
+ expect(container.querySelector('input[placeholder="Optional title"]')?.value).toBe(title);
+ await act(async () => new Promise(resolve => setTimeout(resolve, 1100)));
+ await flush();
+ expect(mockIssuesApi.upsertDocument).toHaveBeenCalledTimes(2);
+ expect(mockIssuesApi.upsertDocument).toHaveBeenLastCalledWith(issue.id, "notes", expect.objectContaining({ title, body, baseRevisionId: initial.latestRevisionId }));
+ if (remoteChange) {
+ expect(savedDocuments).toEqual([remote]);
+ expect(container.textContent).toContain("Out of date");
+ expect(container.textContent).toContain("Overwrite remote");
+ } else {
+ expect(container.textContent).not.toContain("Out of date");
+ expect(savedDocuments[0]).toMatchObject({ title, body, latestRevisionId: "edited-revision" });
+ await act(async () => button("Properties").click());
+ await act(async () => button("Artifacts").click());
+ await flush();
+ expect(container.querySelector('input[placeholder="Document key"]')).toBeNull();
+ expect(container.textContent).toContain(body);
+ }
+ } finally {
+ await act(async () => root.unmount());
+ client.clear();
+ }
+ });
+
+ it.each([false, true])("keeps a newer new-document form when an earlier create completes (general only: %s)", async generalDocumentsOnly => {
+ const issue = createIssue();
+ const saved = createIssueDocument({ key: "first", title: null, body: "Submitted first body" });
+ let savedDocuments: IssueDocument[] = [];
+ const pendingCreate = deferred();
+ mockIssuesApi.listDocuments.mockImplementation(async () => savedDocuments);
+ mockIssuesApi.upsertDocument.mockImplementation(async () => {
+ const saved = await pendingCreate.promise;
+ savedDocuments = [saved];
+ return saved;
+ });
+ let root = createRoot(container);
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
+ client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
+ const show = () => root.render();
+ const typeKey = async (value: string) => act(async () => {
+ const input = container.querySelector('input[placeholder="Document key"]')!;
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, value);
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+ try {
+ await act(async () => show());
+ await flush();
+ await act(async () => Array.from(container.querySelectorAll("button")).find(element => element.textContent?.includes("New document"))!.click());
+ await typeKey("first");
+ await act(async () => markdownEditorMockState.changeBody?.(saved.body));
+ await act(async () => Array.from(container.querySelectorAll("button")).find(element => element.textContent === "Create document")!.click());
+ await typeKey("second");
+ await act(async () => markdownEditorMockState.changeBody?.("Keep the newer document body"));
+ await act(async () => pendingCreate.resolve(saved));
+ await flush();
+ await act(async () => root.unmount());
+ root = createRoot(container);
+ await act(async () => show());
+ await flush();
+ expect(container.querySelector('input[placeholder="Document key"]')?.value).toBe("second");
+ expect(container.querySelector('[data-testid="markdown-editor"]')?.textContent).toBe("Keep the newer document body");
+ expect(mockIssuesApi.upsertDocument).toHaveBeenCalledOnce();
+ } finally {
+ await act(async () => root.unmount());
+ client.clear();
+ }
+ });
+
+ it("keeps the new-document backup if storing the pending-create edits fails", async () => {
+ const issue = createIssue();
+ const saved = createIssueDocument({ key: "notes", title: null, body: "Submitted body" });
+ let savedDocuments: IssueDocument[] = [];
+ const pendingCreate = deferred();
+ mockIssuesApi.listDocuments.mockImplementation(async () => savedDocuments);
+ mockIssuesApi.upsertDocument.mockImplementation(async () => {
+ const saved = await pendingCreate.promise;
+ savedDocuments = [saved];
+ return saved;
+ });
+ const root = createRoot(container);
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
+ client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
+ const write = localStorage.setItem.bind(localStorage);
+ const methodOwner: Storage = Object.hasOwn(localStorage, "setItem") ? localStorage : Object.getPrototypeOf(localStorage);
+ const editKey = `paperclip:document-draft:v2:user-1:${issue.id}:notes`;
+ const setItem = vi.spyOn(methodOwner, "setItem").mockImplementation((key, value) => {
+ if (key === editKey) throw new Error("Storage full");
+ write(key, value);
+ });
+ try {
+ await act(async () => root.render());
+ await flush();
+ await act(async () => Array.from(container.querySelectorAll("button")).find(element => element.textContent?.includes("New document"))!.click());
+ await act(async () => {
+ const input = container.querySelector('input[placeholder="Document key"]')!;
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "notes");
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ markdownEditorMockState.changeBody?.(saved.body);
+ });
+ await act(async () => Array.from(container.querySelectorAll("button")).find(element => element.textContent === "Create document")!.click());
+ await act(async () => markdownEditorMockState.changeBody?.("Keep the unsaved pending-create edits"));
+ await act(async () => pendingCreate.resolve(saved));
+ await flush();
+ expect(container.querySelector('[data-testid="markdown-editor"]')?.textContent).toBe("Keep the unsaved pending-create edits");
+ expect(localStorage.getItem(editKey)).toBeNull();
+ const retained = localStorage.getItem(`paperclip:new-document-draft:v1:user-1:${issue.id}:general`);
+ expect(JSON.parse(retained!)).toMatchObject({ key: "notes", body: "Keep the unsaved pending-create edits", baseRevisionId: saved.latestRevisionId, isNew: false });
+ await act(async () => container.querySelector('[data-testid="markdown-editor"]')!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })));
+ expect(localStorage.getItem(`paperclip:new-document-draft:v1:user-1:${issue.id}:general`)).toBeNull();
+ } finally {
+ setItem.mockRestore();
+ await act(async () => root.unmount());
+ client.clear();
+ }
+ });
+
+ it("keeps native Plans out of the general-document authoring surface", async () => {
+ mockIssuesApi.listDocuments.mockResolvedValue([
+ createIssueDocument({ body: "Native Plan content" }),
+ createIssueDocument({ id: "note", key: "notes", title: "Task note", body: "General document content" }),
+ createIssueDocument({ id: "file-review", key: "artifact-review-output", body: "Work product review content" }),
+ ]);
+ markdownEditorMockState.emitMountValue = "A new general note";
+ const root = createRoot(container);
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ await act(async () => root.render());
+ await flush();
+ expect(container.textContent).toContain("General document content");
+ expect(container.textContent).not.toContain("Native Plan content");
+ expect(container.textContent).not.toContain("Legacy Plan content");
+ expect(container.textContent).not.toContain("Work product review content");
+ const newDocument = Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("New document"))!;
+ await act(async () => newDocument.click());
+ const keyInput = container.querySelector('input[placeholder="Document key"]')!;
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(keyInput, "plan");
+ keyInput.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+ const create = Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("Create document"))!;
+ await act(async () => create.click());
+ await flush();
+ expect(container.textContent).toContain("Create and revise Plans in the task conversation.");
+ expect(mockIssuesApi.upsertDocument).not.toHaveBeenCalled();
+ await act(async () => root.unmount());
+ client.clear();
+ });
+
+ it.each([
+ ["continuation-summary", "This document key is reserved for task state."],
+ ["pipeline-case-body", "This document key is reserved for task state."],
+ ["artifact-review-output", "Review this document through its work product."],
+ ])("refuses reserved general-document key %s and preserves the draft", async (key, message) => {
+ mockIssuesApi.listDocuments.mockResolvedValue([]);
+ mockIssuesApi.upsertDocument.mockResolvedValue(createIssueDocument({ key, body: "Keep my document draft" }));
+ const root = createRoot(container);
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ await act(async () => root.render());
+ await flush();
+ await act(async () => Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("New document"))!.click());
+ const keyInput = container.querySelector('input[placeholder="Document key"]')!;
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(keyInput, key);
+ keyInput.dispatchEvent(new Event("input", { bubbles: true }));
+ markdownEditorMockState.changeBody?.("Keep my document draft");
+ });
+ expect(container.textContent).toContain(message);
+ const create = Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("Create document"))!;
+ await act(async () => create.click());
+ await flush();
+ expect(mockIssuesApi.upsertDocument).not.toHaveBeenCalled();
+ expect(container.textContent).toContain(message);
+ expect(keyInput.value).toBe(key);
+ expect(container.querySelector('[data-testid="markdown-editor"]')?.textContent).toBe("Keep my document draft");
+ await act(async () => root.unmount());
+ client.clear();
+ });
+
it("unfolds a Classic confirmation link and loads its bound revision rather than the latest text", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@@ -1181,6 +1592,13 @@ describe("IssueDocumentsSection", () => {
await act(async () => {
root.unmount();
});
+ const reopened = createRoot(container);
+ await act(async () => reopened.render());
+ await flush();
+ expect(container.querySelector('input[placeholder="Document key"]')).toBeNull();
+ expect(container.textContent).toContain("Notes body");
+ expect(mockIssuesApi.upsertDocument).toHaveBeenCalledOnce();
+ await act(async () => reopened.unmount());
queryClient.clear();
});
diff --git a/ui/src/components/IssueDocumentsSection.tsx b/ui/src/components/IssueDocumentsSection.tsx
index 5ae4dc71b37..22e80663b05 100644
--- a/ui/src/components/IssueDocumentsSection.tsx
+++ b/ui/src/components/IssueDocumentsSection.tsx
@@ -10,7 +10,7 @@ import type {
Issue,
IssueDocument,
} from "@paperclipai/shared";
-import { isSystemIssueDocumentKey } from "@paperclipai/shared";
+import { isArtifactReviewDocumentKey, isSystemIssueDocumentKey } from "@paperclipai/shared";
import { useLocation } from "@/lib/router";
import { ApiError } from "../api/client";
import { issuesApi } from "../api/issues";
@@ -22,6 +22,7 @@ import { deriveDocumentRevisionState } from "../lib/document-revisions";
import { buildDocumentAnnotationHash, parseDocumentAnnotationHash } from "../lib/document-annotation-hash";
import type { CompanyUserProfile } from "../lib/company-members";
import { queryKeys } from "../lib/queryKeys";
+import { isGeneralTaskDocumentKey } from "../lib/issue-artifacts";
import { useDraftOwner } from "../hooks/useDraftOwner";
import { cn, relativeTime } from "../lib/utils";
import { FoldCurtain } from "./FoldCurtain";
@@ -134,6 +135,13 @@ function isPlanKey(key: string) {
return key.trim().toLowerCase() === "plan";
}
+function generalDocumentKeyError(key: string): string | null {
+ if (isGeneralTaskDocumentKey(key)) return null;
+ if (isPlanKey(key)) return "Create and revise Plans in the task conversation.";
+ if (isArtifactReviewDocumentKey(key)) return "Review this document through its work product.";
+ return "This document key is reserved for task state.";
+}
+
function titlesMatchKey(title: string | null | undefined, key: string) {
return (title ?? "").trim().toLowerCase() === key.trim().toLowerCase();
}
@@ -178,6 +186,28 @@ function clearStoredDocumentDraft(scope: string | null, key: string) {
}
}
+function newDocumentDraftStorageKey(scope: string) {
+ return `paperclip:new-document-draft:v1:${scope}`;
+}
+
+function loadStoredNewDocumentDraft(scope: string | null): DraftState | null {
+ if (!scope) return null;
+ const stored = loadStructuredDraft | null>(newDocumentDraftStorageKey(scope), null);
+ // Creation may finish after this pane unmounts. Its retained newer buffer
+ // then carries the confirmed revision and reopens as an edit.
+ if (!stored || typeof stored.isNew !== "boolean" || typeof stored.key !== "string" || typeof stored.title !== "string"
+ || typeof stored.body !== "string" || (stored.isNew ? stored.baseRevisionId !== null : typeof stored.baseRevisionId !== "string")) return null;
+ return { key: stored.key, title: stored.title, body: stored.body, baseRevisionId: stored.baseRevisionId ?? null, isNew: stored.isNew };
+}
+
+function clearStoredNewDocumentDraft(scope: string | null, saved?: DraftState) {
+ if (!scope) return;
+ const stored = loadStoredNewDocumentDraft(scope);
+ // A save may finish after more text was typed or after the pane unmounted.
+ if (saved && stored && (stored.key !== saved.key || stored.title !== saved.title || stored.body !== saved.body)) return;
+ try { localStorage.removeItem(newDocumentDraftStorageKey(scope)); } catch { /* Storage may be unavailable. */ }
+}
+
function downloadDocumentFile(key: string, body: string) {
const blob = new Blob([body], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
@@ -291,6 +321,7 @@ function AccountIssueDocumentsSection({
subject,
canDeleteDocuments,
canManageDocumentLocks = false,
+ generalDocumentsOnly = false,
feedbackVotes = [],
feedbackDataSharingPreference = "prompt",
feedbackTermsUrl = null,
@@ -309,6 +340,8 @@ function AccountIssueDocumentsSection({
subject?: DocumentSubjectConfig;
canDeleteDocuments: boolean;
canManageDocumentLocks?: boolean;
+ /** The chat shell keeps Plans in the conversation and file reviews on their work products. */
+ generalDocumentsOnly?: boolean;
feedbackVotes?: FeedbackVote[];
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
feedbackTermsUrl?: string | null;
@@ -354,6 +387,7 @@ function AccountIssueDocumentsSection({
const [documentConflict, setDocumentConflict] = useState(null);
const draftOwner = useDraftOwner();
const draftScope = draftOwner !== undefined ? `${draftOwner}:${documentSubject.id}` : null;
+ const newDraftScope = draftScope ? `${draftScope}:${generalDocumentsOnly ? "general" : "all"}` : null;
const [foldedDocumentKeys, setFoldedDocumentKeys] = useState(() => loadFoldedDocumentKeys(documentSubject.id));
const [annotationPanelOpenKeys, setAnnotationPanelOpenKeys] = useState(
() => (defaultAnnotationPanelOpenKeys ?? []),
@@ -493,12 +527,15 @@ function AccountIssueDocumentsSection({
});
const sortedDocuments = useMemo(() => {
- return (documents ?? []).filter((doc) => !documentSubject.hideSystemDocuments || !isSystemIssueDocumentKey(doc.key)).sort((a, b) => {
+ return (documents ?? []).filter((doc) =>
+ (!generalDocumentsOnly || isGeneralTaskDocumentKey(doc.key))
+ && (!documentSubject.hideSystemDocuments || !isSystemIssueDocumentKey(doc.key)),
+ ).sort((a, b) => {
if (a.key === "plan" && b.key !== "plan") return -1;
if (a.key !== "plan" && b.key === "plan") return 1;
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
});
- }, [documentSubject.hideSystemDocuments, documents]);
+ }, [documentSubject.hideSystemDocuments, documents, generalDocumentsOnly]);
const feedbackVoteByTargetId = useMemo(() => {
const map = new Map();
@@ -510,11 +547,13 @@ function AccountIssueDocumentsSection({
}, [feedbackVotes]);
const hasRealPlan = sortedDocuments.some((doc) => doc.key === "plan");
- const isEmpty = sortedDocuments.length === 0 && !documentSubject.legacyPlanDocument;
- const newDocumentKeyError =
- draft?.isNew && draft.key.trim().length > 0 && !DOCUMENT_KEY_PATTERN.test(draft.key.trim())
+ const legacyPlanDocument = generalDocumentsOnly ? null : documentSubject.legacyPlanDocument;
+ const isEmpty = sortedDocuments.length === 0 && !legacyPlanDocument;
+ const newGeneralDocumentKeyError = draft?.isNew && generalDocumentsOnly ? generalDocumentKeyError(draft.key) : null;
+ const newDocumentKeyError = newGeneralDocumentKeyError
+ ?? (draft?.isNew && draft.key.trim().length > 0 && !DOCUMENT_KEY_PATTERN.test(draft.key.trim())
? "Use lowercase letters, numbers, -, or _, and start with a letter or number."
- : null;
+ : null);
const resetAutosaveState = useCallback(() => {
setAutosaveDocumentKey(null);
@@ -529,7 +568,7 @@ function AccountIssueDocumentsSection({
const beginNewDocument = () => {
resetAutosaveState();
setDocumentConflict(null);
- setDraft({
+ setDraft(loadStoredNewDocumentDraft(newDraftScope) ?? {
key: "",
title: "",
body: "",
@@ -576,7 +615,11 @@ function AccountIssueDocumentsSection({
if (autosaveDebounceRef.current) {
clearTimeout(autosaveDebounceRef.current);
}
- if (draft && !draft.isNew) clearStoredDocumentDraft(draftScope, draft.key);
+ if (draft && !draft.isNew) {
+ clearStoredDocumentDraft(draftScope, draft.key);
+ clearStoredNewDocumentDraft(newDraftScope, draft);
+ }
+ if (draft?.isNew) clearStoredNewDocumentDraft(newDraftScope);
resetAutosaveState();
setDocumentConflict(null);
setDraft(null);
@@ -593,6 +636,12 @@ function AccountIssueDocumentsSection({
const normalizedTitle = currentDraft.title.trim();
const activeConflict = documentConflict?.key === normalizedKey ? documentConflict : null;
+ const generalKeyError = generalDocumentsOnly ? generalDocumentKeyError(normalizedKey) : null;
+ if (generalKeyError) {
+ setError(generalKeyError);
+ return false;
+ }
+
if (activeConflict && !options?.overrideConflict) {
if (options?.trackAutosave) {
resetAutosaveState();
@@ -647,6 +696,18 @@ function AccountIssueDocumentsSection({
};
const applySaved = (saved: IssueDocument) => {
clearStoredDocumentDraft(draftScope, normalizedKey);
+ clearStoredNewDocumentDraft(newDraftScope, currentDraft);
+ const retained = currentDraft.isNew ? loadStoredNewDocumentDraft(newDraftScope) : null;
+ if (newDraftScope && retained?.isNew && retained.key.trim().toLowerCase() === normalizedKey && saved.latestRevisionId) {
+ // Keep later typing, but bind it to our successful creation even if
+ // its editor has already unmounted. Other forms/scopes stay untouched.
+ saveStructuredDraft(newDocumentDraftStorageKey(newDraftScope), {
+ ...retained,
+ key: saved.key,
+ baseRevisionId: saved.latestRevisionId,
+ isNew: false,
+ });
+ }
setError(null);
setDocumentConflict((current) => current?.key === normalizedKey ? null : current);
setDraft((value) => {
@@ -654,8 +715,8 @@ function AccountIssueDocumentsSection({
if (options?.clearAfterSave) return null;
return {
key: saved.key,
- title: saved.title ?? "",
- // Keep text typed while this save was in flight.
+ // Keep title and body edits made while this save was in flight.
+ title: value.title === currentDraft.title ? saved.title ?? "" : value.title,
body: value.body === currentDraft.body ? saved.body : value.body,
baseRevisionId: saved.latestRevisionId,
isNew: false,
@@ -725,7 +786,7 @@ function AccountIssueDocumentsSection({
// Kept on this device; it saves when the connection allows.
if (result.kind === "waiting") return false;
return handleSaveFailure(result.error);
- }, [documentConflict, documentSubject, draftScope, invalidateIssueDocuments, resetAutosaveState, runAutosave, sortedDocuments, syncDocumentCaches, upsertDocument]);
+ }, [documentConflict, documentSubject, draftScope, generalDocumentsOnly, invalidateIssueDocuments, newDraftScope, resetAutosaveState, runAutosave, sortedDocuments, syncDocumentCaches, upsertDocument]);
const reloadDocumentFromServer = useCallback((key: string) => {
if (documentConflict?.key !== key) return;
@@ -937,15 +998,36 @@ function AccountIssueDocumentsSection({
// Persist every unsaved edit so a reload or an outage cannot lose it.
useEffect(() => {
- if (!draft || draft.isNew) return;
+ if (!draft) return;
+ if (draft.isNew) {
+ if (newDraftScope) {
+ const retained = loadStoredNewDocumentDraft(newDraftScope);
+ if (retained && !retained.isNew && retained.key === draft.key.trim().toLowerCase()) {
+ // A previous mount's create can finish after this editor reopened.
+ // Promote its current text without replacing anything typed since.
+ setDraft({ ...draft, key: retained.key, baseRevisionId: retained.baseRevisionId, isNew: false });
+ return;
+ }
+ if (draft.key || draft.title || draft.body) saveStructuredDraft(newDocumentDraftStorageKey(newDraftScope), draft);
+ else clearStoredNewDocumentDraft(newDraftScope);
+ }
+ return;
+ }
const existing = sortedDocuments.find((doc) => doc.key === draft.key);
if (!existing) return;
+ // Retire a matching new-form backup only after its text is saved remotely
+ // or retained as an edit. A different newer buffer keeps its backup.
if (existing.body === draft.body && (existing.title ?? "") === draft.title) {
clearStoredDocumentDraft(draftScope, draft.key);
+ clearStoredNewDocumentDraft(newDraftScope, draft);
} else if (draftScope) {
saveStructuredDraft(documentDraftStorageKey(draftScope, draft.key), draft);
+ const stored = loadStoredDocumentDraft(draftScope, draft.key);
+ if (stored?.body === draft.body && stored.title === draft.title && stored.baseRevisionId === draft.baseRevisionId) {
+ clearStoredNewDocumentDraft(newDraftScope, draft);
+ }
}
- }, [draftScope, draft, sortedDocuments]);
+ }, [draftScope, draft, newDraftScope, sortedDocuments]);
// After a reload, reopen the first document with an unsaved edit; the
// autosave below then saves it.
@@ -955,6 +1037,11 @@ function AccountIssueDocumentsSection({
if (restoredStoredDraftRef.current || !documents || !draftScope) return;
restoredStoredDraftRef.current = true;
if (draft) return;
+ const newDocument = loadStoredNewDocumentDraft(newDraftScope);
+ if (newDocument) {
+ setDraft(newDocument);
+ return;
+ }
for (const doc of sortedDocuments) {
const stored = loadStoredDocumentDraft(draftScope, doc.key);
if (!stored) continue;
@@ -966,7 +1053,7 @@ function AccountIssueDocumentsSection({
return;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [documents, draftScope]);
+ }, [documents, draftScope, newDraftScope]);
useEffect(() => {
if (!draft || draft.isNew) return;
@@ -1108,7 +1195,7 @@ function AccountIssueDocumentsSection({
)}
- {!hasRealPlan && documentSubject.legacyPlanDocument ? (
+ {!hasRealPlan && legacyPlanDocument ? (
- {renderFoldableBody(documentSubject.legacyPlanDocument.body, documentBodyContentClassName, externalReferences, { documentKey: "plan" })}
+ {renderFoldableBody(legacyPlanDocument.body, documentBodyContentClassName, externalReferences, { documentKey: "plan" })}
) : null}
diff --git a/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx b/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx
index d9b9476c9ba..b006399adb9 100644
--- a/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx
+++ b/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx
@@ -1,5 +1,5 @@
import { TextAttachmentContext } from "@/context/TextAttachmentContext";
-import { useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
+import { useContext, useEffect, useMemo, useRef, useState, type ComponentProps, type ReactNode } from "react";
import { ArtifactUploadContent, type ArtifactUploadEntry } from "@/components/artifacts/ArtifactUploadControls";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
@@ -17,6 +17,8 @@ import {
import { Download, ExternalLink, FileText } from "lucide-react";
import { ApiError } from "@/api/client";
import { issuesApi } from "@/api/issues";
+import { authApi } from "@/api/auth";
+import { IssueDocumentsSection } from "@/components/IssueDocumentsSection";
import { activityApi } from "@/api/activity";
import { agentsApi } from "@/api/agents";
import { queryKeys } from "@/lib/queryKeys";
@@ -24,6 +26,7 @@ import { useIssueDocuments } from "@/hooks/useIssueDocuments";
import { useIssueWorkProducts } from "@/hooks/useIssueWorkProducts";
import {
documentDisplayTitle,
+ isGeneralTaskDocumentKey,
selectArtifactAttachments,
workProductHref,
} from "@/lib/issue-artifacts";
@@ -47,8 +50,28 @@ import { useArtifactsV1Enabled } from "@/hooks/useArtifactsV1Enabled";
import { ArtifactFileRow, ArtifactRow, ROW_ACTION_CLASS, fileRowSourceFromAttachment, fileRowSourceFromWorkProduct, subjectFromAttachment, subjectFromIssueDocument, subjectFromWorkProduct, workProductStateBadge, type ArtifactSubject } from "@/components/artifacts/v1";
import { artifactUrl } from "@/lib/artifact-card-data";
+export type TaskDocumentEditorOptions = Pick,
+ "mentions" | "imageUploadHandler" | "externalReferences" | "feedbackVotes" | "feedbackDataSharingPreference"
+ | "feedbackTermsUrl" | "onVote" | "agentMap" | "userProfileMap">;
+
+/** Shared authoring and signed-in permissions for task and conversation hosts. */
+export function TaskGeneralDocuments({ issue, documentEditor }: { issue: Issue; documentEditor: TaskDocumentEditorOptions }) {
+ const { data: session } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession() });
+ return (
+
+ );
+}
+
interface IssuePropertiesArtifactsTabProps {
issue: Issue;
+ /** Present only when this host owns the chat shell's document editor. */
+ documentEditor?: TaskDocumentEditorOptions;
uploadState?: { entries: ArtifactUploadEntry[]; retry: (id: string) => void; dismiss: (id: string) => void };
onOpenDocument?: (document: IssueDocument) => void;
documentDeepLink?: {
@@ -407,11 +430,13 @@ function DocumentRow({
*/
export function IssuePropertiesArtifactsTab({
issue,
+ documentEditor,
documentDeepLink,
onOpenDocument,
uploadState,
}: IssuePropertiesArtifactsTabProps) {
const { enabled: v1 } = useArtifactsV1Enabled();
+ const documentAuthoring = documentEditor !== undefined;
const {
data: attachments,
isPending: attachmentsPending,
@@ -446,7 +471,7 @@ export function IssuePropertiesArtifactsTab({
// Proxy review documents (`artifact-review-*`) present only through their
// originating Work product row, never as standalone Documents rows.
const documentRows = (documents ?? []).filter(
- (doc) => !isArtifactReviewDocumentKey(doc.key),
+ (doc) => !isArtifactReviewDocumentKey(doc.key) && (!documentAuthoring || doc.key === "plan"),
);
const reviewDocsByKey = new Map(
(documents ?? []).map((doc) => [doc.key, doc]),
@@ -558,9 +583,20 @@ export function IssuePropertiesArtifactsTab({
) : null;
- const withUploads = (content: ReactNode, empty = false) => uploadState ? (
- {content}
- ) : content;
+ const withUploads = (content: ReactNode, empty = false) => {
+ const artifacts = uploadState ? (
+ {content}
+ ) : content;
+ return documentAuthoring ? (
+
+
+ {artifacts}
+
+ ) : artifacts;
+ };
if (
workProductRows.length === 0 &&
@@ -568,6 +604,9 @@ export function IssuePropertiesArtifactsTab({
fileRows.length === 0
) {
if (feedback) return withUploads(feedback);
+ if (documentAuthoring && (documents ?? []).some((doc) => isGeneralTaskDocumentKey(doc.key))) {
+ return withUploads(null);
+ }
return withUploads(
No artifacts yet. Work products, documents, and agent-produced files
diff --git a/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.v1.test.tsx b/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.v1.test.tsx
index 5cb0117aab2..d572aa543e8 100644
--- a/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.v1.test.tsx
+++ b/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.v1.test.tsx
@@ -6,7 +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 { IssuePropertiesArtifactsTab } from "./IssuePropertiesArtifactsTab";
+import { IssuePropertiesArtifactsTab, TaskGeneralDocuments, type TaskDocumentEditorOptions } from "./IssuePropertiesArtifactsTab";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -16,11 +16,21 @@ const mockIssuesApi = vi.hoisted(() => ({
ensureWorkProductReviewDocument: vi.fn(async (): Promise
=> ({})),
}));
vi.mock("@/api/issues", () => ({ issuesApi: mockIssuesApi }));
+vi.mock("@/api/auth", () => ({ authApi: { getSession: async () => ({ user: { id: "user-1" } }) } }));
+let editorOptions: TaskDocumentEditorOptions | undefined;
+const documentEditor = vi.hoisted(() => vi.fn());
+vi.mock("@/components/IssueDocumentsSection", () => ({
+ IssueDocumentsSection: (props: { issue: Issue; generalDocumentsOnly?: boolean }) => {
+ documentEditor(props);
+ return ;
+ },
+}));
vi.mock("@/api/activity", () => ({ activityApi: { runsForIssue: vi.fn(async (): Promise => []) } }));
vi.mock("@/api/agents", () => ({ agentsApi: { list: vi.fn(async (): Promise => []) } }));
const mockUseIssueDocuments = vi.hoisted(() => vi.fn((): { data: IssueDocument[] } => ({ data: [] })));
vi.mock("@/hooks/useIssueDocuments", () => ({ useIssueDocuments: mockUseIssueDocuments }));
-vi.mock("@/hooks/useArtifactsV1Enabled", () => ({ useArtifactsV1Enabled: () => ({ enabled: true, loaded: true }) }));
+const artifactsV1 = vi.hoisted(() => ({ enabled: true, loaded: true }));
+vi.mock("@/hooks/useArtifactsV1Enabled", () => ({ useArtifactsV1Enabled: () => artifactsV1 }));
vi.mock("@/lib/router", () => ({
Link: ({ to, children, ...props }: { to: string; children: React.ReactNode }) => {children},
useLocation: () => ({ hash: "" }),
@@ -157,6 +167,8 @@ describe("IssuePropertiesArtifactsTab with Artifacts V1 on", () => {
mockIssuesApi.listAttachments.mockResolvedValue([heroAttachment]);
mockIssuesApi.listWorkProducts.mockResolvedValue([pdfWorkProduct]);
mockUseIssueDocuments.mockReturnValue({ data: [planDocument] });
+ editorOptions = undefined;
+ artifactsV1.enabled = true;
});
afterEach(() => {
@@ -165,17 +177,90 @@ describe("IssuePropertiesArtifactsTab with Artifacts V1 on", () => {
vi.clearAllMocks();
});
- function render(props: { onOpenDocument?: (document: IssueDocument) => void } = {}) {
+ function render(props: { onOpenDocument?: (document: IssueDocument) => void; documentEditor?: TaskDocumentEditorOptions } = {}) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
flushSync(() => {
root.render(
-
+
,
);
});
}
+ it("restores the shared general document editor in the default chat Artifacts pane without duplicate cards", async () => {
+ editorOptions = {};
+ const note = { ...planDocument, id: "doc-note", key: "notes", title: "Task note", body: "Saved note" };
+ mockUseIssueDocuments.mockReturnValue({ data: [planDocument, note] });
+ render();
+ await waitFor(() => {
+ expect(container.querySelectorAll('[data-testid="artifact-v1-row"]')).toHaveLength(3);
+ expect(container.querySelectorAll('[data-testid="general-document-editor"]')).toHaveLength(1);
+ });
+ expect(documentEditor).toHaveBeenCalledWith(expect.objectContaining({ issue, generalDocumentsOnly: true }));
+ expect(container.querySelector('[aria-label="Open Task note"]')).toBeNull();
+ expect(container.querySelector('[aria-label="Open plan.md"]')).not.toBeNull();
+ expect(container.querySelector('a[aria-label="Download acmehats-interface-craft.pdf"]')).not.toBeNull();
+ expect(container.querySelector('img[src="/api/attachments/' + IMAGE_ID + '/content"]')).not.toBeNull();
+ });
+
+ it("leaves Classic authoring in the task body when this host does not own the editor", async () => {
+ render();
+ await waitFor(() => expect(container.querySelector('[aria-label="Open plan.md"]')).not.toBeNull());
+ expect(container.querySelector('[data-testid="general-document-editor"]')).toBeNull();
+ expect(documentEditor).not.toHaveBeenCalled();
+ });
+
+ it("makes document creation available on an empty default-chat task", async () => {
+ editorOptions = {};
+ mockUseIssueDocuments.mockReturnValue({ data: [] });
+ mockIssuesApi.listAttachments.mockResolvedValue([]);
+ mockIssuesApi.listWorkProducts.mockResolvedValue([]);
+ render();
+ await waitFor(() => expect(container.textContent).toContain("No artifacts yet"));
+ expect(container.querySelectorAll('[data-testid="general-document-editor"]')).toHaveLength(1);
+ });
+
+ it("forwards image upload, mentions, references and feedback from the owning task shell", async () => {
+ const options: TaskDocumentEditorOptions = {
+ imageUploadHandler: vi.fn(async () => "/api/attachments/image/content"),
+ mentions: [{ id: "agent", name: "Author", kind: "agent" }],
+ externalReferences: {}, feedbackVotes: [], feedbackDataSharingPreference: "prompt",
+ feedbackTermsUrl: "https://example.test/terms", onVote: vi.fn(async () => {}), userProfileMap: new Map(),
+ };
+ render({ documentEditor: options });
+ await waitFor(() => expect(documentEditor).toHaveBeenCalledWith(expect.objectContaining(options)));
+ });
+
+ it("uses the same conversation-scoped editor and signed-in permissions beside agent artifacts", async () => {
+ const conversation = { ...issue, conversationAgentId: "agent-1" };
+ const options: TaskDocumentEditorOptions = {
+ imageUploadHandler: vi.fn(async () => "/api/attachments/conversation-image/content"),
+ mentions: [{ id: "agent-1", name: "Agent", kind: "agent" }],
+ externalReferences: {}, onVote: vi.fn(async () => {}), userProfileMap: new Map(),
+ };
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ await act(async () => root.render(
+
+ ));
+ await waitFor(() => expect(documentEditor).toHaveBeenCalledWith(expect.objectContaining({
+ ...options, issue: conversation, generalDocumentsOnly: true, canDeleteDocuments: true, canManageDocumentLocks: true,
+ })));
+ expect(container.querySelectorAll('[data-testid="general-document-editor"]')).toHaveLength(1);
+ });
+
+ it("keeps the same general document authoring with the legacy artifact cards", async () => {
+ editorOptions = {};
+ artifactsV1.enabled = false;
+ mockUseIssueDocuments.mockReturnValue({ data: [planDocument, { ...planDocument, id: "note", key: "notes", title: "Task note" }] });
+ render();
+ await waitFor(() => expect(container.textContent).toContain("acmehats-interface-craft.pdf"));
+ expect(container.querySelectorAll('[data-testid="general-document-editor"]')).toHaveLength(1);
+ expect(container.textContent).not.toContain("Task note");
+ expect(container.textContent).toContain("plan.md");
+ expect(container.textContent).toContain("contact-sheet.png");
+ });
+
it("lists every artifact as one flat set of V1 rows, newest first, with no run sections", async () => {
render();
await waitFor(() => {
diff --git a/ui/src/components/task-side-panel/TaskSidePanel.test.tsx b/ui/src/components/task-side-panel/TaskSidePanel.test.tsx
index 0e9fe399d49..3e9ff756dd5 100644
--- a/ui/src/components/task-side-panel/TaskSidePanel.test.tsx
+++ b/ui/src/components/task-side-panel/TaskSidePanel.test.tsx
@@ -67,8 +67,17 @@ vi.mock("@/components/IssueProperties", () => ({
IssueProperties: () => Properties content
,
}));
+const artifactsTabRender = vi.hoisted(() => vi.fn());
+const generalDocumentsRender = vi.hoisted(() => vi.fn());
vi.mock("@/components/issue-properties/IssuePropertiesArtifactsTab", () => ({
- IssuePropertiesArtifactsTab: () => Artifacts content
,
+ IssuePropertiesArtifactsTab: (props: unknown) => {
+ artifactsTabRender(props);
+ return Artifacts content
;
+ },
+ TaskGeneralDocuments: (props: unknown) => {
+ generalDocumentsRender(props);
+ return Conversation documents
;
+ },
}));
vi.mock("@/components/issue-properties/IssuePropertiesPlansTab", () => ({
@@ -140,6 +149,8 @@ describe("TaskSidePanel", () => {
let queryClient: QueryClient;
beforeEach(() => {
+ artifactsTabRender.mockClear();
+ generalDocumentsRender.mockClear();
coordinator.enabled = false;
window.localStorage.clear();
browserFixture.data = [];
@@ -192,6 +203,18 @@ describe("TaskSidePanel", () => {
);
}
+ it.each([false, true])("owns document authoring in the resolved chat shell (coordinator: %s)", async coordinatorEnabled => {
+ coordinator.enabled = coordinatorEnabled;
+ const documentEditor = { imageUploadHandler: vi.fn(async () => "/api/attachments/image/content"), externalReferences: {} };
+ await render(panel({ documentEditor }));
+ const open = container.querySelector('button[aria-label="Open a new tab"]');
+ if (open) await act(async () => open.click());
+ const artifacts = Array.from(document.body.querySelectorAll('[role="option"]')).find(item => item.textContent?.includes("Artifacts"));
+ expect(artifacts).toBeTruthy();
+ await act(async () => artifacts!.click());
+ expect(artifactsTabRender).toHaveBeenCalledWith(expect.objectContaining({ documentEditor }));
+ });
+
it("offers idle plugin views in the launcher without opening them automatically", async () => {
await render(panel({ pluginPanels: [{ ...pluginPanel, runId: undefined, autoOpen: false }] }));
expect(container.textContent).not.toContain("Plugin viewer");
@@ -481,6 +504,30 @@ describe("TaskSidePanel", () => {
expect(container.textContent).not.toContain("Artifacts content");
});
+ it.each([
+ { coordinatorEnabled: false, mobile: false },
+ { coordinatorEnabled: false, mobile: true },
+ { coordinatorEnabled: true, mobile: false },
+ { coordinatorEnabled: true, mobile: true },
+ ])("keeps conversation document authoring beside agent artifacts ($coordinatorEnabled, $mobile)", async ({ coordinatorEnabled, mobile }) => {
+ coordinator.enabled = coordinatorEnabled;
+ const conversation = issue({ conversationAgentId: "agent-1" });
+ const documentEditor = {
+ imageUploadHandler: vi.fn(async () => "/api/attachments/conversation-image/content"),
+ mentions: [{ id: "agent-1", name: "Agent", kind: "agent" as const }],
+ externalReferences: {}, onVote: vi.fn(async () => {}), userProfileMap: new Map(),
+ };
+ await render(panel({ issue: conversation, documentEditor, mobile }));
+ await act(async () => container.querySelector('button[aria-label="Open a new tab"]')?.click());
+ const artifacts = Array.from(document.body.querySelectorAll('[role="option"]')).find(item => item.textContent?.includes("Artifacts"));
+ expect(artifacts).toBeTruthy();
+ await act(async () => artifacts!.click());
+ expect(container.querySelectorAll('[data-testid="conversation-document-editor"]')).toHaveLength(1);
+ expect(generalDocumentsRender).toHaveBeenCalledWith(expect.objectContaining({ issue: conversation, documentEditor }));
+ expect(container.textContent).toContain("Agent artifacts agent-1");
+ expect(artifactsTabRender).not.toHaveBeenCalled();
+ });
+
it("keeps a chat on the conversation's own Artifacts while Agent Chat is off", async () => {
agentChat.enabled = false;
try {
diff --git a/ui/src/components/task-side-panel/TaskSidePanel.tsx b/ui/src/components/task-side-panel/TaskSidePanel.tsx
index ae370762feb..e112b621f19 100644
--- a/ui/src/components/task-side-panel/TaskSidePanel.tsx
+++ b/ui/src/components/task-side-panel/TaskSidePanel.tsx
@@ -41,7 +41,7 @@ import { PROPERTIES_PANE_HEADER_SLOT_ID } from "@/components/PropertiesPanel";
import { WorkspaceFileBrowser } from "@/components/WorkspaceFileBrowser";
import { AgentArtifactsPanel, AgentTasksPanel } from "@/components/chat/AgentWorkPanels";
import { useAgentChatEnabled } from "@/hooks/useAgentChatEnabled";
-import { IssuePropertiesArtifactsTab } from "@/components/issue-properties/IssuePropertiesArtifactsTab";
+import { IssuePropertiesArtifactsTab, TaskGeneralDocuments, type TaskDocumentEditorOptions } from "@/components/issue-properties/IssuePropertiesArtifactsTab";
import { ArtifactDropzone } from "@/components/artifacts/ArtifactDropzone";
import { ArtifactUploadButton } from "@/components/artifacts/ArtifactUploadControls";
import { useIssueArtifactUploads } from "@/hooks/useIssueArtifactUploads";
@@ -101,6 +101,7 @@ import { TaskSkillPanel } from "./TaskSkillPanel";
export interface TaskSidePanelProps {
issue: Issue;
+ documentEditor?: TaskDocumentEditorOptions;
pluginPanels?: TaskPluginPanel[];
pluginPanelsLoading?: boolean;
openedPluginPanelKeys?: Set;
@@ -265,6 +266,7 @@ function TaskSidePanelForScope({
openBrowserId,
onBrowserOpened,
issue,
+ documentEditor,
accountScope,
childIssues = [],
issueLinkState,
@@ -838,13 +840,20 @@ function TaskSidePanelForScope({
:
) : null;
} else if (activeTab.payload.kind === "artifacts" && conversationAgentId) {
- content = ;
+ content = (
+
+ );
} else if (activeTab.payload.kind === "artifacts") {
content = {
markInteracted();
controller.openTab(taskPanelAttachmentTab(id, title));
if (viewer.state || viewer.browse) viewer.close();
- }}>;
+ }}>;
} else if (activeTab.payload.kind === "issue-document") {
content = activeTab.payload.documentKey === "plan" && !activeTab.payload.revisionId && !activeTab.payload.documentIssueId ? (
diff --git a/ui/src/lib/issue-artifacts.test.ts b/ui/src/lib/issue-artifacts.test.ts
index 1326c06a778..b10c0c7acfd 100644
--- a/ui/src/lib/issue-artifacts.test.ts
+++ b/ui/src/lib/issue-artifacts.test.ts
@@ -4,11 +4,19 @@ import {
documentDisplayTitle,
getAttachmentBackedWorkProductAttachmentIds,
isAgentAttachment,
+ isGeneralTaskDocumentKey,
selectAgentArtifactAttachments,
selectArtifactAttachments,
workProductHref,
} from "./issue-artifacts";
+it.each(["plan", " Plan ", "artifact-review-output", "pipeline-case-body", "continuation-summary"])("keeps %s out of general document authoring", key => {
+ expect(isGeneralTaskDocumentKey(key)).toBe(false);
+});
+it("allows ordinary general document keys", () => {
+ expect(isGeneralTaskDocumentKey("notes")).toBe(true);
+});
+
function makeAttachment(overrides: Partial & { id: string }): IssueAttachment {
return {
companyId: "company-1",
diff --git a/ui/src/lib/issue-artifacts.ts b/ui/src/lib/issue-artifacts.ts
index e7af1582bbb..c4be8194406 100644
--- a/ui/src/lib/issue-artifacts.ts
+++ b/ui/src/lib/issue-artifacts.ts
@@ -1,5 +1,10 @@
import type { IssueAttachment, IssueDocumentSummary, IssueWorkProduct } from "@paperclipai/shared";
-import { getAttachmentArtifactWorkProductMetadata } from "@paperclipai/shared";
+import { getAttachmentArtifactWorkProductMetadata, isArtifactReviewDocumentKey, isSystemIssueDocumentKey } from "@paperclipai/shared";
+
+/** Documents authored here; Plans and file reviews retain their own workflows. */
+export function isGeneralTaskDocumentKey(key: string): boolean {
+ return key.trim().toLowerCase() !== "plan" && !isArtifactReviewDocumentKey(key) && !isSystemIssueDocumentKey(key);
+}
/**
* Selectors for the properties pane's Artifacts tab (PAP-491): which
diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx
index 9c9d8d40570..5460d28091c 100644
--- a/ui/src/pages/IssueDetail.test.tsx
+++ b/ui/src/pages/IssueDetail.test.tsx
@@ -16,6 +16,7 @@ import type {
IssueTreeControlPreview,
IssueTreeHold,
IssueWorkProduct,
+ Project,
} from "@paperclipai/shared";
import { ONBOARDING_FIRST_TASK_ORIGIN_KIND } from "@paperclipai/shared";
import type {
@@ -1617,6 +1618,14 @@ describe("IssueDetail", () => {
await waitForAssertion(() => expect(container.querySelector('[data-testid="task-chat-thread"]')).not.toBeNull());
expect(container.querySelector('[data-testid="issue-chat-thread"]')).toBeNull();
await waitForAssertion(() => expect(mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children?.props).toMatchObject({ accountScope: "coordinator:user-1" }));
+ const editor = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children?.props.documentEditor;
+ expect(editor).toMatchObject({ mentions: expect.any(Array), imageUploadHandler: expect.any(Function), onVote: expect.any(Function) });
+ mockIssuesApi.uploadAttachment.mockResolvedValue(createAttachment({ id: "document-image" }));
+ const image = new File(["image"], "document.png", { type: "image/png" });
+ let uploaded: string | undefined;
+ await act(async () => { uploaded = await editor.imageUploadHandler(image); });
+ expect(uploaded).toBe(createAttachment({ id: "document-image" }).contentPath);
+ expect(mockIssuesApi.uploadAttachment).toHaveBeenCalledWith(createIssue().companyId, createIssue().id, image);
coordinatorPreference.enabled = false;
await act(async () => renderIssueView());
await waitForAssertion(() => expect(container.querySelector('[data-testid="issue-chat-thread"]')).not.toBeNull());
@@ -3161,6 +3170,32 @@ describe("IssueDetail", () => {
await flushReact();
});
+ it.each([false, true])("keeps the panel stable while projects are still loading (Classic: %s)", async classic => {
+ const pendingProjects = createDeferred();
+ mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableClassicTaskInterface: classic });
+ mockIssuesApi.get.mockResolvedValue(createIssue());
+ mockProjectsApi.list.mockReturnValue(pendingProjects.promise);
+ const renderDetail = () => ;
+ try {
+ await act(async () => root.render(renderDetail()));
+ await flushReact();
+ await flushReact();
+ const panelOpenCount = mockOpenPanel.mock.calls.length;
+ expect(panelOpenCount).toBeGreaterThan(0);
+ await act(async () => root.render(renderDetail()));
+ await flushReact();
+ expect(mockOpenPanel).toHaveBeenCalledTimes(panelOpenCount);
+ } finally {
+ const beforeCompletion = mockOpenPanel.mock.calls.length;
+ pendingProjects.resolve([]);
+ await waitForAssertion(() => expect(mockOpenPanel.mock.calls.length).toBeGreaterThan(beforeCompletion));
+ }
+ const settledCount = mockOpenPanel.mock.calls.length;
+ await act(async () => root.render(renderDetail()));
+ await flushReact();
+ expect(mockOpenPanel).toHaveBeenCalledTimes(settledCount);
+ });
+
it("does not load or render decision sections in the issue header", async () => {
mockIssuesApi.get.mockResolvedValue(
createIssue({
diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx
index 42cc6ddf0c9..92588de288e 100644
--- a/ui/src/pages/IssueDetail.tsx
+++ b/ui/src/pages/IssueDetail.tsx
@@ -385,6 +385,7 @@ import {
type IssueRecentComments,
type IssueDocumentSummary,
type IssueWorkProduct,
+ type Project,
type IssueWorkMode,
type IssueThreadInteraction,
type RequestCheckboxConfirmationInteraction,
@@ -403,6 +404,7 @@ import {
// (loading/idle), which destabilizes downstream memos and panel keys that
// depend on it. Reusing one shared reference keeps those values stable.
const EMPTY_ISSUES: Issue[] = [];
+const EMPTY_PROJECTS: Project[] = [];
type StopAndFinalizeRunError = Error & {
runCancelledBeforeStatusUpdateFailed?: boolean;
@@ -3744,7 +3746,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
});
// Bounded pool of recently-updated issues to back the `@task` reference picker.
// The picker filters this list client-side by identifier/title.
- const { data: mentionIssues = [] } = useQuery({
+ const { data: mentionIssues = EMPTY_ISSUES } = useQuery({
queryKey: resolvedCompanyId
? queryKeys.issues.mentionPool(resolvedCompanyId)
: ["issues", "mention-pool", "pending"],
@@ -3766,7 +3768,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
queryFn: () => authApi.getSession(),
});
- const { data: projects } = useQuery({
+ const { data: projects = EMPTY_PROJECTS } = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId!),
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
@@ -3818,7 +3820,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
const fileViewerEnabled =
instanceExperimentalSettings?.enableExperimentalFileViewer === true;
const { orderedProjects } = useProjectOrder({
- projects: projects ?? [],
+ projects,
companyId: selectedCompanyId,
userId: currentUserId,
});
@@ -3987,7 +3989,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
issueLinkState={resolvedIssueDetailState ?? location.state}
subtasks={childIssues}
createdTasks={createdTasks}
- projects={projects ?? []}
+ projects={projects}
isLoading={createdTasksQuery.isLoading || childIssuesLoading}
hasError={hasError}
onRetry={() => {
@@ -6206,6 +6208,25 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
[openIssueGallery],
);
+ const taskDocumentEditor = useMemo>(() => ({
+ feedbackVotes,
+ feedbackDataSharingPreference,
+ feedbackTermsUrl: FEEDBACK_TERMS_URL,
+ mentions: mentionOptions,
+ externalReferences: externalObjectsState.isEnabled ? externalObjectsState.markdownReferences : undefined,
+ imageUploadHandler: async (file) => (await uploadAttachment.mutateAsync(file)).contentPath,
+ onVote: async (revisionId, vote, options) => {
+ await feedbackVoteMutation.mutateAsync({
+ targetType: "issue_document_revision", targetId: revisionId, vote,
+ reason: options?.reason, allowSharing: options?.allowSharing,
+ sharingPreferenceAtSubmit: feedbackDataSharingPreference,
+ });
+ },
+ agentMap,
+ userProfileMap,
+ }), [feedbackVotes, feedbackDataSharingPreference, mentionOptions, externalObjectsState.isEnabled,
+ externalObjectsState.markdownReferences, uploadAttachment.mutateAsync, feedbackVoteMutation.mutateAsync, agentMap, userProfileMap]);
+
useLayoutEffect(() => {
if (!panelIssue || suppressPanelUntilPlan || (conversation && !conversation.issue) || (taskChatShellEnabled && isMobile)) {
closePanel();
@@ -6246,6 +6267,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
openPanel(
{
- const attachment = await uploadAttachment.mutateAsync(file);
- return attachment.contentPath;
- }}
- onVote={async (revisionId, vote, options) => {
- await feedbackVoteMutation.mutateAsync({
- targetType: "issue_document_revision",
- targetId: revisionId,
- vote,
- reason: options?.reason,
- allowSharing: options?.allowSharing,
- sharingPreferenceAtSubmit: feedbackDataSharingPreference,
- });
- }}
extraActions={!hasAttachments ? attachmentUploadButton : null}
- agentMap={agentMap}
- userProfileMap={userProfileMap}
/>
)}
@@ -8883,6 +8882,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
Task side panel
{
let doc = {
...storybookIssueDocuments[0],
+ ...(parameters.generalDocument ? { key: "notes", title: "Release notes" } : {}),
body: "# Release plan\n\nRun the release checks.",
latestRevisionNumber: 2,
latestRevisionId: "revision-latest",
@@ -132,6 +133,18 @@ export const PermissionDenied: Story = {
parameters: { fixtureState: "permission" },
};
export const Edit: Story = { args: { forceEditDocumentKey: "plan" } };
+export const GeneralDocuments: Story = {
+ args: { generalDocumentsOnly: true },
+ parameters: { generalDocument: true },
+ play: async ({ canvasElement }) => {
+ await within(canvasElement).findByText("Run the release checks.");
+ await within(canvasElement).findByRole("button", { name: "New document" });
+ },
+};
+export const GeneralDocumentEdit: Story = {
+ args: { generalDocumentsOnly: true, forceEditDocumentKey: "notes" },
+ parameters: { generalDocument: true },
+};
async function previousRevision(canvasElement: HTMLElement) {
await userEvent.click(