mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 10:48:12 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Task execution controls let board users pause a task or its subtree. > - The composer still accepted messages while a pause hold was active. > - A paused task must require an explicit resume before the user can send another message. > - This pull request replaces the composer with an amber pause card and checks board comment writes on the server. > - The user keeps their draft and resumes through the existing task controls. ## Linked Issues or Issue Description Refs #13104. Refs #13119. **What existing behavior does this improve?** The task composer and existing task/subtree pause controls. **Current behavior** A paused task can still receive a board message. The pause notice sits outside the composer, which leaves the send action available. **Proposed behavior** Show an amber takeover in both task chat and the classic composer. Preserve the draft. Require the user to resume the task or the ancestor subtree before sending. Reject board comment writes through either supported write route while the pause hold is active. **Breaking changes** Board comment writes to a paused task now return HTTP 409. Agent run reports remain supported during a pause. There is no schema migration. ## What Changed - Add a shared amber composer takeover with task, subtree, saved draft, pending, and error states. - Use effective ancestor pause state in both composer interfaces. Refresh it after pause events, task updates, and rejected sends. - Preserve draft text and attachments. Hide editor, send, queued edit, and pending question controls while paused. - Check active pause holds before board comment writes can mutate tasks, store comments, or wake agents. - Connect the approved Storybook examples to the production component and update the design and behavior docs. - Add browser coverage for both composers, draft persistence, resume, inherited holds, and rejected writes. Update ACP continuation coverage for the explicit resume requirement. ## Verification - Passed: `pnpm -r typecheck`. - Passed: `pnpm build`. - Passed: `pnpm build-storybook`. - Passed: `pnpm check:token-gates` and `git diff --check`. - Passed: focused UI tests (398 tests) and server route tests (127 tests). - Passed: `pnpm exec playwright test --config tests/e2e/playwright.config.ts tests/e2e/paused-composer.spec.ts tests/e2e/acp-stop-continuation.spec.ts` (5 tests). - Passed: manual browser walkthrough in a disposable local instance. Pause with a draft, refresh while paused, resume, send, and reopen. The draft returned, and one message persisted. The amber card and resume dialog were readable with no clipping. - Full local `pnpm test:run` did not pass: the general-server stage recorded 9,072 passing tests, 6 database setup failures from macOS shared-memory exhaustion, and 4 failed tests. This stopped the script before its later groups. Latest-head CI runs those groups independently. - Local follow-up: the Git file-resource load test passed on rerun (4 tests); native finalization migration passed after clearing the abandoned browser-test database allocation. Building the native debug fixtures fixed the missing fake provider. The remaining native-session recovery assertion also reproduces on untouched base commit `87b3e5fc6` (36 pass, 1 fail on both base and PR). It expects a settled-session error but receives a semantic-input-digest error. - The final UI build, UI typecheck, token gates, both thread suites (182 tests), and all five browser tests passed after the queued-action review fix. All 31 latest-head CI checks passed, including all server, workspace, browser, build, release, and security gates. Two optional Storybook jobs were skipped by workflow policy. Greptile reviewed `32d8fb5f5` at 5/5 with no open findings. - Review the Paused Composer and Tasks / Execution Controls stories. Pause a task with a draft, verify the amber card, resume, and verify the draft can be sent once. ## Risks - Clients that used board comments to continue paused work must resume first. The response is an explicit HTTP 409. - Pause state can change while a page is open. Live updates refresh the composer, and the server rejects stale sends before their side effects. - Resume keeps the existing dialog and optional agent wake behavior. Agent reports from interrupted runs remain allowed. ## Model Used OpenAI Codex, based on GPT-6, assisted with design, implementation, code execution, and browser verification. The exact runtime model ID and context window are not exposed in this session. The agent used reasoning and tool calls. ## 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 the relevant tests locally and they pass; the full local-suite limits are documented above - [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>
77 lines
5.0 KiB
TypeScript
77 lines
5.0 KiB
TypeScript
import { test, expect, type APIResponse } from "@playwright/test";
|
|
|
|
async function json(response: APIResponse) {
|
|
const body = await response.json();
|
|
expect(response.ok(), JSON.stringify(body)).toBe(true);
|
|
return body;
|
|
}
|
|
|
|
for (const classic of [false, true]) {
|
|
test(`paused composer: ${classic ? "classic" : "task chat"} preserves drafts and requires resume`, async ({ page, request }) => {
|
|
test.setTimeout(120_000);
|
|
const company = await json(await request.post("/api/companies", { data: { name: `Paused composer ${Date.now()}` } }));
|
|
const settings = await json(await request.get("/api/instance/settings/experimental"));
|
|
try {
|
|
await json(await request.patch("/api/instance/settings/experimental", { data: { enableClassicTaskInterface: classic } }));
|
|
const agent = await json(await request.post(`/api/companies/${company.id}/agents`, { data: {
|
|
name: "Paused composer fixture", role: "engineer", adapterType: "process",
|
|
adapterConfig: { command: "/usr/bin/true" },
|
|
runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: false } },
|
|
} }));
|
|
const task = await json(await request.post(`/api/companies/${company.id}/issues`, { data: {
|
|
title: "Review the paused composer", status: "backlog", assigneeAgentId: agent.id,
|
|
} }));
|
|
await page.goto(`/${company.issuePrefix}/issues/${task.identifier}`);
|
|
const editor = (classic ? page.getByTestId("issue-chat-composer") : page).getByRole("textbox", { name: "editable markdown" });
|
|
await editor.fill("Keep this draft until I resume.");
|
|
await page.getByRole("button", { name: "More task actions", exact: true }).click();
|
|
await page.getByRole("button", { name: "Pause work", exact: true }).click();
|
|
const takeover = page.getByTestId("paused-composer-takeover");
|
|
await expect(takeover).toBeVisible();
|
|
await expect(takeover).toContainText("Your draft is saved.");
|
|
await expect(editor).toHaveCount(0);
|
|
await expect(page.getByRole("button", { name: "Send", exact: true })).toHaveCount(0);
|
|
await page.keyboard.press("Meta+Enter");
|
|
const rejected = await request.post(`/api/issues/${task.id}/comments`, { data: { body: "Cannot send yet", reopen: true } });
|
|
expect(rejected.status()).toBe(409);
|
|
const rejectedUpdate = await request.patch(`/api/issues/${task.id}`, { data: { comment: "Cannot reassign and send", assigneeAgentId: null } });
|
|
expect(rejectedUpdate.status()).toBe(409);
|
|
expect((await json(await request.get(`/api/issues/${task.id}`))).assigneeAgentId).toBe(agent.id);
|
|
expect(await json(await request.get(`/api/issues/${task.id}/comments`))).toHaveLength(0);
|
|
await page.reload();
|
|
await expect(takeover).toBeVisible();
|
|
await expect(takeover).toContainText("Your draft is saved.");
|
|
await takeover.getByRole("button", { name: "Resume task" }).click();
|
|
const dialog = page.getByRole("dialog");
|
|
await dialog.getByRole("button", { name: "Resume work", exact: true }).click();
|
|
await expect(takeover).toHaveCount(0);
|
|
await expect(editor).toHaveText("Keep this draft until I resume.");
|
|
await page.getByRole("button", { name: "Send", exact: true }).click();
|
|
await expect.poll(async () => (await json(await request.get(`/api/issues/${task.id}/comments`))).map((comment: { body: string }) => comment.body)).toEqual(["Keep this draft until I resume."]);
|
|
|
|
// Ancestor pause is effective even when this child was created after it.
|
|
await page.getByRole("button", { name: "More task actions", exact: true }).click();
|
|
await page.getByRole("button", { name: "Pause work", exact: true }).click();
|
|
await expect(takeover).toBeVisible();
|
|
const child = await json(await request.post(`/api/companies/${company.id}/issues`, { data: {
|
|
title: "Child held by parent", parentId: task.id, status: "backlog", assigneeAgentId: agent.id,
|
|
} }));
|
|
await page.goto(`/${company.issuePrefix}/issues/${child.identifier}`);
|
|
await expect(takeover).toContainText("Subtree is paused.");
|
|
await expect(editor).toHaveCount(0);
|
|
expect((await request.post(`/api/issues/${child.id}/comments`, { data: { body: "Still paused" } })).status()).toBe(409);
|
|
await takeover.getByRole("link", { name: "Resume subtree" }).click();
|
|
await expect(page).toHaveURL(new RegExp(task.identifier));
|
|
await takeover.getByRole("button", { name: "Resume subtree" }).click();
|
|
await page.getByRole("dialog").getByRole("checkbox").uncheck();
|
|
await page.getByRole("dialog").getByRole("button", { name: "Resume subtree", exact: true }).click();
|
|
await page.goto(`/${company.issuePrefix}/issues/${child.identifier}`);
|
|
await expect(takeover).toHaveCount(0);
|
|
await expect(editor).toBeVisible();
|
|
} finally {
|
|
await request.patch(`/api/companies/${company.id}`, { data: { status: "archived" } });
|
|
await request.patch("/api/instance/settings/experimental", { data: { enableClassicTaskInterface: settings.enableClassicTaskInterface } });
|
|
}
|
|
});
|
|
}
|