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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-31 01:49:24 -05:00
1 parent f1d7808382
commit 322fa6b65c
32 files changed
+1252 -83

No files matched your search

+3 -1
View File
@@ -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`.
+16
View File
@@ -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`
+3 -1
View File
@@ -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 });
});
}
+41 -12
View File
@@ -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) {
+4
View File
@@ -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
+11
View File
@@ -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
+1 -1
View File
@@ -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
+92
View File
@@ -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();
+6 -2
View File
@@ -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);
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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"],
@@ -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 },
+3 -1
View File
@@ -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);
@@ -11,6 +11,7 @@ export function agentJourneyRunCountValid(id: string, count: unknown): boolean {
export const journeyRequiredChecks: Record<string, readonly string[]> = {
"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"],
@@ -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<typeof evidence>) => { e.current.companyId = "other"; }],
["wrong task", (e: ReturnType<typeof evidence>) => { e.current.issueId = "other"; }],
["wrong key", (e: ReturnType<typeof evidence>) => { e.current.key = "plan"; }],
["stale body", (e: ReturnType<typeof evidence>) => { e.current.body = e.original.body; }],
["stale title", (e: ReturnType<typeof evidence>) => { e.current.title = e.original.title; }],
["different document", (e: ReturnType<typeof evidence>) => { e.current.id = "other"; }],
["wrong author", (e: ReturnType<typeof evidence>) => { e.initial.createdByUserId = "other"; }],
["agent edit", (e: ReturnType<typeof evidence>) => { e.current.updatedByAgentId = "agent"; }],
["absent revision pointer", (e: ReturnType<typeof evidence>) => { e.current.latestRevisionId = null; }],
["unchanged revision", (e: ReturnType<typeof evidence>) => { e.current.latestRevisionId = e.initial.latestRevisionId; }],
["regressed revision number", (e: ReturnType<typeof evidence>) => { e.current.latestRevisionNumber = 1; }],
["missing history", (e: ReturnType<typeof evidence>) => { e.revisions = []; }],
["lost original", (e: ReturnType<typeof evidence>) => { e.revisions = [e.currentRevision]; }],
["overwritten original", (e: ReturnType<typeof evidence>) => { e.originalRevision.body = e.revised.body; }],
["wrong revision owner", (e: ReturnType<typeof evidence>) => { e.currentRevision.documentId = "other"; }],
["wrong revision scope", (e: ReturnType<typeof evidence>) => { e.originalRevision.companyId = "other"; }],
["agent-created revision", (e: ReturnType<typeof evidence>) => { e.currentRevision.createdByRunId = "run"; }],
["duplicate revision", (e: ReturnType<typeof evidence>) => { e.revisions.push(e.originalRevision); }],
["list differs from independent read", (e: ReturnType<typeof evidence>) => { 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();
}
});
});
@@ -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);
}
@@ -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();
});
});
@@ -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<TaskDocumentSnapshot[]>(`${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<TaskDocumentSnapshot[]>(`${route}/documents`)).find(document => document.key === key), original),
{ timeout: 30_000, intervals: [300, 600, 1000] }).toBe(true);
const initial = await api.get<TaskDocumentSnapshot>(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<TaskDocumentSnapshot>(documentRoute), revised),
{ timeout: 30_000, intervals: [300, 600, 1000] }).toBe(true);
const current = await api.get<TaskDocumentSnapshot>(documentRoute);
const revisions = await api.get<TaskDocumentRevision[]>(`${documentRoute}/revisions`);
const originalRevision = await api.get<TaskDocumentRevision>(`${documentRoute}/revisions/${initial.latestRevisionId}`);
const currentRevision = await api.get<TaskDocumentRevision>(`${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<TaskDocumentSnapshot>(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<unknown[]>(`${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 });
}
+1 -1
View File
@@ -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 {
+1
View File
@@ -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"]);
});
+1 -1
View File
@@ -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");
@@ -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 <>
<button onClick={() => setArtifacts(true)}>Artifacts</button>
<button onClick={() => setArtifacts(false)}>Properties</button>
{artifacts ? <IssueDocumentsSection issue={createIssue()} canDeleteDocuments={false} generalDocumentsOnly={generalDocumentsOnly} /> : null}
</>;
}
await act(async () => root.render(<QueryClientProvider client={client}><TaskPane /></QueryClientProvider>));
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<HTMLInputElement>('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<HTMLInputElement>('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<HTMLInputElement>('input[placeholder="Document key"]')?.value).toBe("");
expect(container.querySelector<HTMLInputElement>('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(<QueryClientProvider client={client}><IssueDocumentsSection issue={task} canDeleteDocuments={false} generalDocumentsOnly /></QueryClientProvider>);
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<IssueDocument>();
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 <>
<button onClick={() => setArtifacts(true)}>Artifacts</button>
<button onClick={() => setArtifacts(false)}>Properties</button>
{artifacts ? <IssueDocumentsSection issue={issue} canDeleteDocuments={false} generalDocumentsOnly={generalDocumentsOnly} /> : 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<HTMLInputElement>(`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(<QueryClientProvider client={client}><TaskPane /></QueryClientProvider>));
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<HTMLInputElement>('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<HTMLInputElement>('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<IssueDocument>();
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 <>
<button onClick={() => setArtifacts(true)}>Artifacts</button>
<button onClick={() => setArtifacts(false)}>Properties</button>
{artifacts ? <IssueDocumentsSection issue={issue} canDeleteDocuments={false} generalDocumentsOnly={generalDocumentsOnly} /> : 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<HTMLInputElement>(`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(<QueryClientProvider client={client}><TaskPane /></QueryClientProvider>));
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<HTMLInputElement>('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<IssueDocument>();
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(<QueryClientProvider client={client}><IssueDocumentsSection issue={issue} canDeleteDocuments={false} generalDocumentsOnly={generalDocumentsOnly} /></QueryClientProvider>);
const typeKey = async (value: string) => act(async () => {
const input = container.querySelector<HTMLInputElement>('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<HTMLInputElement>('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<IssueDocument>();
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(<QueryClientProvider client={client}><IssueDocumentsSection issue={issue} canDeleteDocuments={false} generalDocumentsOnly /></QueryClientProvider>));
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<HTMLInputElement>('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(<QueryClientProvider client={client}><IssueDocumentsSection
issue={{ ...createIssue(), legacyPlanDocument: { key: "plan", source: "issue_description", body: "Legacy Plan content" } }}
canDeleteDocuments={false} generalDocumentsOnly
/></QueryClientProvider>));
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<HTMLInputElement>('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(<QueryClientProvider client={client}><IssueDocumentsSection
issue={createIssue()} canDeleteDocuments={false} generalDocumentsOnly
/></QueryClientProvider>));
await flush();
await act(async () => Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("New document"))!.click());
const keyInput = container.querySelector<HTMLInputElement>('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(<QueryClientProvider client={queryClient}><IssueDocumentsSection issue={issue} canDeleteDocuments={false} /></QueryClientProvider>));
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();
});
+104 -17
View File
@@ -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<Partial<DraftState> | 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<DocumentConflictState | null>(null);
const draftOwner = useDraftOwner();
const draftScope = draftOwner !== undefined ? `${draftOwner}:${documentSubject.id}` : null;
const newDraftScope = draftScope ? `${draftScope}:${generalDocumentsOnly ? "general" : "all"}` : null;
const [foldedDocumentKeys, setFoldedDocumentKeys] = useState<string[]>(() => loadFoldedDocumentKeys(documentSubject.id));
const [annotationPanelOpenKeys, setAnnotationPanelOpenKeys] = useState<string[]>(
() => (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<string, FeedbackVoteValue>();
@@ -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({
</div>
)}
{!hasRealPlan && documentSubject.legacyPlanDocument ? (
{!hasRealPlan && legacyPlanDocument ? (
<div
id="document-plan"
className={cn(
@@ -1123,7 +1210,7 @@ function AccountIssueDocumentsSection({
</Badge>
</div>
<div className={documentBodyPaddingClassName}>
{renderFoldableBody(documentSubject.legacyPlanDocument.body, documentBodyContentClassName, externalReferences, { documentKey: "plan" })}
{renderFoldableBody(legacyPlanDocument.body, documentBodyContentClassName, externalReferences, { documentKey: "plan" })}
</div>
</div>
) : null}
@@ -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<ComponentProps<typeof IssueDocumentsSection>,
"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 (
<IssueDocumentsSection
issue={issue}
generalDocumentsOnly
canDeleteDocuments={Boolean(session?.user?.id)}
canManageDocumentLocks={Boolean(session?.user?.id)}
{...documentEditor}
/>
);
}
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({
</p>
) : null;
const withUploads = (content: ReactNode, empty = false) => uploadState ? (
<ArtifactUploadContent empty={empty} entries={uploadState.entries} onRetry={uploadState.retry} onDismiss={uploadState.dismiss}>{content}</ArtifactUploadContent>
) : content;
const withUploads = (content: ReactNode, empty = false) => {
const artifacts = uploadState ? (
<ArtifactUploadContent empty={empty} entries={uploadState.entries} onRetry={uploadState.retry} onDismiss={uploadState.dismiss}>{content}</ArtifactUploadContent>
) : content;
return documentAuthoring ? (
<div className="space-y-4">
<TaskGeneralDocuments
issue={issue}
documentEditor={{ agentMap: agentsById, ...documentEditor }}
/>
{artifacts}
</div>
) : 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(
<div className="px-1 py-6 text-sm text-muted-foreground">
No artifacts yet. Work products, documents, and agent-produced files
@@ -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<unknown> => ({})),
}));
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 <section data-testid="general-document-editor"><button>New document</button></section>;
},
}));
vi.mock("@/api/activity", () => ({ activityApi: { runsForIssue: vi.fn(async (): Promise<unknown[]> => []) } }));
vi.mock("@/api/agents", () => ({ agentsApi: { list: vi.fn(async (): Promise<unknown[]> => []) } }));
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 }) => <a href={to} {...props}>{children}</a>,
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(
<QueryClientProvider client={queryClient}>
<IssuePropertiesArtifactsTab issue={issue} {...props} />
<IssuePropertiesArtifactsTab issue={issue} documentEditor={editorOptions} {...props} />
</QueryClientProvider>,
);
});
}
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(<QueryClientProvider client={queryClient}>
<TaskGeneralDocuments issue={conversation} documentEditor={options} />
</QueryClientProvider>));
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(() => {
@@ -67,8 +67,17 @@ vi.mock("@/components/IssueProperties", () => ({
IssueProperties: () => <div>Properties content</div>,
}));
const artifactsTabRender = vi.hoisted(() => vi.fn());
const generalDocumentsRender = vi.hoisted(() => vi.fn());
vi.mock("@/components/issue-properties/IssuePropertiesArtifactsTab", () => ({
IssuePropertiesArtifactsTab: () => <div>Artifacts content</div>,
IssuePropertiesArtifactsTab: (props: unknown) => {
artifactsTabRender(props);
return <div>Artifacts content</div>;
},
TaskGeneralDocuments: (props: unknown) => {
generalDocumentsRender(props);
return <div data-testid="conversation-document-editor">Conversation documents</div>;
},
}));
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<HTMLButtonElement>('button[aria-label="Open a new tab"]');
if (open) await act(async () => open.click());
const artifacts = Array.from(document.body.querySelectorAll<HTMLElement>('[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<HTMLButtonElement>('button[aria-label="Open a new tab"]')?.click());
const artifacts = Array.from(document.body.querySelectorAll<HTMLElement>('[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 {
@@ -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<string>;
@@ -265,6 +266,7 @@ function TaskSidePanelForScope({
openBrowserId,
onBrowserOpened,
issue,
documentEditor,
accountScope,
childIssues = [],
issueLinkState,
@@ -838,13 +840,20 @@ function TaskSidePanelForScope({
: <AgentTasksPanel companyId={issue.companyId} agentId={conversationAgentId} excludeIssueId={issue.id} />
) : null;
} else if (activeTab.payload.kind === "artifacts" && conversationAgentId) {
content = <AgentArtifactsPanel companyId={issue.companyId} agentId={conversationAgentId} />;
content = (
<div className="space-y-4">
<section aria-label="Conversation documents">
<TaskGeneralDocuments issue={issue} documentEditor={documentEditor ?? {}} />
</section>
<AgentArtifactsPanel companyId={issue.companyId} agentId={conversationAgentId} />
</div>
);
} else if (activeTab.payload.kind === "artifacts") {
content = <TextAttachmentContext.Provider value={(id, title) => {
markInteracted();
controller.openTab(taskPanelAttachmentTab(id, title));
if (viewer.state || viewer.browse) viewer.close();
}}><IssuePropertiesArtifactsTab issue={issue} onOpenDocument={openDocument} uploadState={artifactUploads} /></TextAttachmentContext.Provider>;
}}><IssuePropertiesArtifactsTab issue={issue} documentEditor={documentEditor ?? {}} onOpenDocument={openDocument} uploadState={artifactUploads} /></TextAttachmentContext.Provider>;
} else if (activeTab.payload.kind === "issue-document") {
content = activeTab.payload.documentKey === "plan" && !activeTab.payload.revisionId && !activeTab.payload.documentIssueId ? (
<IssuePropertiesPlansTab issue={issue} inline={inline} />
+8
View File
@@ -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<IssueAttachment> & { id: string }): IssueAttachment {
return {
companyId: "company-1",
+6 -1
View File
@@ -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
+35
View File
@@ -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<Project[]>();
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableClassicTaskInterface: classic });
mockIssuesApi.get.mockResolvedValue(createIssue());
mockProjectsApi.list.mockReturnValue(pendingProjects.promise);
const renderDetail = () => <QueryClientProvider client={queryClient}><IssueDetail /></QueryClientProvider>;
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({
+29 -29
View File
@@ -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<NonNullable<TaskSidePanelProps["documentEditor"]>>(() => ({
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(
<IssueGalleryContext.Provider value={openIssueGallery}>
<TaskSidePanel
documentEditor={taskDocumentEditor}
pluginPanels={pluginPanels}
pluginPanelsLoading={pluginPanelsLoading}
openedPluginPanelKeys={openedPluginPanelKeys.current}
@@ -6305,6 +6327,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
externalObjectsState.refetch,
documentDeepLink,
taskChatShellEnabled,
taskDocumentEditor,
currentUserId,
coordinatorWorkspaceEnabled,
fileViewerEnabled,
@@ -8240,35 +8263,11 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
pane (Artifacts tab) — the center column belongs to the thread. */}
{taskChatShellEnabled ? null : (
<IssueDocumentsSection
{...taskDocumentEditor}
issue={issue}
canDeleteDocuments={Boolean(session?.user?.id)}
canManageDocumentLocks={Boolean(session?.user?.id)}
feedbackVotes={feedbackVotes}
feedbackDataSharingPreference={feedbackDataSharingPreference}
feedbackTermsUrl={FEEDBACK_TERMS_URL}
mentions={mentionOptions}
externalReferences={
externalObjectsState.isEnabled
? externalObjectsState.markdownReferences
: undefined
}
imageUploadHandler={async (file) => {
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
<SheetTitle>Task side panel</SheetTitle>
</SheetHeader>
<TaskSidePanel
documentEditor={taskDocumentEditor}
pluginPanels={pluginPanels}
pluginPanelsLoading={pluginPanelsLoading}
openedPluginPanelKeys={openedPluginPanelKeys.current}
@@ -36,6 +36,7 @@ const meta = {
beforeEach: ({ parameters }) => {
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(