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 <noreply@paperclip.ing>
This commit is contained in:
parent
96bba78fba
commit
52811c6ce6
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ const mockIssueService = vi.hoisted(() => ({
|
|||
listReviewAttention: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockPauseGate = vi.hoisted(() => vi.fn(async (): Promise<Record<string, unknown> | 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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 } });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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(<MemoryRouter><IssueChatThread
|
||||
comments={comments} onAdd={async () => {}}
|
||||
onInterruptQueued={async () => {}} onCancelQueued={() => {}}
|
||||
composerPause={paused ? { scope: "leaf", onResume: () => {} } : null}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/></MemoryRouter>));
|
||||
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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, string> | 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> | 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 <TaskChatPausedTakeover {...composerPause} hasDraft={Boolean(body.trim() || attachedFiles.length)} />;
|
||||
}
|
||||
|
||||
if (composerDisabledReason) {
|
||||
return (
|
||||
<div className="rounded-md border border-amber-300/70 bg-amber-50/80 px-3 py-2 text-sm text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100">
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(<TaskChatThread {...props} />);
|
||||
expect(container.querySelector('[data-testid="task-chat-queued-messages"]')).not.toBeNull();
|
||||
|
||||
render(<TaskChatThread {...props} composerPause={{ scope: "leaf", onResume: () => {} }} />);
|
||||
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(<TaskChatThread {...props} />);
|
||||
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(<TaskChatThread
|
||||
comments={[{ ...queuedComment, queueState: "queued", queueTargetRunId: "run-1" }]}
|
||||
onAdd={async () => {}}
|
||||
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],
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
</Button>
|
||||
);
|
||||
},
|
||||
[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 ? (
|
||||
<TaskChatQueuedMessages
|
||||
queue={queuedMessageQueue}
|
||||
onEdit={beginQueuedEdit}
|
||||
|
|
@ -2937,6 +2938,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||
queuedEdit={queuedEdit}
|
||||
onSaveQueuedEdit={saveQueuedEdit}
|
||||
onCancelQueuedEdit={() => setQueuedEdit(null)}
|
||||
pause={composerPause}
|
||||
takeover={composerTakeover}
|
||||
runnerGoalCapability={runnerGoal.data?.capability ?? null}
|
||||
onRunnerGoalCommand={runnerGoal.executeComposerCommand}
|
||||
|
|
|
|||
|
|
@ -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(<TaskChatComposer {...props} />));
|
||||
typeText("Please check mobile too.");
|
||||
act(() => root!.render(<TaskChatComposer {...props} pause={{ scope: "leaf", onResume }} />));
|
||||
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(<TaskChatComposer {...props} pause={{ scope: "leaf", pending: true, onResume }} />));
|
||||
expect(container.querySelector("button")!.disabled).toBe(true);
|
||||
act(() => root!.render(<TaskChatComposer {...props} />));
|
||||
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(<TaskChatComposer
|
||||
onAdd={vi.fn()} workMode="standard"
|
||||
pause={{ scope: "subtree" }}
|
||||
queuedEdit={{ commentId: "queued", body: "Queued draft" }}
|
||||
takeover={{ id: "question", label: "Pending input", pendingCount: 1, content: <button>Answer question</button>, 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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
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<HTMLDivElement>) {
|
||||
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({
|
|||
</Button>
|
||||
) : null;
|
||||
|
||||
if (pause) {
|
||||
return <TaskChatPausedTakeover {...pause} hasDraft={Boolean(body.trim() || attachments.length)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
import { useId } from "react";
|
||||
import { Loader2, Pause, Play } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export interface TaskComposerPause {
|
||||
scope: "leaf" | "subtree";
|
||||
onResume?: () => 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 (
|
||||
<section
|
||||
aria-labelledby={headingId}
|
||||
aria-busy={pending}
|
||||
data-testid="paused-composer-takeover"
|
||||
className="flex flex-col gap-4 rounded-(--radius-task-composer) border border-(--status-agent-paused)/40 bg-(--status-agent-paused)/10 p-(--sz-18px)"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Pause aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-(--status-task-icon-todo)" />
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<h2 id={headingId} className="text-sm font-medium text-foreground">
|
||||
{subtree ? "Subtree is paused." : "Task is paused."}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{subtree
|
||||
? "Resume this subtree to send a message."
|
||||
: "Resume this task to send a message."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||
{hasDraft ? (
|
||||
<p className="mr-auto text-xs text-muted-foreground">Your draft is saved.</p>
|
||||
) : null}
|
||||
{resumeHref ? (
|
||||
<Button asChild size="sm" className="bg-(--status-agent-paused) text-foreground hover:bg-(--status-agent-paused)/80 dark:text-background">
|
||||
<Link to={resumeHref}><Play aria-hidden="true" />Resume subtree</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={pending || !onResume}
|
||||
onClick={onResume}
|
||||
className="bg-(--status-agent-paused) text-foreground hover:bg-(--status-agent-paused)/80 dark:text-background"
|
||||
>
|
||||
{pending ? <Loader2 aria-hidden="true" className="animate-spin" /> : <Play aria-hidden="true" />}
|
||||
{pending ? "Resuming…" : subtree ? "Resume subtree" : "Resume task"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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")} />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{running ? "Running: type to switch Stop to Send." : "Paused: resume from the menu."}</p>
|
||||
{!running ? <TaskPauseNotice scope="subtree" onResume={() => setDialogMode("resume")} /> : null}
|
||||
{!running ? <TaskChatMarker item={{ id: "design-cancelled", kind: "marker", variant: "interrupted", tone: "neutral", label: "Run cancelled", detail: "The run was cancelled before returning an answer.", collapsible: true }} /> : null}
|
||||
<TaskChatComposer onAdd={async () => {}} workMode="standard" stopScope="subtree" onStop={running ? async () => setRunning(false) : undefined} />
|
||||
<TaskChatComposer pause={!running ? { scope: "subtree", onResume: () => setDialogMode("resume") } : null} onAdd={async () => {}} workMode="standard" stopScope="subtree" onStop={running ? async () => setRunning(false) : undefined} />
|
||||
<TaskTreeControlDialog open={dialogMode !== null} onOpenChange={(open) => { if (!open) setDialogMode(null); }}
|
||||
mode={dialogMode ?? "cancel"} scope="subtree" affectedCount={3} affectedAgentCount={2} loading={false} pending={false} valid
|
||||
wakeAgents={wake} onWakeAgentsChange={setWake} onRetry={() => {}}
|
||||
|
|
|
|||
|
|
@ -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> | void;
|
||||
}[];
|
||||
composerPause?: TaskComposerPause | null;
|
||||
footer?: ReactNode;
|
||||
}) => {
|
||||
mockIssueChatThreadRender(props);
|
||||
|
|
@ -368,6 +370,7 @@ vi.mock("../components/IssueChatThread", () => ({
|
|||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
{props.composerPause ? <TaskChatPausedTakeover {...props.composerPause} /> : null}
|
||||
{props.footer}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -396,6 +399,7 @@ vi.mock("../components/TaskChatThread", () => ({
|
|||
label: string;
|
||||
onSelect: (runId: string) => Promise<void> | void;
|
||||
}[];
|
||||
composerPause?: TaskComposerPause | null;
|
||||
footer?: ReactNode;
|
||||
}) => {
|
||||
mockIssueChatThreadRender(props);
|
||||
|
|
@ -436,6 +440,7 @@ vi.mock("../components/TaskChatThread", () => ({
|
|||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
{props.composerPause ? <TaskChatPausedTakeover {...props.composerPause} /> : null}
|
||||
{props.footer}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -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<HTMLElement>(
|
||||
"[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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
</div>
|
||||
)}
|
||||
{activePauseHold && (
|
||||
<TaskPauseNotice
|
||||
scope={
|
||||
activePauseHold.isRoot && childIssues.length === 0
|
||||
? "leaf"
|
||||
: "subtree"
|
||||
}
|
||||
className={cn(
|
||||
shellSectionClass,
|
||||
taskChatShellEnabled &&
|
||||
!issue.hiddenAt &&
|
||||
(isMobile ? "mt-4" : "mt-3"),
|
||||
)}
|
||||
pending={executeTreeControl.isPending}
|
||||
onResume={
|
||||
activePauseHold.isRoot &&
|
||||
(canShowSubtreeControls || canResumeLeafWork)
|
||||
? () => {
|
||||
executeTreeControl.reset();
|
||||
setTreeControlMode("resume");
|
||||
setTreeControlWakeAgentsOnResume(
|
||||
isAgentOwnedNonTerminalIssue || canShowSubtreeControls,
|
||||
);
|
||||
setTreeControlOpen(true);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
resumeLink={
|
||||
!activePauseHold.isRoot ? (
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link
|
||||
to={createIssueDetailPath(
|
||||
activePauseHoldRoot?.identifier ??
|
||||
activePauseHold.rootIssueId,
|
||||
)}
|
||||
>
|
||||
Resume subtree
|
||||
</Link>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{treeControlWakeWarning ? (
|
||||
<p
|
||||
role="alert"
|
||||
|
|
@ -7816,7 +7773,18 @@ export function IssueDetail() {
|
|||
currentAssigneeValue={actualAssigneeValue}
|
||||
suggestedAssigneeValue={suggestedAssigneeValue}
|
||||
mentions={mentionOptions}
|
||||
composerDisabledReason={null}
|
||||
composerPause={activePauseHold ? {
|
||||
scope: activePauseHold.isRoot && childIssues.length === 0 ? "leaf" : "subtree",
|
||||
pending: executeTreeControl.isPending && executeTreeControl.variables?.mode === "resume",
|
||||
onResume: activePauseHold.isRoot && canManageTreeControl ? () => {
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -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<string>(initialState);
|
||||
const [messages, setMessages] = useState<string[]>([]);
|
||||
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 (
|
||||
<div className={cn("mx-auto flex w-full flex-col gap-8 p-6", mobile ? "max-w-sm" : "max-w-3xl")}>
|
||||
{!composerOnly ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-3 border-b border-border pb-6">
|
||||
<span className="font-mono text-xs text-muted-foreground">PAP-204</span>
|
||||
<h1 className="text-xl font-semibold">Polish the task conversation</h1>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<p className="max-w-sm rounded-xl bg-muted px-4 py-3 text-sm">
|
||||
Check the composer and make sure follow-ups work well on mobile.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Bot aria-hidden="true" className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium">Alex</span>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed">
|
||||
I’ve reviewed the composer layout. Next I’ll check the keyboard
|
||||
interaction and spacing on smaller screens.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{messages.map((message, index) => (
|
||||
<p key={index} className="self-end rounded-xl bg-muted px-4 py-3 text-sm">{message}</p>
|
||||
))}
|
||||
<TaskChatComposer
|
||||
workMode="standard"
|
||||
draftKey={draftKey}
|
||||
mobile={mobile}
|
||||
placeholder="Send a message to Alex…"
|
||||
pause={state === "ready" ? null : {
|
||||
scope: subtree ? "subtree" : "leaf",
|
||||
pending: state === "resuming",
|
||||
error: state === "error" ? "Couldn’t resume. Your task is still paused. Try again." : null,
|
||||
onResume: () => setState("resuming"),
|
||||
}}
|
||||
onAdd={(body) => setMessages((current) => [...current, body])}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<typeof PausedComposerPreview>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
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 } },
|
||||
};
|
||||
|
|
@ -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({
|
|||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
{state === "paused" ? (
|
||||
<TaskPauseNotice
|
||||
scope={scope}
|
||||
className="mt-3"
|
||||
onResume={() => openDialog("resume")}
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-6 py-6">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-xl font-semibold">
|
||||
|
|
@ -258,6 +250,7 @@ function TaskExecutionExample({
|
|||
</div>
|
||||
))}
|
||||
<TaskChatComposer
|
||||
pause={state === "paused" ? { scope, pending, onResume: () => openDialog("resume") } : null}
|
||||
draftKey={draftKey}
|
||||
workMode="standard"
|
||||
mobile={mobile}
|
||||
|
|
|
|||
Loading…
Reference in New Issue