From 52811c6ce6552994bc4beca81898a6b50a6edd38 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:28:00 -0500 Subject: [PATCH] fix(tasks): require resume before sending to paused tasks (#13232) ## 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 --- DESIGN.md | 6 +- doc/SPEC-implementation.md | 3 + doc/SPEC.md | 7 ++ doc/composer-stop.md | 17 ++-- ...issue-update-comment-wakeup-routes.test.ts | 24 +++++- server/src/routes/issues.ts | 19 +++++ tests/e2e/acp-stop-continuation.spec.ts | 26 +++--- tests/e2e/paused-composer.spec.ts | 76 ++++++++++++++++++ ui/src/components/IssueChatThread.test.tsx | 24 ++++++ ui/src/components/IssueChatThread.tsx | 17 +++- ui/src/components/TaskChatThread.test.tsx | 35 ++++++++ ui/src/components/TaskChatThread.tsx | 8 +- .../task-chat/TaskChatComposer.test.tsx | 38 +++++++++ .../components/task-chat/TaskChatComposer.tsx | 11 ++- .../task-chat/TaskChatPausedTakeover.tsx | 75 +++++++++++++++++ ui/src/context/LiveUpdatesProvider.tsx | 4 + ui/src/pages/DesignGuide.tsx | 5 +- ui/src/pages/IssueDetail.test.tsx | 15 ++-- ui/src/pages/IssueDetail.tsx | 74 +++++------------ .../prototypes/PausedTaskComposer.tsx | 80 +++++++++++++++++++ .../stories/paused-composer.stories.tsx | 32 ++++++++ .../task-execution-controls.stories.tsx | 9 +-- 22 files changed, 507 insertions(+), 98 deletions(-) create mode 100644 tests/e2e/paused-composer.spec.ts create mode 100644 ui/src/components/task-chat/TaskChatPausedTakeover.tsx create mode 100644 ui/storybook/prototypes/PausedTaskComposer.tsx create mode 100644 ui/storybook/stories/paused-composer.stories.tsx diff --git a/DESIGN.md b/DESIGN.md index e965c7a3b7..102b0dd58e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -40,8 +40,10 @@ Existing tiers already in index.css (~80+ tokens) — extraction maps to these o Do not show a toast for task or run state already visible on the current screen. This includes descendant runs represented by the open subtree. Show local action results in place; keep failures actionable inline. Notifications for other work -remain useful. Expected cancellation is neutral gray, not an error. A paused -subtree needs only “Subtree is paused.” and “Resume subtree.” +remain useful. Expected cancellation is neutral gray, not an error. A paused task replaces the composer with an amber takeover. It says “Task is +paused.” and “Resume this task to send a message.” with a “Resume task” action. +Subtrees use “Subtree is paused.” and “Resume subtree.” The takeover cannot be +dismissed, retains drafts, and hides message inputs until the pause is released. ## Enforcement (what "compliant" means for the extraction run) diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index e660faf5e2..a2e39acd95 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -21,6 +21,9 @@ Paperclip V1 must provide a full control-plane loop for autonomous agents: 4. All work is tracked through tasks/comments with audit visibility. 5. Token/cost usage is reported and budget limits can stop work. 6. The board can intervene anywhere (pause agents/tasks, override decisions). + An effective task or ancestor pause replaces the message composer with an + amber Resume takeover. New board messages, including updates with comments, + are rejected until the hold is released. Drafts survive pause and resume. Success means one operator can run a small AI-native company end-to-end with clear visibility and control. diff --git a/doc/SPEC.md b/doc/SPEC.md index b82ad05ca0..2880a62e05 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -543,3 +543,10 @@ Things Paperclip explicitly does **not** do: 7. **Atomic ownership.** Single assignee per task. Atomic checkout prevents conflicts. 8. **Progressive deployment.** Trivial to start local, straightforward to scale to hosted. 9. **Extensible core.** Clean boundaries so plugins can add capabilities (Adapters, knowledge base, revenue tracking) without modifying core. + +### Paused task messages + +A paused task takes over the composer with an amber notice and a Resume action. +Operators must release the effective task or ancestor pause before sending a new +message. The draft stays intact. This applies to both task interfaces and to +board comment API requests; an agent may still report interrupted work. diff --git a/doc/composer-stop.md b/doc/composer-stop.md index e77b85b404..7c77b2d113 100644 --- a/doc/composer-stop.md +++ b/doc/composer-stop.md @@ -41,8 +41,9 @@ handle does not authorize a signal or replay. Unknown actions remain blocked, and task detail shows the reason even after recovery bookkeeping resolves. A run-level Stop leaves the task unpaused; a subsequent comment can continue the same session with the earlier queued messages. Composer Stop still creates a -pause hold. Human comments can receive a response within the existing paused -conversation scope; task execution requires Resume. Neither path permits a fresh-session fallback +pause hold. New board messages require Resume first. Both comment creation and +updates that include a comment return `409` while an effective task or ancestor +pause hold is active. Interrupted agents may still report their results. Neither path permits a fresh-session fallback when the interrupted checkpoint cannot be restored. The credential-free ACP regression journey uses an actual ACP child process: @@ -65,7 +66,7 @@ and scratch environment. The same conversation must not reuse the stopped run's credential. A regression test checks distinct run IDs and token hashes across the restart without logging the credentials themselves. -On 2026-09-09, all three ACP browser journeys passed. A manual browser walk-through +Historical behavior, superseded by the composer takeover: on 2026-09-09, all three ACP browser journeys passed. A manual browser walk-through also queued a request, used composer Stop, sent “go” while paused, and selected Resume work. The pause stayed in place during the conversation reply. Resume restored the same provider session, answered the pending request once, and moved @@ -89,8 +90,11 @@ The visible task/subtree does not produce duplicate state toasts. Its live notifications are suppressed while foregrounded, including descendant runs; unrelated and background work retains notifications. Tree-control results use inline state, and failures stay in the composer, page, or confirmation dialog. -The pause row contains only “Subtree is paused.” (or “Task is paused.”) and -Resume. Expected cancellation uses a muted gray disclosure with optional details. +The amber composer takeover contains “Subtree is paused.” (or “Task is paused.”), +a short instruction to resume before sending, and Resume. It replaces input +controls in both task interfaces, cannot be dismissed, and preserves text and +attachment drafts. An inherited hold links to the ancestor task. Pending resume +keeps the takeover visible; failed resume leaves the task paused. Expected cancellation uses a muted gray disclosure with optional details. This is recorded as a product rule in `DESIGN.md`. The follow-up passed 213 focused tests, both isolated runner E2E journeys @@ -203,3 +207,6 @@ The broad run was stopped after more than 30 minutes in its serial server lane once these failures were independently reproduced. Later full-suite groups did not run. The full UI suite and the feature's server route suite were run separately as described above. + +The `Tasks / Composer / Paused task takeover` stories use the production composer +and cover task/subtree holds, saved drafts, resume progress/failure, and light/mobile layouts. diff --git a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts index 5ac72ccbfc..88a45800b3 100644 --- a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts +++ b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts @@ -20,6 +20,8 @@ const mockIssueService = vi.hoisted(() => ({ listReviewAttention: vi.fn(), })); +const mockPauseGate = vi.hoisted(() => vi.fn(async (): Promise | null> => null)); + const mockHeartbeatService = vi.hoisted(() => ({ wakeup: vi.fn(async () => undefined), reportRunActivity: vi.fn(async () => undefined), @@ -110,6 +112,7 @@ vi.mock("../services/index.js", () => ({ getActiveForIssue: vi.fn(async () => null), listActiveForIssues: vi.fn(async () => new Map()), }), + issueTreeControlService: () => ({ getActivePauseHoldGate: mockPauseGate }), issueService: () => mockIssueService, issueThreadInteractionService: () => mockIssueThreadInteractionService, logActivity: vi.fn(async () => undefined), @@ -185,7 +188,8 @@ function registerModuleMocks() { getActiveForIssue: vi.fn(async () => null), listActiveForIssues: vi.fn(async () => new Map()), }), - issueService: () => mockIssueService, + issueTreeControlService: () => ({ getActivePauseHoldGate: mockPauseGate }), + issueService: () => mockIssueService, issueThreadInteractionService: () => mockIssueThreadInteractionService, logActivity: vi.fn(async () => undefined), projectService: () => ({}), @@ -253,6 +257,7 @@ describe("issue update comment wakeups", () => { vi.doUnmock("../middleware/index.js"); registerModuleMocks(); vi.clearAllMocks(); + mockPauseGate.mockResolvedValue(null); mockIssueService.findMentionedAgents.mockResolvedValue([]); mockIssueService.getByIdForUpdate.mockImplementation(async () => mockIssueService.getById()); mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] }); @@ -262,6 +267,23 @@ describe("issue update comment wakeups", () => { mockIssueService.listReviewAttention.mockResolvedValue(new Map()); }); + it.each(["post", "patch"] as const)("rejects %s board messages under an inherited pause before any mutation", async (method) => { + const existing = makeIssue(); + mockIssueService.getById.mockResolvedValue(existing); + mockPauseGate.mockResolvedValue({ holdId: "hold-1", rootIssueId: "parent-1" }); + const app = await createApp(); + const res = method === "post" + ? await request(app).post(`/api/issues/${existing.id}/comments`).send({ body: "go", reopen: true, interrupt: true }) + : await request(app).patch(`/api/issues/${existing.id}`).send({ comment: "go", assigneeAgentId: ASSIGNEE_AGENT_ID, status: "todo" }); + expect(res.status).toBe(409); + expect(res.body.error).toBe("Task is paused. Resume it before sending a message."); + expect(res.body.details.rootIssueId).toBe("parent-1"); + expect(mockPauseGate).toHaveBeenCalledWith(existing.companyId, existing.id); + expect(mockIssueService.addComment).not.toHaveBeenCalled(); + expect(mockIssueService.update).not.toHaveBeenCalled(); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); + }); + it("includes the new comment in assignment wakes from issue updates", async () => { const existing = makeIssue(); const updated = makeIssue({ diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 0cc963a4e0..3f7905745d 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -6347,6 +6347,23 @@ export function issueRoutes( return false; } + async function assertBoardCommentNotPaused( + req: Request, + res: Response, + issue: { id: string; companyId: string }, + ) { + // Agents may still finish reporting an interrupted run. New operator + // messages must not enter the paused-conversation triage wake path. + if (req.actor.type !== "board") return true; + const hold = await treeControlSvc.getActivePauseHoldGate(issue.companyId, issue.id); + if (!hold) return true; + res.status(409).json({ + error: "Task is paused. Resume it before sending a message.", + details: { issueId: issue.id, holdId: hold.holdId, rootIssueId: hold.rootIssueId }, + }); + return false; + } + async function assertExplicitResumeIntentAllowed( req: Request, res: Response, @@ -12598,6 +12615,7 @@ export function issueRoutes( { allowVisibleIssueWrite: true }, ); if (!issueMutationAccess) return; + if (req.body.comment && !(await assertBoardCommentNotPaused(req, res, existing))) return; const issueMutationAuthorizationReason = req.actor.type === "agent" ? issueWriteAuthorizationReason( @@ -16930,6 +16948,7 @@ export function issueRoutes( issue, ); if (!commentAccessDecision) return; + if (!(await assertBoardCommentNotPaused(req, res, issue))) return; const commentAuthorizationReason = issueWriteAuthorizationReason( req, commentAccessDecision, diff --git a/tests/e2e/acp-stop-continuation.spec.ts b/tests/e2e/acp-stop-continuation.spec.ts index 813297d861..d4c8c651d4 100644 --- a/tests/e2e/acp-stop-continuation.spec.ts +++ b/tests/e2e/acp-stop-continuation.spec.ts @@ -54,20 +54,21 @@ for (const { unfinishedWrite, pause } of [{ unfinishedWrite: false, pause: false const writesAtStop = unfinishedWrite ? await readFile(path.join(root, "writes"), "utf8") : null; await page.reload(); if (unfinishedWrite) await expect(page.getByText("Work cannot start.", { exact: false })).toBeVisible(); - await editor.fill("go"); - await page.getByRole("button", { name: "Send", exact: true }).click(); if (pause) { - await expect(page.getByText("Task is paused.", { exact: true })).toBeVisible(); - await expect(page.getByText("Task remains paused. Use Resume work to continue.", { exact: false })).toBeVisible(); - await expect.poll(async () => (await json(await request.get(`/api/issues/${issue.id}/live-runs`))).length).toBe(0); - const pausedPrompts = (await readFile(path.join(root, "prompts"), "utf8")).trim().split("\n").map(line => JSON.parse(line)); - expect(pausedPrompts).toHaveLength(2); - expect(JSON.stringify(pausedPrompts[1])).toContain("execution scope: respond or triage the human comment"); + await expect(page.getByTestId("paused-composer-takeover")).toBeVisible(); + await expect(editor).toHaveCount(0); + await expect(page.getByRole("button", { name: "Send", exact: true })).toHaveCount(0); + const rejected = await request.post(`/api/issues/${issue.id}/comments`, { data: { body: "go" } }); + expect(rejected.status()).toBe(409); + expect((await readFile(path.join(root, "prompts"), "utf8")).trim().split("\n")).toHaveLength(1); expect(await readFile(path.join(root, "completed"), "utf8").catch(() => "")).toBe(""); - await page.getByRole("button", { name: "Resume work", exact: true }).click(); + await page.getByRole("button", { name: "Resume task", exact: true }).click(); const dialog = page.getByRole("dialog"); await dialog.getByRole("checkbox").check(); await dialog.getByRole("button", { name: "Resume work", exact: true }).click(); + } else { + await editor.fill("go"); + await page.getByRole("button", { name: "Send", exact: true }).click(); } if (unfinishedWrite) { await expect(page.getByText("Couldn't start", { exact: false })).toBeVisible(); @@ -79,13 +80,12 @@ for (const { unfinishedWrite, pause } of [{ unfinishedWrite: false, pause: false await expect(page.getByText("Answered the pending follow-up once.", { exact: false })).toBeVisible({ timeout: 30_000 }); await expect.poll(async () => (await json(await request.get(`/api/issues/${issue.id}/live-runs`))).length).toBe(0); const prompts = (await readFile(path.join(root, "prompts"), "utf8")).trim().split("\n").map(line => JSON.parse(line)); - expect(prompts).toHaveLength(pause ? 3 : 2); + expect(prompts).toHaveLength(2); expect(new Set(prompts.map(prompt => prompt.sessionId)).size).toBe(1); - // Paused conversation already delivered the request into this same - // provider session; Resume legitimately sends only its next delta. + // Resume delivers the queued follow-up in the same provider session. const continuationPrompts = pause ? prompts.slice(1) : [prompts.at(-1)]; expect(JSON.stringify(continuationPrompts)).toContain("List my recent Drive files."); - expect(JSON.stringify(continuationPrompts)).toContain("go"); + if (!pause) expect(JSON.stringify(continuationPrompts)).toContain("go"); expect(await readFile(path.join(root, "completed"), "utf8")).toBe("follow-up\n"); const completedIssue = await json(await request.get(`/api/issues/${issue.id}`)); expect(completedIssue.executionBlocker).toBeNull(); diff --git a/tests/e2e/paused-composer.spec.ts b/tests/e2e/paused-composer.spec.ts new file mode 100644 index 0000000000..c01d083632 --- /dev/null +++ b/tests/e2e/paused-composer.spec.ts @@ -0,0 +1,76 @@ +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 } }); + } + }); +} diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 4628d21aaf..c6392711ea 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -3087,6 +3087,30 @@ describe("IssueChatThread", () => { }); }); + it("hides queued interrupt and cancel actions until the task resumes", () => { + const root = createRoot(container); + const comments = [{ + id: "comment-paused-queue", companyId: "company-1", issueId: "issue-1", + authorAgentId: null, authorUserId: "user-1", authorType: "user" as const, + body: "Keep this queued message", presentation: null, metadata: null, + queueState: "queued" as const, queueTargetRunId: "run-1", + createdAt: new Date(), updatedAt: new Date(), + }]; + for (const paused of [false, true, false]) { + act(() => root.render( {}} + onInterruptQueued={async () => {}} onCancelQueued={() => {}} + composerPause={paused ? { scope: "leaf", onResume: () => {} } : null} + enableLiveTranscriptPolling={false} + />)); + const labels = [...container.querySelectorAll("button")].map((button) => button.textContent); + expect(labels.includes("Interrupt")).toBe(!paused); + expect(labels.includes("Cancel")).toBe(!paused); + expect(container.textContent).toContain("Keep this queued message"); + } + act(() => root.unmount()); + }); + it("shows deferred wake badge only for hold-deferred queued comments", () => { const root = createRoot(container); diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 3f1a132c53..8d3ebbba96 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -1,3 +1,4 @@ +import { TaskChatPausedTakeover, type TaskComposerPause } from "./task-chat/TaskChatPausedTakeover"; import { AssistantRuntimeProvider } from "@assistant-ui/react"; import type { ReasoningMessagePart, @@ -517,6 +518,7 @@ interface IssueChatComposerProps { hasActiveRun?: boolean; currentUserId?: string | null; userLabelMap?: ReadonlyMap | null; + composerPause?: TaskComposerPause | null; composerDisabledReason?: string | null; composerHint?: string | null; issueStatus?: string; @@ -617,6 +619,7 @@ interface IssueChatThreadProps { currentAssigneeValue?: string; suggestedAssigneeValue?: string; mentions?: MentionOption[]; + composerPause?: TaskComposerPause | null; composerDisabledReason?: string | null; composerHint?: string | null; onWorkModeChange?: (workMode: IssueWorkMode) => Promise | void; @@ -4672,6 +4675,7 @@ const IssueChatComposer = forwardRef< hasActiveRun = false, currentUserId = null, userLabelMap = null, + composerPause = null, composerDisabledReason = null, composerHint = null, issueStatus, @@ -4870,6 +4874,7 @@ const IssueChatComposer = forwardRef< Boolean(onStop || stopControl.stopping); async function handleSubmit() { + if (composerPause) return; const trimmed = body.trim(); if ( (!trimmed && attachedFiles.length === 0) || @@ -4893,6 +4898,7 @@ const IssueChatComposer = forwardRef< } async function submitComment() { + if (composerPause) return; const trimmed = body.trim(); if ( (!trimmed && attachedFiles.length === 0) || @@ -5236,6 +5242,10 @@ const IssueChatComposer = forwardRef< setDismissedCoachToken(plainNameCandidate.matchedText); } + if (composerPause) { + return ; + } + if (composerDisabledReason) { return (
@@ -5752,6 +5762,7 @@ export function IssueChatThread({ currentAssigneeValue = "", suggestedAssigneeValue, mentions = [], + composerPause = null, composerDisabledReason = null, composerHint = null, showComposer = true, @@ -6440,8 +6451,8 @@ export function IssueChatThread({ stoppingRunLabel, stopRunVariant, runFinalizationActions, - onInterruptQueued: stableOnInterruptQueued, - onCancelQueued: stableOnCancelQueued, + onInterruptQueued: composerPause ? undefined : stableOnInterruptQueued, + onCancelQueued: composerPause ? undefined : stableOnCancelQueued, onDeleteComment: stableOnDeleteComment, onImageClick: stableOnImageClick, onAcceptInteraction: stableOnAcceptInteraction, @@ -6469,6 +6480,7 @@ export function IssueChatThread({ stoppingRunLabel, stopRunVariant, runFinalizationActions, + composerPause, stableOnInterruptQueued, stableOnCancelQueued, stableOnDeleteComment, @@ -6714,6 +6726,7 @@ export function IssueChatThread({ stopScope={stopScope} currentUserId={currentUserId} userLabelMap={userLabelMap} + composerPause={composerPause} composerDisabledReason={composerDisabledReason} composerHint={composerHint} issueStatus={issueStatus} diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index a5199d77af..eeefe25a66 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -2783,6 +2783,41 @@ describe("TaskChatThread Paperclip Runner queue", () => { return container.textContent?.split(text).length! - 1; } + it("hides queued actions while paused and restores the queue after resume", () => { + const onSteerQueuedComment = vi.fn(async () => {}); + const props = { + comments: [queuedComment], + onAdd: async () => {}, + queuedCommentQueue: queue, + onEditQueuedComment: async () => {}, + onReorderQueuedComments: async () => {}, + onSteerQueuedComment, + onDiscardQueuedComment: async () => {}, + }; + render(); + expect(container.querySelector('[data-testid="task-chat-queued-messages"]')).not.toBeNull(); + + render( {} }} />); + expect(container.querySelector('[data-testid="paused-composer-takeover"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="task-chat-queued-messages"]')).toBeNull(); + expect(onSteerQueuedComment).not.toHaveBeenCalled(); + + render(); + expect(container.querySelector('[data-testid="task-chat-queued-messages"]')).not.toBeNull(); + expect(occurrenceCount(queuedComment.body)).toBe(1); + }); + + it("hides legacy transcript interrupt actions while paused", () => { + render( {}} + onInterruptQueued={async () => {}} + composerPause={{ scope: "leaf", onResume: () => {} }} + />); + expect(container.querySelector('[data-testid="paused-composer-takeover"]')).not.toBeNull(); + expect([...container.querySelectorAll("button")].some((button) => button.textContent === "Interrupt")).toBe(false); + }); + it("suppresses the transcript echo until the queued entry is consumed", async () => { const props = { comments: [queuedComment], diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index b1e5bb8fda..3c20d416e2 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -488,6 +488,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { composerAccessory, footer, showComposer = true, + composerPause, composerDisabledReason, emptyMessage = "No messages yet.", companyId, @@ -2382,7 +2383,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { const renderQueuedAction = useCallback( (item: TaskChatMessageItem) => { const runId = item.queueTargetRunId; - if (item.optimistic !== "queued" || !runId || !onInterruptQueued) + if (composerPause || item.optimistic !== "queued" || !runId || !onInterruptQueued) return null; const isInterrupting = interruptingQueuedRunId === runId; @@ -2398,7 +2399,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { ); }, - [interruptingQueuedRunId, onInterruptQueued], + [composerPause, interruptingQueuedRunId, onInterruptQueued], ); const reopenToolReview = useCallback((interactionId: string) => { @@ -2870,7 +2871,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { className="relative isolate flex flex-col" data-testid="task-chat-composer-stack" > - {queuedMessageQueue ? ( + {queuedMessageQueue && !composerPause ? ( setQueuedEdit(null)} + pause={composerPause} takeover={composerTakeover} runnerGoalCapability={runnerGoal.data?.capability ?? null} onRunnerGoalCommand={runnerGoal.executeComposerCommand} diff --git a/ui/src/components/task-chat/TaskChatComposer.test.tsx b/ui/src/components/task-chat/TaskChatComposer.test.tsx index 53b5a0ca0a..1de546780b 100644 --- a/ui/src/components/task-chat/TaskChatComposer.test.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.test.tsx @@ -1713,6 +1713,44 @@ describe("TaskChatComposer", () => { }); }); + describe("paused task takeover", () => { + it("preserves a typed draft and blocks sending until resume completes", async () => { + const onAdd = vi.fn(); + const onResume = vi.fn(); + const props = { onAdd, workMode: "standard" as const, draftKey: "paused-draft" }; + act(() => root!.render()); + typeText("Please check mobile too."); + act(() => root!.render()); + expect(container.querySelector('[contenteditable="true"]')).toBeNull(); + expect(container.querySelector('button[aria-label="Send"]')).toBeNull(); + expect(container.textContent).toContain("Your draft is saved."); + act(() => container.querySelector("button")!.click()); + expect(onResume).toHaveBeenCalledOnce(); + expect(onAdd).not.toHaveBeenCalled(); + act(() => root!.render()); + expect(container.querySelector("button")!.disabled).toBe(true); + act(() => root!.render()); + expect(editable().textContent).toBe("Please check mobile too."); + await act(async () => sendButton().click()); + expect(onAdd).toHaveBeenCalledWith("Please check mobile too.", undefined, undefined); + }); + + it("takes precedence over pending questions and queued-message edits", () => { + const onSkip = vi.fn(); + act(() => root!.render(Answer question, onDismiss: vi.fn(), onSkip }} + />)); + expect(container.textContent).toContain("Subtree is paused."); + expect(container.textContent).not.toContain("Answer question"); + expect(container.textContent).not.toContain("Skip"); + expect(container.querySelector('[contenteditable="true"]')).toBeNull(); + expect(container.querySelector("button")!.disabled).toBe(true); + }); + }); + describe("queued message editing", () => { const draftKey = "task-chat-draft:queued-edit"; diff --git a/ui/src/components/task-chat/TaskChatComposer.tsx b/ui/src/components/task-chat/TaskChatComposer.tsx index 1097cc3bf0..8448672a92 100644 --- a/ui/src/components/task-chat/TaskChatComposer.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.tsx @@ -72,6 +72,8 @@ import type { RunnerGoalCapability } from "@paperclipai/shared"; import type { ActionCommandOption } from "@/context/EditorAutocompleteContext"; import { TaskChatComposerTakeoverActionsContext } from "./TaskChatComposerTakeoverContext"; +import { TaskChatPausedTakeover, type TaskComposerPause } from "./TaskChatPausedTakeover"; + /** Structurally identical to IssueChatThread's module-private CommentReassignment. */ export interface CommentReassignment { assigneeAgentId: string | null; @@ -134,6 +136,7 @@ interface TaskChatComposerProps { queuedEdit?: { commentId: string; body: string; stale?: boolean } | null; onSaveQueuedEdit?: (commentId: string, body: string) => Promise; onCancelQueuedEdit?: () => void; + pause?: TaskComposerPause | null; takeover?: TaskChatComposerTakeover | null; pendingTakeover?: { count: number; @@ -400,6 +403,7 @@ export function TaskChatComposer({ queuedEdit = null, onSaveQueuedEdit, onCancelQueuedEdit, + pause = null, takeover = null, pendingTakeover = null, runnerGoalCapability = null, @@ -759,7 +763,7 @@ export function TaskChatComposer({ * the paste when it carries no images the plugin should handle. */ function handlePasteCapture(evt: ReactClipboardEvent) { - if (!canAcceptFiles) return; + if (pause || !canAcceptFiles) return; const files = Array.from(evt.clipboardData?.files ?? []); if (files.length === 0) return; const nonImages = files.filter((file) => !file.type.startsWith("image/")); @@ -804,6 +808,7 @@ export function TaskChatComposer({ }, [queuedEdit, takeoverVisible]); async function submit() { + if (pause || disabled) return; const retained = draftKey && !queuedEdit ? loadDraftSubmission(draftKey) : null; if (retained && !submitting) { @@ -1059,6 +1064,10 @@ export function TaskChatComposer({ ) : null; + if (pause) { + return ; + } + return (
void; + resumeHref?: string; + pending?: boolean; + error?: string | null; +} + +/** Replaces all input controls until the effective pause hold is released. */ +export function TaskChatPausedTakeover({ + scope, + resumeHref, + hasDraft = false, + pending = false, + error, + onResume, +}: { + hasDraft?: boolean; +} & TaskComposerPause) { + const headingId = useId(); + const subtree = scope === "subtree"; + return ( +
+
+
+ {error ? ( +

+ {error} +

+ ) : null} +
+ {hasDraft ? ( +

Your draft is saved.

+ ) : null} + {resumeHref ? ( + + ) : ( + + )} +
+
+ ); +} + diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index b2513f318f..ffdab1de94 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -1262,6 +1262,10 @@ function invalidateActivityQueries( } if (entityType === "issue") { + if (action === "issue.tree_hold_created" || action === "issue.tree_hold_released" || action === "issue.updated") { + // An ancestor hold or reparenting changes descendants' effective pause. + queryClient.invalidateQueries({ queryKey: ["issues", "tree-control-state"] }); + } queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId), }); diff --git a/ui/src/pages/DesignGuide.tsx b/ui/src/pages/DesignGuide.tsx index 544287f4f7..cfefcb1f17 100644 --- a/ui/src/pages/DesignGuide.tsx +++ b/ui/src/pages/DesignGuide.tsx @@ -2,7 +2,7 @@ import { SavedProviderKeySelect } from "../components/onboarding/SavedProviderKe import { RepositoryEditor } from "@/components/RepositoryEditor"; import { TaskChatMarker } from "@/components/task-chat/TaskChatMarker"; import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer"; -import { TaskPauseNotice, TaskTreeControlDialog, TaskTreeControlMenuItems } from "@/components/TaskTreeControls"; +import { TaskTreeControlDialog, TaskTreeControlMenuItems } from "@/components/TaskTreeControls"; import { useState } from "react"; import { ServicesList } from "./apps/app-detail/ServicesPanel"; import { ComposioProvenanceChip } from "./apps/ComposioProvenanceChip"; @@ -463,9 +463,8 @@ function TaskExecutionControlsExample() { onCancel={() => setDialogMode("cancel")} onRestore={() => setDialogMode("restore")} />

{running ? "Running: type to switch Stop to Send." : "Paused: resume from the menu."}

- {!running ? setDialogMode("resume")} /> : null} {!running ? : null} - {}} workMode="standard" stopScope="subtree" onStop={running ? async () => setRunning(false) : undefined} /> + setDialogMode("resume") } : null} onAdd={async () => {}} workMode="standard" stopScope="subtree" onStop={running ? async () => setRunning(false) : undefined} /> { if (!open) setDialogMode(null); }} mode={dialogMode ?? "cancel"} scope="subtree" affectedCount={3} affectedAgentCount={2} loading={false} pending={false} valid wakeAgents={wake} onWakeAgentsChange={setWake} onRetry={() => {}} diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 183c9046f9..2046dd044a 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -1,3 +1,4 @@ +import { TaskChatPausedTakeover, type TaskComposerPause } from "../components/task-chat/TaskChatPausedTakeover"; // @vitest-environment jsdom import { RichWorkProductCard } from "../components/task-chat/RichWorkProductCard"; @@ -345,6 +346,7 @@ vi.mock("../components/IssueChatThread", () => ({ label: string; onSelect: (runId: string) => Promise | void; }[]; + composerPause?: TaskComposerPause | null; footer?: ReactNode; }) => { mockIssueChatThreadRender(props); @@ -368,6 +370,7 @@ vi.mock("../components/IssueChatThread", () => ({ {action.label} ))} + {props.composerPause ? : null} {props.footer}
); @@ -396,6 +399,7 @@ vi.mock("../components/TaskChatThread", () => ({ label: string; onSelect: (runId: string) => Promise | void; }[]; + composerPause?: TaskComposerPause | null; footer?: ReactNode; }) => { mockIssueChatThreadRender(props); @@ -436,6 +440,7 @@ vi.mock("../components/TaskChatThread", () => ({ {action.label} ))} + {props.composerPause ? : null} {props.footer} ); @@ -4315,12 +4320,8 @@ describe("IssueDetail", () => { expect(container.textContent).toContain("Subtree is paused."); }); - const pauseBannerTitle = Array.from( - container.querySelectorAll("span"), - ).find((element) => element.textContent?.trim() === "Subtree is paused."); - expect(pauseBannerTitle?.closest(".rounded-md")?.classList).toContain( - "mt-3", - ); + expect(container.querySelector('[data-testid="paused-composer-takeover"]')).toBeTruthy(); + expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0].composerPause.scope).toBe("subtree"); const taskChatShell = container.querySelector( "[data-task-chat-shell]", ); @@ -5178,7 +5179,7 @@ describe("IssueDetail", () => { }); const resumeButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Resume work", + (button) => button.textContent?.trim() === "Resume task", ); expect(resumeButton).toBeTruthy(); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 9d0848b328..9e37998b8d 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -1,3 +1,4 @@ +import type { TaskComposerPause } from "../components/task-chat/TaskChatPausedTakeover"; import { TaskChatScrollNavigation } from "@/components/task-chat/scroll-navigation"; import { memo, @@ -207,7 +208,6 @@ import { import { TaskSidePanel } from "../components/task-side-panel"; import { SidePanelToggleButton } from "../components/side-panel"; import { - TaskPauseNotice, TaskTreeControlDialog, TaskTreeControlMenuItems, } from "../components/TaskTreeControls"; @@ -1252,6 +1252,7 @@ type IssueDetailChatTabProps = { currentAssigneeValue: string; suggestedAssigneeValue: string; mentions: MentionOption[]; + composerPause?: TaskComposerPause | null; composerDisabledReason: string | null; composerHint: string | null; queuedCommentReason: "hold" | "active_run" | "other"; @@ -1373,6 +1374,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ currentAssigneeValue, suggestedAssigneeValue, mentions, + composerPause, composerDisabledReason, composerHint, queuedCommentReason, @@ -2383,6 +2385,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ currentAssigneeValue={currentAssigneeValue} suggestedAssigneeValue={suggestedAssigneeValue} mentions={mentions} + composerPause={composerPause} composerDisabledReason={composerDisabledReason} composerHint={composerHint} onVote={onVote} @@ -3369,10 +3372,10 @@ export function IssueDetail() { staleTime: 0, retry: false, }); - const { data: treeControlState } = useQuery({ + const { data: treeControlState, isPending: treeControlStatePending, error: treeControlStateError } = useQuery({ queryKey: ["issues", "tree-control-state", issueId ?? "pending"], queryFn: () => issuesApi.getTreeControlState(issueId!), - enabled: !!issueId && canManageTreeControl, + enabled: !!issueId, retry: false, }); const { data: activeRootPauseHolds = [] } = useQuery({ @@ -4462,6 +4465,7 @@ export function IssueDetail() { }); }, onSettled: (_result, _error, variables) => { + if (_error) void queryClient.invalidateQueries({ queryKey: ["issues", "tree-control-state"] }); invalidateIssueThreadLazily(); // Binding happens when the comment saves, after the upload's earlier // refetch. Refresh even after an unknown response: the write may exist. @@ -4849,6 +4853,7 @@ export function IssueDetail() { }); }, onSettled: (_result, _error, variables) => { + if (_error) void queryClient.invalidateQueries({ queryKey: ["issues", "tree-control-state"] }); invalidateIssueThreadLazily(); if (variables.attachmentIds?.length) { for (const ref of issueCacheRefs) { @@ -6684,15 +6689,10 @@ export function IssueDetail() { const previewAffectedIssueCount = treePreviewAffectedIssues.length; const previewAffectedAgentCount = treeControlPreview?.totals.affectedAgents ?? 0; - const pausedComposerHint = activePauseHold - ? issue.assigneeAgentId - ? `Sending this comment will wake ${agentMap.get(issue.assigneeAgentId)?.name ?? "the assignee"} for triage while the subtree remains paused.` - : "Assign an agent to wake them for triage while the subtree remains paused." - : null; const reopenComposerHint = closedIsolatedWorkspaceReopenPending ? "This issue's isolated workspace was archived. Your next comment or resume reopens it and rebuilds the worktree." : null; - const composerHint = pausedComposerHint ?? reopenComposerHint; + const composerHint = activePauseHold ? null : reopenComposerHint; const queuedCommentReason: "hold" | "active_run" | "other" = activePauseHold ? "hold" : "active_run"; @@ -7333,49 +7333,6 @@ export function IssueDetail() { This task is hidden )} - {activePauseHold && ( - { - executeTreeControl.reset(); - setTreeControlMode("resume"); - setTreeControlWakeAgentsOnResume( - isAgentOwnedNonTerminalIssue || canShowSubtreeControls, - ); - setTreeControlOpen(true); - } - : undefined - } - resumeLink={ - !activePauseHold.isRoot ? ( - - ) : undefined - } - /> - )} {treeControlWakeWarning ? (

{ + executeTreeControl.reset(); + setTreeControlMode("resume"); + setTreeControlWakeAgentsOnResume(isAgentOwnedNonTerminalIssue || canShowSubtreeControls); + setTreeControlOpen(true); + } : undefined, + resumeHref: !activePauseHold.isRoot ? createIssueDetailPath(activePauseHoldRoot?.identifier ?? activePauseHold.rootIssueId) : undefined, + } : null} + composerDisabledReason={treeControlStatePending ? "Checking task status…" : treeControlStateError ? "Couldn’t check whether this task is paused. Refresh to try again." : null} composerHint={composerHint} queuedCommentReason={queuedCommentReason} onVote={handleCommentVote} diff --git a/ui/storybook/prototypes/PausedTaskComposer.tsx b/ui/storybook/prototypes/PausedTaskComposer.tsx new file mode 100644 index 0000000000..decd6f518b --- /dev/null +++ b/ui/storybook/prototypes/PausedTaskComposer.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState } from "react"; +import { Bot } from "lucide-react"; +import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer"; +import { clearDraft, saveDraft } from "@/lib/composer-draft"; +import { cn } from "@/lib/utils"; + +export type PausedComposerPreviewProps = { + subtree?: boolean; + draft?: string; + initialState?: "paused" | "resuming" | "error"; + composerOnly?: boolean; + mobile?: boolean; +}; + +export function PausedComposerPreview({ + subtree = false, + draft = "", + initialState = "paused", + composerOnly = false, + mobile = false, +}: PausedComposerPreviewProps) { + const [state, setState] = useState(initialState); + const [messages, setMessages] = useState([]); + const [draftKey] = useState(() => { + const key = `paperclip:storybook:paused-takeover:${crypto.randomUUID()}`; + if (draft) saveDraft(key, draft); + return key; + }); + useEffect(() => () => clearDraft(draftKey), [draftKey]); + useEffect(() => { + // The loading story stays pending. Interactive resumes complete locally. + if (state !== "resuming" || initialState === "resuming") return; + const timer = window.setTimeout(() => setState("ready"), 700); + return () => window.clearTimeout(timer); + }, [state, initialState]); + + return ( +

+ {!composerOnly ? ( + <> +
+ PAP-204 +

Polish the task conversation

+
+
+

+ Check the composer and make sure follow-ups work well on mobile. +

+
+
+
+
+

+ I’ve reviewed the composer layout. Next I’ll check the keyboard + interaction and spacing on smaller screens. +

+
+ + ) : null} + {messages.map((message, index) => ( +

{message}

+ ))} + setState("resuming"), + }} + onAdd={(body) => setMessages((current) => [...current, body])} + /> +
+ ); +} diff --git a/ui/storybook/stories/paused-composer.stories.tsx b/ui/storybook/stories/paused-composer.stories.tsx new file mode 100644 index 0000000000..1cdcf7f807 --- /dev/null +++ b/ui/storybook/stories/paused-composer.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PausedComposerPreview } from "../prototypes/PausedTaskComposer"; + +const meta = { + title: "Tasks/Composer/Paused task takeover", + component: PausedComposerPreview, + parameters: { + layout: "fullscreen", + docs: { + description: { + component: + "Design preview: a paused task replaces the entire composer with an amber takeover. Resume restores the real composer; no dismiss, attachments, or sending while paused. Actions are local Storybook fixtures. See Tasks / Execution Controls for the current behavior.", + }, + }, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const TaskPaused: Story = { name: "Task paused" }; +export const ComposerOnly: Story = { args: { composerOnly: true } }; +export const DraftSaved: Story = { + args: { draft: "Please check the keyboard interaction too." }, +}; +export const SubtreePaused: Story = { args: { subtree: true } }; +export const Resuming: Story = { args: { initialState: "resuming" } }; +export const ResumeFailed: Story = { args: { initialState: "error" } }; +export const Light: Story = { globals: { theme: "light" } }; +export const Mobile: Story = { + args: { mobile: true }, + globals: { viewport: { value: "mobile1", isRotated: false } }, +}; diff --git a/ui/storybook/stories/task-execution-controls.stories.tsx b/ui/storybook/stories/task-execution-controls.stories.tsx index 2a6631031a..c6f748934d 100644 --- a/ui/storybook/stories/task-execution-controls.stories.tsx +++ b/ui/storybook/stories/task-execution-controls.stories.tsx @@ -9,7 +9,6 @@ import { expect, userEvent, within } from "storybook/test"; import { Bot, MoreHorizontal } from "lucide-react"; import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer"; import { - TaskPauseNotice, TaskTreeControlDialog, TaskTreeControlMenuItems, } from "@/components/TaskTreeControls"; @@ -192,13 +191,6 @@ function TaskExecutionExample({ - {state === "paused" ? ( - openDialog("resume")} - /> - ) : null}

@@ -258,6 +250,7 @@ function TaskExecutionExample({

))} openDialog("resume") } : null} draftKey={draftKey} workMode="standard" mobile={mobile}