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}