From 322fa6b65cc707a726d50d84ce72aeb6282aa59e Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:49:24 -0500 Subject: [PATCH] fix(ui): restore task document authoring and add persistence journey (#18356) ## Thinking Path > - Paperclip helps people manage AI agents and their work. > - Task documents keep versioned work beside each conversation. > - The default chat interface could read general documents but hid their editor. > - People also need unfinished edits to survive a tab switch or reload. > - This pull request restores the shared editor in Artifacts. > - A portable browser journey checks exact saved content and revision history. > - The benefit is a usable document flow with independent deployment checks. ## Linked Issues or Issue Description **What happened?** A person using the default task interface could read a general task document but could not create or edit one. The existing editor was available in Classic mode. A pending create could also leave newer text as a new form after its editor unmounted, causing a conflict against the person's own successful creation. **Expected behavior** A person can create and edit general documents through Artifacts. Titles, bodies and revision history persist after reopening and reloading the task. Unfinished text survives tab switches. A successful create makes retained newer text an edit of that confirmed revision. Later writes from another actor still cause a conflict. **Steps to reproduce** 1. Open an ordinary task in the default chat interface, then open Artifacts. 2. Try to create a general document. The previous interface has no authoring control. 3. With authoring restored, submit a new document, keep typing, and switch to Properties before the request finishes. 4. Reopen Artifacts. The previous draft path restores a new form despite the successful create. Related public work: #17598 adds document copy/download and #18157 adds image galleries. The selected actual merged parent is `f1d780838218f975064f38333946805e0ec103a5` from #18427. It includes the stable profile-entry ordering repair and the exact recovery-fixture prerequisite. The resulting PR diff contains 32 document and reporting files. ## What Changed - Reuse `IssueDocumentsSection` for general documents in default-chat Artifacts. Keep Classic authoring in its existing task body. Ordinary tasks, conversations and Coordinator mode share the same editor options and signed-in permission wrapper. - Preserve Plans, file-review documents, attachments, uploads and work products. Hide duplicate general-document cards. Use the same general-document key rules for visible rows, validation and refused writes. - Forward image uploads, mentions, references, author maps and feedback to both editor mounts. Keep empty mention/project values stable while queries load. - Back up unfinished forms within their signed-in account, task and editor scope. Preserve text entered before its key. Record a successful create on its matching retained newer buffer, including after unmount or an early reopen. Preserve newer title/body edits, real remote conflicts and matching cancellation cleanup. - Add the model-free `task-document-persistence` journey. Setup creates only an owned empty backlog task. The browser creates and edits the document; normal APIs independently check company, task, actor, exact content and both revision records. - Reopen the owned task through the explicit Backlog view, then reload it. Require exact UI readback and zero native runs. Add only the Doc registration, definition-hash inputs, report mapping and four closed checks to the shared harness. - Count explicit zero-run browser receipts after private journey metadata is removed for publication. Preserve missing billing evidence, positive usage and native/protocol accounting. - Update browser assertions for the restored inline editor while retaining exact download bytes, revision collisions, paused-state and no-run checks. Update the feature map, usage docs and shared editor stories. ## Verification - Latest focused checks use official Node 24.22.2: 568 journey, catalog, report, billing and attempt-accounting tests across 25 suites pass. All four admitted journeys remain registered. The seven relevant UI suites pass 529 tests, including the task-page container after the parent merge. Maps, token gates, the PR-scope diff check and the owned credential-pattern scan pass. The normal QA import preflight also passes with an empty environment and network access blocked. - Billing regression: before the repair, the explicit empty public receipt counted one run, and two positive-usage receipts were discarded by browser metadata. Those three cases fail before and pass after the repair. Two additional red regressions cover aggregate usage beside an empty run list. That ambiguous shape now keeps an unavailable receipt and a minimum count of one. Missing receipts retain unavailable billing, and native/protocol one-or-more run totals retain their measurements. - The previous fresh CI run failed its unchanged snapshot-only recovery fixture: one global sweep did not call its callback. The original failure remains recorded. An isolated PGlite probe with all canonical migrations exercises the real reconciler: it processes two earlier sources, then reaches the owned third source on the next sweep. It preserves source evidence, files and run IDs. This proves bounded pagination, not full native snapshot repair. - Local native recovery tests could not start PostgreSQL because the host semaphore limit was reached. That run executed zero cases. Canonical Linux CI later passed all 143 fixture cases, including the original snapshot-only case and the new competing-candidate control. The exact fixture repair is now inherited from #18427 and is absent from this PR diff. - The portable oracle rejects 19 stale, missing, foreign or incorrect outcomes. Result validation also rejects model/provider metadata and native runs for this case. - Five real Artifacts-to-Properties unmount regressions fail before the repair and pass after it. They cover both editor modes, reopening before and after completion, later typing, and a foreign revision that must still conflict. The edit-storage failure case also checks that Cancel clears the promoted fallback copy. - UI coverage includes default chat, Classic, conversations, Coordinator, Plan rendering, document-card deduplication, reserved keys, pending-query stability, permissions and rich editor options. - Earlier real browser checks passed five upload cases and two artifact-arrival cases. They retain exact CSV bytes, original/revised content, collision, paused-state and no-run assertions. Current-head normal CI runs the updated browser suites again. - The branch merges only the selected actual parent `f1d780838218f975064f38333946805e0ec103a5`. The merge has no conflicts or manual resolution. The SQL service and recovery fixture match that parent exactly. The own document editor, persistence flow, saved-state oracle and billing repair retain their previously qualified source blobs. Follow-up, clarification, question/resumption, screenshot helpers and the Steering map are byte-identical to the merged parent. Existing project/search/question/follow-up registrations remain present. - Required document source blobs remain exact: Doc UI `22e80663b053363e5f75e1432dd9043dc0e70243`, its unmount tests `16f3f4ed0dc9b11aaff129e06e6cf2bbf4522713`, readiness helper `800a747284db19b8484650b6759e8bd1f356839d`, and budget service `3a15d489f0aa55c22e8f7fe6a6bc6a0e14e5914e`. - Local typing is limited by borrowed declarations from another checkout. They omit current `skill_test` and sandbox creation-operation fields. No dependency declarations or repository config were changed. The authoritative clean CI Typecheck + Release Registry gate passes on exact head `2ffce29827ae170420d740940c1c069c7225a74c`. - Full monorepo typecheck, tests and build were not run locally. Borrowed build declarations from another checkout limit local typing. Authoritative [clean CI](https://github.com/paperclipai/paperclip/actions/runs/41171717008) passes on exact head `82d15946c5844d3ae41c6ade62fa224053f20a9a`: 55 successful checks, 3 skipped and 1 neutral, with zero pending or failed checks. Typecheck, build, Runner, server and browser lanes pass. Greptile gives that same head a fresh 5/5. All 15 review threads are resolved, with no further thread page. The PR is mergeable and clean. - Failure history remains recorded. The earlier recovery failure was repaired with a bounded, owned-source fixture. A later CI worker shut down after 2,375 Runner unit tests passed. Its one authorized narrow retry passed those tests and all five native integration suites. The separate AgentConfig browser failure was reproduced and fixed by the merged #18427 parent. These earlier passes do not replace current-head CI. - Final-source live qualification passed on exact harness and served application `82d15946c5844d3ae41c6ade62fa224053f20a9a`. QA run `41177696858` completed all four closed checks and all five phases with zero native runs. Cleanup passed; the durable manifest is complete with no pending evidence. The coordinator verified the original grade and manifest. - The [public qualification report](https://pages.paperclip.ing/evals-qa-smoke-41177696858-1/) records zero agent runs, empty run IDs and empty run usage. Pricing remains unavailable and billing remains incomplete; the report does not claim measured zero cost. - The earlier harness `36c8d9ddd76d0b7ddb28a9a6c674d9e538f6df88` pass against application `2ffce29827ae170420d740940c1c069c7225a74c` remains historical evidence. It is separate from the final-source measurement above. - The selected parent brings profile-entry ordering, proactive chat, a database migration, Runner updates, and task-page chat/read behavior. Its new task-page container is included in the final application measurement. The coordinator retains the original evidence and owns the primary merge and daily-plan admission. ## Risks Artifacts now mounts the existing shared editor. Its autosave, conflict, revision, annotation and server access contracts remain in place. Tests cover both interface modes, shared scopes, later remote writes and unavailable edit storage. Plans and generated file-review documents keep their existing flows. The PR adds no database schema or API change. The shared billing repair requires explicit captured empty run IDs and an unambiguous usage object containing only an empty run list for public browser-only receipts. Other fields beside an empty list keep conservative unavailable billing. Missing measurements remain unavailable. Contradicting positive usage remains counted. The inherited recovery fixture keeps the production sweep limit and its source, readback and no-agent-work assertions. The live journey must fail when authoring controls or saved outcomes are absent. It must not create the expected document through setup APIs or switch to another interface to pass. Final deployment verification must retain the actual app and harness revisions separately. ## Model Used OpenAI Codex, based on GPT-6, with tool use and code execution. The exact runtime model ID and context window were not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/evals.md | 4 +- maps/feature-map/documents-artifacts.md | 16 + tests/e2e/artifact-tab-arrival.spec.ts | 4 +- tests/e2e/artifact-uploads.spec.ts | 53 ++- tests/runner-e2e/FIXTURES.md | 4 + tests/runner-e2e/PRODUCT-JOURNEYS.md | 11 + tests/runner-e2e/README.md | 2 +- tests/runner-e2e/billing.test.ts | 92 ++++ tests/runner-e2e/billing.ts | 8 +- tests/runner-e2e/catalog.test.ts | 2 +- tests/runner-e2e/journey-features.ts | 2 +- tests/runner-e2e/product-journeys/catalog.ts | 1 + tests/runner-e2e/product-journeys/launch.ts | 4 +- .../product-journeys/success-contract.ts | 1 + .../task-document-evidence.test.ts | 77 ++++ .../task-document-evidence.ts | 54 +++ .../task-document-flow.test.ts | 35 ++ .../product-journeys/task-document-flow.ts | 92 ++++ tests/runner-e2e/product-journeys/types.ts | 2 +- tests/runner-e2e/report-catalog.test.ts | 1 + tests/runner-e2e/result-validation.ts | 2 +- .../components/IssueDocumentsSection.test.tsx | 420 +++++++++++++++++- ui/src/components/IssueDocumentsSection.tsx | 121 ++++- .../IssuePropertiesArtifactsTab.tsx | 49 +- .../IssuePropertiesArtifactsTab.v1.test.tsx | 93 +++- .../task-side-panel/TaskSidePanel.test.tsx | 49 +- .../task-side-panel/TaskSidePanel.tsx | 15 +- ui/src/lib/issue-artifacts.test.ts | 8 + ui/src/lib/issue-artifacts.ts | 7 +- ui/src/pages/IssueDetail.test.tsx | 35 ++ ui/src/pages/IssueDetail.tsx | 58 +-- .../document-revisions.stories.tsx | 13 + 32 files changed, 1252 insertions(+), 83 deletions(-) create mode 100644 tests/runner-e2e/product-journeys/task-document-evidence.test.ts create mode 100644 tests/runner-e2e/product-journeys/task-document-evidence.ts create mode 100644 tests/runner-e2e/product-journeys/task-document-flow.test.ts create mode 100644 tests/runner-e2e/product-journeys/task-document-flow.ts 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(